@rohirik/openltm-core 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@rohirik/openltm-core",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Shared LTM storage engine — path-agnostic SQLite core used by Claude Code, OpenCode, and Pi adapters",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
- "./cli": "./src/cli/index.ts"
9
+ "./cli": "./src/cli/index.ts",
10
+ "./mcp": "./src/mcp/server.ts"
10
11
  },
11
12
  "bin": {
12
13
  "ltm": "src/cli/bin.ts",
@@ -31,8 +32,10 @@
31
32
  "dependencies": {
32
33
  "@clack/prompts": "^1.3.0",
33
34
  "@iarna/toml": "^2.2.5",
35
+ "@modelcontextprotocol/sdk": "^1.27.1",
34
36
  "bun-types": "^1.0.0",
35
- "sqlite-vec": "0.1.9"
37
+ "sqlite-vec": "0.1.9",
38
+ "zod": "^4.3.6"
36
39
  },
37
40
  "optionalDependencies": {
38
41
  "@russellthehippo/honker-bun": "^0.2.2"
@@ -0,0 +1,186 @@
1
+ /**
2
+ * memory.test.ts — unit tests for packages/openltm-core/src/cli/memory.ts
3
+ *
4
+ * RED phase: written before implementation. Defines the contract for the
5
+ * `ltm memory <learn|recall|forget|relate|context>` CLI surface that gives
6
+ * headless/CLI agents a direct path to LTM without the agent TUI.
7
+ */
8
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "fs";
10
+ import { tmpdir } from "os";
11
+ import { join } from "path";
12
+ import { Database } from "bun:sqlite";
13
+
14
+ // ── Arg parsing (pure) ────────────────────────────────────────────────────────
15
+
16
+ describe("cli/memory — parseMemoryArgs", () => {
17
+ it("parses learn with all flags", async () => {
18
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
19
+ const parsed = parseMemoryArgs([
20
+ "learn",
21
+ "--text", "Use WAL mode for SQLite",
22
+ "--title", "SQLite WAL",
23
+ "--category", "pattern",
24
+ "--importance", "4",
25
+ "--project", "homelab",
26
+ "--tags", "sqlite,db",
27
+ "--json",
28
+ ]);
29
+ expect(parsed.ok).toBe(true);
30
+ if (!parsed.ok) return;
31
+ expect(parsed.command).toBe("learn");
32
+ expect(parsed.options["text"]).toBe("Use WAL mode for SQLite");
33
+ expect(parsed.options["title"]).toBe("SQLite WAL");
34
+ expect(parsed.options["category"]).toBe("pattern");
35
+ expect(parsed.options["importance"]).toBe(4);
36
+ expect(parsed.options["project"]).toBe("homelab");
37
+ expect(parsed.options["tags"]).toEqual(["sqlite", "db"]);
38
+ expect(parsed.json).toBe(true);
39
+ });
40
+
41
+ it("learn without --text is a usage error", async () => {
42
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
43
+ const parsed = parseMemoryArgs(["learn", "--category", "pattern"]);
44
+ expect(parsed.ok).toBe(false);
45
+ if (parsed.ok) return;
46
+ expect(parsed.error).toContain("--text");
47
+ });
48
+
49
+ it("learn rejects invalid category", async () => {
50
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
51
+ const parsed = parseMemoryArgs(["learn", "--text", "x", "--category", "bogus"]);
52
+ expect(parsed.ok).toBe(false);
53
+ if (parsed.ok) return;
54
+ expect(parsed.error).toContain("category");
55
+ });
56
+
57
+ it("learn rejects importance outside 1-5", async () => {
58
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
59
+ const parsed = parseMemoryArgs(["learn", "--text", "x", "--importance", "9"]);
60
+ expect(parsed.ok).toBe(false);
61
+ if (parsed.ok) return;
62
+ expect(parsed.error).toContain("importance");
63
+ });
64
+
65
+ it("parses recall with query/limit/project", async () => {
66
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
67
+ const parsed = parseMemoryArgs(["recall", "--query", "docker", "--limit", "5", "--project", "homelab"]);
68
+ expect(parsed.ok).toBe(true);
69
+ if (!parsed.ok) return;
70
+ expect(parsed.command).toBe("recall");
71
+ expect(parsed.options["query"]).toBe("docker");
72
+ expect(parsed.options["limit"]).toBe(5);
73
+ });
74
+
75
+ it("forget requires numeric --id", async () => {
76
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
77
+ expect(parseMemoryArgs(["forget"]).ok).toBe(false);
78
+ expect(parseMemoryArgs(["forget", "--id", "abc"]).ok).toBe(false);
79
+ const parsed = parseMemoryArgs(["forget", "--id", "12", "--reason", "stale"]);
80
+ expect(parsed.ok).toBe(true);
81
+ if (!parsed.ok) return;
82
+ expect(parsed.options["id"]).toBe(12);
83
+ });
84
+
85
+ it("relate requires --from --to --type with valid relationship", async () => {
86
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
87
+ expect(parseMemoryArgs(["relate", "--from", "1"]).ok).toBe(false);
88
+ expect(parseMemoryArgs(["relate", "--from", "1", "--to", "2", "--type", "nope"]).ok).toBe(false);
89
+ const parsed = parseMemoryArgs(["relate", "--from", "1", "--to", "2", "--type", "supports"]);
90
+ expect(parsed.ok).toBe(true);
91
+ });
92
+
93
+ it("context requires --project", async () => {
94
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
95
+ expect(parseMemoryArgs(["context"]).ok).toBe(false);
96
+ expect(parseMemoryArgs(["context", "--project", "homelab"]).ok).toBe(true);
97
+ });
98
+
99
+ it("unknown memory subcommand is a usage error", async () => {
100
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
101
+ const parsed = parseMemoryArgs(["destroy-everything"]);
102
+ expect(parsed.ok).toBe(false);
103
+ });
104
+
105
+ it("no subcommand is a usage error", async () => {
106
+ const { parseMemoryArgs } = await import("../../cli/memory.js");
107
+ expect(parseMemoryArgs([]).ok).toBe(false);
108
+ });
109
+ });
110
+
111
+ // ── Round-trip against temp DB ────────────────────────────────────────────────
112
+
113
+ describe("cli/memory — runMemoryCommand round-trip", () => {
114
+ let dir: string;
115
+
116
+ beforeAll(async () => {
117
+ dir = mkdtempSync(join(tmpdir(), "ltm-cli-test-"));
118
+ const dbPath = join(dir, "test.db");
119
+ const { initDb, _setDbForTesting } = await import("../../shared-db.js");
120
+ const db = await initDb({ dbPath });
121
+ _setDbForTesting(db as Database);
122
+ });
123
+
124
+ afterAll(() => {
125
+ rmSync(dir, { recursive: true, force: true });
126
+ });
127
+
128
+ it("learn then recall returns the stored memory", async () => {
129
+ const { parseMemoryArgs, runMemoryCommand } = await import("../../cli/memory.js");
130
+ const learnParsed = parseMemoryArgs([
131
+ "learn", "--text", "CLI round trip memory about traefik routing", "--category", "gotcha", "--json",
132
+ ]);
133
+ expect(learnParsed.ok).toBe(true);
134
+ if (!learnParsed.ok) return;
135
+ const learnRes = await runMemoryCommand(learnParsed);
136
+ expect(learnRes.exitCode).toBe(0);
137
+ const learnOut = JSON.parse(learnRes.output);
138
+ expect(typeof learnOut.id).toBe("number");
139
+ expect(["created", "reinforced"]).toContain(learnOut.action);
140
+
141
+ const recallParsed = parseMemoryArgs(["recall", "--query", "traefik routing", "--json"]);
142
+ expect(recallParsed.ok).toBe(true);
143
+ if (!recallParsed.ok) return;
144
+ const recallRes = await runMemoryCommand(recallParsed);
145
+ expect(recallRes.exitCode).toBe(0);
146
+ const rows = JSON.parse(recallRes.output);
147
+ expect(Array.isArray(rows)).toBe(true);
148
+ expect(rows.some((r: { id: number }) => r.id === learnOut.id)).toBe(true);
149
+ });
150
+
151
+ it("forget removes the memory", async () => {
152
+ const { parseMemoryArgs, runMemoryCommand } = await import("../../cli/memory.js");
153
+ const learnParsed = parseMemoryArgs(["learn", "--text", "Temporary memory to forget immediately", "--category", "pattern", "--json"]);
154
+ if (!learnParsed.ok) throw new Error("parse failed");
155
+ const learnRes = await runMemoryCommand(learnParsed);
156
+ const { id } = JSON.parse(learnRes.output);
157
+
158
+ const forgetParsed = parseMemoryArgs(["forget", "--id", String(id), "--reason", "test", "--json"]);
159
+ if (!forgetParsed.ok) throw new Error("parse failed");
160
+ const forgetRes = await runMemoryCommand(forgetParsed);
161
+ expect(forgetRes.exitCode).toBe(0);
162
+ expect(JSON.parse(forgetRes.output).ok).toBe(true);
163
+ });
164
+
165
+ it("relate links two memories", async () => {
166
+ const { parseMemoryArgs, runMemoryCommand } = await import("../../cli/memory.js");
167
+ const a = JSON.parse((await runMemoryCommand(parseMemoryArgs(["learn", "--text", "Memory A for relate test", "--category", "pattern", "--json"]) as never)).output);
168
+ const b = JSON.parse((await runMemoryCommand(parseMemoryArgs(["learn", "--text", "Memory B for relate test", "--category", "pattern", "--json"]) as never)).output);
169
+ const parsed = parseMemoryArgs(["relate", "--from", String(a.id), "--to", String(b.id), "--type", "related_to", "--json"]);
170
+ if (!parsed.ok) throw new Error("parse failed");
171
+ const res = await runMemoryCommand(parsed);
172
+ expect(res.exitCode).toBe(0);
173
+ expect(JSON.parse(res.output).ok).toBe(true);
174
+ });
175
+
176
+ it("context returns globals and scoped arrays", async () => {
177
+ const { parseMemoryArgs, runMemoryCommand } = await import("../../cli/memory.js");
178
+ const parsed = parseMemoryArgs(["context", "--project", "ltm-cli-test", "--json"]);
179
+ if (!parsed.ok) throw new Error("parse failed");
180
+ const res = await runMemoryCommand(parsed);
181
+ expect(res.exitCode).toBe(0);
182
+ const out = JSON.parse(res.output);
183
+ expect(Array.isArray(out.globals)).toBe(true);
184
+ expect(Array.isArray(out.scoped)).toBe(true);
185
+ });
186
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * mcp-server.test.ts — smoke tests for packages/openltm-core/src/mcp/server.ts
3
+ *
4
+ * The full stdio handshake is covered by manual smoke tests; here we assert the
5
+ * module loads, the factory builds a connectable server, and the tool surface
6
+ * matches the documented contract.
7
+ */
8
+ import { describe, it, expect } from "bun:test";
9
+
10
+ describe("mcp/server — buildMcpServer", () => {
11
+ it("exports buildMcpServer and startMcpServer", async () => {
12
+ const mod = await import("../mcp/server.js");
13
+ expect(typeof mod.buildMcpServer).toBe("function");
14
+ expect(typeof mod.startMcpServer).toBe("function");
15
+ });
16
+
17
+ it("builds a server exposing the documented tool set", async () => {
18
+ const { buildMcpServer } = await import("../mcp/server.js");
19
+ const server = buildMcpServer();
20
+ expect(typeof server.connect).toBe("function");
21
+ // McpServer keeps registered tools in a private map — assert via the
22
+ // public-ish _registeredTools record the SDK maintains.
23
+ const tools = (server as unknown as { _registeredTools: Record<string, unknown> })._registeredTools;
24
+ const names = Object.keys(tools);
25
+ for (const expected of ["recall", "learn", "relate", "forget", "revalidate", "admin_audit", "context", "graph", "context_items"]) {
26
+ expect(names).toContain(expected);
27
+ }
28
+ });
29
+
30
+ it("accepts host config hooks without invoking them at build time", async () => {
31
+ const { buildMcpServer } = await import("../mcp/server.js");
32
+ let called = false;
33
+ buildMcpServer({
34
+ isEnabled: async () => { called = true; return true; },
35
+ categoriseThreshold: async () => { called = true; return 0.6; },
36
+ });
37
+ expect(called).toBe(false);
38
+ });
39
+ });
package/src/cli/bin.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { runInstallCli } from "./install.js";
16
16
  import { runHook } from "./hook.js";
17
+ import { runMemoryCli } from "./memory.js";
17
18
 
18
19
  function printHelp(): void {
19
20
  process.stdout.write(
@@ -29,8 +30,10 @@ function printHelp(): void {
29
30
  " --help, -h Show this help",
30
31
  "",
31
32
  " Sub-commands:",
33
+ " memory <cmd> Read/write memories from the shell (learn, recall,",
34
+ " forget, relate, context) — run 'memory --help'",
32
35
  " hook --name <event> Lifecycle hook stub (for Claude Code hook wiring)",
33
- " mcp-serve Start the LTM MCP server",
36
+ " mcp-serve Start the LTM MCP server (stdio)",
34
37
  "",
35
38
  " If no target flags are given, agents are auto-detected.",
36
39
  "",
@@ -46,6 +49,12 @@ async function main(): Promise<void> {
46
49
  process.exit(0);
47
50
  }
48
51
 
52
+ // Sub-command: memory (learn | recall | forget | relate | context)
53
+ if (argv[0] === "memory") {
54
+ const exitCode = await runMemoryCli(argv.slice(1));
55
+ process.exit(exitCode);
56
+ }
57
+
49
58
  // Sub-command: hook
50
59
  if (argv[0] === "hook") {
51
60
  const nameIdx = argv.indexOf("--name");
@@ -58,12 +67,19 @@ async function main(): Promise<void> {
58
67
  return;
59
68
  }
60
69
 
61
- // Sub-command: mcp-serve (stub — full implementation deferred)
70
+ // Sub-command: mcp-serve — run the LTM MCP server on stdio
62
71
  if (argv[0] === "mcp-serve") {
63
- process.stderr.write(
64
- " ltm mcp-serve: not yet implemented — install the Claude Code plugin for MCP server support\n",
65
- );
66
- process.exit(0);
72
+ const { startMcpServer } = await import("../mcp/server.js");
73
+ await startMcpServer();
74
+ return; // keep the process alive — transport owns the event loop
75
+ }
76
+
77
+ // Unknown positional sub-command: print help + exit 1. Never fall through
78
+ // to the install wizard (e.g. `ltm memry learn ...` must not start installing).
79
+ if (argv[0] && !argv[0].startsWith("--")) {
80
+ process.stderr.write(` ltm: unknown sub-command '${argv[0]}'\n`);
81
+ printHelp();
82
+ process.exit(1);
67
83
  }
68
84
 
69
85
  // Parse installer flags
@@ -0,0 +1,305 @@
1
+ /**
2
+ * cli/memory.ts — `ltm memory <learn|recall|forget|relate|context>` subcommands.
3
+ *
4
+ * Gives headless/CLI agents (OpenCode CLI, scripts, cron jobs) a direct path
5
+ * to LTM without the agent TUI, slash commands, or a running MCP server:
6
+ *
7
+ * bunx @rohirik/openltm-core memory learn --text "..." --category gotcha
8
+ * bunx @rohirik/openltm-core memory recall --query "docker rate limit" --json
9
+ *
10
+ * DB path resolution follows paths.ts: LTM_DB_PATH > CLAUDE_PLUGIN_DATA > dev
11
+ * fallback. All writes route through db.ts learn() and therefore through
12
+ * scrubSecrets — the CLI is not a secret-leak bypass.
13
+ *
14
+ * Exit codes: 0 success · 1 usage error · 2 runtime/DB error.
15
+ */
16
+ import { learn, recall, forget, relate, getContextMerge, type MemoryCategory } from "../db.js";
17
+ import { waitForInit } from "../shared-db.js";
18
+
19
+ // ── Types ─────────────────────────────────────────────────────────────────────
20
+
21
+ export type MemoryCommand = "learn" | "recall" | "forget" | "relate" | "context";
22
+
23
+ export interface ParsedMemoryArgs {
24
+ ok: true;
25
+ command: MemoryCommand;
26
+ options: Record<string, string | number | string[] | undefined>;
27
+ json: boolean;
28
+ }
29
+
30
+ export interface MemoryArgsError {
31
+ ok: false;
32
+ error: string;
33
+ }
34
+
35
+ export interface MemoryCommandResult {
36
+ exitCode: number;
37
+ output: string;
38
+ }
39
+
40
+ const CATEGORIES: readonly string[] = ["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"];
41
+ const RELATIONSHIP_TYPES: readonly string[] = ["supports", "contradicts", "refines", "depends_on", "related_to", "supersedes"];
42
+
43
+ // ── Parsing ───────────────────────────────────────────────────────────────────
44
+
45
+ /** Scan argv for `--flag value` pairs and bare `--flag` booleans. */
46
+ function scanFlags(argv: string[]): Record<string, string | true> {
47
+ const flags: Record<string, string | true> = {};
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const arg = argv[i];
50
+ if (!arg?.startsWith("--")) continue;
51
+ const name = arg.slice(2);
52
+ const next = argv[i + 1];
53
+ if (next !== undefined && !next.startsWith("--")) {
54
+ flags[name] = next;
55
+ i++;
56
+ } else {
57
+ flags[name] = true;
58
+ }
59
+ }
60
+ return flags;
61
+ }
62
+
63
+ function asInt(value: string | true | undefined): number | undefined {
64
+ if (typeof value !== "string") return undefined;
65
+ const n = Number.parseInt(value, 10);
66
+ return Number.isNaN(n) ? undefined : n;
67
+ }
68
+
69
+ function asList(value: string | true | undefined): string[] | undefined {
70
+ if (typeof value !== "string") return undefined;
71
+ const items = value.split(",").map((s) => s.trim()).filter(Boolean);
72
+ return items.length > 0 ? items : undefined;
73
+ }
74
+
75
+ /**
76
+ * parseMemoryArgs — pure parser for `memory` subcommand argv (without the
77
+ * leading "memory"). Returns a typed parse result; never exits the process.
78
+ */
79
+ export function parseMemoryArgs(argv: string[]): ParsedMemoryArgs | MemoryArgsError {
80
+ const command = argv[0];
81
+ if (!command || command.startsWith("--")) {
82
+ return { ok: false, error: "missing subcommand — expected one of: learn, recall, forget, relate, context" };
83
+ }
84
+
85
+ const flags = scanFlags(argv.slice(1));
86
+ const json = flags["json"] === true;
87
+
88
+ switch (command) {
89
+ case "learn": {
90
+ const text = typeof flags["text"] === "string" ? flags["text"] : undefined;
91
+ if (!text) return { ok: false, error: "learn: --text <content> is required" };
92
+ const category = typeof flags["category"] === "string" ? flags["category"] : undefined;
93
+ if (category && !CATEGORIES.includes(category)) {
94
+ return { ok: false, error: `learn: invalid category '${category}' — expected one of: ${CATEGORIES.join(", ")}` };
95
+ }
96
+ const importance = asInt(flags["importance"]);
97
+ if (flags["importance"] !== undefined && (importance === undefined || importance < 1 || importance > 5)) {
98
+ return { ok: false, error: "learn: --importance must be an integer 1-5" };
99
+ }
100
+ return {
101
+ ok: true,
102
+ command: "learn",
103
+ json,
104
+ options: {
105
+ text,
106
+ title: typeof flags["title"] === "string" ? flags["title"] : undefined,
107
+ category,
108
+ importance,
109
+ project: typeof flags["project"] === "string" ? flags["project"] : undefined,
110
+ tags: asList(flags["tags"]),
111
+ files: asList(flags["files"]),
112
+ },
113
+ };
114
+ }
115
+
116
+ case "recall": {
117
+ const limit = asInt(flags["limit"]);
118
+ if (flags["limit"] !== undefined && (limit === undefined || limit < 1)) {
119
+ return { ok: false, error: "recall: --limit must be a positive integer" };
120
+ }
121
+ const category = typeof flags["category"] === "string" ? flags["category"] : undefined;
122
+ if (category && !CATEGORIES.includes(category)) {
123
+ return { ok: false, error: `recall: invalid category '${category}' — expected one of: ${CATEGORIES.join(", ")}` };
124
+ }
125
+ return {
126
+ ok: true,
127
+ command: "recall",
128
+ json,
129
+ options: {
130
+ query: typeof flags["query"] === "string" ? flags["query"] : undefined,
131
+ category,
132
+ project: typeof flags["project"] === "string" ? flags["project"] : undefined,
133
+ limit,
134
+ tags: asList(flags["tags"]),
135
+ },
136
+ };
137
+ }
138
+
139
+ case "forget": {
140
+ const id = asInt(flags["id"]);
141
+ if (id === undefined) return { ok: false, error: "forget: --id <number> is required" };
142
+ return {
143
+ ok: true,
144
+ command: "forget",
145
+ json,
146
+ options: { id, reason: typeof flags["reason"] === "string" ? flags["reason"] : undefined },
147
+ };
148
+ }
149
+
150
+ case "relate": {
151
+ const from = asInt(flags["from"]);
152
+ const to = asInt(flags["to"]);
153
+ const type = typeof flags["type"] === "string" ? flags["type"] : undefined;
154
+ if (from === undefined || to === undefined) {
155
+ return { ok: false, error: "relate: --from <id> and --to <id> are required" };
156
+ }
157
+ if (!type || !RELATIONSHIP_TYPES.includes(type)) {
158
+ return { ok: false, error: `relate: --type must be one of: ${RELATIONSHIP_TYPES.join(", ")}` };
159
+ }
160
+ return { ok: true, command: "relate", json, options: { from, to, type } };
161
+ }
162
+
163
+ case "context": {
164
+ const project = typeof flags["project"] === "string" ? flags["project"] : undefined;
165
+ if (!project) return { ok: false, error: "context: --project <name> is required" };
166
+ return { ok: true, command: "context", json, options: { project } };
167
+ }
168
+
169
+ default:
170
+ return { ok: false, error: `unknown memory subcommand '${command}' — expected one of: learn, recall, forget, relate, context` };
171
+ }
172
+ }
173
+
174
+ // ── Execution ─────────────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * runMemoryCommand — execute a parsed memory command against the LTM DB.
178
+ * Returns output text + exit code; never writes to stdout/stderr or exits
179
+ * (the bin entrypoint handles I/O), so it is directly unit-testable.
180
+ */
181
+ export async function runMemoryCommand(parsed: ParsedMemoryArgs): Promise<MemoryCommandResult> {
182
+ try {
183
+ await waitForInit();
184
+ const o = parsed.options;
185
+
186
+ switch (parsed.command) {
187
+ case "learn": {
188
+ const result = learn({
189
+ content: o["text"] as string,
190
+ title: o["title"] as string | undefined,
191
+ category: (o["category"] as MemoryCategory | undefined) ?? "pattern",
192
+ importance: o["importance"] as number | undefined,
193
+ project_scope: o["project"] as string | undefined,
194
+ tags: o["tags"] as string[] | undefined,
195
+ files: o["files"] as string[] | undefined,
196
+ actor: "cli:ltm_memory",
197
+ });
198
+ const payload = { ...result, category: (o["category"] as string | undefined) ?? "pattern" };
199
+ return {
200
+ exitCode: 0,
201
+ output: parsed.json
202
+ ? JSON.stringify(payload)
203
+ : ` ${payload.action === "created" ? "Stored" : "Reinforced"} memory #${payload.id} (category: ${payload.category}, confirms: ${payload.confirm_count})`,
204
+ };
205
+ }
206
+
207
+ case "recall": {
208
+ const results = await recall({
209
+ query: o["query"] as string | undefined,
210
+ category: o["category"] as MemoryCategory | undefined,
211
+ project: o["project"] as string | undefined,
212
+ limit: o["limit"] as number | undefined,
213
+ tags: o["tags"] as string[] | undefined,
214
+ });
215
+ if (parsed.json) {
216
+ const compact = results.map((m) => ({
217
+ id: m.id, title: m.title, content: m.content, category: m.category,
218
+ importance: m.importance, project_scope: m.project_scope,
219
+ }));
220
+ return { exitCode: 0, output: JSON.stringify(compact) };
221
+ }
222
+ if (results.length === 0) return { exitCode: 0, output: " No memories found." };
223
+ const lines = results.map(
224
+ (m) => ` #${m.id} [${m.category}/${m.importance}]${m.project_scope ? ` (${m.project_scope})` : ""} ${m.title ?? m.content.slice(0, 60)}`,
225
+ );
226
+ return { exitCode: 0, output: lines.join("\n") };
227
+ }
228
+
229
+ case "forget": {
230
+ forget({ id: o["id"] as number, reason: o["reason"] as string | undefined, actor: "cli:ltm_memory" });
231
+ return {
232
+ exitCode: 0,
233
+ output: parsed.json ? JSON.stringify({ ok: true, id: o["id"] }) : ` Forgot memory #${o["id"]}`,
234
+ };
235
+ }
236
+
237
+ case "relate": {
238
+ relate({ source_id: o["from"] as number, target_id: o["to"] as number, relationship_type: o["type"] as string });
239
+ return {
240
+ exitCode: 0,
241
+ output: parsed.json ? JSON.stringify({ ok: true }) : ` Related #${o["from"]} -[${o["type"]}]-> #${o["to"]}`,
242
+ };
243
+ }
244
+
245
+ case "context": {
246
+ const result = getContextMerge(o["project"] as string);
247
+ if (parsed.json) return { exitCode: 0, output: JSON.stringify(result) };
248
+ const fmt = (m: { id: number; content: string }) => ` #${m.id} ${m.content.slice(0, 80)}`;
249
+ return {
250
+ exitCode: 0,
251
+ output: [
252
+ ` Globals (${result.globals.length}):`,
253
+ ...result.globals.map(fmt),
254
+ ` Scoped to ${o["project"]} (${result.scoped.length}):`,
255
+ ...result.scoped.map(fmt),
256
+ ].join("\n"),
257
+ };
258
+ }
259
+ }
260
+ } catch (err) {
261
+ return { exitCode: 2, output: ` ltm memory ${parsed.command}: ${String(err)}` };
262
+ }
263
+ }
264
+
265
+ // ── Entrypoint glue ───────────────────────────────────────────────────────────
266
+
267
+ export function printMemoryHelp(): string {
268
+ return [
269
+ "",
270
+ " ltm memory <command> [options]",
271
+ "",
272
+ " Commands:",
273
+ " learn --text <content> [--title <t>] [--category <c>] [--importance 1-5]",
274
+ " [--project <scope>] [--tags a,b] [--files f1,f2] [--json]",
275
+ " recall [--query <q>] [--category <c>] [--project <scope>] [--limit N]",
276
+ " [--tags a,b] [--json]",
277
+ " forget --id <n> [--reason <r>] [--json]",
278
+ " relate --from <id> --to <id> --type <rel> [--json]",
279
+ " context --project <name> [--json]",
280
+ "",
281
+ ` categories: ${CATEGORIES.join(", ")}`,
282
+ ` relations: ${RELATIONSHIP_TYPES.join(", ")}`,
283
+ "",
284
+ " DB path: LTM_DB_PATH env var overrides the default plugin data location.",
285
+ "",
286
+ ].join("\n");
287
+ }
288
+
289
+ /** runMemoryCli — top-level handler invoked by bin.ts. Handles I/O + exit code. */
290
+ export async function runMemoryCli(argv: string[]): Promise<number> {
291
+ if (argv[0] === "--help" || argv[0] === "-h" || argv.length === 0) {
292
+ process.stdout.write(printMemoryHelp());
293
+ return argv.length === 0 ? 1 : 0;
294
+ }
295
+ const parsed = parseMemoryArgs(argv);
296
+ if (!parsed.ok) {
297
+ process.stderr.write(` ltm memory: ${parsed.error}\n`);
298
+ process.stderr.write(printMemoryHelp());
299
+ return 1;
300
+ }
301
+ const result = await runMemoryCommand(parsed);
302
+ const stream = result.exitCode === 0 ? process.stdout : process.stderr;
303
+ stream.write(result.output + "\n");
304
+ return result.exitCode;
305
+ }
@@ -0,0 +1,449 @@
1
+ /**
2
+ * mcp/server.ts — LTM MCP Server (STDIO transport), packaged.
3
+ *
4
+ * The full MCP server lives here so any MCP-capable host can run it via
5
+ * `bunx @rohirik/openltm-core mcp-serve` — not only the Claude Code plugin.
6
+ * The plugin's repo-root src/mcp-server.ts is a thin wrapper around this
7
+ * module that injects config from the plugin's config file.
8
+ *
9
+ * IMPORTANT: Never use console.log() — STDIO transport uses stdout for protocol.
10
+ */
11
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { z } from "zod";
14
+ import { learn, recall, relate, forget, revalidate, getContextMerge, type Memory } from "../db.js";
15
+ import { getDb } from "../shared-db.js";
16
+ import { queryAudit } from "../dao/provenanceAudit.js";
17
+ import { getItems } from "../context.js";
18
+ import { traverseGraph, buildReasoningContext } from "../graph.js";
19
+ import { categorise } from "../recall/categorise.js";
20
+
21
+ // ─── Options ─────────────────────────────────────────────────────────────────
22
+
23
+ export interface McpServerOptions {
24
+ /** Host hook: return false to disable the server (e.g. config mcp.enabled=false). Default: enabled. */
25
+ isEnabled?: () => Promise<boolean>;
26
+ /** Host hook: confidence threshold for auto-categorisation (default 0.6). */
27
+ categoriseThreshold?: () => Promise<number>;
28
+ }
29
+
30
+ // Embedding excluded at the SQL query level — strip() is now a no-op passthrough kept for call-site compatibility.
31
+ function strip(obj: unknown): unknown { return obj; }
32
+
33
+ /** Compact formatter — strips verbose fields and truncates content to keep MCP responses small. */
34
+ function compact(memories: unknown[]): unknown[] {
35
+ const MAX_CONTENT = 300;
36
+ return memories.map(m => {
37
+ const mem = m as Record<string, unknown>;
38
+ const content = typeof mem.content === "string" && mem.content.length > MAX_CONTENT
39
+ ? mem.content.slice(0, MAX_CONTENT) + "…"
40
+ : mem.content;
41
+ const relations = Array.isArray(mem.relations) && mem.relations.length > 0
42
+ ? { relations: mem.relations.map((r: Record<string, unknown>) => ({ id: (r.memory as Record<string, unknown>)?.id, type: r.relationship_type, dir: r.direction })) }
43
+ : {};
44
+ const exp = mem.explainer as Record<string, unknown> | undefined;
45
+ const score = exp
46
+ ? { temperature: exp.temperature, score: typeof exp.totalScore === "number" ? Math.round(exp.totalScore * 100) / 100 : undefined }
47
+ : {};
48
+ return { id: mem.id, content, category: mem.category, importance: mem.importance, tags: mem.tags, project_scope: mem.project_scope, ...score, ...relations };
49
+ });
50
+ }
51
+
52
+ // ─── Server factory ──────────────────────────────────────────────────────────
53
+
54
+ /** Build the LTM MCP server with all tools, resources, and prompts registered. */
55
+ export function buildMcpServer(options: McpServerOptions = {}): McpServer {
56
+ const server = new McpServer(
57
+ { name: "openltm", version: "1.0.0" },
58
+ {},
59
+ );
60
+
61
+ // ─── Tools ─────────────────────────────────────────────────────────────────
62
+
63
+ server.tool(
64
+ "recall",
65
+ "Surface prior decisions, gotchas, and patterns before a non-trivial task, or when starting work in an unfamiliar area. Ranks long-term memories by query, category, project scope, or tags. Skip for trivial one-liners.",
66
+ {
67
+ query: z.string().optional().describe("Full-text search query"),
68
+ project: z.string().optional().describe("Filter by project scope"),
69
+ limit: z.number().int().min(1).max(50).optional().describe("Max results (default 10)"),
70
+ category: z.enum(["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"]).optional(),
71
+ verbose: z.boolean().optional().describe("Return full memory objects (default false)"),
72
+ since: z.string().optional().describe("Filter: memories after this ISO date"),
73
+ until: z.string().optional().describe("Filter: memories before this ISO date"),
74
+ sort_by: z.enum(["relevance", "created", "last_recalled", "recall_count"]).optional().describe("Sort results by"),
75
+ workspace_id: z.string().optional().describe("Filter by workspace"),
76
+ agent_id: z.string().optional().describe("Filter by agent"),
77
+ includeProvenance: z.boolean().optional().default(false).describe("Attach provenance chain to each result (off by default)"),
78
+ },
79
+ async ({ query, project, limit, category, verbose, since, until, sort_by, includeProvenance }) => {
80
+ const results = await recall({ query, project, limit, category, since, until, sort_by, includeProvenance });
81
+ const payload = verbose ? strip(results) : compact(strip(results) as unknown[]);
82
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
83
+ },
84
+ );
85
+
86
+ server.tool(
87
+ "learn",
88
+ "Store or reinforce a memory after discovering a non-obvious pattern, gotcha, or architectural decision worth keeping across sessions. Skip facts already derivable from the code or git history. Always pass a concise title (the title param explains how).",
89
+ {
90
+ content: z.string().describe("The insight, pattern, or decision to store"),
91
+ title: z.string().max(60).optional().describe("Short noun-phrase label (≤60 chars) — e.g. 'Repository pattern for all DAO layers'. Always provide it; you generate it inline, no extra LLM call needed."),
92
+ category: z.enum(["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"]).optional().describe("Category (auto-detected when omitted)"),
93
+ importance: z.number().int().min(1).max(5).optional().describe("Importance 1-5 (default 3, 5=never decays)"),
94
+ tags: z.array(z.string()).optional().describe("Tags for categorization"),
95
+ files: z.array(z.string()).optional().describe("Repo-relative file paths this memory references — anchors so a commit touching them flags the memory stale"),
96
+ project: z.string().optional().describe("Scope to a specific project"),
97
+ workspace_id: z.string().optional().describe("Workspace for this memory"),
98
+ agent_id: z.string().optional().describe("Agent ID for this memory"),
99
+ },
100
+ async ({ content, title, category, importance, tags, files, project, workspace_id, agent_id }) => {
101
+ let resolvedCategory = category;
102
+ let categoriseSource: string | undefined;
103
+
104
+ if (!resolvedCategory) {
105
+ try {
106
+ const threshold = await (options.categoriseThreshold?.() ?? Promise.resolve(0.6));
107
+ const result = await categorise(content, threshold);
108
+ resolvedCategory = result.category;
109
+ categoriseSource = result.source;
110
+ } catch {
111
+ resolvedCategory = "pattern";
112
+ }
113
+ }
114
+
115
+ const result = learn({
116
+ content,
117
+ title,
118
+ category: resolvedCategory,
119
+ importance,
120
+ tags,
121
+ files,
122
+ project_scope: project,
123
+ workspace_id,
124
+ agent_id,
125
+ actor: "mcp:ltm_learn",
126
+ });
127
+
128
+ try {
129
+ server.server.notification({
130
+ method: "notifications/message",
131
+ params: { level: "info", logger: "ltm", data: `memory_stored: id=${result.id} category=${resolvedCategory}${categoriseSource ? ` (auto:${categoriseSource})` : ""} importance=${importance ?? 3} action=${result.action}` },
132
+ });
133
+ } catch { /* notifications not supported by this client — ignore */ }
134
+
135
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, category: resolvedCategory, categoriseSource }) }] };
136
+ },
137
+ );
138
+
139
+ server.tool(
140
+ "relate",
141
+ "Link two memories with a typed relationship when they connect — e.g. a decision caused a gotcha, or a pattern applies to an architecture.",
142
+ {
143
+ source_id: z.number().int(),
144
+ target_id: z.number().int(),
145
+ relationship_type: z.enum(["supports", "contradicts", "refines", "depends_on", "related_to", "supersedes"]),
146
+ },
147
+ async ({ source_id, target_id, relationship_type }) => {
148
+ relate({ source_id, target_id, relationship_type });
149
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true }) }] };
150
+ },
151
+ );
152
+
153
+ server.tool(
154
+ "forget",
155
+ "Delete a memory by ID when it is wrong, outdated, or the user requests removal. Cascades to its relations.",
156
+ {
157
+ id: z.number().int(),
158
+ reason: z.string().optional().describe("Why this memory is being removed"),
159
+ },
160
+ async ({ id, reason }) => {
161
+ forget({ id, reason, actor: "mcp:ltm_forget" });
162
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, id, reason }) }] };
163
+ },
164
+ );
165
+
166
+ server.tool(
167
+ "revalidate",
168
+ "Clear a memory's stale flag after reviewing it — the code changed but this memory is still correct. Use forget instead when the memory is actually wrong.",
169
+ {
170
+ id: z.number().int().describe("Memory ID to revalidate"),
171
+ },
172
+ async ({ id }) => {
173
+ const result = revalidate(id);
174
+ return { content: [{ type: "text", text: JSON.stringify({ id, ...result }) }] };
175
+ },
176
+ );
177
+
178
+ server.tool(
179
+ "admin_audit",
180
+ "Query the memory audit log. Returns a list of audit events (insert, update, forget, redact, etc.) with before/after snapshots. Use for tracing who wrote or deleted a memory.",
181
+ {
182
+ memory_id: z.number().int().optional().describe("Filter to a specific memory ID"),
183
+ op: z.enum(["insert","update","forget","deprecate","supersede","redact","restore","archive"]).optional().describe("Filter by operation type"),
184
+ session_id: z.string().optional().describe("Filter by session that triggered the op"),
185
+ since: z.string().optional().describe("ISO date — only events after this time"),
186
+ limit: z.number().int().min(1).max(200).optional().default(50).describe("Max rows (default 50)"),
187
+ verbose: z.boolean().optional().default(false).describe("Include full before/after JSON snapshots"),
188
+ },
189
+ async ({ memory_id, op, session_id, since, limit, verbose }) => {
190
+ const db = getDb();
191
+ const rows = queryAudit(db, { memoryId: memory_id, op, sessionId: session_id, since, limit });
192
+ const payload = verbose ? rows : rows.map(r => ({
193
+ id: r.id, memory_id: r.memory_id, op: r.op, actor: r.actor,
194
+ session_id: r.session_id, created_at: r.created_at,
195
+ before_preview: r.before_json ? r.before_json.slice(0, 120) : null,
196
+ after_preview: r.after_json ? r.after_json.slice(0, 120) : null,
197
+ }));
198
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
199
+ },
200
+ );
201
+
202
+ server.tool(
203
+ "context",
204
+ "Restore project goals, decisions, and gotchas at session start or when switching projects. Returns merged context (globals + project-scoped memories).",
205
+ {
206
+ project: z.string().describe("Project name from registry"),
207
+ },
208
+ async ({ project }) => {
209
+ const result = getContextMerge(project);
210
+ return { content: [{ type: "text", text: JSON.stringify(strip(result)) }] };
211
+ },
212
+ );
213
+
214
+ server.tool(
215
+ "graph",
216
+ "Traverse the memory graph from seed nodes when exploring connections between memories or tracing decision chains. Builds a reasoning context from the traversal.",
217
+ {
218
+ memory_ids: z.array(z.number().int()).min(1).describe("Starting memory IDs for traversal"),
219
+ depth: z.number().int().min(1).max(4).optional().describe("Traversal depth (default 2)"),
220
+ },
221
+ async ({ memory_ids, depth = 2 }) => {
222
+ const results = await Promise.allSettled(
223
+ memory_ids.map((id) => traverseGraph(id, depth, false)),
224
+ );
225
+
226
+ const blocks: string[] = [];
227
+ let totalNodes = 0;
228
+ let totalEdges = 0;
229
+
230
+ for (const r of results) {
231
+ if (r.status === "fulfilled") {
232
+ const block = buildReasoningContext(r.value);
233
+ totalNodes += r.value.chain.length;
234
+ totalEdges += r.value.reinforcements.length + r.value.conflicts.length;
235
+ if (block) blocks.push(block);
236
+ }
237
+ }
238
+
239
+ try {
240
+ server.server.notification({
241
+ method: "notifications/message",
242
+ params: { level: "info", logger: "ltm", data: `graph_traversal: nodes=${totalNodes} edges=${totalEdges} depth=${depth}` },
243
+ });
244
+ } catch { /* notifications not supported by this client — ignore */ }
245
+
246
+ return { content: [{ type: "text", text: blocks.join("\n\n") || "No reasoning context found." }] };
247
+ },
248
+ );
249
+
250
+ server.tool(
251
+ "context_items",
252
+ "List specific context types — goals, decisions, progress, or gotchas — for a project. Returns structured context items.",
253
+ {
254
+ project: z.string().describe("Project name from registry"),
255
+ type: z.enum(["goal", "decision", "progress", "gotcha"]).optional(),
256
+ },
257
+ async ({ project, type }) => {
258
+ const items = getItems(project, type);
259
+ return { content: [{ type: "text", text: JSON.stringify(items) }] };
260
+ },
261
+ );
262
+
263
+ // ─── Resources ─────────────────────────────────────────────────────────────
264
+
265
+ server.resource(
266
+ "memory://globals",
267
+ "memory://globals",
268
+ { description: "All importance=5 global memories (never decay)" },
269
+ async () => {
270
+ const db = getDb();
271
+ const rows = db.query<Memory, []>(
272
+ `SELECT * FROM memories WHERE importance = 5 AND project_scope IS NULL AND status = 'active' ORDER BY created_at DESC`,
273
+ ).all();
274
+ return { contents: [{ uri: "memory://globals", text: JSON.stringify(strip(rows)), mimeType: "application/json" }] };
275
+ },
276
+ );
277
+
278
+ server.resource(
279
+ "memory://recent",
280
+ "memory://recent",
281
+ { description: "Last 20 memories across all projects" },
282
+ async () => {
283
+ const db = getDb();
284
+ const rows = db.query<Memory, []>(
285
+ `SELECT * FROM memories WHERE status = 'active' ORDER BY created_at DESC LIMIT 20`,
286
+ ).all();
287
+ return { contents: [{ uri: "memory://recent", text: JSON.stringify(strip(rows)), mimeType: "application/json" }] };
288
+ },
289
+ );
290
+
291
+ server.resource(
292
+ "memory://tags",
293
+ "memory://tags",
294
+ { description: "All unique tags with usage counts" },
295
+ async () => {
296
+ const db = getDb();
297
+ const rows = db.query<{ name: string; count: number }, []>(
298
+ `SELECT t.name, COUNT(mt.memory_id) as count FROM tags t
299
+ JOIN memory_tags mt ON t.id = mt.tag_id
300
+ GROUP BY t.id ORDER BY count DESC`,
301
+ ).all();
302
+ return { contents: [{ uri: "memory://tags", text: JSON.stringify(strip(rows)), mimeType: "application/json" }] };
303
+ },
304
+ );
305
+
306
+ const projectTemplate = new ResourceTemplate("memory://project/{name}", { list: undefined });
307
+ server.resource(
308
+ "memory://project/{name}",
309
+ projectTemplate,
310
+ { description: "All active memories scoped to a specific project" },
311
+ async (uri, { name }) => {
312
+ const projectName = (Array.isArray(name) ? name[0] : name) ?? "";
313
+ const db = getDb();
314
+ const rows = db.query<Memory, [string]>(
315
+ `SELECT * FROM memories WHERE project_scope = ? AND status = 'active' ORDER BY importance DESC, created_at DESC`,
316
+ ).all(projectName);
317
+ return {
318
+ contents: [{
319
+ uri: uri.href,
320
+ text: JSON.stringify(strip(rows), null, 2),
321
+ mimeType: "application/json",
322
+ }],
323
+ };
324
+ },
325
+ );
326
+
327
+ // ─── Prompts ───────────────────────────────────────────────────────────────
328
+
329
+ server.prompt(
330
+ "recall_before_task",
331
+ "Before starting a task, recall relevant memories and past decisions",
332
+ { topic: z.string().describe("The topic or task you are about to work on") },
333
+ ({ topic }) => ({
334
+ messages: [{
335
+ role: "user",
336
+ content: {
337
+ type: "text",
338
+ text: `Before starting work on "${topic}", use the recall tool to search for relevant memories, past decisions, and gotchas related to this topic. Summarize what you find and note any decisions that should be followed.`,
339
+ },
340
+ }],
341
+ }),
342
+ );
343
+
344
+ server.prompt(
345
+ "learn_after_session",
346
+ "Extract learnable patterns and insights from a session summary",
347
+ { summary: z.string().describe("Summary of the session or work done") },
348
+ ({ summary }) => ({
349
+ messages: [{
350
+ role: "user",
351
+ content: {
352
+ type: "text",
353
+ text: `Extract learnable patterns, gotchas, and architectural decisions from this session summary. For each insight, use learn to store it with the appropriate category and importance.\n\nSession summary:\n${summary}`,
354
+ },
355
+ }],
356
+ }),
357
+ );
358
+
359
+ server.prompt(
360
+ "graph_reason",
361
+ "Use graph traversal to reason about a question using connected memories",
362
+ { question: z.string().describe("The question or topic to reason about") },
363
+ ({ question }) => ({
364
+ messages: [{
365
+ role: "user",
366
+ content: {
367
+ type: "text",
368
+ text: `Use recall to find memories related to "${question}", then use graph on the top result IDs to traverse connected memories. Synthesize the chain of reasoning, conflicts, and reinforcements into a coherent answer.`,
369
+ },
370
+ }],
371
+ }),
372
+ );
373
+
374
+ server.prompt(
375
+ "learn_after_decision",
376
+ "Store an architectural decision or key choice in long-term memory with full context",
377
+ {
378
+ decision: z.string().describe("The architectural decision or key choice that was made"),
379
+ rationale: z.string().describe("Why this decision was made"),
380
+ project: z.string().optional().describe("Project this decision belongs to"),
381
+ },
382
+ ({ decision, rationale, project }) => ({
383
+ messages: [
384
+ {
385
+ role: "user",
386
+ content: {
387
+ type: "text",
388
+ text: `We just made an architectural decision that should be preserved for future sessions. Store it using learn.\n\nDecision: ${decision}\nRationale: ${rationale}${project ? `\nProject: ${project}` : ""}\n\nStore with category=architecture, importance=4, and include the rationale in the content so future recall explains the "why".`,
389
+ },
390
+ },
391
+ {
392
+ role: "assistant",
393
+ content: {
394
+ type: "text",
395
+ text: `I'll store this decision now using learn with category=architecture and importance=4 so it persists across sessions and surfaces in future context loads.`,
396
+ },
397
+ },
398
+ ],
399
+ }),
400
+ );
401
+
402
+ server.prompt(
403
+ "context_before_work",
404
+ "Get full project context before starting work — combines context and recall for a complete picture",
405
+ {
406
+ project: z.string().describe("Project name from the LTM registry"),
407
+ topic: z.string().describe("What you are about to work on"),
408
+ },
409
+ ({ project, topic }) => ({
410
+ messages: [
411
+ {
412
+ role: "user",
413
+ content: {
414
+ type: "text",
415
+ text: `Before starting work on "${topic}" in project "${project}", gather full context:\n\n1. Call context(project="${project}") to load goals, decisions, and gotchas.\n2. Call recall(query="${topic}", project="${project}") to surface relevant past patterns.\n3. Synthesize: list any active decisions, known gotchas, or prior work that affects this task.`,
416
+ },
417
+ },
418
+ {
419
+ role: "assistant",
420
+ content: {
421
+ type: "text",
422
+ text: `I'll call context and recall now, then synthesize the relevant context before proceeding with "${topic}".`,
423
+ },
424
+ },
425
+ ],
426
+ }),
427
+ );
428
+
429
+ return server;
430
+ }
431
+
432
+ // ─── Start ───────────────────────────────────────────────────────────────────
433
+
434
+ /** Connect the LTM MCP server to stdio. Resolves once the transport is up. */
435
+ export async function startMcpServer(options: McpServerOptions = {}): Promise<void> {
436
+ process.on("unhandledRejection", (err) => {
437
+ process.stderr.write(`[ltm-mcp] Unhandled rejection: ${err}\n`);
438
+ });
439
+
440
+ if (options.isEnabled && !(await options.isEnabled())) {
441
+ process.stderr.write("[ltm-mcp] mcp.enabled=false — server disabled\n");
442
+ process.exit(0);
443
+ }
444
+
445
+ const server = buildMcpServer(options);
446
+ const transport = new StdioServerTransport();
447
+ await server.connect(transport);
448
+ process.stderr.write("[ltm-mcp] LTM MCP server running on stdio\n");
449
+ }