pi-tuck 1.0.16 → 1.0.18

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/CHANGELOG.md +75 -0
  2. package/README.md +7 -4
  3. package/dist/src/budget.d.ts +14 -17
  4. package/dist/src/budget.js +22 -29
  5. package/dist/src/build-id.d.ts +1 -1
  6. package/dist/src/build-id.js +1 -1
  7. package/dist/src/compaction.js +113 -20
  8. package/dist/src/diagnostics.d.ts +1 -1
  9. package/dist/src/history-tools.d.ts +16 -0
  10. package/dist/src/history-tools.js +59 -0
  11. package/dist/src/ledger.d.ts +0 -2
  12. package/dist/src/ledger.js +0 -2
  13. package/dist/src/omissions.d.ts +1 -1
  14. package/dist/src/omissions.js +88 -6
  15. package/dist/src/tuck/core/brief.d.ts +6 -3
  16. package/dist/src/tuck/core/brief.js +27 -9
  17. package/dist/src/tuck/core/filter-noise.d.ts +1 -1
  18. package/dist/src/tuck/core/filter-noise.js +1 -3
  19. package/dist/src/tuck/core/normalize.d.ts +2 -5
  20. package/dist/src/tuck/core/normalize.js +15 -33
  21. package/dist/src/tuck/core/shrink.d.ts +6 -14
  22. package/dist/src/tuck/core/shrink.js +162 -102
  23. package/dist/src/tuck/core/summarize.d.ts +3 -14
  24. package/dist/src/tuck/core/summarize.js +9 -20
  25. package/dist/src/tuck/core/transcript.d.ts +5 -0
  26. package/dist/src/tuck/core/transcript.js +27 -4
  27. package/dist/src/tuck/types.d.ts +1 -1
  28. package/dist/src/tuck.d.ts +9 -17
  29. package/dist/src/tuck.js +384 -212
  30. package/package.json +1 -1
  31. package/src/budget.ts +24 -26
  32. package/src/build-id.ts +1 -1
  33. package/src/compaction.ts +102 -21
  34. package/src/history-tools.ts +57 -0
  35. package/src/ledger.ts +0 -2
  36. package/src/omissions.ts +64 -7
  37. package/src/tuck/core/brief.ts +25 -10
  38. package/src/tuck/core/filter-noise.ts +1 -3
  39. package/src/tuck/core/normalize.ts +11 -27
  40. package/src/tuck/core/shrink.ts +141 -91
  41. package/src/tuck/core/summarize.ts +9 -24
  42. package/src/tuck/core/transcript.ts +26 -4
  43. package/src/tuck/types.ts +3 -3
  44. package/src/tuck.ts +336 -185
@@ -1,4 +1,4 @@
1
- import { blockRecords, decorateRecord, isDialogue, isOmittable, omissionRecord } from "./brief.js";
1
+ import { blockRecords, decorateRecord, isDialogue, isOmittable, omissionRecord, userPrefixRecords } from "./brief.js";
2
2
  import { DIALOGUE_SUFFIX } from "./format.js";
3
3
  import { orderByOriginalSource } from "./source-order.js";
4
4
  import { serializeRecord, serializeTranscript, TRANSCRIPT_HEADER } from "./transcript.js";
@@ -6,10 +6,9 @@ const ROLES = ["user", "assistant", "notice"];
6
6
  /** The lossy variants of a unit, least clipped first: the words kept from the start of its text and from its end. */
7
7
  const TIERS = {
8
8
  assistant: [[80, 120], [40, 40], [16, 0]],
9
- user: [[256, 0], [64, 0], [24, 0]],
9
+ user: [],
10
10
  notice: [[256, 0]],
11
11
  };
12
- const TOOLS_OMITTED = { kind: "omission", reason: "Historical tool actions and observations omitted; read original sources with tuck_recall" };
13
12
  const rendered = (records) => ({ records, cost: records.reduce((n, r) => n + serializeRecord(r).length + 1, 0) });
14
13
  const WHOLE = { drop: 0, tier: 0, end: 0 };
15
14
  /** The units of one role in chronological order, with each variant's cost summed over every prefix. */
@@ -23,7 +22,26 @@ const createLane = (role, units, mandatory, decorate) => {
23
22
  // nothing and its unit is counted in the role's one omission notice, like a dropped unit: a notice per empty clip would cost more than text.
24
23
  const NONE = { records: [], cost: 0 };
25
24
  const emptied = [new Uint8Array(n)];
26
- const emptyCounts = [new Int32Array(n + 1)];
25
+ const counts = new Int32Array(n + 1), unknown = new Int32Array(n + 1);
26
+ const minimum = new Float64Array(n + 1).fill(Infinity), maximum = new Float64Array(n + 1).fill(-Infinity);
27
+ const firstBySource = new Map(), lastBySource = new Map();
28
+ let monotone = true, previousSource = -1;
29
+ units.forEach((block, at) => {
30
+ const selected = isOmittable(block, mandatory), source = block.sourceIndex;
31
+ counts[at + 1] = counts[at] + Number(selected);
32
+ unknown[at + 1] = unknown[at] + Number(selected && source === undefined);
33
+ minimum[at + 1] = Math.min(minimum[at], selected && source !== undefined ? source : Infinity);
34
+ maximum[at + 1] = Math.max(maximum[at], selected && source !== undefined ? source : -Infinity);
35
+ if (source !== undefined) {
36
+ if (source < previousSource)
37
+ monotone = false;
38
+ previousSource = source;
39
+ if (!firstBySource.has(source))
40
+ firstBySource.set(source, at);
41
+ lastBySource.set(source, at);
42
+ }
43
+ });
44
+ const tallies = [];
27
45
  /** Level t holds each unit's tier-t variant. A clip that would not make the unit smaller is not taken, so sizes never grow with t. */
28
46
  const level = (t) => {
29
47
  while (levels.length <= t) {
@@ -41,65 +59,76 @@ const createLane = (role, units, mandatory, decorate) => {
41
59
  }
42
60
  return clipped.cost < previous[i].cost ? clipped : previous[i];
43
61
  });
44
- const counts = new Int32Array(n + 1);
45
- empty.forEach((e, i) => counts[i + 1] = counts[i] + e);
62
+ const tally = { count: new Int32Array(n + 1), unknown: new Int32Array(n + 1),
63
+ minSuffix: new Float64Array(n + 1).fill(Infinity), maxSuffix: new Float64Array(n + 1).fill(-Infinity), partial: new Int32Array(n + 1) };
64
+ units.forEach((block, at) => {
65
+ tally.count[at + 1] = tally.count[at] + empty[at];
66
+ tally.unknown[at + 1] = tally.unknown[at] + Number(Boolean(empty[at]) && block.sourceIndex === undefined);
67
+ tally.partial[at + 1] = tally.partial[at] + Number(isOmittable(block, mandatory) && block.sourceIndex !== undefined &&
68
+ next[at].records.some(record => record.kind === "excerpt") && next[at].records.some(record => record.kind === "omission"));
69
+ });
70
+ for (let at = n - 1; at >= 0; at--) {
71
+ const source = empty[at] ? units[at].sourceIndex : undefined;
72
+ tally.minSuffix[at] = Math.min(tally.minSuffix[at + 1], source ?? Infinity);
73
+ tally.maxSuffix[at] = Math.max(tally.maxSuffix[at + 1], source ?? -Infinity);
74
+ }
75
+ tallies[levels.length] = tally;
46
76
  levels.push(next);
47
77
  sums.push(prefix(next));
48
78
  emptied.push(empty);
49
- emptyCounts.push(counts);
50
79
  }
51
80
  return levels[t];
52
81
  };
53
82
  const sum = (t) => { level(t); return sums[t]; };
54
- // The notice for a dropped prefix of p units: how many unselected text blocks, the first locators, and whether some have none.
55
- let tally;
56
- /** The notice for the units a setting omits: the dropped prefix, and the clips in [drop, end) that kept no text. */
57
- const omission = ({ drop, tier, end }) => {
83
+ /** Prefix and clip tallies price every setting in O(1) for chronological
84
+ * session indices. Rendering a candidate never walks its dropped prefix. */
85
+ const omission = ({ drop, tier, end, take }) => {
58
86
  if (role === "notice")
59
87
  return undefined;
60
- if (!tally) {
61
- tally = { counts: new Int32Array(n + 1), unlocated: new Uint8Array(n + 1), smallest: new Array(n + 1) };
62
- let smallest = [], count = 0, unlocated = 0;
63
- tally.smallest[0] = smallest;
64
- units.forEach((block, i) => {
65
- if (isOmittable(block, mandatory)) {
66
- count++;
67
- const source = block.sourceIndex;
68
- if (source === undefined)
69
- unlocated = 1;
70
- else if (!smallest.includes(source) && (smallest.length < 9 || source < smallest[8])) {
71
- smallest = [...smallest, source].sort((a, b) => a - b).slice(0, 9);
88
+ if (tier > 0)
89
+ level(tier);
90
+ let count = counts[drop], unlocated = unknown[drop] > 0;
91
+ let min = minimum[drop], max = maximum[drop];
92
+ if (end > drop && tier > 0) {
93
+ const tally = tallies[tier];
94
+ count += tally.count[end] - tally.count[drop];
95
+ unlocated ||= tally.unknown[end] > tally.unknown[drop];
96
+ if (end === n) {
97
+ min = Math.min(min, tally.minSuffix[drop]);
98
+ max = Math.max(max, tally.maxSuffix[drop]);
99
+ }
100
+ else
101
+ for (let at = drop; at < end; at++)
102
+ if (emptied[tier][at] && units[at].sourceIndex !== undefined) {
103
+ min = Math.min(min, units[at].sourceIndex);
104
+ max = Math.max(max, units[at].sourceIndex);
72
105
  }
106
+ if (Number.isFinite(min)) {
107
+ if (monotone) {
108
+ const from = Math.max(drop, firstBySource.get(min)), through = Math.min(end, lastBySource.get(max) + 1);
109
+ if (through > from)
110
+ count += tally.partial[through] - tally.partial[from];
73
111
  }
74
- tally.counts[i + 1] = count;
75
- tally.unlocated[i + 1] = unlocated;
76
- tally.smallest[i + 1] = smallest;
77
- });
78
- }
79
- let count = tally.counts[drop], unlocated = tally.unlocated[drop] === 1, sources = tally.smallest[drop];
80
- if (end > drop && tier > 0) {
81
- level(tier);
82
- const empty = emptied[tier], counts = emptyCounts[tier];
83
- count += counts[end] - counts[drop];
84
- if (counts[end] > counts[drop]) {
85
- // Text blocks of one message share a locator: collect distinct ones, enough to tell whether the shown list is complete.
86
- const extra = new Set();
87
- for (let i = drop; i < end && (extra.size < 9 || !unlocated); i++)
88
- if (empty[i]) {
89
- const source = units[i].sourceIndex;
90
- if (source === undefined)
91
- unlocated = true;
92
- else if (extra.size < 9)
93
- extra.add(source);
112
+ else
113
+ for (let at = drop; at < end; at++) {
114
+ const source = units[at].sourceIndex, records = levels[tier][at].records;
115
+ if (source !== undefined && source >= min && source <= max && isOmittable(units[at], mandatory) &&
116
+ records.some(record => record.kind === "excerpt") && records.some(record => record.kind === "omission"))
117
+ count++;
94
118
  }
95
- sources = [...new Set([...sources, ...extra])].sort((a, b) => a - b).slice(0, 9);
96
119
  }
97
120
  }
98
- return count ? decorate(omissionRecord(role, count, sources, unlocated)) : undefined;
121
+ if (take !== undefined && Number.isFinite(min)) {
122
+ const block = units[drop], source = block.sourceIndex;
123
+ if (source !== undefined && source >= min && source <= max && isOmittable(block, mandatory) &&
124
+ userPrefixRecords(block, mandatory, take).some(record => record.kind === "omission"))
125
+ count++;
126
+ }
127
+ return count ? decorate(omissionRecord(role, count, Number.isFinite(min) ? [min, max] : [], unlocated)) : undefined;
99
128
  };
100
129
  const omissionCosts = new Map();
101
130
  const omissionCost = (setting) => {
102
- const key = `${setting.drop}:${setting.tier}:${setting.end}`;
131
+ const key = `${setting.drop}:${setting.tier}:${setting.end}:${setting.take ?? ""}`;
103
132
  let cost = omissionCosts.get(key);
104
133
  if (cost === undefined) {
105
134
  const record = omission(setting);
@@ -107,20 +136,25 @@ const createLane = (role, units, mandatory, decorate) => {
107
136
  }
108
137
  return cost;
109
138
  };
139
+ const prefixAt = (at, take) => rendered(userPrefixRecords(units[at], mandatory, take).map(decorate));
110
140
  return {
111
- n, level, sum, omission,
112
- cost: ({ drop, tier, end }) => sums[0][n] - sums[0][Math.max(drop, end)] + (end > drop ? sum(tier)[end] - sum(tier)[drop] : 0) + omissionCost({ drop, tier, end }),
141
+ n, level, sum, omission, prefixAt,
142
+ cost: ({ drop, tier, end, take }) => take !== undefined
143
+ ? sums[0][n] - sums[0][drop + 1] + prefixAt(drop, take).cost + omissionCost({ drop, tier: 0, end: 0, take })
144
+ : sums[0][n] - sums[0][Math.max(drop, end)] + (end > drop ? sum(tier)[end] - sum(tier)[drop] : 0) + omissionCost({ drop, tier, end }),
113
145
  };
114
146
  };
115
147
  /** The units of a dialogue and what any configuration of them renders, by arithmetic over per-unit lengths or as the summary text. */
116
- const createDialogue = (input) => {
148
+ const prepareDialogue = (input) => {
117
149
  const dialogue = input.blocks.filter(isDialogue);
118
150
  const times = dialogue.find(block => block.sourceTimes)?.sourceTimes;
119
151
  const decorate = (record) => decorateRecord(record, times);
120
152
  const lanes = Object.fromEntries(ROLES.map(role => [role, createLane(role, dialogue.filter(block => block.kind === role), input.mandatorySourceIndices, decorate)]));
121
153
  const seen = { user: 0, assistant: 0, notice: 0 };
122
154
  const order = dialogue.map(block => ({ role: block.kind, i: seen[block.kind]++ }));
123
- const tools = input.blocks.length > dialogue.length ? rendered([TOOLS_OMITTED]) : undefined;
155
+ const receipts = input.blocks.filter(block => block.kind === "tool_result" &&
156
+ (block.sourceIndex === undefined || !input.mandatorySourceIndices?.has(block.sourceIndex)));
157
+ const tools = receipts.length ? rendered([omissionRecord("toolResult", receipts.length, [...new Set(receipts.flatMap(block => block.sourceIndex === undefined ? [] : [block.sourceIndex]))], receipts.some(block => block.sourceIndex === undefined))]) : undefined;
124
158
  const overhead = TRANSCRIPT_HEADER.length + DIALOGUE_SUFFIX.length;
125
159
  /** The length of the summary text of a configuration: its header, one line per record, and the closing section; 0 when it has no record. */
126
160
  const size = (config) => {
@@ -132,7 +166,8 @@ const createDialogue = (input) => {
132
166
  for (const { role, i } of order) {
133
167
  const { drop, tier, end } = config[role];
134
168
  if (i >= drop)
135
- records.push(...lanes[role].level(i < end ? tier : 0)[i].records);
169
+ records.push(...(i === drop && config[role].take !== undefined
170
+ ? lanes[role].prefixAt(i, config[role].take).records : lanes[role].level(i < end ? tier : 0)[i].records));
136
171
  }
137
172
  for (const role of ROLES) {
138
173
  const record = lanes[role].omission(config[role]);
@@ -146,73 +181,98 @@ const createDialogue = (input) => {
146
181
  };
147
182
  return { lanes, size, text };
148
183
  };
184
+ const dialogues = new WeakMap();
185
+ const createDialogue = (input) => {
186
+ let dialogue = dialogues.get(input);
187
+ if (!dialogue)
188
+ dialogues.set(input, dialogue = prepareDialogue(input));
189
+ return dialogue;
190
+ };
149
191
  /** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted. "" when it renders nothing. */
150
192
  export const wholeDialogue = (input) => {
151
193
  const { text } = createDialogue(input);
152
194
  return text({ user: WHOLE, assistant: WHOLE, notice: WHOLE });
153
195
  };
154
- /**
155
- * The same dialogue shrunk until its summary text is at most `limit` characters, oldest first. The first configuration that fits wins:
156
- * A. the newest k assistant units whole, the older ones clipped to tier 1, 2, 3 (the largest k that fits, for each tier in turn);
157
- * B. the newest assistant units that fit a third of the limit whole, the older ones at tier 3 with the oldest d dropped;
158
- * C. the newest assistant units that fit a third of the limit at tier 3, the rest dropped; then the oldest u user units clipped to tier 1, 2, 3;
159
- * D. every user unit at tier 3, the oldest dropped;
160
- * E. the oldest notices dropped, then the oldest of the remaining assistant units.
161
- * Every step is a one-parameter family whose size is read from prefix sums of the per-unit lengths, so a step costs O(units). A notice is clipped
162
- * to its one tier in every configuration but the whole dialogue. The size is arithmetic over unit lengths; the rendered text has the last say:
163
- * a configuration whose text is over the limit is passed over.
164
- */
196
+ /** Lower ranks of the single fill: the existing recent assistant third, then
197
+ * more user turns newest first, then the remaining dialogue newest first.
198
+ * Each lane grows only from its recent end; complete passages remain atomic. */
165
199
  export const shrinkDialogue = (input, limit) => {
166
200
  const { lanes, size, text } = createDialogue(input);
167
201
  const { user, assistant, notice } = lanes;
168
- const clipped = { drop: 0, tier: 1, end: notice.n };
169
- const found = (step, config) => {
170
- const length = size(config);
171
- if (length > limit)
172
- return undefined;
202
+ const gone = (lane) => ({ drop: lane.n, tier: 0, end: 0 });
203
+ const config = { user: gone(user), assistant: gone(assistant), notice: gone(notice) };
204
+ const result = (step) => {
173
205
  const summary = text(config);
174
- return summary.length <= limit ? { text: summary, step, size: length } : undefined;
206
+ return summary.length <= limit ? { text: summary, step, size: summary.length } : { text: "", step: "none", size: 0 };
175
207
  };
176
- const gone = (lane) => ({ drop: lane.n, tier: 3, end: lane.n });
177
- const tiers = [1, 2, 3];
178
- let hit = found("whole", { user: WHOLE, assistant: WHOLE, notice: WHOLE });
179
- if (hit)
180
- return hit;
181
- for (const t of tiers)
182
- for (let k = assistant.n; k >= 0; k--)
183
- if (hit = found(`A${t}`, { user: WHOLE, assistant: { drop: 0, tier: t, end: assistant.n - k }, notice: clipped }))
184
- return hit;
185
- // The newest assistant units that fit a third of the limit whole are kept until users have given way: B clips and drops only older ones.
186
- // A clip can keep no text at all, so the third is sized on whole units; when the newest alone is over the third, it keeps its largest clip
187
- // that fits and still has text.
208
+ if (size({ user: WHOLE, assistant: WHOLE, notice: WHOLE }) <= limit) {
209
+ Object.assign(config, { user: WHOLE, assistant: WHOLE, notice: WHOLE });
210
+ return result("whole");
211
+ }
212
+ if (size(config) > limit)
213
+ return { text: "", step: "none", size: 0 };
214
+ // Preserve exactly shrinkDialogue's recent-third definition: newest whole
215
+ // units that fit a third, or the newest unit's largest nonempty passage clip.
188
216
  let kept = 0;
189
217
  while (kept < assistant.n && assistant.sum(0)[assistant.n] - assistant.sum(0)[kept] > limit / 3)
190
218
  kept++;
191
- let recent = { drop: kept, tier: 3, end: kept };
219
+ let recent = { drop: kept, tier: 0, end: 0 };
192
220
  if (kept === assistant.n && assistant.n > 0)
193
- for (const t of tiers) {
194
- const cost = assistant.level(t)[assistant.n - 1].cost;
221
+ for (const tier of [1, 2, 3]) {
222
+ const cost = assistant.level(tier)[assistant.n - 1].cost;
195
223
  if (cost > 0 && cost <= limit / 3) {
196
- recent = { drop: assistant.n - 1, tier: t, end: assistant.n };
224
+ recent = { drop: assistant.n - 1, tier, end: assistant.n };
197
225
  break;
198
226
  }
199
227
  }
200
- for (let d = 0; d <= recent.drop; d++)
201
- if (hit = found("B", { user: WHOLE, assistant: { ...recent, drop: d }, notice: clipped }))
202
- return hit;
203
- // From here users give way, and the assistant keeps only that recent third.
204
- for (const t of tiers)
205
- for (let u = 0; u <= user.n; u++)
206
- if (hit = found(`C${t}`, { user: { drop: 0, tier: t, end: u }, assistant: recent, notice: clipped }))
207
- return hit;
208
- for (let d = 0; d <= user.n; d++)
209
- if (hit = found("D", { user: { drop: d, tier: 3, end: user.n }, assistant: recent, notice: clipped }))
210
- return hit;
211
- for (let d = 0; d <= notice.n; d++)
212
- if (hit = found("E1", { user: gone(user), assistant: recent, notice: { drop: d, tier: 1, end: notice.n } }))
213
- return hit;
214
- for (let d = recent.drop; d <= assistant.n; d++)
215
- if (hit = found("E2", { user: gone(user), assistant: { ...recent, drop: d }, notice: gone(notice) }))
216
- return hit;
217
- return { text: "", step: "none", size: 0 };
228
+ const trySetting = (role, setting) => {
229
+ const previous = config[role];
230
+ config[role] = setting;
231
+ if (size(config) <= limit)
232
+ return true;
233
+ config[role] = previous;
234
+ return false;
235
+ };
236
+ if (!trySetting("assistant", recent)) {
237
+ for (let drop = recent.drop + 1; drop <= assistant.n; drop++)
238
+ if (trySetting("assistant", { ...recent, drop }))
239
+ break;
240
+ }
241
+ // The user lane is filled before older assistant units. If its next whole
242
+ // unit cannot fit, keep its largest exact prefix that fits, then continue
243
+ // with the lower ranks. No fixed task/head character cap participates.
244
+ for (let drop = user.n - 1; drop >= 0; drop--) {
245
+ if (trySetting("user", { drop, tier: 0, end: 0 }))
246
+ continue;
247
+ const block = input.blocks.filter(isDialogue).filter(block => block.kind === "user")[drop];
248
+ if (block.kind === "user" && block.image)
249
+ break;
250
+ let low = 0, high = (block.originalText ?? block.text).length - 1;
251
+ while (low < high) {
252
+ const take = Math.ceil((low + high) / 2);
253
+ if (trySetting("user", { drop, tier: 0, end: 0, take }))
254
+ low = take;
255
+ else
256
+ high = take - 1;
257
+ }
258
+ if (low > 0)
259
+ trySetting("user", { drop, tier: 0, end: 0, take: low });
260
+ break;
261
+ }
262
+ for (let drop = config.assistant.drop - 1; drop >= 0; drop--) {
263
+ if (trySetting("assistant", { drop, tier: 0, end: 0 }))
264
+ continue;
265
+ for (const tier of [1, 2, 3])
266
+ if (assistant.level(tier)[drop].records.some(record => record.kind === "excerpt") &&
267
+ trySetting("assistant", { drop, tier, end: Math.max(drop + 1, recent.end) }))
268
+ break;
269
+ break;
270
+ }
271
+ for (let drop = notice.n - 1; drop >= 0; drop--) {
272
+ if (trySetting("notice", { drop, tier: 0, end: 0 }))
273
+ continue;
274
+ trySetting("notice", { drop, tier: 1, end: drop + 1 });
275
+ break;
276
+ }
277
+ return result("ranked");
218
278
  };
@@ -1,11 +1,6 @@
1
1
  import type { CompilerMessage } from "../types.js";
2
2
  export interface CompileInput {
3
3
  messages: CompilerMessage[];
4
- /** Eligible original branch only: receipt identity and order. */
5
- receiptContext?: {
6
- messages: CompilerMessage[];
7
- sourceIndices: Array<number | undefined>;
8
- };
9
4
  /** Native session-global source indices parallel to messages. */
10
5
  sourceIndices?: Array<number | undefined>;
11
6
  /** Actual native entry IDs parallel to originals, not inferred from text. */
@@ -18,12 +13,6 @@ export interface CompileInput {
18
13
  mandatorySourceIndices?: ReadonlySet<number>;
19
14
  };
20
15
  }
21
- /** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted.
22
- * This disposable view grants neither present authority nor native tool execution. */
23
- export declare const compileDialogue: (input: CompileInput) => string;
24
- /** The dialogue shrunk to `ranking.maxBriefChars`, oldest assistant text first (see `shrinkDialogue`); "" when nothing fits. */
25
- export declare const compileShrunkDialogue: (input: CompileInput & {
26
- ranking: {
27
- maxBriefChars: number;
28
- };
29
- }) => string;
16
+ /** Compile the lower ranks once under their remaining serialized amount.
17
+ * `step` describes the result for diagnostics; it does not select another plan. */
18
+ export declare const compileDialogue: (input: CompileInput) => import("./shrink.js").Shrunk;
@@ -1,22 +1,11 @@
1
1
  import { normalize } from "./normalize.js";
2
2
  import { filterNoise } from "./filter-noise.js";
3
- import { shrinkDialogue, wholeDialogue } from "./shrink.js";
4
- // A cut offers the whole dialogue and then, when it does not fit, the shrunk one, from the same input: normalize it once. The blocks are
5
- // never mutated, and keeping the same objects also keeps their cached passage analyses.
6
- const inputs = new WeakMap();
7
- const dialogueInput = (input) => {
8
- let prepared = inputs.get(input);
9
- if (!prepared)
10
- inputs.set(input, prepared = {
11
- blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.receiptContext, input.sourceEntryIds, input.sourceTimes)),
12
- mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
13
- sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
14
- sourceEntryIds: input.sourceEntryIds,
15
- });
16
- return prepared;
17
- };
18
- /** The whole pre-cut dialogue: user, assistant and notice records; tool actions and observations are omitted.
19
- * This disposable view grants neither present authority nor native tool execution. */
20
- export const compileDialogue = (input) => wholeDialogue(dialogueInput(input));
21
- /** The dialogue shrunk to `ranking.maxBriefChars`, oldest assistant text first (see `shrinkDialogue`); "" when nothing fits. */
22
- export const compileShrunkDialogue = (input) => shrinkDialogue(dialogueInput(input), input.ranking.maxBriefChars).text;
3
+ import { shrinkDialogue } from "./shrink.js";
4
+ /** Compile the lower ranks once under their remaining serialized amount.
5
+ * `step` describes the result for diagnostics; it does not select another plan. */
6
+ export const compileDialogue = (input) => shrinkDialogue({
7
+ blocks: filterNoise(normalize(input.messages, input.sourceIndices, input.sourceEntryIds, input.sourceTimes)),
8
+ mandatorySourceIndices: input.ranking?.mandatorySourceIndices,
9
+ sourceIndices: input.sourceIndices ?? input.messages.map((_, index) => index),
10
+ sourceEntryIds: input.sourceEntryIds,
11
+ }, input.ranking?.maxBriefChars ?? Infinity);
@@ -13,6 +13,11 @@ interface RecordFields {
13
13
  role?: "user" | "assistant" | "custom" | "bashExecution" | "toolResult";
14
14
  sourceIndex?: number;
15
15
  sourceIndices?: number[];
16
+ /** Inclusive original message-index bounds of a counted omission. */
17
+ sourceRange?: {
18
+ start: number;
19
+ end: number;
20
+ };
16
21
  text?: string;
17
22
  span?: {
18
23
  coordinateSpace: "sanitized-block";
@@ -15,6 +15,13 @@ export const smallOriginalArguments = (value, maxCharacters = MAX_PRODUCER_ARGUM
15
15
  return false;
16
16
  }
17
17
  };
18
+ const validSourceRange = (value) => {
19
+ if (!value || typeof value !== "object" || Array.isArray(value))
20
+ return false;
21
+ const range = value;
22
+ return Object.keys(range).length === 2 && Object.keys(range).every(key => key === "start" || key === "end") &&
23
+ Number.isSafeInteger(range.start) && range.start >= 0 && Number.isSafeInteger(range.end) && range.start <= range.end;
24
+ };
18
25
  /** The format this build writes. */
19
26
  export const TRANSCRIPT_HEADER = "TUCK-TRANSCRIPT/2";
20
27
  /** The format of 1.0.x summaries: one JSON object per line. Read, never written. */
@@ -48,15 +55,19 @@ const LEGACY_RECEIPT_RANGE_NOTICE = RECEIPT_RANGE_NOTICE.replace("tuck_recall",
48
55
  * record = [ locator " " ] role [ " " id ] [ " " time ] [ " " span ] ":" ... excerpt
49
56
  * | [ locator " " ] [ role [ " " id ] " " ] "omitted" [ " " time ] [ " " span ] ":" ... omission of part of one source,
50
57
  * or, with no role, of the tool history
58
+ * | role " omitted " count " #" index "-#" index ":" ... counted inclusive index range
51
59
  * | role " omitted " count [ " " locator-time { " " locator-time } ] ":" ... omission of unselected text blocks
52
60
  * | [ locator " " ] title ":" ... section (file ledger, action trail)
53
61
  * locator = "#" index the source's session-wide index, as tuck_recall {"index"} takes it
54
62
  * locator-time = locator [ "@" time-value ] every locator of a record has a time, or none does
55
- * role = "user" | "assistant" | "custom"
63
+ * role = "user" | "assistant" | "custom" | "toolResult" (omissions only)
56
64
  * id = a JSON string the native entry id of a custom notice (role "custom" only)
57
65
  * time = "@" time-value the recorded time of the locator that precedes the role (a record that has one)
58
66
  * time-value = UTC | "journal:" UTC | "unknown" UTC is the message time, "journal:" UTC the session file's time of the entry
59
67
  * span = "[chars " start "-" end " of " total "]" the characters of the source text block the text covers or the omission leaves out
68
+ * // New grouped omissions carry a count and inclusive index bounds. The count
69
+ * // is dropped units of that role inside the bounds, not the width of the range.
70
+ * // Older per-source pointer lists and generic tool notices remain readable.
60
71
  * count = index how many text blocks the omission counts
61
72
  * title = capital letter, then letters, digits, "_" and "-"
62
73
  * index = "0" | nonzero digit, then digits a safe integer, no sign, no leading zero
@@ -120,12 +131,18 @@ export const serializeRecord = (record) => {
120
131
  if (record.kind !== "excerpt" && record.kind !== "omission")
121
132
  return refuse(record);
122
133
  const { role, sourceIndex, sourceEntryId, span } = record, omission = record.kind === "omission";
123
- if (role !== undefined && !["user", "assistant", "custom"].includes(role) || role === undefined && (!omission || sourceEntryId !== undefined) ||
134
+ if (role !== undefined && !["user", "assistant", "custom", ...(omission ? ["toolResult"] : [])].includes(role) || role === undefined && (!omission || sourceEntryId !== undefined) ||
124
135
  sourceEntryId !== undefined && (role !== "custom" || typeof sourceEntryId !== "string"))
125
136
  refuse(record);
126
137
  const who = (sourceEntryId === undefined ? "" : ` ${JSON.stringify(sourceEntryId)}`);
127
138
  if (omission && record.count !== undefined) {
128
- only("kind", "role", "count", "sourceIndices", "reason", "sourceTimes");
139
+ only("kind", "role", "count", "sourceIndices", "sourceRange", "reason", "sourceTimes");
140
+ if (record.sourceRange !== undefined) {
141
+ const range = record.sourceRange;
142
+ if (!validSourceRange(range) || record.sourceIndices !== undefined || record.sourceTimes !== undefined || role === undefined || typeof record.reason !== "string")
143
+ refuse(record);
144
+ return line(`${role} omitted ${index(record.count)} #${index(range.start)}-#${index(range.end)}`, record.reason);
145
+ }
129
146
  const locators = record.sourceIndices ?? [], stamped = times(locators);
130
147
  if (role === undefined || typeof record.reason !== "string")
131
148
  refuse(record);
@@ -148,8 +165,9 @@ const unescapeText = (text) => text.replace(/\\([\\nr])?/g, (_, c) => c === unde
148
165
  // Sticky patterns: each is matched at the position the line has been read up to.
149
166
  const INDEX = "(0|[1-9]\\d*)";
150
167
  const TIME = "(unknown|(?:journal:)?\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z)";
151
- const LOCATOR = new RegExp(`#${INDEX} `, "y"), ROLE = /user|assistant|custom/y, OMITTED = /omitted/y;
168
+ const LOCATOR = new RegExp(`#${INDEX} `, "y"), ROLE = /user|assistant|custom|toolResult/y, OMITTED = /omitted/y;
152
169
  const ID = / ("(?:[^"\\\u0000-\u001f]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*")/y, SPACE_OMITTED = / omitted/y, COUNT = new RegExp(` ${INDEX}`, "y");
170
+ const INDEX_RANGE = new RegExp(` #${INDEX}-#${INDEX}`, "y");
153
171
  const LOCATOR_TIME = new RegExp(` #${INDEX}(?:@${TIME})?`, "y"), TIME_TOKEN = new RegExp(` @${TIME}`, "y");
154
172
  const SPAN = new RegExp(` \\[chars ${INDEX}-${INDEX} of ${INDEX}\\]`, "y"), TITLE_TOKEN = /[A-Z][A-Za-z0-9_-]*/y;
155
173
  /** One line of TUCK-TRANSCRIPT/2, as the record the build that wrote it held (its fields in the order that build emits them). */
@@ -185,6 +203,9 @@ const parseLine = (line) => {
185
203
  record = { kind: "section", title, text, ...common };
186
204
  }
187
205
  else if (count) {
206
+ const range = take(INDEX_RANGE);
207
+ if (range)
208
+ return { kind: "omission", role, count: integer(count[1]), sourceRange: { start: integer(range[1]), end: integer(range[2]) }, reason: end() };
188
209
  const items = [];
189
210
  for (let item; (item = take(LOCATOR_TIME));)
190
211
  items.push({ sourceIndex: integer(item[1]), ...(item[2] !== undefined ? { time: timeOf(item[2]) } : {}) });
@@ -223,6 +244,8 @@ const validate = (r) => {
223
244
  (v.sourceIndex !== undefined && !integer(v.sourceIndex)) ||
224
245
  (v.sourceEntryId !== undefined && (typeof v.sourceEntryId !== "string" || !v.sourceEntryId || v.sourceEntryId.length > 200 ||
225
246
  v.role !== "custom" || !["excerpt", "omission"].includes(v.kind))) ||
247
+ (v.sourceRange !== undefined && (v.kind !== "omission" || v.count === undefined || v.sourceIndices !== undefined || v.sourceTimes !== undefined ||
248
+ v.role === undefined || !validSourceRange(v.sourceRange))) ||
226
249
  (v.sourceIndices !== undefined && (!Array.isArray(v.sourceIndices) || !v.sourceIndices.every(integer))) ||
227
250
  (v.kind !== "omission" && typeof v.text !== "string") ||
228
251
  (v.kind === "omission" && typeof v.reason !== "string") ||
@@ -21,6 +21,7 @@ export type NormalizedBlock = {
21
21
  sourceTimes?: Record<number, import("./core/source-time.js").SourceTime>;
22
22
  } & ({
23
23
  kind: "user";
24
+ image?: true;
24
25
  text: string;
25
26
  originalText?: string;
26
27
  sourceIndex?: number;
@@ -47,7 +48,6 @@ export type NormalizedBlock = {
47
48
  kind: "tool_result";
48
49
  name: string;
49
50
  sourceIndex?: number;
50
- recallExport?: true;
51
51
  } | {
52
52
  kind: "bash";
53
53
  sourceIndex?: number;
@@ -5,8 +5,11 @@ import { type RecallPolicy, type PreparedKeywordQuery, type RecordedAttempt, typ
5
5
  type AgentMessage = Parameters<typeof convertToLlm>[0][number];
6
6
  type Cut = import("./tuck/cut.js").OwnCutResult;
7
7
  /** Counts a projection of the conversation exactly as the triggers, the post-cut checks and the guard do (budget.ts). */
8
- export type Measure = (messages: AgentMessage[]) => number;
9
- /** The summary view that produced a draft: the whole pre-cut dialogue, or the same dialogue shrunk to the allowance. Both carry the file ledger. */
8
+ export type Measure = ((messages: AgentMessage[]) => number) & {
9
+ /** The same per-message estimate, including any temporary native result clip. */
10
+ messageTokens?: (message: AgentMessage) => number;
11
+ };
12
+ /** Diagnostic description of the single ranked compiler result. Never a decision input. */
10
13
  export type DraftMode = "dialogue" | "shrunk";
11
14
  type Draft = {
12
15
  compaction: Pick<Extract<SessionEntry, {
@@ -49,23 +52,17 @@ export interface Tuck {
49
52
  charsPerToken: number;
50
53
  };
51
54
  buildGlobalIndexById(entries: readonly SessionEntry[]): Map<string, number>;
52
- /** The dialogue shrunk to `ranking.maxBriefChars`, oldest assistant text first; "" when not even the least of it fits. */
53
- compileShrunkDialogue(input: {
55
+ /** Fill the lower ranks once within the remaining serialized amount. */
56
+ compileDialogue(input: {
54
57
  messages: AgentMessage[];
55
58
  sourceIndices: (number | undefined)[];
56
59
  sourceEntryIds?: readonly string[];
57
60
  sourceTimes?: readonly SourceTime[];
58
- receiptContext?: {
59
- messages: AgentMessage[];
60
- sourceIndices: (number | undefined)[];
61
- };
62
61
  ranking: {
63
62
  maxBriefChars: number;
64
63
  mandatorySourceIndices?: ReadonlySet<number>;
65
64
  };
66
- }): string;
67
- /** The whole pre-cut dialogue view; "" offers none, and the shrunk view is used. */
68
- compileDialogue(input: Parameters<Tuck["compileShrunkDialogue"]>[0]): string;
65
+ }): ReturnType<typeof compileDialogue>;
69
66
  prepareKeywordQuery(query: string): PreparedKeywordQuery;
70
67
  searchEntriesDetailed(entries: SearchHit[], messages: Message[], query: string, tuning: {
71
68
  relativeFloor: number;
@@ -74,13 +71,8 @@ export interface Tuck {
74
71
  hits: SearchHit[];
75
72
  };
76
73
  }
74
+ import { compileDialogue } from "./tuck/core/summarize.js";
77
75
  export declare const tuck: Tuck;
78
- /** The one historical allowance: at most 25% of remaining estimated room, for both views.
79
- * The whole dialogue is offered only within it (plus actual preview/headroom guards);
80
- * otherwise the same dialogue is shrunk to fit it. The 4-character conversion matches pinned Pi's
81
- * summary estimator, not a provider-token guarantee. Mandatory sources (current input,
82
- * initiating prompt, file ledger) are already charged in base. */
83
- export declare function historicalAllowance(window: number, reserve: number, base: number, before: number): number;
84
76
  /** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
85
77
  export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean): Draft;
86
78
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */