pi-fold 4.0.0 → 4.1.1
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/README.md +22 -10
- package/extensions/active-context.ts +585 -134
- package/extensions/index.js +7 -2
- package/extensions/lib/brief-text.ts +116 -0
- package/extensions/lib/canonical.ts +3 -18
- package/extensions/lib/curation.ts +78 -39
- package/extensions/lib/editor-ui.ts +468 -47
- package/extensions/lib/folding.ts +20 -2
- package/extensions/lib/instrumentation.ts +3 -1
- package/extensions/lib/live-settings.ts +73 -0
- package/extensions/lib/persistence.ts +37 -3
- package/extensions/lib/policy.ts +55 -12
- package/extensions/lib/selection.ts +9 -44
- package/extensions/lib/status-widget.ts +175 -0
- package/extensions/lib/tool-surface.ts +0 -1
- package/extensions/lib/transcript.ts +43 -0
- package/extensions/settings.ts +104 -30
- package/package.json +5 -5
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
refsProtected,
|
|
32
32
|
} from "./measurement.ts";
|
|
33
33
|
import {
|
|
34
|
+
assertFoldForestValid,
|
|
34
35
|
childFoldIds,
|
|
35
36
|
clearPrepared,
|
|
36
37
|
flattenFoldRefs,
|
|
@@ -908,7 +909,7 @@ export function projectActiveContext(
|
|
|
908
909
|
snapshot: ActiveContextSnapshot,
|
|
909
910
|
state: ActiveContextState,
|
|
910
911
|
): unknown[] {
|
|
911
|
-
|
|
912
|
+
assertFoldForestValid(state.folds);
|
|
912
913
|
const replacements = new Map<number, { end: number; messages: unknown[] }>();
|
|
913
914
|
for (const root of orderedRoots(state, snapshot)) {
|
|
914
915
|
const rendered = renderFold(root.fold, state, snapshot);
|
|
@@ -921,7 +922,24 @@ export function projectActiveContext(
|
|
|
921
922
|
output.push(...replacement.messages);
|
|
922
923
|
index = replacement.end + 1;
|
|
923
924
|
} else {
|
|
924
|
-
|
|
925
|
+
// A PASS-THROUGH MESSAGE IS SHARED, NOT COPIED (gate 158, 2026-08-29). This used to
|
|
926
|
+
// push `clone(...)`, which was in the first commit of the runtime and never load
|
|
927
|
+
// bearing: `snapshot.messages` is ALREADY `clone(input.eventMessages)`
|
|
928
|
+
// (transcript.ts:531), against an array Pi itself structuredClones before it calls
|
|
929
|
+
// any handler, so the copy was a third generation guarding objects the host never
|
|
930
|
+
// sees. It cost 54.3ms of a 298.5ms projection, 18 percent, on a sealed 3,364
|
|
931
|
+
// message session, and it is the only remaining per-message cost of that size.
|
|
932
|
+
//
|
|
933
|
+
// What makes sharing indistinguishable from copying: NOTHING writes a field into a
|
|
934
|
+
// projected message at any depth. Every rewrite builds a fresh object and replaces
|
|
935
|
+
// an array SLOT, here in `applyToolClips` and in `withdrawConsumedEphemeralPeeks`
|
|
936
|
+
// and `carryWithdrawalIntoFreeze` one layer up, so a shared element is never the
|
|
937
|
+
// thing that gets edited. Gate 158 pins that by deep-freezing `snapshot.messages`
|
|
938
|
+
// and driving the readers against it, so a future in-place write throws in the
|
|
939
|
+
// suite instead of silently corrupting every later projection through the frozen
|
|
940
|
+
// prefix. Folded spans keep their own copies: line 796 and `renderFoldParts` still
|
|
941
|
+
// clone, because those objects are built for the projection rather than read from it.
|
|
942
|
+
output.push(snapshot.messages[index]);
|
|
925
943
|
index += 1;
|
|
926
944
|
}
|
|
927
945
|
}
|
|
@@ -94,10 +94,12 @@ export type ContextEventKind =
|
|
|
94
94
|
| "context.absorb"
|
|
95
95
|
| "context.recovery"
|
|
96
96
|
| "context.suspend"
|
|
97
|
+
| "context.settings"
|
|
97
98
|
| "context.rollback"
|
|
98
99
|
| "context.projection"
|
|
99
100
|
| "context.usage"
|
|
100
|
-
| "context.prefix"
|
|
101
|
+
| "context.prefix"
|
|
102
|
+
| "context.anchor";
|
|
101
103
|
|
|
102
104
|
export function prefixDivergence(
|
|
103
105
|
previous: string | null,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// THE CHANNEL FROM /fold-settings TO THE RUNNING RUNTIME.
|
|
2
|
+
//
|
|
3
|
+
// THE NEED, from a live session (2026-08-29). /fold-settings wrote the file and nothing
|
|
4
|
+
// else: `registerPiFold` resolves thresholds, toolFoldThreshold and the notice settings
|
|
5
|
+
// ONCE at registration, so a session kept the settings pi booted with while the screen showed
|
|
6
|
+
// what the person had just chosen. Shane's terminal held both at once, "Start folding at
|
|
7
|
+
// 40%" on the screen over "commit at 90%" on the status line, and neither surface said
|
|
8
|
+
// why. The file was the durable truth and the running session was not reading it.
|
|
9
|
+
//
|
|
10
|
+
// The two halves are registered independently, `registerFoldSettings(pi)` and
|
|
11
|
+
// `registerPiFold(pi, settings)`, in either order, and neither returns a handle the other
|
|
12
|
+
// can hold. What they do share is the HOST OBJECT, so that is the channel: the runtime
|
|
13
|
+
// hangs an applier off `pi` under a registry symbol, and the settings screen looks for it
|
|
14
|
+
// when a person saves an edit, which is long after both registrations have run. Late
|
|
15
|
+
// binding is what makes the order irrelevant.
|
|
16
|
+
//
|
|
17
|
+
// A MODULE-LEVEL SLOT WAS THE OTHER CANDIDATE AND IT IS WORSE: the applier would then be
|
|
18
|
+
// process-global, every gate that registers a runtime would leave one standing, and a
|
|
19
|
+
// screen in one host could push settings into a runtime in another. Keeping it on the
|
|
20
|
+
// host means an unregistered host simply has no applier, which is a state the screen can
|
|
21
|
+
// read and state honestly rather than a state it has to guess at.
|
|
22
|
+
//
|
|
23
|
+
// Symbol.for, not a fresh Symbol: the global registry keys by string, so a package loaded
|
|
24
|
+
// twice through different module caches (jiti in the suite, pi's own loader in the
|
|
25
|
+
// deployment) still agrees on the property.
|
|
26
|
+
|
|
27
|
+
import type { ActiveContextThresholds } from "./policy.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the settings screen hands the runtime: exactly the settings FILE's shape, where an
|
|
31
|
+
* absent field means the package default rather than "leave it alone". The runtime
|
|
32
|
+
* resolves it the same way it resolves its registration options, through one code path,
|
|
33
|
+
* so a value cannot mean one thing at boot and another at an edit.
|
|
34
|
+
*/
|
|
35
|
+
export interface LiveFoldSettings {
|
|
36
|
+
thresholds?: ActiveContextThresholds;
|
|
37
|
+
toolFoldThreshold?: number;
|
|
38
|
+
preCommitNotice?: boolean;
|
|
39
|
+
noticeLeadShare?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Refuses by throwing, exactly as registration does, and leaves the runtime untouched. */
|
|
43
|
+
export type LiveSettingsApplier = (settings: LiveFoldSettings, ctx?: unknown) => void;
|
|
44
|
+
|
|
45
|
+
const LIVE_SETTINGS = Symbol.for("pi-fold.live-settings");
|
|
46
|
+
|
|
47
|
+
export function publishLiveSettings(host: any, apply: LiveSettingsApplier): void {
|
|
48
|
+
// A HOST THAT REFUSES THE PROPERTY IS NOT A FAILURE. Nothing about folding depends on
|
|
49
|
+
// this channel: without it the file is still written and the next start still reads
|
|
50
|
+
// it, which is the behaviour every build before this one had.
|
|
51
|
+
try { host[LIVE_SETTINGS] = apply; } catch { }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether an edit saved now would reach a running runtime. The settings screen states
|
|
55
|
+
* which of the two it is instead of promising the one it cannot deliver. */
|
|
56
|
+
export function liveSettingsReachable(host: any): boolean {
|
|
57
|
+
try { return typeof host?.[LIVE_SETTINGS] === "function"; }
|
|
58
|
+
catch { return false; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Push settings into the runtime registered on this host. Returns false when there is
|
|
63
|
+
* none, and lets the applier's own refusal THROW: the caller decides what a refusal
|
|
64
|
+
* means, and swallowing it here would leave a screen believing it had applied a value
|
|
65
|
+
* the runtime rejected.
|
|
66
|
+
*/
|
|
67
|
+
export function applyLiveSettings(host: any, settings: LiveFoldSettings, ctx?: unknown): boolean {
|
|
68
|
+
let apply: LiveSettingsApplier | undefined;
|
|
69
|
+
try { apply = host?.[LIVE_SETTINGS]; } catch { return false; }
|
|
70
|
+
if (typeof apply !== "function") return false;
|
|
71
|
+
apply(settings, ctx);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
@@ -22,9 +22,6 @@ import {
|
|
|
22
22
|
EXPAND_LEASE_GENERATIONS,
|
|
23
23
|
MAX_EXPAND_LEASES,
|
|
24
24
|
MAX_PENDING_MARKS,
|
|
25
|
-
MEMORY_BODY_CHARS,
|
|
26
|
-
MEMORY_KEY_CHARS,
|
|
27
|
-
MEMORY_KEYS_MAX,
|
|
28
25
|
} from "./policy.ts";
|
|
29
26
|
import type {
|
|
30
27
|
ActiveContextCheckpointV2,
|
|
@@ -314,6 +311,43 @@ export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
|
|
|
314
311
|
return values;
|
|
315
312
|
}
|
|
316
313
|
|
|
314
|
+
// A FOREST THAT VALIDATED ONCE IS NOT VALIDATED AGAIN (gate 160, 2026-08-29). Profiling
|
|
315
|
+
// twelve projections of a sealed 681-fold session put 51.2 percent of ALL self time on one
|
|
316
|
+
// call path, and caller attribution found every serialization sample on it:
|
|
317
|
+
//
|
|
318
|
+
// projectActiveContext -> validateFoldForest -> sha256Value -> stableStringify -> safeJson
|
|
319
|
+
//
|
|
320
|
+
// The cost is this function's closing digest-drift loop, which flattens every fold's refs
|
|
321
|
+
// (for a consolidation parent, the union of all descendants) and hashes the array. The
|
|
322
|
+
// projection opens with `validateFoldForest(state.folds)` on EVERY context event and throws
|
|
323
|
+
// the result away: it wants the throw, not the normalized array. So it paid a full forest
|
|
324
|
+
// rehash per event to re-prove something it had already proven.
|
|
325
|
+
//
|
|
326
|
+
// THIS WRAPPER IS THE ONLY THING THAT CHANGES. `validateFoldForest` is untouched, still
|
|
327
|
+
// clones, still normalizes, still returns; the three callers that CONSUME its return value
|
|
328
|
+
// (parseActiveContextState, stateFromFoldRefs, commitPreparedFold) still call it directly
|
|
329
|
+
// and are unaffected. Each of those hands it a freshly built array anyway, so a memo could
|
|
330
|
+
// never have hit there; the projection is the one caller that reads the same array again
|
|
331
|
+
// and again, which is exactly why it is the one that pays.
|
|
332
|
+
//
|
|
333
|
+
// KEYED ON THE INPUT ARRAY, and sound because a state is rebuilt immutably: a changed
|
|
334
|
+
// forest is always a new array, and nothing anywhere mutates a folds array in place (no
|
|
335
|
+
// push, splice, sort, reverse or slot assignment on any `.folds` in extensions/). Nothing
|
|
336
|
+
// is shared out, because nothing is returned, so this carries none of gate 158's aliasing
|
|
337
|
+
// question.
|
|
338
|
+
//
|
|
339
|
+
// THE ONE COST, stated rather than hidden: a folds array mutated IN PLACE keeps its first
|
|
340
|
+
// verdict and a corruption introduced that way would go unreported. Gate 160 asserts that
|
|
341
|
+
// cost directly rather than pretending it away, and still requires a corrupt NEW forest to
|
|
342
|
+
// raise on first sight.
|
|
343
|
+
const VALIDATED_FORESTS = new WeakSet<readonly ActiveFold[]>();
|
|
344
|
+
|
|
345
|
+
export function assertFoldForestValid(folds: ActiveFold[]): void {
|
|
346
|
+
if (VALIDATED_FORESTS.has(folds)) return;
|
|
347
|
+
validateFoldForest(folds);
|
|
348
|
+
VALIDATED_FORESTS.add(folds);
|
|
349
|
+
}
|
|
350
|
+
|
|
317
351
|
export function parseActiveContextState(
|
|
318
352
|
value: unknown,
|
|
319
353
|
sessionId: string,
|
package/extensions/lib/policy.ts
CHANGED
|
@@ -34,14 +34,37 @@ export const ACTIVE_CONTEXT_STATE_ENTRY = `${DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_P
|
|
|
34
34
|
export const ACTIVE_CONTEXT_FOLD_RECORD_ENTRY = `${DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX}-fold-record`;
|
|
35
35
|
export const PROVIDER_CONTEXT_MEASUREMENT_ENTRY = `${DEFAULT_ENTRY_NAMESPACE}-provider-context-measurement`;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* THE NOTICE'S DEFAULT LEAD, as a share of the serving budget below the commit trigger
|
|
38
|
+
* (Shane, 2026-08-22: "around 10% from the commit threshold"; built 2026-08-30).
|
|
38
39
|
*
|
|
39
|
-
* A
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* A DISTANCE, NOT A POINT. Expressed as how far BELOW maxTarget the notice speaks, so it
|
|
41
|
+
* tracks a band that moves: a deployment that drops its trigger to 0.40 gets its notice
|
|
42
|
+
* at 0.30 without touching this, where an absolute 0.70 would have put the warning after
|
|
43
|
+
* the thing it warns about. The fire point is `maxTarget - noticeLeadShare`, floored at
|
|
44
|
+
* zero, so a lead wider than the trigger means "speak from the first measurement".
|
|
45
|
+
*
|
|
46
|
+
* IT REPLACED A COUNT. Until 2026-08-30 the notice fired at UNBRIEFED_FOLDS_BEFORE_NOTICE
|
|
47
|
+
* = 3 unbriefed pending folds, which is a batching size and not an occasion: the frontier
|
|
48
|
+
* cuts continuously and ungated by occupancy, so three folds stand within a few turns of
|
|
49
|
+
* a session's start and the notice spoke a long way from any commit. The share is the
|
|
50
|
+
* occasion, and the edge latch is what batches it: one notice per approach.
|
|
43
51
|
*/
|
|
44
|
-
export const
|
|
52
|
+
export const DEFAULT_NOTICE_LEAD_SHARE = 0.10;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The lead is a share of the serving budget, so it is bounded the way every other share
|
|
56
|
+
* on this surface is: strictly inside 0 and 1. Zero would mean the notice fires exactly
|
|
57
|
+
* at the trigger, which is the same pass the commit fires on and therefore never a
|
|
58
|
+
* warning; 1 and above is not a share.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveNoticeLeadShare(value: unknown): number {
|
|
61
|
+
if (value === undefined) return DEFAULT_NOTICE_LEAD_SHARE;
|
|
62
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value >= 1) {
|
|
63
|
+
throw new Error("noticeLeadShare must be a share above 0 and below 1: how far below the " +
|
|
64
|
+
"commit trigger, as a fraction of the serving budget, the notice speaks");
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
45
68
|
|
|
46
69
|
/**
|
|
47
70
|
* How many spans the frontier cuts on one projection pass.
|
|
@@ -287,15 +310,35 @@ export const MAX_CONTEXT_RECEIPTS = 3;
|
|
|
287
310
|
export const CONTEXT_RECEIPT_BLOCK_BYTES = 900;
|
|
288
311
|
|
|
289
312
|
/**
|
|
290
|
-
* The
|
|
313
|
+
* The pre-commit notice's bound, and its own rather than the receipt block's.
|
|
291
314
|
*
|
|
292
|
-
* It is larger because it carries a variable-length list the receipt does not:
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
315
|
+
* It is larger because it carries a variable-length list the receipt does not: the fixed
|
|
316
|
+
* status and verb block plus one entry per staged fold, and the pending set grows past
|
|
317
|
+
* MAX_FRONTIER_CUTS_PER_PASS whenever the frontier stages faster than a commit lands.
|
|
318
|
+
* 1,200 seated the old instruction and roughly eight folds; past the bound the list gives
|
|
319
|
+
* way and states how many it could not name.
|
|
297
320
|
*/
|
|
298
321
|
export const FOLD_NOTICE_BYTES = 2_400;
|
|
322
|
+
/**
|
|
323
|
+
* WHAT ONE /fold-editor ROW SUMMARIZES, and what one page of its detail serves
|
|
324
|
+
* (2026-08-30).
|
|
325
|
+
*
|
|
326
|
+
* The editor's whole reading of a message was 300 characters of its first text block,
|
|
327
|
+
* with a detail row that sliced to 480 and therefore could never bind. Two numbers replace
|
|
328
|
+
* it because they answer two different questions. EDITOR_ROW_PREVIEW_CHARS is a SUMMARY
|
|
329
|
+
* on a line that also carries a role, an id and a badge, so it is generous enough that a
|
|
330
|
+
* reader can tell two rows apart and no more. EDITOR_DETAIL_PAGE_CHARS is a READ, served
|
|
331
|
+
* a page at a time by an injected accessor with `total` and `hasMore` travelling beside
|
|
332
|
+
* it, so depth is bounded by the reader's patience rather than by a constant: there is no
|
|
333
|
+
* ceiling on what the editor can reach, only on what it hands over at once.
|
|
334
|
+
*
|
|
335
|
+
* THIS IS A READER'S BOUND, NOT A WINDOW'S. Nothing here reaches the projection: paging
|
|
336
|
+
* an entry to its end in the editor moves no bytes and the model never learns it
|
|
337
|
+
* happened. `maxSourceChars` 200,000 is the bound on a read that DOES reach the agent.
|
|
338
|
+
*/
|
|
339
|
+
export const EDITOR_ROW_PREVIEW_CHARS = 240;
|
|
340
|
+
export const EDITOR_DETAIL_PAGE_CHARS = 4_000;
|
|
341
|
+
|
|
299
342
|
export const CONTEXT_STATUS_RESPONSE_BYTES = 24_000;
|
|
300
343
|
export const MAX_UNMARKED_CANDIDATES = 3;
|
|
301
344
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { EvidenceRef } from "../json.ts";
|
|
2
|
+
import { boundedSubject, oneLine, seatSubjects, toolClipHead } from "./brief-text.ts";
|
|
3
|
+
export { oneLine, seatSubjects, toolClipHead };
|
|
2
4
|
import {
|
|
3
5
|
denseOwnArrayValues,
|
|
4
6
|
objectRefKey,
|
|
@@ -397,13 +399,6 @@ function assistantNoteText(message: unknown): string {
|
|
|
397
399
|
// "content continues in the exact source", and a reader never mistakes a cut
|
|
398
400
|
// value for a complete one. oneLine collapses whitespace then delegates;
|
|
399
401
|
// factualValue sanitizes then delegates through oneLine. No other site slices.
|
|
400
|
-
function boundedSubject(text: string, budget: number): string {
|
|
401
|
-
if (text.length <= budget) return text;
|
|
402
|
-
let kept = text.slice(0, Math.max(0, budget - 3)).trimEnd();
|
|
403
|
-
const finalCode = kept.charCodeAt(kept.length - 1);
|
|
404
|
-
if (finalCode >= 0xd800 && finalCode <= 0xdbff) kept = kept.slice(0, -1);
|
|
405
|
-
return `${kept}...`;
|
|
406
|
-
}
|
|
407
402
|
|
|
408
403
|
export function deterministicConsolidationBrief(
|
|
409
404
|
candidate: FoldCandidate,
|
|
@@ -467,29 +462,16 @@ export function deterministicConsolidationBrief(
|
|
|
467
462
|
const noteCount = notes.length;
|
|
468
463
|
const subjects = [...childSubjects, ...notes];
|
|
469
464
|
if (!subjects.length) return "Grouped completed context covering no readable folds.";
|
|
470
|
-
const separator = " | ";
|
|
471
465
|
const lead = noteCount
|
|
472
466
|
? `Grouped completed context covering ${foldCount} folds and ${noteCount} agent notes: `
|
|
473
467
|
: `Grouped completed context covering ${subjects.length} folds: `;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const budget = room - tail.length - separator.length * (named - 1);
|
|
482
|
-
const order = subjects.slice(0, named).map((text, index) => ({ text, index })).sort((a, b) =>
|
|
483
|
-
a.text.length - b.text.length || a.index - b.index);
|
|
484
|
-
const bounded = new Array<string>(named);
|
|
485
|
-
let left = budget;
|
|
486
|
-
for (let taken = 0; taken < order.length; taken += 1) {
|
|
487
|
-
const owed = Math.max(1, Math.floor(left / (order.length - taken)));
|
|
488
|
-
const kept = boundedSubject(order[taken].text, owed);
|
|
489
|
-
bounded[order[taken].index] = kept;
|
|
490
|
-
left -= kept.length;
|
|
491
|
-
}
|
|
492
|
-
return `${lead}${bounded.join(separator)}${tail}.`.replace(/\s+/g, " ").trim();
|
|
468
|
+
// With notes in the division, what the count-slice drops may be a note rather
|
|
469
|
+
// than a fold, so the tail only claims folds when folds are all it holds.
|
|
470
|
+
return seatSubjects(subjects, lead, {
|
|
471
|
+
total: ACTIVE_CONTEXT_POLICY.maxBriefChars,
|
|
472
|
+
minSubjectChars: MIN_SUBJECT_CHARS,
|
|
473
|
+
omittedNoun: noteCount ? "in this group" : "folds in this group",
|
|
474
|
+
});
|
|
493
475
|
}
|
|
494
476
|
|
|
495
477
|
export const ALL_FOLD_KINDS: ReadonlySet<FoldKind> =
|
|
@@ -1106,23 +1088,6 @@ export function manualFoldCandidate(
|
|
|
1106
1088
|
// blank line is kept whole up to the cap; a result that is bulk from line one keeps its
|
|
1107
1089
|
// first cap's worth. The caller states the cut, per gate 136: the marker rides beside
|
|
1108
1090
|
// the count of what it hides, never silently.
|
|
1109
|
-
export function toolClipHead(text: string, cap: number): string {
|
|
1110
|
-
const lines = String(text ?? "").split(/\r?\n/);
|
|
1111
|
-
const kept: string[] = [];
|
|
1112
|
-
for (const line of lines) {
|
|
1113
|
-
if (line.trim()) kept.push(line);
|
|
1114
|
-
else if (kept.length) break;
|
|
1115
|
-
}
|
|
1116
|
-
const paragraph = kept.join("\n");
|
|
1117
|
-
const head = paragraph.length > 0 && paragraph.length <= cap
|
|
1118
|
-
? paragraph
|
|
1119
|
-
: String(text ?? "").slice(0, cap);
|
|
1120
|
-
return head;
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
export function oneLine(value: string, maximum: number): string {
|
|
1124
|
-
return boundedSubject(value.replace(/\s+/g, " ").trim(), maximum);
|
|
1125
|
-
}
|
|
1126
1091
|
|
|
1127
1092
|
export function deterministicChapterBrief(
|
|
1128
1093
|
refs: EvidenceRef[],
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FOLD BAR: one widget row above the footer, drawn by us rather than by the host.
|
|
3
|
+
*
|
|
4
|
+
* The always-on status line is a STRING handed to `ctx.ui.setStatus`, and everything a
|
|
5
|
+
* bar would need is denied to a string: the host sorts statuses by key and joins them,
|
|
6
|
+
* collapses runs of spaces, truncates from the right, and bakes ANSI that goes stale on a
|
|
7
|
+
* theme switch. `ctx.ui.setWidget` hands us a component instead: `render(width)` receives
|
|
8
|
+
* the terminal width, the row is ours alone, and the theme is read at render time so a
|
|
9
|
+
* `/theme` switch repaints it. Placed below the editor it sits directly above the footer,
|
|
10
|
+
* which is where Claude Code draws the same bar.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THE BAR ARGUES: how full the window is against the two points where something
|
|
13
|
+
* happens to it. The fill is read against ticks ON the bar at the aim (minTarget) and the
|
|
14
|
+
* commit point (maxTarget). Inside the fill, oldest on the left: `▂` for the placeholders
|
|
15
|
+
* of standing folds, `▓` for staged raw spans (what the next commit collapses), `▇` for
|
|
16
|
+
* raw the ladder has not claimed. The label states what folding has already bought.
|
|
17
|
+
*
|
|
18
|
+
* THE BAR IS A FIXED WIDTH. A bar sized to the leftover width moves the commit tick
|
|
19
|
+
* between renders, and the tick is the landmark the whole row exists to place the fill
|
|
20
|
+
* against. What varies with the terminal is the label, cut from the right.
|
|
21
|
+
*
|
|
22
|
+
* WHAT IT WILL NOT DRAW: an unmeasured window (an empty bar is a guess drawn; law 2 of
|
|
23
|
+
* the human surfaces), a suspended session (no bar promising a commit beside FOLDING
|
|
24
|
+
* STOPPED; law 5), fold positions (a placeholder is a few hundred tokens against cells of
|
|
25
|
+
* tens of thousands), and fold depth, which /fold-editor shows with a row per fold.
|
|
26
|
+
*
|
|
27
|
+
* COLOUR: raw, staged and folded form an ordered ladder of how much of the window each
|
|
28
|
+
* state costs, so they are three classes sampled off Crameri's batlow, a perceptually
|
|
29
|
+
* uniform, colour-vision-deficiency-safe sequential map; on a 256-colour terminal they
|
|
30
|
+
* fall back to the theme's own names. Scaffolding (empty cells, the aim tick, the brand)
|
|
31
|
+
* stays on the theme's dim ink; the commit tick takes the theme's warning colour, which
|
|
32
|
+
* is what the host uses for its own context percentage past 70.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
36
|
+
|
|
37
|
+
export const FOLD_BAR_WIDTH = 40;
|
|
38
|
+
|
|
39
|
+
/** Everything the row reads. Built by the runtime on every status update. */
|
|
40
|
+
export interface FoldBarModel {
|
|
41
|
+
brand: string;
|
|
42
|
+
/** The provider's own count against the serving budget, or null when unmeasured. */
|
|
43
|
+
share: number | null;
|
|
44
|
+
/** maxTarget and minTarget of the live thresholds, as shares. */
|
|
45
|
+
commitShare: number;
|
|
46
|
+
aimShare: number;
|
|
47
|
+
/** Share of the budget standing marks would free at the next commit (estimated). */
|
|
48
|
+
stagedShare: number;
|
|
49
|
+
stagedMarks: number;
|
|
50
|
+
stagedTokens: number;
|
|
51
|
+
/** Share of the budget the standing folds' placeholders occupy (estimated). */
|
|
52
|
+
foldedShare: number;
|
|
53
|
+
folds: number;
|
|
54
|
+
/** Tokens the standing folds hide: source minus placeholder, summed over the forest. */
|
|
55
|
+
hiddenTokens: number;
|
|
56
|
+
/** Whether a band-top commit has already been weighed against the current count. */
|
|
57
|
+
weighed: boolean;
|
|
58
|
+
/** The suspension message when automatic folding has stopped, else null. */
|
|
59
|
+
stopped: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The subset of pi's Theme the row uses. */
|
|
63
|
+
export interface FoldBarTheme {
|
|
64
|
+
fg(color: "dim" | "muted" | "text" | "warning" | "error", text: string): string;
|
|
65
|
+
bold(text: string): string;
|
|
66
|
+
getColorMode?(): "truecolor" | "256color";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Crameri batlow10, classes 3, 6 and 8 (0-indexed). The two darkest classes are unusable
|
|
70
|
+
// as fill on a dark terminal, and the lightest washes out on a light one.
|
|
71
|
+
const BATLOW = { raw: "#3C6D56", staged: "#D29343", folded: "#FDB7BC" } as const;
|
|
72
|
+
|
|
73
|
+
const truecolor = (hex: string, text: string): string => {
|
|
74
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
75
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
76
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
77
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function formatTokens(count: number): string {
|
|
81
|
+
if (count < 1000) return String(count);
|
|
82
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
83
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
84
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Cell = "folded" | "staged" | "raw" | "empty";
|
|
88
|
+
// SCORED CELLS (Shane 2026-09-03): the README figure draws every cell as its own rectangle
|
|
89
|
+
// with a hairline gap, and the full-width block fused adjacent cells into one solid run,
|
|
90
|
+
// so the terminal read as a different figure. The left seven-eighths block leaves an
|
|
91
|
+
// eighth of each cell empty on the right, which is the same score in a glyph. Staged and
|
|
92
|
+
// raw share the glyph and differ by colour; the shade glyph is gone because at a small
|
|
93
|
+
// font its texture read as a solid block anyway.
|
|
94
|
+
const GLYPH: Record<Cell, string> = { folded: "▂", staged: "▉", raw: "▉", empty: "░" };
|
|
95
|
+
|
|
96
|
+
/** The bar's cells, oldest on the left, before any tick is laid over them. */
|
|
97
|
+
export function foldBarCells(model: FoldBarModel, width: number = FOLD_BAR_WIDTH): Cell[] {
|
|
98
|
+
const share = model.share ?? 0;
|
|
99
|
+
const filled = Math.max(0, Math.min(width, Math.round(share * width)));
|
|
100
|
+
const folded = Math.min(filled, Math.round(model.foldedShare * width));
|
|
101
|
+
const staged = Math.min(filled - folded, Math.round(model.stagedShare * width));
|
|
102
|
+
const cells: Cell[] = [];
|
|
103
|
+
for (let i = 0; i < width; i += 1) {
|
|
104
|
+
if (i < folded) cells.push("folded");
|
|
105
|
+
else if (i < folded + staged) cells.push("staged");
|
|
106
|
+
else if (i < filled) cells.push("raw");
|
|
107
|
+
else cells.push("empty");
|
|
108
|
+
}
|
|
109
|
+
return cells;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Tick positions: the cell whose right edge is the share. */
|
|
113
|
+
export function foldBarTicks(model: FoldBarModel, width: number = FOLD_BAR_WIDTH): Map<number, "aim" | "commit"> {
|
|
114
|
+
const at = (share: number): number => Math.max(0, Math.min(width - 1, Math.round(share * width) - 1));
|
|
115
|
+
const ticks = new Map<number, "aim" | "commit">();
|
|
116
|
+
ticks.set(at(model.aimShare), "aim");
|
|
117
|
+
ticks.set(at(model.commitShare), "commit");
|
|
118
|
+
return ticks;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The row without colour, for tests and for a host that strips ANSI. */
|
|
122
|
+
export function foldBarPlainText(model: FoldBarModel): string {
|
|
123
|
+
return renderFoldBar(model, Number.POSITIVE_INFINITY, {
|
|
124
|
+
fg: (_c, t) => t, bold: (t) => t,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBarTheme): string {
|
|
129
|
+
const brand = theme.fg("dim", model.brand);
|
|
130
|
+
if (model.stopped) {
|
|
131
|
+
const line = `${brand} ${theme.fg("error", theme.bold("FOLDING STOPPED"))}` +
|
|
132
|
+
(model.share === null ? "" : theme.fg("text", ` · ${Math.round(model.share * 100)}% full`));
|
|
133
|
+
return truncateToWidth(line, width, theme.fg("dim", "..."));
|
|
134
|
+
}
|
|
135
|
+
if (model.share === null) {
|
|
136
|
+
return truncateToWidth(
|
|
137
|
+
`${brand} ${theme.fg("muted", `not measured yet · folds automatically at ${Math.round(model.commitShare * 100)}%`)}`,
|
|
138
|
+
width, theme.fg("dim", "..."));
|
|
139
|
+
}
|
|
140
|
+
const rich = theme.getColorMode?.() === "truecolor";
|
|
141
|
+
const ink: Record<Cell, (text: string) => string> = {
|
|
142
|
+
raw: (t) => rich ? truecolor(BATLOW.raw, t) : theme.fg("muted", t),
|
|
143
|
+
staged: (t) => rich ? truecolor(BATLOW.staged, t) : theme.fg("text", t),
|
|
144
|
+
folded: (t) => rich ? truecolor(BATLOW.folded, t) : theme.fg("dim", t),
|
|
145
|
+
empty: (t) => theme.fg("dim", t),
|
|
146
|
+
};
|
|
147
|
+
const cells = foldBarCells(model);
|
|
148
|
+
const ticks = foldBarTicks(model);
|
|
149
|
+
let bar = "";
|
|
150
|
+
for (let i = 0; i < cells.length; i += 1) {
|
|
151
|
+
const tick = ticks.get(i);
|
|
152
|
+
if (tick === "commit") bar += theme.fg("warning", "│");
|
|
153
|
+
else if (tick === "aim") bar += theme.fg("dim", "│");
|
|
154
|
+
else bar += ink[cells[i]](GLYPH[cells[i]]);
|
|
155
|
+
}
|
|
156
|
+
const pct = Math.round(model.share * 100);
|
|
157
|
+
const parts: string[] = [theme.fg("text", `${pct}%`)];
|
|
158
|
+
if (model.share < model.commitShare) parts.push(theme.fg("muted", `commit at ${Math.round(model.commitShare * 100)}%`));
|
|
159
|
+
else if (model.weighed) parts.push(theme.fg("muted", "commit held"));
|
|
160
|
+
else parts.push(theme.fg("warning", "COMMIT DUE"));
|
|
161
|
+
// A staged count with nothing priced behind it reads "0 to free", which is a
|
|
162
|
+
// contradiction on the face of the row; state the count alone until the marks price.
|
|
163
|
+
if (model.stagedMarks > 0) {
|
|
164
|
+
parts.push(ink.staged(model.stagedTokens > 0
|
|
165
|
+
? `${model.stagedMarks} staged, ${formatTokens(model.stagedTokens)} to free`
|
|
166
|
+
: `${model.stagedMarks} staged`));
|
|
167
|
+
}
|
|
168
|
+
if (model.folds > 0) {
|
|
169
|
+
parts.push(ink.folded(`${model.folds} fold${model.folds === 1 ? "" : "s"} hold ${formatTokens(model.hiddenTokens)}`));
|
|
170
|
+
}
|
|
171
|
+
// NO BRAND IN FRONT OF THE BAR (Shane 2026-09-02): the bar identifies itself, and the
|
|
172
|
+
// two prose states above keep the brand because nothing else on them says whose they are.
|
|
173
|
+
const line = `${bar} ${parts.join(theme.fg("dim", " · "))}`;
|
|
174
|
+
return truncateToWidth(line, width, theme.fg("dim", "..."));
|
|
175
|
+
}
|
|
@@ -31,6 +31,49 @@ import type {
|
|
|
31
31
|
MappedMessage,
|
|
32
32
|
} from "./policy.ts";
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* ONE MESSAGE, AS THE TEXT A PERSON WOULD READ (2026-08-30).
|
|
36
|
+
*
|
|
37
|
+
* WHAT THIS REPLACED. `/fold-editor` built its entry previews inline, and the reading was
|
|
38
|
+
* `content.find((part) => part.type === "text")` sliced to 300 characters. So a message
|
|
39
|
+
* whose first block is not text previewed as the empty string, a tool result carrying its
|
|
40
|
+
* body in a later block previewed as its first block alone, and everything past 300
|
|
41
|
+
* characters was unreachable from the editor at any depth: the detail row sliced to 480,
|
|
42
|
+
* a bound the 300-character preview could never reach, so the deeper number was dead.
|
|
43
|
+
* Shane, 2026-08-30: "the amount of detail we can expand out to is a bit limited".
|
|
44
|
+
*
|
|
45
|
+
* EVERY BLOCK IS ACCOUNTED FOR, and the ones that are not text are NAMED rather than
|
|
46
|
+
* dropped. A window a person is auditing may not show them an empty row where an image or
|
|
47
|
+
* a tool call stands; naming it is how the row stays honest about what it could not
|
|
48
|
+
* render. The result is UNBOUNDED here on purpose. Bounding belongs to the caller, which
|
|
49
|
+
* knows whether it is drawing a one-line row or serving a paged read, and a cut applied
|
|
50
|
+
* here would be a cut nobody could state.
|
|
51
|
+
*/
|
|
52
|
+
export function entryText(message: unknown): string {
|
|
53
|
+
const content = ownValue(message, "content");
|
|
54
|
+
if (typeof content === "string") return content;
|
|
55
|
+
const parts = denseOwnArrayValues(content);
|
|
56
|
+
if (!parts) return "";
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
if (typeof part === "string") { out.push(part); continue; }
|
|
60
|
+
const type = ownValue(part, "type");
|
|
61
|
+
const text = ownValue(part, "text");
|
|
62
|
+
if (typeof text === "string" && text) { out.push(text); continue; }
|
|
63
|
+
if (type === "toolCall") {
|
|
64
|
+
const name = ownValue(part, "name");
|
|
65
|
+
// The ARGUMENTS are the content of a tool call, and they are what a person auditing
|
|
66
|
+
// a window needs to see: the call is why the result below it exists.
|
|
67
|
+
const args = ownValue(part, "arguments");
|
|
68
|
+
out.push(`[calls ${typeof name === "string" ? name : "a tool"}` +
|
|
69
|
+
`${args === undefined ? "" : ` ${stableStringify(args)}`}]`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
out.push(`[${typeof type === "string" && type ? type : "content"}]`);
|
|
73
|
+
}
|
|
74
|
+
return out.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
34
77
|
export function terminalAssistant(message: unknown): boolean {
|
|
35
78
|
if (messageRole(message) !== "assistant") return false;
|
|
36
79
|
const stop = ownValue(message, "stopReason");
|