@fanchao8609/agent_brain_sync 1.16.3 → 1.17.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/bin/abs.js +6 -3
- package/bin/mcp.js +8 -3
- package/hooks/abs.pi.ts +94 -13
- package/package.json +6 -1
- package/src/install.js +5 -9
- package/src/lock.js +1 -1
- package/src/note.js +2 -4
- package/src/page.js +1 -1
- package/src/relevant.js +41 -1
- package/src/store.js +55 -34
- package/src/todo/archive.js +320 -0
- package/src/todo/common.js +181 -0
- package/src/todo/spec.js +218 -0
- package/src/todo/structure.js +324 -0
- package/src/todo/tasks.js +167 -0
- package/src/todo/validate.js +204 -0
- package/src/todo.js +10 -1320
- package/src/wrapup.js +1 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// src/todo/archive.js — Done 结语契约与任务归档(分组、折叠、迁归档区)。
|
|
2
|
+
|
|
3
|
+
import { today, SEC, isTaskLine, isChildLine, daysAgo } from './common.js';
|
|
4
|
+
import { ARCHIVE_HEADING, ARCHIVE_SECTION } from './spec.js';
|
|
5
|
+
|
|
6
|
+
export function doneDateOf(line) {
|
|
7
|
+
const m = String(line).match(/\(完成\s*(\d{4}-\d{2}-\d{2})[^)]*\)/);
|
|
8
|
+
return m ? m[1] : '';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ---------- Done 结语契约 ----------
|
|
12
|
+
// 为什么需要: `[x]` 原本同时表示「真落地」「评估后不做」「仅设计过」三种完全不同的状态,
|
|
13
|
+
// 读的人(下一个会话/未来的自己)无法区分。实测翻车: 读 daemon 条的 [x] 当成已落地,
|
|
14
|
+
// 实际它跑通后被撤销、代码全删 —— 基于假记录得出「daemon 是过配项」的错误结论。
|
|
15
|
+
// 结论: 图谱的价值取决于「可被信任」,一条状态失真的记录比一百条冗长记录的危害大一个量级。
|
|
16
|
+
|
|
17
|
+
export function doneKindOf(line) {
|
|
18
|
+
const m = String(line).match(/【(落地|否决|仅方案)】/);
|
|
19
|
+
return m ? m[1] : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 追写结语到任务行:插在 `(完成 …)` 之前,幂等(已有则原位替换)。 */
|
|
23
|
+
|
|
24
|
+
export function withDoneKind(line, kind) {
|
|
25
|
+
if (!kind) return line;
|
|
26
|
+
const base = String(line).replace(/【(落地|否决|仅方案)】/g, '').replace(/\s{2,}/g, ' ').trimEnd();
|
|
27
|
+
const i = base.lastIndexOf(' (完成 ');
|
|
28
|
+
if (i === -1) return `${base} 【${kind}】`;
|
|
29
|
+
return `${base.slice(0, i)} 【${kind}】${base.slice(i)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 判断一行是否为任务行(- [x] / - [ ],允许缩进)。 */
|
|
33
|
+
|
|
34
|
+
function parseDoneUnits(bodyLines) {
|
|
35
|
+
const units = [];
|
|
36
|
+
let cur = null;
|
|
37
|
+
for (const l of bodyLines) {
|
|
38
|
+
if (/^### /.test(l.trim()) || l.trim() === '') { cur = null; continue; } // 分组标题/空行断开会话
|
|
39
|
+
if (isTaskLine(l)) {
|
|
40
|
+
cur = { date: doneDateOf(l), lines: [l] };
|
|
41
|
+
units.push(cur);
|
|
42
|
+
} else if (isChildLine(l) && cur) {
|
|
43
|
+
cur.lines.push(l); // 附属行挂到上一个任务
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return units;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 按 (完成 date) 分组 Done 任务单元并重建文本行:新日期在前,未标日期归尾组。
|
|
50
|
+
* 同组内保持输入顺序(幂等)。返回不含 `## Done` 标题的主体行。 */
|
|
51
|
+
|
|
52
|
+
function renderDoneGroups(units) {
|
|
53
|
+
const byDate = new Map();
|
|
54
|
+
const undated = [];
|
|
55
|
+
for (const u of units) {
|
|
56
|
+
if (!u.date) undated.push(u);
|
|
57
|
+
else {
|
|
58
|
+
if (!byDate.has(u.date)) byDate.set(u.date, []);
|
|
59
|
+
byDate.get(u.date).push(u);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const dates = [...byDate.keys()].sort().reverse(); // 新日期在前
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const d of dates) out.push(`### ${d}`, '', ...byDate.get(d).flatMap((u) => u.lines), '');
|
|
65
|
+
if (undated.length) out.push(`### ${SEC.undated}`, '', ...undated.flatMap((u) => u.lines), '');
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 把 Done 区从「全量行」折叠为「按日期计数」,供 `abs load` / `abs todo` 这类
|
|
70
|
+
* 给人(和 AI 上下文)看的视图用。
|
|
71
|
+
*
|
|
72
|
+
* 坑: 这两个命令此前直接 `todo.trim()` 全量打印,而 Done 区**无上限增长** ——
|
|
73
|
+
* 本仓库一处就占 load 输出的 68.8%(19.6KB/28.4KB)。长历史机器上直接把
|
|
74
|
+
* 上下文塞满(实报:「另一台机器 abs load 塞了 40%」)。
|
|
75
|
+
* 这与 hooks.log / wrapup.log 同类问题(那两处有轮转),故这里只给计数,
|
|
76
|
+
* 要看明细用 `abs todo` 加 `--full`,或直接看 todo.md / 归档页。
|
|
77
|
+
*
|
|
78
|
+
* `### 归档` 区(历史的「归档 N 条」标记行)体积小且是长期引用,原样保留。
|
|
79
|
+
* 返回 { text, doneCount };text 不含 `## Done` 标题行。 */
|
|
80
|
+
|
|
81
|
+
export function collapseDone(text) {
|
|
82
|
+
const lines = String(text).split('\n');
|
|
83
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
84
|
+
if (di === -1) return { text: String(text).trim(), doneCount: 0 };
|
|
85
|
+
const head = lines.slice(0, di);
|
|
86
|
+
const rest = lines.slice(di + 1);
|
|
87
|
+
const { groupLines, archiveLines } = splitDoneBody(rest);
|
|
88
|
+
const units = parseDoneUnits(groupLines);
|
|
89
|
+
// 按日期归组计数(新日期在前),未标日期的归尾
|
|
90
|
+
const byDate = new Map();
|
|
91
|
+
let undated = 0;
|
|
92
|
+
for (const u of units) {
|
|
93
|
+
if (!u.date) undated++;
|
|
94
|
+
else byDate.set(u.date, (byDate.get(u.date) || 0) + 1);
|
|
95
|
+
}
|
|
96
|
+
const counts = [...byDate.entries()]
|
|
97
|
+
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
|
|
98
|
+
.map(([d, n]) => `${d} ${n} 条`);
|
|
99
|
+
if (undated) counts.push(`未标日期 ${undated} 条`);
|
|
100
|
+
// 只列最近 MAX_DONE_DATE_LINES 个日期组:日期组本身也随历史增长(实测 300 条跨 28 天
|
|
101
|
+
// 会把 load 输出从 660B 拉到 1203B)。载入视图只需"最近做了多少",早期历史看归档页。
|
|
102
|
+
const MAX_DONE_DATE_LINES = 7;
|
|
103
|
+
const shown = counts.slice(0, MAX_DONE_DATE_LINES);
|
|
104
|
+
if (counts.length > MAX_DONE_DATE_LINES) {
|
|
105
|
+
const rest = counts.length - MAX_DONE_DATE_LINES;
|
|
106
|
+
shown.push(`… 另有 ${rest} 个更早日期组(abs todo --full 看全量)`);
|
|
107
|
+
}
|
|
108
|
+
const doneLines = units.length
|
|
109
|
+
? [`## Done(${units.length} 条,按日期折叠)`, ...shown.map((c) => `- ${c}`)]
|
|
110
|
+
: ['## Done(0 条)'];
|
|
111
|
+
const out = [...head, ...doneLines, '', ...archiveLines].join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
112
|
+
return { text: out, doneCount: units.length };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 归档标记区标题。它在 Done 区内部、日期分组之后,形如:
|
|
116
|
+
* ### Archived
|
|
117
|
+
* - [[2026-09-10-todo归档]] 完成任务 10 条
|
|
118
|
+
* 这区不是任务行,不能被 parseDoneUnits 吃挂,否则 markDone 重建 Done 时会把它丢掉。
|
|
119
|
+
* 故先切出去当"不透明区域"原样保留。 */
|
|
120
|
+
|
|
121
|
+
function splitDoneBody(bodyLines) {
|
|
122
|
+
const ai = bodyLines.findIndex((l) => l.trim() === ARCHIVE_HEADING);
|
|
123
|
+
if (ai === -1) return { groupLines: bodyLines, archiveLines: [] };
|
|
124
|
+
let end = ai + 1;
|
|
125
|
+
while (end < bodyLines.length && !/^#{2,3} \S/.test(bodyLines[end].trim())) end++;
|
|
126
|
+
return {
|
|
127
|
+
groupLines: [...bodyLines.slice(0, ai), ...bodyLines.slice(end)],
|
|
128
|
+
archiveLines: bodyLines.slice(ai, end),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 合并归档标记行:**每天一行** `- [[<日期>-todo归档]] 完成任务 N 条`。
|
|
133
|
+
* 计数按 slug **累计**(同一天分两批归档时叠加,否则标记行与归档页实际内容不符);
|
|
134
|
+
* 已存在的其他天的标记行原样保留。返回含标题的行数组。 */
|
|
135
|
+
|
|
136
|
+
function mergeArchiveLines(existing, entries) {
|
|
137
|
+
const out = existing.length ? [...existing] : [ARCHIVE_HEADING];
|
|
138
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
139
|
+
for (const { slug, count } of entries) {
|
|
140
|
+
const i = out.findIndex((l) => l.includes(`[[${slug}]]`));
|
|
141
|
+
const prev = i !== -1 ? Number((out[i].match(/完成任务 (\d+) 条/) || [])[1] || 0) : 0;
|
|
142
|
+
const line = `- [[${slug}]] 完成任务 ${prev + count} 条`;
|
|
143
|
+
if (i !== -1) out[i] = line;
|
|
144
|
+
else out.push(line);
|
|
145
|
+
}
|
|
146
|
+
out.push('');
|
|
147
|
+
// 标记行按日期倒序(与 Done 日期组同风格:新的在上)。
|
|
148
|
+
// 否则顺序 = 归档先后,多次归档后读起来是乱的(如 08/09/07)。
|
|
149
|
+
const dateOf = (l) => (String(l).match(/(\d{4}-\d{2}-\d{2})/) || [''])[0];
|
|
150
|
+
const body = out.slice(1).filter((l) => l.trim());
|
|
151
|
+
body.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
|
|
152
|
+
return [out[0], ...body, ''];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 本地日期减 n 天(YYYY-MM-DD)。纯字符串入出,避免时区漂移。 */
|
|
156
|
+
|
|
157
|
+
function isUndoneLine(l) {
|
|
158
|
+
return /^\s*- \[ \]/.test(l);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 把 Done 区里"可归档"的日期组摘出来,并追回归档标记行。**纯函数**(不碰磁盘)。
|
|
163
|
+
* 规则(用户定):
|
|
164
|
+
* ① 只保留近 keepDays 天(含今天);更早的才归档。
|
|
165
|
+
* ② 某一天只要还有未完成(- [ ])任务,**整天都不归档**(不拆半天)。
|
|
166
|
+
* ③ 每**天**一个文件,slug 为 `log-<日期>`(由 slugFor 给)—— 即**归进那天的会话快照**
|
|
167
|
+
* (同一天的东西放一处,别为归档另建文件)。本函数只负责从 todo 文本里移除
|
|
168
|
+
* + 在 Done 区尾部的 `### 归档` 区**每天记一行** `- [[log-<日期>]] 完成任务 N 条`。
|
|
169
|
+
* 无日期组(### (未标日期))无法判天数,**保守不归档**。
|
|
170
|
+
* @returns {{text:string, archived:{date:string,lines:string[],slug:string,count:number}[], skipped:{date:string,reason:string}[], count:number}}
|
|
171
|
+
*/
|
|
172
|
+
|
|
173
|
+
export function archiveDoneInText(text, { keepDays = 3, from = today(), maxLines = 0, slugFor = (d) => `log-${d}` } = {}) {
|
|
174
|
+
const lines = String(text || '').split('\n');
|
|
175
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
176
|
+
if (di === -1) return { text, archived: [], skipped: [], count: 0 };
|
|
177
|
+
const head = lines.slice(0, di + 1);
|
|
178
|
+
const { groupLines, archiveLines } = splitDoneBody(lines.slice(di + 1));
|
|
179
|
+
const units = parseDoneUnits(groupLines);
|
|
180
|
+
if (!units.length) return { text, archived: [], skipped: [], count: 0 };
|
|
181
|
+
|
|
182
|
+
const keep = Math.max(1, Number(keepDays) || 3);
|
|
183
|
+
// 保留 cutoff..from 这 keep 天;比 cutoff 更早的才归档
|
|
184
|
+
let cutoff = daysAgo(keep - 1, from);
|
|
185
|
+
// 超限放宽(2026-10-05 加):ROOT-OVER-SIZE 限看板 ≤60 行,而 keepDays≥1 意味着
|
|
186
|
+
// 密集工作日(一天几十条 Done)必然超限 —— 两条规则打架,且 archive 拒绝动,
|
|
187
|
+
// 于是每天提交都被 lint 拦一次。修法:**看板已超行数上限时,把 cutoff 推到当天**,
|
|
188
|
+
// 让「整天已完成」的组可以立刻归档(仍有 ② 未完成则整天不归档 的保护)。
|
|
189
|
+
// 注意裁到「今天之后」:判断是 `date >= cutoff → 保留`,若 cutoff = 今天,
|
|
190
|
+
// 今天的组仍被保留(首版就栽在这,dry-run 一看当天没进归档列表)。
|
|
191
|
+
if (maxLines && lines.length > maxLines) cutoff = daysAgo(-1, from);
|
|
192
|
+
|
|
193
|
+
const byDate = new Map();
|
|
194
|
+
const undated = [];
|
|
195
|
+
for (const u of units) {
|
|
196
|
+
if (!u.date) { undated.push(u); continue; }
|
|
197
|
+
if (!byDate.has(u.date)) byDate.set(u.date, []);
|
|
198
|
+
byDate.get(u.date).push(u);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const archived = [];
|
|
202
|
+
const skipped = [];
|
|
203
|
+
const keepUnits = [...undated];
|
|
204
|
+
if (undated.length) skipped.push({ date: '(未标日期)', reason: '无完成日期,无法判断天数(保守不归档)' });
|
|
205
|
+
|
|
206
|
+
for (const date of [...byDate.keys()].sort()) {
|
|
207
|
+
const us = byDate.get(date);
|
|
208
|
+
if (date >= cutoff) { keepUnits.push(...us); continue; } // ① 近 N 天:保留
|
|
209
|
+
const undone = us.filter((u) => isUndoneLine(u.lines[0]));
|
|
210
|
+
if (undone.length) { // ② 有未完成:整天不归档
|
|
211
|
+
skipped.push({ date, reason: `有 ${undone.length} 条未完成,整天不归档` });
|
|
212
|
+
keepUnits.push(...us);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const gLines = us.flatMap((u) => u.lines); // ③ 可归档
|
|
216
|
+
archived.push({
|
|
217
|
+
date,
|
|
218
|
+
lines: gLines,
|
|
219
|
+
slug: slugFor(date),
|
|
220
|
+
count: gLines.filter((l) => /^\s*- \[x\]/.test(l)).length,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!archived.length) return { text, archived: [], skipped, count: 0 };
|
|
225
|
+
|
|
226
|
+
const count = archived.reduce((n, g) => n + g.count, 0);
|
|
227
|
+
const entries = archived.map((g) => ({ slug: g.slug, count: g.count }));
|
|
228
|
+
const rebuilt = [...head, ...renderDoneGroups(keepUnits), ...mergeArchiveLines(archiveLines, entries)];
|
|
229
|
+
return { text: rebuilt.join('\n').replace(/\n+$/, '\n'), archived, skipped, count };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 归档页正文(按日期分组,原文保留)。供同日追写复用。 */
|
|
233
|
+
|
|
234
|
+
export function renderArchiveBody(groups) {
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const g of groups) out.push(`### ${g.date}`, '', ...g.lines, '');
|
|
237
|
+
return out.join('\n').replace(/\n+$/, '\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 归档区标题。归档内容全部落在这个二级标题下,**与 AI 手写的会话快照共存**:
|
|
241
|
+
* 同一天的记录(快照 + 任务明细)放一个文件里,而不是分两个文件。
|
|
242
|
+
* 写入侧只动这一段:标题之前的内容原样保留(已存在的人工内容一律不覆盖)。 */
|
|
243
|
+
|
|
244
|
+
export function upsertArchiveSection(body, group) {
|
|
245
|
+
const chunk = renderArchiveBody([group]);
|
|
246
|
+
if (body == null) {
|
|
247
|
+
// ① 新文件:只有归档段(无手写快照也合法 —— 那天本来就只有任务记录)
|
|
248
|
+
const fm = [
|
|
249
|
+
'---',
|
|
250
|
+
'tags: [session-log, todo-archive, 历史]',
|
|
251
|
+
`updated: ${group.date}`,
|
|
252
|
+
'status: reviewed',
|
|
253
|
+
'---',
|
|
254
|
+
'',
|
|
255
|
+
`# ${group.date} 记录`,
|
|
256
|
+
'',
|
|
257
|
+
'> 本页 = 该日的会话快照(若有)+ 从 `todo.md` Done 区迁出的任务明细。',
|
|
258
|
+
'',
|
|
259
|
+
ARCHIVE_SECTION,
|
|
260
|
+
'',
|
|
261
|
+
chunk.trimEnd(),
|
|
262
|
+
'',
|
|
263
|
+
];
|
|
264
|
+
return fm.join('\n');
|
|
265
|
+
}
|
|
266
|
+
// ② 已存在:只动归档段,段外原样
|
|
267
|
+
const lines = body.split('\n');
|
|
268
|
+
const i = lines.findIndex((l) => l.trim() === ARCHIVE_SECTION);
|
|
269
|
+
if (i === -1) {
|
|
270
|
+
// 有文件但还没归档段 → 追加到末尾(不搅动已有内容)
|
|
271
|
+
return `${body.replace(/\s*$/, '')}\n\n${ARCHIVE_SECTION}\n\n${chunk.trimEnd()}\n`;
|
|
272
|
+
}
|
|
273
|
+
// 找到本段结束(下一个同级或更高级标题)
|
|
274
|
+
let j = i + 1;
|
|
275
|
+
while (j < lines.length && !/^#{1,2} \S/.test(lines[j].trim())) j++;
|
|
276
|
+
const head = lines.slice(0, i + 1);
|
|
277
|
+
const tailPart = lines.slice(j);
|
|
278
|
+
const existingInner = lines.slice(i + 1, j).join('\n').trim();
|
|
279
|
+
const merged = existingInner ? `${existingInner}\n\n${chunk.trimEnd()}` : chunk.trimEnd();
|
|
280
|
+
return [...head, '', merged, '', ...tailPart].join('\n').replace(/\n{3,}/g, '\n\n');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 幂等:把 todo 全文里平铺的旧 Done 区按日期分组(新日期在前,未标日期归尾)。
|
|
284
|
+
* 已是分组态(### date)则原样返回——惰性迁移用,避免每次读都动文件。 */
|
|
285
|
+
|
|
286
|
+
export function groupDoneSection(text) {
|
|
287
|
+
const lines = String(text || '').split('\n');
|
|
288
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
289
|
+
if (di === -1) return text;
|
|
290
|
+
const body = lines.slice(di + 1);
|
|
291
|
+
const first = body.find((l) => l.trim());
|
|
292
|
+
if (first && /^### /.test(first.trim())) return text; // 已分组,幂等不动
|
|
293
|
+
const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
|
|
294
|
+
const units = parseDoneUnits(groupLines);
|
|
295
|
+
if (!units.length) return text;
|
|
296
|
+
return [...lines.slice(0, di + 1), ...renderDoneGroups(units), ...archiveLines]
|
|
297
|
+
.join('\n').replace(/\n+$/, '\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 把 moved 单元(已完成任务行+附属行)放入 Done 区并整体按日期分组重建。
|
|
301
|
+
* 供 markDone 用,锁内一次完成「归位 + 分组」。无 Done 区则文件尾补建。 */
|
|
302
|
+
|
|
303
|
+
export function insertDoneGrouped(text, movedLines) {
|
|
304
|
+
const date = doneDateOf(movedLines[0]) || today();
|
|
305
|
+
const lines = String(text || '').split('\n');
|
|
306
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
307
|
+
const newUnit = { date, lines: movedLines };
|
|
308
|
+
if (di === -1) {
|
|
309
|
+
const header = `## ${SEC.done}`;
|
|
310
|
+
return [...lines, '', header, '', ...renderDoneGroups([newUnit])].join('\n').replace(/\n+$/, '\n');
|
|
311
|
+
}
|
|
312
|
+
const head = lines.slice(0, di + 1);
|
|
313
|
+
const body = lines.slice(di + 1);
|
|
314
|
+
const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
|
|
315
|
+
// 新完成单元置前:同日期组内新在最上(renderDoneGroups 按 encounter 顺序保持,日期再倒序排)
|
|
316
|
+
const units = [newUnit, ...parseDoneUnits(groupLines)];
|
|
317
|
+
return [...head, ...renderDoneGroups(units), ...archiveLines].join('\n').replace(/\n+$/, '\n');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 占位/空任务行(老模板的 "(无)"/"无"/纯 - [ ])不迁移
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// src/todo/common.js — 跨模块共用的常量与纯工具(无业务状态,无跨依赖)。
|
|
2
|
+
|
|
3
|
+
export const TODO_MAX_LINES = 60;
|
|
4
|
+
|
|
5
|
+
export const LOG_MAX_LINES = 2000;
|
|
6
|
+
|
|
7
|
+
export const INDEX_MAX_LINES = 200;
|
|
8
|
+
|
|
9
|
+
// ---------- 本地日期 ----------
|
|
10
|
+
|
|
11
|
+
export function today() {
|
|
12
|
+
// 用本地时区取 YYYY-MM-DD(toISOString 是 UTC, 会跨天错一天)
|
|
13
|
+
const d = new Date();
|
|
14
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
15
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 本地日期时间 YYYY-MM-DD HH:MM(log 流水用)
|
|
19
|
+
|
|
20
|
+
export function localStamp() {
|
|
21
|
+
const d = new Date();
|
|
22
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
23
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------- 读取 todo.md ----------
|
|
27
|
+
|
|
28
|
+
export const SEC = {
|
|
29
|
+
todo: 'Todo',
|
|
30
|
+
done: 'Done',
|
|
31
|
+
archived: 'Archived', // Done 区内部的归档标记区(原 '### 归档')
|
|
32
|
+
undated: 'Undated', // Done 区内部无完成日期的尾组(原 '### (未标日期)')
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** 旧名 → 新名。供 `abs init --repair` 一次性迁移(幂等)。
|
|
36
|
+
* 只改匹配整行的标题,不动正文;不做模糊替换(防误改正文里提到的旧名)。 */
|
|
37
|
+
// 分区标题归一(旧名 → 标准名)
|
|
38
|
+
|
|
39
|
+
export function trimBlank(arr) {
|
|
40
|
+
const a = [...arr];
|
|
41
|
+
while (a.length && !a[0].trim()) a.shift();
|
|
42
|
+
while (a.length && !a[a.length - 1].trim()) a.pop();
|
|
43
|
+
return a;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 叶子条目行:log 的 `## [时间] …` 条目、todo/index 的 `- …` 行。
|
|
47
|
+
* 空行落在两个叶子条目之间 = 人为排版漂移,会随条目增长把文件撑成两倍行数。
|
|
48
|
+
* 注意(踩坑):标题行不算 —— `## Done` 与 `### 日期` 之间的空行是分区排版,
|
|
49
|
+
* 删了会把 Done 区挤成一片(首版误删)。 */
|
|
50
|
+
|
|
51
|
+
export function sigOfLine(l) {
|
|
52
|
+
return String(l).replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 把一份文本里所有「违规行」的签名收成集合(供存量/新增比对)。
|
|
56
|
+
* 只有三个根文件用得上;非目标文件直接返回空集。
|
|
57
|
+
* ★ 空行特殊:签名取「上下两行拼接」不可靠 —— 插入一行就会让空行的
|
|
58
|
+
* 相邻行变样,导致存量空行被当成新引入(实测:cmdNote 插一行后,
|
|
59
|
+
* 同一处空行的签名就对不上了)。所以空行只记一个存在标记:
|
|
60
|
+
* 入库前就有「条目间空行」这个毛病 → 后续写入全放行(lint 报)。 */
|
|
61
|
+
|
|
62
|
+
export const REPORT_MARKERS = [
|
|
63
|
+
/^\s{0,4}[-*]\s+\S/m, // 列表项
|
|
64
|
+
/^\s{0,4}\d+[.)]\s+\S/m, // 有序列表
|
|
65
|
+
/```/, // 代码块
|
|
66
|
+
/^\s*\|.*\|\s*$/m, // 表格行
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** 数一个断点里有几个「句子」—— 断点是一件事,多句 = 在塞报告。
|
|
70
|
+
* 分隔符只用**句末标点**(。!?!?)与带圈编号 ①②③。
|
|
71
|
+
* ★ 分号(;/;)**不算**分隔符:中文技术写作里它常用来连接同一个意思的两部分
|
|
72
|
+
* (「改到哪个文件;同类约束见 xx」),算成两句会误伤正常断点(实测:
|
|
73
|
+
* 一条正常的「改到哪 + 同类约束 + 待确认」被判 3 句而拒绝写入)。
|
|
74
|
+
* 为什么不用关键词表:关键词命中不稳(「功能/方案」正常描述也会出现),
|
|
75
|
+
* 而「一句还是一段」是结构判据 —— 结构判据才可靠。 */
|
|
76
|
+
|
|
77
|
+
export function sentenceCount(s) {
|
|
78
|
+
return String(s)
|
|
79
|
+
.split(/[。!?!?]+|\s*[\u2460-\u2473]\s*/)
|
|
80
|
+
.map((x) => x.trim())
|
|
81
|
+
.filter(Boolean).length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 「绝对不允许乱空行」(2026-10-05 用户定):条目之间不能有空行。
|
|
85
|
+
* 为什么必须拒而不只是修:旧实现是「自动删」(fixed.push('删除条目之间的空行')),
|
|
86
|
+
* 但那是**默默改盘** —— 写进去的东西被改了而 AI 不知道,下次又写一遍。
|
|
87
|
+
* 现在直接报错,让写入方自己写对。
|
|
88
|
+
* 允许的位置:H1 与首个标签之间、标签与首条目之间、区与区之间(排版需要)。 */
|
|
89
|
+
|
|
90
|
+
export function isTaskLine(l) {
|
|
91
|
+
return /^\s*- \[[ x]\]/.test(l);
|
|
92
|
+
}
|
|
93
|
+
/** 判断一行是否为任务附属行(↳ 开头)。 */
|
|
94
|
+
|
|
95
|
+
export function isChildLine(l) {
|
|
96
|
+
return /^\s*↳/.test(l);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 把 Done 区文本(bodyLines,不含 `## Done` 标题)解析成任务单元 [{ date, lines:[main,...children] }]。
|
|
100
|
+
* 剥 `### 日期` 分组标题与空行;任务行下紧跟的 ↳ 行并入该单元。 */
|
|
101
|
+
|
|
102
|
+
export function daysAgo(n, from) {
|
|
103
|
+
const [y, m, d] = String(from).split('-').map(Number);
|
|
104
|
+
const t = new Date(y, m - 1, d);
|
|
105
|
+
t.setDate(t.getDate() - n);
|
|
106
|
+
const pad = (x) => String(x).padStart(2, '0');
|
|
107
|
+
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 一行是否为未完成任务行(- [ ])。 */
|
|
111
|
+
|
|
112
|
+
export function isPlaceholder(line) {
|
|
113
|
+
const t = line.trim();
|
|
114
|
+
if (!/^- \[[ x]\]/.test(t)) return false; // 只判任务行
|
|
115
|
+
const body = t.replace(/^- \[[ x]\]\s*/, '').replace(/\(认领[^)]*\)/g, '').trim();
|
|
116
|
+
return !body || /^(无|(无)|None|null)$/.test(body);
|
|
117
|
+
}
|
|
118
|
+
/** 在指定分区段落后插入一行任务;找不到分区则在文件末尾追加回退。写前惰性迁移老格式。 */
|
|
119
|
+
|
|
120
|
+
export function extractId(text) {
|
|
121
|
+
// 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
|
|
122
|
+
const t = String(text).replace(/^\[[^\]]+\]\s+/, '');
|
|
123
|
+
const m = t.match(/^([A-Za-z][\w-]*)\b/);
|
|
124
|
+
return m ? m[1] : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 提取 " — " 后的 note 部分(剥掉紧跟在 id 后的 `@author` 标记及尾部 `(认领 ...)`)。
|
|
128
|
+
* 格式: `<id> @author — <note> (认领 date)`。@author 可选(旧行/未设置姓名时无)。 */
|
|
129
|
+
|
|
130
|
+
export function extractNote(text) {
|
|
131
|
+
const i = String(text).indexOf(' — ');
|
|
132
|
+
if (i === -1) return '';
|
|
133
|
+
return String(text).slice(i + 3).replace(/\s*\(认领[^)]*\)\s*$/, '').trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 取整段 `(认领 …)`(含日期与可选的会话 id)。无则空串。
|
|
137
|
+
* 为何要整段取:sid 塞在 `(认领 日期 <sid>)` 里,原位重建任务行时
|
|
138
|
+
* 必须把原值原样搬回去,否则重复 start 会把 sid 与认领日期一起冲掉。
|
|
139
|
+
* 用通配 `[^)]*` 而非写死日期格式 —— 尾部多一个 sid 也能吃到。 */
|
|
140
|
+
export function claimOf(text) {
|
|
141
|
+
const m = String(text).match(/\(认领[^)]*\)/);
|
|
142
|
+
return m ? m[0] : '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 取 `(认领 …)` 里的会话 id(末尾那个非日期 token)。无则空串。
|
|
146
|
+
* 形态:`(认领 2026-10-09)` 或 `(认领 2026-10-09 01a11eae)`。 */
|
|
147
|
+
export function sessionOf(text) {
|
|
148
|
+
const c = claimOf(text);
|
|
149
|
+
if (!c) return '';
|
|
150
|
+
const parts = c.replace(/^[((]认领\s*/, '').replace(/[))]$/, '').trim().split(/\s+/);
|
|
151
|
+
return parts.length > 1 ? parts[parts.length - 1] : '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 提取 id 后的作者标记(无则空)。供 upsertTask 原位重建行时保留作者。
|
|
155
|
+
* 两种形态都要认:
|
|
156
|
+
* - 新: `- [ ] ID [[fanchao]] — note` (wikilink 到人页)
|
|
157
|
+
* - 旧: `- [ ] ID @fanchao — note` (裸 at 标记, 历史行)
|
|
158
|
+
* 旧行只在原位更新时按原形态保留, 不做批量回填(原文/现场已不在, 回填等于编造)。
|
|
159
|
+
* 返回的是**纯姓名**(不含 [[ ]] 与 @),由调用方决定用什么形态重写。 */
|
|
160
|
+
|
|
161
|
+
export function extractAuthor(text) {
|
|
162
|
+
const s = String(text);
|
|
163
|
+
const link = s.match(/^\S+\s+\[\[([^\]]+)\]\]/);
|
|
164
|
+
if (link) return link[1].trim();
|
|
165
|
+
const at = s.match(/^\S+\s+@([\w\u4e00-\u9fff.-]+)/);
|
|
166
|
+
return at ? at[1] : '';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 判断行里的作者标记是旧裸 `@name` 形态(保留旧形态,不擅自升级)。 */
|
|
170
|
+
|
|
171
|
+
export function isLegacyAuthorTag(text) {
|
|
172
|
+
return !/^\S+\s+\[\[/.test(String(text)) && /^\S+\s+@/.test(String(text));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 从任务行取出行首 id token(`- [ ] <id> …` 里的 <id>)。
|
|
177
|
+
* 坑: 不能用 l.includes(id) 定位任务 —— 那是子串匹配,`T1` 会命中 `T11`(同理
|
|
178
|
+
* TASK-1/TASK-10、fix-hook/fix-hook-2),导致 upsert 误判"已存在"而原地改写别的任务、
|
|
179
|
+
* 新任务静默消失。必须取出 id token 做全等比对。
|
|
180
|
+
* id 里可能混入零宽字符(历史瑕疵),比对前先剥掉。
|
|
181
|
+
*/
|