@fanchao8609/agent_brain_sync 1.25.0 → 1.26.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/db.js CHANGED
@@ -26,9 +26,9 @@ import { promises as fs } from 'node:fs';
26
26
  import { join, dirname } from 'node:path';
27
27
  import { unlinkSync } from 'node:fs';
28
28
  import { homedir } from 'node:os';
29
- import { resolveLayer, anchorsOf, decayOf, DEFAULT_LAYER } from './layers.js';
29
+ import { resolveLayer, anchorsOf, decayOf, DEFAULT_LAYER, isUnfilledDigest } from './layers.js';
30
30
 
31
- export const DB_VERSION = 4;
31
+ export const DB_VERSION = 5;
32
32
 
33
33
  /** 库路径。ABS_DB 可覆盖(测试隔离 / 多库切换)。 */
34
34
  export function dbPath() {
@@ -73,6 +73,13 @@ CREATE TABLE IF NOT EXISTS nodes (
73
73
  -- 因此任何查询都能命中它。实测(2026-11-01)刚 capture 一条「并发写」,log 页
74
74
  -- 亮度 3.87 排第一,压过真正的内容页 —— 这是纯噪音,必须从检索候选里排除。
75
75
  is_ledger INTEGER NOT NULL DEFAULT 0,
76
+ -- 会话页骨架还没人填(只认 kind=session-digest)。1 = 空骨架。
77
+ -- 为何要单开一列(2026-11-05 实测):ingest --auto 每次关会话都落一张空骨架,
78
+ -- 而空骨架的「用户原话」段有高词频、正文又短 —— bm25 天然占优。
79
+ -- 实测造 20 张空骨架后查「登录超时」:前 12 名全是空页,真正讲过这个问题的
80
+ -- 知识页被彻底挤出结果。降到 0.5 亮度不够(它们仍然霸榜),必须整个排除。
81
+ -- 填好后自动重算 → 重新参与检索并享受 0.5 折扣。
82
+ unfilled INTEGER NOT NULL DEFAULT 0,
76
83
  -- 被哪一页推翻(对应 frontmatter 的 superseded-by)。
77
84
  -- 为何要进库(2026-11-02):知识自我进化的第一个需要 —— 能查「哪些页被推翻了、
78
85
  -- 被谁推翻」。原来这关系只存在于 markdown 的一行 frontmatter 里,
@@ -260,6 +267,18 @@ export function estTokens(s) {
260
267
  return Math.round(cjk / 1.5 + rest / 4);
261
268
  }
262
269
 
270
+ /** 会话页在检索里的亮度折扣。
271
+ *
272
+ * 为何要降权(2026-11-05 用户定,方案里选择「亮度乘 0.5」):
273
+ * `abs ingest` 把会话落成页后,sessions/ 里会出现大量正文很长的页。
274
+ * 会话页正文天然长、词频高,bm25 天然占优 —— 不降权就会把真正的内容页淹掉。
275
+ * 同一个病本仓已经踩过一次(log-2026-11-02 第八节:会话流水页压过知识页)。
276
+ * 0.5 是起步值:能搜到、能被双链扩散带出,但不会抢占榜单首位。
277
+ *
278
+ * 判据用 kind 而不是目录:sessions/ 里还有当天流水(log-<日期>),
279
+ * 那是另一类页,不该被这条折扣管。 */
280
+ export const SESSION_DIGEST_DISCOUNT = 0.5;
281
+
263
282
  /** 从页正文取一句话摘要:优先 frontmatter 的 description,退化到首个非标题行。
264
283
  * 与 relevant.js 的 digest 同思路,但这里在库侧用(不读文件)。 */
265
284
  export function digest(body, maxLen = 90) {
@@ -301,8 +320,8 @@ export function rebuild(handle, pages) {
301
320
  db.exec('DELETE FROM anchors; DELETE FROM edges; DELETE FROM nodes; DELETE FROM rules; DELETE FROM nodes_bigram;');
302
321
  try { db.exec("INSERT INTO fts(fts) VALUES('delete-all')"); } catch { /* 空表时无可删 */ }
303
322
 
304
- const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger,superseded_by,kind)
305
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`);
323
+ const insNode = db.prepare(`INSERT INTO nodes(project,dir,slug,layer,title,tags,status,body,mtime,is_ledger,superseded_by,kind,unfilled)
324
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`);
306
325
  const insGram = db.prepare('INSERT OR REPLACE INTO nodes_bigram(id,g) VALUES(?,?)');
307
326
  const insEdge = db.prepare('INSERT OR IGNORE INTO edges(src,dst,raw) VALUES(?,?,?)');
308
327
  const insAnchor = db.prepare('INSERT OR IGNORE INTO anchors(word,node) VALUES(?,?)');
@@ -314,8 +333,10 @@ export function rebuild(handle, pages) {
314
333
  const { title, tags, status, supersededBy, kind } = metaOf(p.body);
315
334
  const { layer } = resolveLayer(p.body, p.dir);
316
335
  const ledger = (p.dir === '' && LEDGER_SLUGS.has(p.slug)) ? 1 : 0;
336
+ // 空骨架会话页在检索里整页排除(见 SCHEMA 里 unfilled 列的注释)
337
+ const unfilled = isUnfilledDigest(p.body) ? 1 : 0;
317
338
  const r = insNode.run(p.project, p.dir, p.slug, layer, title, tags, status,
318
- p.body, p.mtime || now, ledger, supersededBy, kind);
339
+ p.body, p.mtime || now, ledger, supersededBy, kind, unfilled);
319
340
  const id = Number(r.lastInsertRowid);
320
341
  // FTS 源:正文 + 标题 + tags 切 2-gram。查询侧必须用同一个 bigrams()。
321
342
  insGram.run(id, bigrams([p.slug, title, tags, p.body].join('\n')).join(' '));
@@ -470,6 +491,10 @@ export function search(handle, query, { limit = 20 } = {}) {
470
491
  JOIN nodes_bigram b ON b.id = fts.rowid
471
492
  JOIN nodes n ON n.id = fts.rowid
472
493
  WHERE fts MATCH ? AND n.is_ledger = 0
494
+ -- 未填的空骨架会话页不参与检索(2026-11-05 实测):
495
+ -- 它们正文短、素材段词频高,bm25 天然占优。实测 20 张空页就把
496
+ -- 真正讲过该问题的知识页彻底挤出结果 —— 降权不够,必须整页排除。
497
+ AND n.unfilled = 0
473
498
  -- 被推翻的经验不出现在检索里。
474
499
  -- 它的存在意义是「别再用它」,而不是回答「我上次怎么解决 X」——
475
500
  -- 那会拿到一个已知错误的答案。与 abs query 的既有判据一致。
@@ -512,7 +537,10 @@ export function search(handle, query, { limit = 20 } = {}) {
512
537
  // 输出里一片 0.3~0.5,用户看不出谁更相关 —— 亮度失去了「一眼看强弱」的作用。
513
538
  // 层的作用改由**排序**承担(见下面 sort),亮度回归「这一页与查询多贴」本身。
514
539
  const bright = Math.max(0.3, Math.min(1, band * (0.1 + 0.9 * rel)));
515
- return { ...r, bright };
540
+ // 会话页降权(2026-11-05):见 SESSION_DIGEST_DISCOUNT 的注释。
541
+ // 扣亮度而不扣排序权重 —— 亮度是输出给人看的东西,权重是内部排序秩序。
542
+ const discounted = r.kind === 'session-digest' ? bright * SESSION_DIGEST_DISCOUNT : bright;
543
+ return { ...r, bright: discounted };
516
544
  })
517
545
  // 排序:层优先,同层再看亮度。
518
546
  // 为何层要排第一(2026-11-02,586 页实测):查「静默失效」的前三名全是 L7
@@ -591,7 +619,12 @@ export function illuminate(handle, seeds, { hops = HOPS, limit = 10 } = {}) {
591
619
  for (const [id, v] of bright) {
592
620
  // 扩散出口也要排除 superseded —— 入口(search)排了,出口不排就绕过去:
593
621
  // 实测旧结论页自己不出现在种子里,却被邻居扩散点亮(亮度 0.24 带 ↝)。
594
- const n = db.prepare("SELECT id,project,dir,slug,title,layer,kind,body FROM nodes WHERE id = ? AND is_ledger = 0 AND status != 'superseded'").get(id);
622
+ //
623
+ // ★ 同一条判据也要管 unfilled(2026-11-05 实测):入口排了空骨架,
624
+ // 出口不排就会从邻居扩散进来 —— 实测知识页链到一张空骨架页后,
625
+ // 查「缓存穿透」时它以 `↝ 0.24` 出现在结果里。亮度虽低,但占了结果位,
626
+ // 且带 ↝ 标记看起来像有效线索,比直接排掉更容易误导。
627
+ const n = db.prepare("SELECT id,project,dir,slug,title,layer,kind,body FROM nodes WHERE id = ? AND is_ledger = 0 AND status != 'superseded' AND unfilled = 0").get(id);
595
628
  if (n) {
596
629
  // 与 search 保持同一形态:索引优先,只给摘要与成本,不带正文。
597
630
  // 坑(2026-11-02 实测):这里原来只查少数字段,于是 CLI 侧读不到 digest/tokens,
@@ -0,0 +1,377 @@
1
+ // src/brain/ingest.js — 把一场 pi 会话生成一张**会话页**,并与会话记录双向挂钩。
2
+ //
3
+ // 为何要它(2026-11-05 用户定):abs 原来只沉淀「编程经验与教训」——
4
+ // 所有落盘管道都压成「触发场景/❌表现/🛠解法/验证」四段(note.js 的 concept 骨架),
5
+ // 于是会话里那些「谈成了什么决定」「这次干了什么活」全没有落点。
6
+ // 实测:全机 485 场 pi 会话,只有 27 天有快照,最多提过 66 场;
7
+ // 而且 92 场通读下来只提炼出 3 条规律 —— 因为多数会话是「干具体的活」,不是修 bug。
8
+ // 要做大型知识图谱,会话本身就得成为一等知识。
9
+ //
10
+ // 与 summon 的分工:summon 回答「哪几场没总结」(只读,不写盘);
11
+ // 本模块回答「这场会话留下了什么」(写盘,但**只写素材,不写结论**)。
12
+ // 判断仍是 agent 的 —— 这里不猜语义,只把会话里**已经存在的**事实整理成页。
13
+ //
14
+ // 双向挂钩(用户 2026-11-05 要求「资料要与 pi 的会话挂钩」):
15
+ // 页 → 会话:frontmatter 记 session / session-file / project
16
+ // 会话 → 页:页文件名带会话 id 前 8 位(`log-<日期>-s<8位>.md`),
17
+ // 拿会话文件就能反查有没有被总结过(summon 的 md5 账本同理)。
18
+ // 为什么不把「页路径」回写进 jsonl:pi 的会话文件是宿主的产物,
19
+ // 往里加字段既越界又会让 md5 变化,导致账本把已总结的会话又当成「有更新」。
20
+
21
+ import { promises as fs } from 'node:fs';
22
+ import { join, basename } from 'node:path';
23
+ import { createHash } from 'node:crypto';
24
+ import { requireBrain, brainPath } from '../index.js';
25
+ import { requireUser, atTag } from '../userconfig.js';
26
+ import { today } from '../todo.js';
27
+ import { registerInIndex } from '../note.js';
28
+ import { clip } from '../text.js';
29
+ import { digestSession, sessionsRoot, projectDirName, updateLedger, markSummarized, ledgerPath, md5Of } from './summon.js';
30
+
31
+ /** 会话页的三种形状。形状由「改了什么东西」机械判定,不让模型猜。 */
32
+ export const SHAPES = {
33
+ tech: {
34
+ key: 'tech', label: '技术类',
35
+ when: '改了代码文件(.js/.ts/.py/.vue 等)',
36
+ sections: [
37
+ ['出了什么问题', '具体症状 / 报错 / 复现条件'],
38
+ ['怎么解决的', '根因 + 改了哪里'],
39
+ ['验证什么命令', '跑什么确认已修好'],
40
+ ],
41
+ },
42
+ ops: {
43
+ key: 'ops', label: '业务操作类',
44
+ when: '改了数据/配置/文档,没动代码',
45
+ sections: [
46
+ ['做了什么操作', '对哪些数据/配置/文档做了什么'],
47
+ ['结果怎样', '结果、数字、可见变化'],
48
+ ['下次注意事项', '再干一次要当心什么'],
49
+ ],
50
+ },
51
+ talk: {
52
+ key: 'talk', label: '讨论类',
53
+ when: '一场文件都没改(聊需求、问方案、做决策)',
54
+ sections: [
55
+ ['谈成了什么', '结论 / 决定 / 达成的共识'],
56
+ ['没结论的地方', '开着的问题、还没定的取舍'],
57
+ ],
58
+ },
59
+ };
60
+
61
+ /** 代码文件扩展名。判据只看扩展名,不看目录 —— 目录约定各项目不同。 */
62
+ const CODE_EXT = /\.(js|mjs|cjs|ts|tsx|jsx|vue|svelte|py|go|rs|java|kt|c|cc|cpp|h|hpp|rb|php|swift|sh|sql)$/i;
63
+ /** 数据/配置/文档扩展名。两者都不命中时按「讨论类」算。 */
64
+ const OPS_EXT = /\.(json|ya?ml|toml|ini|conf|env|md|txt|csv|xml|html|css|scss|less)$/i;
65
+
66
+ /** 从改过的文件集合判形状。
67
+ * 混合改动时按「有代码改动就是技术类」——
68
+ * 因为技术类的三段最严(要写验证命令),宁可多要求不可漏要求。 */
69
+ export function shapeOf(files) {
70
+ const list = [...files.keys()];
71
+ if (!list.length) return 'talk';
72
+ const code = list.filter((p) => CODE_EXT.test(p)).length;
73
+ if (code) return 'tech';
74
+ const ops = list.filter((p) => OPS_EXT.test(p)).length;
75
+ if (ops) return 'ops';
76
+ return 'talk';
77
+ }
78
+
79
+ /** 会话文件名 → 能定位它的标识。
80
+ *
81
+ * 正常形态:`2026-11-05T03-35-14-951Z_<uuid>.jsonl`
82
+ * (实测全机 485 个会话文件 100% 匹配,pi 的 id 确实是十六进制 uuid)。
83
+ *
84
+ * ★ 匹配不上时**不能**退化成固定字符串(2026-11-05 实测踩到):
85
+ * 原来退成 `sunknown`,于是同一天两场「认不出文件名」的会话会落成同一个
86
+ * `session-<日期>-sunknown.md`,后来的静默覆盖前面的 —— 悄无声息地丢一场会话。
87
+ * 改为取文件名哈希:认不出也保证不同文件 → 不同页。
88
+ * (真实数据目前 100% 匹配,所以这是给「将来 pi 改了命名规则」留的护栏。) */
89
+ function idsOf(file) {
90
+ const name = basename(file);
91
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})T[^_]*_([0-9a-f-]{8,})\.jsonl$/);
92
+ if (m) return { name, day: m[1], id: m[2], short: m[2].slice(0, 8) };
93
+ // 兜底:日期尽量从文件名里抠(形如 2026-11-05),抠不到就用今天;
94
+ // short 用文件名 md5 前 8 位 —— 唯一,且同一文件每次结果相同。
95
+ const dayM = name.match(/(\d{4}-\d{2}-\d{2})/);
96
+ const h = createHash('md5').update(name).digest('hex').slice(0, 8);
97
+ return { name, day: dayM ? dayM[1] : '', id: '', short: h };
98
+ }
99
+
100
+ /** 会话用户原话 → 「这场会话在干什么」的一行主旨。
101
+ *
102
+ * 边界:这是**机械提取**,不是语义总结。
103
+ * 不给「AI 总结的主旨」—— 那需要模型,而本命令不调模型。
104
+ * agent 拿到素材后自己写主旨,这正是「工具落点、判断归 agent」的分工。
105
+ *
106
+ * ★ 判据:优先短句,跳过长文档(2026-11-05 实测修正)。
107
+ * 原来取「第一句够长(≥6 字)的话」,结果真实会话里第一句常是**粘贴进来的需求文档**
108
+ * ——zj_shop 那场抓出来是:「附件:小程序商城开发详细功能清单 一、项目概述 本项目为…
109
+ * 下面是功能明细。二、详细功能明细(一)基础模块 1. 店铺信息配置…」
110
+ * 这种东西进 index 完全没法读,还得手工改。
111
+ *
112
+ * 新判据按三条打分,取第一个命中的:
113
+ * ① 长度在 6~40 字之间(够具体又不像文档)
114
+ * ② 不含文档特征标记(一、/(一)/附件:/功能明细/项目概述 这类)
115
+ * ③ 不含换行与连续空白(粘贴体通常有大段排版)
116
+ * 全不命中就返回空串 —— 让 title 留占位、由 agent 填,好过塞一段文档进去。 */
117
+ const DOC_MARKERS = /(附件[::]|项目概述|功能明细|详细功能|^[一二三四五六七八九十]、|([一二三四五六七八九十])|\d+[.、]\s*\S{2,6}[::])/;
118
+
119
+ export function headlineOf(digest) {
120
+ const users = (digest.users || []).map((u) => u.replace(/\s+/g, ' ').trim());
121
+ // 优先:短、非文档体、且**句子是说完的**
122
+ for (const u of users) {
123
+ if (!usableHeadline(u)) continue;
124
+ const len = Array.from(u).length;
125
+ if (len < 6 || len > 40) continue;
126
+ return clip(u, 40);
127
+ }
128
+ // 退一步:允许长一点,其余条件不变
129
+ for (const u of users) {
130
+ if (!usableHeadline(u)) continue;
131
+ return clip(u, 40);
132
+ }
133
+ return '';
134
+ }
135
+
136
+ /** 这句话适合当标题吗(与长度无关的那些条件)。
137
+ *
138
+ * ★ 「说完没有」这条是 2026-11-05 实测补的:用户第一句话常是**被回车打断的半句**。
139
+ * 本场会话自己的页就被抓成「找一下所有会话中 关于 会话总结提炼的信息,我/」
140
+ * —— 用户在打字中途按了回车,那个 `/` 是断的。这种东西进 index 比空着更糟:
141
+ * 它看起来像个标题,实际什么也没说。
142
+ * 判据:结尾不能是未完成信号(斜杠、逗号、连接词、冒号、开引号)。
143
+ * 「句号/问号/叹号结尾」当然可以;没有标点但语义完整(如「优化一下命令」)也放行。 */
144
+ // 判据只收「几乎不可能出现在完整句尾」的信号。
145
+ // ★ 刻意不含「的 / 了」:它们看着像没说完,实际是中文完整句的高频结尾
146
+ // (「不想要了」「去掉不必要的」),收进来会把大量正常句子误杀
147
+ // —— 实测这两条一条被拦、一条被拦,而它们都是完整的诉求。
148
+ const UNFINISHED_END = /(?:[/、,,;;::]|(?:和|与|或|在|把|被|对|给|让|使|因为|但是|然后|还是|或者))$/;
149
+
150
+ function usableHeadline(u) {
151
+ const t = String(u || '').trim();
152
+ if (!t) return false;
153
+ if (DOC_MARKERS.test(t)) return false; // 粘贴的文档体
154
+ if (UNFINISHED_END.test(t)) return false; // 没说完的半句
155
+ return true;
156
+ }
157
+
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 = [
164
+ '---',
165
+ `title: ${head || '会话 ' + (ids.short || ids.day)}`,
166
+ 'tags: [session-digest, 会话]',
167
+ 'layer: 5',
168
+ `kind: session-digest`,
169
+ `author: ${who}`,
170
+ `updated: ${ids.day || today()}`,
171
+ '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
+ '---',
180
+ '',
181
+ `# 会话:${head || ids.day}`,
182
+ '',
183
+ `> ${ids.day} · ${sh.label} · 用户发言 ${digest.users.length} 句 · 工具调用 ${digest.toolCalls} 次`,
184
+ '',
185
+ ];
186
+ for (const [title, hint] of sh.sections) {
187
+ body.push(`## ${title}`, `<!-- ${hint} -->`, '');
188
+ }
189
+ if (files.length) {
190
+ body.push('## 改过的文件');
191
+ for (const f of files.slice(0, 40)) {
192
+ body.push(`- \`${f}\` ×${digest.filesEdited.get(f)}`);
193
+ }
194
+ if (files.length > 40) body.push(`- …另有 ${files.length - 40} 个`);
195
+ body.push('');
196
+ }
197
+ body.push(
198
+ '## 用户原话',
199
+ '<!-- 从会话文件提取的原始诉求,按发生顺序。写上面几段时以这里为依据 -->',
200
+ );
201
+ for (const u of digest.users.slice(0, 20)) {
202
+ body.push(`- ${u.replace(/\s+/g, ' ').trim().slice(0, 200)}`);
203
+ }
204
+ if (digest.users.length > 20) body.push(`- …另有 ${digest.users.length - 20} 句`);
205
+ body.push(
206
+ '',
207
+ '## 关联连接',
208
+ `- ${atTag(who)} — 本页沉淀者`,
209
+ '(把本场会话改到的知识页挂在这里,双向可回溯)',
210
+ '',
211
+ );
212
+ return body.join('\n');
213
+ }
214
+
215
+ /** 把会话摘要落成会话页。返回可读的结果文本。 */
216
+ export async function cmdIngest({ dir, file, force = false, mark = false, last = false, auto = false, session = '' }) {
217
+ if (!file && !last && !session) {
218
+ return [
219
+ '用法: abs ingest <会话文件名或路径> [--force] [--mark]',
220
+ ' abs ingest --last [--auto] # 最近一场会话(手工用)',
221
+ ' abs ingest --session <id> [--auto] # 指定会话(hook 用,避免多会话时选错)',
222
+ ' 例: abs ingest 2026-11-05T03-35-14-951Z_01a1a9c6-...jsonl',
223
+ ' 说明: 把一场 pi 会话整理成 .brain/sessions/ 的会话页,并把会话 id 写进 frontmatter(双向可回溯)。',
224
+ ' 只看 --dir 指定的项目(或当前目录)的会话目录;不跨项目落盘。',
225
+ ].join('\n');
226
+ }
227
+
228
+ let root;
229
+ try {
230
+ root = await requireBrain(dir || process.cwd());
231
+ } catch {
232
+ return '未找到 .brain/ 图谱。先在项目根运行: abs init';
233
+ }
234
+
235
+ // 会话文件定位:允许给绝对路径、文件名、或只给 id 前 8 位
236
+ const sessDir = join(sessionsRoot(), projectDirName(root));
237
+ let names;
238
+ try { names = (await fs.readdir(sessDir)).filter((f) => f.endsWith('.jsonl')); } catch {
239
+ return `✗ 找不到会话目录: ${sessDir}\n 说明: 本项目的会话可能记在别的路径(用 ABS_PI_SESSIONS 覆盖)。`;
240
+ }
241
+ let hit;
242
+ if (session) {
243
+ // ★ 优先用宿主给的会话 id(2026-11-05 实测修正):
244
+ // `--last` 按 mtime 选文件,同时开着两个会话时会选错 —— 实测关会话 A 却落了 B 的页,
245
+ // 因为 B 的 jsonl 更新。hook 那侧本来就能拿到本会话 id(sessionIdFor),
246
+ // 传下来就能精确定位,不用猜。
247
+ const want = String(session).trim();
248
+ hit = names.find((n) => n === want)
249
+ || names.find((n) => want.length >= 8 && n.includes(want));
250
+ if (!hit) return `✗ 会话目录里找不到 session=${want} 对应的文件`;
251
+ } else if (last) {
252
+ // --last:最近**写过**的那个会话文件(hook 在 session_shutdown 时用,
253
+ // 那时刚关的就是这一个)。按 mtime 取最大,不靠文件名排序 ——
254
+ // 文件名里的时间戳是会话**开始**时刻,跨天续聊时它不再是最新的。
255
+ let best = null, bestMs = -1;
256
+ for (const n of names) {
257
+ const st = await fs.stat(join(sessDir, n)).catch(() => null);
258
+ if (st && st.mtimeMs > bestMs) { bestMs = st.mtimeMs; best = n; }
259
+ }
260
+ hit = best;
261
+ } else {
262
+ const want = basename(String(file).trim());
263
+ hit = names.find((n) => n === want)
264
+ || names.find((n) => want.length >= 8 && n.includes(want))
265
+ || names.find((n) => n.includes(want));
266
+ if (!hit) {
267
+ return `✗ 会话目录里找不到 "${want}"。\n 可用: abs summon(列出待总结的会话)`;
268
+ }
269
+ }
270
+ if (!hit) return `• 会话目录是空的,没有可落页的会话`;
271
+
272
+ const digest = await digestSession(join(sessDir, hit));
273
+ if (!digest) return `✗ 读不了会话文件: ${hit}`;
274
+ if (!digest.users.length) return `• ${hit} 没有真实用户输入,不生成会话页(空会话)`;
275
+
276
+ const ids = idsOf(hit);
277
+ if (!ids.id) ids.id = digest.id || '';
278
+
279
+ const shape = shapeOf(digest.filesEdited);
280
+
281
+ // ── --auto:hook 在会话关闭时机械调用,判据只有一条 —— 真的动过文件 ──
282
+ //
283
+ // 为何只认文件改动(2026-11-05):纯聊天/纯查询的会话落成页是噪音,
284
+ // 而且从这类会话里本来也提炼不出「干了什么」。判据与 cmdTeardownCheck
285
+ // 的条件①同源(本会话真改过文件才打扰),两边必须一致,否则
286
+ // 一个说「这会话值得记」另一个说「不值得」,用户看到的是自相矛盾。
287
+ //
288
+ // 只落骨架不填内容:内容要读会话后写,机器不猜。这条边界与本仓
289
+ // 「判断力在 agent、工具只做落点」一致(见 skill 的主动提炼一节)。
290
+ if (auto && shape === 'talk') {
291
+ return `• 跳过:这场会话没有改动任何文件(纯聊天/查询),不值当落页`;
292
+ }
293
+
294
+ const who = await requireUser();
295
+ const sessOut = brainPath(root, 'sessions');
296
+ await fs.mkdir(sessOut, { recursive: true });
297
+
298
+ // 文件名带会话 id 前 8 位 —— 这是「会话 → 页」的反查凭据(用户 2026-11-05 要求)。
299
+ // 不带 id 只按日期命名会和当天的 log-<日期>.md 撞,且无法区分同日多场会话。
300
+ const slug = `session-${ids.day || today()}-s${ids.short || 'unknown'}`;
301
+ const dst = join(sessOut, `${slug}.md`);
302
+
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 { /* 不存在,正常写入 */ }
316
+
317
+ const body = renderPage({ shape, digest, ids, who });
318
+ // 骨架段里的 <!-- 提示 --> 占位**故意保留**:它告诉填页的人每段该写什么。
319
+ // 它不会干扰「填没填」的判定 —— load 的 digestFilled 与 lint 的 hasTail
320
+ // 都把占位注释当空内容(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
+
327
+ await registerInIndex(root, 'Sessions', slug, `会话:${headlineOf(digest) || ids.day}`);
328
+
329
+ // 不写 log.md(2026-11-05,沿用 abs concept 的既有判据):
330
+ // 「新建了会话页 X」是**命令的副作用**,不是工作成果 —— 零信息量,
331
+ // 而「该页存在」已由 registerInIndex 落在 index 的 Sessions 区。
332
+ // 更要紧的是:--auto 在每次关会话时跑,一天开 10 次就刷 10 行流水,
333
+ // 靠事后清理治不了(abs concept 当初正是因为「建 10 页 = 10 行噪声」才删掉这次调用的)。
334
+
335
+ // 记账:这场会话已提取,下次 summon 不再列。
336
+ //
337
+ // ★ 落页即记账,不再等 --mark(2026-11-05 实测修正):
338
+ // 原来只有 --mark 才写账本,于是一个已落页的会话**永远停在「未总结」名单里**
339
+ // —— summon 反复催同一场,而人已经能看到那张页了。催一个已存在的东西
340
+ // 是最快让人忽略提醒的方式,比不提醒更糟。
341
+ // 判据变了:账本记的不是「人读过没读」,是「这场会话有没有对应的页」。
342
+ // 读没读由 load 的「未填会话页」提醒负责,那是两件事。
343
+ // --mark 保留(旧脚本仍在用),效果相同。
344
+ let markNote = '';
345
+ {
346
+ void mark; // --mark 已并入默认行为,保留参数只为旧脚本兼容
347
+
348
+ const st = await fs.stat(join(sessDir, hit)).catch(() => null);
349
+ // md5 必须算真值(2026-11-05 审查发现):原来写空串,而 scanProject 用
350
+ // `rec.md5 === md5` 判重 —— 空串永不等于真 md5。后果是 size/mtime 一变、
351
+ // 且当天没有快照时,内容一字未改的会话会被报成「有更新(需补充)」。
352
+ // 实测复现:删掉快照后 summon 输出 `有更新 1 ↳ md5 与账本不符`。
353
+ const md5 = await md5Of(join(sessDir, hit));
354
+ // 用 updateLedger 而不是「读 → 改 → 写」三连(2026-11-05 实测):
355
+ // 两个会话同时关闭时,两个进程各自读到同一份旧账本、各自加一条,
356
+ // 后写的盖掉先写的 —— 先写那条静默消失,下轮 summon 又把它当「未落页」催。
357
+ // updateLedger 按文件加锁,把读改写串行化。
358
+ await updateLedger((book) => {
359
+ markSummarized(book, [{
360
+ name: hit, md5: md5 || '', day: ids.day,
361
+ size: st?.size, mtime: st?.mtimeMs,
362
+ }]);
363
+ }, ledgerPath());
364
+ markNote = '\n 已记账(abs summon 不再列这场)';
365
+ }
366
+
367
+ const sh = SHAPES[shape];
368
+ return [
369
+ `✓ 会话页 → .brain/sessions/${slug}.md`,
370
+ ` ${ids.day} · ${sh.label}(判据: ${sh.when})`,
371
+ ` 用户 ${digest.users.length} 句 · 工具 ${digest.toolCalls} 次 · 改过 ${digest.filesEdited.size} 个文件`,
372
+ ` 挂钩: session=${ids.id} file=${hit}`,
373
+ ' 骨架已给好,三段内容要读会话后填;填完 status 改 active。',
374
+ ].join('\n') + markNote;
375
+ }
376
+
377
+ export { idsOf };
@@ -125,3 +125,43 @@ export function layerConflict(body, dirName) {
125
125
  if (!expect || expect === r.layer) return null;
126
126
  return `layer: ${r.layer} 与目录 ${dirName}/(默认 ${expect})不符`;
127
127
  }
128
+
129
+ /** 会话页的骨架段名(由 ingest.js 的 SHAPES 决定,这里只用于判「填没填」)。
130
+ * 与 ingest 的 SHAPES 必须同步 —— 加新形状时两处都要改。 */
131
+ export const DIGEST_SECTIONS = ['出了什么问题', '怎么解决的', '验证什么命令',
132
+ '做了什么操作', '结果怎样', '下次注意事项', '谈成了什么', '没结论的地方'];
133
+
134
+ /** 会话页的三段骨架里有没有任何一段写了真内容。
135
+ *
136
+ * 为何要这个判据(2026-11-05 实测):`ingest --auto` 每次关会话都落一张空骨架,
137
+ * 而空骨架的「用户原话」段有高词频、正文又短 —— bm25 天然占优。
138
+ * 实测造 20 张空骨架后查「登录超时」:前 12 名全是空页,
139
+ * 真正讲过这个问题的知识页被彻底挤出结果。
140
+ * 所以「未填」必须能被算出来,才能把它们排除出检索。
141
+ *
142
+ * 判据:骨架段内除占位注释与空行外还有没有真内容。
143
+ * 不看 status —— 人可能填完忘了改 active,用 status 会漏判。
144
+ * 只看骨架段,不数「用户原话/改过的文件」—— 那两段是素材,不是填写成果。 */
145
+ export function digestFilled(body) {
146
+ const lines = String(body || '').split('\n');
147
+ 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;
150
+ for (let j = i + 1; j < lines.length; j++) {
151
+ const t = lines[j].trim();
152
+ if (/^##\s/.test(t)) break; // 下一段开始
153
+ if (!t) continue;
154
+ if (t.startsWith('<!--') || t === '-->') continue; // 占位注释不算填
155
+ return true;
156
+ }
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /** 这一页是不是「没人填过的会话页骨架」。入库时算一次,存成 unfilled 列。
162
+ * 非会话页一律 false(只有 ingest 落的页才可能是空骨架)。 */
163
+ export function isUnfilledDigest(body) {
164
+ const t = String(body || '');
165
+ if (!/^kind:\s*session-digest$/m.test(t)) return false;
166
+ return !digestFilled(t);
167
+ }
package/src/brain/load.js CHANGED
@@ -9,6 +9,14 @@ import { keywords, recentFiles, topicStrength, digest } from '../relevant.js';
9
9
  import { readIfExists, recentLogLines } from '../text.js';
10
10
  import { listPages } from '../lint.js';
11
11
  import { SOURCES_MAX } from '../lint.js';
12
+ // 判「会话页骨架填没填」的唯一真源在 layers.js(入库侧 db.js 也用它算 unfilled 列)。
13
+ // 两处必须同一个判据 —— 否则会出现「load 说不算未填、检索却把它排除掉」的矛盾。
14
+ import { digestFilled } from './layers.js';
15
+
16
+ /** 未填会话页在 load 里最多列几个页名(其余折叠成条数)。
17
+ * 为何要限:会话页数量只增不减,全列会让 load 首屏随规模膨胀
18
+ * —— 违反本仓「读取侧输出不得随规模增长」。5 个够 agent 判断先填哪张。 */
19
+ const SESSION_UNFILLED_SHOW = 5;
12
20
  import { checkBrainShape } from './shape.js';
13
21
  import { rulesSection } from './rules.js';
14
22
  import { collapseIndex } from '../page.js';
@@ -142,6 +150,16 @@ export async function cmdLoad({ dir }) {
142
150
  const hot = await hotPagesSection(root, todo);
143
151
  if (hot) sections.push('', hot);
144
152
  } catch { /* 同上,失败静默 */ }
153
+ // 未填会话页提醒(2026-11-05):ingest/hook 落的是空骨架,内容要人填。
154
+ // 若不在开工时摆出来,「落了没填」就永远没人知道 —— 等于采集了不消化。
155
+ // 与 sources 提醒同一个位置、同一种语气:只给线索,判断仍归 agent。
156
+ 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
+ }
162
+ } catch { /* 提醒失败不影响 load */ }
145
163
  // sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
146
164
  // 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
147
165
  try {
@@ -423,6 +441,58 @@ export async function cmdRecall({ dir, terms, limit = 5 } = {}) {
423
441
  }
424
442
  }
425
443
 
444
+ /** 会话页 fill 判据:骨架段是否还没人填。
445
+ *
446
+ * 为何要这个判据(2026-11-05):`abs ingest` / hook 的 --auto 落的是**空骨架**,
447
+ * 内容要读会话后填。但「落了没填」原来没有任何地方看得见 —— 落盘等于进了黑洞,
448
+ * 正是本仓反复踩的「采集了不消化」。这里只判「填没填」,不判「该不该留」。
449
+ *
450
+ * 判据与 lint 的 hasTail 同源:段内除了占位注释与空行之外还有没有真内容。
451
+ * 只认 ingest 生成的三种骨架段名(见 ingest.js 的 SHAPES)——它们都带
452
+ * `<!-- 提示 -->` 占位;任何一段有了真内容就算填过。
453
+ *
454
+ * 不看 status:人可能填完忘了改 active。用 status 当判据会漏报,
455
+ * 反过来「status: draft 但已填」也不该反复提醒。 */
456
+ /** 未填会话页提醒:返回 [标题行, ...内容行] 或 null。失败静默。
457
+ *
458
+ * 守「读取侧输出不随规模增长」:只列表头最多 SESSION_UNFILLED_SHOW 个页名,
459
+ * 其余折叠成条数。页名够用 —— agent 要读哪张再 abs resolve 那一张,别在这贴正文。 */
460
+ async function unfilledSessionSection(root) {
461
+ let files;
462
+ try {
463
+ files = await fs.readdir(brainPath(root, 'sessions'));
464
+ } catch { return null; }
465
+ const unfilled = [];
466
+ for (const f of files) {
467
+ if (!f.endsWith('.md') || !f.startsWith('session-')) continue;
468
+ const body = await fs.readFile(brainPath(root, 'sessions', f), 'utf8').catch(() => '');
469
+ if (!body) continue;
470
+ // 只认 ingest 落的页:它有 kind: session-digest。手写页不该被催。
471
+ if (!/^kind:\s*session-digest$/m.test(body)) continue;
472
+ if (!digestFilled(body)) unfilled.push(f.replace(/\.md$/, ''));
473
+ }
474
+ if (!unfilled.length) return null;
475
+ // 按日期倒序(2026-11-05 实测修正):原来按 readdir 顺序(≈文件名升序),
476
+ // 60 张时列出来的是 18/27/36/45/54 —— 随机五张,既不是最近的也不是最该填的。
477
+ // 页名形如 session-<日期>-s<id8>,日期就在里面,直接按名字倒排即可。
478
+ unfilled.sort((a, b) => b.localeCompare(a));
479
+ const show = unfilled.slice(0, SESSION_UNFILLED_SHOW);
480
+ const rows = [
481
+ `📝 未填会话页 ${unfilled.length} 张(会话已落盘,内容还没人读)`,
482
+ ...show.map((s) => ` - ${s}`),
483
+ ];
484
+ if (unfilled.length > show.length) {
485
+ rows.push(` …另有 ${unfilled.length - show.length} 张更早的(不逐条列,守读取侧不膨胀)`);
486
+ }
487
+ // 数量大时明说「别想着全填」——否则这个提醒会变成一种罪疚感,而不是决策依据。
488
+ if (unfilled.length > SESSION_UNFILLED_SHOW) {
489
+ rows.push(' 挑最近、且有结论的那几场填;其余留着当流水,不填也不用管(它们不进检索)。');
490
+ }
491
+ rows.push('→ 填法: abs resolve <页名> 读那张 → 按骨架段写内容 → status 改 active');
492
+ rows.push('');
493
+ return rows;
494
+ }
495
+
426
496
  /** sources 堆积提醒:返回 [标题行, ...内容行] 或 null。失败静默。 */
427
497
  async function sourcesCountSection(root) {
428
498
  const nsrc = await countSources(root);
@@ -10,7 +10,7 @@
10
10
  import { resolve } from 'node:path';
11
11
  import { findBrainRoot } from '../index.js';
12
12
  import {
13
- scanProject, renderScan, readLedger, writeLedger, markSummarized, ledgerPath,
13
+ scanProject, renderScan, readLedger, updateLedger, markSummarized, ledgerPath,
14
14
  } from './summon.js';
15
15
 
16
16
  /** 解析项目根:显式 --dir 优先,否则用当前目录的图谱根。 */
@@ -50,8 +50,9 @@ export async function cmdSummon({ dir, mark = false, full = false, limit = 15, s
50
50
 
51
51
  // --mark:记为已提取。不写知识页 —— 那是人的判断,不是本命令的。
52
52
  if (!pending.length) return '✓ 没有待提取的会话,账本未改动。';
53
- markSummarized(book, pending);
54
- await writeLedger(book, path);
53
+ // updateLedger 加锁改写(2026-11-05):与 ingest 落页并发时,
54
+ // 两个「读 → 改 → 写」会互相盖掉,先写那条静默消失。
55
+ await updateLedger((latest) => { markSummarized(latest, pending); }, path);
55
56
  return [
56
57
  `✓ 已记账 ${pending.length} 个会话(下次不再列出)`,
57
58
  ` 账本: ${path}`,