@fanchao8609/agent_brain_sync 1.28.7 → 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.
@@ -98,13 +98,37 @@ export function effortAnsi(level: string | null | undefined, colorMode = 'trueco
98
98
  return colorMode === 'truecolor' ? `38;2;${g.rgb}` : `38;5;${g.ansi256}`
99
99
  }
100
100
 
101
+ /** 「搁置」橙 #F59E0B —— 与 todo 面板的 `搁置` 标签同色(abs.pi.ts 的 `C.hold`)。
102
+ * 用途(2026-11-08 用户要求):输入框上边框的 working 状态与右上角状态文字。
103
+ * ★ 色值在 abs.pi.ts 里也有一份,不抽公共模块是故意的(见 EFFORT_COLOR 的注释)。 */
104
+ export const HOLD_COLOR = { rgb: '245;158;11', ansi256: 214 }
105
+
106
+ export function holdAnsi(colorMode = 'truecolor'): string {
107
+ return colorMode === 'truecolor' ? `38;2;${HOLD_COLOR.rgb}` : `38;5;${HOLD_COLOR.ansi256}`
108
+ }
109
+
110
+ /** 上边框最左那段「working 状态」(如 `⠼ Working`)。
111
+ *
112
+ * 为何要单独把它抠出来(2026-11-08 用户要求它和状态文字同色):那段由 pi 的
113
+ * WorkingStatusIndicator 画、夹在 `── ` 与后续虚线之间(见 pi 的 custom-editor
114
+ * `renderTopBorder`),而 roundBorder 会先剥掉整行的色再重新上色,
115
+ * 不能靠「它原本的颜色」认它。判据取形状:开头 `─+ ` 之后、下一个 `─` 之前的那段。
116
+ *
117
+ * 排除 `↑ N more` / `↓ N more`(pi 自己的滚动提示):那不是 working 状态,
118
+ * 染成橙色就是错色。 */
119
+ export function leadSpanOf(plain: string): string {
120
+ const m = String(plain).match(/^─+ ([^─]+?) ?─/)
121
+ if (!m) return ''
122
+ const text = m[1]
123
+ return /[↑↓]/.test(text) ? '' : text
124
+ }
125
+
101
126
  /** 用 effortAnsi 的结果包一段文字;空串原样返回(off 时不着色)。 */
102
127
  export function paintAnsi(ansi: string, text: string): string {
103
128
  return ansi ? `\x1b[${ansi}m${text}\x1b[39m` : text
104
129
  }
105
130
 
106
- /**
107
- * 把一行边框文本换成「左圆角 + 左标签 + 中段 + 右圆角」,**显示宽度**保持原样。
131
+ /** 把一行边框文本换成「左圆角 + 左标签 + 中段 + 右圆角」,**显示宽度**保持原样。
108
132
  * 边框行内含 ANSI 时直接 slice 会切坏转义序列,所以先剥色、改字、再整行着色。
109
133
  *
110
134
  * ★ 宽度一律按 textWidth(CJK = 2 列)算,不按字符数 —— 标签里的中文昵称
@@ -112,6 +136,10 @@ export function paintAnsi(ansi: string, text: string): string {
112
136
  *
113
137
  * leftLabel 贴在左圆角之后;label 贴最右端。哪个放不下就丢掉哪个
114
138
  * (宁可没有,也不让边框溢出折行)。
139
+ *
140
+ * opts.leadFg / opts.labelFg(2026-11-08 用户要求):只给「working 状态那段」
141
+ * 和「右标签」换个色,其余(圆角、虚线)仍用 fg。不传 opts 时整行一个色,
142
+ * 与旧行为逐字相同。
115
143
  */
116
144
  export function roundBorder(
117
145
  line: string,
@@ -120,6 +148,7 @@ export function roundBorder(
120
148
  right: string,
121
149
  label = '',
122
150
  leftLabel = '',
151
+ opts: { leadFg?: (s: string) => string; labelFg?: (s: string) => string } = {},
123
152
  ): string {
124
153
  const plain = stripAnsi(line)
125
154
  if (plain.length === 0) return line
@@ -146,5 +175,38 @@ export function roundBorder(
146
175
  mid = ' ' + leftLabel + ' ' + body + mid.slice(mid.length - tailLen)
147
176
  }
148
177
  }
149
- return fg(left + mid + right)
178
+ return paintSpans(left + mid + right, fg, [
179
+ // working 段从**开头**找(它就在最左);右标签从**末尾**找
180
+ // (万一同一串又出现在左边,不能把左边那个也染成标签色)。
181
+ [leadSpanOf(plain), opts.leadFg, 'first'],
182
+ [label, opts.labelFg, 'last'],
183
+ ])
184
+ }
185
+
186
+ /** 把 out 里的几个区间换成各自的色,其余整段用 fg。区间按位置排序、重叠则跳过。
187
+ * 为何不用「拼段」实现:标签与 working 段的字符是从 mid 里苹/插进去的,
188
+ * 只有拿到最终串才能准确定位(直接拼段会在 dropCols 后错位)。 */
189
+ function paintSpans(
190
+ out: string,
191
+ fg: (s: string) => string,
192
+ spans: [string, ((s: string) => string) | undefined, 'first' | 'last'][],
193
+ ): string {
194
+ const found: [number, number, (s: string) => string][] = []
195
+ for (const [text, paint, side] of spans) {
196
+ if (!text || !paint) continue
197
+ const i = side === 'last' ? out.lastIndexOf(text) : out.indexOf(text)
198
+ if (i === -1) continue
199
+ found.push([i, i + text.length, paint])
200
+ }
201
+ found.sort((a, b) => a[0] - b[0])
202
+ let res = ''
203
+ let pos = 0
204
+ for (const [s, e, paint] of found) {
205
+ if (s < pos) continue
206
+ if (s > pos) res += fg(out.slice(pos, s)) // 空片段不上色,免得留下空色码对
207
+ res += paint(out.slice(s, e))
208
+ pos = e
209
+ }
210
+ if (pos < out.length) res += fg(out.slice(pos))
211
+ return res
150
212
  }
@@ -18,7 +18,7 @@
18
18
  * 注意:自动补全菜单行排在底边之下、且自带内容宽度,不套竖线(否则菜单被框住会错位)。
19
19
  */
20
20
  import { CustomEditor } from '@earendil-works/pi-coding-agent'
21
- import { buildStatus, effortAnsi, paintAnsi, roundBorder, shortenModel } from './format.ts'
21
+ import { buildStatus, effortAnsi, holdAnsi, paintAnsi, roundBorder, shortenModel } from './format.ts'
22
22
 
23
23
  const SIDE = '│'
24
24
 
@@ -52,10 +52,13 @@ export class RoundedEditor extends CustomEditor {
52
52
  const textFg = (s: string) => paintAnsi(effortAnsi(effortLevel, colorMode), s)
53
53
  const lineFg = textFg
54
54
  const wrap = lineFg
55
+ // working 状态与右上角状态文字用「搁置」橙 #F59E0B(2026-11-08 用户要求):
56
+ // 上边框的圆角与虚线仍走正文色,只有这两段橙 —— 一眼区分「还在跑」与上下文占用。
57
+ const holdFg = (s: string) => paintAnsi(holdAnsi(colorMode), s)
55
58
  const out: string[] = []
56
59
 
57
60
  // 上边框:原本是整行 `─`(含 working 图标与滚动提示),两端换圆角,右端贴状态。
58
- out.push(roundBorder(lines[0], wrap, '╭', '╮', statusLabel))
61
+ out.push(roundBorder(lines[0], wrap, '╭', '╮', statusLabel, '', { leadFg: holdFg, labelFg: holdFg }))
59
62
  // 下边框在原结果的倒数第 (1 + 自动补全行数) 位 —— 补全菜单排在它后面。
60
63
  const autocompleteHeight = (this as any).renderedAutocompleteHeight ?? 0
61
64
  const bottomIdx = lines.length - 1 - autocompleteHeight
package/hooks/abs.pi.ts CHANGED
@@ -1021,9 +1021,12 @@ export function renderPanelLines(
1021
1021
  // ★ off 时整块面板用灰阶(同日用户补提:「当推理关闭的时候 todo 面板文字应该全是灰色」)——
1022
1022
  // 高亮色只在真的开着推理时给,否则「推理关了面板还在发光」让人误以为还在深度思考。
1023
1023
  // 主色用 muted(中灰),描述与子项降一阶到 dim(深灰),两阶灰保留层次,不一整片平。
1024
- // 现代暗色方案(2026-11-08 用户给定),不再跟推理强度走
1024
+ // 现代暗色方案(2026-11-08 用户给定),不再跟推理强度走。
1025
+ // 标题色(2026-11-08 用户实测「太亮了」):从纯金 #FFD700 换成与「搁置」同一个橙 #F59E0B
1026
+ // —— 纯金在暗底上抢眼,标题只是分区标签,不该比当天要做的事更跳。
1027
+ // 代价:标题与 `[搁置]` 标签同色;两者一个在最左(标题)一个在行中(状态),位置分得开。
1025
1028
  const C = {
1026
- title: '38;2;255;215;0',
1029
+ title: '38;2;245;158;11',
1027
1030
  task: '38;2;226;232;240',
1028
1031
  desc: '38;2;148;163;184',
1029
1032
  tree: '38;2;150;170;198',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.28.7",
3
+ "version": "1.29.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,11 +12,17 @@
12
12
  // 判断仍是 agent 的 —— 这里不猜语义,只把会话里**已经存在的**事实整理成页。
13
13
  //
14
14
  // 双向挂钩(用户 2026-11-05 要求「资料要与 pi 的会话挂钩」):
15
- // 页 → 会话:frontmatter 记 session / session-file / project
16
- // 会话 → 页:页文件名带会话 id 前 8 位(`log-<日期>-s<8位>.md`),
17
- // 拿会话文件就能反查有没有被总结过(summon 的 md5 账本同理)。
15
+ // 页 → 会话:段内的 `挂钩: session=… session-file=… project=…` 一行
16
+ // 会话 → 页:段标题 `## 会话 <会话 id 前 8 位>`,拿会话文件就能在当天页里反查
17
+ // 有没有被总结过(summon 的 md5 账本同理)。
18
18
  // 为什么不把「页路径」回写进 jsonl:pi 的会话文件是宿主的产物,
19
19
  // 往里加字段既越界又会让 md5 变化,导致账本把已总结的会话又当成「有更新」。
20
+ //
21
+ // 落点为何是「当天的 log-<日期>.md」而不是「一场一张 session-<日期>-s<id8>.md」
22
+ // (2026-11-08 用户定):同一天多场会话会散成多个文件,而且 todo.md 的归档行用
23
+ // `[[log-<日期>]]`、这里用 `[[session-…]]`,同一个东西两套名字。
24
+ // 用户原话:「生成的文件标签要一致」「这些乱名称 不标准的名称……以后绝对不允许出现」。
25
+ // 现约定:sessions/ 只收 `log-<日期>[-<slug>].md`,lint 的 SESSIONS-NAMING 强制。
20
26
 
21
27
  import { promises as fs } from 'node:fs';
22
28
  import { join, basename } from 'node:path';
@@ -26,6 +32,8 @@ import { requireUser, atTag } from '../userconfig.js';
26
32
  import { today } from '../todo.js';
27
33
  import { registerInIndex } from '../note.js';
28
34
  import { clip } from '../text.js';
35
+ import { editFile, SKIP } from '../lock.js';
36
+ import { DIR_LAYER } from './layers.js';
29
37
  import { digestSession, sessionsRoot, projectDirName, updateLedger, markSummarized, ledgerPath, md5Of } from './summon.js';
30
38
 
31
39
  /** 会话页的三种形状。形状由「改了什么东西」机械判定,不让模型猜。 */
@@ -155,39 +163,103 @@ function usableHeadline(u) {
155
163
  return true;
156
164
  }
157
165
 
158
- /** 会话页的 markdown 正文。值留空位 —— 内容由 agent 读会话后填。 */
159
- export function renderPage({ shape, digest, ids, who }) {
160
- const sh = SHAPES[shape];
161
- const files = [...digest.filesEdited.keys()];
162
- const head = headlineOf(digest);
163
- const body = [
166
+ /** 会话段的标题:`## 会话 <会话 id 前 8 位>`。
167
+ * 这就是「会话 → 段」的反查凭据 —— 拿会话文件在当天页里搜这行即可。 */
168
+ export function digestSectionOf(short) {
169
+ return `## 会话 ${short || 'unknown'}`;
170
+ }
171
+
172
+ /** 当天页的骨架。会话摘要并进来后,一页就是「这一天的一切」:
173
+ * 多个 `## 会话 <id8>` 段 + `## 📦 任务归档` 段(后者由 abs todo archive 追写)。 */
174
+ export function renderDayPage({ ids, who, section }) {
175
+ const day = ids.day || today();
176
+ return [
164
177
  '---',
165
- `title: ${head || '会话 ' + (ids.short || ids.day)}`,
166
- 'tags: [session-digest, 会话]',
167
- 'layer: 5',
168
- `kind: session-digest`,
178
+ 'tags: [session-log, session-digest, 会话]',
179
+ `layer: ${DIR_LAYER.sessions}`,
180
+ 'kind: session-digest',
169
181
  `author: ${who}`,
170
- `updated: ${ids.day || today()}`,
182
+ `updated: ${day}`,
171
183
  'status: draft',
172
- // ── 与 pi 会话的挂钩(三行缺一不可)──
173
- // session: pi 的会话 id(首行 type:session 的 id 字段),唯一且不随文件名变
174
- // session-file: 会话 jsonl 的文件名,能直接定位到文件
175
- // project: 会话发生时的 cwd,跨项目时靠它知道上下文属于哪
176
- `session: ${ids.id}`,
177
- `session-file: ${ids.name}`,
178
- `project: ${digest.cwd || ''}`,
179
184
  '---',
180
185
  '',
181
- `# 会话:${head || ids.day}`,
186
+ `# ${day} 记录`,
187
+ '',
188
+ '> 本页 = 该日的一切:会话摘要(一场一段 `## 会话 <id8>`)+ 从 `todo.md` Done 区迁出的任务明细。',
189
+ '',
190
+ section.replace(/\s*$/, ''),
191
+ '',
192
+ ].join('\n');
193
+ }
194
+
195
+ /** 已有当天页被并入会话摘要时,补上「这页含会话摘要」的 frontmatter 标记。
196
+ *
197
+ * 为何必须补(2026-11-05 实测的检索保护):空骨架会话段有高词频、正文又短,
198
+ * bm25 天然占优 —— 实测 20 张空骨架把真知识页全挤出结果。保护靠两条,
199
+ * 两条都只认 `kind: session-digest`:db 的 unfilled 列(整页排除出检索)
200
+ * 与 0.5 亮度折扣。缺 kind 就等于这层保护失效。
201
+ * 已存在的 tags/status/updated 一律不动 —— 那是那天的记录,机器不替它改。 */
202
+ export function ensureDigestFrontmatter(body) {
203
+ const t = String(body || '');
204
+ if (!t.startsWith('---\n')) return t;
205
+ const end = t.indexOf('\n---', 3);
206
+ if (end === -1) return t;
207
+ const fm = t.slice(4, end);
208
+ const add = [];
209
+ if (!/^kind:\s/m.test(fm)) add.push('kind: session-digest');
210
+ if (!/^layer:\s/m.test(fm)) add.push(`layer: ${DIR_LAYER.sessions}`);
211
+ if (!add.length) return t;
212
+ return `---\n${fm.replace(/\s*$/, '')}\n${add.join('\n')}${t.slice(end)}`;
213
+ }
214
+
215
+ /** 把会话段并进当天页。位置:`## 📦 任务归档` 之前(会话在前、归档在后);
216
+ * 没有归档段就放到 `## 关联连接` 之前;都没有就追加到末尾。 */
217
+ export function insertDigestSection(body, section) {
218
+ const t = String(body || '');
219
+ const lines = t.split('\n');
220
+ const at = ['## 📦 任务归档', '## 关联连接']
221
+ .map((h) => lines.findIndex((l) => l.trim() === h))
222
+ .filter((i) => i !== -1)
223
+ .sort((a, b) => a - b)[0];
224
+ const block = section.replace(/\s*$/, '');
225
+ if (at === undefined) return `${t.replace(/\s*$/, '')}\n\n${block}\n`;
226
+ const head = lines.slice(0, at).join('\n').replace(/\s*$/, '');
227
+ const tail = lines.slice(at).join('\n').replace(/\s*$/, '');
228
+ return `${head}\n\n${block}\n\n${tail}\n`;
229
+ }
230
+
231
+ /** `--force`:把已有那一段重建回空骨架(段内已填的结论会被覆盖,调用方要先警告)。 */
232
+ export function replaceDigestSection(body, section, short) {
233
+ const t = String(body || '');
234
+ const lines = t.split('\n');
235
+ const i = lines.findIndex((l) => l.trim() === digestSectionOf(short));
236
+ if (i === -1) return insertDigestSection(t, section);
237
+ let j = i + 1;
238
+ while (j < lines.length && !/^#{1,2}\s/.test(lines[j].trim())) j++;
239
+ return [...lines.slice(0, i), ...section.replace(/\s*$/, '').split('\n'), '', ...lines.slice(j)]
240
+ .join('\n');
241
+ }
242
+
243
+ /** 会话段的 markdown。值留空位 —— 内容由 agent 读会话后填。
244
+ * 段标题层级从 `##` 降为 `###`:外面套了一层 `## 会话 <id8>`,一天多场不会撞标题。 */
245
+ export function renderDigestSection({ shape, digest, ids, who }) {
246
+ const sh = SHAPES[shape];
247
+ const files = [...digest.filesEdited.keys()];
248
+ const head = headlineOf(digest);
249
+ const body = [
250
+ digestSectionOf(ids.short),
182
251
  '',
183
252
  `> ${ids.day} · ${sh.label} · 用户发言 ${digest.users.length} 句 · 工具调用 ${digest.toolCalls} 次`,
253
+ `> 会话:${head || ids.day}`,
254
+ `> 挂钩: session=${ids.id} · session-file=${ids.name} · project=${digest.cwd || ''}`,
255
+ `> 沉淀者: ${who}`,
184
256
  '',
185
257
  ];
186
258
  for (const [title, hint] of sh.sections) {
187
- body.push(`## ${title}`, `<!-- ${hint} -->`, '');
259
+ body.push(`### ${title}`, `<!-- ${hint} -->`, '');
188
260
  }
189
261
  if (files.length) {
190
- body.push('## 改过的文件');
262
+ body.push('### 改过的文件');
191
263
  for (const f of files.slice(0, 40)) {
192
264
  body.push(`- \`${f}\` ×${digest.filesEdited.get(f)}`);
193
265
  }
@@ -195,7 +267,7 @@ export function renderPage({ shape, digest, ids, who }) {
195
267
  body.push('');
196
268
  }
197
269
  body.push(
198
- '## 用户原话',
270
+ '### 用户原话',
199
271
  '<!-- 从会话文件提取的原始诉求,按发生顺序。写上面几段时以这里为依据 -->',
200
272
  );
201
273
  for (const u of digest.users.slice(0, 20)) {
@@ -204,8 +276,8 @@ export function renderPage({ shape, digest, ids, who }) {
204
276
  if (digest.users.length > 20) body.push(`- …另有 ${digest.users.length - 20} 句`);
205
277
  body.push(
206
278
  '',
207
- '## 关联连接',
208
- `- ${atTag(who)} — 本页沉淀者`,
279
+ '### 关联连接',
280
+ `- ${atTag(who)} — 本场沉淀者`,
209
281
  '(把本场会话改到的知识页挂在这里,双向可回溯)',
210
282
  '',
211
283
  );
@@ -295,40 +367,46 @@ export async function cmdIngest({ dir, file, force = false, mark = false, last =
295
367
  const sessOut = brainPath(root, 'sessions');
296
368
  await fs.mkdir(sessOut, { recursive: true });
297
369
 
298
- // 文件名带会话 id 前 8 位 —— 这是「会话 → 页」的反查凭据(用户 2026-11-05 要求)。
299
- // 不带 id 只按日期命名会和当天的 log-<日期>.md 撞,且无法区分同日多场会话。
300
- const slug = `session-${ids.day || today()}-s${ids.short || 'unknown'}`;
370
+ // 落页位置 = 当天的 `log-<日期>.md`(2026-11-08 用户定「一天一文件」)。
371
+ // 会话 → 段的凭据是段标题 `## 会话 <id8>`:拿会话文件能在当天页里搜到它。
372
+ const short = ids.short || 'unknown';
373
+ const slug = `log-${ids.day || today()}`;
301
374
  const dst = join(sessOut, `${slug}.md`);
302
375
 
303
- try {
304
- await fs.access(dst);
305
- if (!force) {
306
- // 提示写得直白(2026-11-05 实测):开发时自己就误用 --force 覆盖掉填好的页。
307
- // 会丢的是「三段结论 + 关联连接」—— 重建回来的只是空骨架 + 素材,
308
- // 那些结论要重读整场会话才能写回来。这不是一个小损失,所以文案要点明。
309
- return [
310
- `• 已有会话页,不覆盖 → .brain/sessions/${slug}.md`,
311
- ' --force 会把它重建回空骨架:三段结论与关联连接全部丢失,只剩素材。',
312
- ' 只想要素材、保留结论 → 直接读那张页;确定要重填再用 --force。',
313
- ].join('\n');
314
- }
315
- } catch { /* 不存在,正常写入 */ }
376
+ // 幂等判据从「文件存在吗」改成「这一天页里已有这场会话的段吗」——
377
+ // 一天一文件后,同一天多场会话挂在同一个文件里,文件粒度不区分场次。
378
+ // 读-改-写必须走 editFile 锁:abs todo archive 也在写同一个文件,
379
+ // 裸 readFile+rename 会让两边互相覆盖(archive.js 的同一坑,已修)。
380
+ let existed = false;
381
+ await editFile(dst, (cur) => {
382
+ const has = String(cur || '').split('\n').some((l) => l.trim() === digestSectionOf(short));
383
+ if (has && !force) { existed = true; return SKIP; }
384
+ const section = renderDigestSection({ shape, digest, ids, who });
385
+ let text;
386
+ if (cur == null) text = renderDayPage({ ids, who, section });
387
+ else if (has) text = replaceDigestSection(cur, section, short); // --force:只重建这一段
388
+ else text = insertDigestSection(ensureDigestFrontmatter(cur), section);
389
+ return { text };
390
+ });
391
+ if (existed) {
392
+ // 提示写得直白(2026-11-05 实测的收益要保住):开发时自己就误用 --force
393
+ // 覆盖掉填好的段。会丢的是「三段结论 + 关联连接」—— 重建回来的只是空骨架 + 素材。
394
+ return [
395
+ `• 这一天页已有本场会话段,不覆盖 → .brain/sessions/${slug}.md(## 会话 ${short})`,
396
+ ' --force 会把这一段重建回空骨架:三段结论与关联连接全部丢失,只剩素材。',
397
+ ' 只想要素材、保留结论 → 直接读那一段;确定要重填再用 --force。',
398
+ ].join('\n');
399
+ }
316
400
 
317
- const body = renderPage({ shape, digest, ids, who });
318
401
  // 骨架段里的 <!-- 提示 --> 占位**故意保留**:它告诉填页的人每段该写什么。
319
402
  // 它不会干扰「填没填」的判定 —— load 的 digestFilled 与 lint 的 hasTail
320
403
  // 都把占位注释当空内容(2026-11-05 审查:原注释声称「去掉注释」但代码没做,
321
- // 注释与实现不符已改正)。
322
- // tmp + rename 原子落盘(并发读不会读到半写文件)
323
- const tmp = join(sessOut, `.${slug}.md.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
324
- await fs.writeFile(tmp, body, 'utf8');
325
- await fs.rename(tmp, dst);
326
-
404
+ // 注释与实现不符已改正)。写盘由 editFile 锁内完成,并发读不会读到半写文件。
327
405
  await registerInIndex(root, 'Sessions', slug, `会话:${headlineOf(digest) || ids.day}`);
328
406
 
329
407
  // 不写 log.md(2026-11-05,沿用 abs concept 的既有判据):
330
- // 「新建了会话页 X」是**命令的副作用**,不是工作成果 —— 零信息量,
331
- // 而「该页存在」已由 registerInIndex 落在 index 的 Sessions 区。
408
+ // 「并入了一场会话摘要」是**命令的副作用**,不是工作成果 —— 零信息量,
409
+ // 而「这一天页存在」已由 registerInIndex 落在 index 的 Sessions 区。
332
410
  // 更要紧的是:--auto 在每次关会话时跑,一天开 10 次就刷 10 行流水,
333
411
  // 靠事后清理治不了(abs concept 当初正是因为「建 10 页 = 10 行噪声」才删掉这次调用的)。
334
412
 
@@ -366,11 +444,11 @@ export async function cmdIngest({ dir, file, force = false, mark = false, last =
366
444
 
367
445
  const sh = SHAPES[shape];
368
446
  return [
369
- `✓ 会话页 → .brain/sessions/${slug}.md`,
447
+ `✓ 会话摘要 → .brain/sessions/${slug}.md(${digestSectionOf(short)})`,
370
448
  ` ${ids.day} · ${sh.label}(判据: ${sh.when})`,
371
449
  ` 用户 ${digest.users.length} 句 · 工具 ${digest.toolCalls} 次 · 改过 ${digest.filesEdited.size} 个文件`,
372
450
  ` 挂钩: session=${ids.id} file=${hit}`,
373
- ' 骨架已给好,三段内容要读会话后填;填完 status 改 active。',
451
+ ' 骨架已给好,三段内容要读会话后填;填完段内写实、别再留 <!-- 提示 -->。',
374
452
  ].join('\n') + markNote;
375
453
  }
376
454
 
@@ -131,7 +131,7 @@ export function layerConflict(body, dirName) {
131
131
  export const DIGEST_SECTIONS = ['出了什么问题', '怎么解决的', '验证什么命令',
132
132
  '做了什么操作', '结果怎样', '下次注意事项', '谈成了什么', '没结论的地方'];
133
133
 
134
- /** 会话页的三段骨架里有没有任何一段写了真内容。
134
+ /** 会话摘要的骨架段里有没有任何一段写了真内容。
135
135
  *
136
136
  * 为何要这个判据(2026-11-05 实测):`ingest --auto` 每次关会话都落一张空骨架,
137
137
  * 而空骨架的「用户原话」段有高词频、正文又短 —— bm25 天然占优。
@@ -141,15 +141,21 @@ export const DIGEST_SECTIONS = ['出了什么问题', '怎么解决的', '验证
141
141
  *
142
142
  * 判据:骨架段内除占位注释与空行外还有没有真内容。
143
143
  * 不看 status —— 人可能填完忘了改 active,用 status 会漏判。
144
- * 只看骨架段,不数「用户原话/改过的文件」—— 那两段是素材,不是填写成果。 */
144
+ * 只看骨架段,不数「用户原话/改过的文件」—— 那两段是素材,不是填写成果。
145
+ *
146
+ * 段标题层级不写死(2026-11-08):摘要现在并进当天的 `log-<日期>.md`,
147
+ * 骨架段降为 `###`(`## 会话 <id8>` 是容器)。同一段名叫 `##` 或 `###` 都算,
148
+ * 段的结束判据改成「遇到同级或更高级的标题」。 */
145
149
  export function digestFilled(body) {
146
150
  const lines = String(body || '').split('\n');
147
151
  for (let i = 0; i < lines.length; i++) {
148
- const h = lines[i].match(/^##\s+(.+?)\s*$/);
149
- if (!h || !DIGEST_SECTIONS.includes(h[1].trim())) continue;
152
+ const h = lines[i].match(/^(#{2,4})\s+(.+?)\s*$/);
153
+ if (!h || !DIGEST_SECTIONS.includes(h[2].trim())) continue;
154
+ const lvl = h[1].length;
150
155
  for (let j = i + 1; j < lines.length; j++) {
151
156
  const t = lines[j].trim();
152
- if (/^##\s/.test(t)) break; // 下一段开始
157
+ const hh = t.match(/^(#+)\s/);
158
+ if (hh && hh[1].length <= lvl) break; // 同级/更高级标题 = 本段结束
153
159
  if (!t) continue;
154
160
  if (t.startsWith('<!--') || t === '-->') continue; // 占位注释不算填
155
161
  return true;
package/src/brain/load.js CHANGED
@@ -427,7 +427,7 @@ export async function cmdRecall({ dir, terms, limit = 5 } = {}) {
427
427
  * 内容要读会话后填。但「落了没填」原来没有任何地方看得见 —— 落盘等于进了黑洞,
428
428
  * 正是本仓反复踩的「采集了不消化」。这里只判「填没填」,不判「该不该留」。
429
429
  *
430
- * 判据与 lint 的 hasTail 同源:段内除了占位注释与空行之外还有没有真内容。
430
+ * 判据是「骨架段里填过没有」,段标题层级不写死 —— 摘要并进当天页后骨架段是 `###`。
431
431
  * 只认 ingest 生成的三种骨架段名(见 ingest.js 的 SHAPES)——它们都带
432
432
  * `<!-- 提示 -->` 占位;任何一段有了真内容就算填过。
433
433
  *
@@ -444,10 +444,12 @@ async function unfilledSessionSection(root, { oneLine = false } = {}) {
444
444
  } catch { return null; }
445
445
  const unfilled = [];
446
446
  for (const f of files) {
447
- if (!f.endsWith('.md') || !f.startsWith('session-')) continue;
447
+ if (!f.endsWith('.md') || f.startsWith('_')) continue;
448
448
  const body = await fs.readFile(brainPath(root, 'sessions', f), 'utf8').catch(() => '');
449
449
  if (!body) continue;
450
- // 只认 ingest 落的页:它有 kind: session-digest。手写页不该被催。
450
+ // 只认 ingest 落的页:它有 `kind: session-digest`。手写页不该被催。
451
+ // 不再按文件名前缀筛(2026-11-08):会话摘要已并进当天的 `log-<日期>.md`,
452
+ // 文件名里没有会话 id,前缀判据会把它整类漏掉。
451
453
  if (!/^kind:\s*session-digest$/m.test(body)) continue;
452
454
  if (!digestFilled(body)) unfilled.push(f.replace(/\.md$/, ''));
453
455
  }
@@ -456,7 +458,7 @@ async function unfilledSessionSection(root, { oneLine = false } = {}) {
456
458
  if (oneLine) return `📝 未填会话页 ${unfilled.length} 张(abs ingest --list 看哪几张;不进检索,不急着全填)`;
457
459
  // 按日期倒序(2026-11-05 实测修正):原来按 readdir 顺序(≈文件名升序),
458
460
  // 60 张时列出来的是 18/27/36/45/54 —— 随机五张,既不是最近的也不是最该填的。
459
- // 页名形如 session-<日期>-s<id8>,日期就在里面,直接按名字倒排即可。
461
+ // 页名现在是 `log-<日期>`,日期就在里面,直接按名字倒排即可。
460
462
  unfilled.sort((a, b) => b.localeCompare(a));
461
463
  const show = unfilled.slice(0, SESSION_UNFILLED_SHOW);
462
464
  const rows = [
@@ -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
@@ -267,6 +267,11 @@ function checkPage(pg, names, linkedNames, inbound) {
267
267
  issues.push(`OVER-SIZE: ${pg.rel} (${pg.lines}L/${pg.bytes}B > ${PAGE_MAX_LINES}L/${PAGE_MAX_BYTES / 1024}KB; 拆或外链)`);
268
268
  }
269
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
+ }
270
275
  if (pg.dir === 'concepts' && !hasTail(pg.body)) {
271
276
  // 尾巴按 kind 分档(2026-11-05):原来「必须写验证命令」对所有 concepts 页一律要求,
272
277
  // 后果是「当时为什么这样选」这类决策知识根本落不了地 —— 决策没有可跑的验证命令,
@@ -296,6 +301,14 @@ function checkPage(pg, names, linkedNames, inbound) {
296
301
 
297
302
  // ---------- 跨页图谱检查 ----------
298
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
+
299
312
  /** 跨页检查:sources 堆积/超龄、sessions 命名、superseded 悬挂、draft 超龄。 */
300
313
  async function checkGraph(root, pages) {
301
314
  const issues = [];
@@ -314,20 +327,24 @@ async function checkGraph(root, pages) {
314
327
  }
315
328
  }
316
329
 
317
- // SESSIONS-NAMING: sessions/ 的一天一文件契约
318
- // 例外(2026-11-05):`abs ingest` 落的会话页叫 `session-<日期>-s<id8>.md`,
319
- // 它不是「当天工作流水」,而是**一场具体会话的结构化记录** ——
320
- // 同一天可以有好几场,硬套「一天一个 log」会把它挤掉。
321
- // 判据用 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
+ }
322
342
  const sessByDate = new Map();
323
343
  for (const pg of pages) {
324
344
  if (pg.dir !== 'sessions') continue;
325
- if (/^session-/.test(pg.slug)) continue;
326
345
  const m = pg.slug.match(/^(?:log-)?(\d{4}-\d{2}-\d{2})/);
327
346
  if (!m) continue;
328
- const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
329
- const tagList = tags.replace(/^\[|\]$/g, '').split(',').map((t) => t.trim());
330
- if (tagList.includes('archive')) continue;
347
+ if (tagListOf(pg.frontmatter).includes('archive')) continue;
331
348
  if (!sessByDate.has(m[1])) sessByDate.set(m[1], []);
332
349
  sessByDate.get(m[1]).push(pg.slug);
333
350
  }
@@ -335,19 +352,16 @@ async function checkGraph(root, pages) {
335
352
  if (slugs.length > 1) {
336
353
  issues.push(`SESSIONS-SPLIT: ${date} 在 sessions/ 有 ${slugs.length} 个文件(${slugs.join('、')});` +
337
354
  `一天只应有一个 \`log-${date}.md\`:归档写进其「## 📦 任务归档」段,多主题写成多个 ## 子段`);
338
- } else if (!slugs[0].startsWith('log-')) {
339
- issues.push(`SESSIONS-NAMING: ${date} 的文件 \`${slugs[0]}.md\` 不是规范名;` +
340
- `应为 \`log-${date}.md\`(跑 abs todo archive 会并入;旧归档页可删)`);
341
355
  }
342
356
  }
343
357
 
344
358
  // SESSIONS-MISPLACED: sessions/ 里放了不属 sessions 的页
345
359
  for (const pg of pages) {
346
360
  if (pg.dir !== 'sessions') continue;
347
- const tags = String(pg.frontmatter).match(/^tags:\s*(.+)$/m)?.[1] || '';
361
+ const tags = tagListOf(pg.frontmatter).join(', ');
348
362
  if (!/session-log|todo-archive|archive|session-digest/.test(tags)) {
349
363
  issues.push(`SESSIONS-MISPLACED: ${pg.rel}(tags: ${tags.trim()} 不属 sessions/;` +
350
- `当天工作写进 \`log-<日期>.md\` 正文;一场会话的结构化记录用 \`abs ingest\`)`);
364
+ `当天工作写进 \`log-<日期>.md\` 正文;一场会话的结构化摘要用 \`abs ingest\`)`);
351
365
  }
352
366
  }
353
367
 
@@ -450,6 +464,52 @@ async function readFileOrNull(p) {
450
464
  try { return await fs.readFile(p, 'utf8'); } catch { return null; }
451
465
  }
452
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
+
453
513
  // ---------- 入口 ----------
454
514
 
455
515
  export async function cmdLint({ dir, all = false }) {
@@ -479,6 +539,7 @@ export async function cmdLint({ dir, all = false }) {
479
539
  issues.push(...await checkGraph(root, pages));
480
540
  issues.push(...await checkFiles(vault, pages, indexLinks));
481
541
  issues.push(...await checkRootFiles(vault));
542
+ issues.push(...await checkGeneratedFileLinks(vault));
482
543
 
483
544
  const n = issues.length;
484
545
  // ★ 同类折叠(2026-10-05):体检输出必须按问题**类型**折叠计数。
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
  }
@@ -288,7 +288,7 @@ export function upsertArchiveSection(body, group) {
288
288
  '',
289
289
  `# ${group.date} 记录`,
290
290
  '',
291
- '> 本页 = 该日的会话快照(若有)+ 从 `todo.md` Done 区迁出的任务明细。',
291
+ '> 本页 = 该日的一切:会话摘要(一场一段 `## 会话 <id8>`)+ 从 `todo.md` Done 区迁出的任务明细。',
292
292
  '',
293
293
  ARCHIVE_SECTION,
294
294
  '',