Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
51 changes: 34 additions & 17 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -341,18 +341,26 @@ 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."),
},
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 });
Expand All @@ -363,29 +371,40 @@ 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."),
},
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) {
Expand All @@ -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." }] };
Expand Down
95 changes: 85 additions & 10 deletions src/tools/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,33 +269,91 @@ 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("");
}
if (memories.length > 0) {
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).",
Expand All @@ -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). */
Expand Down
75 changes: 75 additions & 0 deletions test/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
getFullContextSections,
getFullContext,
getCloseContext,
buildDecisionsCatalogString,
buildMemoriesCatalogString,
} from "../src/tools/context.js";

const TEST_ROOT = "/tmp/axme-context-test";
Expand Down Expand Up @@ -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"));
});
});
Loading