@pi-unipi/compactor 2.6.1 → 2.6.2

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 (44) hide show
  1. package/README.md +5 -4
  2. package/package.json +1 -1
  3. package/skills/compactor/SKILL.md +1 -1
  4. package/skills/compactor-detail/SKILL.md +3 -5
  5. package/skills/compactor-doctor/SKILL.md +1 -1
  6. package/skills/compactor-stats/SKILL.md +1 -1
  7. package/src/commands/index.ts +47 -78
  8. package/src/compaction/brief.ts +161 -90
  9. package/src/compaction/build-sections.ts +3 -4
  10. package/src/compaction/compact-args.ts +86 -0
  11. package/src/compaction/cut.ts +270 -28
  12. package/src/compaction/drill-down.ts +261 -0
  13. package/src/compaction/format-recall.ts +96 -0
  14. package/src/compaction/format.ts +8 -3
  15. package/src/compaction/hooks.ts +248 -72
  16. package/src/compaction/merge.ts +34 -4
  17. package/src/compaction/rank.ts +270 -0
  18. package/src/compaction/recall-scope.ts +28 -0
  19. package/src/compaction/search-entries.ts +333 -96
  20. package/src/compaction/skill-collapse.ts +35 -0
  21. package/src/compaction/summarize.ts +37 -6
  22. package/src/compaction/token-estimate.ts +104 -0
  23. package/src/compaction/touched-files.ts +35 -0
  24. package/src/config/manager.ts +2 -27
  25. package/src/config/presets.ts +0 -2
  26. package/src/config/schema.ts +2 -16
  27. package/src/executor/executor.ts +6 -15
  28. package/src/executor/runtime.ts +2 -12
  29. package/src/index.ts +12 -122
  30. package/src/info-screen.ts +3 -10
  31. package/src/security/evaluator.ts +0 -53
  32. package/src/security/policy.ts +7 -8
  33. package/src/session/db.ts +0 -6
  34. package/src/tools/ctx-execute-file.ts +0 -5
  35. package/src/tools/register.ts +27 -50
  36. package/src/tools/vcc-recall.ts +86 -48
  37. package/src/tui/settings-overlay.ts +20 -40
  38. package/src/types.ts +43 -100
  39. package/src/display/diff-renderer.ts +0 -281
  40. package/src/display/line-width-safety.ts +0 -28
  41. package/src/display/render-utils.ts +0 -52
  42. package/src/display/thinking-label.ts +0 -18
  43. package/src/display/tool-overrides.ts +0 -136
  44. package/src/tools/compact.ts +0 -20
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Ranked brief selection (pi-vcc parity) — score normalized blocks by signal
3
+ * density and select the highest-value ones under a size-relative char budget.
4
+ */
5
+
6
+ import type { NormalizedBlock, FileOps } from "../types.js";
7
+ import { extractPath } from "./extract/files.js";
8
+ import { compileBrief, heredocCloseIndex } from "./brief.js";
9
+
10
+ export interface BriefRankingOptions {
11
+ /** Maximum normalized blocks used to build the brief transcript. */
12
+ maxBlocks?: number;
13
+ /** Always keep this many latest blocks to preserve local continuity. */
14
+ preserveRecentBlocks?: number;
15
+ /** Hook-provided file activity, used as structural signal instead of prose guessing. */
16
+ fileOps?: FileOps;
17
+ /**
18
+ * Optional size budget (in characters of rendered brief) for the selected
19
+ * blocks. When set, this is the PRIMARY limit: blocks are added by score
20
+ * until the budget is reached, and lower-value blocks are skipped rather
21
+ * than truncating the tail. maxBlocks still applies as a safety upper bound.
22
+ * Callers derive this from a token budget via charsPerToken.
23
+ * When maxBriefCharsCeiling + briefCharsPerBlock are also set, this acts as
24
+ * the FLOOR of a size-relative budget (see below).
25
+ */
26
+ maxBriefChars?: number;
27
+ /**
28
+ * Optional upper bound for a size-relative char budget. When set together
29
+ * with maxBriefChars (floor) and briefCharsPerBlock (slope), the effective
30
+ * budget becomes clamp(briefCharsPerBlock * blockCount, maxBriefChars,
31
+ * maxBriefCharsCeiling): larger transcripts (which carry more high-value
32
+ * long-tail -- edits, commands, tests) get more brief budget, while small
33
+ * sessions stay at the floor and this hard ceiling prevents unbounded growth.
34
+ */
35
+ maxBriefCharsCeiling?: number;
36
+ /** Per-block slope (chars) for the size-relative budget. Requires the ceiling. */
37
+ briefCharsPerBlock?: number;
38
+ }
39
+
40
+ export interface RankedBlock {
41
+ block: NormalizedBlock;
42
+ index: number;
43
+ score: number;
44
+ reasons: string[];
45
+ }
46
+
47
+ const DEFAULT_MAX_BLOCKS = 80;
48
+ const DEFAULT_RECENT_BLOCKS = 16;
49
+
50
+ const EDIT_TOOL_RE = /^(edit|write|multiedit|quick_edit|target_edit|apply_patch)$/i;
51
+ const READ_TOOL_RE = /^(read|glob|grep|ls|find|semantic_query|semantic_grep|semantic_show)$/i;
52
+ const TEST_COMMAND_RE = /\b(?:bun|npm|pnpm|yarn|node|pytest|cargo|go|mvn|gradle)\b[^\n]*(?:test|spec|check|lint|build|tsc)/i;
53
+ const GH_PR_POLL_RE = /(?:^|\s)gh\s+pr\s+(?:view|checks)\s+(\d+)\b/i;
54
+ // Durable workflow facts: which PR/issue was acted on, and git state changes.
55
+ // Structural (command shape), not prose — same spirit as TEST_COMMAND_RE.
56
+ const WORKFLOW_COMMAND_RE =
57
+ /(?:^|\s)(?:gh\s+(?:pr|issue)\s+[a-z-]+|git\s+(?:commit|push|merge|rebase|revert|cherry-pick|tag|reset|checkout|branch)\b)/i;
58
+ const MIN_SEGMENT_CLOSING_ASSISTANT_CHARS = 120;
59
+
60
+ // A bash block whose every meaningful line is pure scaffolding (set -e, cd,
61
+ // export, ls, echo, sleep, pwd, comments, heredoc bodies) carries no durable
62
+ // fact. Such blocks get a score penalty so the size-relative budget never
63
+ // pulls them in ahead of real edits/commands when spare chars appear.
64
+ const TRIVIAL_BASH_LINE_RE =
65
+ /^(?:set\s+[-+]|cd(?:\s+\S+)?$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#|ls(?:\s|$)|echo\b|clear$|sleep\b)/;
66
+ const TRIVIAL_BASH_PENALTY = 16;
67
+
68
+ const isTrivialOnlyBash = (raw: string): boolean => {
69
+ const lines = raw.split("\n");
70
+ const kept: string[] = [];
71
+ for (let i = 0; i < lines.length; i++) {
72
+ kept.push(lines[i]);
73
+ // Skip heredoc bodies (they are content, not scaffolding) using the same
74
+ // hardened detection as brief.ts: only skip when the terminator exists
75
+ // downstream, so a stray `<<` never swallows a later real command.
76
+ const close = heredocCloseIndex(lines, i);
77
+ if (close !== -1) i = close;
78
+ }
79
+ const meaningful = kept.map((l) => l.trim()).filter(Boolean).filter((l) => !TRIVIAL_BASH_LINE_RE.test(l));
80
+ return meaningful.length === 0;
81
+ };
82
+
83
+ const asPathSet = (paths?: string[]): Set<string> => new Set((paths ?? []).filter(Boolean));
84
+
85
+ const bashCommandFromBlock = (block: NormalizedBlock): string | undefined => {
86
+ if (block.kind === "tool_call" && /^bash$/i.test(block.name) && typeof block.args.command === "string") {
87
+ return block.args.command;
88
+ }
89
+ return undefined;
90
+ };
91
+
92
+ const pathFromBlock = (block: NormalizedBlock): string | undefined => {
93
+ if (block.kind === "tool_call") return extractPath(block.args);
94
+ return undefined;
95
+ };
96
+
97
+ const add = (ranked: RankedBlock, points: number, reason: string) => {
98
+ ranked.score += points;
99
+ ranked.reasons.push(reason);
100
+ };
101
+
102
+ const scoreBlock = (
103
+ block: NormalizedBlock,
104
+ index: number,
105
+ total: number,
106
+ modifiedFiles: Set<string>,
107
+ readFiles: Set<string>,
108
+ ): RankedBlock => {
109
+ const ranked: RankedBlock = { block, index, score: 0, reasons: [] };
110
+ const recency = total <= 1 ? 0 : Math.round((index / (total - 1)) * 12);
111
+ add(ranked, recency, "recency");
112
+
113
+ if (block.kind === "user") add(ranked, 18, "user-turn");
114
+ if (block.kind === "assistant") add(ranked, 10, "assistant-context");
115
+ if (block.kind === "tool_result") add(ranked, 1, "tool-result-low-value");
116
+
117
+ if (block.kind === "tool_call") {
118
+ const command = bashCommandFromBlock(block);
119
+ if (EDIT_TOOL_RE.test(block.name)) add(ranked, 34, "edit-tool");
120
+ else if (command && TEST_COMMAND_RE.test(command)) add(ranked, 26, "test-command");
121
+ else if (READ_TOOL_RE.test(block.name)) add(ranked, 6, "read-tool");
122
+ else add(ranked, 12, "tool-call");
123
+ if (command && WORKFLOW_COMMAND_RE.test(command)) add(ranked, 14, "workflow-command");
124
+ if (command && isTrivialOnlyBash(command)) add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
125
+ }
126
+
127
+ const path = pathFromBlock(block);
128
+ if (path) {
129
+ if (modifiedFiles.has(path)) add(ranked, 18, "hook-modified-file");
130
+ if (readFiles.has(path)) add(ranked, 6, "hook-read-file");
131
+ }
132
+
133
+ if (block.kind === "tool_result" && block.text.length > 1000) add(ranked, -8, "long-tool-result");
134
+ return ranked;
135
+ };
136
+
137
+ const boostAdjacency = (ranked: RankedBlock[]) => {
138
+ const important = ranked
139
+ .filter((r) => r.score >= 34 || r.reasons.includes("edit-tool") || r.reasons.includes("test-command") || r.reasons.includes("nonzero-exit"))
140
+ .map((r) => r.index);
141
+
142
+ for (const idx of important) {
143
+ for (let i = idx - 1; i >= Math.max(0, idx - 8); i--) {
144
+ if (ranked[i].block.kind === "user") {
145
+ add(ranked[i], 10, "near-important-event");
146
+ break;
147
+ }
148
+ }
149
+ for (let i = idx - 1; i >= Math.max(0, idx - 4); i--) {
150
+ if (ranked[i].block.kind === "assistant") {
151
+ add(ranked[i], 7, "near-important-event");
152
+ break;
153
+ }
154
+ }
155
+ for (let i = idx + 1; i <= Math.min(ranked.length - 1, idx + 4); i++) {
156
+ if (ranked[i].block.kind === "assistant" || ranked[i].block.kind === "tool_call") {
157
+ add(ranked[i], 5, "after-important-event");
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ };
163
+
164
+ const nextNonToolResult = (ranked: RankedBlock[], index: number): NormalizedBlock | undefined => {
165
+ for (let i = index + 1; i < ranked.length; i++) {
166
+ if (ranked[i].block.kind !== "tool_result") return ranked[i].block;
167
+ }
168
+ return undefined;
169
+ };
170
+
171
+ const boostSegmentClosingAssistants = (ranked: RankedBlock[]) => {
172
+ for (let i = 0; i < ranked.length; i++) {
173
+ const current = ranked[i];
174
+ if (current.block.kind !== "assistant") continue;
175
+ if (current.block.text.trim().length < MIN_SEGMENT_CLOSING_ASSISTANT_CHARS) continue;
176
+ const next = nextNonToolResult(ranked, i);
177
+ if (!next || next.kind === "user") {
178
+ add(current, 14, "segment-closing-assistant");
179
+ }
180
+ }
181
+ };
182
+
183
+ const dedupKey = (block: NormalizedBlock): string | undefined => {
184
+ const command = bashCommandFromBlock(block);
185
+ const ghPrPoll = command?.match(GH_PR_POLL_RE);
186
+ if (ghPrPoll) return `gh-pr-poll:${ghPrPoll[1]}`;
187
+ if (command) {
188
+ const normalized = command.replace(/\s+/g, " ").trim();
189
+ return normalized ? `bash:${normalized}` : undefined;
190
+ }
191
+ if (block.kind === "tool_call") {
192
+ const path = pathFromBlock(block);
193
+ return path ? `tool:${block.name.toLowerCase()}:${path}` : undefined;
194
+ }
195
+ return undefined;
196
+ };
197
+
198
+ export const rankBriefBlocks = (blocks: NormalizedBlock[], options: BriefRankingOptions = {}): RankedBlock[] => {
199
+ const modifiedFiles = asPathSet(options.fileOps?.modifiedFiles);
200
+ const readFiles = asPathSet(options.fileOps?.readFiles);
201
+ const ranked = blocks.map((block, index) => scoreBlock(block, index, blocks.length, modifiedFiles, readFiles));
202
+ boostAdjacency(ranked);
203
+ boostSegmentClosingAssistants(ranked);
204
+ return ranked;
205
+ };
206
+
207
+ export const selectRankedBriefBlocks = (
208
+ blocks: NormalizedBlock[],
209
+ options: BriefRankingOptions = {},
210
+ ): NormalizedBlock[] => {
211
+ const maxBlocks = options.maxBlocks ?? DEFAULT_MAX_BLOCKS;
212
+ // Size-relative budget: when a ceiling + slope are provided, the effective
213
+ // char budget scales with transcript length (block count) between the floor
214
+ // (maxBriefChars) and the ceiling. Larger transcripts carry more high-value
215
+ // long-tail, so they earn more brief budget; small sessions stay at the floor.
216
+ const maxBriefChars =
217
+ options.maxBriefChars != null && options.maxBriefCharsCeiling != null && options.briefCharsPerBlock != null
218
+ ? Math.round(
219
+ Math.min(
220
+ options.maxBriefCharsCeiling,
221
+ Math.max(options.maxBriefChars, options.briefCharsPerBlock * blocks.length),
222
+ ),
223
+ )
224
+ : options.maxBriefChars;
225
+ // Fast path: nothing to trim by count and no char budget to enforce.
226
+ if (blocks.length <= maxBlocks && maxBriefChars == null) return blocks;
227
+
228
+ const preserveRecentBlocks = Math.min(options.preserveRecentBlocks ?? DEFAULT_RECENT_BLOCKS, maxBlocks);
229
+ const ranked = rankBriefBlocks(blocks, options);
230
+ const selected = new Set<number>();
231
+ const seenKeys = new Set<string>();
232
+
233
+ // Per-block rendered size, only computed when a char budget is active.
234
+ const costs = maxBriefChars == null
235
+ ? null
236
+ : blocks.map((b) => (b.kind === "tool_result" ? 0 : compileBrief([b]).length + 1));
237
+ let usedChars = 0;
238
+
239
+ // Keep the latest blocks to preserve local continuity, iterating NEWEST first
240
+ // so the most recent context is guaranteed. When a char budget is active these
241
+ // are charged against it too and over-budget blocks are skipped -- otherwise a
242
+ // run of large recent blocks could blow past maxBriefChars.
243
+ for (let i = blocks.length - 1; i >= Math.max(0, blocks.length - preserveRecentBlocks); i--) {
244
+ if (blocks[i].kind === "tool_result") continue;
245
+ if (selected.has(i)) continue;
246
+ if (costs && usedChars + costs[i] > maxBriefChars!) continue;
247
+ selected.add(i);
248
+ if (costs) usedChars += costs[i];
249
+ const key = dedupKey(blocks[i]);
250
+ if (key) seenKeys.add(key);
251
+ }
252
+
253
+ const ordered = [...ranked].sort((a, b) => b.score - a.score || b.index - a.index);
254
+ for (const item of ordered) {
255
+ if (selected.size >= maxBlocks) break;
256
+ if (selected.has(item.index)) continue;
257
+ if (item.block.kind === "tool_result") continue;
258
+ const key = dedupKey(item.block);
259
+ if (key && seenKeys.has(key)) continue;
260
+ if (costs) {
261
+ // Skip (not break) so smaller high-value blocks can still fit the budget.
262
+ if (usedChars + costs[item.index] > maxBriefChars!) continue;
263
+ usedChars += costs[item.index];
264
+ }
265
+ selected.add(item.index);
266
+ if (key) seenKeys.add(key);
267
+ }
268
+
269
+ return [...selected].sort((a, b) => a - b).map((i) => blocks[i]);
270
+ };
@@ -0,0 +1,28 @@
1
+ /** Recall scope/mode normalization (pi-vcc parity) */
2
+
3
+ export type RecallScope = "lineage" | "all";
4
+ export type RecallMode = "hybrid" | "touched";
5
+
6
+ const SCOPE_RE = /\bscope:(lineage|all)\b/i;
7
+
8
+ const VALID_MODES = new Set(["hybrid", "touched"]);
9
+
10
+ export const normalizeRecallScope = (scope?: unknown): RecallScope =>
11
+ typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
12
+
13
+ /**
14
+ * Normalize a mode param to a supported recall mode. Only "touched" adds
15
+ * behavior beyond the default hybrid search.
16
+ */
17
+ export const normalizeRecallMode = (mode?: unknown): RecallMode =>
18
+ typeof mode === "string" && VALID_MODES.has(mode.toLowerCase())
19
+ ? (mode.toLowerCase() as RecallMode)
20
+ : "hybrid";
21
+
22
+ export const parseRecallScope = (text: string): { scope: RecallScope; text: string } => {
23
+ const match = text.match(SCOPE_RE);
24
+ return {
25
+ scope: normalizeRecallScope(match?.[1]),
26
+ text: text.replace(SCOPE_RE, "").replace(/\s+/g, " ").trim(),
27
+ };
28
+ };