pi-mega-compact 0.11.7 → 0.11.9

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 (93) hide show
  1. package/dist/extensions/dashboard-server/gather-debug-keys.js +18 -0
  2. package/dist/extensions/dashboard-server/gather-debug.js +129 -0
  3. package/dist/extensions/dashboard-server/routes-maintenance.js +6 -0
  4. package/dist/extensions/dashboard-server/routes-memory-map.js +8 -15
  5. package/dist/extensions/mega-commands.js +65 -5
  6. package/dist/extensions/mega-config.js +7 -0
  7. package/dist/extensions/mega-events/agent-handlers.js +74 -5
  8. package/dist/extensions/mega-events/context-handler.js +132 -3
  9. package/dist/extensions/mega-runtime/snapshot.js +1 -1
  10. package/dist/extensions/mega-trim.js +10 -0
  11. package/dist/src/config/turns.js +2 -1
  12. package/dist/src/memoryGraph/embedding.js +50 -0
  13. package/dist/src/memoryGraph/gates.js +379 -0
  14. package/dist/src/memoryGraph/sources.js +390 -0
  15. package/dist/src/memoryGraph.js +133 -259
  16. package/dist/src/store/sqlite/embedding-cache.js +71 -0
  17. package/dist/src/store/sqlite.js +1 -0
  18. package/dist/src/topics/cluster.js +10 -2
  19. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DR9cLGDL.js → AchievementTiles-DFitrb4Y.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DR9cLGDL.js.map → AchievementTiles-DFitrb4Y.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/{AchievementsTab-svNUgJ1e.js → AchievementsTab-PJrmq8pa.js} +2 -2
  22. package/extensions/dashboard-client/dist/assets/{AchievementsTab-svNUgJ1e.js.map → AchievementsTab-PJrmq8pa.js.map} +1 -1
  23. package/extensions/dashboard-client/dist/assets/CacheTab-C1WE7dEx.js +2 -0
  24. package/extensions/dashboard-client/dist/assets/CacheTab-C1WE7dEx.js.map +1 -0
  25. package/extensions/dashboard-client/dist/assets/{ConfigTab-CmmWheuW.js → ConfigTab-Dx-RpO8C.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{ConfigTab-CmmWheuW.js.map → ConfigTab-Dx-RpO8C.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/{EventsTab-CFE7jLsa.js → EventsTab-C63BEWEs.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{EventsTab-CFE7jLsa.js.map → EventsTab-C63BEWEs.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{GameTab-CJAX_th_.js → GameTab-DHpJE6km.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{GameTab-CJAX_th_.js.map → GameTab-DHpJE6km.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/MaintenanceTab-BSv0GfBb.js +2 -0
  32. package/extensions/dashboard-client/dist/assets/MaintenanceTab-BSv0GfBb.js.map +1 -0
  33. package/extensions/dashboard-client/dist/assets/MemoryMapTab-Cg1fgEx_.js +2 -0
  34. package/extensions/dashboard-client/dist/assets/MemoryMapTab-Cg1fgEx_.js.map +1 -0
  35. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHQscixy.js → MetricsTab-DCahxNOQ.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHQscixy.js.map → MetricsTab-DCahxNOQ.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{OverviewTab-DBs4DJdd.js → OverviewTab-RYvO1SZL.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{OverviewTab-DBs4DJdd.js.map → OverviewTab-RYvO1SZL.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{ReposTab-DeFz76L5.js → ReposTab-BDGr_UXb.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{ReposTab-DeFz76L5.js.map → ReposTab-BDGr_UXb.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/assets/{SessionsTab-BWNsj1OB.js → SessionsTab-ByrTnHaB.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{SessionsTab-BWNsj1OB.js.map → SessionsTab-ByrTnHaB.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{SetupTab-uS6A0xmw.js → SetupTab-CELafKis.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{SetupTab-uS6A0xmw.js.map → SetupTab-CELafKis.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-CiqCOQuL.js → TimeSavedCard-BLWtmfr_.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-CiqCOQuL.js.map → TimeSavedCard-BLWtmfr_.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/TopicsTab-8Fsh_Igx.js +2 -0
  48. package/extensions/dashboard-client/dist/assets/TopicsTab-8Fsh_Igx.js.map +1 -0
  49. package/extensions/dashboard-client/dist/assets/{TurnsTab-CVz4f4-o.js → TurnsTab-D7mgXDe2.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{TurnsTab-CVz4f4-o.js.map → TurnsTab-D7mgXDe2.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/{index-DrktNOoT.js → index-h3jgEs6G.js} +10 -10
  52. package/extensions/dashboard-client/dist/assets/{index-DrktNOoT.js.map → index-h3jgEs6G.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{useSSE-Bf5LOLSi.js → useSSE-C5lmipBF.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{useSSE-Bf5LOLSi.js.map → useSSE-C5lmipBF.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/index.html +1 -1
  56. package/extensions/dashboard-client/src/api/client.ts +5 -0
  57. package/extensions/dashboard-client/src/memory-map-layout.ts +161 -0
  58. package/extensions/dashboard-client/src/memory-map-shapes.tsx +126 -0
  59. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +29 -2
  60. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +101 -0
  61. package/extensions/dashboard-client/src/tabs/MemoryMapTab.tsx +201 -165
  62. package/extensions/dashboard-client/src/tabs/ReposTab.tsx +21 -0
  63. package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +1 -1
  64. package/extensions/dashboard-server/api-contracts/index.ts +3 -1
  65. package/extensions/dashboard-server/api-contracts/maintenance.ts +13 -0
  66. package/extensions/dashboard-server/api-contracts/memory-map.ts +14 -6
  67. package/extensions/dashboard-server/gather-debug-keys.ts +18 -0
  68. package/extensions/dashboard-server/gather-debug.ts +136 -0
  69. package/extensions/dashboard-server/routes-maintenance.ts +7 -0
  70. package/extensions/dashboard-server/routes-memory-map.ts +21 -29
  71. package/extensions/mega-commands.ts +63 -6
  72. package/extensions/mega-config.ts +22 -0
  73. package/extensions/mega-events/agent-handlers.ts +88 -5
  74. package/extensions/mega-events/context-handler.ts +152 -3
  75. package/extensions/mega-runtime/snapshot.ts +1 -1
  76. package/extensions/mega-trim.ts +15 -0
  77. package/package.json +1 -1
  78. package/src/config/turns.ts +5 -2
  79. package/src/memoryGraph/embedding.ts +63 -0
  80. package/src/memoryGraph/gates.ts +451 -0
  81. package/src/memoryGraph/sources.ts +484 -0
  82. package/src/memoryGraph.ts +188 -350
  83. package/src/store/sqlite/embedding-cache.ts +81 -0
  84. package/src/store/sqlite.ts +1 -0
  85. package/src/topics/cluster.ts +11 -1
  86. package/extensions/dashboard-client/dist/assets/CacheTab-ASwhOMjR.js +0 -2
  87. package/extensions/dashboard-client/dist/assets/CacheTab-ASwhOMjR.js.map +0 -1
  88. package/extensions/dashboard-client/dist/assets/MaintenanceTab-P4SUsvMf.js +0 -2
  89. package/extensions/dashboard-client/dist/assets/MaintenanceTab-P4SUsvMf.js.map +0 -1
  90. package/extensions/dashboard-client/dist/assets/MemoryMapTab-BBiH76eF.js +0 -2
  91. package/extensions/dashboard-client/dist/assets/MemoryMapTab-BBiH76eF.js.map +0 -1
  92. package/extensions/dashboard-client/dist/assets/TopicsTab-BWWQxltv.js +0 -2
  93. package/extensions/dashboard-client/dist/assets/TopicsTab-BWWQxltv.js.map +0 -1
@@ -7,11 +7,26 @@ import { stampTurnsEpochFor } from "../mega-turn-store.js";
7
7
  import { TurnsConfig } from "../../src/config/turns.js";
8
8
  import { openTurnStore } from "../../src/store/turns/connection.js";
9
9
  import { buildTopicModel, createTopicStore, bumpWikiCompactCounter, } from "../../src/topics/index.js";
10
+ import { TrigramEmbedder } from "../../src/embedder.js";
10
11
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "../mega-trim.js";
11
12
  import { pressureFromPct, pressureRatio, } from "../mega-config.js";
12
13
  import { appendMirrorMessages } from "./mirror-append.js";
13
14
  import { stagedForTail, withRecallTail } from "./recall-tail.js";
14
- import { buildSeparatedPrompt } from "./separated-prompt.js";
15
+ import { buildSeparatedPrompt, buildCacheOptimizedPrompt } from "./separated-prompt.js";
16
+ /** Best-effort text extraction from an AgentMessage for analytics/logging.
17
+ * AgentMessage is a discriminated union; .content exists only on some
18
+ * variants (user/assistant/toolResult/custom). Narrow by role, never assume. */
19
+ function messageContentText(m) {
20
+ const c = m.content;
21
+ if (typeof c === "string")
22
+ return c;
23
+ if (Array.isArray(c)) {
24
+ return c
25
+ .map((b) => (b && typeof b === "object" && "text" in b ? String(b.text ?? "") : ""))
26
+ .join(" ");
27
+ }
28
+ return "";
29
+ }
15
30
  /** Register the context event handler (live-trim auto-trigger). */
16
31
  export function registerContextHandler(pi, runtime, config) {
17
32
  // ---- Auto-trigger: live trim (compact and continue) + native durable ----
@@ -40,14 +55,32 @@ export function registerContextHandler(pi, runtime, config) {
40
55
  // reaches raw_transcript (PREVENT-PI: append-only, view-only).
41
56
  const tailResult = (msgs) => {
42
57
  const base = msgs ?? messages;
43
- if (!stagedForTail(runtime, config) && !config.messageSeparation)
58
+ if (!stagedForTail(runtime, config) && !config.messageSeparation && !config.cacheStriping)
44
59
  return undefined;
45
60
  let result = stagedForTail(runtime, config)
46
61
  ? withRecallTail(base, runtime, config)
47
62
  : base;
48
- if (config.messageSeparation) {
63
+ if (config.cacheStriping) {
64
+ result = buildCacheOptimizedPrompt(result);
65
+ }
66
+ else if (config.messageSeparation) {
49
67
  result = buildSeparatedPrompt(result);
50
68
  }
69
+ // P2.5: log prefix stability (fire-and-forget, non-fatal).
70
+ // tailResult is sync, so use .then().catch() on the dynamic import.
71
+ if (result.length > 1) {
72
+ import("../../src/cache-stripe-impl.js").then(({ computeStabilityScore }) => {
73
+ const stableScore = computeStabilityScore({ content: messageContentText(result[0] ?? result[0]), chunkId: "prefix", accessCount: 0, lastAccessedAt: 0 }, result.slice(0, 2).map((m) => ({ content: messageContentText(m), chunkId: "prefix", accessCount: 0, lastAccessedAt: 0 })));
74
+ runtime.logger.info("prefix_stability", {
75
+ stableScore: Number.isFinite(stableScore) ? stableScore : 0,
76
+ prefixMessages: result.length,
77
+ separation: config.messageSeparation ? "v2" : "off",
78
+ striping: config.cacheStriping ? "v3" : "off",
79
+ });
80
+ }).catch(() => {
81
+ // Non-fatal: stability logging is best-effort.
82
+ });
83
+ }
51
84
  return { messages: result };
52
85
  };
53
86
  // Always track context for the dashboard/widget, even when auto is off.
@@ -88,6 +121,30 @@ export function registerContextHandler(pi, runtime, config) {
88
121
  try {
89
122
  const db = openStore(runtime.currentStateDir);
90
123
  appendMirrorMessages(db, messages, runtime.rt.sessionId, epochIdFor(runtime.rt.sessionId), runtime.currentTurn);
124
+ // P2.2: populate conversation_thread + tool_results tables for
125
+ // prompt-cache analytics and durable separation. The live-array
126
+ // separation (buildSeparatedPrompt / buildCacheOptimizedPrompt in
127
+ // tailResult above) is sufficient for the prompt-construction path;
128
+ // these DB writes persist the split for post-hoc analysis, dashboard
129
+ // queries, and future readers. Non-fatal — failure here never breaks
130
+ // the agent loop (PREVENT-PI-004: zero network, local SQLite only).
131
+ {
132
+ const sid = runtime.rt.sessionId;
133
+ const turn = runtime.currentTurn;
134
+ const now = Date.now();
135
+ const threadStmt = db.prepare("INSERT OR IGNORE INTO conversation_thread (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)");
136
+ const toolStmt = db.prepare("INSERT OR IGNORE INTO tool_results (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)");
137
+ for (const m of messages) {
138
+ const role = m.role;
139
+ const content = messageContentText(m);
140
+ if (role === "user" || role === "assistant") {
141
+ threadStmt.run(sid, role, content, turn, now);
142
+ }
143
+ else if (role === "toolResult" || role === "bashExecution") {
144
+ toolStmt.run(sid, role, content, turn, now);
145
+ }
146
+ }
147
+ }
91
148
  }
92
149
  catch (e) {
93
150
  runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
@@ -250,6 +307,70 @@ export function registerContextHandler(pi, runtime, config) {
250
307
  error: String(wikiErr),
251
308
  });
252
309
  }
310
+ // D1: seed the topic model from raw_transcript when context_chunks is
311
+ // thin (pre-compaction). Gated on WIKI_SEED_FROM_TURNS; non-fatal.
312
+ // Seeds buildTopicModel with on-the-fly trigram embeddings from
313
+ // recent raw_transcript rows for the current session.
314
+ try {
315
+ if (config.autoWikiEnabled &&
316
+ config.turnsDbEnabled &&
317
+ TurnsConfig.WIKI_SEED_FROM_TURNS) {
318
+ const floor = 50;
319
+ const countRow = db
320
+ .prepare(`SELECT COUNT(*) AS cnt FROM context_chunks WHERE session_id = ?`)
321
+ .get(runtime.rt.sessionId);
322
+ const chunkCount = countRow?.cnt ?? 0;
323
+ if (chunkCount < floor) {
324
+ const transcriptRows = db
325
+ .prepare(`SELECT DISTINCT content_bytes
326
+ FROM raw_transcript
327
+ WHERE session_id = ?
328
+ AND length(content_bytes) > 10
329
+ ORDER BY seq ASC
330
+ LIMIT 200`)
331
+ .all(runtime.rt.sessionId);
332
+ if (transcriptRows.length > 0) {
333
+ const embedder = new TrigramEmbedder();
334
+ const seedChunks = [];
335
+ for (let i = 0; i < transcriptRows.length; i++) {
336
+ const text = transcriptRows[i].content_bytes.trim();
337
+ if (text.length === 0)
338
+ continue;
339
+ const vec = embedder.embed(text);
340
+ seedChunks.push({
341
+ chunkId: `seed_transcript_${i}`,
342
+ sessionId: runtime.rt.sessionId,
343
+ vec,
344
+ text,
345
+ });
346
+ }
347
+ if (seedChunks.length > 0) {
348
+ const model = buildTopicModel(db, {
349
+ kRange: [
350
+ TurnsConfig.WIKI_K_MIN,
351
+ TurnsConfig.WIKI_K_MAX,
352
+ ],
353
+ labelTopTerms: TurnsConfig.WIKI_LABEL_TOP_TERMS,
354
+ restarts: 5,
355
+ seed: 0x9e3779b9,
356
+ }, seedChunks);
357
+ createTopicStore(runtime.currentStateDir).replaceTopicModel(model);
358
+ runtime.logger.info("wiki_seed", {
359
+ clusterCount: model.k,
360
+ sourceChunks: seedChunks.length,
361
+ totalChunks: model.totalChunks,
362
+ method: "kmeans+tfidf",
363
+ });
364
+ }
365
+ }
366
+ }
367
+ }
368
+ }
369
+ catch (seedErr) {
370
+ runtime.logger.warn("wiki_seed_failed", {
371
+ error: String(seedErr),
372
+ });
373
+ }
253
374
  // S27 Task 6: Fire-and-forget dedup pipeline.
254
375
  // Deduplicates raw_transcript rows for the compacted range.
255
376
  try {
@@ -336,6 +457,13 @@ export function registerContextHandler(pi, runtime, config) {
336
457
  compactedFrom: ran.result.compactedFrom,
337
458
  summary: ran.result.summary,
338
459
  anchorUserMessages,
460
+ // CRITICAL-OVER ESCAPE HATCH: when context is at/over ~90% of the
461
+ // window, relief takes priority over the anchor floor. Without this,
462
+ // computeLiveTrimCut bails to null (can't satisfy the floor) and the
463
+ // model is fed a raw overflow that errors every turn — the
464
+ // "Already compacted" + overflow death-spiral (2026-08-01 incident).
465
+ // A thin anchor is recoverable; an overflowed session is not.
466
+ criticalOver: (pct ?? 0) >= 90,
339
467
  });
340
468
  if (cut === null) {
341
469
  runtime.diagCtxCutNull++;
@@ -344,6 +472,7 @@ export function registerContextHandler(pi, runtime, config) {
344
472
  compactedFrom: ran.result.compactedFrom,
345
473
  viewLen: view.length,
346
474
  anchorUserMessages,
475
+ criticalOver: (pct ?? 0) >= 90,
347
476
  });
348
477
  return tailResult() ?? undefined; // unsafe / below anchor floor — no trim this call
349
478
  }
@@ -131,7 +131,7 @@ export function computeMegaSnapshot(p) {
131
131
  cachePct,
132
132
  megaCacheFlare: p.megaCacheFlare,
133
133
  megaCacheFlarePct: p.megaCacheFlarePct,
134
- perTurnCacheHitPct: p.perTurnCacheHitPct ?? cachePct,
134
+ perTurnCacheHitPct: p.perTurnCacheHitPct,
135
135
  levelUpFlare: p.levelUpFlare,
136
136
  achievementFlare: p.achievementFlare,
137
137
  achievementFlareTitles: p.achievementFlareTitles,
@@ -48,10 +48,20 @@ export function computeLiveTrimCut(view, opts) {
48
48
  if (cut > 1) {
49
49
  const finalRecent = view.slice(cut);
50
50
  if (finalRecent.filter((m) => m.role === "user").length < opts.anchorUserMessages) {
51
+ // CRITICAL-OVER ESCAPE HATCH: when context is critically over the window,
52
+ // return the boundary-safe cut even though it's below the anchor floor.
53
+ // Bailing to null here would feed the model the raw overflow and trap the
54
+ // session in an unrecoverable error loop ("Already compacted" + overflow).
55
+ // A thin anchor is recoverable; an overflowed session is not.
56
+ if (opts.criticalOver && cut > 0)
57
+ return cut;
51
58
  return null; // cannot satisfy the floor without dropping too much — retry next call
52
59
  }
53
60
  }
54
61
  else {
62
+ // Same escape hatch for the cut > 1 false branch.
63
+ if (opts.criticalOver && cut > 0)
64
+ return cut;
55
65
  return null;
56
66
  }
57
67
  }
@@ -42,7 +42,8 @@ export function loadTurnsConfig() {
42
42
  WIKI_K_MIN: kMin,
43
43
  WIKI_K_MAX: kMax,
44
44
  WIKI_LABEL_TOP_TERMS: envNum("MEGACOMPACT_WIKI_LABEL_TOP_TERMS", 5),
45
- WIKI_REBUILD_EVERY_N_COMPACTS: envNum("MEGACOMPACT_WIKI_REBUILD_EVERY", 10),
45
+ WIKI_REBUILD_EVERY_N_COMPACTS: envNum("MEGACOMPACT_WIKI_REBUILD_EVERY", 3),
46
+ WIKI_SEED_FROM_TURNS: envBool("MEGACOMPACT_WIKI_SEED_FROM_TURNS", true),
46
47
  };
47
48
  }
48
49
  export const TurnsConfig = loadTurnsConfig();
@@ -0,0 +1,50 @@
1
+ import { TrigramEmbedder } from "../embedder.js";
2
+ let _trigramEmbedder = null;
3
+ function lazyEmbedder() {
4
+ if (!_trigramEmbedder) {
5
+ _trigramEmbedder = new TrigramEmbedder();
6
+ }
7
+ return _trigramEmbedder;
8
+ }
9
+ function simpleHash(s) {
10
+ let h = 0x811c9dc5;
11
+ for (let i = 0; i < s.length; i++) {
12
+ h ^= s.charCodeAt(i);
13
+ h = Math.imul(h, 0x01000193);
14
+ }
15
+ return h.toString(36);
16
+ }
17
+ /** Compute or retrieve a cached embedding vector for a text string.
18
+ * Uses the embedding_cache table (created by the A2 migration) for
19
+ * persistence. Falls back to uncached TrigramEmbedder on table error. */
20
+ export function getOrComputeEmbedding(db, content) {
21
+ try {
22
+ const hash = simpleHash(content);
23
+ const cached = db
24
+ .prepare("SELECT embedding FROM embedding_cache WHERE content_hash = ?")
25
+ .get(hash);
26
+ if (cached && typeof cached.embedding === "string") {
27
+ return JSON.parse(cached.embedding);
28
+ }
29
+ const vec = lazyEmbedder().embed(content);
30
+ db.prepare("INSERT OR REPLACE INTO embedding_cache (content_hash, content, embedding) VALUES (?, ?, ?)").run(hash, content, JSON.stringify(vec));
31
+ return vec;
32
+ }
33
+ catch {
34
+ return lazyEmbedder().embed(content);
35
+ }
36
+ }
37
+ /** Cosine similarity between two equal-length vectors. */
38
+ export function cosineSimilarity(a, b) {
39
+ let dot = 0;
40
+ let na = 0;
41
+ let nb = 0;
42
+ const len = Math.min(a.length, b.length);
43
+ for (let i = 0; i < len; i++) {
44
+ dot += a[i] * b[i];
45
+ na += a[i] * a[i];
46
+ nb += b[i] * b[i];
47
+ }
48
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
49
+ return denom > 0 ? dot / denom : 0;
50
+ }
@@ -0,0 +1,379 @@
1
+ import { Logger } from "../log.js";
2
+ const log = new Logger();
3
+ // ---------------------------------------------------------------------------
4
+ // Gate 1 — Source availability guard (informational, no drop)
5
+ // ---------------------------------------------------------------------------
6
+ export function gateSourceAvailable(ws) {
7
+ if (ws.nodes.length === 0) {
8
+ log.warn("graph_source_unavailable", {
9
+ detail: "No nodes from any source — graph will be empty",
10
+ });
11
+ return { ok: false, dropped: 0, reason: "No nodes from any source" };
12
+ }
13
+ return { ok: true, dropped: 0 };
14
+ }
15
+ // ---------------------------------------------------------------------------
16
+ // Gate 2 — Identity merge: duplicate nodes with same id → richest wins
17
+ // ---------------------------------------------------------------------------
18
+ export function gateIdentityMerge(ws) {
19
+ const { nodes } = ws;
20
+ const seen = new Map();
21
+ let dropped = 0;
22
+ for (const n of nodes) {
23
+ const existing = seen.get(n.id);
24
+ if (!existing) {
25
+ seen.set(n.id, n);
26
+ continue;
27
+ }
28
+ const existingScore = fieldScore(existing);
29
+ const currentScore = fieldScore(n);
30
+ if (currentScore > existingScore) {
31
+ seen.set(n.id, n);
32
+ }
33
+ dropped++;
34
+ log.warn("graph_identity_leak", {
35
+ nodeId: n.id,
36
+ dropped: currentScore > existingScore ? existing.id : n.id,
37
+ });
38
+ }
39
+ ws.nodes = [...seen.values()];
40
+ return { ok: dropped === 0, dropped, reason: dropped > 0 ? `${dropped} duplicate node ids merged` : undefined };
41
+ }
42
+ function fieldScore(n) {
43
+ // Base score by nodeType — richer sources win ties (spec: "richest available
44
+ // source wins"). checkpoint > turn-content > memory > turn-structural.
45
+ let s = 0;
46
+ switch (n.nodeType) {
47
+ case "checkpoint":
48
+ s += 4;
49
+ break;
50
+ case "turn-content":
51
+ s += 3;
52
+ break;
53
+ case "memory":
54
+ s += 2;
55
+ break;
56
+ case "turn":
57
+ s += 1;
58
+ break;
59
+ }
60
+ if (n.summaryTruncated)
61
+ s++;
62
+ if (n.textSnippet)
63
+ s++;
64
+ if (n.topicSummary)
65
+ s++;
66
+ if (n.tokenEstimate > 0)
67
+ s++;
68
+ if (n.decisionCount > 0)
69
+ s++;
70
+ if (n.dedupStatus)
71
+ s++;
72
+ return s;
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Gate 3 — Promotion guard: suppress turns with epoch_id if checkpoint exists
76
+ // ---------------------------------------------------------------------------
77
+ export function gatePromotionGuard(ws) {
78
+ const checkpointIds = new Set();
79
+ for (const n of ws.nodes) {
80
+ if (n.nodeType === "checkpoint")
81
+ checkpointIds.add(n.id);
82
+ }
83
+ const keep = [];
84
+ const droppedIds = new Set();
85
+ let dropped = 0;
86
+ for (const n of ws.nodes) {
87
+ if (n.nodeType !== "turn" && n.nodeType !== "turn-content") {
88
+ keep.push(n);
89
+ continue;
90
+ }
91
+ const epochId = n.epochId;
92
+ if (epochId && checkpointIds.has(epochId)) {
93
+ droppedIds.add(n.id);
94
+ dropped++;
95
+ log.warn("graph_orphaned_epoch", {
96
+ nodeId: n.id,
97
+ epochId,
98
+ detail: "Turn suppressed because its checkpoint exists in the graph",
99
+ });
100
+ }
101
+ else {
102
+ if (epochId && !checkpointIds.has(epochId)) {
103
+ log.warn("graph_orphaned_epoch", {
104
+ nodeId: n.id,
105
+ epochId,
106
+ detail: "Orphaned epoch — no matching checkpoint; keeping turn",
107
+ });
108
+ }
109
+ keep.push(n);
110
+ }
111
+ }
112
+ ws.nodes = keep;
113
+ ws.edges = ws.edges.filter((e) => !droppedIds.has(e.source) && !droppedIds.has(e.target));
114
+ return {
115
+ ok: dropped === 0,
116
+ dropped,
117
+ reason: dropped > 0 ? `${dropped} turn nodes suppressed (checkpoint exists)` : undefined,
118
+ };
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Gate 4 — Node type / field completeness
122
+ // ---------------------------------------------------------------------------
123
+ export function gateNodeCompleteness(ws) {
124
+ const validNodes = [];
125
+ let dropped = 0;
126
+ for (const n of ws.nodes) {
127
+ if (!n.nodeType || !n.sessionId) {
128
+ dropped++;
129
+ log.warn("graph_node_double", {
130
+ nodeId: n.id,
131
+ detail: "Missing nodeType or sessionId",
132
+ });
133
+ continue;
134
+ }
135
+ validNodes.push(n);
136
+ }
137
+ ws.nodes = validNodes;
138
+ return {
139
+ ok: dropped === 0,
140
+ dropped,
141
+ reason: dropped > 0 ? `${dropped} nodes dropped for missing fields` : undefined,
142
+ };
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Gate 5 — Dangling edge guard
146
+ // ---------------------------------------------------------------------------
147
+ export function gateDanglingEdges(ws) {
148
+ const validIds = new Set(ws.nodes.map((n) => n.id));
149
+ const validEdges = [];
150
+ let dropped = 0;
151
+ for (const e of ws.edges) {
152
+ if (validIds.has(e.source) && validIds.has(e.target)) {
153
+ validEdges.push(e);
154
+ }
155
+ else {
156
+ dropped++;
157
+ log.warn("graph_dangling_edge", {
158
+ source: e.source,
159
+ target: e.target,
160
+ detail: "Edge references non-existent node(s)",
161
+ });
162
+ }
163
+ }
164
+ ws.edges = validEdges;
165
+ return {
166
+ ok: dropped === 0,
167
+ dropped,
168
+ reason: dropped > 0 ? `${dropped} dangling edges removed` : undefined,
169
+ };
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Gate 6 — Edge type / threshold enforcement
173
+ // ---------------------------------------------------------------------------
174
+ function envThreshold(name, def) {
175
+ const v = process.env[name];
176
+ if (v === undefined)
177
+ return def;
178
+ const n = Number(v);
179
+ return Number.isFinite(n) ? n : def;
180
+ }
181
+ export function gateEdgeThresholds(ws, options) {
182
+ const withinThreshold = options?.withinTypeThreshold ?? envThreshold("MEGACOMPACT_MEMORY_GRAPH_WITHIN_TYPE_THRESHOLD", 0.7);
183
+ const crossThreshold = options?.crossTypeThreshold ?? envThreshold("MEGACOMPACT_MEMORY_GRAPH_CROSS_TYPE_THRESHOLD", 0.85);
184
+ const nodeTypeMap = new Map(ws.nodes.map((n) => [n.id, n.nodeType]));
185
+ const validEdges = [];
186
+ let dropped = 0;
187
+ for (const e of ws.edges) {
188
+ if (e.type !== "semantic") {
189
+ validEdges.push(e);
190
+ continue;
191
+ }
192
+ const srcType = nodeTypeMap.get(e.source);
193
+ const tgtType = nodeTypeMap.get(e.target);
194
+ if (srcType === "turn" || tgtType === "turn") {
195
+ dropped++;
196
+ log.warn("graph_structural_semantic_edge", {
197
+ nodeId: srcType === "turn" ? e.source : e.target,
198
+ source: e.source,
199
+ target: e.target,
200
+ score: e.weight,
201
+ detail: "Semantic edge on structural (turn) node",
202
+ });
203
+ continue;
204
+ }
205
+ const sameType = srcType != null && srcType === tgtType;
206
+ const threshold = sameType ? withinThreshold : crossThreshold;
207
+ if (e.weight < threshold) {
208
+ dropped++;
209
+ log.warn("graph_edge_below_threshold", {
210
+ source: e.source,
211
+ target: e.target,
212
+ score: e.weight,
213
+ typePair: `${String(srcType)}↔${String(tgtType)}`,
214
+ threshold,
215
+ detail: `Edge below ${sameType ? "within-type" : "cross-type"} threshold`,
216
+ });
217
+ continue;
218
+ }
219
+ validEdges.push(e);
220
+ }
221
+ ws.edges = validEdges;
222
+ return {
223
+ ok: dropped === 0,
224
+ dropped,
225
+ reason: dropped > 0 ? `${dropped} edges below threshold removed` : undefined,
226
+ };
227
+ }
228
+ // ---------------------------------------------------------------------------
229
+ // Gate 7 — Dedup mirror cross-check
230
+ // ---------------------------------------------------------------------------
231
+ export function gateDedupRedundant(ws, options) {
232
+ const redundant = options?.redundantIds ?? new Set();
233
+ if (redundant.size === 0)
234
+ return { ok: true, dropped: 0 };
235
+ const keep = [];
236
+ const droppedIds = new Set();
237
+ let dropped = 0;
238
+ for (const n of ws.nodes) {
239
+ if (n.nodeType === "turn-content" && redundant.has(n.id)) {
240
+ dropped++;
241
+ droppedIds.add(n.id);
242
+ log.warn("graph_dedup_redundant", {
243
+ nodeId: n.id,
244
+ contentHash: n.id,
245
+ detail: "Turn content already collapsed into checkpoint",
246
+ });
247
+ }
248
+ else {
249
+ keep.push(n);
250
+ }
251
+ }
252
+ if (droppedIds.size > 0) {
253
+ ws.edges = ws.edges.filter((e) => !droppedIds.has(e.source) && !droppedIds.has(e.target));
254
+ }
255
+ ws.nodes = keep;
256
+ return {
257
+ ok: dropped === 0,
258
+ dropped,
259
+ reason: dropped > 0 ? `${dropped} turn-content nodes redundant with checkpoints` : undefined,
260
+ };
261
+ }
262
+ // ---------------------------------------------------------------------------
263
+ // Gate 8 — Structural vs semantic edge consistency
264
+ // ---------------------------------------------------------------------------
265
+ export function gateStructuralSemanticEdges(ws) {
266
+ const structuralTypes = new Set(["checkpoint", "turn"]);
267
+ const keep = [];
268
+ let dropped = 0;
269
+ for (const e of ws.edges) {
270
+ if (e.type !== "semantic") {
271
+ keep.push(e);
272
+ continue;
273
+ }
274
+ const srcNode = ws.nodes.find((n) => n.id === e.source);
275
+ const tgtNode = ws.nodes.find((n) => n.id === e.target);
276
+ if (srcNode && structuralTypes.has(srcNode.nodeType)) {
277
+ dropped++;
278
+ log.warn("graph_structural_semantic_edge", {
279
+ nodeId: srcNode.id,
280
+ nodeType: srcNode.nodeType,
281
+ detail: "Structural node has semantic edge",
282
+ });
283
+ }
284
+ else if (tgtNode && structuralTypes.has(tgtNode.nodeType)) {
285
+ dropped++;
286
+ log.warn("graph_structural_semantic_edge", {
287
+ nodeId: tgtNode.id,
288
+ nodeType: tgtNode.nodeType,
289
+ detail: "Structural node has semantic edge",
290
+ });
291
+ }
292
+ else {
293
+ keep.push(e);
294
+ }
295
+ }
296
+ ws.edges = keep;
297
+ return {
298
+ ok: dropped === 0,
299
+ dropped,
300
+ reason: dropped > 0 ? `${dropped} semantic edges on structural nodes removed` : undefined,
301
+ };
302
+ }
303
+ // ---------------------------------------------------------------------------
304
+ // Gate 9 — Same-type edge deduplication
305
+ // ---------------------------------------------------------------------------
306
+ export function gateDedupEdges(ws) {
307
+ const best = new Map();
308
+ let dropped = 0;
309
+ for (const e of ws.edges) {
310
+ const key = `${e.source}|${e.target}|${e.type}`;
311
+ const existing = best.get(key);
312
+ if (!existing || e.weight > existing.weight) {
313
+ if (existing)
314
+ dropped++;
315
+ best.set(key, e);
316
+ }
317
+ else {
318
+ dropped++;
319
+ }
320
+ }
321
+ if (dropped > 0) {
322
+ log.warn("graph_dedup_redundant", {
323
+ count: dropped,
324
+ detail: "Redundant edges deduplicated",
325
+ });
326
+ }
327
+ ws.edges = [...best.values()];
328
+ return {
329
+ ok: dropped === 0,
330
+ dropped,
331
+ reason: dropped > 0 ? `${dropped} redundant edges deduplicated` : undefined,
332
+ };
333
+ }
334
+ /** Run all 9 gates in order over a working set. Returns validated set + stats.
335
+ * Gate 7 uses optional redundantIds from the caller. */
336
+ export function runValidationPipeline(ws, options) {
337
+ const gatesRun = [];
338
+ const gatesPassed = [];
339
+ const warnings = [];
340
+ let totalDroppedNodes = 0;
341
+ let totalDroppedEdges = 0;
342
+ const gates = [
343
+ { name: "source_available", fn: () => gateSourceAvailable(ws) },
344
+ { name: "identity_merge", fn: () => gateIdentityMerge(ws) },
345
+ { name: "promotion_guard", fn: () => gatePromotionGuard(ws) },
346
+ { name: "node_completeness", fn: () => gateNodeCompleteness(ws) },
347
+ { name: "dangling_edges", fn: () => gateDanglingEdges(ws) },
348
+ { name: "edge_thresholds", fn: () => gateEdgeThresholds(ws, { withinTypeThreshold: options?.withinTypeThreshold, crossTypeThreshold: options?.crossTypeThreshold }) },
349
+ { name: "dedup_redundant", fn: () => gateDedupRedundant(ws, { redundantIds: options?.redundantIds }) },
350
+ { name: "structural_semantic", fn: () => gateStructuralSemanticEdges(ws) },
351
+ { name: "dedup_edges", fn: () => gateDedupEdges(ws) },
352
+ ];
353
+ for (const gate of gates) {
354
+ gatesRun.push(gate.name);
355
+ const r = gate.fn();
356
+ if (r.ok) {
357
+ gatesPassed.push(gate.name);
358
+ }
359
+ if (r.reason) {
360
+ warnings.push({ gate: gate.name, code: "gate_warning", count: r.dropped || 1 });
361
+ }
362
+ // Track by category
363
+ if (["identity_merge", "promotion_guard", "node_completeness"].includes(gate.name)) {
364
+ totalDroppedNodes += r.dropped;
365
+ }
366
+ if (["dangling_edges", "edge_thresholds", "dedup_redundant", "structural_semantic", "dedup_edges"].includes(gate.name)) {
367
+ totalDroppedEdges += r.dropped;
368
+ }
369
+ }
370
+ return {
371
+ ws,
372
+ stats: {
373
+ gatesRun,
374
+ gatesPassed,
375
+ dropped: { nodes: totalDroppedNodes, edges: totalDroppedEdges },
376
+ warnings,
377
+ },
378
+ };
379
+ }