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.
- package/CHANGELOG.md +43 -0
- package/README.md +8 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +7 -4
- package/dist/src/budget.d.ts +17 -4
- package/dist/src/budget.js +33 -10
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +4 -0
- package/dist/src/compaction.js +8 -5
- package/dist/src/diagnostics.d.ts +2 -1
- package/dist/src/omissions.d.ts +1 -1
- package/dist/src/omissions.js +8 -8
- package/dist/src/tuck/core/brief.d.ts +13 -43
- package/dist/src/tuck/core/brief.js +39 -255
- package/dist/src/tuck/core/format.d.ts +2 -6
- package/dist/src/tuck/core/format.js +4 -10
- package/dist/src/tuck/core/normalize.js +20 -43
- package/dist/src/tuck/core/shrink.d.ts +33 -0
- package/dist/src/tuck/core/shrink.js +221 -0
- package/dist/src/tuck/core/summarize.d.ts +15 -7
- package/dist/src/tuck/core/summarize.js +10 -23
- package/dist/src/tuck/core/transcript.d.ts +24 -13
- package/dist/src/tuck/core/transcript.js +243 -83
- package/dist/src/tuck/types.d.ts +4 -14
- package/dist/src/tuck.d.ts +8 -10
- package/dist/src/tuck.js +23 -25
- package/index.ts +8 -5
- package/package.json +1 -1
- package/src/budget.ts +29 -9
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +11 -5
- package/src/diagnostics.ts +1 -1
- package/src/omissions.ts +8 -8
- package/src/tuck/core/brief.ts +46 -265
- package/src/tuck/core/format.ts +4 -11
- package/src/tuck/core/normalize.ts +20 -43
- package/src/tuck/core/shrink.ts +218 -0
- package/src/tuck/core/summarize.ts +20 -25
- package/src/tuck/core/transcript.ts +234 -82
- package/src/tuck/types.ts +7 -19
- package/src/tuck.ts +30 -32
- package/dist/src/tuck/core/brief-meter.d.ts +0 -42
- package/dist/src/tuck/core/brief-meter.js +0 -273
- package/dist/src/tuck/core/rank.d.ts +0 -42
- package/dist/src/tuck/core/rank.js +0 -292
- package/src/tuck/core/brief-meter.ts +0 -235
- 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
|
-
|
|
30
|
-
|
|
31
|
-
| { kind: "
|
|
32
|
-
| { kind: "
|
|
33
|
-
| { kind: "
|
|
34
|
-
| { kind: "
|
|
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
|
|
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
|
|
19
|
-
export type DraftMode = "dialogue" | "
|
|
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
|
-
|
|
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;
|
|
35
|
-
|
|
36
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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}}
|
|
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) }]
|
|
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
|
-
|
|
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
|
-
//
|
|
225
|
-
|
|
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) +
|
|
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
|
|
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
|
|
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.
|
|
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" : "
|
|
260
|
-
return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "
|
|
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[];
|