@rohirik/openltm-core 2.12.3 → 2.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohirik/openltm-core",
3
- "version": "2.12.3",
3
+ "version": "2.13.0",
4
4
  "description": "Shared LTM storage engine — path-agnostic SQLite core used by Claude Code, OpenCode, and Pi adapters",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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, maxMemories: 10, maxLines: 18 });
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
- const rows = db.query<Memory, typeof params>(
640
- `SELECT id, content, category, importance, confidence, source, project_scope, dedup_key,
641
- created_at, last_confirmed_at, last_used_at, confirm_count, status,
642
- first_recalled_at, last_recalled_at, recall_count, superseded_by, superseded_at,
643
- workspace_id, agent_id, decay_score, stale_flagged_at, stale_reason
644
- FROM memories ${where} ${orderBy} LIMIT ${limit}`
645
- ).all(...params);
646
-
647
- let sorted: typeof rows;
648
- if (defaultSqlSort) {
649
- sorted = rows; // already ordered by decay_score DESC in SQL
650
- } else if (input.sort_by === "created") {
651
- sorted = rows.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
652
- } else if (input.sort_by === "last_recalled") {
653
- sorted = rows.sort((a, b) => new Date(b.last_recalled_at ?? "1970").getTime() - new Date(a.last_recalled_at ?? "1970").getTime());
654
- } else if (input.sort_by === "recall_count") {
655
- sorted = rows.sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0));
656
- } else {
657
- // FTS/semantic path: small result set (≤50), JS sort is O(k log k) — negligible.
658
- // Use materialised decay_score if available, fall back to computing it.
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 formatMemory(memory: Pick<Memory, "id" | "content" | "category" | "importance">): string {
35
- return `- [${memory.id}] (${memory.category}/${memory.importance}) ${oneLine(memory.content)}`;
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
- export function selectPrefillMemories(project: string, opts: Omit<PrefillOptions, "project"> = {}): PrefillSelection {
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 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);
176
+ const quotas: Partial<Record<PrefillCategory, number>> = { ...DEFAULT_QUOTAS, ...(opts.quotas ?? {}) };
60
177
 
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
- }
178
+ const merged = getContextMerge(project);
65
179
 
66
- return { globals, scoped };
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 (globals.length > 0) {
77
- lines.push("Global:");
78
- for (const memory of globals) lines.push(formatMemory(memory));
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 (scoped.length > 0) {
83
- lines.push(`Project (${opts.project}):`);
84
- for (const memory of scoped) lines.push(formatMemory(memory));
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
+ }