@fanchao8609/agent_brain_sync 1.23.0 → 1.25.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/bin/abs.js +24 -3
- package/bin/mcp.js +7 -1
- package/hooks/abs.omp.ts +15 -13
- package/hooks/abs.pi.ts +23 -24
- package/hooks/pi-rounded-editor.ts +90 -0
- package/hooks/pi-rounded-format.ts +60 -0
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +37 -0
- package/src/brain/db-cmd.js +15 -1
- package/src/brain/load.js +101 -0
- package/src/brain/maintain.js +29 -20
- package/src/install.js +12 -0
- package/src/query.js +38 -7
- package/src/store.js +1 -1
package/bin/abs.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// bin/abs.js — abs CLI 入口。
|
|
3
3
|
// abs <cmd> [args]
|
|
4
4
|
// 命令: init / board / status / load / task / install / uninstall / help
|
|
5
|
-
import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } from '../src/store.js';
|
|
5
|
+
import { cmdInit, cmdStatus, cmdLoad, cmdRecall, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } from '../src/store.js';
|
|
6
6
|
import { cmdDbRebuild, cmdDbSearch, cmdDbStatus, cmdDbEvolve, addProject } from '../src/brain/db-cmd.js';
|
|
7
7
|
import { cmdCapture } from '../src/brain/capture.js';
|
|
8
8
|
import { cmdSummon } from '../src/brain/summon-cmd.js';
|
|
@@ -135,6 +135,8 @@ const FLAG_SPEC = {
|
|
|
135
135
|
'by': { type: 'string' },
|
|
136
136
|
'all': { type: 'boolean' },
|
|
137
137
|
'keep-days': { type: 'string' },
|
|
138
|
+
// db evolve 用:多少天没动且无人链算 stale。未声明会被静默吞成 true(同 keep-days 坑)。
|
|
139
|
+
'stale-days': { type: 'string' },
|
|
138
140
|
'help': { type: 'boolean' },
|
|
139
141
|
'dry-run': { type: 'boolean' },
|
|
140
142
|
'full': { type: 'boolean' },
|
|
@@ -145,7 +147,7 @@ const FLAG_SPEC = {
|
|
|
145
147
|
};
|
|
146
148
|
|
|
147
149
|
/** 已被"规范键"接管的 raw flag:不再原样漏出(见 parseArgv 返回处的白名单注释)。 */
|
|
148
|
-
const KNOWN_RAW = new Set(['keep-days', 'dry-run', 'no-mcp', 'no-skill']);
|
|
150
|
+
const KNOWN_RAW = new Set(['keep-days', 'stale-days', 'dry-run', 'no-mcp', 'no-skill']);
|
|
149
151
|
|
|
150
152
|
function parseArgv(args) {
|
|
151
153
|
// 坑: parseArgs 会把**任何** `-` 开头的 token 当选项,连正文一起吃:
|
|
@@ -220,6 +222,7 @@ function parseArgv(args) {
|
|
|
220
222
|
repair: !!values.repair,
|
|
221
223
|
full: !!values.full,
|
|
222
224
|
keepDays: values['keep-days'],
|
|
225
|
+
staleDays: values['stale-days'],
|
|
223
226
|
dryRun: !!values['dry-run'],
|
|
224
227
|
mcp: !values['no-mcp'],
|
|
225
228
|
skill: !values['no-skill'],
|
|
@@ -239,6 +242,7 @@ const usage = `abs — 跨会话记忆工具(.brain/ 图谱)
|
|
|
239
242
|
abs todo add X --note "做什么" 登记任务 (done/state/note 管后续)
|
|
240
243
|
abs note "经验一句话" 随手记经验; abs log "完成 X" 记成果
|
|
241
244
|
abs query <词…> 检索 .brain/; abs todo 看任务板
|
|
245
|
+
abs recall [词…] 卡住时主动取相关页(不给词就按当前处境推)
|
|
242
246
|
|
|
243
247
|
全部命令 (细节: abs <命令> --help):
|
|
244
248
|
init [--repair] 建图谱 (当前目录)
|
|
@@ -363,6 +367,18 @@ const subUsage = {
|
|
|
363
367
|
'参数:',
|
|
364
368
|
' --all 连 superseded(已失效)页一起返回',
|
|
365
369
|
].join('\n'),
|
|
370
|
+
recall: [
|
|
371
|
+
'abs recall — 卡住时主动取相关页(不需先想到检索词)',
|
|
372
|
+
'',
|
|
373
|
+
'用法:',
|
|
374
|
+
' abs recall [词1 词2 …] [--limit N]',
|
|
375
|
+
'',
|
|
376
|
+
'不给词时,从当前处境推:活跃 todo 的关键词 + 最近改过的文件名。',
|
|
377
|
+
'给了词就只用你给的词,不混处境词。',
|
|
378
|
+
'',
|
|
379
|
+
'与 load 的区别: load 只在开工跑一次;recall 随时可跑(卡住/换方向时)。',
|
|
380
|
+
'与 query 的区别: query 枚全图命中;recall 只给按亮度亮的几页。',
|
|
381
|
+
].join('\n'),
|
|
366
382
|
config: [
|
|
367
383
|
'abs config — 使用者姓名(写操作的作者标记)',
|
|
368
384
|
'',
|
|
@@ -457,6 +473,11 @@ async function main() {
|
|
|
457
473
|
rejectExtra(opts._, 'abs load');
|
|
458
474
|
console.log(await cmdLoad({ dir: opts.dir }));
|
|
459
475
|
break;
|
|
476
|
+
case 'recall': {
|
|
477
|
+
const limit = Number(opts.limit) > 0 ? Number(opts.limit) : 5;
|
|
478
|
+
console.log(await cmdRecall({ dir: opts.dir, terms: opts._, limit }));
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
460
481
|
case 'index':
|
|
461
482
|
rejectExtra(opts._, 'abs index');
|
|
462
483
|
console.log(await cmdShow({ dir: opts.dir, view: 'index' }));
|
|
@@ -582,7 +603,7 @@ async function main() {
|
|
|
582
603
|
if (sub === 'rebuild') { console.log(await cmdDbRebuild({ dir: opts.dir })); break; }
|
|
583
604
|
if (sub === 'search') { console.log(await cmdDbSearch({ query: rest.join(' '), limit: Number(opts.limit) || 8, full: !!opts.full })); break; }
|
|
584
605
|
if (sub === 'status') { console.log(await cmdDbStatus()); break; }
|
|
585
|
-
if (sub === 'evolve') { console.log(await cmdDbEvolve({ staleDays: Number(opts
|
|
606
|
+
if (sub === 'evolve') { console.log(await cmdDbEvolve({ staleDays: Number(opts.staleDays) || 90 })); break; }
|
|
586
607
|
if (sub === 'add') {
|
|
587
608
|
const r = await addProject(opts.dir || process.cwd());
|
|
588
609
|
console.log(`✓ 已登记项目: ${r}\n 建库: abs db rebuild`);
|
package/bin/mcp.js
CHANGED
|
@@ -11,7 +11,7 @@ import { appendFile, mkdir } from 'node:fs/promises';
|
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { findBrainRoot, absLogDir } from '../src/index.js';
|
|
14
|
+
import { findBrainRoot, absLogDir, AbsError } from '../src/index.js';
|
|
15
15
|
import { cmdBoard, cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdWrapup, cmdRule, cmdResolve, cmdSupersede } from '../src/store.js';
|
|
16
16
|
|
|
17
17
|
// ---------- 技术日志: MCP 请求跟踪(调试用, 与图谱 log.md 完全分开) ----------
|
|
@@ -40,6 +40,12 @@ function withTrace(name, handler) {
|
|
|
40
40
|
return r;
|
|
41
41
|
} catch (e) {
|
|
42
42
|
await tlog(`tool=${name} cwd=${args?.cwd || '-'} args=${argBrief} → THROW ${Date.now() - t0}ms | ${String(e?.message || e).slice(0, 200)}`);
|
|
43
|
+
// 带 code 的 AbsError(如 requireUser 的 NO_USER)必须走 err() 结构化返回,
|
|
44
|
+
// 保留 code + fallback 自救指引 —— 裸 rethrow 只带 message,LLM 拿到后
|
|
45
|
+
// 不知道下一步跑什么(与 CLI 侧 `[CODE] + → fallback` 的既有行为不一致)。
|
|
46
|
+
if (e instanceof AbsError) {
|
|
47
|
+
return err(e.message, { code: e.code, fallback: e.fallback || undefined });
|
|
48
|
+
}
|
|
43
49
|
throw e;
|
|
44
50
|
}
|
|
45
51
|
};
|
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
|
-
//
|
|
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 ?
|
|
524
|
-
|
|
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', `
|
|
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
|
@@ -231,6 +231,10 @@ const TODO_GUIDELINES = [
|
|
|
231
231
|
// ⑦ 文档精简九条(用户 2026-10-22 定,原文见 skill 的「文档精简」一节):
|
|
232
232
|
// 管每一行写进 .brain 的文字;与上面六条行为规则不重叠,故不合并。
|
|
233
233
|
'Every line you write into .brain/ (todo rows, log rows, knowledge pages, session snapshots) must pass the nine-bullet doc-slimming rule in the abs skill: only what serves the topic (drop the rest), no unverified summaries, ordered and layered, plain declarative wording (no rhetorical questions, no guessing), no duplication (cite the other doc instead), terse prohibitions, nothing the user did not ask for, plain Chinese.',
|
|
234
|
+
// ⑧ 有疑惑先查记忆(用户 2026-11-03 定):主动搜索不是等报错才应激,
|
|
235
|
+
// 而是「心里形成关键词/有疑问的那一刻」自己查。上面七条只讲了登记,
|
|
236
|
+
// 没讲「记下来的东西怎么取」——实测一场会话从头到尾没查过一次记忆。
|
|
237
|
+
'When a doubt forms about something the project has likely dealt with before (a design choice, a past bug, a convention), run `abs recall <关键词>` (or `abs query <词>` for wider matches) BEFORE acting on the assumption. Do not wait for an error or for the user to remind you — asking the memory is your own decision, made the moment the question arises.',
|
|
234
238
|
]
|
|
235
239
|
|
|
236
240
|
/** 当前项目是否有 .brain 图谱 —— 有才加指引(没图谱的项目里这指引是噪音)。
|
|
@@ -709,10 +713,16 @@ const USER_NICKNAMES = [
|
|
|
709
713
|
let sessionNickname = ''
|
|
710
714
|
|
|
711
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
|
+
|
|
712
722
|
export function pickNickname(rnd: () => number = Math.random): string {
|
|
713
723
|
if (String(process.env.ABS_TODO_NICK || '') === '0') return ''
|
|
714
724
|
const i = Math.min(USER_NICKNAMES.length - 1, Math.floor(rnd() * USER_NICKNAMES.length))
|
|
715
|
-
return USER_NICKNAMES[Math.max(0, i)]
|
|
725
|
+
return stripNicknameEmoji(USER_NICKNAMES[Math.max(0, i)])
|
|
716
726
|
}
|
|
717
727
|
|
|
718
728
|
/** 解析 todo.md 的 ## Todo 区 —— 只收未完成行(- [ ]),Done 区不计。
|
|
@@ -919,30 +929,19 @@ export function renderPanelLines(
|
|
|
919
929
|
// 下面仍要画框和标题,所以这里只是不再提前 return。
|
|
920
930
|
const colorOf = (state: string): string => (state === '搁置' ? 'dim' : state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
|
|
921
931
|
const lines: string[] = []
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
// ③ 真相:输入框的描边是 theme.getThinkingBorderColor(level),level=off 时
|
|
927
|
-
// 就是 thinkingOff = okhsl(229 8% 49%)(纯灰低饱和)。
|
|
928
|
-
// 教训:看到“跟宿主某个 UI 元素一致”的需求,必须去宿主源码查那个元素
|
|
929
|
-
// **实际**用了哪个主题色,不能按名字猜(border 听着像框线,其实不是输入框那个)。
|
|
930
|
-
// 满宽(不 -1)—— 对齐 pi 自己的 DynamicBorder.render:`"─".repeat(Math.max(1, width))`。
|
|
931
|
-
// 曾写 width-1 怕折行,但 pi 自己就满宽画,用户实报“右侧缺一小块”。
|
|
932
|
-
// 且我们传入的 fg() 包了一层 ANSI,ANSI 不占列宽,不会因此溢出。
|
|
933
|
-
lines.push(fg('thinkingOff', '─'.repeat(Math.max(1, width))))
|
|
932
|
+
// 上描边行已删除(2026-11-04 用户要求「去掉上边框」)。
|
|
933
|
+
// 历史:那行曾用 `thinkingOff` 对齐 pi 输入框描边色(排查三轮才定色)。
|
|
934
|
+
// 首行现在是标题行。
|
|
935
|
+
//
|
|
934
936
|
// 昵称附在作者名后(会话内固定,启动时随机抽)。
|
|
935
937
|
const whoPart = who ? who + (nickname ? ` · ${nickname}` : '') : nickname
|
|
936
|
-
const title = whoPart ?
|
|
937
|
-
|
|
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))
|
|
938
941
|
|
|
939
942
|
const lastIdx = data.rows.length - 1
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
if (data.total === 0) {
|
|
943
|
-
lines.push(clipToWidth(` ${fg('dim', '(无未完成任务)')}`, width))
|
|
944
|
-
return lines
|
|
945
|
-
}
|
|
943
|
+
// 空看板只显示标题行(2026-11-04 用户要求去掉「(无未完成任务)」那行)。
|
|
944
|
+
if (data.total === 0) return lines
|
|
946
945
|
data.rows.forEach((t, i) => {
|
|
947
946
|
// `└─` 只给最后一条 —— 不管它有没有子项。
|
|
948
947
|
// (2026-10-03 审查修正:原条件 `i === lastIdx && !t.note` 会让“末条带子项”时
|
|
@@ -956,7 +955,7 @@ export function renderPanelLines(
|
|
|
956
955
|
const box = fg('dim', '[ ]')
|
|
957
956
|
const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
|
|
958
957
|
const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
|
|
959
|
-
lines.push(clipToWidth(head + desc, width))
|
|
958
|
+
lines.push(clipToWidth(' ' + head + desc, width))
|
|
960
959
|
if (t.kids && t.kids.length) {
|
|
961
960
|
// 子项:标准 md 嵌套列表 → 画成树
|
|
962
961
|
// ├─ [ ] 子项一
|
|
@@ -974,7 +973,7 @@ export function renderPanelLines(
|
|
|
974
973
|
}
|
|
975
974
|
})
|
|
976
975
|
|
|
977
|
-
if (data.hidden > 0) lines.push(clipToWidth(fg('dim', `
|
|
976
|
+
if (data.hidden > 0) lines.push(clipToWidth(fg('dim', ` +${data.hidden} more`), width))
|
|
978
977
|
return lines
|
|
979
978
|
}
|
|
980
979
|
/** 隐藏面板。**必须主动 requestRender** —— 否则数据清了、屏幕还留着旧画面。
|
|
@@ -998,7 +997,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
|
|
|
998
997
|
const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
|
|
999
998
|
const who = await currentUser()
|
|
1000
999
|
const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
|
|
1001
|
-
//
|
|
1000
|
+
// 任务为空**也显示面板**(只留标题行),只有无图谱才隐藏。
|
|
1002
1001
|
// 曾写 data.total === 0 → hidePanel;用户 2026-10-06 要求改掉。
|
|
1003
1002
|
// factory 形式:render(width) 每帧拿真实宽度 → 按宽度截断,不再断字。
|
|
1004
1003
|
// 动画:phase 存在模块级,render 时读当前值;定时器只在本帧重绘,不重建 widget。
|
|
@@ -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 './pi-rounded-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
|
+
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -5,6 +5,23 @@ description: "USE FOR abs / agent-brain-sync 跨会话记忆与任务续接。
|
|
|
5
5
|
|
|
6
6
|
# abs — 跨会话记忆 (agent-brain-sync)
|
|
7
7
|
|
|
8
|
+
## 你有知识库,随时都能查
|
|
9
|
+
|
|
10
|
+
`.brain/` 是**你自己的跨会话记忆库**:踩过的坑、做过的决定、验证过的做法都在里面。
|
|
11
|
+
|
|
12
|
+
**查它是你的决定,不是别人提醒你才做。** 干活中一旦心里形成关键词、对某件事有疑问(这个坑以前踩过吗?这个约定是谁定的?),那一刻就主动查,**不要等报错、不要等用户提醒、不要等开工那一秒**。
|
|
13
|
+
|
|
14
|
+
怎么查:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
abs recall <关键词> # 有疑问时用:按亮度出最相关的几页(不给词就从当前处境推)
|
|
18
|
+
ab query <词> # 想拿更全:多词 OR,枚举全图命中
|
|
19
|
+
db search <词> # 更高级:种子扩散亮起相关页,先出铁律
|
|
20
|
+
abs resolve <页名> # 拿到页名后反查路径,只读那一页
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
查完只读命中那几页,**绝不通读 `.brain/`**。答用 `[[页面名]]` 标来源。
|
|
24
|
+
|
|
8
25
|
## 触发器
|
|
9
26
|
|
|
10
27
|
**关键词不是触发器,意图才是。**句子里出现 todo / 沉淀 / load / note 这些词,若是谈论、提问、定规则,只回应不执行。
|
|
@@ -163,6 +180,26 @@ abs todo memo <id> # 查
|
|
|
163
180
|
|
|
164
181
|
经验刚冒出来就 `abs_note` 暂存(幂等去重),宁少勿滥。**跨会话任务只用 abs todo**,别用宿主原生 todo(TodoWrite / `/list`),那些不写 `.brain/todo.md`,下会话接不上。
|
|
165
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
|
+
|
|
166
203
|
## 收尾循环(用户明确要求收尾时才走)
|
|
167
204
|
|
|
168
205
|
1. **读 todo**:`abs load`,看还有哪些没完成。
|
package/src/brain/db-cmd.js
CHANGED
|
@@ -54,7 +54,21 @@ export function uniqueProjectNames(roots) {
|
|
|
54
54
|
for (const [r, v] of cand) out.set(r, v);
|
|
55
55
|
break;
|
|
56
56
|
}
|
|
57
|
-
if (depth === 4)
|
|
57
|
+
if (depth === 4) {
|
|
58
|
+
// 深度到 4 仍重名(如 ~/a/x/软件/.agents 与 ~/b/y/软件/.agents 共享 4 层尾路径):
|
|
59
|
+
// 不能「认了」直接塞 —— 那会让两个项目拿到同一个项目名,rebuild 撞
|
|
60
|
+
// UNIQUE(project,dir,slug) 约束整体崩溃(正是本函数要避免的场景)。
|
|
61
|
+
// 给仍碰撞的条目追加序号(-2/-3…),保证互不重名且可读。
|
|
62
|
+
const byVal = new Map();
|
|
63
|
+
for (const [r, v] of cand) {
|
|
64
|
+
if (!byVal.has(v)) { byVal.set(v, []); }
|
|
65
|
+
byVal.get(v).push(r);
|
|
66
|
+
}
|
|
67
|
+
for (const [v, rs] of byVal) {
|
|
68
|
+
if (rs.length === 1) { out.set(rs[0], v); continue; }
|
|
69
|
+
rs.forEach((r, i) => out.set(r, `${v}-${i + 2}`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
58
72
|
}
|
|
59
73
|
}
|
|
60
74
|
return out;
|
package/src/brain/load.js
CHANGED
|
@@ -322,6 +322,107 @@ async function queryHintSection(root, todo) {
|
|
|
322
322
|
return queryHint(root, todo);
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/**
|
|
326
|
+
* recall:卡住时主动取「跟当前处境相关的几页」,不需要先想到检索词。
|
|
327
|
+
*
|
|
328
|
+
* 为何要它(2026-11-03 实测):hotPages 只挂在 load 上,于是只有开工那一刻会送页。
|
|
329
|
+
* 实测一场完整会话从头到尾没调过一次 abs —— 不是检索不准(命中确实准),
|
|
330
|
+
* 而是「要人先想到发起查询」这一步天然被跳过。一个出口只开一次,就等于没开。
|
|
331
|
+
*
|
|
332
|
+
* 与 hotPages 的分工:hotPages 是开机首屏(自动、固定 3 页);
|
|
333
|
+
* recall 是随时可调(agent 卡住/换方向时自己跑),且能补词。
|
|
334
|
+
* 两者共用 contextKeywords + 亮度搜索,不各算一套。
|
|
335
|
+
*
|
|
336
|
+
* 词从哪来:
|
|
337
|
+
* - 传了词 → 用它(这时它就是个带上下文的搜索,仍会补处境词)
|
|
338
|
+
* - 没传词 → 纯靠处境(活跃 todo 的关键词 + 最近改过的文件)
|
|
339
|
+
* 全部凭空无词 → 说清楚缺什么,不返回空列表充数。
|
|
340
|
+
*
|
|
341
|
+
* @param {{dir?:string, terms?:string[], limit?:number}} opts
|
|
342
|
+
*/
|
|
343
|
+
export async function cmdRecall({ dir, terms, limit = 5 } = {}) {
|
|
344
|
+
const given = (terms || []).flatMap((t) => String(t).trim().split(/\s+/)).filter(Boolean);
|
|
345
|
+
let root;
|
|
346
|
+
try {
|
|
347
|
+
root = await requireBrain(dir || process.cwd());
|
|
348
|
+
} catch {
|
|
349
|
+
return '未找到 .brain/ 图谱(无记忆可查)。先在项目根运行: abs init';
|
|
350
|
+
}
|
|
351
|
+
const todo = await readTodo(root).catch(() => '');
|
|
352
|
+
let kws = [];
|
|
353
|
+
let files = [];
|
|
354
|
+
try {
|
|
355
|
+
({ kws, files } = await contextKeywords(root, todo));
|
|
356
|
+
} catch {
|
|
357
|
+
// 拔词失败不该拦住整个 recall
|
|
358
|
+
}
|
|
359
|
+
// 手动传的词优先级最高:用户/agent 已经想清楚要什么,就别把处境词混进去稀释
|
|
360
|
+
const words = given.length ? given : kws.slice(0, 5);
|
|
361
|
+
if (!words.length) {
|
|
362
|
+
return 'recall: 无从判断当前处境(todo 空、近期无改动、也没给词)。\n'
|
|
363
|
+
+ '→ 直接给词: abs recall <词1> <词2>;或先 abs todo add 把在干的事登记上。';
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const { openDb, search, illuminate, dbPath } = await import('./db.js');
|
|
367
|
+
const { promises: fsp } = await import('node:fs');
|
|
368
|
+
const exists = await fsp.stat(dbPath()).then(() => true).catch(() => false);
|
|
369
|
+
if (!exists) return 'recall: 图数据库还没建(亮度检索需要它)。先跑: abs db rebuild';
|
|
370
|
+
|
|
371
|
+
const project = String(root).split('/').filter(Boolean).pop() || '';
|
|
372
|
+
const h = openDb(dbPath());
|
|
373
|
+
let lit;
|
|
374
|
+
try {
|
|
375
|
+
const seeds = new Map();
|
|
376
|
+
// 整串先查(实测:`db search "并发 锁"` 能出 file-write-locking,
|
|
377
|
+
// 而逐词分开查的种子表里根本没它 —— 2-gram 索引对整串和单词的切法不同,
|
|
378
|
+
// 拆开就把最有用的命中拆没了。所以整串优先,再用逐词兜底补漏。
|
|
379
|
+
const whole = given.length ? words.join(' ') : words.join(' ');
|
|
380
|
+
for (const s of search(h, whole, { limit: 12 })) seeds.set(s.id, s);
|
|
381
|
+
for (const w of words) {
|
|
382
|
+
for (const s of search(h, w, { limit: 6 })) {
|
|
383
|
+
const prev = seeds.get(s.id);
|
|
384
|
+
if (!prev || s.bright > prev.bright) seeds.set(s.id, s);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!seeds.size) {
|
|
388
|
+
return `recall [${words.join(', ')}]: 库里没搜到相关页。\n`
|
|
389
|
+
+ '→ 换个说法: abs recall <别的词>;看图谱全貌: abs query <词>。';
|
|
390
|
+
}
|
|
391
|
+
// 流水页降权(2026-11-03 实测):不加时前 5 位里挤进 2 页 sessions/log-*,
|
|
392
|
+
// 还被排到第一,把 file-write-locking 这种真经验页顶下去。
|
|
393
|
+
// 与 query 用同一套判据(lint.js 的 isTerminal),不另造。只降权不剔除。
|
|
394
|
+
const isFlow = (r) => r.dir === 'sources' || (r.dir === 'sessions' && /^log-/.test(r.slug));
|
|
395
|
+
lit = illuminate(h, [...seeds.values()], { limit: limit * 8 })
|
|
396
|
+
.filter((r) => r.project === project)
|
|
397
|
+
.sort((a, b) => (b.bright - (isFlow(b) ? 1 : 0)) - (a.bright - (isFlow(a) ? 1 : 0)))
|
|
398
|
+
.slice(0, limit);
|
|
399
|
+
} finally {
|
|
400
|
+
h.close();
|
|
401
|
+
}
|
|
402
|
+
if (!lit.length) {
|
|
403
|
+
return `recall [${words.join(', ')}]: 搜到了种子,但本项目的页没有亮起来。\n`
|
|
404
|
+
+ '→ 试试 abs query <词> 看全图命中。';
|
|
405
|
+
}
|
|
406
|
+
const rows = [`recall [${words.join(', ')}] → ${lit.length} 页(按亮度;看全文: abs resolve <页名>)`];
|
|
407
|
+
if (!given.length) {
|
|
408
|
+
const why = [
|
|
409
|
+
files.length ? `近期改动 ${files.length} 个文件` : '',
|
|
410
|
+
todo ? '活跃 todo' : '',
|
|
411
|
+
].filter(Boolean).join(' + ');
|
|
412
|
+
if (why) rows.push(` 处境: ${why}`);
|
|
413
|
+
}
|
|
414
|
+
rows.push('');
|
|
415
|
+
for (const r of lit) {
|
|
416
|
+
const one = await fsp.readFile(brainPath(root, r.dir, `${r.slug}.md`), 'utf8').catch(() => '');
|
|
417
|
+
const about = one ? digest(one, 80) : '';
|
|
418
|
+
rows.push(` ${r.bright.toFixed(2)} L${r.layer} [[${r.slug}]]${about ? ' — ' + about : ''}`);
|
|
419
|
+
}
|
|
420
|
+
return rows.join('\n');
|
|
421
|
+
} catch (e) {
|
|
422
|
+
return `recall 失败: ${e instanceof Error ? e.message : String(e)}`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
325
426
|
/** sources 堆积提醒:返回 [标题行, ...内容行] 或 null。失败静默。 */
|
|
326
427
|
async function sourcesCountSection(root) {
|
|
327
428
|
const nsrc = await countSources(root);
|
package/src/brain/maintain.js
CHANGED
|
@@ -57,38 +57,45 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
57
57
|
return `[dry-run] 将归档 ${plan.archived.length} 天 / ${plan.count} 条(每天一个文件)\n ${brief}${why}`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// 1)
|
|
60
|
+
// 1) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖),
|
|
61
|
+
// todo 是权威 —— 会话页/ index 都跟随它,不反向。
|
|
62
|
+
// 先写 todo 再写会话页:todo 写失败(闸门拒绝/SKIP)时会话页不写,
|
|
63
|
+
// 避免「会话页已写、看板没动」的两边不一致(原顺序相反,且会话页裸写绕过锁)。
|
|
64
|
+
let archivedFinal = [];
|
|
65
|
+
await editFile(todoP, (cur) => {
|
|
66
|
+
// 坑(2026-10-05 实测抓到): 这里漏传 maxLines → 锁内重算的 archived 为空 →
|
|
67
|
+
// 返回 SKIP → 磁盘上的 todo.md 一个字没动(而 sessions/ 归档页已经写了,
|
|
68
|
+
// 两边不一致)。**同参数的每个调用点都要一起改**。
|
|
69
|
+
const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
70
|
+
if (!p2.archived.length) return SKIP;
|
|
71
|
+
archivedFinal = p2.archived; // 锁内算出的权威清单,后续写会话页/index 用它
|
|
72
|
+
return { text: p2.text };
|
|
73
|
+
});
|
|
74
|
+
if (!archivedFinal.length) {
|
|
75
|
+
return `(todo 锁内重算无归档项,已跳过)${why}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2) 归档目标 = 当天的会话快照文件(log-<日期>.md),写进其「任务归档」段。
|
|
61
79
|
// 为何合一(2026-09-15 用户定): 归档与会话快照是**同一天的记录**,分两个文件查着要开两处。
|
|
62
80
|
// 边界: ① 文件不存在→建只有归档段的页(那天可能没写快照)
|
|
63
81
|
// ② 文件已存在(含 AI 手写快照)→ 只替换归档段,段外逐字保留
|
|
64
82
|
// (硬规则「已存在的人工内容一律不覆盖」由 upsertArchiveSection 保证)。
|
|
83
|
+
// 走 editFile 锁(2026-11-03 审查修复):append 语义必须与 setMemo 同一路数,
|
|
84
|
+
// 裸 readFile+tmp+rename 会让并发 archive 互相覆盖,丢一批归档任务行。
|
|
65
85
|
const sessDir = brainPath(root, 'sessions');
|
|
66
|
-
|
|
86
|
+
await fs.mkdir(sessDir, { recursive: true });
|
|
87
|
+
for (const g of archivedFinal) {
|
|
67
88
|
const pageP = join(sessDir, `${g.slug}.md`);
|
|
68
|
-
|
|
69
|
-
try { page = await fs.readFile(pageP, 'utf8'); } catch { /* 首次 */ }
|
|
70
|
-
const nextPage = upsertArchiveSection(page, g);
|
|
71
|
-
const tmp = join(sessDir, `.${g.slug}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
|
|
72
|
-
await fs.writeFile(tmp, nextPage, 'utf8');
|
|
73
|
-
await fs.rename(tmp, pageP);
|
|
89
|
+
await editFile(pageP, (cur) => ({ text: upsertArchiveSection(cur, g) }));
|
|
74
90
|
}
|
|
75
91
|
|
|
76
|
-
// 2) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖)
|
|
77
|
-
await editFile(todoP, (cur) => {
|
|
78
|
-
// 坑(2026-10-05 实测抓到): 这里漏传 maxLines → 锁内重算的 archived 为空 →
|
|
79
|
-
// 返回 SKIP → 磁盘上的 todo.md 一个字没动(而 sessions/ 归档页已经写了,
|
|
80
|
-
// 两边不一致:"说归档了但看板没变")。**同参数的每个调用点都要一起改**。
|
|
81
|
-
const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
82
|
-
return p2.archived.length ? { text: p2.text } : SKIP;
|
|
83
|
-
});
|
|
84
|
-
|
|
85
92
|
// 3) index 登记(每个日期页一行,幂等)
|
|
86
93
|
// 坑(2026-09-15 修): 原先把归档页登记到 `## Sources` 区 —— 但它们在 sessions/ 里,
|
|
87
94
|
// 旧数据实测就是错位的(09-10/09-12 两条曾键在 Sources 段,人工才发现)。现改登 `## Sessions`。
|
|
88
95
|
const iP = brainPath(root, 'index.md');
|
|
89
96
|
await editFile(iP, (index) => {
|
|
90
97
|
if (!index) return SKIP;
|
|
91
|
-
const missing =
|
|
98
|
+
const missing = archivedFinal.filter((g) => !index.includes(`[[${g.slug}]]`));
|
|
92
99
|
if (!missing.length) return SKIP;
|
|
93
100
|
const sIdx = index.indexOf('## Sessions');
|
|
94
101
|
if (sIdx === -1) return SKIP;
|
|
@@ -100,8 +107,10 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
100
107
|
return { text: next };
|
|
101
108
|
});
|
|
102
109
|
|
|
103
|
-
const files =
|
|
104
|
-
|
|
110
|
+
const files = archivedFinal.map((g) => `sessions/${g.slug}.md`).join('\n ');
|
|
111
|
+
const count = archivedFinal.reduce((n, g) => n + g.count, 0);
|
|
112
|
+
const briefFinal = archivedFinal.map((g) => `${g.date}(${g.count})`).join(' ');
|
|
113
|
+
return `✓ 已归档 ${archivedFinal.length} 天 / ${count} 条\n ${briefFinal}\n → ${files}${why}`;
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
/**
|
package/src/install.js
CHANGED
|
@@ -818,6 +818,15 @@ 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
|
+
// 为什么要独立成第二个扩展文件:它是可选样式,用户不想要时删掉这一个文件即可,
|
|
823
|
+
// 不必动 abs.ts 这个核心 hook。模板无占位符,直接拷。
|
|
824
|
+
// 两个文件必须一起装:editor 里 import 了 format 的纯函数(抽出来是为了能单测)。
|
|
825
|
+
const roundedFiles = ['pi-rounded-editor.ts', 'pi-rounded-format.ts'];
|
|
826
|
+
for (const f of roundedFiles) {
|
|
827
|
+
await atomicWrite(join(dir, f), renderPluginTemplate(f, {}));
|
|
828
|
+
}
|
|
829
|
+
steps.push(`✓ editor(rounded box) → ${join(dir, 'pi-rounded-editor.ts')}`);
|
|
821
830
|
// MCP → ~/.pi/agent/mcp-adapter.json 的 mcpServers (stdio)
|
|
822
831
|
// 坑(2026-09-30 用户实报):曾写 mcp.json,但 pi-mcp-adapter 已不再读它
|
|
823
832
|
// (pi 自有 mcp.json 被 adapter 忽略,adapter 改读 mcp-adapter.json)→ 写了等于没写。
|
|
@@ -839,6 +848,9 @@ async function installPi({ withMcp, withSkill }) {
|
|
|
839
848
|
async function uninstallPi() {
|
|
840
849
|
const steps = [];
|
|
841
850
|
await fs.rm(join(hostConfigRoot('pi'), 'agent', 'extensions', 'abs.ts'), { force: true });
|
|
851
|
+
for (const f of ['pi-rounded-editor.ts', 'pi-rounded-format.ts']) {
|
|
852
|
+
await fs.rm(join(hostConfigRoot('pi'), 'agent', 'extensions', f), { force: true });
|
|
853
|
+
}
|
|
842
854
|
steps.push(`✓ extension 已删除`);
|
|
843
855
|
steps.push(...await uninstallSkills('pi'));
|
|
844
856
|
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,11 +50,27 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
|
|
|
49
50
|
// 而不是回答"我上次怎么解決 X"(那会拿到一个已知错误的答案)。
|
|
50
51
|
// 但它不是静默消失:计数里告知还有几条被隐藏(带 --all 能看)。
|
|
51
52
|
if (st === 'superseded' && !includeSuperseded) {
|
|
52
|
-
hits.push({ hidden:
|
|
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({
|
|
56
|
-
full, slug, matched: rank.matched, kind: rank.kind, fuzzyScore: rank.score, via: rank.via,
|
|
73
|
+
full, slug, dir: d, matched: rank.matched, kind: rank.kind, fuzzyScore: rank.score, via: rank.via,
|
|
57
74
|
author: au ? au[1].trim() : '',
|
|
58
75
|
id: idOfPage(body, slug),
|
|
59
76
|
status: st,
|
|
@@ -63,22 +80,34 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
|
|
|
63
80
|
}
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
|
-
const
|
|
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
|
// 规则:只要有任何精确命中,模糊命中的就不排在前面(但也不丢弃,单列一段)。
|
|
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);
|
|
71
100
|
const exactHits = hits.filter((h) => !h.hidden && h.kind === 'exact')
|
|
72
|
-
.map((h) => ({ ...h, score: h
|
|
101
|
+
.map((h) => ({ ...h, score: rankBy(h) }))
|
|
73
102
|
.sort((a, b) => b.score - a.score);
|
|
74
103
|
const fuzzyHits = hits.filter((h) => !h.hidden && h.kind === 'fuzzy')
|
|
75
|
-
.map((h) => ({ ...h, score: h
|
|
104
|
+
.map((h) => ({ ...h, score: rankBy(h) }))
|
|
76
105
|
.sort((a, b) => b.score - a.score);
|
|
77
106
|
// 有精确命中 → 模糊全藏起来(但不静默:告知数量 + 怎么看)
|
|
78
107
|
const fuzzySuppressed = exactHits.length > 0 && fuzzyHits.length > 0;
|
|
79
108
|
const shown = exactHits.length ? exactHits : fuzzyHits;
|
|
80
109
|
if (!shown.length) {
|
|
81
|
-
const extra = hidden ? `(另外 ${hidden}
|
|
110
|
+
const extra = hidden ? `(另外 ${hidden} 页被隐藏,用 abs query ${words.join(' ')} --all 查看)` : '';
|
|
82
111
|
return `query [${words.join(', ')}]: 无命中。${extra}用 abs lint 看图谱健康;首次使用先 abs init。`;
|
|
83
112
|
}
|
|
84
113
|
// 输出条数上限(2026-09-30 补):落实 read-side-output-must-not-scale 规则。
|
|
@@ -107,7 +136,9 @@ export async function cmdQuery({ dir, terms, includeSuperseded }) {
|
|
|
107
136
|
const tail = [];
|
|
108
137
|
if (words.length > 1 && !anyFull) tail.push('', `(无页同时命中全部 ${words.length} 个词,以下按命中数排序)`);
|
|
109
138
|
if (fuzzySuppressed) tail.push('', `(另有 ${fuzzyHits.length} 页字形相近但字面未命中,已隐藏 —— 它们通常不相关)`);
|
|
110
|
-
if (
|
|
139
|
+
if (hiddenSup) tail.push(``, `(${hiddenSup} 页 superseded 已隐藏;--all 可看)`);
|
|
140
|
+
if (hiddenFlow) tail.push(``, `(${hiddenFlow} 页流水已隐藏;--all 可看)`);
|
|
141
|
+
if (hiddenDraft) tail.push(``, `(${hiddenDraft} 页 draft 未核实已隐藏;--all 可看)`);
|
|
111
142
|
if (overflow > 0) tail.push(``, `(另有 ${overflow} 页未展示 —— 用更具体的词收窄:abs query <词1> <词2>)`);
|
|
112
143
|
return [`query [${words.join(', ')}] → ${shown.length} 页(展示前 ${displayed.length} 页):`, '', ...lines, ...tail].join('\n');
|
|
113
144
|
}
|
package/src/store.js
CHANGED
|
@@ -19,7 +19,7 @@ export { BRAIN_SHAPE, LEGACY_MARKS, checkBrainShape } from './brain/shape.js';
|
|
|
19
19
|
import './brain/gate.js';
|
|
20
20
|
|
|
21
21
|
// ---- 只读呈现 ----
|
|
22
|
-
export { cmdBoard, cmdStatus, cmdLoad, cmdShow, extractBlocks } from './brain/load.js';
|
|
22
|
+
export { cmdBoard, cmdStatus, cmdLoad, cmdShow, cmdRecall, extractBlocks } from './brain/load.js';
|
|
23
23
|
|
|
24
24
|
// ---- Rules ----
|
|
25
25
|
export { RULES_HEADING, RULES_MAX, RULE_BODY_MAX, readRules, cmdRule } from './brain/rules.js';
|