@fanchao8609/agent_brain_sync 1.16.2 → 1.16.3
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/bin/abs.js +26 -1
- package/hooks/abs.pi.ts +272 -9
- package/package.json +1 -1
package/bin/abs.js
CHANGED
|
@@ -20,6 +20,21 @@ function pkgVersion() {
|
|
|
20
20
|
} catch { return 'unknown'; }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** 比较两个 x.y.z 版本号:a>b 返回 1,a<b 返回 -1,相等返回 0。
|
|
24
|
+
* 缺位按 0 补(1.2 == 1.2.0)。不引 semver —— 本包版本号只有 x.y.z 一种形态。 */
|
|
25
|
+
function cmpVersion(a, b) {
|
|
26
|
+
const pa = String(a).split('.').map(Number);
|
|
27
|
+
const pb = String(b).split('.').map(Number);
|
|
28
|
+
// 非 x.y.z(如 'unknown')→ 不判定。注意必须先查 NaN 再补 0:
|
|
29
|
+
// NaN 是 falsy,写成 `pa[i] || 0` 会把 'unknown' 静默当成 0.0.0。
|
|
30
|
+
if ([...pa, ...pb].some(Number.isNaN)) return 0;
|
|
31
|
+
for (let i = 0; i < 3; i++) {
|
|
32
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
33
|
+
if (x !== y) return x > y ? 1 : -1;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
/** 跑一个命令并把 stdout 当字符串返回(失败返回 null)。 */
|
|
24
39
|
function runCmd(cmd, args) {
|
|
25
40
|
return new Promise((resolvePromise) => {
|
|
@@ -42,12 +57,22 @@ async function cmdUpdate({ yes }) {
|
|
|
42
57
|
console.error('无法查询 npm registry(网络/代理问题)。手动升级:npm i -g @fanchao8609/agent_brain_sync@latest');
|
|
43
58
|
process.exit(1);
|
|
44
59
|
}
|
|
60
|
+
const cmp = cmpVersion(cur, latest);
|
|
45
61
|
console.log(`当前: ${cur}`);
|
|
46
62
|
console.log(`最新: ${latest}`);
|
|
47
|
-
if (
|
|
63
|
+
if (cmp === 0) {
|
|
48
64
|
console.log('已是最新,无需升级。');
|
|
49
65
|
return;
|
|
50
66
|
}
|
|
67
|
+
// 本地比 registry 新 —— 典型场景:开发中从源码装过全局,或在改一个未发布的版本。
|
|
68
|
+
// 此时不能走 npm i -g @latest:那会把本地版**降级**覆盖掉(2026-10-06 实际发生:
|
|
69
|
+
// 源码 1.16.2 被拉回 registry 的 1.16.1)。只比较 `===` 而没有方向判断,就是这个坑。
|
|
70
|
+
if (cmp > 0) {
|
|
71
|
+
console.log('\n本地版本比 registry 上的更新(未发布的开发版?),跳过升级以免被降级。');
|
|
72
|
+
console.log(' 想用本地源码覆盖全局:npm i -g ' + ABS_DIR);
|
|
73
|
+
console.log(' 想把它正式发布: npm publish(在源码目录,发布后 update 即恢复正常)');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
51
76
|
console.log(`\n升级 ${cur} → ${latest} …`);
|
|
52
77
|
const r = await runCmd('npm', ['i', '-g', '@fanchao8609/agent_brain_sync@latest']);
|
|
53
78
|
if (r === null) {
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* Pi 事件: session_start / session_shutdown (对应 host hook 的 SessionStart/SessionEnd)。
|
|
6
6
|
* session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
|
|
7
7
|
* agent_end 只留可观测性埋点(agent_end:seen), 不再向对话注入任何东西。
|
|
8
|
+
* 另在 agent_end 确认开局 load 段「送达」(loadPending → loadInjected,2026-10-06 方案 A):
|
|
9
|
+
* 注入点在 before_agent_start(prompt 提交后、agent loop 前),此刻对话还没发出去;
|
|
10
|
+
* 若在那时就置位,被 ESC/中断的那轮会把一次性标记烧掉,之后永远不再注入(日志实证)。
|
|
11
|
+
* 故标记只在 agent_end 置 —— 没走完就下轮补。
|
|
12
|
+
* 且标记按 **cwd** 记(load 内容取自 cwd/.brain/)—— 会话级单值会让一个目录
|
|
13
|
+
* 拿到 load 后、其它目录全被永久跳过(2026-10-06 用户实报,日志有干净复现)。
|
|
8
14
|
*
|
|
9
15
|
* 已删除(2026-09-15):「写文件即任务开始 → 每轮注入登记提醒」。实报「pi 里一直报 Follow-up」。
|
|
10
16
|
* 已删除(2026-09-18):收尾注入本身(agent_end + sendUserMessage deliverAs=followUp)。
|
|
@@ -198,6 +204,43 @@ async function hasBrain(cwd: string): Promise<boolean> {
|
|
|
198
204
|
}
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
/** 已**确认送达**过 load 的目录集合(本会话内)。
|
|
208
|
+
* “只第一次”:开工那一刻把全套状态摆一次(Rules/图谱/滞留/最近 log),
|
|
209
|
+
* 之后靠看板段每轮对齐 —— 不重复塞 load(它是全量,每轮塞会随图谱无界膨胀)。
|
|
210
|
+
* 在 session_start 清空(同 filesSeen)。
|
|
211
|
+
*
|
|
212
|
+
* ★ 语义一:是「LLM 真看到了」,不是「我跑过了」(2026-10-06 用户拍板方案 A)。
|
|
213
|
+
* 病根:原实现注入完立刻置位,但注入发生在 `before_agent_start`(prompt 提交之后、
|
|
214
|
+
* agent loop 之前)—— 此时对话**还没发出去**。ESC/中断掉的那轮,标记已烧且不回滚,
|
|
215
|
+
* 后续全部 skip。日志实证(~/.abs/log/hooks.log 07:06-07:09 一段):
|
|
216
|
+
* session_start → load_guide=on → 之后连续 skip
|
|
217
|
+
* 修正:只在 `agent_end`(这轮真跑完)置位;中断则保持未置 → 下轮自动补。
|
|
218
|
+
* **不要再改回「注入时直接置位」** —— 那就是把「没走完就补」重新关掉。
|
|
219
|
+
*
|
|
220
|
+
* ★ 语义二:**按目录分,不是会话级单值**(2026-10-06 用户实报后修的第二个 bug)。
|
|
221
|
+
* load 的内容来自 `cwd/.brain/`,**每个目录不同**;而原实现只有一份布尔值。
|
|
222
|
+
* 后果:任一目录拿到过 load,**其它所有目录被永久跳过**。日志实证(一次干净复现):
|
|
223
|
+
* 07:40:08 session_start
|
|
224
|
+
* 07:40:10 before_agent_start load_guide=on cwd=~/.agents ← 注入
|
|
225
|
+
* 07:40:17 agent_end load_delivered cwd=~/.agents ← 置位
|
|
226
|
+
* 07:40:43 before_agent_start load_guide=skip cwd=agent_brain_sync ← ✗ 被跳过
|
|
227
|
+
* 而 pi 一个会话里 `turn_end` 会在多个 cwd 间交替(多个项目目录同时挂着)。
|
|
228
|
+
* **不要再改回单个布尔量** —— 一改回去就重现「在 A 说过话,B 再也看不到 load」。 */
|
|
229
|
+
const loadInjected = new Set<string>()
|
|
230
|
+
|
|
231
|
+
/** 本轮已注入 load、正等 `agent_end` 确认送达的 **cwd**(见 loadInjected 的注解)。
|
|
232
|
+
* 与 loadInjected 的分工:这是「抛出去了」,那是「对方接到了」。
|
|
233
|
+
* 空串 = 无待确认(不用 null,避免每次都要判空类型)。 */
|
|
234
|
+
let loadPending = ''
|
|
235
|
+
|
|
236
|
+
/** 本会话是否已送过首轮硬规则(只为日志可观测,不参与注入判断 —— 那个由 loadInjected 守)。
|
|
237
|
+
* 每会话重置(session_start),同 loadInjected。 */
|
|
238
|
+
let openingRulesLogged = false
|
|
239
|
+
|
|
240
|
+
/** load 子进程超时(ms)。卡住不能拖死开工 —— 到点杀子进程并当失败(下次不再重试)。
|
|
241
|
+
* 2s:本机 load 实测 ~50-100ms,2s 已是“明显不对”的阈值。 */
|
|
242
|
+
const LOAD_GUIDE_TIMEOUT_MS = 2000
|
|
243
|
+
|
|
201
244
|
/** 看板快照在 guidelines 里的前缀 —— 用于每轮替换上轮快照(内容会变,不能用 includes 去重)。 */
|
|
202
245
|
const BOARD_MARK = '[看板] '
|
|
203
246
|
|
|
@@ -225,11 +268,23 @@ const BOARD_MARK = '[看板] '
|
|
|
225
268
|
* 与以前删掉的两种注入的本质区别:那不调 sendUserMessage(不插话、不抢 turn),
|
|
226
269
|
* 只改 system prompt 内容 —— 和静默的 TODO_GUIDELINES 走同一个通道。
|
|
227
270
|
*
|
|
228
|
-
*
|
|
271
|
+
* 看板为空时仍然输出 —— 但**不能只摆「(空)」**(2026-10-06 用户实报后改):
|
|
272
|
+
* 实报:在 ~ 目录干活,看板 Todo 区是空的,模型看到「(空)」读成了「一切正常」,
|
|
273
|
+
* 于是动手却没登记。日志里 todo_guide=on / load_guide=on 都是真的 ——
|
|
274
|
+
* **但「注入了」不等于「起作用了」**。空看板摆成一行「(空)」,传达不出「不对齐」。
|
|
275
|
+
* 故空看板时要把**事实 + 后果**摆出来:你正在开工,而看板一片空白,这就是该登记没登记。
|
|
276
|
+
* (仍然不替 LLM 做决定、不自动登记 —— 只把事实摆清楚,判断还是它的。)
|
|
277
|
+
*
|
|
278
|
+
* 仍然静默的情形:无图谱/读失败(readBoardForGuide 捕不到 todo.md);
|
|
279
|
+
* **以及看板里全是别人的任务**(那不是空,是「这看板不是我在用」——
|
|
280
|
+
* 摆个「(空)」会误导成「我可以去登记」,跟真的空看板不是一回事)。 */
|
|
229
281
|
export function boardGuideline(md: string, who: string, touched: string[] = []): string | null {
|
|
230
282
|
const { total, rows } = parseOpenTasks(md, PANEL_MAX_ROWS, who)
|
|
231
|
-
//
|
|
232
|
-
|
|
283
|
+
// 看板里有没有**任何**未完成条目(不分作者)。用来区分两种“过滤后为空”:
|
|
284
|
+
// 没有 → 真的空看板(摆「空」这个事实 + 它是异常信号)
|
|
285
|
+
// 有但都是别人的 → 不是我的看板(该静默,别误导我去登记)
|
|
286
|
+
const anyOpen = /^\s*- \[ \]/m.test(md)
|
|
287
|
+
if (!total && anyOpen) return null
|
|
233
288
|
const lines = rows.map((r) => {
|
|
234
289
|
// desc 为空时不拖空破折号(MCP 登记只给 id 的常见情形)。
|
|
235
290
|
const head = ` ${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
|
|
@@ -243,12 +298,96 @@ export function boardGuideline(md: string, who: string, touched: string[] = []):
|
|
|
243
298
|
: []
|
|
244
299
|
return [
|
|
245
300
|
'当前 .brain/todo.md 看板(开工前对齐:正在做的算哪条?有新任务要加吗?有该删的吗?):',
|
|
246
|
-
|
|
301
|
+
// ★ 空看板不能只摆「(空)」——用户 2026-10-06 实报:看到「(空)」读成了「一切正常」,
|
|
302
|
+
// 于是动手却没登记(日志证实 todo_guide=on、load_guide=on,即「看见了,照样不做」)。
|
|
303
|
+
// 故把事实说完整:本目录有图谱、但零未完成任务。
|
|
304
|
+
// ⚠ 但**不得写劝告**(「请登记」「别忘了」)—— 知识库 remind-vs-gate 记了 12 次全败,
|
|
305
|
+
// 且本文件自己的用例就在守这条(不得夹带劝告)。只摆事实,判断留给 LLM。
|
|
306
|
+
...(lines.length
|
|
307
|
+
? lines
|
|
308
|
+
: [' (空 —— 本目录有 .brain/ 图谱,但一条未完成任务都没有)']),
|
|
247
309
|
...(more ? [more] : []),
|
|
248
310
|
...touchLines,
|
|
249
311
|
].join('\n')
|
|
250
312
|
}
|
|
251
313
|
|
|
314
|
+
/** 开工第一轮注入 load 全文(只一次)。
|
|
315
|
+
*
|
|
316
|
+
* ★ 用户 2026-10-06 定:「打开 pi 说第一句话就自动 abs load」,且**只第一轮**。
|
|
317
|
+
* 痛点:我开工时手上什么都没有(Rules/图谱/上会话滞留/最近 log 全缺),
|
|
318
|
+
* 得靠用户提醒「abs」我才去 load —— 今天这场活就是这么漏掉登记的。
|
|
319
|
+
*
|
|
320
|
+
* 为何只第一轮(与看板段的反复对齐是两件事):
|
|
321
|
+
* - 看板:小而关键,**每轮都摆**(反复对齐,不厌其烦)。
|
|
322
|
+
* - load:全量(实测 ~2.7k 字符),**只在开工那一次**。若每轮塞,
|
|
323
|
+
* 会违反项目 Rule「读取侧输出不得随规模增长」—— 图谱越大 prompt 越肿。
|
|
324
|
+
*
|
|
325
|
+
* 为何走 ABS_BIN 跑子进程(而非 import 源码):hook 是被**单文件复制**到
|
|
326
|
+
* ~/.pi/agent/extensions/abs.ts 的,那里没有 ../src/ —— import 源码必炸。
|
|
327
|
+
* ABS_BIN 是 install 时烧进来的稳定入口路径,本文件 wrapup 已在用同一路子。
|
|
328
|
+
*
|
|
329
|
+
* 与删掉的自动登记「每轮 spawn」的差别:**只第一轮一次**,不是每轮。
|
|
330
|
+
* 每轮 spawn 才是卡顿的根因(见 auto-todo-register-design)。
|
|
331
|
+
*
|
|
332
|
+
* 与删掉的两次注入的差别:那两次是 sendUserMessage(往对话里插话、抢 turn);
|
|
333
|
+
* 本函数只改 system prompt 内容,与 TODO_GUIDELINES / 看板段同一通道。
|
|
334
|
+
*
|
|
335
|
+
* 失败静默:跑子进程/读盘出任何错都不弄坏对话(返回 null)。 */
|
|
336
|
+
async function readLoadForGuide(cwd: string): Promise<string | null> {
|
|
337
|
+
try {
|
|
338
|
+
const out = await new Promise<string | null>((resolvePromise) => {
|
|
339
|
+
let buf = ''
|
|
340
|
+
const c = spawn(process.execPath, [ABS_BIN, 'load'], {
|
|
341
|
+
cwd,
|
|
342
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
343
|
+
env: { ...process.env },
|
|
344
|
+
})
|
|
345
|
+
// 超时保护:load 卡住不能拖住开工(子进程永不返回 = 会话永远开不动)。
|
|
346
|
+
const t = setTimeout(() => { try { c.kill() } catch {} resolvePromise(null) }, LOAD_GUIDE_TIMEOUT_MS)
|
|
347
|
+
c.stdout.on('data', (d) => (buf += d))
|
|
348
|
+
c.on('error', () => { clearTimeout(t); resolvePromise(null) })
|
|
349
|
+
c.on('close', (code) => {
|
|
350
|
+
clearTimeout(t)
|
|
351
|
+
resolvePromise(code === 0 && buf.trim() ? buf.trim() : null)
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
return out
|
|
355
|
+
} catch {
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 自动建图谱(无 .brain 时)。返回是否成功建出了 .brain/。
|
|
361
|
+
*
|
|
362
|
+
* ★ 用户 2026-10-06 拍板方案 A:**任何目录都建**(含 ~、含临时目录)。
|
|
363
|
+
* 曾考虑「只对有 .git/package.json 的目录建」—— 被用户否定:
|
|
364
|
+
* 用户认定 ~ 这种也是项目,而 ~ 没有 .git,按项目标志判会误伤。
|
|
365
|
+
*
|
|
366
|
+
* 幂等且安全:init 遇到已有内容一律不动(见 `abs init --help` 的「--repair 只补缺不覆盖」),
|
|
367
|
+
* 所以重复调用无副作用;并且只在 hasBrain 为假时才调。
|
|
368
|
+
*
|
|
369
|
+
* 为何走子进程而非 import 源码:hook 被单文件复制到 ~/.pi/agent/extensions/,
|
|
370
|
+
* 那里没有 ../src/ —— 与 readLoadForGuide 同一原因。
|
|
371
|
+
*
|
|
372
|
+
* 超时用同一个 LOAD_GUIDE_TIMEOUT_MS:建图谱就是写几个小文件,卡住即异常。
|
|
373
|
+
* 失败静默:无权限/只读盘等不能弄坏对话(返回 false,调用方会再查一次 hasBrain 兜底)。 */
|
|
374
|
+
async function autoInit(cwd: string): Promise<boolean> {
|
|
375
|
+
try {
|
|
376
|
+
return await new Promise<boolean>((resolvePromise) => {
|
|
377
|
+
const c = spawn(process.execPath, [ABS_BIN, 'init'], {
|
|
378
|
+
cwd,
|
|
379
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
380
|
+
env: { ...process.env },
|
|
381
|
+
})
|
|
382
|
+
const t = setTimeout(() => { try { c.kill() } catch {} resolvePromise(false) }, LOAD_GUIDE_TIMEOUT_MS)
|
|
383
|
+
c.on('error', () => { clearTimeout(t); resolvePromise(false) })
|
|
384
|
+
c.on('close', (code) => { clearTimeout(t); resolvePromise(code === 0) })
|
|
385
|
+
})
|
|
386
|
+
} catch {
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
252
391
|
/** 读看板拼成对齐段。失败/无图谱/看板空都返 null(静默降级,绝不阻断对话)。 */
|
|
253
392
|
async function readBoardForGuide(cwd: string): Promise<string | null> {
|
|
254
393
|
try {
|
|
@@ -278,6 +417,68 @@ export function injectTodoGuidelines(options: any, board?: string | null): boole
|
|
|
278
417
|
return true
|
|
279
418
|
}
|
|
280
419
|
|
|
420
|
+
/** 开局 load 段在 guidelines 里的前缀(与看板段区分,两者都挤 promptGuidelines)。 */
|
|
421
|
+
const LOAD_MARK = '[开工] '
|
|
422
|
+
|
|
423
|
+
/** 从 skill 里挑出的【硬规则】—— 只第一轮送,与 load 同机会。
|
|
424
|
+
*
|
|
425
|
+
* ★ 用户 2026-10-06 拍板方案 B:不动 skill 全文(~200 行),只把管「登记」的 3 条硬规矩搬过来。
|
|
426
|
+
*
|
|
427
|
+
* 为何不做 A(首轮塞 skill 全文):撞项目 Rule「读取侧输出不得随规模增长」——
|
|
428
|
+
* 每次开会话都多塞 200 行,而其中大部分(图谱组织/容量纪律/tesardown 流程)
|
|
429
|
+
* 只在真正做 abs 维护时才用得上。
|
|
430
|
+
*
|
|
431
|
+
* 与【常驻 6 条】TODO_GUIDELINES 的区别(两者职责不同,别合并):
|
|
432
|
+
* - 6 条常驻:行为要求,每轮都在(看板对齐用)。
|
|
433
|
+
* - 这 3 条:**只在开工第一轮出现一次** —— 因为它们是「开工那一刻」的触发器,
|
|
434
|
+
* 每次都说等于噪音(历史教训:静态指引有痕 414 次、行为 0 变化)。
|
|
435
|
+
*
|
|
436
|
+
* ⚠ 必读的实证背景(别当作「这次会有用」):
|
|
437
|
+
* 知识库 remind-vs-gate 记了 12 次尝试全败 —— 「提醒 LLM」这一类全死于
|
|
438
|
+
* 「看见了,照样不做」(静态指引有痕 414 次)。本方案属于该类。
|
|
439
|
+
* 用户知情后仍然选择做,理由:「首轮显式送完整规则」这个形态确实没被试过
|
|
440
|
+
* (历史试的是「一直挂着」和「关键词触发」,与「首轮一次性强提示」不同)。
|
|
441
|
+
* 所以:**不要因为「效果不理想」就反复改这几句** —— 那正是用户说的死循环。
|
|
442
|
+
* 要改的是机制(找「不做就过不去」的关卡),不是把提醒写得更用力。 */
|
|
443
|
+
export const OPENING_RULES = [
|
|
444
|
+
// ① skill「新需求受理协议」+「开工前先登记」的核心:先登记,再动手。
|
|
445
|
+
// 为何强调「第一个文件」:实测漏登记都发生在「先读代码再说」的天真开工上。
|
|
446
|
+
'This project has a .brain/ board. Before you touch any file or run any command for a task that changes this project, register it first with the abs task tool (action "start" + a short id). Read the first file only after that. A rough name is fine — the name can be fixed later, but an unregistered start cannot be recovered.',
|
|
447
|
+
// ② 名字要「说清在干什么」,不抄用户原话(实测抄出来的是「我已经重启测试一下」这种开场白)。
|
|
448
|
+
'Name it so it says what you are doing (e.g. fix-skill-trigger, oversize-exempt) — do not copy the user\'s wording; a greeting or a question is not a task name.',
|
|
449
|
+
// ③ 「不攒」:一段活干完立刻 done,宁可拆小。
|
|
450
|
+
'Mark a task "done" as soon as that piece of work ends — do not batch. If you are more than two or three rounds in without a registration, you are already off the rails: register now.',
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
/** 硬规则段的前缀(与 [开工] load 段 / [看板] 段区分)。 */
|
|
454
|
+
const OPENING_MARK = '[开工规则] '
|
|
455
|
+
|
|
456
|
+
/** 注入首轮硬规则(只第一轮,由调用方的 loadInjected 守)。与 load 段同一通道。
|
|
457
|
+
* 关掉即设 ABS_OPENING_RULES=0。 */
|
|
458
|
+
export function injectOpeningRules(options: any): boolean {
|
|
459
|
+
if (String(process.env.ABS_OPENING_RULES || '') === '0') return false
|
|
460
|
+
const list: string[] = options.promptGuidelines || (options.promptGuidelines = [])
|
|
461
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
462
|
+
if (list[i].startsWith(OPENING_MARK)) list.splice(i, 1)
|
|
463
|
+
}
|
|
464
|
+
list.push(OPENING_MARK + OPENING_RULES.join(' '))
|
|
465
|
+
return true
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** 注入开局 load 全文。与看板段同一个通道(静态 system prompt 内容)。
|
|
469
|
+
* 只第一次调(由调用方的 loadInjected 守);这里只管推入,不自己去重。 */
|
|
470
|
+
export function injectLoadGuideline(options: any, text: string): boolean {
|
|
471
|
+
if (String(process.env.ABS_LOAD_GUIDE || '') === '0') return false
|
|
472
|
+
if (!text) return false
|
|
473
|
+
const list: string[] = options.promptGuidelines || (options.promptGuidelines = [])
|
|
474
|
+
// 先删旧的:理论上只会调一次,但重入/session 重置边界上不该堆叠。
|
|
475
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
476
|
+
if (list[i].startsWith(LOAD_MARK)) list.splice(i, 1)
|
|
477
|
+
}
|
|
478
|
+
list.push(LOAD_MARK + text)
|
|
479
|
+
return true
|
|
480
|
+
}
|
|
481
|
+
|
|
281
482
|
// ---------------------------------------------------------------------------
|
|
282
483
|
// todo 面板(2026-10-03):把 .brain/todo.md 的未完成任务显示在编辑器上方
|
|
283
484
|
// ---------------------------------------------------------------------------
|
|
@@ -613,7 +814,9 @@ export function renderPanelLines(
|
|
|
613
814
|
animPhase = -1,
|
|
614
815
|
nickname = '',
|
|
615
816
|
): string[] {
|
|
616
|
-
|
|
817
|
+
// ★ 空看板不再直接返回空数组(2026-10-06 用户要求:「即便 todo 没有列表,也要在 pi 上显示」)。
|
|
818
|
+
// 理由与看板段一致:面板是「机制在不在」的常驻指示,进去就看不见 = 不知道机制活着。
|
|
819
|
+
// 下面仍要画框和标题,所以这里只是不再提前 return。
|
|
617
820
|
const colorOf = (state: string): string => (state === '搁置' ? 'dim' : state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
|
|
618
821
|
const lines: string[] = []
|
|
619
822
|
// 上描边:用 `thinkingOff` —— 跟 Pi 输入框描边**完全同一个色**。
|
|
@@ -634,6 +837,12 @@ export function renderPanelLines(
|
|
|
634
837
|
lines.push(clipToWidth(fg('accent', title), width))
|
|
635
838
|
|
|
636
839
|
const lastIdx = data.rows.length - 1
|
|
840
|
+
// 空看板:标题下摆一行事实(与 system prompt 里的看板段同口径)。
|
|
841
|
+
// 只摆事实、不写劝告 —— 「记得登记」那类已被 13 次尝试证明无效(见 OPENING_RULES 注解)。
|
|
842
|
+
if (data.total === 0) {
|
|
843
|
+
lines.push(clipToWidth(` ${fg('dim', '(无未完成任务)')}`, width))
|
|
844
|
+
return lines
|
|
845
|
+
}
|
|
637
846
|
data.rows.forEach((t, i) => {
|
|
638
847
|
// `└─` 只给最后一条 —— 不管它有没有断点。
|
|
639
848
|
// (2026-10-03 审查修正:原条件 `i === lastIdx && !t.note` 会让“末条带断点”时
|
|
@@ -676,7 +885,8 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
|
|
|
676
885
|
const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
|
|
677
886
|
const who = await currentUser()
|
|
678
887
|
const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
|
|
679
|
-
|
|
888
|
+
// 任务为空**也显示面板**(摆一行「(无未完成任务)」),只有无图谱才隐藏。
|
|
889
|
+
// 曾写 data.total === 0 → hidePanel;用户 2026-10-06 要求改掉。
|
|
680
890
|
// factory 形式:render(width) 每帧拿真实宽度 → 按宽度截断,不再断字。
|
|
681
891
|
// 动画:phase 存在模块级,render 时读当前值;定时器只在本帧重绘,不重建 widget。
|
|
682
892
|
let tuiRef: any = null
|
|
@@ -704,6 +914,12 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
|
|
|
704
914
|
const needAnim = data.rows.some((t) => t.state === '进行中')
|
|
705
915
|
if (needAnim) startAnimTimer(() => tuiRef)
|
|
706
916
|
else stopAnimTimer()
|
|
917
|
+
// ★ 无动画时得自己刷一帧(2026-10-06)。
|
|
918
|
+
// 旧代码靠“任务清空→hidePanel”顺带 requestRender;现在空看板不隐藏了,
|
|
919
|
+
// 若这里不主动刷,从“有任务”变成“无任务”时屏幕会冻结在旧帧
|
|
920
|
+
// (标题还写着旧计数)—— 与 2026-10-05 那个静默 bug 同一族。
|
|
921
|
+
// 仍在 needAnim 时不用管:定时器每帧会刷。
|
|
922
|
+
if (!needAnim) try { lastTui?.requestRender?.() } catch {}
|
|
707
923
|
} catch {
|
|
708
924
|
hidePanel(ui)
|
|
709
925
|
}
|
|
@@ -717,6 +933,9 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
717
933
|
// 新会话 = 重新登记:不重置则同进程的第二个会话永远不再落断点
|
|
718
934
|
autoTaskSeen = false
|
|
719
935
|
filesSeen.clear() // 同理:新会话的文件痕迹不能带到下个会话
|
|
936
|
+
loadInjected.clear() // 同:新会话的每个目录都要重新摆一次开局状态
|
|
937
|
+
loadPending = '' // 同:上一会话末轮未完的“待确认”不得带到新会话
|
|
938
|
+
openingRulesLogged = false // 同:首轮硬规则的日志标记每会话重算
|
|
720
939
|
resetThrottle()
|
|
721
940
|
// 本会话的随机昵称 —— 在这里抽一次(不是每帧抽,否则面板会疯狂闪)。
|
|
722
941
|
// 每次 session_start(startup/reload/new/resume/fork)重抽 → “每次打开 pi 都是随机的”。
|
|
@@ -790,11 +1009,46 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
790
1009
|
return (async () => {
|
|
791
1010
|
try {
|
|
792
1011
|
if (!(await hasBrain(cwd))) {
|
|
793
|
-
|
|
794
|
-
|
|
1012
|
+
// 无图谱 → 自动建一个(2026-10-06 用户拍板方案 A:任何目录都建)。
|
|
1013
|
+
// 为何是 A 而非「只对有 .git 的目录建」:用户明确认定 ~ 这种也算项目,
|
|
1014
|
+
// 而 ~ 没有 .git/package.json —— 按项目标志判会把它排除掉,与需求相反。
|
|
1015
|
+
// 子进程跑(与 readLoadForGuide 同一路子):hook 是被单文件复制到
|
|
1016
|
+
// ~/.pi/agent/extensions/ 的,那里没有 ../src/,不能 import 源码。
|
|
1017
|
+
// 幂等:init 自带「结构不完整报明细」,已有内容一律不动(见 abs init --help)。
|
|
1018
|
+
// 失败静默:建不出来(无权限/只读盘)不该弄坏对话,只是本轮不注入。
|
|
1019
|
+
const inited = await autoInit(cwd)
|
|
1020
|
+
logHook(`before_agent_start auto_init=${inited ? 'ok' : 'fail'} cwd=${cwd}`).catch(() => {})
|
|
1021
|
+
if (!(await hasBrain(cwd))) {
|
|
1022
|
+
logHook(`before_agent_start todo_guide=off reason=no_brain`).catch(() => {})
|
|
1023
|
+
return
|
|
1024
|
+
}
|
|
795
1025
|
}
|
|
796
1026
|
const ok = injectTodoGuidelines(event?.systemPromptOptions, await readBoardForGuide(cwd))
|
|
797
|
-
|
|
1027
|
+
// 开学每个目录垫一次 load 全文(同目录只一次;之后靠上面那个看板段反复对齐)。
|
|
1028
|
+
// 注意这里**不置 loadInjected** —— 注入点只是在 prompt 提交后、agent loop 前,
|
|
1029
|
+
// 对话还没发出去;此刻置位会让被中断的那轮白烧掉标记(见 loadInjected 注解)。
|
|
1030
|
+
let loadState = 'skip'
|
|
1031
|
+
if (!loadInjected.has(cwd)) {
|
|
1032
|
+
const loadText = await readLoadForGuide(cwd)
|
|
1033
|
+
// 首轮硬规则(方案 B,2026-10-06)—— 与 load 同一机会(同目录只一次)。
|
|
1034
|
+
// 为何挂在这里而不另设 flag:两者都是「开工那一刻只说一次」,
|
|
1035
|
+
// 且共用「送达确认」语义 —— 若这轮被中断,下轮两者一起重送,不会一个送一个不送。
|
|
1036
|
+
const rulesOn = injectOpeningRules(event?.systemPromptOptions)
|
|
1037
|
+
if (loadText) {
|
|
1038
|
+
injectLoadGuideline(event?.systemPromptOptions, loadText)
|
|
1039
|
+
loadPending = cwd // 送达确认交给 agent_end
|
|
1040
|
+
loadState = 'on'
|
|
1041
|
+
} else {
|
|
1042
|
+
loadState = 'empty'
|
|
1043
|
+
loadInjected.add(cwd) // 失败也不重试:不每轮白查一次盘
|
|
1044
|
+
}
|
|
1045
|
+
if (rulesOn) openingRulesLogged = true
|
|
1046
|
+
}
|
|
1047
|
+
// 【临时取证 2026-10-06】seen= 把 Set 内容原样打出来:
|
|
1048
|
+
// 真实环境里 agent_brain_sync 从未 load_delivered 却 still skip → 与「按 cwd 分」矛盾。
|
|
1049
|
+
// 靠这行区分:Set 空(→ 判断写错)vs Set 里已有该 cwd(→ 有东西提前加进去了)。
|
|
1050
|
+
// 定位并修好后删掉这行。
|
|
1051
|
+
logHook(`before_agent_start todo_guide=${ok ? 'on' : 'off'} load_guide=${loadState} opening_rules=${openingRulesLogged ? 'on' : 'skip'} seen=[${[...loadInjected].join(',')}] pending=[${loadPending}] cwd=${cwd}`).catch(() => {})
|
|
798
1052
|
|
|
799
1053
|
// 自动登记已整体移除(2026-10-05 用户实报两点):
|
|
800
1054
|
// ① 卡顿 —— agent_end 每轮 spawn 一个 node 进程
|
|
@@ -822,6 +1076,15 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
822
1076
|
agentEndSeen = true
|
|
823
1077
|
await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
|
|
824
1078
|
}
|
|
1079
|
+
// 送达确认(2026-10-06 方案 A):走到这里 = 这轮真跑完了 → 才算“LLM 看到了”。
|
|
1080
|
+
// 中断/ESC 掉的那轮不会到 agent_end → 该 cwd 入不了集合 → 下轮自动补注入。
|
|
1081
|
+
// 这是**唯一**该写 loadInjected 的地方(别挪回 before_agent_start)。
|
|
1082
|
+
if (loadPending) {
|
|
1083
|
+
loadInjected.add(loadPending)
|
|
1084
|
+
const done = loadPending
|
|
1085
|
+
loadPending = ''
|
|
1086
|
+
await logHook(`agent_end load_delivered cwd=${done} seen=[${[...loadInjected].join(',')}]`).catch(() => {})
|
|
1087
|
+
}
|
|
825
1088
|
// 每轮 spawn 已删除(2026-10-05 用户实报"卡顿"):agentEndSeen 一旦置位,
|
|
826
1089
|
// 下面那个条件就恒真 → 每轮结束都起一个 node 进程,纯属白烧。
|
|
827
1090
|
// 断点改由 agent 在用 abs_task 时自己写(那时它本来就在跑,不额外起进程)。
|