Skip to content

Commit 03d0994

Browse files
authored
Merge pull request #127 from AxmeAI/feat/mode-aware-decisions-memories-20260429
feat(context): mode-aware axme_decisions/axme_memories + active KB triggers
2 parents 2a99ee8 + 9294336 commit 03d0994

4 files changed

Lines changed: 205 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Changed
6+
7+
- **`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.
8+
- **`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.
9+
10+
### Fixed
11+
12+
- **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).
13+
314
## [0.5.0] - 2026-04-29
415

516
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).

src/server.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1313
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1414
import { z } from "zod";
1515

16-
import { getFullContextSections, getOracle, getDecisions } from "./tools/context.js";
16+
import { getFullContextSections, getOracle, getDecisions, buildDecisionsCatalogString, buildMemoriesCatalogString } from "./tools/context.js";
1717
import { allMemoryContext, getMemorySections } from "./storage/memory.js";
1818
import { getOracleSections } from "./storage/oracle.js";
1919
import { getDecisionSections } from "./storage/decisions.js";
@@ -341,18 +341,26 @@ server.tool(
341341
// --- axme_decisions ---
342342
server.tool(
343343
"axme_decisions",
344-
"Show all project decisions with enforce levels.",
344+
"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).",
345345
{
346346
project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"),
347347
page: z.number().optional().describe("Page number (1-based). Omit for first page. Follow pagination instructions if output is split."),
348348
},
349349
async ({ project_path, page }) => {
350350
const resolved = pp(project_path);
351351
deliveredContext.add("decisions:" + resolved);
352-
let sections = getDecisionSections(resolved);
353-
// If requesting repo decisions and workspace decisions already delivered, return repo-only
354-
if (isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath
355-
&& deliveredContext.has("decisions:" + defaultWorkspacePath)) {
352+
const config = readConfig(resolved);
353+
const wsAlreadyDelivered = isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath
354+
&& deliveredContext.has("decisions:" + defaultWorkspacePath);
355+
let sections: string[];
356+
if (config.contextMode === "search") {
357+
const wsForMerge = isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath && !wsAlreadyDelivered
358+
? defaultWorkspacePath : undefined;
359+
sections = [buildDecisionsCatalogString(resolved, wsForMerge)];
360+
} else {
361+
sections = getDecisionSections(resolved);
362+
}
363+
if (wsAlreadyDelivered) {
356364
sections = [...sections, "*(Workspace decisions already loaded)*"];
357365
}
358366
const result = paginateSections(sections, page ?? 1, "axme_decisions", { project_path });
@@ -363,29 +371,40 @@ server.tool(
363371
// --- axme_memories ---
364372
server.tool(
365373
"axme_memories",
366-
"Show all project memories (feedback + patterns). Call at session start alongside axme_oracle and axme_decisions.",
374+
"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.",
367375
{
368376
project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"),
369377
page: z.number().optional().describe("Page number (1-based). Omit for first page. Follow pagination instructions if output is split."),
370378
},
371379
async ({ project_path, page }) => {
372380
const resolved = pp(project_path);
373381
deliveredContext.add("memories:" + resolved);
382+
const config = readConfig(resolved);
383+
384+
const wsAlreadyDelivered = isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath
385+
&& deliveredContext.has("memories:" + defaultWorkspacePath);
386+
const isRepoCall = isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath;
387+
const wsForMerge = isRepoCall && !wsAlreadyDelivered ? defaultWorkspacePath : undefined;
374388

375389
let sections: string[];
376390

377-
// If requesting repo memories and workspace memories already delivered: repo-only
378-
if (isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath
379-
&& deliveredContext.has("memories:" + defaultWorkspacePath)) {
391+
if (config.contextMode === "search") {
392+
// Search mode: catalog only — bodies fetched on demand by the agent.
393+
sections = [buildMemoriesCatalogString(resolved, wsForMerge ?? undefined)];
394+
if (wsAlreadyDelivered) sections.push("*(Workspace memories already loaded)*");
395+
const result = paginateSections(sections, page ?? 1, "axme_memories", { project_path });
396+
return { content: [{ type: "text" as const, text: result.text }] };
397+
}
398+
399+
// Full mode: existing behaviour (titles + descriptions grouped by type, with workspace merge).
400+
if (wsAlreadyDelivered) {
380401
sections = getMemorySections(resolved);
381402
if (sections.length === 0) sections = ["No repo-specific memories."];
382403
sections.push("*(Workspace memories already loaded)*");
383-
}
384-
// If requesting repo memories but workspace NOT yet delivered: merged (workspace + repo)
385-
else if (isWorkspace && defaultWorkspacePath && resolved !== defaultWorkspacePath) {
404+
} else if (wsForMerge) {
386405
const { listMemories } = await import("./storage/memory.js");
387406
const { mergeMemories } = await import("./storage/workspace-merge.js");
388-
const wsMemories = listMemories(defaultWorkspacePath);
407+
const wsMemories = listMemories(wsForMerge);
389408
const projMemories = listMemories(resolved);
390409
const merged = mergeMemories(wsMemories, projMemories);
391410
if (merged.length === 0) {
@@ -402,9 +421,7 @@ server.tool(
402421
sections.push(`### Patterns (${patterns.length}):\n` +
403422
patterns.map(m => `- **${m.title}**: ${m.description}`).join("\n"));
404423
}
405-
}
406-
// Workspace call or single-repo: return as-is
407-
else {
424+
} else {
408425
sections = getMemorySections(resolved);
409426
if (sections.length === 0) {
410427
return { content: [{ type: "text" as const, text: "No memories recorded." }] };

src/tools/context.ts

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -269,33 +269,91 @@ function buildSearchModeCatalog(projectPath: string, workspacePath?: string): st
269269
lines.push("### Decisions");
270270
lines.push("");
271271
for (const d of decisions) {
272-
const enforce = d.enforce ?? "info";
273-
const desc = d.decision ? d.decision.replace(/\s+/g, " ").slice(0, 200) : "";
274-
lines.push(`- [${enforce}] **${d.id}** — ${d.title}${desc ? ` — ${desc}` : ""}`);
272+
lines.push(renderDecisionCatalogLine(d));
275273
}
276274
lines.push("");
277275
}
278276
if (memories.length > 0) {
279277
lines.push("### Memories");
280278
lines.push("");
281279
for (const m of memories) {
282-
const desc = m.description ? m.description.replace(/\s+/g, " ").slice(0, 200) : "";
283-
lines.push(`- [${m.type}] **${m.slug}** — ${m.title}${desc ? ` — ${desc}` : ""}`);
280+
lines.push(renderMemoryCatalogLine(m));
284281
}
285282
lines.push("");
286283
}
287284
return lines.join("\n");
288285
}
289286

287+
function renderDecisionCatalogLine(d: { id: string; title: string; enforce?: string | null; decision?: string }): string {
288+
const enforce = d.enforce ?? "info";
289+
const desc = d.decision ? d.decision.replace(/\s+/g, " ").slice(0, 200) : "";
290+
return `- [${enforce}] **${d.id}** — ${d.title}${desc ? ` — ${desc}` : ""}`;
291+
}
292+
293+
function renderMemoryCatalogLine(m: { slug: string; title: string; type: string; description?: string }): string {
294+
const desc = m.description ? m.description.replace(/\s+/g, " ").slice(0, 200) : "";
295+
return `- [${m.type}] **${m.slug}** — ${m.title}${desc ? ` — ${desc}` : ""}`;
296+
}
297+
298+
/**
299+
* Build the catalog string returned by `axme_decisions` in search mode.
300+
* Lists all decisions (project + workspace-merged when applicable) as
301+
* `[enforce] D-NNN — title — short description (≤200 chars)`. No bodies.
302+
*
303+
* Format intentionally matches page-2 of `axme_context` so the agent sees
304+
* the same shape regardless of which entry point loaded the data.
305+
*/
306+
export function buildDecisionsCatalogString(projectPath: string, workspacePath?: string): string {
307+
const decisions = listDecisionsMerged(projectPath, workspacePath);
308+
const lines: string[] = [
309+
"## Decisions Catalog (search mode)",
310+
"",
311+
`${decisions.length} decision(s). Bodies NOT loaded — fetch via axme_get_decision(id_or_slug) or axme_search_kb(query).`,
312+
"",
313+
];
314+
if (decisions.length === 0) {
315+
lines.push("No decisions recorded.");
316+
return lines.join("\n");
317+
}
318+
for (const d of decisions) lines.push(renderDecisionCatalogLine(d));
319+
return lines.join("\n");
320+
}
321+
322+
/**
323+
* Build the catalog string returned by `axme_memories` in search mode.
324+
* Same shape as decisions catalog but keyed by slug + memory type.
325+
*/
326+
export function buildMemoriesCatalogString(projectPath: string, workspacePath?: string): string {
327+
const memories = listMemoriesMerged(projectPath, workspacePath);
328+
const lines: string[] = [
329+
"## Memories Catalog (search mode)",
330+
"",
331+
`${memories.length} memory(ies). Bodies NOT loaded — fetch via axme_get_memory(slug) or axme_search_kb(query).`,
332+
"",
333+
];
334+
if (memories.length === 0) {
335+
lines.push("No memories recorded.");
336+
return lines.join("\n");
337+
}
338+
for (const m of memories) lines.push(renderMemoryCatalogLine(m));
339+
return lines.join("\n");
340+
}
341+
290342
/**
291343
* Instructions agent must follow in search mode: scan the catalog, fetch
292344
* bodies via the three new MCP tools, never write code from titles alone.
345+
*
346+
* The "Active KB usage" block lists concrete trigger predicates so the
347+
* agent calls search proactively instead of relying on memory of past
348+
* sessions. Triggers are phrased as situations the agent can recognize
349+
* in the user's task text ("how did we ...", file/area names, library
350+
* names) — no enforcement, but explicit MUSTs.
293351
*/
294352
function buildSearchModeInstructions(runtimeInstalled: boolean): string {
295353
const searchAvailable = runtimeInstalled
296354
? "- `axme_search_kb(query, type?, k?)` — semantic search across both"
297355
: "- `axme_search_kb(query, ...)` — currently UNAVAILABLE (transformers runtime not installed; falls back to a hint message)";
298-
return [
356+
const lines = [
299357
"## Search mode active — bodies fetched on demand",
300358
"",
301359
"You have a catalog of every memory and decision above (titles + descriptions only).",
@@ -308,10 +366,27 @@ function buildSearchModeInstructions(runtimeInstalled: boolean): string {
308366
"- `axme_get_decision(id_or_slug)` — full body of one decision",
309367
searchAvailable,
310368
"",
311-
runtimeInstalled
312-
? "Use `axme_search_kb` for fuzzy lookups (\"how did we handle X?\"). Use `axme_get_*` when you already know the slug from the catalog."
313-
: "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).",
314-
].join("\n");
369+
"## Active KB usage (when to call search/get)",
370+
"",
371+
"**MUST** call `axme_search_kb` (or `axme_get_*` when slug is known) when ANY of these triggers fire:",
372+
"",
373+
"- User asks \"how did we…\", \"why did we…\", \"что мы решили про…\", \"why is X this way?\" → search the topic.",
374+
"- About to write or modify code that touches: git, safety hooks, storage, agent SDK, build, release, telemetry, auth, MCP tools → search the area first.",
375+
"- About to suggest a fix for a bug → search similar past failures (memory type=feedback) before proposing.",
376+
"- User mentions a library, platform, tool, or error message by name → search that name.",
377+
"- A catalog title looks partially relevant but its 1-line description is too short to decide → fetch the body.",
378+
"- Before any architectural recommendation or new pattern → search decisions for that subsystem to avoid contradiction or duplication.",
379+
"- Before saving a new decision/memory → search to check if a similar one already exists (avoids dupes).",
380+
"",
381+
"Skipping search has caused real regressions in this project (force-pushing main, missing #!axme gate suffix,",
382+
"duplicating an existing decision). The catalog scan is free; semantic search is sub-second and uses zero",
383+
"API tokens (runs locally on CPU). When in doubt, search.",
384+
];
385+
lines.push("");
386+
lines.push(runtimeInstalled
387+
? "Use `axme_search_kb` for fuzzy lookups. Use `axme_get_*` when you already know the slug from the catalog."
388+
: "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).");
389+
return lines.join("\n");
315390
}
316391

317392
/** Legacy joined output (for backward compat where needed). */

test/context.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
getFullContextSections,
77
getFullContext,
88
getCloseContext,
9+
buildDecisionsCatalogString,
10+
buildMemoriesCatalogString,
911
} from "../src/tools/context.js";
1012

1113
const TEST_ROOT = "/tmp/axme-context-test";
@@ -93,3 +95,76 @@ describe("uninitialized project", () => {
9395
assert.ok(joined.toLowerCase().includes("not initialized"));
9496
});
9597
});
98+
99+
describe("search-mode catalog rendering", () => {
100+
function setupSearchMode() {
101+
setupTestProject();
102+
const axme = join(PROJECT, ".axme-code");
103+
// Switch project to search mode + add a memory so both tools have data.
104+
writeFileSync(join(axme, "config.yaml"),
105+
"model: claude-sonnet-4-6\npresets:\n - essential-safety\ncontext:\n mode: search\n");
106+
writeFileSync(join(axme, "memory", "feedback", "test-memo.md"),
107+
`---\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`);
108+
}
109+
110+
it("buildDecisionsCatalogString renders id + title + short description, no body", () => {
111+
setupSearchMode();
112+
const out = buildDecisionsCatalogString(PROJECT);
113+
assert.ok(out.includes("Decisions Catalog (search mode)"));
114+
assert.ok(out.includes("D-001"));
115+
assert.ok(out.includes("Test decision"));
116+
assert.ok(out.includes("axme_get_decision"));
117+
// Decision body line is short here ("Test decision body.") so we just assert
118+
// the id+title shape is present and the announcement says bodies are not loaded.
119+
assert.ok(out.includes("Bodies NOT loaded"));
120+
});
121+
122+
it("buildDecisionsCatalogString reports empty when no decisions", () => {
123+
setupTestProject();
124+
rmSync(join(PROJECT, ".axme-code", "decisions", "D-001-test-decision.md"));
125+
writeFileSync(join(PROJECT, ".axme-code", "decisions", "index.md"), "# Decisions\n");
126+
const out = buildDecisionsCatalogString(PROJECT);
127+
assert.ok(out.includes("No decisions recorded."));
128+
});
129+
130+
it("buildMemoriesCatalogString renders slug + title + description, no body", () => {
131+
setupSearchMode();
132+
const out = buildMemoriesCatalogString(PROJECT);
133+
assert.ok(out.includes("Memories Catalog (search mode)"));
134+
assert.ok(out.includes("test-memo"));
135+
assert.ok(out.includes("Test memo"));
136+
assert.ok(out.includes("axme_get_memory"));
137+
// Body content must be excluded — only description appears
138+
assert.ok(!out.includes("Long body that must NOT appear"));
139+
});
140+
141+
it("buildMemoriesCatalogString reports empty when no memories", () => {
142+
setupTestProject();
143+
const out = buildMemoriesCatalogString(PROJECT);
144+
assert.ok(out.includes("No memories recorded."));
145+
});
146+
147+
it("getFullContextSections in search mode emits Active KB usage block with triggers", () => {
148+
setupSearchMode();
149+
const sections = getFullContextSections(PROJECT);
150+
const joined = sections.join("\n");
151+
assert.ok(joined.includes("Search mode active"));
152+
assert.ok(joined.includes("Active KB usage"));
153+
// Concrete trigger predicates we promised to surface
154+
assert.ok(joined.includes("how did we"));
155+
assert.ok(joined.includes("axme_search_kb"));
156+
assert.ok(joined.includes("axme_get_memory"));
157+
assert.ok(joined.includes("axme_get_decision"));
158+
// Full-mode load instruction must NOT appear in search mode
159+
assert.ok(!joined.includes("Load Full Knowledge Base"));
160+
});
161+
162+
it("getFullContextSections in full mode does NOT emit search-mode catalog or instructions", () => {
163+
setupTestProject();
164+
const sections = getFullContextSections(PROJECT);
165+
const joined = sections.join("\n");
166+
assert.ok(joined.includes("Load Full Knowledge Base"));
167+
assert.ok(!joined.includes("Search mode active"));
168+
assert.ok(!joined.includes("Active KB usage"));
169+
});
170+
});

0 commit comments

Comments
 (0)