pi-tuck 1.0.1 → 1.0.3

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 (48) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +8 -5
  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 +4 -0
  10. package/dist/src/compaction.js +8 -5
  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 +39 -255
  16. package/dist/src/tuck/core/format.d.ts +2 -6
  17. package/dist/src/tuck/core/format.js +4 -10
  18. package/dist/src/tuck/core/normalize.js +20 -43
  19. package/dist/src/tuck/core/shrink.d.ts +33 -0
  20. package/dist/src/tuck/core/shrink.js +221 -0
  21. package/dist/src/tuck/core/summarize.d.ts +15 -7
  22. package/dist/src/tuck/core/summarize.js +10 -23
  23. package/dist/src/tuck/core/transcript.d.ts +24 -13
  24. package/dist/src/tuck/core/transcript.js +243 -83
  25. package/dist/src/tuck/types.d.ts +4 -14
  26. package/dist/src/tuck.d.ts +8 -10
  27. package/dist/src/tuck.js +23 -25
  28. package/index.ts +8 -5
  29. package/package.json +1 -1
  30. package/src/budget.ts +29 -9
  31. package/src/build-id.ts +1 -1
  32. package/src/compaction.ts +11 -5
  33. package/src/diagnostics.ts +1 -1
  34. package/src/omissions.ts +8 -8
  35. package/src/tuck/core/brief.ts +46 -265
  36. package/src/tuck/core/format.ts +4 -11
  37. package/src/tuck/core/normalize.ts +20 -43
  38. package/src/tuck/core/shrink.ts +218 -0
  39. package/src/tuck/core/summarize.ts +20 -25
  40. package/src/tuck/core/transcript.ts +234 -82
  41. package/src/tuck/types.ts +7 -19
  42. package/src/tuck.ts +30 -32
  43. package/dist/src/tuck/core/brief-meter.d.ts +0 -42
  44. package/dist/src/tuck/core/brief-meter.js +0 -273
  45. package/dist/src/tuck/core/rank.d.ts +0 -42
  46. package/dist/src/tuck/core/rank.js +0 -292
  47. package/src/tuck/core/brief-meter.ts +0 -235
  48. package/src/tuck/core/rank.ts +0 -331
package/src/tuck/types.ts CHANGED
@@ -4,19 +4,6 @@ import type { convertToLlm } from "@earendil-works/pi-coding-agent";
4
4
  export type CompilerMessage = Parameters<typeof convertToLlm>[0][number];
5
5
 
6
6
 
7
- export interface FileOps {
8
- readFiles?: string[];
9
- modifiedFiles?: string[];
10
- createdFiles?: string[];
11
- }
12
-
13
- export interface ReceiptLink {
14
- status: "missing" | "ambiguous" | "error" | "non-error";
15
- sourceIndices: number[];
16
- /** Every original call/receipt contributing to the derived status. */
17
- dependencyIndices: number[];
18
- }
19
-
20
7
  export interface HistoricalReceipt {
21
8
  text: string;
22
9
  isError: boolean;
@@ -26,10 +13,11 @@ export interface HistoricalReceipt {
26
13
  );
27
14
  }
28
15
 
29
- export type NormalizedBlock = { sourceTime?: import("./core/source-time").SourceTime; sourceTimes?: Record<number, import("./core/source-time").SourceTime> } & (
30
- | { kind: "user"; text: string; originalText?: string; sourceIndex?: number }
31
- | { kind: "assistant"; text: string; originalText?: string; sourceIndex?: number }
32
- | { kind: "notice"; text: string; originalText?: string; sourceIndex?: number; sourceEntryId?: string }
33
- | { kind: "tool_call"; name: string; args: Record<string, unknown>; callId?: string; receipt?: ReceiptLink; sourceIndex?: number }
34
- | { kind: "tool_result"; name: string; text: string; sourceIndex?: number; recallExport?: true; originalReceipt?: HistoricalReceipt }
16
+ /** `startsSource`: the block's original text begins the message's readable text, so an offset into it is a tuck_recall offset into the source. */
17
+ export type NormalizedBlock = { sourceTimes?: Record<number, import("./core/source-time").SourceTime> } & (
18
+ | { kind: "user"; text: string; originalText?: string; sourceIndex?: number; startsSource?: true }
19
+ | { kind: "assistant"; text: string; originalText?: string; sourceIndex?: number; startsSource?: true }
20
+ | { kind: "notice"; text: string; originalText?: string; sourceIndex?: number; sourceEntryId?: string; startsSource?: true }
21
+ | { kind: "tool_call"; name: string; args: Record<string, unknown>; callId?: string; sourceIndex?: number }
22
+ | { kind: "tool_result"; name: string; text: string; sourceIndex?: number; recallExport?: true }
35
23
  | { kind: "bash"; command: string; output: string; exitCode: number | undefined; cancelled?: boolean; truncated?: boolean; sourceIndex?: number });
package/src/tuck.ts CHANGED
@@ -2,7 +2,7 @@ import { resolveSourceTime, type SourceTime } from "./tuck/core/source-time";
2
2
  import { orderByOriginalSource } from "./tuck/core/source-order";
3
3
  import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
4
4
  import { parseRecords, serializeTranscript, splitSummary, type TranscriptRecord } from "./tuck/core/transcript";
5
- import { DIALOGUE_SUFFIX, RANKED_SUFFIX } from "./tuck/core/format";
5
+ import { DIALOGUE_SUFFIX } from "./tuck/core/format";
6
6
  import { isTextOnly, textOf } from "./tuck/core/content";
7
7
  import { MAX_TASK_CONTRACT_CHARACTERS, actionItems, actionRecords, head, ledgerItems, ledgerRecords } from "./ledger.js";
8
8
  import type { Message } from "@earendil-works/pi-ai";
@@ -15,8 +15,8 @@ type AgentMessage = Parameters<typeof convertToLlm>[0][number];
15
15
  type Cut = import("./tuck/cut").OwnCutResult;
16
16
  /** Counts a projection of the conversation exactly as the triggers, the post-cut checks and the guard do (budget.ts). */
17
17
  export type Measure = (messages: AgentMessage[]) => number;
18
- /** The summary view that produced a draft: the whole pre-cut dialogue, or the ranked fallback. Both carry the file ledger. */
19
- export type DraftMode = "dialogue" | "ranked";
18
+ /** 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. */
19
+ export type DraftMode = "dialogue" | "shrunk";
20
20
  type Draft = { compaction: Pick<Extract<SessionEntry, { type: "compaction" }>, "summary" | "firstKeptEntryId" | "tokensBefore" | "details">; after: number; mode: DraftMode; unavailable?: never; capacityRejected?: never }
21
21
  | { compaction?: never; mode?: never; unavailable: string; capacityRejected?: boolean };
22
22
  interface SearchHit { index: number; role: string; summary: string }
@@ -29,12 +29,12 @@ export interface Tuck {
29
29
  applyTailBudget(entries: SessionEntry[], cut: Extract<Cut, { ok: true }>, options: { charsPerToken: number; maxTokens?: number; oversizedFactor?: number }): Cut;
30
30
  calibrateCharsPerToken(chars: number, tokens: number): { charsPerToken: number };
31
31
  buildGlobalIndexById(entries: readonly SessionEntry[]): Map<string, number>;
32
- compileRanked(input: { messages: AgentMessage[]; sourceIndices: (number | undefined)[]; sourceEntryIds?: readonly string[]; sourceTimes?: readonly SourceTime[];
32
+ /** The dialogue shrunk to `ranking.maxBriefChars`, oldest assistant text first; "" when not even the least of it fits. */
33
+ compileShrunkDialogue(input: { messages: AgentMessage[]; sourceIndices: (number | undefined)[]; sourceEntryIds?: readonly string[]; sourceTimes?: readonly SourceTime[];
33
34
  receiptContext?: { messages: AgentMessage[]; sourceIndices: (number | undefined)[] };
34
- ranking: { maxBriefChars: number; maxBriefCharsCeiling?: number; briefCharsPerBlock?: number; maxBriefUnits?: number;
35
- mandatorySourceIndices?: ReadonlySet<number> } }): string;
36
- /** The whole pre-cut dialogue view; "" offers none, and the ranked view is used. */
37
- compileDialogue(input: Parameters<Tuck["compileRanked"]>[0]): string;
35
+ ranking: { maxBriefChars: number; mandatorySourceIndices?: ReadonlySet<number> } }): string;
36
+ /** The whole pre-cut dialogue view; "" offers none, and the shrunk view is used. */
37
+ compileDialogue(input: Parameters<Tuck["compileShrunkDialogue"]>[0]): string;
38
38
  prepareKeywordQuery(query: string): PreparedKeywordQuery;
39
39
  searchEntriesDetailed(entries: SearchHit[], messages: Message[], query: string,
40
40
  tuning: { relativeFloor: number; cap: number }): {
@@ -44,20 +44,20 @@ export interface Tuck {
44
44
  import { buildOwnCut, applyTailBudget, MAX_TAIL_TOKENS, OVERSIZED_TAIL_FACTOR } from "./tuck/cut";
45
45
  import { calibrateCharsPerToken, estimateSourceMessageChars } from "./tuck/core/token-estimate";
46
46
  import { buildGlobalIndexById } from "./tuck/core/global-indices";
47
- import { compileRanked, compileDialogue } from "./tuck/core/summarize";
47
+ import { compileShrunkDialogue, compileDialogue } from "./tuck/core/summarize";
48
48
  import { prepareKeywordQuery } from "./tuck/core/keyword-query";
49
49
  import { searchEntriesDetailed } from "./tuck/core/search-entries";
50
- export const tuck: Tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById, compileRanked, compileDialogue, prepareKeywordQuery, searchEntriesDetailed };
50
+ export const tuck: Tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById, compileShrunkDialogue, compileDialogue, prepareKeywordQuery, searchEntriesDetailed };
51
51
  /** The one historical allowance: at most 25% of remaining estimated room, for both views.
52
52
  * The whole dialogue is offered only within it (plus actual preview/headroom guards);
53
- * otherwise the ranked view fills it. The 4-character conversion matches pinned Pi's
53
+ * otherwise the same dialogue is shrunk to fit it. The 4-character conversion matches pinned Pi's
54
54
  * summary estimator, not a provider-token guarantee. Mandatory sources (current input,
55
55
  * initiating prompt, file ledger) are already charged in base. */
56
56
  export function historicalAllowance(window: number, reserve: number, base: number, before: number): number {
57
57
  const free = Math.max(0, window - reserve - base - 1);
58
58
  return Math.max(0, Math.min(Math.floor(free / 4), before - base - 1)) * 4;
59
59
  }
60
- /** Uses pi-tuck's own cut, calibrated maximum tail and ranked compiler; Pi owns persistence and continuation. */
60
+ /** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
61
61
  export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false) {
62
62
  return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual }, ctx, reserveTokens, measure, policy, retainCurrentInput);
63
63
  }
@@ -81,9 +81,9 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
81
81
  const chars = (calibration.ok ? calibration.messages.reduce((n, m) => n + estimateSourceMessageChars(m), 0) : 0)
82
82
  + (input.previousSummary?.length ?? 0);
83
83
  const { charsPerToken } = tuck.calibrateCharsPerToken(chars, input.tokensBefore);
84
- // A preferred tail cannot consume more than the current input allowance.
85
- // Current/fresh-source floors below may still require an explicit rejection.
86
- const inputRoom = Math.max(0, (ctx.model?.contextWindow ?? 0) - reserveTokens - 1);
84
+ // A preferred tail takes at most half the current input allowance, so a cut leaves room to grow: a tail that fills the room lands the
85
+ // request just under the band and the next round cuts again. Current/fresh-source floors below may still require an explicit rejection.
86
+ const inputRoom = Math.floor(Math.max(0, (ctx.model?.contextWindow ?? 0) - reserveTokens - 1) / 2);
87
87
  const maxTokens = Math.min(MAX_TAIL_TOKENS, inputRoom);
88
88
  // Keep the current user turn native; older conversation competes in the
89
89
  // existing exact historical view instead of boosting a minimum native tail.
@@ -183,13 +183,13 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
183
183
  if (index === undefined) throw new Error("Missing initiating user source index");
184
184
  const quoted = head(text, MAX_TASK_CONTRACT_CHARACTERS), sourceTimes = [{ sourceIndex: index, ...resolveSourceTime(initiating) }];
185
185
  if (text.trim()) {
186
- mandatory.push({ kind: "excerpt", role: "user", sourceIndex: index, sourceTimes, text: quoted });
187
- if (quoted.length < text.length) mandatory.push({ kind: "omission", role: "user", sourceIndex: index, sourceTimes,
188
- reason: `Initiating prompt continues: characters ${quoted.length}-${text.length} omitted; read them with tuck_recall {"index":${index},"offset":${quoted.length}}` });
186
+ mandatory.push({ kind: "excerpt", role: "user", sourceIndex: index, text: quoted, sourceTimes });
187
+ if (quoted.length < text.length) mandatory.push({ kind: "omission", role: "user", sourceIndex: index,
188
+ reason: `Initiating prompt continues: characters ${quoted.length}-${text.length} omitted; read them with tuck_recall {"index":${index},"offset":${quoted.length}}`, sourceTimes });
189
189
  }
190
190
  }
191
191
  if (protectedUser?.type === "message" && protectedUser.message.role === "user")
192
- mandatory.push({ kind: "excerpt", role: "user", sourceIndex: currentIndex, sourceTimes: [{ sourceIndex: currentIndex!, ...resolveSourceTime(protectedUser) }], text: textOf(protectedUser.message.content) });
192
+ mandatory.push({ kind: "excerpt", role: "user", sourceIndex: currentIndex, text: textOf(protectedUser.message.content), sourceTimes: [{ sourceIndex: currentIndex!, ...resolveSourceTime(protectedUser) }] });
193
193
  // The file ledger and the action trail of the compacted history are mandatory, so they are charged in base like the quotes above.
194
194
  // A locator is given only where tuck_recall {index} resolves it: a unique global index of a readable entry.
195
195
  const compacted = entries.slice(0, boundary), resolves = (entry: SessionEntry) => readable(entry) !== undefined;
@@ -207,8 +207,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
207
207
  const model = ctx.model;
208
208
  const floorAt = (keep: number) => {
209
209
  const ledger = [...actionRecords(actions, keep), ...ledgerRecords(items, keep)];
210
- // Charged with the longer (ranked) closing, since the view is chosen later against this base.
211
- return { ledger, base: measure(preview(serializeTranscript([...mandatory, ...ledger]) + RANKED_SUFFIX)) };
210
+ return { ledger, base: measure(preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX)) };
212
211
  };
213
212
  const fits = (base: number) => Boolean(model) && base + reserveTokens < model!.contextWindow && base < before;
214
213
  // When the whole floor cannot fit, the trail and the ledger keep only their headings and counts; only that can refuse the cut.
@@ -221,13 +220,12 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
221
220
  const compileInput = { messages, sourceIndices, sourceEntryIds: originalCut.selectedIds, sourceTimes: originalCut.selectedIds.map(id => resolveSourceTime(entryById.get(id)!)), receiptContext,
222
221
  ranking: { maxBriefChars, mandatorySourceIndices: new Set(mandatory.flatMap(r => r.sourceIndex === undefined ? [] : [r.sourceIndex])) },
223
222
  };
224
- // All offers use the same parser, mandatory insertion, native preview and guard.
225
- // Each view ends with its own closing section, true for what that view holds.
226
- const assemble = (compiled: string, closing: string) => {
223
+ // Both views use the same parser, mandatory insertion, native preview and guard, and end with the same closing section.
224
+ const assemble = (compiled: string) => {
227
225
  if (typeof compiled !== "string") throw new Error("pi-tuck returned an invalid summary");
228
- const split = compiled ? splitSummary(compiled) : { body: "" };
226
+ const split = compiled ? splitSummary(compiled) : { body: "", version: 2 as const };
229
227
  if (!split) throw new Error("pi-tuck returned an invalid summary");
230
- const records = parseRecords(split.body);
228
+ const records = parseRecords(split.body, split.version);
231
229
  for (const record of mandatory) {
232
230
  const insertion = records.findIndex(row => row.sourceIndex !== undefined && row.sourceIndex > record.sourceIndex!);
233
231
  records.splice(insertion < 0 ? records.length : insertion, 0, record);
@@ -235,29 +233,29 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
235
233
  orderByOriginalSource(records, sourceIndices, originalCut.selectedIds);
236
234
  // The ledger always has its heading, so no summary is empty.
237
235
  records.push(...ledger);
238
- const summary = serializeTranscript(records) + closing;
236
+ const summary = serializeTranscript(records) + DIALOGUE_SUFFIX;
239
237
  const projection = preview(summary);
240
238
  assertNativeToolRounds(projection);
241
239
  return { summary, after: measure(projection) };
242
240
  };
243
241
  const free = Math.max(0, model.contextWindow - reserveTokens - base - 1);
244
242
  const headroom = Math.ceil(free / 4);
245
- // The whole pre-cut dialogue, when it fits the allowance; otherwise the ranked view fills the same allowance.
243
+ // The whole pre-cut dialogue, when it fits the allowance; otherwise the same dialogue shrunk to the same allowance.
246
244
  // With no allowance only an operator's manual cut can use it.
247
245
  const dialogue = maxBriefChars > 0 || input.operatorManual ? tuck.compileDialogue(compileInput) : "";
248
246
  const dialogueFits = dialogue.length <= maxBriefChars;
249
- const candidate = dialogue && (dialogueFits || input.operatorManual) ? assemble(dialogue, DIALOGUE_SUFFIX) : undefined;
247
+ const candidate = dialogue && (dialogueFits || input.operatorManual) ? assemble(dialogue) : undefined;
250
248
  const offer = candidate && dialogueFits && candidate.after < before && candidate.after + reserveTokens + headroom < model.contextWindow ? candidate : undefined;
251
249
  // Manual no-work preserves native originals; final request admission still owns full overhead.
252
250
  if (!offer && input.operatorManual && candidate && candidate.after >= before &&
253
251
  before + reserveTokens < model.contextWindow)
254
252
  return { unavailable: "Manual compaction preserved original dialogue: the complete-dialogue view does not reduce context" };
255
- const { summary, after } = offer ?? assemble(tuck.compileRanked(compileInput), RANKED_SUFFIX);
253
+ const { summary, after } = offer ?? assemble(tuck.compileShrunkDialogue(compileInput));
256
254
  // Draft reduction concerns conversation. Final request admission owns the effective system/tools.
257
255
  if (after + reserveTokens >= model.contextWindow || after >= before)
258
256
  return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow };
259
- const mode: DraftMode = offer ? "dialogue" : "ranked";
260
- return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "ranked", mode } }, after, mode };
257
+ const mode: DraftMode = offer ? "dialogue" : "shrunk";
258
+ return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "shrunk-dialogue", mode } }, after, mode };
261
259
  };
262
260
  const preferred = compileAtCut(cut);
263
261
  if (preferred.mode === "dialogue") return preferred;
@@ -1,42 +0,0 @@
1
- import type { NormalizedBlock } from "../types.js";
2
- import { type ExcerptCache } from "./brief.js";
3
- /** A set of integers in [0, size) with ordered neighbour queries, in a tree of 32-bit words. */
4
- export declare class IndexSet {
5
- private readonly levels;
6
- constructor(size: number);
7
- has(i: number): boolean;
8
- add(i: number): void;
9
- remove(i: number): void;
10
- /** The smallest member above `i`, or -1. */
11
- next(i: number): number;
12
- /** The largest member below `i`, or -1. */
13
- prev(i: number): number;
14
- }
15
- export interface BriefSize {
16
- chars: number;
17
- units: number;
18
- }
19
- export interface BriefMeter {
20
- /** Block `i` joins the selection, or leaves it again. */
21
- select(i: number): void;
22
- unselect(i: number): void;
23
- /** Block `i` is rendered whole instead of as an excerpt, or no longer. */
24
- promote(i: number): void;
25
- demote(i: number): void;
26
- /** The rendered size of the selection, were the unselected blocks `added` selected too, with and without the omission notices. */
27
- size(added: readonly number[]): {
28
- withNotices: BriefSize;
29
- withoutNotices: BriefSize;
30
- };
31
- /** Settles the omission notices for blocks selected for good. */
32
- commit(added: readonly number[]): void;
33
- }
34
- /**
35
- * The size of `TRANSCRIPT_HEADER + "\n" + compileBrief(selected)` as blocks join, kept up to date by recomputing only the records a change
36
- * can affect, which are those of the block, its neighbours and the identical calls merged with them. This is the admission loop's yardstick,
37
- * so it must equal what the full render measures; the final render in `selectRankedBriefBlocks` remains the authority.
38
- *
39
- * It relies on what `normalize` guarantees for the blocks of one brief: they are distinct objects; all carry the same `sourceTimes` or none
40
- * does; and notices that share a `sourceEntryId` share a `sourceTime`.
41
- */
42
- export declare const createBriefMeter: (blocks: NormalizedBlock[], mandatory: ReadonlySet<number> | undefined, cache: ExcerptCache) => BriefMeter;
@@ -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[];