pi-zip 0.2.7 → 0.3.0-rc.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 +60 -42
- package/package.json +1 -1
- package/src/cache.ts +50 -12
- package/src/guard.ts +3 -2
- package/src/index.ts +45 -8
- package/src/learn.ts +18 -8
- package/src/notice.ts +91 -33
- package/src/placeholder.ts +12 -4
- package/src/plan.ts +434 -106
- package/src/recall.ts +111 -27
- package/src/recallfile.ts +165 -0
- package/src/run.ts +782 -116
- package/src/state.ts +53 -0
- package/src/summary.ts +22 -10
- package/src/ui.ts +107 -44
- package/src/util.ts +57 -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,14 +37,14 @@ 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 ------------------------------------------------------------------------------------------------------
|
|
@@ -49,9 +53,9 @@ export interface CardData {
|
|
|
49
53
|
state: "on" | "off" | "paused" | "reread-only";
|
|
50
54
|
stateNote?: string; // why paused / reread-only
|
|
51
55
|
model?: string; // provider/id
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
alive?: number[]; // P(
|
|
56
|
+
life?: string; // "~5 min" | "~1 h" | "2 h+": how long the cache outlives the last reply
|
|
57
|
+
measured?: boolean; // life comes from this provider's own replies, not the declared TTL
|
|
58
|
+
alive?: number[]; // P(cache still there) after being away chartGap(i), one per chart column
|
|
55
59
|
folds: number;
|
|
56
60
|
foldedTokens: string; // "310K"
|
|
57
61
|
summaries: number;
|
|
@@ -59,42 +63,93 @@ export interface CardData {
|
|
|
59
63
|
recalls: number;
|
|
60
64
|
last?: string; // "10:50 folded 12 old outputs · cache cold (away 47 min)"
|
|
61
65
|
quiet: boolean;
|
|
66
|
+
unzip?: string; // how the model gets folded outputs back, when it is not zip_recall (file recall under a tool allowlist)
|
|
62
67
|
}
|
|
63
68
|
|
|
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
69
|
/** "~5 min", "~1 h", "~2.5 h" */
|
|
70
70
|
export function fmtLife(s: number): string {
|
|
71
71
|
if (s < 90) return `~${Math.round(s)} s`;
|
|
72
|
-
if (s <
|
|
72
|
+
if (Math.round(s / 60) < 60) return `~${Math.round(s / 60)} min`;
|
|
73
73
|
return `~${+(s / 3600).toFixed(1)} h`;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** The cache chart: one column per time away, log-spaced from 30 s to 2 h. The row labels are the axes ("cache" up, "away" across),
|
|
77
|
+
* so it reads like any chart; the bars still there are in the accent colour, the dead ones dim. */
|
|
78
|
+
export const CHART_COLS = 32;
|
|
79
|
+
const CHART_T0 = 30, CHART_T1 = 7200;
|
|
80
|
+
export const chartGap = (i: number): number => CHART_T0 * (CHART_T1 / CHART_T0) ** (i / (CHART_COLS - 1));
|
|
81
|
+
const chartCol = (s: number): number => Math.round(((CHART_COLS - 1) * Math.log(s / CHART_T0)) / Math.log(CHART_T1 / CHART_T0));
|
|
82
|
+
const BARS = "▁▂▃▄▅▆▇█";
|
|
83
|
+
const TICKS: [number, string][] = [[60, "1m"], [300, "5m"], [900, "15m"], [3600, "1h"], [7200, "2h"]];
|
|
84
|
+
const bar = (p: number): string => BARS[Math.max(0, Math.min(7, Math.round(p * 7)))];
|
|
85
|
+
const ALIVE = 0.5; // a bar at or above this is drawn as still there
|
|
86
|
+
|
|
87
|
+
/** "1m 5m 15m 1h 2h": tick labels start at their column. */
|
|
88
|
+
export function chartAxis(): string {
|
|
89
|
+
const s = Array<string>(CHART_COLS + 2).fill(" ");
|
|
90
|
+
for (const [t, lab] of TICKS) {
|
|
91
|
+
const c = Math.min(chartCol(t), CHART_COLS - lab.length + 1);
|
|
92
|
+
if (s.slice(Math.max(0, c - 1), c + lab.length + 1).every((x) => x === " ")) s.splice(c, lab.length, ...lab);
|
|
93
|
+
}
|
|
94
|
+
return s.join("").trimEnd();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const lifeText = (c: CardData): string => (c.life ? `expires after ${c.life} idle${c.measured ? " (measured)" : ""}` : "");
|
|
98
|
+
|
|
76
99
|
export function cardText(c: CardData): string[] {
|
|
77
100
|
const rows: [string, string][] = [];
|
|
78
|
-
|
|
79
|
-
if (c.alive?.length) rows.push(["
|
|
101
|
+
// One fact the user can act on ("come back within ~5 min and nothing is touched"), drawn and said: no classes, no jargon.
|
|
102
|
+
if (c.model && c.alive?.length) rows.push(["model", c.model], ["cache", `${c.alive.map(bar).join("")} ${lifeText(c)}`.trimEnd()], ["away", chartAxis()]);
|
|
103
|
+
else if (c.model) rows.push(["cache", `${c.model}${c.life ? ` · ${lifeText(c)}` : ""}`]);
|
|
80
104
|
const n = (x: number, one: string, many = one + "s") => `${x} ${x === 1 ? one : many}`;
|
|
81
|
-
rows.push(["session", `${
|
|
105
|
+
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
106
|
if (c.last) rows.push(["last", c.last]);
|
|
83
|
-
|
|
84
|
-
rows.push(["
|
|
107
|
+
if (c.unzip) rows.push(["unzip", c.unzip]);
|
|
108
|
+
if (c.quiet) rows.push(["notices", "off (/zip quiet turns them back on)"]); // the default says nothing, so it is not shown
|
|
85
109
|
return rows.map(([k, v]) => `${k.padEnd(LABEL_W)}${v}`);
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
export function renderCard(c: CardData, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
89
113
|
const out = renderState(c.state, c.stateNote ?? "", width, th, m);
|
|
90
|
-
const indent = " ";
|
|
114
|
+
const indent = " "; // under the words after the monogram
|
|
115
|
+
// Too narrow for the chart and its axis: the sentence alone says the same thing.
|
|
116
|
+
if (c.alive?.length && width < indent.length + LABEL_W + Math.max(c.alive.length, chartAxis().length)) c = { ...c, alive: undefined };
|
|
117
|
+
let under = "";
|
|
91
118
|
for (const row of cardText(c)) {
|
|
119
|
+
if (under && !row.startsWith("away ")) out.push(under), (under = "");
|
|
120
|
+
if (c.alive?.length && row.startsWith("cache ")) {
|
|
121
|
+
const runs: string[] = [];
|
|
122
|
+
for (let i = 0; i < c.alive.length; ) {
|
|
123
|
+
const on = c.alive[i] >= ALIVE;
|
|
124
|
+
let j = i;
|
|
125
|
+
while (j < c.alive.length && c.alive[j] >= ALIVE === on) j++;
|
|
126
|
+
runs.push(th.fg(on ? "accent" : "dim", c.alive.slice(i, j).map(bar).join("")));
|
|
127
|
+
i = j;
|
|
128
|
+
}
|
|
129
|
+
// The sentence goes beside the bars when it fits there whole, else on its own line under the axis: never cut mid-fact.
|
|
130
|
+
const say = lifeText(c), beside = m.vw(say) <= width - indent.length - LABEL_W - c.alive.length - 2;
|
|
131
|
+
out.push(th.fg("dim", (indent + "cache").padEnd(indent.length + LABEL_W)) + runs.join("") + (say && beside ? " " + th.fg("dim", say) : ""));
|
|
132
|
+
if (say && !beside) under = th.fg("dim", m.cut(" ".repeat(indent.length + LABEL_W) + say, width));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!c.alive?.length && c.model && c.life && row.startsWith("cache ")) {
|
|
136
|
+
// Narrow: keep the fact whole, dropping the model name first, then "(measured)".
|
|
137
|
+
const head = (indent + "cache").padEnd(indent.length + LABEL_W), room = width - m.vw(head);
|
|
138
|
+
const v = [row.slice(LABEL_W), lifeText(c), lifeText({ ...c, measured: false })].find((x) => m.vw(x) <= room);
|
|
139
|
+
if (v) { out.push(th.fg("dim", head + v)); continue; }
|
|
140
|
+
}
|
|
141
|
+
if (row.startsWith("unzip ")) {
|
|
142
|
+
// Narrow: the bracketed reason goes first, so where the files are stays whole.
|
|
143
|
+
const head = (indent + "unzip").padEnd(indent.length + LABEL_W), room = width - m.vw(head);
|
|
144
|
+
const v = [row.slice(LABEL_W), row.slice(LABEL_W).replace(/ \([^()]*\)$/, "")].find((x) => m.vw(x) <= room);
|
|
145
|
+
if (v) { out.push(th.fg("dim", head + v)); continue; }
|
|
146
|
+
}
|
|
92
147
|
const line = m.cut(indent + row, width);
|
|
93
148
|
if (!line) continue;
|
|
94
|
-
const warn = row.startsWith("mode") && c.state === "reread-only";
|
|
95
149
|
const k = line.slice(0, indent.length + LABEL_W), v = line.slice(indent.length + LABEL_W);
|
|
96
|
-
out.push(th.fg("dim", k) + (row.startsWith("session") ? th.fg("text", v) : th.fg(
|
|
150
|
+
out.push(th.fg("dim", k) + (row.startsWith("session") ? th.fg("text", v) : th.fg("dim", v)));
|
|
97
151
|
}
|
|
152
|
+
if (under) out.push(under);
|
|
98
153
|
return out;
|
|
99
154
|
}
|
|
100
155
|
|
|
@@ -110,35 +165,42 @@ export interface RecallSectionInfo {
|
|
|
110
165
|
missing?: boolean;
|
|
111
166
|
}
|
|
112
167
|
|
|
113
|
-
/** "
|
|
114
|
-
|
|
168
|
+
/** "ƶ unzip src/learn.ts:1-2000 /HALF_LIFE/": the same shape as Pi's own tool rows (bold name, accent argument) after the monogram;
|
|
169
|
+
* the argument says WHAT comes back (the folded output's label), not its handle. `labelOf` maps a handle to that label when known. */
|
|
170
|
+
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
171
|
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
|
-
|
|
172
|
+
const label = (h: string) => (labelOf(h.trim()) ?? h).replace(/^read /, ""); // "read src/a.ts" reads as "unzip src/a.ts"
|
|
173
|
+
const what = hs.length > 1 ? `${hs.length} outputs` : hs[0] ? label(hs[0]) : args?.grep ? "all outputs" : ""; // grep without a handle searches every output
|
|
174
|
+
const opt = [args?.grep ? `/${args.grep}/` : "", args?.range ? `lines ${args.range}` : "", args?.offset ? `from char ${args.offset}` : ""].filter(Boolean).join(" · ");
|
|
175
|
+
const head = `${SYM} unzip`;
|
|
176
|
+
const plain = m.cut(`${head} ${what}${opt ? " " + opt : ""}`, width);
|
|
177
|
+
const bold = (t: string) => (th.bold ? th.bold(t) : t);
|
|
178
|
+
if (!plain.startsWith(head + " ")) return [th.fg("dim", plain)];
|
|
179
|
+
const w = plain.slice(head.length + 1, head.length + 1 + what.length);
|
|
180
|
+
return [brand(th) + " " + th.fg("toolTitle", bold("unzip")) + " " + th.fg("accent", w) + th.fg("dim", plain.slice(head.length + 1 + w.length))];
|
|
122
181
|
}
|
|
123
182
|
|
|
124
|
-
export function sectionLine(s: RecallSectionInfo): string {
|
|
183
|
+
export function sectionLine(s: RecallSectionInfo, many = false): string {
|
|
125
184
|
if (s.missing) return `${s.handle} not found`;
|
|
126
|
-
const where = [s.label, s.turn ? `turn ${s.turn}` : ""].filter(Boolean).join(" · ");
|
|
127
185
|
const total = s.totalLines ?? 0;
|
|
128
186
|
const got = s.how === "all" ? `all ${total} lines` : `${s.shownLines ?? 0} of ${total} lines`;
|
|
129
|
-
return `${
|
|
187
|
+
return many ? `${s.label || s.handle} ${got}` : got;
|
|
130
188
|
}
|
|
131
189
|
|
|
132
|
-
|
|
190
|
+
const RECALL_FRAME = /^\[(handle \S+( · |\]| not found)|grep( \(|: | over )|range: lines |chars \d+-\d+ of |… \d+ more chars: )/;
|
|
191
|
+
|
|
192
|
+
/** Collapsed: what came back ("2 of 142 lines"; one line per output for a batch). Expanded: the recalled text, at most maxLines. */
|
|
133
193
|
export function recallResultLines(sections: RecallSectionInfo[], text: string, expanded: boolean, width: number, th: Th, m: Measure = plainMeasure, maxLines = 40): string[] {
|
|
134
194
|
const out: string[] = [];
|
|
135
195
|
for (const s of sections) {
|
|
136
|
-
const l = m.cut(sectionLine(s), width);
|
|
196
|
+
const l = m.cut(sectionLine(s, sections.length > 1), width);
|
|
137
197
|
if (l) out.push(s.missing ? th.fg("error", l) : th.fg("dim", l));
|
|
138
198
|
}
|
|
139
199
|
if (!sections.length && text) out.push(th.fg("dim", m.cut(clean(text.split("\n")[0]), width)));
|
|
140
200
|
if (!expanded || !text) return out;
|
|
141
|
-
|
|
201
|
+
// 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
|
|
202
|
+
const lines = clean(text).split("\n").filter((l) => !RECALL_FRAME.test(l));
|
|
203
|
+
if (!lines.some((l) => l.trim())) return out;
|
|
142
204
|
for (const l of lines.slice(0, maxLines)) out.push(th.fg("toolOutput", m.cut(l, width)));
|
|
143
205
|
if (lines.length > maxLines) out.push(th.fg("dim", m.cut(`… ${lines.length - maxLines} more lines (the model got them all)`, width)));
|
|
144
206
|
return out;
|
|
@@ -146,13 +208,14 @@ export function recallResultLines(sections: RecallSectionInfo[], text: string, e
|
|
|
146
208
|
|
|
147
209
|
// ---- the mark on a folded output ---------------------------------------------------------------------------------------
|
|
148
210
|
|
|
149
|
-
/**
|
|
211
|
+
/** "$ npm test ƶ zipped": the mark follows the call row's own text, if it fits; otherwise the row is left alone. */
|
|
150
212
|
export function markFirstLine(lines: string[], handle: string, width: number, th: Th, m: Measure = plainMeasure): string[] {
|
|
151
213
|
if (!lines.length) return lines;
|
|
152
|
-
|
|
214
|
+
// the handle is in the notice (click / ctrl+o) and in the model's view; here it would only be noise
|
|
153
215
|
// renderers often pad a row to the full width (and may close styles after the padding): drop the padding, keep the codes
|
|
154
216
|
const first = lines[0].replace(/ +((?:\x1b\[[0-9;]*m)*)$/, "$1");
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
|
|
217
|
+
const markW = 2 + m.vw(`${SYM} zipped`);
|
|
218
|
+
if (m.vw(first) + markW > width) return lines;
|
|
219
|
+
const pad = width - m.vw(first) - markW; // keep the row as wide as it was (Pi pads rows to the full width)
|
|
220
|
+
return [first + " " + brand(th) + th.fg("dim", " zipped") + " ".repeat(pad), ...lines.slice(1)];
|
|
158
221
|
}
|
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;
|