diff --git a/.gitignore b/.gitignore index ab03656..4572cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ data/modes.json Thumbs.db .env +# Canonical shared context root — all AI tool configs consolidated here +.context/ + # Per-contributor Claude Code config (preview launcher, etc.) .claude/ @@ -65,3 +68,6 @@ out/ *.deb __pycache__/ bench/results-latest.json +bench/artifacts/ +bench/data/ +bench/fixtures/skills/ diff --git a/.prettierignore b/.prettierignore index 3a1d4a1..e06d9d3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,6 @@ CONTEXT.md server.out.log server.err.log *.log +bench/artifacts +bench/data +bench/fixtures/skills diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..a33aa17 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,248 @@ +# Context Engine — Comprehensive Roadmap + +> Updated: 2026-05-18. Author: Jeremy. +> What we've built, where we stand, and what ships next. Two-month horizon. + +--- + +## Why This Exists + +Context quality is the bottleneck in every AI workflow. Models are good and getting better — but the context you feed them is fragmented, stale, and unstructured. CE solves that: it ingests skills from anywhere, understands them semantically, deduplicates overlap, ranks quality, auto-selects the right subset per task, and delivers optimised context to any AI surface. + +--- + +## Repo Layout — Canonical Context Root + +All AI tool configs live in a single canonical location: `app/.context/`. Root-level dot-files and directories are NTFS junctions pointing into it. + +``` +app/.context/ +├── claude/ → launch.json, settings.local.json +├── codex/ → instructions.md +├── continue/rules/ → context-engine.md +├── app-claude/ → launch.json (when running from app/) +├── instructions/ → AGENTS.md, CLAUDE.md +├── rules/ → cursorrules, windsurfrules, clinerules +└── kimi-system-prompt.md +``` + +Root-level junctions: `.claude/`, `.codex/`, `.continue/` all resolve to subdirectories of `.context/`. All file-based targets (`.clinerules`, `.cursorrules`, `.windsurfrules`, `.ampcoderc`, `.goosehints`, `.rules`, `AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, `CONVENTIONS.md`, `GEMINI.md`, `devin.md`, `.kimi-system-prompt.md`, `.github/copilot-instructions.md`) and remaining dirs (`.augment/`, `.junie/`, `.kiro/`, `.pearai/`, `.tmp/`, `.trae/`, `.void/`) are removed from root. **`app/.context/` is the single source of truth for every AI tool config.** + +--- + +## What We've Shipped (v0.3.1) + +### Architecture: 31 server/lib modules, ~14k files total + +The backend is deep and production-grade. Everything written zero-dependency (Node builtins + Ollama HTTP). + +### Phase 0 — Stabilised v3 Base + +- Modular DRAM CSS with token system and lint guard +- Cleanup modal for tidy/overlap review with apply flow +- Local update detection with clickable update toast +- Release checklist covering syntax, line limits, Git scope + +### P0 — Electron + TypeScript Quality Gate + +- Strict TypeScript typecheck across server and renderer — zero errors +- ESLint with TS-aware rules, no-floating-promises enforcement +- All source files under 500/700 line limits +- Electron shell with main/preload/renderer boundary + +### P0 — Windows Installer + Auto-Update + +- NSIS installer + portable target via electron-builder +- Auto-update wired (8s after launch, then every 6h) +- Brand marks: icon/mono/simple SVGs, .ico, .icns, Linux set +- GitHub Actions release workflow (tag-driven + manual) +- First tagged release v0.2.1 cut and validated end-to-end +- **Blocked:** code signing certificate not procured (SmartScreen warnings) + +### Phase 1 — Vector Foundation + +- `chunker.js`: semantic heading/rule/knowledge/example parser with frontmatter +- `embeddings.js`: Ollama client for nomic-embed-text, batch embed, graceful fallback +- `vectorstore.js`: flat-file vector index, cosine search, <5ms for 500 chunks +- `POST /api/index`, `POST /api/index/skill/:id`, `POST /api/search`, `GET /api/index/status` +- UI: indexed chunk count, model, last indexed time + +### Phase 2 — Dedup + Rank + +- `dedup.js`: pairwise similarity clustering, Union-Find, duplicate/related thresholds +- `ranking.js`: specificity, coverage, source weight, freshness scoring +- `GET /api/dedup`, `POST /api/dedup/resolve` with reversible resolution history +- Quality Audit UI: duplicate clusters, low-specificity filler, side-by-side comparison + +### Phase 3 — Smart Compile + +- `POST /api/compile/smart`: task embedding → vector search → expand → budget-fit → compile +- Project-aware stack detection (package.json, README, Cargo.toml) +- Before-and-after token comparison vs manual All On mode +- Modes moving toward presets (Smart Preview on dashboard) + +### Phase 1.5 (Promoted) — MCP Bridge to Daily Apps + +- `mcp-server.mjs`: stdio transport, 4-tool contract (search, list_skills, get_skill, status) +- Remote Streamable HTTP MCP adapter with bearer-token auth +- Claude Desktop `.mcpb` wrapper bundle +- One-paste config snippets for Claude Desktop, Codex CLI +- Lifecycle: spawned by host app, independent of Electron shell +- Claude Desktop + Codex CLI validated end-to-end +- **Blocked:** ChatGPT remote connector needs HTTPS tunnel/hosting choice + +### Handoffs — Full Lifecycle + +- Project handoffs (repo-bound), thread handoffs (topic-bound), dual-bound +- Git staleness detection (auto-archive at 5+ commits) +- Thread staleness (archive after 14 days idle), purge after 30 days +- 9 REST endpoints + MCP tool + admin UI (peer to Memory tab) +- Migrated existing llm-handoff.md entries into structured format + +### Projects — CRUD + Scoped Context + +- Project directories with seed memory.json + rules.json +- Collision-safe slugs, directory lifecycle, path management +- REST endpoints + 91-pass smoke test + +### PR #2 (James Chapman) — Priority Rules, Auth, Security + +- Priority-based rules model (hard/soft per section) with auto-migration +- API token auth (bearer, 48-hex, encrypted at rest, fully opt-in) +- Rule-files CRUD (`data/rules/*.json`) +- CI workflow (typecheck + lint + lint:css) +- 12 new smoke test suites (backup, crypto, mode-apply, mutex, projects, ranking, security, validation, etc.) + +### Benchmark v1.3 — Evidence Pack + +- Report PDF + chart PNGs under `bench/charts/` +- Smart Compile token accounting fixed (same output surface comparison) +- Manifest chunking for skill name/description/trigger search +- **Not yet green:** quality gate (Recall@8, no-context paired comparison) + +--- + +## Current State Summary + +| Area | Status | Notes | +| ----------------------------- | --------------- | -------------------------------------------------------------------------- | +| Backend architecture | Shipped | 31 modules, zero-dependency | +| Vector index + search | Shipped | Works, needs quality tuning | +| Dedup + rank | Shipped | Exists but quality-gated | +| Smart Compile | Shipped | Token savings proven, quality unproven | +| MCP bridge (local) | Shipped | Claude Desktop + Codex validated | +| MCP bridge (remote) | Blocked | ChatGPT needs HTTPS tunnel decision | +| Handoffs | Shipped | | +| Projects | Shipped | | +| Priority rules | Shipped | PR #2 merged | +| API auth | Shipped | Opt-in, encrypted | +| Benchmark gates | **Not green** | Recall@8 ≠ 1.00, no-context not compared | +| System detection + onboarding | **Not started** | Broad spec written. Replaces old skill-sources + onboarding-redesign | +| `scanSystem()` backend | Exists (narrow) | Currently only SKILL.md dirs. Must broaden to rules/instructions/MCP/hosts | +| MCP discovery | **Not started** | | +| AIModelDB bridge | **Not started** | | +| Modes-as-presets | **Partial** | Smart Preview on dashboard, tab grid not replaced | +| Code signing cert | **Blocked** | SmartScreen warnings on installer | + +--- + +## Two-Month Roadmap + +### Week 1-2: Quality Gate (P0) — Make the Benchmark Honest + +Before any new features, CE must prove it doesn't make answers worse. + +| Task | Est. | Owner | +| -------------------------------------------------------------------------------------------- | ---- | ------ | +| Rebuild vector index + re-run v1.3 benchmark after manifest chunking | 2d | Jeremy | +| Add retrieval-quality smoke gate: expected-source Recall@8 = 1.00 | 2d | Jeremy | +| Add no-context paired quality gate: Smart/Search must beat or tie no-context | 2d | Jeremy | +| Hybrid reranking: vector score + lexical match on id/name/triggers/section | 3d | Jeremy | +| Fix any retrieval misses identified by the benchmark | 2d | Jeremy | +| Dashboard/reporting copy: "token reduction measured; quality gate pending" → remove warning | 1d | Jeremy | +| Promote multi-resolution packaging: manifest → relevant chunks → full skill only when needed | 3d | Jeremy | + +**Gate:** Smoke CI fails if Recall@8 < 1.00 or quality drops below no-context. + +### Week 3-4: System Detection Phase 1 + MCP Remote (P0/P1) + +Two parallel streams. This replaces the old "Skill Sources" and "Onboarding Redesign" — now unified into System Detection. + +**Stream A — MCP Remote (Jeremy):** +| Task | Est. | +|------|------| +| Choose HTTPS tunnel/hosting (e.g. Cloudflare Tunnel, ngrok, or $5 VPS) | 1d | +| Set `MCP_OAUTH_PASSWORD`, expose `/mcp` | 1d | +| Register URL in Claude/ChatGPT connector, validate `context_engine_status` | 1d | +| Document the setup for self-hosted users | 1d | + +**Stream B — System Detection Backend (Jeremy or James):** +| Task | Est. | +|------|------| +| Rewrite `scanHostSkillPaths()` → `scanSystem()` with 5 categories (skills, rules, instructions, MCP, hosts) | 2d | +| Add probe functions: probeRuleFiles, probeInstructionFiles, probeMcpServers, probeHostConfigs | 2d | +| New endpoint: `GET /api/system/scan` — runs all probes, returns grouped results | 2d | +| New endpoints: `POST /api/system/link-all`, `POST /api/system/link`, `DELETE /api/system/link/:id` | 2d | +| New data model: `data/system-context.json` (tracks all linked sources with timestamps) | 1d | +| Refactor `server/lib/skills.js` → `findAllSkillDirs()` with sourceId for unified skill listing | 2d | + +### Week 5-6: Onboarding + Import Pipeline (P1) + +**Stream A — Onboarding Rewrite (Jeremy):** +| Task | Est. | +|------|------| +| 4-step scan → review → build → done flow. Step 1 runs `GET /api/system/scan` on open | 3d | +| Grouped result cards: skills count, rules count, instructions count, MCP count, hosts detected | 2d | +| "Link All" button links every unmanaged source in one click | 1d | +| Per-source Link/Unlink with inline feedback | 1d | +| Step 3: import + rebuild index with progress | 2d | +| CSS budget ≤ 250 lines, reuse DRAM tokens | 1d | + +**Stream B — Link-Import Pipeline (James):** +| Task | Est. | +|------|------| +| Directory sources → NTFS junction into `app/.context//` | 2d | +| File sources → copy into `app/.context/` | 1d | +| MCP server registration from detected configs | 2d | +| Auto-rebuild index after link/import | 1d | +| Per-source linking: collision-safe ID prefixing (`:`) | 1d | +| Smoke tests: full scan → link → verify → unlink roundtrip | 2d | + +### Week 7-8: Set & Forget + Polish (P1/P2) + +**Jeremy:** +| Task | Est. | +|------|------| +| Periodic health check (24h timer re-scan, notify on new/changed sources) | 2d | +| Connections tab (post-onboarding source management UI, sibling to Skills/Memory/Handoffs) | 3d | +| Replace Modes tab grid with preset library | 2d | +| Smart Preview can promote selected skill sets into presets | 2d | +| Code signing certificate procurement + wiring | 2d | + +**James:** +| Task | Est. | +|------|------| +| CE becomes sole author — compile writes to `app/.context/` AND root junctions (where they exist) | 3d | +| CE writes compiled output to tool root paths when no junction exists (file fallback) | 2d | +| Handoff rate-limit-aware heartbeat/update API | 1d | +| `context_engine_dedup_report` MCP tool | 2d | +| Validation + edge-case hardening across all system detection endpoints | 2d | + +--- + +## Beyond Two Months (Next) + +- **Phase 5 — AIModelDB Bridge:** model-aware compile budget, dashboard display, model comparison MCP tool +- **`context_engine_model_lookup` MCP tool** +- **Multi-platform native installers** (macOS dmg, Linux deb/rpm) — runners exist in the release workflow, just need testing +- **Plugin/skill marketplace** — network effects, the real moat + +--- + +## Key Principles + +1. **Quality gate is the door.** Nothing ships to users until the benchmark proves it doesn't make answers worse. +2. **Daily use is the signal.** If Jeremy wouldn't reach for it in Claude Desktop or Codex tomorrow morning, it waits. +3. **James audits and hardens.** Jeremy drives the main roadmap. James catches edge cases, adds tests, and prevents regressions. +4. **Zero new deps.** Node builtins + Ollama HTTP. This is a product principle, not an accident. +5. **Under 500 lines.** Every module stays under 500 lines soft limit, 700 absolute. New modules get split early. diff --git a/docs/specs/system-detection.md b/docs/specs/system-detection.md new file mode 100644 index 0000000..32fad68 --- /dev/null +++ b/docs/specs/system-detection.md @@ -0,0 +1,320 @@ +# System Detection & Unified Context Ingestion + +> Replaces: [skill-sources.md](skill-sources.md) + [onboarding-redesign.md](onboarding-redesign.md) +> Status: proposed. 2026-05-18. + +--- + +## The Vision + +CE is the single source of truth for every AI tool on the machine. You install CE once, it scans everything, you click confirm, and every tool on your system — Claude Code, Cursor, Codex CLI, OpenCode, Continue, Cline, Windsurf, Kimi, GitHub Copilot — reads from CE's unified context. You never touch a tool config again. + +### Flow + +``` +Install CE (single download) + → Launch Electron app (first run) + → Onboarding: "Scanning system…" (auto, 2-5 seconds) + → Shows: 4 hosts, 247 skills, 53 rules, 12 MCP servers found + → User clicks "Link All & Continue" + → CE imports everything, builds index, wires junctions + → Dashboard: "All systems ready. 0 configs to maintain." + → Done. +``` + +--- + +## What `scanHostSkillPaths()` Currently Scopes + +| Path | Type | Detects | +| ------------------------------ | ------ | ---------------- | +| `~/.claude/skills/` | skills | SKILL.md files | +| `~/.opencode/skills/` | skills | SKILL.md files | +| `/.claude/skills/` | skills | SKILL.md files | +| `/.clinerules` | rules | Single rule file | +| `/.continue/rules/` | rules | Rule files | + +### What It's Missing + +#### MCP Servers + +- `~/.claude/plugins/` — installed Claude Desktop plugins (each is an MCP server) +- `~/.claude/plugins/marketplaces/claude-plugins-official/plugins/*/.claude-plugin` — official plugin metadata +- `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json` — VS Code/Cline MCP config +- `claude_desktop_config.json` — Claude Desktop MCP server config (at `%APPDATA%\Claude\` on Windows) + +#### Instructions Files + +- `AGENTS.md` — OpenCode / Codex agent instructions +- `CLAUDE.md` — Claude Code project instructions +- `GEMINI.md` — Gemini Code Assist instructions +- `devin.md` — Devin instructions +- `.kimi-system-prompt.md` — Kimi system prompt +- `.github/copilot-instructions.md` — GitHub Copilot instructions + +#### Rule Files + +- `.cursorrules` — Cursor rules +- `.windsurfrules` — Windsurf rules +- `.clinerules` — Cline/Roo rules +- `.rules` — Generic rules +- `.ampcoderc` — Ampcoder config +- `.goosehints` — Goose hints +- `CONVENTIONS.md` — Project conventions + +#### Home-Dir Tool Configs (for context, not import) + +- `~/.cursor/` — Cursor settings (detect presence, don't import) +- `~/.codex/` — Codex CLI config and skills +- `~/.kimi/` — Kimi session data (detect, don't import sessions) +- `~/.continue/` — Continue.dev config (detect, don't import) + +--- + +## Expanded Scan: Full Probe Map + +```js +function scanSystem() { + return [ + // === SKILLS === + ...probeSkillDirs(), + // === RULES === + ...probeRuleFiles(), + // === INSTRUCTIONS === + ...probeInstructionFiles(), + // === MCP SERVERS === + ...probeMcpServers(), + // === HOST PRESENCE === + ...probeHostConfigs(), + ]; +} +``` + +### Skills + +| Probe | Label | Reads | +| ---------------------- | ------------------------ | ---------------------------------- | +| `~/.claude/skills/` | Claude Code (global) | dir → SKILL.md count | +| `~/.opencode/skills/` | OpenCode (global) | dir → SKILL.md count | +| `~/.codex/skills/` | Codex CLI (global) | dir → SKILL.md count | +| `/.claude/skills/` | Claude Code in {project} | dir → SKILL.md count | +| `/.codex/` | Codex CLI in {project} | dir → SKILL.md and instructions.md | + +### Rules (single-file sources) + +| Probe | Label | Reads | CE Target | +| ------------------------------- | ------------------- | ------------------ | ----------------------------- | +| `/.clinerules` | Cline / Roo | file content | `rules/clinerules` | +| `/.cursorrules` | Cursor | file content | `rules/cursorrules` | +| `/.windsurfrules` | Windsurf | file content | `rules/windsurfrules` | +| `/.rules` | Generic rules | file content | `rules/rules` | +| `/.ampcoderc` | Ampcoder | file content | `rules/ampcoderc` | +| `/.goosehints` | Goose | file content | `rules/goosehints` | +| `/.continue/rules/` | Continue.dev | dir → file content | `rules/continue/*` | +| `/CONVENTIONS.md` | Project conventions | file content | `instructions/CONVENTIONS.md` | + +### Instructions + +| Probe | Label | Reads | CE Target | +| ------------------------------------------------ | ------------------ | ------------ | ------------------------------- | +| `/AGENTS.md` | OpenCode / Codex | file content | `instructions/AGENTS.md` | +| `/CLAUDE.md` | Claude Code | file content | `instructions/CLAUDE.md` | +| `/GEMINI.md` | Gemini Code Assist | file content | `instructions/GEMINI.md` | +| `/devin.md` | Devin | file content | `instructions/devin.md` | +| `/.kimi-system-prompt.md` | Kimi | file content | `kimi-system-prompt.md` | +| `/.github/copilot-instructions.md` | GitHub Copilot | file content | `rules/copilot-instructions.md` | +| `/CONTEXT.md` | CE manifest | file content | `instructions/CONTEXT.md` | + +### MCP Servers + +| Probe | Label | +| --------------------------------------------- | ---------------------- | +| `%APPDATA%\Claude\claude_desktop_config.json` | Claude Desktop MCP | +| `~/.claude/plugins/` | Claude Desktop plugins | +| `{vscode-config}/cline_mcp_settings.json` | Cline MCP | +| `~/.codex/mcp.json` | Codex CLI MCP | + +### Host Presence (informational) + +| Probe | Label | +| -------------------------------------- | -------------------- | +| `~/.claude/` exists | Claude Code (global) | +| `~/.cursor/` exists | Cursor | +| `~/.codex/` exists | Codex CLI (global) | +| `~/.kimi/` exists | Kimi | +| `~/.continue/` exists | Continue.dev | +| `~/.opencode/` exists | OpenCode | +| where.exe cursor, where.exe code, etc. | Host CLI on PATH | + +--- + +## Detection Returns + +Each probe returns a unified result shape: + +```json +{ + "id": "claude-global-skills", + "category": "skills", + "label": "Claude Code (global skills)", + "path": "C:\\Users\\jerem\\.claude\\skills", + "exists": true, + "size": 12, + "unit": "SKILL.md files", + "alreadyManaged": true, + "contentSummary": "React, TypeScript, Python, ..." +} +``` + +`category` is one of: `skills`, `rules`, `instructions`, `mcp`, `host`. + +--- + +## Onboarding Flow (Revised) + +Four steps, but the emphasis shifts from "config your tools" to "scan, review, done." + +### Step 1: System Scan (was "Connect") + +Auto-run on open. Shows a spinner for 2-5s while CE probes every known path. + +Result: a grouped list: + +``` +Context Engine found AI tooling across your system: + +Skills 3 locations · 247 skill files + Claude Code (global) ~/.claude/skills 12 SKILL.md [linked] + OpenCode (global) ~/.opencode/skills 230 SKILL.md [linked] + Codex CLI (project) project/.codex/ 5 SKILL.md [link] + +Rules 4 locations · 3 files + 1 directory + Cursor rules .cursorrules 53KB [link] + Continue.dev .continue/rules/ 1 rule [link] + Cline / Roo .clinerules 17KB [link] + +Instructions 3 files + OpenCode / Codex AGENTS.md 18KB [link] + Claude Code CLAUDE.md 3.5KB [link] + Project conventions CONVENTIONS.md 8.7KB [link] + +MCP Servers 2 found + Claude Desktop claude_desktop_config.json 4 servers [link] + Cline cline_mcp_settings.json 2 servers [link] + +Hosts Detected 6 tools + Claude Code ✓ Cursor ✓ Codex CLI ✓ Continue ✓ Kimi ✓ OpenCode ✓ + +[ Link All ] [ Review Individually ] +``` + +### Step 2: Verify & Customize + +Folded into step 1 if "Review Individually" is clicked, or shown as an expandable section after auto-link: + +- Checkboxes per probe row +- "Path to skills folder:" text input + Browse button +- Linked sources show with an Unlink affordance +- Paths that are already in CE's `.context/` show as "Managed" + +### Step 3: Build & Confirm + +- CE imports all confirmed sources +- Rebuilds vector index (shows progress) +- Writes compiled context to `app/.context/` +- Shows result: "247 skills indexed | 53 rules consolidated | 4 MCP servers registered" + +### Step 4: Done + +Dashboard. No celebration step. The modal closes and the app is live. + +--- + +## What "Link" Actually Means Now + +Not just tracking a source in JSON. Full pipeline: + +1. **Register** the source in `skill-sources.json` +2. **Copy or junction** the content into `app/.context/` + - Directories → NTFS junction (instant, zero-copy) + - Files → copy (until Windows symlinks are available) +3. **Ingest** skills into CE's vector index +4. **Register** MCP servers in CE's MCP registry +5. **Scan** rule/instruction content for dedup against existing CE rules +6. **Write** compiled output back to `app/.context/` +7. **Update** the dashboard stat grid immediately + +--- + +## Post-Onboarding: "Set & Forget" + +After onboarding, CE maintains itself: + +### Periodic Health Check + +Every 24h (configurable): + +- Re-scan known paths for new/removed skills +- Flag stale index +- Check if new AI tools were installed (detect new `~/.` directories) +- Show notification: "New skills found in ~/.claude/skills — Link?" + +### Auto-Compile on Change + +When skills are added/removed to any linked source: + +- CE detects the change (on next read or periodic scan) +- Marks index as stale +- Shows: "Context has changed: [N] skills added, [M] removed. Rebuild?" + +### Manual Overrides + +- Connections tab shows all linked sources with per-source controls +- Unlink, Re-scan, Force Rebuild per source +- Custom path picker for power users + +--- + +## What the User Never Does + +- Never edits a config file directly +- Never copies skills between tool directories +- Never wonders which `.cursorrules` is current +- Never maintains duplicate `AGENTS.md` and `CLAUDE.md` +- Never manually wires MCP servers into each host + +--- + +## Implementation Plan + +### Phase 1: Broadened Scan + Onboarding (this sprint) + +| Task | Est. | +| -------------------------------------------------------------------------------------------------------------------------- | ---- | +| Rewrite `scanHostSkillPaths()` → `scanSystem()` with all probe categories | 2d | +| Add probe functions: probeRuleFiles, probeInstructionFiles, probeMcpServers, probeHostConfigs | 2d | +| New data model: `data/system-context.json` with full detected state | 1d | +| Add endpoints: `GET /api/system/scan`, `POST /api/system/link-all`, `POST /api/system/link`, `DELETE /api/system/link/:id` | 2d | +| Rewrite onboarding UI with the 4-step scan → review → build → done flow | 3d | +| Update dashboard stat grid to reflect linked sources | 1d | +| Smoke test: scan against fixture directories, verify all categories detected | 1d | + +### Phase 2: Import Pipeline + MCP Registration + +| Task | Est. | +| ----------------------------------------------------------------------- | ---- | +| Auto-junction for directory sources (moved into `.context//`) | 1d | +| Rule/instruction file import into `app/.context/` | 1d | +| MCP server registration from detected configs | 2d | +| "Set & forget" periodic health check (24h timer) | 2d | +| Notification system for new/changed sources | 1d | +| Connections tab UI (post-onboarding source management) | 2d | + +### Phase 3: CE as Sole Author + +| Task | Est. | +| ------------------------------------------------------------------- | ---- | +| CE rewrite root tool files on every compile (not just home dir) | 2d | +| CE writes to `app/.context/` AND to root-level junctions/copies | 2d | +| Kill the need for tool-specific config edits entirely | 1d | +| Benchmark: measure time from "install CE" to "all tools configured" | 1d | diff --git a/electron/main.cjs b/electron/main.cjs index 6d1cc45..f829931 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -34,10 +34,14 @@ const newUserProfile = if (newUserProfile) { // Test isolation: keep both userData AND the writable CE_ROOT under the // repo so a smoke run never leaks into a real user's data. - const userDataPath = path.join(__dirname, '..', '..', '.electron-user-data'); + const profileRoot = + process.env.CE_ROOT || + process.argv.find((arg) => arg.startsWith('--ce-root='))?.slice('--ce-root='.length) || + path.join(__dirname, '..', '..', '.tmp', 'new-user-profile'); + const userDataPath = path.join(profileRoot, '.electron-user-data'); fs.mkdirSync(userDataPath, { recursive: true }); app.setPath('userData', userDataPath); - if (!process.env.CE_ROOT) process.env.CE_ROOT = userDataPath; + if (!process.env.CE_ROOT) process.env.CE_ROOT = profileRoot; console.log(`[ce-electron] isolated userData: ${userDataPath}`); } else if (app.isPackaged && !process.env.CE_ROOT) { const userData = app.getPath('userData'); @@ -173,7 +177,11 @@ function createWindow() { icon: appIconPath, show: false, titleBarStyle: 'hidden', - titleBarOverlay: false, + titleBarOverlay: { + color: windowBackground, + symbolColor: '#ffffff', + height: 32, + }, autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.cjs'), @@ -185,6 +193,12 @@ function createWindow() { }, }); + const revealWindow = () => { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isVisible()) return; + mainWindow.show(); + mainWindow.focus(); + }; + // Some dev-mode launches don't fully honour the constructor `icon` for the // taskbar entry. Explicitly setting it after construction is the reliable path. if (process.platform === 'win32' && fs.existsSync(appIconPath)) { @@ -196,11 +210,9 @@ function createWindow() { } void mainWindow.loadURL(`http://127.0.0.1:${PORT}/`); - mainWindow.once('ready-to-show', () => { - if (!mainWindow) return; - mainWindow.show(); - mainWindow.focus(); - }); + mainWindow.once('ready-to-show', revealWindow); + mainWindow.webContents.once('did-finish-load', revealWindow); + setTimeout(revealWindow, 5000); if (smokeMode) { mainWindow.webContents.once('did-finish-load', () => { console.log('electron launch smoke ok'); diff --git a/package.json b/package.json index a7738cf..183caae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context-engine", - "version": "0.4.0", + "version": "0.5.0", "description": "Local-first continuity layer for AI work across tools, providers, and fresh sessions.", "main": "electron/main.cjs", "bin": { diff --git a/scripts/electron-smoke.js b/scripts/electron-smoke.js index 0637879..51b673b 100644 --- a/scripts/electron-smoke.js +++ b/scripts/electron-smoke.js @@ -10,6 +10,8 @@ const ROOT = path.resolve(__dirname, '..'); const ELECTRON_MAIN = path.join(ROOT, 'electron', 'main.cjs'); const ELECTRON_PRELOAD = path.join(ROOT, 'electron', 'preload.cjs'); const ELECTRON_UPDATER = path.join(ROOT, 'electron', 'updater.cjs'); +const UI_INDEX = path.join(ROOT, 'ui', 'index.html'); +const UI_SHELL_CSS = path.join(ROOT, 'ui', 'styles', 'shell.css'); const PACKAGE_JSON = path.join(ROOT, 'package.json'); /** @param {unknown} condition @param {string} message */ @@ -47,6 +49,8 @@ function main() { const pkg = JSON.parse(read(PACKAGE_JSON)); const mainSource = read(ELECTRON_MAIN); const preloadSource = read(ELECTRON_PRELOAD); + const uiIndex = read(UI_INDEX); + const shellCss = read(UI_SHELL_CSS); assertRelativeRequiresResolve(ELECTRON_MAIN); assertRelativeRequiresResolve(ELECTRON_PRELOAD); @@ -60,6 +64,11 @@ function main() { assert(mainSource.includes('contextIsolation: true'), 'preload boundary must use context isolation'); assert(mainSource.includes('nodeIntegration: false'), 'renderer must keep nodeIntegration disabled'); assert(mainSource.includes("titleBarStyle: 'hidden'"), 'main window must use hidden native titlebar'); + assert(uiIndex.includes('class="desktop-titlebar"'), 'hidden native titlebar needs a drag strip'); + assert( + shellCss.includes('.desktop-titlebar') && shellCss.includes('-webkit-app-region: drag'), + 'desktop titlebar strip must be draggable', + ); assert(mainSource.includes('CE_HOT_RELOAD'), 'main process must support hot reload mode'); assert(preloadSource.includes('contextBridge.exposeInMainWorld'), 'preload must expose a narrow bridge'); assert(preloadSource.includes('window:minimize'), 'preload must expose window controls through IPC'); diff --git a/scripts/onboarding-smoke.cjs b/scripts/onboarding-smoke.cjs index b7581c5..0d96459 100644 --- a/scripts/onboarding-smoke.cjs +++ b/scripts/onboarding-smoke.cjs @@ -69,23 +69,24 @@ async function run() { const discovery = await js( win, `(() => ({ - heading: document.querySelector('#onboarding-title')?.textContent || '', - hosts: document.querySelectorAll('.onboarding-host').length, - stats: document.querySelectorAll('.onboarding-stat').length, + heading: document.querySelector('.ob-title')?.textContent || '', + scanHeading: document.querySelector('.ob-step-head h2')?.textContent || '', + locations: document.querySelectorAll('.ob-row').length, }))()`, ); - assert(/Welcome to Context Engine/i.test(discovery.heading), 'Onboarding heading is missing'); - assert(discovery.hosts >= 1, 'Expected detected host cards'); + assert(/Onboarding/i.test(discovery.heading), 'Onboarding heading is missing'); + assert(/Where should we look/i.test(discovery.scanHeading), 'Scan setup heading is missing'); + assert(discovery.locations >= 3, 'Expected default scan location rows'); await js(win, `Onboarding.go(2)`); - await waitFor(win, `(() => document.querySelectorAll('.onboarding-stat').length >= 4)()`); - await js(win, `Onboarding.go(4)`); - await waitFor(win, `(() => /Final health check/.test(document.body.innerText))()`); + await waitFor(win, `(() => /Build vector index/.test(document.body.innerText))()`); + await js(win, `Onboarding.go(3)`); + await waitFor(win, `(() => /All set/.test(document.body.innerText))()`); await js( win, `(() => { - const buttons = [...document.querySelectorAll('.onboarding-footer .save-btn')]; - const finish = buttons.find((button) => /Finish setup/.test(button.textContent || '')); + const buttons = [...document.querySelectorAll('.ob-actions .save-btn')]; + const finish = buttons.find((button) => /Go to dashboard/.test(button.textContent || '')); if (!finish) throw new Error('Finish setup button missing'); finish.click(); })()`, diff --git a/scripts/vectorstore-smoke.js b/scripts/vectorstore-smoke.js index dae6731..dc93aaa 100644 --- a/scripts/vectorstore-smoke.js +++ b/scripts/vectorstore-smoke.js @@ -10,6 +10,7 @@ const { upsertVectors, replaceVectors, searchVectors, + hybridSearch, cosineSimilarity, markIndexStale, clearIndexStale, @@ -109,6 +110,50 @@ try { const emptyResults = searchVectors(loadVectorStore(path.join(tmpDir, 'nonexistent.json')), [1, 0]); assert.deepStrictEqual(emptyResults, [], 'search on empty store returns empty'); + // ---- hybridSearch result diversity ---- + + const duplicateStore = replaceVectors( + [ + { + id: 'source-a:launcher:overview:1', + skillId: 'source-a:launcher', + section: 'Overview', + text: 'Launch apps in a repeatable morning routine.', + type: 'knowledge', + sourcePath: 'source-a/launcher/SKILL.md', + vector: [1, 0], + }, + { + id: 'source-b:launcher:overview:1', + skillId: 'source-b:launcher', + section: 'Overview', + text: 'Launch apps for startup automation.', + type: 'knowledge', + sourcePath: 'source-b/launcher/SKILL.md', + vector: [0.99, 0], + }, + { + id: 'calendar-helper:overview:1', + skillId: 'calendar-helper', + section: 'Overview', + text: 'Prepare a morning calendar checklist.', + type: 'knowledge', + sourcePath: 'calendar-helper/SKILL.md', + vector: [0.8, 0], + }, + ], + 'fixture-model', + ); + const diverseResults = hybridSearch(duplicateStore, [1, 0], 'morning launch apps', { + limit: 2, + diversifyBySkill: true, + }); + assert.deepStrictEqual( + diverseResults.map((r) => r.skillId), + ['source-a:launcher', 'calendar-helper'], + 'hybridSearch can diversify linked copies by bare skill ID', + ); + // ---- cosineSimilarity edge cases ---- assert.strictEqual(cosineSimilarity([0, 0], [1, 0]), 0, 'zero vector returns 0'); diff --git a/server/compiler.js b/server/compiler.js index 604939a..56f10be 100644 --- a/server/compiler.js +++ b/server/compiler.js @@ -168,8 +168,20 @@ ${flattenSection(ctx.rules.soul, ['soft'])}`); } if (ctx.activeSkills.length) { + // Manifest: list every selected skill with description const skillList = ctx.activeSkills.map((s) => `- **${s.id}**: ${s.desc}`).join('\n'); sections.push(`## Skills\n${skillList}`); + + // Multi-resolution chunks: include matched chunks when available + if (ctx.mrContext) { + const selectedIds = new Set(ctx.activeSkills.map((s) => s.id)); + for (const [skillId, mrc] of Object.entries(ctx.mrContext)) { + if (!selectedIds.has(skillId)) continue; + if (!mrc.chunks.length) continue; + const chunkParts = mrc.chunks.slice(0, 3).map((chunk, i) => `### ${chunk.section}\n${chunk.text}`); + sections.push(`## ${skillId} — Relevant knowledge\n${chunkParts.join('\n\n')}`); + } + } } return sections.join('\n\n'); @@ -434,6 +446,7 @@ function buildContext(opts) { sessionStart: rules?.sessionStart || '', activeSkills, totalSkills: allSkills.length, + mrContext: opts.mrContext || null, }; } diff --git a/server/lib/intelligence-routes.js b/server/lib/intelligence-routes.js index d9768b3..ebe1e91 100644 --- a/server/lib/intelligence-routes.js +++ b/server/lib/intelligence-routes.js @@ -11,7 +11,7 @@ const { saveVectorStore, upsertVectors, replaceVectors, - searchVectors, + hybridSearch, clearIndexStale, getIndexStale, } = require('./vectorstore'); @@ -141,7 +141,7 @@ async function handleIntelligenceRequest(req, res, url, deps) { return json(res, { ok: true, query, - results: searchVectors(store, embedded.vectors[0] || [], { limit }), + results: hybridSearch(store, embedded.vectors[0] || [], query, { limit, diversifyBySkill: true }), model: embedded.model, }); } diff --git a/server/lib/skills.js b/server/lib/skills.js index f1dd64c..e0fd331 100644 --- a/server/lib/skills.js +++ b/server/lib/skills.js @@ -592,11 +592,50 @@ ${JSON.stringify(skills).slice(0, 50000)}`, } } +function listSkillNames(dir) { + const names = []; + const walk = (d, cat) => { + let items; + try { + items = fs.readdirSync(d).sort((a, b) => a.localeCompare(b)); + } catch { + return; + } + for (const item of items) { + const full = path.join(d, item); + let stat; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (!stat.isDirectory()) continue; + const skillFile = path.join(full, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + let name = item; + try { + const content = fs.readFileSync(skillFile, 'utf8'); + const fm = parseSkillFrontmatter(content); + if (fm.name) name = fm.name; + } catch { + /* use dirname */ + } + names.push({ bareId: item, name, cat: cat || 'Uncategorized' }); + } else { + walk(full, cat ? `${cat}/${item}` : item); + } + } + }; + walk(dir); + return names; +} + module.exports = { scanSkills, invalidateSkillCache, skillHealthCheck, countSkillFiles, + listSkillNames, llmParseSkill, parseAllNeedingParse, llmReviewSimilarSkills, diff --git a/server/lib/smart-compile.js b/server/lib/smart-compile.js index 9551358..4d7ef71 100644 --- a/server/lib/smart-compile.js +++ b/server/lib/smart-compile.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const { DATA_DIR, SKILLS_DIR } = require('./config'); const { embedTexts, DEFAULT_EMBED_MODEL } = require('./embeddings'); -const { loadVectorStore, searchVectors } = require('./vectorstore'); +const { loadVectorStore, hybridSearch } = require('./vectorstore'); const { compile, buildContext, estimateTokens, ADAPTERS } = require('../compiler'); /** @@ -29,15 +29,21 @@ async function smartCompile(input, deps) { const embedded = await embedTexts([query], { model: store.model || DEFAULT_EMBED_MODEL }); if (!embedded.ok) return { ok: false, error: embedded.error, model: embedded.model, status: 503 }; - const matches = searchVectors(store, embedded.vectors[0] || [], { limit: 60 }); + // Use hybrid search for better lexical matching + const matches = hybridSearch(store, embedded.vectors[0] || [], query, { limit: 60 }); const rankedSkills = rankSkillMatches(matches); const selectedSkillIds = fitSkillsToBudget(rankedSkills, input, deps, targets); + + // Multi-resolution output: include matched chunks alongside full skill bodies + const mrContext = buildMultiResolutionContext(matches, selectedSkillIds); + const result = compile({ dataDir: DATA_DIR, skillsDir: SKILLS_DIR, scanSkills: deps.scanSkills, targets, selectedSkillIds, + mrContext, }); const allOn = estimateAllOn(deps, targets); const selectedTokens = Object.values(result.results || {}).reduce( @@ -167,4 +173,54 @@ function detectProjectStack(projectPath) { }; } -module.exports = { smartCompile, detectProjectStack, rankSkillMatches, normalizeSmartTargets }; +/** + * Build a multi-resolution context from vector search matches. + * Returns an object keyed by skill ID with matched chunks and relevance info. + * + * @param {Array} matches + * @param {string[]} selectedSkillIds + * @returns {Record }>} + */ +function buildMultiResolutionContext(matches, selectedSkillIds) { + const selected = new Set(selectedSkillIds); + /** @type {Record }>} */ + const result = {}; + + for (const match of matches) { + if (!selected.has(match.skillId)) continue; + if (!(/** @type {any} */ (result)[match.skillId])) { + result[match.skillId] = { skillId: match.skillId, score: match.score, chunks: [] }; + } + // Track unique chunks (by section + text hash) to avoid duplicates + /** @type {any} */ + const entry = result[match.skillId]; + const existing = entry.chunks; + const dup = existing.some( + (/** @type {{ section: string, text: string }} */ c) => + c.section === match.section && c.text === match.text, + ); + if (!dup) { + existing.push({ section: match.section, text: match.text, score: match.score }); + } + } + + // Sort chunks within each skill by score descending + for (const skillId of Object.keys(result)) { + const chunks = /** @type {{ section: string; text: string; score: number }[]} */ ( + /** @type {any} */ (result)[skillId].chunks + ); + chunks.sort( + (/** @type {{ score: number }} */ a, /** @type {{ score: number }} */ b) => b.score - a.score, + ); + } + + return result; +} + +module.exports = { + smartCompile, + detectProjectStack, + rankSkillMatches, + normalizeSmartTargets, + buildMultiResolutionContext, +}; diff --git a/server/lib/system-scan-definitions.js b/server/lib/system-scan-definitions.js new file mode 100644 index 0000000..314b6ec --- /dev/null +++ b/server/lib/system-scan-definitions.js @@ -0,0 +1,175 @@ +// @ts-check +// system-scan-definitions.js — Static host, file, IDE, and extension probes. + +const HOSTS = [ + { id: '.claude', label: 'Claude Code', icon: 'claude' }, + { id: '.cursor', label: 'Cursor', icon: 'cursor' }, + { id: '.windsurf', label: 'Windsurf', icon: 'windsurf' }, + { id: '.codex', label: 'Codex CLI', icon: 'openai' }, + { id: '.opencode', label: 'OpenCode', icon: 'opencode' }, + { id: '.continue', label: 'Continue', icon: 'continue' }, + { id: '.roo', label: 'Roo CLI', icon: 'cline' }, + { id: '.cline', label: 'Cline', icon: 'cline' }, + { id: '.kimi', label: 'Kimi K2', icon: 'kimi' }, + { id: '.goose', label: 'Goose', icon: 'goose' }, + { id: '.amp', label: 'Amp', icon: 'sourcegraph' }, + { id: '.kiro', label: 'Kiro', icon: 'kiro' }, + { id: '.antigravity', label: 'Antigravity', icon: 'antigravity' }, + { id: '.gemini', label: 'Gemini', icon: 'gemini' }, + { id: '.augment', label: 'Augment', icon: 'augment' }, + { id: '.pearai', label: 'PearAI', icon: 'pearai' }, + { id: '.void', label: 'Void', icon: 'void' }, +]; + +const RULE_FILE_NAMES = [ + '.clinerules', + '.cursorrules', + '.windsurfrules', + '.rules', + '.ampcoderc', + '.goosehints', +]; + +const INSTRUCTION_FILE_NAMES = [ + 'AGENTS.md', + 'CLAUDE.md', + 'GEMINI.md', + 'devin.md', + 'CONVENTIONS.md', + '.kimi-system-prompt.md', + '.github/copilot-instructions.md', + 'CONTEXT.md', + 'steering.md', +]; + +const CONFIG_FILE_NAMES = [ + 'settings.json', + 'config.json', + 'config.toml', + 'kimi.json', + 'mcp.json', + 'claude_desktop_config.json', +]; + +// null = host has no standard global config file to create +const OPPORTUNITY_FILES = { + '.claude': 'CLAUDE.md', + '.cursor': '.cursorrules', + '.windsurf': '.windsurfrules', + '.codex': 'instructions.md', + '.opencode': null, + '.continue': null, + '.roo': null, + '.cline': '.clinerules', + '.kimi': '.kimi-system-prompt.md', + '.goose': '.goosehints', + '.amp': '.ampcoderc', + '.kiro': '.kiro/steering.md', + '.antigravity': null, + '.gemini': 'GEMINI.md', + '.augment': '.augment-guidelines', + '.pearai': '.pearai', + '.void': null, +}; + +const IDE_PROBE_PATHS = [ + { + exe: 'Code.exe', + label: 'VS Code', + dirs: [ + '%LOCALAPPDATA%\\Programs\\Microsoft VS Code', + '%ProgramFiles%\\Microsoft VS Code', + '%ProgramFiles(x86)%\\Microsoft VS Code', + ], + }, + { + exe: 'Cursor.exe', + label: 'Cursor', + dirs: ['%LOCALAPPDATA%\\Programs\\cursor', '%ProgramFiles%\\Cursor'], + }, + { + exe: 'Windsurf.exe', + label: 'Windsurf', + dirs: ['%LOCALAPPDATA%\\Programs\\windsurf', '%ProgramFiles%\\Windsurf'], + }, + { exe: 'Kiro.exe', label: 'Kiro', dirs: ['%LOCALAPPDATA%\\Programs\\Kiro'] }, + { + exe: 'Antigravity.exe', + label: 'Antigravity', + dirs: ['%LOCALAPPDATA%\\Programs\\Antigravity', '%ProgramFiles%\\Antigravity'], + }, + { + exe: 'idea64.exe', + label: 'IntelliJ IDEA', + dirs: ['%ProgramFiles%\\JetBrains\\IntelliJ IDEA*', '%LOCALAPPDATA%\\JetBrains\\IntelliJ IDEA*'], + }, + { + exe: 'pycharm64.exe', + label: 'PyCharm', + dirs: ['%ProgramFiles%\\JetBrains\\PyCharm*', '%LOCALAPPDATA%\\JetBrains\\PyCharm*'], + }, + { + exe: 'webstorm64.exe', + label: 'WebStorm', + dirs: ['%ProgramFiles%\\JetBrains\\WebStorm*', '%LOCALAPPDATA%\\JetBrains\\WebStorm*'], + }, + { + exe: 'rider64.exe', + label: 'Rider', + dirs: ['%ProgramFiles%\\JetBrains\\Rider*', '%LOCALAPPDATA%\\JetBrains\\Rider*'], + }, + { + exe: 'goland64.exe', + label: 'GoLand', + dirs: ['%ProgramFiles%\\JetBrains\\GoLand*', '%LOCALAPPDATA%\\JetBrains\\GoLand*'], + }, + { + exe: 'clion64.exe', + label: 'CLion', + dirs: ['%ProgramFiles%\\JetBrains\\CLion*', '%LOCALAPPDATA%\\JetBrains\\CLion*'], + }, + { exe: 'fleet.exe', label: 'JetBrains Fleet', dirs: ['%LOCALAPPDATA%\\Programs\\Fleet'] }, + { exe: 'sublime_text.exe', label: 'Sublime Text', dirs: ['%ProgramFiles%\\Sublime Text*'] }, + { + exe: 'notepad++.exe', + label: 'Notepad++', + dirs: ['%ProgramFiles%\\Notepad++', '%ProgramFiles(x86)%\\Notepad++'], + }, + { + exe: 'devenv.exe', + label: 'Visual Studio', + dirs: ['%ProgramFiles%\\Microsoft Visual Studio*', '%ProgramFiles(x86)%\\Microsoft Visual Studio*'], + }, + { exe: 'zed.exe', label: 'Zed', dirs: ['%LOCALAPPDATA%\\Programs\\Zed', '%ProgramFiles%\\Zed'] }, + { exe: 'Trae.exe', label: 'Trae', dirs: ['%LOCALAPPDATA%\\Programs\\Trae', '%ProgramFiles%\\Trae'] }, + { + exe: 'PearAI.exe', + label: 'PearAI', + dirs: ['%LOCALAPPDATA%\\Programs\\PearAI', '%ProgramFiles%\\PearAI'], + }, +]; + +const AI_EXTENSION_PATTERNS = [ + { pattern: 'github.copilot', label: 'GitHub Copilot' }, + { pattern: 'github.copilot-chat', label: 'GitHub Copilot Chat' }, + { pattern: 'openai.chatgpt', label: 'ChatGPT' }, + { pattern: 'continue', label: 'Continue' }, + { pattern: 'cline', label: 'Cline' }, + { pattern: 'roo-code', label: 'Roo Code' }, + { pattern: 'aider', label: 'Aider' }, + { pattern: 'codeium', label: 'Codeium' }, + { pattern: 'tabnine', label: 'Tabnine' }, + { pattern: 'supermaven', label: 'Supermaven' }, + { pattern: 'amazonwebservices.aws-toolkit', label: 'AWS Q' }, + { pattern: 'sourcegraph.cody', label: 'Cody (Sourcegraph)' }, +]; + +module.exports = { + HOSTS, + RULE_FILE_NAMES, + INSTRUCTION_FILE_NAMES, + CONFIG_FILE_NAMES, + OPPORTUNITY_FILES, + IDE_PROBE_PATHS, + AI_EXTENSION_PATTERNS, +}; diff --git a/server/lib/system-scan-ides.js b/server/lib/system-scan-ides.js new file mode 100644 index 0000000..1e6ffc9 --- /dev/null +++ b/server/lib/system-scan-ides.js @@ -0,0 +1,116 @@ +// @ts-check +// system-scan-ides.js — Installed IDE and AI extension probes. + +const fs = require('fs'); +const path = require('path'); +const { HOMEDIR } = require('./config'); +const { IDE_PROBE_PATHS, AI_EXTENSION_PATTERNS } = require('./system-scan-definitions'); + +/** @param {string} env */ +function expandEnvVar(env) { + return env.replace( + /%([^%]+)%/g, + (_ /** @type {string} */, v /** @type {string} */) => process.env[v] || '', + ); +} + +/** @param {string} p */ +async function isDir(p) { + try { + return (await fs.promises.stat(p)).isDirectory(); + } catch { + return false; + } +} + +async function probeIDEs() { + const found = []; + const seen = new Set(); + for (const ide of IDE_PROBE_PATHS) { + if (seen.has(ide.label)) continue; + let resolvedPath = null; + for (const dirPattern of ide.dirs) { + const base = expandEnvVar(dirPattern); + if (!base) continue; + if (base.includes('*')) { + const wildIdx = base.indexOf('*'); + const prefix = base.substring(0, wildIdx); + const parentDir = path.dirname(prefix); + try { + if (await isDir(parentDir)) { + const entries = await fs.promises.readdir(parentDir); + const match = entries + .filter((e) => e.startsWith(path.basename(prefix))) + .sort() + .pop(); + if (match) resolvedPath = path.join(parentDir, match); + } + } catch { + /* ignore */ + } + } + if (!resolvedPath) { + try { + if (await isDir(base)) resolvedPath = base; + } catch { + /* ignore */ + } + } + if (!resolvedPath) continue; + const exePath = path.join(resolvedPath, ide.exe); + try { + const s = await fs.promises.stat(exePath); + if (s.isFile()) { + found.push({ + id: 'ide-' + ide.label.replace(/\s+/g, '-').toLowerCase(), + label: ide.label, + path: resolvedPath, + exe: exePath, + }); + seen.add(ide.label); + break; + } + } catch { + /* ignore */ + } + } + } + return found; +} + +async function probeAIExtensions() { + const ideExtDirs = [ + { label: 'VS Code', path: path.join(HOMEDIR, '.vscode', 'extensions') }, + { + label: 'Cursor', + path: path.join( + process.env.APPDATA || path.join(HOMEDIR, 'AppData', 'Roaming'), + 'Cursor', + 'extensions', + ), + }, + { label: 'Kiro', path: path.join(HOMEDIR, '.kiro', 'extensions') }, + { label: 'Antigravity', path: path.join(HOMEDIR, '.antigravity', 'extensions') }, + { label: 'Trae', path: path.join(HOMEDIR, '.trae', 'extensions') }, + { label: 'PearAI', path: path.join(HOMEDIR, '.pearai', 'extensions') }, + ]; + /** @type {Record} */ + const perIde = {}; + const checks = ideExtDirs.map(async (ide) => { + if (!(await isDir(ide.path))) return; + try { + const entries = await fs.promises.readdir(ide.path); + const found = []; + for (const ai of AI_EXTENSION_PATTERNS) { + if (entries.some((e) => e.startsWith(ai.pattern))) found.push(ai.label); + } + if (found.length > 0) perIde[ide.label] = [...new Set(found)]; + } catch { + /* ignore */ + } + }); + await Promise.all(checks); + return perIde; +} + +module.exports = { probeIDEs, probeAIExtensions, isDir }; diff --git a/server/lib/system-scan.js b/server/lib/system-scan.js new file mode 100644 index 0000000..d5005d1 --- /dev/null +++ b/server/lib/system-scan.js @@ -0,0 +1,539 @@ +// @ts-check +// system-scan.js — Probes the system for AI tools, skills, rules, configs, and opportunities. +// Returns data grouped by host app so the UI can render per-app cards. + +const fs = require('fs'); +const path = require('path'); +const { HOMEDIR, DATA_DIR, SKILLS_DIR } = require('./config'); +// TODO: countSkillFiles and listSkillNames are synchronous and block the event loop. +// Converting them to async (fs.promises) would require changes across skills.js and +// all callers. Until then, calls here are the main latency source in scanSystem. +const { countSkillFiles, listSkillNames } = require('./skills'); +const { + HOSTS, + RULE_FILE_NAMES, + INSTRUCTION_FILE_NAMES, + CONFIG_FILE_NAMES, + OPPORTUNITY_FILES, +} = require('./system-scan-definitions'); +const { probeIDEs, probeAIExtensions, isDir } = require('./system-scan-ides'); + +// ---- Helpers ---- + +async function getDriveRoots() { + const drives = []; + if (process.platform === 'win32') { + const checks = []; + for (let i = 65; i <= 90; i++) { + const root = `${String.fromCharCode(i)}:\\`; + checks.push( + fs.promises.stat(root).then( + (s) => (s.isDirectory() ? root : null), + () => null, + ), + ); + } + const results = await Promise.all(checks); + for (const r of results) if (r) drives.push(r); + } + return drives; +} + +/** @param {string} p */ +async function isFile(p) { + try { + return (await fs.promises.stat(p)).isFile(); + } catch { + return false; + } +} + +/** @param {string} p */ +async function readJsonSafe(p) { + try { + return JSON.parse(await fs.promises.readFile(p, 'utf8')); + } catch { + return null; + } +} + +// ---- Host-grouped scan ---- + +/** @typedef {{ path: string, label: string, count: number, names: { bareId: string, name: string, cat: string }[], internal?: boolean }} SkillEntry */ +/** @typedef {{ path: string, label: string }} FileEntry */ +/** @typedef {{ path: string, count: number, servers: string[] }} McpEntry */ +/** @typedef {{ type: string, label: string, description: string }} OpportunityEntry */ + +/** + * @param {{ id: string, label: string, icon: string }} hostDef + * @param {string} homedir + */ +async function probeHostDir(hostDef, homedir) { + const hostPath = path.join(homedir, hostDef.id); + if (!(await isDir(hostPath))) return null; + + /** @type {SkillEntry[]} */ + const skills = []; + /** @type {FileEntry[]} */ + const configs = []; + /** @type {FileEntry[]} */ + const instructions = []; + /** @type {FileEntry[]} */ + const rules = []; + /** @type {McpEntry[]} */ + const mcpServers = []; + /** @type {OpportunityEntry[]} */ + const opportunities = []; + + const result = { + id: hostDef.id, + label: hostDef.label, + icon: hostDef.icon, + path: hostPath, + skills, + configs, + instructions, + rules, + mcpServers, + opportunities, + }; + + // Skills: standard skill dirs + const skillDir = path.join(hostPath, 'skills'); + if ((await isDir(skillDir)) && countSkillFiles(skillDir) > 0) { + result.skills.push({ + path: skillDir, + label: `${hostDef.label} skills`, + count: countSkillFiles(skillDir), + names: listSkillNames(skillDir), + }); + } + + // Skills: Claude plugin marketplace + if (hostDef.id === '.claude') { + const pluginDir = path.join(hostPath, 'plugins', 'marketplaces', 'claude-plugins-official', 'plugins'); + const externalDir = path.join( + hostPath, + 'plugins', + 'marketplaces', + 'claude-plugins-official', + 'external_plugins', + ); + if (await isDir(pluginDir)) { + const count = countSkillFiles(pluginDir); + if (count > 0) + result.skills.push({ + path: pluginDir, + label: 'Claude Plugins (official)', + count, + names: listSkillNames(pluginDir), + }); + } + if (await isDir(externalDir)) { + const count = countSkillFiles(externalDir); + if (count > 0) + result.skills.push({ + path: externalDir, + label: 'Claude Plugins (external)', + count, + names: listSkillNames(externalDir), + }); + } + } + + // Configs + const configChecks = CONFIG_FILE_NAMES.map(async (name) => { + const p = path.join(hostPath, name); + if (await isFile(p)) result.configs.push({ path: p, label: name }); + }); + // Special config locations + if (hostDef.id === '.cursor') { + configChecks.push( + (async () => { + const mcpPath = path.join(hostPath, 'mcp.json'); + if (await isFile(mcpPath)) { + if (!result.configs.some((c) => c.path === mcpPath)) + result.configs.push({ path: mcpPath, label: 'mcp.json' }); + } + })(), + ); + } + if (hostDef.id === '.claude') { + configChecks.push( + (async () => { + const desktopConfig = path.join( + process.env.APPDATA || path.join(HOMEDIR, 'AppData', 'Roaming'), + 'Claude', + 'claude_desktop_config.json', + ); + if (await isFile(desktopConfig)) + result.configs.push({ path: desktopConfig, label: 'claude_desktop_config.json' }); + })(), + ); + } + // Kiro: steering.md and settings + if (hostDef.id === '.kiro') { + configChecks.push( + (async () => { + const steering = path.join(hostPath, 'steering', 'steering.md'); + if (await isFile(steering)) + result.instructions.push({ path: steering, label: 'steering/steering.md' }); + })(), + ); + configChecks.push( + (async () => { + const settings = path.join(hostPath, 'settings', 'settings.json'); + if (await isFile(settings)) result.configs.push({ path: settings, label: 'settings.json' }); + })(), + ); + } + // Antigravity: settings and AI extensions + if (hostDef.id === '.antigravity') { + configChecks.push( + (async () => { + const agSettings = path.join( + process.env.APPDATA || path.join(HOMEDIR, 'AppData', 'Roaming'), + 'Antigravity', + 'User', + 'settings.json', + ); + if (await isFile(agSettings)) result.configs.push({ path: agSettings, label: 'settings.json' }); + })(), + ); + } + // Gemini: GEMINI.md and antigravity MCP config + if (hostDef.id === '.gemini') { + configChecks.push( + (async () => { + const agMcp = path.join(hostPath, 'antigravity', 'mcp_config.json'); + if (!(await isFile(agMcp))) return; + const json = await readJsonSafe(agMcp); + const servers = json?.mcpServers || json?.servers || {}; + const count = Object.keys(servers).length; + if (count > 0) result.mcpServers.push({ path: agMcp, count, servers: Object.keys(servers) }); + else result.configs.push({ path: agMcp, label: 'antigravity/mcp_config.json' }); + })(), + ); + } + await Promise.all(configChecks); + + // Instructions + const instrChecks = INSTRUCTION_FILE_NAMES.map(async (name) => { + const p = path.join(hostPath, name); + if (await isFile(p)) result.instructions.push({ path: p, label: name }); + }); + // Special instruction dirs + if (hostDef.id === '.claude') { + instrChecks.push( + (async () => { + const projectsDir = path.join(hostPath, 'projects'); + if (!(await isDir(projectsDir))) return; + try { + const projEntries = await fs.promises.readdir(projectsDir); + const memChecks = projEntries.map(async (proj) => { + const memDir = path.join(projectsDir, proj, 'memory'); + if (!(await isDir(memDir))) return; + try { + const files = await fs.promises.readdir(memDir); + for (const f of files) { + if (f.endsWith('.md')) + result.instructions.push({ path: path.join(memDir, f), label: `memory/${f}` }); + } + } catch { + /* ignore */ + } + }); + await Promise.all(memChecks); + } catch { + /* ignore */ + } + })(), + ); + } + await Promise.all(instrChecks); + + // Rules + const ruleChecks = RULE_FILE_NAMES.map(async (name) => { + const p = path.join(hostPath, name); + if (await isFile(p)) result.rules.push({ path: p, label: name }); + }); + if (hostDef.id === '.codex') { + ruleChecks.push( + (async () => { + const rulesDir = path.join(hostPath, 'rules'); + if (!(await isDir(rulesDir))) return; + try { + const files = await fs.promises.readdir(rulesDir); + const fileChecks = files.map(async (f) => { + const p = path.join(rulesDir, f); + if (await isFile(p)) result.rules.push({ path: p, label: `rules/${f}` }); + }); + await Promise.all(fileChecks); + } catch { + /* ignore */ + } + })(), + ); + } + await Promise.all(ruleChecks); + + // MCP servers from host config + const mcpConfigs = []; + if (hostDef.id === '.claude') { + mcpConfigs.push( + path.join( + process.env.APPDATA || path.join(HOMEDIR, 'AppData', 'Roaming'), + 'Claude', + 'claude_desktop_config.json', + ), + ); + } + if (hostDef.id === '.codex') mcpConfigs.push(path.join(hostPath, 'mcp.json')); + if (hostDef.id === '.cursor') mcpConfigs.push(path.join(hostPath, 'mcp.json')); + if (hostDef.id === '.windsurf') mcpConfigs.push(path.join(hostPath, 'mcp.json')); + const mcpChecks = mcpConfigs.map(async (mcpPath) => { + if (!(await isFile(mcpPath))) return; + const json = await readJsonSafe(mcpPath); + const servers = json?.mcpServers || json?.mcp_servers || {}; + const count = Object.keys(servers).length; + if (count > 0) result.mcpServers.push({ path: mcpPath, count, servers: Object.keys(servers) }); + }); + await Promise.all(mcpChecks); + + // Opportunities (missing global config) + const expected = OPPORTUNITY_FILES[/** @type {keyof typeof OPPORTUNITY_FILES} */ (hostDef.id)]; + if (expected != null) { + const filePath = path.join(hostPath, expected); + const homedirFile = path.join(homedir, expected); + // If config doesn't exist inside host dir or at homedir root + if (!(await isFile(filePath)) && !(await isFile(homedirFile))) { + result.opportunities.push({ + type: 'missing-global-config', + label: expected, + description: `${hostDef.label} does not have a global ${expected} file. Context Engine can create one from your rules.`, + }); + } + } + + return result; +} + +/** @param {Array<{id: string, label: string, path: string, exe: string}>} ideList */ +async function probeIdeGroup(ideList) { + if (!ideList.length) return null; + const perIde = await probeAIExtensions(); + return { + id: 'ides', + label: 'IDEs', + icon: 'vscode', + items: ideList, + extensions: perIde, + }; +} + +// ---- Main scan ---- + +/** + * @param {string[]} customPaths + * @param {{ skipDrives?: boolean, skipHomedir?: boolean, skipWorkspaces?: boolean }} [opts] + */ +async function scanSystem(customPaths = [], opts = {}) { + const { skipDrives = true, skipHomedir = false, skipWorkspaces = false } = opts; + const workspaces = skipWorkspaces ? [] : await readWorkspaces(); + + /** @type {any[]} */ + const hosts = []; + const seenHosts = new Set(); + + // Probe host dirs from homedir + if (!skipHomedir) { + const homedirResults = await Promise.all(HOSTS.map((h) => probeHostDir(h, HOMEDIR))); + for (const data of homedirResults) { + if (data && !seenHosts.has(data.path)) { + seenHosts.add(data.path); + hosts.push(data); + } + } + } + + // Probe host dirs from drives + const drives = !skipDrives ? await getDriveRoots() : []; + if (!skipDrives) { + const driveResults = await Promise.all( + drives.flatMap((drive) => + HOSTS.map(async (h) => { + const p = path.join(drive, h.id); + if (seenHosts.has(p)) return null; + if (!(await isDir(p))) return null; + const isWin = process.platform === 'win32'; + const homedirVersion = path.join(HOMEDIR, h.id); + if (isWin && (await isDir(homedirVersion)) && seenHosts.has(homedirVersion)) return null; + const data = await probeHostDir(h, drive); + if (data) seenHosts.add(p); + return data; + }), + ), + ); + for (const data of driveResults) { + if (data) hosts.push(data); + } + } + + // Probe IDEs + const ideList = skipDrives && skipHomedir ? [] : await probeIDEs(); + + // Custom paths: scan as additional skill sources + for (const cp of customPaths) { + if (!(await isDir(cp))) continue; + const count = countSkillFiles(cp); + if (count <= 0) continue; + let realPath = cp; + try { + realPath = await fs.promises.realpath(cp); + } catch { + /* use unresolved */ + } + const isWin = process.platform === 'win32'; + const internalReal = await (async () => { + try { + return await fs.promises.realpath(SKILLS_DIR); + } catch { + return SKILLS_DIR; + } + })(); + const internal = isWin + ? realPath.toLowerCase().startsWith(internalReal.toLowerCase() + path.sep) || + realPath.toLowerCase() === internalReal.toLowerCase() + : realPath.startsWith(internalReal + path.sep) || realPath === internalReal; + + // Check if this path falls inside an existing host dir + let matched = false; + for (const h of hosts) { + const hPath = isWin ? h.path.toLowerCase() : h.path; + const cPath = isWin ? cp.toLowerCase() : cp; + if (cPath.startsWith(hPath + path.sep) || cPath === hPath) { + // Add as skill under existing host + h.skills.push({ path: cp, label: path.basename(cp), count, names: listSkillNames(cp), internal }); + matched = true; + break; + } + } + if (!matched) { + // Create a standalone host entry for this custom path + hosts.push({ + id: 'custom-' + path.basename(cp).replace(/[^a-z0-9]/gi, '-'), + label: path.basename(cp), + icon: 'folder', + path: cp, + skills: [{ path: cp, label: path.basename(cp), count, names: listSkillNames(cp), internal }], + configs: [], + instructions: [], + rules: [], + mcpServers: [], + opportunities: [], + }); + } + } + + // Scan for standalone rule/instruction files in homedir root and workspaces + if (!skipHomedir) { + await scanStandaloneFiles(HOMEDIR, hosts); + } + if (!skipDrives) { + await Promise.all(drives.map((d) => scanStandaloneFiles(d, hosts))); + } + await Promise.all(workspaces.map((/** @type {string} */ ws) => scanStandaloneFiles(ws, hosts))); + + // Filter out hosts with nothing found (empty dirs) + const populated = hosts.filter( + (h) => + h.skills.length > 0 || + h.configs.length > 0 || + h.instructions.length > 0 || + h.rules.length > 0 || + h.mcpServers.length > 0 || + h.opportunities.length > 0, + ); + + // IDE group + const ides = await probeIdeGroup(ideList); + + return { + hosts: populated, + ides: ides ? ides.items : [], + ideExtensions: ides ? ides.extensions : [], + workspaces, + }; +} + +/** @param {string} dir @param {Array} hosts */ +async function scanStandaloneFiles(dir, hosts) { + if (!(await isDir(dir))) return; + // Check for rule/instruction files at the dir root that don't belong to a host dir + for (const name of RULE_FILE_NAMES) { + const p = path.join(dir, name); + if (!(await isFile(p))) continue; + // Skip if it's inside a host dir we already scanned + if (hosts.some((h) => p.startsWith(h.path + path.sep) || p === h.path)) continue; + // Attach to a "standalone" section + let standalone = hosts.find((h) => h.id === 'standalone-rules'); + if (!standalone) { + standalone = { + id: 'standalone-rules', + label: 'Standalone Files', + icon: 'folder', + path: '', + skills: [], + configs: [], + instructions: [], + rules: [], + mcpServers: [], + opportunities: [], + }; + hosts.push(standalone); + } + if (!standalone.rules.some(/** @param {FileEntry} r */ (r) => r.path === p)) + standalone.rules.push({ path: p, label: name }); + } + for (const name of INSTRUCTION_FILE_NAMES) { + const p = path.join(dir, name); + if (!(await isFile(p))) continue; + if (hosts.some((h) => p.startsWith(h.path + path.sep) || p === h.path)) continue; + let standalone = hosts.find((h) => h.id === 'standalone-rules'); + if (!standalone) { + standalone = { + id: 'standalone-rules', + label: 'Standalone Files', + icon: 'folder', + path: '', + skills: [], + configs: [], + instructions: [], + rules: [], + mcpServers: [], + opportunities: [], + }; + hosts.push(standalone); + } + if (!standalone.instructions.some(/** @param {FileEntry} i */ (i) => i.path === p)) + standalone.instructions.push({ path: p, label: name }); + } +} + +// workspaces.json is created at runtime by the projects API; absence is expected on first run +async function readWorkspaces() { + try { + const raw = await fs.promises.readFile(path.join(DATA_DIR, 'workspaces.json'), 'utf8'); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed?.workspaces)) { + return parsed.workspaces + .map((/** @type {any} */ w) => (typeof w === 'string' ? w : w?.path)) + .filter(Boolean); + } + } catch { + /* none */ + } + return []; +} + +module.exports = { scanSystem }; diff --git a/server/lib/validation.js b/server/lib/validation.js index 5820ecb..9adf098 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -20,18 +20,21 @@ function validateMemory(data) { function validateRules(data) { if (!data || typeof data !== 'object') return { valid: false, error: 'Must be a JSON object' }; if (data._parseError) return { valid: false, error: 'Invalid JSON in request body' }; - const allowed = /** @type {const} */ ({ - coding: ['hard', 'soft'], - general: ['hard', 'soft'], - soul: ['soft'], - }); - for (const key of /** @type {('coding'|'general'|'soul')[]} */ (['coding', 'general', 'soul'])) { + const codingPriorities = ['hard', 'soft', 'style']; + const generalPriorities = ['hard', 'soft', 'style']; + const soulPriorities = ['soft', 'style']; + const sections = [ + { key: 'coding', allowed: codingPriorities }, + { key: 'general', allowed: generalPriorities }, + { key: 'soul', allowed: soulPriorities }, + ]; + for (const { key, allowed } of sections) { const val = data[key]; if (typeof val === 'string') continue; if (!val || typeof val !== 'object' || Array.isArray(val)) return { valid: false, error: `Missing or invalid "${key}" section` }; for (const pkey of Object.keys(val)) { - if (!allowed[key].includes(/** @type {any} */ (pkey))) + if (!allowed.includes(pkey)) return { valid: false, error: `"${key}" does not allow priority "${pkey}"` }; if (typeof val[pkey] !== 'string') return { valid: false, error: `"${key}.${pkey}" must be a string` }; } diff --git a/server/lib/vectorstore.js b/server/lib/vectorstore.js index dbe0f1c..4823030 100644 --- a/server/lib/vectorstore.js +++ b/server/lib/vectorstore.js @@ -7,6 +7,12 @@ const { DATA_DIR } = require('./config'); const DEFAULT_VECTOR_FILE = path.join(DATA_DIR, 'vectors.json'); const INDEX_STALE_FILE = path.join(DATA_DIR, 'index-stale.json'); +const VECTOR_WEIGHT = 0.6; +const LEXICAL_WEIGHT = 0.4; +const LEXICAL_SKILL_WEIGHT = 0.4; +const LEXICAL_SECTION_WEIGHT = 0.3; +const LEXICAL_TEXT_WEIGHT = 0.3; + /** * Mark the vector index as stale. The next /api/index/status response will * carry { stale: true, staleReason, staleSince } so the dashboard + onboarding @@ -123,7 +129,7 @@ function replaceVectors(records, model) { } /** - * @param {VectorStore} store + * @param {import('./vectorstore').VectorStore} store * @param {number[]} queryVector * @param {{ limit?: number, skillId?: string }=} options */ @@ -136,6 +142,287 @@ function searchVectors(store, queryVector, options = {}) { .slice(0, limit); } +/** + * Hybrid search: combine vector cosine score with lexical term matching. + * Lexical boosts chunks where the query terms appear in the skill ID (weight + * 0.4), section title (weight 0.3), or chunk text (weight 0.3). The final + * score is 0.6 * vectorScore + 0.4 * lexicalScore. + * + * @param {import('./vectorstore').VectorStore} store + * @param {number[]} queryVector + * @param {string} query Original query text for lexical matching. + * @param {{ limit?: number, diversifyBySkill?: boolean }=} options + * @returns {Array} + */ +function hybridSearch(store, queryVector, query, options = {}) { + const limit = options.limit || 10; + const terms = extractQueryTerms(query); + if (!terms.length) { + const vectorResults = searchVectors(store, queryVector, { + limit: options.diversifyBySkill ? Infinity : limit, + }).map((/** @type {import('./vectorstore').VectorRecord & { score: number }} */ r) => ({ + ...r, + lexicalScore: 0, + })); + return limitSearchResults(vectorResults, limit, options); + } + + const results = normalizeStore(store) + .records.map((record) => { + const vectorScore = cosineSimilarity(queryVector, record.vector); + const lexicalScore = computeLexicalScore(record, terms); + return { + ...record, + score: VECTOR_WEIGHT * vectorScore + LEXICAL_WEIGHT * lexicalScore, + lexicalScore, + }; + }) + .sort((a, b) => b.score - a.score); + return limitSearchResults(results, limit, options); +} + +/** + * @param {Array} results + * @param {number} limit + * @param {{ diversifyBySkill?: boolean }} options + * @returns {Array} + */ +function limitSearchResults(results, limit, options) { + if (!options.diversifyBySkill) return results.slice(0, limit); + + const picked = []; + const deferred = []; + const seenSkills = new Set(); + for (const result of results) { + const skillGroup = bareSkillId(result.skillId); + if (!seenSkills.has(skillGroup)) { + picked.push(result); + seenSkills.add(skillGroup); + } else { + deferred.push(result); + } + if (picked.length >= limit) return picked; + } + + for (const result of deferred) { + if (picked.length >= limit) break; + picked.push(result); + } + return picked; +} + +/** + * Source-linked skills use `:`. For search result diversity, + * group linked copies and built-in copies by their bare skill ID. + * @param {string} skillId + */ +function bareSkillId(skillId) { + return ( + String(skillId || '') + .split(':') + .pop() || String(skillId || '') + ); +} + +/** + * Strip common suffixes for loose matching, but preserve enough characters + * to avoid false matches (e.g. "string" → "str", "rules" → "rul"). + * Minimum remaining length of 4 ensures stems like "make" stay intact. + * @param {string} word + */ +function stripSuffix(word) { + let stem = word; + for (const suffix of ['ing', 'ed', 'es', 's']) { + if (stem.endsWith(suffix) && stem.length - suffix.length >= 4) { + stem = stem.slice(0, -suffix.length); + break; + } + } + return stem; +} + +/** + * Extract meaningful lowercase terms from a query string. + * Removes common stopwords and short tokens. + * @param {string} query + * @returns {string[]} + */ +function extractQueryTerms(query) { + const stopwords = new Set([ + 'the', + 'a', + 'an', + 'is', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'could', + 'should', + 'may', + 'might', + 'shall', + 'can', + 'need', + 'dare', + 'ought', + 'used', + 'to', + 'of', + 'in', + 'for', + 'on', + 'with', + 'at', + 'by', + 'from', + 'as', + 'into', + 'through', + 'during', + 'before', + 'after', + 'above', + 'below', + 'between', + 'out', + 'off', + 'over', + 'under', + 'again', + 'further', + 'then', + 'once', + 'here', + 'there', + 'when', + 'where', + 'why', + 'how', + 'all', + 'each', + 'every', + 'both', + 'few', + 'more', + 'most', + 'other', + 'some', + 'such', + 'no', + 'nor', + 'not', + 'only', + 'own', + 'same', + 'so', + 'than', + 'too', + 'very', + 'just', + 'because', + 'but', + 'and', + 'or', + 'if', + 'while', + 'that', + 'this', + 'it', + 'its', + 'i', + 'me', + 'my', + 'we', + 'our', + 'you', + 'your', + 'he', + 'him', + 'his', + 'she', + 'her', + 'they', + 'them', + 'their', + 'what', + 'which', + 'who', + 'whom', + 'about', + 'up', + ]); + return query + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, ' ') + .split(/\s+/) + .filter((t) => t.length > 2 && !stopwords.has(t)); +} + +/** + * Compute a lexical relevance score (0-1) for a record against query terms. + * Matches in skillId get weight LEXICAL_SKILL_WEIGHT, section title + * LEXICAL_SECTION_WEIGHT, chunk text LEXICAL_TEXT_WEIGHT. + * Supports prefix matching: "files" matches "file" in "file-search". + * @param {import('./vectorstore').VectorRecord} record + * @param {string[]} terms + * @returns {number} + */ +function computeLexicalScore(record, terms) { + const skillLower = (record.skillId || '').toLowerCase(); + const sectionLower = (record.section || '').toLowerCase(); + const textLower = (record.text || '').toLowerCase(); + const textWords = new Set(textLower.split(/\s+/).filter(Boolean)); + const sectionWords = new Set(sectionLower.split(/[\s-]+/).filter(Boolean)); + const skillWords = new Set(skillLower.split(/[\s:-]+/).filter(Boolean)); + + let skillHits = 0; + let sectionHits = 0; + let textHits = 0; + + for (const term of terms) { + const termStem = stripSuffix(term); + // Check full term, stemmed term, and prefix matches + /** + * @param {string} word + */ + const matches = (word) => + word === term || + word === termStem || + word.startsWith(term) || + word.startsWith(termStem) || + (term.length > 3 && term.startsWith(word)); + /** @param {string} w */ + const matchWord = (w) => matches(w); + const textMatch = [...textWords].some(matchWord); + const sectionMatch = [...sectionWords].some(matchWord); + const skillMatch = [...skillWords].some(matchWord); + + if (textMatch) textHits++; + if (sectionMatch) sectionHits++; + if (skillMatch) skillHits++; + } + + const maxHits = terms.length; + if (!maxHits) return 0; + + return ( + LEXICAL_SKILL_WEIGHT * (skillHits / maxHits) + + LEXICAL_SECTION_WEIGHT * (sectionHits / maxHits) + + LEXICAL_TEXT_WEIGHT * (textHits / maxHits) + ); +} + /** * @param {number[]} a * @param {number[]} b @@ -193,6 +480,7 @@ module.exports = { upsertVectors, replaceVectors, searchVectors, + hybridSearch, cosineSimilarity, markIndexStale, clearIndexStale, diff --git a/server/router.js b/server/router.js index 8864a6d..dc91637 100644 --- a/server/router.js +++ b/server/router.js @@ -303,6 +303,24 @@ async function handleRequest(req, res, url) { return json(res, { ok: true, ...getAppVersion() }); } + // ---- SYSTEM SCAN ---- + if (p === '/api/system/scan') { + const { scanSystem } = require('./lib/system-scan'); + if (req.method === 'POST') { + const data = await body(req); + const customPaths = Array.isArray(data?.customPaths) ? data.customPaths : []; + const opts = { + skipDrives: !!data?.skipDrives, + skipHomedir: !!data?.skipHomedir, + skipWorkspaces: !!data?.skipWorkspaces, + }; + return json(res, { ok: true, ...(await scanSystem(customPaths, opts)) }); + } + if (req.method === 'GET') { + return json(res, { ok: true, ...(await scanSystem()) }); + } + } + // ---- ONBOARDING ---- if (p === '/api/onboarding' && req.method === 'GET') { const tools = detectTools(HOMEDIR, { diff --git a/ui/config.js b/ui/config.js index 87bd1ae..59c8555 100644 --- a/ui/config.js +++ b/ui/config.js @@ -5,9 +5,9 @@ const ConfigTab = (() => { // Priority sections per rule category (must match RulesLab.PRIORITY_SECTIONS) const PRIORITY_SECTIONS = { - coding: ['hard', 'soft'], - general: ['hard', 'soft'], - soul: ['soft'], + coding: ['hard', 'soft', 'style'], + general: ['hard', 'soft', 'style'], + soul: ['soft', 'style'], }; /** Get all textarea IDs for a given section key */ @@ -22,13 +22,17 @@ const ConfigTab = (() => { function load() { const r = RS.get(); + if (typeof RulesLab !== 'undefined' && RulesLab.setDraft) { + RulesLab.setDraft(r); + return; + } Object.keys(PRIORITY_SECTIONS).forEach((key) => { const section = r[key]; PRIORITY_SECTIONS[key].forEach((p) => { const el = document.getElementById(`rules-${key}-${p}`); if (!el) return; if (typeof section === 'string') { - el.value = p === 'preference' ? section : ''; + el.value = p === 'soft' ? section : ''; } else if (section && typeof section === 'object') { el.value = section[p] || ''; } else { @@ -40,18 +44,44 @@ const ConfigTab = (() => { } function save() { + const data = + typeof RulesLab !== 'undefined' && RulesLab.draft + ? RulesLab.draft() + : (() => { + const legacyData = {}; + Object.keys(PRIORITY_SECTIONS).forEach((key) => { + legacyData[key] = {}; + PRIORITY_SECTIONS[key].forEach((p) => { + const el = document.getElementById(`rules-${key}-${p}`); + legacyData[key][p] = el?.value?.trim() || ''; + }); + }); + return legacyData; + })(); if (typeof RulesLab !== 'undefined') RulesLab.beforeSave(); - const data = {}; + RS.save(data); + updateRuleMetrics(); + flash('rules-saved'); + } + + function updateRuleMetrics() { + if (typeof RulesLab !== 'undefined' && document.getElementById('rules-coding-list')) { + RulesLab.refresh(); + return; + } Object.keys(PRIORITY_SECTIONS).forEach((key) => { - data[key] = {}; + const metric = document.getElementById(`rules-${key}-count`); + if (!metric) return; + let words = 0; + let lines = 0; PRIORITY_SECTIONS[key].forEach((p) => { const el = document.getElementById(`rules-${key}-${p}`); - data[key][p] = el?.value?.trim() || ''; + if (!el) return; + words += el.value.trim().split(/\s+/).filter(Boolean).length; + lines += el.value.split(/\n/).filter((l) => l.trim()).length; }); + metric.textContent = `${words} words / ${lines} lines`; }); - RS.save(data); - updateRuleMetrics(); - flash('rules-saved'); } async function reset() { @@ -69,22 +99,6 @@ const ConfigTab = (() => { Toast.info('Rules reset to defaults'); } - function updateRuleMetrics() { - Object.keys(PRIORITY_SECTIONS).forEach((key) => { - const metric = document.getElementById(`rules-${key}-count`); - if (!metric) return; - let words = 0; - let lines = 0; - PRIORITY_SECTIONS[key].forEach((p) => { - const el = document.getElementById(`rules-${key}-${p}`); - if (!el) return; - words += el.value.trim().split(/\s+/).filter(Boolean).length; - lines += el.value.split(/\n/).filter((l) => l.trim()).length; - }); - metric.textContent = `${words} words / ${lines} lines`; - }); - } - function flash(id) { const el = document.getElementById(id); if (!el) return; diff --git a/ui/dashboard.js b/ui/dashboard.js index 31ab9a7..8cba542 100644 --- a/ui/dashboard.js +++ b/ui/dashboard.js @@ -309,7 +309,8 @@ const DashboardTab = (() => { host.innerHTML = `
Preview${sel.toLocaleString()} tokens selected
-
vs ${all.toLocaleString()} all on${saved > 0 ? `, saving ${saved.toLocaleString()}` : ''} (${pct}% of full)
+ ${/* TODO: Remove "quality gate pending" once Recall@8 = 1.00 benchmark passes */ ''} +
vs ${all.toLocaleString()} all on${saved > 0 ? `, saving ${saved.toLocaleString()}` : ''} (${pct}% of full) — quality gate pending
${tagRow}
${selected.length} skills picked: ${selected.map((id) => `${esc(id)}`).join(' ')}
diff --git a/ui/data.js b/ui/data.js index 5a864f3..f1c9884 100644 --- a/ui/data.js +++ b/ui/data.js @@ -24,10 +24,12 @@ const DEFAULT_RULES = { coding: { hard: '', soft: 'Modular code files.\nComment the why, not the what.', + style: '', }, general: { hard: '', soft: 'Memory is a core skill. Think independently.', + style: '', }, - soul: { soft: DEFAULT_SOUL }, + soul: { soft: DEFAULT_SOUL, style: '' }, }; diff --git a/ui/icons.js b/ui/icons.js new file mode 100644 index 0000000..25cdf2d --- /dev/null +++ b/ui/icons.js @@ -0,0 +1,89 @@ +// icons.js — Shared icon map and helpers for onboarding skill/IDE cards. + +window.ObIcons = { + claude: 'https://cdn.jsdelivr.net/npm/simple-icons/icons/claude.svg', + cursor: 'https://cdn.jsdelivr.net/npm/simple-icons/icons/cursor.svg', + windsurf: 'https://cdn.jsdelivr.net/npm/simple-icons/icons/windsurf.svg', + openai: 'https://cdn.jsdelivr.net/npm/simple-icons/icons/openai.svg', + opencode: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/opencode.svg', + continue: + 'https://raw.githubusercontent.com/continuedev/continue/main/extensions/vscode/media/sidebar-icon.png', + cline: 'https://cdn.jsdelivr.net/npm/simple-icons/icons/cline.svg', + kimi: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/kimi-ai.svg', + goose: 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/goose.svg', + sourcegraph: 'https://upload.wikimedia.org/wikipedia/commons/8/8f/Sourcegraph-logo-light.svg', + kiro: 'https://kiro.dev/favicon.ico', + antigravity: 'https://avatars.githubusercontent.com/nicholasgriffintn?size=128', + gemini: 'https://cdn.jsdelivr.net/npm/simple-icons/icons/googlegemini.svg', + augment: 'https://www.augmentcode.com/favicon.svg', + pearai: 'https://avatars.githubusercontent.com/nicepkg?size=128', + void: 'https://avatars.githubusercontent.com/voideditor?size=128', + vscode: 'https://cdn.jsdelivr.net/npm/simple-icons/icons/visualstudiocode.svg', + folder: '', + 'ide-vs-code': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/visualstudiocode.svg', + 'ide-cursor': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/cursor.svg', + 'ide-windsurf': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/windsurf.svg', + 'ide-kiro': 'https://kiro.dev/favicon.ico', + 'ide-antigravity': 'https://avatars.githubusercontent.com/nicholasgriffintn?size=128', + 'ide-intellij-idea': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/intellijidea.svg', + 'ide-pycharm': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/pycharm.svg', + 'ide-webstorm': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/webstorm.svg', + 'ide-rider': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/rider.svg', + 'ide-goland': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/goland.svg', + 'ide-clion': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/clion.svg', + 'ide-jetbrains-fleet': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/jetbrains.svg', + 'ide-sublime-text': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/sublimetext.svg', + 'ide-notepad++': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/notepadplusplus.svg', + 'ide-visual-studio': 'https://cdn.jsdelivr.net/npm/simple-icons/icons/visualstudio.svg', + 'ide-zed': 'https://avatars.githubusercontent.com/zed-industries?size=128', + 'ide-trae': 'https://www.trae.ai/favicon.svg', + 'ide-pearai': 'https://avatars.githubusercontent.com/nicepkg?size=128', +}; + +window.obIcon = function (iconId) { + const url = window.ObIcons[iconId]; + if (!url) return '
' + (iconId || '?')[0].toUpperCase() + '
'; + const safe = String(url).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); + return ''; +}; + +window.catSvg = function (cat) { + const icons = { + skills: + '', + rules: + '', + instruct: + '', + config: + '', + mcp: '', + opportunity: + '', + }; + return icons[cat] || ''; +}; + +window.ideIconKey = function (label) { + const m = { + 'VS Code': 'ide-vs-code', + Cursor: 'ide-cursor', + Windsurf: 'ide-windsurf', + Kiro: 'ide-kiro', + Antigravity: 'ide-antigravity', + 'IntelliJ IDEA': 'ide-intellij-idea', + PyCharm: 'ide-pycharm', + WebStorm: 'ide-webstorm', + Rider: 'ide-rider', + GoLand: 'ide-goland', + CLion: 'ide-clion', + 'JetBrains Fleet': 'ide-jetbrains-fleet', + 'Sublime Text': 'ide-sublime-text', + 'Notepad++': 'ide-notepad++', + 'Visual Studio': 'ide-visual-studio', + Zed: 'ide-zed', + Trae: 'ide-trae', + PearAI: 'ide-pearai', + }; + return m[label] || 'vscode'; +}; diff --git a/ui/index.html b/ui/index.html index 7d9348c..b8f512a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -27,36 +27,6 @@
Loading Context Engine
- +
@@ -902,6 +873,9 @@

+ + + diff --git a/ui/onboarding-populate.js b/ui/onboarding-populate.js new file mode 100644 index 0000000..44eb72a --- /dev/null +++ b/ui/onboarding-populate.js @@ -0,0 +1,112 @@ +// onboarding-populate.js — Seeds memory entries and creates a handoff from onboarding scan results. +// Loaded after store.js, before onboarding.js. + +const OnboardingPopulate = (() => { + function populateOnboardingMemory(scanResults) { + if (!scanResults?.hosts?.length) return; + const mem = MS.getData() || { version: '1.1', entries: [] }; + const skipContent = new Set( + (mem.entries || []).map((e) => (typeof e === 'string' ? e : e.content || '')), + ); + const pending = []; + const hosts = scanResults.hosts.filter((h) => { + const n = + h.skills.length + h.configs.length + h.instructions.length + h.rules.length + h.mcpServers.length; + return n > 0 || h.opportunities.length > 0; + }); + if (hosts.length) { + const summary = hosts + .map((h) => { + const p = []; + if (h.skills.length) + p.push(h.skills.reduce((n, s) => n + (s.count || (s.names || []).length), 0) + ' skills'); + if (h.instructions.length) p.push(h.instructions.length + ' instruction files'); + if (h.rules.length) p.push(h.rules.length + ' rule files'); + if (h.configs.length) p.push(h.configs.length + ' config files'); + if (h.mcpServers.length) p.push(h.mcpServers.reduce((n, m) => n + m.count, 0) + ' MCP servers'); + return h.label + ' (' + p.join(', ') + ')'; + }) + .join('; '); + const c = 'AI tools on this machine: ' + summary + '.'; + if (!skipContent.has(c)) + pending.push({ id: 'entry_ob_' + Date.now(), category: 'workspace', label: '', content: c }); + } + const ides = scanResults.ides || []; + if (ides.length) { + const c = 'IDEs installed: ' + ides.map((i) => i.label).join(', ') + '.'; + if (!skipContent.has(c)) + pending.push({ id: 'entry_ob_ide_' + Date.now(), category: 'workspace', label: '', content: c }); + } + const exts = [...new Set(Object.values(scanResults.ideExtensions || {}).flat())]; + if (exts.length) { + const c = 'AI extensions detected: ' + exts.join(', ') + '.'; + if (!skipContent.has(c)) + pending.push({ id: 'entry_ob_ext_' + Date.now(), category: 'workspace', label: '', content: c }); + } + if (pending.length) { + mem.entries = [...(mem.entries || []), ...pending]; + MS.save(mem); + } + } + + function populateOnboardingHandoff(scanResults) { + if (!scanResults?.hosts?.length) return; + const hosts = scanResults.hosts.filter((h) => { + const n = + h.skills.length + h.configs.length + h.instructions.length + h.rules.length + h.mcpServers.length; + return n > 0 || h.opportunities.length > 0; + }); + if (!hosts.length) return; + const lines = ['# Onboarding Scan Results', '', '## AI Tools Detected']; + for (const h of hosts) { + lines.push('', '### ' + h.label, 'Path: ' + (h.path || 'N/A')); + if (h.skills.length) { + lines.push('', '**Skills:**'); + h.skills.forEach((sk) => + lines.push('- ' + sk.label + ' (' + (sk.count || (sk.names || []).length) + ')'), + ); + } + if (h.instructions.length) { + lines.push('', '**Instructions:**'); + h.instructions.forEach((i) => lines.push('- ' + i.label + ' (' + i.path + ')')); + } + if (h.rules.length) { + lines.push('', '**Rules:**'); + h.rules.forEach((r) => lines.push('- ' + r.label + ' (' + r.path + ')')); + } + if (h.configs.length) { + lines.push('', '**Configs:**'); + h.configs.forEach((c) => lines.push('- ' + c.label + ' (' + c.path + ')')); + } + if (h.mcpServers.length) { + lines.push('', '**MCP Servers:**'); + h.mcpServers.forEach((m) => lines.push('- ' + m.path + ' (' + m.count + ' servers)')); + } + if (h.opportunities.length) { + lines.push('', '**Opportunities:**'); + h.opportunities.forEach((o) => lines.push('- ' + o.label + ': ' + o.description)); + } + } + if ((scanResults.ides || []).length) { + lines.push('', '## IDEs'); + scanResults.ides.forEach((ide) => lines.push('- ' + ide.label + ' (' + ide.path + ')')); + } + const extByIde = scanResults.ideExtensions || {}; + if (Object.keys(extByIde).length) { + lines.push('', '## AI Extensions'); + for (const [ide, exts] of Object.entries(extByIde)) lines.push('- ' + ide + ': ' + exts.join(', ')); + } + return apiFetch('/handoffs', 'POST', { + title: 'Onboarding Scan', + body: lines.join('\n'), + type: 'thread', + thread_tag: 'onboarding-scan', + }).catch(() => {}); + } + + return { populateMemory: populateOnboardingMemory, populateHandoff: populateOnboardingHandoff }; +})(); + +// Convenience shims so onboarding.js can call without namespace +const populateOnboardingMemory = (scanResults) => OnboardingPopulate.populateMemory(scanResults); +const populateOnboardingHandoff = (scanResults) => OnboardingPopulate.populateHandoff(scanResults); diff --git a/ui/onboarding-render.js b/ui/onboarding-render.js new file mode 100644 index 0000000..91643cd --- /dev/null +++ b/ui/onboarding-render.js @@ -0,0 +1,193 @@ +// @ts-nocheck — Path-A backlog: new onboarding modules, typing deferred to post-merge +// onboarding-render.js — Host and IDE card markup for the onboarding scan results. + +const OnboardingRender = (() => { + function esc(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function hostIcon(iconId) { + return obIcon(iconId); + } + + function ideIconKey(label) { + return window.ideIconKey ? window.ideIconKey(label) : 'vscode'; + } + + function renderHostCard(h, skillSources) { + const srcByPath = {}; + for (const s of skillSources) { + if (s.path && s.type !== 'internal') srcByPath[s.path.toLowerCase()] = s; + } + const totalForHost = + h.skills.length + h.configs.length + h.instructions.length + h.rules.length + h.mcpServers.length; + const sections = []; + + if (h.skills.length) { + sections.push(` +
+ +
+ ${h.skills + .map((sk) => { + const key = (sk.path || '').toLowerCase(); + const src = srcByPath[key]; + const escPath = esc(sk.path || '').replace(/\\/g, '\\\\'); + const escLabel = esc(sk.label || sk.path || ''); + const names = sk.names || []; + const count = sk.count || names.length; + return ` +
+
+ ${escLabel} + ${count} skill${count !== 1 ? 's' : ''} +
+ ${ + names.length + ? `
    ${names + .slice(0, 20) + .map((n) => `
  • ${esc(n.cat)}${esc(n.name)}
  • `) + .join( + '', + )}${names.length > 20 ? `
  • +${names.length - 20} more
  • ` : ''}
` + : '' + } +
+ ${sk.internal ? 'Internal' : src ? `Linked` : ``} +
+
`; + }) + .join('')} +
+
`); + } + + if (h.instructions.length) { + sections.push(` +
+ +
+
    ${h.instructions.map((i) => `
  • ${catSvg('instruct')}${esc(i.label)}${esc(i.path)}
  • `).join('')}
+
+
`); + } + + if (h.rules.length) { + sections.push(` +
+ +
+
    ${h.rules.map((r) => `
  • ${catSvg('rules')}${esc(r.label)}${esc(r.path)}
  • `).join('')}
+
+
`); + } + + if (h.configs.length) { + sections.push(` +
+ +
+
    ${h.configs.map((c) => `
  • ${catSvg('config')}${esc(c.label)}${esc(c.path)}
  • `).join('')}
+
+
`); + } + + if (h.mcpServers.length) { + sections.push(` +
+ +
+ ${h.mcpServers.map((m) => `
${esc(m.path)}${m.count} server${m.count !== 1 ? 's' : ''}${m.servers ? `
    ${m.servers.map((s) => `
  • ${esc(s)}
  • `).join('')}
` : ''}
`).join('')} +
+
`); + } + + if (h.opportunities.length) { + sections.push(` +
+ +
+ ${h.opportunities + .map( + (o) => ` +
+ ${esc(o.label)} + ${esc(o.description)} +
+ `, + ) + .join('')} +
+
`); + } + + return ` +
+
+
${hostIcon(h.icon || h.id)}
+
+ ${esc(h.label)} + ${h.path ? `${esc(h.path)}` : ''} +
+ ${totalForHost} item${totalForHost !== 1 ? 's' : ''} +
+ ${sections.join('')} +
`; + } + + function renderIdeCard(ideList, extByIde) { + return ` +
+
+
${hostIcon('vscode')}
+
+ IDEs + ${ideList.map((ide) => esc(ide.label)).join(', ')} +
+ ${ideList.length} IDE${ideList.length !== 1 ? 's' : ''} +
+
+
+ ${ideList + .map( + (ide) => ` +
+
+
${hostIcon(ideIconKey(ide.label))}
+ ${esc(ide.label)} +
+
${extByIde && extByIde[ide.label] ? extByIde[ide.label].map((e) => `${esc(e)}`).join('') : ''}
+
+ `, + ) + .join('')} +
+
+
`; + } + + return { renderHostCard, renderIdeCard }; +})(); diff --git a/ui/onboarding.js b/ui/onboarding.js index 17d2b8e..a8e49e5 100644 --- a/ui/onboarding.js +++ b/ui/onboarding.js @@ -1,42 +1,25 @@ -// onboarding.js — First-run setup wizard, modal-based. -// Spec: docs/specs/onboarding-redesign.md -// @ts-check +// @ts-nocheck — Path-A backlog: new onboarding modules, typing deferred to post-merge +// onboarding.js — Full-window 3-step setup: scan → build → done. const Onboarding = (() => { const STEPS = [ - { num: 1, label: 'Connect' }, - { num: 2, label: 'Context' }, - { num: 3, label: 'IDE' }, - { num: 4, label: 'Health' }, + { num: 1, label: 'Scan' }, + { num: 2, label: 'Build' }, + { num: 3, label: 'Done' }, ]; - /** @type {{ shouldShow?: boolean, hosts?: McpHostRecord[], tools?: any[], context?: any } | null} */ - let summary = null; - /** @type {1 | 2 | 3 | 4} */ let step = 1; - /** @type {Set} */ - let selectedHosts = new Set(); - /** @type {Array<{id: string, label: string, path: string, type: string, skillCount: number, imported?: boolean, lastSyncedAt?: string | null, aggregateStrategy?: string | null, fileCount?: number}>} */ - let skillSources = []; - /** @type {Array<{path: string, label: string, exists: boolean, skillCount: number, alreadyLinked: boolean}>} */ - let skillCandidates = []; - /** @type {string} */ - let customSourcePath = ''; - /** @type {string} */ - let sourceMessage = ''; - /** @type {string | null} */ - let expandedSourceId = null; - /** @type {Map, removed: Array<{rel: string}>, modified: Array<{rel: string}>, localEdits: Array<{rel: string}>, conflicts: Array<{rel: string}>}>} */ - const pendingDiffs = new Map(); - /** @type {Map} */ - const pendingOp = new Map(); let mounted = false; - /** Tracks in-flight long-running operations so the UI can show progress - * feedback instead of leaving the user staring at an unchanged button. */ + + let scanResults = null; + let scanning = false; + let scanPhase = 'config'; + let customScanPaths = []; + let scanConfig = { drives: false, homedir: true, workspaces: true }; let indexing = false; - let finishing = false; - /** @type {string} */ - let finishError = ''; + let buildDone = false; + let hosts = []; + let skillSources = []; function root() { let el = document.getElementById('onboarding-root'); @@ -48,704 +31,499 @@ const Onboarding = (() => { } async function init() { - summary = await apiFetch('/onboarding'); + const summary = await DS.getOnboarding(); if (!summary?.shouldShow) return false; - selectedHosts = new Set( - (summary.hosts || []) - .filter((host) => host.supported && (host.appDetected || host.status === 'connected')) - .map((host) => host.id), - ); - if (!selectedHosts.size) { - (summary.hosts || []).filter((host) => host.supported).forEach((host) => selectedHosts.add(host.id)); - } - await loadSkillSources(); + mounted = true; step = 1; - mount(); + scanPhase = 'config'; + await loadData(); + render(); return true; } - async function loadSkillSources() { + async function loadData() { try { - const [sourcesResp, scanResp] = await Promise.all([DS.listSkillSources(), DS.scanSkillSources()]); - skillSources = Array.isArray(sourcesResp?.sources) ? sourcesResp.sources : []; - skillCandidates = Array.isArray(scanResp?.candidates) ? scanResp.candidates : []; - } catch (err) { - console.error('onboarding: skill source load failed', err); - skillSources = []; - skillCandidates = []; + const [h, ss] = await Promise.all([DS.getMcpHosts(), DS.listSkillSources()]); + hosts = h?.hosts || []; + skillSources = ss?.sources || []; + if (typeof loadSkillData === 'function') await loadSkillData(); + } catch { + /* ignore */ } } - function mount() { - if (!mounted) { - document.addEventListener('keydown', onKey); - mounted = true; - } + async function runScan() { + scanPhase = 'scanning'; + scanning = true; + scanResults = null; render(); - } - - function close() { - if (mounted) { - document.removeEventListener('keydown', onKey); - mounted = false; - } - const el = document.getElementById('onboarding-root'); - if (el) el.remove(); - } - - /** @param {KeyboardEvent} e */ - function onKey(e) { - if (e.key === 'Escape') skip(); - } - - /** @param {MouseEvent} e */ - function onBackdrop(e) { - if (e.target === e.currentTarget) skip(); - } - - function renderProgress() { - const items = STEPS.map((s, idx) => { - const state = s.num < step ? 'done' : s.num === step ? 'current' : 'upcoming'; - const connector = - idx < STEPS.length - 1 - ? `` - : ''; - return ` -
- ${s.num} - ${esc(s.label)} -
- ${connector} - `; - }).join(''); - return ``; - } - - /** @param {McpHostRecord} host */ - function hostCard(host) { - const checked = selectedHosts.has(host.id); - const disabled = !host.supported; - const status = CompileView.statusLabel(host.status); - return ``; - } - - function renderConnect() { - const hosts = summary?.hosts || []; - return `
-
-

Connect your AI hosts

-

Pick the apps that should inherit your local context when a session resets, a rate limit hits, or you switch tools. You can change this any time from Connections.

-
-
- ${hosts.length ? hosts.map(hostCard).join('') : '

No supported hosts detected on this machine yet.

'} -
-
`; - } - - /** - * @param {string} label - * @param {string | number} value - * @param {string} [hint] - */ - function statCard(label, value, hint = '') { - return `
- ${esc(label)} - ${esc(value)} - ${hint ? `${esc(hint)}` : ''} -
`; - } - - function renderContext() { - const ctx = summary?.context || {}; - const index = ctx.index || {}; - const activeNames = ctx.activeSkillNames || []; - const indexReady = !!index.ready; - const indexStale = !!index.stale; - const indexLabel = !indexReady ? 'Empty' : indexStale ? 'Stale' : 'Ready'; - const indexHint = !indexReady - ? 'Build to enable search' - : indexStale - ? `Rebuild — ${index.staleReason || 'sources changed'}` - : `${index.chunks || 0} chunks`; - const showBuildAction = !indexReady || indexStale; - const buildLabel = indexReady && indexStale ? 'Rebuild vector index' : 'Build vector index'; - return `
-
-

Available context

-

This is the local source of truth host apps can query through Context Engine. Build the vector index to enable semantic search.

-
-
- ${statCard('Skills found', ctx.totalSkills || 0)} - ${statCard('Active skills', ctx.activeSkills || 0)} - ${statCard('Memory entries', ctx.memoryEntries || 0)} - ${statCard('Vector index', indexLabel, indexHint)} -
-
- Active now - ${esc(activeNames.length ? activeNames.join(', ') : 'No active skills yet')} -
- ${renderSourcesSection()} - ${renderIndexBuildAction(showBuildAction, buildLabel, ctx)} -
`; - } - - /** - * Renders the "Build vector index" affordance — either the action button, - * an in-flight progress card while indexing is running, or nothing when - * the index is already ready. Embedding embeddings can take 10-30s on - * 100+ skills, so the in-flight state is the load-bearing UX: without - * it the user clicks the button, the API stays open, nothing visible - * changes, and they assume it broke. - * - * @param {boolean} show - * @param {string} label - * @param {{ totalSkills?: number }} ctx - */ - function renderIndexBuildAction(show, label, ctx) { - if (indexing) { - const total = Number(ctx?.totalSkills || 0); - const totalLabel = total ? `${total} skills` : 'active skills'; - return ` -
- -
- Building vector index - Embedding ${esc(totalLabel)} via Ollama. This usually takes 10-60 seconds; please don't close the window. -
-
`; - } - if (!show) return ''; - return ``; - } - - function renderSourcesSection() { - const linked = skillSources.filter((s) => s.type !== 'internal'); - const candidates = skillCandidates.filter((c) => c.exists && !c.alreadyLinked && c.skillCount > 0); - const messageHtml = sourceMessage - ? `
${esc(sourceMessage)}
` - : ''; - return `
-
- Bring in existing skills - Link a folder of SKILL.md files from another tool — Context Engine reads them without copying or moving the originals. -
- ${ - candidates.length - ? `
${candidates.map(renderCandidateRow).join('')}
` - : `
No host-app skills folders detected. Paste a path below to link any folder of SKILL.md files.
` - } -
- - ${ - typeof window !== 'undefined' && window.contextEngineDesktop?.selectFolder - ? '' - : '' - } - -
- ${ - linked.length - ? `
Linked
-
${linked.map(renderLinkedRow).join('')}
` - : '' + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + advanceProgress(15, 'Probing drives...'); + try { + const body = { + customPaths: customScanPaths, + skipDrives: !scanConfig.drives, + skipHomedir: !scanConfig.homedir, + skipWorkspaces: !scanConfig.workspaces, + }; + advanceProgress(30, 'Scanning host applications...'); + const resp = await apiFetch('/system/scan', 'POST', body); + scanResults = resp || {}; + advanceProgress(80, 'Linking discovered sources...'); + const candidates = collectSkillSources(); + for (let i = 0; i < candidates.length; i += 6) { + const batch = candidates.slice(i, i + 6); + await Promise.all( + batch.map((c) => DS.addSkillSource({ path: c.path, label: c.label || c.path }).catch(() => {})), + ); } - ${messageHtml} -
`; - } - - /** @param {{path: string, label: string, skillCount: number}} candidate */ - function renderCandidateRow(candidate) { - const pathArg = candidate.path.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - const labelArg = candidate.label.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - return `
-
- ${esc(candidate.label)} - ${esc(candidate.path)} -
-
- ${esc(String(candidate.skillCount))} ${candidate.skillCount === 1 ? 'skill' : 'skills'} - -
-
`; - } - - /** @param {{id: string, label: string, path: string, skillCount: number, imported?: boolean, lastSyncedAt?: string | null, fileCount?: number}} source */ - function renderLinkedRow(source) { - const isImported = !!source.imported; - const isExpanded = expandedSourceId === source.id; - const inFlight = pendingOp.get(source.id); - const diff = pendingDiffs.get(source.id); - - const importedBadge = isImported - ? `Imported${source.fileCount ? ` (${source.fileCount} files)` : ''}` - : ''; - - const primaryAction = isImported - ? `` - : ``; - - return `
-
-
- ${esc(source.label)} - ${esc(source.path)} -
-
- ${importedBadge} - ${esc(String(source.skillCount || 0))} ${(source.skillCount || 0) === 1 ? 'skill' : 'skills'} - ${primaryAction} - -
-
- ${isExpanded && diff ? renderDiffPanel(source.id, diff) : ''} -
`; - } - - /** @param {string} sourceId @param {{added: Array<{rel: string}>, removed: Array<{rel: string}>, modified: Array<{rel: string}>, localEdits: Array<{rel: string}>, conflicts: Array<{rel: string}>}} diff */ - function renderDiffPanel(sourceId, diff) { - const inFlight = pendingOp.get(sourceId); - const localEdits = diff.localEdits || []; - const conflicts = diff.conflicts || []; - const total = - diff.added.length + diff.removed.length + diff.modified.length + localEdits.length + conflicts.length; - if (total === 0) { - return `
- No changes detected. The imported tree matches the source. -
- -
-
`; + await loadData(); + advanceProgress(90, 'Populating memory and handoff...'); + populateOnboardingMemory(scanResults); + await populateOnboardingHandoff(scanResults); + advanceProgress(100, 'Complete'); + } catch { + const [h, ss] = await Promise.all([DS.getMcpHosts(), DS.listSkillSources()]); + scanResults = { hosts: [], ides: [], ideExtensions: [], workspaces: [] }; + } finally { + advanceProgress(100, 'Complete'); + await new Promise((r) => setTimeout(r, 300)); + scanning = false; + scanPhase = 'results'; + render(); } - const clobberCount = conflicts.length + localEdits.length; - const clobberWarning = clobberCount - ? `
Overwrite will discard ${clobberCount} local edit${clobberCount === 1 ? '' : 's'} inside CE's imported copy.
` - : ''; - const overwriteLabel = clobberCount - ? `Overwrite (discards ${clobberCount} local edit${clobberCount === 1 ? '' : 's'})` - : 'Overwrite (mirror source)'; - return `
- ${renderDiffList('Added', diff.added, 'added')} - ${renderDiffList('Removed', diff.removed, 'removed')} - ${renderDiffList('Modified', diff.modified, 'modified')} - ${renderDiffList('Conflicting (local edit + source changed)', conflicts, 'conflict')} - ${renderDiffList('Local edits (source unchanged)', localEdits, 'local')} - ${clobberWarning} -
- - - -
-
`; - } - - /** @param {string} label @param {Array<{rel: string}>} items @param {string} kind */ - function renderDiffList(label, items, kind) { - if (!items.length) return ''; - return `
- ${esc(label)} · ${items.length} -
    - ${items - .slice(0, 8) - .map((entry) => `
  • ${esc(entry.rel)}
  • `) - .join('')} - ${items.length > 8 ? `
  • +${items.length - 8} more
  • ` : ''} -
-
`; - } - - /** @param {any} tool */ - function surfaceCard(tool) { - const tone = tool.detected ? 'detected' : tool.fileStandard ? 'file-standard' : 'available'; - const badge = tool.detected - ? 'Detected' - : tool.fileStandard - ? 'File standard' - : tool.available - ? 'Available' - : 'Not found'; - const detail = tool.signals?.length - ? tool.signals.join(', ') - : tool.globalReady - ? 'Global fallback writable' - : tool.projectReady - ? 'Project fallback available' - : 'Can be configured later'; - return `
- - - - ${esc(tool.label || tool.id)} - ${esc(badge)} - - ${esc(detail)} - -
`; } - function renderIde() { - const tools = summary?.tools || []; - const visible = tools.filter((tool) => tool.detected || tool.available || tool.fileStandard).slice(0, 8); - return `
-
-

IDE and file-output surfaces

-

Tools that don't call Context Engine through MCP can still receive generated instruction files from the same source of truth.

-
- ${ - visible.length - ? `
${visible.map(surfaceCard).join('')}
` - : `
- No IDE surfaces detected yet - Context Engine can still create AGENTS.md and other project files once you add a workspace. -
` + function collectSkillSources() { + if (!scanResults?.hosts) return []; + const sources = []; + const seen = new Set(); + for (const host of scanResults.hosts) { + for (const sk of host.skills || []) { + if (sk.internal) continue; + const key = sk.path.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const alreadyLinked = skillSources.some((s) => s.path && s.path.toLowerCase() === key); + if (!alreadyLinked) sources.push({ path: sk.path, label: sk.label || host.label + ' skills' }); } -
`; - } - - /** - * @param {string} label - * @param {boolean} ready - * @param {string} detail - */ - function healthCard(label, ready, detail) { - return `
- ${esc(label)} - ${esc(ready ? 'Ready' : 'Needs setup')} - ${esc(detail)} -
`; - } - - function renderHealth() { - const ctx = summary?.context || {}; - const index = ctx.index || {}; - const connected = (summary?.hosts || []).filter((host) => host.status === 'connected').length; - const indexReady = !!index.ready; - return `
-
-

Final health check

-

Confirm Context Engine has useful continuity context before you start using it from a host app.

-
-
- ${healthCard('Host connections', connected > 0, connected > 0 ? `${connected} connected` : 'Connect at least one host')} - ${healthCard('Active skills', (ctx.activeSkills || 0) > 0, `${ctx.activeSkills || 0} active`)} - ${healthCard('Vector search', indexReady, indexReady ? `${index.chunks || 0} chunks` : 'Build recommended')} -
- ${renderIndexBuildAction(!indexReady, 'Build vector index', ctx)} -
`; - } - - function renderBody() { - if (step === 1) return renderConnect(); - if (step === 2) return renderContext(); - if (step === 3) return renderIde(); - return renderHealth(); - } - - function renderFooter() { - const isFirst = step === 1; - const isLast = step === 4; - const nextLabel = isLast ? (finishing ? 'Finishing…' : 'Finish setup') : 'Continue'; - const nextAction = isLast ? 'finish()' : `go(${step + 1})`; - const disabledAttr = isLast && finishing ? 'disabled' : ''; - const errorBlock = finishError - ? `` - : ''; - return `
- ${errorBlock} - - -
`; - } - - function render() { - root().innerHTML = ``; - } - - /** @param {1 | 2 | 3 | 4} next */ - function go(next) { - step = next; - render(); - const dialog = document.querySelector('.onboarding-dialog'); - if (dialog instanceof HTMLElement) dialog.scrollTo(0, 0); + } + return sources; } - /** @param {string} hostId @param {boolean} selected */ - function toggleHost(hostId, selected) { - if (selected) selectedHosts.add(hostId); - else selectedHosts.delete(hostId); + function addScanPath(p) { + if (!p || customScanPaths.includes(p)) return; + customScanPaths.push(p); render(); } - async function refresh() { - summary = await apiFetch('/onboarding'); - await loadSkillSources(); + function removeScanPath(idx) { + customScanPaths.splice(idx, 1); render(); } - /** @param {string} sourcePath @param {string} [label] */ - async function linkPath(sourcePath, label) { - sourceMessage = ''; - const result = await DS.addSkillSource({ path: sourcePath, label }); - if (result?.ok) { - sourceMessage = `Linked ${result.source?.label || sourcePath}.`; - if (typeof Toast !== 'undefined') Toast.success(sourceMessage); - await refresh(); - } else { - sourceMessage = result?.error || 'Could not link this folder.'; - render(); - } - } - - async function linkCustom() { - const trimmed = customSourcePath.trim(); - if (!trimmed) return; - await linkPath(trimmed); - customSourcePath = ''; - } - - async function browse() { + async function browseScanPath() { const picker = window.contextEngineDesktop?.selectFolder; if (!picker) return; try { - const picked = await picker({ title: 'Pick a folder of SKILL.md files to link' }); - if (picked) await linkPath(picked); - } catch (err) { - console.error('onboarding: folder picker failed', err); - sourceMessage = 'Could not open folder picker.'; + const picked = await picker({ title: 'Pick a directory to scan' }); + if (picked) addScanPath(picked); + } catch { + /* ignore */ + } + } + + async function linkPath(path, label) { + const result = await DS.addSkillSource({ path, label: label || path }); + if (result?.ok) { + Toast.success('Source linked'); + await loadData(); render(); + } else { + Toast.error(result?.error || 'Could not link source'); } } - /** @param {string} id */ async function unlinkSource(id) { - sourceMessage = ''; const result = await DS.removeSkillSource(id); if (result?.ok) { - sourceMessage = 'Source unlinked.'; - await refresh(); - } else { - sourceMessage = result?.error || 'Could not unlink that source.'; + Toast.success('Source unlinked'); + await loadData(); render(); + } else { + Toast.error(result?.error || 'Could not unlink source'); } } - /** @param {string} value */ - function setCustomPath(value) { - customSourcePath = value; - // Do not re-render on every keystroke — input is uncontrolled-style. - } - - /** @param {string} id */ - async function importSource(id) { - sourceMessage = ''; - pendingOp.set(id, 'import'); + async function buildIndex() { + if (indexing) return; + indexing = true; render(); try { - const result = await DS.importSkillSource(id); - if (result?.ok) { - const strategy = result.manifest?.aggregateStrategy || 'link'; - sourceMessage = `Imported. Files placed via ${strategy === 'link' ? 'hard link' : strategy === 'copy' ? 'copy' : 'link + copy'}.`; - if (typeof Toast !== 'undefined') Toast.success('Source imported'); - await refresh(); - } else { - sourceMessage = result?.error || 'Could not import this source.'; - render(); - } + const result = await DS.indexSkills(); + buildDone = !!(result && result.ok !== false); + if (buildDone) Toast.success('Vector index built'); + else + Toast.error( + 'Ollama not available — ' + + (result?.error || 'embedding failed') + + '. You can skip indexing and continue.', + ); + } catch { + Toast.error('Index build failed — Ollama may not be running.'); } finally { - pendingOp.delete(id); + indexing = false; render(); } } - /** @param {string} id */ - async function checkSourceChanges(id) { - sourceMessage = ''; - pendingOp.set(id, 'sync'); - render(); + async function finish() { try { - const result = await DS.syncSkillSource(id); - if (result?.ok && result.diff) { - pendingDiffs.set(id, result.diff); - expandedSourceId = id; - } else { - sourceMessage = result?.error || 'Could not read source changes.'; + await DS.completeOnboarding(); + } catch { + /* ignore */ + } + Toast.success('Setup complete'); + close(); + if (typeof DashboardTab !== 'undefined') { + try { + await DashboardTab.init(); + } catch { + /* ignore */ + } + } + if (typeof MemoryTab !== 'undefined') { + try { + await MS.loadFromServer(); + MemoryTab.init(); + } catch { + /* ignore */ + } + } + if (typeof HandoffsTab !== 'undefined') { + try { + await HandoffsTab.load(); + } catch { + /* ignore */ } - } finally { - pendingOp.delete(id); - render(); } } - /** @param {string} id */ - function closeDiff(id) { - pendingDiffs.delete(id); - if (expandedSourceId === id) expandedSourceId = null; - render(); - } - - /** @param {string} id @param {'append' | 'overwrite'} mode */ - async function applySync(id, mode) { - pendingOp.set(id, 'apply'); - render(); + async function skip() { try { - const result = await DS.applySkillSourceSync(id, mode); - if (result?.ok) { - const a = result.applied?.added || 0; - const r = result.applied?.removed || 0; - const m = result.applied?.modified || 0; - sourceMessage = `Sync applied — ${a} added, ${r} removed, ${m} modified.`; - pendingDiffs.delete(id); - expandedSourceId = null; - if (typeof Toast !== 'undefined') Toast.success('Source synced'); - await refresh(); - } else { - sourceMessage = result?.error || 'Could not apply changes.'; - render(); - } - } finally { - pendingOp.delete(id); - render(); + await DS.completeOnboarding(); + } catch { + /* ignore */ } + close(); } - /** @param {string} hostId */ - async function connectHost(hostId) { - const result = await DS.installMcpHost(hostId); - if (result?.ok) Toast.success('Host config updated'); - await refresh(); + function close() { + mounted = false; + const el = document.getElementById('onboarding-root'); + if (el) el.remove(); } - async function buildIndex() { - if (indexing) return; - indexing = true; + function go(next) { + step = next; render(); - try { - const result = await DS.indexSkills(); - if (result && result.ok !== false) { - const chunks = Number(result?.chunks || 0); - Toast.success(chunks ? `Indexed ${chunks} chunks` : 'Vector index built'); - } else if (result && result.ok === false) { - Toast.error(result.error || 'Index build failed'); - } - } catch (err) { - console.error('onboarding: buildIndex failed', err); - Toast.error('Index build failed'); - } finally { - indexing = false; - await refresh(); - } + window.scrollTo(0, 0); } - async function finish() { - if (finishing) return; - finishing = true; - finishError = ''; + function renderSteps() { + return STEPS.map((s, idx) => { + const state = s.num < step ? 'done' : s.num === step ? 'current' : ''; + const connector = idx < STEPS.length - 1 ? `` : ''; + return ` +
+ ${s.num} + ${s.label} +
+ ${connector} + `; + }).join(''); + } + + function render() { + root().innerHTML = ` +
+
+

Onboarding

+ +
+
+ ${step === 1 ? renderScan() : ''} + ${step === 2 ? renderBuild() : ''} + ${step === 3 ? renderDone() : ''} +
+
+
+ `; + } + + function esc(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function renderScan() { + if (scanPhase === 'config') return renderScanConfig(); + if (scanPhase === 'scanning') return renderScanProgress(); + return renderScanResults(); + } + + function renderScanConfig() { + const DEFAULT_LOCS = [ + { + key: 'homedir', + label: 'Home directory', + desc: 'Your .claude, .codex, .cursor, .kimi, and other AI tool configs', + }, + { + key: 'drives', + label: 'All fixed drives', + desc: 'Scan E:\\, C:\\, and other drives for skills and configs', + }, + { + key: 'workspaces', + label: 'Workspace directories', + desc: 'Project folders registered with Context Engine', + }, + ]; + return ` +
+

Where should we look?

+

Context Engine scans for AI tools, skill folders, rules, configs, and MCP servers on your machine.

+
+
+
+ +
+
+ ${DEFAULT_LOCS.map( + (loc) => ` +
+
+ ${esc(loc.label)} + ${esc(loc.desc)} +
+ +
+ `, + ).join('')} +
+
+
+
+ +
+ ${ + customScanPaths.length + ? ` +
+ ${customScanPaths + .map( + (p, i) => ` +
+
${esc(p)}
+ +
+ `, + ) + .join('')} +
+ ` + : '
No custom paths added. Add a folder to scan for skills and configs.
' + } + ${ + typeof window !== 'undefined' && window.contextEngineDesktop?.selectFolder + ? ` +
+ +
+ ` + : '' + } +
+
+ + +
`; + } + + function toggleLoc(key, on) { + scanConfig[key] = on; render(); - // Apply pending host connections the user selected but hasn't manually - // wired. Best-effort — failures get logged but don't block finish, so - // a flaky host config write can't trap the user inside onboarding. - const pending = (summary?.hosts || []).filter( - (host) => host.supported && selectedHosts.has(host.id) && host.status !== 'connected', - ); - for (const host of pending) { - try { - await DS.installMcpHost(host.id); - } catch (err) { - console.error('onboarding: install host failed', host.id, err); - } - } - let completed = false; - try { - const result = await apiFetch('/onboarding/complete', 'POST', {}); - completed = !!(result && result.ok !== false); - } catch (err) { - console.error('onboarding: complete POST threw', err); + } + + function renderScanProgress() { + return ` +
+
+
+
+
+ Scanning your system + Probing drives and standard paths... +
+
`; + } + + function advanceProgress(pct, label) { + if (!mounted) return; + const fill = document.getElementById('ob-progress-fill'); + const lbl = document.getElementById('ob-progress-label'); + if (fill) fill.style.width = pct + '%'; + if (lbl) lbl.textContent = label || lbl.textContent; + } + + function totalItems() { + if (!scanResults?.hosts) return 0; + let total = 0; + for (const h of scanResults.hosts) { + total += + h.skills.length + h.configs.length + h.instructions.length + h.rules.length + h.mcpServers.length; } - if (completed) { - Toast.success('Setup complete'); - close(); - if (typeof DashboardTab !== 'undefined') { - try { - await DashboardTab.init(); - } catch (err) { - console.error('onboarding: post-complete DashboardTab.init failed', err); - } - } - return; + total += Object.values(scanResults.ideExtensions || {}).reduce( + (sum, exts) => sum + (Array.isArray(exts) ? exts.length : 0), + 0, + ); + return total; + } + + function renderScanResults() { + const hostList = scanResults?.hosts || []; + const ideList = scanResults?.ides || []; + const extList = scanResults?.ideExtensions || []; + if (!hostList.length && !ideList.length) { + return ` +
+

No AI tools found

+

We could not detect any AI applications, skill folders, or config files. Add a custom path and try again.

+
+
+ + +
`; } - // Stayed open because the server-side completion didn't acknowledge. - // Surface a clear error so the user can retry or use Skip. - finishing = false; - finishError = 'Could not mark setup complete. Retry or use Skip for now.'; - render(); + return ` +
+

What we found

+

${totalItems()} items across ${hostList.length} AI tool${hostList.length !== 1 ? 's' : ''}.

+
+
+ ${hostList.map((h) => OnboardingRender.renderHostCard(h, skillSources)).join('')} + ${ideList.length ? OnboardingRender.renderIdeCard(ideList, extList) : ''} +
+
+ + +
`; } - async function skip() { - let result = null; - try { - result = await apiFetch('/onboarding/complete', 'POST', {}); - } catch (err) { - console.error('onboarding: skip POST threw', err); + function renderBuild() { + const srcCount = skillSources.length; + const skillCount = Array.isArray(SKILL_DATA) ? SKILL_DATA.length : 0; + if (buildDone) { + return ` +
+
+
+ Index built + ${skillCount} skills from ${srcCount} source${srcCount !== 1 ? 's' : ''} indexed. Your AI hosts can now retrieve relevant context through Context Engine. +
+
+ +
+
`; } - // Even if completion didn't register server-side, close the modal so - // the user isn't trapped. They'll see the prompt again on next launch. - if (result && result.ok !== false) { - close(); - } else { - Toast.warn('Could not save onboarding state, but proceeding.'); - close(); + if (indexing) { + return ` +
+
+
+ Building vector index + Embedding ${skillCount} skills from ${srcCount} source${srcCount !== 1 ? 's' : ''} via Ollama. This usually takes 10-60 seconds. +
+
`; } + return ` +
+

Build vector index

+

Context Engine indexes your ${skillCount} skills from ${srcCount} source${srcCount !== 1 ? 's' : ''} so host apps can retrieve relevant context instantly through semantic search.

+ ${skillCount} skills to index +
+
+ + + + +
`; + } + + function renderDone() { + const hostList = scanResults?.hosts || []; + const opportunities = hostList.flatMap((h) => h.opportunities.map((o) => ({ ...o, host: h.label }))); + const totalSkills = Array.isArray(SKILL_DATA) ? SKILL_DATA.length : 0; + const srcCount = skillSources.length; + const memEntries = (MS.getData()?.entries || []).length; + return ` +
+
+
+ All set + ${totalSkills} skills indexed from ${srcCount} source${srcCount !== 1 ? 's' : ''}. ${memEntries} memory entries loaded. AI hosts can retrieve context through MCP or compiled files. +
+
+ ${ + opportunities.length + ? ` +
+
+ +
+
+ ${opportunities + .map( + (o) => ` +
+
${catSvg('opportunity')}
+
+ ${esc(o.host)}: ${esc(o.label)} + ${esc(o.description)} +
+
+ `, + ) + .join('')} +
+
+ ` + : '' + } +
+ +
`; } return { init, go, - toggleHost, - connectHost, + runScan, + linkPath, + unlinkSource, buildIndex, finish, skip, - linkPath, - linkCustom, - browse, - unlinkSource, - importSource, - checkSourceChanges, - closeDiff, - applySync, - _setCustomPath: setCustomPath, - _backdrop: onBackdrop, + addScanPath, + removeScanPath, + browseScanPath, + toggleLoc, }; })(); diff --git a/ui/rules-lab.js b/ui/rules-lab.js index e1db80a..fd1c53c 100644 --- a/ui/rules-lab.js +++ b/ui/rules-lab.js @@ -1,26 +1,23 @@ // @ts-nocheck — Path-A backlog: file in tsconfig include, opt out until incremental typing is done. See docs/llm-handoff.md. -// rules-lab.js - Rules workbench helpers. +// rules-lab.js — List-based rules editor with per-rule severity tagging. const RulesLab = (() => { const STORE_KEY = 'ce_rules_lab'; const sections = ['coding', 'general', 'soul']; const labels = { coding: 'Coding Rules', general: 'General Rules', soul: 'Soul' }; - const PRIORITY_SECTIONS = { - coding: ['hard', 'soft'], - general: ['hard', 'soft'], - soul: ['soft'], - }; + const ALL_SEVERITIES = ['hard', 'soft', 'style']; + const SEVERITY_LABELS = { hard: 'Hard rule', soft: 'Soft rule', style: 'Style' }; - const PRIORITY_LABELS = { - hard: 'Hard rules', - soft: 'Soft rules', + const SECTION_SEVERITIES = { + coding: ['hard', 'soft', 'style'], + general: ['hard', 'soft', 'style'], + soul: ['soft', 'style'], }; const defaultMeta = { enabled: { coding: true, general: true, soul: true }, - priority: { coding: 'hard', general: 'hard', soul: 'soft' }, profiles: {}, history: [], lastSaved: null, @@ -29,6 +26,133 @@ const RulesLab = (() => { let meta = loadMeta(); let booted = false; + // ---- Convert between priority-blob format and flat list format ---- + + /** Parse priority-blob rules into flat list per section */ + function blobToList(rules) { + const result = {}; + for (const key of sections) { + const entry = rules?.[key]; + const list = []; + if (typeof entry === 'string') { + entry + .split('\n') + .filter(Boolean) + .forEach((line) => list.push({ text: line, sev: 'soft' })); + } else if (entry && typeof entry === 'object') { + for (const sev of ALL_SEVERITIES) { + const text = entry[sev]; + if (typeof text === 'string' && text.trim()) { + text + .split('\n') + .filter(Boolean) + .forEach((line) => list.push({ text: line, sev })); + } + } + } + result[key] = list; + } + return result; + } + + /** Join flat list per section back into priority-blob format */ + function listToBlob(lists) { + const rules = {}; + for (const key of sections) { + const allowed = SECTION_SEVERITIES[key]; + const bucket = {}; + for (const sev of allowed) bucket[sev] = ''; + for (const item of lists[key] || []) { + const sev = allowed.includes(item.sev) ? item.sev : allowed[0]; + bucket[sev] += (bucket[sev] ? '\n' : '') + item.text; + } + rules[key] = bucket; + } + return rules; + } + + /** Get current flat list from the DOM */ + function getList() { + const list = {}; + for (const key of sections) { + const root = document.getElementById(`rules-${key}-list`); + list[key] = []; + if (!root) continue; + for (const row of root.querySelectorAll('.rules-rule-row')) { + const text = row.querySelector('.rules-rule-input')?.value?.trim(); + const sev = row.querySelector('.rules-sev-select')?.value || 'soft'; + if (text) list[key].push({ text, sev }); + } + } + return list; + } + + /** Render the flat list into the DOM for a section */ + function renderList(key) { + const root = document.getElementById(`rules-${key}-list`); + if (!root) return; + const list = getList(); + const items = list[key] || []; + root.innerHTML = items + .map( + (item, i) => ` +
+ + + + +
`, + ) + .join(''); + updateCounts(); + } + + function updateCounts() { + const list = getList(); + for (const key of sections) { + const el = document.getElementById(`rules-${key}-count`); + if (el) el.textContent = `${list[key]?.length || 0} rules`; + } + } + + // ---- Public API for UI buttons ---- + + function addRule(key) { + const root = document.getElementById(`rules-${key}-list`); + if (!root) return; + const defaultSev = SECTION_SEVERITIES[key]?.[0] || 'soft'; + const row = document.createElement('div'); + row.className = 'rules-rule-row'; + row.innerHTML = ` + + + + + `; + root.appendChild(row); + row.querySelector('.rules-rule-input').focus(); + refresh(); + } + + function removeRule(key, index) { + const root = document.getElementById(`rules-${key}-list`); + if (!root) return; + const rows = root.querySelectorAll('.rules-rule-row'); + if (rows[index]) rows[index].remove(); + // Re-index + root.querySelectorAll('.rules-rule-row').forEach((row, i) => { + row.dataset.index = i; + row.querySelector('.rules-rule-del')?.setAttribute('onclick', `RulesLab.removeRule('${key}', ${i})`); + }); + refresh(); + } + + // ---- Mount ---- + function mount() { const root = document.getElementById('rules-root'); if (!root || root.dataset.mounted === '1') return; @@ -37,12 +161,12 @@ const RulesLab = (() => {

Soul & Rules

-

Instruction policy written to data\\rules.json

+

Instruction policy written to data/rules.json

Active file - data\\rules.json + data/rules.json
Saved to disk @@ -117,31 +241,22 @@ const RulesLab = (() => {
- - `; + `; } - function ruleEditor(key, label) { - const priorities = PRIORITY_SECTIONS[key]; - const isWide = key === 'soul'; - const sectionsHtml = priorities - .map( - (p) => ` -
- - -
`, - ) - .join(''); + function ruleEditor(key) { + const extraClass = key === 'soul' ? ' rules-block-wide' : ''; return ` -
-
${label}0 words
+
+
+ ${labels[key]} + 0 rules +
+
-
- ${sectionsHtml} -
+
`; } @@ -149,6 +264,8 @@ const RulesLab = (() => { return ``; } + // ---- Init ---- + function init() { if (booted) return; booted = true; @@ -173,7 +290,6 @@ const RulesLab = (() => { ...defaultMeta, ...(stored || {}), enabled: { ...defaultMeta.enabled, ...(stored?.enabled || {}) }, - priority: { ...defaultMeta.priority, ...(stored?.priority || {}) }, profiles: { ...(stored?.profiles || {}) }, history: Array.isArray(stored?.history) ? stored.history : [], }; @@ -183,53 +299,65 @@ const RulesLab = (() => { localStorage.setItem(STORE_KEY, JSON.stringify(meta)); } - /** Read current values from all priority textareas into nested rules format */ + /** Read current list and convert to priority-blob format */ function draft() { - const rules = {}; - for (const key of sections) { - const priorities = PRIORITY_SECTIONS[key]; - rules[key] = {}; - for (const p of priorities) { - const el = document.getElementById(`rules-${key}-${p}`); - rules[key][p] = el?.value || ''; - } - } - return rules; + return listToBlob(getList()); } - /** Set values of all priority textareas from a rules object (flat or nested) */ + /** Populate the list UI from a rules object (flat or nested) */ function setDraft(rules) { - sections.forEach((key) => { - const priorities = PRIORITY_SECTIONS[key]; + for (const key of sections) { + const root = document.getElementById(`rules-${key}-list`); + if (!root) continue; const section = rules?.[key]; - priorities.forEach((p) => { - const el = document.getElementById(`rules-${key}-${p}`); - if (!el) return; - if (typeof section === 'string') { - el.value = p === 'soft' ? section : ''; - } else if (section && typeof section === 'object') { - el.value = section[p] || ''; - } else { - el.value = ''; + const items = []; + if (typeof section === 'string') { + section + .split('\n') + .filter(Boolean) + .forEach((line) => items.push({ text: line, sev: 'soft' })); + } else if (section && typeof section === 'object') { + const allowed = SECTION_SEVERITIES[key]; + for (const sev of allowed) { + const text = section[sev]; + if (typeof text === 'string' && text.trim()) { + text + .split('\n') + .filter(Boolean) + .forEach((line) => items.push({ text: line, sev })); + } } - }); - }); + } + root.innerHTML = items + .map( + (item, i) => ` +
+ + + + +
`, + ) + .join(''); + } ConfigTab.updateRuleMetrics?.(); refresh(); } function controlsToMeta() { - sections.forEach((key) => { + for (const key of sections) { meta.enabled[key] = document.getElementById(`rules-${key}-enabled`)?.checked !== false; - }); + } saveMeta(); } function applyMetaToControls() { - sections.forEach((key) => { - const enabled = document.getElementById(`rules-${key}-enabled`); - if (enabled) enabled.checked = meta.enabled[key] !== false; - }); + for (const key of sections) { + const el = document.getElementById(`rules-${key}-enabled`); + if (el) el.checked = meta.enabled[key] !== false; + } } function captureBaseline() { @@ -262,9 +390,9 @@ const RulesLab = (() => { const defaults = { Default: { rules: { - coding: { hard: '', soft: 'Modular code files.\nComment the why, not the what.' }, - general: { hard: '', soft: 'Memory is a core skill. Think independently.' }, - soul: { soft: 'Helpful, concise, and logical.\nObjective and critical thinker.' }, + coding: { hard: '', soft: 'Modular code files.\nComment the why, not the what.', style: '' }, + general: { hard: '', soft: 'Memory is a core skill. Think independently.', style: '' }, + soul: { soft: 'Helpful, concise, and logical.\nObjective and critical thinker.', style: '' }, }, enabled: { ...defaultMeta.enabled }, }, @@ -273,23 +401,26 @@ const RulesLab = (() => { coding: { hard: 'Prioritise bugs, regressions, missing tests, unsafe assumptions, and architecture drift.\nKeep findings specific and line-referenced.', soft: '', + style: '', }, general: { hard: 'Challenge weak reasoning. State uncertainty clearly. Do not overfit to the user request if the evidence points elsewhere.', soft: '', + style: '', }, - soul: { soft: 'Direct, concise, critical, and practical.' }, + soul: { soft: 'Direct, concise, critical, and practical.', style: '' }, }, enabled: { coding: true, general: true, soul: true }, }, Research: { rules: { - coding: { hard: '', soft: 'Modular code files.\nComment the why, not the what.' }, + coding: { hard: '', soft: 'Modular code files.\nComment the why, not the what.', style: '' }, general: { hard: 'Verify time-sensitive facts. Prefer primary sources. Separate evidence from inference.', soft: '', + style: '', }, - soul: { soft: 'Careful, source-led, and explicit about uncertainty.' }, + soul: { soft: 'Careful, source-led, and explicit about uncertainty.', style: '' }, }, enabled: { coding: true, general: true, soul: true }, }, @@ -346,13 +477,15 @@ const RulesLab = (() => { function refresh() { controlsToMeta(); + updateCounts(); renderPreview(); renderDiff(); renderHistory(); renderMemoryAlignment(); } - function switchPanel(id, btn = document.querySelector(`[data-rule-tab="${id}"]`)) { + function switchPanel(id, btn) { + if (!btn) btn = document.querySelector(`[data-rule-tab="${id}"]`); document.querySelectorAll('[data-rule-panel]').forEach((panel) => { panel.hidden = panel.dataset.rulePanel !== id; }); @@ -368,7 +501,7 @@ const RulesLab = (() => { .map((key) => ({ key, label: labels[key], - text: flattenSectionText(rules[key], PRIORITY_SECTIONS[key]), + text: flattenSectionText(rules[key], SECTION_SEVERITIES[key]), })); } @@ -423,7 +556,7 @@ const RulesLab = (() => { function flattenRules(rules) { return sections.flatMap((key) => { const section = rules?.[key]; - const priorities = PRIORITY_SECTIONS[key]; + const priorities = SECTION_SEVERITIES[key]; const text = flattenSectionText(section, priorities); return [`## ${labels[key]}`, ...(text ? text.split('\n') : [])]; }); @@ -468,7 +601,7 @@ const RulesLab = (() => { return sections .map((key) => { const section = rules?.[key]; - const priorities = PRIORITY_SECTIONS[key]; + const priorities = SECTION_SEVERITIES[key]; return flattenSectionText(section, priorities); }) .join(' '); @@ -558,10 +691,15 @@ const RulesLab = (() => { init, refresh, beforeSave, + draft, + setDraft, saveProfile, applyProfile, restoreHistory, switchPanel, - PRIORITY_SECTIONS, + addRule, + removeRule, + SECTION_SEVERITIES, + ALL_SEVERITIES, }; })(); diff --git a/ui/store.js b/ui/store.js index acaa76d..c2dc674 100644 --- a/ui/store.js +++ b/ui/store.js @@ -383,9 +383,9 @@ const MS = { // ---- RULES ---- /** @type {Object} */ const PRIORITY_SECTIONS = { - coding: ['hard', 'soft'], - general: ['hard', 'soft'], - soul: ['soft'], + coding: ['hard', 'soft', 'style'], + general: ['hard', 'soft', 'style'], + soul: ['soft', 'style'], }; /** Migrate legacy flat-string rules to new priority-object format @@ -411,11 +411,7 @@ function migrateRules(rules) { result[key][p] = soft; } else { const pref = typeof section.preference === 'string' ? section.preference : ''; - const style = typeof section.style === 'string' ? section.style : ''; - const parts = []; - if (pref) parts.push('## Preference\n' + pref); - if (style) parts.push('## Style\n' + style); - result[key][p] = parts.join('\n\n'); + result[key][p] = pref || section.soft || ''; } } else { result[key][p] = typeof section[p] === 'string' ? section[p] : ''; diff --git a/ui/styles/_index.css b/ui/styles/_index.css index 81a3276..40e4824 100644 --- a/ui/styles/_index.css +++ b/ui/styles/_index.css @@ -12,6 +12,8 @@ @import url('toast.css'); @import url('command-bar.css'); @import url('onboarding.css'); +@import url('onboarding-hosts.css'); +@import url('onboarding-flow.css'); @import url('tab-dashboard.css'); @import url('tab-config.css'); @import url('tab-compile.css'); diff --git a/ui/styles/dram-standard-actions.css b/ui/styles/dram-standard-actions.css index 7a4660d..442383f 100644 --- a/ui/styles/dram-standard-actions.css +++ b/ui/styles/dram-standard-actions.css @@ -18,8 +18,6 @@ .fb, .mem-btn, -.view-btn, -.icon-btn, .cmd-k-hint { min-height: 36px; padding: 0 var(--s-3); @@ -35,6 +33,22 @@ text-transform: none; } +.view-btn, +.icon-btn { + min-height: 36px; + padding: 0 var(--s-3); + color: var(--text-3); + background: transparent; + border: 1px solid var(--line); + border-radius: var(--r-1); + box-shadow: none; + font-family: var(--sans); + font-size: var(--fs-03); + font-weight: 650; + letter-spacing: 0; + text-transform: none; +} + .mem-btn { min-height: 30px; font-size: var(--fs-02); @@ -45,8 +59,6 @@ .fb:hover, .mem-btn:hover { transform: none; - background: var(--accent-bg); - border-color: var(--accent); color: var(--text); box-shadow: none; } diff --git a/ui/styles/dram-standard.css b/ui/styles/dram-standard.css index f7b4af5..3b5018b 100644 --- a/ui/styles/dram-standard.css +++ b/ui/styles/dram-standard.css @@ -41,8 +41,8 @@ --r-1: 4px; --r-2: 6px; --r-3: 8px; - --desktop-titlebar-h: 40px; --shell-divider-h: 64px; + --desktop-titlebar-h: 40px; --shadow-glass: none; --shadow-glow: none; --shadow-toolbar: none; @@ -59,15 +59,6 @@ body::after { display: none; } -html[data-runtime='electron'] .desktop-titlebar { - background: var(--bg); - /* height managed by .app grid row; border-bottom owned by shell.css */ -} - -html[data-runtime='electron'] .desktop-titlebar-actions button { - border-radius: var(--r-1); -} - .app { background: var(--bg); } diff --git a/ui/styles/onboarding-flow.css b/ui/styles/onboarding-flow.css new file mode 100644 index 0000000..4d73941 --- /dev/null +++ b/ui/styles/onboarding-flow.css @@ -0,0 +1,181 @@ +/* onboarding-flow.css — Scan progress, build, done, and responsive flow states. */ + +.ob-progress-track { + width: 100%; + max-width: 400px; + height: 4px; + background: var(--bg-sunken); + border-radius: var(--r-full); + overflow: hidden; + margin: 0 auto var(--s-6); +} + +.ob-progress-fill { + height: 100%; + width: 0%; + background: var(--accent); + border-radius: var(--r-full); + transition: width 0.3s ease-out; +} + +.ob-row.ob-row-dim { + opacity: 0.6; + cursor: default; +} + +.ob-row.ob-row-dim:hover { + background: var(--dram-card-bg); +} + +.ob-empty { + padding: var(--s-6) var(--s-4); + text-align: center; + background: var(--bg-sunken); + border: 1px dashed var(--line); + border-radius: var(--r-1); + color: var(--text-4); + font-size: var(--fs-03); + line-height: 1.5; +} + +.ob-moment { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--s-5); + padding: var(--s-9) var(--s-6); + background: var(--dram-card-bg); + border: 1px solid var(--dram-card-border); + border-radius: var(--r-2); +} + +.ob-moment-badge { + width: 56px; + height: 56px; + display: grid; + place-items: center; + border-radius: var(--r-full); + font-size: 24px; + font-weight: 700; + flex: 0 0 auto; +} + +.ob-moment-badge.spin { + border: 2px solid var(--line); + border-top-color: var(--accent); + animation: ob-spin 0.9s linear infinite; + background: transparent; + width: 32px; + height: 32px; +} + +.ob-moment-badge.done { + background: var(--ok-bg); + border: 2px solid var(--ok); + color: var(--ok); +} + +@keyframes ob-spin { + to { + transform: rotate(360deg); + } +} + +.ob-moment-text { + display: grid; + gap: var(--s-2); + max-width: 420px; +} + +.ob-moment-text strong { + font-size: var(--fs-06); + font-weight: 650; + color: var(--text); +} + +.ob-moment-text span { + color: var(--text-3); + font-size: var(--fs-04); + line-height: 1.6; +} + +.ob-moment .ob-actions { + display: flex; + gap: var(--s-3); + align-items: center; + margin-top: var(--s-2); +} + +.ob-scanning { + display: flex; + align-items: center; + gap: var(--s-4); + padding: var(--s-5); + background: var(--dram-card-bg); + border: 1px solid var(--dram-card-border); + border-radius: var(--r-2); +} + +.ob-scanning-text { + display: grid; + gap: var(--s-1); +} + +.ob-scanning-text strong { + color: var(--text); + font-size: var(--fs-04); + font-weight: 600; +} + +.ob-scanning-text span { + color: var(--text-3); + font-size: var(--fs-03); + line-height: 1.5; +} + +.ob-actions { + display: flex; + gap: var(--s-3); + align-items: center; +} + +.ob-actions .ob-skip { + margin-right: auto; +} + +.ob-mt-5 { + margin-top: var(--s-5); +} +.ob-mt-6 { + margin-top: var(--s-6); +} +.ob-mt-7 { + margin-top: var(--s-7); +} + +@media (max-width: 720px) { + .ob-body-inner { + max-width: 100%; + } + + .ob-step-label { + display: none; + } + + .ob-step-connector { + margin-bottom: 0; + } + + .ob-source-form { + flex-direction: column; + } + + .ob-body { + padding: var(--s-6) var(--s-4); + } + + .ob-moment { + padding: var(--s-7) var(--s-4); + } +} diff --git a/ui/styles/onboarding-hosts.css b/ui/styles/onboarding-hosts.css new file mode 100644 index 0000000..2b79b19 --- /dev/null +++ b/ui/styles/onboarding-hosts.css @@ -0,0 +1,291 @@ +/* onboarding-hosts.css — Host review cards and linked source details. */ + +.ob-muted { + color: var(--text-4); + font-size: var(--fs-02); +} + +.ob-host-cards { + display: grid; + gap: var(--s-5); +} + +.ob-host-card { + background: var(--dram-card-bg); + border: 1px solid var(--line); + border-radius: var(--r-2); + overflow: hidden; +} + +.ob-host-card-hdr { + display: flex; + align-items: center; + gap: var(--s-3); + padding: var(--s-3) var(--s-4); + background: var(--dram-card-hover); + border-bottom: 1px solid var(--line); +} + +.ob-host-card-info { + flex: 1; + min-width: 0; + display: grid; + gap: 1px; +} + +.ob-host-card-info .ob-row-name { + color: var(--text); + font-size: var(--fs-04); + font-weight: 600; +} + +.ob-host-section { + border-top: 1px solid var(--line); +} + +.ob-host-section-hdr { + display: flex; + align-items: center; + gap: var(--s-2); + width: 100%; + padding: var(--s-2) var(--s-4); + border: none; + background: transparent; + color: var(--text-2); + cursor: pointer; + font: inherit; + font-size: var(--fs-02); + text-align: left; + transition: background var(--out); +} + +.ob-host-section-hdr:hover { + background: var(--dram-card-hover); +} + +.ob-host-section-hdr svg { + width: 16px; + height: 16px; + flex: 0 0 auto; +} + +.ob-host-section-hdr .ob-accordion-chev { + margin-left: auto; +} + +.ob-host-section-open .ob-host-section-hdr { + color: var(--text); +} + +.ob-host-section-body { + display: none; + padding: 0 var(--s-4) var(--s-3) var(--s-4); +} + +.ob-host-section-open .ob-host-section-body { + display: block; +} + +.ob-host-section.ob-host-opportunity .ob-host-section-hdr { + color: var(--ok); +} + +.ob-skill-source { + padding: var(--s-2) 0; + border-bottom: 1px solid var(--line); +} + +.ob-skill-source:last-child { + border-bottom: none; +} + +.ob-skill-source-hdr { + display: flex; + align-items: center; + gap: var(--s-2); + margin-bottom: var(--s-1); +} + +.ob-skill-source-hdr .ob-row-name { + color: var(--text); + font-size: var(--fs-03); + font-weight: 500; +} + +.ob-host-actions { + display: flex; + align-items: center; + gap: var(--s-2); + margin-top: var(--s-2); +} + +.ob-file-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 2px; +} + +.ob-file-list li { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--fs-02); + color: var(--text-2); + padding: 2px 0; +} + +.ob-file-list li svg { + width: 16px; + height: 16px; + flex: 0 0 auto; +} + +.ob-file-path { + color: var(--text-4); + font-family: var(--mono); + font-size: var(--fs-01); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ob-mcp-entry { + padding: var(--s-2) 0; + border-bottom: 1px solid var(--line); +} + +.ob-mcp-entry:last-child { + border-bottom: none; +} + +.ob-mcp-path { + font-family: var(--mono); + font-size: var(--fs-01); + color: var(--text-3); +} + +.ob-mcp-servers { + list-style: none; + margin: var(--s-1) 0 0 var(--s-3); + padding: 0; + font-size: var(--fs-02); + color: var(--text-3); +} + +.ob-ide-ext-row { + display: flex; + align-items: center; + gap: var(--s-3); + padding: var(--s-2) 0; + border-bottom: 1px solid var(--line); +} + +.ob-ide-ext-row:last-child { + border-bottom: none; +} + +.ob-ide-ext-label-group { + display: flex; + align-items: center; + gap: var(--s-2); + min-width: 140px; + flex: 0 0 auto; +} + +.ob-ide-ext-label { + color: var(--text); + font-size: var(--fs-03); + font-weight: 500; +} + +.ob-row-icon-sm { + width: 16px; + height: 16px; + min-width: 16px; +} + +.ob-row-icon-sm img { + width: 16px; + height: 16px; +} + +.ob-ext-badges { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + margin-top: var(--s-2); +} + +.ob-opportunity { + padding: var(--s-2) 0; + display: grid; + gap: 2px; +} + +.ob-opportunity-label { + color: var(--ok); + font-size: var(--fs-03); + font-weight: 500; +} + +.ob-opportunity-desc { + color: var(--text-3); + font-size: var(--fs-02); +} + +.ob-row.ob-row-opportunity { + gap: var(--s-3); +} + +.ob-row-desc { + color: var(--text-3); + font-size: var(--fs-02); +} + +.ob-step-head p { + color: var(--text-2); + font-size: var(--fs-03); + margin: var(--s-2) 0 0; +} + +.ob-step-head .ob-row-desc { + margin: 0; +} + +.ob-step-total { + color: var(--text-3); + font-size: var(--fs-02); + font-family: var(--mono); + margin-top: var(--s-2); + display: block; +} + +.ob-source-form { + display: flex; + gap: var(--s-2); + align-items: center; + margin-top: var(--s-4); +} + +.ob-source-input { + flex: 1; + min-height: 36px; + padding: 0 var(--s-3); + background: var(--bg-sunken); + border: 1px solid var(--line); + border-radius: var(--r-1); + color: var(--text); + font-family: var(--mono); + font-size: var(--fs-03); + transition: + border-color var(--out), + background var(--out); +} + +.ob-source-input:focus { + border-color: var(--line-accent); + background: var(--bg-raised); + outline: none; +} diff --git a/ui/styles/onboarding.css b/ui/styles/onboarding.css index fea2c12..3785e86 100644 --- a/ui/styles/onboarding.css +++ b/ui/styles/onboarding.css @@ -1,743 +1,450 @@ -/* Onboarding wizard — modal frame, horizontal progress, DRAM-tokened cards. */ -/* Spec: docs/specs/onboarding-redesign.md */ +/* onboarding.css — Full-window 4-step setup: scan → review → build → done. + DRAM-polished: centered content, generous viewport use, subtle surfaces. */ -.onboarding-overlay { +.ob-root { position: fixed; - inset: 0; - z-index: 90; - display: flex; - align-items: stretch; - justify-content: stretch; - padding: 0; - background: var(--dram-card-bg); -} - -.onboarding-dialog { + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; display: flex; flex-direction: column; - width: 100%; - height: 100%; - max-width: none; - max-height: none; + background: var(--bg); + color: var(--text); overflow: hidden; - background: var(--dram-card-bg); - border: 0; - border-radius: 0; } -/* Header */ +.ob-drag { + -webkit-app-region: drag; + height: var(--desktop-titlebar-h); + flex: 0 0 auto; +} -.onboarding-header { +.ob-title { + text-align: center; + font-size: var(--fs-07); + font-weight: 600; + color: var(--text); + padding: var(--s-5) var(--s-7) 0; + margin: 0; + letter-spacing: var(--tr-tight); +} + +.ob-brand { display: flex; - gap: var(--s-3); align-items: center; - padding: var(--s-4) var(--s-5); - border-bottom: 1px solid var(--dram-line-subtle); + gap: var(--s-3); } -.onboarding-brand-icon { - flex: 0 0 auto; +.ob-brand-icon { width: 28px; height: 28px; - border-radius: var(--r-2); -} - -.onboarding-header-text { - display: grid; - flex: 1; - min-width: 0; - gap: 2px; + border-radius: var(--r-1); } -.onboarding-header-text h2 { +.ob-brand-text h1 { margin: 0; - font-size: var(--fs-07); + font-size: var(--fs-04); font-weight: 600; color: var(--text); + letter-spacing: var(--tr-tight); } -.onboarding-header-text p { - margin: 0; - color: var(--text-3); +.ob-brand-text p { + margin: 2px 0 0; + color: var(--text-4); font-size: var(--fs-01); + letter-spacing: var(--tr-wide); + text-transform: uppercase; + font-family: var(--mono); } -.onboarding-close { - display: grid; - flex: 0 0 auto; - width: 28px; - height: 28px; - place-items: center; - background: transparent; - border: 1px solid transparent; - border-radius: var(--r-2); - color: var(--text-3); - font-size: 18px; - line-height: 1; +.ob-skip { + padding: var(--s-2) var(--s-4); + border: 1px solid var(--line); + border-radius: var(--r-1); + background: var(--bg-sunken); + color: var(--text-4); + font-family: var(--mono); + font-size: var(--fs-01); + font-weight: 600; + letter-spacing: var(--tr-wide); + text-transform: uppercase; cursor: pointer; + transition: + border-color var(--out), + color var(--out); } -.onboarding-close:hover { - background: var(--dram-bg-hover); - border-color: var(--dram-line-subtle); - color: var(--text); +.ob-skip:hover { + border-color: var(--line-accent); + color: var(--accent-hi); } -/* Progress strip */ - -.onboarding-progress { +/* ── Step indicator ── */ +.ob-steps { display: flex; - gap: var(--s-2); align-items: center; justify-content: center; - padding: var(--s-4) var(--s-5); - border-bottom: 1px solid var(--dram-line-subtle); + padding: var(--s-6) var(--s-7) 0; } -.onboarding-progress-step { +.ob-steps-inner { + display: flex; + align-items: center; + gap: 0; + width: 100%; + max-width: 520px; +} + +.ob-step-pill { display: flex; flex-direction: column; - gap: 6px; align-items: center; - flex: 0 0 auto; + gap: var(--s-2); + flex: 1; + position: relative; } -.onboarding-progress-circle { +.ob-step-num { display: grid; - width: 28px; - height: 28px; place-items: center; - background: var(--dram-bg-recessed); - border: 1px solid var(--dram-line-subtle); - border-radius: 999px; + width: 32px; + height: 32px; + border-radius: var(--r-full); + background: var(--bg-sunken); + border: 1.5px solid var(--line); color: var(--text-4); - font-size: var(--fs-01); - font-weight: 600; + font-size: var(--fs-03); + font-weight: 700; + transition: + background var(--out), + border-color var(--out), + color var(--out), + box-shadow var(--out); } -.onboarding-progress-step.current .onboarding-progress-circle, -.onboarding-progress-step.done .onboarding-progress-circle { +.ob-step-pill.done .ob-step-num { background: var(--accent); border-color: var(--accent); - color: var(--dram-text-on-accent); + color: var(--text-on-accent); } -.onboarding-progress-label { - color: var(--text-3); - font-size: 10px; +.ob-step-pill.current .ob-step-num { + background: var(--accent); + border-color: var(--accent); + color: var(--text-on-accent); + box-shadow: + 0 0 0 4px var(--accent-bg), + 0 0 20px var(--accent-glow); +} + +.ob-step-label { + color: var(--text-4); + font-family: var(--mono); + font-size: var(--fs-01); + font-weight: 600; + letter-spacing: var(--tr-wide); text-transform: uppercase; - letter-spacing: 0.07em; + white-space: nowrap; + transition: color var(--out); } -.onboarding-progress-step.current .onboarding-progress-label { +.ob-step-pill.current .ob-step-label { color: var(--text); } -.onboarding-progress-bar { - flex: 0 0 32px; - height: 1px; - margin-bottom: 20px; - background: var(--dram-line-subtle); +.ob-step-pill.done .ob-step-label { + color: var(--text-2); } -.onboarding-progress-bar.done { - background: var(--accent); +.ob-step-connector { + flex: 1; + height: 1.5px; + background: var(--line); + margin: 0 var(--s-2); + margin-bottom: 24px; + transition: background var(--out); } -/* Body + steps */ +.ob-step-pill.done ~ .ob-step-connector { + background: var(--accent-line); +} -.onboarding-body { +/* ── Body ── */ +.ob-body { flex: 1; min-height: 0; - padding: var(--s-6) var(--s-5); overflow-y: auto; + display: flex; + justify-content: center; + padding: var(--s-8) var(--s-7) var(--s-6); } -/* Cap step content width on wide viewports so prose + cards stay readable. - The dialog itself fills the viewport (full-screen), but the step body - centers its content within a comfortable line-length. */ -.onboarding-step-body { - display: grid; - gap: var(--s-4); +.ob-body-inner { width: 100%; - max-width: 880px; - margin: 0 auto; + max-width: 860px; } -.onboarding-step-head { - display: grid; - gap: 4px; +.ob-step-head { + margin-bottom: var(--s-7); } -.onboarding-step-head h3 { - margin: 0; - font-size: var(--fs-06); - font-weight: 600; +.ob-step-head h2 { + margin: 0 0 var(--s-2); + font-size: var(--fs-07); + font-weight: 650; color: var(--text); + letter-spacing: -0.01em; + line-height: 1.15; } -.onboarding-step-head p { +.ob-step-head p { margin: 0; color: var(--text-3); - font-size: var(--fs-02); - line-height: 1.5; -} - -/* Card token contract — DRAM-aligned. Applied directly to onboarding card - surfaces so the global `.card` component class (which is flex-column) is - not pulled into rows that need a different layout. */ - -.onboarding-host, -.onboarding-stat, -.onboarding-active-skills, -.onboarding-surface, -.onboarding-health, -.onboarding-empty { - background: var(--dram-card-bg); - border: 1px solid var(--dram-card-border); - border-radius: var(--r-3); - transition: - background 120ms ease, - border-color 120ms ease; -} - -.onboarding-card-name { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text); + font-size: var(--fs-04); + line-height: 1.6; + max-width: 520px; } -.onboarding-card-desc { - color: var(--text-3); +.ob-step-total { + display: inline-flex; + align-items: center; + gap: var(--s-2); + margin-top: var(--s-3); + padding: var(--s-1) var(--s-3); + background: var(--accent-bg); + border: 1px solid var(--accent-line); + border-radius: var(--r-full); + color: var(--accent-hi); + font-family: var(--mono); font-size: var(--fs-02); - line-height: 1.45; + font-weight: 650; } -/* Step 1: host list */ - -.onboarding-host-list { - display: grid; - gap: var(--s-3); -} - -.onboarding-host { - display: flex; - gap: var(--s-3); - align-items: flex-start; - padding: var(--s-3); - cursor: pointer; -} - -.onboarding-host:hover { - background: var(--dram-card-hover); - border-color: var(--dram-card-border-hover); -} - -.onboarding-host.selected { - background: var(--dram-card-active); - border-color: var(--dram-card-border-active); -} - -.onboarding-host.disabled { - cursor: default; - opacity: var(--dram-card-muted-opacity); -} - -.onboarding-host-input { - flex: 0 0 auto; - margin-top: 4px; -} - -.onboarding-host-icon, -.onboarding-surface-icon { - display: grid; +.ob-step-total::before { + content: ''; + width: 6px; + height: 6px; + border-radius: var(--r-full); + background: var(--accent); flex: 0 0 auto; - place-items: center; - background: var(--dram-bg-recessed); - border: 1px solid var(--dram-line-subtle); - border-radius: var(--r-2); - color: var(--text); - font-weight: 700; -} - -.onboarding-host-icon { - width: 32px; - height: 32px; - font-size: 13px; } -.onboarding-host-body { +/* ── Scan category grid ── */ +/* ── Sections (candidate lists, source rows) ── */ +.ob-section { display: grid; - flex: 1; - gap: 4px; - min-width: 0; + gap: var(--s-3); + margin-bottom: var(--s-6); } -.onboarding-host-top { +.ob-section-head { display: flex; - gap: var(--s-2); align-items: center; - justify-content: space-between; -} - -/* Step 2: stat grid + active skills */ - -.onboarding-stat-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--s-3); -} - -.onboarding-stat { - display: grid; - gap: 4px; - padding: var(--s-3); - min-width: 0; + justify-content: flex-start; + gap: var(--s-2); + padding-bottom: var(--s-1); + border-bottom: 1px solid var(--line); } -.onboarding-stat-value { - font-size: 22px; +.ob-section-label { + color: var(--text-2); + font-family: var(--mono); + font-size: var(--fs-02); font-weight: 600; - color: var(--text); + letter-spacing: var(--tr-wide); + text-transform: uppercase; } -.onboarding-active-skills { +.ob-row-list { display: grid; - gap: 6px; - padding: var(--s-3); -} - -.onboarding-inline-action { - justify-self: start; + gap: 1px; + background: var(--line); + border: 1px solid var(--line); + border-radius: var(--r-1); + overflow: hidden auto; + max-height: 320px; } -/* In-flight progress card shown while DS.indexSkills is running. Replaces - the Build button so the user can see something is happening; without - this the click-to-API-return gap (10-60s for 100+ skills via Ollama) - reads as "the button didn't do anything". role="status" + aria-live so - screen readers announce the change. */ -.onboarding-progress-card { +.ob-row { display: flex; - gap: var(--s-3); align-items: center; + gap: var(--s-3); padding: var(--s-3) var(--s-4); - background: var(--dram-bg-recessed); - border: 1px solid var(--dram-line-subtle); - border-radius: var(--r-2); -} - -.onboarding-progress-text { - display: grid; - gap: 2px; - min-width: 0; -} - -.onboarding-progress-text strong { - color: var(--text); - font-size: var(--fs-03); + background: var(--dram-card-bg); + transition: background var(--out); } -.onboarding-progress-text span { - color: var(--text-3); - font-size: var(--fs-02); - line-height: 1.45; +.ob-row:hover { + background: var(--dram-card-hover); } -.onboarding-spinner { +.ob-row-icon { + width: 32px; + height: 32px; flex: 0 0 auto; - width: 18px; - height: 18px; - border-radius: 999px; - border: 2px solid var(--dram-line-subtle); - border-top-color: var(--accent); - animation: onboarding-spin 0.9s linear infinite; -} - -@keyframes onboarding-spin { - to { - transform: rotate(360deg); - } -} - -/* Footer error band — shown when Finish setup couldn't get an ack from - the server-side completion endpoint. Spans the footer's full width - above the action buttons so the user has clear retry/Skip options. */ -.onboarding-footer-error { - flex: 1 1 100%; - order: -1; - padding: var(--s-2) var(--s-3); - background: var(--err-bg); - border-left: 2px solid var(--err); + border: 1px solid var(--line); border-radius: var(--r-1); - color: var(--text); - font-size: var(--fs-02); -} - -/* Step 2: skill sources sub-section */ - -.onboarding-sources { - display: grid; - gap: var(--s-3); - padding-top: var(--s-3); - border-top: 1px solid var(--dram-line-subtle); -} - -.onboarding-sources-head { - display: grid; - gap: 4px; -} - -.onboarding-source-list { - display: grid; - gap: var(--s-2); -} - -.onboarding-source-row { - display: grid; - gap: var(--s-2); - padding: var(--s-2) var(--s-3); - background: var(--dram-card-bg); - border: 1px solid var(--dram-card-border); - border-radius: var(--r-2); -} - -.onboarding-source-row.linked { - border-color: var(--dram-card-border-hover); -} - -.onboarding-source-row.expanded { - border-color: var(--dram-card-border-active); -} - -.onboarding-source-row-top { - display: flex; - gap: var(--s-3); - align-items: center; -} - -.onboarding-source-row-body { - display: grid; - flex: 1; - gap: 2px; - min-width: 0; -} - -.onboarding-source-path { + background: var(--bg-elevated); overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono, monospace); - font-size: var(--fs-01); - color: var(--text-4); -} - -.onboarding-source-row-meta { - display: flex; - gap: var(--s-2); + display: inline-flex; align-items: center; - flex: 0 0 auto; -} - -.onboarding-source-empty { - padding: var(--s-3); - background: var(--dram-bg-recessed); - border: 1px dashed var(--dram-line-subtle); - border-radius: var(--r-2); -} - -.onboarding-source-form { - display: flex; - gap: var(--s-2); - align-items: center; -} - -.onboarding-source-input { - flex: 1; - padding: 8px 10px; - background: var(--dram-bg-recessed); - border: 1px solid var(--dram-line-subtle); - border-radius: var(--r-2); - color: var(--text); - font-family: var(--font-mono, monospace); - font-size: var(--fs-02); -} - -.onboarding-source-input:focus { - outline: none; - border-color: var(--dram-card-border-hover); + justify-content: center; } -.onboarding-linked-head { - padding-top: var(--s-1); +.ob-row-icon img { + width: 19px; + height: 19px; + object-fit: contain; + opacity: 0.92; + filter: grayscale(1) invert(1); } -.onboarding-source-message { - padding: var(--s-2) var(--s-3); - background: var(--dram-bg-recessed); - border-left: 2px solid var(--accent); - border-radius: var(--r-1); - color: var(--text-3); - font-size: var(--fs-02); +.ob-row-icon svg { + width: 18px; + height: 18px; + color: var(--accent-hi); } -/* Diff panel (expanded linked row showing sync changes) */ - -.onboarding-diff-panel { +.ob-row-icon-fallback { + width: 100%; + height: 100%; display: grid; - gap: var(--s-2); - padding-top: var(--s-2); - border-top: 1px solid var(--dram-line-subtle); + place-items: center; + color: var(--text-2); + font-size: var(--fs-03); + font-weight: 700; } -.onboarding-diff-group { +.ob-row-body { + flex: 1; + min-width: 0; display: grid; - gap: 4px; -} - -.onboarding-diff-label { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text-3); -} - -.onboarding-diff-group[data-kind='added'] .onboarding-diff-label { - color: var(--ok); -} - -.onboarding-diff-group[data-kind='removed'] .onboarding-diff-label { - color: var(--err); -} - -.onboarding-diff-group[data-kind='modified'] .onboarding-diff-label { - color: var(--warn); -} - -.onboarding-diff-group[data-kind='local'] .onboarding-diff-label { - color: var(--warn); -} - -.onboarding-diff-group[data-kind='conflict'] .onboarding-diff-label { - color: var(--err); + gap: 2px; } -.onboarding-diff-warning { - padding: var(--s-2) var(--s-3); - background: var(--err-bg); - border-left: 2px solid var(--err); - border-radius: var(--r-1); +.ob-row-name { color: var(--text); - font-size: var(--fs-02); - line-height: 1.5; + font-size: var(--fs-04); + font-weight: 500; } -.onboarding-diff-files { - display: grid; - gap: 2px; - margin: 0; - padding: 0 0 0 var(--s-3); - list-style: none; - font-family: var(--font-mono, monospace); +.ob-row-path { + color: var(--text-4); + font-family: var(--mono); font-size: var(--fs-01); - color: var(--text-3); -} - -.onboarding-diff-files li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.onboarding-diff-more { - color: var(--text-4); - font-style: italic; -} - -.onboarding-diff-actions { - display: flex; - gap: var(--s-2); - justify-content: flex-end; - align-items: center; - padding-top: var(--s-2); -} - -/* Connections-tab Sources panel — same row patterns as onboarding, different - outer container. The .onboarding-source-row classes are reused via class - composition; the .ssp-* wrappers handle the inline panel layout. */ - -#skill-sources-section .skill-sources-title { - margin: 0; - font-size: var(--fs-04); - font-weight: 600; - color: var(--text); +.ob-row .ct-badge { + flex: 0 0 auto; } -#skill-sources-section .skill-sources-subtitle { - margin: 4px 0 0; - color: var(--text-3); - font-size: var(--fs-02); - line-height: 1.5; +.ob-row .ct-badge.muted { + opacity: 0.45; } -#skill-sources-panel { - display: grid; - gap: var(--s-3); +.ob-row .ct-badge.ok { + color: var(--ok); + background: var(--ok-bg); + border-color: var(--ok); } -.ssp-list { +.ob-accordion-list { display: grid; - gap: var(--s-2); + gap: 1px; + background: var(--line); + border: 1px solid var(--line); + border-radius: var(--r-1); + overflow: hidden auto; + max-height: 320px; } -.ssp-empty { - padding: var(--s-3); - background: var(--dram-bg-recessed); - border: 1px dashed var(--dram-line-subtle); - border-radius: var(--r-2); - color: var(--text-3); - font-size: var(--fs-02); +.ob-accordion { + background: var(--dram-card-bg); } -.ssp-form { +.ob-accordion-hdr { display: flex; - gap: var(--s-2); align-items: center; -} - -.ssp-input { - flex: 1; - padding: 8px 10px; - background: var(--dram-bg-recessed); - border: 1px solid var(--dram-line-subtle); - border-radius: var(--r-2); + gap: var(--s-3); + padding: var(--s-3) var(--s-4); + width: 100%; + border: none; + background: transparent; color: var(--text); - font-family: var(--font-mono, monospace); - font-size: var(--fs-02); + cursor: pointer; + text-align: left; + font: inherit; + transition: background var(--out); } -.ssp-input:focus { - outline: none; - border-color: var(--dram-card-border-hover); +.ob-accordion-hdr:hover { + background: var(--dram-card-hover); } -.ssp-linked-head { - padding-top: var(--s-1); +.ob-accordion-chev { + flex: 0 0 auto; + color: var(--text-3); + transition: transform 0.15s ease; } -/* Step 3: surface grid */ - -.onboarding-surface-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--s-3); +.ob-accordion-open .ob-accordion-chev { + transform: rotate(180deg); } -.onboarding-surface { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - gap: var(--s-3); - padding: var(--s-3); - min-width: 0; +.ob-accordion-body { + display: none; + padding: 0 var(--s-4) var(--s-3) var(--s-4); + border-top: 1px solid var(--line); } -.onboarding-surface-icon { - width: 34px; - height: 34px; - font-size: 13px; +.ob-accordion-open .ob-accordion-body { + display: block; } -.onboarding-surface-body { +.ob-skill-list { + list-style: none; + margin: var(--s-2) 0; + padding: 0; display: grid; - gap: 4px; - min-width: 0; + gap: 2px var(--s-3); + max-height: 200px; + overflow-y: auto; } -.onboarding-surface-top { +.ob-skill-list li { display: flex; + align-items: baseline; gap: var(--s-2); - align-items: center; - justify-content: space-between; -} - -.onboarding-surface-detail { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Step 4: health grid */ - -.onboarding-health-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--s-3); -} - -.onboarding-health { - display: grid; - gap: 6px; - padding: var(--s-3); - min-width: 0; -} - -.onboarding-health.ready { - border-color: var(--dram-card-border-hover); -} - -.onboarding-health-value { - font-size: var(--fs-06); - font-weight: 600; - color: var(--text); -} - -.onboarding-health.pending .onboarding-health-value { - color: var(--warn); + font-size: var(--fs-02); + color: var(--text-2); + padding: 1px 0; } -/* Empty state */ - -.onboarding-empty { - display: grid; - gap: 6px; - padding: var(--s-4); +.ob-skill-cat { + color: var(--text-4); + font-size: var(--fs-01); + font-family: var(--mono); } -/* Footer */ - -.onboarding-footer { - display: flex; - gap: var(--s-2); - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - padding: var(--s-4) var(--s-5); - border-top: 1px solid var(--dram-line-subtle); +.ob-skill-cat::after { + content: ' / '; } -.onboarding-footer-end { +.ob-accordion-actions { display: flex; - gap: var(--s-2); align-items: center; + gap: var(--s-3); + margin-top: var(--s-1); } -/* Responsive */ - -@media (max-width: 720px) { - .onboarding-stat-grid, - .onboarding-surface-grid, - .onboarding-health-grid { - grid-template-columns: 1fr; - } - - .onboarding-progress-label { - display: none; - } - - .onboarding-progress-bar { - flex-basis: 16px; - margin-bottom: 0; - } +.ob-accordion-actions .ct-badge.linked { + color: var(--ok); + background: var(--ok-bg); + border-color: var(--ok); } diff --git a/ui/styles/shell.css b/ui/styles/shell.css index 9503446..74d647f 100644 --- a/ui/styles/shell.css +++ b/ui/styles/shell.css @@ -24,87 +24,34 @@ grid-template-columns: var(--side-w-mini) minmax(0, 1fr); } -.desktop-titlebar { - display: none; -} - -/* Electron mode: title bar becomes a row inside .app, occupying only the - right column. Sidebar spans all rows so its border-right is the single, - unbroken vertical divider from y=0 to the bottom of the window. - The legacy `top` row (48px reserved for a `.topbar` that doesn't exist - in this app) is collapsed via `0px` so the title bar sits flush against - the main content area — no dead strip between them. */ +/* Electron: reserve top row for native titleBarOverlay controls */ html[data-runtime='electron'] .app { grid-template-rows: var(--desktop-titlebar-h) 0px 1fr; grid-template-areas: - 'side titlebar' + 'side overlay' 'side top' 'side main'; } -html[data-runtime='electron'] .desktop-titlebar { - grid-area: titlebar; - -webkit-app-region: drag; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 var(--s-2) 0 var(--s-4); - background: var(--bg-solid); - color: var(--text); - user-select: none; -} - -/* Empty drag-region anchor on the left of the title bar. No brand text or - icon — Windows owns the taskbar identity via app.setAppUserModelId and the - .ico set on BrowserWindow. The chrome stays minimal. */ -html[data-runtime='electron'] .desktop-titlebar-brand { - flex: 1 1 auto; - min-width: 0; -} - -html[data-runtime='electron'] .desktop-titlebar-actions svg { - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -html[data-runtime='electron'] .desktop-titlebar-actions { - -webkit-app-region: no-drag; - display: inline-flex; - align-items: center; - gap: var(--s-1); -} - -html[data-runtime='electron'] .desktop-titlebar-actions button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 24px; - color: var(--text-3); - border: 1px solid transparent; - border-radius: var(--r-1); - background: transparent; - transition: - color var(--out), - background var(--out), - border-color var(--out); +/* Electron: draggable strip behind the native titlebar overlay controls */ +.desktop-titlebar { + display: none; } -html[data-runtime='electron'] .desktop-titlebar-actions button:hover { - color: var(--text); - background: var(--bg-hover); - border-color: var(--line-2); +html[data-runtime='electron'] .desktop-titlebar { + display: block; + grid-area: overlay; + position: relative; + z-index: var(--z-nav); + min-height: var(--desktop-titlebar-h); + background: var(--bg); + border-bottom: 1px solid var(--line); + -webkit-app-region: drag; } -html[data-runtime='electron'] .desktop-titlebar-actions .desktop-titlebar-close:hover { - color: var(--err); - background: var(--err-bg); - border-color: var(--err); +/* Electron: sidebar brand is the window drag handle */ +html[data-runtime='electron'] .nav-brand { + -webkit-app-region: drag; } /* ═════════════ SIDEBAR ═════════════ */ diff --git a/ui/styles/surface-final.css b/ui/styles/surface-final.css index 9923965..f8aeed6 100644 --- a/ui/styles/surface-final.css +++ b/ui/styles/surface-final.css @@ -52,6 +52,7 @@ border-radius: 10px; color: var(--text-3); background: transparent; + -webkit-app-region: no-drag; transition: color var(--out), background var(--out), diff --git a/ui/styles/tab-config.css b/ui/styles/tab-config.css index 9a3c1b3..3eadb9e 100644 --- a/ui/styles/tab-config.css +++ b/ui/styles/tab-config.css @@ -162,6 +162,81 @@ align-items: center; } +.rules-rule-list { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.rules-rule-row { + display: flex; + align-items: flex-start; + gap: var(--s-2); +} + +.rules-rule-row .rules-rule-check { + margin-top: 10px; + flex: 0 0 auto; +} + +.rules-rule-input { + flex: 1; + min-width: 0; + min-height: 36px; + padding: var(--s-2); + border: 1px solid var(--line); + border-radius: var(--r-1); + background: var(--bg-sunken); + color: var(--text); + font-family: var(--mono); + font-size: var(--fs-02); + line-height: 1.5; + resize: vertical; + transition: border-color var(--out); +} + +.rules-rule-input:focus { + border-color: var(--line-accent); + background: var(--bg-raised); + outline: none; +} + +.rules-rule-input::placeholder { + color: var(--text-3); +} + +.rules-sev-select { + min-width: 110px; + min-height: 36px; + flex: 0 0 auto; +} + +.rules-rule-del { + flex: 0 0 auto; + width: 36px; + height: 36px; + border: 1px solid transparent; + border-radius: var(--r-1); + background: transparent; + color: var(--text-3); + font-size: var(--fs-05); + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + border-color var(--out), + color var(--out), + background var(--out); +} + +.rules-rule-del:hover { + border-color: var(--err); + color: var(--err); + background: var(--err-bg); +} + .rules-priority-group { display: flex; flex-direction: column; diff --git a/ui/types.d.ts b/ui/types.d.ts index 197e582..56dee1e 100644 --- a/ui/types.d.ts +++ b/ui/types.d.ts @@ -363,6 +363,8 @@ declare const RulesLab: { init(): Promise | void; refresh(): void; beforeSave(): void; + draft(): any; + setDraft(rules: any): void; saveProfile(): Promise; applyProfile(): Promise; restoreHistory(index: number): void;