pi-mega-compact 0.21.9 → 0.21.10

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 (30) hide show
  1. package/dist/dedup/degenerate.js +68 -0
  2. package/dist/extensions/dashboard-server/routes-dedup-attribution.js +8 -1
  3. package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +3 -0
  4. package/dist/extensions/mega-events/context-handler/liveTrim.js +6 -2
  5. package/dist/extensions/mega-events/context-handler.js +7 -1
  6. package/dist/extensions/mega-pipeline/compact/run.js +14 -3
  7. package/dist/src/config/dedup.js +3 -0
  8. package/dist/src/dedup/degenerate.js +68 -0
  9. package/dist/src/extractive-salvage.js +195 -0
  10. package/dist/src/extractive.js +63 -72
  11. package/dist/src/vector-cortex/dedup-attr/rollup.js +5 -0
  12. package/dist/src/vectorStore/add-degenerate.js +25 -0
  13. package/dist/src/vectorStore/add.js +28 -5
  14. package/dist/src/vectorStore/dedup-audit.js +8 -0
  15. package/dist/vector-cortex/dedup-attr/rollup.js +5 -0
  16. package/dist/vectorStore/dedup-audit.js +8 -0
  17. package/extensions/dashboard-server/routes-dedup-attribution.ts +11 -1
  18. package/extensions/dashboard-server/routes-rag-settings-helpers.ts +8 -0
  19. package/extensions/mega-events/context-handler/liveTrim.ts +6 -2
  20. package/extensions/mega-events/context-handler.ts +7 -1
  21. package/extensions/mega-pipeline/compact/run.ts +14 -4
  22. package/package.json +1 -1
  23. package/src/config/dedup.ts +15 -0
  24. package/src/dedup/degenerate.ts +125 -0
  25. package/src/extractive-salvage.ts +212 -0
  26. package/src/extractive.ts +70 -75
  27. package/src/vector-cortex/dedup-attr/rollup.ts +4 -0
  28. package/src/vectorStore/add-degenerate.ts +64 -0
  29. package/src/vectorStore/add.ts +29 -5
  30. package/src/vectorStore/dedup-audit.ts +25 -2
@@ -0,0 +1,25 @@
1
+ import { shouldSkipDegenerateMatch } from "../dedup/degenerate.js";
2
+ /**
3
+ * Build the decliner for one add() cascade.
4
+ *
5
+ * Returns a predicate the L1/L2 call sites use as a one-line guard. When it
6
+ * returns true it has ALREADY recorded the declined decision (monitoring event +
7
+ * `skipped` audit line + the live `onTier` detail), so the caller only has to
8
+ * fall through. When the umbrella flag is off it always returns false and emits
9
+ * nothing — byte-identical to the pre-guard cascade.
10
+ */
11
+ export function degenerateDecliner(args) {
12
+ const { store, input, contentHash, cfg, audit, t0 } = args;
13
+ const candidate = { ...input, contentHash };
14
+ return (tier, matched, similarity) => {
15
+ if (!shouldSkipDegenerateMatch(matched, candidate, cfg))
16
+ return false;
17
+ // Reported as `mark_only`: a tier matched but policy declined to collapse —
18
+ // exactly the existing MARK_ONLY shape, with a distinct reason string so the
19
+ // dashboard can tell a guard decline from an operator-configured MARK_ONLY.
20
+ store.record(tier, "mark_only", "degenerateGuard", Date.now() - t0, similarity, matched.checkpointId);
21
+ audit.skipped(tier, matched.checkpointId, "degenerateGuard", similarity);
22
+ input.onTier?.({ tier, status: "passed", detail: "degenerateGuard" });
23
+ return true;
24
+ };
25
+ }
@@ -27,6 +27,7 @@ import { lshBands } from "../dedup/l1-lsh.js";
27
27
  import { openBloom, saveBloom } from "../store/bloom.js";
28
28
  import { listCheckpoints, nextCheckpointId, upsertCheckpoint, loadSessionState, saveSessionState, upsertMinhashSignature, insertLshBuckets, addTokensSaved, bumpDedupStats, } from "../store/sqlite.js";
29
29
  import { computeRegionHash } from "./hash.js";
30
+ import { degenerateDecliner } from "./add-degenerate.js";
30
31
  import { runL0Tier, computeSummaryHash } from "./add-l0.js";
31
32
  import { findL1Duplicate } from "./add-l1.js";
32
33
  import { dedupAuditRecorder } from "./dedup-audit.js";
@@ -67,8 +68,22 @@ export function addCheckpoint(store, input) {
67
68
  // Tracks whether a tier matched while in MARK_ONLY (record-but-don't-collapse),
68
69
  // and which tier.
69
70
  let markOnly = null;
70
- // L0 exact-match tier (contentHash / regionHash / summaryHash) — see add-l0.ts.
71
+ // Content digest for this candidate (also consumed by the L0 tier below).
71
72
  const digest = computeContentDigest(input.regionText);
73
+ // Degenerate-match guard (incident 2026-08-19): declines a fuzzy-tier collapse
74
+ // onto a content-free skeleton when the incoming region is richer, so the
75
+ // skeleton stops absorbing every future compaction. Returns true (having
76
+ // already recorded the decision) ⇒ the caller treats the match as a non-match.
77
+ // Flag-off ⇒ always false ⇒ byte-identical predecessor. See add-degenerate.ts.
78
+ const declineDegenerate = degenerateDecliner({
79
+ store,
80
+ input,
81
+ contentHash: digest.contentHash,
82
+ cfg,
83
+ audit,
84
+ t0,
85
+ });
86
+ // L0 exact-match tier (contentHash / regionHash / summaryHash) — see add-l0.ts.
72
87
  const bloom = openBloom(store.stateDir);
73
88
  const summaryHash = computeSummaryHash(input.topicSummary);
74
89
  const l0 = runL0Tier({
@@ -94,7 +109,10 @@ export function addCheckpoint(store, input) {
94
109
  onTier?.({ tier: "L1", status: "scanning" });
95
110
  if (cfg.L1_ENABLED) {
96
111
  const l1 = findL1Duplicate(store, sessionId, input.regionText, all);
97
- if (l1 && !cfg.MARK_ONLY_L1) {
112
+ // Guard first: a declined match must not collapse and must not be recorded
113
+ // as a MARK_ONLY hit either — it is a non-match for the rest of the cascade.
114
+ const l1Declined = l1 !== undefined && declineDegenerate("L1", l1);
115
+ if (l1 && !l1Declined && !cfg.MARK_ONLY_L1) {
98
116
  l1.timestamp = input.timestamp;
99
117
  upsertCheckpoint(l1, store.stateDir);
100
118
  bumpDedupStats(true, store.stateDir);
@@ -105,7 +123,7 @@ export function addCheckpoint(store, input) {
105
123
  onTier?.({ tier: "L1", status: "deduped", detail: "l1MinHash" });
106
124
  return r;
107
125
  }
108
- if (l1 && cfg.MARK_ONLY_L1)
126
+ if (l1 && !l1Declined && cfg.MARK_ONLY_L1)
109
127
  markOnly = "L1";
110
128
  }
111
129
  onTier?.({ tier: "L1", status: "passed" });
@@ -131,7 +149,12 @@ export function addCheckpoint(store, input) {
131
149
  const sim = cosineSimilarity(embedding, cp.embedding);
132
150
  return sim > best.sim ? { checkpoint: cp, sim } : best;
133
151
  }, { checkpoint: all[0], sim: -1 });
134
- if (!timedOut && nearest.sim >= simThreshold) {
152
+ // A declined match falls through to the "store a fresh checkpoint" path; the
153
+ // guard already audited the decision, so the near-miss emit below is skipped.
154
+ const l2Declined = !timedOut &&
155
+ nearest.sim >= simThreshold &&
156
+ declineDegenerate("L2", nearest.checkpoint, nearest.sim);
157
+ if (!timedOut && !l2Declined && nearest.sim >= simThreshold) {
135
158
  if (!cfg.MARK_ONLY_L2) {
136
159
  // Near-identical — update timestamp on existing checkpoint
137
160
  nearest.checkpoint.timestamp = input.timestamp;
@@ -164,7 +187,7 @@ export function addCheckpoint(store, input) {
164
187
  // Near-miss: how close did we come to collapsing? Only emitted when the
165
188
  // scan actually completed and scored a candidate — a timed-out scan has no
166
189
  // honest best to report.
167
- if (!timedOut && nearest.sim >= 0) {
190
+ if (!timedOut && !l2Declined && nearest.sim >= 0) {
168
191
  audit.passed("L2", nearest.checkpoint.checkpointId, nearest.sim);
169
192
  }
170
193
  }
@@ -69,6 +69,14 @@ export function dedupAuditRecorder(ctx, scope) {
69
69
  matchedEntry,
70
70
  similarity,
71
71
  }),
72
+ skipped: (tier, matchedEntry, dedupReason, similarity) => emitDedupAudit(ctx, {
73
+ ...base,
74
+ tier,
75
+ status: "skipped",
76
+ matchedEntry,
77
+ dedupReason,
78
+ ...(similarity === undefined ? {} : { similarity }),
79
+ }),
72
80
  stored: (storedEntry, dedupReason, tokenEstimate) => emitDedupAudit(ctx, {
73
81
  ...base,
74
82
  tier: "new",
@@ -49,6 +49,11 @@ export function computeDedupTierRollup(events, windowMs, now) {
49
49
  const ts = Date.parse(ev.ts);
50
50
  if (Number.isNaN(ts) || ts < windowStart || ts > windowEnd)
51
51
  continue;
52
+ // "skipped" (degenerate-match guard declined a collapse) is a decision, but
53
+ // NOT a tier catch — it is attributed to no tier below. Excluding it from the
54
+ // denominator keeps l0Share + l1Share + l2Share summing to 1 over the window.
55
+ if (ev.status === "skipped")
56
+ continue;
52
57
  total += 1;
53
58
  switch (ev.tier) {
54
59
  case "L0":
@@ -69,6 +69,14 @@ export function dedupAuditRecorder(ctx, scope) {
69
69
  matchedEntry,
70
70
  similarity,
71
71
  }),
72
+ skipped: (tier, matchedEntry, dedupReason, similarity) => emitDedupAudit(ctx, {
73
+ ...base,
74
+ tier,
75
+ status: "skipped",
76
+ matchedEntry,
77
+ dedupReason,
78
+ ...(similarity === undefined ? {} : { similarity }),
79
+ }),
72
80
  stored: (storedEntry, dedupReason, tokenEstimate) => emitDedupAudit(ctx, {
73
81
  ...base,
74
82
  tier: "new",
@@ -59,7 +59,17 @@ function parseAuditLine(line: string): DedupAuditEvent | null {
59
59
  const tier = obj.tier;
60
60
  const status = obj.status;
61
61
  if (tier !== "L0" && tier !== "L1" && tier !== "L2" && tier !== "new") return null;
62
- if (status !== "deduped" && status !== "passed" && status !== "stored") return null;
62
+ // "skipped" = a tier matched but the degenerate-match guard declined to
63
+ // collapse. Accepted so the line is not silently dropped from the tail; the
64
+ // rollup below counts only deduped/passed, so tier catch-share math is
65
+ // unchanged by its presence.
66
+ if (
67
+ status !== "deduped" &&
68
+ status !== "passed" &&
69
+ status !== "stored" &&
70
+ status !== "skipped"
71
+ )
72
+ return null;
63
73
  // sessionId is never read by the rollup; a parsed line may omit richer fields.
64
74
  return { type: "dedup_audit", ts: obj.ts, tier, status, sessionId: "" };
65
75
  }
@@ -228,6 +228,12 @@ export const SETTINGS: ReadonlyArray<SettingGroup> = [
228
228
  boolDirect("MEGACOMPACT_MARK_ONLY_L1", "Mark Only L1", "L1 runs but does not collapse", false),
229
229
  boolDirect("MEGACOMPACT_MARK_ONLY_L2", "Mark Only L2", "L2 runs but does not collapse", false),
230
230
  boolDirect("MEGACOMPACT_MINILM", "MiniLM Embedder", "Use MiniLM instead of trigram", false),
231
+ boolDirect(
232
+ "MEGACOMPACT_DEDUP_DEGENERATE_GUARD",
233
+ "Degenerate Match Guard",
234
+ "Decline an L1/L2 collapse when the MATCHED stored checkpoint is a content-free skeleton (a ~30-40 token structural summary) and the incoming region is richer. Without this, one degenerate checkpoint absorbs every later compaction forever and the store can never heal. OFF = byte-identical pre-guard cascade. Calibrated by the two Degenerate floors under Dedup Thresholds.",
235
+ true,
236
+ ),
231
237
  boolDirect(
232
238
  "MEGACOMPACT_DEDUP_AUDIT",
233
239
  "Dedup Audit Trail",
@@ -242,6 +248,8 @@ export const SETTINGS: ReadonlyArray<SettingGroup> = [
242
248
  num("MEGACOMPACT_L2_THRESHOLD", "L2 Cosine Threshold", "L2 semantic dedup firing point", 0.85, 0, 1),
243
249
  num("MEGACOMPACT_L1_JACCARD", "L1 Jaccard Threshold", "L1 MinHash near-dup threshold", 0.8, 0, 1),
244
250
  num("MEGACOMPACT_DEDUP_SIM", "Dedup Similarity", "Legacy content-similarity fallback", 0.9, 0, 1),
251
+ num("MEGACOMPACT_DEDUP_DEGEN_MIN_TOKENS", "Degenerate Min Tokens", "Absolute token floor below which a stored summary counts as a degenerate skeleton (Degenerate Match Guard)", 48, 0, 10000, "tokens"),
252
+ num("MEGACOMPACT_DEDUP_DEGEN_MIN_PCT", "Degenerate Min Percent", "Relative floor as a fraction of the summary's original region size; a summary under max(min-tokens, pct x original) is degenerate", 0.005, 0, 1),
245
253
  num("MEGACOMPACT_RECALL_MIN_COSINE", "Recall Min Cosine (same-repo)", "3WF-3 same-repo floor the 3-source validator applies to the top winner (cross-repo 0.90 stays separate)", 0.12, 0, 1),
246
254
  num("MEGACOMPACT_MMR_LAMBDA", "MMR Lambda", "Maximal Marginal Relevance diversity", 0.5, 0, 1),
247
255
  num("MEGACOMPACT_SEMDEDUP_COSINE", "SemDeDup Cosine", "Offline SemDeDup pair threshold", 0.95, 0, 1),
@@ -170,8 +170,12 @@ export function buildLiveTrimView(
170
170
  // (rt.lastCheckpointId) instead of ran.result.checkpointId, which is
171
171
  // dedup-volatile: on a re-compact that dedups onto a DIFFERENT existing
172
172
  // checkpoint, result.checkpointId is the matched id (engine.ts:188) while
173
- // lastCheckpointId is only updated on a genuinely new checkpoint
174
- // (compact.ts:100-104). Keying on result.checkpointId would make
173
+ // lastCheckpointId was, pre-C1, only updated on a genuinely new checkpoint.
174
+ // C1 (v0.21.10) now stamps lastCheckpointId on the dedup path too (see
175
+ // compact/run.ts) — it means "the checkpoint backing this epoch" — so this
176
+ // key and the D.2/D.3 comparison agree in both directions and the `??`
177
+ // fallbacks below are now only for the truly-no-checkpoint edge case.
178
+ // Keying on result.checkpointId directly would still make
175
179
  // trimCache.checkpointId != rt.lastCheckpointId forever after that
176
180
  // dedup fire, disabling replay for the rest of the epoch (the
177
181
  // alternating cache-miss that 0.8.6 meant to fix). Prefer the stable
@@ -219,8 +219,14 @@ export function registerContextHandler(
219
219
 
220
220
  // Debounce so we don't fire on every context event past threshold.
221
221
  // (Replay already returned above — only fresh compacts reach this point.)
222
+ // C2 (v0.21.10): EXEMPT headroom-triggered fires, matching the thrash-guard
223
+ // exemption above. pi's own overflow recovery (400 → compact → immediate
224
+ // retry) re-fires a context event <2s after our last fire; debouncing it
225
+ // returned the RAW untrimmed view, so input + output reserve still blew the
226
+ // window → 400 → "recovery failed after one compact-and-retry attempt".
227
+ // An overflowed session is unrecoverable; a re-fire is merely wasteful.
222
228
  const now = Date.now();
223
- if (now < runtime.debounceUntil) {
229
+ if (now < runtime.debounceUntil && !gate.headroomExceeded) {
224
230
  runtime.diagCtxDebounce++;
225
231
  return tailResult() ?? undefined;
226
232
  }
@@ -109,10 +109,20 @@ function doCompact(
109
109
  runtime.pulsing = false;
110
110
 
111
111
  if (result.skipped) return { skipped: true };
112
- if (!result.deduped) {
113
- runtime.rt.persistedThisSession = true;
114
- runtime.rt.lastCheckpointId = result.checkpointId;
115
- }
112
+ // C1 (v0.21.10): lastCheckpointId tracks "the checkpoint backing this epoch",
113
+ // so it is stamped on BOTH paths — a matched-dedup checkpoint backs this epoch
114
+ // just as much as a freshly created one. Previously the dedup path left it
115
+ // undefined, so a runtime session whose every compaction deduped (common after
116
+ // a process restart, when checkpoints persist but `rt` is rebuilt) never set it
117
+ // → liveTrim's trimCache fell back to result.checkpointId (the matched id) →
118
+ // `trimCache.checkpointId === rt.lastCheckpointId` was `"chkpt_001" !== undefined`
119
+ // → the D.2/D.3 replay NEVER matched and the full pipeline re-ran on every
120
+ // context event (liveTrimReplays: 0, "comp lag warn"). A later fire matching a
121
+ // DIFFERENT checkpoint now changes the key once (one cache regeneration), then
122
+ // replays stabilise. `persistedThisSession` keeps its narrower meaning ("we
123
+ // wrote NEW state this session") and stays gated on !deduped.
124
+ if (!result.deduped) runtime.rt.persistedThisSession = true;
125
+ runtime.rt.lastCheckpointId = result.checkpointId;
116
126
  runtime.rt.lastCompactedFrom = result.compactedFrom;
117
127
  runtime.rt.lastCompactedTokens = result.tokenEstimate;
118
128
  runtime.rt.dedupAttempts++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.21.9",
3
+ "version": "0.21.10",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -66,6 +66,18 @@ export interface DedupConfigShape {
66
66
  L2_COSINE_CODE: number | null;
67
67
  L2_COSINE_PROSE: number | null;
68
68
  L1_JACCARD: number; // MinHash/LSH near-dup verification
69
+ /**
70
+ * Degenerate-match guard (incident 2026-08-19). ON declines an L1/L2 collapse
71
+ * when the MATCHED stored checkpoint is a content-free skeleton and the
72
+ * incoming candidate is richer, so a degenerate checkpoint can no longer
73
+ * absorb every future compaction forever. OFF is byte-identical to the
74
+ * pre-guard cascade. See src/dedup/degenerate.ts.
75
+ */
76
+ DEDUP_DEGENERATE_GUARD: boolean;
77
+ /** Absolute token floor under which a stored summary counts as degenerate. */
78
+ DEDUP_DEGEN_MIN_TOKENS: number;
79
+ /** Relative floor as a fraction of the summary's original region size. */
80
+ DEDUP_DEGEN_MIN_PCT: number;
69
81
  DEDUP_SIM: number; // legacy content-similarity fallback
70
82
  MMR_LAMBDA: number; // retrieval diversity
71
83
  SEMDEDUP_COSINE: number; // offline SemDeDup pair threshold
@@ -123,6 +135,9 @@ export function loadDedupConfig(): DedupConfigShape {
123
135
  L2_COSINE_CODE: envNumOrNull("MEGACOMPACT_L2_THRESHOLD_CODE"),
124
136
  L2_COSINE_PROSE: envNumOrNull("MEGACOMPACT_L2_THRESHOLD_PROSE"),
125
137
  L1_JACCARD: envNum("MEGACOMPACT_L1_JACCARD", 0.8),
138
+ DEDUP_DEGENERATE_GUARD: envBool("MEGACOMPACT_DEDUP_DEGENERATE_GUARD", true),
139
+ DEDUP_DEGEN_MIN_TOKENS: envNum("MEGACOMPACT_DEDUP_DEGEN_MIN_TOKENS", 48),
140
+ DEDUP_DEGEN_MIN_PCT: envNum("MEGACOMPACT_DEDUP_DEGEN_MIN_PCT", 0.005),
126
141
  DEDUP_SIM: envNum("MEGACOMPACT_DEDUP_SIM", 0.9),
127
142
  MMR_LAMBDA: envNum("MEGACOMPACT_MMR_LAMBDA", 0.5),
128
143
  SEMDEDUP_COSINE: envNum("MEGACOMPACT_SEMDEDUP_COSINE", 0.95),
@@ -0,0 +1,125 @@
1
+ /**
2
+ * degenerate.ts — the degenerate-checkpoint predicate + the L1/L2 collapse guard.
3
+ *
4
+ * WHY THIS EXISTS (incident 2026-08-19). A defect in the summarizer could emit a
5
+ * DEGENERATE summary: a ~30-40 token structural skeleton with no informational
6
+ * content, e.g.
7
+ *
8
+ * "Conversation: 64 messages (5 user, 29 assistant, 27 tool). Tools: bash, edit, read."
9
+ *
10
+ * Once such a checkpoint landed in the store it became an absorbing state. Every
11
+ * later compaction produced a structurally identical skeleton, so L1 MinHash and
12
+ * L2 cosine (0.872 against a 0.85 threshold) matched it EVERY time — each add()
13
+ * returned `deduped: true`, discarded the incoming content, and only bumped the
14
+ * stored skeleton's timestamp. The store could never heal, even after the
15
+ * summarizer was fixed, because a rich new summary that happened to match the
16
+ * skeleton would still be swallowed by it.
17
+ *
18
+ * THE GUARD. When L1 or L2 finds a match, we ask whether the MATCHED (stored)
19
+ * checkpoint is degenerate and the incoming candidate is RICHER. If both hold, we
20
+ * decline the collapse and let the cascade continue as if nothing matched — so a
21
+ * fresh, informative checkpoint is written and the skeleton stops absorbing.
22
+ *
23
+ * Direction matters: only "poor stored ← rich incoming" is unblocked. Two equal
24
+ * skeletons still collapse (dedup is doing its job), and a rich stored
25
+ * checkpoint absorbing a poor incoming one is left alone — that is ordinary
26
+ * dedup, not the pathology.
27
+ *
28
+ * PREVENT-PI-004: pure arithmetic over already-loaded fields. No IO, no network.
29
+ */
30
+ import type { StoredCheckpoint } from "../store.js";
31
+
32
+ /** The tunables the guard reads (thread from DedupConfigShape). */
33
+ export interface DegenerateGuardTunables {
34
+ /** Umbrella flag. OFF ⇒ the guard never fires (byte-identical predecessor). */
35
+ readonly DEDUP_DEGENERATE_GUARD: boolean;
36
+ /** Absolute token floor below which a stored summary is structural, not informational. */
37
+ readonly DEDUP_DEGEN_MIN_TOKENS: number;
38
+ /** Relative floor as a fraction of the ORIGINAL region the summary stands in for. */
39
+ readonly DEDUP_DEGEN_MIN_PCT: number;
40
+ }
41
+
42
+ /** The two fields the predicate scores. Kept structural so tests need no full row. */
43
+ export interface DegenerateSubject {
44
+ tokenEstimate?: number;
45
+ originalTokenEstimate?: number;
46
+ }
47
+
48
+ /**
49
+ * The effective token floor for a checkpoint: the larger of the absolute floor
50
+ * and `MIN_PCT × originalTokenEstimate`.
51
+ *
52
+ * A missing / zero / non-finite `originalTokenEstimate` contributes nothing, so
53
+ * the absolute floor applies alone — direct add() callers and pre-v0.4 rows that
54
+ * never recorded the original region size are judged on absolute size only,
55
+ * never accidentally deemed degenerate by a 0-valued percentage term.
56
+ */
57
+ export function degenerateFloor(
58
+ subject: DegenerateSubject,
59
+ tunables: DegenerateGuardTunables,
60
+ ): number {
61
+ const orig = subject.originalTokenEstimate;
62
+ const relative =
63
+ typeof orig === "number" && Number.isFinite(orig) && orig > 0
64
+ ? orig * tunables.DEDUP_DEGEN_MIN_PCT
65
+ : 0;
66
+ return Math.max(tunables.DEDUP_DEGEN_MIN_TOKENS, relative);
67
+ }
68
+
69
+ /**
70
+ * Is this stored checkpoint a degenerate (content-free) summary?
71
+ *
72
+ * Calibration against the incident data:
73
+ * - skeleton: tokenEstimate 34, original ≈19166 → 34 < max(48, 95.8) → TRUE
74
+ * - normal: tokenEstimate 2000, original 70000 → 2000 > max(48, 350) → FALSE
75
+ *
76
+ * The relative term is what makes this scale: a 34-token summary of a 900-token
77
+ * region is a legitimate 26× compression, while the same 34 tokens standing in
78
+ * for 19k is a skeleton.
79
+ */
80
+ export function isDegenerateCheckpoint(
81
+ subject: DegenerateSubject,
82
+ tunables: DegenerateGuardTunables,
83
+ ): boolean {
84
+ const tokens = subject.tokenEstimate ?? 0;
85
+ return tokens < degenerateFloor(subject, tunables);
86
+ }
87
+
88
+ /**
89
+ * Should an L1/L2 match be DECLINED because it would collapse richer incoming
90
+ * content onto a degenerate stored checkpoint?
91
+ *
92
+ * Returns true only when all four hold:
93
+ * 1. the umbrella flag is ON,
94
+ * 2. the matched (stored) checkpoint is degenerate,
95
+ * 3. the candidate is strictly richer than the match,
96
+ * 4. the candidate's content is not byte-identical to the match's.
97
+ *
98
+ * Condition 3 uses a strict `>`: equal-size skeletons collapsing is harmless and
99
+ * keeps the store from growing one row per compaction while the summarizer is
100
+ * broken. Only a genuine improvement is worth declining a collapse for.
101
+ *
102
+ * Condition 4 is a CORRECTNESS requirement, not a refinement. `context_chunks`
103
+ * carries a partial UNIQUE index on (session_id, content_hash) (schema/core.ts
104
+ * QA #1), so declining a match whose content hash already exists would fall
105
+ * through to an INSERT that throws — inside add(), which sits on the agent loop.
106
+ * It is also the semantically right call: identical bytes are the SAME region,
107
+ * so re-storing them adds no information and heals nothing. Only L0 may own the
108
+ * exact-match case; the guard exists for fuzzy matches on genuinely different
109
+ * text, which is exactly the incident's shape (each compaction produced a
110
+ * *similar but distinct* skeleton).
111
+ */
112
+ export function shouldSkipDegenerateMatch(
113
+ matched: StoredCheckpoint,
114
+ candidate: DegenerateSubject & { contentHash?: string },
115
+ tunables: DegenerateGuardTunables,
116
+ ): boolean {
117
+ if (!tunables.DEDUP_DEGENERATE_GUARD) return false;
118
+ if (!isDegenerateCheckpoint(matched, tunables)) return false;
119
+ if ((candidate.tokenEstimate ?? 0) <= (matched.tokenEstimate ?? 0)) return false;
120
+ // Byte-identical content → not a healing opportunity (and would violate the
121
+ // UNIQUE index). Compared only when both hashes are known.
122
+ const a = candidate.contentHash;
123
+ const b = matched.contentHash;
124
+ return !(a !== undefined && b !== undefined && a === b);
125
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * extractive-salvage.ts — file-path policy + skeleton salvage for extractive.ts.
3
+ *
4
+ * Extracted from extractive.ts (A1/A2 sprint) to keep that file under the
5
+ * 300-line src/ soft limit. Pure functions only — no I/O, no logging.
6
+ *
7
+ * DESIGN (A1): blocklist, not allowlist. The old `INTERESTING_EXT` allowlist
8
+ * (rs/ts/tsx/js/json/md) silently produced content-free summaries for every
9
+ * other language — a .go/.py/.c project got a 34-token skeleton. An allowlist
10
+ * has to be *right* about ~40 ecosystems to be useful and fails closed (drops
11
+ * real work) when it is wrong; a NOISE blocklist only has to be right about the
12
+ * small, stable set of binary/generated/asset extensions and fails open (an
13
+ * unknown extension is surfaced, which is the safe direction for a summary).
14
+ */
15
+
16
+ import type { EngineMessage } from "./types.js";
17
+
18
+ // ---- File path policy ------------------------------------------------------
19
+
20
+ /**
21
+ * Generic extension capture: any 1–6 char ALPHABETIC extension. Path
22
+ * character class is unchanged from the original FILE_PATH_RE so existing
23
+ * matching behaviour (quotes/backticks/whitespace as delimiters) is preserved.
24
+ * Alphabetic-only is deliberate: every real source/config extension is alpha
25
+ * (c..tsx, tsconfig.json), while an alphanumeric class matches version strings
26
+ * ("GLM-4.7", "v0.21.9", "Node 18.2") as "files" and spams Key files.
27
+ */
28
+ export const FILE_PATH_RE = /(?:^|\s)([^\s"`']+\.([A-Za-z]{1,6}))\b/g;
29
+
30
+ /** Same policy, single-match, for inferCurrentWork (also excludes ':'). */
31
+ export const CURRENT_WORK_PATH_RE = /(?:^|\s)([^\s"`':]+\.([A-Za-z]{1,6}))\b/m;
32
+
33
+ /**
34
+ * Binary / generated / asset / vendored extensions that carry no summary value.
35
+ * Deliberately small and stable. Note `md`, `json`, `toml`, `yaml`, `sql`, `css`
36
+ * and `html` are NOT noise — they are hand-edited source in most repos.
37
+ */
38
+ export const NOISE_EXT = new Set([
39
+ // lockfiles & logs
40
+ "lock", "log", "sum",
41
+ // images & media
42
+ "png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff",
43
+ "mp3", "mp4", "mov", "wav", "webm", "avi",
44
+ // fonts
45
+ "woff", "woff2", "ttf", "otf", "eot",
46
+ // archives & binaries
47
+ "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar",
48
+ "exe", "dll", "so", "dylib", "bin", "o", "a", "obj", "class", "pyc", "pyo",
49
+ "wasm", "node", "jar", "war", "deb", "rpm", "dmg", "iso", "img",
50
+ // generated / build artifacts
51
+ "map", "min", "lockb", "snap", "cache", "tmp", "temp", "swp", "bak", "orig",
52
+ // data blobs & databases
53
+ "pdf", "db", "sqlite", "sqlite3", "pack", "idx", "pem", "key", "crt",
54
+ ]);
55
+
56
+ /** Directory fragments whose files are never "key files" for a summary. */
57
+ const NOISE_DIR_RE = /(?:^|\/)(?:node_modules|\.git|dist|build|coverage|vendor|target|__pycache__|\.venv|venv)(?:\/|$)/;
58
+
59
+ /**
60
+ * TLD-shaped extensions: `example.com`, `github.com`, `npm.cmd` are domains and
61
+ * launchers, not code. (QA lens 1 finding, 2026-08-19.) Conservative list only —
62
+ * no real source extension lives here (.go/.rs/.ts stay interesting).
63
+ */
64
+ const TLD_EXT = new Set([
65
+ "com", "org", "net", "io", "gov", "edu", "biz", "info", "dev", "app",
66
+ "page", "xyz", "site", "online", "cloud", "me", "co", "us", "uk", "de",
67
+ "fr", "jp", "cn", "nl", "se", "eu", "int", "mil", "cmd",
68
+ ]);
69
+
70
+ /** True when a matched path is worth surfacing in a summary. */
71
+ export function isInterestingPath(filePath: string, ext: string): boolean {
72
+ const lowerExt = ext.toLowerCase();
73
+ if (NOISE_EXT.has(lowerExt)) return false;
74
+ if (TLD_EXT.has(lowerExt)) return false; // bare domains, not code
75
+ if (/^(?:https?|ftp):\/\//i.test(filePath) || filePath.toLowerCase().startsWith("www.")) return false;
76
+ if (NOISE_DIR_RE.test(filePath)) return false;
77
+ // `app.min.js` / `bundle.min.css` style double extensions.
78
+ if (/\.min\.[A-Za-z0-9]{1,6}$/.test(filePath)) return false;
79
+ if (/\.map$/.test(filePath)) return false;
80
+ // Prose abbreviations ("e.g", "i.e", "U.S"): a single-char base with no slash
81
+ // and no digit is never a filename. ("a.c" is rare collateral; "q1.py" and
82
+ // "src/a.ts" survive — digit / slash both exempt.)
83
+ const base = filePath.slice(0, filePath.lastIndexOf("."));
84
+ if (!filePath.includes("/") && !/\d/.test(filePath) && base.length <= 1) return false;
85
+ return true;
86
+ }
87
+
88
+ // ---- Path collection -------------------------------------------------------
89
+
90
+ const MAX_KEY_FILES = 5;
91
+ const MAX_FILES = 10;
92
+ const FRESHNESS_WINDOW = 10;
93
+
94
+ /** All interesting file paths mentioned in a blob of text. */
95
+ export function extractFilePaths(text: string): string[] {
96
+ const paths: string[] = [];
97
+ for (const m of text.matchAll(FILE_PATH_RE)) {
98
+ if (isInterestingPath(m[1], m[2])) paths.push(m[1]);
99
+ }
100
+ return paths;
101
+ }
102
+
103
+ /** Most-mentioned paths within the recency window (behaviour unchanged). */
104
+ export function collectKeyFiles(messages: EngineMessage[]): string[] {
105
+ const recent = messages.slice(-FRESHNESS_WINDOW);
106
+ const pathFreq = new Map<string, number>();
107
+ for (const m of recent) {
108
+ for (const p of extractFilePaths(m.text)) {
109
+ pathFreq.set(p, (pathFreq.get(p) ?? 0) + 1);
110
+ }
111
+ }
112
+ return [...pathFreq.entries()]
113
+ .sort((a, b) => b[1] - a[1])
114
+ .slice(0, MAX_KEY_FILES)
115
+ .map(([p]) => p);
116
+ }
117
+
118
+ /** Paths written/edited by tools (extension-agnostic; behaviour unchanged). */
119
+ export function extractFilesModified(tools: EngineMessage[]): string[] {
120
+ const files = new Set<string>();
121
+ for (const m of tools) {
122
+ if (!m.toolName) continue;
123
+ const name = m.toolName.toLowerCase();
124
+ if (name === "write" || name === "edit" || name === "notebookedit") {
125
+ const input = m.input ?? m.text;
126
+ const pathMatch = input.match(/["']?(\/[^\s"']+\.\w+)["']?/);
127
+ if (pathMatch) files.add(pathMatch[1]);
128
+ }
129
+ if (name === "bash") {
130
+ const cmd = m.input ?? m.text;
131
+ if (cmd.includes("git add") || cmd.includes("git commit") || cmd.includes("git diff")) {
132
+ for (const p of extractFilePaths(cmd)) files.add(p);
133
+ }
134
+ }
135
+ }
136
+ return [...files].slice(0, MAX_FILES);
137
+ }
138
+
139
+ // ---- Placeholder user turns (A2b) ------------------------------------------
140
+
141
+ /**
142
+ * Content-free user turns. Extremely common in resumed sessions; taking them
143
+ * verbatim as "User requests" is what produced the three "• resume" bullets.
144
+ */
145
+ const PLACEHOLDER_RE =
146
+ /^(?:resume|continue|go on|go ahead|proceed|next|yes|yeah|yep|y|ok|okay|k|sure|thanks|thank you|ty|done|please continue|carry on)\W*$/i;
147
+
148
+ export function isPlaceholderRequest(text: string): boolean {
149
+ return PLACEHOLDER_RE.test(text.trim());
150
+ }
151
+
152
+ // ---- Skeleton salvage (A2c) ------------------------------------------------
153
+
154
+ const MAX_SALVAGE_LINES = 5;
155
+ const SALVAGE_LINE_LEN = 120;
156
+
157
+ /**
158
+ * A "skeleton" summary is the scope line and nothing else — no files, no current
159
+ * work, no decisions, no pending items, and no *substantive* user request. That
160
+ * is ~34 tokens of zero information and is what breaks a resumed session.
161
+ *
162
+ * NOTE: `recentUser` is treated as empty when it holds only placeholders. The
163
+ * incident summary DID have three "• resume" bullets, so a plain
164
+ * `recentUser.length === 0` test would never have fired on the very case this
165
+ * salvage exists for.
166
+ */
167
+ export function isSkeletonSummary(parts: {
168
+ recentUser: string[];
169
+ keyFiles: string[];
170
+ currentWork: string | undefined;
171
+ decisions: string[];
172
+ pending: string[];
173
+ }): boolean {
174
+ const hasRealRequest = parts.recentUser.some((r) => !isPlaceholderRequest(r));
175
+ return (
176
+ !hasRealRequest &&
177
+ parts.keyFiles.length === 0 &&
178
+ !parts.currentWork &&
179
+ parts.decisions.length === 0 &&
180
+ parts.pending.length === 0
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Last-resort content: the first meaningful line of the most recent assistant
186
+ * and tool messages. Deterministic (pure scan, newest-first, then re-ordered
187
+ * oldest-first for reading). Returns [] when there is genuinely nothing.
188
+ */
189
+ export function buildSalvageDigest(messages: EngineMessage[]): string[] {
190
+ const out: string[] = [];
191
+ const seen = new Set<string>();
192
+ for (let i = messages.length - 1; i >= 0 && out.length < MAX_SALVAGE_LINES; i--) {
193
+ const m = messages[i];
194
+ if (m.role !== "assistant" && m.role !== "tool") continue;
195
+ const raw = m.text || m.output || m.input || "";
196
+ const line = raw
197
+ .split("\n")
198
+ .map((l) => l.trim())
199
+ .find((l) => l.length > 0);
200
+ if (!line) continue;
201
+ const label = m.role === "tool" ? `${m.toolName ?? "tool"}: ` : "";
202
+ const entry = truncateLine(`${label}${line}`, SALVAGE_LINE_LEN);
203
+ if (seen.has(entry)) continue;
204
+ seen.add(entry);
205
+ out.push(entry);
206
+ }
207
+ return out.reverse();
208
+ }
209
+
210
+ function truncateLine(s: string, maxLen: number): string {
211
+ return s.length <= maxLen ? s : s.slice(0, maxLen - 1) + "…";
212
+ }