@rohirik/openltm-core 2.11.0 → 2.12.2

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/README.md CHANGED
@@ -22,6 +22,7 @@ idempotent — safe to run multiple times.
22
22
 
23
23
  - Adds the `ltm` MCP server entry (`bunx @rohirik/openltm-core mcp-serve`)
24
24
  - Wires three lifecycle hooks: `SessionStart`, `PreCompact`, `PostEditCheck`
25
+ - `SessionStart` now emits a portable Prior Knowledge prefill directly from `openltm-core`; the other two are safe no-ops unless the full Claude plugin checkout is installed
25
26
 
26
27
  ### OpenCode (`opencode.json`)
27
28
 
@@ -54,6 +55,8 @@ config directories.
54
55
 
55
56
  ## Programmatic API
56
57
 
58
+ Installers are registry-driven under `INSTALL_TARGETS`, so adding a new host now means defining one target entry (detect + install) instead of editing the CLI orchestrator in multiple places.
59
+
57
60
  ```typescript
58
61
  import { installClaude, installOpenCode, installPi, detectAgents } from "@rohirik/openltm-core/cli";
59
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohirik/openltm-core",
3
- "version": "2.11.0",
3
+ "version": "2.12.2",
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",
@@ -32,7 +32,7 @@
32
32
  "dependencies": {
33
33
  "@clack/prompts": "^1.3.0",
34
34
  "@iarna/toml": "^2.2.5",
35
- "@modelcontextprotocol/sdk": "^1.27.1",
35
+ "@modelcontextprotocol/sdk": "^1.29.0",
36
36
  "bun-types": "^1.0.0",
37
37
  "sqlite-vec": "0.1.9",
38
38
  "zod": "^4.3.6"
@@ -0,0 +1,47 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { readFileSync, unlinkSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ const dbPath = `/tmp/test-openltm-hook-${process.pid}-${Date.now()}.db`;
7
+ const SCHEMA_PATH = join(import.meta.dir, "..", "..", "schema.sql");
8
+
9
+ beforeAll(async () => {
10
+ const { runPendingMigrations, _setDbForTesting } = await import("../../index.js");
11
+ const db = new Database(dbPath, { create: true });
12
+ db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
13
+ db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
14
+ await runPendingMigrations(db);
15
+ _setDbForTesting(db);
16
+ }, 30_000);
17
+
18
+ afterAll(() => {
19
+ try { unlinkSync(dbPath); } catch {}
20
+ try { unlinkSync(`${dbPath}-shm`); } catch {}
21
+ try { unlinkSync(`${dbPath}-wal`); } catch {}
22
+ });
23
+
24
+ describe("cli hook dispatcher", () => {
25
+ it("builds a SessionStart prefill block from hook stdin JSON", async () => {
26
+ const { learn } = await import("../../index.js");
27
+ const { buildHookOutput } = await import("../../cli/hook.js");
28
+
29
+ learn({
30
+ content: "SessionStart should restore this memory",
31
+ category: "pattern",
32
+ importance: 3,
33
+ project_scope: "hook-project",
34
+ skipExport: true,
35
+ });
36
+
37
+ const output = await buildHookOutput("SessionStart", JSON.stringify({ cwd: "/tmp/hook-project" }));
38
+ expect(output).toContain("Prior Knowledge");
39
+ expect(output).toContain("restore this memory");
40
+ });
41
+
42
+ it("keeps non-SessionStart hook events as safe no-ops", async () => {
43
+ const { buildHookOutput } = await import("../../cli/hook.js");
44
+ expect(await buildHookOutput("PreCompact", JSON.stringify({ cwd: "/tmp/hook-project" }))).toBe("");
45
+ expect(await buildHookOutput("PostEditCheck", JSON.stringify({ cwd: "/tmp/hook-project" }))).toBe("");
46
+ });
47
+ });
@@ -0,0 +1,56 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { readFileSync, unlinkSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ const dbPath = `/tmp/test-openltm-prefill-${process.pid}-${Date.now()}.db`;
7
+ const SCHEMA_PATH = join(import.meta.dir, "..", "schema.sql");
8
+
9
+ beforeAll(async () => {
10
+ const { runPendingMigrations, _setDbForTesting } = await import("../index.js");
11
+ const db = new Database(dbPath, { create: true });
12
+ db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
13
+ db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
14
+ await runPendingMigrations(db);
15
+ _setDbForTesting(db);
16
+ }, 30_000);
17
+
18
+ afterAll(() => {
19
+ try { unlinkSync(dbPath); } catch {}
20
+ try { unlinkSync(`${dbPath}-shm`); } catch {}
21
+ try { unlinkSync(`${dbPath}-wal`); } catch {}
22
+ });
23
+
24
+ describe("prefill helpers", () => {
25
+ it("returns empty output when no memories exist", async () => {
26
+ const { buildPrefillContext } = await import("../index.js");
27
+ expect(buildPrefillContext({ project: "empty-prefill-project", maxMemories: 4 })).toBe("");
28
+ });
29
+
30
+ it("builds a compact shared prefill block from globals + project memories", async () => {
31
+ const { buildPrefillContext, deriveProjectFromCwd, learn } = await import("../index.js");
32
+
33
+ learn({
34
+ content: "Global guardrail — prefer Bun commands in this repo",
35
+ category: "workflow",
36
+ importance: 4,
37
+ skipExport: true,
38
+ });
39
+ learn({
40
+ content: "Project decision — adapter hooks should use the shared prefill builder",
41
+ category: "architecture",
42
+ importance: 3,
43
+ project_scope: "prefill-project",
44
+ skipExport: true,
45
+ });
46
+
47
+ expect(deriveProjectFromCwd("/tmp/prefill-project")).toBe("prefill-project");
48
+
49
+ const block = buildPrefillContext({ project: "prefill-project", maxMemories: 6, maxLines: 18 });
50
+ expect(block).toContain("## Prior Knowledge (LTM)");
51
+ expect(block).toContain("Global:");
52
+ expect(block).toContain("Project (prefill-project):");
53
+ expect(block).toContain("prefer Bun commands");
54
+ expect(block).toContain("shared prefill builder");
55
+ });
56
+ });
package/src/cli/bin.ts CHANGED
@@ -9,8 +9,8 @@
9
9
  * bunx @rohirik/openltm-core --pi # Pi only
10
10
  * bunx @rohirik/openltm-core --dry-run --claude # preview without writing
11
11
  *
12
- * bunx @rohirik/openltm-core hook --name <hookName> # lifecycle hook stub
13
- * bunx @rohirik/openltm-core mcp-serve # MCP server (future)
12
+ * bunx @rohirik/openltm-core hook --name <hookName> # lifecycle hook entrypoint
13
+ * bunx @rohirik/openltm-core mcp-serve # MCP server
14
14
  */
15
15
  import { runInstallCli } from "./install.js";
16
16
  import { runHook } from "./hook.js";
@@ -32,7 +32,7 @@ function printHelp(): void {
32
32
  " Sub-commands:",
33
33
  " memory <cmd> Read/write memories from the shell (learn, recall,",
34
34
  " forget, relate, context) — run 'memory --help'",
35
- " hook --name <event> Lifecycle hook stub (for Claude Code hook wiring)",
35
+ " hook --name <event> Lifecycle hook entrypoint (SessionStart prefill + safe no-ops)",
36
36
  " mcp-serve Start the LTM MCP server (stdio)",
37
37
  "",
38
38
  " If no target flags are given, agents are auto-detected.",
@@ -63,7 +63,7 @@ async function main(): Promise<void> {
63
63
  process.stderr.write(" ltm hook: missing --name argument\n");
64
64
  process.exit(1);
65
65
  }
66
- runHook(hookName);
66
+ await runHook(hookName);
67
67
  return;
68
68
  }
69
69
 
package/src/cli/detect.ts CHANGED
@@ -7,37 +7,9 @@
7
7
  *
8
8
  * Pure function — accepts a homedir argument so tests can supply a tmp dir.
9
9
  */
10
- import { existsSync } from "fs";
11
- import { join } from "path";
12
10
  import os from "os";
13
11
  import type { DetectResult } from "./types.js";
14
-
15
- /**
16
- * Probe for an OpenCode config directory using the standard path resolution
17
- * order: $XDG_CONFIG_HOME/opencode → ~/.config/opencode → (darwin only)
18
- * ~/Library/Application Support/opencode.
19
- */
20
- function probeOpenCode(homedir: string): boolean {
21
- const xdg = process.env["XDG_CONFIG_HOME"];
22
- if (xdg && existsSync(join(xdg, "opencode"))) return true;
23
- if (existsSync(join(homedir, ".config", "opencode"))) return true;
24
- if (process.platform === "darwin") {
25
- if (existsSync(join(homedir, "Library", "Application Support", "opencode"))) return true;
26
- }
27
- return false;
28
- }
29
-
30
- /**
31
- * Probe for a Pi config using the three known locations:
32
- * ~/.pi/ | ~/pi.toml | ~/.pi/config.toml
33
- */
34
- function probePi(homedir: string): boolean {
35
- return (
36
- existsSync(join(homedir, ".pi")) ||
37
- existsSync(join(homedir, "pi.toml")) ||
38
- existsSync(join(homedir, ".pi", "config.toml"))
39
- );
40
- }
12
+ import { INSTALL_TARGETS } from "./targets.js";
41
13
 
42
14
  /**
43
15
  * detectAgents — inspect the filesystem to determine which AI coding agents
@@ -47,9 +19,8 @@ function probePi(homedir: string): boolean {
47
19
  * @returns DetectResult with boolean flags for each supported agent.
48
20
  */
49
21
  export function detectAgents(homedir: string = os.homedir()): DetectResult {
50
- return {
51
- claude: existsSync(join(homedir, ".claude")),
52
- opencode: probeOpenCode(homedir),
53
- pi: probePi(homedir),
54
- };
22
+ return INSTALL_TARGETS.reduce<DetectResult>((acc, target) => {
23
+ acc[target.id] = target.detect(homedir);
24
+ return acc;
25
+ }, { claude: false, opencode: false, pi: false });
55
26
  }
package/src/cli/hook.ts CHANGED
@@ -1,25 +1,63 @@
1
1
  /**
2
- * cli/hook.ts — Hook dispatcher stub.
2
+ * cli/hook.ts — Lightweight Claude-compatible hook dispatcher.
3
3
  *
4
- * Full hook logic lives in the Claude Code plugin (hooks/src/).
5
- * When invoked via `bunx @rohirik/openltm-core hook --name <hookName>`, this
6
- * stub exits cleanly (exit 0) so Claude Code does not treat the absent hook
7
- * runner as an error.
8
- *
9
- * A future phase can wire actual hook logic here once openltm-core ships the
10
- * compiled hook handlers.
4
+ * For bunx installs we support a portable SessionStart prefill directly from
5
+ * openltm-core so users still get an "already pre-filled" experience without
6
+ * the full Claude plugin checkout. Other hook events are safe no-ops.
11
7
  */
8
+ import { buildPrefillContext, deriveProjectFromCwd } from "../prefill.js";
9
+
10
+ function parseHookCwd(raw: string): string {
11
+ if (!raw.trim()) return "";
12
+ try {
13
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
14
+ const cwd = parsed["cwd"]
15
+ ?? parsed["working_directory"]
16
+ ?? (parsed["session"] as Record<string, unknown> | undefined)?.["cwd"];
17
+ return typeof cwd === "string" ? cwd : "";
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ export async function buildHookOutput(name: string, rawInput: string): Promise<string> {
24
+ switch (name) {
25
+ case "SessionStart": {
26
+ const cwd = parseHookCwd(rawInput);
27
+ if (!cwd) return "";
28
+ const project = deriveProjectFromCwd(cwd);
29
+ if (!project) return "";
30
+ return buildPrefillContext({ project, maxMemories: 10, maxLines: 18 });
31
+ }
32
+ case "PreCompact":
33
+ case "PostEditCheck":
34
+ return "";
35
+ default:
36
+ return "";
37
+ }
38
+ }
39
+
40
+ async function readStdin(): Promise<string> {
41
+ let result = "";
42
+ try {
43
+ for await (const chunk of Bun.stdin.stream()) {
44
+ result += new TextDecoder().decode(chunk);
45
+ }
46
+ } catch {
47
+ // stdin may be absent in some hook invocations
48
+ }
49
+ return result;
50
+ }
12
51
 
13
52
  /**
14
- * runHook — stub dispatcher for LTM lifecycle hooks.
53
+ * runHook — dispatch a lifecycle hook.
15
54
  *
16
- * @param name - Hook event name (e.g. "SessionStart", "PreCompact").
55
+ * SessionStart emits a compact Prior Knowledge block when memories exist.
56
+ * Other events intentionally no-op until a richer host-independent contract is
57
+ * extracted from the full Claude plugin hook suite.
17
58
  */
18
- export function runHook(name: string): void {
19
- // Stub: full hook logic requires the Claude Code plugin.
20
- // Exit 0 so Claude Code does not break when the hook is registered.
21
- process.stderr.write(
22
- `LTM: hook '${name}' via bunx not yet supported — install the Claude Code plugin for full hook support\n`,
23
- );
24
- process.exit(0);
59
+ export async function runHook(name: string): Promise<void> {
60
+ const raw = await readStdin();
61
+ const output = await buildHookOutput(name, raw);
62
+ if (output) process.stdout.write(output);
25
63
  }
package/src/cli/index.ts CHANGED
@@ -20,3 +20,5 @@ export { detectAgents } from "./detect.js";
20
20
  export { installClaude } from "./claude.js";
21
21
  export { installOpenCode } from "./opencode.js";
22
22
  export { installPi } from "./pi.js";
23
+ export { INSTALL_TARGETS, getInstallTarget } from "./targets.js";
24
+ export type { InstallTargetDefinition } from "./targets.js";
@@ -11,11 +11,9 @@
11
11
  */
12
12
  import * as clack from "@clack/prompts";
13
13
  import { detectAgents } from "./detect.js";
14
- import { installClaude } from "./claude.js";
15
- import { installOpenCode } from "./opencode.js";
16
- import { installPi } from "./pi.js";
17
14
  import type { CliInstallOptions, CliInstallResult, InstallStep, InstallResult } from "./types.js";
18
15
  import { InstallTarget } from "./types.js";
16
+ import { INSTALL_TARGETS } from "./targets.js";
19
17
 
20
18
  // ── Known targets set (legacy validation) ────────────────────────────────────
21
19
 
@@ -124,53 +122,19 @@ export async function runInstallCli(opts: CliRunOpts): Promise<CliRunResult> {
124
122
 
125
123
  const results: InstallResult[] = [];
126
124
 
127
- // Claude
128
- if (targets.claude) {
125
+ for (const target of INSTALL_TARGETS) {
126
+ if (!targets[target.id]) continue;
129
127
  const s = silent ? null : clack.spinner();
130
- if (s) s.start("Installing into Claude Code…");
131
- const r = await installClaude({ homedir, dryRun });
128
+ if (s) s.start(`Installing into ${target.label}…`);
129
+ const r = await target.install({ homedir, dryRun });
132
130
  results.push(r);
133
131
  if (s) {
134
132
  if (r.status === "installed") {
135
- s.stop(`Claude Code: installed${dryRun ? " (dry-run)" : ""}`);
133
+ s.stop(`${target.label}: installed${dryRun ? " (dry-run)" : ""}`);
136
134
  } else if (r.status === "skipped") {
137
- s.stop("Claude Code: already configured (skipped)");
135
+ s.stop(`${target.label}: already configured (skipped)`);
138
136
  } else {
139
- s.stop(`Claude Code: error — ${r.detail ?? "unknown error"}`);
140
- }
141
- }
142
- }
143
-
144
- // OpenCode
145
- if (targets.opencode) {
146
- const s = silent ? null : clack.spinner();
147
- if (s) s.start("Installing into OpenCode…");
148
- const r = await installOpenCode({ homedir, dryRun });
149
- results.push(r);
150
- if (s) {
151
- if (r.status === "installed") {
152
- s.stop(`OpenCode: installed${dryRun ? " (dry-run)" : ""}`);
153
- } else if (r.status === "skipped") {
154
- s.stop("OpenCode: already configured (skipped)");
155
- } else {
156
- s.stop(`OpenCode: error — ${r.detail ?? "unknown error"}`);
157
- }
158
- }
159
- }
160
-
161
- // Pi
162
- if (targets.pi) {
163
- const s = silent ? null : clack.spinner();
164
- if (s) s.start("Installing into Pi…");
165
- const r = await installPi({ dryRun });
166
- results.push(r);
167
- if (s) {
168
- if (r.status === "installed") {
169
- s.stop(`Pi: installed${dryRun ? " (dry-run)" : ""}`);
170
- } else if (r.status === "skipped") {
171
- s.stop("Pi: already configured (skipped)");
172
- } else {
173
- s.stop(`Pi: error — ${r.detail ?? "unknown error"}`);
137
+ s.stop(`${target.label}: error — ${r.detail ?? "unknown error"}`);
174
138
  }
175
139
  }
176
140
  }
@@ -0,0 +1,62 @@
1
+ import type { InstallResult } from "./types.js";
2
+ import { installClaude } from "./claude.js";
3
+ import { installOpenCode } from "./opencode.js";
4
+ import { installPi } from "./pi.js";
5
+ import { existsSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ export interface InstallTargetDefinition {
9
+ id: "claude" | "opencode" | "pi";
10
+ label: string;
11
+ detect: (homedir: string) => boolean;
12
+ install: (opts: { homedir?: string; dryRun?: boolean }) => Promise<InstallResult>;
13
+ }
14
+
15
+ function detectClaude(homedir: string): boolean {
16
+ return existsSync(join(homedir, ".claude"));
17
+ }
18
+
19
+ function detectOpenCode(homedir: string): boolean {
20
+ const xdg = process.env["XDG_CONFIG_HOME"];
21
+ if (xdg && existsSync(join(xdg, "opencode"))) return true;
22
+ if (existsSync(join(homedir, ".config", "opencode"))) return true;
23
+ if (process.platform === "darwin") {
24
+ if (existsSync(join(homedir, "Library", "Application Support", "opencode"))) return true;
25
+ }
26
+ return false;
27
+ }
28
+
29
+ function detectPi(homedir: string): boolean {
30
+ return (
31
+ existsSync(join(homedir, ".pi")) ||
32
+ existsSync(join(homedir, "pi.toml")) ||
33
+ existsSync(join(homedir, ".pi", "config.toml"))
34
+ );
35
+ }
36
+
37
+ export const INSTALL_TARGETS: readonly InstallTargetDefinition[] = [
38
+ {
39
+ id: "claude",
40
+ label: "Claude Code",
41
+ detect: detectClaude,
42
+ install: ({ homedir, dryRun }) => installClaude({ homedir, dryRun }),
43
+ },
44
+ {
45
+ id: "opencode",
46
+ label: "OpenCode",
47
+ detect: detectOpenCode,
48
+ install: ({ homedir, dryRun }) => installOpenCode({ homedir, dryRun }),
49
+ },
50
+ {
51
+ id: "pi",
52
+ label: "Pi",
53
+ detect: detectPi,
54
+ install: ({ dryRun }) => installPi({ dryRun }),
55
+ },
56
+ ] as const;
57
+
58
+ export function getInstallTarget(id: InstallTargetDefinition["id"]): InstallTargetDefinition {
59
+ const target = INSTALL_TARGETS.find((entry) => entry.id === id);
60
+ if (!target) throw new Error(`Unknown install target: ${id}`);
61
+ return target;
62
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,10 @@ export { categorise } from "./recall/categorise.js";
43
43
  export { buildExplainer, computeTemperature } from "./recall/explainer.js";
44
44
  export type { MemoryTemperature, RecallExplainer, ExplainerInput } from "./recall/explainer.js";
45
45
 
46
+ // Session prefill helpers
47
+ export { buildPrefillContext, deriveProjectFromCwd, selectPrefillMemories } from "./prefill.js";
48
+ export type { PrefillOptions, PrefillSelection } from "./prefill.js";
49
+
46
50
  // Embedding providers
47
51
  export * from "./providers/index.js";
48
52
  export { WriteQueue, writeQueue } from "./lib/writeQueue.js";
package/src/migrations.ts CHANGED
@@ -210,6 +210,352 @@ export function parseMigration(content: string): ParsedMigration {
210
210
  return { up, down };
211
211
  }
212
212
 
213
+ // ── R-2: fail-closed self-heal gate ─────────────────────────────────────────────
214
+ // The old runPendingMigrations() catch treated any `duplicate column name` /
215
+ // `already exists` error as proof the migration could be recorded. That is
216
+ // unsound: db.exec(up) aborts at the FIRST failing statement, so the error only
217
+ // proves the first colliding statement — nothing about later columns, tables,
218
+ // indexes, or backfills. The recorder must never trust the error string; it must
219
+ // independently prove the whole post-migration state against the live schema.
220
+
221
+ function skipWs(s: string, pos: number): number {
222
+ while (pos < s.length && /\s/.test(s.charAt(pos))) pos++;
223
+ return pos;
224
+ }
225
+
226
+ /**
227
+ * Match a single SQL keyword at `pos` (whitespace before it is skipped),
228
+ * case-insensitively. Returns the position just past the keyword, or null if
229
+ * `kw` does not appear there as a whole word.
230
+ */
231
+ function matchKeyword(s: string, pos: number, kw: string): number | null {
232
+ const p = skipWs(s, pos);
233
+ const raw = s.slice(p, p + kw.length);
234
+ if (raw.toUpperCase() !== kw) return null;
235
+ const after = p + kw.length;
236
+ if (after < s.length && /[A-Za-z0-9_]/.test(s.charAt(after))) return null;
237
+ return after;
238
+ }
239
+
240
+ /**
241
+ * If the text at `pos` begins `IF NOT EXISTS`, return the position just past
242
+ * it; otherwise return `pos` unchanged. Only the exact `IF NOT EXISTS` keyword
243
+ * sequence is recognised.
244
+ */
245
+ function consumeIfNotExists(s: string, pos: number): number {
246
+ const ifEnd = matchKeyword(s, pos, "IF");
247
+ if (ifEnd === null) return pos;
248
+ const notEnd = matchKeyword(s, ifEnd, "NOT");
249
+ if (notEnd === null) return pos;
250
+ const existsEnd = matchKeyword(s, notEnd, "EXISTS");
251
+ return existsEnd === null ? pos : existsEnd;
252
+ }
253
+
254
+ /**
255
+ * Match a single SQL identifier starting at `pos`: a bare `[A-Za-z_][A-Za-z0-9_]*`
256
+ * name or one of the quoted forms `"..."`, `` `...` ``, `[...]`. Returns the
257
+ * unquoted name and the position just past the identifier, or null if none starts
258
+ * there.
259
+ */
260
+ function matchIdent(s: string, pos: number): { name: string; end: number } | null {
261
+ if (pos >= s.length) return null;
262
+ const ch = s.charAt(pos);
263
+ if (ch === '"' || ch === "`") {
264
+ let i = pos + 1;
265
+ let name = "";
266
+ for (; i < s.length; i++) {
267
+ if (s.charAt(i) === ch) {
268
+ if (s.charAt(i + 1) === ch) {
269
+ name += ch;
270
+ i++;
271
+ continue;
272
+ }
273
+ return { name, end: i + 1 };
274
+ }
275
+ name += s.charAt(i);
276
+ }
277
+ return null; // unterminated quote
278
+ }
279
+ if (ch === "[") {
280
+ const end = s.indexOf("]", pos + 1);
281
+ if (end === -1) return null;
282
+ return { name: s.slice(pos + 1, end), end: end + 1 };
283
+ }
284
+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(s.slice(pos));
285
+ return m ? { name: m[0], end: pos + m[0].length } : null;
286
+ }
287
+
288
+ /**
289
+ * True when `tail` (the column-definition remainder of an `ALTER TABLE ... ADD
290
+ * COLUMN <col>` statement) contains nothing beyond a plain column definition.
291
+ * Rejects a top-level comma (SQLite multi-action ALTER) and any top-level
292
+ * DROP/RENAME keyword, so a statement such as `ALTER TABLE t ADD COLUMN a, DROP
293
+ * COLUMN b` can never slip through. Commas and keywords inside parentheses or
294
+ * string literals are allowed (CHECK constraints, functional defaults).
295
+ */
296
+ function isColumnDefinitionTail(tail: string): boolean {
297
+ let depth = 0;
298
+ let quote: string | null = null;
299
+ for (let i = 0; i < tail.length; i++) {
300
+ const ch = tail.charAt(i);
301
+ if (quote !== null) {
302
+ if (ch === quote) {
303
+ if (tail.charAt(i + 1) === quote) {
304
+ i++; // doubled quote inside a string literal
305
+ continue;
306
+ }
307
+ quote = null;
308
+ }
309
+ continue;
310
+ }
311
+ if (ch === "'" || ch === '"' || ch === "`") {
312
+ quote = ch;
313
+ continue;
314
+ }
315
+ if (ch === "(") {
316
+ depth++;
317
+ continue;
318
+ }
319
+ if (ch === ")") {
320
+ depth = Math.max(0, depth - 1);
321
+ continue;
322
+ }
323
+ if (depth === 0 && ch === ",") return false;
324
+ if (
325
+ depth === 0 &&
326
+ ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_")
327
+ ) {
328
+ if (/^(DROP|RENAME)\b/i.test(tail.slice(i))) return false;
329
+ }
330
+ }
331
+ return true;
332
+ }
333
+
334
+ export type DdlTargetKind =
335
+ | "alter-add-column"
336
+ | "create-table"
337
+ | "create-index"
338
+ | "create-trigger";
339
+
340
+ export interface DdlTarget {
341
+ kind: DdlTargetKind;
342
+ /** Table / index / trigger name that must exist in the live schema. */
343
+ name: string;
344
+ /** Column name that must exist on `name` (only for "alter-add-column"). */
345
+ column?: string;
346
+ /** Table an index is built on (informational — the index itself is verified). */
347
+ onTable?: string;
348
+ }
349
+
350
+ /**
351
+ * Parse a single SQL statement into one of the four DDL forms the self-heal
352
+ * gate can prove against the live schema:
353
+ *
354
+ * - `ALTER TABLE <t> ADD [COLUMN] <c> <column-definition>`
355
+ * - `CREATE TABLE [IF NOT EXISTS] <t> ( ... )`
356
+ * - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] <i> ON <t> ( ... )`
357
+ * - `CREATE [TEMP|TEMPORARY] TRIGGER [IF NOT EXISTS] <t> ...`
358
+ *
359
+ * Returns null for anything else — UPDATE / INSERT / DELETE, DROP, ALTER ...
360
+ * RENAME, CREATE VIEW / VIRTUAL TABLE, PRAGMA writes, VACUUM, REPLACE,
361
+ * multi-action ALTER — so the gate refuses to self-heal whenever any statement
362
+ * is not one of the supported forms.
363
+ */
364
+ export function parseStatementDdl(stmt: string): DdlTarget | null {
365
+ const s = stmt.trim();
366
+ if (s.length === 0) return null;
367
+ const start = skipWs(s, 0);
368
+
369
+ // ── CREATE forms ──────────────────────────────────────────────────────────
370
+ const createEnd = matchKeyword(s, start, "CREATE");
371
+ if (createEnd !== null) {
372
+ // CREATE TABLE [IF NOT EXISTS] <t> ( ... )
373
+ const tableEnd = matchKeyword(s, createEnd, "TABLE");
374
+ if (tableEnd !== null) {
375
+ const ident = matchIdent(s, skipWs(s, consumeIfNotExists(s, tableEnd)));
376
+ if (ident === null) return null;
377
+ const after = skipWs(s, ident.end);
378
+ // Require the column-list form — rejects `CREATE TABLE ... AS SELECT`.
379
+ if (after >= s.length || s.charAt(after) !== "(") return null;
380
+ return { kind: "create-table", name: ident.name };
381
+ }
382
+
383
+ // CREATE [UNIQUE] INDEX [IF NOT EXISTS] <i> ON <t> ( ... )
384
+ const uniqueEnd = matchKeyword(s, createEnd, "UNIQUE");
385
+ const indexStart = uniqueEnd !== null ? uniqueEnd : createEnd;
386
+ const indexEnd = matchKeyword(s, indexStart, "INDEX");
387
+ if (indexEnd !== null) {
388
+ const ident = matchIdent(s, skipWs(s, consumeIfNotExists(s, indexEnd)));
389
+ if (ident === null) return null;
390
+ const onEnd = matchKeyword(s, ident.end, "ON");
391
+ if (onEnd === null) return null;
392
+ const tableIdent = matchIdent(s, skipWs(s, onEnd));
393
+ if (tableIdent === null) return null;
394
+ const after = skipWs(s, tableIdent.end);
395
+ if (after >= s.length || s[after] !== "(") return null;
396
+ return { kind: "create-index", name: ident.name, onTable: tableIdent.name };
397
+ }
398
+
399
+ // CREATE [TEMP | TEMPORARY] TRIGGER [IF NOT EXISTS] <i> ...
400
+ const tempEnd = matchKeyword(s, createEnd, "TEMP");
401
+ const tmpEnd = tempEnd !== null ? tempEnd : matchKeyword(s, createEnd, "TEMPORARY");
402
+ const triggerEnd = matchKeyword(s, tmpEnd ?? createEnd, "TRIGGER");
403
+ if (triggerEnd !== null) {
404
+ const ident = matchIdent(s, skipWs(s, consumeIfNotExists(s, triggerEnd)));
405
+ if (ident === null) return null;
406
+ return { kind: "create-trigger", name: ident.name };
407
+ }
408
+
409
+ return null; // CREATE VIEW / CREATE VIRTUAL TABLE / any other CREATE form
410
+ }
411
+
412
+ // ── ALTER TABLE <t> ADD [COLUMN] <c> <column-definition> ──────────────────
413
+ const alterEnd = matchKeyword(s, start, "ALTER");
414
+ if (alterEnd !== null) {
415
+ const tableEnd = matchKeyword(s, alterEnd, "TABLE");
416
+ if (tableEnd === null) return null;
417
+ const tableIdent = matchIdent(s, skipWs(s, tableEnd));
418
+ if (tableIdent === null) return null;
419
+ const addEnd = matchKeyword(s, tableIdent.end, "ADD");
420
+ if (addEnd === null) return null;
421
+ const columnEnd = matchKeyword(s, addEnd, "COLUMN");
422
+ const columnStart = columnEnd !== null ? columnEnd : addEnd;
423
+ const columnIdent = matchIdent(s, skipWs(s, columnStart));
424
+ if (columnIdent === null) return null;
425
+ if (!isColumnDefinitionTail(s.slice(skipWs(s, columnIdent.end)))) return null;
426
+ return { kind: "alter-add-column", name: tableIdent.name, column: columnIdent.name };
427
+ }
428
+
429
+ return null;
430
+ }
431
+
432
+ function quoteIdent(name: string): string {
433
+ return `"${name.replace(/"/g, '""')}"`;
434
+ }
435
+
436
+ /** True when `column` exists on `table` in the live schema (PRAGMA table_info). */
437
+ function columnExists(db: Database, table: string, column: string): boolean {
438
+ const rows = db
439
+ .query<{ name: string }, []>(`PRAGMA table_info(${quoteIdent(table)})`)
440
+ .all();
441
+ return rows.some((r) => r.name === column);
442
+ }
443
+
444
+ /** True when a table / index / trigger of `name` exists in the live schema. */
445
+ function schemaObjectExists(
446
+ db: Database,
447
+ type: "table" | "index" | "trigger",
448
+ name: string,
449
+ ): boolean {
450
+ const row = db
451
+ .query<{ cnt: number }, [string, string]>(
452
+ "SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type = ? AND name = ?",
453
+ )
454
+ .get(type, name);
455
+ return (row?.cnt ?? 0) > 0;
456
+ }
457
+
458
+ /**
459
+ * Narrow, source-independent recovery gate. Called from the error catch in
460
+ * `runPendingMigrations` instead of trusting the error string.
461
+ *
462
+ * Splits `up` into statements and requires every statement to be one of the
463
+ * four supported DDL forms (`parseStatementDdl`), then verifies every declared
464
+ * schema target (column / table / index / trigger) against the LIVE schema. If
465
+ * any statement is unrecognised, data-changing, or destructive, or any declared
466
+ * target is missing, this throws and the runner fails closed — it refuses to
467
+ * record the version.
468
+ *
469
+ * Empty marker migrations (no DDL) never reach the caller's catch and return
470
+ * here defensively with nothing to prove.
471
+ */
472
+ export function assertSelfHealEligible(db: Database, up: string): void {
473
+ const statements = up
474
+ .split(";")
475
+ .map((stmt) => stmt.trim())
476
+ .filter((stmt) => stmt.length > 0);
477
+
478
+ if (statements.length === 0) return;
479
+
480
+ const targets: DdlTarget[] = [];
481
+ for (const statement of statements) {
482
+ const target = parseStatementDdl(statement);
483
+ if (target === null) {
484
+ throw new Error(
485
+ `statement is not a supported idempotent DDL form (ALTER TABLE ... ADD COLUMN, ` +
486
+ `CREATE TABLE, CREATE INDEX, CREATE TRIGGER): "${statement}"`,
487
+ );
488
+ }
489
+ targets.push(target);
490
+ }
491
+
492
+ for (const target of targets) {
493
+ switch (target.kind) {
494
+ case "alter-add-column":
495
+ if (!columnExists(db, target.name, target.column!)) {
496
+ throw new Error(
497
+ `column "${target.column}" is missing on table "${target.name}"`,
498
+ );
499
+ }
500
+ break;
501
+ case "create-table":
502
+ if (!schemaObjectExists(db, "table", target.name)) {
503
+ throw new Error(`table "${target.name}" does not exist`);
504
+ }
505
+ break;
506
+ case "create-index":
507
+ if (!schemaObjectExists(db, "index", target.name)) {
508
+ throw new Error(`index "${target.name}" does not exist`);
509
+ }
510
+ break;
511
+ case "create-trigger":
512
+ if (!schemaObjectExists(db, "trigger", target.name)) {
513
+ throw new Error(`trigger "${target.name}" does not exist`);
514
+ }
515
+ break;
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Integrity check that runs at the top of `runPendingMigrations`, before any
522
+ * pending work is selected. For every applied version it recomputes
523
+ * `sha256(file.content)` from the on-disk migration file and requires exact
524
+ * equality with the recorded checksum. An edited or renamed migration file, or
525
+ * a missing file for an applied version, stops the whole run. There is no
526
+ * allowlist to bypass this — recorded state is never trusted.
527
+ */
528
+ export function verifyRecordedChecksums(
529
+ db: Database,
530
+ files: MigrationFile[],
531
+ applied: Set<number>,
532
+ ): void {
533
+ if (applied.size === 0) return;
534
+
535
+ const fileByVersion = new Map(files.map((f) => [f.version, f] as const));
536
+ const rows = db
537
+ .query<{ version: number; checksum: string }, []>(
538
+ "SELECT version, checksum FROM _schema_version",
539
+ )
540
+ .all();
541
+
542
+ for (const row of rows) {
543
+ const file = fileByVersion.get(row.version);
544
+ if (file === undefined) {
545
+ throw new Error(
546
+ `[migrations] fail closed: applied version ${row.version} has no matching migration file`,
547
+ );
548
+ }
549
+ const current = computeChecksum(file.content);
550
+ if (current !== row.checksum) {
551
+ throw new Error(
552
+ `[migrations] fail closed: checksum mismatch for applied version ${row.version} ` +
553
+ `(${file.name}); recorded ${row.checksum} != current ${current}. Refusing to continue.`,
554
+ );
555
+ }
556
+ }
557
+ }
558
+
213
559
  // ── Core migration actions ─────────────────────────────────────────────────────
214
560
 
215
561
  export interface MigrationResult {
@@ -224,6 +570,13 @@ export async function runPendingMigrations(db?: Database): Promise<MigrationResu
224
570
 
225
571
  const files = await getMigrationFiles();
226
572
  const applied = getAppliedVersions(_db);
573
+
574
+ // R-2: before any pending work is selected, prove every already-recorded
575
+ // version still matches its on-disk migration file. An edited or renamed
576
+ // migration file, or a missing file, stops the whole run — recorded state
577
+ // is never trusted.
578
+ verifyRecordedChecksums(_db, files, applied);
579
+
227
580
  const pending = files.filter((f) => !applied.has(f.version));
228
581
 
229
582
  if (pending.length === 0) return [];
@@ -245,16 +598,25 @@ export async function runPendingMigrations(db?: Database): Promise<MigrationResu
245
598
  );
246
599
  })();
247
600
  } catch (err: unknown) {
248
- const msg = (err as { message?: string }).message ?? "";
249
- // Column was already added by shared-db.ts runMigrations() — self-heal by recording as applied
250
- if (msg.includes("duplicate column name") || msg.includes("already exists")) {
251
- _db.run(
252
- `INSERT OR IGNORE INTO _schema_version (version, name, checksum) VALUES (?, ?, ?)`,
253
- [file.version, file.name, checksum],
601
+ // R-2: a duplicate-column / already-exists error only proves the FIRST
602
+ // colliding statement failed — db.exec(up) aborts there, so later
603
+ // columns, tables, indexes, and backfills may still be missing. Never
604
+ // trust the error string: self-heal only when every statement is a
605
+ // supported idempotent DDL form AND every declared schema target is
606
+ // verifiably present in the live schema. Otherwise fail closed.
607
+ try {
608
+ assertSelfHealEligible(_db, up);
609
+ } catch (selfHealErr: unknown) {
610
+ throw new Error(
611
+ `Migration ${file.version} (${file.name}) failed and is not self-heal eligible; ` +
612
+ `refusing to record it. Original error: ${(err as Error).message}. ` +
613
+ `Self-heal refusal: ${(selfHealErr as Error).message}`,
254
614
  );
255
- } else {
256
- throw err;
257
615
  }
616
+ _db.run(
617
+ `INSERT OR IGNORE INTO _schema_version (version, name, checksum) VALUES (?, ?, ?)`,
618
+ [file.version, file.name, checksum],
619
+ );
258
620
  }
259
621
 
260
622
  results.push({ version: file.version, name: file.name, action: "applied" });
package/src/prefill.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * prefill.ts — Shared session-start context builder for host adapters.
3
+ *
4
+ * Keeps the "already pre-filled" experience consistent across Claude Code,
5
+ * OpenCode, Pi, and any future host that can call into openltm-core.
6
+ */
7
+ import { getContextMerge, type Memory } from "./db.js";
8
+ import { readConfigSync } from "./config.js";
9
+
10
+ export interface PrefillOptions {
11
+ project: string;
12
+ maxMemories?: number;
13
+ maxGlobalMemories?: number;
14
+ maxLines?: number;
15
+ header?: string;
16
+ }
17
+
18
+ export interface PrefillSelection {
19
+ globals: Memory[];
20
+ scoped: Memory[];
21
+ }
22
+
23
+ const DEFAULT_HEADER = "## Prior Knowledge (LTM)";
24
+
25
+ function oneLine(text: string): string {
26
+ return text.replace(/\s+/g, " ").trim();
27
+ }
28
+
29
+ function clampPositiveInt(value: number | undefined, fallback: number): number {
30
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return fallback;
31
+ return Math.floor(value);
32
+ }
33
+
34
+ function formatMemory(memory: Pick<Memory, "id" | "content" | "category" | "importance">): string {
35
+ return `- [${memory.id}] (${memory.category}/${memory.importance}) ${oneLine(memory.content)}`;
36
+ }
37
+
38
+ function trimLines(lines: string[], maxLines: number): string[] {
39
+ if (lines.length <= maxLines) return lines;
40
+ const trimmed = lines.slice(0, Math.max(0, maxLines - 1));
41
+ trimmed.push("… (truncated)");
42
+ return trimmed;
43
+ }
44
+
45
+ export function deriveProjectFromCwd(cwd: string): string {
46
+ return cwd.replace(/\/$/, "").split("/").pop() ?? "";
47
+ }
48
+
49
+ export function selectPrefillMemories(project: string, opts: Omit<PrefillOptions, "project"> = {}): PrefillSelection {
50
+ const cfg = readConfigSync();
51
+ const configuredTopN = cfg.ltm?.injectTopN;
52
+ const maxMemories = clampPositiveInt(opts.maxMemories, configuredTopN ?? 8);
53
+ const desiredGlobals = clampPositiveInt(opts.maxGlobalMemories, Math.min(3, Math.max(1, Math.ceil(maxMemories / 3))));
54
+
55
+ const merged = getContextMerge(project);
56
+
57
+ const globals = merged.globals.slice(0, Math.min(desiredGlobals, maxMemories));
58
+ const remaining = Math.max(0, maxMemories - globals.length);
59
+ const scoped = merged.scoped.slice(0, remaining);
60
+
61
+ if (scoped.length < remaining && globals.length < maxMemories) {
62
+ const refillGlobals = merged.globals.slice(globals.length, Math.min(merged.globals.length, maxMemories - scoped.length));
63
+ globals.push(...refillGlobals);
64
+ }
65
+
66
+ return { globals, scoped };
67
+ }
68
+
69
+ export function buildPrefillContext(opts: PrefillOptions): string {
70
+ const maxLines = clampPositiveInt(opts.maxLines, 18);
71
+ const { globals, scoped } = selectPrefillMemories(opts.project, opts);
72
+ if (globals.length === 0 && scoped.length === 0) return "";
73
+
74
+ const lines: string[] = [opts.header ?? DEFAULT_HEADER, ""];
75
+
76
+ if (globals.length > 0) {
77
+ lines.push("Global:");
78
+ for (const memory of globals) lines.push(formatMemory(memory));
79
+ lines.push("");
80
+ }
81
+
82
+ if (scoped.length > 0) {
83
+ lines.push(`Project (${opts.project}):`);
84
+ for (const memory of scoped) lines.push(formatMemory(memory));
85
+ lines.push("");
86
+ }
87
+
88
+ return trimLines(lines, maxLines).join("\n").trimEnd() + "\n";
89
+ }
@@ -27,6 +27,10 @@ export interface ExplainerInput {
27
27
  semanticScore?: number | null;
28
28
  }
29
29
 
30
+ function roundScore(value: number): number {
31
+ return Math.round(value * 1_000_000) / 1_000_000;
32
+ }
33
+
30
34
  /** Compute memory temperature from access pattern. */
31
35
  export function computeTemperature(
32
36
  recallCount: number,
@@ -49,7 +53,7 @@ export function buildExplainer(input: ExplainerInput): RecallExplainer {
49
53
  const daysSince = input.last_recalled_at
50
54
  ? (Date.now() - new Date(input.last_recalled_at).getTime()) / 86_400_000
51
55
  : 90;
52
- const recencyBoost = Math.max(0, 1 - daysSince / 90);
56
+ const recencyBoost = roundScore(Math.max(0, 1 - daysSince / 90));
53
57
 
54
58
  const ftsRank = input.ftsRank ?? null;
55
59
  const semanticScore = input.semanticScore ?? null;
@@ -70,7 +74,7 @@ export function buildExplainer(input: ExplainerInput): RecallExplainer {
70
74
  semanticScore,
71
75
  importanceBoost,
72
76
  recencyBoost,
73
- totalScore,
77
+ totalScore: roundScore(totalScore),
74
78
  temperature: computeTemperature(input.recall_count, input.last_recalled_at),
75
79
  };
76
80
  }