Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased (master, since v0.1.38)
- **feat(nudge): nudge 注入持久化为只读 session entry + acp.log 单物理行(closes #326)** — 此前 nudge 提示仅经 `ctx.ui.notify()` 显示为瞬态 `Text` 子节点,进程重启后 `chatContainer.clear()` + 重建把注入历史从视图里全部抹掉;且 acp.log 中多行 nudge body 溢出到未打标签的物理行,`grep nudge-injected` 只能拿到标题行。现在注入时经 `pi.appendEntry("acp-nudge", ...)` 追加一条紧凑记录(如 `[ACP nudge] EMERGENCY 95% · T1 · top range m00120–m00168`):pi 的 custom entry 为 `type:"custom"`,不进入发送视图(只有 `custom_message` 会投影给模型),故零模型上下文污染;entry 落盘 session JSONL,重启可恢复,并注册 entry renderer(基于 pi-tui 的 dim box,内联 bundle 进 dist、零新增运行时依赖)使其在 TUI scrollback 与重建后均可见;按用户轮去重(每轮至多一条,emergency 逐次重注入不重复落盘)。同时 `log.ts` 对字符串字段(含 stack)转义 `\r`/`\n`,保证每条日志恰好占一个物理行、grep 自包含
- **fix(delegate): 并发完成的 delegate 通知合并为单条批量消息(closes #157)** — 多个 subagent 同时(或主窗口正忙时接连)完成时,每条完成各自 `sendUserMessage(deliverAs: followUp)` 注入一条通知,N 个完成的 delegate 吃掉 N 个完整模型轮次,且模型已收尾后通知仍在持续涌入。现在 finalize 不再直接注入,而是进入 2s 尾沿防抖窗口(自首个排队完成起硬上限 10s,防连续错峰完成饿死投递);窗口关闭时 `flushDelegateNotifications` 发**一条**批量消息:头部计数(`[acp_delegate] 3 delegates finished (2 completed, 1 FAILED)`)+ 每个 run 一节(状态/exit code/超时注记/任务/结果文件/失败错误摘录)+ 单一尾部(仍在跑的 delegate 数、session delegate usage、收尾指令)。窗口期内获得 waiter 或被 `acp_delegate_wait`/`acp_delegate_cancel` 消费的 run 自动出批(不重复投递);发送失败不置 delivered,run 留在未送达集由后续 carrier 补投(`findUndeliveredRuns` 将排队中视为已排程而非丢失);单 run flush 保留原单条格式。净效果:N 个同时完成 → 1 个模型轮次
- **feat(delegate): 失败/取消保留日志 + 失败诊断 + `resumeFrom` 续跑中断的 run (closes #235)** — 此前 cancel 与 spawn error 路径直接删除 `.out`/`.activity` 文件,失败通知只有 exit code(信号被丢弃、stderr 可能为空、activity 轨迹不可见)。现在:① 所有终止路径保留文件(spawn error 把错误写入 `.out`;cancel 回填部分回复,cancel/wait 结果明确给出文件路径);② 失败通知带 exit 信号(`exit SIGTERM`)、stderr、activity 日志尾部(400 字符)与 activity 文件路径;③ pi 宿主 delegate 用 `--session <OUT_DIR>/<runId>.session.jsonl` + `--session-dir` 持久化自身会话(omp 不变,保持 `--no-session`),新增 `resumeFrom: "<runId>"` 参数让新 run 恢复原会话(原任务 + 已执行的 tool calls + 部分结果)从中断处继续——`task` 变为可选(提供时作为本次追加指引);校验:原 run 不得仍在运行、session 文件必须存在、非 pi 宿主拒绝
- **fix(floor): provider-usage floor 跳过压缩后一轮的 stale anchor(#258 评审)** — pi 的 `getContextUsage()` 锚定最后一条有效 assistant usage;成功 compress 落在锚点之后时,下一个 LLM 调用仍以压缩前的大数字 floor,会在刚缩小的上下文上重跑 emergency(nudge 注入 + 工具结果机械截断),面板也继续显示压缩前数字,直到下一条 usage 到达。新增 `src/floor-stale.ts`(`usageAnchorPredatesCompression`):锚点(跳过 aborted/error/全零)早于最后一条成功 compress toolResult(失败/0-block no-op 不算)时跳过 floor,context transform / `acp_status` / `/acp` 三处一致(面板 sessionTokens 同步);对齐 pi 自身 compaction 的 "usage source must be post-compaction" 检查。已知接受:provider 永不报 usage 时(omp #18 tree-sum 回退)floor 仍会永久高位
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,9 @@ billion-context-pi writes a structured, always-on log to `~/.pi/acp.log` (overri
- **Always written** (even with `debug: false`): `error`, `warn`, `info` levels — session start, every context turn (token usage / nudge decision), compress/decompress, delegate spawn/done, and **all errors and warnings** (config/state/tool failures, delegate errors, guardrail caps, update failures). Error lines include the message and stack trace.
- **Written only when `debug: true`**: verbose `debug`-level diagnostics (full field dumps, per-turn internals).

Each line: `<ISO timestamp> [<level>] [<scope>] key=value key=value`. The file rotates to `~/.pi/acp.log.old` at 10 MB.
- **Nudge audit trail**: whenever a context-limit nudge is injected, a compact one-line record (e.g. `[ACP nudge] EMERGENCY 95% · T1 · top range m00120–m00168`) is appended as a *display-only* session entry — it survives process restarts, shows up in TUI scrollback and the session file, and is never sent to the model.

Each line: `<ISO timestamp> [<level>] [<scope>] key=value key=value`. Multi-line values (e.g. the full nudge text in `nudge-injected`) are escaped (`\n` → literal `\\n`), so every entry occupies exactly **one physical line** and stays self-contained for `grep`. The file rotates to `~/.pi/acp.log.old` at 10 MB.

```sh
tail -f ~/.pi/acp.log # watch the session live
Expand Down
41 changes: 41 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "0.83.0",
"@earendil-works/pi-tui": "0.83.0",
"@types/node": "^26.1.2",
"acp-kernel": "0.0.48",
"tsup": "^8.5.1",
Expand Down
42 changes: 41 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
SessionMessageEntry,
} from "@earendil-works/pi-coding-agent";
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
import { Box, Text } from "@earendil-works/pi-tui";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
Expand All @@ -18,7 +19,7 @@ import { makeSearchTool } from "./search-tool.js";
import { makeStatusTool } from "./status-tool.js";
import { makeDelegateTool, makeDelegateWaitTool, makeDelegateCancelTool, runningRunsSnapshot, resetDelegateUsage, setDelegateDisplayUsage } from "./delegate-tool.js";
import { makeCommands } from "./commands.js";
import { coreOutToAgentMessages, extractText } from "./messages.js";
import { coreOutToAgentMessages, extractText, ACP_NUDGE_CUSTOM_TYPE, type AcpNudgeRecord } from "./messages.js";
import { buildAcpSystemPrompt, ACP_DELEGATE_PROMPT } from "./system-prompt.js";
import { delegateStatusWidget } from "./fleet-widget.js";
import { wireToolGuardrails } from "./tool-guardrails.js";
Expand Down Expand Up @@ -69,6 +70,7 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor
for (const { name, options } of makeCommands(runtime, pi)) {
pi.registerCommand(name, options);
}
wireNudgeRecords(pi);
};
}

Expand Down Expand Up @@ -369,6 +371,19 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void {
}
if (!emergency) runtime.markNudgeShown(turnKey);
debug.event("nudge-injected", { sid: ctx.sessionManager.getSessionId(), voice: rendered.voice, channels: ["context", debugOn ? "terminal" : null].filter(Boolean), emergency, turnKey, text: rendered.text + example });
// issue #326: persist a compact display-only record so the injection
// survives restarts (TUI scrollback + session file). One per user turn:
// emergency nudges re-inject on every LLM call and must not flood the
// session file; type:"custom" entries are never projected into the
// sent view, so model context stays clean.
if (typeof pi.appendEntry === "function" && !runtime.nudgeRecordedFor(turnKey)) {
try {
pi.appendEntry(ACP_NUDGE_CUSTOM_TYPE, { text: formatNudgeRecord(turn.nudge, emergency) });
runtime.markNudgeRecorded(turnKey);
} catch (e) {
logWarn("nudge", { sid: ctx.sessionManager.getSessionId(), event: "persist-failed", error: e instanceof Error ? e.message : String(e) });
}
}
} else {
debug.event("nudge-suppressed", { sid: ctx.sessionManager.getSessionId(), turnKey, reason: turn.nudge.reason });
}
Expand Down Expand Up @@ -528,6 +543,20 @@ function wireThrottleRetry(pi: ExtensionAPI, runtime: AcpRuntime): void {
});
}

// issue #326: render persisted nudge records in the TUI. Without a registered
// renderer pi silently drops custom entries on both live append and session
// rebuild, so this is required for the records to be visible at all.
function wireNudgeRecords(pi: ExtensionAPI): void {
if (typeof pi.registerEntryRenderer !== "function") return;
pi.registerEntryRenderer<AcpNudgeRecord>(ACP_NUDGE_CUSTOM_TYPE, (entry, _options, theme) => {
const text = entry.data?.text;
if (!text) return undefined;
const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
box.addChild(new Text(theme.fg("dim", text), 0, 0));
return box;
});
}

function collectOriginals(entries: Array<{ type: string; id: string; message?: AgentMessage; content?: unknown }>): Map<string, AgentMessage> {
const map = new Map<string, AgentMessage>();
for (const entry of entries) {
Expand Down Expand Up @@ -573,6 +602,17 @@ function collectCompressOutcomes(entries: Array<{ type: string; id: string; mess
return out;
}

// Compact one-line twin of nudgeMessage() for the persisted session entry
// (issue #326): auditability only — the full multi-line nudge deliberately
// stays out of the session file.
export function formatNudgeRecord(nudge: NudgeDecision, emergency: boolean): string {
const pct = Math.round(nudge.contextUsage * 100);
const tier = `T${nudge.tier ?? 1}`;
const top = [...nudge.compressibleRanges].sort((a, b) => b.tokens - a.tokens)[0];
const range = top ? ` · top range ${top.startRef}–${top.endRef}` : "";
return `[ACP nudge]${emergency ? " EMERGENCY" : ""} ${pct}% · ${tier}${range}`;
}

function nudgeMessage(nudge: NudgeDecision, blocks: CompressionBlock[], prompts: Prompts): AgentMessage {
const rendered = renderNudgeText(nudge, prompts);
const lines = [rendered.text];
Expand Down
13 changes: 10 additions & 3 deletions src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,20 @@ function debugOn(): boolean {
return runtimeDebug ?? ENV_DEBUG;
}

// One physical line per entry (issue #326): multi-line field values (nudge
// text, error stacks) must not spill onto untagged lines or grep of an event
// name stops at the headline. JSON.stringify already escapes control chars.
function escapeLine(s: string): string {
return s.replace(/\r/g, "\\r").replace(/\n/g, "\\n");
}

function fmt(v: unknown): string {
if (typeof v === "string") return v;
if (v instanceof Error) return v.stack || String(v);
if (typeof v === "string") return escapeLine(v);
if (v instanceof Error) return escapeLine(v.stack || String(v));
try {
return JSON.stringify(v);
} catch {
return String(v);
return escapeLine(String(v));
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const TRAILING_REF_TAG = new RegExp(`\\n*${REF_TAG_SOURCE}\\s*$`);
// session, but never projected into the sent view.
export const ACP_STATUS_CUSTOM_TYPE = "acp-status";

// Nudge persistence record (issue #326): written via pi.appendEntry as a
// type:"custom" entry — never projected into the sent view (see above), so
// the compact one-liner stays out of model context while surviving restarts.
export const ACP_NUDGE_CUSTOM_TYPE = "acp-nudge";
export interface AcpNudgeRecord {
text: string;
}

export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] {
const out: CoreMessage[] = [];
for (const entry of entries) {
Expand Down
8 changes: 7 additions & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export interface AcpRuntime {
setPrompts(prompts: Prompts): void;
markNudgeShown(turnKey: string): void;
nudgeShownFor(turnKey: string): boolean;
/** Like the nudgeShown* pair but tracks the persisted display-only session
* entry (issue #326): at most one record per user turn even when the
* emergency nudge re-injects on every LLM call. */
markNudgeRecorded(turnKey: string): void;
nudgeRecordedFor(turnKey: string): boolean;
/** Process compress toolResults for the CURRENT user turn only (the caller
* scopes the list — see collectCompressOutcomes in src/index.ts); idempotent
* per toolCallId. Outcome classes: isError or noop (0-block panel) →
Expand Down Expand Up @@ -252,6 +257,7 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
let lastUserConfigKey: string | undefined;
let promptsRef: Prompts = defaultPrompts;
const nudgeShownTurns = new Set<string>();
const nudgeRecordedTurns = new Set<string>();
// Per-session overflow self-heal state (learned window + armed emergency).
const overflowEpisodes = new Map<string, OverflowEpisode>();
function overflowFor(sid: string): OverflowEpisode {
Expand Down Expand Up @@ -405,4 +411,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
}

let refused = false;
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };}
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), markNudgeRecorded: (k) => { nudgeRecordedTurns.add(k); }, nudgeRecordedFor: (k) => nudgeRecordedTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeRecordedTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };}
17 changes: 17 additions & 0 deletions tests/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ test("logThrow records message and stack as error", async () => {
await rm(path.dirname(file), { recursive: true, force: true });
});

test("multi-line field values stay on one physical line (issue #326)", async () => {
const file = await freshLog();
const log = await loadLogger(file);
log.setDebugEnabled(true);
log.debug.event("nudge", { event: "nudge-injected", text: "⚠️ Context limit reached\nTier 1 ranges:\n m00001–m00050: 12K tokens" });
log.logThrow("ctx", new Error("boom"), { sid: "s9" });
log.closeLogStream();
const content = await readFile(file, "utf8");
const lines = content.split("\n").filter((l) => l.length > 0);
for (const l of lines) {
assert.match(l, /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[/, `every entry is one tagged physical line: ${l}`);
}
assert.ok(lines.some((l) => l.includes("text=⚠️ Context limit reached\\nTier 1 ranges:\\n m00001–m00050: 12K tokens")), "escaped newlines keep the nudge body self-contained for grep");
assert.ok(lines.some((l) => /\[error\] \[ctx\]/.test(l) && l.includes("stack=Error: boom\\n")), "stack trace stays on the same physical line");
await rm(path.dirname(file), { recursive: true, force: true });
});

test("log lines carry ISO timestamp, level and scope", async () => {
const file = await freshLog();
const log = await loadLogger(file);
Expand Down
Loading