@fanchao8609/agent_brain_sync 1.3.1 → 1.4.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/README.md CHANGED
@@ -72,6 +72,7 @@ abs log "完成 X" # 记一行流水;abs log 无参 =
72
72
  abs query <词> # 检索图谱(多词 OR)
73
73
  abs status # 当前项目 + 图谱概要
74
74
  abs lint # 体检:死链/孤岛/超尺寸/堆积
75
+ abs todo archive # 归档 Done 区旧日期组(默认留近 3 天)
75
76
  ```
76
77
 
77
78
  > `abs todo start` 与 `abs todo add` 等价(都登记任务)。
package/bin/abs.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // bin/abs.js — abs CLI 入口。
3
3
  // abs <cmd> [args]
4
4
  // 命令: init / board / status / load / task / install / uninstall / help
5
- import { cmdInit, cmdBoard, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdTeardownCheck } from '../src/store.js';
5
+ import { cmdInit, cmdBoard, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdShow, cmdRepair, cmdWrapup, cmdTeardownCheck, cmdTodoArchive } from '../src/store.js';
6
6
  import { runInstall, runUninstall, installSummary } from '../src/install.js';
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { join, dirname, resolve } from 'node:path';
@@ -70,6 +70,8 @@ function parseArgv(args) {
70
70
  else if (a === '--section') { o.section = args[++i]; }
71
71
  else if (a === '--note') { o.note = args[++i]; }
72
72
  else if (a === '--payload') { o.payload = args[++i]; }
73
+ else if (a === '--keep-days') { o.keepDays = args[++i]; }
74
+ else if (a === '--dry-run') { o.dryRun = true; }
73
75
  else if (a === '--yes') { o.yes = true; }
74
76
  else if (a === '--repair') { o.repair = true; }
75
77
  else if (a === '--no-mcp') { o.mcp = false; }
@@ -99,6 +101,9 @@ const usage = `abs — agent-brain-sync 记忆工具
99
101
 
100
102
  维护:
101
103
  abs query <词1> [词2 …] 检索 .brain/ 知识页 (多词 OR)
104
+ abs todo archive [--keep-days N] [--dry-run]
105
+ 归档 Done 区旧日期组 → sessions/<日期>-todo归档.md
106
+ (默认保留近 3 天; 任一天有未完成则整天不归档)
102
107
  abs lint 图谱体检 (死链/孤岛/超尺寸/堆积)
103
108
  abs init [--repair] 建 .brain/ 图谱; 结构不完整时报明细, --repair 只补缺不覆盖
104
109
  abs install [--agent <宿主>] 安装 MCP+hook+skill (宿主: claude-code/codex/opencode/pi)
@@ -181,10 +186,17 @@ async function main() {
181
186
  break;
182
187
  }
183
188
  const action = TODO_ACTIONS[sub];
189
+ // 归档:Done 区迁出旧日期组(非任务子命令,单独处理)
190
+ if (sub === 'archive') {
191
+ rejectExtra(rest2, 'abs todo archive [--keep-days N] [--dry-run]');
192
+ const a = { dir: opts.dir, keepDays: opts.keepDays, dryRun: opts.dryRun };
193
+ console.log(await cmdTodoArchive(a));
194
+ break;
195
+ }
184
196
  if (!action) {
185
197
  throw new Error(
186
198
  `✗ 未知子命令 "${sub}"\n` +
187
- ` 可用: add / start / note / blocked / done\n` +
199
+ ` 可用: add / start / note / blocked / done / archive\n` +
188
200
  ` 看板: abs todo(不带参数)\n` +
189
201
  ` 登记任务: abs todo add <id> --note "做什么"`,
190
202
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/skill/SKILL.md CHANGED
@@ -115,6 +115,12 @@ monorepo 若多个子包各自独立交付,可各建一份 `.brain/`;横向
115
115
  | 碰壁/阻塞 | `abs todo blocked <id> --note "卡点原因"`(移 Blocked) |
116
116
  | 被打断/改向/干到一半停 | `abs todo note <id> --note "改到哪个文件/到哪步"`(补 ↳ 断点 行) |
117
117
 
118
+ **Done 区会自动收口**:会话结束时 hook 调 `abs wrapup`,顺手把「超过 3 天 且 整天都已完成」
119
+ 的日期组迁到 `.brain/sessions/<日期>-todo归档.md`,并在 Done 区尾部留一行
120
+ `### 归档` → `- [[<日期>-todo归档]] 完成任务 N 条`。
121
+ **任一天只要还有未完成任务(`- [ ]`),整天都不归档** —— 不会把半成品扫走。
122
+ 手动跑:`abs todo archive [--keep-days N] [--dry-run]`。
123
+
118
124
  > `abs todo start` 与 `abs todo add` 等价(老写法仍可用)。
119
125
  > 旧版 `abs task ...` / `abs board` 已改名,会报错并提示新写法。
120
126
  > 只读命令(`todo`/`status`/`lint`/`load`/`index`)遇多余参数会报错 —— 不再静默吞掉。
package/src/install.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // - OpenCode / Pi: 官方只吃 TS plugin/extension(无 shell-hook 配置) → 初版给出手工指引。
7
7
  // 纪律: 幂等(重复安装=更新)、原子写(tmp+rename)、卸载只删自己装的、写入前备份。
8
8
  import { promises as fs } from 'node:fs';
9
+ import * as fsSync from 'node:fs';
9
10
  import { homedir } from 'node:os';
10
11
  import { join, dirname, resolve } from 'node:path';
11
12
  import { fileURLToPath } from 'node:url';
@@ -15,6 +16,31 @@ const ABS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
15
16
  const HOOK_TEMPLATE = join(ABS_DIR, 'hooks', 'event.sh');
16
17
  const SKILL_SOURCE = join(ABS_DIR, 'skill', 'SKILL.md');
17
18
 
19
+ /**
20
+ * 写进宿主 MCP 配置的 mcp.js 路径。
21
+ * 坑: 直接用 ABS_DIR 是不稳定的 —— ABS_DIR = "install.js 自己住哪", 从仓库跑
22
+ * `abs install` 就把仓库路径写进宿主配置。之后若卸载/移动全局包, 该路径直接失效;
23
+ * 而 Co​dex 分支又"已存在即跳过", 一旦写错永不修正。
24
+ * 现在优先解析本包的稳定安装位置(全局 node_modules),解析不到才回退 ABS_DIR。
25
+ */
26
+ function mcpEntryPath() {
27
+ // 本包名(package.json),用于反查全局安装位置
28
+ let pkgName = '@fanchao8609/agent_brain_sync';
29
+ try {
30
+ pkgName = JSON.parse(fsSync.readFileSync(join(ABS_DIR, 'package.json'), 'utf8')).name || pkgName;
31
+ } catch { /* 保持默认 */ }
32
+ const candidates = [
33
+ // npm 全局根(最常见)
34
+ join(dirname(process.execPath), '..', 'lib', 'node_modules', pkgName, 'bin', 'mcp.js'),
35
+ join(process.env.npm_config_prefix || '', 'lib', 'node_modules', pkgName, 'bin', 'mcp.js'),
36
+ ];
37
+ for (const c of candidates) {
38
+ try { if (fsSync.existsSync(c)) return c; } catch { /* 试下一个 */ }
39
+ }
40
+ // 回退:仓库/本地安装形态
41
+ return join(ABS_DIR, 'bin', 'mcp.js');
42
+ }
43
+
18
44
  const MARK = '// abs-managed (agent-brain-sync)'; // TS plugin 标记
19
45
  const JSON_MARK_KEY = 'abs-managed'; // JSON 内我们的命名空间
20
46
 
@@ -103,7 +129,7 @@ async function installClaudeCode({ withMcp, withSkill, log }) {
103
129
  settings.mcpServers = settings.mcpServers || {};
104
130
  settings.mcpServers['abs'] = {
105
131
  command: process.execPath,
106
- args: [join(ABS_DIR, 'bin', 'mcp.js')],
132
+ args: [mcpEntryPath()],
107
133
  };
108
134
  await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
109
135
  steps.push(`✓ MCP → settings.json mcpServers.abs (stdio)`);
@@ -183,12 +209,21 @@ async function installCodex({ withMcp, withSkill, log }) {
183
209
  let text = '';
184
210
  try { text = await fs.readFile(mcpP, 'utf8'); } catch {}
185
211
  if (!text.includes('[mcp_servers.abs]')) {
186
- const block = `\n[mcp_servers.abs]\ncommand = "${process.execPath}"\nargs = ["${join(ABS_DIR, 'bin', 'mcp.js')}"]\n`;
212
+ const block = `\n[mcp_servers.abs]\ncommand = "${process.execPath}"\nargs = ["${mcpEntryPath()}"]\n`;
187
213
  await backup(mcpP);
188
214
  await atomicWrite(mcpP, text.replace(/\s*$/, '') + '\n' + block);
189
215
  steps.push(`✓ MCP → ${mcpP} [mcp_servers.abs]`);
190
216
  } else {
191
- steps.push(`• MCP → ${mcpP} 已存在, 跳过`);
217
+ // 已存在也要校对路径: 旧版可能写入了仓库路径(不稳定) 或全局包已迁移。
218
+ const want = mcpEntryPath();
219
+ const re = /(\[mcp_servers\.abs\][^\[]*?args\s*=\s*\[)"[^"]*"(\])/s;
220
+ if (re.test(text) && !text.includes(`"${want}"`)) {
221
+ await backup(mcpP);
222
+ await atomicWrite(mcpP, text.replace(re, `$1"${want}"$2`));
223
+ steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
224
+ } else {
225
+ steps.push(`• MCP → ${mcpP} 已存在且路径正确, 跳过`);
226
+ }
192
227
  }
193
228
  }
194
229
  if (withSkill) {
@@ -526,7 +561,7 @@ async function installOpenCode({ withMcp, withSkill, log }) {
526
561
  cfg.mcp = cfg.mcp || {};
527
562
  cfg.mcp['abs'] = {
528
563
  type: 'local',
529
- command: [process.execPath, join(ABS_DIR, 'bin', 'mcp.js')],
564
+ command: [process.execPath, mcpEntryPath()],
530
565
  };
531
566
  await backup(mcpP);
532
567
  await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
package/src/store.js CHANGED
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
4
4
  import { join, resolve, dirname } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { requireBrain, findBrainRoot, brainPath } from './index.js';
7
- import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine } from './todo.js';
7
+ import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody } from './todo.js';
8
8
  import { editFile, SKIP } from './lock.js';
9
9
  import { appendWrapup, strandedFor, wrapupLogPath } from './wrapup.js';
10
10
 
@@ -190,7 +190,82 @@ export async function cmdLoad({ dir }) {
190
190
  // ---------- wrapup: 滞留快照(B)/ load 内展示由 cmdLoad 完成(A) ----------
191
191
  export async function cmdWrapup({ dir }) {
192
192
  const root = await requireBrain(dir || process.cwd());
193
- return appendWrapup(root);
193
+ const snap = await appendWrapup(root);
194
+ // 顺手做 Done 归档。**不引入 cron/定时器**:Stop hook 已经会在会话结束时调
195
+ // `abs wrapup`,直接复用这个触发点(零新基础设施、零新失败面)。
196
+ // 只对「超过保留天数 且 整天都已完成」的日期组动手,平时无动作。
197
+ // 本函数 stdout 会被 hook 追写到 ~/.abs/log/hooks.log → 归档动作自动留痕。
198
+ let arch = '';
199
+ try {
200
+ const a = await cmdTodoArchive({ dir: root, keepDays: 3 });
201
+ if (a.startsWith('✓')) arch = '\n' + a;
202
+ } catch { /* 归档失败不影响快照本身 */ }
203
+ return snap + arch;
204
+ }
205
+
206
+ /**
207
+ * `abs todo archive` —— 把 Done 区里「超过保留天数 且 整天都已完成」的日期组迁到归档页。
208
+ * 规则见 `archiveDoneInText`(① 只留近 N 天 ② 任一天有未完成则整天不归档 ③ 归档成一个文件
209
+ * 并在 Done 区尾部 `### 归档` 记 `- [[slug]] 完成任务 N 条`)。
210
+ * 幂等:同日重跑 = 追写到同一归档页 + 就地更新标记行。
211
+ */
212
+ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {}) {
213
+ let root;
214
+ try { root = await requireBrain(dir || process.cwd()); } catch { return '未找到 .brain/ 图谱。先在项目根运行: abs init'; }
215
+ const days = Math.max(1, Number(keepDays) || 3);
216
+ const todoP = brainPath(root, 'todo.md');
217
+ const raw = await fs.readFile(todoP, 'utf8').catch(() => '');
218
+ if (!raw.trim()) return '(todo.md 为空)';
219
+
220
+ const plan = archiveDoneInText(raw, { keepDays: days, from: today() });
221
+ const why = plan.skipped.length
222
+ ? `\n 跳过: ${plan.skipped.map((s) => `${s.date}(${s.reason})`).join(';')}`
223
+ : '';
224
+ if (!plan.archived.length) return `(无可归档:保留近 ${days} 天)${why}`;
225
+ const brief = plan.archived.map((g) => `${g.date}(${g.count})`).join(' ');
226
+ if (dryRun) {
227
+ return `[dry-run] 将归档 ${plan.archived.length} 天 / ${plan.count} 条(每天一个文件)\n ${brief}${why}`;
228
+ }
229
+
230
+ // 1) 归档页:**每天一个文件**,文件名用被归档那天的日期(便于按天回溯)。
231
+ // 同一天再次归档(罕见:该日组已被移走,除非有人重新补当天任务)则追写正文。
232
+ const sessDir = brainPath(root, 'sessions');
233
+ for (const g of plan.archived) {
234
+ const pageP = join(sessDir, `${g.slug}.md`);
235
+ let page = null;
236
+ try { page = await fs.readFile(pageP, 'utf8'); } catch { /* 首次 */ }
237
+ const nextPage = page == null
238
+ ? renderArchivePage({ group: g })
239
+ : page.replace(/\s*$/, '') + '\n\n' + renderArchiveBody([g]) + '\n';
240
+ const tmp = join(sessDir, `.${g.slug}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
241
+ await fs.writeFile(tmp, nextPage, 'utf8');
242
+ await fs.rename(tmp, pageP);
243
+ }
244
+
245
+ // 2) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖)
246
+ await editFile(todoP, (cur) => {
247
+ const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today() });
248
+ return p2.archived.length ? { text: p2.text } : SKIP;
249
+ });
250
+
251
+ // 3) index 登记(每个日期页一行,幂等)
252
+ const iP = brainPath(root, 'index.md');
253
+ await editFile(iP, (index) => {
254
+ if (!index) return SKIP;
255
+ const missing = plan.archived.filter((g) => !index.includes(`[[${g.slug}]]`));
256
+ if (!missing.length) return SKIP;
257
+ const sIdx = index.indexOf('## Sources');
258
+ if (sIdx === -1) return SKIP;
259
+ const after = index.indexOf('\n## ', sIdx + 1);
260
+ const add = missing.map((g) => `- [[${g.slug}]] — Todo 归档:${g.date},共 ${g.count} 条已完成任务`).join('\n');
261
+ const next = after === -1
262
+ ? `${index.replace(/\s*$/, '')}\n${add}\n`
263
+ : index.slice(0, after) + `\n${add}` + index.slice(after);
264
+ return { text: next };
265
+ });
266
+
267
+ const files = plan.archived.map((g) => `sessions/${g.slug}.md`).join('\n ');
268
+ return `✓ 已归档 ${plan.archived.length} 天 / ${plan.count} 条\n ${brief}\n → ${files}${why}`;
194
269
  }
195
270
 
196
271
  /**
@@ -502,8 +577,10 @@ export async function cmdLint({ dir }) {
502
577
  if (/slug|Name|name|Date|页面名$/.test(ln)) issues.push(`TEMPLATE-LINK: ${pg.rel} -> [[${ln}]]`);
503
578
  if (!names.has(ln)) issues.push(`DEAD-LINK: ${pg.rel} -> [[${ln}]]`);
504
579
  }
505
- // ORPHAN: sources/ 暂存页豁免(暂存线索天然孤立,提炼成 concept 前不强制挂链)
506
- if (pg.dir !== 'sources' && !pg.links.length && !linkedNames.has(pg.slug)) {
580
+ // ORPHAN: sources/ 暂存页与 todo 归档页豁免。前者是暂存线索(提炼成 concept 前天然孤立),
581
+ // 后者是历史数据倾倒(已登记在 index.md,就是图谱入口,无需再制造双链)。
582
+ const isTerminal = pg.dir === 'sources' || /todo归档$/.test(pg.slug);
583
+ if (!isTerminal && !pg.links.length && !linkedNames.has(pg.slug)) {
507
584
  issues.push(`ORPHAN-PAGE: ${pg.rel} (no links out, no links in)`);
508
585
  }
509
586
  if (/知识冲突/.test(pg.body) && /status: draft/.test(pg.frontmatter)) {
@@ -529,6 +606,22 @@ export async function cmdLint({ dir }) {
529
606
  const nsrc = pages.filter((p) => p.dir === 'sources').length;
530
607
  if (nsrc > 10) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > 10; 提炼归档旧 source`);
531
608
 
609
+ // Done 区堆积:它无上限增长,且 `abs todo`/`abs load` 每次全量打印 → 越积越难用。
610
+ // (与 hooks.log/wrapup.log 同类问题;那两处有轮转,这里靠 `abs todo archive`。)
611
+ // 坑: 曾经写成 brainPath(vault, 'todo.md'),而 vault 已经是 .brain 目录
612
+ // → 拼出 .brain/.brain/todo.md(ENOENT),又被外层 try/catch 吞掉
613
+ // → 检查静默失效(lint 永远 0 problem)。故这里不用 try/catch 吞错,
614
+ // 只对 ENOENT 做缺省,写错路径这类编程错会直接暴露。
615
+ const todoTxt = await fs.readFile(join(vault, 'todo.md'), 'utf8').catch(() => '');
616
+ const di = todoTxt.split('\n').findIndex((l) => l.startsWith('## Done'));
617
+ if (di !== -1) {
618
+ const doneLines = todoTxt.split('\n').slice(di + 1).filter((l) => l.trim()).length;
619
+ const DONE_MAX = 60;
620
+ if (doneLines > DONE_MAX) {
621
+ issues.push(`DONE-PILED-UP: Done 区 ${doneLines} 行 > ${DONE_MAX}; 跑 \`abs todo archive\` 迁出旧日期组`);
622
+ }
623
+ }
624
+
532
625
  const n = issues.length;
533
626
  return [
534
627
  ...(issues.length ? issues : []),
package/src/todo.js CHANGED
@@ -138,6 +138,153 @@ function renderDoneGroups(units) {
138
138
  return out;
139
139
  }
140
140
 
141
+ /** 归档标记区标题。它在 Done 区内部、日期分组之后,形如:
142
+ * ### 归档
143
+ * - [[2026-09-10-todo归档]] 完成任务 10 条
144
+ * 这区不是任务行,不能被 parseDoneUnits 吃挂,否则 markDone 重建 Done 时会把它丢掉。
145
+ * 故先切出去当"不透明区域"原样保留。 */
146
+ const ARCHIVE_HEADING = '### 归档';
147
+
148
+ /** 把 Done 主体行切成 { groupLines, archiveLines }:把 `### 归档` 区当"不透明块"原样保留。
149
+ * 注意不能简单"从标题切到文件尾" —— 否则一旦有日期组落在归档区之后(手改/旧数据),
150
+ * 它会被当不透明内容除在分组之外,永远不参与归档。故只取到下一个 ###/## 标题为止。 */
151
+ function splitDoneBody(bodyLines) {
152
+ const ai = bodyLines.findIndex((l) => l.trim() === ARCHIVE_HEADING);
153
+ if (ai === -1) return { groupLines: bodyLines, archiveLines: [] };
154
+ let end = ai + 1;
155
+ while (end < bodyLines.length && !/^#{2,3} \S/.test(bodyLines[end].trim())) end++;
156
+ return {
157
+ groupLines: [...bodyLines.slice(0, ai), ...bodyLines.slice(end)],
158
+ archiveLines: bodyLines.slice(ai, end),
159
+ };
160
+ }
161
+
162
+ /** 合并归档标记行:**每天一行** `- [[<日期>-todo归档]] 完成任务 N 条`。
163
+ * 计数按 slug **累计**(同一天分两批归档时叠加,否则标记行与归档页实际内容不符);
164
+ * 已存在的其他天的标记行原样保留。返回含标题的行数组。 */
165
+ function mergeArchiveLines(existing, entries) {
166
+ const out = existing.length ? [...existing] : [ARCHIVE_HEADING];
167
+ while (out.length && !out[out.length - 1].trim()) out.pop();
168
+ for (const { slug, count } of entries) {
169
+ const i = out.findIndex((l) => l.includes(`[[${slug}]]`));
170
+ const prev = i !== -1 ? Number((out[i].match(/完成任务 (\d+) 条/) || [])[1] || 0) : 0;
171
+ const line = `- [[${slug}]] 完成任务 ${prev + count} 条`;
172
+ if (i !== -1) out[i] = line;
173
+ else out.push(line);
174
+ }
175
+ out.push('');
176
+ // 标记行按日期倒序(与 Done 日期组同风格:新的在上)。
177
+ // 否则顺序 = 归档先后,多次归档后读起来是乱的(如 08/09/07)。
178
+ const dateOf = (l) => (String(l).match(/(\d{4}-\d{2}-\d{2})/) || [''])[0];
179
+ const body = out.slice(1).filter((l) => l.trim());
180
+ body.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
181
+ return [out[0], ...body, ''];
182
+ }
183
+
184
+ /** 本地日期减 n 天(YYYY-MM-DD)。纯字符串入出,避免时区漂移。 */
185
+ function daysAgo(n, from) {
186
+ const [y, m, d] = String(from).split('-').map(Number);
187
+ const t = new Date(y, m - 1, d);
188
+ t.setDate(t.getDate() - n);
189
+ const pad = (x) => String(x).padStart(2, '0');
190
+ return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
191
+ }
192
+
193
+ /** 一行是否为未完成任务行(- [ ])。 */
194
+ function isUndoneLine(l) {
195
+ return /^\s*- \[ \]/.test(l);
196
+ }
197
+
198
+ /**
199
+ * 把 Done 区里"可归档"的日期组摘出来,并追回归档标记行。**纯函数**(不碰磁盘)。
200
+ * 规则(用户定):
201
+ * ① 只保留近 keepDays 天(含今天);更早的才归档。
202
+ * ② 某一天只要还有未完成(- [ ])任务,**整天都不归档**(不拆半天)。
203
+ * ③ 每**天**归一个文件,slug 为 `<日期>-todo归档`(由 slugFor 给),本函数只负责
204
+ * 从 todo 文本里移除 + 在 Done 区尾部的 `### 归档` 区**每天记一行**
205
+ * `- [[<日期>-todo归档]] 完成任务 N 条`。
206
+ * 无日期组(### (未标日期))无法判天数,**保守不归档**。
207
+ * @returns {{text:string, archived:{date:string,lines:string[],slug:string,count:number}[], skipped:{date:string,reason:string}[], count:number}}
208
+ */
209
+ export function archiveDoneInText(text, { keepDays = 3, from = today(), slugFor = (d) => `${d}-todo归档` } = {}) {
210
+ const lines = String(text || '').split('\n');
211
+ const di = lines.findIndex((l) => l.startsWith('## Done'));
212
+ if (di === -1) return { text, archived: [], skipped: [], count: 0 };
213
+ const head = lines.slice(0, di + 1);
214
+ const { groupLines, archiveLines } = splitDoneBody(lines.slice(di + 1));
215
+ const units = parseDoneUnits(groupLines);
216
+ if (!units.length) return { text, archived: [], skipped: [], count: 0 };
217
+
218
+ const keep = Math.max(1, Number(keepDays) || 3);
219
+ // 保留 cutoff..from 这 keep 天;比 cutoff 更早的才归档
220
+ const cutoff = daysAgo(keep - 1, from);
221
+
222
+ const byDate = new Map();
223
+ const undated = [];
224
+ for (const u of units) {
225
+ if (!u.date) { undated.push(u); continue; }
226
+ if (!byDate.has(u.date)) byDate.set(u.date, []);
227
+ byDate.get(u.date).push(u);
228
+ }
229
+
230
+ const archived = [];
231
+ const skipped = [];
232
+ const keepUnits = [...undated];
233
+ if (undated.length) skipped.push({ date: '(未标日期)', reason: '无完成日期,无法判断天数(保守不归档)' });
234
+
235
+ for (const date of [...byDate.keys()].sort()) {
236
+ const us = byDate.get(date);
237
+ if (date >= cutoff) { keepUnits.push(...us); continue; } // ① 近 N 天:保留
238
+ const undone = us.filter((u) => isUndoneLine(u.lines[0]));
239
+ if (undone.length) { // ② 有未完成:整天不归档
240
+ skipped.push({ date, reason: `有 ${undone.length} 条未完成,整天不归档` });
241
+ keepUnits.push(...us);
242
+ continue;
243
+ }
244
+ const gLines = us.flatMap((u) => u.lines); // ③ 可归档
245
+ archived.push({
246
+ date,
247
+ lines: gLines,
248
+ slug: slugFor(date),
249
+ count: gLines.filter((l) => /^\s*- \[x\]/.test(l)).length,
250
+ });
251
+ }
252
+
253
+ if (!archived.length) return { text, archived: [], skipped, count: 0 };
254
+
255
+ const count = archived.reduce((n, g) => n + g.count, 0);
256
+ const entries = archived.map((g) => ({ slug: g.slug, count: g.count }));
257
+ const rebuilt = [...head, ...renderDoneGroups(keepUnits), ...mergeArchiveLines(archiveLines, entries)];
258
+ return { text: rebuilt.join('\n').replace(/\n+$/, '\n'), archived, skipped, count };
259
+ }
260
+
261
+ /** 归档页正文(按日期分组,原文保留)。供首次建页与同日追写复用。 */
262
+ export function renderArchiveBody(groups) {
263
+ const out = [];
264
+ for (const g of groups) out.push(`### ${g.date}`, '', ...g.lines, '');
265
+ return out.join('\n').replace(/\n+$/, '\n');
266
+ }
267
+
268
+ /** 归档页全文(带 frontmatter)。**每天一个文件**,故只收一组。 */
269
+ export function renderArchivePage({ group }) {
270
+ const { date, lines } = group;
271
+ const n = lines.filter((l) => /^\s*- \[x\]/.test(l)).length;
272
+ const head = [
273
+ '---',
274
+ 'tags: [todo-archive, 历史]',
275
+ `updated: ${date}`,
276
+ 'status: reviewed',
277
+ '---',
278
+ '',
279
+ `# Todo 归档 — ${date}`,
280
+ '',
281
+ `> 从 \`.brain/todo.md\` 的 Done 区迁出(该区只保留近期)。本页含 ${date} 的 ${n} 条已完成任务。`,
282
+ '> 原文完整保留(含 `↳ 断点/卡点`),查"某任务当时做到哪"看这里。',
283
+ '',
284
+ ];
285
+ return head.join('\n') + '\n' + renderArchiveBody([group]) + '\n';
286
+ }
287
+
141
288
  /** 幂等:把 todo 全文里平铺的旧 Done 区按日期分组(新日期在前,未标日期归尾)。
142
289
  * 已是分组态(### date)则原样返回——惰性迁移用,避免每次读都动文件。 */
143
290
  export function groupDoneSection(text) {
@@ -147,9 +294,11 @@ export function groupDoneSection(text) {
147
294
  const body = lines.slice(di + 1);
148
295
  const first = body.find((l) => l.trim());
149
296
  if (first && /^### /.test(first.trim())) return text; // 已分组,幂等不动
150
- const units = parseDoneUnits(body);
297
+ const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
298
+ const units = parseDoneUnits(groupLines);
151
299
  if (!units.length) return text;
152
- return [...lines.slice(0, di + 1), ...renderDoneGroups(units)].join('\n').replace(/\n+$/, '\n');
300
+ return [...lines.slice(0, di + 1), ...renderDoneGroups(units), ...archiveLines]
301
+ .join('\n').replace(/\n+$/, '\n');
153
302
  }
154
303
 
155
304
  /** 把 moved 单元(已完成任务行+附属行)放入 Done 区并整体按日期分组重建。
@@ -165,9 +314,10 @@ export function insertDoneGrouped(text, movedLines) {
165
314
  }
166
315
  const head = lines.slice(0, di + 1);
167
316
  const body = lines.slice(di + 1);
317
+ const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
168
318
  // 新完成单元置前:同日期组内新在最上(renderDoneGroups 按 encounter 顺序保持,日期再倒序排)
169
- const units = [newUnit, ...parseDoneUnits(body)];
170
- return [...head, ...renderDoneGroups(units)].join('\n').replace(/\n+$/, '\n');
319
+ const units = [newUnit, ...parseDoneUnits(groupLines)];
320
+ return [...head, ...renderDoneGroups(units), ...archiveLines].join('\n').replace(/\n+$/, '\n');
171
321
  }
172
322
 
173
323
  // 占位/空任务行(老模板的 "(无)"/"无"/纯 - [ ])不迁移