pi-pier 0.1.3 → 0.1.5
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/package.json +1 -1
- package/src/core/grid-shape.ts +10 -5
- package/src/core/subagent.ts +157 -53
- package/src/core/todo.ts +120 -66
- package/src/herdr-client.ts +6 -3
- package/src/history-store.ts +10 -0
- package/src/index.ts +72 -10
- package/src/pane-title.ts +11 -1
- package/src/pipe-channel.ts +3 -0
- package/src/settle-wake-core.ts +54 -0
- package/src/slim-frame.ts +219 -0
- package/src/stale-core.ts +76 -0
- package/src/subagent-core.ts +30 -0
- package/src/todo-core.ts +41 -4
- package/src/todo-read-hook.ts +122 -20
- package/src/todos-service.ts +13 -3
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D97 窄格静帧(slim frame):pane 长宽放不下最小可用 TUI 时,用非捕获
|
|
3
|
+
* 全屏 overlay 盖一张 pane title 同源静帧。
|
|
4
|
+
*
|
|
5
|
+
* 动机(用户实证):热力把非焦点格压窄后,worker 的流式 thinking 在窄格
|
|
6
|
+
* 里持续整屏重绘 → 闪烁。静帧只在 todo_write / 状态上报时更新(与
|
|
7
|
+
* formatPaneTitle 同参同源),thinking token 碰不到它 → alt-screen 行级
|
|
8
|
+
* 差分下 PTY 零输出,闪烁物理消失。
|
|
9
|
+
*
|
|
10
|
+
* 前提:worker/master 以 --tui-mode fullscreen 运行(buildLaunchParts 默认
|
|
11
|
+
* 注入)。regular 主屏的整屏 dump 路径(tui-main-screen firstChanged <
|
|
12
|
+
* viewportTop → 全量重倒)overlay 盖不住——composite 在文档之上,dump 在
|
|
13
|
+
* 文档之内。
|
|
14
|
+
*
|
|
15
|
+
* 注册闸:仅 herdr pane 内(index.ts 以 env 判定)——普通小终端窗口里盖住
|
|
16
|
+
* 交互面是事故;herdr 窄格点击聚焦即被热力放大,visible 谓词随 SIGWINCH
|
|
17
|
+
* 每帧重估,静帧自动消失、真 TUI 原样在底下(agent/会话/widget 从未停)。
|
|
18
|
+
*
|
|
19
|
+
* 生命周期:进程内单例,session_start(非 resume)注册一次;overlay 常驻
|
|
20
|
+
* (done() 永不调用 → Promise 永挂,吞 rejection,绝不 await)。pi 会话
|
|
21
|
+
* 切换若拆掉 overlay(resetExtensionUI)则静帧自然退场,不影响主流程。
|
|
22
|
+
* PI_HERDR_SLIM_FRAME=0 逃生口。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** TUI 可用下限(pi interactive-mode 布局:editor 3 行 + footer 1 + transcript ≥3 + 间距)。
|
|
26
|
+
* 任一轴低于此值 → 该格读不了 TUI,画 title 静帧。 */
|
|
27
|
+
export const SLIM_MIN_COLS = 24;
|
|
28
|
+
export const SLIM_MIN_ROWS = 12;
|
|
29
|
+
|
|
30
|
+
/** 静帧可见性谓词(overlayOptions.visible 每渲染帧回调)。 */
|
|
31
|
+
export function isSlimFrame(cols: number, rows: number): boolean {
|
|
32
|
+
return cols < SLIM_MIN_COLS || rows < SLIM_MIN_ROWS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ── 宽度感知折行(保守 East Asian 宽度;▶○■✓ 等歧义宽字符按 1 记,仅影响折点位置) ── */
|
|
36
|
+
|
|
37
|
+
function isWide(cp: number): boolean {
|
|
38
|
+
return (
|
|
39
|
+
(cp >= 0x1100 && cp <= 0x115f) // 谚文兼容字母
|
|
40
|
+
|| (cp >= 0x2e80 && cp <= 0xa4cf) // CJK 部首~彝文(含假名)
|
|
41
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3) // 谚文音节
|
|
42
|
+
|| (cp >= 0xf900 && cp <= 0xfaff) // CJK 兼容表意
|
|
43
|
+
|| (cp >= 0xfe30 && cp <= 0xfe4f) // CJK 兼容形式
|
|
44
|
+
|| (cp >= 0xff00 && cp <= 0xff60) // 全角形式
|
|
45
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function displayWidth(s: string): number {
|
|
50
|
+
let w = 0;
|
|
51
|
+
for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
52
|
+
return w;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 按可见宽度折行:词折行(行内有空格则退到最后一个空格断,英文短语不腰斩;
|
|
57
|
+
* CJK 无空格自然硬折)。折行处吞掉断点空格。
|
|
58
|
+
*/
|
|
59
|
+
export function wrapByWidth(text: string, width: number): string[] {
|
|
60
|
+
const w = Math.max(1, Math.floor(width));
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
let line = '';
|
|
63
|
+
let lineW = 0;
|
|
64
|
+
const push = () => {
|
|
65
|
+
lines.push(line);
|
|
66
|
+
line = '';
|
|
67
|
+
lineW = 0;
|
|
68
|
+
};
|
|
69
|
+
for (const ch of text) {
|
|
70
|
+
const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
71
|
+
if (lineW + cw > w) {
|
|
72
|
+
const lastSpace = line.lastIndexOf(' ');
|
|
73
|
+
if (lastSpace > 0) {
|
|
74
|
+
lines.push(line.slice(0, lastSpace));
|
|
75
|
+
line = line.slice(lastSpace + 1) + ch;
|
|
76
|
+
lineW = displayWidth(line);
|
|
77
|
+
} else {
|
|
78
|
+
push();
|
|
79
|
+
line = ch;
|
|
80
|
+
lineW = cw;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
line += ch;
|
|
84
|
+
lineW += cw;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (line !== '' || lines.length === 0) lines.push(line);
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 静帧行:title 折行 → 垂直居中 → 钳到 rows。每行都补齐到满宽(可见宽度):
|
|
93
|
+
* overlay 的合成按行覆盖,空行会让底下的 TUI 透出来(漏 flicker),
|
|
94
|
+
* 满宽空格行才是真正的不透明冻结。无 title → 居中一枚 `·`(活着、没计划)。
|
|
95
|
+
*/
|
|
96
|
+
export function frameLines(
|
|
97
|
+
text: string,
|
|
98
|
+
opts: { width: number; rows: number; colorize?: (s: string) => string },
|
|
99
|
+
): string[] {
|
|
100
|
+
const width = Math.max(1, Math.floor(opts.width));
|
|
101
|
+
const rows = Math.max(1, Math.floor(opts.rows));
|
|
102
|
+
const colorize = opts.colorize ?? ((s: string) => s);
|
|
103
|
+
const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
|
|
104
|
+
|
|
105
|
+
const body = text.trim()
|
|
106
|
+
? wrapByWidth(text.trim(), width).slice(0, rows)
|
|
107
|
+
: ['·'];
|
|
108
|
+
const top = Math.max(0, Math.floor((rows - body.length) / 2));
|
|
109
|
+
const out: string[] = [];
|
|
110
|
+
for (let i = 0; i < rows; i++) {
|
|
111
|
+
const idx = i - top;
|
|
112
|
+
out.push(idx >= 0 && idx < body.length ? pad(colorize(body[idx])) : ' '.repeat(width));
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── overlay 组件 + 注册/更新(进程内单例) ─────────────────────────── */
|
|
118
|
+
|
|
119
|
+
interface FrameTui {
|
|
120
|
+
requestRender(): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class SlimFrameComponent {
|
|
124
|
+
private text = '';
|
|
125
|
+
private lastRows = 0;
|
|
126
|
+
private cache: { width: number; lines: string[] } | null = null;
|
|
127
|
+
private readonly tui: FrameTui;
|
|
128
|
+
private readonly colorize: (s: string) => string;
|
|
129
|
+
|
|
130
|
+
constructor(tui: FrameTui, colorize: (s: string) => string) {
|
|
131
|
+
this.tui = tui;
|
|
132
|
+
this.colorize = colorize;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** visible 谓词每帧回调:顺手记录行数(render(width) 拿不到高度,居中布局靠它)。 */
|
|
136
|
+
onViewport(cols: number, rows: number): boolean {
|
|
137
|
+
if (this.lastRows !== rows) {
|
|
138
|
+
this.lastRows = rows;
|
|
139
|
+
this.cache = null; // 行数变了 → 居中重排
|
|
140
|
+
}
|
|
141
|
+
return isSlimFrame(cols, rows);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setText(text: string): void {
|
|
145
|
+
if (this.text === text) return;
|
|
146
|
+
this.text = text;
|
|
147
|
+
this.cache = null;
|
|
148
|
+
this.tui.requestRender();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
if (this.cache && this.cache.width === width) return this.cache.lines;
|
|
153
|
+
const lines = frameLines(this.text, { width, rows: this.lastRows, colorize: this.colorize });
|
|
154
|
+
this.cache = { width, lines };
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
invalidate(): void {
|
|
159
|
+
this.cache = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** pi 拆 overlay(会话切换/退出)时回收模块单例,重注册不被 no-op。 */
|
|
163
|
+
dispose(): void {
|
|
164
|
+
if (active === this) active = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
/** 测试注入点(module state 重置)。 */
|
|
170
|
+
export function resetForTest(): void {
|
|
171
|
+
active = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let active: SlimFrameComponent | null = null;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 注册静帧 overlay(幂等:进程一次)。eventCtx = pi 生命周期事件上下文
|
|
178
|
+
* (session_start 的 ctx,与 todo widget 的 widgetUi 同来源)。
|
|
179
|
+
*/
|
|
180
|
+
export function registerSlimFrame(eventCtx: unknown): void {
|
|
181
|
+
if (active) return;
|
|
182
|
+
if (process.env.PI_HERDR_SLIM_FRAME === '0') return;
|
|
183
|
+
const ui = eventCtx !== null && typeof eventCtx === 'object' && 'ui' in eventCtx
|
|
184
|
+
? eventCtx.ui
|
|
185
|
+
: null;
|
|
186
|
+
const custom = ui !== null && typeof ui === 'object' && 'custom' in ui && typeof ui.custom === 'function'
|
|
187
|
+
? ui.custom
|
|
188
|
+
: undefined;
|
|
189
|
+
try {
|
|
190
|
+
// overlay: true = 浮层不清屏(pi ctx.ui.custom 默认替换编辑器区,必须显式盖);
|
|
191
|
+
// nonCapturing = 键盘仍归编辑器(人点进窄格也能打字,热力放大后静帧自动消失)。
|
|
192
|
+
const pending = custom(
|
|
193
|
+
(tui, theme) => {
|
|
194
|
+
const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
|
|
195
|
+
active = comp;
|
|
196
|
+
return comp;
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
overlay: true,
|
|
200
|
+
overlayOptions: {
|
|
201
|
+
width: '100%',
|
|
202
|
+
maxHeight: '100%',
|
|
203
|
+
anchor: 'top-left',
|
|
204
|
+
nonCapturing: true,
|
|
205
|
+
visible: (cols: number, rows: number) => active !== null && active.onViewport(cols, rows),
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
// done() 永不调用 → Promise 永挂(overlay 常驻);吞 rejection,绝不 await。
|
|
210
|
+
Promise.resolve(pending).catch(() => {});
|
|
211
|
+
} catch {
|
|
212
|
+
/* 无 overlay 能力的 pi 版本静默降级 */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 静帧内容更新(title 与 pane 标题投影同参同源;null = 无计划)。 */
|
|
217
|
+
export function updateSlimFrame(title: string | null): void {
|
|
218
|
+
active?.setText(title ?? '');
|
|
219
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo 陈旧度纯核心(反冻结:会话 01a03253 实证)。
|
|
3
|
+
*
|
|
4
|
+
* 实证:全完成列表(3✓)冻结 16h / 37 轮,模型持续做新工作但 todo_write 零调用;
|
|
5
|
+
* 读钩每轮复读死列表反而背书"无事可跟踪",标题满权重渲染死计数。
|
|
6
|
+
* 本模块把"列表是否还反映现实"收敛为纯函数,三层消费:
|
|
7
|
+
* - stale(A):open==0 且 ≥ STALE_TURNS 轮未写 → 读钩注入改 stale 警告(限频 + 封顶);
|
|
8
|
+
* - archived(B):open==0 且墙钟 ≥ STALE_CLOCK_MS 未写 → 按不存在处理
|
|
9
|
+
* (注入/投影不再复读明细;条目保留,/todos 可查;会话 JSONL 权威不动);
|
|
10
|
+
* - 标题(D):archived 时窗格/侧栏渲染 `✓N done <age>`,不再冒充当前状态。
|
|
11
|
+
*
|
|
12
|
+
* 有 open 项(pending/in_progress/blocked)的列表永不判 stale——未完成工作
|
|
13
|
+
* 仍由 agent_settled 提醒兜底;lastWriteAt 未知(旧会话无时间戳)→ 时钟维度
|
|
14
|
+
* 保守不判 archived,turns 维度不受影响。
|
|
15
|
+
*/
|
|
16
|
+
import { countTodos, type TodoItem } from './vocab.ts';
|
|
17
|
+
|
|
18
|
+
/** A:turns 维度过期阈值(用户轮次,自上次 todo 写入起算)。 */
|
|
19
|
+
export const STALE_TURNS = 6;
|
|
20
|
+
|
|
21
|
+
/** B:墙钟维度归档阈值(全完成列表最后一次写入距今)。 */
|
|
22
|
+
export const STALE_CLOCK_MS = 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** A:每次停滞期最多注入的 stale 警告数(防空转噪音;archived 后转归档通知节奏)。 */
|
|
25
|
+
export const STALE_NOTICE_MAX = 3;
|
|
26
|
+
|
|
27
|
+
export type StalenessKind = 'fresh' | 'stale' | 'archived';
|
|
28
|
+
|
|
29
|
+
export interface Staleness {
|
|
30
|
+
kind: StalenessKind;
|
|
31
|
+
/** open = pending + in_progress + blocked(abandoned 不算未完成)。 */
|
|
32
|
+
open: number;
|
|
33
|
+
/** now - lastWriteAt;lastWriteAt 未知 → null。 */
|
|
34
|
+
ageMs: number | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** open 计数:stale/archived 只对 open==0 的全完成(或全放弃)列表生效。 */
|
|
38
|
+
export function openTodos(items: readonly TodoItem[]): number {
|
|
39
|
+
const c = countTodos(items as TodoItem[]);
|
|
40
|
+
return c.pending + c.inProgress + c.blocked;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 陈旧度判定(双条件:turns 维度 → stale;墙钟维度 → archived,时钟优先)。
|
|
45
|
+
* 空列表 → fresh(空守卫另有归属);lastWriteAt null → 永不 stale(保守)。
|
|
46
|
+
*/
|
|
47
|
+
export function evaluateStaleness(opts: {
|
|
48
|
+
items: readonly TodoItem[];
|
|
49
|
+
lastWriteAt: number | null;
|
|
50
|
+
turnsSinceWrite: number | null;
|
|
51
|
+
now: number;
|
|
52
|
+
}): Staleness {
|
|
53
|
+
const open = openTodos(opts.items);
|
|
54
|
+
const ageMs = opts.lastWriteAt == null ? null : Math.max(0, opts.now - opts.lastWriteAt);
|
|
55
|
+
const st: Staleness = { kind: 'fresh', open, ageMs };
|
|
56
|
+
if (opts.items.length === 0 || open > 0) return st;
|
|
57
|
+
if (ageMs != null && ageMs >= STALE_CLOCK_MS) st.kind = 'archived';
|
|
58
|
+
else if (opts.turnsSinceWrite != null && opts.turnsSinceWrite >= STALE_TURNS) st.kind = 'stale';
|
|
59
|
+
return st;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 标题/镜像路径专用(无 turn 信息):仅墙钟维度的 archived 判定。 */
|
|
63
|
+
export function isArchived(
|
|
64
|
+
items: readonly TodoItem[],
|
|
65
|
+
lastWriteAt: number | null,
|
|
66
|
+
now: number,
|
|
67
|
+
): boolean {
|
|
68
|
+
return evaluateStaleness({ items, lastWriteAt, turnsSinceWrite: null, now }).kind === 'archived';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 年龄显示:<60m → `Nm`;<48h → `Nh`(floor);否则 `Nd`。 */
|
|
72
|
+
export function formatAge(ms: number): string {
|
|
73
|
+
if (ms < 60 * 60_000) return `${Math.max(1, Math.floor(ms / 60_000))}m`;
|
|
74
|
+
if (ms < 48 * 3_600_000) return `${Math.floor(ms / 3_600_000)}h`;
|
|
75
|
+
return `${Math.floor(ms / 86_400_000)}d`;
|
|
76
|
+
}
|
package/src/subagent-core.ts
CHANGED
|
@@ -378,3 +378,33 @@ export function planTabPlacement(opts: {
|
|
|
378
378
|
const tabName = nextTaskTabName(base, new Set(known.keys()));
|
|
379
379
|
return { mode: 'new', tabName, tabId: null };
|
|
380
380
|
}
|
|
381
|
+
|
|
382
|
+
/* ── D97:worker 启动参数(纯逻辑,可单测) ───────────────────────── */
|
|
383
|
+
|
|
384
|
+
/** launchLine 的运行时三要素(master 侧探测:node 解释器 / pi cli / 本扩展入口)。 */
|
|
385
|
+
export interface LaunchRuntime {
|
|
386
|
+
nodePath: string;
|
|
387
|
+
cliPath: string;
|
|
388
|
+
extPath: string;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* worker 启动裸 argv。`--tui-mode fullscreen`(D97 静帧前提):
|
|
393
|
+
* alt-screen 行级差分——窄条静帧不变 → PTY 零输出,流式 thinking 不再整屏
|
|
394
|
+
* dump 闪烁;regular 主屏改动在视口上方即全量重倒(pi tui-main-screen
|
|
395
|
+
* firstChanged < viewportTop 路径),overlay 盖不住。
|
|
396
|
+
* `PI_HERDR_TUI=regular` 回退旧行为(逃生口)。
|
|
397
|
+
*/
|
|
398
|
+
export function buildLaunchParts(
|
|
399
|
+
runtime: LaunchRuntime,
|
|
400
|
+
opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
|
|
401
|
+
env: Pick<NodeJS.ProcessEnv, 'PI_HERDR_TUI'> = process.env,
|
|
402
|
+
): string[] {
|
|
403
|
+
const parts = [runtime.nodePath, runtime.cliPath];
|
|
404
|
+
if (opts.approve) parts.push('-a');
|
|
405
|
+
parts.push('-e', runtime.extPath);
|
|
406
|
+
if (env.PI_HERDR_TUI !== 'regular') parts.push('--tui-mode', 'fullscreen');
|
|
407
|
+
if (opts.roleModel) parts.push('--provider', opts.roleModel.split('/')[0], '--model', opts.roleModel.split('/')[1] ?? opts.roleModel);
|
|
408
|
+
if (opts.resumeFile) parts.push('--session', opts.resumeFile);
|
|
409
|
+
return parts;
|
|
410
|
+
}
|
package/src/todo-core.ts
CHANGED
|
@@ -198,6 +198,8 @@ export function makeSnapshot(items: TodoItem[]): TodoSnapshot {
|
|
|
198
198
|
export interface BranchEntryLike {
|
|
199
199
|
/** pi 会话条目:type 为 'message' 的消息条目或 'custom' 的自定义条目。 */
|
|
200
200
|
type?: string;
|
|
201
|
+
/** 会话 JSONL 顶层时间戳(ISO 字符串或 epoch ms;重建 lastWriteAt 用)。 */
|
|
202
|
+
timestamp?: unknown;
|
|
201
203
|
message?: {
|
|
202
204
|
role?: string;
|
|
203
205
|
toolName?: string;
|
|
@@ -270,21 +272,56 @@ function extractEditPayload(data: unknown): TodoEditPayload | null {
|
|
|
270
272
|
* - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
|
|
271
273
|
* 分支正确性 = 沿分支路径顺序折叠。
|
|
272
274
|
*/
|
|
273
|
-
export
|
|
275
|
+
export interface FoldedTodos {
|
|
276
|
+
items: TodoItem[];
|
|
277
|
+
/** 产生该列表的最后一个条目时间戳(epoch ms);条目无时间戳 → null。 */
|
|
278
|
+
writtenAt: number | null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function entryTimestamp(entry: BranchEntryLike): number | null {
|
|
282
|
+
const ts = entry.timestamp;
|
|
283
|
+
if (typeof ts === 'number') return ts;
|
|
284
|
+
if (typeof ts === 'string') {
|
|
285
|
+
const parsed = Date.parse(ts);
|
|
286
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 从 pi 会话分支条目里折叠出最新 todo 列表(D38 双源):
|
|
293
|
+
* - toolResult(todo_write) → 快照替换(last-wins);
|
|
294
|
+
* - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
|
|
295
|
+
* 分支正确性 = 沿分支路径顺序折叠。writtenAt = 最后一次触碰该列表的条目时间戳
|
|
296
|
+
* (无时间戳的旧条目 → null,陈旧度判定保守判 fresh)。
|
|
297
|
+
*/
|
|
298
|
+
export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): FoldedTodos | null {
|
|
274
299
|
let found: TodoItem[] | null = null;
|
|
300
|
+
let writtenAt: number | null = null;
|
|
275
301
|
for (const entry of entries) {
|
|
276
302
|
if (entry.type === 'message') {
|
|
277
303
|
const msg = entry.message;
|
|
278
304
|
if (typeof msg !== 'object' || msg === null) continue;
|
|
279
305
|
if (msg.role !== 'toolResult' || msg.toolName !== TODO_TOOL_NAME) continue;
|
|
280
306
|
const snapshot = extractSnapshotFromDetails(msg.details);
|
|
281
|
-
if (snapshot)
|
|
307
|
+
if (snapshot) {
|
|
308
|
+
found = snapshot.items.map((it) => ({ ...it }));
|
|
309
|
+
writtenAt = entryTimestamp(entry);
|
|
310
|
+
}
|
|
282
311
|
} else if (entry.type === 'custom' && entry.customType === TODO_EDIT_CUSTOM_TYPE) {
|
|
283
312
|
const payload = extractEditPayload(entry.data);
|
|
284
|
-
if (payload && found)
|
|
313
|
+
if (payload && found) {
|
|
314
|
+
found = applyTodoEdits(found, payload.edits);
|
|
315
|
+
writtenAt = entryTimestamp(entry) ?? (payload.ts > 0 ? payload.ts : writtenAt);
|
|
316
|
+
}
|
|
285
317
|
}
|
|
286
318
|
}
|
|
287
|
-
return found;
|
|
319
|
+
return found ? { items: found, writtenAt } : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** foldLatestTodosMeta 的条目-only 视图(兼容既有调用方)。 */
|
|
323
|
+
export function foldLatestTodos(entries: readonly BranchEntryLike[]): TodoItem[] | null {
|
|
324
|
+
return foldLatestTodosMeta(entries)?.items ?? null;
|
|
288
325
|
}
|
|
289
326
|
|
|
290
327
|
export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {
|
package/src/todo-read-hook.ts
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* D69:todo 阅读钩子规划(before_agent_start + display:false)。
|
|
3
|
+
* 反冻结(stale-core):全完成列表过期后不再复读——
|
|
4
|
+
* - stale(turns 维度)→ 警告 + 旧条目(供改写参照),每 N 轮一次、封顶;
|
|
5
|
+
* - archived(墙钟维度)→ 按不存在处理,归档通知与空列表共用守卫节奏。
|
|
3
6
|
*/
|
|
4
7
|
import { boundedView, currentActivity, type TodoItem } from './todo-core.ts';
|
|
5
8
|
import { countTodos } from './vocab.ts';
|
|
9
|
+
import { STALE_NOTICE_MAX, evaluateStaleness, formatAge } from './stale-core.ts';
|
|
6
10
|
|
|
11
|
+
/** 空守卫 / stale 警告 / 归档通知共用的注入节奏(每 N 轮)。 */
|
|
7
12
|
export const EMPTY_GUARD_EVERY_N = 4;
|
|
8
13
|
export const TODO_READ_CUSTOM_TYPE = 'pi-herdr.todo-read';
|
|
9
14
|
|
|
15
|
+
export type TodoReadEffect =
|
|
16
|
+
| 'recite' // 正常复读(列表新鲜)
|
|
17
|
+
| 'empty-guard' // 空列表守卫
|
|
18
|
+
| 'stale-notice' // A:turns 维度过期警告
|
|
19
|
+
| 'archive-notice' // B:墙钟维度归档通知
|
|
20
|
+
| 'none'; // 限频/封顶跳过
|
|
21
|
+
|
|
10
22
|
export interface TodoReadPlan {
|
|
11
23
|
inject: boolean;
|
|
24
|
+
effect: TodoReadEffect;
|
|
25
|
+
/** 当前归档态(与 inject 无关;调用方据此刷新 widget/title 投影)。 */
|
|
26
|
+
archived: boolean;
|
|
27
|
+
/** R1:归档通知本次注入即清空内存列表(调用方执行 todos.replace([]) + rm 全量
|
|
28
|
+
* 落 JSONL——死列表挡住空守卫的 before-stopping 驱动,01a03c0d 实证归档后
|
|
29
|
+
* 2h 多步工作零跟踪)。 */
|
|
30
|
+
clearArchived: boolean;
|
|
12
31
|
message: {
|
|
13
32
|
customType: string;
|
|
14
33
|
content: string;
|
|
@@ -16,39 +35,122 @@ export interface TodoReadPlan {
|
|
|
16
35
|
};
|
|
17
36
|
}
|
|
18
37
|
|
|
38
|
+
const MARKS: Record<TodoItem['status'], string> = {
|
|
39
|
+
pending: '○',
|
|
40
|
+
in_progress: '▶',
|
|
41
|
+
completed: '✓',
|
|
42
|
+
blocked: '■',
|
|
43
|
+
abandoned: '✗',
|
|
44
|
+
};
|
|
45
|
+
|
|
19
46
|
export function planTodoReadHook(opts: {
|
|
20
47
|
items: readonly TodoItem[];
|
|
21
48
|
turn: number;
|
|
22
49
|
lastEmptyGuardTurn: number | null;
|
|
50
|
+
/** stale-core 时钟锚点(TodosService.lastWriteAt)。 */
|
|
51
|
+
lastWriteAt: number | null;
|
|
52
|
+
/** 距上次 todo 写入的轮数(本进程内已知时)。 */
|
|
53
|
+
turnsSinceWrite: number | null;
|
|
54
|
+
now: number;
|
|
55
|
+
/** 本停滞期已注入的 stale 警告数。 */
|
|
56
|
+
staleNotices: number;
|
|
57
|
+
lastStaleGuardTurn: number | null;
|
|
23
58
|
}): TodoReadPlan {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
59
|
+
const msg = (content: string) => ({
|
|
60
|
+
customType: TODO_READ_CUSTOM_TYPE,
|
|
61
|
+
content,
|
|
62
|
+
display: false as const,
|
|
63
|
+
});
|
|
64
|
+
const guardDue = opts.lastEmptyGuardTurn == null
|
|
65
|
+
|| opts.turn - opts.lastEmptyGuardTurn >= EMPTY_GUARD_EVERY_N;
|
|
66
|
+
|
|
67
|
+
if (opts.items.length === 0) {
|
|
68
|
+
return {
|
|
69
|
+
inject: guardDue,
|
|
70
|
+
effect: 'empty-guard',
|
|
71
|
+
clearArchived: false,
|
|
72
|
+
archived: false,
|
|
73
|
+
message: msg(
|
|
74
|
+
guardDue
|
|
75
|
+
? 'Your todo list is empty. If the current work needs tracking, call todo_write before stopping.'
|
|
76
|
+
: '',
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const st = evaluateStaleness({
|
|
82
|
+
items: opts.items,
|
|
83
|
+
lastWriteAt: opts.lastWriteAt,
|
|
84
|
+
turnsSinceWrite: opts.turnsSinceWrite,
|
|
85
|
+
now: opts.now,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (st.kind === 'archived') {
|
|
89
|
+
const c = countTodos(opts.items as TodoItem[]);
|
|
90
|
+
const age = st.ageMs == null ? '' : ` ${formatAge(st.ageMs)} ago`;
|
|
91
|
+
// R2:重写窗口——本停滞期还没给过任何 stale 警告时,先给一次带旧条目参照的
|
|
92
|
+
// 重写机会(idle 期不消耗 turn,墙钟先到 1h 而 turns 警告从未触发,
|
|
93
|
+
// 01a03c0d 实证:回来后直接吃终态通知,todo_write 5 次被无视)。
|
|
94
|
+
if (opts.staleNotices === 0 && guardDue) {
|
|
95
|
+
const lines = opts.items.map((it) => ` ${MARKS[it.status]} ${it.content}`);
|
|
96
|
+
const head = `todos ✓${c.completed} (all completed, last updated${age}) — about to be archived`;
|
|
97
|
+
const warn = 'One rewrite window before archiving: if the work you are doing now is multi-step, rewrite the full list with todo_write to track it (old entries below as reference — reuse what still applies). Single-step Q&A may skip tracking. If the list stays untouched, it will be archived and cleared on the next reminder.';
|
|
98
|
+
return {
|
|
99
|
+
inject: true,
|
|
100
|
+
effect: 'stale-notice',
|
|
101
|
+
archived: true,
|
|
102
|
+
clearArchived: false,
|
|
103
|
+
message: msg([head, ...lines, warn].join('\n')),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// B + R1/R3:终态通知——明细不再注入,本次注入即清空;去掉 [] 豁免出口,
|
|
107
|
+
// 多步工作必须建清单(单步问答明确放行)。
|
|
108
|
+
return {
|
|
109
|
+
inject: guardDue,
|
|
110
|
+
effect: 'archive-notice',
|
|
111
|
+
archived: true,
|
|
112
|
+
clearArchived: guardDue,
|
|
113
|
+
message: msg(
|
|
114
|
+
guardDue
|
|
115
|
+
? `Your previous todo list (${c.completed} completed entries, last updated${age}) has been archived and cleared from tracking; the list is now empty. If the current work is multi-step, call todo_write with a fresh list now — multi-step work must be tracked. Single-step answers may proceed without a list.`
|
|
116
|
+
: '',
|
|
117
|
+
),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (st.kind === 'stale') {
|
|
122
|
+
// A:复读改警告;保留旧条目行供模型改写参照。封顶 STALE_NOTICE_MAX。
|
|
123
|
+
const staleDue = opts.lastStaleGuardTurn == null
|
|
124
|
+
|| opts.turn - opts.lastStaleGuardTurn >= EMPTY_GUARD_EVERY_N;
|
|
125
|
+
if (!staleDue || opts.staleNotices >= STALE_NOTICE_MAX) {
|
|
126
|
+
return { inject: false, effect: 'none', archived: false, clearArchived: false, message: msg('') };
|
|
127
|
+
}
|
|
128
|
+
const c = countTodos(opts.items as TodoItem[]);
|
|
129
|
+
const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
|
|
130
|
+
+ ` · unchanged for ${opts.turnsSinceWrite} turns, nothing open`;
|
|
131
|
+
const lines = opts.items.map((it) => ` ${MARKS[it.status]} ${it.content}`);
|
|
132
|
+
const warn = 'This list no longer reflects the work you are doing. Rewrite the full list with todo_write to match current work, or send [] to clear it.';
|
|
28
133
|
return {
|
|
29
|
-
inject:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
},
|
|
134
|
+
inject: true,
|
|
135
|
+
effect: 'stale-notice',
|
|
136
|
+
clearArchived: false,
|
|
137
|
+
archived: false,
|
|
138
|
+
message: msg([head, ...lines, warn].join('\n')),
|
|
35
139
|
};
|
|
36
140
|
}
|
|
141
|
+
|
|
142
|
+
// fresh:原有每轮复读。
|
|
37
143
|
const c = countTodos(opts.items);
|
|
38
144
|
const view = boundedView(opts.items, 6);
|
|
39
|
-
const lines = view.visible.map((it) => {
|
|
40
|
-
|
|
41
|
-
return `${mark} ${it.content}`;
|
|
42
|
-
});
|
|
43
|
-
const activity = currentActivity(opts.items as TodoItem[]);
|
|
145
|
+
const lines = view.visible.map((it) => `${MARKS[it.status]} ${it.content}`);
|
|
146
|
+
const activity = currentActivity(opts.items);
|
|
44
147
|
const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
|
|
45
148
|
+ (activity ? ` · ${activity}` : '');
|
|
46
149
|
return {
|
|
47
150
|
inject: true,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
},
|
|
151
|
+
effect: 'recite',
|
|
152
|
+
clearArchived: false,
|
|
153
|
+
archived: false,
|
|
154
|
+
message: msg([head, ...lines].join('\n')),
|
|
53
155
|
};
|
|
54
156
|
}
|
package/src/todos-service.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { EventEmitter } from 'node:events';
|
|
7
7
|
import {
|
|
8
8
|
applyTodoEdits,
|
|
9
|
-
|
|
9
|
+
foldLatestTodosMeta,
|
|
10
10
|
type TodoEdit,
|
|
11
11
|
type TodoItem,
|
|
12
12
|
} from './todo-core.ts';
|
|
@@ -20,6 +20,11 @@ export interface TodosConfig {
|
|
|
20
20
|
|
|
21
21
|
export class TodosService extends EventEmitter {
|
|
22
22
|
items: TodoItem[] = [];
|
|
23
|
+
/**
|
|
24
|
+
* 列表最后一次真实变更时间(epoch ms);分支重建取自条目时间戳,无则 null。
|
|
25
|
+
* 陈旧度判定(stale-core)的时钟锚点;null → 保守永不判 stale。
|
|
26
|
+
*/
|
|
27
|
+
lastWriteAt: number | null = null;
|
|
23
28
|
readonly config: TodosConfig;
|
|
24
29
|
|
|
25
30
|
constructor(config: TodosConfig) {
|
|
@@ -38,6 +43,7 @@ export class TodosService extends EventEmitter {
|
|
|
38
43
|
replace(items: readonly TodoItem[]): void {
|
|
39
44
|
const before = this.items;
|
|
40
45
|
this.items = items.map((it) => ({ ...it }));
|
|
46
|
+
this.lastWriteAt = Date.now();
|
|
41
47
|
this.emitCompletedTransitions(before, this.items);
|
|
42
48
|
this.emit('todo.updated', { items: this.items });
|
|
43
49
|
}
|
|
@@ -45,6 +51,7 @@ export class TodosService extends EventEmitter {
|
|
|
45
51
|
applyEdits(edits: readonly TodoEdit[]): void {
|
|
46
52
|
const before = this.items;
|
|
47
53
|
this.items = applyTodoEdits(this.items, edits);
|
|
54
|
+
this.lastWriteAt = Date.now();
|
|
48
55
|
this.emitCompletedTransitions(before, this.items);
|
|
49
56
|
this.emit('todo.edited', { edits, items: this.items });
|
|
50
57
|
this.emit('todo.updated', { items: this.items });
|
|
@@ -59,8 +66,11 @@ export class TodosService extends EventEmitter {
|
|
|
59
66
|
rebuild(entries: readonly unknown[]): void {
|
|
60
67
|
// 与 v1.x rebuildFromBranch 同语义:分支上折叠不到快照时保留现值
|
|
61
68
|
//(重建失败不影响主流程;下次 todo_write 会重新锚定)。
|
|
62
|
-
const folded =
|
|
63
|
-
if (folded)
|
|
69
|
+
const folded = foldLatestTodosMeta(entries as Parameters<typeof foldLatestTodosMeta>[0]);
|
|
70
|
+
if (folded) {
|
|
71
|
+
this.items = folded.items;
|
|
72
|
+
this.lastWriteAt = folded.writtenAt;
|
|
73
|
+
}
|
|
64
74
|
this.emit('todo.updated', { items: this.items, reason: 'rebuild' });
|
|
65
75
|
}
|
|
66
76
|
}
|