omp-vcc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/commands/omp-vcc.md +19 -0
  4. package/commands/vcc-recall.md +21 -0
  5. package/extensions/main.ts +319 -0
  6. package/extensions/vcc-core/commands/vcc-recall.ts +2 -0
  7. package/extensions/vcc-core/core/brief.ts +404 -0
  8. package/extensions/vcc-core/core/build-sections.ts +77 -0
  9. package/extensions/vcc-core/core/compact-args.ts +46 -0
  10. package/extensions/vcc-core/core/content.ts +157 -0
  11. package/extensions/vcc-core/core/drill-down.ts +299 -0
  12. package/extensions/vcc-core/core/filter-noise.ts +42 -0
  13. package/extensions/vcc-core/core/format-recall.ts +101 -0
  14. package/extensions/vcc-core/core/format.ts +82 -0
  15. package/extensions/vcc-core/core/lineage.ts +27 -0
  16. package/extensions/vcc-core/core/load-messages.ts +44 -0
  17. package/extensions/vcc-core/core/normalize.ts +66 -0
  18. package/extensions/vcc-core/core/rank.ts +284 -0
  19. package/extensions/vcc-core/core/recall-scope.ts +31 -0
  20. package/extensions/vcc-core/core/render-entries.ts +55 -0
  21. package/extensions/vcc-core/core/report.ts +233 -0
  22. package/extensions/vcc-core/core/sanitize.ts +6 -0
  23. package/extensions/vcc-core/core/search-entries.ts +576 -0
  24. package/extensions/vcc-core/core/settings.ts +151 -0
  25. package/extensions/vcc-core/core/skill-collapse.ts +36 -0
  26. package/extensions/vcc-core/core/summarize.ts +208 -0
  27. package/extensions/vcc-core/core/token-estimate.ts +101 -0
  28. package/extensions/vcc-core/core/tool-args.ts +17 -0
  29. package/extensions/vcc-core/details.ts +12 -0
  30. package/extensions/vcc-core/extract/commits.ts +70 -0
  31. package/extensions/vcc-core/extract/files.ts +88 -0
  32. package/extensions/vcc-core/extract/goals.ts +80 -0
  33. package/extensions/vcc-core/extract/preferences.ts +56 -0
  34. package/extensions/vcc-core/hook.ts +1017 -0
  35. package/extensions/vcc-core/sections.ts +9 -0
  36. package/extensions/vcc-core/types.ts +17 -0
  37. package/package.json +104 -0
  38. package/scripts/smoke.ts +116 -0
  39. package/scripts/uninstall-reset.js +73 -0
  40. package/skills/omp-vcc/SKILL.md +35 -0
  41. package/types.d.ts +114 -0
@@ -0,0 +1,284 @@
1
+ // @ts-nocheck
2
+ import type { NormalizedBlock, FileOps } from "../types";
3
+ import { extractPath } from "./tool-args";
4
+ import { compileBrief, heredocCloseIndex } from "./brief";
5
+
6
+ export interface BriefRankingOptions {
7
+ /** Maximum normalized blocks used to build the brief transcript. */
8
+ maxBlocks?: number;
9
+ /** Always keep this many latest blocks to preserve local continuity. */
10
+ preserveRecentBlocks?: number;
11
+ /** Hook-provided file activity, used as structural signal instead of prose guessing. */
12
+ fileOps?: FileOps;
13
+ /**
14
+ * Optional size budget (in characters of rendered brief) for the selected
15
+ * blocks. When set, this is the PRIMARY limit: blocks are added by score
16
+ * until the budget is reached, and lower-value blocks are skipped rather
17
+ * than truncating the tail. maxBlocks still applies as a safety upper bound.
18
+ * Callers derive this from a token budget via charsPerToken.
19
+ * When maxBriefCharsCeiling + briefCharsPerBlock are also set, this acts as
20
+ * the FLOOR of a size-relative budget (see below).
21
+ */
22
+ maxBriefChars?: number;
23
+ /**
24
+ * Optional upper bound for a size-relative char budget. When set together
25
+ * with maxBriefChars (floor) and briefCharsPerBlock (slope), the effective
26
+ * budget becomes clamp(briefCharsPerBlock * blockCount, maxBriefChars,
27
+ * maxBriefCharsCeiling): larger transcripts (which carry more high-value
28
+ * long-tail -- edits, commands, tests) get more brief budget, while small
29
+ * sessions stay at the floor and this hard ceiling prevents unbounded growth.
30
+ */
31
+ maxBriefCharsCeiling?: number;
32
+ /** Per-block slope (chars) for the size-relative budget. Requires the ceiling. */
33
+ briefCharsPerBlock?: number;
34
+ }
35
+
36
+ export interface RankedBlock {
37
+ block: NormalizedBlock;
38
+ index: number;
39
+ score: number;
40
+ reasons: string[];
41
+ }
42
+
43
+ const DEFAULT_MAX_BLOCKS = 80;
44
+ const DEFAULT_RECENT_BLOCKS = 16;
45
+
46
+ const EDIT_TOOL_RE = /^(edit|write|multiedit|quick_edit|target_edit|apply_patch)$/i;
47
+ const READ_TOOL_RE = /^(read|glob|grep|ls|find|semantic_query|semantic_grep|semantic_show)$/i;
48
+ const TEST_COMMAND_RE = /\b(?:bun|npm|pnpm|yarn|node|pytest|cargo|go|mvn|gradle)\b[^\n]*(?:test|spec|check|lint|build|tsc)/i;
49
+ const GH_PR_POLL_RE = /(?:^|\s)gh\s+pr\s+(?:view|checks)\s+(\d+)\b/i;
50
+ // Durable workflow facts: which PR/issue was acted on, and git state changes.
51
+ // Structural (command shape), not prose — same spirit as TEST_COMMAND_RE.
52
+ const WORKFLOW_COMMAND_RE =
53
+ /(?:^|\s)(?:gh\s+(?:pr|issue)\s+[a-z-]+|git\s+(?:commit|push|merge|rebase|revert|cherry-pick|tag|reset|checkout|branch)\b)/i;
54
+ const MIN_SEGMENT_CLOSING_ASSISTANT_CHARS = 120;
55
+
56
+ // A bash block whose every meaningful line is pure scaffolding (set -e, cd,
57
+ // export, ls, echo, sleep, pwd, comments, heredoc bodies) carries no durable
58
+ // fact. Such blocks get a score penalty so the size-relative budget never
59
+ // pulls them in ahead of real edits/commands when spare chars appear.
60
+ const TRIVIAL_BASH_LINE_RE =
61
+ /^(?:set\s+[-+]|cd(?:\s+\S+)?$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#|ls(?:\s|$)|echo\b|clear$|sleep\b)/;
62
+ const TRIVIAL_BASH_PENALTY = 16;
63
+
64
+ const isTrivialOnlyBash = (raw: string): boolean => {
65
+ const lines = raw.split("\n");
66
+ const kept: string[] = [];
67
+ for (let i = 0; i < lines.length; i++) {
68
+ kept.push(lines[i]);
69
+ // Skip heredoc bodies (they are content, not scaffolding) using the same
70
+ // hardened detection as brief.ts: only skip when the terminator exists
71
+ // downstream, so a stray `<<` never swallows a later real command.
72
+ const close = heredocCloseIndex(lines, i);
73
+ if (close !== -1) i = close;
74
+ }
75
+ const meaningful = kept.map((l) => l.trim()).filter(Boolean).filter((l) => !TRIVIAL_BASH_LINE_RE.test(l));
76
+ return meaningful.length === 0;
77
+ };
78
+
79
+ const asPathSet = (paths?: string[]): Set<string> => new Set((paths ?? []).filter(Boolean));
80
+
81
+ const bashCommandFromBlock = (block: NormalizedBlock): string | undefined => {
82
+ if (block.kind === "bash") return block.command;
83
+ if (block.kind === "tool_call" && /^bash$/i.test(block.name) && typeof block.args.command === "string") {
84
+ return block.args.command;
85
+ }
86
+ return undefined;
87
+ };
88
+
89
+ const pathFromBlock = (block: NormalizedBlock): string | undefined => {
90
+ if (block.kind === "tool_call") return extractPath(block.args) ?? undefined;
91
+ if (block.kind === "bash") {
92
+ const match = block.command.match(/(?:^|\s)([\w./-]+\.[\w-]+)(?:\s|$)/);
93
+ return match?.[1];
94
+ }
95
+ return undefined;
96
+ };
97
+
98
+ const add = (ranked: RankedBlock, points: number, reason: string) => {
99
+ ranked.score += points;
100
+ ranked.reasons.push(reason);
101
+ };
102
+
103
+ const scoreBlock = (
104
+ block: NormalizedBlock,
105
+ index: number,
106
+ total: number,
107
+ modifiedFiles: Set<string>,
108
+ readFiles: Set<string>,
109
+ ): RankedBlock => {
110
+ const ranked: RankedBlock = { block, index, score: 0, reasons: [] };
111
+ const recency = total <= 1 ? 0 : Math.round((index / (total - 1)) * 12);
112
+ add(ranked, recency, "recency");
113
+
114
+ if (block.kind === "user") add(ranked, 18, "user-turn");
115
+ if (block.kind === "assistant") add(ranked, 10, "assistant-context");
116
+ if (block.kind === "tool_result") add(ranked, 1, "tool-result-low-value");
117
+
118
+ if (block.kind === "tool_call") {
119
+ const command = bashCommandFromBlock(block);
120
+ if (EDIT_TOOL_RE.test(block.name)) add(ranked, 34, "edit-tool");
121
+ else if (command && TEST_COMMAND_RE.test(command)) add(ranked, 26, "test-command");
122
+ else if (READ_TOOL_RE.test(block.name)) add(ranked, 6, "read-tool");
123
+ else add(ranked, 12, "tool-call");
124
+ if (command && WORKFLOW_COMMAND_RE.test(command)) add(ranked, 14, "workflow-command");
125
+ if (command && isTrivialOnlyBash(command)) add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
126
+ }
127
+
128
+ if (block.kind === "bash") {
129
+ add(ranked, 8, "bash");
130
+ if (block.exitCode != null && block.exitCode !== 0) add(ranked, 24, "nonzero-exit");
131
+ if (TEST_COMMAND_RE.test(block.command)) add(ranked, 22, "test-command");
132
+ if (WORKFLOW_COMMAND_RE.test(block.command)) add(ranked, 14, "workflow-command");
133
+ // Penalize scaffolding-only commands, but not ones that failed (a failed
134
+ // `ls`/`cd` still records a real state fact via nonzero-exit).
135
+ if (isTrivialOnlyBash(block.command) && !(block.exitCode != null && block.exitCode !== 0)) {
136
+ add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
137
+ }
138
+ }
139
+
140
+ const path = pathFromBlock(block);
141
+ if (path) {
142
+ if (modifiedFiles.has(path)) add(ranked, 18, "hook-modified-file");
143
+ if (readFiles.has(path)) add(ranked, 6, "hook-read-file");
144
+ }
145
+
146
+ if (block.kind === "tool_result" && block.text.length > 1000) add(ranked, -8, "long-tool-result");
147
+ return ranked;
148
+ };
149
+
150
+ const boostAdjacency = (ranked: RankedBlock[]) => {
151
+ const important = ranked
152
+ .filter((r) => r.score >= 34 || r.reasons.includes("edit-tool") || r.reasons.includes("test-command") || r.reasons.includes("nonzero-exit"))
153
+ .map((r) => r.index);
154
+
155
+ for (const idx of important) {
156
+ for (let i = idx - 1; i >= Math.max(0, idx - 8); i--) {
157
+ if (ranked[i].block.kind === "user") {
158
+ add(ranked[i], 10, "near-important-event");
159
+ break;
160
+ }
161
+ }
162
+ for (let i = idx - 1; i >= Math.max(0, idx - 4); i--) {
163
+ if (ranked[i].block.kind === "assistant") {
164
+ add(ranked[i], 7, "near-important-event");
165
+ break;
166
+ }
167
+ }
168
+ for (let i = idx + 1; i <= Math.min(ranked.length - 1, idx + 4); i++) {
169
+ if (ranked[i].block.kind === "assistant" || ranked[i].block.kind === "bash") {
170
+ add(ranked[i], 5, "after-important-event");
171
+ break;
172
+ }
173
+ }
174
+ }
175
+ };
176
+
177
+ const nextNonToolResult = (ranked: RankedBlock[], index: number): NormalizedBlock | undefined => {
178
+ for (let i = index + 1; i < ranked.length; i++) {
179
+ if (ranked[i].block.kind !== "tool_result") return ranked[i].block;
180
+ }
181
+ return undefined;
182
+ };
183
+
184
+ const boostSegmentClosingAssistants = (ranked: RankedBlock[]) => {
185
+ for (let i = 0; i < ranked.length; i++) {
186
+ const current = ranked[i];
187
+ if (current.block.kind !== "assistant") continue;
188
+ if (current.block.text.trim().length < MIN_SEGMENT_CLOSING_ASSISTANT_CHARS) continue;
189
+ const next = nextNonToolResult(ranked, i);
190
+ if (!next || next.kind === "user") {
191
+ add(current, 14, "segment-closing-assistant");
192
+ }
193
+ }
194
+ };
195
+
196
+ const dedupKey = (block: NormalizedBlock): string | undefined => {
197
+ const command = bashCommandFromBlock(block);
198
+ const ghPrPoll = command?.match(GH_PR_POLL_RE);
199
+ if (ghPrPoll) return `gh-pr-poll:${ghPrPoll[1]}`;
200
+ if (command) {
201
+ const normalized = command.replace(/\s+/g, " ").trim();
202
+ return normalized ? `bash:${normalized}` : undefined;
203
+ }
204
+ if (block.kind === "tool_call") {
205
+ const path = pathFromBlock(block);
206
+ return path ? `tool:${block.name.toLowerCase()}:${path}` : undefined;
207
+ }
208
+ return undefined;
209
+ };
210
+
211
+ export const rankBriefBlocks = (blocks: NormalizedBlock[], options: BriefRankingOptions = {}): RankedBlock[] => {
212
+ const modifiedFiles = asPathSet(options.fileOps?.modifiedFiles);
213
+ const readFiles = asPathSet(options.fileOps?.readFiles);
214
+ const ranked = blocks.map((block, index) => scoreBlock(block, index, blocks.length, modifiedFiles, readFiles));
215
+ boostAdjacency(ranked);
216
+ boostSegmentClosingAssistants(ranked);
217
+ return ranked;
218
+ };
219
+
220
+ export const selectRankedBriefBlocks = (
221
+ blocks: NormalizedBlock[],
222
+ options: BriefRankingOptions = {},
223
+ ): NormalizedBlock[] => {
224
+ const maxBlocks = options.maxBlocks ?? DEFAULT_MAX_BLOCKS;
225
+ // Size-relative budget: when a ceiling + slope are provided, the effective
226
+ // char budget scales with transcript length (block count) between the floor
227
+ // (maxBriefChars) and the ceiling. Larger transcripts carry more high-value
228
+ // long-tail, so they earn more brief budget; small sessions stay at the floor.
229
+ const maxBriefChars =
230
+ options.maxBriefChars != null && options.maxBriefCharsCeiling != null && options.briefCharsPerBlock != null
231
+ ? Math.round(
232
+ Math.min(
233
+ options.maxBriefCharsCeiling,
234
+ Math.max(options.maxBriefChars, options.briefCharsPerBlock * blocks.length),
235
+ ),
236
+ )
237
+ : options.maxBriefChars;
238
+ // Fast path: nothing to trim by count and no char budget to enforce.
239
+ if (blocks.length <= maxBlocks && maxBriefChars == null) return blocks;
240
+
241
+ const preserveRecentBlocks = Math.min(options.preserveRecentBlocks ?? DEFAULT_RECENT_BLOCKS, maxBlocks);
242
+ const ranked = rankBriefBlocks(blocks, options);
243
+ const selected = new Set<number>();
244
+ const seenKeys = new Set<string>();
245
+
246
+ // Per-block rendered size, only computed when a char budget is active.
247
+ const costs = maxBriefChars == null
248
+ ? null
249
+ : blocks.map((b) => (b.kind === "tool_result" ? 0 : compileBrief([b]).length + 1));
250
+ let usedChars = 0;
251
+
252
+ // Keep the latest blocks to preserve local continuity, iterating NEWEST first
253
+ // so the most recent context is guaranteed. When a char budget is active these
254
+ // are charged against it too and over-budget blocks are skipped -- otherwise a
255
+ // run of large recent blocks could blow past maxBriefChars (the old bug on very
256
+ // long transcripts, where preserve-recent alone exceeded the budget).
257
+ for (let i = blocks.length - 1; i >= Math.max(0, blocks.length - preserveRecentBlocks); i--) {
258
+ if (blocks[i].kind === "tool_result") continue;
259
+ if (selected.has(i)) continue;
260
+ if (costs && usedChars + costs[i] > maxBriefChars!) continue;
261
+ selected.add(i);
262
+ if (costs) usedChars += costs[i];
263
+ const key = dedupKey(blocks[i]);
264
+ if (key) seenKeys.add(key);
265
+ }
266
+
267
+ const ordered = [...ranked].sort((a, b) => b.score - a.score || b.index - a.index);
268
+ for (const item of ordered) {
269
+ if (selected.size >= maxBlocks) break;
270
+ if (selected.has(item.index)) continue;
271
+ if (item.block.kind === "tool_result") continue;
272
+ const key = dedupKey(item.block);
273
+ if (key && seenKeys.has(key)) continue;
274
+ if (costs) {
275
+ // Skip (not break) so smaller high-value blocks can still fit the budget.
276
+ if (usedChars + costs[item.index] > maxBriefChars!) continue;
277
+ usedChars += costs[item.index];
278
+ }
279
+ selected.add(item.index);
280
+ if (key) seenKeys.add(key);
281
+ }
282
+
283
+ return [...selected].sort((a, b) => a - b).map((i) => blocks[i]);
284
+ };
@@ -0,0 +1,31 @@
1
+ // @ts-nocheck
2
+ export type RecallScope = "lineage" | "all";
3
+ export type RecallMode = "hybrid" | "touched";
4
+
5
+ const SCOPE_RE = /\bscope:(lineage|all)\b/i;
6
+
7
+ const VALID_MODES = new Set(["hybrid", "touched"]);
8
+
9
+ export const normalizeRecallScope = (scope?: unknown): RecallScope =>
10
+ typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
11
+
12
+ /**
13
+ * Normalize a mode param to a supported recall mode. Without OM integration,
14
+ * only "touched" adds behavior beyond the default hybrid search — "file"-only
15
+ * search is not implemented in pi-vcc, so it is not exposed.
16
+ *
17
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
18
+ * k0valik — a pi-vcc derivative.
19
+ */
20
+ export const normalizeRecallMode = (mode?: unknown): RecallMode =>
21
+ typeof mode === "string" && VALID_MODES.has(mode.toLowerCase())
22
+ ? (mode.toLowerCase() as RecallMode)
23
+ : "hybrid";
24
+
25
+ export const parseRecallScope = (text: string): { scope: RecallScope; text: string } => {
26
+ const match = text.match(SCOPE_RE);
27
+ return {
28
+ scope: normalizeRecallScope(match?.[1]),
29
+ text: text.replace(SCOPE_RE, "").replace(/\s+/g, " ").trim(),
30
+ };
31
+ };
@@ -0,0 +1,55 @@
1
+ // @ts-nocheck
2
+ import type { Message } from "@oh-my-pi/pi-ai";
3
+ import { clip, textOf } from "./content";
4
+ import { summarizeToolArgs } from "./tool-args";
5
+ import { extractPath } from "./tool-args";
6
+
7
+ export interface RenderedEntry {
8
+ index: number;
9
+ role: string;
10
+ summary: string;
11
+ files?: string[];
12
+ }
13
+
14
+ const toolCalls = (content: Message["content"]): string => {
15
+ if (!content || typeof content === "string") return "";
16
+ return content
17
+ .filter((c) => c.type === "toolCall")
18
+ .map((c) => `${c.name}(${summarizeToolArgs(c.arguments)})`)
19
+ .join(", ");
20
+ };
21
+
22
+ const extractFilesFromContent = (content: Message["content"]): string[] => {
23
+ if (!content || typeof content === "string") return [];
24
+ return content
25
+ .filter((c) => c.type === "toolCall")
26
+ .map((c) => extractPath(c.arguments))
27
+ .filter((p): p is string => p !== null);
28
+ };
29
+
30
+ export const renderMessage = (msg: Message, index: number, full = false): RenderedEntry => {
31
+ if (msg.role === "user") {
32
+ return { index, role: "user", summary: full ? textOf(msg.content) : clip(textOf(msg.content), 300) };
33
+ }
34
+ if (msg.role === "toolResult") {
35
+ const text = full ? textOf(msg.content) : clip(textOf(msg.content), 200);
36
+ return {
37
+ index, role: "tool_result",
38
+ summary: `[${msg.toolName}] ${text}`,
39
+ };
40
+ }
41
+ // bashExecution has command+output instead of content
42
+ if ((msg as any).role === "bashExecution") {
43
+ const cmd = (msg as any).command ?? "";
44
+ const out = (msg as any).output ?? "";
45
+ const text = full ? `$ ${cmd}\n${out}` : clip(`$ ${cmd}\n${out}`, 300);
46
+ return { index, role: "bash", summary: text };
47
+ }
48
+ const text = full ? textOf(msg.content) : clip(textOf(msg.content), 300);
49
+ const tools = toolCalls(msg.content);
50
+ const files = extractFilesFromContent(msg.content);
51
+ const summary = tools ? `${tools}\n${text}` : text;
52
+ return { index, role: "assistant", summary, ...(files.length > 0 && { files }) };
53
+ };
54
+
55
+
@@ -0,0 +1,233 @@
1
+ // @ts-nocheck
2
+ import type { Message } from "@oh-my-pi/pi-ai";
3
+ import { buildSections } from "./build-sections";
4
+ import { clip } from "./content";
5
+ import { normalize } from "./normalize";
6
+ import { renderMessage } from "./render-entries";
7
+ import { searchEntries } from "./search-entries";
8
+ import { type CompileInput, compile } from "./summarize";
9
+ import { estimateTokensFromChars } from "./token-estimate";
10
+
11
+ const SECTION_HEADERS = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context"];
12
+
13
+ interface RoleCounts {
14
+ user: number;
15
+ assistant: number;
16
+ toolResult: number;
17
+ }
18
+
19
+ interface BlockCounts {
20
+ user: number;
21
+ assistant: number;
22
+ toolCalls: number;
23
+ toolResults: number;
24
+ }
25
+
26
+ export interface RecallProbe {
27
+ label: string;
28
+ sourceText: string;
29
+ query: string;
30
+ summaryMentioned: boolean;
31
+ recallHits: number;
32
+ }
33
+
34
+ export interface CompactReport {
35
+ summary: string;
36
+ before: {
37
+ messageCount: number;
38
+ roleCounts: RoleCounts;
39
+ blockCounts: BlockCounts;
40
+ inputChars: number;
41
+ estimatedTokens: number;
42
+ topFiles: string[];
43
+ preview: string;
44
+ };
45
+ after: {
46
+ summaryLength: number;
47
+ estimatedTokens: number;
48
+ sectionCount: number;
49
+ summaryPreview: string;
50
+ goalsCount: number;
51
+ blockersCount: number;
52
+ briefTranscriptLines: number;
53
+ };
54
+ compression: {
55
+ charsBefore: number;
56
+ charsAfter: number;
57
+ ratio: number;
58
+ messagesBefore: number;
59
+ };
60
+ recall: {
61
+ probes: RecallProbe[];
62
+ };
63
+ }
64
+
65
+ const countRoles = (messages: Message[]): RoleCounts => {
66
+ const counts: RoleCounts = { user: 0, assistant: 0, toolResult: 0 };
67
+ for (const msg of messages) {
68
+ if (msg.role === "user") counts.user += 1;
69
+ else if (msg.role === "assistant") counts.assistant += 1;
70
+ else if (msg.role === "toolResult") counts.toolResult += 1;
71
+ }
72
+ return counts;
73
+ };
74
+
75
+ const countBlocks = (messages: Message[]): BlockCounts => {
76
+ const counts: BlockCounts = {
77
+ user: 0,
78
+ assistant: 0,
79
+ toolCalls: 0,
80
+ toolResults: 0,
81
+ };
82
+
83
+ for (const block of normalize(messages)) {
84
+ if (block.kind === "user") counts.user += 1;
85
+ else if (block.kind === "assistant") counts.assistant += 1;
86
+ else if (block.kind === "tool_call") counts.toolCalls += 1;
87
+ else if (block.kind === "tool_result") counts.toolResults += 1;
88
+ }
89
+
90
+ return counts;
91
+ };
92
+
93
+ const inputCharsOf = (messages: Message[]): number =>
94
+ messages
95
+ .map((msg, index) => renderMessage(msg, index, true).summary.length)
96
+ .reduce((sum, len) => sum + len, 0);
97
+
98
+ const topFilesOf = (messages: Message[]): string[] => {
99
+ const files = new Set<string>();
100
+ for (const block of normalize(messages)) {
101
+ if (block.kind === "tool_call") {
102
+ for (const key of ["path", "file_path", "filePath", "file"]) {
103
+ const val = block.args[key];
104
+ if (typeof val === "string") { files.add(val); break; }
105
+ }
106
+ }
107
+ }
108
+ return [...files].slice(0, 10);
109
+ };
110
+
111
+ const previewOf = (messages: Message[], edgeCount = 3): string => {
112
+ const rendered = messages.map((msg, index) => renderMessage(msg, index));
113
+ if (rendered.length === 0) return "(empty)";
114
+ if (rendered.length <= edgeCount * 2) {
115
+ return rendered
116
+ .map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`)
117
+ .join("\n");
118
+ }
119
+
120
+ const first = rendered.slice(0, edgeCount);
121
+ const last = rendered.slice(-edgeCount);
122
+ return [
123
+ ...first.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
124
+ "...",
125
+ ...last.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
126
+ ].join("\n");
127
+ };
128
+
129
+ const sectionCountOf = (summary: string): number =>
130
+ SECTION_HEADERS.filter((header) => summary.includes(`[${header}]`)).length;
131
+
132
+ const briefLineCountOf = (summary: string): number => {
133
+ const sep = "\n\n---\n\n";
134
+ const idx = summary.indexOf(sep);
135
+ if (idx < 0) return 0;
136
+ return summary.slice(idx + sep.length).split("\n").length;
137
+ };
138
+
139
+ const queryTermsOf = (text: string): string[] =>
140
+ (text.match(/[\p{L}\p{N}_./-]{3,}/gu) ?? [])
141
+ .map((part) => part.trim())
142
+ .filter(Boolean);
143
+
144
+ const queryOf = (text: string): string => {
145
+ const terms = queryTermsOf(text);
146
+ return terms.slice(0, 6).join(" ");
147
+ };
148
+
149
+ const matchesQuery = (text: string, query: string): boolean => {
150
+ const hay = text.toLowerCase();
151
+ return query
152
+ .toLowerCase()
153
+ .split(/\s+/)
154
+ .filter(Boolean)
155
+ .every((term) => hay.includes(term));
156
+ };
157
+
158
+ const probesOf = (messages: Message[], summary: string): RecallProbe[] => {
159
+ const blocks = normalize(messages);
160
+ const data = buildSections({ blocks });
161
+
162
+ // Find first file from tool calls
163
+ let firstFile = "";
164
+ for (const b of blocks) {
165
+ if (b.kind === "tool_call") {
166
+ for (const key of ["path", "file_path", "filePath", "file"]) {
167
+ if (typeof b.args[key] === "string") { firstFile = b.args[key] as string; break; }
168
+ }
169
+ if (firstFile) break;
170
+ }
171
+ }
172
+
173
+ const rawProbes = [
174
+ { label: "goal", text: data.sessionGoal[0] ?? "" },
175
+ { label: "file", text: firstFile },
176
+ { label: "problem", text: data.outstandingContext[0] ?? "" },
177
+ ];
178
+
179
+ const rendered = messages.map((msg, index) => renderMessage(msg, index));
180
+
181
+ return rawProbes
182
+ .map(({ label, text }) => {
183
+ const sourceText = text.trim();
184
+ const query = queryOf(sourceText);
185
+ if (!query) return null;
186
+ return {
187
+ label,
188
+ sourceText,
189
+ query,
190
+ summaryMentioned: matchesQuery(summary, query),
191
+ recallHits: searchEntries(rendered, messages, query).length,
192
+ };
193
+ })
194
+ .filter((probe): probe is RecallProbe => probe !== null);
195
+ };
196
+
197
+ export const buildCompactReport = (input: CompileInput): CompactReport => {
198
+ const summary = compile(input);
199
+ const data = buildSections({ blocks: normalize(input.messages) });
200
+ const inputChars = inputCharsOf(input.messages);
201
+ const topFiles = topFilesOf(input.messages);
202
+
203
+ return {
204
+ summary,
205
+ before: {
206
+ messageCount: input.messages.length,
207
+ roleCounts: countRoles(input.messages),
208
+ blockCounts: countBlocks(input.messages),
209
+ inputChars,
210
+ estimatedTokens: estimateTokensFromChars(inputChars),
211
+ topFiles,
212
+ preview: previewOf(input.messages),
213
+ },
214
+ after: {
215
+ summaryLength: summary.length,
216
+ estimatedTokens: estimateTokensFromChars(summary.length),
217
+ sectionCount: sectionCountOf(summary),
218
+ summaryPreview: summary,
219
+ goalsCount: data.sessionGoal.length,
220
+ blockersCount: data.outstandingContext.length,
221
+ briefTranscriptLines: briefLineCountOf(summary),
222
+ },
223
+ compression: {
224
+ charsBefore: inputChars,
225
+ charsAfter: summary.length,
226
+ ratio: summary.length === 0 ? 0 : Number((inputChars / summary.length).toFixed(2)),
227
+ messagesBefore: input.messages.length,
228
+ },
229
+ recall: {
230
+ probes: probesOf(input.messages, summary),
231
+ },
232
+ };
233
+ };
@@ -0,0 +1,6 @@
1
+ // @ts-nocheck
2
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
3
+ const CTRL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
4
+
5
+ export const sanitize = (text: string): string =>
6
+ text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(ANSI_RE, "").replace(CTRL_RE, "");