pi-zip 0.2.8 → 0.3.0-rc.2
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 +72 -42
- package/package.json +1 -1
- package/src/cache.ts +50 -12
- package/src/guard.ts +3 -2
- package/src/index.ts +30 -8
- package/src/learn.ts +18 -8
- package/src/notice.ts +91 -33
- package/src/placeholder.ts +12 -4
- package/src/plan.ts +470 -107
- package/src/recall.ts +111 -27
- package/src/recallfile.ts +165 -0
- package/src/run.ts +807 -122
- package/src/state.ts +53 -0
- package/src/summary.ts +22 -10
- package/src/ui.ts +214 -46
- package/src/util.ts +62 -2
package/src/state.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Planner state that is a function of the session: rebuilt from the branch at session start (a restart, a resume, a fork), so a fresh
|
|
2
|
+
// process plans exactly like one that never left (formal/TRIAGE.md (a) 1: growth g, "the newest response carried an edit", and
|
|
3
|
+
// "a summary was made in this warm stretch" lived only in memory).
|
|
4
|
+
import { COMMIT_CUSTOM, PRODUCT, type Any } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
/** A pi-zip summary that went in through Pi's compaction before a run (the run's first request is the first to carry it). */
|
|
7
|
+
const nativeSummary = (e: Any): boolean => e?.type === "compaction" && e.details?.by === PRODUCT && e.details?.via === "native";
|
|
8
|
+
|
|
9
|
+
/** For each assistant message of the branch (by branch index): was it the first response to carry a pi-zip edit? A run plan is sent from
|
|
10
|
+
* the run's first request and persisted after its response (the commit entry says `carried`); a warm-valve plan, or a summary that went
|
|
11
|
+
* in through Pi's compaction, is first carried by the next request. Errors and aborts count: the request was made. */
|
|
12
|
+
export function carriedEdits(branch: Any[]): Set<number> {
|
|
13
|
+
const edited = new Set<number>();
|
|
14
|
+
let next = false, last = -1;
|
|
15
|
+
branch.forEach((e, i) => {
|
|
16
|
+
if (e?.type === "message" && e.message?.role === "assistant") {
|
|
17
|
+
if (next) edited.add(i);
|
|
18
|
+
next = false;
|
|
19
|
+
last = i;
|
|
20
|
+
} else if (nativeSummary(e)) next = true;
|
|
21
|
+
else if (e?.type === "custom" && e.customType === COMMIT_CUSTOM) {
|
|
22
|
+
if (e.data?.carried && last >= 0) edited.add(last);
|
|
23
|
+
else if (!e.data?.carried) next = true;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return edited;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Growth per request, as run.ts `learn` accumulates it: EWMA(0.1) of the growth between consecutive responses of one model that
|
|
30
|
+
* carried no edit of ours, each capped at the p90 of the last 20 deltas; and whether the newest response was the first to carry an edit. */
|
|
31
|
+
export function replayGrowth(branch: Any[], g0: number): { g: number; deltas: number[]; justEdited: boolean } {
|
|
32
|
+
const edited = carriedEdits(branch);
|
|
33
|
+
let g = g0, deltas: number[] = [], prevKey = "", prevTotal = 0, justEdited = false;
|
|
34
|
+
branch.forEach((e, i) => {
|
|
35
|
+
const m = e?.type === "message" ? e.message : null;
|
|
36
|
+
if (m?.role !== "assistant") return;
|
|
37
|
+
justEdited = edited.has(i);
|
|
38
|
+
const u = m.usage;
|
|
39
|
+
if (!u || m.stopReason === "error" || m.stopReason === "aborted") return;
|
|
40
|
+
const total = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
41
|
+
if (!(total > 0)) return;
|
|
42
|
+
const key = `${m.provider ?? ""}/${m.model ?? ""}`;
|
|
43
|
+
if (!edited.has(i) && prevKey === key && prevTotal > 0 && total >= prevTotal) {
|
|
44
|
+
const d = total - prevTotal;
|
|
45
|
+
deltas = [...deltas.slice(-19), d];
|
|
46
|
+
const sorted = [...deltas].sort((a, b) => a - b);
|
|
47
|
+
g = 0.9 * g + 0.1 * Math.min(d, sorted[Math.ceil(0.9 * sorted.length) - 1]);
|
|
48
|
+
}
|
|
49
|
+
prevTotal = total;
|
|
50
|
+
prevKey = key;
|
|
51
|
+
});
|
|
52
|
+
return { g, deltas, justEdited };
|
|
53
|
+
}
|
package/src/summary.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { handleFor, outcomeHint, pickKeyLines, RECALL_TOOL, shortArgs } from "./placeholder.ts";
|
|
5
5
|
import { type Block, type Cut, type PlanResult, settings, toolCallIndex } from "./plan.ts";
|
|
6
|
+
import { type FileRecall, fileRecallHow } from "./recallfile.ts";
|
|
6
7
|
import { type Any, clamp, clip, textOf, tok4 } from "./util.ts";
|
|
7
8
|
|
|
8
9
|
export const SUMMARY_MARK = "[summary of earlier conversation by pi-zip]";
|
|
@@ -21,6 +22,8 @@ export interface HandleRow {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const TABLE_HEAD = "## Folded outputs (originals recallable with zip_recall; handles stay valid after later summaries or compaction)";
|
|
25
|
+
/** The same heading when zip_recall is hidden and the originals are read from recall files (parseSummary keys on "## Folded outputs"). */
|
|
26
|
+
const tableHead = (file?: FileRecall) => (file ? `## Folded outputs (originals in ${file.dir}/<handle>.txt; handles stay valid after later summaries or compaction)` : TABLE_HEAD);
|
|
24
27
|
const TABLE_ROWS = 40;
|
|
25
28
|
const tableLine = (r: HandleRow) => `- ${r.handle} · ${r.tool}${r.args ? " " + r.args : ""}${r.turn ? ` · turn ${r.turn}` : ""}${r.outcome ? ` · ${r.outcome}` : ""} · ${clip(r.hint, 90)}`;
|
|
26
29
|
|
|
@@ -28,12 +31,12 @@ const tableLine = (r: HandleRow) => `- ${r.handle} · ${r.tool}${r.args ? " " +
|
|
|
28
31
|
* Compact table appended to every summary so handles survive it: this prefix's rows merged with the rows of the previous summary's
|
|
29
32
|
* table(s) (hints and outcomes kept), newest TABLE_ROWS rows, each handle once, and a count of the older rows left out.
|
|
30
33
|
*/
|
|
31
|
-
export function handleTable(rows: HandleRow[], previous: string | null = null): string | null {
|
|
34
|
+
export function handleTable(rows: HandleRow[], previous: string | null = null, file?: FileRecall): string | null {
|
|
32
35
|
const prev = parseSummary(previous);
|
|
33
36
|
const merged = mergeItems(prev.table.items, rows.map(tableLine), prev.table.omitted, TABLE_ROWS, Infinity, (l) => TABLE_ROW.exec(l)?.[1]);
|
|
34
37
|
if (!merged.kept.length) return null;
|
|
35
38
|
const lines = merged.omitted ? [`[… ${merged.omitted} older folded outputs omitted from this table …]`, ...merged.kept] : merged.kept;
|
|
36
|
-
return
|
|
39
|
+
return tableHead(file) + "\n" + lines.join("\n");
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/** Keep the newest `n` lines and say how many older ones were left out (never a silent drop). */
|
|
@@ -93,7 +96,7 @@ export function handleRowsOf(text: string | null): IndexRow[] {
|
|
|
93
96
|
* Handles of the tool results the summary would otherwise lose: this prefix's results and the earlier summary's rows, newest first,
|
|
94
97
|
* minus handles already written elsewhere in the summary, under a token budget. Says how many rows did not fit.
|
|
95
98
|
*/
|
|
96
|
-
function handleIndex(now: IndexRow[], previous: string | null, listed: string, budgetTokens: number): string | null {
|
|
99
|
+
function handleIndex(now: IndexRow[], previous: string | null, listed: string, budgetTokens: number, file?: FileRecall): string | null {
|
|
97
100
|
const seen = new Set<string>(listed.match(new RegExp(`\\b${H}\\b`, "g")) ?? []);
|
|
98
101
|
const rows: IndexRow[] = [];
|
|
99
102
|
for (const r of [...now].reverse().concat(handleRowsOf(previous).reverse())) {
|
|
@@ -102,7 +105,7 @@ function handleIndex(now: IndexRow[], previous: string | null, listed: string, b
|
|
|
102
105
|
rows.push({ ...r, args: clip(r.args, 60) });
|
|
103
106
|
}
|
|
104
107
|
if (!rows.length) return null;
|
|
105
|
-
const head = `${INDEX_HEAD} (older outputs, newest first; zip_recall with a handle returns the original)`;
|
|
108
|
+
const head = `${INDEX_HEAD} (older outputs, newest first; ${file ? `${file.dir}/<handle>.txt holds the original` : "zip_recall with a handle returns the original"})`;
|
|
106
109
|
let used = tok4(head) + 12; // 12: the "not listed" line
|
|
107
110
|
const lines: string[] = [];
|
|
108
111
|
for (const r of rows) {
|
|
@@ -321,7 +324,7 @@ const list = (title: string, m: { kept: string[]; omitted: number }, marker: str
|
|
|
321
324
|
* merged from the previous summary `previous` (any format) and this prefix. `tableText` = the folded-outputs table the caller will
|
|
322
325
|
* append, so the index does not repeat its handles.
|
|
323
326
|
*/
|
|
324
|
-
export function skeleton(prefix: Block[], previous: string | null, tableText = ""): { text: string; users: number } {
|
|
327
|
+
export function skeleton(prefix: Block[], previous: string | null, tableText = "", file?: FileRecall): { text: string; users: number } {
|
|
325
328
|
const calls = toolCallIndex(prefix);
|
|
326
329
|
const results = new Map<string, Block>();
|
|
327
330
|
for (const b of prefix) if (b.kind === "toolResult") results.set(b.msg.toolCallId, b);
|
|
@@ -377,7 +380,13 @@ export function skeleton(prefix: Block[], previous: string | null, tableText = "
|
|
|
377
380
|
const mo = mergeItems(prev.others.items, others, prev.others.omitted, BUDGET.lines, BUDGET.others);
|
|
378
381
|
const me = mergeItems(prev.errors.items, errors, prev.errors.omitted, BUDGET.errorLines, BUDGET.errors);
|
|
379
382
|
const out: string[] = [SUMMARY_MARK];
|
|
380
|
-
out.push(
|
|
383
|
+
out.push(
|
|
384
|
+
file
|
|
385
|
+
? `Covers ${prefix.length} earlier messages. Their tool outputs are no longer in your context but are kept exactly: the 10-character code next to a command, file or call below is its handle, and ${file.dir}/<handle>.txt holds that output. ` +
|
|
386
|
+
`To use what an output said, ${fileRecallHow(file)}${file.grep || file.bash ? ` (grep over ${file.dir} searches every earlier output)` : ""} rather than re-running or re-reading (results may differ). Folded blocks later in the conversation work the same way.`
|
|
387
|
+
: `Covers ${prefix.length} earlier messages. Their tool outputs are no longer in your context but are kept exactly: the 10-character code next to a command, file or call below is its handle. ` +
|
|
388
|
+
`To use what an output said, ${RECALL_TOOL} it (grep/range for a part; grep alone searches every earlier output) rather than re-running or re-reading (results may differ). Folded blocks later in the conversation work the same way.`,
|
|
389
|
+
);
|
|
381
390
|
out.push(USERS_HEAD);
|
|
382
391
|
if (mu.omitted) out.push(`[… ${mu.omitted} older requests omitted …]`);
|
|
383
392
|
out.push(...(userLines.length ? userLines : [NONE_ROW]));
|
|
@@ -385,7 +394,7 @@ export function skeleton(prefix: Block[], previous: string | null, tableText = "
|
|
|
385
394
|
out.push(...list("## Commands run (with exit status)", mc, "older omitted"));
|
|
386
395
|
if (mo.kept.length) out.push(...list("## Other tool calls (turn, handle)", mo, "older omitted"));
|
|
387
396
|
out.push(...list("## Errors", me, "older omitted"));
|
|
388
|
-
const index = handleIndex(now, previous, out.join("\n") + "\n" + tableText, INDEX_TOKENS);
|
|
397
|
+
const index = handleIndex(now, previous, out.join("\n") + "\n" + tableText, INDEX_TOKENS, file);
|
|
389
398
|
if (index) out.push(index);
|
|
390
399
|
const foreign = !prev.structured;
|
|
391
400
|
const narr = prev.narrative.join("\n\n");
|
|
@@ -477,12 +486,14 @@ export async function buildCut(p: PlanResult, ctx: Any, signal?: AbortSignal): P
|
|
|
477
486
|
const tool = call?.name ?? b.msg.toolName ?? "tool";
|
|
478
487
|
return { handle: handleFor(b.entryId!), tool, args: call ? shortArgs(call.args) : "", turn: b.userTurn, outcome: outcomeHint(textOf((b.raw ?? b.msg).content), tool, !!b.msg.isError), hint: (keys.find((k) => k.why === "error" || k.why === "id") ?? keys[0])?.text ?? "" };
|
|
479
488
|
});
|
|
480
|
-
const
|
|
481
|
-
const
|
|
489
|
+
const file = p.fileRecall; // zip_recall hidden by an allowlist: the summary names the recall files instead
|
|
490
|
+
const table = handleTable(rows, prev, file);
|
|
491
|
+
const sk = skeleton(prefix.filter((b) => b.kind !== "summary"), prev, table ?? "", file);
|
|
482
492
|
const nb = clamp(p.summaryTokensPlanned - tok4(sk.text), 300, 4000);
|
|
483
493
|
const nar = await narrative(prefix, ctx, nb, signal);
|
|
484
494
|
let text = sk.text;
|
|
485
|
-
|
|
495
|
+
const how = file ? `read an output's file by its handle (${file.dir}/<handle>.txt)` : `${RECALL_TOOL} an output by its handle`;
|
|
496
|
+
text += nar.ok && nar.text ? `\n## Narrative (model-written)\n${nar.text.slice(0, nb * 6)}` : `\n## Narrative\n(unavailable: ${nar.error ?? "n/a"}; rely on the sections above and ${how} rather than re-running or re-reading)`;
|
|
486
497
|
if (table) text += "\n" + table;
|
|
487
498
|
return {
|
|
488
499
|
firstKeptEntryId: p.blocks[p.cutIdx!].entryId!,
|
|
@@ -496,5 +507,6 @@ export async function buildCut(p: PlanResult, ctx: Any, signal?: AbortSignal): P
|
|
|
496
507
|
costUsd: nar.costUsd,
|
|
497
508
|
usage: nar.usage,
|
|
498
509
|
ms: Math.round(performance.now() - t0),
|
|
510
|
+
...(p.ahead ? { ahead: true } : {}),
|
|
499
511
|
};
|
|
500
512
|
}
|
package/src/ui.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Transcript surfaces (pure, testable): the status card, state lines, the zip_recall row and the fold mark on a folded output.
|
|
2
|
-
// One visual grammar everywhere:
|
|
3
|
-
//
|
|
2
|
+
// One visual grammar everywhere: the monogram ƶ (bold, accent) says whose line it is, the words zip / zipped / unzip say what
|
|
3
|
+
// happened, the one bright thing is the size now; everything else is dim, no boxes, no other symbols.
|
|
4
|
+
// Collapsed = the conclusion, click or ctrl+o = the detail. Every returned line fits the given width.
|
|
5
|
+
// ƶ (U+01B6) is a Latin letter: East Asian width N, so one column in every terminal and locale; fonts without it fall back.
|
|
4
6
|
import { PRODUCT } from "./util.ts";
|
|
5
7
|
|
|
6
8
|
export interface Th {
|
|
7
9
|
fg(color: string, text: string): string;
|
|
10
|
+
bold?(text: string): string;
|
|
8
11
|
}
|
|
9
12
|
/** Display width and truncation: Pi's visibleWidth / truncateToWidth in the renderer, string length in tests. */
|
|
10
13
|
export interface Measure {
|
|
@@ -21,8 +24,9 @@ export function setMeasure(m: Measure) {
|
|
|
21
24
|
measure = m;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
const
|
|
27
|
+
/** The pi-zip monogram: z for zip, the stroke is the slider. */
|
|
28
|
+
export const SYM = "ƶ";
|
|
29
|
+
export const brand = (th: Th): string => th.fg("accent", th.bold ? th.bold(SYM) : SYM);
|
|
26
30
|
const LABEL_W = 9;
|
|
27
31
|
|
|
28
32
|
/** Plain text without control characters (tabs become two spaces): recalled output goes into the TUI as text, never as escape codes. */
|
|
@@ -33,25 +37,31 @@ export const clean = (s: string): string => s.replace(/\t/g, " ").replace(/\x1b
|
|
|
33
37
|
export type StateWord = "on" | "off" | "paused" | "reread-only" | "quiet" | "notices on" | "cache";
|
|
34
38
|
const WARN = new Set<StateWord>(["off", "paused", "reread-only"]);
|
|
35
39
|
|
|
36
|
-
/** "
|
|
40
|
+
/** "ƶ pi-zip paused billion-context also manages context · run: pi remove npm:billion-context" */
|
|
37
41
|
export function renderState(word: StateWord, reason: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
38
|
-
const wordC = th.fg(WARN.has(word) ? "warning" : "
|
|
39
|
-
const base = `${
|
|
42
|
+
const wordC = th.fg(WARN.has(word) ? "warning" : word === "on" ? "success" : "muted", word);
|
|
43
|
+
const base = `${SYM} ${PRODUCT} ${word}`;
|
|
40
44
|
if (m.vw(base) > width) return m.vw(word) <= width ? [wordC] : [];
|
|
41
45
|
const room = width - m.vw(base) - 2;
|
|
42
46
|
const r = reason && room >= 12 ? m.cut(reason, room) : "";
|
|
43
|
-
return [`${th.fg("
|
|
47
|
+
return [`${brand(th)} ${th.fg("muted", PRODUCT)} ${wordC}${r ? " " + th.fg("dim", r) : ""}`];
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
// ---- status card ------------------------------------------------------------------------------------------------------
|
|
47
51
|
|
|
52
|
+
/** One learned gap bin of the cache chart: how many times you came back after a gap in [lo, hi) s and the cache was still there / gone.
|
|
53
|
+
* Counts are rounded decayed weights (learn.ts keeps no raw per-bin count: old evidence halves after HALF_LIFE newer ones of the bin). */
|
|
54
|
+
export interface ChartBin { lo: number; hi: number; alive: number; gone: number }
|
|
55
|
+
|
|
48
56
|
export interface CardData {
|
|
49
57
|
state: "on" | "off" | "paused" | "reread-only";
|
|
50
58
|
stateNote?: string; // why paused / reread-only
|
|
51
59
|
model?: string; // provider/id
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
life?: string; // "~5 min" | "~1 h" | "2 h+": how long the cache outlives the last reply (the declared TTL until something is learned)
|
|
61
|
+
bins?: ChartBin[]; // learned bins that round to at least one return; none = nothing learned yet: one line, no chart
|
|
62
|
+
s1?: number; // seconds: the cache is (nearly) always still there under this (learned curve, never the prior)
|
|
63
|
+
s2?: number; // seconds: it is mostly gone from here on; between s1 and s2 it is not sure yet
|
|
64
|
+
usually?: boolean; // some bin under s1 has P(still there) < 0.95: "usually still there" instead of "still there"
|
|
55
65
|
folds: number;
|
|
56
66
|
foldedTokens: string; // "310K"
|
|
57
67
|
summaries: number;
|
|
@@ -59,41 +69,191 @@ export interface CardData {
|
|
|
59
69
|
recalls: number;
|
|
60
70
|
last?: string; // "10:50 folded 12 old outputs · cache cold (away 47 min)"
|
|
61
71
|
quiet: boolean;
|
|
72
|
+
unzip?: string; // how the model gets folded outputs back, when it is not zip_recall (file recall under a tool allowlist)
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
/** Gaps (seconds) the "alive" sparkline samples: 30 s to 2 h, denser where caches usually expire. */
|
|
65
|
-
export const SPARK_GAPS = [30, 60, 120, 180, 240, 300, 360, 420, 600, 900, 1200, 1800, 2700, 3600, 5400, 7200];
|
|
66
|
-
const SPARK = "▁▂▃▄▅▆▇█";
|
|
67
|
-
export const sparkline = (ps: number[]): string => ps.map((p) => SPARK[Math.max(0, Math.min(7, Math.round(p * 7)))]).join("");
|
|
68
|
-
|
|
69
75
|
/** "~5 min", "~1 h", "~2.5 h" */
|
|
70
76
|
export function fmtLife(s: number): string {
|
|
71
77
|
if (s < 90) return `~${Math.round(s)} s`;
|
|
72
|
-
if (s <
|
|
78
|
+
if (Math.round(s / 60) < 60) return `~${Math.round(s / 60)} min`;
|
|
73
79
|
return `~${+(s / 3600).toFixed(1)} h`;
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
// ---- the cache chart: one dot per time you came back ----------------------------------------------------------------------
|
|
83
|
+
// Across: how long you were away, 30 s to 2 h on a log scale. Above the axis (accent): the times the cache was still there, below
|
|
84
|
+
// (dim): the times it was gone. The axis itself says what to expect: solid while it is (nearly) always there, dashed while not sure,
|
|
85
|
+
// thin after it is gone. A column shows at most STACK_CAP marks; beyond that STACK_CAP - 1 dots and then the count.
|
|
86
|
+
|
|
87
|
+
export const CHART_W = 60; // columns 0..CHART_W
|
|
88
|
+
const CT0 = 30, CT1 = 7200;
|
|
89
|
+
export const STACK_CAP = 4;
|
|
90
|
+
const IND = 11; // the values column of the card: " " + the 9-wide label
|
|
91
|
+
export const colW = (s: number): number => Math.round((CHART_W * Math.log(s / CT0)) / Math.log(CT1 / CT0));
|
|
92
|
+
const gapW = (c: number): number => CT0 * (CT1 / CT0) ** (c / CHART_W);
|
|
93
|
+
const AXIS_TICKS: [number, string][] = [[30, "30s"], [60, "1m"], [120, "2m"], [300, "5m"], [600, "10m"], [1800, "30m"], [3600, "1h"], [7200, "2h"]];
|
|
94
|
+
const fmtGap = (s: number): string => (s < 60 ? `${Math.round(s)} s` : s < 3600 ? `${+(s / 60).toFixed(1)} min` : `${+(s / 3600).toFixed(1)} h`);
|
|
95
|
+
|
|
96
|
+
/** What the marks of one side of the axis look like, as rows from the axis outwards: row r (1-based) holds, per column, a dot, a digit of
|
|
97
|
+
* the count or a blank. `items` are (column, times); items on the same column merge. A count is drawn on the row of the stack's end,
|
|
98
|
+
* centred on its column; when that would touch another mark in its row (neighbouring columns, other counts) it moves, the nearest free
|
|
99
|
+
* place first, right before left, and keeps one blank on each side, so two counts never read as one number. Deterministic. */
|
|
100
|
+
export function stackRows(items: { col: number; n: number }[], dot: string = "\u25cf", cap: number = STACK_CAP, width: number = CHART_W + 1): { rows: string[][]; height: number } {
|
|
101
|
+
const merged = new Map<number, number>();
|
|
102
|
+
for (const it of items) {
|
|
103
|
+
const col = Math.max(0, Math.min(width - 1, it.col));
|
|
104
|
+
if (it.n > 0) merged.set(col, (merged.get(col) ?? 0) + it.n);
|
|
105
|
+
}
|
|
106
|
+
const cols = [...merged.entries()].sort((a, b) => a[0] - b[0]);
|
|
107
|
+
const height = Math.max(1, ...cols.map(([, n]) => Math.min(n, cap)));
|
|
108
|
+
const rows = Array.from({ length: height }, () => Array<string>(width).fill(" "));
|
|
109
|
+
const counts: { col: number; text: string; row: number }[] = [];
|
|
110
|
+
for (const [col, n] of cols) {
|
|
111
|
+
const dots = n <= cap ? n : cap - 1;
|
|
112
|
+
for (let r = 0; r < dots; r++) rows[r][col] = dot;
|
|
113
|
+
if (n > cap) counts.push({ col, text: String(n), row: cap - 1 });
|
|
114
|
+
}
|
|
115
|
+
for (const { col, text, row } of counts) {
|
|
116
|
+
const len = text.length, want = col - Math.floor((len - 1) / 2), line = rows[row];
|
|
117
|
+
const free = (st: number, gap: boolean) => {
|
|
118
|
+
if (st < 0 || st + len > width) return false;
|
|
119
|
+
for (let x = st - (gap ? 1 : 0); x < st + len + (gap ? 1 : 0); x++) if (x >= 0 && x < width && line[x] !== " ") return false;
|
|
120
|
+
return true;
|
|
121
|
+
};
|
|
122
|
+
let at = -1;
|
|
123
|
+
for (const gap of [true, false]) {
|
|
124
|
+
for (let d = 0; d < width && at < 0; d++) for (const st of d ? [want + d, want - d] : [want]) if (at < 0 && free(st, gap)) at = st;
|
|
125
|
+
if (at >= 0) break;
|
|
126
|
+
}
|
|
127
|
+
if (at >= 0) for (let k = 0; k < len; k++) line[at + k] = text[k];
|
|
128
|
+
}
|
|
129
|
+
return { rows, height };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Colours the cells of a row (one string per column, null = blank) with as few runs as possible. */
|
|
133
|
+
function paint(cells: { ch: string; color: string }[], th: Th): string {
|
|
134
|
+
let out = "", run = "", color = "";
|
|
135
|
+
const flush = () => { if (run) out += color ? th.fg(color, run) : run; run = ""; };
|
|
136
|
+
for (const c of cells) {
|
|
137
|
+
if (c.color !== color) flush(), (color = c.color);
|
|
138
|
+
run += c.ch;
|
|
139
|
+
}
|
|
140
|
+
flush();
|
|
141
|
+
return out.trimEnd();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface Seg { lead: string; word: string; color: string }
|
|
145
|
+
/** "under 5 min: usually still there · 5–10 min: not sure yet · over 10 min: gone", as segments that wrap whole. */
|
|
146
|
+
function captionSegs(c: CardData): Seg[] {
|
|
147
|
+
const s1 = c.s1 ?? CT0, s2 = c.s2 ?? CT1;
|
|
148
|
+
const range = s1 >= 60 && s2 < 3600 ? `${+(s1 / 60).toFixed(1)}–${fmtGap(s2)}` : `${fmtGap(s1)}–${fmtGap(s2)}`; // "5–10 min"
|
|
149
|
+
return [
|
|
150
|
+
{ lead: `under ${fmtGap(s1)}: `, word: c.usually ? "usually still there" : "still there", color: "accent" },
|
|
151
|
+
...(s2 > s1 ? [{ lead: `${range}: `, word: "not sure yet", color: "warning" }] : []),
|
|
152
|
+
{ lead: `over ${fmtGap(s2)}: `, word: "gone", color: "muted" },
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
const SEP = " \u00b7 ";
|
|
156
|
+
const segW = (g: Seg) => g.lead.length + g.word.length;
|
|
157
|
+
/** The caption as lines of at most `room` columns, wrapped between segments (a segment wider than the room is cut). */
|
|
158
|
+
function captionLines(segs: Seg[], room: number, th: Th, m: Measure): string[] {
|
|
159
|
+
if (room < 2) return []; // no room for even a cut sentence
|
|
160
|
+
const lines: Seg[][] = [[]];
|
|
161
|
+
for (const g of segs) {
|
|
162
|
+
const cur = lines[lines.length - 1], w = cur.reduce((a, x) => a + segW(x), 0) + SEP.length * cur.length + segW(g);
|
|
163
|
+
if (cur.length && w > room) lines.push([g]);
|
|
164
|
+
else cur.push(g);
|
|
165
|
+
}
|
|
166
|
+
return lines.map((l) => {
|
|
167
|
+
const plain = l.map((g) => g.lead + g.word).join(SEP);
|
|
168
|
+
if (plain.length > room) return th.fg("dim", m.cut(plain, room));
|
|
169
|
+
return l.map((g) => th.fg("dim", g.lead) + th.fg(g.color, g.word)).join(th.fg("dim", SEP));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const captionPlain = (c: CardData) => captionSegs(c).map((g) => g.lead + g.word).join(SEP);
|
|
173
|
+
|
|
174
|
+
const idle = (c: CardData): string => (c.life ? `expires after ${c.life} idle` : "");
|
|
175
|
+
const NO_DATA = "a chart appears once you come back after a break";
|
|
176
|
+
const hasChart = (c: CardData): boolean => !!c.model && !!c.bins?.length;
|
|
177
|
+
|
|
76
178
|
export function cardText(c: CardData): string[] {
|
|
77
179
|
const rows: [string, string][] = [];
|
|
78
|
-
if (c.model) rows.push(["
|
|
79
|
-
if (c.
|
|
180
|
+
if (c.model) rows.push(["model", c.model]);
|
|
181
|
+
if (c.model) rows.push(["cache", hasChart(c) ? `each dot is one time you came back · ${captionPlain(c)}` : `${idle(c) || "expires after ~5 min idle"} · ${NO_DATA}`]);
|
|
80
182
|
const n = (x: number, one: string, many = one + "s") => `${x} ${x === 1 ? one : many}`;
|
|
81
|
-
rows.push(["session", `${
|
|
183
|
+
rows.push(["session", `${c.folds} zipped ~${c.foldedTokens} · ${n(c.summaries, "summary", "summaries")}${c.summaryUsd > 0 ? ` $${c.summaryUsd.toFixed(2)}` : ""} · ${n(c.recalls, "unzip")}`]);
|
|
82
184
|
if (c.last) rows.push(["last", c.last]);
|
|
83
|
-
|
|
84
|
-
rows.push(["
|
|
185
|
+
if (c.unzip) rows.push(["unzip", c.unzip]);
|
|
186
|
+
if (c.quiet) rows.push(["notices", "off (/zip quiet turns them back on)"]); // the default says nothing, so it is not shown
|
|
85
187
|
return rows.map(([k, v]) => `${k.padEnd(LABEL_W)}${v}`);
|
|
86
188
|
}
|
|
87
189
|
|
|
190
|
+
/** The chart rows (without the model row): header, marks above, axis, marks below, axis labels, [legend], caption. */
|
|
191
|
+
function chartRows(c: CardData, width: number, th: Th, m: Measure): string[] {
|
|
192
|
+
const indent = " ", label = (k: string) => th.fg("dim", (indent + k).padEnd(indent.length + LABEL_W)), pad = " ".repeat(IND);
|
|
193
|
+
const bins = c.bins ?? [];
|
|
194
|
+
const na = bins.reduce((a, b) => a + b.alive, 0), nd = bins.reduce((a, b) => a + b.gone, 0);
|
|
195
|
+
const up = stackRows(bins.map((b) => ({ col: colW(Math.sqrt(b.lo * b.hi)), n: b.alive })));
|
|
196
|
+
const down = stackRows(bins.map((b) => ({ col: colW(Math.sqrt(b.lo * b.hi)), n: b.gone })), "\u25cb");
|
|
197
|
+
const legUp = ["\u25cf", ` still there ${na}`], legDown = ["\u25cb", ` gone ${nd}`];
|
|
198
|
+
const legW = (l: string[]) => l[0].length + l[1].length;
|
|
199
|
+
const beside = width >= IND + CHART_W + 1 + 3 + Math.max(legW(legUp), legW(legDown));
|
|
200
|
+
const s1 = c.s1 ?? CT0, s2 = c.s2 ?? CT1;
|
|
201
|
+
const out: string[] = [label("cache") + th.fg("dim", "each dot is one time you came back")];
|
|
202
|
+
const side = (rows: string[][], color: string, leg: string[], first: number) =>
|
|
203
|
+
rows.map((cells, r) => {
|
|
204
|
+
const line = paint(cells.map((ch) => ({ ch, color: ch === " " ? "" : color })), th);
|
|
205
|
+
return pad + line + (beside && r === first ? " " + th.fg(color, leg[0]) + th.fg("muted", leg[1]) : "");
|
|
206
|
+
});
|
|
207
|
+
out.push(...side(up.rows.slice().reverse(), "accent", legUp, up.height - 1)); // outermost row first; the legend sits on the row next to the axis
|
|
208
|
+
out.push(pad + paint(Array.from({ length: CHART_W + 1 }, (_, i) => ({ ch: gapW(i) < s1 ? "\u2501" : gapW(i) < s2 ? "\u2505" : "\u2500", color: gapW(i) < s1 ? "accent" : gapW(i) < s2 ? "warning" : "dim" })), th));
|
|
209
|
+
out.push(...side(down.rows, "dim", legDown, 0));
|
|
210
|
+
const ax = Array<string>(CHART_W + 3).fill(" ");
|
|
211
|
+
for (const [t, lab] of AXIS_TICKS) {
|
|
212
|
+
const col = Math.min(colW(t), CHART_W - lab.length + 1);
|
|
213
|
+
for (let k = 0; k < lab.length; k++) ax[col + k] = lab[k];
|
|
214
|
+
}
|
|
215
|
+
out.push(label("away") + th.fg("dim", ax.join("").trimEnd()));
|
|
216
|
+
if (!beside) out.push(pad + th.fg("accent", legUp[0]) + th.fg("muted", legUp[1]) + " " + th.fg("dim", legDown[0]) + th.fg("muted", legDown[1]));
|
|
217
|
+
for (const l of captionLines(captionSegs(c), width - IND, th, m)) out.push(pad + l);
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
88
221
|
export function renderCard(c: CardData, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
89
222
|
const out = renderState(c.state, c.stateNote ?? "", width, th, m);
|
|
90
|
-
const indent = " ";
|
|
91
|
-
|
|
92
|
-
|
|
223
|
+
const indent = " "; // under the words after the monogram
|
|
224
|
+
const head = (k: string) => th.fg("dim", (indent + k).padEnd(indent.length + LABEL_W));
|
|
225
|
+
const row = (k: string, v: string) => {
|
|
226
|
+
const line = m.cut(indent + k.padEnd(LABEL_W) + v, width);
|
|
227
|
+
if (!line) return;
|
|
228
|
+
const kk = line.slice(0, indent.length + LABEL_W);
|
|
229
|
+
out.push(th.fg("dim", kk) + th.fg("dim", line.slice(kk.length)));
|
|
230
|
+
};
|
|
231
|
+
if (c.model) {
|
|
232
|
+
row("model", c.model);
|
|
233
|
+
if (!hasChart(c)) {
|
|
234
|
+
// nothing learned yet: one line, the chart appears with the first return after a break
|
|
235
|
+
const room = width - indent.length - LABEL_W;
|
|
236
|
+
const v = [`${idle(c) || "expires after ~5 min idle"} \u00b7 ${NO_DATA}`, idle(c) || "expires after ~5 min idle"].find((x) => m.vw(x) <= room);
|
|
237
|
+
if (v) out.push(head("cache") + th.fg("dim", v));
|
|
238
|
+
else row("cache", idle(c) || "expires after ~5 min idle");
|
|
239
|
+
} else if (width >= IND + CHART_W + 1) out.push(...chartRows(c, width, th, m));
|
|
240
|
+
else {
|
|
241
|
+
// too narrow for the chart: the caption sentence alone says the same thing
|
|
242
|
+
const lines = captionLines(captionSegs(c), width - IND, th, m);
|
|
243
|
+
lines.forEach((l, i) => out.push((i ? " ".repeat(IND) : head("cache")) + l));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const r of cardText({ ...c, model: undefined })) {
|
|
247
|
+
if (r.startsWith("unzip ")) {
|
|
248
|
+
// Narrow: the bracketed reason goes first, so where the files are stays whole.
|
|
249
|
+
const h = head("unzip"), room = width - m.vw(h);
|
|
250
|
+
const v = [r.slice(LABEL_W), r.slice(LABEL_W).replace(/ \([^()]*\)$/, "")].find((x) => m.vw(x) <= room);
|
|
251
|
+
if (v) { out.push(th.fg("dim", h + v)); continue; }
|
|
252
|
+
}
|
|
253
|
+
const line = m.cut(indent + r, width);
|
|
93
254
|
if (!line) continue;
|
|
94
|
-
const warn = row.startsWith("mode") && c.state === "reread-only";
|
|
95
255
|
const k = line.slice(0, indent.length + LABEL_W), v = line.slice(indent.length + LABEL_W);
|
|
96
|
-
out.push(th.fg("dim", k) + (
|
|
256
|
+
out.push(th.fg("dim", k) + (r.startsWith("session") ? th.fg("text", v) : th.fg("dim", v)));
|
|
97
257
|
}
|
|
98
258
|
return out;
|
|
99
259
|
}
|
|
@@ -110,35 +270,42 @@ export interface RecallSectionInfo {
|
|
|
110
270
|
missing?: boolean;
|
|
111
271
|
}
|
|
112
272
|
|
|
113
|
-
/** "
|
|
114
|
-
|
|
273
|
+
/** "ƶ unzip src/learn.ts:1-2000 /HALF_LIFE/": the same shape as Pi's own tool rows (bold name, accent argument) after the monogram;
|
|
274
|
+
* the argument says WHAT comes back (the folded output's label), not its handle. `labelOf` maps a handle to that label when known. */
|
|
275
|
+
export function recallCallLine(args: { handle?: string; handles?: string[]; grep?: string; range?: string; offset?: number }, width: number, th: Th, m: Measure = plainMeasure, labelOf: (h: string) => string | undefined = () => undefined): string[] {
|
|
115
276
|
const hs = [...(typeof args?.handle === "string" ? [args.handle] : []), ...(Array.isArray(args?.handles) ? args.handles.filter((h) => typeof h === "string") : [])];
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const head =
|
|
120
|
-
|
|
121
|
-
|
|
277
|
+
const label = (h: string) => (labelOf(h.trim()) ?? h).replace(/^read /, ""); // "read src/a.ts" reads as "unzip src/a.ts"
|
|
278
|
+
const what = hs.length > 1 ? `${hs.length} outputs` : hs[0] ? label(hs[0]) : args?.grep ? "all outputs" : ""; // grep without a handle searches every output
|
|
279
|
+
const opt = [args?.grep ? `/${args.grep}/` : "", args?.range ? `lines ${args.range}` : "", args?.offset ? `from char ${args.offset}` : ""].filter(Boolean).join(" · ");
|
|
280
|
+
const head = `${SYM} unzip`;
|
|
281
|
+
const plain = m.cut(`${head} ${what}${opt ? " " + opt : ""}`, width);
|
|
282
|
+
const bold = (t: string) => (th.bold ? th.bold(t) : t);
|
|
283
|
+
if (!plain.startsWith(head + " ")) return [th.fg("dim", plain)];
|
|
284
|
+
const w = plain.slice(head.length + 1, head.length + 1 + what.length);
|
|
285
|
+
return [brand(th) + " " + th.fg("toolTitle", bold("unzip")) + " " + th.fg("accent", w) + th.fg("dim", plain.slice(head.length + 1 + w.length))];
|
|
122
286
|
}
|
|
123
287
|
|
|
124
|
-
export function sectionLine(s: RecallSectionInfo): string {
|
|
288
|
+
export function sectionLine(s: RecallSectionInfo, many = false): string {
|
|
125
289
|
if (s.missing) return `${s.handle} not found`;
|
|
126
|
-
const where = [s.label, s.turn ? `turn ${s.turn}` : ""].filter(Boolean).join(" · ");
|
|
127
290
|
const total = s.totalLines ?? 0;
|
|
128
291
|
const got = s.how === "all" ? `all ${total} lines` : `${s.shownLines ?? 0} of ${total} lines`;
|
|
129
|
-
return `${
|
|
292
|
+
return many ? `${s.label || s.handle} ${got}` : got;
|
|
130
293
|
}
|
|
131
294
|
|
|
132
|
-
|
|
295
|
+
const RECALL_FRAME = /^\[(handle \S+( · |\]| not found)|grep( \(|: | over )|range: lines |chars \d+-\d+ of |… \d+ more chars: )/;
|
|
296
|
+
|
|
297
|
+
/** Collapsed: what came back ("2 of 142 lines"; one line per output for a batch). Expanded: the recalled text, at most maxLines. */
|
|
133
298
|
export function recallResultLines(sections: RecallSectionInfo[], text: string, expanded: boolean, width: number, th: Th, m: Measure = plainMeasure, maxLines = 40): string[] {
|
|
134
299
|
const out: string[] = [];
|
|
135
300
|
for (const s of sections) {
|
|
136
|
-
const l = m.cut(sectionLine(s), width);
|
|
301
|
+
const l = m.cut(sectionLine(s, sections.length > 1), width);
|
|
137
302
|
if (l) out.push(s.missing ? th.fg("error", l) : th.fg("dim", l));
|
|
138
303
|
}
|
|
139
304
|
if (!sections.length && text) out.push(th.fg("dim", m.cut(clean(text.split("\n")[0]), width)));
|
|
140
305
|
if (!expanded || !text) return out;
|
|
141
|
-
|
|
306
|
+
// the model's framing lines ("[handle … · 117 lines]", "[grep: 0 matching line(s) of 117]", page notes) repeat the row above: the user sees only the text
|
|
307
|
+
const lines = clean(text).split("\n").filter((l) => !RECALL_FRAME.test(l));
|
|
308
|
+
if (!lines.some((l) => l.trim())) return out;
|
|
142
309
|
for (const l of lines.slice(0, maxLines)) out.push(th.fg("toolOutput", m.cut(l, width)));
|
|
143
310
|
if (lines.length > maxLines) out.push(th.fg("dim", m.cut(`… ${lines.length - maxLines} more lines (the model got them all)`, width)));
|
|
144
311
|
return out;
|
|
@@ -146,13 +313,14 @@ export function recallResultLines(sections: RecallSectionInfo[], text: string, e
|
|
|
146
313
|
|
|
147
314
|
// ---- the mark on a folded output ---------------------------------------------------------------------------------------
|
|
148
315
|
|
|
149
|
-
/**
|
|
316
|
+
/** "$ npm test ƶ zipped": the mark follows the call row's own text, if it fits; otherwise the row is left alone. */
|
|
150
317
|
export function markFirstLine(lines: string[], handle: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
151
318
|
if (!lines.length) return lines;
|
|
152
|
-
|
|
319
|
+
// the handle is in the notice (click / ctrl+o) and in the model's view; here it would only be noise
|
|
153
320
|
// renderers often pad a row to the full width (and may close styles after the padding): drop the padding, keep the codes
|
|
154
321
|
const first = lines[0].replace(/ +((?:\x1b\[[0-9;]*m)*)$/, "$1");
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
|
|
322
|
+
const markW = 2 + m.vw(`${SYM} zipped`);
|
|
323
|
+
if (m.vw(first) + markW > width) return lines;
|
|
324
|
+
const pad = width - m.vw(first) - markW; // keep the row as wide as it was (Pi pads rows to the full width)
|
|
325
|
+
return [first + " " + brand(th) + th.fg("dim", " zipped") + " ".repeat(pad), ...lines.slice(1)];
|
|
158
326
|
}
|
package/src/util.ts
CHANGED
|
@@ -3,6 +3,13 @@ export type Any = any;
|
|
|
3
3
|
|
|
4
4
|
export const PRODUCT = "pi-zip";
|
|
5
5
|
|
|
6
|
+
/** Custom session entry pi-zip appends after the edits (context_edit / compaction) it persists at a turn_end: `{ carried }` says whether
|
|
7
|
+
* the request(s) of the run had already sent them (a run plan: persisted after the response that first carried it) or whether the next
|
|
8
|
+
* request is the first to carry them (a warm-valve plan). The edits alone do not say which, and the planner's state (growth, "the
|
|
9
|
+
* newest response carried an edit", the size scale) is read from the branch, so a restart plans like a process that never left.
|
|
10
|
+
* Custom entries never reach the model. */
|
|
11
|
+
export const COMMIT_CUSTOM = "pi-zip/commit";
|
|
12
|
+
|
|
6
13
|
export const textOf = (c: Any): string =>
|
|
7
14
|
typeof c === "string" ? c : Array.isArray(c) ? c.filter((b: Any) => b?.type === "text").map((b: Any) => b.text ?? "").join("\n") : "";
|
|
8
15
|
|
|
@@ -25,17 +32,65 @@ const IMAGE_CHARS = 4800;
|
|
|
25
32
|
const contentChars = (c: Any): number =>
|
|
26
33
|
typeof c === "string" ? c.length : Array.isArray(c) ? c.reduce((a: number, b: Any) => a + (b?.type === "text" ? (b.text?.length ?? 0) : b?.type === "image" ? IMAGE_CHARS : 0), 0) : 0;
|
|
27
34
|
|
|
28
|
-
/**
|
|
35
|
+
/** Pi's own estimateTokens (core/compaction/compaction.js), rule for rule: no tool framing, system messages counted. Used only to predict
|
|
36
|
+
* what Pi's compaction will decide (plan.ts piCanCompact); every pi-zip size uses tokensOf. */
|
|
37
|
+
export function piTokensOf(m: Any): number {
|
|
38
|
+
let chars = 0;
|
|
39
|
+
switch (m?.role) {
|
|
40
|
+
case "system":
|
|
41
|
+
chars = contentChars(m.content);
|
|
42
|
+
for (const s of Object.values(m.sections ?? {})) if (typeof s === "string" && s) chars += s.length;
|
|
43
|
+
if (m.toolsAdded) chars += JSON.stringify(m.toolsAdded).length;
|
|
44
|
+
break;
|
|
45
|
+
case "user":
|
|
46
|
+
case "custom":
|
|
47
|
+
case "toolResult":
|
|
48
|
+
chars = contentChars(m.content);
|
|
49
|
+
break;
|
|
50
|
+
case "assistant":
|
|
51
|
+
for (const b of m.content ?? []) {
|
|
52
|
+
if (b?.type === "text") chars += b.text?.length ?? 0;
|
|
53
|
+
else if (b?.type === "thinking") chars += b.thinking?.length ?? 0;
|
|
54
|
+
else if (b?.type === "toolCall") chars += (b.name?.length ?? 0) + (JSON.stringify(b.arguments ?? {}) ?? "").length;
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
case "bashExecution":
|
|
58
|
+
chars = (m.command?.length ?? 0) + (m.output?.length ?? 0);
|
|
59
|
+
break;
|
|
60
|
+
case "branchSummary":
|
|
61
|
+
case "compactionSummary":
|
|
62
|
+
chars = m.summary?.length ?? 0;
|
|
63
|
+
break;
|
|
64
|
+
default:
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
return Math.ceil(chars / 4);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What every tool call puts on the wire that Pi's chars/4 rule does not see, in chars: the call id (in the call and again in its
|
|
71
|
+
* result) and this much framing per block (the provider's tool-use / tool-result markup). It does not shrink when the output is
|
|
72
|
+
* folded, so without it the size after a deep fold came out low: Tier1 fold plans that removed over half the content, median
|
|
73
|
+
* -9.5% / -7.0% / -10.9% (Claude / GLM / GPT) -> -0.2% / -2.7% / -2.0% with 120 (research round5/affine.md section 8). */
|
|
74
|
+
export const TOOL_FRAME_CHARS = 120;
|
|
75
|
+
|
|
76
|
+
/** chars/4 estimate of a message's CONTENT as the wire carries it: Pi's estimateTokens rules (kept local so plan.ts has no runtime
|
|
77
|
+
* Pi dependency) plus each tool block's id and framing (TOOL_FRAME_CHARS). A system message counts 0: the system prompt and the tools
|
|
78
|
+
* are the fixed prefix O of plan.ts, never content. */
|
|
29
79
|
export function tokensOf(m: Any): number {
|
|
30
80
|
let chars = 0;
|
|
31
81
|
switch (m?.role) {
|
|
82
|
+
case "system":
|
|
83
|
+
return 0;
|
|
32
84
|
case "assistant":
|
|
33
85
|
for (const b of m.content ?? []) {
|
|
34
86
|
if (b?.type === "text") chars += b.text?.length ?? 0;
|
|
35
87
|
else if (b?.type === "thinking") chars += b.thinking?.length ?? 0;
|
|
36
|
-
else if (b?.type === "toolCall") chars += (b.name?.length ?? 0) + JSON.stringify(b.arguments ?? {}).length;
|
|
88
|
+
else if (b?.type === "toolCall") chars += (b.name?.length ?? 0) + JSON.stringify(b.arguments ?? {}).length + String(b.id ?? "").length + TOOL_FRAME_CHARS;
|
|
37
89
|
}
|
|
38
90
|
break;
|
|
91
|
+
case "toolResult":
|
|
92
|
+
chars = contentChars(m.content) + String(m.toolCallId ?? "").length + TOOL_FRAME_CHARS;
|
|
93
|
+
break;
|
|
39
94
|
case "bashExecution":
|
|
40
95
|
chars = (m.command?.length ?? 0) + (m.output?.length ?? 0);
|
|
41
96
|
break;
|
|
@@ -48,3 +103,8 @@ export function tokensOf(m: Any): number {
|
|
|
48
103
|
}
|
|
49
104
|
return Math.ceil(chars / 4);
|
|
50
105
|
}
|
|
106
|
+
|
|
107
|
+
/** Custom session entry pi-zip appends right before the prompt of a cold return. A warm plan holds back what that return protected
|
|
108
|
+
* (plan.ts FOLD_HOLD); the entry makes the return a fact of the session, so a restart reads it instead of guessing from timestamps at
|
|
109
|
+
* the TTL's edge. Custom entries never reach the model. */
|
|
110
|
+
export const COLD_CUSTOM = "pi-zip/cold";
|