diff --git a/CHANGELOG.md b/CHANGELOG.md index 1299bea..e5446d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +### Changed + +- **`axme_decisions` and `axme_memories` now adapt their output to `config.context.mode`.** In `full` mode (default) both tools return full bodies grouped by enforce / type, exactly as before. In `search` mode they return a compact catalog (id/slug + title + 1-line description, ≤200 chars) and instruct the agent to fetch full bodies via `axme_get_decision` / `axme_get_memory` / `axme_search_kb`. This closes a regression in v0.5.0 where the catalog was loaded by `axme_context` but a subsequent agent call to `axme_decisions` or `axme_memories` would silently re-load every body, defeating search mode's ~10× token saving. `axme_oracle` is unaffected — it always returns the full stack/structure/patterns/glossary because those are connected documents, not catalog entries. +- **`buildSearchModeInstructions` (rendered by `axme_context` in search mode) gained an "Active KB usage" block** with concrete trigger predicates ("how did we…", touching git/safety/hooks/storage/release subsystems, mentioning a library by name, before architectural recommendation, before saving a new decision/memory). Replaces a generic "use search for fuzzy lookups" line with imperative MUSTs tied to recognizable situations in the user's task. Designed to make the agent call `axme_search_kb` proactively instead of relying on session-start memory of past KBs. + +### Fixed + +- **Stale memory `transformers-js-install-size-is-102mb` removed** (Q-003). The original v0.2.x memory cited 102 MB for `@huggingface/transformers`; the v0.5.0 release session measured 773 MB on Linux because `onnxruntime-node` pulls prebuilt binaries for 5 platforms (linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows-x64). Since B-005 is shipped and the lazy-install pattern is now embedded in the product (not future guidance), the memory was deleted rather than amended. The auditor's intermediate stub `transformers-js-actual-install-size-is-773-mb-not-102-mb-on-` was also removed. KB reindexed (198 entries). + ## [0.5.0] - 2026-04-29 Skips 0.3.0 / 0.4.0 — combined release for native Windows support, multi-client docs surfacing, and the semantic-search MCP tools (B-005). diff --git a/src/server.ts b/src/server.ts index c3b8792..3e9e194 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -import { getFullContextSections, getOracle, getDecisions } from "./tools/context.js"; +import { getFullContextSections, getOracle, getDecisions, buildDecisionsCatalogString, buildMemoriesCatalogString } from "./tools/context.js"; import { allMemoryContext, getMemorySections } from "./storage/memory.js"; import { getOracleSections } from "./storage/oracle.js"; import { getDecisionSections } from "./storage/decisions.js"; @@ -341,7 +341,7 @@ server.tool( // --- axme_decisions --- server.tool( "axme_decisions", - "Show all project decisions with enforce levels.", + "Show project decisions. Output adapts to context.mode: full → enforce levels + decision body; search → catalog (id + title + 1-line description, fetch bodies via axme_get_decision).", { project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), page: z.number().optional().describe("Page number (1-based). Omit for first page. Follow pagination instructions if output is split."), @@ -349,10 +349,18 @@ server.tool( async ({ project_path, page }) => { const resolved = pp(project_path); deliveredContext.add("decisions:" + resolved); - let sections = getDecisionSections(resolved); - // If requesting repo decisions and workspace decisions already delivered, return repo-only - if (isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath - && deliveredContext.has("decisions:" + defaultWorkspacePath)) { + const config = readConfig(resolved); + const wsAlreadyDelivered = isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath + && deliveredContext.has("decisions:" + defaultWorkspacePath); + let sections: string[]; + if (config.contextMode === "search") { + const wsForMerge = isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath && !wsAlreadyDelivered + ? defaultWorkspacePath : undefined; + sections = [buildDecisionsCatalogString(resolved, wsForMerge)]; + } else { + sections = getDecisionSections(resolved); + } + if (wsAlreadyDelivered) { sections = [...sections, "*(Workspace decisions already loaded)*"]; } const result = paginateSections(sections, page ?? 1, "axme_decisions", { project_path }); @@ -363,7 +371,7 @@ server.tool( // --- axme_memories --- server.tool( "axme_memories", - "Show all project memories (feedback + patterns). Call at session start alongside axme_oracle and axme_decisions.", + "Show project memories (feedback + patterns). Output adapts to context.mode: full → titles + descriptions grouped by type; search → catalog (slug + title + 1-line description, fetch bodies via axme_get_memory). Call at session start alongside axme_oracle and axme_decisions.", { project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), page: z.number().optional().describe("Page number (1-based). Omit for first page. Follow pagination instructions if output is split."), @@ -371,21 +379,32 @@ server.tool( async ({ project_path, page }) => { const resolved = pp(project_path); deliveredContext.add("memories:" + resolved); + const config = readConfig(resolved); + + const wsAlreadyDelivered = isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath + && deliveredContext.has("memories:" + defaultWorkspacePath); + const isRepoCall = isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath; + const wsForMerge = isRepoCall && !wsAlreadyDelivered ? defaultWorkspacePath : undefined; let sections: string[]; - // If requesting repo memories and workspace memories already delivered: repo-only - if (isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath - && deliveredContext.has("memories:" + defaultWorkspacePath)) { + if (config.contextMode === "search") { + // Search mode: catalog only — bodies fetched on demand by the agent. + sections = [buildMemoriesCatalogString(resolved, wsForMerge ?? undefined)]; + if (wsAlreadyDelivered) sections.push("*(Workspace memories already loaded)*"); + const result = paginateSections(sections, page ?? 1, "axme_memories", { project_path }); + return { content: [{ type: "text" as const, text: result.text }] }; + } + + // Full mode: existing behaviour (titles + descriptions grouped by type, with workspace merge). + if (wsAlreadyDelivered) { sections = getMemorySections(resolved); if (sections.length === 0) sections = ["No repo-specific memories."]; sections.push("*(Workspace memories already loaded)*"); - } - // If requesting repo memories but workspace NOT yet delivered: merged (workspace + repo) - else if (isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath) { + } else if (wsForMerge) { const { listMemories } = await import("./storage/memory.js"); const { mergeMemories } = await import("./storage/workspace-merge.js"); - const wsMemories = listMemories(defaultWorkspacePath); + const wsMemories = listMemories(wsForMerge); const projMemories = listMemories(resolved); const merged = mergeMemories(wsMemories, projMemories); if (merged.length === 0) { @@ -402,9 +421,7 @@ server.tool( sections.push(`### Patterns (${patterns.length}):\n` + patterns.map(m => `- **${m.title}**: ${m.description}`).join("\n")); } - } - // Workspace call or single-repo: return as-is - else { + } else { sections = getMemorySections(resolved); if (sections.length === 0) { return { content: [{ type: "text" as const, text: "No memories recorded." }] }; diff --git a/src/tools/context.ts b/src/tools/context.ts index d3c6422..0b9c045 100644 --- a/src/tools/context.ts +++ b/src/tools/context.ts @@ -269,9 +269,7 @@ function buildSearchModeCatalog(projectPath: string, workspacePath?: string): st lines.push("### Decisions"); lines.push(""); for (const d of decisions) { - const enforce = d.enforce ?? "info"; - const desc = d.decision ? d.decision.replace(/\s+/g, " ").slice(0, 200) : ""; - lines.push(`- [${enforce}] **${d.id}** — ${d.title}${desc ? ` — ${desc}` : ""}`); + lines.push(renderDecisionCatalogLine(d)); } lines.push(""); } @@ -279,23 +277,83 @@ function buildSearchModeCatalog(projectPath: string, workspacePath?: string): st lines.push("### Memories"); lines.push(""); for (const m of memories) { - const desc = m.description ? m.description.replace(/\s+/g, " ").slice(0, 200) : ""; - lines.push(`- [${m.type}] **${m.slug}** — ${m.title}${desc ? ` — ${desc}` : ""}`); + lines.push(renderMemoryCatalogLine(m)); } lines.push(""); } return lines.join("\n"); } +function renderDecisionCatalogLine(d: { id: string; title: string; enforce?: string | null; decision?: string }): string { + const enforce = d.enforce ?? "info"; + const desc = d.decision ? d.decision.replace(/\s+/g, " ").slice(0, 200) : ""; + return `- [${enforce}] **${d.id}** — ${d.title}${desc ? ` — ${desc}` : ""}`; +} + +function renderMemoryCatalogLine(m: { slug: string; title: string; type: string; description?: string }): string { + const desc = m.description ? m.description.replace(/\s+/g, " ").slice(0, 200) : ""; + return `- [${m.type}] **${m.slug}** — ${m.title}${desc ? ` — ${desc}` : ""}`; +} + +/** + * Build the catalog string returned by `axme_decisions` in search mode. + * Lists all decisions (project + workspace-merged when applicable) as + * `[enforce] D-NNN — title — short description (≤200 chars)`. No bodies. + * + * Format intentionally matches page-2 of `axme_context` so the agent sees + * the same shape regardless of which entry point loaded the data. + */ +export function buildDecisionsCatalogString(projectPath: string, workspacePath?: string): string { + const decisions = listDecisionsMerged(projectPath, workspacePath); + const lines: string[] = [ + "## Decisions Catalog (search mode)", + "", + `${decisions.length} decision(s). Bodies NOT loaded — fetch via axme_get_decision(id_or_slug) or axme_search_kb(query).`, + "", + ]; + if (decisions.length === 0) { + lines.push("No decisions recorded."); + return lines.join("\n"); + } + for (const d of decisions) lines.push(renderDecisionCatalogLine(d)); + return lines.join("\n"); +} + +/** + * Build the catalog string returned by `axme_memories` in search mode. + * Same shape as decisions catalog but keyed by slug + memory type. + */ +export function buildMemoriesCatalogString(projectPath: string, workspacePath?: string): string { + const memories = listMemoriesMerged(projectPath, workspacePath); + const lines: string[] = [ + "## Memories Catalog (search mode)", + "", + `${memories.length} memory(ies). Bodies NOT loaded — fetch via axme_get_memory(slug) or axme_search_kb(query).`, + "", + ]; + if (memories.length === 0) { + lines.push("No memories recorded."); + return lines.join("\n"); + } + for (const m of memories) lines.push(renderMemoryCatalogLine(m)); + return lines.join("\n"); +} + /** * Instructions agent must follow in search mode: scan the catalog, fetch * bodies via the three new MCP tools, never write code from titles alone. + * + * The "Active KB usage" block lists concrete trigger predicates so the + * agent calls search proactively instead of relying on memory of past + * sessions. Triggers are phrased as situations the agent can recognize + * in the user's task text ("how did we ...", file/area names, library + * names) — no enforcement, but explicit MUSTs. */ function buildSearchModeInstructions(runtimeInstalled: boolean): string { const searchAvailable = runtimeInstalled ? "- `axme_search_kb(query, type?, k?)` — semantic search across both" : "- `axme_search_kb(query, ...)` — currently UNAVAILABLE (transformers runtime not installed; falls back to a hint message)"; - return [ + const lines = [ "## Search mode active — bodies fetched on demand", "", "You have a catalog of every memory and decision above (titles + descriptions only).", @@ -308,10 +366,27 @@ function buildSearchModeInstructions(runtimeInstalled: boolean): string { "- `axme_get_decision(id_or_slug)` — full body of one decision", searchAvailable, "", - runtimeInstalled - ? "Use `axme_search_kb` for fuzzy lookups (\"how did we handle X?\"). Use `axme_get_*` when you already know the slug from the catalog." - : "Without the runtime, navigate the catalog above by topic and fetch bodies via `axme_get_*`. To enable semantic search: `axme-code config set context.mode search` (re-runs install).", - ].join("\n"); + "## Active KB usage (when to call search/get)", + "", + "**MUST** call `axme_search_kb` (or `axme_get_*` when slug is known) when ANY of these triggers fire:", + "", + "- User asks \"how did we…\", \"why did we…\", \"что мы решили про…\", \"why is X this way?\" → search the topic.", + "- About to write or modify code that touches: git, safety hooks, storage, agent SDK, build, release, telemetry, auth, MCP tools → search the area first.", + "- About to suggest a fix for a bug → search similar past failures (memory type=feedback) before proposing.", + "- User mentions a library, platform, tool, or error message by name → search that name.", + "- A catalog title looks partially relevant but its 1-line description is too short to decide → fetch the body.", + "- Before any architectural recommendation or new pattern → search decisions for that subsystem to avoid contradiction or duplication.", + "- Before saving a new decision/memory → search to check if a similar one already exists (avoids dupes).", + "", + "Skipping search has caused real regressions in this project (force-pushing main, missing #!axme gate suffix,", + "duplicating an existing decision). The catalog scan is free; semantic search is sub-second and uses zero", + "API tokens (runs locally on CPU). When in doubt, search.", + ]; + lines.push(""); + lines.push(runtimeInstalled + ? "Use `axme_search_kb` for fuzzy lookups. Use `axme_get_*` when you already know the slug from the catalog." + : "Runtime not installed: navigate the catalog above by topic and fetch bodies via `axme_get_*`. To enable semantic search: `axme-code config set context.mode search` (re-runs install)."); + return lines.join("\n"); } /** Legacy joined output (for backward compat where needed). */ diff --git a/test/context.test.ts b/test/context.test.ts index 9f772b7..627c68e 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -6,6 +6,8 @@ import { getFullContextSections, getFullContext, getCloseContext, + buildDecisionsCatalogString, + buildMemoriesCatalogString, } from "../src/tools/context.js"; const TEST_ROOT = "/tmp/axme-context-test"; @@ -93,3 +95,76 @@ describe("uninitialized project", () => { assert.ok(joined.toLowerCase().includes("not initialized")); }); }); + +describe("search-mode catalog rendering", () => { + function setupSearchMode() { + setupTestProject(); + const axme = join(PROJECT, ".axme-code"); + // Switch project to search mode + add a memory so both tools have data. + writeFileSync(join(axme, "config.yaml"), + "model: claude-sonnet-4-6\npresets:\n - essential-safety\ncontext:\n mode: search\n"); + writeFileSync(join(axme, "memory", "feedback", "test-memo.md"), + `---\nslug: test-memo\ntype: feedback\ntitle: Test memo\nsource: manual\ndate: "2026-04-01"\nkeywords: [test]\n---\n\n# Test memo\n\nA short description that should appear in the catalog row.\n\n## Details\n\nLong body that must NOT appear in the catalog string.\n`); + } + + it("buildDecisionsCatalogString renders id + title + short description, no body", () => { + setupSearchMode(); + const out = buildDecisionsCatalogString(PROJECT); + assert.ok(out.includes("Decisions Catalog (search mode)")); + assert.ok(out.includes("D-001")); + assert.ok(out.includes("Test decision")); + assert.ok(out.includes("axme_get_decision")); + // Decision body line is short here ("Test decision body.") so we just assert + // the id+title shape is present and the announcement says bodies are not loaded. + assert.ok(out.includes("Bodies NOT loaded")); + }); + + it("buildDecisionsCatalogString reports empty when no decisions", () => { + setupTestProject(); + rmSync(join(PROJECT, ".axme-code", "decisions", "D-001-test-decision.md")); + writeFileSync(join(PROJECT, ".axme-code", "decisions", "index.md"), "# Decisions\n"); + const out = buildDecisionsCatalogString(PROJECT); + assert.ok(out.includes("No decisions recorded.")); + }); + + it("buildMemoriesCatalogString renders slug + title + description, no body", () => { + setupSearchMode(); + const out = buildMemoriesCatalogString(PROJECT); + assert.ok(out.includes("Memories Catalog (search mode)")); + assert.ok(out.includes("test-memo")); + assert.ok(out.includes("Test memo")); + assert.ok(out.includes("axme_get_memory")); + // Body content must be excluded — only description appears + assert.ok(!out.includes("Long body that must NOT appear")); + }); + + it("buildMemoriesCatalogString reports empty when no memories", () => { + setupTestProject(); + const out = buildMemoriesCatalogString(PROJECT); + assert.ok(out.includes("No memories recorded.")); + }); + + it("getFullContextSections in search mode emits Active KB usage block with triggers", () => { + setupSearchMode(); + const sections = getFullContextSections(PROJECT); + const joined = sections.join("\n"); + assert.ok(joined.includes("Search mode active")); + assert.ok(joined.includes("Active KB usage")); + // Concrete trigger predicates we promised to surface + assert.ok(joined.includes("how did we")); + assert.ok(joined.includes("axme_search_kb")); + assert.ok(joined.includes("axme_get_memory")); + assert.ok(joined.includes("axme_get_decision")); + // Full-mode load instruction must NOT appear in search mode + assert.ok(!joined.includes("Load Full Knowledge Base")); + }); + + it("getFullContextSections in full mode does NOT emit search-mode catalog or instructions", () => { + setupTestProject(); + const sections = getFullContextSections(PROJECT); + const joined = sections.join("\n"); + assert.ok(joined.includes("Load Full Knowledge Base")); + assert.ok(!joined.includes("Search mode active")); + assert.ok(!joined.includes("Active KB usage")); + }); +});