@fanchao8609/agent_brain_sync 1.21.0 → 1.23.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
@@ -5,7 +5,7 @@ import { brainPath, requireBrain } from '../index.js';
5
5
  import { getUser, placeholderWarn } from '../userconfig.js';
6
6
  import { boardText, readTodo, collapseDone } from '../todo.js';
7
7
  import { strandedFor } from '../wrapup.js';
8
- import { keywords, recentFiles, topicStrength } from '../relevant.js';
8
+ 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';
@@ -136,6 +136,12 @@ export async function cmdLoad({ dir }) {
136
136
  const rel = await queryHintSection(root, todo);
137
137
  if (rel) sections.push('', rel);
138
138
  } catch { /* 提示失败不影响 load */ }
139
+ // 最相关几页(2026-11-02):queryHint 给「该查什么词」,这里给「该读哪几页」。
140
+ // 放最后 —— 它是「再深入一层」的东西,前面先给状态与待办。
141
+ try {
142
+ const hot = await hotPagesSection(root, todo);
143
+ if (hot) sections.push('', hot);
144
+ } catch { /* 同上,失败静默 */ }
139
145
  // sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
140
146
  // 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
141
147
  try {
@@ -174,6 +180,94 @@ export function extractBlocks(todoText) {
174
180
  return out;
175
181
  }
176
182
 
183
+ /** 从「活跃 todo + 最近改动的文件」推出当前上下文的关键词。
184
+ *
185
+ * 抽成公共函数(2026-11-02):queryHint(给查询词)与 hotPages(给正文)
186
+ * 都要用,两处各算一遍必然漂移。
187
+ *
188
+ * 已剔掉任务行的结构记号:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
189
+ * 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
190
+ * 而作者名恰好有自己的 entities/<作者>.md 页 → 命中页名 → 提出「查你自己的名字」。
191
+ */
192
+ async function contextKeywords(root, todoText) {
193
+ const active = String(todoText || '')
194
+ .split('\n')
195
+ .filter((l) => /^\s*-\s*\[\s*\]/.test(l))
196
+ .map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
197
+ .join(' ');
198
+ const files = await recentFiles(root, { n: 5 }).catch(() => []);
199
+ const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
200
+ return { kws: keywords(src), files };
201
+ }
202
+
203
+ /**
204
+ * 开机带出「当前上下文最相关的几页」—— 只给页名 + 一句话摘要,不贴正文。
205
+ *
206
+ * 与 queryHint 的分工(2026-11-02 用户定):
207
+ * queryHint 给「该查什么词」,本函数给「该读哪几页」。两者都只是**送达线索**,
208
+ * 不替代 query / db search,也不贴正文。
209
+ *
210
+ * 为何不贴正文:既有判据是「自动带出正文会让 query 更没人用,且无法按需求变化调整词」。
211
+ * 只给页名 + 摘要把选择权留给人:想看再 abs resolve / 读文件。
212
+ *
213
+ * 输入是环境事实(活跃 todo 的关键词 + 最近改过的文件),不需要人先想到搜索词 ——
214
+ * 这正是不依赖「主动查询」的那条路。
215
+ *
216
+ * 降级:库不存在 / 库坏了 / 任何异常 → 返回空串,不影响 load。
217
+ *
218
+ * @param {string} root 项目根
219
+ * @param {string} todoText
220
+ * @param {number} limit 最多几页
221
+ */
222
+ export async function hotPagesSection(root, todoText, { limit = 3 } = {}) {
223
+ try {
224
+ const { kws } = await contextKeywords(root, todoText);
225
+ if (!kws.length) return '';
226
+ const { openDb, search, illuminate, dbPath } = await import('./db.js');
227
+ const { promises: fsp } = await import('node:fs');
228
+ const exists = await fsp.stat(dbPath()).then(() => true).catch(() => false);
229
+ if (!exists) return ''; // 库没建 → 静默跳过(不是错误)
230
+
231
+ // 项目名要与入库时一致 —— 铁律按项目隔离,读错项目就是串味
232
+ const project = String(root).split('/').filter(Boolean).pop() || '';
233
+ const h = openDb(dbPath());
234
+ try {
235
+ // 用几个强词合并成一组种子:逐个查再按 id 去重取最高亮度
236
+ const seeds = new Map();
237
+ for (const kw of kws.slice(0, 5)) {
238
+ for (const s of search(h, kw, { limit: 6 })) {
239
+ const prev = seeds.get(s.id);
240
+ if (!prev || s.bright > prev.bright) seeds.set(s.id, s);
241
+ }
242
+ }
243
+ if (!seeds.size) return '';
244
+ const lit = illuminate(h, [...seeds.values()], { limit: limit + 6 })
245
+ // 只留本项目的页:跨项目的经验可以查,但不该在开机首屏默认塞进来
246
+ .filter((r) => r.project === project)
247
+ // 再按**纯亮度**重排 —— illuminate 用的是「层权重 × 亮度」,
248
+ // 那是为了让知识页压过流水页(搜索场景对);但开机首屏要看的是
249
+ // 「跟我现在干的事最贴的」,不是「层级最高的」。
250
+ // 实测:不重排时 L3 的 0.925 会排在 L4 的 1.000 前面 —— 读起来反直觉。
251
+ .sort((a, b) => b.bright - a.bright)
252
+ .slice(0, limit);
253
+ if (!lit.length) return '';
254
+
255
+ const rows = ['--- 当前最相关(按亮度,纯线索不贴正文;要看用 abs resolve <页名>)---'];
256
+ for (const r of lit) {
257
+ const one = await fsp.readFile(brainPath(root, r.dir, `${r.slug}.md`), 'utf8').catch(() => '');
258
+ const about = one ? digest(one, 80) : '';
259
+ const tag = r.from === 'spread' ? ' ↝' : '';
260
+ rows.push(` ${r.bright.toFixed(2)} L${r.layer} [[${r.slug}]]${tag}${about ? ' — ' + about : ''}`);
261
+ }
262
+ return rows.join('\n');
263
+ } finally {
264
+ h.close();
265
+ }
266
+ } catch {
267
+ return ''; // 任何异常都不该弄坏 load
268
+ }
269
+ }
270
+
177
271
  /**
178
272
  * 开工提示:直接给出该查的几个词,降低发起 query 的成本。
179
273
  *
@@ -195,19 +289,7 @@ export function extractBlocks(todoText) {
195
289
  * @param {{name:string,body:string}[]} [pages] 已读好的页(省一次磁盘扫描)
196
290
  */
197
291
  async function queryHint(root, todoText, pages) {
198
- const active = String(todoText || '')
199
- .split('\n')
200
- .filter((l) => /^\s*-\s*\[\s*\]/.test(l))
201
- // 剔掉任务行的结构记号再分词:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
202
- // 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
203
- // 而作者名恰好有自己的 entities/<作者>.md 页 → 命中**页名** → 强度 1 过了门槛,
204
- // 于是提示语变成「查一下你自己的名字」。建议查作者页不是“别重踩的经验”。
205
- // 用 task 行自身的形状剥(与 idOfTaskLine 同源),不维护人名/任务名黑名单。
206
- .map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
207
- .join(' ');
208
- const files = await recentFiles(root, { n: 5 }).catch(() => []);
209
- const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
210
- const kws = keywords(src);
292
+ const { kws, files } = await contextKeywords(root, todoText);
211
293
  if (!kws.length) return '';
212
294
  const all = pages || await listPages(brainPath(root)).catch(() => []);
213
295
  const strength = topicStrength(all.map((p) => ({ name: p.slug, body: p.body })), kws);
@@ -166,10 +166,12 @@ export async function scanProject(projectRoot, { ledger, now = Date.now() } = {}
166
166
  try { names = (await fs.readdir(dir)).filter((f) => f.endsWith('.jsonl')); } catch {
167
167
  return { error: `找不到会话目录: ${dir}`, fresh: [], changed: [], skipped: [], active: [] };
168
168
  }
169
+ const ownBook = !ledger; // 账本是自己读的(不是调用方给的)
169
170
  const book = ledger || await readLedger();
170
171
  // 已有快照的日子整日跳过 —— 人已经总结过,托底不该再吵。
171
172
  const haveSnapshot = await daysWithSnapshot(join(projectRoot, '.brain', 'sessions'));
172
173
  const fresh = [], changed = [], skipped = [], active = [];
174
+ let touched = false; // 是否补记过 stat(需要回写账本,否则下次又得读全文)
173
175
 
174
176
  for (const name of names.sort()) {
175
177
  const full = join(dir, name);
@@ -177,25 +179,60 @@ export async function scanProject(projectRoot, { ledger, now = Date.now() } = {}
177
179
  try { st = await fs.stat(full); } catch { continue; }
178
180
  if (now - st.mtimeMs < ACTIVE_MS) { active.push({ name, reason: '会话仍在写' }); continue; }
179
181
 
180
- const md5 = await md5Of(full);
181
- if (!md5) continue;
182
+ const rec = book.files[name];
183
+
184
+ // ── 便宜的预筛:文件大小 + mtime 都没变 → 内容必然没变 → 跳过,不读全文。
185
+ // 为何要它(2026-11-01 实测):全读算 md5 在 63 个文件 / 55MB 上是 74ms,
186
+ // 而只读 stat 是 0ms(快 74 倍)。这条路径会被 abs log 每次收尾调用,
187
+ // 且文件数只增不减 —— 不预筛就是「读取侧随规模增长」。
188
+ // 漏判风险为零:改内容必动 mtime,内容变了而 size 与 mtime 都不变不可能发生。
189
+ if (rec && rec.size === st.size && rec.mtime === st.mtimeMs && rec.md5) {
190
+ skipped.push({ name, reason: 'size+mtime 未变' });
191
+ continue;
192
+ }
193
+
194
+ // 已有快照的日子,先看账本里记的 day —— 不必读文件就能判。
195
+ // 实测(2026-11-01):63 个文件里有 41 个属于「已有快照」的日子,
196
+ // 原来要先 digestSession 读全文才知道日期,等于白读 41 次。账本里有 day 时先拦。
197
+ if (rec && rec.day && haveSnapshot.has(rec.day)) {
198
+ skipped.push({ name, reason: `${rec.day} 已有快照` });
199
+ continue;
200
+ }
201
+
182
202
  const d = await digestSession(full);
183
203
  if (!d || !d.users.length) { skipped.push({ name, reason: '无真实用户输入' }); continue; }
184
204
  if (d.day && haveSnapshot.has(d.day)) { skipped.push({ name, reason: `${d.day} 已有快照` }); continue; }
185
205
 
186
- const rec = book.files[name];
187
- if (rec && rec.md5 === md5) { skipped.push({ name, reason: 'md5 一致' }); continue; }
206
+ // 到这里才需要 md5(要么文件变了,要么账本里没有)。
207
+ const md5 = await md5Of(full);
208
+ if (!md5) continue;
209
+ if (rec && rec.md5 === md5) {
210
+ // size/mtime 变了但内容没变(如 touch、备份还原)→ 补记新的 stat,下次走预筛
211
+ book.files[name] = { ...rec, size: st.size, mtime: st.mtimeMs };
212
+ touched = true;
213
+ skipped.push({ name, reason: 'md5 一致(仅 stat 变化)' });
214
+ continue;
215
+ }
188
216
 
189
- const item = { name, md5, day: d.day, digest: d, sizeKB: Math.round(st.size / 1024) };
217
+ const item = { name, md5, day: d.day, digest: d, sizeKB: Math.round(st.size / 1024), size: st.size, mtime: st.mtimeMs };
190
218
  if (rec) changed.push({ ...item, prevMd5: rec.md5 }); else fresh.push(item);
191
219
  }
220
+ // 补记的 stat 要落盘 —— 只改内存等于每次重读全文,预筛白做。
221
+ // 只在自己读的账本上回写:调用方传了 ledger 时,回写时机由它决定
222
+ // (cmdSummon 要等 --mark 才写,免得扫描顺手改了用户的账本)。
223
+ if (touched && ownBook) await writeLedger(book);
224
+
192
225
  return { fresh, changed, skipped, active, dir };
193
226
  }
194
227
 
195
228
  /** 记一笔:这些会话已提取(不管人是否落盘,账本只记「提过了」)。 */
229
+ /** 记一笔:这些会话已提取。同时存 size+mtime —— 下次扫描靠它做便宜预筛,避免全读。 */
196
230
  export function markSummarized(ledger, items, when = new Date().toISOString()) {
197
231
  for (const it of items) {
198
- ledger.files[it.name] = { md5: it.md5, day: it.day, summarizedAt: when };
232
+ ledger.files[it.name] = {
233
+ md5: it.md5, day: it.day, summarizedAt: when,
234
+ size: it.size, mtime: it.mtime,
235
+ };
199
236
  }
200
237
  return ledger;
201
238
  }
@@ -234,3 +271,33 @@ export function renderScan(result, { full = false, limit = 15 } = {}) {
234
271
  }
235
272
  return out.join('\n');
236
273
  }
274
+
275
+ /** 收尾时顺手列出未总结/有更新的会话(给人看的尾巴)。
276
+ *
277
+ * 为何放在这里而不是各调用方(2026-11-02):abs log 与 abs capture 都要用,
278
+ * 两处各写一份必然漂移。失败一律吞掉 —— 记流水/记经验不能因为扫会话而失败。
279
+ *
280
+ * @param {string} root 项目根
281
+ * @param {object} opts { limit } 最多列几场
282
+ * @returns {Promise<string>} 无待总结时返回空串(不占输出)
283
+ */
284
+ export async function summonTail(root, { limit = 8 } = {}) {
285
+ try {
286
+ const r = await scanProject(root);
287
+ const pending = (r.fresh?.length || 0) + (r.changed?.length || 0);
288
+ if (!pending) return '';
289
+ const lines = ['', `--- 本项目有 ${pending} 场会话待总结 ---`];
290
+ for (const it of [...r.fresh, ...r.changed].slice(0, limit)) {
291
+ const tag = it.prevMd5 ? '(有更新)' : '';
292
+ lines.push(` ${it.day} ${it.sizeKB}KB${tag}`);
293
+ for (const u of (it.digest?.users || []).slice(0, 2)) {
294
+ lines.push(` · ${u.replace(/\s+/g, ' ').trim().slice(0, 70)}`);
295
+ }
296
+ }
297
+ if (pending > limit) lines.push(` …另有 ${pending - limit} 场(abs summon 看全量)`);
298
+ lines.push(' 落哪条由你判断: abs capture --layer N "…" | abs summon --mark 记账不再提');
299
+ return '\n' + lines.join('\n');
300
+ } catch {
301
+ return '';
302
+ }
303
+ }
package/src/install.js CHANGED
@@ -919,8 +919,11 @@ export function installSummary() {
919
919
  return HOSTS.map((h) => ` ${h.key.padEnd(12)} ${h.label}`).join('\n');
920
920
  }
921
921
 
922
- export async function runInstall({ agent, mcp = true, skill = true, yes = false } = {}) {
923
- const targets = agent ? [agent] : await pickAgents(yes);
922
+ export async function runInstall({ agent, all = false, mcp = true, skill = true, yes = false } = {}) {
923
+ // all=true 是显式全选的官方入口。
924
+ // 为何要它(2026-11-02):默认改成只装 pi 后,「装到所有宿主」这个能力
925
+ // 必须有明确表达方式 —— 否则测试只能靠 --yes 的旧语义,行为一变就全挂。
926
+ const targets = agent ? [agent] : (all ? Object.keys(INSTALLERS) : await pickAgents(yes));
924
927
  const failed = [];
925
928
  for (const key of targets) {
926
929
  const inst = INSTALLERS[key];
@@ -976,17 +979,28 @@ export async function runUninstall({ agent } = {}) {
976
979
  }
977
980
 
978
981
  // 交互式多选(无 TTY 时回退为全部)
982
+ /** 默认装的宿主。
983
+ *
984
+ * 2026-11-02 用户定:默认只装 pi,其余宿主 opt-in。
985
+ * 理由:装到不用的宿主上会往那边的配置文件里塞 hook/MCP 条目 ——
986
+ * 用户没在用 Claude Code / Codex / OpenCode 时,那是纯污染。
987
+ * 要装别的:abs install --agent claude-code(可逐个装)。
988
+ */
989
+ export const DEFAULT_AGENT = 'pi';
990
+
979
991
  async function pickAgents(yes = false) {
980
- // --yes / 非 TTY: 直接全选,绝不弹交互。
992
+ // --yes / 非 TTY: 装默认宿主(pi),绝不弹交互。
981
993
  // 坑: 曾经只看 isTTY,--yes 被收下却从不使用 —— 在 TTY 里跑
982
994
  // abs install --yes(自动化/脚本)仍会弹提示并挂起等输入。
983
- if (yes || !process.stdin.isTTY) return Object.keys(INSTALLERS);
995
+ if (yes || !process.stdin.isTTY) return [DEFAULT_AGENT];
984
996
  const readline = await import('node:readline/promises');
985
997
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
986
- console.log('选择要安装的智能体 (逗号分隔, 回车=全部):');
998
+ console.log(`选择要安装的智能体 (逗号分隔, 回车=只装 ${DEFAULT_AGENT}):`);
987
999
  console.log(installSummary());
988
1000
  const ans = (await rl.question('> ')).trim();
989
1001
  rl.close();
990
- if (!ans) return Object.keys(INSTALLERS);
1002
+ if (!ans) return [DEFAULT_AGENT];
1003
+ // all / * 作为显式全选的逃生门,避免把「装全部」的能力彻底拿掉
1004
+ if (ans === 'all' || ans === '*') return Object.keys(INSTALLERS);
991
1005
  return ans.split(',').map((s) => s.trim()).filter(Boolean);
992
1006
  }
package/src/note.js CHANGED
@@ -11,6 +11,7 @@ import { today, localStamp, LOG_KINDS } from './todo.js';
11
11
  import { editFile, SKIP } from './lock.js';
12
12
  import { impactOf } from './codegraph.js';
13
13
  import { clip, slugOf } from './text.js';
14
+ import { summonTail } from './brain/summon.js';
14
15
 
15
16
 
16
17
  /** 惰性调 autoIndex —— 断循环 import:note.js ← capture.js ← db-cmd.js ← note.js。
@@ -102,7 +103,7 @@ export async function cmdNote({ dir, text, tags, when, impact, type }) {
102
103
  // index Sources 区登记(锁内幂等:别页已登记则跳过,防并发重复) + log 一行
103
104
  const slug = file.replace(/\.md$/, '');
104
105
  await registerInIndex(root, 'Sources', slug, heading);
105
- await cmdLog({ dir: root, title: clean, kind: 'note' });
106
+ await cmdLog({ dir: root, title: clean, kind: 'note', summon: false });
106
107
  // 落盘即入库(库已存在时)。失败不阻断 —— 见 autoIndex 的边界说明。
107
108
  await autoIndexLazy();
108
109
  return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
@@ -260,7 +261,7 @@ export async function registerInIndex(root, section, slug, desc) {
260
261
  });
261
262
  }
262
263
  // ---------- log: 追加工作成果沉淀摘要(用户/AI 主动 abs log "..." 记, 不收工具动作流水) ----------
263
- export async function cmdLog({ dir, title, kind = 'dev' }) {
264
+ export async function cmdLog({ dir, title, kind = 'dev', summon = true }) {
264
265
  const root = await requireBrain(dir || process.cwd());
265
266
  const who = await requireUser(); // 写操作守卫
266
267
  // ★ kind 必须是枚举值(2026-10-05 加):此前无校验,传什么写什么 ——
@@ -294,6 +295,16 @@ export async function cmdLog({ dir, title, kind = 'dev' }) {
294
295
  return { text: lines.join('\n') };
295
296
  });
296
297
  // 重复时不报「✓ 已写入」—— 那是在说假话(用户会以为多了一条)。
297
- if (res === SKIP) return `• 同一分钟已有同一内容,已跳过 → ${p}\n ${line}`;
298
- return `✓ log → ${p}\n ${line}`;
299
- }
298
+ const head = res === SKIP
299
+ ? `• 同一分钟已有同一内容,已跳过 → ${p}\n ${line}`
300
+ : `✓ log → ${p}\n ${line}`;
301
+
302
+ // 落盘顺手深读本项目下的会话:没总结过的、有更新的,一并提出来。
303
+ // 为何挂在 log 上(2026-11-01 用户定):`abs log` 是收尾动作本身,
304
+ // 人在这里才愿意看「还有哪些会话没总结」;平时他不会主动跑 summon。
305
+ // 只给线索不写盘 —— 落哪一条仍是人的判断(见 summon.js 的边界说明)。
306
+ // summon=false 供 note/concept 内部调用时关闭:那些也是写 log,但不是收尾场景。
307
+ const tail = summon ? await summonTail(root) : '';
308
+ return head + tail;
309
+ }
310
+