@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/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 { rulesSection } from './rules.js';
22
- import { collapseIndex } from '../page.js';
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
- // Rules 单独成段且放在最前(仅次于项目行/滞留):它是硬规则,不是普通清单。
100
- // 坑: 曾在 index 段内与页面清单平铺 —— AI 会当普通清单划过,而它每条都是付过代价的。
101
- ...(rulesSection(index) ? [rulesSection(index), ''] : []),
102
- collapseIndex(index) || '(index.md 为空)',
115
+ `📖 Rules ${ruleItems.length} 条(违反会丢数据/静默失效;全文: abs rule)`,
103
116
  '',
104
- // 两区制:Todo(未完成,行首带状态标记)+ Done。
105
- '--- Todo Board (todo.md) ---',
106
- collapseDone(todo).text || '(todo.md 为空)',
107
- '\n(Done 已按日期折叠计数;明细: abs todo --full)',
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, 最新 5 条) ---',
110
- recentLogLines(log, 5) || '(log.md 为空)',
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
- // 卡点提取(2026-09-16,定位 = 看板可见性):
127
- // 从 todo 里抽出带 `阻塞:` / `等待:` 的断点行,集中展示。
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
- /** queryHint 的 load 端封装:返回提示文本(含标题行),无强词则空串。失败静默。 */
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
- * 判据与 lint 的 hasTail 同源:段内除了占位注释与空行之外还有没有真内容。
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') || !f.startsWith('session-')) continue;
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
- // 页名形如 session-<日期>-s<id8>,日期就在里面,直接按名字倒排即可。
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 并清引用',
@@ -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
- const add = missing.map((g) => `- [[${g.slug}]] — ${g.date}(会话快照 + 任务归档)`).join('\n');
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);
@@ -136,28 +136,36 @@ export async function digestSession(file) {
136
136
  return out;
137
137
  }
138
138
 
139
- /** 已有快照的日期集合(从 .brain/sessions/*.md 文件名取)。
139
+ /** 已总结状态(从 .brain/sessions/*.md 的正文取):
140
+ * ids —— 页面里已有 `挂钩: session=<会话 id>` 的会话(已提炼过的那几场)
141
+ * days —— 「整天快照」的日期集合(页里没有任何 `## 会话 ` 段)
140
142
  *
141
- * 为何要和 md5 账本并用(2026-11-01):两条判据回答的是不同问题 ——
142
- * · 有快照 → 那天**已经总结过**(人写的,最权威)
143
- * · 账本记录 md5 → 那个**会话文件**提过了
143
+ * 两个判据回答的是不同问题:
144
+ * · 该天页存在且是人写的快照 → 那天**已经总结过**(人写的,最权威)
145
+ * · 页里有该会话 id 的段 → 那**场**会话已经提过
146
+ * · 账本记 md5 → 那个**会话文件**提过了
144
147
  * 只按 md5 判会把「已有快照的日子」全列出来当未总结(实测 15 条全是已有快照的),
145
- * 那是把托底做成噪音。两条都要:有快照就跳过,没有才看账本。 */
146
- async function daysWithSnapshot(brainSessionsDir) {
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
- // ★ 只认「整天快照」,不认单场会话页(2026-11-05 实测修正):
152
- // `log-<日期>.md` 是人写的一天流水 —— 那天确实整理过了,跳过合理。
153
- // 但 `session-<日期>-s<id8>.md` 是**一场会话一张**(abs ingest 的产物)。
154
- // 按天跳过它,会让「同一天只落了一场」把其余几场永久埋掉:
155
- // 实测真实项目有 14 天存在多场会话(最多一天 10 场),落一场就全跳过。
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 daysWithSnapshot(join(projectRoot, '.brain', 'sessions'));
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
- // 例外(2026-11-05):`abs ingest` 落的会话页叫 `session-<日期>-s<id8>.md`,
312
- // 它不是「当天工作流水」,而是**一场具体会话的结构化记录** ——
313
- // 同一天可以有好几场,硬套「一天一个 log」会把它挤掉。
314
- // 判据用 slug 前缀 `session-`:这是 ingest 独占的命名空间,与 log-<日期> 不交叉。
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
- const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
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 = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
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\` 正文;一场会话的结构化记录用 \`abs ingest\`)`);
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:取前 n 码点 → 非词字符折叠为 '-'。只用于**文件名**,完整标题另存 TITLE 行。
33
- * 在标点/空格边界收口,不在字中间切断(否则出 `...-硬切-不` 这种残尾)。 */
34
- export function slugOf(text, n = 24) {
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
- let head = cps.slice(0, n).join('');
39
- if (cps.length > n) {
40
- const cut = Math.max(head.lastIndexOf(','), head.lastIndexOf('。'), head.lastIndexOf('、'),
41
- head.lastIndexOf(':'), head.lastIndexOf(','), head.lastIndexOf('.'), head.lastIndexOf(' '));
42
- if (cut > n / 2) head = head.slice(0, cut);
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, 220))
93
+ .map((l) => clip(l, clipLen))
63
94
  .join('\n');
64
95
  }
@@ -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
- '> 本页 = 该日的会话快照(若有)+ 从 `todo.md` Done 区迁出的任务明细。',
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 补:摘要正文本身必须是**纯文本一句话**。