@rohirik/openltm-core 2.10.0 → 2.12.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 +6 -3
- package/src/__tests__/cli/memory.test.ts +186 -0
- package/src/__tests__/mcp-server.test.ts +39 -0
- package/src/cli/bin.ts +22 -6
- package/src/cli/memory.ts +305 -0
- package/src/mcp/server.ts +449 -0
- package/src/migrations.ts +370 -8
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohirik/openltm-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.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
|
|
70
|
+
// Sub-command: mcp-serve — run the LTM MCP server on stdio
|
|
62
71
|
if (argv[0] === "mcp-serve") {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|