pi-mega-compact 0.21.9 → 0.21.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dedup/degenerate.js +68 -0
- package/dist/extensions/dashboard-server/routes-dedup-attribution.js +8 -1
- package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +3 -0
- package/dist/extensions/mega-events/context-handler/headroom.js +58 -3
- package/dist/extensions/mega-events/context-handler/liveTrim.js +6 -2
- package/dist/extensions/mega-events/context-handler.js +7 -1
- package/dist/extensions/mega-pipeline/compact/run.js +14 -3
- package/dist/src/config/dedup.js +3 -0
- package/dist/src/dedup/degenerate.js +68 -0
- package/dist/src/extractive-salvage.js +195 -0
- package/dist/src/extractive.js +63 -72
- package/dist/src/vector-cortex/dedup-attr/rollup.js +5 -0
- package/dist/src/vectorStore/add-degenerate.js +25 -0
- package/dist/src/vectorStore/add.js +28 -5
- package/dist/src/vectorStore/dedup-audit.js +8 -0
- package/dist/vector-cortex/dedup-attr/rollup.js +5 -0
- package/dist/vectorStore/dedup-audit.js +8 -0
- package/extensions/dashboard-server/routes-dedup-attribution.ts +11 -1
- package/extensions/dashboard-server/routes-rag-settings-helpers.ts +8 -0
- package/extensions/mega-events/context-handler/headroom.ts +50 -3
- package/extensions/mega-events/context-handler/liveTrim.ts +6 -2
- package/extensions/mega-events/context-handler.ts +7 -1
- package/extensions/mega-pipeline/compact/run.ts +14 -4
- package/package.json +1 -1
- package/src/config/dedup.ts +15 -0
- package/src/dedup/degenerate.ts +125 -0
- package/src/extractive-salvage.ts +212 -0
- package/src/extractive.ts +70 -75
- package/src/vector-cortex/dedup-attr/rollup.ts +4 -0
- package/src/vectorStore/add-degenerate.ts +64 -0
- package/src/vectorStore/add.ts +29 -5
- package/src/vectorStore/dedup-audit.ts +25 -2
|
@@ -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
|
+
}
|
package/src/extractive.ts
CHANGED
|
@@ -10,14 +10,24 @@
|
|
|
10
10
|
|
|
11
11
|
import type { EngineMessage } from "./types.js";
|
|
12
12
|
import { estimateBlockTokens } from "./tokens.js";
|
|
13
|
+
import {
|
|
14
|
+
CURRENT_WORK_PATH_RE,
|
|
15
|
+
isInterestingPath,
|
|
16
|
+
isPlaceholderRequest,
|
|
17
|
+
isSkeletonSummary,
|
|
18
|
+
buildSalvageDigest,
|
|
19
|
+
collectKeyFiles,
|
|
20
|
+
extractFilesModified,
|
|
21
|
+
} from "./extractive-salvage.js";
|
|
13
22
|
|
|
14
23
|
// ---- Limits ----------------------------------------------------------------
|
|
15
24
|
|
|
16
25
|
const MAX_RECENT_USER = 3;
|
|
17
26
|
const MAX_DECISIONS = 5;
|
|
18
|
-
const MAX_FILES = 10;
|
|
19
27
|
const MAX_PENDING = 5;
|
|
20
28
|
const MAX_TOPIC_LINES = 12;
|
|
29
|
+
/** Cap for the merged keyFiles ∪ filesModified "Key files" line (A2a). */
|
|
30
|
+
const MAX_SUMMARY_FILES = 8;
|
|
21
31
|
|
|
22
32
|
// ---- Truncation helper -----------------------------------------------------
|
|
23
33
|
|
|
@@ -57,9 +67,27 @@ function buildTopicSummary(
|
|
|
57
67
|
currentWork: string | undefined,
|
|
58
68
|
keyFiles: string[],
|
|
59
69
|
pending: string[],
|
|
70
|
+
filesModified: string[],
|
|
71
|
+
decisions: string[],
|
|
60
72
|
): string {
|
|
61
73
|
const lines: string[] = [];
|
|
62
74
|
|
|
75
|
+
// A2a: files captured from write/edit tool inputs are extracted
|
|
76
|
+
// extension-agnostically but never reached the summary. Fold them in so work
|
|
77
|
+
// outside the recency window (and outside the path regex) is still reported.
|
|
78
|
+
// Drop an absolute path when a kept relative path already names the SAME file.
|
|
79
|
+
// Only MULTI-COMPONENT relative paths fold ("engine/mesh.go" absorbs
|
|
80
|
+
// "/proj/engine/mesh.go"); a bare basename ("mesh.go") is never folded into an
|
|
81
|
+
// absolute path, since "/proj/other/x/mesh.go" may be a genuinely different
|
|
82
|
+
// file (QA lens 1 finding: the naive endsWith dropped different directories).
|
|
83
|
+
const combined = [...keyFiles, ...filesModified];
|
|
84
|
+
const allFiles = [...new Set(combined)].filter((p) => {
|
|
85
|
+
if (!p.startsWith("/")) return true;
|
|
86
|
+
return !combined.some(
|
|
87
|
+
(r) => r !== p && !r.startsWith("/") && r.includes("/") && p.endsWith("/" + r),
|
|
88
|
+
);
|
|
89
|
+
}).slice(0, MAX_SUMMARY_FILES);
|
|
90
|
+
|
|
63
91
|
// Scope line
|
|
64
92
|
const users = messages.filter((m) => m.role === "user");
|
|
65
93
|
const assistants = messages.filter((m) => m.role === "assistant");
|
|
@@ -79,8 +107,8 @@ function buildTopicSummary(
|
|
|
79
107
|
// Current work
|
|
80
108
|
if (currentWork) lines.push(`Current work: ${currentWork}`);
|
|
81
109
|
|
|
82
|
-
// Key files
|
|
83
|
-
if (
|
|
110
|
+
// Key files (keyFiles ∪ filesModified)
|
|
111
|
+
if (allFiles.length) lines.push(`Key files: ${allFiles.join(", ")}.`);
|
|
84
112
|
|
|
85
113
|
// Pending work
|
|
86
114
|
if (pending.length) {
|
|
@@ -88,42 +116,52 @@ function buildTopicSummary(
|
|
|
88
116
|
for (const p of pending) lines.push(` • ${p}`);
|
|
89
117
|
}
|
|
90
118
|
|
|
119
|
+
// A2c: a scope-line-only summary carries zero information and strands a
|
|
120
|
+
// resumed session. Salvage the tail of the conversation instead. The line cap
|
|
121
|
+
// is raised ONLY here: the salvage block is bounded at 5 lines + 1 header, and
|
|
122
|
+
// a skeleton by definition contributed just the 1 scope line, so the worst
|
|
123
|
+
// case is 7 lines — still well under the normal 12-line budget.
|
|
124
|
+
const skeleton = isSkeletonSummary({ recentUser, keyFiles: allFiles, currentWork, decisions, pending });
|
|
125
|
+
if (skeleton) {
|
|
126
|
+
const digest = buildSalvageDigest(messages);
|
|
127
|
+
if (digest.length) {
|
|
128
|
+
lines.push("Recent activity:");
|
|
129
|
+
for (const d of digest) lines.push(` • ${d}`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
91
134
|
// Cap total length
|
|
92
135
|
return lines.slice(0, MAX_TOPIC_LINES).join("\n");
|
|
93
136
|
}
|
|
94
137
|
|
|
95
|
-
// ----
|
|
96
|
-
|
|
97
|
-
const INTERESTING_EXT = new Set(["rs", "ts", "tsx", "js", "json", "md"]);
|
|
98
|
-
const FILE_PATH_RE = /(?:^|\s)([^\s"`']+\.(rs|ts|tsx|js|json|md|py|sh|sql|toml|yaml|yml|css|html))\b/g;
|
|
99
|
-
|
|
100
|
-
function extractFilePaths(text: string): string[] {
|
|
101
|
-
const paths: string[] = [];
|
|
102
|
-
for (const m of text.matchAll(FILE_PATH_RE)) {
|
|
103
|
-
const filePath = m[1];
|
|
104
|
-
const ext = m[2];
|
|
105
|
-
const basename = filePath.split("/").pop() ?? filePath;
|
|
106
|
-
if (basename === "node_modules" || filePath.includes("node_modules/")) continue;
|
|
107
|
-
if (INTERESTING_EXT.has(ext)) paths.push(filePath);
|
|
108
|
-
}
|
|
109
|
-
return paths;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ---- Recent user requests (existing logic, kept) ---------------------------
|
|
138
|
+
// ---- Recent user requests --------------------------------------------------
|
|
113
139
|
|
|
140
|
+
/**
|
|
141
|
+
* A2b: skip content-free "resume"/"continue" turns and look further back to
|
|
142
|
+
* fill the quota, so a resumed session surfaces its real requests. Falls back
|
|
143
|
+
* to the placeholders when EVERY user turn is one (an honest "• resume" beats
|
|
144
|
+
* an empty section).
|
|
145
|
+
*/
|
|
114
146
|
function collectRecentUserRequests(
|
|
115
147
|
messages: EngineMessage[],
|
|
116
148
|
limit: number,
|
|
117
149
|
): string[] {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
150
|
+
const substantive: string[] = [];
|
|
151
|
+
const placeholders: string[] = [];
|
|
152
|
+
for (let i = messages.length - 1; i >= 0 && substantive.length < limit; i--) {
|
|
153
|
+
if (messages[i].role !== "user") continue;
|
|
154
|
+
let snippet = messages[i].text.split("\n").slice(0, 3).join(" ");
|
|
155
|
+
snippet = snippet.replace(/^.+\nProcessed\$?\s*/i, "").replace(/\n/g, " ");
|
|
156
|
+
const cleaned = truncate(snippet, 200);
|
|
157
|
+
if (!cleaned.trim()) continue;
|
|
158
|
+
if (isPlaceholderRequest(cleaned)) {
|
|
159
|
+
if (placeholders.length < limit) placeholders.push(cleaned);
|
|
160
|
+
continue;
|
|
124
161
|
}
|
|
162
|
+
substantive.push(cleaned);
|
|
125
163
|
}
|
|
126
|
-
return
|
|
164
|
+
return (substantive.length ? substantive : placeholders).reverse();
|
|
127
165
|
}
|
|
128
166
|
|
|
129
167
|
// ---- Pending work (existing logic, kept) -----------------------------------
|
|
@@ -149,10 +187,9 @@ function inferCurrentWork(messages: EngineMessage[]): string | undefined {
|
|
|
149
187
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
150
188
|
const m = messages[i];
|
|
151
189
|
if (m.role !== "assistant") continue;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
if (path) {
|
|
190
|
+
// A1: same language-agnostic policy as extractFilePaths.
|
|
191
|
+
const path = m.text.match(CURRENT_WORK_PATH_RE);
|
|
192
|
+
if (path && isInterestingPath(path[1], path[2])) {
|
|
156
193
|
const line = m.text.split("\n").slice(0, 2).join(" ");
|
|
157
194
|
return truncate(line, 200);
|
|
158
195
|
}
|
|
@@ -190,29 +227,6 @@ function extractDecisions(messages: EngineMessage[]): string[] {
|
|
|
190
227
|
return [...new Set(decisions)];
|
|
191
228
|
}
|
|
192
229
|
|
|
193
|
-
// ---- Files modified --------------------------------------------------------
|
|
194
|
-
|
|
195
|
-
function extractFilesModified(tools: EngineMessage[]): string[] {
|
|
196
|
-
const files = new Set<string>();
|
|
197
|
-
for (const m of tools) {
|
|
198
|
-
if (!m.toolName) continue;
|
|
199
|
-
const name = m.toolName.toLowerCase();
|
|
200
|
-
if (name === "write" || name === "edit" || name === "notebookedit") {
|
|
201
|
-
// Extract file path from input payload
|
|
202
|
-
const input = m.input ?? m.text;
|
|
203
|
-
const pathMatch = input.match(/["']?(\/[^\s"']+\.\w+)["']?/);
|
|
204
|
-
if (pathMatch) files.add(pathMatch[1]);
|
|
205
|
-
}
|
|
206
|
-
if (name === "bash") {
|
|
207
|
-
const cmd = m.input ?? m.text;
|
|
208
|
-
if (cmd.includes("git add") || cmd.includes("git commit") || cmd.includes("git diff")) {
|
|
209
|
-
for (const p of extractFilePaths(cmd)) files.add(p);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
return [...files].slice(0, MAX_FILES);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
230
|
// ---- Public API ------------------------------------------------------------
|
|
217
231
|
|
|
218
232
|
/**
|
|
@@ -243,29 +257,10 @@ export function extractiveSummarize(messages: EngineMessage[]): ExtractiveSummar
|
|
|
243
257
|
const filesModified = extractFilesModified(toolMsgs);
|
|
244
258
|
|
|
245
259
|
const topicSummary = buildTopicSummary(
|
|
246
|
-
safe, tools, recentUser, currentWork, keyFiles, pending,
|
|
260
|
+
safe, tools, recentUser, currentWork, keyFiles, pending, filesModified, keyDecisions,
|
|
247
261
|
);
|
|
248
262
|
|
|
249
263
|
const tokenEstimate = estimateBlockTokens(topicSummary);
|
|
250
264
|
|
|
251
265
|
return { topicSummary, keyDecisions, nextSteps: pending, filesModified, tokenEstimate };
|
|
252
266
|
}
|
|
253
|
-
|
|
254
|
-
// ---- Key files (existing logic from compact.ts, moved here) ----------------
|
|
255
|
-
|
|
256
|
-
const MAX_KEY_FILES = 5;
|
|
257
|
-
const FRESHNESS_WINDOW = 10;
|
|
258
|
-
|
|
259
|
-
function collectKeyFiles(messages: EngineMessage[]): string[] {
|
|
260
|
-
const recent = messages.slice(-FRESHNESS_WINDOW);
|
|
261
|
-
const pathFreq = new Map<string, number>();
|
|
262
|
-
for (const m of recent) {
|
|
263
|
-
for (const p of extractFilePaths(m.text)) {
|
|
264
|
-
pathFreq.set(p, (pathFreq.get(p) ?? 0) + 1);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return [...pathFreq.entries()]
|
|
268
|
-
.sort((a, b) => b[1] - a[1])
|
|
269
|
-
.slice(0, MAX_KEY_FILES)
|
|
270
|
-
.map(([p]) => p);
|
|
271
|
-
}
|
|
@@ -91,6 +91,10 @@ export function computeDedupTierRollup(
|
|
|
91
91
|
if (ev.type !== "dedup_audit") continue;
|
|
92
92
|
const ts = Date.parse(ev.ts);
|
|
93
93
|
if (Number.isNaN(ts) || ts < windowStart || ts > windowEnd) continue;
|
|
94
|
+
// "skipped" (degenerate-match guard declined a collapse) is a decision, but
|
|
95
|
+
// NOT a tier catch — it is attributed to no tier below. Excluding it from the
|
|
96
|
+
// denominator keeps l0Share + l1Share + l2Share summing to 1 over the window.
|
|
97
|
+
if (ev.status === "skipped") continue;
|
|
94
98
|
total += 1;
|
|
95
99
|
switch (ev.tier) {
|
|
96
100
|
case "L0":
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* add-degenerate.ts — the L1/L2 degenerate-match decliner bound to one add().
|
|
3
|
+
*
|
|
4
|
+
* Split out of add.ts to keep it under the 300-line src/ soft limit (the same
|
|
5
|
+
* sibling-helper pattern as add-l0.ts / add-l1.ts). The PREDICATE lives in
|
|
6
|
+
* src/dedup/degenerate.ts alongside the other dedup primitives; what lives here
|
|
7
|
+
* is only the cascade-side glue: the telemetry the decision emits and the
|
|
8
|
+
* boolean the two call sites branch on.
|
|
9
|
+
*
|
|
10
|
+
* See src/dedup/degenerate.ts for the incident (2026-08-19) this guards against.
|
|
11
|
+
*/
|
|
12
|
+
import type { StoredCheckpoint } from "../store.js";
|
|
13
|
+
import { shouldSkipDegenerateMatch } from "../dedup/degenerate.js";
|
|
14
|
+
import type { DegenerateGuardTunables } from "../dedup/degenerate.js";
|
|
15
|
+
import type { DedupAuditRecorder } from "./dedup-audit.js";
|
|
16
|
+
import type { VectorStore } from "./class.js";
|
|
17
|
+
import type { AddInput } from "./types.js";
|
|
18
|
+
|
|
19
|
+
/** Declines one fuzzy-tier match; true ⇒ treat the match as if it never happened. */
|
|
20
|
+
export type DegenerateDecliner = (
|
|
21
|
+
tier: "L1" | "L2",
|
|
22
|
+
matched: StoredCheckpoint,
|
|
23
|
+
similarity?: number,
|
|
24
|
+
) => boolean;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the decliner for one add() cascade.
|
|
28
|
+
*
|
|
29
|
+
* Returns a predicate the L1/L2 call sites use as a one-line guard. When it
|
|
30
|
+
* returns true it has ALREADY recorded the declined decision (monitoring event +
|
|
31
|
+
* `skipped` audit line + the live `onTier` detail), so the caller only has to
|
|
32
|
+
* fall through. When the umbrella flag is off it always returns false and emits
|
|
33
|
+
* nothing — byte-identical to the pre-guard cascade.
|
|
34
|
+
*/
|
|
35
|
+
export function degenerateDecliner(args: {
|
|
36
|
+
store: VectorStore;
|
|
37
|
+
input: AddInput;
|
|
38
|
+
/** This candidate's content hash — blocks declining an exact-content match. */
|
|
39
|
+
contentHash: string;
|
|
40
|
+
cfg: DegenerateGuardTunables;
|
|
41
|
+
audit: DedupAuditRecorder;
|
|
42
|
+
/** Cascade start time, for the latency field on the monitoring event. */
|
|
43
|
+
t0: number;
|
|
44
|
+
}): DegenerateDecliner {
|
|
45
|
+
const { store, input, contentHash, cfg, audit, t0 } = args;
|
|
46
|
+
const candidate = { ...input, contentHash };
|
|
47
|
+
return (tier, matched, similarity) => {
|
|
48
|
+
if (!shouldSkipDegenerateMatch(matched, candidate, cfg)) return false;
|
|
49
|
+
// Reported as `mark_only`: a tier matched but policy declined to collapse —
|
|
50
|
+
// exactly the existing MARK_ONLY shape, with a distinct reason string so the
|
|
51
|
+
// dashboard can tell a guard decline from an operator-configured MARK_ONLY.
|
|
52
|
+
store.record(
|
|
53
|
+
tier,
|
|
54
|
+
"mark_only",
|
|
55
|
+
"degenerateGuard",
|
|
56
|
+
Date.now() - t0,
|
|
57
|
+
similarity,
|
|
58
|
+
matched.checkpointId,
|
|
59
|
+
);
|
|
60
|
+
audit.skipped(tier, matched.checkpointId, "degenerateGuard", similarity);
|
|
61
|
+
input.onTier?.({ tier, status: "passed", detail: "degenerateGuard" });
|
|
62
|
+
return true;
|
|
63
|
+
};
|
|
64
|
+
}
|