pi-tuck 1.0.1 → 1.0.4

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 (50) hide show
  1. package/CHANGELOG.md +65 -3
  2. package/README.md +138 -57
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +7 -4
  5. package/dist/src/budget.d.ts +17 -4
  6. package/dist/src/budget.js +33 -10
  7. package/dist/src/build-id.d.ts +1 -1
  8. package/dist/src/build-id.js +1 -1
  9. package/dist/src/compaction.d.ts +7 -0
  10. package/dist/src/compaction.js +32 -16
  11. package/dist/src/diagnostics.d.ts +2 -1
  12. package/dist/src/omissions.d.ts +1 -1
  13. package/dist/src/omissions.js +8 -8
  14. package/dist/src/tuck/core/brief.d.ts +13 -43
  15. package/dist/src/tuck/core/brief.js +41 -255
  16. package/dist/src/tuck/core/filter-noise.js +2 -1
  17. package/dist/src/tuck/core/format.d.ts +2 -6
  18. package/dist/src/tuck/core/format.js +4 -10
  19. package/dist/src/tuck/core/normalize.js +22 -50
  20. package/dist/src/tuck/core/shrink.d.ts +33 -0
  21. package/dist/src/tuck/core/shrink.js +218 -0
  22. package/dist/src/tuck/core/summarize.d.ts +15 -7
  23. package/dist/src/tuck/core/summarize.js +17 -22
  24. package/dist/src/tuck/core/transcript.d.ts +24 -13
  25. package/dist/src/tuck/core/transcript.js +251 -83
  26. package/dist/src/tuck/types.d.ts +4 -21
  27. package/dist/src/tuck.d.ts +8 -10
  28. package/dist/src/tuck.js +23 -25
  29. package/index.ts +8 -5
  30. package/package.json +1 -1
  31. package/src/budget.ts +29 -9
  32. package/src/build-id.ts +1 -1
  33. package/src/compaction.ts +33 -14
  34. package/src/diagnostics.ts +1 -1
  35. package/src/omissions.ts +8 -8
  36. package/src/tuck/core/brief.ts +48 -265
  37. package/src/tuck/core/filter-noise.ts +2 -1
  38. package/src/tuck/core/format.ts +4 -11
  39. package/src/tuck/core/normalize.ts +21 -50
  40. package/src/tuck/core/shrink.ts +217 -0
  41. package/src/tuck/core/summarize.ts +26 -24
  42. package/src/tuck/core/transcript.ts +238 -82
  43. package/src/tuck/types.ts +9 -20
  44. package/src/tuck.ts +30 -32
  45. package/dist/src/tuck/core/brief-meter.d.ts +0 -42
  46. package/dist/src/tuck/core/brief-meter.js +0 -273
  47. package/dist/src/tuck/core/rank.d.ts +0 -42
  48. package/dist/src/tuck/core/rank.js +0 -292
  49. package/src/tuck/core/brief-meter.ts +0 -235
  50. package/src/tuck/core/rank.ts +0 -331
@@ -1,273 +0,0 @@
1
- import { absorbCall, blockRecords, coveredCallKey, decorateRecord, isOmittable, noticeTimes, omissionRecord, receiptKey, repeatsCall, segmentClosing } from "./brief.js";
2
- import { TRANSCRIPT_HEADER, lineUnits } from "./transcript.js";
3
- /** A set of integers in [0, size) with ordered neighbour queries, in a tree of 32-bit words. */
4
- export class IndexSet {
5
- levels = [];
6
- constructor(size) {
7
- let words = Math.max(1, size);
8
- do {
9
- words = Math.ceil(words / 32);
10
- this.levels.push(new Uint32Array(words));
11
- } while (words > 1);
12
- }
13
- has(i) { return (this.levels[0][i >>> 5] & (1 << (i & 31))) !== 0; }
14
- add(i) {
15
- for (const level of this.levels) {
16
- const word = i >>> 5, was = level[word];
17
- level[word] = was | (1 << (i & 31));
18
- if (was !== 0)
19
- return;
20
- i = word;
21
- }
22
- }
23
- remove(i) {
24
- for (const level of this.levels) {
25
- const word = i >>> 5;
26
- level[word] = level[word] & ~(1 << (i & 31));
27
- if (level[word] !== 0)
28
- return;
29
- i = word;
30
- }
31
- }
32
- /** The smallest member above `i`, or -1. */
33
- next(i) {
34
- for (let l = 0; l < this.levels.length; l++) {
35
- const word = i >>> 5, bit = i & 31;
36
- const rest = bit === 31 ? 0 : this.levels[l][word] & (-1 << (bit + 1));
37
- if (rest === 0) {
38
- i = word;
39
- continue;
40
- }
41
- let at = (word << 5) | (31 - Math.clz32(rest & -rest));
42
- for (let k = l - 1; k >= 0; k--) {
43
- const w = this.levels[k][at];
44
- at = (at << 5) | (31 - Math.clz32(w & -w));
45
- }
46
- return at;
47
- }
48
- return -1;
49
- }
50
- /** The largest member below `i`, or -1. */
51
- prev(i) {
52
- for (let l = 0; l < this.levels.length; l++) {
53
- const word = i >>> 5, bit = i & 31;
54
- const rest = this.levels[l][word] & ~(-1 << bit);
55
- if (rest === 0) {
56
- i = word;
57
- continue;
58
- }
59
- let at = (word << 5) | (31 - Math.clz32(rest));
60
- for (let k = l - 1; k >= 0; k--)
61
- at = (at << 5) | (31 - Math.clz32(this.levels[k][at]));
62
- return at;
63
- }
64
- return -1;
65
- }
66
- }
67
- /**
68
- * The size of `TRANSCRIPT_HEADER + "\n" + compileBrief(selected)` as blocks join, kept up to date by recomputing only the records a change
69
- * can affect, which are those of the block, its neighbours and the identical calls merged with them. This is the admission loop's yardstick,
70
- * so it must equal what the full render measures; the final render in `selectRankedBriefBlocks` remains the authority.
71
- *
72
- * It relies on what `normalize` guarantees for the blocks of one brief: they are distinct objects; all carry the same `sourceTimes` or none
73
- * does; and notices that share a `sourceEntryId` share a `sourceTime`.
74
- */
75
- export const createBriefMeter = (blocks, mandatory, cache) => {
76
- const n = blocks.length;
77
- const closing = segmentClosing(blocks);
78
- const times = blocks[0]?.sourceTimes;
79
- const entryTimes = noticeTimes(blocks);
80
- const lengths = new WeakMap();
81
- const lengthOf = (record) => {
82
- let length = lengths.get(record);
83
- if (length === undefined)
84
- lengths.set(record, length = JSON.stringify(decorateRecord(record, times, entryTimes)).length);
85
- return length;
86
- };
87
- // The state of each block: selected, rendered whole, and the records it renders alone while it has any (`active`).
88
- const selected = new Uint8Array(n), whole = new Uint8Array(n);
89
- const records = new Array(n);
90
- const rendered = [new Array(n), new Array(n)];
91
- const active = new IndexSet(n);
92
- // Blocks whose first record is a repeat of the record before it fold into it; the rest of the output follows from these relations.
93
- const merged = new Uint8Array(n);
94
- const contributedChars = new Int32Array(n), contributedUnits = new Int32Array(n), contributedRecords = new Int32Array(n);
95
- let chars = 0, units = 0, count = 0;
96
- // A selected receipt makes the overview of the call it quotes redundant.
97
- const callKeys = new Array(n);
98
- const receiptKeys = new Array(n);
99
- const callsOf = new Map(), receipts = new Map();
100
- blocks.forEach((block, i) => {
101
- const call = coveredCallKey(block);
102
- if (call !== undefined) {
103
- callKeys[i] = call;
104
- const calls = callsOf.get(call);
105
- if (calls)
106
- calls.push(i);
107
- else
108
- callsOf.set(call, [i]);
109
- }
110
- receiptKeys[i] = receiptKey(block);
111
- });
112
- const own = (i) => {
113
- const block = blocks[i], key = callKeys[i];
114
- if (key !== undefined && receipts.get(key))
115
- return undefined;
116
- const slot = rendered[whole[i]];
117
- const result = slot[i] ??= blockRecords(block, { full: whole[i] === 1, closing: closing.has(block), mandatory, bounded: true, covered: false }, cache);
118
- return result.length ? result : undefined;
119
- };
120
- const repeats = (a, b) => {
121
- const next = records[b];
122
- return next.length === 1 && repeatsCall(records[a].at(-1), next[0]);
123
- };
124
- const first = (from) => active.has(from) ? from : active.next(from);
125
- const setRecords = (j, next) => {
126
- if (records[j] === next)
127
- return;
128
- // Every group of repeats that the change can reach: from the head of the group before `j` to the end of the group after it.
129
- const before = active.prev(j), after = active.next(j);
130
- let low = j, high = j;
131
- if (before !== -1)
132
- for (low = before; merged[low]; low = active.prev(low))
133
- ;
134
- if (after !== -1)
135
- for (high = after;;) {
136
- const further = active.next(high);
137
- if (further === -1 || !merged[further])
138
- break;
139
- high = further;
140
- }
141
- for (let b = first(low); b !== -1 && b <= high; b = active.next(b)) {
142
- chars -= contributedChars[b];
143
- units -= contributedUnits[b];
144
- count -= contributedRecords[b];
145
- contributedChars[b] = contributedUnits[b] = contributedRecords[b] = 0;
146
- }
147
- records[j] = next;
148
- if (next)
149
- active.add(j);
150
- else
151
- active.remove(j);
152
- let group = [];
153
- const settle = () => {
154
- if (!group.length)
155
- return;
156
- const head = records[group[0]];
157
- const shown = group.length === 1 ? head :
158
- [group.slice(1).reduce((all, b) => absorbCall(all, records[b][0]), head[0])];
159
- for (const record of shown) {
160
- const length = lengthOf(record);
161
- contributedChars[group[0]] += length;
162
- contributedUnits[group[0]] += lineUnits(length);
163
- contributedRecords[group[0]]++;
164
- }
165
- chars += contributedChars[group[0]];
166
- units += contributedUnits[group[0]];
167
- count += contributedRecords[group[0]];
168
- };
169
- for (let b = first(low); b !== -1 && b <= high; b = active.next(b)) {
170
- const previous = active.prev(b);
171
- merged[b] = previous !== -1 && repeats(previous, b) ? 1 : 0;
172
- if (!merged[b]) {
173
- settle();
174
- group = [];
175
- }
176
- group.push(b);
177
- }
178
- settle();
179
- };
180
- const refresh = (i) => setRecords(i, selected[i] ? own(i) : undefined);
181
- const quoted = (i, delta) => {
182
- const key = receiptKeys[i];
183
- if (key === undefined)
184
- return;
185
- const now = (receipts.get(key) ?? 0) + delta;
186
- receipts.set(key, now);
187
- if (now === (delta === 1 ? 1 : 0))
188
- for (const call of callsOf.get(key) ?? [])
189
- if (selected[call])
190
- refresh(call);
191
- };
192
- // The omission notices say how many unselected user and assistant text blocks there are and where the first of them were read.
193
- const roles = ["user", "assistant"].map(role => {
194
- const perSource = new Map();
195
- let total = 0, unlocated = 0;
196
- for (const block of blocks)
197
- if (block.kind === role && isOmittable(block, mandatory)) {
198
- total++;
199
- if (block.sourceIndex === undefined)
200
- unlocated++;
201
- else
202
- perSource.set(block.sourceIndex, (perSource.get(block.sourceIndex) ?? 0) + 1);
203
- }
204
- const sources = [...perSource.keys()].sort((a, b) => a - b);
205
- const ids = new Map(sources.map((source, id) => [source, id]));
206
- const alive = new IndexSet(sources.length);
207
- sources.forEach((_, id) => alive.add(id));
208
- return { role, total, unlocated, perSource, sources, ids, alive };
209
- });
210
- const omittedBy = (added, role) => {
211
- let removed = 0, unlocated = 0;
212
- const taken = new Map();
213
- for (const i of added) {
214
- const block = blocks[i];
215
- if (block.kind !== role.role || !isOmittable(block, mandatory))
216
- continue;
217
- removed++;
218
- if (block.sourceIndex === undefined)
219
- unlocated++;
220
- else
221
- taken.set(block.sourceIndex, (taken.get(block.sourceIndex) ?? 0) + 1);
222
- }
223
- return { removed, unlocated, taken };
224
- };
225
- const notice = (added, role) => {
226
- const { removed, unlocated, taken } = omittedBy(added, role);
227
- if (role.total === removed)
228
- return undefined;
229
- const gone = new Set();
230
- for (const [source, k] of taken)
231
- if (role.perSource.get(source) === k)
232
- gone.add(source);
233
- const sources = [];
234
- for (let id = role.alive.has(0) ? 0 : role.alive.next(0); id !== -1 && sources.length < 9; id = role.alive.next(id))
235
- if (!gone.has(role.sources[id]))
236
- sources.push(role.sources[id]);
237
- return omissionRecord(role.role, role.total - removed, sources, role.unlocated > unlocated);
238
- };
239
- const header = TRANSCRIPT_HEADER.length + 1;
240
- const rendering = (c, u, k) => k === 0 ? { chars: header, units: 2 } : { chars: header + c + k - 1, units: 1 + u };
241
- return {
242
- select(i) { selected[i] = 1; quoted(i, 1); refresh(i); },
243
- unselect(i) { selected[i] = 0; refresh(i); quoted(i, -1); },
244
- promote(i) { whole[i] = 1; refresh(i); },
245
- demote(i) { whole[i] = 0; refresh(i); },
246
- size(added) {
247
- let c = 0, u = 0, k = 0;
248
- for (const role of roles) {
249
- const record = notice(added, role);
250
- if (!record)
251
- continue;
252
- const length = lengthOf(record);
253
- c += length;
254
- u += lineUnits(length);
255
- k++;
256
- }
257
- return { withNotices: rendering(chars + c, units + u, count + k), withoutNotices: rendering(chars, units, count) };
258
- },
259
- commit(added) {
260
- for (const role of roles) {
261
- const { removed, unlocated, taken } = omittedBy(added, role);
262
- role.total -= removed;
263
- role.unlocated -= unlocated;
264
- for (const [source, k] of taken) {
265
- const left = role.perSource.get(source) - k;
266
- role.perSource.set(source, left);
267
- if (left === 0)
268
- role.alive.remove(role.ids.get(source));
269
- }
270
- }
271
- },
272
- };
273
- };
@@ -1,42 +0,0 @@
1
- import type { NormalizedBlock, FileOps } from "../types.js";
2
- export interface BriefRankingOptions {
3
- /** Maximum normalized blocks used to build the brief transcript. */
4
- maxBlocks?: number;
5
- /** Optional complete serialized historical-view allowance. */
6
- maxBriefUnits?: number;
7
- /** Full sources rendered separately by the caller; retain their structural relationships. */
8
- mandatorySourceIndices?: ReadonlySet<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
- export interface RankedBlock {
36
- block: NormalizedBlock;
37
- index: number;
38
- score: number;
39
- reasons: string[];
40
- }
41
- export declare const rankBriefBlocks: (blocks: NormalizedBlock[], options?: BriefRankingOptions) => RankedBlock[];
42
- export declare const selectRankedBriefBlocks: (blocks: NormalizedBlock[], options?: BriefRankingOptions) => NormalizedBlock[];
@@ -1,292 +0,0 @@
1
- import { extractPath } from "./tool-args.js";
2
- import { compileBrief, planBrief, heredocCloseIndex, isSegmentClosingAssistant } from "./brief.js";
3
- import { createBriefMeter } from "./brief-meter.js";
4
- import { TRANSCRIPT_HEADER, transcriptUnits, MAX_RECEIPT_CHARACTERS } from "./transcript.js";
5
- const DEFAULT_MAX_BLOCKS = 80;
6
- const DEFAULT_RECENT_BLOCKS = 16;
7
- const EDIT_TOOL_RE = /^(edit|write|multiedit|quick_edit|target_edit|apply_patch)$/i;
8
- const READ_TOOL_RE = /^(read|glob|grep|ls|find|semantic_query|semantic_grep|semantic_show)$/i;
9
- const TEST_COMMAND_RE = /\b(?:bun|npm|pnpm|yarn|node|pytest|cargo|go|mvn|gradle)\b[^\n]*(?:test|spec|check|lint|build|tsc)/i;
10
- // Durable workflow facts: which PR/issue was acted on, and git state changes.
11
- // Structural (command shape), not prose — same spirit as TEST_COMMAND_RE.
12
- const WORKFLOW_COMMAND_RE = /(?:^|\s)(?:gh\s+(?:pr|issue)\s+[a-z-]+|git\s+(?:commit|push|merge|rebase|revert|cherry-pick|tag|reset|checkout|branch)\b)/i;
13
- const MIN_SEGMENT_CLOSING_ASSISTANT_CHARS = 120;
14
- // A bash block whose every meaningful line is pure scaffolding (set -e, cd,
15
- // export, ls, echo, sleep, pwd, comments, heredoc bodies) carries no durable
16
- // fact. Such blocks get a score penalty so the size-relative budget never
17
- // pulls them in ahead of real edits/commands when spare chars appear.
18
- const TRIVIAL_BASH_LINE_RE = /^(?:set\s+[-+]|cd(?:\s+\S+)?$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#|ls(?:\s|$)|echo\b|clear$|sleep\b)/;
19
- const TRIVIAL_BASH_PENALTY = 16;
20
- const isTrivialOnlyBash = (raw) => {
21
- const lines = raw.split("\n");
22
- const kept = [];
23
- for (let i = 0; i < lines.length; i++) {
24
- kept.push(lines[i]);
25
- // Skip heredoc bodies (they are content, not scaffolding) using the same
26
- // hardened detection as brief.ts: only skip when the terminator exists
27
- // downstream, so a stray `<<` never swallows a later real command.
28
- const close = heredocCloseIndex(lines, i);
29
- if (close !== -1)
30
- i = close;
31
- }
32
- const meaningful = kept.map((l) => l.trim()).filter(Boolean).filter((l) => !TRIVIAL_BASH_LINE_RE.test(l));
33
- return meaningful.length === 0;
34
- };
35
- const asPathSet = (paths) => new Set((paths ?? []).filter(Boolean));
36
- const bashCommandFromBlock = (block) => {
37
- if (block.kind === "bash")
38
- return block.command;
39
- if (block.kind === "tool_call" && /^bash$/i.test(block.name) && typeof block.args.command === "string") {
40
- return block.args.command;
41
- }
42
- return undefined;
43
- };
44
- const pathFromBlock = (block) => {
45
- if (block.kind === "tool_call")
46
- return extractPath(block.args) ?? undefined;
47
- if (block.kind === "bash") {
48
- const match = block.command.match(/(?:^|\s)([\w./-]+\.[\w-]+)(?:\s|$)/);
49
- return match?.[1];
50
- }
51
- return undefined;
52
- };
53
- const add = (ranked, points, reason) => {
54
- ranked.score += points;
55
- ranked.reasons.push(reason);
56
- };
57
- const scoreBlock = (block, index, total, modifiedFiles, readFiles) => {
58
- const ranked = { block, index, score: 0, reasons: [] };
59
- const recency = total <= 1 ? 0 : Math.round((index / (total - 1)) * 12);
60
- add(ranked, recency, "recency");
61
- if (block.kind === "user")
62
- add(ranked, 18, "user-turn");
63
- if (block.kind === "assistant")
64
- add(ranked, 10, "assistant-context");
65
- if (block.kind === "notice")
66
- add(ranked, 10, "framework-notice");
67
- if (block.kind === "tool_result")
68
- add(ranked, 1, "tool-result-low-value");
69
- if (block.kind === "tool_call") {
70
- const command = bashCommandFromBlock(block);
71
- if (EDIT_TOOL_RE.test(block.name))
72
- add(ranked, 34, "edit-tool");
73
- else if (command && TEST_COMMAND_RE.test(command))
74
- add(ranked, 26, "test-command");
75
- else if (READ_TOOL_RE.test(block.name))
76
- add(ranked, 6, "read-tool");
77
- else
78
- add(ranked, 12, "tool-call");
79
- if (command && WORKFLOW_COMMAND_RE.test(command))
80
- add(ranked, 14, "workflow-command");
81
- if (command && isTrivialOnlyBash(command))
82
- add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
83
- }
84
- if (block.kind === "bash") {
85
- add(ranked, 8, "bash");
86
- if (block.exitCode != null && block.exitCode !== 0)
87
- add(ranked, 24, "nonzero-exit");
88
- if (TEST_COMMAND_RE.test(block.command))
89
- add(ranked, 22, "test-command");
90
- if (WORKFLOW_COMMAND_RE.test(block.command))
91
- add(ranked, 14, "workflow-command");
92
- // Penalize scaffolding-only commands, but not ones that failed (a failed
93
- // `ls`/`cd` still records a real state fact via nonzero-exit).
94
- if (isTrivialOnlyBash(block.command) && !(block.exitCode != null && block.exitCode !== 0)) {
95
- add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
96
- }
97
- }
98
- const path = pathFromBlock(block);
99
- if (path) {
100
- if (modifiedFiles.has(path))
101
- add(ranked, 18, "hook-modified-file");
102
- if (readFiles.has(path))
103
- add(ranked, 6, "hook-read-file");
104
- }
105
- if (block.kind === "tool_result" && block.text.length > 1000)
106
- add(ranked, -8, "long-tool-result");
107
- return ranked;
108
- };
109
- const boostAdjacency = (ranked) => {
110
- const important = ranked
111
- .filter((r) => r.score >= 34 || r.reasons.includes("edit-tool") || r.reasons.includes("test-command") || r.reasons.includes("nonzero-exit"))
112
- .map((r) => r.index);
113
- for (const idx of important) {
114
- for (let i = idx - 1; i >= Math.max(0, idx - 8); i--) {
115
- if (ranked[i].block.kind === "user") {
116
- add(ranked[i], 10, "near-important-event");
117
- break;
118
- }
119
- }
120
- for (let i = idx - 1; i >= Math.max(0, idx - 4); i--) {
121
- if (ranked[i].block.kind === "assistant") {
122
- add(ranked[i], 7, "near-important-event");
123
- break;
124
- }
125
- }
126
- for (let i = idx + 1; i <= Math.min(ranked.length - 1, idx + 4); i++) {
127
- if (ranked[i].block.kind === "assistant" || ranked[i].block.kind === "bash") {
128
- add(ranked[i], 5, "after-important-event");
129
- break;
130
- }
131
- }
132
- }
133
- };
134
- const boostSegmentClosingAssistants = (ranked, blocks) => {
135
- for (const current of ranked) {
136
- if (current.block.kind !== "assistant")
137
- continue;
138
- if (current.block.text.trim().length < MIN_SEGMENT_CLOSING_ASSISTANT_CHARS)
139
- continue;
140
- if (isSegmentClosingAssistant(blocks, current.index))
141
- add(current, 14, "segment-closing-assistant");
142
- }
143
- };
144
- export const rankBriefBlocks = (blocks, options = {}) => {
145
- const modifiedFiles = asPathSet(options.fileOps?.modifiedFiles);
146
- const readFiles = asPathSet(options.fileOps?.readFiles);
147
- const ranked = blocks.map((block, index) => scoreBlock(block, index, blocks.length, modifiedFiles, readFiles));
148
- boostAdjacency(ranked);
149
- boostSegmentClosingAssistants(ranked, blocks);
150
- return ranked;
151
- };
152
- export const selectRankedBriefBlocks = (blocks, options = {}) => {
153
- // A rendered allowance owns capacity. An independent count must not discard
154
- // fitting original dialogue; all blocks were already normalized in memory.
155
- const maxBlocks = options.maxBlocks ?? (options.maxBriefChars != null ? blocks.length : DEFAULT_MAX_BLOCKS);
156
- // Size-relative budget: when a ceiling + slope are provided, the effective
157
- // char budget scales with transcript length (block count) between the floor
158
- // (maxBriefChars) and the ceiling. Larger transcripts carry more high-value
159
- // long-tail, so they earn more brief budget; small sessions stay at the floor.
160
- const maxBriefChars = options.maxBriefChars != null && options.maxBriefCharsCeiling != null && options.briefCharsPerBlock != null
161
- ? Math.round(Math.min(options.maxBriefCharsCeiling, Math.max(options.maxBriefChars, options.briefCharsPerBlock * blocks.length)))
162
- : options.maxBriefChars;
163
- // Fast path: nothing to trim by count and no char budget to enforce.
164
- if (blocks.length <= maxBlocks && maxBriefChars == null && options.maxBriefUnits == null)
165
- return blocks;
166
- const preserveRecentBlocks = Math.min(options.preserveRecentBlocks ?? DEFAULT_RECENT_BLOCKS, maxBlocks);
167
- const ranked = rankBriefBlocks(blocks, options);
168
- const selected = new Set();
169
- // Charge the actual candidate serialization. Adjacent-block truncation and
170
- // tool omission notices can change its size; isolated-block estimates cannot certify it.
171
- // The meter keeps that size current as blocks join; the final render below remains the authority.
172
- const excerpts = new WeakMap();
173
- const full = new Set();
174
- const meter = createBriefMeter(blocks, options.mandatorySourceIndices, excerpts);
175
- const within = (size) => (maxBriefChars == null || size.chars <= maxBriefChars) &&
176
- (options.maxBriefUnits == null || size.units <= options.maxBriefUnits);
177
- const admit = (indices, preferFull = false) => {
178
- const added = indices.filter(i => !selected.has(i));
179
- if (!added.length && !preferFull)
180
- return true;
181
- if (selected.size + added.length > maxBlocks)
182
- return false;
183
- const promoted = preferFull ? indices.filter(i => ["user", "assistant", "bash"].includes(blocks[i].kind) && !full.has(blocks[i])) : [];
184
- // Notices are best effort: never overflow or displace otherwise fitting text.
185
- const fits = () => { const { withNotices, withoutNotices } = meter.size(added); return within(withNotices) || within(withoutNotices); };
186
- for (const i of added)
187
- meter.select(i);
188
- for (const i of promoted)
189
- meter.promote(i);
190
- if (!fits()) {
191
- for (const i of promoted.splice(0))
192
- meter.demote(i);
193
- // A whole shell proposal that does not fit is withdrawn, not retried as an excerpt.
194
- if ((preferFull && indices.some(i => blocks[i].kind === "bash")) || !fits()) {
195
- for (const i of added)
196
- meter.unselect(i);
197
- return false;
198
- }
199
- }
200
- for (const i of added)
201
- selected.add(i);
202
- meter.commit(added);
203
- for (const i of promoted)
204
- full.add(blocks[i]);
205
- return true;
206
- };
207
- // Keep the latest blocks to preserve local continuity, iterating NEWEST first
208
- // so the most recent context is guaranteed. When a char budget is active these
209
- // are charged against it too and over-budget blocks are skipped -- otherwise a
210
- // run of large recent blocks could blow past maxBriefChars (the old bug on very
211
- // long transcripts, where preserve-recent alone exceeded the budget).
212
- for (let i = blocks.length - 1; i >= Math.max(0, blocks.length - preserveRecentBlocks); i--) {
213
- // Historical action overviews compete after the conversational base. Fresh
214
- // native rounds are protected by the adapter, outside this old-prefix view.
215
- if (["tool_result", "tool_call", "bash"].includes(blocks[i].kind))
216
- continue;
217
- if (selected.has(i))
218
- continue;
219
- if (!admit([i]))
220
- continue;
221
- }
222
- // Structural exchanges are derived from the original chronology. A user
223
- // carries prose from the preceding assistant message, including prose before
224
- // that message's trailing calls. Only explicit source identity crosses calls.
225
- const groups = ranked.filter(r => r.block.kind === "user").map(item => {
226
- let preceding = item.index - 1;
227
- while (preceding >= 0 && blocks[preceding].kind === "tool_result")
228
- preceding--;
229
- const previous = blocks[preceding];
230
- const prose = [];
231
- if (previous?.kind === "assistant" || previous?.kind === "tool_call") {
232
- const source = previous.sourceIndex;
233
- for (let i = preceding; i >= 0; i--) {
234
- if (source === undefined ? i !== preceding : blocks[i].sourceIndex !== source)
235
- break;
236
- if (blocks[i].kind === "assistant")
237
- prose.unshift(i);
238
- }
239
- }
240
- return [...prose, item.index];
241
- }).reverse();
242
- // Admit the conversational base before spending room on any full proposal.
243
- // Otherwise a newly fitting recent assistant can crowd out an older user.
244
- for (const group of groups) {
245
- if (!admit(group)) {
246
- // Bounded atomicity: an oversized pair must not exclude a useful user turn.
247
- admit([group.at(-1)]);
248
- }
249
- }
250
- const ordered = [...ranked].sort((a, b) => b.score - a.score || b.index - a.index);
251
- for (const item of ordered) {
252
- if (selected.size >= maxBlocks)
253
- break;
254
- if (selected.has(item.index))
255
- continue;
256
- if (item.block.kind === "tool_result")
257
- continue;
258
- if (!admit([item.index]))
259
- continue;
260
- }
261
- // Complete the existing coverage pass before expanding already represented
262
- // user wording. Whole proposals/choices are still eligible in spare room.
263
- for (const i of [...selected].sort((a, b) => b - a))
264
- if (blocks[i].kind === "user")
265
- admit([i], true);
266
- // Expand selected assistant prose only into spare capacity. A large recent
267
- // message must not consume the budget before user exchanges are considered.
268
- for (const i of [...selected].sort((a, b) => b - a))
269
- if (blocks[i].kind === "assistant")
270
- admit([i], true);
271
- // Cheap exact observations use only spare allowance after dialogue. Size
272
- // and recency are signals, not proof of usefulness, freshness or success.
273
- for (let i = blocks.length - 1; i >= 0; i--) {
274
- const block = blocks[i];
275
- if (block.kind === "tool_result" && block.originalReceipt)
276
- admit([i]);
277
- }
278
- // Enrich already retained shell events only after the unchanged base. Whole
279
- // stored output may be truncated; it is historical evidence, not verification.
280
- for (const i of [...selected].sort((a, b) => b - a)) {
281
- const block = blocks[i];
282
- if (block.kind === "bash" && block.sourceIndex !== undefined && block.output.length <= MAX_RECEIPT_CHARACTERS)
283
- admit([i], true);
284
- }
285
- const result = [...selected].sort((a, b) => a - b).map((i) => blocks[i]);
286
- planBrief(result, full, blocks, options.mandatorySourceIndices);
287
- const rendered = TRANSCRIPT_HEADER + "\n" + compileBrief(result, excerpts);
288
- if ((maxBriefChars != null && rendered.length > maxBriefChars) ||
289
- (options.maxBriefUnits != null && transcriptUnits(rendered) > options.maxBriefUnits))
290
- planBrief(result, full, blocks, options.mandatorySourceIndices, false);
291
- return result;
292
- };