@rohirik/openltm-core 2.12.3 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/quality.test.ts +251 -0
- package/src/cli/hook.ts +2 -2
- package/src/db.ts +179 -32
- package/src/index.ts +9 -1
- package/src/prefill.ts +160 -21
- package/src/similarity.ts +82 -0
package/package.json
CHANGED
|
@@ -0,0 +1,251 @@
|
|
|
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-quality-${process.pid}-${Date.now()}.db`;
|
|
7
|
+
const SCHEMA_PATH = join(import.meta.dir, "..", "schema.sql");
|
|
8
|
+
|
|
9
|
+
type Core = typeof import("../index.js");
|
|
10
|
+
let core: Core;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const mod = await import("../index.js");
|
|
14
|
+
core = mod;
|
|
15
|
+
const db = new Database(dbPath, { create: true });
|
|
16
|
+
db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
|
|
17
|
+
db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
|
|
18
|
+
await mod.runPendingMigrations(db);
|
|
19
|
+
mod._setDbForTesting(db);
|
|
20
|
+
}, 30_000);
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
try { unlinkSync(dbPath); } catch {}
|
|
24
|
+
try { unlinkSync(`${dbPath}-shm`); } catch {}
|
|
25
|
+
try { unlinkSync(`${dbPath}-wal`); } catch {}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("similarity — isNearDuplicate", () => {
|
|
29
|
+
it("treats an elaboration as a duplicate", () => {
|
|
30
|
+
expect(
|
|
31
|
+
core.isNearDuplicate(
|
|
32
|
+
"docker hub rate limits unauthenticated pulls",
|
|
33
|
+
"docker hub rate limits unauthenticated pulls at 100 per 6 hours",
|
|
34
|
+
),
|
|
35
|
+
).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not merge deliberate siblings that differ by a word", () => {
|
|
39
|
+
expect(
|
|
40
|
+
core.isNearDuplicate("Phase4 high importance sort check", "Phase4 low importance sort check"),
|
|
41
|
+
).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not merge memories differing only by a short marker", () => {
|
|
45
|
+
expect(core.isNearDuplicate("concurrent audit 123 -A", "concurrent audit 123 -B")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not merge unrelated content", () => {
|
|
49
|
+
expect(core.isNearDuplicate("docker hub rate limits", "use bun instead of npm")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("is a no-op on very short strings", () => {
|
|
53
|
+
expect(core.isNearDuplicate("ok", "okay")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("hygiene — isOperationalNoise", () => {
|
|
58
|
+
it("flags runtime chatter", () => {
|
|
59
|
+
expect(core.isOperationalNoise("ok")).toBe(true);
|
|
60
|
+
expect(core.isOperationalNoise("done")).toBe(true);
|
|
61
|
+
expect(core.isOperationalNoise("process took 320ms")).toBe(true);
|
|
62
|
+
expect(core.isOperationalNoise("exit status 0")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps anything that states a durable rule", () => {
|
|
66
|
+
expect(core.isOperationalNoise("Always use bun, never npm, for scripts")).toBe(false);
|
|
67
|
+
expect(core.isOperationalNoise("Avoid running migrations on the live host")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps normal knowledge that merely looks operational", () => {
|
|
71
|
+
expect(
|
|
72
|
+
core.isOperationalNoise("The graph server batches debounced WAL writes every 3 seconds"),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("hygiene — learn() noise downgrade", () => {
|
|
78
|
+
it("clamps importance for operational noise but still stores it", () => {
|
|
79
|
+
const result = core.learn({ content: "background process completed", category: "pattern", importance: 5, skipExport: true });
|
|
80
|
+
expect(result.action).toBe("created");
|
|
81
|
+
const row = core.getDb()
|
|
82
|
+
.query<{ importance: number }, [number]>("SELECT importance FROM memories WHERE id=?")
|
|
83
|
+
.get(result.id);
|
|
84
|
+
expect(row?.importance).toBeLessThanOrEqual(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("hygiene — near-duplicate reinforcement", () => {
|
|
89
|
+
it("reinforces an elaboration instead of creating a second row", () => {
|
|
90
|
+
const first = core.learn({
|
|
91
|
+
content: "Infisical holds the homelab database credentials for every service",
|
|
92
|
+
category: "architecture",
|
|
93
|
+
importance: 4,
|
|
94
|
+
project_scope: "hygiene-project",
|
|
95
|
+
skipExport: true,
|
|
96
|
+
});
|
|
97
|
+
const second = core.learn({
|
|
98
|
+
content: "Infisical holds the homelab database credentials for every service including Traefik",
|
|
99
|
+
category: "architecture",
|
|
100
|
+
importance: 4,
|
|
101
|
+
project_scope: "hygiene-project",
|
|
102
|
+
skipExport: true,
|
|
103
|
+
});
|
|
104
|
+
expect(second.action).toBe("reinforced");
|
|
105
|
+
expect(second.id).toBe(first.id);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("keeps parallel siblings separate", () => {
|
|
109
|
+
const a = core.learn({ content: "rank probe alpha variant", category: "pattern", importance: 3, skipExport: true });
|
|
110
|
+
const b = core.learn({ content: "rank probe beta variant", category: "pattern", importance: 3, skipExport: true });
|
|
111
|
+
expect(b.action).toBe("created");
|
|
112
|
+
expect(b.id).not.toBe(a.id);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("recall v2 — ranking", () => {
|
|
117
|
+
it("ranks a project-scoped memory above a stronger global", () => {
|
|
118
|
+
const global = core.learn({
|
|
119
|
+
content: "ranking scoped preference — always verify the compose file before deploy",
|
|
120
|
+
category: "preference",
|
|
121
|
+
importance: 5,
|
|
122
|
+
skipExport: true,
|
|
123
|
+
});
|
|
124
|
+
const scoped = core.learn({
|
|
125
|
+
content: "ranking scoped preference for this project only",
|
|
126
|
+
category: "preference",
|
|
127
|
+
importance: 3,
|
|
128
|
+
project_scope: "ranking-project",
|
|
129
|
+
skipExport: true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const rows = [global.id, scoped.id].map((id) =>
|
|
133
|
+
core.getDb()
|
|
134
|
+
.query<never, [number]>("SELECT * FROM memories WHERE id=?")
|
|
135
|
+
.get(id),
|
|
136
|
+
);
|
|
137
|
+
const ranked = core.rankRecallResults(rows as never[], { limit: 2, project: "ranking-project" });
|
|
138
|
+
expect(ranked[0]!.id).toBe(scoped.id);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("demotes a stale memory without dropping it", () => {
|
|
142
|
+
const fresh = { id: 1, importance: 3, recall_count: 0, decay_score: 1, stale_flagged_at: null } as never;
|
|
143
|
+
const stale = { id: 2, importance: 3, recall_count: 0, decay_score: 1, stale_flagged_at: "2026-01-01" } as never;
|
|
144
|
+
const ranked = core.rankRecallResults([stale, fresh], { limit: 2 });
|
|
145
|
+
expect(ranked.map((m) => m.id)).toEqual([1, 2]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("is deterministic for identical input", () => {
|
|
149
|
+
const rows = [
|
|
150
|
+
{ id: 3, importance: 2, recall_count: 1, decay_score: 0.5, stale_flagged_at: null },
|
|
151
|
+
{ id: 1, importance: 2, recall_count: 1, decay_score: 0.5, stale_flagged_at: null },
|
|
152
|
+
{ id: 2, importance: 2, recall_count: 1, decay_score: 0.5, stale_flagged_at: null },
|
|
153
|
+
] as never[];
|
|
154
|
+
const first = core.rankRecallResults(rows, { limit: 3 }).map((m) => m.id);
|
|
155
|
+
const second = core.rankRecallResults([...rows].reverse(), { limit: 3 }).map((m) => m.id);
|
|
156
|
+
expect(first).toEqual(second);
|
|
157
|
+
expect(first).toEqual([1, 2, 3]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("progressively demotes near-duplicate clusters without dropping them", () => {
|
|
161
|
+
const base = { importance: 3, recall_count: 0, decay_score: 1, stale_flagged_at: null, project_scope: null };
|
|
162
|
+
const rows = [
|
|
163
|
+
{ ...base, id: 1, content: "alpha duplicate probe entry" },
|
|
164
|
+
{ ...base, id: 2, content: "alpha duplicate probe entry for the deploy path" },
|
|
165
|
+
{ ...base, id: 3, content: "completely separate unrelated statement about typography" },
|
|
166
|
+
] as never[];
|
|
167
|
+
const ranked = core.rankRecallResults(rows, { limit: 3 }).map((m) => m.id);
|
|
168
|
+
// The near-duplicate (2) is demoted below the unrelated memory (3) but is
|
|
169
|
+
// still returned, and the cluster's best member (1) stays on top.
|
|
170
|
+
expect(ranked).toEqual([1, 3, 2]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("honours explicit sort_by requests", () => {
|
|
174
|
+
const rows = [
|
|
175
|
+
{ id: 1, created_at: "2026-01-01", importance: 3, recall_count: 0, decay_score: 1, stale_flagged_at: null },
|
|
176
|
+
{ id: 2, created_at: "2026-06-01", importance: 3, recall_count: 0, decay_score: 1, stale_flagged_at: null },
|
|
177
|
+
] as never[];
|
|
178
|
+
expect(core.rankRecallResults(rows, { limit: 2, sortBy: "created" }).map((m) => m.id)).toEqual([2, 1]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("prefill v2 — quotas and dedupe", () => {
|
|
183
|
+
it("respects per-category quotas and fills leftover slots", () => {
|
|
184
|
+
for (let i = 0; i < 6; i++) {
|
|
185
|
+
core.learn({
|
|
186
|
+
content: `quota gotcha number ${i} about the staging deploy pipeline failing intermittently`,
|
|
187
|
+
category: "gotcha",
|
|
188
|
+
importance: 3,
|
|
189
|
+
project_scope: "quota-project",
|
|
190
|
+
skipExport: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
core.learn({
|
|
194
|
+
content: "quota decision: staging deploys are frozen on fridays",
|
|
195
|
+
category: "architecture",
|
|
196
|
+
importance: 3,
|
|
197
|
+
project_scope: "quota-project",
|
|
198
|
+
skipExport: true,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const { scoped, globals, report } = core.selectPrefillMemories("quota-project", {
|
|
202
|
+
maxMemories: 4,
|
|
203
|
+
quotas: { gotcha: 1, architecture: 1 },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const categories = scoped.map((m) => m.category);
|
|
207
|
+
expect(categories.filter((c) => c === "gotcha").length).toBeLessThanOrEqual(2); // 1 quota + 1 fill
|
|
208
|
+
expect(categories).toContain("architecture");
|
|
209
|
+
expect(scoped.length).toBeLessThanOrEqual(4);
|
|
210
|
+
expect(report.selected).toBe(scoped.length + globals.length);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("suppresses near-duplicate entries from the block", () => {
|
|
214
|
+
core.learn({
|
|
215
|
+
content: "dedupe probe: the wiki docs are generated and must never be hand edited",
|
|
216
|
+
category: "constraint",
|
|
217
|
+
importance: 4,
|
|
218
|
+
project_scope: "dedupe-project",
|
|
219
|
+
skipExport: true,
|
|
220
|
+
});
|
|
221
|
+
core.learn({
|
|
222
|
+
content: "dedupe probe: the wiki docs are generated and must never be hand edited at all",
|
|
223
|
+
category: "constraint",
|
|
224
|
+
importance: 4,
|
|
225
|
+
project_scope: "dedupe-project",
|
|
226
|
+
skipExport: true,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const { scoped, report } = core.selectPrefillMemories("dedupe-project", { maxMemories: 6 });
|
|
230
|
+
const texts = scoped.map((m) => m.content);
|
|
231
|
+
expect(new Set(texts).size).toBe(texts.length);
|
|
232
|
+
expect(report.suppressedDuplicates).toBeGreaterThanOrEqual(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("puts the project section before the global section", () => {
|
|
236
|
+
const block = core.buildPrefillContext({
|
|
237
|
+
project: "order-project",
|
|
238
|
+
maxMemories: 6,
|
|
239
|
+
maxLines: 24,
|
|
240
|
+
});
|
|
241
|
+
const projectIdx = block.indexOf("Project (order-project):");
|
|
242
|
+
const globalIdx = block.indexOf("Global:");
|
|
243
|
+
if (projectIdx !== -1 && globalIdx !== -1) {
|
|
244
|
+
expect(projectIdx).toBeLessThan(globalIdx);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("respects the shared host budget", () => {
|
|
249
|
+
expect(core.PREFILL_DEFAULTS).toEqual({ maxMemories: 10, maxLines: 18 });
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/cli/hook.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* openltm-core so users still get an "already pre-filled" experience without
|
|
6
6
|
* the full Claude plugin checkout. Other hook events are safe no-ops.
|
|
7
7
|
*/
|
|
8
|
-
import { buildPrefillContext, deriveProjectFromCwd } from "../prefill.js";
|
|
8
|
+
import { buildPrefillContext, deriveProjectFromCwd, PREFILL_DEFAULTS } from "../prefill.js";
|
|
9
9
|
|
|
10
10
|
function parseHookCwd(raw: string): string {
|
|
11
11
|
if (!raw.trim()) return "";
|
|
@@ -27,7 +27,7 @@ export async function buildHookOutput(name: string, rawInput: string): Promise<s
|
|
|
27
27
|
if (!cwd) return "";
|
|
28
28
|
const project = deriveProjectFromCwd(cwd);
|
|
29
29
|
if (!project) return "";
|
|
30
|
-
return buildPrefillContext({ project,
|
|
30
|
+
return buildPrefillContext({ project, ...PREFILL_DEFAULTS });
|
|
31
31
|
}
|
|
32
32
|
case "PreCompact":
|
|
33
33
|
case "PostEditCheck":
|
package/src/db.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Database } from "bun:sqlite";
|
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
import { normalizeKey } from "./dedup.js";
|
|
9
|
+
import { isNearDuplicate } from "./similarity.js";
|
|
9
10
|
import { normalizeAnchorPaths } from "./anchors.js";
|
|
10
11
|
import { getDb, DB_PATH, configure as configureDb } from "./shared-db.js";
|
|
11
12
|
import { enqueueEmbedding } from "./queue/index.js";
|
|
@@ -140,6 +141,32 @@ function tryAudit(fn: () => void): void {
|
|
|
140
141
|
try { fn(); } catch (e) { process.stderr.write(`[audit] write failed: ${e}\n`); }
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
function oneLineForLog(text: string): string {
|
|
145
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
146
|
+
return line.length > 80 ? `${line.slice(0, 77)}…` : line;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* findNearDuplicate — locate an existing memory that says the same thing.
|
|
151
|
+
* Scoped to the same project scope (a global memory never absorbs a scoped one)
|
|
152
|
+
* and only considers recently active rows to keep the probe cheap.
|
|
153
|
+
*/
|
|
154
|
+
function findNearDuplicate(
|
|
155
|
+
db: Database,
|
|
156
|
+
content: string,
|
|
157
|
+
projectScope: string | null,
|
|
158
|
+
dedupKey: string,
|
|
159
|
+
): Memory | null {
|
|
160
|
+
const rows = db.query<Memory, [string, string | null, string | null]>(
|
|
161
|
+
`SELECT * FROM memories
|
|
162
|
+
WHERE status='active' AND dedup_key<>?
|
|
163
|
+
AND (project_scope IS ? OR project_scope = ?)
|
|
164
|
+
ORDER BY decay_score DESC, id DESC
|
|
165
|
+
LIMIT 100`
|
|
166
|
+
).all(dedupKey, projectScope, projectScope);
|
|
167
|
+
return rows.find((row) => isNearDuplicate(row.content, content)) ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
function upsertTag(db: Database, name: string): number {
|
|
144
171
|
db.run(`INSERT OR IGNORE INTO tags (name) VALUES (?)`, [name]);
|
|
145
172
|
return db.query<{ id: number }, [string]>(`SELECT id FROM tags WHERE name=?`).get(name)!.id;
|
|
@@ -410,6 +437,123 @@ async function autoDetectRelations(
|
|
|
410
437
|
}
|
|
411
438
|
}
|
|
412
439
|
|
|
440
|
+
// ── Recall ranking (Recall v2) ────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
/** Weights for the composite relevance score. Kept in one place so the
|
|
443
|
+
* explainer and the sort can never drift apart. */
|
|
444
|
+
export const RANK_WEIGHTS = {
|
|
445
|
+
decay: 1.0,
|
|
446
|
+
importance: 0.6,
|
|
447
|
+
projectScope: 0.9,
|
|
448
|
+
recallFrequency: 0.25,
|
|
449
|
+
stalePenalty: 0.8,
|
|
450
|
+
duplicatePenalty: 0.5,
|
|
451
|
+
} as const;
|
|
452
|
+
|
|
453
|
+
/** Penalty applied to the k-th occurrence of a near-duplicate cluster. */
|
|
454
|
+
const DUPLICATE_DECAY_FACTOR = 0.4;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* rankRecallResults — order recall candidates for relevance.
|
|
458
|
+
*
|
|
459
|
+
* Behaviour:
|
|
460
|
+
* - explicit `sort_by` requests are honoured verbatim
|
|
461
|
+
* - project-scoped memories outrank globals of otherwise equal strength
|
|
462
|
+
* - stale (code-invalidated) memories are demoted but still returned
|
|
463
|
+
* - near-duplicate clusters are demoted progressively, never dropped
|
|
464
|
+
* - ties break on ascending id so identical inputs give identical output
|
|
465
|
+
*/
|
|
466
|
+
export function rankRecallResults(
|
|
467
|
+
candidates: Memory[],
|
|
468
|
+
opts: {
|
|
469
|
+
limit: number;
|
|
470
|
+
project?: string;
|
|
471
|
+
defaultSort?: boolean;
|
|
472
|
+
sortBy?: "relevance" | "created" | "last_recalled" | "recall_count";
|
|
473
|
+
},
|
|
474
|
+
): Memory[] {
|
|
475
|
+
const rows = [...candidates];
|
|
476
|
+
|
|
477
|
+
// Explicit user-requested sorts stay literal.
|
|
478
|
+
if (opts.sortBy === "created") {
|
|
479
|
+
return rows
|
|
480
|
+
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() || a.id - b.id)
|
|
481
|
+
.slice(0, opts.limit);
|
|
482
|
+
}
|
|
483
|
+
if (opts.sortBy === "last_recalled") {
|
|
484
|
+
return rows
|
|
485
|
+
.sort((a, b) => new Date(b.last_recalled_at ?? "1970").getTime() - new Date(a.last_recalled_at ?? "1970").getTime() || a.id - b.id)
|
|
486
|
+
.slice(0, opts.limit);
|
|
487
|
+
}
|
|
488
|
+
if (opts.sortBy === "recall_count") {
|
|
489
|
+
return rows
|
|
490
|
+
.sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0) || a.id - b.id)
|
|
491
|
+
.slice(0, opts.limit);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const scored = rows.map((memory) => {
|
|
495
|
+
const decay = memory.decay_score ?? computeDecayScore(memory);
|
|
496
|
+
const importance = memory.importance / 5;
|
|
497
|
+
const projectScope = opts.project && memory.project_scope === opts.project ? 1 : 0;
|
|
498
|
+
const recallFrequency = Math.min(1, Math.log2((memory.recall_count ?? 0) + 1) / 4);
|
|
499
|
+
const stale = memory.stale_flagged_at ? RANK_WEIGHTS.stalePenalty : 0;
|
|
500
|
+
const score =
|
|
501
|
+
decay * RANK_WEIGHTS.decay +
|
|
502
|
+
importance * RANK_WEIGHTS.importance +
|
|
503
|
+
projectScope * RANK_WEIGHTS.projectScope +
|
|
504
|
+
recallFrequency * RANK_WEIGHTS.recallFrequency -
|
|
505
|
+
stale;
|
|
506
|
+
return { memory, score, decay, stale };
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
scored.sort((a, b) => b.score - a.score || b.decay - a.decay || a.memory.id - b.memory.id);
|
|
510
|
+
|
|
511
|
+
// Progressive near-duplicate demotion: first occurrence keeps its rank,
|
|
512
|
+
// later ones decay by a fixed factor each. Nothing is removed.
|
|
513
|
+
const seen: Memory[] = [];
|
|
514
|
+
const adjusted = scored.map((entry) => {
|
|
515
|
+
const clusterIndex = seen.findIndex((m) => isNearDuplicate(m.content, entry.memory.content));
|
|
516
|
+
if (clusterIndex === -1) {
|
|
517
|
+
seen.push(entry.memory);
|
|
518
|
+
return { ...entry, finalScore: entry.score };
|
|
519
|
+
}
|
|
520
|
+
const penalty = Math.min(1, entry.score * RANK_WEIGHTS.duplicatePenalty * Math.pow(DUPLICATE_DECAY_FACTOR, clusterIndex));
|
|
521
|
+
return { ...entry, finalScore: entry.score - penalty };
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
adjusted.sort((a, b) => b.finalScore - a.finalScore || b.decay - a.decay || a.memory.id - b.memory.id);
|
|
525
|
+
return adjusted.slice(0, opts.limit).map((entry) => entry.memory);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Memory hygiene (hygiene v2) ───────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
/** Operational noise: runtime chatter, not durable knowledge. */
|
|
531
|
+
const NOISE_PATTERNS: RegExp[] = [
|
|
532
|
+
/^\s*(?:ok|okay|done|thanks|thank you|sure|got it|understood|acknowledged)\b[.!]?\s*$/i,
|
|
533
|
+
/\b(?:running|running\.\.\.|in progress|queued|processing)\b\s*$/i,
|
|
534
|
+
/\btook \d+(?:\.\d+)?\s*m?s\b/i,
|
|
535
|
+
/\bexit(?:ed)?\s+(?:code|status)\s+\d+\b/i,
|
|
536
|
+
/\b(?:compacted|compaction)\b.{0,40}\b(?:reference only|summary only)\b/i,
|
|
537
|
+
/\b(?:background|async|delegation)\b.{0,30}\b(?:process|task|batch|job)?\b.*\b(?:complete|completed|done|finished)\b/i,
|
|
538
|
+
/\bnotification\b.{0,30}\b(?:background|completed)\b/i,
|
|
539
|
+
/^\s*[\[\(<{].{0,20}[\]\)>}].{0,40}$/,
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
/** Noise that is only noise when it carries no durable signal. */
|
|
543
|
+
const DURABLE_SIGNAL_RE = /\b(must|never|always|avoid|prefer|require|constraint|gotcha|decision|instead of|do not|don't)\b/i;
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* isOperationalNoise — true when text looks like runtime chatter.
|
|
547
|
+
* A message that still states a rule ("always use bun, not npm") is kept even
|
|
548
|
+
* if it matches, because the durable signal outweighs the shape.
|
|
549
|
+
*/
|
|
550
|
+
export function isOperationalNoise(content: string): boolean {
|
|
551
|
+
const text = content.trim();
|
|
552
|
+
if (text.length < 12) return true;
|
|
553
|
+
if (DURABLE_SIGNAL_RE.test(text)) return false;
|
|
554
|
+
return NOISE_PATTERNS.some((pattern) => pattern.test(text));
|
|
555
|
+
}
|
|
556
|
+
|
|
413
557
|
export function learn(input: LearnInput): LearnResult {
|
|
414
558
|
const db = getDb();
|
|
415
559
|
|
|
@@ -420,10 +564,24 @@ export function learn(input: LearnInput): LearnResult {
|
|
|
420
564
|
}
|
|
421
565
|
const content = scrubbed;
|
|
422
566
|
|
|
567
|
+
// Hygiene: operational noise is downgraded rather than stored verbatim.
|
|
568
|
+
if (isOperationalNoise(content)) {
|
|
569
|
+
input = {
|
|
570
|
+
...input,
|
|
571
|
+
importance: Math.min(input.importance ?? 3, 2),
|
|
572
|
+
category: (input.category ?? "pattern") as MemoryCategory,
|
|
573
|
+
};
|
|
574
|
+
if ((input.importance ?? 3) >= 4) {
|
|
575
|
+
process.stderr.write(`[learn] Downgraded operational-noise memory: "${oneLineForLog(content)}"\n`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
423
579
|
const dedupKey = normalizeKey(content);
|
|
424
580
|
const skipExport = input.skipExport ?? false;
|
|
425
581
|
|
|
426
|
-
const existing = db.query<Memory, [string]>(`SELECT * FROM memories WHERE dedup_key=?`).get(dedupKey)
|
|
582
|
+
const existing = db.query<Memory, [string]>(`SELECT * FROM memories WHERE dedup_key=?`).get(dedupKey)
|
|
583
|
+
// Near-duplicate reinforcement: same knowledge, different wording.
|
|
584
|
+
?? findNearDuplicate(db, content, input.project_scope ?? null, dedupKey);
|
|
427
585
|
|
|
428
586
|
const actor = input.actor ?? "mcp:ltm_learn";
|
|
429
587
|
|
|
@@ -636,37 +794,26 @@ export async function recall(input: RecallInput = {}): Promise<MemoryWithRelatio
|
|
|
636
794
|
// default sort (no query): ORDER BY decay_score DESC pushed to SQL → O(log N)
|
|
637
795
|
const defaultSqlSort = (!input.sort_by || input.sort_by === "relevance") && ids === null;
|
|
638
796
|
const orderBy = defaultSqlSort ? "ORDER BY decay_score DESC" : "";
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
sorted = rows
|
|
660
|
-
.map(m => ({ m, score: m.decay_score ?? computeDecayScore(m) }))
|
|
661
|
-
.sort((a, b) => b.score - a.score)
|
|
662
|
-
.map(({ m }) => m);
|
|
663
|
-
}
|
|
664
|
-
// Downrank stale (code-invalidated) memories: stable partition pushes them
|
|
665
|
-
// after fresh ones at equal relevance — still returned, just demoted.
|
|
666
|
-
sorted = [
|
|
667
|
-
...sorted.filter(m => !m.stale_flagged_at),
|
|
668
|
-
...sorted.filter(m => m.stale_flagged_at),
|
|
669
|
-
];
|
|
797
|
+
// Recall v2 ranking inputs need project scope + staleness, so both are
|
|
798
|
+
// selected up front even on the default sort path.
|
|
799
|
+
const rankSql = defaultSqlSort
|
|
800
|
+
? `SELECT id, content, category, importance, confidence, source, project_scope, dedup_key,
|
|
801
|
+
created_at, last_confirmed_at, last_used_at, confirm_count, status,
|
|
802
|
+
first_recalled_at, last_recalled_at, recall_count, superseded_by, superseded_at,
|
|
803
|
+
workspace_id, agent_id, decay_score, stale_flagged_at, stale_reason
|
|
804
|
+
FROM memories ${where} ORDER BY decay_score DESC LIMIT ${limit * 3}`
|
|
805
|
+
: `SELECT id, content, category, importance, confidence, source, project_scope, dedup_key,
|
|
806
|
+
created_at, last_confirmed_at, last_used_at, confirm_count, status,
|
|
807
|
+
first_recalled_at, last_recalled_at, recall_count, superseded_by, superseded_at,
|
|
808
|
+
workspace_id, agent_id, decay_score, stale_flagged_at, stale_reason
|
|
809
|
+
FROM memories ${where} ${orderBy} LIMIT ${limit * 3}`;
|
|
810
|
+
const candidateRows = db.query<Memory, typeof params>(rankSql).all(...params);
|
|
811
|
+
const sorted = rankRecallResults(candidateRows, {
|
|
812
|
+
limit,
|
|
813
|
+
project: input.project,
|
|
814
|
+
defaultSort: defaultSqlSort,
|
|
815
|
+
sortBy: input.sort_by,
|
|
816
|
+
});
|
|
670
817
|
if (sorted.length > 0) {
|
|
671
818
|
const placeholders = sorted.map(() => "?").join(",");
|
|
672
819
|
db.run(
|
package/src/index.ts
CHANGED
|
@@ -45,7 +45,15 @@ export type { MemoryTemperature, RecallExplainer, ExplainerInput } from "./recal
|
|
|
45
45
|
|
|
46
46
|
// Session prefill helpers
|
|
47
47
|
export { buildPrefillContext, deriveProjectFromCwd, selectPrefillMemories } from "./prefill.js";
|
|
48
|
-
export type { PrefillOptions, PrefillSelection } from "./prefill.js";
|
|
48
|
+
export type { PrefillOptions, PrefillSelection, PrefillCategory, PrefillQuotaReport } from "./prefill.js";
|
|
49
|
+
export { PREFILL_DEFAULTS } from "./prefill.js";
|
|
50
|
+
|
|
51
|
+
// Recall ranking
|
|
52
|
+
export { rankRecallResults, isOperationalNoise } from "./db.js";
|
|
53
|
+
export { RANK_WEIGHTS } from "./db.js";
|
|
54
|
+
|
|
55
|
+
// Text similarity (prefill dedupe + learn hygiene)
|
|
56
|
+
export { isNearDuplicate, jaccardSimilarity, tokenize, tokenizeAll } from "./similarity.js";
|
|
49
57
|
|
|
50
58
|
// Embedding providers
|
|
51
59
|
export * from "./providers/index.js";
|
package/src/prefill.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Keeps the "already pre-filled" experience consistent across Claude Code,
|
|
5
5
|
* OpenCode, Pi, and any future host that can call into openltm-core.
|
|
6
|
+
*
|
|
7
|
+
* Prefill v2 behaviour:
|
|
8
|
+
* - project-scoped memories are prioritised over globals
|
|
9
|
+
* - category quotas stop one noisy category from filling the block
|
|
10
|
+
* - near-duplicate entries are suppressed
|
|
11
|
+
* - the block is hard-capped by line budget for every host
|
|
6
12
|
*/
|
|
7
|
-
import { getContextMerge, type Memory } from "./db.js";
|
|
13
|
+
import { getContextMerge, computeDecayScore, type Memory } from "./db.js";
|
|
8
14
|
import { readConfigSync } from "./config.js";
|
|
15
|
+
import { isNearDuplicate } from "./similarity.js";
|
|
9
16
|
|
|
10
17
|
export interface PrefillOptions {
|
|
11
18
|
project: string;
|
|
@@ -13,6 +20,8 @@ export interface PrefillOptions {
|
|
|
13
20
|
maxGlobalMemories?: number;
|
|
14
21
|
maxLines?: number;
|
|
15
22
|
header?: string;
|
|
23
|
+
/** Per-category caps applied before the global budget. */
|
|
24
|
+
quotas?: Partial<Record<PrefillCategory, number>>;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export interface PrefillSelection {
|
|
@@ -20,8 +29,53 @@ export interface PrefillSelection {
|
|
|
20
29
|
scoped: Memory[];
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
export type PrefillCategory =
|
|
33
|
+
| "preference"
|
|
34
|
+
| "architecture"
|
|
35
|
+
| "gotcha"
|
|
36
|
+
| "pattern"
|
|
37
|
+
| "workflow"
|
|
38
|
+
| "constraint";
|
|
39
|
+
|
|
40
|
+
export interface PrefillQuotaReport {
|
|
41
|
+
selected: number;
|
|
42
|
+
suppressedDuplicates: number;
|
|
43
|
+
quotaLimited: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
const DEFAULT_HEADER = "## Prior Knowledge (LTM)";
|
|
24
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Shared prefill budget for every host adapter.
|
|
50
|
+
*
|
|
51
|
+
* Adapters pass these instead of literal numbers so Claude, OpenCode, and Pi
|
|
52
|
+
* cannot drift apart: the block a user sees is the same shape everywhere.
|
|
53
|
+
*/
|
|
54
|
+
export const PREFILL_DEFAULTS = { maxMemories: 10, maxLines: 18 } as const;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Category quotas. The block stays useful when a project has 40 gotchas and no
|
|
58
|
+
* decisions: gotcha is capped and the freed slots go to the other categories.
|
|
59
|
+
*/
|
|
60
|
+
const DEFAULT_QUOTAS: Record<PrefillCategory, number> = {
|
|
61
|
+
preference: 2,
|
|
62
|
+
architecture: 2,
|
|
63
|
+
gotcha: 3,
|
|
64
|
+
pattern: 3,
|
|
65
|
+
workflow: 2,
|
|
66
|
+
constraint: 2,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Order used when a category is over its quota and slots are contested. */
|
|
70
|
+
const CATEGORY_PRIORITY: PrefillCategory[] = [
|
|
71
|
+
"constraint",
|
|
72
|
+
"preference",
|
|
73
|
+
"gotcha",
|
|
74
|
+
"architecture",
|
|
75
|
+
"workflow",
|
|
76
|
+
"pattern",
|
|
77
|
+
];
|
|
78
|
+
|
|
25
79
|
function oneLine(text: string): string {
|
|
26
80
|
return text.replace(/\s+/g, " ").trim();
|
|
27
81
|
}
|
|
@@ -31,8 +85,9 @@ function clampPositiveInt(value: number | undefined, fallback: number): number {
|
|
|
31
85
|
return Math.floor(value);
|
|
32
86
|
}
|
|
33
87
|
|
|
34
|
-
function
|
|
35
|
-
|
|
88
|
+
function clampNonNegativeInt(value: number | undefined, fallback: number): number {
|
|
89
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return fallback;
|
|
90
|
+
return Math.floor(value);
|
|
36
91
|
}
|
|
37
92
|
|
|
38
93
|
function trimLines(lines: string[], maxLines: number): string[] {
|
|
@@ -46,24 +101,104 @@ export function deriveProjectFromCwd(cwd: string): string {
|
|
|
46
101
|
return cwd.replace(/\/$/, "").split("/").pop() ?? "";
|
|
47
102
|
}
|
|
48
103
|
|
|
49
|
-
|
|
104
|
+
/** Rank key: higher is better. Project scope wins ties against globals. */
|
|
105
|
+
function rank(memory: Memory, scopeRank: number): number {
|
|
106
|
+
return memory.importance * 1000 + computeDecayScore(memory) * 10 + scopeRank;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sortForSelection(memories: Memory[], scopeRank: number): Memory[] {
|
|
110
|
+
return [...memories].sort((a, b) => {
|
|
111
|
+
const diff = rank(b, scopeRank) - rank(a, scopeRank);
|
|
112
|
+
if (diff !== 0) return diff;
|
|
113
|
+
return a.id - b.id; // deterministic tie-break
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Apply per-category quotas, then fill any leftover slots with the highest
|
|
119
|
+
* ranked remaining memories so the block is never artificially small.
|
|
120
|
+
*/
|
|
121
|
+
function applyQuotas(
|
|
122
|
+
ordered: Memory[],
|
|
123
|
+
quotas: Partial<Record<PrefillCategory, number>>,
|
|
124
|
+
budget: number,
|
|
125
|
+
): { picked: Memory[]; quotaLimited: number } {
|
|
126
|
+
const counts = new Map<PrefillCategory, number>();
|
|
127
|
+
const picked: Memory[] = [];
|
|
128
|
+
const deferred: Memory[] = [];
|
|
129
|
+
let quotaLimited = 0;
|
|
130
|
+
|
|
131
|
+
for (const memory of ordered) {
|
|
132
|
+
if (picked.length >= budget) break;
|
|
133
|
+
const category = memory.category as PrefillCategory;
|
|
134
|
+
const cap = quotas[category];
|
|
135
|
+
const used = counts.get(category) ?? 0;
|
|
136
|
+
if (cap !== undefined && used >= cap) {
|
|
137
|
+
quotaLimited++;
|
|
138
|
+
deferred.push(memory);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
counts.set(category, used + 1);
|
|
142
|
+
picked.push(memory);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const memory of deferred) {
|
|
146
|
+
if (picked.length >= budget) break;
|
|
147
|
+
picked.push(memory);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { picked, quotaLimited };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Suppress near-duplicate memories, keeping the highest ranked occurrence. */
|
|
154
|
+
function dedupe(memories: Memory[]): { kept: Memory[]; suppressed: number } {
|
|
155
|
+
const kept: Memory[] = [];
|
|
156
|
+
let suppressed = 0;
|
|
157
|
+
for (const memory of memories) {
|
|
158
|
+
const duplicate = kept.some((existing) => isNearDuplicate(existing.content, memory.content));
|
|
159
|
+
if (duplicate) {
|
|
160
|
+
suppressed++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
kept.push(memory);
|
|
164
|
+
}
|
|
165
|
+
return { kept, suppressed };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function selectPrefillMemories(
|
|
169
|
+
project: string,
|
|
170
|
+
opts: Omit<PrefillOptions, "project"> = {},
|
|
171
|
+
): PrefillSelection & { report: PrefillQuotaReport } {
|
|
50
172
|
const cfg = readConfigSync();
|
|
51
173
|
const configuredTopN = cfg.ltm?.injectTopN;
|
|
52
174
|
const maxMemories = clampPositiveInt(opts.maxMemories, configuredTopN ?? 8);
|
|
53
|
-
const desiredGlobals = clampPositiveInt(opts.maxGlobalMemories, Math.min(3, Math.max(1, Math.ceil(maxMemories / 3))));
|
|
54
175
|
|
|
55
|
-
const
|
|
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);
|
|
176
|
+
const quotas: Partial<Record<PrefillCategory, number>> = { ...DEFAULT_QUOTAS, ...(opts.quotas ?? {}) };
|
|
60
177
|
|
|
61
|
-
|
|
62
|
-
const refillGlobals = merged.globals.slice(globals.length, Math.min(merged.globals.length, maxMemories - scoped.length));
|
|
63
|
-
globals.push(...refillGlobals);
|
|
64
|
-
}
|
|
178
|
+
const merged = getContextMerge(project);
|
|
65
179
|
|
|
66
|
-
|
|
180
|
+
// Project-scoped candidates come first and outrank globals of equal strength.
|
|
181
|
+
const scopedOrdered = sortForSelection(merged.scoped, 2);
|
|
182
|
+
const globalCeiling = clampNonNegativeInt(opts.maxGlobalMemories, Math.max(1, Math.ceil(maxMemories / 3)));
|
|
183
|
+
const globalsOrdered = sortForSelection(merged.globals, 0).slice(0, Math.min(merged.globals.length, globalCeiling + maxMemories));
|
|
184
|
+
|
|
185
|
+
const scopedBudget = Math.max(0, maxMemories - Math.min(globalCeiling, merged.globals.length));
|
|
186
|
+
const scopedResult = applyQuotas(scopedOrdered, quotas, scopedBudget);
|
|
187
|
+
const globalsResult = applyQuotas(globalsOrdered, quotas, maxMemories - scopedResult.picked.length);
|
|
188
|
+
|
|
189
|
+
const { kept: scoped, suppressed: scopedDupes } = dedupe(scopedResult.picked);
|
|
190
|
+
const { kept: globals, suppressed: globalDupes } = dedupe(globalsResult.picked);
|
|
191
|
+
const crossDupes = globals.filter((g) => scoped.some((s) => isNearDuplicate(s.content, g.content))).length;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
scoped,
|
|
195
|
+
globals: globals.filter((g) => !scoped.some((s) => isNearDuplicate(s.content, g.content))),
|
|
196
|
+
report: {
|
|
197
|
+
selected: scoped.length + globals.length,
|
|
198
|
+
suppressedDuplicates: scopedDupes + globalDupes + crossDupes,
|
|
199
|
+
quotaLimited: scopedResult.quotaLimited + globalsResult.quotaLimited,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
67
202
|
}
|
|
68
203
|
|
|
69
204
|
export function buildPrefillContext(opts: PrefillOptions): string {
|
|
@@ -73,17 +208,21 @@ export function buildPrefillContext(opts: PrefillOptions): string {
|
|
|
73
208
|
|
|
74
209
|
const lines: string[] = [opts.header ?? DEFAULT_HEADER, ""];
|
|
75
210
|
|
|
76
|
-
if (
|
|
77
|
-
lines.push(
|
|
78
|
-
for (const memory of
|
|
211
|
+
if (scoped.length > 0) {
|
|
212
|
+
lines.push(`Project (${opts.project}):`);
|
|
213
|
+
for (const memory of scoped) lines.push(renderLine(memory));
|
|
79
214
|
lines.push("");
|
|
80
215
|
}
|
|
81
216
|
|
|
82
|
-
if (
|
|
83
|
-
lines.push(
|
|
84
|
-
for (const memory of
|
|
217
|
+
if (globals.length > 0) {
|
|
218
|
+
lines.push("Global:");
|
|
219
|
+
for (const memory of globals) lines.push(renderLine(memory));
|
|
85
220
|
lines.push("");
|
|
86
221
|
}
|
|
87
222
|
|
|
88
223
|
return trimLines(lines, maxLines).join("\n").trimEnd() + "\n";
|
|
89
224
|
}
|
|
225
|
+
|
|
226
|
+
function renderLine(memory: Pick<Memory, "id" | "content" | "category" | "importance">): string {
|
|
227
|
+
return `- [${memory.id}] (${memory.category}/${memory.importance}) ${oneLine(memory.content)}`;
|
|
228
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* similarity.ts — Dependency-free text similarity helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by prefill selection (near-duplicate suppression) and learn hygiene
|
|
5
|
+
* (near-duplicate reinforcement) so both paths agree on "same memory".
|
|
6
|
+
*/
|
|
7
|
+
import { normalizeKey } from "./dedup.js";
|
|
8
|
+
|
|
9
|
+
const STOPWORDS = new Set([
|
|
10
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "to", "of", "in", "on",
|
|
11
|
+
"for", "with", "and", "or", "but", "not", "no", "it", "its", "this", "that", "these",
|
|
12
|
+
"those", "as", "at", "by", "from", "if", "then", "so", "than", "we", "you", "our",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/** Tokenize into comparable lowercase content tokens (stopwords removed). */
|
|
16
|
+
export function tokenize(text: string): string[] {
|
|
17
|
+
if (typeof text !== "string") return [];
|
|
18
|
+
return normalizeKey(text)
|
|
19
|
+
.split(" ")
|
|
20
|
+
.filter((t) => t.length > 1 && !STOPWORDS.has(t));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Tokenize without dropping anything — used where a difference matters. */
|
|
24
|
+
export function tokenizeAll(text: string): string[] {
|
|
25
|
+
if (typeof text !== "string") return [];
|
|
26
|
+
return normalizeKey(text).split(" ").filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Jaccard token similarity in [0,1]. Empty-vs-empty is 0, not 1. */
|
|
30
|
+
export function jaccardSimilarity(a: string, b: string): number {
|
|
31
|
+
const ta = new Set(tokenize(a));
|
|
32
|
+
const tb = new Set(tokenize(b));
|
|
33
|
+
if (ta.size === 0 || tb.size === 0) return 0;
|
|
34
|
+
let intersection = 0;
|
|
35
|
+
for (const token of ta) {
|
|
36
|
+
if (tb.has(token)) intersection++;
|
|
37
|
+
}
|
|
38
|
+
const union = ta.size + tb.size - intersection;
|
|
39
|
+
return union === 0 ? 0 : intersection / union;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Tokens that look like a counter, suffix, or marker rather than content. */
|
|
43
|
+
function isShortMarker(token: string): boolean {
|
|
44
|
+
return token.length <= 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* isNearDuplicate — true when two contents describe the same knowledge.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately conservative, because this feeds the learn() path where a false
|
|
51
|
+
* positive silently merges two memories the caller meant to keep apart. Only
|
|
52
|
+
* one rule qualifies: full token containment, i.e. one memory is an elaboration
|
|
53
|
+
* of the other. Looser similarity thresholds merged deliberate siblings
|
|
54
|
+
* ("X high importance" vs "X low importance") and are deliberately absent —
|
|
55
|
+
* reworded duplicates are left to the embedding-based `autoRelate` path.
|
|
56
|
+
*/
|
|
57
|
+
export function isNearDuplicate(a: string, b: string): boolean {
|
|
58
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
59
|
+
const ta = new Set(tokenize(a));
|
|
60
|
+
const tb = new Set(tokenize(b));
|
|
61
|
+
if (ta.size < 3 || tb.size < 3) return false;
|
|
62
|
+
|
|
63
|
+
// Symmetric difference is computed on the *unfiltered* token list, because
|
|
64
|
+
// stopword/short-token filtering would hide exactly the "…-A" vs "…-B"
|
|
65
|
+
// difference this guard exists to protect.
|
|
66
|
+
const allA = new Set(tokenizeAll(a));
|
|
67
|
+
const allB = new Set(tokenizeAll(b));
|
|
68
|
+
const differing = [
|
|
69
|
+
...[...allA].filter((t) => !allB.has(t)),
|
|
70
|
+
...[...allB].filter((t) => !allA.has(t)),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Differing only by short markers (counter/suffix) → intentionally distinct.
|
|
74
|
+
if (differing.length > 0 && differing.every(isShortMarker)) return false;
|
|
75
|
+
|
|
76
|
+
const [smaller, larger] = ta.size <= tb.size ? [ta, tb] : [tb, ta];
|
|
77
|
+
let contained = 0;
|
|
78
|
+
for (const token of smaller) {
|
|
79
|
+
if (larger.has(token)) contained++;
|
|
80
|
+
}
|
|
81
|
+
return contained === smaller.size;
|
|
82
|
+
}
|