pi-mega-compact 0.4.28 → 0.5.1

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.
Files changed (56) hide show
  1. package/README.md +47 -2
  2. package/dist/extensions/dashboard-server.js +66 -3
  3. package/dist/extensions/dashboard-server.test.js +95 -3
  4. package/dist/extensions/mega-commands.js +25 -9
  5. package/dist/extensions/mega-compact.test.js +133 -31
  6. package/dist/extensions/mega-config.js +5 -0
  7. package/dist/extensions/mega-conflict-cmds.js +79 -0
  8. package/dist/extensions/mega-dashboard-cmds.js +6 -4
  9. package/dist/extensions/mega-events.js +144 -27
  10. package/dist/extensions/mega-pipeline.js +84 -1
  11. package/dist/extensions/mega-runtime.js +35 -2
  12. package/dist/extensions/mega-trim.js +48 -0
  13. package/dist/extensions/mega-trim.test.js +58 -0
  14. package/dist/src/config/dedup.js +1 -0
  15. package/dist/src/driftDetection.js +103 -0
  16. package/dist/src/driftDetection.test.js +87 -0
  17. package/dist/src/memory.js +147 -0
  18. package/dist/src/memory.test.js +41 -0
  19. package/dist/src/memoryConsolidate.test.js +38 -0
  20. package/dist/src/memoryOps.js +58 -0
  21. package/dist/src/memoryOps.test.js +41 -0
  22. package/dist/src/memoryRecall.js +60 -0
  23. package/dist/src/memoryRecall.test.js +92 -0
  24. package/dist/src/recall.js +70 -1
  25. package/dist/src/recall.test.js +69 -1
  26. package/dist/src/store/sqlite.js +127 -11
  27. package/dist/src/vectorStore.js +6 -1
  28. package/extensions/dashboard-server.test.ts +115 -3
  29. package/extensions/dashboard-server.ts +69 -4
  30. package/extensions/mega-commands.ts +24 -9
  31. package/extensions/mega-compact.test.ts +134 -31
  32. package/extensions/mega-config.ts +22 -0
  33. package/extensions/mega-conflict-cmds.ts +81 -0
  34. package/extensions/mega-dashboard-cmds.ts +6 -4
  35. package/extensions/mega-events.ts +139 -28
  36. package/extensions/mega-pipeline.ts +94 -1
  37. package/extensions/mega-runtime.ts +35 -2
  38. package/extensions/mega-trim.test.ts +64 -0
  39. package/extensions/mega-trim.ts +75 -0
  40. package/extensions/openclaw-mega-compact.ts +24 -9
  41. package/package.json +2 -2
  42. package/src/config/dedup.ts +2 -0
  43. package/src/driftDetection.test.ts +100 -0
  44. package/src/driftDetection.ts +136 -0
  45. package/src/memory.test.ts +46 -0
  46. package/src/memory.ts +164 -0
  47. package/src/memoryConsolidate.test.ts +47 -0
  48. package/src/memoryOps.test.ts +53 -0
  49. package/src/memoryOps.ts +75 -0
  50. package/src/memoryRecall.test.ts +100 -0
  51. package/src/memoryRecall.ts +83 -0
  52. package/src/recall.test.ts +77 -1
  53. package/src/recall.ts +94 -1
  54. package/src/store/sqlite.ts +188 -11
  55. package/src/store.ts +3 -0
  56. package/src/vectorStore.ts +10 -1
@@ -8,10 +8,11 @@
8
8
  */
9
9
  import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
10
10
  import { compactSession } from "../src/engine.js";
11
- import { recallAndInline } from "../src/recall.js";
11
+ import { recallAndInline, recallAndInlineAsync, formatRecallBlock } from "../src/recall.js";
12
12
  import { normalizeSessionId } from "../src/store.js";
13
13
  import { estimateBlockTokens } from "../src/tokens.js";
14
14
  import { touchSession, logDaily } from "../src/store/sqlite.js";
15
+ import { consolidateMemories } from "../src/memory.js";
15
16
  import { C, MARKER_TYPE, } from "./mega-runtime.js";
16
17
  import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
17
18
  import { runRaptor } from "../src/dedup/raptor/index.js";
@@ -42,6 +43,10 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
42
43
  }
43
44
  function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
44
45
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
46
+ // S21.2: reset the per-compaction memory-op counter so the post-compact
47
+ // consolidate pass only fires when memory rows actually changed during the
48
+ // compaction window (turn_end → auto-review may have written some).
49
+ runtime.memoriesTouchedThisCompaction = 0;
45
50
  const result = compactSession({
46
51
  sessionId: sid,
47
52
  messages: view,
@@ -108,6 +113,26 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
108
113
  catch {
109
114
  /* non-fatal: stats bookkeeping only */
110
115
  }
116
+ // S21.2: best-effort consolidation of near-duplicate memories for this repo.
117
+ // Runs after the per-repo stats touch so `consolidateMemories` can use the
118
+ // same stateDir. Non-fatal — a failed consolidate never blocks a compaction.
119
+ // Only runs when new memory ops landed in this pass (otherwise the prior
120
+ // compaction's consolidate already had its shot — re-running would just
121
+ // touch every row again with no merges).
122
+ if (!result.deduped && runtime.memoriesTouchedThisCompaction > 0) {
123
+ try {
124
+ const root = resolveRepoRoot(ctx.cwd);
125
+ void consolidateMemories(runtime.currentStateDir, root).then((n) => {
126
+ if (n > 0)
127
+ runtime.pushTicker(`${C.green}∫${C.reset} consolidated ${n} memory dup${n === 1 ? "" : "s"}`);
128
+ }, () => {
129
+ /* swallow: consolidate failures must never surface to the user */
130
+ });
131
+ }
132
+ catch {
133
+ /* non-fatal */
134
+ }
135
+ }
111
136
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
112
137
  // skip re-vectorizing an already-compacted region (zero token cost).
113
138
  pi.appendEntry(MARKER_TYPE, {
@@ -310,6 +335,64 @@ export function doRecall(runtime, config, ctx, query, source) {
310
335
  }
311
336
  return result;
312
337
  }
338
+ /**
339
+ * S17: async recall with optional cross-repo augmentation. Used on resume
340
+ * (session_start) and /mega-recall --cross-repo — NEVER from the mid-turn
341
+ * context handler (that stays sync). Runs the sync same-repo scan first; if it
342
+ * returns < config.autoInlineK hits AND crossRepo is enabled, awaits the PGlite
343
+ * HNSW cross-repo path and merges (source-labeled, deduped by checkpointId). The
344
+ * recallMaxTokens cap + windowDedupe apply to the merged set so cross-repo can
345
+ * never net-inflate the window. Cross-repo uses a stricter cosine floor
346
+ * (config.crossRepoCosine) than same-repo. Non-fatal: any async failure returns
347
+ * the same-repo result unchanged.
348
+ */
349
+ export async function doRecallAsync(runtime, config, ctx, query, source, opts = {}) {
350
+ runtime.bindRepo(ctx.cwd);
351
+ const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
352
+ const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
353
+ // Sync same-repo first (fast, never blocks).
354
+ const sameRepo = recallAndInline({
355
+ sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
356
+ recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
357
+ liveWindow, dedupSim: config.dedupSim,
358
+ }, runtime.store);
359
+ if (!config.crossRepoEnabled || !opts.crossRepo)
360
+ return sameRepo;
361
+ if (sameRepo.toInject.length >= config.autoInlineK)
362
+ return sameRepo; // same-repo satisfied
363
+ // Augment: cross-repo HNSW (async) with the stricter floor. Non-fatal.
364
+ try {
365
+ const x = await recallAndInlineAsync({
366
+ sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
367
+ recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
368
+ liveWindow, dedupSim: config.crossRepoCosine, crossRepo: true,
369
+ globalIndexDir: process.env.MEGACOMPACT_INDEX_DIR,
370
+ }, runtime.store);
371
+ runtime.dashboard.event("recall-crossrepo", {
372
+ source, query: query.slice(0, 120), injected: x.toInject.length,
373
+ sourceRepos: x.toInject.map((h) => h.repoId).filter(Boolean),
374
+ });
375
+ // Merge, dedup by checkpointId, respect the same token cap by reformatting.
376
+ const seen = new Set(sameRepo.toInject.map((h) => h.checkpoint.checkpointId));
377
+ const merged = [...sameRepo.toInject];
378
+ for (const h of x.toInject) {
379
+ if (!seen.has(h.checkpoint.checkpointId)) {
380
+ merged.push(h);
381
+ seen.add(h.checkpoint.checkpointId);
382
+ }
383
+ }
384
+ const block = merged.length ? formatRecallBlock(merged) : "";
385
+ return {
386
+ toInject: merged,
387
+ report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),
388
+ block,
389
+ empty: merged.length === 0,
390
+ };
391
+ }
392
+ catch {
393
+ return sameRepo; // cross-repo failure → same-repo only (non-fatal)
394
+ }
395
+ }
313
396
  /**
314
397
  * Extract the live-window message texts from the session manager (Fix C),
315
398
  * for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
@@ -9,7 +9,9 @@
9
9
  * original closure.
10
10
  */
11
11
  import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
12
- import { join } from "node:path";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { readFileSync } from "node:fs";
13
15
  import { VectorStore } from "../src/vectorStore.js";
14
16
  import { toEngineMessages } from "../src/adapt.js";
15
17
  import { normalizeSessionId } from "../src/store.js";
@@ -20,6 +22,23 @@ import { Dashboard } from "./mega-dashboard.js";
20
22
  export const STATUS_KEY = "mega-compact";
21
23
  export const WIDGET_KEY = "mega-compact-stats";
22
24
  export const MARKER_TYPE = "mega-compact-marker";
25
+ /** Cached npm version, read once from this extension's own package.json. */
26
+ let CACHED_VERSION = null;
27
+ function ownVersion() {
28
+ if (CACHED_VERSION !== null)
29
+ return CACHED_VERSION;
30
+ let v = "?";
31
+ try {
32
+ const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
33
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
34
+ v = pkg.version ?? "?";
35
+ }
36
+ catch {
37
+ v = "?";
38
+ }
39
+ CACHED_VERSION = v;
40
+ return v;
41
+ }
23
42
  /** ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
24
43
  * escape codes (see wrapTextWithAnsi), so raw escapes render as colors. No
25
44
  * chalk dependency needed — these are just strings. */
@@ -57,12 +76,17 @@ export class MegaRuntime {
57
76
  tokensSaved: 0,
58
77
  };
59
78
  debounceUntil = 0;
79
+ // S16: debounce for the agent_end resume nudge (avoid busy-loops).
80
+ resumeNudgeUntil = 0;
60
81
  // Agent tracking for real-time widget updates
61
82
  activeAgents = 0;
62
83
  currentTurn = 0;
63
84
  // Recall block produced by auto-inline (resume/branch) that the next
64
85
  // before_agent_start should prepend to the system prompt. Unset after use.
65
86
  pendingRecallBlock;
87
+ // S21: memory recall block, parallel to pendingRecallBlock. Same one-shot
88
+ // semantics; composed with the checkpoint block in before_agent_start.
89
+ pendingMemoryRecallBlock;
66
90
  statusKey; // current status text for dashboard
67
91
  // Active model/provider (for real cost estimation). Captured from ctx.model
68
92
  // on model_select + session_start; persisted to SQL so cost + the dashboard
@@ -81,6 +105,11 @@ export class MegaRuntime {
81
105
  TICKER_MAX = 5;
82
106
  // Pulsing status: set true while a compaction is in flight, cleared on result.
83
107
  pulsing = false;
108
+ // S21.2: set by `applyMemoryOps` when a memory add/replace/remove lands in
109
+ // the current compaction. The pipeline reads this after a successful compact
110
+ // to decide whether to fire `consolidateMemories` (skip the work entirely
111
+ // when no memory rows changed).
112
+ memoriesTouchedThisCompaction = 0;
84
113
  // Rolling "saved" goal for the progress bar — grows as we save more, so the
85
114
  // bar always has a meaningful denominator (never sits at 100% forever).
86
115
  savedGoal = 50_000;
@@ -229,7 +258,7 @@ export class MegaRuntime {
229
258
  // Phase 3 — pulsing status glyph while a compaction is in flight.
230
259
  const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
231
260
  const lines = [
232
- ` ${C.amber}⚡ ${this.config.tier}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
261
+ ` ${C.amber}⚡ ${this.config.tier}${C.reset} v${C.bold}${ownVersion()}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
233
262
  ` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset} │ ${C.gray}memory held:${C.reset} ${usedStr} │ ${C.gray}space freed:${C.reset} ${savedStr}`,
234
263
  ];
235
264
  // Phase 3 — compact progress bar: session tokens saved toward the rolling goal.
@@ -343,6 +372,10 @@ export class MegaRuntime {
343
372
  }
344
373
  catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
345
374
  }
375
+ /** S21: state dir of the currently bound repo (where memories live). */
376
+ getStateDir() {
377
+ return this.currentStateDir;
378
+ }
346
379
  /** Build the sync onTier callback that paints the live per-tier trace. */
347
380
  makeTierCallback(ctx) {
348
381
  const order = ["L0", "L1", "L2", "new"];
@@ -0,0 +1,48 @@
1
+ import { isBoundarySafe } from "../src/boundary.js";
2
+ import { formatCompactSummary } from "../src/compact.js";
3
+ /**
4
+ * Compute the safe cut index for the live trim. Snaps `compactedFrom` back to a
5
+ * boundary-safe index (PREVENT-PI-002: never start the preserved run on an
6
+ * orphaned tool result), and enforces the anchor floor (PREVENT-PI-001: keep at
7
+ * least `anchorUserMessages` user-role messages). Returns `null` when no trim is
8
+ * safe this call (empty summary, unsafe boundary, or below the anchor floor) so
9
+ * the caller keeps the original view and retries on the next context event.
10
+ *
11
+ * Exposed separately from `buildLiveTrimmedView` so the context handler can map
12
+ * the cut back onto the original pi `AgentMessage[]` (lossless index alignment,
13
+ * mirroring `dropCompactedRange` in src/adapt.ts).
14
+ */
15
+ export function computeLiveTrimCut(view, opts) {
16
+ if (!opts.summary || !opts.summary.trim())
17
+ return null;
18
+ let cut = opts.compactedFrom;
19
+ while (cut > 0 && !isBoundarySafe(view, cut))
20
+ cut--;
21
+ if (cut <= 0)
22
+ return null; // nothing safe to cut — keep everything this call
23
+ const recent = view.slice(cut);
24
+ const userCount = recent.filter((m) => m.role === "user").length;
25
+ if (userCount < opts.anchorUserMessages)
26
+ return null;
27
+ return cut;
28
+ }
29
+ /** The formatted compacted-region summary as a user-role engine message. */
30
+ export function liveTrimSummaryMessage(opts) {
31
+ return {
32
+ role: "user",
33
+ text: formatCompactSummary(opts.summary),
34
+ toolName: undefined,
35
+ input: undefined,
36
+ output: undefined,
37
+ };
38
+ }
39
+ /** Build the live trimmed view. Returns the original view if summary is empty
40
+ * or the boundary is unsafe (no trim this call — try next). Pure + tested. */
41
+ export function buildLiveTrimmedView(view, opts) {
42
+ const cut = computeLiveTrimCut(view, opts);
43
+ if (cut === null)
44
+ return view;
45
+ const recent = view.slice(cut);
46
+ const summaryMsg = liveTrimSummaryMessage(opts);
47
+ return [summaryMsg, ...recent];
48
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * mega-trim.test.ts — tests for the live compaction view builder (S16).
3
+ */
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { buildLiveTrimmedView } from "./mega-trim.js";
7
+ function m(role, text, extra = {}) {
8
+ return { role, text, toolName: undefined, input: undefined, output: undefined, ...extra };
9
+ }
10
+ test("buildLiveTrimmedView: prepends a compacted summary and keeps the recent anchor", () => {
11
+ const view = [
12
+ m("user", "old request one"), m("assistant", "old answer one"),
13
+ m("user", "old request two"), m("assistant", "old answer two"),
14
+ m("user", "recent keep me"), m("assistant", "recent keep me too"),
15
+ ];
16
+ // Compacted region = first 4; recent anchor = last 2.
17
+ const result = buildLiveTrimmedView(view, {
18
+ compactedFrom: 4, // index where the compacted region ends
19
+ summary: "<summary>earlier work on old requests</summary>",
20
+ anchorUserMessages: 1,
21
+ });
22
+ // First element is the injected compacted summary as a user-role message.
23
+ assert.equal(result[0].role, "user");
24
+ assert.ok(String(result[0].text).includes("earlier work on old requests"));
25
+ // Recent anchor preserved in order, no older messages leak through.
26
+ assert.equal(result.length, 1 + 2, "summary + 2 recent");
27
+ assert.ok(result.slice(1).some((x) => String(x.text).includes("recent keep me")));
28
+ });
29
+ test("buildLiveTrimmedView: empty summary returns the original view unchanged", () => {
30
+ const view = [m("user", "x"), m("assistant", "y")];
31
+ const result = buildLiveTrimmedView(view, { compactedFrom: 0, summary: "", anchorUserMessages: 1 });
32
+ assert.deepEqual(result, view);
33
+ });
34
+ test("buildLiveTrimmedView: never splits a toolCall/toolResult pair (PREVENT-PI-002)", () => {
35
+ const view = [
36
+ m("user", "q"), m("assistant", "calls tool", { toolName: "read" }), m("tool", "result"),
37
+ m("user", "keep"), m("assistant", "ok"),
38
+ ];
39
+ // cut=3 would start the preserved run on the orphaned tool result at index 2 —
40
+ // the builder must snap back so the toolCall/toolResult pair is not split.
41
+ const result = buildLiveTrimmedView(view, { compactedFrom: 3, summary: "<summary>s</summary>", anchorUserMessages: 1 });
42
+ // The tool result must never appear preserved WITHOUT its preceding toolCall.
43
+ const preserved = result.slice(1);
44
+ const hasToolResult = preserved.some((x) => x.role === "tool");
45
+ const hasToolCall = preserved.some((x) => x.role === "assistant" && x.toolName);
46
+ // Either the tool pair is kept together, or the tool result is dropped into
47
+ // the compacted region — it is never left orphaned.
48
+ assert.ok(!(hasToolResult && !hasToolCall), "no orphaned tool result in the preserved run");
49
+ });
50
+ test("buildLiveTrimmedView: honors the anchor floor (PREVENT-PI-001)", () => {
51
+ // cut would leave zero user messages in the anchor — must skip the trim.
52
+ const view = [
53
+ m("user", "old q"), m("assistant", "old a"),
54
+ m("assistant", "only assistant kept"),
55
+ ];
56
+ const result = buildLiveTrimmedView(view, { compactedFrom: 2, summary: "<summary>s</summary>", anchorUserMessages: 1 });
57
+ assert.deepEqual(result, view, "below anchor floor → no trim this call");
58
+ });
@@ -46,6 +46,7 @@ export function loadDedupConfig() {
46
46
  DEDUP_SIM: envNum("MEGACOMPACT_DEDUP_SIM", 0.9),
47
47
  MMR_LAMBDA: envNum("MEGACOMPACT_MMR_LAMBDA", 0.5),
48
48
  SEMDEDUP_COSINE: envNum("MEGACOMPACT_SEMDEDUP_COSINE", 0.95),
49
+ CONSOLIDATE_COSINE: envNum("MEGACOMPACT_CONSOLIDATE_COSINE", 0.7),
49
50
  SIMILARITY_BUDGET_MS: envNum("MEGACOMPACT_SIMILARITY_BUDGET_MS", 50),
50
51
  L1_VERIFY_BUDGET_MS: envNum("MEGACOMPACT_L1_VERIFY_BUDGET_MS", 20),
51
52
  L1_CANDIDATE_CAP: envNum("MEGACOMPACT_L1_CANDIDATE_CAP", 100),
@@ -0,0 +1,103 @@
1
+ /**
2
+ * driftDetection.ts — R4: cross-repo drift detection over the machine-wide
3
+ * repo_registry (index.sqlite). Reads the registry, classifies each repo
4
+ * against simple drift signals, and returns a structured report that the
5
+ * dashboard's Multi-repo tab and the /api/drift endpoint can render.
6
+ *
7
+ * Signals (all derived from repo_registry alone — no checkpoint scans):
8
+ * - stale: last_seen older than STALE_DAYS (default 30). Repo is up but
9
+ * hasn't touched the dashboard in a while — usually parked work.
10
+ * - compaction_lag: last_seen within ACTIVE_DAYS (default 7) but
11
+ * last_compacted_at is null or > 24h behind. The repo is actively running
12
+ * work but compaction isn't keeping pace — usually a config regression.
13
+ * - model_churn: model_captured_at within MODEL_CHURN_DAYS (default 7) —
14
+ * the active model changed recently. Could be a routine upgrade or a
15
+ * silent fallback; both worth flagging.
16
+ *
17
+ * Scope: read-only by design. No writes — drift reporting should never mutate
18
+ * the registry. Severity classification is conservative: warnings, not alarms.
19
+ * @module
20
+ */
21
+ import { existsSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { DatabaseSync } from "node:sqlite";
24
+ import { getIndexDir } from "./store/sqlite.js";
25
+ const DAY_SEC = 86_400;
26
+ const STALE_DAYS = 30;
27
+ const ACTIVE_DAYS = 7;
28
+ const MODEL_CHURN_DAYS = 7;
29
+ /** Compaction lag threshold: last_seen newer than this AND last_compacted_at
30
+ * more than this far behind. 24h is generous — compactions usually fire in
31
+ * minutes; >24h usually means something is wedged. */
32
+ const COMPACTION_LAG_SEC = 24 * 3600;
33
+ /** Read all repos from the machine-wide registry, classify drift, return report. */
34
+ export function detectCrossRepoDrift(indexDir = getIndexDir()) {
35
+ const generatedAt = Math.floor(Date.now() / 1000);
36
+ const indexPath = join(indexDir, "index.sqlite");
37
+ const totals = { ok: 0, warn: 0, stale: 0, compactionLag: 0, modelChurn: 0 };
38
+ if (!existsSync(indexPath))
39
+ return { generatedAt, totals, repos: [] };
40
+ let db;
41
+ try {
42
+ db = new DatabaseSync(indexPath, { readOnly: true });
43
+ db.exec("PRAGMA journal_mode = WAL");
44
+ const rows = db
45
+ .prepare(`SELECT repo_root, display_name, last_seen, last_compacted_at,
46
+ model_name, provider, model_captured_at
47
+ FROM repo_registry`)
48
+ .all();
49
+ const repos = [];
50
+ for (const r of rows) {
51
+ const lastSeen = r.last_seen ?? 0;
52
+ const lastCompacted = r.last_compacted_at ?? null;
53
+ const modelCaptured = r.model_captured_at ?? null;
54
+ const signals = [];
55
+ if (lastSeen > 0 && generatedAt - lastSeen > STALE_DAYS * DAY_SEC) {
56
+ const daysAgo = Math.floor((generatedAt - lastSeen) / DAY_SEC);
57
+ signals.push({ kind: "stale", severity: "info", detail: `last activity ${daysAgo}d ago` });
58
+ totals.stale++;
59
+ }
60
+ if (lastSeen > 0 &&
61
+ generatedAt - lastSeen <= ACTIVE_DAYS * DAY_SEC &&
62
+ (lastCompacted === null || generatedAt - lastCompacted > COMPACTION_LAG_SEC)) {
63
+ const lagSec = lastCompacted ? generatedAt - lastCompacted : generatedAt - lastSeen;
64
+ const lagH = Math.floor(lagSec / 3600);
65
+ signals.push({
66
+ kind: "compaction_lag",
67
+ severity: "warn",
68
+ detail: lastCompacted ? `${lagH}h behind last activity` : "never compacted",
69
+ });
70
+ totals.compactionLag++;
71
+ }
72
+ if (modelCaptured && generatedAt - modelCaptured <= MODEL_CHURN_DAYS * DAY_SEC) {
73
+ const label = [r.provider, r.model_name].filter(Boolean).join("/") || "model";
74
+ signals.push({ kind: "model_churn", severity: "info", detail: `${label} captured recently` });
75
+ totals.modelChurn++;
76
+ }
77
+ const status = signals.some((s) => s.severity === "warn") ? "warn" : "ok";
78
+ if (status === "warn")
79
+ totals.warn++;
80
+ else
81
+ totals.ok++;
82
+ repos.push({
83
+ repoRoot: r.repo_root,
84
+ displayName: r.display_name ?? r.repo_root,
85
+ lastSeen,
86
+ lastCompactedAt: lastCompacted,
87
+ modelCapturedAt: modelCaptured,
88
+ signals,
89
+ status,
90
+ });
91
+ }
92
+ // Sort: warn first, then by lastSeen desc so the active ones are on top.
93
+ repos.sort((a, b) => {
94
+ if (a.status !== b.status)
95
+ return a.status === "warn" ? -1 : 1;
96
+ return b.lastSeen - a.lastSeen;
97
+ });
98
+ return { generatedAt, totals, repos };
99
+ }
100
+ finally {
101
+ db?.close();
102
+ }
103
+ }
@@ -0,0 +1,87 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { upsertRepoRegistry } from "./store/sqlite.js";
7
+ import { detectCrossRepoDrift } from "./driftDetection.js";
8
+ const NOW = Math.floor(Date.now() / 1000);
9
+ const D = 86_400;
10
+ test("driftDetection: empty registry returns ok report", () => {
11
+ const dir = mkdtempSync(join(tmpdir(), "drift-empty-"));
12
+ try {
13
+ const report = detectCrossRepoDrift(dir);
14
+ assert.equal(report.totals.ok, 0);
15
+ assert.equal(report.totals.warn, 0);
16
+ assert.equal(report.repos.length, 0);
17
+ }
18
+ finally {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ }
21
+ });
22
+ test("driftDetection: flags stale repos older than 30 days", () => {
23
+ const dir = mkdtempSync(join(tmpdir(), "drift-stale-"));
24
+ try {
25
+ upsertRepoRegistry({ repoRoot: "/r/old", displayName: "old", stateDir: "/r/old", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW - 45 * D }, dir);
26
+ const report = detectCrossRepoDrift(dir);
27
+ assert.equal(report.repos.length, 1);
28
+ assert.ok(report.repos[0].signals.some((s) => s.kind === "stale"), "stale signal present");
29
+ assert.equal(report.repos[0].status, "ok", "stale alone is info, not warn");
30
+ assert.equal(report.totals.stale, 1);
31
+ }
32
+ finally {
33
+ rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+ test("driftDetection: active repo with no compaction flagged as warn", () => {
37
+ const dir = mkdtempSync(join(tmpdir(), "drift-lag-"));
38
+ try {
39
+ upsertRepoRegistry({ repoRoot: "/r/active", displayName: "active", stateDir: "/r/active", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW - 1 * D }, dir);
40
+ const report = detectCrossRepoDrift(dir);
41
+ const r = report.repos[0];
42
+ assert.ok(r.signals.some((s) => s.kind === "compaction_lag"), "lag signal present");
43
+ assert.equal(r.status, "warn", "compaction lag is warn-level");
44
+ assert.equal(report.totals.warn, 1);
45
+ }
46
+ finally {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+ test("driftDetection: active repo with recent compaction is ok", () => {
51
+ const dir = mkdtempSync(join(tmpdir(), "drift-ok-"));
52
+ try {
53
+ upsertRepoRegistry({ repoRoot: "/r/healthy", displayName: "healthy", stateDir: "/r/healthy", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW, lastCompactedAt: NOW }, dir);
54
+ const report = detectCrossRepoDrift(dir);
55
+ const r = report.repos[0];
56
+ assert.equal(r.status, "ok");
57
+ assert.equal(r.signals.length, 0);
58
+ }
59
+ finally {
60
+ rmSync(dir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ test("driftDetection: recent model churn flagged as info", () => {
64
+ const dir = mkdtempSync(join(tmpdir(), "drift-model-"));
65
+ try {
66
+ upsertRepoRegistry({
67
+ repoRoot: "/r/swap",
68
+ displayName: "swap",
69
+ stateDir: "/r/swap",
70
+ checkpointCount: 1,
71
+ tokensSaved: 0,
72
+ compressedOriginalBytes: 0,
73
+ lastSeen: NOW,
74
+ lastCompactedAt: NOW,
75
+ provider: "anthropic",
76
+ providerName: "Anthropic",
77
+ modelName: "sonnet-4.6",
78
+ modelCapturedAt: NOW - 1 * D,
79
+ }, dir);
80
+ const report = detectCrossRepoDrift(dir);
81
+ assert.ok(report.repos[0].signals.some((s) => s.kind === "model_churn"), "model churn detected");
82
+ assert.equal(report.totals.modelChurn, 1);
83
+ }
84
+ finally {
85
+ rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ });
@@ -0,0 +1,147 @@
1
+ import { collectRecentUserRequests } from "./compact.js";
2
+ import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
3
+ import { DedupConfig } from "./config/dedup.js";
4
+ import { listMemories, removeMemory, replaceMemory } from "./store/sqlite.js";
5
+ import { getStateDir } from "./store.js";
6
+ const DECISION_PATTERNS = [
7
+ /\bwe (?:use|chose|decided|will use|standardized on|go with)\b/i,
8
+ /\b(?:the|our) (?:threshold|policy|rule|convention|default) is\b/i,
9
+ /\bactually\b/i, /\braise (?:the )?|lower (?:the )?|switch (?:to )?\b/i,
10
+ ];
11
+ // Patterns that signal an explicit memory drop. Grounded only when the user
12
+ // references an existing memory's content (handled in reviewConversation).
13
+ const DROP_PATTERNS = [
14
+ /\b(?:stop using|don't use|dont use|drop(?:ping)?|forget|remove from memory|no longer)\b/i,
15
+ ];
16
+ /** Heuristic, extractive review. No LLM. Downgrades un-grounded claims to none. */
17
+ export function reviewConversation(messages, existing = []) {
18
+ const ops = [];
19
+ const requests = collectRecentUserRequests(messages, 20);
20
+ for (let i = 0; i < requests.length; i++) {
21
+ const r = requests[i];
22
+ const isDecision = DECISION_PATTERNS.some((p) => p.test(r));
23
+ const isDrop = DROP_PATTERNS.some((p) => p.test(r));
24
+ // A "stop using/drop/forget" signal takes precedence over a decision — the
25
+ // user asking to forget a memory supersedes any "switch to" phrasing it
26
+ // happens to contain (which would otherwise route into REPLACE).
27
+ if (isDrop && existing.some((e) => sharesTopic(e.content, r))) {
28
+ const target = existing.find((e) => sharesTopic(e.content, r));
29
+ if (target)
30
+ ops.push({ op: "remove", content: target.content });
31
+ continue;
32
+ }
33
+ if (isDrop) {
34
+ // Drop pattern matched but no existing topic-overlapping memory — nothing
35
+ // to remove. Don't fall through to the decision branch (the request might
36
+ // also contain 'switch to' phrasing).
37
+ continue;
38
+ }
39
+ if (!isDecision)
40
+ continue;
41
+ // Treat earlier in-conversation decisions as "existing" too, so later messages
42
+ // that contradict them emit a REPLACE instead of an ADD.
43
+ const inConvoExisting = requests.slice(0, i).map((r) => ({ content: r }));
44
+ const contradicted = [...inConvoExisting, ...existing].find((e) => sharesTopic(e.content, r) && differs(e.content, r));
45
+ if (contradicted) {
46
+ ops.push({ op: "replace", targetContent: contradicted.content, memory: { content: r, category: "decision", sourceTurn: i } });
47
+ }
48
+ else if (!existing.some((e) => nearDup(e.content, r))) {
49
+ ops.push({ op: "add", memory: { content: r, category: "decision", sourceTurn: i } });
50
+ }
51
+ }
52
+ // Guardrail: drop any add/replace op whose memory content isn't grounded in a
53
+ // real message (hallucination prevention). REMOVE ops are exempt — their
54
+ // `content` is an EXISTING memory (matched by topic overlap), so it predates
55
+ // the current conversation and won't appear verbatim in any message.
56
+ return ops.filter((o) => o.op === "remove"
57
+ ? true
58
+ : messages.some((m) => String(m.text ?? "").includes(o.memory.content)));
59
+ }
60
+ function sharesTopic(a, b) {
61
+ const aw = new Set(a.toLowerCase().split(/\W+/).filter((w) => w.length > 3));
62
+ const bw = new Set(b.toLowerCase().split(/\W+/).filter((w) => w.length > 3));
63
+ let shared = 0;
64
+ for (const w of bw)
65
+ if (aw.has(w))
66
+ shared++;
67
+ return shared >= 1;
68
+ }
69
+ function differs(a, b) { return !nearDup(a, b); }
70
+ function nearDup(a, b) {
71
+ const aw = new Set(a.toLowerCase().split(/\W+/));
72
+ const bw = new Set(b.toLowerCase().split(/\W+/));
73
+ let shared = 0;
74
+ for (const w of bw)
75
+ if (aw.has(w))
76
+ shared++;
77
+ return shared / Math.max(1, bw.size) >= 0.8;
78
+ }
79
+ /**
80
+ * mergePhrases — for two near-duplicate texts (cosine >= threshold), build a
81
+ * merged content string. If the loser's token set is mostly contained in the
82
+ * survivor's, the survivor already covers the meaning and we keep it as-is.
83
+ * Otherwise we append the loser's text as a new paragraph so no phrasing is
84
+ * lost.
85
+ */
86
+ function mergePhrases(survivor, loser) {
87
+ if (nearDup(survivor, loser))
88
+ return survivor;
89
+ return `${survivor}\n\n${loser}`;
90
+ }
91
+ /**
92
+ * consolidateMemories — merge near-duplicate rows in the `memories` table
93
+ * (Sprint 21, Task S21.2). Pure local cosine over `defaultEmbedder`
94
+ * embeddings (zero-net, deterministic, no LLM). Uses the consolidation
95
+ * threshold `DedupConfig.CONSOLIDATE_COSINE` (default 0.7) — lower than the
96
+ * off-line SemDeDup threshold (0.95) because drift between manually-typed
97
+ * memories about the same topic is expected, and the goal is to clean it up
98
+ * rather than be paranoid about over-merging. One row survives; redundant
99
+ * rows are removed. Returns the number of merges performed.
100
+ *
101
+ * Algorithm:
102
+ * 1. Load all memories for the repo (or all repos when repo is null).
103
+ * 2. Embed their `content` field.
104
+ * 3. For every pair, if cosine >= threshold AND same category → merge:
105
+ * survivor is the newest (largest id). Loser's content is appended as a
106
+ * paragraph to the survivor's content if it adds non-redundant phrasing,
107
+ * otherwise dropped. Loser's row is removed.
108
+ *
109
+ * PREVENT-PI-004: local-only embedding, no network.
110
+ */
111
+ export async function consolidateMemories(stateDir = getStateDir(), repo = null, threshold = DedupConfig.CONSOLIDATE_COSINE) {
112
+ const rows = listMemories(repo, 1000, stateDir);
113
+ if (rows.length < 2)
114
+ return 0;
115
+ const emb = defaultEmbedder();
116
+ const vectors = rows.map((r) => emb.embed(r.content));
117
+ let merges = 0;
118
+ // Iterate once per row. Older rows are processed first; the merge keeps the
119
+ // newer (larger id), so we always merge away the older side.
120
+ for (let i = 0; i < rows.length; i++) {
121
+ if (rows[i].id == null)
122
+ continue; // safety: in case the row was removed mid-loop
123
+ for (let j = i + 1; j < rows.length; j++) {
124
+ if (rows[j].id == null)
125
+ continue;
126
+ if (rows[i].category !== rows[j].category)
127
+ continue; // different buckets: not a dup
128
+ const sim = cosineSimilarity(vectors[i], vectors[j]);
129
+ if (sim < threshold)
130
+ continue;
131
+ // Pick survivor: largest id (most recently inserted / referenced wins).
132
+ const survivorId = rows[i].id > rows[j].id ? rows[i].id : rows[j].id;
133
+ const loserId = survivorId === rows[i].id ? rows[j].id : rows[i].id;
134
+ const survivor = survivorId === rows[i].id ? rows[i] : rows[j];
135
+ const loser = survivorId === rows[i].id ? rows[j] : rows[i];
136
+ // Merge content: keep survivor's content; if loser's content adds a
137
+ // phrase (token overlap < 80% with survivor) append it as a paragraph.
138
+ const mergedContent = mergePhrases(survivor.content, loser.content);
139
+ replaceMemory(survivorId, { content: mergedContent }, stateDir);
140
+ removeMemory(loserId, stateDir);
141
+ // Mark the loser row in the surviving array so we skip it on later pairs.
142
+ rows[loserId === rows[i].id ? i : j] = { ...loser, id: undefined };
143
+ merges++;
144
+ }
145
+ }
146
+ return merges;
147
+ }