@fanchao8609/agent_brain_sync 1.28.6 → 1.29.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 +8 -41
- package/hooks/abs-rounded-editor/format.ts +77 -22
- package/hooks/abs-rounded-editor/index.ts +14 -19
- package/hooks/abs.omp.ts +1 -2
- package/hooks/abs.pi.ts +50 -51
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +68 -68
- package/src/brain/ingest.js +132 -54
- package/src/brain/layers.js +11 -5
- package/src/brain/load.js +50 -65
- package/src/brain/maintain.js +15 -2
- package/src/brain/summon.js +25 -16
- package/src/lint.js +82 -14
- package/src/note.js +7 -0
- package/src/store.js +1 -1
- package/src/text.js +42 -11
- package/src/todo/archive.js +22 -1
- package/src/todo/spec.js +6 -0
- package/src/todo/task-cmd.js +18 -0
- package/src/todo.js +1 -1
package/src/brain/load.js
CHANGED
|
@@ -17,9 +17,18 @@ import { digestFilled } from './layers.js';
|
|
|
17
17
|
* 为何要限:会话页数量只增不减,全列会让 load 首屏随规模膨胀
|
|
18
18
|
* —— 违反本仓「读取侧输出不得随规模增长」。5 个够 agent 判断先填哪张。 */
|
|
19
19
|
const SESSION_UNFILLED_SHOW = 5;
|
|
20
|
+
/** 开机屏的使用方法(2026-11-08 用户定 load 三件套之一)。一行一个动作,别写成手册。 */
|
|
21
|
+
const LOAD_USAGE = [
|
|
22
|
+
'--- 用法 ---',
|
|
23
|
+
'接经验 abs search <词> / abs recall 记一句 abs note "…"',
|
|
24
|
+
'立概念 abs concept <id> --title "…" 登记活 abs todo start <id> --note "做什么"',
|
|
25
|
+
'收尾 abs todo done <id> --as 落地|否决|仅方案 "结论"',
|
|
26
|
+
'体检 abs lint 看全量 abs show index|log|status',
|
|
27
|
+
'纪律: 绝不通读 .brain/;检索词一冒出来就查,别等报错。',
|
|
28
|
+
].join('\n');
|
|
20
29
|
import { checkBrainShape } from './shape.js';
|
|
21
|
-
import {
|
|
22
|
-
|
|
30
|
+
import { readRules } from './rules.js';
|
|
31
|
+
|
|
23
32
|
|
|
24
33
|
// ---------- board: 看板 ----------
|
|
25
34
|
export async function cmdBoard({ dir, full }) {
|
|
@@ -95,19 +104,27 @@ export async function cmdLoad({ dir }) {
|
|
|
95
104
|
''
|
|
96
105
|
);
|
|
97
106
|
}
|
|
107
|
+
// 2026-11-08 用户定:load 只留三样 —— 未完成 todo、使用方法、最近日志。
|
|
108
|
+
// Rules 全文 / 图谱计数 / Done 折叠明细 / 查询提示 / 热点页 撤出开机屏;
|
|
109
|
+
// 逃生口还在:abs rule / abs show index / abs todo --full / abs lint。
|
|
110
|
+
const board = collapseDone(todo);
|
|
111
|
+
const todoHead = String(board.text || '').split(/\n## Done/)[0].trim();
|
|
112
|
+
const { items: ruleItems } = readRules(index);
|
|
113
|
+
const blocks = extractBlocks(todo);
|
|
98
114
|
sections.push(
|
|
99
|
-
|
|
100
|
-
// 坑: 曾在 index 段内与页面清单平铺 —— AI 会当普通清单划过,而它每条都是付过代价的。
|
|
101
|
-
...(rulesSection(index) ? [rulesSection(index), ''] : []),
|
|
102
|
-
collapseIndex(index) || '(index.md 为空)',
|
|
115
|
+
`📖 Rules ${ruleItems.length} 条(违反会丢数据/静默失效;全文: abs rule)`,
|
|
103
116
|
'',
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
'--- 未完成 (todo.md) ---',
|
|
118
|
+
todoHead || '(无未完成任务)',
|
|
119
|
+
...(blocks.length
|
|
120
|
+
? [` ⛔ 卡点 ${blocks.length} 条: ${blocks.map((b) => `${b.id}(${b.why})`).join('; ')}`]
|
|
121
|
+
: []),
|
|
122
|
+
` Done ${board.doneCount} 条已折叠(明细: abs todo --full)`,
|
|
108
123
|
'',
|
|
109
|
-
'--- 最近动作 (log.md, 最新
|
|
110
|
-
recentLogLines(log,
|
|
124
|
+
'--- 最近动作 (log.md, 最新 3 条) ---',
|
|
125
|
+
recentLogLines(log, 3, 140) || '(log.md 为空)',
|
|
126
|
+
'',
|
|
127
|
+
LOAD_USAGE,
|
|
111
128
|
);
|
|
112
129
|
if (stranded.length) {
|
|
113
130
|
const rows = stranded.map((t) => {
|
|
@@ -123,55 +140,21 @@ export async function cmdLoad({ dir }) {
|
|
|
123
140
|
''
|
|
124
141
|
);
|
|
125
142
|
}
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// 只提取不求全 —— 只有明确写了前缀的才被抽出,没写的不猜。
|
|
130
|
-
const blocks = extractBlocks(todo);
|
|
131
|
-
if (blocks.length) {
|
|
132
|
-
const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
|
|
133
|
-
sections.splice(at === -1 ? 1 : at, 0,
|
|
134
|
-
`⛔ 卡点 ${blocks.length} 条(在等外部条件)`,
|
|
135
|
-
...blocks.map((b) => ` - ${b.id}: ${b.why}`),
|
|
136
|
-
'');
|
|
137
|
-
}
|
|
138
|
-
// 相关页提示(2026-09-16):实测 abs_query 几乎不被调用(MCP 日志 task 234 / note 54 / load 53 vs query 2)。
|
|
139
|
-
// 根因:load 只列目录 → 模型觉得"我记过了" → 真需要时靠印象。模型不知道自己不知道什么。
|
|
140
|
-
// 解法(用户定):不替它查 —— 而是【把该查的词直接给出来】,降低发起查询的成本。
|
|
141
|
-
// 自动带出正文是错的方向:那会让 query 更没人用,且无法按需求变化调整词。
|
|
142
|
-
// 失败静默:这只是 additive 提示,出任何错都不应弄坏 load。
|
|
143
|
-
try {
|
|
144
|
-
const rel = await queryHintSection(root, todo);
|
|
145
|
-
if (rel) sections.push('', rel);
|
|
146
|
-
} catch { /* 提示失败不影响 load */ }
|
|
147
|
-
// 最相关几页(2026-11-02):queryHint 给「该查什么词」,这里给「该读哪几页」。
|
|
148
|
-
// 放最后 —— 它是「再深入一层」的东西,前面先给状态与待办。
|
|
149
|
-
try {
|
|
150
|
-
const hot = await hotPagesSection(root, todo);
|
|
151
|
-
if (hot) sections.push('', hot);
|
|
152
|
-
} catch { /* 同上,失败静默 */ }
|
|
153
|
-
// 未填会话页提醒(2026-11-05):ingest/hook 落的是空骨架,内容要人填。
|
|
154
|
-
// 若不在开工时摆出来,「落了没填」就永远没人知道 —— 等于采集了不消化。
|
|
155
|
-
// 与 sources 提醒同一个位置、同一种语气:只给线索,判断仍归 agent。
|
|
143
|
+
|
|
144
|
+
// 未填会话页 / sources 堆积:只给一行计数,不列页名(细节走 abs ingest --list / abs lint)。
|
|
145
|
+
// 当年列页名是为送达;现在按总量预算收口,一线足够让人决定要不要深查。
|
|
156
146
|
try {
|
|
157
|
-
const sessWarn = await unfilledSessionSection(root);
|
|
158
|
-
if (sessWarn)
|
|
159
|
-
const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
|
|
160
|
-
sections.splice(at === -1 ? 1 : at, 0, ...sessWarn);
|
|
161
|
-
}
|
|
147
|
+
const sessWarn = await unfilledSessionSection(root, { oneLine: true });
|
|
148
|
+
if (sessWarn) sections.push('', sessWarn);
|
|
162
149
|
} catch { /* 提醒失败不影响 load */ }
|
|
163
|
-
// sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
|
|
164
|
-
// 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
|
|
165
150
|
try {
|
|
166
|
-
const srcWarn = await sourcesCountSection(root);
|
|
167
|
-
if (srcWarn)
|
|
168
|
-
const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
|
|
169
|
-
sections.splice(at === -1 ? 1 : at, 0, ...srcWarn);
|
|
170
|
-
}
|
|
151
|
+
const srcWarn = await sourcesCountSection(root, { oneLine: true });
|
|
152
|
+
if (srcWarn) sections.push('', srcWarn);
|
|
171
153
|
} catch { /* 提醒失败不影响 load */ }
|
|
172
154
|
return sections.join('\n');
|
|
173
155
|
}
|
|
174
156
|
|
|
157
|
+
|
|
175
158
|
/**
|
|
176
159
|
* 从 todo 正文抽出"卡点"行,供 load 集中展示。
|
|
177
160
|
*
|
|
@@ -306,7 +289,7 @@ export async function hotPagesSection(root, todoText, { limit = 3 } = {}) {
|
|
|
306
289
|
* @param {string} todoText
|
|
307
290
|
* @param {{name:string,body:string}[]} [pages] 已读好的页(省一次磁盘扫描)
|
|
308
291
|
*/
|
|
309
|
-
async function queryHint(root, todoText, pages) {
|
|
292
|
+
export async function queryHint(root, todoText, pages) {
|
|
310
293
|
const { kws, files } = await contextKeywords(root, todoText);
|
|
311
294
|
if (!kws.length) return '';
|
|
312
295
|
const all = pages || await listPages(brainPath(root)).catch(() => []);
|
|
@@ -335,10 +318,7 @@ async function countSources(root) {
|
|
|
335
318
|
} catch { return 0; }
|
|
336
319
|
}
|
|
337
320
|
|
|
338
|
-
|
|
339
|
-
async function queryHintSection(root, todo) {
|
|
340
|
-
return queryHint(root, todo);
|
|
341
|
-
}
|
|
321
|
+
|
|
342
322
|
|
|
343
323
|
/**
|
|
344
324
|
* recall:卡住时主动取「跟当前处境相关的几页」,不需要先想到检索词。
|
|
@@ -447,7 +427,7 @@ export async function cmdRecall({ dir, terms, limit = 5 } = {}) {
|
|
|
447
427
|
* 内容要读会话后填。但「落了没填」原来没有任何地方看得见 —— 落盘等于进了黑洞,
|
|
448
428
|
* 正是本仓反复踩的「采集了不消化」。这里只判「填没填」,不判「该不该留」。
|
|
449
429
|
*
|
|
450
|
-
*
|
|
430
|
+
* 判据是「骨架段里填过没有」,段标题层级不写死 —— 摘要并进当天页后骨架段是 `###`。
|
|
451
431
|
* 只认 ingest 生成的三种骨架段名(见 ingest.js 的 SHAPES)——它们都带
|
|
452
432
|
* `<!-- 提示 -->` 占位;任何一段有了真内容就算填过。
|
|
453
433
|
*
|
|
@@ -457,24 +437,28 @@ export async function cmdRecall({ dir, terms, limit = 5 } = {}) {
|
|
|
457
437
|
*
|
|
458
438
|
* 守「读取侧输出不随规模增长」:只列表头最多 SESSION_UNFILLED_SHOW 个页名,
|
|
459
439
|
* 其余折叠成条数。页名够用 —— agent 要读哪张再 abs resolve 那一张,别在这贴正文。 */
|
|
460
|
-
async function unfilledSessionSection(root) {
|
|
440
|
+
async function unfilledSessionSection(root, { oneLine = false } = {}) {
|
|
461
441
|
let files;
|
|
462
442
|
try {
|
|
463
443
|
files = await fs.readdir(brainPath(root, 'sessions'));
|
|
464
444
|
} catch { return null; }
|
|
465
445
|
const unfilled = [];
|
|
466
446
|
for (const f of files) {
|
|
467
|
-
if (!f.endsWith('.md') ||
|
|
447
|
+
if (!f.endsWith('.md') || f.startsWith('_')) continue;
|
|
468
448
|
const body = await fs.readFile(brainPath(root, 'sessions', f), 'utf8').catch(() => '');
|
|
469
449
|
if (!body) continue;
|
|
470
|
-
// 只认 ingest 落的页:它有 kind: session-digest
|
|
450
|
+
// 只认 ingest 落的页:它有 `kind: session-digest`。手写页不该被催。
|
|
451
|
+
// 不再按文件名前缀筛(2026-11-08):会话摘要已并进当天的 `log-<日期>.md`,
|
|
452
|
+
// 文件名里没有会话 id,前缀判据会把它整类漏掉。
|
|
471
453
|
if (!/^kind:\s*session-digest$/m.test(body)) continue;
|
|
472
454
|
if (!digestFilled(body)) unfilled.push(f.replace(/\.md$/, ''));
|
|
473
455
|
}
|
|
474
456
|
if (!unfilled.length) return null;
|
|
457
|
+
// 2026-11-08 load 三件套收口:只回一行计数,不列页名(列名那套留给 --list / 以后要用再传 oneLine:false)。
|
|
458
|
+
if (oneLine) return `📝 未填会话页 ${unfilled.length} 张(abs ingest --list 看哪几张;不进检索,不急着全填)`;
|
|
475
459
|
// 按日期倒序(2026-11-05 实测修正):原来按 readdir 顺序(≈文件名升序),
|
|
476
460
|
// 60 张时列出来的是 18/27/36/45/54 —— 随机五张,既不是最近的也不是最该填的。
|
|
477
|
-
//
|
|
461
|
+
// 页名现在是 `log-<日期>`,日期就在里面,直接按名字倒排即可。
|
|
478
462
|
unfilled.sort((a, b) => b.localeCompare(a));
|
|
479
463
|
const show = unfilled.slice(0, SESSION_UNFILLED_SHOW);
|
|
480
464
|
const rows = [
|
|
@@ -494,9 +478,10 @@ async function unfilledSessionSection(root) {
|
|
|
494
478
|
}
|
|
495
479
|
|
|
496
480
|
/** sources 堆积提醒:返回 [标题行, ...内容行] 或 null。失败静默。 */
|
|
497
|
-
async function sourcesCountSection(root) {
|
|
481
|
+
async function sourcesCountSection(root, { oneLine = false } = {}) {
|
|
498
482
|
const nsrc = await countSources(root);
|
|
499
483
|
if (nsrc <= SOURCES_MAX) return null;
|
|
484
|
+
if (oneLine) return `♻ 待消化: sources/ 有 ${nsrc} 条 > ${SOURCES_MAX}(abs lint 看明细)`;
|
|
500
485
|
return [
|
|
501
486
|
`♻ 待消化: sources/ 有 ${nsrc} 条 > ${SOURCES_MAX}(采集了没提炼)`,
|
|
502
487
|
'→ abs lint 看明细;提炼成 concepts/ 后删 source 并清引用',
|
package/src/brain/maintain.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname } from 'node:path';
|
|
|
5
5
|
import { brainPath, requireBrain, absLogDir } from '../index.js';
|
|
6
6
|
import { editFile, SKIP } from '../lock.js';
|
|
7
7
|
import { appendWrapup } from '../wrapup.js';
|
|
8
|
-
import { readIfExists } from '../text.js';
|
|
8
|
+
import { clip, readIfExists } from '../text.js';
|
|
9
9
|
import { getUser } from '../userconfig.js';
|
|
10
10
|
import { today, localStamp, archiveDoneInText, upsertArchiveSection, TODO_MAX_LINES } from '../todo.js';
|
|
11
11
|
export async function cmdWrapup({ dir }) {
|
|
@@ -100,7 +100,20 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
100
100
|
const sIdx = index.indexOf('## Sessions');
|
|
101
101
|
if (sIdx === -1) return SKIP;
|
|
102
102
|
const after = index.indexOf('\n## ', sIdx + 1);
|
|
103
|
-
|
|
103
|
+
// index 行要能回头查(2026-11-08 用户要求):光写「日期(会话快照 + 任务归档)」
|
|
104
|
+
// 查不到那天干了什么。描述 = archiveBrief 的任务首段(截 110 字);
|
|
105
|
+
// 老数据/无描述时退回原样 —— 不造假描述。
|
|
106
|
+
// 行是覆盖式登记(registerInIndex 同族),这里只在**首次登记**写,之后不追改。
|
|
107
|
+
const add = missing.map((g) => {
|
|
108
|
+
const b = String(g.brief || '').trim();
|
|
109
|
+
// 无首段时用任务 id 充当描述(是真的,不是编的)—— 光写日期模板会被 INDEX-ENTRY 打回。
|
|
110
|
+
const ids = (g.lines || []).filter((l) => /^- \[x\]/.test(l))
|
|
111
|
+
.map((l) => (String(l).match(/\s(\S+)[^\n]*$/) || [])[1])
|
|
112
|
+
.filter(Boolean).slice(0, 3);
|
|
113
|
+
const fallback = `完成任务 ${g.count} 条${ids.length ? ':' + ids.join('、') : ''}`;
|
|
114
|
+
const desc = `${g.date}(会话快照 + 任务归档):${b ? clip(b, 110) : fallback}`;
|
|
115
|
+
return `- [[${g.slug}]] — ${desc}`;
|
|
116
|
+
}).join('\n');
|
|
104
117
|
const next = after === -1
|
|
105
118
|
? `${index.replace(/\s*$/, '')}\n${add}\n`
|
|
106
119
|
: index.slice(0, after) + `\n${add}` + index.slice(after);
|
package/src/brain/summon.js
CHANGED
|
@@ -136,28 +136,36 @@ export async function digestSession(file) {
|
|
|
136
136
|
return out;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
/**
|
|
139
|
+
/** 已总结状态(从 .brain/sessions/*.md 的正文取):
|
|
140
|
+
* ids —— 页面里已有 `挂钩: session=<会话 id>` 的会话(已提炼过的那几场)
|
|
141
|
+
* days —— 「整天快照」的日期集合(页里没有任何 `## 会话 ` 段)
|
|
140
142
|
*
|
|
141
|
-
*
|
|
142
|
-
* ·
|
|
143
|
-
* ·
|
|
143
|
+
* 两个判据回答的是不同问题:
|
|
144
|
+
* · 该天页存在且是人写的快照 → 那天**已经总结过**(人写的,最权威)
|
|
145
|
+
* · 页里有该会话 id 的段 → 那**场**会话已经提过
|
|
146
|
+
* · 账本记 md5 → 那个**会话文件**提过了
|
|
144
147
|
* 只按 md5 判会把「已有快照的日子」全列出来当未总结(实测 15 条全是已有快照的),
|
|
145
|
-
*
|
|
146
|
-
|
|
148
|
+
* 那是把托底做成噪音;而只按日期跳过会把同一天没落的几场永久埋掉。
|
|
149
|
+
*
|
|
150
|
+
* 为何改成读正文(2026-11-08):会话摘要不再是一場一文件(`session-<日期>-s<id8>.md`),
|
|
151
|
+
* 而是并进当天的 `log-<日期>.md` 里的 `## 会话 <id8>` 段 —— 文件名里不再有会话 id,
|
|
152
|
+
* 所以「这场提过没有」只能按段里的 `挂钩: session=` 查,不能按文件名猜。
|
|
153
|
+
* 而「那天已有人写快照」仍按天判:页里没有任何 `## 会话 ` 段就说明那天只有人写的内容。 */
|
|
154
|
+
async function summarizedState(brainSessionsDir) {
|
|
147
155
|
const days = new Set();
|
|
156
|
+
const ids = new Set();
|
|
148
157
|
let files;
|
|
149
|
-
try { files = await fs.readdir(brainSessionsDir); } catch { return days; }
|
|
158
|
+
try { files = await fs.readdir(brainSessionsDir); } catch { return { days, ids }; }
|
|
150
159
|
for (const f of files) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
if (f.startsWith('session-')) continue;
|
|
160
|
+
if (!f.endsWith('.md')) continue;
|
|
161
|
+
const body = await fs.readFile(join(brainSessionsDir, f), 'utf8').catch(() => '');
|
|
162
|
+
if (!body) continue;
|
|
163
|
+
for (const m of body.matchAll(/^>\s*挂钩:\s*session=(\S+)/gm)) ids.add(m[1]);
|
|
164
|
+
if (/^##\s*会话\s/m.test(body)) continue; // 含 ingest 摘要的页不整日跳过
|
|
157
165
|
const m = f.match(/(\d{4}-\d{2}-\d{2})/);
|
|
158
166
|
if (m) days.add(m[1]);
|
|
159
167
|
}
|
|
160
|
-
return days;
|
|
168
|
+
return { days, ids };
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
/** 读账本。格式 { version, files: { "<相对文件名>": { md5, day, summarizedAt } } } */
|
|
@@ -225,8 +233,8 @@ export async function scanProject(projectRoot, { ledger, now = Date.now() } = {}
|
|
|
225
233
|
}
|
|
226
234
|
const ownBook = !ledger; // 账本是自己读的(不是调用方给的)
|
|
227
235
|
const book = ledger || await readLedger();
|
|
228
|
-
//
|
|
229
|
-
const haveSnapshot = await
|
|
236
|
+
// 已总结状态:整天快照的日期(整日跳过)+ 已提过的会话 id(逐场跳过)。
|
|
237
|
+
const { days: haveSnapshot, ids: haveDigest } = await summarizedState(join(projectRoot, '.brain', 'sessions'));
|
|
230
238
|
const fresh = [], changed = [], skipped = [], active = [];
|
|
231
239
|
let touched = false; // 是否补记过 stat(需要回写账本,否则下次又得读全文)
|
|
232
240
|
|
|
@@ -258,6 +266,7 @@ export async function scanProject(projectRoot, { ledger, now = Date.now() } = {}
|
|
|
258
266
|
|
|
259
267
|
const d = await digestSession(full);
|
|
260
268
|
if (!d || !d.users.length) { skipped.push({ name, reason: '无真实用户输入' }); continue; }
|
|
269
|
+
if (d.id && haveDigest.has(d.id)) { skipped.push({ name, reason: '这一场已提炼过' }); continue; }
|
|
261
270
|
if (d.day && haveSnapshot.has(d.day)) { skipped.push({ name, reason: `${d.day} 已有快照` }); continue; }
|
|
262
271
|
|
|
263
272
|
// 到这里才需要 md5(要么文件变了,要么账本里没有)。
|
package/src/lint.js
CHANGED
|
@@ -193,6 +193,13 @@ async function checkRootFiles(vault) {
|
|
|
193
193
|
`总结/报告/清单请分流:流水 → abs log,经验 → abs note,长设计 → sources/)`,
|
|
194
194
|
);
|
|
195
195
|
}
|
|
196
|
+
// 格式闸(2026-11-08 用户定):Todo 区任务行必须有 ` — 描述`,只有 id 打回。
|
|
197
|
+
if (todoSectionOf(lines, i) === 'Todo' && /^- \[ \]/.test(l) && !l.includes(' — ')) {
|
|
198
|
+
issues.push(
|
|
199
|
+
`TODO-NO-DESC: ${BRAIN_DIR}/${file}:${i + 1} (任务行没有描述;` +
|
|
200
|
+
`只有 id 查不到、接不上 —— abs todo start <id> --note "一句话" 补上,或 state 搁置/删除)`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
196
203
|
// 断点行(新旧格式同一个解析器 —— 不再自己切前缀)
|
|
197
204
|
const bpLine = parseChild(l);
|
|
198
205
|
if (!bpLine) continue;
|
|
@@ -260,6 +267,11 @@ function checkPage(pg, names, linkedNames, inbound) {
|
|
|
260
267
|
issues.push(`OVER-SIZE: ${pg.rel} (${pg.lines}L/${pg.bytes}B > ${PAGE_MAX_LINES}L/${PAGE_MAX_BYTES / 1024}KB; 拆或外链)`);
|
|
261
268
|
}
|
|
262
269
|
}
|
|
270
|
+
if (pg.slug.includes('-todo归档')) {
|
|
271
|
+
// 旧归档页命名(`<日期>-todo归档`,2026-09-15 治理时已并进 `log-<日期>.md`)。
|
|
272
|
+
// 留一条命中即报的规则:漏一个就会在 todo.md 的 Archived 里留一条死名。
|
|
273
|
+
issues.push(`SESSIONS-OLD-NAME: ${pg.rel}(旧归档命名,应并进 sessions/log-<日期>.md)`);
|
|
274
|
+
}
|
|
263
275
|
if (pg.dir === 'concepts' && !hasTail(pg.body)) {
|
|
264
276
|
// 尾巴按 kind 分档(2026-11-05):原来「必须写验证命令」对所有 concepts 页一律要求,
|
|
265
277
|
// 后果是「当时为什么这样选」这类决策知识根本落不了地 —— 决策没有可跑的验证命令,
|
|
@@ -289,6 +301,14 @@ function checkPage(pg, names, linkedNames, inbound) {
|
|
|
289
301
|
|
|
290
302
|
// ---------- 跨页图谱检查 ----------
|
|
291
303
|
|
|
304
|
+
/** 页面 frontmatter 的标签列表。三处判据共用,避免各自写一份解析而漂移。
|
|
305
|
+
* 注意:**精确匹配标签**,不用 `\b` —— `\barchive\b` 会连 `todo-archive` 一起豁免
|
|
306
|
+
* (`-` 与 `a` 之间也是词边界),实测漏报 4 天旧命名归档页。 */
|
|
307
|
+
function tagListOf(frontmatter) {
|
|
308
|
+
const tags = String(frontmatter || '').match(/^tags:\s*(.+)$/m)?.[1] || '';
|
|
309
|
+
return tags.replace(/^\[|\]$/g, '').split(',').map((t) => t.trim()).filter(Boolean);
|
|
310
|
+
}
|
|
311
|
+
|
|
292
312
|
/** 跨页检查:sources 堆积/超龄、sessions 命名、superseded 悬挂、draft 超龄。 */
|
|
293
313
|
async function checkGraph(root, pages) {
|
|
294
314
|
const issues = [];
|
|
@@ -307,20 +327,24 @@ async function checkGraph(root, pages) {
|
|
|
307
327
|
}
|
|
308
328
|
}
|
|
309
329
|
|
|
310
|
-
// SESSIONS-NAMING: sessions/
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
330
|
+
// SESSIONS-NAMING: sessions/ 只收 `log-<日期>[-<slug>].md`(2026-11-08 用户定)。
|
|
331
|
+
// 曾经的例外 `session-<日期>-s<id8>.md`(ingest 一场会话一张)已废:
|
|
332
|
+
// 同一天散成多份、且与 todo.md 归档行两套链接名 —— 正是「一天一文件」要治的病。
|
|
333
|
+
// 会话摘要现在并进当天页,写成 `## 会话 <id8>` 段(见 ingest 的 renderDayPage)。
|
|
334
|
+
// 豁免只给「独立 `archive` 标签」的外部产物全文归档(2026-09-15 定的):
|
|
335
|
+
// 它不是当天工作流水,是别人仓库的产物整篇存进来,名字不由 abs 生成。
|
|
336
|
+
for (const pg of pages) {
|
|
337
|
+
if (pg.dir !== 'sessions') continue;
|
|
338
|
+
if (/^log-\d{4}-\d{2}-\d{2}(?:-.+)?$/.test(pg.slug)) continue;
|
|
339
|
+
if (tagListOf(pg.frontmatter).includes('archive')) continue;
|
|
340
|
+
issues.push(`SESSIONS-NAMING: ${pg.rel} 不是规范名;sessions/ 只收 \`log-<日期>[-<slug>].md\`(会话摘要并进当天页的 \`## 会话 <id8>\` 段)`);
|
|
341
|
+
}
|
|
315
342
|
const sessByDate = new Map();
|
|
316
343
|
for (const pg of pages) {
|
|
317
344
|
if (pg.dir !== 'sessions') continue;
|
|
318
|
-
if (/^session-/.test(pg.slug)) continue;
|
|
319
345
|
const m = pg.slug.match(/^(?:log-)?(\d{4}-\d{2}-\d{2})/);
|
|
320
346
|
if (!m) continue;
|
|
321
|
-
|
|
322
|
-
const tagList = tags.replace(/^\[|\]$/g, '').split(',').map((t) => t.trim());
|
|
323
|
-
if (tagList.includes('archive')) continue;
|
|
347
|
+
if (tagListOf(pg.frontmatter).includes('archive')) continue;
|
|
324
348
|
if (!sessByDate.has(m[1])) sessByDate.set(m[1], []);
|
|
325
349
|
sessByDate.get(m[1]).push(pg.slug);
|
|
326
350
|
}
|
|
@@ -328,19 +352,16 @@ async function checkGraph(root, pages) {
|
|
|
328
352
|
if (slugs.length > 1) {
|
|
329
353
|
issues.push(`SESSIONS-SPLIT: ${date} 在 sessions/ 有 ${slugs.length} 个文件(${slugs.join('、')});` +
|
|
330
354
|
`一天只应有一个 \`log-${date}.md\`:归档写进其「## 📦 任务归档」段,多主题写成多个 ## 子段`);
|
|
331
|
-
} else if (!slugs[0].startsWith('log-')) {
|
|
332
|
-
issues.push(`SESSIONS-NAMING: ${date} 的文件 \`${slugs[0]}.md\` 不是规范名;` +
|
|
333
|
-
`应为 \`log-${date}.md\`(跑 abs todo archive 会并入;旧归档页可删)`);
|
|
334
355
|
}
|
|
335
356
|
}
|
|
336
357
|
|
|
337
358
|
// SESSIONS-MISPLACED: sessions/ 里放了不属 sessions 的页
|
|
338
359
|
for (const pg of pages) {
|
|
339
360
|
if (pg.dir !== 'sessions') continue;
|
|
340
|
-
const tags =
|
|
361
|
+
const tags = tagListOf(pg.frontmatter).join(', ');
|
|
341
362
|
if (!/session-log|todo-archive|archive|session-digest/.test(tags)) {
|
|
342
363
|
issues.push(`SESSIONS-MISPLACED: ${pg.rel}(tags: ${tags.trim()} 不属 sessions/;` +
|
|
343
|
-
`当天工作写进 \`log-<日期>.md\`
|
|
364
|
+
`当天工作写进 \`log-<日期>.md\` 正文;一场会话的结构化摘要用 \`abs ingest\`)`);
|
|
344
365
|
}
|
|
345
366
|
}
|
|
346
367
|
|
|
@@ -443,6 +464,52 @@ async function readFileOrNull(p) {
|
|
|
443
464
|
try { return await fs.readFile(p, 'utf8'); } catch { return null; }
|
|
444
465
|
}
|
|
445
466
|
|
|
467
|
+
/** 取标题行之后、同级或更高级标题之前的行。找不到标题返回空数组。 */
|
|
468
|
+
function blockAfter(text, heading) {
|
|
469
|
+
const lines = String(text || '').split('\n');
|
|
470
|
+
const i = lines.findIndex((l) => l.trim() === heading);
|
|
471
|
+
if (i === -1) return [];
|
|
472
|
+
const lvl = (lines[i].trim().match(/^#+/) || ['##'])[0].length;
|
|
473
|
+
let j = i + 1;
|
|
474
|
+
while (j < lines.length) {
|
|
475
|
+
const h = lines[j].trim().match(/^(#+)\s/);
|
|
476
|
+
if (h && h[1].length <= lvl) break;
|
|
477
|
+
j++;
|
|
478
|
+
}
|
|
479
|
+
return lines.slice(i + 1, j);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** 机器生成的「文件双链」链接名必须**逐字等于磁盘文件名**(2026-11-08 用户定)。
|
|
483
|
+
*
|
|
484
|
+
* 为何单开一条规则:index.md 的 `## Sessions` 段与 todo.md 的 `### Archived` 段
|
|
485
|
+
* 都是机器生成、且指向 sessions/ 下文件的链接行。它们一旦与文件名脱钩就成了指向
|
|
486
|
+
* 不存在页的死名 —— 而 sessions/ 的页面豁免 DEAD-LINK(历史快照不改),
|
|
487
|
+
* 所以这类死名长期无人发现:实测 todo.md 里 `[[2026-09-12-todo归档]]` 挂了两个月,
|
|
488
|
+
* 真页早在 2026-09-15 治理时并成了 `log-2026-09-12`。
|
|
489
|
+
*
|
|
490
|
+
* 判据只有一条:链接名(去 .md、去 | 别名)=== sessions/ 下的某个文件名。
|
|
491
|
+
* 因此大小写、日期格式、前后缀漂移都会被抓到,不靠词表猜「哪个名字像标准」。 */
|
|
492
|
+
export async function checkGeneratedFileLinks(vault) {
|
|
493
|
+
const issues = [];
|
|
494
|
+
const sessFiles = new Set();
|
|
495
|
+
for (const f of await fs.readdir(join(vault, 'sessions')).catch(() => [])) {
|
|
496
|
+
if (f.endsWith('.md') && !f.startsWith('_')) sessFiles.add(f.replace(/\.md$/, ''));
|
|
497
|
+
}
|
|
498
|
+
const check = (where, lines) => {
|
|
499
|
+
for (const l of lines) {
|
|
500
|
+
const m = l.match(/^\s*-\s*\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/);
|
|
501
|
+
if (!m) continue;
|
|
502
|
+
const link = m[1].trim();
|
|
503
|
+
if (sessFiles.has(link)) continue;
|
|
504
|
+
issues.push(`LINK-NAME-STALE: ${where} -> [[${link}]](sessions/ 里没有同名文件)。` +
|
|
505
|
+
`生成的链接名必须逐字等于磁盘文件名(不带 .md),改回真页名后重跑 abs lint`);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
check('index.md ## Sessions', blockAfter(await readFileOrNull(join(vault, 'index.md')), '## Sessions'));
|
|
509
|
+
check('todo.md ### Archived', blockAfter(await readFileOrNull(join(vault, 'todo.md')), '### Archived'));
|
|
510
|
+
return issues;
|
|
511
|
+
}
|
|
512
|
+
|
|
446
513
|
// ---------- 入口 ----------
|
|
447
514
|
|
|
448
515
|
export async function cmdLint({ dir, all = false }) {
|
|
@@ -472,6 +539,7 @@ export async function cmdLint({ dir, all = false }) {
|
|
|
472
539
|
issues.push(...await checkGraph(root, pages));
|
|
473
540
|
issues.push(...await checkFiles(vault, pages, indexLinks));
|
|
474
541
|
issues.push(...await checkRootFiles(vault));
|
|
542
|
+
issues.push(...await checkGeneratedFileLinks(vault));
|
|
475
543
|
|
|
476
544
|
const n = issues.length;
|
|
477
545
|
// ★ 同类折叠(2026-10-05):体检输出必须按问题**类型**折叠计数。
|
package/src/note.js
CHANGED
|
@@ -273,6 +273,13 @@ export async function cmdLog({ dir, title, kind = 'dev', summon = true }) {
|
|
|
273
273
|
` note=经验/踩坑 / dev=完成的工作 / concept=新建概念页 / ingest=沉淀资料`,
|
|
274
274
|
);
|
|
275
275
|
}
|
|
276
|
+
// 格式闸(2026-11-08 用户定):空标题/纯空白的 log 打回。
|
|
277
|
+
if (!String(title || '').trim()) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`✗ log 必须有一句话结论 —— 空标题打回。\n` +
|
|
280
|
+
` 用法: abs log "做完什么 + 怎么验证的" --kind dev`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
276
283
|
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
277
284
|
const p = brainPath(root, 'log.md');
|
|
278
285
|
const stamp = localStamp();
|
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, cmdRecall, extractBlocks } from './brain/load.js';
|
|
22
|
+
export { cmdBoard, cmdStatus, cmdLoad, cmdShow, cmdRecall, extractBlocks, queryHint } 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';
|
package/src/text.js
CHANGED
|
@@ -29,17 +29,48 @@ export function clip(text, n) {
|
|
|
29
29
|
return `${keep.replace(/[\s,,、;;.。]+$/, '')}…`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** slug
|
|
33
|
-
*
|
|
34
|
-
export
|
|
32
|
+
/** slug 上限(码点)。真需要这个上限的只有「一整句都没句读」的长句 —— 罕见路径。
|
|
33
|
+
* 为什么不要一个小的固定窗口(如 24):见 slugOf 的注释。 */
|
|
34
|
+
export const SLUG_MAX = 60;
|
|
35
|
+
|
|
36
|
+
/** 在这个位置算「一句话的边界」吗。
|
|
37
|
+
* 强句读(。!?;:)、短语标点(,、)、换行、开括号(括号里的内容是补充,可以不要)、
|
|
38
|
+
* 以及**后面跟空格/结尾**的英文 . 与 ,(否则 `1.28.5`、`design.md` 里的点会把句子砍成两半)。 */
|
|
39
|
+
function isClauseBoundary(cps, i) {
|
|
40
|
+
const c = cps[i];
|
|
41
|
+
if ('。!?;:,、\n((「'.includes(c)) return true;
|
|
42
|
+
if (c === '.' || c === ',') {
|
|
43
|
+
const prev = cps[i - 1];
|
|
44
|
+
const next = cps[i + 1];
|
|
45
|
+
// 前面必须是字(否则 `i -g . 会把` 里那个孤立的 `.` 会把句子砍断)
|
|
46
|
+
return !!prev && /[\w一-鿿]/.test(prev) && (next === undefined || next === ' ');
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** slug:取**第一句话**(到第一个句读/开括号/换行为止)→ 非词字符折叠为 '-'、小写。
|
|
52
|
+
* 只用于**文件名**,完整标题另存 TITLE 行。
|
|
53
|
+
*
|
|
54
|
+
* 为何改成「按句取」而不是「按长度窗口切」(2026-11-08 用户实测)。窗口必然切在半句上,实测生成过:
|
|
55
|
+
* `2026-11-08-abs-sleep-离线整理方案在` ← 断在介词「在」
|
|
56
|
+
* `2026-11-07-发版-1-28-5-时-npm-i-g` ← 断在半条命令
|
|
57
|
+
* `2026-11-03-…-主动取靠-触发规则写进` ← 断在动词「写进」
|
|
58
|
+
* 而「末尾是不是半句」没法用结构判据认出来(中文没有词边界,靠虚词表就是猜),
|
|
59
|
+
* 所以判据反过来:**不切**。句子到边界就停,边界前的字一律完整收进名字。
|
|
60
|
+
* 实测效果:上面三条分别变成
|
|
61
|
+
* `…-abs-sleep-离线整理方案在-docs-sleep-design-md`(“方案在 docs/…”是完整短语)
|
|
62
|
+
* `…-发版-1-28-5-时-npm-i-g-会把全局包装成指向仓库的符号链接`
|
|
63
|
+
* `2026-11-03-检索有效不等于记忆被用`
|
|
64
|
+
* 只有整句无边界且超过 SLUG_MAX(60 码点)时才退到窗口内的最后一个空格/开括号。 */
|
|
65
|
+
export function slugOf(text, n = SLUG_MAX) {
|
|
35
66
|
const s = String(text || '').trim();
|
|
36
|
-
// 同 clip:按码点切,避免把 emoji 切成半个代理对(见 clip 注释)。
|
|
37
67
|
const cps = Array.from(s);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
68
|
+
const end = cps.findIndex((_, i) => isClauseBoundary(cps, i));
|
|
69
|
+
let head = (end === -1 ? cps : cps.slice(0, end)).join('');
|
|
70
|
+
if (Array.from(head).length > n) {
|
|
71
|
+
const h = Array.from(head).slice(0, n).join('');
|
|
72
|
+
const cut = Math.max(h.lastIndexOf(' '), h.lastIndexOf('('), h.lastIndexOf('('));
|
|
73
|
+
head = cut > 0 ? h.slice(0, cut) : h;
|
|
43
74
|
}
|
|
44
75
|
return head.replace(/[^\w一-鿿]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
45
76
|
}
|
|
@@ -52,13 +83,13 @@ export async function readIfExists(p) {
|
|
|
52
83
|
/** log.md 是"新在上"(cmdLog 把新行插在标题后)——取**最新** n 条,只认 `## [` 条目行。
|
|
53
84
|
* 坑: 曾用 tailLines 取末尾 → 拿到的永远是最旧几条,而标签写着"最近动作",
|
|
54
85
|
* 于是"开机读状态"最该看的一节长期显示两天前的旧记录(今天的新条目从未显示)。 */
|
|
55
|
-
export function recentLogLines(text, n) {
|
|
86
|
+
export function recentLogLines(text, n = 5, clipLen = 220) {
|
|
56
87
|
return String(text || '')
|
|
57
88
|
.split('\n')
|
|
58
89
|
.filter((l) => /^##\s*\[/.test(l))
|
|
59
90
|
.slice(0, n)
|
|
60
91
|
// 每条按语义边界收口:load 是开机读状态,只求"上会话做到哪"的线索,不须全文。
|
|
61
92
|
// 坑: 不收口时 5 条能占 2.9KB(单条 800B),又是一处随日志变长而膨胀的上下文。
|
|
62
|
-
.map((l) => clip(l,
|
|
93
|
+
.map((l) => clip(l, clipLen))
|
|
63
94
|
.join('\n');
|
|
64
95
|
}
|
package/src/todo/archive.js
CHANGED
|
@@ -231,6 +231,11 @@ export function archiveDoneInText(text, { keepDays = 3, from = today(), maxLines
|
|
|
231
231
|
// 会把子项一起计数(实测:1 任务 + 1 子项 → 报 "完成任务 2 条")。
|
|
232
232
|
// 归档标记的语义是「完成了多少条任务」,子项不算。
|
|
233
233
|
count: gLines.filter((l) => /^- \[x\]/.test(l)).length,
|
|
234
|
+
// index 行描述(2026-11-08 用户要求):归档页在 index 里只有
|
|
235
|
+
// 「日期(会话快照 + 任务归档)」这种零信息量行,回头找某天的活查不到。
|
|
236
|
+
// 取每个任务的**第一段**(登记时 `--note` 的那句话)拼成摘要,给
|
|
237
|
+
// maintain.js 写 index 行用。纯函数放在这,cli/store 测试可直接断言。
|
|
238
|
+
brief: archiveBrief(gLines),
|
|
234
239
|
});
|
|
235
240
|
}
|
|
236
241
|
|
|
@@ -242,6 +247,22 @@ export function archiveDoneInText(text, { keepDays = 3, from = today(), maxLines
|
|
|
242
247
|
return { text: rebuilt.join('\n').replace(/\n+$/, '\n'), archived, skipped, count };
|
|
243
248
|
}
|
|
244
249
|
|
|
250
|
+
/** 从任务行抽第一段描述:`<id> [[who]] — 第一段 — 结语 【kind】 (完成 …)`。
|
|
251
|
+
* 第一段 = 第一个 `—` 到下一个 `—`/`【`/`(完成` 之间(即登记时的 --note)。
|
|
252
|
+
* 没有的行(如老格式 `- [x] KEEP (完成 …)`)跳过;一行都没有返回空串。
|
|
253
|
+
* 给 index 行用 —— 只取每条任务的首段、截 60 字,别把结语也卷进来。 */
|
|
254
|
+
export function archiveBrief(lines) {
|
|
255
|
+
const parts = [];
|
|
256
|
+
for (const raw of lines) {
|
|
257
|
+
if (!/^- \[x\]/.test(raw)) continue; // 断点/子项行不参与
|
|
258
|
+
const s = raw.replace(/^\s*-\s*\[x\]\s*/, '');
|
|
259
|
+
const m = s.match(/—\s*([\s\S]*?)(?=\s*—|\s*【|\s*\(完成|$)/);
|
|
260
|
+
const t = m ? m[1].trim().replace(/\s+/g, ' ') : '';
|
|
261
|
+
if (t) parts.push(t);
|
|
262
|
+
}
|
|
263
|
+
return parts.join(';');
|
|
264
|
+
}
|
|
265
|
+
|
|
245
266
|
/** 归档页正文(按日期分组,原文保留)。供同日追写复用。 */
|
|
246
267
|
|
|
247
268
|
export function renderArchiveBody(groups) {
|
|
@@ -267,7 +288,7 @@ export function upsertArchiveSection(body, group) {
|
|
|
267
288
|
'',
|
|
268
289
|
`# ${group.date} 记录`,
|
|
269
290
|
'',
|
|
270
|
-
'> 本页 =
|
|
291
|
+
'> 本页 = 该日的一切:会话摘要(一场一段 `## 会话 <id8>`)+ 从 `todo.md` Done 区迁出的任务明细。',
|
|
271
292
|
'',
|
|
272
293
|
ARCHIVE_SECTION,
|
|
273
294
|
'',
|
package/src/todo/spec.js
CHANGED
|
@@ -79,6 +79,12 @@ export const ENTRY_SHAPES = {
|
|
|
79
79
|
if (!/^-\s/.test(l)) return '链接区只能放 `- [[页名]] — 一句话`(正文段落/编号列表/代码块都不行)';
|
|
80
80
|
if (!/^-\s*\[\[[^\]]+\]\]/.test(l)) return '应以 `- [[页名]] …` 开头';
|
|
81
81
|
if (!/^-\s*\[\[[^\]]+\]\]\s*[—-]\s*\S/.test(l)) return '`[[页名]]` 后要跟 `— 一句话` 描述';
|
|
82
|
+
// 格式闸(2026-11-08 用户定):机器模板行(只有日期+归档字样)不算描述,打回。
|
|
83
|
+
// 归档登记时必须带任务首段(见 maintain.js / archiveBrief)。
|
|
84
|
+
const dTpl = l.replace(/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*/, '').trim();
|
|
85
|
+
if (/^\d{4}-\d{2}-\d{2}((?:会话快照 \+ )?任务归档)?$/.test(dTpl)) {
|
|
86
|
+
return '描述不能只是日期模板 —— 要写那天干了什么(任务首段,≥4 字)';
|
|
87
|
+
}
|
|
82
88
|
// 摘要里不许带 Markdown 结构符(实测污染就是摘要取了页面正文开头)
|
|
83
89
|
if (/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*#+\s/.test(l)) return '摘要不能带 `## ` 标题前缀(取页面首行标题了)';
|
|
84
90
|
// ★ 2026-10-18 补:摘要正文本身必须是**纯文本一句话**。
|