diff --git a/.plans/ollama-native-provider.md b/.plans/ollama-native-provider.md new file mode 100644 index 000000000..312743c95 --- /dev/null +++ b/.plans/ollama-native-provider.md @@ -0,0 +1,241 @@ +# Ollama Extension — First-Class Local LLM Support + +## Status: DRAFT — Awaiting approval + +## Problem + +Ollama support in GSD2 currently requires manual `models.json` configuration. Users must: +1. Know the OpenAI-compatibility endpoint (`localhost:11434/v1`) +2. Manually list every model they want to use +3. Set compat flags (`supportsDeveloperRole: false`, etc.) +4. Use a dummy API key + +There's an `ollama-cloud` provider for hosted Ollama, and a discovery adapter that can list models, but no first-class **local Ollama** extension that "just works." + +## Goal + +Make Ollama the easiest way to use GSD2 — zero config when Ollama is running locally. All Ollama functionality lives in a single extension: `src/resources/extensions/ollama/`. + +## Architecture + +Everything is a self-contained extension under `src/resources/extensions/ollama/`. The extension: +- Auto-detects Ollama on startup via health check +- Discovers and registers local models with the model registry +- Provides native Ollama API streaming (not OpenAI shim) +- Exposes `/ollama` slash commands for model management +- Registers an LLM-callable tool for model pull/status + +Minimal core changes — only `KnownProvider` and `KnownApi` type additions in `pi-ai`, and `env-api-keys.ts` for key resolution. Everything else is in the extension. + +## File Structure + +``` +src/resources/extensions/ollama/ +├── index.ts # Extension entry — wires everything on session_start +├── ollama-client.ts # HTTP client for Ollama REST API (/api/*) +├── ollama-discovery.ts # Model discovery + capability detection +├── ollama-provider.ts # Native /api/chat streaming provider (registers with pi-ai) +├── ollama-commands.ts # /ollama slash commands (status, pull, list, remove, ps) +├── ollama-tool.ts # LLM-callable tool for model management +├── model-capabilities.ts # Known model capability table (context window, vision, reasoning) +└── types.ts # Shared types for Ollama API responses +``` + +## Scope + +### Phase 1: Auto-Discovery + OpenAI-Compat Routing + +**What:** Extension that auto-detects Ollama, discovers models, registers them using the existing `openai-completions` API provider. Zero config needed. + +**Extension files:** +- `ollama/index.ts` — Main entry. On `session_start`: + 1. Probe `localhost:11434` (or `OLLAMA_HOST`) with 1.5s timeout + 2. If reachable, discover models via `/api/tags` + 3. Register discovered models with `ctx.modelRegistry` using correct defaults + 4. Show status widget if Ollama is detected +- `ollama/ollama-client.ts` — Low-level HTTP client: + - `isRunning()` — `GET /` health check + - `getVersion()` — `GET /api/version` + - `listModels()` — `GET /api/tags` + - `showModel(name)` — `POST /api/show` (details, template, parameters, size) + - `getRunningModels()` — `GET /api/ps` (loaded models, VRAM usage) + - `pullModel(name, onProgress)` — `POST /api/pull` (streaming progress) + - `deleteModel(name)` — `DELETE /api/delete` + - `copyModel(source, dest)` — `POST /api/copy` + - Respects `OLLAMA_HOST` env var for non-default endpoints +- `ollama/ollama-discovery.ts` — Enhanced model discovery: + - Calls `/api/tags` to get model list + - Calls `/api/show` per model (batch, cached) to get: + - `details.parameter_size` → estimate context window + - `details.families` → detect vision (clip), reasoning (deepseek-r1) + - `modelfile` → extract default parameters + - Returns enriched `DiscoveredModel[]` with proper capabilities +- `ollama/model-capabilities.ts` — Known model lookup table: + - Maps well-known model families to capabilities + - e.g., `llama3.1` → `{ contextWindow: 131072, input: ["text"] }` + - e.g., `llava` → `{ contextWindow: 4096, input: ["text", "image"] }` + - e.g., `deepseek-r1` → `{ reasoning: true, contextWindow: 131072 }` + - e.g., `qwen2.5-coder` → `{ contextWindow: 131072, input: ["text"] }` + - Fallback: estimate from parameter count if not in table +- `ollama/types.ts` — Ollama API response types + +**Core changes (minimal):** +- `packages/pi-ai/src/types.ts` — Add `"ollama"` to `KnownProvider` +- `packages/pi-ai/src/env-api-keys.ts` — Add `"ollama"` key resolution (returns `"ollama"` placeholder — no real key needed) +- `src/onboarding.ts` — Add `"ollama"` to provider selection list +- `src/wizard.ts` — Add `ollama` entry (no key required) + +**Model registration details:** +Each discovered model registers as: +```typescript +{ + id: "llama3.1:8b", // from /api/tags + name: "Llama 3.1 8B", // humanized + api: "openai-completions", // uses existing provider + provider: "ollama", + baseUrl: "http://localhost:11434/v1", + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, // from capabilities table + input: ["text"], // from capabilities table + contextWindow: 131072, // from capabilities table or /api/show + maxTokens: 16384, // conservative default + compat: { + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsUsageInStreaming: false, + maxTokensField: "max_tokens", + }, +} +``` + +**Behavior:** +- `gsd --list-models` shows all locally-pulled Ollama models automatically +- `/model ollama/llama3.1:8b` works without any config file +- If Ollama isn't running, extension is silent — no errors, no models listed +- `models.json` overrides still work (user config wins over auto-discovery) + +### Phase 2: Native Ollama API Provider (`/api/chat`) + +**What:** A dedicated streaming provider that talks Ollama's native protocol instead of the OpenAI compatibility shim. + +**Extension files:** +- `ollama/ollama-provider.ts` — Native `/api/chat` streaming: + - Registers `"ollama-chat"` API with `registerApiProvider()` + - Implements `stream()` and `streamSimple()`: + - Maps GSD `Context` → Ollama messages format + - Maps GSD `Tool[]` → Ollama tool format + - Streams NDJSON responses, maps back to `AssistantMessage` events + - Extracts `` blocks for reasoning models (deepseek-r1, qwq) + - Ollama-specific options: + - `keep_alive` — control model memory retention (default: "5m") + - `num_ctx` — pass through model's context window + - `num_predict` — max output tokens + - Temperature, top_p, top_k + - Response metadata: + - `eval_count` / `eval_duration` → tokens/sec in usage stats + - `total_duration`, `load_duration` → performance visibility + - Vision support: converts image content to base64 for multimodal models + +**Core changes:** +- `packages/pi-ai/src/types.ts` — Add `"ollama-chat"` to `KnownApi` + +**Phase 1 models switch to `api: "ollama-chat"` by default.** Users can force OpenAI-compat via `models.json` override if needed. + +**Why native over OpenAI-compat:** +- Full `keep_alive` / `num_ctx` control +- Better error messages (Ollama-native vs generic OpenAI) +- More reliable tool calling on Ollama's native format +- Performance metrics in response (tokens/sec) +- Foundation for model management commands + +### Phase 3: Local LLM Management UX + +**What:** `/ollama` slash commands and an LLM tool for model management. + +**Extension files:** +- `ollama/ollama-commands.ts` — Slash commands registered via `pi.registerCommand()`: + - `/ollama` — Status overview: + ``` + Ollama v0.5.7 — running (localhost:11434) + + Loaded: + llama3.1:8b 4.7 GB VRAM idle 3m + + Available: + llama3.1:8b (4.7 GB) + qwen2.5-coder:7b (4.4 GB) + deepseek-r1:8b (4.9 GB) + ``` + - `/ollama pull ` — Pull with streaming progress via `ctx.ui.setWidget()` + - `/ollama list` — List all local models with sizes and families + - `/ollama remove ` — Delete a model (with confirmation) + - `/ollama ps` — Running models + VRAM usage +- `ollama/ollama-tool.ts` — LLM-callable tool registered via `pi.registerTool()`: + - `ollama_manage` tool — lets the agent pull/list/check models + - Parameters: `{ action: "list" | "pull" | "status" | "ps", model?: string }` + - Use case: agent detects it needs a model, pulls it automatically + +**UX Flow:** +``` +$ gsd +> /ollama +Ollama v0.5.7 — running (localhost:11434) +Loaded: + llama3.1:8b — 4.7 GB VRAM, idle 3m +Available: + llama3.1:8b (4.7 GB) + qwen2.5-coder:7b (4.4 GB) + deepseek-r1:8b (4.9 GB) + +> /ollama pull codestral:22b +Pulling codestral:22b... +████████████████████████████░░░░ 78% (14.2 GB / 18.1 GB) +✓ codestral:22b ready + +> /model ollama/codestral:22b +Switched to codestral:22b (local, Ollama) +``` + +## Implementation Order + +1. **Phase 1** — Auto-discovery with OpenAI-compat routing. Biggest user impact, smallest risk. +2. **Phase 3** — Management UX (`/ollama` commands). Valuable even before native API. +3. **Phase 2** — Native `/api/chat` provider. Optimization over OpenAI-compat; do last. + +## Core Changes Summary (minimal) + +| File | Change | +|------|--------| +| `packages/pi-ai/src/types.ts` | Add `"ollama"` to `KnownProvider`, `"ollama-chat"` to `KnownApi` (Phase 2) | +| `packages/pi-ai/src/env-api-keys.ts` | Add `"ollama"` → always returns `"ollama"` placeholder | +| `src/onboarding.ts` | Add `"ollama"` to provider picker | +| `src/wizard.ts` | Add `"ollama"` key mapping (no key required) | + +Everything else lives in `src/resources/extensions/ollama/`. + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Ollama not running — startup probe latency | 1.5s timeout; cache result; probe async so it doesn't block TUI paint | +| Model capabilities unknown | Known-model table + `/api/show` fallback + parameter_size estimation | +| Tool calling unreliable on small models | Detect param count; warn on <7B models | +| Ollama API changes between versions | Version detect via `/api/version`; stable endpoints only | +| Conflicts with `models.json` Ollama config | User config always wins; auto-discovered models merge beneath manual config | +| Extension disabled — no impact on core | Extension is additive; disabling removes all Ollama features cleanly | + +## Testing Strategy + +- Unit tests: `ollama-client.ts` with mocked fetch responses +- Unit tests: `ollama-discovery.ts` model capability parsing +- Unit tests: `ollama-provider.ts` message format mapping + NDJSON stream parsing +- Unit tests: `model-capabilities.ts` known model lookups +- Integration test: mock HTTP server simulating Ollama `/api/tags`, `/api/chat`, `/api/pull` +- Manual test: real Ollama instance with llama3.1, qwen2.5-coder, deepseek-r1 + +## Open Questions + +1. **Startup probe** — Probe Ollama on `session_start` (adds ~1.5s if not running) or lazy on first `/model`? **Recommendation: async probe on session_start (non-blocking), eager if `OLLAMA_HOST` is set.** +2. **Auto-start** — Try to launch Ollama if installed but not running? **Recommendation: no — too invasive. Show helpful message in `/ollama` status.** +3. **Vision support** — Support multimodal models (llava, etc.) in Phase 2 native API? **Recommendation: yes, detected via capabilities table.** +4. **Model refresh** — How often to re-probe Ollama for new models? **Recommendation: on `/ollama list`, on `/model` command, and every 5 min (existing TTL).** diff --git a/packages/pi-ai/src/env-api-keys.ts b/packages/pi-ai/src/env-api-keys.ts index b6577d99d..1036c4b28 100644 --- a/packages/pi-ai/src/env-api-keys.ts +++ b/packages/pi-ai/src/env-api-keys.ts @@ -137,6 +137,7 @@ export function getEnvApiKey(provider: any): string | undefined { "opencode-go": "OPENCODE_API_KEY", "kimi-coding": "KIMI_API_KEY", "alibaba-coding-plan": "ALIBABA_API_KEY", + ollama: "OLLAMA_API_KEY", "ollama-cloud": "OLLAMA_API_KEY", "custom-openai": "CUSTOM_OPENAI_API_KEY", }; diff --git a/packages/pi-ai/src/types.ts b/packages/pi-ai/src/types.ts index ea3e1491a..42a6b3478 100644 --- a/packages/pi-ai/src/types.ts +++ b/packages/pi-ai/src/types.ts @@ -43,6 +43,7 @@ export type KnownProvider = | "opencode-go" | "kimi-coding" | "alibaba-coding-plan" + | "ollama" | "ollama-cloud"; export type Provider = KnownProvider | string; diff --git a/packages/pi-coding-agent/src/core/model-resolver.ts b/packages/pi-coding-agent/src/core/model-resolver.ts index bfe6ee86f..6d07b940b 100644 --- a/packages/pi-coding-agent/src/core/model-resolver.ts +++ b/packages/pi-coding-agent/src/core/model-resolver.ts @@ -37,6 +37,7 @@ const defaultModelPerProvider: Record = { "opencode-go": "kimi-k2.5", "kimi-coding": "kimi-k2-thinking", "alibaba-coding-plan": "qwen3.5-plus", + ollama: "llama3.1:8b", "ollama-cloud": "qwen3:32b", }; diff --git a/src/onboarding.ts b/src/onboarding.ts index 93e39d0f5..6b21d94d6 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -74,6 +74,7 @@ const LLM_PROVIDER_IDS = [ 'xai', 'openrouter', 'mistral', + 'ollama', 'ollama-cloud', 'custom-openai', ] @@ -90,6 +91,7 @@ const OTHER_PROVIDERS = [ { value: 'xai', label: 'xAI (Grok)' }, { value: 'openrouter', label: 'OpenRouter' }, { value: 'mistral', label: 'Mistral' }, + { value: 'ollama', label: 'Ollama (Local)' }, { value: 'ollama-cloud', label: 'Ollama Cloud' }, { value: 'custom-openai', label: 'Custom (OpenAI-compatible)' }, ] @@ -335,6 +337,9 @@ async function runLlmStep(p: ClackModule, pc: PicoModule, authStorage: AuthStora if (provider === 'custom-openai') { return await runCustomOpenAIFlow(p, pc, authStorage) } + if (provider === 'ollama') { + return await runOllamaLocalFlow(p, pc, authStorage) + } const label = provider === 'anthropic' ? 'Anthropic' : provider === 'openai' ? 'OpenAI' : OTHER_PROVIDERS.find(op => op.value === provider)?.label ?? String(provider) @@ -444,6 +449,54 @@ async function runApiKeyFlow( return true } +// ─── Ollama Local Flow ─────────────────────────────────────────────────────── + +async function runOllamaLocalFlow( + p: ClackModule, + pc: PicoModule, + authStorage: AuthStorage, +): Promise { + const host = process.env.OLLAMA_HOST || 'http://localhost:11434' + + const s = p.spinner() + s.start(`Checking Ollama at ${host}...`) + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 3000) + const response = await fetch(host, { signal: controller.signal }) + clearTimeout(timeout) + + if (response.ok) { + s.stop(`Ollama is running at ${pc.green(host)}`) + // Store a placeholder so the provider is recognized as authenticated + authStorage.set('ollama', { type: 'api_key', key: 'ollama' }) + p.log.success(`${pc.green('Ollama (Local)')} configured — no API key needed`) + p.log.info(pc.dim('Models are discovered automatically from your local Ollama instance.')) + return true + } else { + s.stop('Ollama check failed') + p.log.warn(`Ollama responded with status ${response.status} at ${host}`) + } + } catch { + s.stop('Ollama not detected') + p.log.warn(`Could not reach Ollama at ${host}`) + p.log.info(pc.dim('Install Ollama from https://ollama.com and run "ollama serve"')) + p.log.info(pc.dim('Set OLLAMA_HOST if using a non-default address.')) + } + + // Even if not reachable now, save the config — the extension will detect it at runtime + const proceed = await p.confirm({ + message: 'Save Ollama as your provider anyway? (it will auto-detect when running)', + }) + + if (p.isCancel(proceed) || !proceed) return false + + authStorage.set('ollama', { type: 'api_key', key: 'ollama' }) + p.log.success(`${pc.green('Ollama (Local)')} saved — models will appear when Ollama is running`) + return true +} + // ─── Custom OpenAI-compatible Flow ──────────────────────────────────────────── async function runCustomOpenAIFlow( diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index c6eca1004..92cb389c8 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -1566,17 +1566,18 @@ export function mergeMilestoneToMain( // Non-fatal — proceed with merge; untracked files may block it } - // 7b. Clean up stale merge state files before starting the squash merge (#2912). - // A previous failed merge, libgit2 native path, or external tooling may leave - // MERGE_HEAD on disk. git refuses to start a new merge when MERGE_HEAD exists, - // causing `git merge --squash` to fail with "You have not concluded your merge". + // 7b. Clean up stale merge state before attempting squash merge (#2912). + // A leftover MERGE_HEAD (from a previous failed merge, libgit2 native path, + // or interrupted operation) causes `git merge --squash` to refuse with + // "fatal: You have not concluded your merge (MERGE_HEAD exists)". + // Defensively remove merge artifacts before starting. try { const gitDir_ = resolveGitDir(originalBasePath_); - for (const f of ["MERGE_HEAD", "MERGE_MSG", "SQUASH_MSG"]) { + for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) { const p = join(gitDir_, f); if (existsSync(p)) unlinkSync(p); } - } catch { /* best-effort — proceed and let the merge report the error if it fails */ } + } catch { /* best-effort */ } // 8. Squash merge — auto-resolve .gsd/ state file conflicts (#530) const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch); diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index d2cded710..94930375a 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -95,6 +95,27 @@ export async function buildBeforeAgentStartResult( } } + let codebaseBlock = ""; + const codebasePath = resolveGsdRootFile(process.cwd(), "CODEBASE"); + if (existsSync(codebasePath)) { + try { + const rawContent = readFileSync(codebasePath, "utf-8").trim(); + if (rawContent) { + // Cap injection size to ~2 000 tokens to avoid bloating every request. + // Full map is always available at .gsd/CODEBASE.md. + const MAX_CODEBASE_CHARS = 8_000; + const generatedMatch = rawContent.match(/Generated: (\S+)/); + const generatedAt = generatedMatch?.[1] ?? "unknown"; + const content = rawContent.length > MAX_CODEBASE_CHARS + ? rawContent.slice(0, MAX_CODEBASE_CHARS) + "\n\n*(truncated — see .gsd/CODEBASE.md for full map)*" + : rawContent; + codebaseBlock = `\n\n[PROJECT CODEBASE — File structure and descriptions (generated ${generatedAt}, may be stale — run /gsd codebase update to refresh)]\n\n${content}`; + } + } catch { + // skip + } + } + warnDeprecatedAgentInstructions(); const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd()); @@ -103,7 +124,7 @@ export async function buildBeforeAgentStartResult( const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd()) : null; const worktreeBlock = buildWorktreeContextBlock(); - const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`; + const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`; stopContextTimer({ systemPromptSize: fullSystem.length, diff --git a/src/resources/extensions/gsd/codebase-generator.ts b/src/resources/extensions/gsd/codebase-generator.ts new file mode 100644 index 000000000..6fe558abb --- /dev/null +++ b/src/resources/extensions/gsd/codebase-generator.ts @@ -0,0 +1,351 @@ +/** + * GSD Codebase Map Generator + * + * Produces .gsd/CODEBASE.md — a structural table of contents for the project. + * Gives fresh agent contexts instant orientation without filesystem exploration. + * + * Generation: walk `git ls-files`, group by directory, output with descriptions. + * Maintenance: agent updates descriptions as it works; incremental update preserves them. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname, extname } from "node:path"; + +import { execSync } from "node:child_process"; +import { gsdRoot } from "./paths.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface CodebaseMapOptions { + excludePatterns?: string[]; + maxFiles?: number; + collapseThreshold?: number; +} + +interface FileEntry { + path: string; + description: string; +} + +interface DirectoryGroup { + path: string; + files: FileEntry[]; + collapsed: boolean; +} + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +const DEFAULT_EXCLUDES = [ + ".gsd/", + ".planning/", + ".git/", + "node_modules/", + "dist/", + "build/", + ".next/", + "coverage/", + "__pycache__/", + ".venv/", + "vendor/", +]; + +const DEFAULT_MAX_FILES = 500; +const DEFAULT_COLLAPSE_THRESHOLD = 20; + +// ─── Parsing ───────────────────────────────────────────────────────────────── + +/** + * Parse an existing CODEBASE.md to extract file → description mappings. + * Also scans comment blocks to preserve + * descriptions for files in collapsed directories across incremental updates. + */ +export function parseCodebaseMap(content: string): Map { + const descriptions = new Map(); + let inCollapsedBlock = false; + + for (const line of content.split("\n")) { + // Track collapsed-description comment blocks + if (line.trimStart().startsWith("")) { + inCollapsedBlock = false; + continue; + } + + // Match: - `path/to/file.ts` — Description here + const match = line.match(/^- `(.+?)` — (.+)$/); + if (match) { + descriptions.set(match[1], match[2]); + continue; + } + + // Match: - `path/to/file.ts` (no description) — only outside collapsed blocks + if (!inCollapsedBlock) { + const bareMatch = line.match(/^- `(.+?)`\s*$/); + if (bareMatch) { + descriptions.set(bareMatch[1], ""); + } + } + } + return descriptions; +} + +// ─── File Enumeration ──────────────────────────────────────────────────────── + +function shouldExclude(filePath: string, excludes: string[]): boolean { + for (const pattern of excludes) { + if (pattern.endsWith("/")) { + if (filePath.startsWith(pattern) || filePath.includes(`/${pattern}`)) return true; + } else if (filePath === pattern || filePath.endsWith(`/${pattern}`)) { + return true; + } + } + // Skip binary/lock files + const ext = extname(filePath).toLowerCase(); + if ([".lock", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".svg"].includes(ext)) { + return true; + } + return false; +} + +function lsFiles(basePath: string): string[] { + try { + const result = execSync("git ls-files", { cwd: basePath, encoding: "utf-8", timeout: 10000 }); + return result.split("\n").filter(Boolean); + } catch { + return []; + } +} + +/** + * Enumerate tracked files, applying exclusions and the maxFiles cap. + * Returns both the file list and whether truncation occurred. + */ +function enumerateFiles(basePath: string, excludes: string[], maxFiles: number): { files: string[]; truncated: boolean } { + const allFiles = lsFiles(basePath); + const filtered = allFiles.filter((f) => !shouldExclude(f, excludes)); + const truncated = filtered.length > maxFiles; + return { files: truncated ? filtered.slice(0, maxFiles) : filtered, truncated }; +} + +// ─── Grouping ──────────────────────────────────────────────────────────────── + +function groupByDirectory( + files: string[], + descriptions: Map, + collapseThreshold: number, +): DirectoryGroup[] { + const dirMap = new Map(); + + for (const file of files) { + const dir = dirname(file); + const dirKey = dir === "." ? "" : dir; + if (!dirMap.has(dirKey)) { + dirMap.set(dirKey, []); + } + dirMap.get(dirKey)!.push({ + path: file, + description: descriptions.get(file) ?? "", + }); + } + + const groups: DirectoryGroup[] = []; + const sortedDirs = [...dirMap.keys()].sort(); + + for (const dir of sortedDirs) { + const dirFiles = dirMap.get(dir)!; + dirFiles.sort((a, b) => a.path.localeCompare(b.path)); + + groups.push({ + path: dir, + files: dirFiles, + collapsed: dirFiles.length > collapseThreshold, + }); + } + + return groups; +} + +// ─── Rendering ─────────────────────────────────────────────────────────────── + +function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncated: boolean): string { + const lines: string[] = []; + const now = new Date().toISOString().split(".")[0] + "Z"; + const described = groups.reduce((sum, g) => sum + g.files.filter((f) => f.description).length, 0); + + lines.push("# Codebase Map"); + lines.push(""); + lines.push(`Generated: ${now} | Files: ${totalFiles} | Described: ${described}/${totalFiles}`); + if (truncated) { + lines.push(`Note: Truncated to first ${totalFiles} files. Run with higher --max-files to include all.`); + } + lines.push(""); + + for (const group of groups) { + const heading = group.path || "(root)"; + lines.push(`### ${heading}/`); + + if (group.collapsed) { + // Summarize collapsed directories + const extensions = new Map(); + for (const f of group.files) { + const ext = extname(f.path) || "(no ext)"; + extensions.set(ext, (extensions.get(ext) ?? 0) + 1); + } + const extSummary = [...extensions.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([ext, count]) => `${count} ${ext}`) + .join(", "); + lines.push(`- *(${group.files.length} files: ${extSummary})*`); + + // Preserve any existing descriptions in a hidden comment block so + // incremental updates can recover them via parseCodebaseMap. + const descLines = group.files + .filter((f) => f.description) + .map((f) => `- \`${f.path}\` — ${f.description}`); + if (descLines.length > 0) { + lines.push(""); + } + } else { + for (const file of group.files) { + if (file.description) { + lines.push(`- \`${file.path}\` — ${file.description}`); + } else { + lines.push(`- \`${file.path}\``); + } + } + } + lines.push(""); + } + + return lines.join("\n"); +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Generate a fresh CODEBASE.md from scratch. + * Preserves existing descriptions if `existingDescriptions` is provided. + */ +export function generateCodebaseMap( + basePath: string, + options?: CodebaseMapOptions, + existingDescriptions?: Map, +): { content: string; fileCount: number; truncated: boolean; files: string[] } { + const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])]; + const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; + const collapseThreshold = options?.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD; + + const { files, truncated } = enumerateFiles(basePath, excludes, maxFiles); + const descriptions = existingDescriptions ?? new Map(); + const groups = groupByDirectory(files, descriptions, collapseThreshold); + const content = renderCodebaseMap(groups, files.length, truncated); + + return { content, fileCount: files.length, truncated, files }; +} + +/** + * Incremental update: re-scan files, preserve existing descriptions, + * add new files, remove deleted files. + */ +export function updateCodebaseMap( + basePath: string, + options?: CodebaseMapOptions, +): { content: string; added: number; removed: number; unchanged: number; fileCount: number; truncated: boolean } { + const codebasePath = join(gsdRoot(basePath), "CODEBASE.md"); + + // Load existing descriptions + let existingDescriptions = new Map(); + if (existsSync(codebasePath)) { + const existing = readFileSync(codebasePath, "utf-8"); + existingDescriptions = parseCodebaseMap(existing); + } + + const existingFiles = new Set(existingDescriptions.keys()); + + // Generate new map preserving descriptions — reuse the returned file list + // to avoid a second enumeration (prevents race between content and stats). + const result = generateCodebaseMap(basePath, options, existingDescriptions); + const currentSet = new Set(result.files); + + // Count changes + let added = 0; + let removed = 0; + + for (const f of result.files) { + if (!existingFiles.has(f)) added++; + } + for (const f of existingFiles) { + if (!currentSet.has(f)) removed++; + } + + return { + content: result.content, + added, + removed, + unchanged: result.files.length - added, + fileCount: result.fileCount, + truncated: result.truncated, + }; +} + +/** + * Write CODEBASE.md to .gsd/ directory. + */ +export function writeCodebaseMap(basePath: string, content: string): string { + const root = gsdRoot(basePath); + mkdirSync(root, { recursive: true }); + const outPath = join(root, "CODEBASE.md"); + writeFileSync(outPath, content, "utf-8"); + return outPath; +} + +/** + * Read existing CODEBASE.md, or return null if it doesn't exist. + */ +export function readCodebaseMap(basePath: string): string | null { + const codebasePath = join(gsdRoot(basePath), "CODEBASE.md"); + if (!existsSync(codebasePath)) return null; + try { + return readFileSync(codebasePath, "utf-8"); + } catch { + return null; + } +} + +/** + * Get stats about the codebase map. + */ +export function getCodebaseMapStats(basePath: string): { + exists: boolean; + fileCount: number; + describedCount: number; + undescribedCount: number; + generatedAt: string | null; +} { + const content = readCodebaseMap(basePath); + if (!content) { + return { exists: false, fileCount: 0, describedCount: 0, undescribedCount: 0, generatedAt: null }; + } + + // Parse total file count from the header line (accurate even for collapsed dirs) + const fileCountMatch = content.match(/Files:\s*(\d+)/); + const totalFiles = fileCountMatch ? parseInt(fileCountMatch[1], 10) : 0; + + // Use parseCodebaseMap to count described files (includes collapsed-description blocks) + const descriptions = parseCodebaseMap(content); + const described = [...descriptions.values()].filter((d) => d.length > 0).length; + const dateMatch = content.match(/Generated: (\S+)/); + + return { + exists: true, + fileCount: totalFiles, + describedCount: described, + undescribedCount: totalFiles - described, + generatedAt: dateMatch?.[1] ?? null, + }; +} diff --git a/src/resources/extensions/gsd/commands-codebase.ts b/src/resources/extensions/gsd/commands-codebase.ts new file mode 100644 index 000000000..305f09256 --- /dev/null +++ b/src/resources/extensions/gsd/commands-codebase.ts @@ -0,0 +1,164 @@ +/** + * GSD Command — /gsd codebase + * + * Generate and manage the codebase map (.gsd/CODEBASE.md). + * Subcommands: generate, update, stats, help + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { + generateCodebaseMap, + updateCodebaseMap, + writeCodebaseMap, + getCodebaseMapStats, + readCodebaseMap, +} from "./codebase-generator.js"; + +const USAGE = + "Usage: /gsd codebase [generate|update|stats]\n\n" + + " generate [--max-files N] — Generate or regenerate CODEBASE.md\n" + + " update — Incremental update (preserves descriptions)\n" + + " stats — Show file count, coverage, and generation time\n" + + " help — Show this help\n\n" + + "With no subcommand, shows stats if a map exists or help if not."; + +export async function handleCodebase( + args: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise { + const basePath = process.cwd(); + const parts = args.trim().split(/\s+/); + const sub = parts[0] ?? ""; + + switch (sub) { + case "generate": { + const maxFiles = parseMaxFiles(args, ctx); + if (maxFiles === false) return; // validation failed, message already shown + + const existing = readCodebaseMap(basePath); + const existingDescriptions = existing + ? (await import("./codebase-generator.js")).parseCodebaseMap(existing) + : undefined; + + const result = generateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined }, existingDescriptions); + + if (result.fileCount === 0) { + ctx.ui.notify( + "Codebase map generated with 0 files.\n" + + "Is this a git repository? Run 'git ls-files' to verify.", + "warning", + ); + return; + } + + const outPath = writeCodebaseMap(basePath, result.content); + ctx.ui.notify( + `Codebase map generated: ${result.fileCount} files\n` + + `Written to: ${outPath}` + + (result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""), + "success", + ); + return; + } + + case "update": { + const existing = readCodebaseMap(basePath); + if (!existing) { + ctx.ui.notify( + "No codebase map found. Run /gsd codebase generate to create one.", + "warning", + ); + return; + } + + const maxFiles = parseMaxFiles(args, ctx); + if (maxFiles === false) return; + + const result = updateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined }); + writeCodebaseMap(basePath, result.content); + + ctx.ui.notify( + `Codebase map updated: ${result.fileCount} files\n` + + ` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}` + + (result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""), + "success", + ); + return; + } + + case "stats": { + showStats(basePath, ctx); + return; + } + + case "help": + ctx.ui.notify(USAGE, "info"); + return; + + case "": { + // Safe default: show stats if map exists, help if not + const existing = readCodebaseMap(basePath); + if (existing) { + showStats(basePath, ctx); + } else { + ctx.ui.notify(USAGE, "info"); + } + return; + } + + default: + ctx.ui.notify( + `Unknown subcommand "${sub}".\n\n${USAGE}`, + "warning", + ); + } +} + +function showStats(basePath: string, ctx: ExtensionCommandContext): void { + const stats = getCodebaseMapStats(basePath); + if (!stats.exists) { + ctx.ui.notify("No codebase map found. Run /gsd codebase generate to create one.", "info"); + return; + } + + const coverage = stats.fileCount > 0 + ? Math.round((stats.describedCount / stats.fileCount) * 100) + : 0; + + ctx.ui.notify( + `Codebase Map Stats:\n` + + ` Files: ${stats.fileCount}\n` + + ` Described: ${stats.describedCount} (${coverage}%)\n` + + ` Undescribed: ${stats.undescribedCount}\n` + + ` Generated: ${stats.generatedAt ?? "unknown"}\n\n` + + (stats.undescribedCount > 0 + ? `Tip: Run /gsd codebase update to refresh after file changes.` + : `Coverage is complete.`), + "info", + ); +} + +/** + * Parse and validate --max-files flag. + * Returns the parsed number, undefined if flag not present, or false if invalid. + */ +function parseMaxFiles(args: string, ctx: ExtensionCommandContext): number | undefined | false { + const maxFilesStr = extractFlag(args, "--max-files"); + if (!maxFilesStr) return undefined; + + const maxFiles = parseInt(maxFilesStr, 10); + if (isNaN(maxFiles) || maxFiles < 1) { + ctx.ui.notify("--max-files must be a positive integer (e.g. --max-files 200).", "warning"); + return false; + } + return maxFiles; +} + +function extractFlag(args: string, flag: string): string | undefined { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escaped}[=\\s]+(\\S+)`); + const match = args.match(regex); + return match?.[1]; +} diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 7d688d41c..02882a07c 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -15,7 +15,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record; export const GSD_COMMAND_DESCRIPTION = - "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink"; + "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -71,6 +71,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "mcp", desc: "MCP server status and connectivity check (status, check )" }, { cmd: "rethink", desc: "Conversational project reorganization — reorder, park, discard, add milestones" }, { cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" }, + { cmd: "codebase", desc: "Generate and manage codebase map (.gsd/CODEBASE.md)" }, ]; const NESTED_COMPLETIONS: CompletionMap = { @@ -225,6 +226,14 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "pause", desc: "Pause custom workflow auto-mode" }, { cmd: "resume", desc: "Resume paused custom workflow auto-mode" }, ], + codebase: [ + { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, + { cmd: "generate --max-files", desc: "Generate with custom file limit (default: 500)" }, + { cmd: "update", desc: "Incremental update (preserves descriptions)" }, + { cmd: "update --max-files", desc: "Update with custom file limit" }, + { cmd: "stats", desc: "Show file count, description coverage, and generation time" }, + { cmd: "help", desc: "Show usage and available subcommands" }, + ], }; function filterOptions( diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts index a1996dfef..4ebfad1bf 100644 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ b/src/resources/extensions/gsd/commands/handlers/ops.ts @@ -206,5 +206,10 @@ Examples: await handleRethink(trimmed, ctx, pi); return true; } + if (trimmed === "codebase" || trimmed.startsWith("codebase ")) { + const { handleCodebase } = await import("../../commands-codebase.js"); + await handleCodebase(trimmed.replace(/^codebase\s*/, "").trim(), ctx, pi); + return true; + } return false; } diff --git a/src/resources/extensions/gsd/complexity-classifier.ts b/src/resources/extensions/gsd/complexity-classifier.ts index 73e505958..c7ae14dbf 100644 --- a/src/resources/extensions/gsd/complexity-classifier.ts +++ b/src/resources/extensions/gsd/complexity-classifier.ts @@ -35,15 +35,17 @@ const UNIT_TYPE_TIERS: Record = { "complete-slice": "light", "run-uat": "light", - // Tier 2 — Standard: research, routine planning, discussion + // Tier 2 — Standard: research, routine discussion "discuss-milestone": "standard", "discuss-slice": "standard", "research-milestone": "standard", "research-slice": "standard", - "plan-milestone": "standard", - "plan-slice": "standard", - // Tier 3 — Heavy: execution, replanning (requires deep reasoning) + // Tier 3 — Heavy: planning, execution, replanning (requires deep reasoning) + // Planning is heavy so it uses the best configured model (e.g. Opus) and is + // not downgraded by dynamic routing when a capable model is configured. + "plan-milestone": "heavy", + "plan-slice": "heavy", "execute-task": "standard", // default standard, upgraded by metadata "replan-slice": "heavy", "reassess-roadmap": "heavy", @@ -185,8 +187,8 @@ function analyzePlanComplexity( // Check if this is a milestone-level plan (more complex) vs single slice const { milestone: mid, slice: sid } = parseUnitId(unitId); if (!sid) { - // Milestone-level planning is always at least standard - return { tier: "standard", reason: "milestone-level planning" }; + // Milestone-level planning is always heavy — requires full context and best model + return { tier: "heavy", reason: "milestone-level planning" }; } // For slice planning, try to read the context/research to gauge complexity diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index 1cdfc0334..8beaefdaa 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -264,6 +264,7 @@ export const GSD_ROOT_FILES = { REQUIREMENTS: "REQUIREMENTS.md", OVERRIDES: "OVERRIDES.md", KNOWLEDGE: "KNOWLEDGE.md", + CODEBASE: "CODEBASE.md", } as const; export type GSDRootFileKey = keyof typeof GSD_ROOT_FILES; @@ -276,6 +277,7 @@ const LEGACY_GSD_ROOT_FILES: Record = { REQUIREMENTS: "requirements.md", OVERRIDES: "overrides.md", KNOWLEDGE: "knowledge.md", + CODEBASE: "codebase.md", }; // ─── GSD Root Discovery ─────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 7f9a0504c..628ea5907 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -228,15 +228,36 @@ export async function deriveState(basePath: string): Promise { const stopTimer = debugTime("derive-state-impl"); let result: GSDState; - // Dual-path: try DB-backed derivation when DB is available. - // Always go through deriveStateFromDb when DB is open — even if hierarchy - // tables are empty — because it contains disk→DB reconciliation logic that - // discovers milestones created outside the DB write path (#2631). + // Dual-path: try DB-backed derivation first when hierarchy tables are populated if (isDbAvailable()) { - const stopDbTimer = debugTime("derive-state-db"); - result = await deriveStateFromDb(basePath); - stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id }); - _telemetry.dbDeriveCount++; + let dbMilestones = getAllMilestones(); + + // Disk→DB reconciliation when DB is empty but disk has milestones (#2631). + // deriveStateFromDb() does its own reconciliation, but deriveState() skips + // it entirely when the DB is empty. Sync here so the DB path is used when + // disk milestones exist but haven't been migrated yet. + if (dbMilestones.length === 0) { + const diskIds = findMilestoneIds(basePath); + let synced = false; + for (const diskId of diskIds) { + if (!isGhostMilestone(basePath, diskId)) { + insertMilestone({ id: diskId, status: 'active' }); + synced = true; + } + } + if (synced) dbMilestones = getAllMilestones(); + } + + if (dbMilestones.length > 0) { + const stopDbTimer = debugTime("derive-state-db"); + result = await deriveStateFromDb(basePath); + stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id }); + _telemetry.dbDeriveCount++; + } else { + // DB open but no milestones on disk either — use filesystem path + result = await _deriveStateImpl(basePath); + _telemetry.markdownDeriveCount++; + } } else { result = await _deriveStateImpl(basePath); _telemetry.markdownDeriveCount++; diff --git a/src/resources/extensions/gsd/tests/codebase-generator.test.ts b/src/resources/extensions/gsd/tests/codebase-generator.test.ts new file mode 100644 index 000000000..c698fc65f --- /dev/null +++ b/src/resources/extensions/gsd/tests/codebase-generator.test.ts @@ -0,0 +1,488 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; + +import { + parseCodebaseMap, + generateCodebaseMap, + updateCodebaseMap, + writeCodebaseMap, + readCodebaseMap, + getCodebaseMapStats, +} from "../codebase-generator.ts"; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function makeTmpRepo(): string { + const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + execSync("git init", { cwd: base, stdio: "ignore" }); + return base; +} + +function addFile(base: string, path: string, content = ""): void { + const fullPath = join(base, path); + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, content || `// ${path}\n`, "utf-8"); + execSync(`git add "${path}"`, { cwd: base, stdio: "ignore" }); +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +// ─── parseCodebaseMap ──────────────────────────────────────────────────── + +test("parseCodebaseMap: parses file with description", () => { + const content = `# Codebase Map + +### src/ +- \`main.ts\` — Application entry point +- \`utils.ts\` — Shared utilities +`; + + const map = parseCodebaseMap(content); + assert.equal(map.size, 2); + assert.equal(map.get("main.ts"), "Application entry point"); + assert.equal(map.get("utils.ts"), "Shared utilities"); +}); + +test("parseCodebaseMap: parses file without description", () => { + const content = `- \`config.ts\`\n- \`index.ts\` — Entry\n`; + const map = parseCodebaseMap(content); + assert.equal(map.size, 2); + assert.equal(map.get("config.ts"), ""); + assert.equal(map.get("index.ts"), "Entry"); +}); + +test("parseCodebaseMap: empty content returns empty map", () => { + const map = parseCodebaseMap(""); + assert.equal(map.size, 0); +}); + +test("parseCodebaseMap: ignores non-matching lines", () => { + const content = `# Codebase Map\n\nGenerated: 2026-03-23\n\n### src/\n- \`file.ts\` — desc\n`; + const map = parseCodebaseMap(content); + assert.equal(map.size, 1); +}); + +test("parseCodebaseMap: recovers descriptions from collapsed-description comments", () => { + const content = `# Codebase Map + +### src/components/ +- *(25 files: 25 .ts)* + +`; + const map = parseCodebaseMap(content); + assert.equal(map.get("src/components/Foo.ts"), "The Foo component"); + assert.equal(map.get("src/components/Bar.ts"), "The Bar component"); + // The collapsed summary line itself should not be parsed as a file + assert.ok(!map.has("*(25 files: 25 .ts)*")); +}); + +test("parseCodebaseMap: handles corrupted/malformed input gracefully", () => { + const content = [ + "- `unclosed backtick", + "- `` — empty filename", + "- `valid.ts` — ok", + "random garbage line", + "- `a.ts` — desc with other text", + ].join("\n"); + const map = parseCodebaseMap(content); + assert.ok(map.has("valid.ts")); + assert.ok(map.has("a.ts")); + // Malformed lines should be silently skipped + assert.equal(map.size, 2); +}); + +// ─── generateCodebaseMap ───────────────────────────────────────────────── + +test("generateCodebaseMap: generates from git ls-files", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "src/utils.ts"); + addFile(base, "README.md"); + + const result = generateCodebaseMap(base); + assert.ok(result.content.includes("# Codebase Map")); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(result.content.includes("`src/utils.ts`")); + assert.ok(result.content.includes("README.md")); + assert.equal(result.fileCount, 3); + assert.equal(result.truncated, false); + assert.equal(result.files.length, 3); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: excludes .gsd/ files", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, ".gsd/PROJECT.md"); + + const result = generateCodebaseMap(base); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(!result.content.includes("PROJECT.md")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: excludes binary and lock files", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "package-lock.json"); // .json not excluded + addFile(base, "yarn.lock"); // .lock excluded + addFile(base, "assets/logo.png"); // .png excluded + + const result = generateCodebaseMap(base); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(result.content.includes("package-lock.json")); + assert.ok(!result.content.includes("yarn.lock")); + assert.ok(!result.content.includes("logo.png")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: respects custom excludePatterns", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "docs/guide.md"); + addFile(base, "docs/api.md"); + + const result = generateCodebaseMap(base, { excludePatterns: ["docs/"] }); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(!result.content.includes("guide.md")); + assert.ok(!result.content.includes("api.md")); + assert.equal(result.fileCount, 1); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: preserves existing descriptions", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "src/utils.ts"); + + const descriptions = new Map(); + descriptions.set("src/main.ts", "App entry point"); + + const result = generateCodebaseMap(base, undefined, descriptions); + assert.ok(result.content.includes("`src/main.ts` — App entry point")); + assert.ok(result.content.includes("`src/utils.ts`")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: collapses large directories", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 25; i++) { + addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`); + } + + const result = generateCodebaseMap(base); + // Collapsed summary should appear + assert.ok(result.content.includes("*(25 files: 25 .ts)*")); + // Individual file entries should NOT appear in main body + assert.ok(!result.content.includes("`src/components/comp00.ts`\n")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: respects custom collapseThreshold", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 5; i++) addFile(base, `src/comp${i}.ts`); + + // Low threshold: 5 files should collapse + const collapsed = generateCodebaseMap(base, { collapseThreshold: 3 }); + assert.ok(collapsed.content.includes("5 files")); + + // High threshold: 5 files should expand + const expanded = generateCodebaseMap(base, { collapseThreshold: 10 }); + assert.ok(expanded.content.includes("`src/comp0.ts`")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: truncated=false when file count is below maxFiles", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 4; i++) addFile(base, `file${i}.ts`); + const result = generateCodebaseMap(base, { maxFiles: 5 }); + assert.equal(result.fileCount, 4); + assert.equal(result.truncated, false); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: truncated=false when file count equals maxFiles exactly", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 5; i++) addFile(base, `file${i}.ts`); + const result = generateCodebaseMap(base, { maxFiles: 5 }); + assert.equal(result.fileCount, 5); + assert.equal(result.truncated, false); // exactly at limit — nothing was truncated + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: truncated=true when file count exceeds maxFiles", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 10; i++) addFile(base, `file${i}.ts`); + const result = generateCodebaseMap(base, { maxFiles: 5 }); + assert.equal(result.fileCount, 5); + assert.equal(result.truncated, true); + assert.ok(result.content.includes("Truncated")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: returns empty map for non-git directory", () => { + const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + // No git init + try { + const result = generateCodebaseMap(base); + assert.equal(result.fileCount, 0); + assert.equal(result.truncated, false); + assert.ok(result.content.includes("# Codebase Map")); + assert.equal(result.files.length, 0); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: handles empty repository (no committed files)", () => { + const base = makeTmpRepo(); + try { + const result = generateCodebaseMap(base); + assert.equal(result.fileCount, 0); + assert.equal(result.truncated, false); + assert.ok(result.content.includes("Files: 0")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: collapsed directories preserve descriptions in hidden comment", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 25; i++) { + addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`); + } + + // Generate with a description for one file in the collapsed dir + const descriptions = new Map([["src/components/comp00.ts", "The first component"]]); + const result = generateCodebaseMap(base, undefined, descriptions); + + // The description should be in the hidden comment block + assert.ok(result.content.includes(" +
+
+
+ ${ui.statusLabel} + ${escapeHtml(ui.modelDisplay)} + ${ui.costDisplay ? `${ui.costDisplay}` : ""} +
+
+ ${escapeHtml(ui.sessionDisplay)} + / + ${info.messageCount} msg${pendingBadge} + / + ${info.thinkingLevel === "off" ? "no think" : info.thinkingLevel} +
+ ${info.contextWindow > 0 ? ` +
+
+
+
+
${ui.contextPct}% context (${formatNum((info.stats?.inputTokens ?? 0) + (info.stats?.outputTokens ?? 0))} / ${formatNum(info.contextWindow)})
+
+ ` : ""} +
+ + ${info.isStreaming ? ` +
+ + Agent is working... + +
+ ` : ""} + + +
+
Workflow
+
+
+ + + + +
+
+
+ + ${ui.hasStats ? ` + +
+
Stats
+
+
${ui.statRows}
+
+
+ ` : ""} + + +
+
Actions
+
+
+ + + + + + +
+
+ +
+
+
+ + + `; + } } function escapeHtml(text: string): string { @@ -621,6 +799,12 @@ function escapeHtml(text: string): string { .replace(/"/g, """); } +function formatNum(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + function getNonce(): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let nonce = ""; diff --git a/vscode-extension/src/slash-completion.ts b/vscode-extension/src/slash-completion.ts index ce9885dd5..c36299d5b 100644 --- a/vscode-extension/src/slash-completion.ts +++ b/vscode-extension/src/slash-completion.ts @@ -77,7 +77,9 @@ export class GsdSlashCompletionProvider private async refreshCache(): Promise { try { - this.cachedCommands = await this.client.getCommands(); + const all = await this.client.getCommands(); + // Only show /gsd commands — filter out unrelated extension/skill commands + this.cachedCommands = all.filter((cmd) => cmd.name.startsWith("gsd")); } catch { // Silently ignore — agent may not be ready yet. }