@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 +3 -0
- package/package.json +2 -2
- package/src/__tests__/cli/hook.test.ts +47 -0
- package/src/__tests__/prefill.test.ts +56 -0
- package/src/cli/bin.ts +4 -4
- package/src/cli/detect.ts +5 -34
- package/src/cli/hook.ts +55 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/install.ts +8 -44
- package/src/cli/targets.ts +62 -0
- package/src/index.ts +4 -0
- package/src/migrations.ts +370 -8
- package/src/prefill.ts +89 -0
- package/src/recall/explainer.ts +6 -2
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.
|
|
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.
|
|
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
|
|
13
|
-
* bunx @rohirik/openltm-core mcp-serve # MCP server
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 —
|
|
2
|
+
* cli/hook.ts — Lightweight Claude-compatible hook dispatcher.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 —
|
|
53
|
+
* runHook — dispatch a lifecycle hook.
|
|
15
54
|
*
|
|
16
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
process.
|
|
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";
|
package/src/cli/install.ts
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
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(
|
|
131
|
-
const r = await
|
|
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(
|
|
133
|
+
s.stop(`${target.label}: installed${dryRun ? " (dry-run)" : ""}`);
|
|
136
134
|
} else if (r.status === "skipped") {
|
|
137
|
-
s.stop(
|
|
135
|
+
s.stop(`${target.label}: already configured (skipped)`);
|
|
138
136
|
} else {
|
|
139
|
-
s.stop(
|
|
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
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
}
|
package/src/recall/explainer.ts
CHANGED
|
@@ -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
|
}
|