@rohirik/openltm-core 2.8.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/README.md +67 -0
- package/assets/opencode/agents/aegis.md +211 -0
- package/assets/opencode/plugins/aegis.ts +3 -0
- package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
- package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
- package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
- package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
- package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
- package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
- package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
- package/package.json +41 -0
- package/src/__tests__/cli/claude.test.ts +122 -0
- package/src/__tests__/cli/detect.test.ts +91 -0
- package/src/__tests__/cli/install.test.ts +161 -0
- package/src/__tests__/cli/opencode.test.ts +169 -0
- package/src/__tests__/cli/pi.test.ts +113 -0
- package/src/__tests__/cli.test.ts +70 -0
- package/src/__tests__/events/crossProcess.test.ts +82 -0
- package/src/__tests__/events/index.test.ts +32 -0
- package/src/__tests__/extensions.test.ts +81 -0
- package/src/__tests__/migrations/retention.test.ts +118 -0
- package/src/__tests__/queue/index.test.ts +61 -0
- package/src/__tests__/scheduler/index.test.ts +39 -0
- package/src/__tests__/vec/index.test.ts +130 -0
- package/src/__tests__/vec/parity.test.ts +70 -0
- package/src/adapterTypes.ts +23 -0
- package/src/cli/_shared.ts +120 -0
- package/src/cli/bin.ts +97 -0
- package/src/cli/claude.ts +124 -0
- package/src/cli/detect.ts +55 -0
- package/src/cli/hook.ts +25 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/install.ts +185 -0
- package/src/cli/opencode.ts +193 -0
- package/src/cli/pi.ts +74 -0
- package/src/cli/types.ts +78 -0
- package/src/config.ts +163 -0
- package/src/context.ts +172 -0
- package/src/dao/conflicts.ts +26 -0
- package/src/dao/contextItems.ts +70 -0
- package/src/dao/embeddings.ts +78 -0
- package/src/dao/index.ts +9 -0
- package/src/dao/provenanceAudit.ts +108 -0
- package/src/dao/types.ts +142 -0
- package/src/db.ts +780 -0
- package/src/dedup.ts +12 -0
- package/src/embeddings.ts +386 -0
- package/src/events/index.ts +130 -0
- package/src/extensions.ts +140 -0
- package/src/graph.ts +268 -0
- package/src/index.ts +95 -0
- package/src/janitor/archive.ts +66 -0
- package/src/janitor/decay.ts +60 -0
- package/src/janitor/dedup.ts +333 -0
- package/src/janitor/embeddings.ts +209 -0
- package/src/janitor/index.ts +215 -0
- package/src/janitor/promote.ts +188 -0
- package/src/janitor/providers/anthropic.ts +91 -0
- package/src/janitor/providers/cohere.ts +135 -0
- package/src/janitor/providers/gemini.ts +156 -0
- package/src/janitor/providers/ollama.ts +177 -0
- package/src/janitor/providers/openai.ts +121 -0
- package/src/janitor/providers/openrouter.ts +182 -0
- package/src/janitor/providers/types.ts +154 -0
- package/src/janitor/providers/utils.ts +35 -0
- package/src/janitor/supersedes.ts +199 -0
- package/src/lib/honker.ts +54 -0
- package/src/lib/honkerTypes.ts +109 -0
- package/src/lib/jsonlLogger.ts +92 -0
- package/src/lib/writeQueue.ts +28 -0
- package/src/migrations.ts +415 -0
- package/src/paths.ts +22 -0
- package/src/proposals.ts +120 -0
- package/src/providers/disabled.ts +19 -0
- package/src/providers/embeddingProvider.ts +49 -0
- package/src/providers/gemini.ts +37 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/ollama.ts +43 -0
- package/src/providers/openai.ts +35 -0
- package/src/queue/index.ts +53 -0
- package/src/queue/worker.ts +77 -0
- package/src/recall/categorise.ts +139 -0
- package/src/recall/explainer.ts +76 -0
- package/src/scheduler/index.ts +97 -0
- package/src/schema.sql +191 -0
- package/src/secretsScrubber.ts +105 -0
- package/src/shared-db.ts +158 -0
- package/src/vec/index.ts +161 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* events/index.test.ts — dormant-path contract for the graph-app liveness bus.
|
|
3
|
+
* Without an external libhonker_ext binary, notify must report false and the
|
|
4
|
+
* listener must return an inert handle so the caller keeps its fs.watch +
|
|
5
|
+
* 3s-debounce file-watcher fallback.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
8
|
+
import { notifyLtm, startLtmListener, LTM_CHANNEL } from "../../events/index.js";
|
|
9
|
+
import { _resetHonkerForTesting } from "../../lib/honker.js";
|
|
10
|
+
|
|
11
|
+
describe("events — dormant path (no honker)", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
_resetHonkerForTesting();
|
|
14
|
+
delete process.env["LTM_HONKER_EXT"];
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("exposes the stable channel name", () => {
|
|
18
|
+
expect(LTM_CHANNEL).toBe("ltm");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("notifyLtm returns false when honker is unavailable", () => {
|
|
22
|
+
expect(notifyLtm({ type: "refresh" })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("startLtmListener returns an inert handle and never invokes the callback", async () => {
|
|
26
|
+
let calls = 0;
|
|
27
|
+
const h = startLtmListener(() => { calls++; });
|
|
28
|
+
expect(h.running).toBe(false);
|
|
29
|
+
await expect(h.stop()).resolves.toBeUndefined();
|
|
30
|
+
expect(calls).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import {
|
|
4
|
+
locateSystemSqlite,
|
|
5
|
+
ensureCustomSqlite,
|
|
6
|
+
loadExtensions,
|
|
7
|
+
getCapabilities,
|
|
8
|
+
resetCapabilitiesForTesting,
|
|
9
|
+
} from "../extensions.js";
|
|
10
|
+
|
|
11
|
+
describe("extensions — capability probe", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
resetCapabilitiesForTesting();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("locateSystemSqlite returns a string path or null, never throws", () => {
|
|
17
|
+
const p = locateSystemSqlite();
|
|
18
|
+
expect(p === null || typeof p === "string").toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("getCapabilities before any probe returns all-false without throwing", () => {
|
|
22
|
+
const caps = getCapabilities();
|
|
23
|
+
expect(caps).toEqual({ customSqlite: false, vec: false, honker: false });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("loadExtensions never throws and returns a Capabilities shape", () => {
|
|
27
|
+
ensureCustomSqlite();
|
|
28
|
+
const db = new Database(":memory:");
|
|
29
|
+
const caps = loadExtensions(db);
|
|
30
|
+
expect(typeof caps.customSqlite).toBe("boolean");
|
|
31
|
+
expect(typeof caps.vec).toBe("boolean");
|
|
32
|
+
expect(typeof caps.honker).toBe("boolean");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("honker is false when no LTM_HONKER_EXT binary is configured", () => {
|
|
36
|
+
const prev = process.env["LTM_HONKER_EXT"];
|
|
37
|
+
delete process.env["LTM_HONKER_EXT"];
|
|
38
|
+
ensureCustomSqlite();
|
|
39
|
+
const db = new Database(":memory:");
|
|
40
|
+
const caps = loadExtensions(db);
|
|
41
|
+
expect(caps.honker).toBe(false);
|
|
42
|
+
if (prev) process.env["LTM_HONKER_EXT"] = prev;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("respects LTM_DISABLE_VEC — vec capability is false when force-disabled", () => {
|
|
46
|
+
const prev = process.env["LTM_DISABLE_VEC"];
|
|
47
|
+
process.env["LTM_DISABLE_VEC"] = "1";
|
|
48
|
+
const db = new Database(":memory:");
|
|
49
|
+
const caps = loadExtensions(db);
|
|
50
|
+
expect(caps.vec).toBe(false);
|
|
51
|
+
if (prev === undefined) delete process.env["LTM_DISABLE_VEC"];
|
|
52
|
+
else process.env["LTM_DISABLE_VEC"] = prev;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("when customSqlite is active, vec loads and vec0 KNN works end-to-end", () => {
|
|
56
|
+
ensureCustomSqlite();
|
|
57
|
+
const db = new Database(":memory:");
|
|
58
|
+
const caps = loadExtensions(db);
|
|
59
|
+
// setCustomSQLite is process-global and only takes effect before the first
|
|
60
|
+
// Database opens. In a shared test process an earlier file may have already
|
|
61
|
+
// opened a bundled-sqlite connection, leaving customSqlite false — that is
|
|
62
|
+
// the graceful-degradation path, so skip the KNN assertion there.
|
|
63
|
+
if (!caps.customSqlite || process.env["LTM_DISABLE_VEC"]) return;
|
|
64
|
+
expect(caps.vec).toBe(true);
|
|
65
|
+
db.run("create virtual table t using vec0(embedding float[4])");
|
|
66
|
+
const q = new Uint8Array(new Float32Array([1, 2, 3, 4]).buffer);
|
|
67
|
+
db.run("insert into t(rowid, embedding) values (1, ?)", [q]);
|
|
68
|
+
const r = db
|
|
69
|
+
.query("select rowid, distance from t where embedding match ? order by distance limit 1")
|
|
70
|
+
.all(q) as Array<{ rowid: number; distance: number }>;
|
|
71
|
+
expect(r[0]?.rowid).toBe(1);
|
|
72
|
+
expect(r[0]?.distance).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("getCapabilities returns the cached result after loadExtensions", () => {
|
|
76
|
+
ensureCustomSqlite();
|
|
77
|
+
const db = new Database(":memory:");
|
|
78
|
+
const caps = loadExtensions(db);
|
|
79
|
+
expect(getCapabilities()).toEqual(caps);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, utimesSync, statSync, existsSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { enforceRetention, getRetentionDefault } from "../../migrations.js";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = mkdtempSync(join(tmpdir(), "ltm-retention-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function makeBak(name: string, ageSeconds: number = 120): string {
|
|
18
|
+
const path = join(tmpDir, name);
|
|
19
|
+
writeFileSync(path, "x".repeat(100));
|
|
20
|
+
const past = new Date(Date.now() - ageSeconds * 1000);
|
|
21
|
+
utimesSync(path, past, past);
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("enforceRetention", () => {
|
|
26
|
+
it("returns empty result on empty directory", async () => {
|
|
27
|
+
const result = await enforceRetention(1, { dbPath: join(tmpDir, "openltm.db") });
|
|
28
|
+
expect(result.deleted).toEqual([]);
|
|
29
|
+
expect(result.kept).toEqual([]);
|
|
30
|
+
expect(result.warnings).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps all files when under max", async () => {
|
|
34
|
+
const a = makeBak("openltm.db.bak-2026-01-01T00-00-00-000Z", 200);
|
|
35
|
+
const b = makeBak("openltm.db.bak-2026-01-02T00-00-00-000Z", 200);
|
|
36
|
+
const result = await enforceRetention(5, { dbPath: join(tmpDir, "openltm.db") });
|
|
37
|
+
expect(result.deleted).toEqual([]);
|
|
38
|
+
expect(result.kept.sort()).toEqual([a, b].sort());
|
|
39
|
+
expect(existsSync(a)).toBe(true);
|
|
40
|
+
expect(existsSync(b)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("deletes oldest when over max", async () => {
|
|
44
|
+
const f1 = makeBak("openltm.db.bak-2026-01-01T00-00-00-000Z", 200);
|
|
45
|
+
const f2 = makeBak("openltm.db.bak-2026-01-02T00-00-00-000Z", 200);
|
|
46
|
+
const f3 = makeBak("openltm.db.bak-2026-01-03T00-00-00-000Z", 200);
|
|
47
|
+
const f4 = makeBak("openltm.db.bak-2026-01-04T00-00-00-000Z", 200);
|
|
48
|
+
const f5 = makeBak("openltm.db.bak-2026-01-05T00-00-00-000Z", 200);
|
|
49
|
+
const result = await enforceRetention(2, { dbPath: join(tmpDir, "openltm.db") });
|
|
50
|
+
expect(result.deleted.sort()).toEqual([f1, f2, f3].sort());
|
|
51
|
+
expect(result.kept.sort()).toEqual([f4, f5].sort());
|
|
52
|
+
expect(existsSync(f1)).toBe(false);
|
|
53
|
+
expect(existsSync(f5)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("never deletes the currentBackupPath", async () => {
|
|
57
|
+
const f1 = makeBak("openltm.db.bak-2026-01-01T00-00-00-000Z", 200);
|
|
58
|
+
const f2 = makeBak("openltm.db.bak-2026-01-02T00-00-00-000Z", 200);
|
|
59
|
+
const f3 = makeBak("openltm.db.bak-2026-01-03T00-00-00-000Z", 200);
|
|
60
|
+
const result = await enforceRetention(1, {
|
|
61
|
+
dbPath: join(tmpDir, "openltm.db"),
|
|
62
|
+
currentBackupPath: f2,
|
|
63
|
+
});
|
|
64
|
+
expect(result.deleted).not.toContain(f2);
|
|
65
|
+
expect(result.kept).toContain(f2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("skips files newer than gracePeriodMs", async () => {
|
|
69
|
+
const old = makeBak("openltm.db.bak-2026-01-01T00-00-00-000Z", 200);
|
|
70
|
+
const fresh = makeBak("openltm.db.bak-2026-06-03T16-00-00-000Z", 0);
|
|
71
|
+
const result = await enforceRetention(0, {
|
|
72
|
+
dbPath: join(tmpDir, "openltm.db"),
|
|
73
|
+
gracePeriodMs: 60_000,
|
|
74
|
+
});
|
|
75
|
+
expect(result.deleted).toEqual([old]);
|
|
76
|
+
expect(result.kept).toEqual([fresh]);
|
|
77
|
+
expect(existsSync(fresh)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reads LTM_BACKUP_RETENTION env var as default", () => {
|
|
81
|
+
const original = process.env["LTM_BACKUP_RETENTION"];
|
|
82
|
+
try {
|
|
83
|
+
process.env["LTM_BACKUP_RETENTION"] = "5";
|
|
84
|
+
expect(getRetentionDefault()).toBe(5);
|
|
85
|
+
process.env["LTM_BACKUP_RETENTION"] = "invalid";
|
|
86
|
+
expect(getRetentionDefault()).toBe(1);
|
|
87
|
+
process.env["LTM_BACKUP_RETENTION"] = "-1";
|
|
88
|
+
expect(getRetentionDefault()).toBe(1);
|
|
89
|
+
delete process.env["LTM_BACKUP_RETENTION"];
|
|
90
|
+
expect(getRetentionDefault()).toBe(1);
|
|
91
|
+
} finally {
|
|
92
|
+
if (original === undefined) delete process.env["LTM_BACKUP_RETENTION"];
|
|
93
|
+
else process.env["LTM_BACKUP_RETENTION"] = original;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does not touch non-.bak files", async () => {
|
|
98
|
+
writeFileSync(join(tmpDir, "openltm.db"), "active");
|
|
99
|
+
writeFileSync(join(tmpDir, "openltm.db-shm"), "shm");
|
|
100
|
+
writeFileSync(join(tmpDir, "openltm.db-wal"), "wal");
|
|
101
|
+
const bak = makeBak("openltm.db.bak-2026-01-01T00-00-00-000Z", 200);
|
|
102
|
+
const result = await enforceRetention(0, { dbPath: join(tmpDir, "openltm.db") });
|
|
103
|
+
expect(result.deleted).toEqual([bak]);
|
|
104
|
+
expect(existsSync(join(tmpDir, "openltm.db"))).toBe(true);
|
|
105
|
+
expect(existsSync(join(tmpDir, "openltm.db-shm"))).toBe(true);
|
|
106
|
+
expect(existsSync(join(tmpDir, "openltm.db-wal"))).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("is idempotent and silent when files disappear before the call", async () => {
|
|
110
|
+
const f1 = makeBak("openltm.db.bak-2026-01-01T00-00-00-000Z", 200);
|
|
111
|
+
const f2 = makeBak("openltm.db.bak-2026-01-02T00-00-00-000Z", 200);
|
|
112
|
+
rmSync(f1);
|
|
113
|
+
const result = await enforceRetention(0, { dbPath: join(tmpDir, "openltm.db") });
|
|
114
|
+
expect(result.deleted).toEqual([f2]);
|
|
115
|
+
expect(result.warnings).toEqual([]);
|
|
116
|
+
expect(result.kept).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queue/index.test.ts — dormant-path contract for the Honker embedding queue.
|
|
3
|
+
*
|
|
4
|
+
* Honker requires an externally-built libhonker_ext binary (LTM_HONKER_EXT)
|
|
5
|
+
* that is absent in CI, so every queue/worker export must degrade to a graceful
|
|
6
|
+
* no-op without throwing. The live claim/ack/retry path is exercised only where
|
|
7
|
+
* the binary is present.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
getEmbeddingQueue,
|
|
12
|
+
enqueueEmbedding,
|
|
13
|
+
parseEmbeddingJob,
|
|
14
|
+
EMBED_QUEUE,
|
|
15
|
+
} from "../../queue/index.js";
|
|
16
|
+
import { startEmbeddingWorker } from "../../queue/worker.js";
|
|
17
|
+
import { isHonkerAvailable, _resetHonkerForTesting } from "../../lib/honker.js";
|
|
18
|
+
|
|
19
|
+
describe("queue — dormant path (no honker)", () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
_resetHonkerForTesting();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("EMBED_QUEUE has a stable name", () => {
|
|
25
|
+
expect(EMBED_QUEUE).toBe("ltm-embeddings");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("honker is unavailable without LTM_HONKER_EXT", () => {
|
|
29
|
+
delete process.env["LTM_HONKER_EXT"];
|
|
30
|
+
expect(isHonkerAvailable()).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("getEmbeddingQueue returns null when honker is unavailable", () => {
|
|
34
|
+
expect(getEmbeddingQueue()).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("enqueueEmbedding returns null (caller falls back to inline) when unavailable", () => {
|
|
38
|
+
expect(enqueueEmbedding(123)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("startEmbeddingWorker returns an inert handle when unavailable", async () => {
|
|
42
|
+
const w = startEmbeddingWorker();
|
|
43
|
+
expect(w.running).toBe(false);
|
|
44
|
+
await expect(w.stop()).resolves.toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("parseEmbeddingJob", () => {
|
|
49
|
+
it("accepts a valid positive-integer memoryId", () => {
|
|
50
|
+
expect(parseEmbeddingJob({ memoryId: 42 })).toEqual({ memoryId: 42 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects malformed payloads", () => {
|
|
54
|
+
expect(parseEmbeddingJob(null)).toBeNull();
|
|
55
|
+
expect(parseEmbeddingJob({})).toBeNull();
|
|
56
|
+
expect(parseEmbeddingJob({ memoryId: "5" })).toBeNull();
|
|
57
|
+
expect(parseEmbeddingJob({ memoryId: 0 })).toBeNull();
|
|
58
|
+
expect(parseEmbeddingJob({ memoryId: -3 })).toBeNull();
|
|
59
|
+
expect(parseEmbeddingJob({ memoryId: 1.5 })).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler/index.test.ts — dormant-path contract for the Honker janitor cron.
|
|
3
|
+
* Without an external libhonker_ext binary, registration and the leader loop
|
|
4
|
+
* must degrade to graceful no-ops so the caller keeps startAutoRun()+HTTP poll.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
registerJanitorSchedule,
|
|
9
|
+
startJanitorScheduler,
|
|
10
|
+
JANITOR_SCHEDULE_NAME,
|
|
11
|
+
JANITOR_QUEUE,
|
|
12
|
+
JANITOR_CHANNEL,
|
|
13
|
+
DEFAULT_JANITOR_CRON,
|
|
14
|
+
} from "../../scheduler/index.js";
|
|
15
|
+
import { _resetHonkerForTesting } from "../../lib/honker.js";
|
|
16
|
+
|
|
17
|
+
describe("scheduler — dormant path (no honker)", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
_resetHonkerForTesting();
|
|
20
|
+
delete process.env["LTM_HONKER_EXT"];
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("exposes stable names + default cron", () => {
|
|
24
|
+
expect(JANITOR_SCHEDULE_NAME).toBe("ltm-janitor");
|
|
25
|
+
expect(JANITOR_QUEUE).toBe("ltm-janitor-queue");
|
|
26
|
+
expect(JANITOR_CHANNEL).toBe("janitor");
|
|
27
|
+
expect(DEFAULT_JANITOR_CRON).toBe("@every 6h");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("registerJanitorSchedule returns false when honker is unavailable", () => {
|
|
31
|
+
expect(registerJanitorSchedule()).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("startJanitorScheduler returns an inert handle when unavailable", async () => {
|
|
35
|
+
const s = startJanitorScheduler();
|
|
36
|
+
expect(s.running).toBe(false);
|
|
37
|
+
await expect(s.stop()).resolves.toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { ensureCustomSqlite, loadExtensions, getCapabilities } from "../../extensions.js";
|
|
4
|
+
import {
|
|
5
|
+
ensureVecTable,
|
|
6
|
+
upsertVec,
|
|
7
|
+
deleteVec,
|
|
8
|
+
knnVec,
|
|
9
|
+
rebuildVecIndex,
|
|
10
|
+
getActiveEmbedDim,
|
|
11
|
+
isVecAvailable,
|
|
12
|
+
DEFAULT_EMBED_DIM,
|
|
13
|
+
} from "../../vec/index.js";
|
|
14
|
+
|
|
15
|
+
const DIM = 4;
|
|
16
|
+
const blob = (a: number[]) => new Uint8Array(new Float32Array(a).buffer);
|
|
17
|
+
|
|
18
|
+
function seedEmbeddingsTable(db: Database): void {
|
|
19
|
+
db.exec(`CREATE TABLE memory_embeddings (
|
|
20
|
+
memory_id INTEGER PRIMARY KEY, embedding BLOB NOT NULL,
|
|
21
|
+
model TEXT NOT NULL DEFAULT 'unknown', dim INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')))`);
|
|
23
|
+
db.exec(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT)`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("vec module", () => {
|
|
27
|
+
let db: Database;
|
|
28
|
+
let vecOn: boolean;
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
ensureCustomSqlite();
|
|
32
|
+
db = new Database(":memory:");
|
|
33
|
+
loadExtensions(db);
|
|
34
|
+
vecOn = getCapabilities().vec;
|
|
35
|
+
seedEmbeddingsTable(db);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("getActiveEmbedDim defaults to 768 with no settings row", () => {
|
|
39
|
+
expect(getActiveEmbedDim(db)).toBe(DEFAULT_EMBED_DIM);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("getActiveEmbedDim reads the ltm.embed.dim setting", () => {
|
|
43
|
+
db.run("INSERT OR REPLACE INTO settings(key,value) VALUES ('ltm.embed.dim','512')");
|
|
44
|
+
expect(getActiveEmbedDim(db)).toBe(512);
|
|
45
|
+
db.run("DELETE FROM settings WHERE key='ltm.embed.dim'");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("isVecAvailable mirrors getCapabilities().vec", () => {
|
|
49
|
+
expect(isVecAvailable()).toBe(vecOn);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("all ops are graceful no-ops when vec is unavailable", () => {
|
|
53
|
+
if (vecOn) return; // covered by the active-path tests below
|
|
54
|
+
expect(ensureVecTable(db, DIM)).toBe(false);
|
|
55
|
+
expect(upsertVec(db, 1, blob([1, 0, 0, 0]))).toBe(false);
|
|
56
|
+
expect(deleteVec(db, 1)).toBe(false);
|
|
57
|
+
expect(knnVec(db, blob([1, 0, 0, 0]), 3)).toEqual([]);
|
|
58
|
+
expect(rebuildVecIndex(db, { dim: DIM })).toBe(-1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("ensure + upsert + KNN returns nearest by cosine similarity", () => {
|
|
62
|
+
if (!vecOn) return;
|
|
63
|
+
expect(ensureVecTable(db, DIM)).toBe(true);
|
|
64
|
+
expect(upsertVec(db, 10, blob([1, 0, 0, 0]))).toBe(true);
|
|
65
|
+
expect(upsertVec(db, 20, blob([0, 1, 0, 0]))).toBe(true);
|
|
66
|
+
expect(upsertVec(db, 30, blob([1, 1, 0, 0]))).toBe(true);
|
|
67
|
+
|
|
68
|
+
const hits = knnVec(db, blob([1, 0, 0, 0]), 3);
|
|
69
|
+
expect(hits[0]?.id).toBe(10);
|
|
70
|
+
expect(hits[0]?.similarity).toBeCloseTo(1, 5);
|
|
71
|
+
// orthogonal vector ranks last
|
|
72
|
+
expect(hits[hits.length - 1]?.id).toBe(20);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("upsert overwrites an existing vector (update path)", () => {
|
|
76
|
+
if (!vecOn) return;
|
|
77
|
+
upsertVec(db, 20, blob([1, 0, 0, 0])); // was orthogonal, now identical
|
|
78
|
+
const hits = knnVec(db, blob([1, 0, 0, 0]), 1);
|
|
79
|
+
expect(hits[0]?.similarity).toBeCloseTo(1, 5);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("deleteVec removes a row from the index", () => {
|
|
83
|
+
if (!vecOn) return;
|
|
84
|
+
expect(deleteVec(db, 30)).toBe(true);
|
|
85
|
+
const hits = knnVec(db, blob([1, 1, 0, 0]), 5);
|
|
86
|
+
expect(hits.some((h) => h.id === 30)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rebuildVecIndex repopulates from memory_embeddings for the active dim", () => {
|
|
90
|
+
if (!vecOn) return;
|
|
91
|
+
db.exec("DELETE FROM memory_embeddings");
|
|
92
|
+
for (let i = 1; i <= 5; i++) {
|
|
93
|
+
db.run("INSERT INTO memory_embeddings(memory_id, embedding, model, dim) VALUES (?,?,?,?)", [
|
|
94
|
+
i,
|
|
95
|
+
blob([i, 0, 0, 0]),
|
|
96
|
+
"nomic-embed-text",
|
|
97
|
+
DIM,
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
// a stray row at a different dim must be excluded
|
|
101
|
+
db.run("INSERT INTO memory_embeddings(memory_id, embedding, model, dim) VALUES (?,?,?,?)", [
|
|
102
|
+
99,
|
|
103
|
+
new Uint8Array(new Float32Array([1, 2]).buffer),
|
|
104
|
+
"nomic-embed-text",
|
|
105
|
+
2,
|
|
106
|
+
]);
|
|
107
|
+
const n = rebuildVecIndex(db, { dim: DIM, model: "nomic-embed-text" });
|
|
108
|
+
expect(n).toBe(5);
|
|
109
|
+
const hits = knnVec(db, blob([5, 0, 0, 0]), 1);
|
|
110
|
+
expect(hits[0]?.id).toBe(5);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("scales to 1k vectors and returns the exact nearest", () => {
|
|
114
|
+
if (!vecOn) return;
|
|
115
|
+
rebuildVecIndex(db, { dim: DIM }); // clear
|
|
116
|
+
ensureVecTable(db, DIM);
|
|
117
|
+
db.transaction(() => {
|
|
118
|
+
for (let i = 1; i <= 1000; i++) {
|
|
119
|
+
upsertVec(db, i, blob([Math.random(), Math.random(), Math.random(), Math.random()]));
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
const target = blob([0.123, 0.456, 0.789, 0.321]);
|
|
123
|
+
upsertVec(db, 7777, target);
|
|
124
|
+
const t0 = performance.now();
|
|
125
|
+
const hits = knnVec(db, target, 5);
|
|
126
|
+
const ms = performance.now() - t0;
|
|
127
|
+
expect(hits[0]?.id).toBe(7777);
|
|
128
|
+
expect(ms).toBeLessThan(500); // generous bound — ANN over 1k must be fast
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parity.test.ts — proves the sqlite-vec vec0 KNN ranking agrees with the
|
|
3
|
+
* brute-force JS-cosine ranking that getSimilarMemories falls back to. These
|
|
4
|
+
* are the two code paths the read path switches between, so their top-K order
|
|
5
|
+
* over identical data must match.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeAll } from "bun:test";
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { ensureCustomSqlite, loadExtensions, getCapabilities } from "../../extensions.js";
|
|
10
|
+
import { ensureVecTable, upsertVec, knnVec } from "../../vec/index.js";
|
|
11
|
+
|
|
12
|
+
const DIM = 8;
|
|
13
|
+
const blob = (a: number[]) => new Uint8Array(new Float32Array(a).buffer);
|
|
14
|
+
|
|
15
|
+
function cosine(a: Float32Array, b: Float32Array): number {
|
|
16
|
+
let dot = 0, na = 0, nb = 0;
|
|
17
|
+
for (let i = 0; i < a.length; i++) {
|
|
18
|
+
dot += (a[i] as number) * (b[i] as number);
|
|
19
|
+
na += (a[i] as number) ** 2;
|
|
20
|
+
nb += (b[i] as number) ** 2;
|
|
21
|
+
}
|
|
22
|
+
const d = Math.sqrt(na) * Math.sqrt(nb);
|
|
23
|
+
return d === 0 ? 0 : dot / d;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("vec/brute-force recall parity", () => {
|
|
27
|
+
let db: Database;
|
|
28
|
+
let vecOn: boolean;
|
|
29
|
+
const vectors = new Map<number, Float32Array>();
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
ensureCustomSqlite();
|
|
33
|
+
db = new Database(":memory:");
|
|
34
|
+
loadExtensions(db);
|
|
35
|
+
vecOn = getCapabilities().vec;
|
|
36
|
+
if (!vecOn) return;
|
|
37
|
+
ensureVecTable(db, DIM);
|
|
38
|
+
for (let id = 1; id <= 200; id++) {
|
|
39
|
+
const v = Array.from({ length: DIM }, () => Math.random());
|
|
40
|
+
vectors.set(id, new Float32Array(v));
|
|
41
|
+
upsertVec(db, id, blob(v));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("top-5 KNN ids match the top-5 by JS cosine", () => {
|
|
46
|
+
if (!vecOn) return;
|
|
47
|
+
const query = Array.from({ length: DIM }, () => Math.random());
|
|
48
|
+
const qVec = new Float32Array(query);
|
|
49
|
+
|
|
50
|
+
const knnIds = knnVec(db, blob(query), 5).map((h) => h.id);
|
|
51
|
+
|
|
52
|
+
const bruteIds = [...vectors.entries()]
|
|
53
|
+
.map(([id, v]) => ({ id, sim: cosine(qVec, v) }))
|
|
54
|
+
.sort((a, b) => b.sim - a.sim)
|
|
55
|
+
.slice(0, 5)
|
|
56
|
+
.map((r) => r.id);
|
|
57
|
+
|
|
58
|
+
expect(knnIds).toEqual(bruteIds);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("KNN similarity equals JS cosine for each returned hit", () => {
|
|
62
|
+
if (!vecOn) return;
|
|
63
|
+
const query = Array.from({ length: DIM }, () => Math.random());
|
|
64
|
+
const qVec = new Float32Array(query);
|
|
65
|
+
const hits = knnVec(db, blob(query), 5);
|
|
66
|
+
for (const h of hits) {
|
|
67
|
+
expect(h.similarity).toBeCloseTo(cosine(qVec, vectors.get(h.id)!), 4);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Configuration injected by the host adapter (Claude Code, OpenCode, Pi). */
|
|
2
|
+
export interface LtmCoreConfig {
|
|
3
|
+
dbPath: string;
|
|
4
|
+
schemaPath?: string;
|
|
5
|
+
migrationsDir?: string;
|
|
6
|
+
docsDir?: string;
|
|
7
|
+
decayEnabled?: boolean;
|
|
8
|
+
graphReasoning?: boolean;
|
|
9
|
+
semanticFallback?: boolean;
|
|
10
|
+
autoRelate?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Session context passed from the host to openltm-core hooks. */
|
|
14
|
+
export interface LtmAdapterContext {
|
|
15
|
+
cwd: string;
|
|
16
|
+
project?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Options provided to the adapter factory. */
|
|
20
|
+
export interface LtmAdapterOptions {
|
|
21
|
+
config: LtmCoreConfig;
|
|
22
|
+
pluginDataDir?: string;
|
|
23
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/_shared.ts — Pure wiring helpers shared by all per-target installers.
|
|
3
|
+
*
|
|
4
|
+
* No filesystem access — all functions are pure transformations over plain
|
|
5
|
+
* objects. This makes them easy to unit-test and safe to compose.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface McpServerEntry {
|
|
11
|
+
command: string;
|
|
12
|
+
args?: string[];
|
|
13
|
+
env?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ClaudeSettings {
|
|
17
|
+
mcpServers?: Record<string, McpServerEntry>;
|
|
18
|
+
hooks?: Record<string, unknown>;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deep-compare two MCP server entries for structural equivalence.
|
|
26
|
+
* Returns true only if command, args, and env are all identical.
|
|
27
|
+
*/
|
|
28
|
+
function entriesEqual(a: McpServerEntry, b: McpServerEntry): boolean {
|
|
29
|
+
if (a.command !== b.command) return false;
|
|
30
|
+
const aArgs = JSON.stringify(a.args ?? []);
|
|
31
|
+
const bArgs = JSON.stringify(b.args ?? []);
|
|
32
|
+
if (aArgs !== bArgs) return false;
|
|
33
|
+
const aEnv = JSON.stringify(a.env ?? {});
|
|
34
|
+
const bEnv = JSON.stringify(b.env ?? {});
|
|
35
|
+
return aEnv === bEnv;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* mergeMcpEntry — add or replace a named MCP server entry in settings.
|
|
40
|
+
*
|
|
41
|
+
* - If `name` is already present with the same shape → return `settings`
|
|
42
|
+
* unchanged (no new object allocation).
|
|
43
|
+
* - Otherwise → return a new settings object with the entry added/replaced.
|
|
44
|
+
*
|
|
45
|
+
* @param settings - Existing Claude settings object (not mutated).
|
|
46
|
+
* @param name - Key under `mcpServers` (e.g. `"ltm"`).
|
|
47
|
+
* @param entry - The MCP server config to set.
|
|
48
|
+
*/
|
|
49
|
+
export function mergeMcpEntry(
|
|
50
|
+
settings: ClaudeSettings,
|
|
51
|
+
name: string,
|
|
52
|
+
entry: McpServerEntry,
|
|
53
|
+
): ClaudeSettings {
|
|
54
|
+
const existing = settings.mcpServers?.[name];
|
|
55
|
+
if (existing && entriesEqual(existing, entry)) {
|
|
56
|
+
return settings;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
...settings,
|
|
60
|
+
mcpServers: {
|
|
61
|
+
...(settings.mcpServers ?? {}),
|
|
62
|
+
[name]: entry,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* wireHooks — add a hook command under `settings.hooks[hookName]` if not
|
|
69
|
+
* already present.
|
|
70
|
+
*
|
|
71
|
+
* The hook format expected by Claude Code is an array of matchers under each
|
|
72
|
+
* event name. We store hooks as:
|
|
73
|
+
* { hooks: { [hookName]: [ { command, args } ] } }
|
|
74
|
+
*
|
|
75
|
+
* Idempotency: a hook is considered present if any existing entry has the
|
|
76
|
+
* same `command` and `args[0]` (package name).
|
|
77
|
+
*
|
|
78
|
+
* @param settings - Existing Claude settings object (not mutated).
|
|
79
|
+
* @param hookName - Event name e.g. `"SessionStart"`.
|
|
80
|
+
* @param command - Executable e.g. `"bunx"`.
|
|
81
|
+
* @param args - Argument list e.g. `["@rohirik/openltm-core", "hook", "--name", "SessionStart"]`.
|
|
82
|
+
*/
|
|
83
|
+
export function wireHooks(
|
|
84
|
+
settings: ClaudeSettings,
|
|
85
|
+
hookName: string,
|
|
86
|
+
command: string,
|
|
87
|
+
args: string[],
|
|
88
|
+
): ClaudeSettings {
|
|
89
|
+
const existing = (settings.hooks ?? {}) as Record<string, unknown>;
|
|
90
|
+
const hookList = existing[hookName];
|
|
91
|
+
const newEntry = { command, args };
|
|
92
|
+
|
|
93
|
+
if (Array.isArray(hookList)) {
|
|
94
|
+
const alreadyPresent = hookList.some(
|
|
95
|
+
(h): h is { command: string; args: string[] } =>
|
|
96
|
+
typeof h === "object" &&
|
|
97
|
+
h !== null &&
|
|
98
|
+
(h as { command?: string }).command === command &&
|
|
99
|
+
Array.isArray((h as { args?: unknown[] }).args) &&
|
|
100
|
+
(h as { args: unknown[] }).args[0] === args[0],
|
|
101
|
+
);
|
|
102
|
+
if (alreadyPresent) return settings;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...settings,
|
|
106
|
+
hooks: {
|
|
107
|
+
...existing,
|
|
108
|
+
[hookName]: [...hookList, newEntry],
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
...settings,
|
|
115
|
+
hooks: {
|
|
116
|
+
...existing,
|
|
117
|
+
[hookName]: [newEntry],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|