@fanchao8609/agent_brain_sync 1.26.1 → 1.28.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/hooks/abs.pi.ts CHANGED
@@ -307,9 +307,25 @@ async function hasBrain(cwd: string): Promise<boolean> {
307
307
  * 07:40:17 agent_end load_delivered cwd=~/.agents ← 置位
308
308
  * 07:40:43 before_agent_start load_guide=skip cwd=agent_brain_sync ← ✗ 被跳过
309
309
  * 而 pi 一个会话里 `turn_end` 会在多个 cwd 间交替(多个项目目录同时挂着)。
310
- * **不要再改回单个布尔量** —— 一改回去就重现「在 A 说过话,B 再也看不到 load」。 */
310
+ * **不要再改回单个布尔量** —— 一改回去就重现「在 A 说过话,B 再也看不到 load」。
311
+ *
312
+ * ★ 语义三:**按「会话 + 目录」分,不是只按目录**(2026-11-06 用户实报后修的第三个 bug)。
313
+ * 病根:这个 Set 只在 `session_start` 清空,而**一个会话可能持续好几天、跨几十次 cwd 切换**,
314
+ * 期间的 `new`(在同一进程里新建会话)不一定重发带 ctx 的 `session_start`。
315
+ * 于是某个目录只要注入过一次,此后整个进程生命周期里永远 skip。
316
+ * 日志实证(~/.abs/log/hooks.log 2026-11-06):agent_brain_sync 出现 4 次全是 skip,
317
+ * 一次 on 都没有;而 Code/ren 连着 3 次 on。用户原话:
318
+ * 「用 new 开新会话,没有主动运行 abs,只有当我输入 abs 的时候铁律和知识才加载进来」。
319
+ * 修法:key 带上会话 id —— 换会话就该重新摆一次开局状态。
320
+ * **不要再改回只按 cwd** —— 那就重现「新会话开局是空的」。 */
311
321
  const loadInjected = new Set<string>()
312
322
 
323
+ /** 注入去重用的 key:会话 id + cwd。
324
+ * 会话 id 取不到时退化为纯 cwd(老行为,至少不会因空 id 而每轮重注入刷屏)。 */
325
+ function loadKeyOf(cwd: string, sid: string): string {
326
+ return sid ? `${sid}::${cwd}` : cwd
327
+ }
328
+
313
329
  /** 本轮已注入 load、正等 `agent_end` 确认送达的 **cwd**(见 loadInjected 的注解)。
314
330
  * 与 loadInjected 的分工:这是「抛出去了」,那是「对方接到了」。
315
331
  * 空串 = 无待确认(不用 null,避免每次都要判空类型)。 */
@@ -912,17 +928,25 @@ function clipToWidth(s: string, width: number): string {
912
928
  return out + '…'
913
929
  }
914
930
  // ---------------------------------------------------------------------------
915
- // “进行中”行的迷你动画(2026-10-03 用户需求):三个小符号依次由空心变实心。
916
- // 帧宽固定 3 列 —— 宽度不变才不会让面板每帧抖动(这也是不用 ⠋⠙⠹ 那种 spinner 的原因:
917
- // 那个只闪一个符,用户明确要的是“三个点依次点亮”)。
918
- // 平铺 6 帧:亮→全亮→退回(呼吸感),而不是全亮后硬跳回全空。
919
- const TODO_ANIM_FRAMES = ['○○○', '●○○', '●●○', '●●●', '●●○', '●○○']
920
- const TODO_ANIM_MS = 250
931
+ // “进行中”行的迷你动画。
932
+ //
933
+ // 2026-10-03 用户要的是三个点依次点亮;2026-11-06 改成**单字符盲文 spinner**
934
+ // (用户原话:「●○○ 改成一个字符的、动画吧 放在 [进行中] 后面」)。
935
+ //
936
+ // 为何用盲文点阵而不是 ⠋⠙⠹ 之外的 ASCII 旋转符:
937
+ // ⠿ 系符号在等宽字体里都是 1 列宽,且视觉上是个转动的方框,比 | / - \ 均匀。
938
+ // 为何帧宽必须恒定:面板每 250ms 重绘,宽度一变整行就会左右抖。盲文恰好全是 1 列。
939
+ const TODO_ANIM_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
940
+ const TODO_ANIM_MS = 100
921
941
 
922
942
  /** 动画相位(每 tick +1)。模块级 —— 同 agentEndSeen,闭包 flag 在重复注册时不共享。 */
923
943
  let animPhase = 0
924
944
  let animTimer: ReturnType<typeof setInterval> | undefined
925
945
 
946
+ /** 当前推理强度(off/minimal/low/…/max)。模块级,理由同上:widget factory 每帧重新调,
947
+ * 拿不到 ctx,只能由事件回调先存好。2026-11-06 用户需求:面板主色跟推理强度走。 */
948
+ let effortLevel = ''
949
+
926
950
  /** 启动/保持动画定时器。重复调用不会叠加(已存在则不动)。
927
951
  * getTui 是为了每 tick 拿**当前**的 tui 引用 —— widget 可能因刷新被重建,
928
952
  * 持旧的引用会调到一个已被丢弃的 renderer。 */
@@ -947,6 +971,24 @@ export function todoAnimFrame(phase: number): string {
947
971
  return TODO_ANIM_FRAMES[i]
948
972
  }
949
973
 
974
+ /**
975
+ * 推理强度 → 主题色 token。空串表示「不上推理色,用调用方给的兼底色」。
976
+ *
977
+ * 规则:`thinking` + 首字母大写,正好对上主题里的 thinkingMinimal…thinkingMax 七档。
978
+ * 为什么 off 返回空串而不是 `thinkingOff`:主题给 thinkingOff 的是接近背景的暗灰
979
+ * (dark.json: `okhsl(229 8% 49%)`),拿它去染文字会看不清。用户 2026-11-06 定的是
980
+ * 「off 时保持默认色」。
981
+ *
982
+ * ★ 同一份逻辑在 hooks/abs-rounded-editor/format.ts 里也有一份(`thinkingColorToken`)。
983
+ * 不抽公共模块是故意的:安装后 `abs.ts` 与 `abs-rounded-editor/` 是两个可独立删除的扩展,
984
+ * 抽成共享文件会让「删掉输入框样式目录」连带把 todo 面板搞崩。
985
+ */
986
+ export function effortToken(level: string | null | undefined): string {
987
+ const l = String(level || '').trim()
988
+ if (!l || l === 'off') return ''
989
+ return 'thinking' + l[0].toUpperCase() + l.slice(1)
990
+ }
991
+
950
992
  export function renderPanelLines(
951
993
  data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string }[] }[]; hidden: number },
952
994
  who: string,
@@ -954,25 +996,37 @@ export function renderPanelLines(
954
996
  fg: (color: string, s: string) => string,
955
997
  animPhase = -1,
956
998
  nickname = '',
999
+ effort = '',
957
1000
  ): string[] {
958
1001
  // ★ 空看板不再直接返回空数组(2026-10-06 用户要求:「即便 todo 没有列表,也要在 pi 上显示」)。
959
1002
  // 理由与看板段一致:面板是「机制在不在」的常驻指示,进去就看不见 = 不知道机制活着。
960
1003
  // 下面仍要画框和标题,所以这里只是不再提前 return。
961
- const colorOf = (state: string): string => (state === '搁置' ? 'dim' : state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
1004
+ //
1005
+ // ★ 主色跟推理强度走(2026-11-06 用户需求):标题、[状态]、树线 ├─└─、复选框 [ ]、
1006
+ // 进行中动画同用一色,与 pi 输入框描边/文字同色系。
1007
+ // ★ off 时整块面板用灰阶(同日用户补提:「当推理关闭的时候 todo 面板文字应该全是灰色」)——
1008
+ // 高亮色只在真的开着推理时给,否则「推理关了面板还在发光」让人误以为还在深度思考。
1009
+ // 主色用 muted(中灰),描述与子项降一阶到 dim(深灰),两阶灰保留层次,不一整片平。
1010
+ const off = !effortToken(effort)
1011
+ const main = effortToken(effort) || 'muted'
1012
+ // 描述/未勾子项的颜色:开着推理时 muted(比主色暗、又不至于暗到看不清);
1013
+ // off 时主色已经是 muted,描述得再降一阶用 dim,否则两者同色分不出主次。
1014
+ const flow = off ? 'dim' : 'muted'
962
1015
  const lines: string[] = []
963
1016
  // 上描边行已删除(2026-11-04 用户要求「去掉上边框」)。
964
1017
  // 历史:那行曾用 `thinkingOff` 对齐 pi 输入框描边色(排查三轮才定色)。
965
1018
  // 首行现在是标题行。
966
1019
  //
967
- // 昵称附在作者名后(会话内固定,启动时随机抽)。
968
- const whoPart = who ? who + (nickname ? ` · ${nickname}` : '') : nickname
969
- const title = whoPart ? `Todo (${data.total}) — ${whoPart}` : `Todo (${data.total})`
970
- // 空看板标题前加 1 格(与输入框文字对齐);有任务时不加,任务行顶到最左(2026-11-04 用户)。
971
- lines.push(clipToWidth((data.total === 0 ? ' ' : '') + fg('accent', title), width))
1020
+ // 标题只有计数。**作者名与昵称已从面板去掉**(2026-11-07 用户要求「去掉」)——
1021
+ // 它们改用输入框左下角显示(见 hooks/abs-rounded-editor)。面板不再占这两段宽度,
1022
+ // 窄终端下任务行因此能多显示几个字。
1023
+ // **不要再往标题后面拼作者名/昵称** —— 那就与输入框左下角重复了。
1024
+ const title = `Todo (${data.total})`
1025
+ // 标题也顶最左(2026-11-04 用户)。旧代码曾在空看板时多缩 1 格,2026-11-06 空看板
1026
+ // 已整块不显示(见 refreshTodoPanel),那个分支没意义了。
1027
+ lines.push(clipToWidth(fg(main, title), width))
972
1028
 
973
1029
  const lastIdx = data.rows.length - 1
974
- // 空看板只显示标题行(2026-11-04 用户要求去掉「(无未完成任务)」那行)。
975
- if (data.total === 0) return lines
976
1030
  data.rows.forEach((t, i) => {
977
1031
  // `└─` 只给最后一条 —— 不管它有没有子项。
978
1032
  // (2026-10-03 审查修正:原条件 `i === lastIdx && !t.note` 会让“末条带子项”时
@@ -980,12 +1034,15 @@ export function renderPanelLines(
980
1034
  const isLast = i === lastIdx
981
1035
  const branch = isLast ? '└─' : '├─'
982
1036
  // 进行中的行带一个小动画(其余行不加,避免满屏都在跳)
983
- const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
1037
+ // 单字符 spinner 紧跟 [进行中]:靠前放才能一眼看到(放行尾会被长描述挤到前面去看不见)。
1038
+ // 2026-11-06 用户要求:「放在 [进行中] 后面」。
1039
+ const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg(main, todoAnimFrame(animPhase)) : ''
984
1040
  // 复选框 `[ ]`(2026-10-16 用户要求):面板是 todo.md 的只读视图,
985
1041
  // 就该长成 todo 原貌 —— 摆成与文件里 `- [ ]` 同形,读到就认得出对应哪一行。
986
- const box = fg('dim', '[ ]')
987
- const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
988
- const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
1042
+ const box = fg(main, '[ ]')
1043
+ // 状态标签统一跟主色(2026-11-06 用户定:靠符号和文字区分状态,不靠颜色)。
1044
+ const head = `${fg(main, branch)} ${box} ${fg(main, `[${t.state}]`)}${anim} ${fg('text', t.id)}`
1045
+ const desc = t.desc ? ` ${fg('dim', '—')} ${fg(flow, t.desc)}` : ''
989
1046
  lines.push(clipToWidth(head + desc, width))
990
1047
  if (t.kids && t.kids.length) {
991
1048
  // 子项:标准 md 嵌套列表 → 画成树
@@ -993,13 +1050,13 @@ export function renderPanelLines(
993
1050
  // └─ [x] 子项二
994
1051
  // 树线前缀 = 父任务的 │(非末条)或空格(末条),与父任务分支对齐。
995
1052
  // 勾过的用 dim 压暗(一眼看出哪些已完成),未勾用正常色。
996
- const stem = isLast ? ' ' : fg('dim', '│ ')
1053
+ const stem = isLast ? ' ' : fg(main, '│ ')
997
1054
  const n = t.kids.length
998
1055
  t.kids.forEach((k, ki) => {
999
1056
  const kb = ki === n - 1 ? '└─' : '├─'
1000
- const kbox = fg('dim', k.done ? '[x]' : '[ ]')
1001
- const ktext = fg(k.done ? 'dim' : 'muted', k.text)
1002
- lines.push(clipToWidth(`${stem} ${fg('dim', kb)} ${kbox} ${ktext}`, width))
1057
+ const kbox = fg(main, k.done ? '[x]' : '[ ]')
1058
+ const ktext = fg(k.done ? 'dim' : flow, k.text)
1059
+ lines.push(clipToWidth(`${stem} ${fg(main, kb)} ${kbox} ${ktext}`, width))
1003
1060
  })
1004
1061
  }
1005
1062
  })
@@ -1028,8 +1085,13 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
1028
1085
  const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
1029
1086
  const who = await currentUser()
1030
1087
  const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
1031
- // 任务为空**也显示面板**(只留标题行),只有无图谱才隐藏。
1032
- // 曾写 data.total === 0 → hidePanel;用户 2026-10-06 要求改掉。
1088
+ // ★ 无任务就不显示面板(2026-11-06 用户要求,**推翻了 2026-10-06 的旧决定**)。
1089
+ // 旧决定是「即便 todo 没有列表,也要在 pi 上显示」,理由:面板是「机制在不在」的
1090
+ // 常驻指示,看不见就不知道机制活着。用户 2026-11-06 把它改回来了:
1091
+ // 「当没有任务的时候 就别显示 todo 面板了」—— 一行占着编辑器上方是纯噪音。
1092
+ // **不要再改回「空看板也显示标题行」** —— 那就是用户刚提掉的东西。
1093
+ // (想知道机制活没活看别处:abs load 的流水、~/.abs/log/hooks.log。)
1094
+ if (data.total === 0) { hidePanel(ui); return }
1033
1095
  // factory 形式:render(width) 每帧拿真实宽度 → 按宽度截断,不再断字。
1034
1096
  // 动画:phase 存在模块级,render 时读当前值;定时器只在本帧重绘,不重建 widget。
1035
1097
  let tuiRef: any = null
@@ -1047,6 +1109,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
1047
1109
  (c: string, s: string) => theme.fg(c, s),
1048
1110
  animPhase,
1049
1111
  sessionNickname,
1112
+ effortLevel,
1050
1113
  ),
1051
1114
  invalidate: () => {},
1052
1115
  }
@@ -1057,8 +1120,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
1057
1120
  const needAnim = data.rows.some((t) => t.state === '进行中')
1058
1121
  if (needAnim) startAnimTimer(() => tuiRef)
1059
1122
  else stopAnimTimer()
1060
- // ★ 无动画时得自己刷一帧(2026-10-06)。
1061
- // 旧代码靠“任务清空→hidePanel”顺带 requestRender;现在空看板不隐藏了,
1123
+ // ★ 无动画时得自己刷一帧。
1062
1124
  // 若这里不主动刷,从“有任务”变成“无任务”时屏幕会冻结在旧帧
1063
1125
  // (标题还写着旧计数)—— 与 2026-10-05 那个静默 bug 同一族。
1064
1126
  // 仍在 needAnim 时不用管:定时器每帧会刷。
@@ -1083,7 +1145,9 @@ export default function absPiHook(pi: ExtensionAPI): void {
1083
1145
  // 本会话的随机昵称 —— 在这里抽一次(不是每帧抽,否则面板会疯狂闪)。
1084
1146
  // 每次 session_start(startup/reload/new/resume/fork)重抽 → “每次打开 pi 都是随机的”。
1085
1147
  sessionNickname = pickNickname()
1086
- logHook(`session_start nickname=${sessionNickname || '(off)'}`).catch(() => {})
1148
+ // 推理强度同样存在模块级:面板主色要跟它走,而 widget factory 拿不到 ctx。
1149
+ effortLevel = String((ctx && ctx.thinkingLevel) || '')
1150
+ logHook(`session_start nickname=${sessionNickname || '(off)'} effort=${effortLevel || '(off)'}`).catch(() => {})
1087
1151
  // 启动/重载时就画出面板(reason=startup|reload|new|resume|fork)。
1088
1152
  // 时序:pi 在 session_start 时已给 ctx.ui(无 UI 时是 noOp,调了不报错),
1089
1153
  // 但设 setWidget 需真的 TUI —— 故用 hasUI 挡一下并留痕,便于判定“没显示”的原因。
@@ -1143,6 +1207,14 @@ export default function absPiHook(pi: ExtensionAPI): void {
1143
1207
  refreshTodoPanel(ctx?.ui, cwd).catch(() => {})
1144
1208
  })
1145
1209
 
1210
+ // 切推理强度 → 面板主色跟着变(2026-11-06 用户需求)。
1211
+ // 不重建 widget、只重读 effortLevel 再刷新:面板数据没变,成本与一次 turn_end 刷一样。
1212
+ pi.on("thinking_level_select", (event: any, ctx: any) => {
1213
+ effortLevel = String(event?.level || (ctx && ctx.thinkingLevel) || "")
1214
+ logHook(`thinking_level_select level=${effortLevel || "(off)"}`).catch(() => {})
1215
+ refreshTodoPanel(ctx?.ui, (ctx && ctx.cwd) || process.cwd()).catch(() => {})
1216
+ })
1217
+
1146
1218
  // todo 及时性验证(2026-10-03):往 system prompt 的 Guidelines 段追加静态条目。
1147
1219
  // 不新增消息、不抢 turn、不产生对话条目 —— 与删掉的两次注入做法本质不同(见文件头)。
1148
1220
  // 留一行日志:否则「有没有生效」只能凭感觉,无法验证。
@@ -1175,7 +1247,8 @@ export default function absPiHook(pi: ExtensionAPI): void {
1175
1247
  // 注意这里**不置 loadInjected** —— 注入点只是在 prompt 提交后、agent loop 前,
1176
1248
  // 对话还没发出去;此刻置位会让被中断的那轮白烧掉标记(见 loadInjected 注解)。
1177
1249
  let loadState = 'skip'
1178
- if (!loadInjected.has(cwd)) {
1250
+ const loadKey = loadKeyOf(cwd, sidNow)
1251
+ if (!loadInjected.has(loadKey)) {
1179
1252
  const loadText = await readLoadForGuide(cwd)
1180
1253
  // 首轮硬规则(方案 B,2026-10-06)—— 与 load 同一机会(同目录只一次)。
1181
1254
  // 为何挂在这里而不另设 flag:两者都是「开工那一刻只说一次」,
@@ -1183,11 +1256,11 @@ export default function absPiHook(pi: ExtensionAPI): void {
1183
1256
  const rulesOn = injectOpeningRules(event?.systemPromptOptions)
1184
1257
  if (loadText) {
1185
1258
  injectLoadGuideline(event?.systemPromptOptions, loadText)
1186
- loadPending = cwd // 送达确认交给 agent_end
1259
+ loadPending = loadKey // 送达确认交给 agent_end(key 与注入判定同源)
1187
1260
  loadState = 'on'
1188
1261
  } else {
1189
1262
  loadState = 'empty'
1190
- loadInjected.add(cwd) // 失败也不重试:不每轮白查一次盘
1263
+ loadInjected.add(loadKey) // 失败也不重试:不每轮白查一次盘
1191
1264
  }
1192
1265
  if (rulesOn) openingRulesLogged = true
1193
1266
  }
@@ -1220,13 +1293,13 @@ export default function absPiHook(pi: ExtensionAPI): void {
1220
1293
  await logHook(`agent_end:seen cwd=${cwd} brain=${brain || 'none'}`).catch(() => {})
1221
1294
  }
1222
1295
  // 送达确认(2026-10-06 方案 A):走到这里 = 这轮真跑完了 → 才算“LLM 看到了”。
1223
- // 中断/ESC 掉的那轮不会到 agent_end → 该 cwd 入不了集合 → 下轮自动补注入。
1296
+ // 中断/ESC 掉的那轮不会到 agent_end → 该 key 入不了集合 → 下轮自动补注入。
1224
1297
  // 这是**唯一**该写 loadInjected 的地方(别挪回 before_agent_start)。
1225
1298
  if (loadPending) {
1226
1299
  loadInjected.add(loadPending)
1227
1300
  const done = loadPending
1228
1301
  loadPending = ''
1229
- await logHook(`agent_end load_delivered cwd=${done} seen=[${[...loadInjected].join(',')}]`).catch(() => {})
1302
+ await logHook(`agent_end load_delivered key=${done} seen=[${[...loadInjected].join(',')}]`).catch(() => {})
1230
1303
  }
1231
1304
  // 每轮 spawn 已删除(2026-10-05 用户实报"卡顿"):agentEndSeen 一旦置位,
1232
1305
  // 下面那个条件就恒真 → 每轮结束都起一个 node 进程,纯属白烧。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.26.1",
3
+ "version": "1.28.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",