@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.
@@ -1,107 +1,107 @@
1
1
  ---
2
2
  name: abs-agent-brain-sync
3
- description: "USE FOR abs / agent-brain-sync 跨会话记忆与任务续接。触发词:用户直接输入 abs(单独一个词或 abs + 命令,如 'abs 开工'、'abs 沉淀'),以及「开工续接状态」「沉淀会话收获」「任务登记不丢」「经验落成知识页」「图谱体检」等任务。"
3
+ description: "USE FOR abs / agent-brain-sync cross-session memory and task handoff. Triggers: the user types `abs` alone or `abs` + a command (e.g. 'abs 开工', 'abs 沉淀'), or asks to 开工续接状态 / 沉淀会话收获 / 任务登记不丢 / 经验落成知识页 / 图谱体检."
4
4
  ---
5
5
 
6
- # abs — 跨会话记忆 (agent-brain-sync)
6
+ # abs — cross-session memory
7
7
 
8
- `.brain/` 是**你自己的跨会话记忆库**:踩过的坑、做过的决定、验证过的做法都在里面。
8
+ `.brain/` is your memory across sessions: bugs, decisions, verified fixes.
9
9
 
10
- **查它是你的决定,不是别人提醒你才做。** 干活中一旦心里形成关键词、对某件事有疑问
11
- (这个坑以前踩过吗?这个约定是谁定的?),那一刻就主动查 —— 不要等报错、不要等用户提醒。
10
+ **Querying it is your decision, not something you wait to be reminded of.** The moment a
11
+ keyword forms or a question appears ("was this bug solved before?", "who decided this?"),
12
+ search — do not wait for an error or for the user to prompt you.
12
13
 
13
- ## 开工:跑一句就够
14
+ ## Startup: one command
14
15
 
15
16
  ```bash
16
17
  abs load
17
18
  ```
18
19
 
19
- 它给:Rules(铁律,最先读)+ 图谱计数 + 任务板 + 最近流水 + 未填会话页提醒。
20
- 顶部若有 `⏳ 上会话滞留`,先对账再开工 —— 做完的 `abs todo done <id>`,
21
- 没做完的 `abs todo note <id> --note "接到哪"`。
20
+ Returns: unfinished todos + usage + last 3 log lines. If the top shows `⏳ 上会话滞留`,
21
+ reconcile first — finished items get `abs todo done <id> --as 落地|否决|仅方案 "结语"`,
22
+ unfinished ones get `abs todo note <id> --note "where it stopped"`.
22
23
 
23
- **绝不 `cat .brain/` 通读**。要主题用检索,要单页用 `abs resolve` 后只读那一页。
24
+ **Never `cat .brain/` end-to-end.** Search by topic; `abs resolve <page>` then read that one page.
24
25
 
25
- ## 找东西:三档,按精确度选
26
+ ## Format gates (reject, do not guess)
26
27
 
27
- | 你在干什么 | 用哪个 |
28
- |---|---|
29
- | 不知道找什么,卡住了 | `abs recall [词…]` —— 按当前处境推(不给词也行) |
30
- | 知道关键词,要最准 | `abs search <词>` —— 亮度排序 + 邻居扩散 + 跨项目 |
31
- | 要字面精确、输出短 | `abs query <词…>` —— 多词 OR 命中 |
28
+ - `todo start/add` without `--note "what"` → rejected. One sentence, verb-first, ≤40 chars.
29
+ - `todo done` without a conclusion → rejected. Every Done line must say what happened.
30
+ - `abs log` without a one-line conclusion → rejected.
31
+ - index rows without a real description (date-template only) → rejected by lint.
32
+ - Sub-items are one sentence each. Reports go to `abs log` / `sources/`, not the board.
32
33
 
33
- `abs search` 与 `abs query` 是**详略两档,不是重复**:前者带亮度和扩散更准,
34
- 后者只给页名+摘要更省 token。MCP 侧分别对应 `abs_search` / `abs_query`。
34
+ ## Find knowledge: three tiers
35
35
 
36
- 拿到页名后 `abs resolve <页名>` 反查路径,只读命中的那几页。答时用 `[[页面名]]` 标来源。
36
+ | Situation | Command |
37
+ |---|---|
38
+ | Stuck, no keywords | `abs recall [词…]` — infers from current work |
39
+ | Have keywords, want best recall | `abs search <词>` — luminance + neighbor spread, cross-project |
40
+ | Want exact literal, short output | `abs query <词…>` — multi-word OR |
37
41
 
38
- ## 写知识:三个落点,别混
42
+ `search` and `query` are detailed vs short, not duplicates. MCP: `abs_search` / `abs_query`.
43
+ Got a page name? `abs resolve <页名>` → read only that page. Cite sources as `[[页面名]]`.
39
44
 
40
- | 你手上的东西 | 落点 | 命令 |
41
- |---|---|---|
42
- | 一句经验,还没想清 | `sources/` 待提炼 | `abs note "一句话"` |
43
- | 要成页(有骨架好填) | `concepts/` | `abs concept <slug> --title "…"` |
44
- | 一句话记下,日期即时间线 | `concepts/` | `abs capture --layer 2/3/4 "…"` |
45
- | 违反就必须停下的硬约束 | index 的 `## Rules` | `abs rule add "≤42 字"` |
45
+ ## Write knowledge: three sinks, do not mix
46
46
 
47
- `concept` 与 `capture` 都写 `concepts/`,但产出不同:前者带四段骨架(触发场景/表现/解法/验证),
48
- 后者只落一句话 + 日期前缀。要「写细」用 concept,要「快记」用 capture。
47
+ | What you have | Sink | Command |
48
+ |---|---|---|
49
+ | One experience, not yet clear | `sources/` | `abs note "一句话"` |
50
+ | A real page (4-section skeleton) | `concepts/` | `abs concept <slug> --title "…"` |
51
+ | One line, date as timeline | `concepts/` | `abs capture --layer 2/3/4 "…"` |
52
+ | Hard rule that must stop the agent | index `## Rules` | `abs rule add "≤42 字"` |
49
53
 
50
- **写完跑 `abs lint`**,它报死链、超限、无尾(`NO-TAIL`:页要有「做完怎么确认」那段)。
54
+ `concept` = four sections (trigger / symptom / fix / verify). `capture` = one line + date.
55
+ Then run `abs lint` (dead links, over-size, NO-TAIL).
51
56
 
52
- ## 写任务:todo 是活看板
57
+ ## Tasks: the board is alive
53
58
 
54
59
  ```bash
55
- abs todo add <id> --note "做什么" # 登记
56
- abs todo note <id> --note "到哪一步" # 追加子项(一句话)
60
+ abs todo start <id> --note "做什么" # register (note required)
61
+ abs todo note <id> --note "到哪一步" # append child (one sentence)
57
62
  abs todo done <id> --as 落地|否决|仅方案 "结语"
58
63
  abs todo state <id> --note 进行中|讨论中|滞留中|搁置
59
- abs todo remove <id> # 删除(连子项,不可恢复)
64
+ abs todo remove <id> # delete incl. children, no undo
60
65
  ```
61
66
 
62
- - **开工前先登记**:要动文件/跑命令就先 `start`,名字粗糙没关系(事后可 `rename`)。
63
- - **一段活干完立刻 done**:别攒着等大需求收尾。
64
- - `搁置` = 先不做、还留在看板上;`remove` = 当作没登记过。**两者别混**。
65
- - 随手记多句发现用 `abs todo memo <id> "…"`,不占看板。
66
-
67
- ## 触发器:意图,不是关键词
67
+ - Register before touching files. A rough id is fine (`rename` later).
68
+ - `done` immediately when a unit of work finishes; do not batch at session end.
69
+ - One `[进行中]` at a time. `搁置` = parked on the board; `remove` = never existed.
70
+ - `memo` for extra notes; it does not occupy the board.
68
71
 
69
- 句子里出现 todo / 沉淀 / load / note 这些词,若是**谈论、提问、定规则**,只回应不执行。
72
+ ## Triggers are intent, not keywords
70
73
 
71
- 唯一例外:用户直接输入 `abs`(单独一个词或 `abs` + 命令,如 `abs 开工`)—— 明确要用 abs,
72
- 直接执行;无子命令时走开工流程。
74
+ `todo` / `沉淀` / `load` / `note` in a sentence that is discussion, a question, or
75
+ rule-making → respond only, do not execute.
73
76
 
74
- ## 文档精简(写 todo / 知识库文档必须遵守)
77
+ Exception: the user types `abs` alone or `abs` + command → execute; no subcommand = startup.
75
78
 
76
- 1. 所有内容服务于主题目标,无关的直接去掉。
77
- 2. 不写没验证过的经验和总结。
78
- 3. 层次分明,按前因后果组织,不用自造词。
79
- 4. 禁止反问、猜测。
80
- 5. 拒绝重复,已有文档写过的优先引用。
81
- 6. 禁令直接了当,不唠叨。
79
+ ## Document diet (todo / knowledge pages)
82
80
 
83
- ## ⛔ 禁止做
81
+ 1. Everything serves the topic; cut the rest.
82
+ 2. No unverified summaries. Only what was done, run, tested.
83
+ 3. Ordered, layered, plain declarative sentences. No coined words.
84
+ 4. No rhetorical questions, no guessing.
85
+ 5. No duplication — cite the other page instead.
86
+ 6. Bans are direct, not chatty.
84
87
 
85
- 1. 禁止手工建骨架 / 手工登记任务 —— 用 `abs init` / `abs todo add`。
86
- 2. 禁止通读 `.brain/` —— 要状态用 `abs load`,要主题用检索,要单页用 `abs resolve`。
87
- 3. 禁止删知识页表达"已失效" —— 用 `abs supersede <页> --by <新页>`。
88
- 4. 禁止直写 Rules —— 先提 `[Rules 提议]` 问用户,确认后 `abs rule add`。
89
- 5. 禁止用宿主原生 todo 承载跨会话任务 —— 用 `abs todo`。
90
- 6. 禁止编造没跑过的验证结论。
88
+ ## ⛔ Never
91
89
 
92
- ## 需要时再查的细节
90
+ 1. Hand-build skeletons or hand-register tasks — `abs init` / `abs todo start`.
91
+ 2. Read `.brain/` end-to-end — `abs load` for state, search for topics, resolve for one page.
92
+ 3. Delete a knowledge page to mark it obsolete — `abs supersede <页> --by <新页>`.
93
+ 4. Write Rules directly — propose `[Rules 提议]`, wait for the user, then `abs rule add`.
94
+ 5. Host-native todo for cross-session work — use `abs todo`.
95
+ 6. Invent verification results that were not run.
93
96
 
94
- 以下内容已移到图谱页,**不要凭记忆猜**,用到时读它:
97
+ ## Details live in the graph, not in memory
95
98
 
96
- | 要做什么 | 读哪页 |
99
+ | Need | Read |
97
100
  |---|---|
98
- | 七层怎么判、`--kind` 怎么选、收尾深提炼全流程 | `abs resolve abs-skill-details` |
99
- | 会话页怎么落、三种骨架、与会话的挂钩 | `abs resolve session-digest-design` |
100
- | 新需求受理协议(先方案→登记→问开工) | `abs resolve abs-skill-details` |
101
- | `.brain/` 目录结构、知识页 frontmatter 格式 | `abs resolve abs-skill-details` |
102
- | 体检报错码含义(NO-INBOUND / NO-TAIL / …) | `abs resolve abs-skill-details` |
103
-
104
- ## 自我约束
101
+ | Seven layers, `--kind`, deep wrap-up | `abs resolve abs-skill-details` |
102
+ | Session page shapes, ingest hooks | `abs resolve session-digest-design` |
103
+ | New-request protocol (plan → register → start) | `abs resolve abs-skill-details` |
104
+ | `.brain/` layout, frontmatter | `abs resolve abs-skill-details` |
105
+ | Lint codes (NO-INBOUND / NO-TAIL / …) | `abs resolve abs-skill-details` |
105
106
 
106
- 只读写 `.brain/` 与目标代码,不动全局配置(一次性接入除外);
107
- 双链、frontmatter、index 必须自洽 —— 坏链等于掰断接力棒。
107
+ Only read/write `.brain/` and target code. Broken dual-links = broken handoff.
@@ -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;