[Sync] Lua Hyprland chain, licence cleanup
This commit is contained in:
1 parent
93b4f695ce
commit
47166d877f
183 files changed
+5618
-5839
No files matched your search
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Pi agent-state extension: mirrors Pi's state into the tmux pane it runs in via
|
||||
* ~/.config/tmux/scripts/agent-state.sh (pi busy|done|input|off), so the tmux status bar
|
||||
* shows which pane is done or waiting for you. The script is a no-op outside tmux.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { execFile } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SCRIPT = join(homedir(), ".config", "tmux", "scripts", "agent-state.sh");
|
||||
|
||||
function state(s: "busy" | "done" | "input" | "error" | "off"): void {
|
||||
if (!process.env.TMUX_PANE) return;
|
||||
execFile(SCRIPT, ["pi", s], () => {});
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("agent_start", async () => state("busy"));
|
||||
// agent_settled, not agent_end: Pi may still retry, compact, or run queued follow-ups.
|
||||
pi.on("agent_settled", async () => state("done"));
|
||||
// Blocking confirm/select prompts from extensions (permission-gate, confirm-destructive, questionnaire).
|
||||
pi.on("ui_prompt_start", async () => state("input"));
|
||||
pi.on("ui_prompt_end", async () => state("busy"));
|
||||
pi.on("session_shutdown", async () => state("off"));
|
||||
}
|
||||
@@ -3,31 +3,94 @@
|
||||
*
|
||||
* Compaction is otherwise silent, which is why "compacting fails a lot" took code
|
||||
* reading and arithmetic to diagnose rather than a glance at a log. This makes it
|
||||
* visible, and specifically names the failure mode that was breaking it here before
|
||||
* 2026-08-16.
|
||||
* visible and, for the one reason that signals trouble, says WHICH trouble.
|
||||
*
|
||||
* pi reports a `reason` on every compaction:
|
||||
* "manual" - you ran /compact
|
||||
* "threshold" - the healthy path: contextTokens passed contextWindow - reserveTokens
|
||||
* "overflow" - the window was ALREADY full; this is recovery, not prevention
|
||||
* "overflow" - recovery, not prevention: the previous assistant turn ended badly,
|
||||
* pi compacts and (when `willRetry`) re-runs that turn once
|
||||
*
|
||||
* An "overflow" compaction means the turn blew past contextWindow before the threshold
|
||||
* could fire, which happens whenever `compaction.reserveTokens` is smaller than the
|
||||
* model's `maxTokens`. pi then gets one recovery attempt, summarizing from an
|
||||
* already-overflowed context — and when that does not fit either, the session dies with
|
||||
* "Context overflow recovery failed after one compact-and-retry attempt".
|
||||
* Since pi 0.84.3 "overflow" covers TWO different situations, and the numbers on the
|
||||
* last assistant message tell them apart (CLAUDE.md > Compaction tuning has the
|
||||
* arithmetic for both):
|
||||
*
|
||||
* So: seeing "threshold" is fine. Seeing "overflow" means the settings are wrong for the
|
||||
* model in use, and this says so at the moment it happens instead of leaving you to
|
||||
* infer it later. See CLAUDE.md > Compaction tuning.
|
||||
* 1. A real overflow — the server rejected the prompt (stopReason "error" matching
|
||||
* pi's overflow patterns), or reported more input than the declared window.
|
||||
* Fix: contextWindow / reserveTokens / maxTokens are wrong for the model in use.
|
||||
* 2. Truncated-reply recovery — the reply stopped with stopReason "length" while
|
||||
* usage.output is BELOW the model's maxTokens. The request itself carried a smaller
|
||||
* max_tokens: before every call pi-ai clamps it to
|
||||
* `contextWindow − its own token estimate − 4096` (clampMaxTokensToContext,
|
||||
* CONTEXT_SAFETY_TOKENS) and then shrinks the thinking budget so that only 1024
|
||||
* answer tokens remain. The window was never full. On a 24k window with maxTokens
|
||||
* 4096 the clamp bites at any estimate above 16384 — the same number as the
|
||||
* compaction threshold with reserveTokens 8192 — so it hits in the gaps the threshold
|
||||
* check never sees: the first request after a compaction (no server usage to anchor
|
||||
* on, so the system prompt and the tool schemas join the estimate) and the first call
|
||||
* of a user turn (the new message). Thinking models feel it first: 2048 of thinking
|
||||
* leaves a clamped reply about 1024 tokens of answer, and a `write` payload is cut.
|
||||
*
|
||||
* The old message here blamed every "overflow" on `reserveTokens < maxTokens`, which is
|
||||
* wrong for case 2 — the one that actually shows up (Ornith, 2026-10-22). Now the
|
||||
* notification names the case and prints the numbers (output vs maxTokens, context vs
|
||||
* window, the estimate the clamp implies), so the next occurrence is diagnosable from the
|
||||
* screen instead of from a session file.
|
||||
*
|
||||
* Note there is no event for a *failed* auto-compaction — `onError` exists only on the
|
||||
* manual `ctx.compact()` call — so catching the overflow entry is the earliest reliable
|
||||
* warning available.
|
||||
* warning available. Observe-only: it never alters the compaction itself.
|
||||
*/
|
||||
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** pi-ai's CONTEXT_SAFETY_TOKENS: the margin clampMaxTokensToContext keeps below the window. */
|
||||
const PI_CONTEXT_SAFETY_TOKENS = 4096;
|
||||
|
||||
/** Last assistant message on the branch — the turn that triggered the recovery. */
|
||||
function lastAssistant(entries: readonly unknown[]): AssistantMessage | undefined {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const e = entries[i] as { type?: string; message?: { role?: string } };
|
||||
if (e.type === "message" && e.message?.role === "assistant") return e.message as AssistantMessage;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One line saying which kind of "overflow" this is, with the numbers that prove it. */
|
||||
export function describeOverflow(last: AssistantMessage | undefined, window: number, maxTokens: number): string {
|
||||
if (!last) return "no assistant message on the branch to inspect.";
|
||||
const u = last.usage;
|
||||
const output = u?.output ?? 0;
|
||||
const context = (u?.input ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0) + output;
|
||||
const ctxStr = window > 0 ? `${context}/${window}` : `${context}`;
|
||||
const checkWindow = "check contextWindow against the server ctx-size (CLAUDE.md > Compaction tuning).";
|
||||
|
||||
if (last.stopReason === "error") {
|
||||
return `the server rejected the request at context ${ctxStr} (${last.errorMessage ?? "no message"}) — a real overflow; ${checkWindow}`;
|
||||
}
|
||||
if (last.stopReason === "length") {
|
||||
if (output === 0 && window > 0 && context >= window * 0.99) {
|
||||
return `the server produced no tokens at context ${ctxStr} — the prompt alone filled the window; ${checkWindow}`;
|
||||
}
|
||||
if (window > 0 && context >= window - 64) {
|
||||
return (
|
||||
`the server ran out of context mid-reply (${ctxStr}, output ${output}/${maxTokens}) — the prompt was ` +
|
||||
`under-estimated (big tool results or images count more than chars ÷ 4); not a reserveTokens problem.`
|
||||
);
|
||||
}
|
||||
if (maxTokens > 0 && output < maxTokens) {
|
||||
const estimate = window > 0 ? ` pi's estimate at request time ≈ ${window - PI_CONTEXT_SAFETY_TOKENS - output}, server-side context ${ctxStr}.` : "";
|
||||
return (
|
||||
`truncated-reply recovery, NOT a full window: the reply stopped at ${output} of ${maxTokens} max tokens, ` +
|
||||
`so pi had clamped max_tokens to window − estimate − ${PI_CONTEXT_SAFETY_TOKENS}.${estimate} ` +
|
||||
`reserveTokens is not the knob — see CLAUDE.md > Compaction tuning > max_tokens clamp.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return `stopReason ${last.stopReason}, context ${ctxStr}, output ${output}/${maxTokens} — input reported above the declared window; ${checkWindow}`;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let overflowCount = 0;
|
||||
|
||||
@@ -36,12 +99,9 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
if (event.reason === "overflow") {
|
||||
overflowCount++;
|
||||
ctx.ui.notify(
|
||||
`Compacting in OVERFLOW recovery (#${overflowCount}) — the context window was already ` +
|
||||
`full before compaction could fire. Raise compaction.reserveTokens above this model's ` +
|
||||
`maxTokens, or cap maxTokens in models.json. See CLAUDE.md > Compaction tuning.`,
|
||||
"warning",
|
||||
);
|
||||
const model = ctx.model;
|
||||
const verdict = describeOverflow(lastAssistant(event.branchEntries), model?.contextWindow ?? 0, model?.maxTokens ?? 0);
|
||||
ctx.ui.notify(`Compacting in OVERFLOW recovery (#${overflowCount}) — ${verdict}`, "warning");
|
||||
}
|
||||
|
||||
// Never alter the compaction itself — observe only.
|
||||
|
||||
@@ -17,7 +17,13 @@
|
||||
* model model id + · thinking level (Claude 🤖) U+F06A9 nf-md-robot
|
||||
* context % of context window used (+ tokens) (Claude 📝) U+F021A nf-md-text_box
|
||||
* t/s last response's decode throughput U+F04C5 nf-md-speedometer
|
||||
* ↑ ↓ tokens session input / output tokens (Claude 💰) plain arrows
|
||||
* (llama.cpp's own server-side number + dim ` pp N` prompt-processing
|
||||
* speed when the final SSE chunk carries `timings`; otherwise the
|
||||
* client-side clock -- see "server-side timings" below)
|
||||
* ↑ ↓ tokens session prompt / output tokens (Claude 🔢) plain arrows
|
||||
* (prompt = input + cache read + cache write: everything the server
|
||||
* processed, matching the Claude bar — llama.cpp reports a cached
|
||||
* prefix as cacheRead, so bare usage.input is a few dozen tokens)
|
||||
*
|
||||
* git, context, t/s and tokens only appear once there is data for them (i.e. after
|
||||
* the first response, and git only inside a git repo). Extension statuses (e.g.
|
||||
@@ -58,12 +64,111 @@ const CTX_WARN_AT = 80;
|
||||
// direct access to it). Defaults to the settings.json default of "off".
|
||||
let thinkingLevel = "off";
|
||||
|
||||
// Decode throughput (tokens/sec) of the most recent assistant response, measured
|
||||
// from the first streamed token to message end so prompt-eval time is excluded.
|
||||
// Decode throughput (tokens/sec) of the most recent assistant response. Preferred
|
||||
// source: the server's own timings (below). Fallback: a client-side clock from the
|
||||
// first streamed token to message end, so prompt-eval time is excluded.
|
||||
let lastTps = 0;
|
||||
let lastPp = 0; // prompt-processing t/s, server-side only (0 = unknown)
|
||||
let genFirstTokenAt = 0;
|
||||
let sawToken = false;
|
||||
|
||||
// --- server-side timings (llama.cpp) ----------------------------------------
|
||||
// llama.cpp puts a `timings` object on the final SSE chunk (the one carrying `usage`):
|
||||
// prompt_n / prompt_ms / prompt_per_second, predicted_n / predicted_ms /
|
||||
// predicted_per_second, cache_n, draft_n / draft_n_accepted. pi-ai's openai-completions
|
||||
// provider reads only `usage` and drops the rest, and the extension API has no HTTP
|
||||
// hook, so the one way to see it is to wrap globalThis.fetch and tee the SSE body.
|
||||
// That is sanctioned: pi's http-dispatcher installs undici's fetch at startup and
|
||||
// deliberately keeps a later override ("If a caller replaced fetch after module load,
|
||||
// preserve that deliberate override"), and pi-ai builds `new OpenAI({ fetch: undefined })`
|
||||
// per request, which falls back to globalThis.fetch -- so a wrapper installed at
|
||||
// session_start sees every chat completion. Only POST */chat/completions responses
|
||||
// with a text/event-stream body are teed; other providers' streams carry no `timings`
|
||||
// and are passed through untouched apart from the tee. The tap stays installed for the
|
||||
// life of the process (`/statusbar` off just stops displaying it; `/reload` rebinds the
|
||||
// sink instead of wrapping twice).
|
||||
interface ServerTimings {
|
||||
tps: number; // predicted_per_second
|
||||
pp: number; // prompt_per_second
|
||||
predictedN: number;
|
||||
promptN: number;
|
||||
cacheN: number;
|
||||
}
|
||||
// Latest timings seen on the wire, consumed by message_end. Paired with the message by
|
||||
// predicted_n === usage.output, so a compaction/summary call's timings never get
|
||||
// attributed to the next visible response.
|
||||
let pendingServerTimings: ServerTimings | undefined;
|
||||
|
||||
const FETCH_TAP_KEY = "__piStatusbarFetchTap";
|
||||
|
||||
/** Read one branch of a teed SSE body and report every `timings` object it carries. */
|
||||
async function scanSse(stream: ReadableStream<Uint8Array>, onTimings: (t: ServerTimings) => void): Promise<void> {
|
||||
const reader = stream.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload.includes('"timings"')) continue; // cheap pre-filter: parse only the final chunk
|
||||
try {
|
||||
const t = JSON.parse(payload)?.timings;
|
||||
if (t && typeof t.predicted_per_second === "number") {
|
||||
onTimings({
|
||||
tps: t.predicted_per_second,
|
||||
pp: typeof t.prompt_per_second === "number" ? t.prompt_per_second : 0,
|
||||
predictedN: t.predicted_n ?? 0,
|
||||
promptN: t.prompt_n ?? 0,
|
||||
cacheN: t.cache_n ?? 0,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// not JSON: ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// aborted / cancelled stream: the primary branch reports that, we just stop
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap globalThis.fetch once; on /reload only rebind the sink. */
|
||||
function installFetchTap(): void {
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
const sink = (t: ServerTimings) => {
|
||||
pendingServerTimings = t;
|
||||
};
|
||||
const existing = g[FETCH_TAP_KEY] as { sink: (t: ServerTimings) => void } | undefined;
|
||||
if (existing) {
|
||||
existing.sink = sink;
|
||||
return;
|
||||
}
|
||||
const tap = { sink };
|
||||
g[FETCH_TAP_KEY] = tap;
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
||||
const res = await orig(input, init);
|
||||
let url = "";
|
||||
try {
|
||||
url = typeof input === "string" ? input : input instanceof URL ? input.href : ((input as Request)?.url ?? "");
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
if (!url.endsWith("/chat/completions") || !res.body) return res;
|
||||
if (!(res.headers.get("content-type") ?? "").includes("text/event-stream")) return res;
|
||||
const [ours, theirs] = res.body.tee();
|
||||
void scanSse(ours, (t) => tap.sink(t));
|
||||
return new Response(theirs, res);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
// Set from inside the footer factory so events can trigger a re-render.
|
||||
let requestRender: (() => void) | undefined;
|
||||
|
||||
@@ -74,12 +179,27 @@ function collapseHome(p: string): string {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 1234 -> 1.2k, 843000 -> 843k, 1234567 -> 1.2M (same boundaries as the Claude bar's
|
||||
* fmt_tokens: 0.9995 / 9.95 so a value never prints as "10.0k" or "1000k"). */
|
||||
function fmtTokens(n: number): string {
|
||||
return n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
|
||||
for (const [unit, div] of [
|
||||
["M", 1e6],
|
||||
["k", 1e3],
|
||||
] as const) {
|
||||
const v = n / div;
|
||||
if (v < 0.9995) continue;
|
||||
return v >= 9.95 ? `${v.toFixed(0)}${unit}` : `${v.toFixed(1)}${unit}`;
|
||||
}
|
||||
return `${Math.round(n)}`;
|
||||
}
|
||||
|
||||
/** Cumulative session tokens, plus the last request's input tokens (~ current
|
||||
* context-window fill). */
|
||||
/** Cumulative session tokens, plus the last request's prompt size (~ current
|
||||
* context-window fill).
|
||||
*
|
||||
* Prompt = input + cacheRead + cacheWrite. pi-ai splits a cached prefix out of
|
||||
* `usage.input` (llama.cpp too: the second call of a session reports e.g. input 20,
|
||||
* cacheRead 2417), so summing bare `input` undercounted ↑ and -- worse -- made the
|
||||
* context % and the ≥80% warning read near zero on every cached call. */
|
||||
function tokenStats(ctx: ExtensionContext): {
|
||||
input: number;
|
||||
output: number;
|
||||
@@ -92,9 +212,10 @@ function tokenStats(ctx: ExtensionContext): {
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const usage = (entry.message as AssistantMessage).usage;
|
||||
if (!usage) continue; // aborted stream: no usage — a throwing render kills the TUI
|
||||
input += usage.input;
|
||||
output += usage.output;
|
||||
lastInput = usage.input;
|
||||
const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
input += prompt;
|
||||
output += usage.output ?? 0;
|
||||
lastInput = prompt;
|
||||
}
|
||||
}
|
||||
return { input, output, lastInput };
|
||||
@@ -161,8 +282,19 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.on("message_end", async (event, ctx) => {
|
||||
if (event.message?.role !== "assistant") return;
|
||||
const output = (event.message as AssistantMessage).usage?.output ?? 0;
|
||||
const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0;
|
||||
if (secs > 0 && output > 0) lastTps = output / secs;
|
||||
// The tee reader and pi-ai consume the same final chunk; pi-ai's path to this
|
||||
// event is longer, but give the reader one macrotask in case it is still behind.
|
||||
if (!pendingServerTimings) await new Promise((r) => setTimeout(r, 20));
|
||||
const srv = pendingServerTimings;
|
||||
pendingServerTimings = undefined;
|
||||
if (srv && srv.predictedN > 0 && (output === 0 || srv.predictedN === output)) {
|
||||
lastTps = srv.tps;
|
||||
lastPp = srv.pp;
|
||||
} else {
|
||||
const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0;
|
||||
if (secs > 0 && output > 0) lastTps = output / secs;
|
||||
lastPp = 0;
|
||||
}
|
||||
requestRender?.();
|
||||
if (enabled) updateContextWidget(ctx);
|
||||
});
|
||||
@@ -217,14 +349,15 @@ export default function (pi: ExtensionAPI) {
|
||||
parts.push(seg);
|
||||
}
|
||||
|
||||
// tokens/sec of the last response
|
||||
// tokens/sec of the last response (+ dim prompt-processing t/s when the
|
||||
// server reported it)
|
||||
if (lastTps > 0) {
|
||||
parts.push(
|
||||
theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s"),
|
||||
);
|
||||
let seg = theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s");
|
||||
if (lastPp > 0) seg += theme.fg("dim", ` pp ${lastPp.toFixed(0)}`);
|
||||
parts.push(seg);
|
||||
}
|
||||
|
||||
// ↑ ↓ cumulative session tokens (Claude's 💰 slot; local models are $0)
|
||||
// ↑ ↓ cumulative session prompt / output tokens (Claude's 🔢 segment)
|
||||
if (input || output) {
|
||||
parts.push(theme.fg("dim", `${ICON.up}${fmtTokens(input)} ${ICON.down}${fmtTokens(output)}`));
|
||||
}
|
||||
@@ -236,6 +369,10 @@ export default function (pi: ExtensionAPI) {
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
// Installed here, not at load time: pi's http-dispatcher only swaps in undici's
|
||||
// fetch (idle timeout, proxy) while globalThis.fetch is still the original, so the
|
||||
// tap must not get there first. By session_start, startup is done.
|
||||
installFetchTap();
|
||||
if (enabled) install(ctx);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user