@fanchao8609/agent_brain_sync 1.24.0 → 1.25.1

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 圆角输入框的纯格式化函数(无宿主依赖,可单测)。
3
+ *
4
+ * 单独成文件的原因:hooks/pi-rounded-editor.ts 要 import `@earendil-works/pi-coding-agent`,
5
+ * 测试进程里没有这个包;把纯函数抽出来才能直接 import 断言。
6
+ */
7
+
8
+ /** 把模型 id 压短:只留最后一段,去掉日期后缀。deepseek/deepseek-v4.1-flash → deepseek-v4.1-flash */
9
+ export function shortenModel(id: string): string {
10
+ if (!id) return ''
11
+ const tail = id.split('/').pop() || id
12
+ return tail.replace(/-\d{8}$/, '')
13
+ }
14
+
15
+ /** 把 token 数压成 1.0M / 280k 这种短写。 */
16
+ export function shortTokens(n: number | null | undefined): string {
17
+ if (n == null || !Number.isFinite(n)) return '?'
18
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
19
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`
20
+ return String(n)
21
+ }
22
+
23
+ /** 拼右上角状态:`8.0%/1.0M 模型名`。缺哪段就省哪段。 */
24
+ export function buildStatus(usage: any, modelId: string): string {
25
+ const parts: string[] = []
26
+ if (usage && usage.percent != null) parts.push(`${usage.percent.toFixed(1)}%`)
27
+ if (usage && usage.contextWindow) parts.push(`/${shortTokens(usage.contextWindow)}`)
28
+ const model = shortenModel(modelId)
29
+ return [parts.join(''), model].filter(Boolean).join(' ')
30
+ }
31
+
32
+ export function stripAnsi(s: string): string {
33
+ return s.replace(/\x1b\[[0-9;]*m/g, '')
34
+ }
35
+
36
+ /**
37
+ * 把一行边框文本换成「左圆角 + 中段 + 右圆角」,可见宽度保持原样。
38
+ * 边框行内含 ANSI 时直接 slice 会切坏转义序列,所以先剥色、改字、再整行着色。
39
+ *
40
+ * label 贴在最右端;放不下就丢掉 label(宁可没有,也不让边框溢出折行)。
41
+ */
42
+ export function roundBorder(
43
+ line: string,
44
+ fg: (s: string) => string,
45
+ left: string,
46
+ right: string,
47
+ label = '',
48
+ ): string {
49
+ // 宽度按字符数算(不是显示宽度)。成立的前提:line 只含 ASCII 的 `─` 与空格
50
+ // —— pi 的边框行正是如此。若将来边框里出现中日韩宽字符,这里要改用 visibleWidth。
51
+ const plain = stripAnsi(line)
52
+ if (plain.length === 0) return line
53
+ // 原边框行宽 = width-2;两端圆角各占 1 列,合起来正好 width。
54
+ let mid = plain
55
+ // label 两侧各留 1 空格;放不下就不要 label。
56
+ if (label && label.length + 2 < mid.length) {
57
+ mid = mid.slice(0, mid.length - label.length - 2) + ' ' + label + ' '
58
+ }
59
+ return fg(left + mid + right)
60
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 圆角四边框输入框 + 右上角状态(pi 扩展)。
3
+ *
4
+ * 需求(2026-11-04 用户):
5
+ * ① 输入框左右都有竖线、四角圆角
6
+ * ② working 不单独占一行,只在上边框显示运行图标(embedWorkingStatus)
7
+ * ③ 上边框最右侧显示「上下文百分比/上限 模型名」,如 `8.0%/1.0M deepseek-v4.1-flash`
8
+ * ④ 输入框下方原有 footer 两行全部隐藏
9
+ *
10
+ * 为什么要覆写 render():
11
+ * pi 的 Editor.render() 只画上下两条 `─`,每行是「左 padding + 文本 + 右 padding」,
12
+ * 源码注释明写 `no side borders`。左右竖线逐行拼,没有单独钩子,只能接管整个 render。
13
+ *
14
+ * 做法:调 super.render(width - 2) 拿原结果(光标、滚动、自动补全原样保留),
15
+ * 再给上下边换成圆角、给内容行两端各包一根 `│`。
16
+ * 不复制 pi 内部逻辑,pi 改光标/滚动时本扩展自动跟住。
17
+ *
18
+ * 注意:自动补全菜单行排在底边之下、且自带内容宽度,不套竖线(否则菜单被框住会错位)。
19
+ */
20
+ import { CustomEditor } from '@earendil-works/pi-coding-agent'
21
+ import { buildStatus, roundBorder, shortenModel } from './format.ts'
22
+
23
+ const SIDE = '│'
24
+
25
+ /** 右上角状态文本,如 `8.0%/1.0M deepseek-v4.1-flash`。render() 拿不到 ctx,故由事件回调维护。 */
26
+ let statusLabel = ''
27
+
28
+ export class RoundedEditor extends CustomEditor {
29
+ render(width: number): string[] {
30
+ if (width < 4) return super.render(width)
31
+ // 内容窄 2 列,给左右竖线让位。
32
+ const lines = super.render(width - 2)
33
+ if (lines.length < 2) return lines
34
+
35
+ const borderColor = (this as any).borderColor?.bind(this)
36
+ const wrap = (s: string) => (borderColor ? borderColor(s) : s)
37
+ const out: string[] = []
38
+
39
+ // 上边框:原本是整行 `─`(含 working 图标与滚动提示),两端换圆角,右端贴状态。
40
+ out.push(roundBorder(lines[0], wrap, '╭', '╮', statusLabel))
41
+ // 下边框在原结果的倒数第 (1 + 自动补全行数) 位 —— 补全菜单排在它后面。
42
+ const autocompleteHeight = (this as any).renderedAutocompleteHeight ?? 0
43
+ const bottomIdx = lines.length - 1 - autocompleteHeight
44
+
45
+ for (let i = 1; i < bottomIdx; i++) {
46
+ out.push(wrap(SIDE) + lines[i] + wrap(SIDE))
47
+ }
48
+ out.push(roundBorder(lines[bottomIdx], wrap, '╰', '╯'))
49
+ // 自动补全行原样透传(不套竖线)。
50
+ for (let i = bottomIdx + 1; i < lines.length; i++) out.push(lines[i])
51
+ return out
52
+ }
53
+ }
54
+
55
+ /** 刷新右上角状态文本(每次渲染前由事件与定时器调用)。 */
56
+ function refreshStatus(ctx: any): void {
57
+ try {
58
+ statusLabel = buildStatus(ctx?.getContextUsage?.(), ctx?.model?.id ?? '')
59
+ } catch {
60
+ statusLabel = shortenModel(ctx?.model?.id ?? '')
61
+ }
62
+ }
63
+
64
+ export default function piRoundedEditor(pi: any): void {
65
+ pi.on('session_start', (_event: unknown, ctx: any) => {
66
+ if (!ctx?.ui?.setEditorComponent) return
67
+ refreshStatus(ctx)
68
+ // 第四个参数 embedWorkingStatus: true —— working 状态进上边框,不再单独占一行。
69
+ ctx.ui.setEditorComponent(
70
+ (tui: any, theme: any, keybindings: any) =>
71
+ new RoundedEditor(tui, theme, keybindings, { embedWorkingStatus: true }),
72
+ )
73
+ // ④ 隐藏输入框下方的 footer 两行(路径/git/token、MCP/扩展状态),信息并入右上角。
74
+ ctx.ui.setFooter?.(() => ({ render: () => [], invalidate() {} }))
75
+ })
76
+
77
+ // 切模型时更新右上角名字(/model、Ctrl+P 都走这个事件)。
78
+ pi.on('model_changed', (_event: any, ctx: any) => {
79
+ refreshStatus(ctx)
80
+ ctx?.ui?.requestRender?.()
81
+ })
82
+
83
+ // 上下文占用随每轮对话变化 —— 轮次结束与消息结束时刷新。
84
+ for (const ev of ['turn_end', 'message_end', 'tool_execution_end']) {
85
+ pi.on(ev, (_event: any, ctx: any) => {
86
+ refreshStatus(ctx)
87
+ ctx?.ui?.requestRender?.()
88
+ })
89
+ }
90
+ }
package/hooks/abs.omp.ts CHANGED
@@ -341,10 +341,16 @@ const USER_NICKNAMES = [
341
341
  let sessionNickname = ''
342
342
 
343
343
  /** 抽一条昵称。传 rnd 便于测试注入;默认 Math.random。关掉:ABS_TODO_NICK=0。 */
344
+ /** 昵称池每条存成「emoji + 空格 + 正文」,展示时剥掉 emoji,只留正文(2026-11-04 用户要求)。 */
345
+ export function stripNicknameEmoji(s: string): string {
346
+ // 循环剥:🏴‍☠️、😮‍💨 这类 ZWJ 组合 emoji 由多段拼成,只剥一次会剩下后半截。
347
+ return s.replace(/^(?:\p{Extended_Pictographic}[\uFE0F\u200D\u{1F3FB}-\u{1F3FF}]*)+[\s]*/u, '').trim()
348
+ }
349
+
344
350
  export function pickNickname(rnd: () => number = Math.random): string {
345
351
  if (String(process.env.ABS_TODO_NICK || '') === '0') return ''
346
352
  const i = Math.min(USER_NICKNAMES.length - 1, Math.floor(rnd() * USER_NICKNAMES.length))
347
- return USER_NICKNAMES[Math.max(0, i)]
353
+ return stripNicknameEmoji(USER_NICKNAMES[Math.max(0, i)])
348
354
  }
349
355
 
350
356
  /** 解析 todo.md 的 ## Todo 区 —— 只收未完成行(- [ ]),Done 区不计。
@@ -516,19 +522,15 @@ export function renderPanelLines(
516
522
  // ★ 空看板不返回空数组:面板是「机制在不在」的常驻指示,进去就看不见 = 不知道机制活着。
517
523
  const colorOf = (state: string): string => (state === '搁置' ? 'dim' : state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
518
524
  const lines: string[] = []
519
- // 上描边用 `thinkingOff` —— 与输入框描边同一个色(pi 版排查三轮才确认:不能按名字猜主题色)。
520
- // 满宽(不 -1)—— 对齐宿主自己的 DynamicBorder.render。
521
- lines.push(fg('thinkingOff', '─'.repeat(Math.max(1, width))))
525
+ // 上描边行已删除(2026-11-04 与 pi 版同步,用户要求「去掉上边框」)。首行现在是标题行。
522
526
  const whoPart = who ? who + (nickname ? ` · ${nickname}` : '') : nickname
523
- const title = whoPart ? `📋 todo (${data.total}) — ${whoPart}` : `📋 todo (${data.total})`
524
- lines.push(clipToWidth(fg('accent', title), width))
527
+ const title = whoPart ? `Todo (${data.total}) — ${whoPart}` : `Todo (${data.total})`
528
+ // 前导一格空格:对齐输入框内文本(输入框左右竖线内侧各留一格,见 hooks/pi-rounded-editor.ts)。
529
+ lines.push(clipToWidth(' ' + fg('accent', title), width))
525
530
 
526
531
  const lastIdx = data.rows.length - 1
527
- // 空看板:标题下摆一行事实。只摆事实、不写劝告。
528
- if (data.total === 0) {
529
- lines.push(clipToWidth(` ${fg('dim', '(无未完成任务)')}`, width))
530
- return lines
531
- }
532
+ // 空看板只显示标题行(2026-11-04 用户要求去掉「(无未完成任务)」那行)。
533
+ if (data.total === 0) return lines
532
534
  data.rows.forEach((t, i) => {
533
535
  // `└─` 只给最后一条 —— 不管它有没有子项(否则末条带子项时全篇没有收尾)。
534
536
  const isLast = i === lastIdx
@@ -538,7 +540,7 @@ export function renderPanelLines(
538
540
  const box = fg('dim', '[ ]')
539
541
  const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
540
542
  const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
541
- lines.push(clipToWidth(head + desc, width))
543
+ lines.push(clipToWidth(' ' + head + desc, width))
542
544
  if (t.kids && t.kids.length) {
543
545
  // 子项:标准 md 嵌套列表 → 画成树
544
546
  const stem = isLast ? ' ' : fg('dim', '│ ')
@@ -552,7 +554,7 @@ export function renderPanelLines(
552
554
  }
553
555
  })
554
556
 
555
- if (data.hidden > 0) lines.push(clipToWidth(fg('dim', ` +${data.hidden} more`), width))
557
+ if (data.hidden > 0) lines.push(clipToWidth(fg('dim', ` +${data.hidden} more`), width))
556
558
  return lines
557
559
  }
558
560
 
package/hooks/abs.pi.ts CHANGED
@@ -713,10 +713,16 @@ const USER_NICKNAMES = [
713
713
  let sessionNickname = ''
714
714
 
715
715
  /** 抽一条昵称。传 rnd 便于测试注入;默认 Math.random。关掉:ABS_TODO_NICK=0。 */
716
+ /** 昵称池每条存成「emoji + 空格 + 正文」,展示时剥掉 emoji,只留正文(2026-11-04 用户要求)。 */
717
+ export function stripNicknameEmoji(s: string): string {
718
+ // 循环剥:🏴‍☠️、😮‍💨 这类 ZWJ 组合 emoji 由多段拼成,只剥一次会剩下后半截。
719
+ return s.replace(/^(?:\p{Extended_Pictographic}[\uFE0F\u200D\u{1F3FB}-\u{1F3FF}]*)+[\s]*/u, '').trim()
720
+ }
721
+
716
722
  export function pickNickname(rnd: () => number = Math.random): string {
717
723
  if (String(process.env.ABS_TODO_NICK || '') === '0') return ''
718
724
  const i = Math.min(USER_NICKNAMES.length - 1, Math.floor(rnd() * USER_NICKNAMES.length))
719
- return USER_NICKNAMES[Math.max(0, i)]
725
+ return stripNicknameEmoji(USER_NICKNAMES[Math.max(0, i)])
720
726
  }
721
727
 
722
728
  /** 解析 todo.md 的 ## Todo 区 —— 只收未完成行(- [ ]),Done 区不计。
@@ -923,30 +929,19 @@ export function renderPanelLines(
923
929
  // 下面仍要画框和标题,所以这里只是不再提前 return。
924
930
  const colorOf = (state: string): string => (state === '搁置' ? 'dim' : state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
925
931
  const lines: string[] = []
926
- // 上描边:用 `thinkingOff` —— 跟 Pi 输入框描边**完全同一个色**。
927
- // 排查过程(2026-10-03 用户两轮反馈):
928
- // ① 用 dim = okhsl(229 8% 56%) → 用户说“太亮”(比输入框亮 7%)
929
- // ② 改 border = okhsl(231 57% 65%) → 用户说“怎么是蓝色的”(高饱和度蓝紫)
930
- // ③ 真相:输入框的描边是 theme.getThinkingBorderColor(level),level=off 时
931
- // 就是 thinkingOff = okhsl(229 8% 49%)(纯灰低饱和)。
932
- // 教训:看到“跟宿主某个 UI 元素一致”的需求,必须去宿主源码查那个元素
933
- // **实际**用了哪个主题色,不能按名字猜(border 听着像框线,其实不是输入框那个)。
934
- // 满宽(不 -1)—— 对齐 pi 自己的 DynamicBorder.render:`"─".repeat(Math.max(1, width))`。
935
- // 曾写 width-1 怕折行,但 pi 自己就满宽画,用户实报“右侧缺一小块”。
936
- // 且我们传入的 fg() 包了一层 ANSI,ANSI 不占列宽,不会因此溢出。
937
- lines.push(fg('thinkingOff', '─'.repeat(Math.max(1, width))))
932
+ // 上描边行已删除(2026-11-04 用户要求「去掉上边框」)。
933
+ // 历史:那行曾用 `thinkingOff` 对齐 pi 输入框描边色(排查三轮才定色)。
934
+ // 首行现在是标题行。
935
+ //
938
936
  // 昵称附在作者名后(会话内固定,启动时随机抽)。
939
937
  const whoPart = who ? who + (nickname ? ` · ${nickname}` : '') : nickname
940
- const title = whoPart ? `📋 todo (${data.total}) — ${whoPart}` : `📋 todo (${data.total})`
941
- lines.push(clipToWidth(fg('accent', title), width))
938
+ const title = whoPart ? `Todo (${data.total}) — ${whoPart}` : `Todo (${data.total})`
939
+ // 前导一格空格:对齐输入框内文本(输入框左右竖线内侧各留一格,见 hooks/pi-rounded-editor.ts)。
940
+ lines.push(clipToWidth(' ' + fg('accent', title), width))
942
941
 
943
942
  const lastIdx = data.rows.length - 1
944
- // 空看板:标题下摆一行事实(与 system prompt 里的看板段同口径)。
945
- // 只摆事实、不写劝告 —— 「记得登记」那类已被 13 次尝试证明无效(见 OPENING_RULES 注解)。
946
- if (data.total === 0) {
947
- lines.push(clipToWidth(` ${fg('dim', '(无未完成任务)')}`, width))
948
- return lines
949
- }
943
+ // 空看板只显示标题行(2026-11-04 用户要求去掉「(无未完成任务)」那行)。
944
+ if (data.total === 0) return lines
950
945
  data.rows.forEach((t, i) => {
951
946
  // `└─` 只给最后一条 —— 不管它有没有子项。
952
947
  // (2026-10-03 审查修正:原条件 `i === lastIdx && !t.note` 会让“末条带子项”时
@@ -960,7 +955,7 @@ export function renderPanelLines(
960
955
  const box = fg('dim', '[ ]')
961
956
  const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
962
957
  const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
963
- lines.push(clipToWidth(head + desc, width))
958
+ lines.push(clipToWidth(' ' + head + desc, width))
964
959
  if (t.kids && t.kids.length) {
965
960
  // 子项:标准 md 嵌套列表 → 画成树
966
961
  // ├─ [ ] 子项一
@@ -978,7 +973,7 @@ export function renderPanelLines(
978
973
  }
979
974
  })
980
975
 
981
- if (data.hidden > 0) lines.push(clipToWidth(fg('dim', ` +${data.hidden} more`), width))
976
+ if (data.hidden > 0) lines.push(clipToWidth(fg('dim', ` +${data.hidden} more`), width))
982
977
  return lines
983
978
  }
984
979
  /** 隐藏面板。**必须主动 requestRender** —— 否则数据清了、屏幕还留着旧画面。
@@ -1002,7 +997,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
1002
997
  const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
1003
998
  const who = await currentUser()
1004
999
  const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
1005
- // 任务为空**也显示面板**(摆一行「(无未完成任务)」),只有无图谱才隐藏。
1000
+ // 任务为空**也显示面板**(只留标题行),只有无图谱才隐藏。
1006
1001
  // 曾写 data.total === 0 → hidePanel;用户 2026-10-06 要求改掉。
1007
1002
  // factory 形式:render(width) 每帧拿真实宽度 → 按宽度截断,不再断字。
1008
1003
  // 动画:phase 存在模块级,render 时读当前值;定时器只在本帧重绘,不重建 widget。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.24.0",
3
+ "version": "1.25.1",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -180,6 +180,26 @@ abs todo memo <id> # 查
180
180
 
181
181
  经验刚冒出来就 `abs_note` 暂存(幂等去重),宁少勿滥。**跨会话任务只用 abs todo**,别用宿主原生 todo(TodoWrite / `/list`),那些不写 `.brain/todo.md`,下会话接不上。
182
182
 
183
+ ## 主动提炼(不等人说「收尾」)
184
+
185
+ 提炼是**你的自觉**,不是等用户发指令。两个触发时机,任中一个就主动做:
186
+
187
+ 1. **sources 积压**:`abs load` 顶部出现「♻ 待消化」或 `abs lint` 报 `SOURCES-PILED-UP`(> 10 条)→ 主动收拢。
188
+ 2. **一段活收尾**:任务 `done`、或踩坑后刚 `abs_note` 暂存完 → 顺手看这条值不值得提炼成规律。
189
+
190
+ 动作只有一步判断 + 一个落点:
191
+
192
+ ```bash
193
+ abs review # 列出待确认的 draft 页(concepts + sources)
194
+ # 值得留 → 用自己的判断提炼成一句话,落到正确层:
195
+ abs capture --layer 3 "某工具怎么用/有什么坑" # 工具经验
196
+ abs capture "踩坑的根因与解法" # 认知(默认 L4)
197
+ # 不值得留 → 否决掉:
198
+ abs review --reject <页名>
199
+ ```
200
+
201
+ **判断力在你(agent),工具只做落点**——`capture` 不替你判「这条值不值得留、归哪一层」。提炼成规律后删原 source、清指向它的引用(防死链,见 Teardown 第 5 步)。
202
+
183
203
  ## 收尾循环(用户明确要求收尾时才走)
184
204
 
185
205
  1. **读 todo**:`abs load`,看还有哪些没完成。
package/src/install.js CHANGED
@@ -818,6 +818,18 @@ async function installPi({ withMcp, withSkill }) {
818
818
  const p = join(dir, 'abs.ts');
819
819
  await atomicWrite(p, piPluginSource()); // Pi 用专用模板, 语法与 OpenCode 不同构
820
820
  steps.push(`✓ hook(ts extension) → ${p}`);
821
+ // 圆角四边框输入框(纯 UI,与记忆功能无关,pi 专属)。
822
+ // 为什么独立成一个扩展:它是可选样式,不想要时删掉这个目录即可,不必动 abs.ts。
823
+ // 为什么用「目录 + index.ts」而不是两个平铺的 .ts:
824
+ // pi 会把 extensions/ 下**每个顶层 .ts 都当独立扩展**并强制要求默认导出工厂函数 ——
825
+ // 平铺一个纯函数文件会让 pi 启动直接报错(2026-11-04 实报:
826
+ // "Failed to load extension pi-rounded-format.ts: does not export a valid factory function")。
827
+ // 子目录只为入口 index.ts 加载,helper 文件不参与扫描(官方多文件扩展写法)。
828
+ const roundedDir = join(dir, 'abs-rounded-editor');
829
+ for (const f of ['index.ts', 'format.ts']) {
830
+ await atomicWrite(join(roundedDir, f), renderPluginTemplate(`abs-rounded-editor/${f}`, {}));
831
+ }
832
+ steps.push(`✓ editor(rounded box) → ${join(roundedDir, 'index.ts')}`);
821
833
  // MCP → ~/.pi/agent/mcp-adapter.json 的 mcpServers (stdio)
822
834
  // 坑(2026-09-30 用户实报):曾写 mcp.json,但 pi-mcp-adapter 已不再读它
823
835
  // (pi 自有 mcp.json 被 adapter 忽略,adapter 改读 mcp-adapter.json)→ 写了等于没写。
@@ -839,6 +851,11 @@ async function installPi({ withMcp, withSkill }) {
839
851
  async function uninstallPi() {
840
852
  const steps = [];
841
853
  await fs.rm(join(hostConfigRoot('pi'), 'agent', 'extensions', 'abs.ts'), { force: true });
854
+ // 目录形式:整个删掉。顺带清掉旧版本平铺的两个文件(1.25.0 装过那种布局)。
855
+ await fs.rm(join(hostConfigRoot('pi'), 'agent', 'extensions', 'abs-rounded-editor'), { recursive: true, force: true });
856
+ for (const legacy of ['pi-rounded-editor.ts', 'pi-rounded-format.ts']) {
857
+ await fs.rm(join(hostConfigRoot('pi'), 'agent', 'extensions', legacy), { force: true });
858
+ }
842
859
  steps.push(`✓ extension 已删除`);
843
860
  steps.push(...await uninstallSkills('pi'));
844
861
  const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp-adapter.json');
package/src/query.js CHANGED
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { requireBrain, brainPath } from './index.js';
5
5
  import { rankPage } from './relevant.js';
6
6
  import { statusOfPage, idOfPage, supersededByOf, clip } from './store.js';
7
+ import { DIR_LAYER, decayOf } from './brain/layers.js';
7
8
 
8
9
  // ---------- query: 检索知识图谱(多词 OR,扫全 .md 页) ----------
9
10
  const KNOWN_SLUG_HINT = /模板残留|\[\[slug\]\]/;
@@ -49,7 +50,23 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
49
50
  // 而不是回答"我上次怎么解決 X"(那会拿到一个已知错误的答案)。
50
51
  // 但它不是静默消失:计数里告知还有几条被隐藏(带 --all 能看)。
51
52
  if (st === 'superseded' && !includeSuperseded) {
52
- hits.push({ hidden: true, slug });
53
+ hits.push({ hidden: 'superseded', slug });
54
+ continue;
55
+ }
56
+ // 流水页默认不进结果(2026-11-03 修):sessions/log-* 是「那天做了什么」
57
+ // 的草稿记录,不是知识页。它正文里碰巧出现检索词不代表它是这条经验所在 ——
58
+ // 与 db search 对齐(db 里层权重把 L5 会话页压到底,query 扫 md 没有这层,
59
+ // 只能显式隐藏)。不是静默消失:计数告知 + --all 可看。
60
+ const isFlow = d === 'sessions' && /^log-/.test(slug);
61
+ if (isFlow && !includeSuperseded) {
62
+ hits.push({ hidden: 'flow', slug });
63
+ continue;
64
+ }
65
+ // draft(未核实)默认也隐藏(2026-11-03 修):主动 recall 要的是「可信的
66
+ // 知识」,draft 是没核实的草稿,与 db search 对齐(db 里 L7 草稿被层权重
67
+ // 压到底、L4 draft 骨架亮度低)。与 superseded/flow 同待遇:--all 可看。
68
+ if (st === 'draft' && !includeSuperseded) {
69
+ hits.push({ hidden: 'draft', slug });
53
70
  continue;
54
71
  }
55
72
  hits.push({
@@ -63,18 +80,23 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
63
80
  }
64
81
  }
65
82
  }
66
- const hidden = hits.filter((h) => h.hidden).length;
83
+ const hiddenSup = hits.filter((h) => h.hidden === 'superseded').length;
84
+ const hiddenFlow = hits.filter((h) => h.hidden === 'flow').length;
85
+ const hiddenDraft = hits.filter((h) => h.hidden === 'draft').length;
86
+ const hidden = hiddenSup + hiddenFlow + hiddenDraft;
67
87
  // 分两区:精确命中优先,模糊只作补充。
68
88
  // 坑(2026-09-16 实测): 不加区分时查 file-write-locking 返回 26 页(几乎全图)——
69
89
  // 连字符词的 2-gram 太通用,模糊命中把无关页也拉进来。
70
90
  // 规则:只要有任何精确命中,模糊命中的就不排在前面(但也不丢弃,单列一段)。
71
- // 流水页降权(2026-11-02 实测):查「并发 写 锁」时前 10 位里挤进 4 页
72
- // sessions/log-*,内容页被顶下去。流水页只是「那天做了什么」的草稿记录,
73
- // 正文里碰巧出现检索词不代表它是这条经验所在。判据沿用 lint.js 的
74
- // isTerminal(sources/ 与 sessions/log-*),不另造一套。
75
- // 只降权不剔除:真要查某天的流水,它仍在结果里,只是排到内容页之后。
76
- const isFlow = (h) => h.dir === 'sources' || (h.dir === 'sessions' && /^log-/.test(h.slug));
77
- const rankBy = (h) => h.fuzzyScore - (isFlow(h) ? 20 : 0);
91
+ // 层权重(2026-11-03 修):query 之前只按分数排,流水页(sessions/log-*)和
92
+ // 草稿(sources/)与内容页同分竞争,于是 log 页挤进前 10。db search 干净的本质
93
+ // 是它按层权重排序 —— sessions=L5(decay 0.75)、sources=L7(decay 0.95),亮度被
94
+ // 压到 0.25/0.05,而 concepts/entities/syntheses=L4(decay 0.6) 权重 0.4。
95
+ // 这里复用同一套 DIR_LAYER + decayOf,与 db search 对齐,不再用笼统的「降 20 分」
96
+ // (那个修法在命中分数接近时失效,实测「长期记忆 提炼 主动回忆」前 10 仍混 3 个 log 页)。
97
+ // 权重 = 1 - decay(与 db.js 的 layerWeight 同口径)。
98
+ const layerWeight = (h) => Math.max(0.05, 1 - decayOf(DIR_LAYER[h.dir] ?? 4));
99
+ const rankBy = (h) => h.fuzzyScore * layerWeight(h);
78
100
  const exactHits = hits.filter((h) => !h.hidden && h.kind === 'exact')
79
101
  .map((h) => ({ ...h, score: rankBy(h) }))
80
102
  .sort((a, b) => b.score - a.score);
@@ -85,7 +107,7 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
85
107
  const fuzzySuppressed = exactHits.length > 0 && fuzzyHits.length > 0;
86
108
  const shown = exactHits.length ? exactHits : fuzzyHits;
87
109
  if (!shown.length) {
88
- const extra = hidden ? `(另外 ${hidden} 页已标记 superseded,用 abs query ${words.join(' ')} --all 查看)` : '';
110
+ const extra = hidden ? `(另外 ${hidden} 页被隐藏,用 abs query ${words.join(' ')} --all 查看)` : '';
89
111
  return `query [${words.join(', ')}]: 无命中。${extra}用 abs lint 看图谱健康;首次使用先 abs init。`;
90
112
  }
91
113
  // 输出条数上限(2026-09-30 补):落实 read-side-output-must-not-scale 规则。
@@ -114,7 +136,9 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
114
136
  const tail = [];
115
137
  if (words.length > 1 && !anyFull) tail.push('', `(无页同时命中全部 ${words.length} 个词,以下按命中数排序)`);
116
138
  if (fuzzySuppressed) tail.push('', `(另有 ${fuzzyHits.length} 页字形相近但字面未命中,已隐藏 —— 它们通常不相关)`);
117
- if (hidden) tail.push(``, `(${hidden} 页 superseded 已隐藏;--all 可看)`);
139
+ if (hiddenSup) tail.push(``, `(${hiddenSup} 页 superseded 已隐藏;--all 可看)`);
140
+ if (hiddenFlow) tail.push(``, `(${hiddenFlow} 页流水已隐藏;--all 可看)`);
141
+ if (hiddenDraft) tail.push(``, `(${hiddenDraft} 页 draft 未核实已隐藏;--all 可看)`);
118
142
  if (overflow > 0) tail.push(``, `(另有 ${overflow} 页未展示 —— 用更具体的词收窄:abs query <词1> <词2>)`);
119
143
  return [`query [${words.join(', ')}] → ${shown.length} 页(展示前 ${displayed.length} 页):`, '', ...lines, ...tail].join('\n');
120
144
  }