From e0b3bad2a5b3735b09bfdc91d3ee25c6d574a4c7 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 24 Mar 2026 23:03:00 -0500 Subject: [PATCH] feat(system-context): inject global ~/.gsd/agent/KNOWLEDGE.md into system prompt (#2331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(system-context): inject global ~/.gsd/agent/KNOWLEDGE.md into system prompt Reads ~/.gsd/agent/KNOWLEDGE.md (global) alongside the existing project .gsd/KNOWLEDGE.md and merges both into the [KNOWLEDGE] block. Global section appears first so project entries can override or refine global rules. Emits a startup warning when the global file exceeds 4 KB to keep system prompt size in check. Extracted loading logic into loadKnowledgeBlock() for testability. Five new unit tests cover: empty state, project-only, global-only, merged order, and size threshold. Closes #2316 * fix(test): relax derive-state-db perf threshold from 1ms to 10ms The <1ms assertion was intermittently failing on loaded CI runners (observed: 1.054ms). 10ms still validates the in-memory cache path is fast while being robust across shared CI environments. --------- Co-authored-by: TÂCHES --- .../gsd/bootstrap/system-context.ts | 59 +++++++++--- .../extensions/gsd/tests/knowledge.test.ts | 89 +++++++++++++++++++ 2 files changed, 137 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index 6d4070d7f..0a8255fdc 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -64,17 +64,12 @@ export async function buildBeforeAgentStartResult( } } - let knowledgeBlock = ""; - const knowledgePath = resolveGsdRootFile(process.cwd(), "KNOWLEDGE"); - if (existsSync(knowledgePath)) { - try { - const content = readFileSync(knowledgePath, "utf-8").trim(); - if (content) { - knowledgeBlock = `\n\n[PROJECT KNOWLEDGE — Rules, patterns, and lessons learned]\n\n${content}`; - } - } catch { - // skip - } + const { block: knowledgeBlock, globalSizeKb } = loadKnowledgeBlock(gsdHome, process.cwd()); + if (globalSizeKb > 4) { + ctx.ui.notify( + `GSD: ~/.gsd/agent/KNOWLEDGE.md is ${globalSizeKb.toFixed(1)}KB — consider trimming to keep system prompt lean.`, + "warning", + ); } let memoryBlock = ""; @@ -126,6 +121,48 @@ export async function buildBeforeAgentStartResult( }; } +export function loadKnowledgeBlock(gsdHomeDir: string, cwd: string): { block: string; globalSizeKb: number } { + // 1. Global knowledge (~/.gsd/agent/KNOWLEDGE.md) — cross-project, user-maintained + let globalKnowledge = ""; + let globalSizeKb = 0; + const globalKnowledgePath = join(gsdHomeDir, "agent", "KNOWLEDGE.md"); + if (existsSync(globalKnowledgePath)) { + try { + const content = readFileSync(globalKnowledgePath, "utf-8").trim(); + if (content) { + globalSizeKb = Buffer.byteLength(content, "utf-8") / 1024; + globalKnowledge = content; + } + } catch { + // skip + } + } + + // 2. Project knowledge (.gsd/KNOWLEDGE.md) — project-specific + let projectKnowledge = ""; + const knowledgePath = resolveGsdRootFile(cwd, "KNOWLEDGE"); + if (existsSync(knowledgePath)) { + try { + const content = readFileSync(knowledgePath, "utf-8").trim(); + if (content) projectKnowledge = content; + } catch { + // skip + } + } + + if (!globalKnowledge && !projectKnowledge) { + return { block: "", globalSizeKb: 0 }; + } + + const parts: string[] = []; + if (globalKnowledge) parts.push(`## Global Knowledge\n\n${globalKnowledge}`); + if (projectKnowledge) parts.push(`## Project Knowledge\n\n${projectKnowledge}`); + return { + block: `\n\n[KNOWLEDGE — Rules, patterns, and lessons learned]\n\n${parts.join("\n\n")}`, + globalSizeKb, + }; +} + function buildWorktreeContextBlock(): string { const worktreeName = getActiveWorktreeName(); const worktreeMainCwd = getWorktreeOriginalCwd(); diff --git a/src/resources/extensions/gsd/tests/knowledge.test.ts b/src/resources/extensions/gsd/tests/knowledge.test.ts index 5fa832577..a48e936f2 100644 --- a/src/resources/extensions/gsd/tests/knowledge.test.ts +++ b/src/resources/extensions/gsd/tests/knowledge.test.ts @@ -6,6 +6,7 @@ * - resolveGsdRootFile resolves KNOWLEDGE paths correctly * - inlineGsdRootFile works with the KNOWLEDGE key * - before_agent_start hook includes/omits knowledge block appropriately + * - loadKnowledgeBlock merges global and project knowledge correctly */ import test from 'node:test'; @@ -16,6 +17,7 @@ import { tmpdir } from 'node:os'; import { GSD_ROOT_FILES, resolveGsdRootFile } from '../paths.ts'; import { inlineGsdRootFile } from '../auto-prompts.ts'; import { appendKnowledge } from '../files.ts'; +import { loadKnowledgeBlock } from '../bootstrap/system-context.ts'; // ─── KNOWLEDGE is registered in GSD_ROOT_FILES ───────────────────────────── @@ -159,3 +161,90 @@ test('knowledge: appendKnowledge handles lesson type', async () => { rmSync(tmp, { recursive: true, force: true }); }); + +// ─── loadKnowledgeBlock — global + project merge ──────────────────────────── + +test('loadKnowledgeBlock: returns empty block when neither file exists', () => { + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-'))); + const gsdHome = join(tmp, 'home'); + const cwd = join(tmp, 'project'); + mkdirSync(join(cwd, '.gsd'), { recursive: true }); + mkdirSync(join(gsdHome, 'agent'), { recursive: true }); + + const result = loadKnowledgeBlock(gsdHome, cwd); + assert.strictEqual(result.block, ''); + assert.strictEqual(result.globalSizeKb, 0); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('loadKnowledgeBlock: uses project knowledge alone when no global file', () => { + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-'))); + const gsdHome = join(tmp, 'home'); + const cwd = join(tmp, 'project'); + mkdirSync(join(cwd, '.gsd'), { recursive: true }); + mkdirSync(join(gsdHome, 'agent'), { recursive: true }); + writeFileSync(join(cwd, '.gsd', 'KNOWLEDGE.md'), 'K001: Use real DB'); + + const result = loadKnowledgeBlock(gsdHome, cwd); + assert.ok(result.block.includes('[KNOWLEDGE — Rules, patterns, and lessons learned]')); + assert.ok(result.block.includes('## Project Knowledge')); + assert.ok(result.block.includes('K001: Use real DB')); + assert.ok(!result.block.includes('## Global Knowledge')); + assert.strictEqual(result.globalSizeKb, 0); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('loadKnowledgeBlock: uses global knowledge alone when no project file', () => { + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-'))); + const gsdHome = join(tmp, 'home'); + const cwd = join(tmp, 'project'); + mkdirSync(join(cwd, '.gsd'), { recursive: true }); + mkdirSync(join(gsdHome, 'agent'), { recursive: true }); + writeFileSync(join(gsdHome, 'agent', 'KNOWLEDGE.md'), 'G001: Respond in English'); + + const result = loadKnowledgeBlock(gsdHome, cwd); + assert.ok(result.block.includes('[KNOWLEDGE — Rules, patterns, and lessons learned]')); + assert.ok(result.block.includes('## Global Knowledge')); + assert.ok(result.block.includes('G001: Respond in English')); + assert.ok(!result.block.includes('## Project Knowledge')); + assert.ok(result.globalSizeKb > 0); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('loadKnowledgeBlock: merges global before project when both exist', () => { + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-'))); + const gsdHome = join(tmp, 'home'); + const cwd = join(tmp, 'project'); + mkdirSync(join(cwd, '.gsd'), { recursive: true }); + mkdirSync(join(gsdHome, 'agent'), { recursive: true }); + writeFileSync(join(gsdHome, 'agent', 'KNOWLEDGE.md'), 'G001: Global rule'); + writeFileSync(join(cwd, '.gsd', 'KNOWLEDGE.md'), 'K001: Project rule'); + + const result = loadKnowledgeBlock(gsdHome, cwd); + assert.ok(result.block.includes('## Global Knowledge')); + assert.ok(result.block.includes('## Project Knowledge')); + assert.ok(result.block.includes('G001: Global rule')); + assert.ok(result.block.includes('K001: Project rule')); + // Global section appears before project section + assert.ok(result.block.indexOf('## Global Knowledge') < result.block.indexOf('## Project Knowledge')); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('loadKnowledgeBlock: reports globalSizeKb above 4KB threshold', () => { + const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-'))); + const gsdHome = join(tmp, 'home'); + const cwd = join(tmp, 'project'); + mkdirSync(join(cwd, '.gsd'), { recursive: true }); + mkdirSync(join(gsdHome, 'agent'), { recursive: true }); + // Write > 4KB of content + writeFileSync(join(gsdHome, 'agent', 'KNOWLEDGE.md'), 'x'.repeat(5000)); + + const result = loadKnowledgeBlock(gsdHome, cwd); + assert.ok(result.globalSizeKb > 4, `expected > 4KB, got ${result.globalSizeKb}`); + + rmSync(tmp, { recursive: true, force: true }); +});