pi-zip 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +139 -2
- package/package.json +38 -4
- package/src/cache.ts +116 -0
- package/src/classify.ts +77 -0
- package/src/guard.ts +192 -0
- package/src/index.ts +20 -0
- package/src/learn.ts +141 -0
- package/src/notice.ts +108 -0
- package/src/placeholder.ts +135 -0
- package/src/plan.ts +588 -0
- package/src/recall.ts +199 -0
- package/src/run.ts +649 -0
- package/src/summary.ts +500 -0
- package/src/util.ts +50 -0
package/src/learn.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// What the prompt cache costs and how long it lives, learned from the provider's own usage reports. Counts only, never content.
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { type Any, clamp } from "./util.ts";
|
|
6
|
+
|
|
7
|
+
/** explicit: the provider reports cacheWrite (a write premium, Anthropic style); automatic: it never does (prefix cache, writes cost input). */
|
|
8
|
+
export type CacheClass = "explicit" | "automatic";
|
|
9
|
+
|
|
10
|
+
/** Prices of one prefix token in units of the input price: r = cache read, w = rewrite, out = output. */
|
|
11
|
+
export interface Prices { r: number; w: number; input: number; out: number; cls: CacheClass; src: "class" }
|
|
12
|
+
|
|
13
|
+
/** Price ratios to input per cache class. explicit = Anthropic list multipliers (read 0.1, 5-min write 1.25, 1-h write 2: kappa = w/r - 1
|
|
14
|
+
* = 11.5 / 19); automatic: r = 0.2 (kappa = 4: GLM's own ratio 0.186 -> 4.38 is the only automatic provider measured; the class spans
|
|
15
|
+
* kappa 1..9 and its geometric centre 3 was worse on quality). Output = 4 x the class's 5-min write: 5 (= 50 r) / 4 (= 20 r). */
|
|
16
|
+
export const CLASS_RATIOS: Record<CacheClass, { r: number; w: number }> = { explicit: { r: 0.1, w: 1.25 }, automatic: { r: 0.2, w: 1 } };
|
|
17
|
+
|
|
18
|
+
/** The class ratios, never Pi's price table (model.cost): the law is homogeneous in prices, so ratios are all it needs, and true prices
|
|
19
|
+
* instead of the class ratios moved the bill by 0.0-1.0%. w: explicit 1.25, or 2 on the 1-h tier; automatic 1. The class comes from the
|
|
20
|
+
* usage reports (learned, persisted); unknown before the first response = null (the legacy rule). */
|
|
21
|
+
export function lawPrices(cls: CacheClass | undefined, longTier: boolean): Prices | null {
|
|
22
|
+
if (!cls) return null;
|
|
23
|
+
const d = CLASS_RATIOS[cls];
|
|
24
|
+
const w = cls === "explicit" && longTier ? 2 : d.w;
|
|
25
|
+
return { r: d.r, w, input: 1, out: 4 * d.w, cls, src: "class" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---- cache survival ------------------------------------------------------------------------------------------------
|
|
29
|
+
/** Gap bins (s): [GAP_EDGES[i], GAP_EDGES[i+1]). Edges sit on the known TTL tiers (300 s, 3600 s): a deterministic TTL never splits a bin. */
|
|
30
|
+
export const GAP_EDGES = [30, 60, 120, 180, 240, 300, 330, 360, 420, 480, 600, 900, 1200, 1800, 2700, 3600, 5400];
|
|
31
|
+
export const HALF_LIFE = 16; // observations per bin: old evidence counts half after 16 newer ones in the same bin (a provider may change its TTL)
|
|
32
|
+
// The declared TTL as pseudo-observations. Asymmetric because the signal is: a read of the re-sent prefix cannot happen on a dead cache,
|
|
33
|
+
// but warm misses do (GLM 4-9%, glm-flash ~22%, research round 5). Beyond the TTL one clean read overrides it (weight 1/4: one hit -> 0.8);
|
|
34
|
+
// inside it a lone miss does not (weight 2: one miss -> 0.67, two -> 0.5, three -> 0.4). Otherwise one random miss flips a warm bin to
|
|
35
|
+
// dead, the next returns there fold, and a miss right after a fold is censored: nothing would ever correct it (live smoke, glm-5.3-flash).
|
|
36
|
+
const PRIOR_WEIGHT = { alive: 2, dead: 0.25 };
|
|
37
|
+
// Prefix tokens a sample needs: far above the shared prefix other sessions keep warm (system prompt + tools, ~2-4K; live GLM reads on dead
|
|
38
|
+
// caches: 0 / 704 / 1792 / 3200). Also the head every edit leaves untouched on an automatic cache (plan.ts), so returns stay observable.
|
|
39
|
+
export const MIN_EXPECT = 8192;
|
|
40
|
+
|
|
41
|
+
export interface Entry { cls?: CacheClass; bins: Record<string, [number, number]>; n: number } // bin index -> [alive, dead] (decayed counts)
|
|
42
|
+
interface File { v: 1; models: Record<string, Entry> }
|
|
43
|
+
|
|
44
|
+
export const statsPath = (): string =>
|
|
45
|
+
process.env.PI_ZIP_CACHE_STATS || join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "pi-zip", "cache-survival.json");
|
|
46
|
+
|
|
47
|
+
export const binOf = (gapS: number): number => GAP_EDGES.findLastIndex((e) => gapS >= e); // -1 = below 30 s: not recorded
|
|
48
|
+
const fin = (x: Any) => typeof x === "number" && Number.isFinite(x) && x >= 0;
|
|
49
|
+
|
|
50
|
+
/** The persisted stats; a missing, unreadable or malformed file is ignored (empty). */
|
|
51
|
+
export function loadStats(path = statsPath()): File {
|
|
52
|
+
try {
|
|
53
|
+
const j = JSON.parse(readFileSync(path, "utf8"));
|
|
54
|
+
const out: File = { v: 1, models: {} };
|
|
55
|
+
if (j?.v !== 1 || typeof j.models !== "object" || !j.models) return out;
|
|
56
|
+
for (const [k, e] of Object.entries<Any>(j.models)) {
|
|
57
|
+
const bins: Entry["bins"] = {};
|
|
58
|
+
for (const [b, c] of Object.entries<Any>(e?.bins ?? {})) if (GAP_EDGES[Number(b)] !== undefined && Array.isArray(c) && fin(c[0]) && fin(c[1])) bins[b] = [c[0], c[1]];
|
|
59
|
+
out.models[k] = { bins, n: fin(e?.n) ? e.n : 0, ...(e?.cls === "explicit" || e?.cls === "automatic" ? { cls: e.cls } : {}) };
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
} catch {
|
|
63
|
+
return { v: 1, models: {} };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Was the cache alive after the gap? `expect` = prefix tokens this request re-sent unchanged (the previous prompt, or the untouched prefix
|
|
68
|
+
* before our first edit), `total` = this prompt, `cls` = the model's cache class. One bar, half of max(expect, MIN_EXPECT):
|
|
69
|
+
* alive = the read AND our own unchanged prefix both clear it (a read the shared prefix cannot explain; a smaller untouched prefix still
|
|
70
|
+
* counts when the read covers it: live GLM 6.3K expected, 7.8-9.8K read), dead = a read below half of an expect of at least MIN_EXPECT.
|
|
71
|
+
* null = censored: too small to tell, the prompt shrank under someone else's edit, or a miss after our own edit on a breakpoint cache
|
|
72
|
+
* (explicit: the provider looks back only ~20 blocks from the last breakpoint; an automatic prefix cache reads any untouched head). */
|
|
73
|
+
export function sample(expect: number, cacheRead: number, total = Infinity, edited = false, cls?: CacheClass): boolean | null {
|
|
74
|
+
const e = Math.min(expect, total), bar = 0.5 * Math.max(e, MIN_EXPECT);
|
|
75
|
+
if (cacheRead >= bar && e >= bar) return true;
|
|
76
|
+
if (!(e >= MIN_EXPECT)) return null;
|
|
77
|
+
return total >= expect && (!edited || cls === "automatic") ? false : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Fold one response into the stats (re-read, update, atomic write: concurrent sessions share the file). Returns the model's entry. */
|
|
81
|
+
export function record(key: string, u: { explicit: boolean; total: number; gapS?: number; alive?: boolean | null }, path = statsPath()): Entry {
|
|
82
|
+
const f = loadStats(path);
|
|
83
|
+
const e = (f.models[key] ??= { bins: {}, n: 0 });
|
|
84
|
+
let dirty = false;
|
|
85
|
+
if (u.explicit && e.cls !== "explicit") (e.cls = "explicit"), (dirty = true); // sticky: a write premium once reported stays
|
|
86
|
+
else if (!e.cls && u.total >= 2048) (e.cls = "automatic"), (dirty = true);
|
|
87
|
+
const b = u.gapS !== undefined && typeof u.alive === "boolean" ? binOf(u.gapS) : -1;
|
|
88
|
+
if (b >= 0) {
|
|
89
|
+
const decay = 2 ** (-1 / HALF_LIFE);
|
|
90
|
+
const [a, d] = e.bins[b] ?? [0, 0];
|
|
91
|
+
e.bins[b] = [+(a * decay + (u.alive ? 1 : 0)).toFixed(3), +(d * decay + (u.alive ? 0 : 1)).toFixed(3)];
|
|
92
|
+
e.n++;
|
|
93
|
+
dirty = true;
|
|
94
|
+
}
|
|
95
|
+
if (dirty) {
|
|
96
|
+
try {
|
|
97
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
98
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
99
|
+
writeFileSync(tmp, JSON.stringify(f));
|
|
100
|
+
renameSync(tmp, path);
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
return e;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Monotone (non-increasing in the gap) survival per observed bin: (alive + m x prior) / (n + m), pooled adjacent violators. */
|
|
107
|
+
export function curve(e: Entry | undefined, priorS: number): { bin: number; p: number; n: number }[] {
|
|
108
|
+
const pts = Object.entries(e?.bins ?? {}).map(([b, [a, d]]) => {
|
|
109
|
+
const i = Number(b), mid = Math.sqrt(GAP_EDGES[i] * (GAP_EDGES[i + 1] ?? 2 * GAP_EDGES[i])), pi = mid <= priorS ? 1 : 0;
|
|
110
|
+
const m = pi ? PRIOR_WEIGHT.alive : PRIOR_WEIGHT.dead;
|
|
111
|
+
return { bins: [i], p: (a + m * pi) / (a + d + m), w: a + d + m };
|
|
112
|
+
}).sort((x, y) => x.bins[0] - y.bins[0]);
|
|
113
|
+
const st: typeof pts = [];
|
|
114
|
+
for (const q of pts) {
|
|
115
|
+
st.push(q);
|
|
116
|
+
while (st.length > 1 && st[st.length - 2].p < st[st.length - 1].p) {
|
|
117
|
+
const y = st.pop()!, x = st.pop()!;
|
|
118
|
+
st.push({ bins: [...x.bins, ...y.bins], p: (x.p * x.w + y.p * y.w) / (x.w + y.w), w: x.w + y.w });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return st.flatMap((q) => q.bins.map((bin) => ({ bin, p: q.p, n: (e!.bins[bin][0] + e!.bins[bin][1]) })));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** P(the cache survived a gap of gapS). Observed bin: its monotone estimate. Otherwise the declared TTL (cold start = today's rule), clamped
|
|
125
|
+
* between the nearest observed bins below and above (an alive read at 365 s makes every shorter gap warm; a miss makes longer ones dead). */
|
|
126
|
+
export function pWarm(e: Entry | undefined, gapS: number, priorS: number): { p: number; src: "prior" | "learned" } {
|
|
127
|
+
const prior = gapS <= priorS ? 1 : 0;
|
|
128
|
+
const c = curve(e, priorS), b = binOf(gapS);
|
|
129
|
+
const at = c.find((x) => x.bin === b);
|
|
130
|
+
if (at) return { p: at.p, src: "learned" };
|
|
131
|
+
const hi = c.filter((x) => x.bin < b).at(-1)?.p ?? 1, lo = c.find((x) => x.bin > b)?.p ?? 0;
|
|
132
|
+
const p = clamp(prior, lo, hi);
|
|
133
|
+
return { p, src: p === prior ? "prior" : "learned" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** /zip status text: class and the learned curve ("360-420s 0.80 n1"). */
|
|
137
|
+
export function describe(e: Entry | undefined, priorS: number): string {
|
|
138
|
+
const c = curve(e, priorS);
|
|
139
|
+
const bins = c.map((x) => `${GAP_EDGES[x.bin]}-${GAP_EDGES[x.bin + 1] ?? "∞"}s ${x.p.toFixed(2)} n${Math.round(x.n * 10) / 10}`).join(", ");
|
|
140
|
+
return `survival ${bins || `none learned yet (declared TTL ${Math.round(priorS)} s)`} · ${e?.n ?? 0} sample${e?.n === 1 ? "" : "s"}`;
|
|
141
|
+
}
|
package/src/notice.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// User-facing text (F15, F16): one notice line per turn, honest stats, the /zip command. Notices never enter model context.
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type Any, PRODUCT } from "./util.ts";
|
|
4
|
+
|
|
5
|
+
export interface NoticeAction {
|
|
6
|
+
kind: "fold" | "summary";
|
|
7
|
+
count: number;
|
|
8
|
+
tokensBefore: number;
|
|
9
|
+
tokensAfter: number;
|
|
10
|
+
ms: number;
|
|
11
|
+
prepared?: boolean; // summary computed while the user was away
|
|
12
|
+
pressure?: boolean; // done by the warm valve (the law fired while the cache is still warm), not because the cache was cold
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const fmtK = (tokens: number): string => `${tokens >= 99_500 ? Math.round(tokens / 1000) : Math.round(tokens / 100) / 10}K`;
|
|
16
|
+
|
|
17
|
+
export function noticeText(actions: NoticeAction[]): string {
|
|
18
|
+
const segs = actions.map((a) => {
|
|
19
|
+
const sizes = `${fmtK(a.tokensBefore)} → ${fmtK(a.tokensAfter)} tokens`;
|
|
20
|
+
if (a.kind === "fold") {
|
|
21
|
+
const ms = a.ms < 10 ? (Math.round(a.ms * 10) / 10).toFixed(1) : String(Math.round(a.ms));
|
|
22
|
+
return `folded ${a.count} old output${a.count === 1 ? "" : "s"} · ${sizes} · ${ms} ms · originals recallable${a.pressure ? " · context over the warm-cache limit" : ""}`;
|
|
23
|
+
}
|
|
24
|
+
const s = (Math.round(a.ms / 100) / 10).toFixed(1);
|
|
25
|
+
return `summarized ${a.count} request${a.count === 1 ? "" : "s"} · ${sizes} · ${a.prepared ? `${s} s (done while you were away)` : `waited ${s} s`}`;
|
|
26
|
+
});
|
|
27
|
+
return `${PRODUCT} · ${segs.join(" · ")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Prices are $ per million tokens, as declared by the model. */
|
|
31
|
+
export interface Prices {
|
|
32
|
+
cacheWrite: number;
|
|
33
|
+
cacheRead: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class Stats {
|
|
37
|
+
folds = 0; // outputs folded
|
|
38
|
+
foldedTokens = 0; // tokens removed by folds (estimate: chars/4)
|
|
39
|
+
summaries = 0;
|
|
40
|
+
summarizedTokens = 0; // tokens removed by summaries
|
|
41
|
+
summaryMs = 0;
|
|
42
|
+
summaryUsd = 0; // what the narrative model calls cost
|
|
43
|
+
recalls = 0;
|
|
44
|
+
recallChars = 0;
|
|
45
|
+
pressureEdits = 0; // warm-valve edits
|
|
46
|
+
pressureRewriteTokens = 0; // upper bound: a valve edit on a warm cache may rewrite the whole context
|
|
47
|
+
writeSavedTokens = 0; // tokens NOT rewritten at a cold return
|
|
48
|
+
readSavedTokens = 0; // tokens NOT read, summed over every request sent after an edit
|
|
49
|
+
activeSaved = 0; // tokens currently removed from the context by our edits
|
|
50
|
+
notices = 0;
|
|
51
|
+
|
|
52
|
+
/** Estimated $ saved versus doing nothing: avoided cache writes and reads, minus summary calls, recalled content re-entering the context, and warm-valve rewrites. */
|
|
53
|
+
savedUsd(p: Prices | null): number | null {
|
|
54
|
+
if (!p) return null;
|
|
55
|
+
const gross = (this.writeSavedTokens * p.cacheWrite + this.readSavedTokens * p.cacheRead) / 1e6;
|
|
56
|
+
const recall = (this.recallChars / 4) * p.cacheWrite / 1e6;
|
|
57
|
+
const pressure = this.pressureRewriteTokens * Math.max(p.cacheWrite - p.cacheRead, 0) / 1e6;
|
|
58
|
+
return gross - this.summaryUsd - recall - pressure;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const pricesOf = (model: Any): Prices | null => {
|
|
63
|
+
const c = model?.cost;
|
|
64
|
+
const cw = c?.cacheWrite > 0 ? c.cacheWrite : c?.input > 0 ? c.input : 0;
|
|
65
|
+
const cr = c?.cacheRead > 0 ? c.cacheRead : cw * 0.1;
|
|
66
|
+
return cw > 0 ? { cacheWrite: cw, cacheRead: cr } : null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function statsText(s: Stats, model: Any): string {
|
|
70
|
+
const usd = s.savedUsd(pricesOf(model));
|
|
71
|
+
const money = usd === null ? "n/a (model declares no prices)" : `${usd < 0 ? "-" : ""}$${Math.abs(usd).toFixed(usd !== 0 && Math.abs(usd) < 0.1 ? 4 : 2)}`;
|
|
72
|
+
return (
|
|
73
|
+
`${PRODUCT} stats (since pi started): folded ${s.folds} output${s.folds === 1 ? "" : "s"} (${fmtK(s.foldedTokens)} tokens), ` +
|
|
74
|
+
`${s.summaries} summar${s.summaries === 1 ? "y" : "ies"} (${fmtK(s.summarizedTokens)} tokens, ${(s.summaryMs / 1000).toFixed(1)} s waited, $${s.summaryUsd.toFixed(4)}), ` +
|
|
75
|
+
`${s.recalls} recall${s.recalls === 1 ? "" : "s"} (~${fmtK(s.recallChars / 4)} tokens re-read). ` +
|
|
76
|
+
`Estimated saved vs doing nothing: ${money}, counting avoided cache writes and reads minus summary calls, recalled content and warm-valve rewrites; token counts are chars/4 estimates.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ZipControl {
|
|
81
|
+
status(): string;
|
|
82
|
+
statsLine(): string;
|
|
83
|
+
setOff(off: boolean): string;
|
|
84
|
+
toggleQuiet(): string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function registerZipCommand(pi: ExtensionAPI, zip: ZipControl) {
|
|
88
|
+
pi.registerCommand("zip", {
|
|
89
|
+
description: `${PRODUCT}: status | stats | off | on | quiet`,
|
|
90
|
+
handler: async (args: string, cctx: Any) => {
|
|
91
|
+
const sub = (args ?? "").trim().toLowerCase() || "status";
|
|
92
|
+
const text =
|
|
93
|
+
sub === "status" ? zip.status()
|
|
94
|
+
: sub === "stats" ? zip.statsLine()
|
|
95
|
+
: sub === "off" ? zip.setOff(true)
|
|
96
|
+
: sub === "on" ? zip.setOff(false)
|
|
97
|
+
: sub === "quiet" ? zip.toggleQuiet()
|
|
98
|
+
: `${PRODUCT}: unknown subcommand "${sub}" (use status | stats | off | on | quiet)`;
|
|
99
|
+
if (cctx?.ui?.notify && (cctx.mode === "tui" || cctx.mode === "rpc")) {
|
|
100
|
+
try {
|
|
101
|
+
cctx.ui.notify(text);
|
|
102
|
+
return;
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
process.stderr.write(text + "\n"); // stdout carries the program's own output (JSON mode): never mix command text into it
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Placeholders (F2): tool + args + size + key lines + handle. Handles are deterministic in the session entry id.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { Block, Calls } from "./plan.ts";
|
|
4
|
+
import { type Any, clip, textOf } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
export const PH_MARK = "[folded by pi-zip";
|
|
7
|
+
export const RECALL_TOOL = "zip_recall";
|
|
8
|
+
const MIN_FOLD_CHARS = 1000; // shorter outputs are never worth a placeholder
|
|
9
|
+
|
|
10
|
+
/** Deterministic recall handle for a session entry id: 10-char base36 of sha256("pi-zip:"+id)[0..12). */
|
|
11
|
+
export function handleFor(entryId: string): string {
|
|
12
|
+
const hex = createHash("sha256").update(`pi-zip:${entryId}`).digest("hex").slice(0, 12);
|
|
13
|
+
return BigInt("0x" + hex).toString(36).padStart(Math.ceil(48 / Math.log2(36)), "0").slice(-10);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface KeyLine {
|
|
17
|
+
no: number;
|
|
18
|
+
text: string;
|
|
19
|
+
why: "error" | "id" | "first" | "last";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ERROR_LINE_RE = /error|fail|exception|warning/i;
|
|
23
|
+
const ID_LINE_RE = /\b[A-Z]{2,}-[A-Z0-9-]{3,}\b/;
|
|
24
|
+
const KEYVAL_LINE_RE = /\b[A-Za-z_][A-Za-z0-9_.-]*=(?:"[^"]*"|\S+)/;
|
|
25
|
+
const LABEL_NUM_RE = /\b[A-Za-z][A-Za-z0-9_. \/-]{0,30}:\s*-?\d|\b-?\d[\d.,]*\s+(?:ms|s|kb|mb|gb|tokens?|bytes|lines)\b/i;
|
|
26
|
+
const HEXISH_RE = /\b[0-9a-f]{8,}\b/gi;
|
|
27
|
+
|
|
28
|
+
const hexLike = (line: string): boolean => {
|
|
29
|
+
for (const m of line.matchAll(HEXISH_RE)) if (/[a-f]/i.test(m[0]) && /\d/.test(m[0])) return true; // letters+digits reads as an id/hash
|
|
30
|
+
return false;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Deterministic key lines: first line, error/warning lines, id-like lines (tickets, hashes, key=value, labelled numbers), last line; sorted by line number. */
|
|
34
|
+
export function pickKeyLines(text: string, keep = 8): KeyLine[] {
|
|
35
|
+
const lines = text.split("\n");
|
|
36
|
+
if (keep <= 0 || !lines.length) return [];
|
|
37
|
+
const picked = new Map<number, KeyLine>();
|
|
38
|
+
const add = (no: number, why: KeyLine["why"]) => {
|
|
39
|
+
if (picked.size >= keep || picked.has(no)) return;
|
|
40
|
+
const t = lines[no - 1] ?? "";
|
|
41
|
+
if (t.trim()) picked.set(no, { no, text: t, why });
|
|
42
|
+
};
|
|
43
|
+
const room = () => picked.size < keep - (picked.has(lines.length) ? 0 : 1); // always leave room for the last line
|
|
44
|
+
add(1, "first");
|
|
45
|
+
for (let i = 0; i < lines.length && room(); i++) if (i > 0 && i < lines.length - 1 && ERROR_LINE_RE.test(lines[i])) add(i + 1, "error");
|
|
46
|
+
for (let i = 0; i < lines.length && room(); i++) {
|
|
47
|
+
const no = i + 1;
|
|
48
|
+
if (no === 1 || no === lines.length || picked.has(no)) continue;
|
|
49
|
+
if (ID_LINE_RE.test(lines[i]) || hexLike(lines[i]) || KEYVAL_LINE_RE.test(lines[i]) || LABEL_NUM_RE.test(lines[i])) add(no, "id");
|
|
50
|
+
}
|
|
51
|
+
add(lines.length, "last");
|
|
52
|
+
return [...picked.values()].sort((a, b) => a.no - b.no);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Collapse whitespace and cut to `n` chars keeping head AND tail (the distinguishing part of a path or command is often its end). */
|
|
56
|
+
export function clipMid(s: string, n: number): string {
|
|
57
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
58
|
+
if (s.length <= n) return s;
|
|
59
|
+
const head = Math.ceil((n - 1) * 0.6);
|
|
60
|
+
return s.slice(0, head) + "…" + s.slice(s.length - (n - 1 - head));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ARG_CHARS = 90;
|
|
64
|
+
|
|
65
|
+
export function shortArgs(args: Any): string {
|
|
66
|
+
if (!args || typeof args !== "object") return "";
|
|
67
|
+
if (typeof args.command === "string") return clipMid(args.command, ARG_CHARS);
|
|
68
|
+
const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : null;
|
|
69
|
+
if (path !== null && typeof args.pattern !== "string") {
|
|
70
|
+
const win = ["offset", "limit"].filter((k) => typeof args[k] === "number" && Number.isFinite(args[k])).map((k) => ` ${k}=${args[k]}`).join(""); // which slice of the file
|
|
71
|
+
return clipMid(path, ARG_CHARS) + win;
|
|
72
|
+
}
|
|
73
|
+
if (typeof args.pattern === "string") return clipMid(args.pattern + (args.path ? " " + args.path : ""), ARG_CHARS);
|
|
74
|
+
try {
|
|
75
|
+
return clipMid(JSON.stringify(args), ARG_CHARS);
|
|
76
|
+
} catch {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const COUNT_RE = /\b(\d+)\s+(passed|passing|pass|failed|failing|fail|skipped|errors?)\b/gi;
|
|
82
|
+
const COUNT_ORDER = ["passed", "failed", "skipped", "errors"];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Deterministic one-line outcome of a command, from the output alone: exit status (bash) and test counts when the tail of the
|
|
86
|
+
* output has "N passed / N failed" style summaries. `isError` (the tool result flag) lets a bash result without an exit-code
|
|
87
|
+
* line read as "exit 0" or "error"; leave it undefined when unknown. Empty string = nothing distinctive to say.
|
|
88
|
+
*/
|
|
89
|
+
export function outcomeHint(text: string, tool: string, isError?: boolean): string {
|
|
90
|
+
if (tool !== "bash") return "";
|
|
91
|
+
const tail = text.slice(-2000);
|
|
92
|
+
const parts: string[] = [];
|
|
93
|
+
const ex = [...tail.matchAll(/Command exited with code (\d+)/g)].pop();
|
|
94
|
+
if (ex) parts.push(`exit ${ex[1]}`);
|
|
95
|
+
else if (/Command timed out/.test(tail)) parts.push("timed out");
|
|
96
|
+
else if (/Command aborted/.test(tail)) parts.push("aborted");
|
|
97
|
+
else if (isError === true) parts.push("error");
|
|
98
|
+
else if (isError === false) parts.push("exit 0");
|
|
99
|
+
const last = new Map<string, string>();
|
|
100
|
+
for (const m of tail.matchAll(COUNT_RE)) {
|
|
101
|
+
const w = m[2].toLowerCase();
|
|
102
|
+
last.set(w.startsWith("pass") ? "passed" : w.startsWith("fail") ? "failed" : w === "skipped" ? "skipped" : "errors", m[1]);
|
|
103
|
+
}
|
|
104
|
+
if (last.has("passed") || last.has("failed")) parts.push(COUNT_ORDER.filter((k) => last.has(k)).map((k) => `${last.get(k)} ${k}`).join(", "));
|
|
105
|
+
return parts.join(", ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface PlaceholderMeta {
|
|
109
|
+
turn?: number; // user turn the output belongs to (1-based, as seen in the projection when it was folded)
|
|
110
|
+
isError?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Placeholder text, or null when the output is too short to be worth folding. The text is stored with the fold (context_edit),
|
|
114
|
+
* so it is rendered once and old folds keep their bytes whatever this format becomes. */
|
|
115
|
+
export function makePlaceholder(text: string, tool: string, args: string, handle: string, keep = 8, meta: PlaceholderMeta = {}): string | null {
|
|
116
|
+
if (text.length <= MIN_FOLD_CHARS) return null;
|
|
117
|
+
const lines = text.split("\n").length;
|
|
118
|
+
const keys = pickKeyLines(text, keep);
|
|
119
|
+
const body = keys.map((k) => `${k.no}: ${clip(k.text, 160)}`).join("\n");
|
|
120
|
+
const outcome = outcomeHint(text, tool, meta.isError);
|
|
121
|
+
return (
|
|
122
|
+
`${PH_MARK} · ${tool}${args ? " " + args : ""}${meta.turn ? ` · turn ${meta.turn}` : ""}${outcome ? ` · ${outcome}` : ""} · ${text.length} chars, ${lines} lines · handle ${handle}]\n` +
|
|
123
|
+
(keys.length ? `key lines kept (original line numbers; up to ${keep}):\n${body}\n` : "") +
|
|
124
|
+
`Original kept byte for byte, recallable even after summaries or compaction: ${RECALL_TOOL}("${handle}") (optional grep/range) is instant, free, no side effects; prefer it to re-running or re-reading (output may differ). Do not guess its content.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Placeholder for a toolResult block (null for image results, entries without an id, or short outputs). */
|
|
129
|
+
export function makePlaceholderFor(b: Block, calls: Calls, keep = 8): string | null {
|
|
130
|
+
const content = b.msg.content;
|
|
131
|
+
if (Array.isArray(content) && content.some((c: Any) => c?.type !== "text")) return null;
|
|
132
|
+
if (!b.entryId) return null;
|
|
133
|
+
const call = calls.get(b.msg.toolCallId);
|
|
134
|
+
return makePlaceholder(textOf(content), call?.name ?? b.msg.toolName ?? "tool", call ? shortArgs(call.args) : "", handleFor(b.entryId), keep, { turn: b.userTurn, isError: !!b.msg.isError });
|
|
135
|
+
}
|