Skip to content

Commit 754eec7

Browse files
authored
Merge pull request #124 from AxmeAI/feat/semantic-search-mcp-tool-20260429
feat(search): semantic search MCP tools + tiered context mode (B-005 Phase 2)
2 parents 9279b70 + 4b5620c commit 754eec7

10 files changed

Lines changed: 1270 additions & 11 deletions

File tree

src/cli.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,89 @@ Do NOT skip — without context you will miss critical project rules.
796796
process.exit(1);
797797
}
798798

799+
case "config": {
800+
// axme-code config get <key>
801+
// axme-code config set <key> <value>
802+
// Currently supported keys: context.mode (full|search). The set path
803+
// for context.mode = search atomically installs the transformers
804+
// runtime + builds the initial embeddings index, and rolls the
805+
// config back to "full" if either step fails (D-136 / B-005 design).
806+
const sub = args[1];
807+
const key = args[2];
808+
const value = args[3];
809+
const projectPath = resolve(".");
810+
const { readConfig: rc, writeConfig: wc } = await import("./storage/config.js");
811+
812+
if (sub === "get") {
813+
if (!key) {
814+
console.error("usage: axme-code config get <key> (e.g. context.mode)");
815+
process.exit(1);
816+
}
817+
const cfg = rc(projectPath);
818+
if (key === "context.mode") console.log(cfg.contextMode);
819+
else if (key === "model") console.log(cfg.model);
820+
else if (key === "auditor_model") console.log(cfg.auditorModel);
821+
else if (key === "review_enabled") console.log(String(cfg.reviewEnabled));
822+
else { console.error(`Unknown config key: ${key}`); process.exit(1); }
823+
break;
824+
}
825+
826+
if (sub === "set") {
827+
if (!key || value === undefined) {
828+
console.error("usage: axme-code config set <key> <value>");
829+
process.exit(1);
830+
}
831+
if (key !== "context.mode") {
832+
console.error(`Set is currently supported only for context.mode. Got: ${key}`);
833+
process.exit(1);
834+
}
835+
if (value !== "full" && value !== "search") {
836+
console.error(`context.mode must be 'full' or 'search'. Got: ${value}`);
837+
process.exit(1);
838+
}
839+
const cfg = rc(projectPath);
840+
const prevMode = cfg.contextMode;
841+
842+
if (value === "full") {
843+
wc(projectPath, { ...cfg, contextMode: "full" });
844+
console.log("Saved: context.mode = full");
845+
break;
846+
}
847+
848+
// value === "search" — install runtime if missing, then reindex.
849+
const { runConfigSetSearch } = await import("./tools/search-install.js");
850+
const result = await runConfigSetSearch(projectPath);
851+
if (result.ok) {
852+
wc(projectPath, { ...cfg, contextMode: "search" });
853+
console.log(`Saved: context.mode = search (indexed ${result.indexed} entries)`);
854+
} else {
855+
// Rollback config to whatever it was before — we never changed it
856+
// in the failure path, but be explicit so future edits don't drop
857+
// this guarantee.
858+
wc(projectPath, { ...cfg, contextMode: prevMode });
859+
console.error(`\nFailed to enable search mode: ${result.error}`);
860+
console.error(`Config left at context.mode = ${prevMode}.`);
861+
process.exit(1);
862+
}
863+
break;
864+
}
865+
866+
console.error("Unknown 'config' subcommand. Available: get <key>, set <key> <value>");
867+
process.exit(1);
868+
}
869+
870+
case "reindex": {
871+
// Rebuild the embeddings index from every memory + decision on disk.
872+
// No-op if context.mode = full and runtime is missing — caller should
873+
// run `axme-code config set context.mode search` first.
874+
const projectPath = resolve(args[1] || ".");
875+
const { reindexAll } = await import("./tools/search-install.js");
876+
const result = await reindexAll(projectPath);
877+
if (result.ok) console.log(`Reindexed ${result.indexed} entries.`);
878+
else { console.error(`Reindex failed: ${result.error}`); process.exit(1); }
879+
break;
880+
}
881+
799882
case "help":
800883
case "--help":
801884
case "-h":

src/server.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import { saveMemoryTool } from "./tools/memory-tools.js";
2222
import { saveDecisionTool } from "./tools/decision-tools.js";
2323
import { updateSafetyTool, showSafetyTool } from "./tools/safety-tools.js";
2424
import { statusTool, worklogTool } from "./tools/status.js";
25+
import { getMemoryTool, getDecisionTool, searchKbTool } from "./tools/kb-search.js";
26+
import { embedKbEntry } from "./storage/embeddings.js";
27+
import { readConfig } from "./storage/config.js";
2528
import { detectWorkspace } from "./utils/workspace-detector.js";
2629
import {
2730
findOrphanSessions,
@@ -432,6 +435,10 @@ server.tool(
432435
const sid = getOwnedSessionIdForLogging();
433436
const resolved = ppWithScope(project_path, scope);
434437
const result = saveMemoryTool(resolved, { type, title, description, body, keywords, scope }, sid);
438+
// Update the embeddings index when search mode is on. Awaited so the
439+
// index is consistent on return; ~50-200ms once the embedder is warm.
440+
// Skips silently in full mode and on missing runtime.
441+
await embedKbEntry(resolved, result.slug, "memory", title, description, readConfig(resolved).contextMode);
435442
return { content: [{ type: "text" as const, text: `Memory saved: ${result.slug} (${type}) -> ${resolved}` }] };
436443
},
437444
);
@@ -452,6 +459,9 @@ server.tool(
452459

453460
const resolved = ppWithScope(project_path, scope);
454461
const result = saveDecisionTool(resolved, { title, decision, reasoning, enforce, scope });
462+
// Use decision text as description so the search index returns hits
463+
// ranked by the actual rule, not just the title.
464+
await embedKbEntry(resolved, result.id, "decision", title, decision, readConfig(resolved).contextMode);
455465
return { content: [{ type: "text" as const, text: `Decision saved: ${result.id} - ${title} -> ${resolved}` }] };
456466
},
457467
);
@@ -485,6 +495,48 @@ server.tool(
485495
},
486496
);
487497

498+
// --- axme_get_memory ---
499+
server.tool(
500+
"axme_get_memory",
501+
"Fetch the full body of one memory by slug. Use after seeing the slug in axme_context (search mode catalog) or axme_search_kb results.",
502+
{
503+
project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"),
504+
slug: z.string().describe("Memory slug, e.g. 'always-call-axme-context-first'"),
505+
},
506+
async ({ project_path, slug }) => {
507+
return { content: [{ type: "text" as const, text: getMemoryTool(pp(project_path), slug) }] };
508+
},
509+
);
510+
511+
// --- axme_get_decision ---
512+
server.tool(
513+
"axme_get_decision",
514+
"Fetch the full body of one decision by ID (e.g. 'D-110') or slug. Use after seeing it in axme_context (search mode catalog) or axme_search_kb results.",
515+
{
516+
project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"),
517+
id_or_slug: z.string().describe("Decision ID like 'D-110' or its slug"),
518+
},
519+
async ({ project_path, id_or_slug }) => {
520+
return { content: [{ type: "text" as const, text: getDecisionTool(pp(project_path), id_or_slug) }] };
521+
},
522+
);
523+
524+
// --- axme_search_kb ---
525+
server.tool(
526+
"axme_search_kb",
527+
"Semantic search across memories and decisions. Useful for fuzzy lookups mid-session ('how did we handle X?'). Requires the embeddings runtime — install with `axme-code config set context.mode search`.",
528+
{
529+
project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"),
530+
query: z.string().describe("Search query in natural language"),
531+
k: z.number().int().min(1).max(50).optional().describe("Top K results to return (default 5, max 50)"),
532+
type: z.enum(["memory", "decision"]).optional().describe("Filter results to one type. Omit to search both."),
533+
},
534+
async ({ project_path, query, k, type }) => {
535+
const text = await searchKbTool(pp(project_path), { query, k, type });
536+
return { content: [{ type: "text" as const, text }] };
537+
},
538+
);
539+
488540
// --- axme_backlog ---
489541
server.tool(
490542
"axme_backlog",

src/storage/config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,27 @@ function formatConfig(config: ProjectConfig): string {
4949
`presets:`,
5050
...config.presets.map(p => ` - ${p}`),
5151
"",
52+
"# Context-loading mode at session start.",
53+
"# full — every memory and decision body loaded (default; best for KBs <=100 entries)",
54+
"# search — only catalog loaded, bodies fetched via axme_get_memory / axme_get_decision /",
55+
"# axme_search_kb. Recommended for KBs >100 entries. Requires embeddings runtime,",
56+
"# installed by: axme-code config set context.mode search",
57+
"context:",
58+
` mode: ${config.contextMode}`,
59+
"",
5260
].join("\n");
5361
}
5462

5563
function parseConfig(content: string): ProjectConfig {
5664
const doc = yaml.load(content) as Record<string, any> | null;
5765
if (!doc || typeof doc !== "object") return { ...DEFAULT_PROJECT_CONFIG };
5866

67+
let contextMode: "full" | "search" = DEFAULT_PROJECT_CONFIG.contextMode;
68+
const ctxRaw = doc.context;
69+
if (ctxRaw && typeof ctxRaw === "object" && (ctxRaw.mode === "full" || ctxRaw.mode === "search")) {
70+
contextMode = ctxRaw.mode;
71+
}
72+
5973
return {
6074
model: String(doc.model ?? DEFAULT_PROJECT_CONFIG.model),
6175
auditorModel: String(doc.auditor_model ?? DEFAULT_PROJECT_CONFIG.auditorModel),
@@ -71,5 +85,6 @@ function parseConfig(content: string): ProjectConfig {
7185
return true; // keep all, just warn
7286
})
7387
: DEFAULT_PROJECT_CONFIG.presets,
88+
contextMode,
7489
};
7590
}

0 commit comments

Comments
 (0)