@kira905/dsh-memory-toolkit 0.1.3 → 0.2.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.en.md +225 -2
  3. package/README.md +180 -2
  4. package/lib/deploy-guard.mjs +463 -275
  5. package/lib/guard-http.mjs +59 -62
  6. package/lib/pitfall-form-map.mjs +94 -84
  7. package/lib/pitfall-score.mjs +262 -185
  8. package/lib/session-file-name.mjs +116 -100
  9. package/lib/zstd-frames.mjs +88 -67
  10. package/memory-db-tools/README.md +29 -1
  11. package/memory-db-tools/audit-analyze.mjs +1 -1
  12. package/memory-db-tools/audit-export.mjs +60 -16
  13. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  14. package/memory-db-tools/lib/known-devices.mjs +137 -0
  15. package/memory-engine/README.md +58 -4
  16. package/memory-engine/bench/bench.mjs +1 -1
  17. package/memory-engine/cli/band-sentinel.mjs +164 -0
  18. package/memory-engine/cli/distill-rollback.mjs +112 -44
  19. package/memory-engine/cli/distill-sessions.mjs +237 -0
  20. package/memory-engine/cli/distill.mjs +196 -95
  21. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  22. package/memory-engine/cli/doc-index.mjs +47 -38
  23. package/memory-engine/cli/domain-tags.mjs +246 -162
  24. package/memory-engine/cli/layer-migrate.mjs +176 -0
  25. package/memory-engine/cli/layer-move.mjs +189 -0
  26. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  27. package/memory-engine/cli/recall.mjs +243 -110
  28. package/memory-engine/cli/selfcheck.mjs +93 -44
  29. package/memory-engine/cli/serve-start.mjs +221 -140
  30. package/memory-engine/cli/sweep.mjs +83 -44
  31. package/memory-engine/cli/sync.mjs +93 -57
  32. package/memory-engine/config/audit-devices.json +6 -0
  33. package/memory-engine/config/bands.json +7 -0
  34. package/memory-engine/config/layers.json +7 -0
  35. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  36. package/memory-engine/dict/lexicon-config.json +11 -0
  37. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  38. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  39. package/memory-engine/lib/band-sentinel.mjs +417 -0
  40. package/memory-engine/lib/cli-strict.mjs +128 -0
  41. package/memory-engine/lib/cli-util.mjs +147 -125
  42. package/memory-engine/lib/config.mjs +83 -96
  43. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  44. package/memory-engine/lib/distill-rollback.mjs +173 -133
  45. package/memory-engine/lib/distill.mjs +878 -585
  46. package/memory-engine/lib/doc-index-store.mjs +208 -235
  47. package/memory-engine/lib/doc-index.mjs +351 -350
  48. package/memory-engine/lib/doc-scan.mjs +178 -156
  49. package/memory-engine/lib/doc-summarize.mjs +220 -205
  50. package/memory-engine/lib/domain-tags.mjs +358 -277
  51. package/memory-engine/lib/embedder.mjs +175 -194
  52. package/memory-engine/lib/hashes.mjs +22 -7
  53. package/memory-engine/lib/layer-migrate.mjs +330 -0
  54. package/memory-engine/lib/layer-move.mjs +307 -0
  55. package/memory-engine/lib/layers.mjs +191 -0
  56. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  57. package/memory-engine/lib/memory-source.mjs +78 -80
  58. package/memory-engine/lib/pitfall-source.mjs +115 -114
  59. package/memory-engine/lib/policy-source.mjs +279 -261
  60. package/memory-engine/lib/recall-log.mjs +285 -0
  61. package/memory-engine/lib/recall.mjs +565 -240
  62. package/memory-engine/lib/selfcheck.mjs +142 -106
  63. package/memory-engine/lib/session-distill.mjs +603 -0
  64. package/memory-engine/lib/session-source.mjs +421 -0
  65. package/memory-engine/lib/sweep.mjs +107 -130
  66. package/memory-engine/lib/sync.mjs +110 -140
  67. package/memory-engine/lib/tick-hook.mjs +123 -148
  68. package/memory-engine/lib/vectors-store.mjs +213 -188
  69. package/memory-engine/test/12-layering.test.mjs +539 -0
  70. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  71. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  72. package/memory-engine/tools/fetch-model.mjs +167 -87
  73. package/memory-engine/tools/smoke-recall.mjs +94 -38
  74. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  75. package/package.json +1 -1
@@ -1,21 +1,20 @@
1
- // 记忆引擎 v2 · S3b —— 域标签补齐(方案 §3.6 幂等键改「去重键」 / §5.4 / §7 / §3.9 D2 从严)
1
+ // 域标签补齐 —— 给存量记忆条追加「家庭 / 工作 / 健康 / 技术 / DSH生态 / 梗」这几类域标签
2
2
  //
3
- // 逐条口径(都能在方案里找到出处):
4
- // · 域标签集 = 家庭 / 工作 / 健康 / 技术 / DSH生态 / 梗,**追加**到 tags,绝不覆盖原标签(§3.6)
5
- // · 幂等键 = **去重键 `(id, 域标签集哈希)`**;写前**逐标签判重**(已含则该条跳过)。
6
- // 不许用"批次游标"当幂等键 —— 重复执行会往 tags 累加同一批标签(`normalizeTags` 的去重只在
7
- // 插件写入口生效,直写 SQL 不经过它)(§3.6 v3 改 · 关 I2)
8
- // · 前置 `VACUUM INTO` 快照;失败可整表还原(§3.6 / §7)
9
- // · 并发保护:`busy_timeout` + 重试 **3 次**、退避上限 **60 s**(§3.6 / §7 Z2)
10
- // · 成本闸:调用 ≤ **20 次/日**、token ≤ **150K/日**,**超限即停**(不重试)+ 记账(§3.5 / §3.6)
11
- // · 分批、**可中断续跑**(state 落盘,逐条幂等台账)(§3.6)
12
- // · **D2 从严**(§3.9 注 / 服务面 §11.3):只把 `owner_user == 本机 owner` 的条送分类模型;
13
- // 判不出归属的条 **跳过并记账**(宁可漏,不可错送)
3
+ // 口径(每条都有出处,动之前先看这里):
4
+ // · 域标签是**追加**到 tags 尾部,绝不覆盖原有标签;
5
+ // · 幂等键 = (条 id, 域标签集哈希):写之前逐个 token 判重,已含就跳过。
6
+ // 别拿「批次游标」当幂等键 —— 那样重复执行会把同一批标签一遍遍往 tags 上堆;
7
+ // · 动手前先 VACUUM INTO 快照,写坏了可以整表还原;
8
+ // · 并发:busy_timeout + 最多 3 次重试,退避累计封顶 60 秒;
9
+ // · 成本闸:每日 ≤20 次调用、≤150K token,触顶即停(不重试)并记账;
10
+ // · 分批跑、可中断续跑(台账落 state 文件,逐条幂等);
11
+ // · 从严口径:只有 owner_user == 本机 owner 的条才送分类模型,判不出归属的跳过并记账 ——
12
+ // 宁可漏标,不可错送。
14
13
  //
15
- // 两个刻意的设计决定(写在这里防将来"照字面实现"踩坑):
16
- // ① 分类模型输出只用来**追加标签**,不产生任何其它写入 ⇒ 半完成态无害(§5.4 域标签行);
17
- // ② 追加标签**不动 `updated_at`** —— 否则会把这批老条顶进 `memory:recall` 段最前,
18
- // 并让提炼窗口(按 updated_at 取变更)误判为"新内容"(§3.5 输入行)。
14
+ // 两个刻意的决定(防止将来「照字面改」踩坑):
15
+ // ① 模型输出只用来追加标签、不产生别的写入 ⇒ 半完成态无害;
16
+ // ② 追加标签不动 updated_at —— 否则这批老条会被顶到召回段最前,
17
+ // 提炼窗口也会把它们误判成「新内容」。
19
18
  import fs from 'node:fs';
20
19
  import path from 'node:path';
21
20
  import os from 'node:os';
@@ -23,30 +22,30 @@ import { DatabaseSync } from 'node:sqlite';
23
22
 
24
23
  // ── 常量 ────────────────────────────────────────────────────────────────────
25
24
 
26
- /** 域标签集(显示名 = 方案 §3.6 原文)。 */
25
+ // 域标签集(显示名;落库前统一小写)
27
26
  export const DOMAIN_TAGS = Object.freeze(['家庭', '工作', '健康', '技术', 'DSH生态', '梗']);
28
27
 
29
- /** 显示名 → 落库 token。`tags` 一律按插件 `normalizeTags` 的口径小写化(`DSH生态` → `dsh生态`)。 */
30
- export function normalizeTag(s) {
31
- return String(s ?? '').trim().toLowerCase().replace(/\s+/g, '-');
32
- }
28
+ // 显示名 → 落库 token;tags 一律按插件那条 normalizeTags 的口径小写化(DSH生态 → dsh生态)
29
+ export const normalizeTag = (s) => String(s ?? '').trim().toLowerCase().replace(/\s+/g, '-');
33
30
 
34
- /** 显示名 → 规范 token 的映射(兼容大小写/空白写法的输入)。 */
31
+ // 显示名 → 规范 token 的查表(兼容大小写与多余空白的输入写法)
35
32
  export const DOMAIN_TOKEN_BY_LABEL = Object.freeze(
36
33
  Object.fromEntries(DOMAIN_TAGS.map((label) => [label, normalizeTag(label)])),
37
34
  );
38
- export const DOMAIN_TOKENS = Object.freeze(DOMAIN_TAGS.map((l) => normalizeTag(l)));
35
+ export const DOMAIN_TOKENS = Object.freeze(DOMAIN_TAGS.map((label) => normalizeTag(label)));
39
36
 
40
- /** 成本闸默认值(§3.5,与提炼 job 同一档)。 */
37
+ // 成本闸默认值(与提炼那条链同一档)
41
38
  export const COST_LIMITS = Object.freeze({ callsPerDay: 20, tokensPerDay: 150000 });
42
39
 
43
- /** 并发保护默认值(§3.6 / §7)。 */
44
- export const RETRY = Object.freeze({ busyTimeoutMs: 5000, maxAttempts: 3, backoffMs: [1000, 4000, 16000], totalCapMs: 60000 });
40
+ // 并发保护默认值
41
+ export const RETRY = Object.freeze({
42
+ busyTimeoutMs: 5000,
43
+ maxAttempts: 3,
44
+ backoffMs: [1000, 4000, 16000],
45
+ totalCapMs: 60000,
46
+ });
45
47
 
46
- /**
47
- * 分类模型默认档(**2026-09-21 主人拍板:记忆线一律走本鱼链**;真源 = 方案 §3.5 订正注 2)。
48
- * 不用 `deepseek-v4-pro`(贵 4.4 倍,分类任务不需要强推理);不许改回第三方(D2 延长效:新增外发面须先拍)。
49
- */
48
+ // 分类模型默认档:记忆线统一走本鱼链,不用更贵的强推理档,也不许改回第三方
50
49
  export const DEFAULT_LLM = Object.freeze({
51
50
  provider: 'deepseek',
52
51
  baseUrl: 'https://api.deepseek.com',
@@ -60,256 +59,311 @@ export const STATE_SCHEMA = 1;
60
59
 
61
60
  // ── 小工具 ──────────────────────────────────────────────────────────────────
62
61
 
63
- /** 稳定哈希(域标签集 → 短哈希;幂等键的右半)。 */
64
- export function tagSetHash(tokens) {
62
+ const FNV_OFFSET = 0x811c9dc5;
63
+ const FNV_PRIME = 0x01000193;
64
+ const FIELD_SEP = 0x2c; // 分隔符:防 "ab"+"c" 与 "a"+"bc" 撞成同一个哈希
65
+
66
+ // 域标签集 → 短哈希(幂等键的右半)。轻量 FNV-1a:只用来比「集合变没变」,不需要密码学强度。
67
+ export const tagSetHash = (tokens) => {
65
68
  const sorted = [...new Set(tokens.map(normalizeTag).filter(Boolean))].sort();
66
- // 轻量 FNV-1a(零依赖;只用做"集合是否变过"的比较,不需要密码学强度)
67
- let h = 0x811c9dc5;
68
- for (const t of sorted) {
69
- for (let i = 0; i < t.length; i += 1) {
70
- h ^= t.charCodeAt(i);
71
- h = Math.imul(h, 0x01000193) >>> 0;
69
+ let h = FNV_OFFSET;
70
+ for (const token of sorted) {
71
+ for (let i = 0; i < token.length; i += 1) {
72
+ h = Math.imul(h ^ token.charCodeAt(i), FNV_PRIME) >>> 0;
72
73
  }
73
- h ^= 0x2c; // 分隔符(防 "ab"+"c" 与 "a"+"bc" 撞)
74
- h = Math.imul(h, 0x01000193) >>> 0;
74
+ h = Math.imul(h ^ FIELD_SEP, FNV_PRIME) >>> 0;
75
75
  }
76
76
  return `fnv1a:${h.toString(16).padStart(8, '0')}`;
77
- }
78
-
79
- /** tags 文本 → token 数组(空格分隔;与插件 normalizeTags 的落库形态一致)。 */
80
- export function tokensOf(tags) {
81
- return String(tags ?? '').split(/\s+/).map((t) => t.trim()).filter(Boolean);
82
- }
83
-
84
- /** 该条 already 有哪些域标签(token 级精确比较,**不用 includes**:`工作记录` 不等于 `工作`)。 */
85
- export function domainTokensIn(tags) {
86
- const set = new Set(tokensOf(tags));
87
- return DOMAIN_TOKENS.filter((t) => set.has(t));
88
- }
89
-
90
- /**
91
- * 追加域标签(**幂等**):返回新 tags 文本 + 实际新增了哪些。
92
- * 只在"目标标签不在现有 token 里"时追加 ⇒ 同批次重跑不会产生重复 token(§3.6 验收)。
93
- */
94
- export function appendDomainTags(existingTags, domainLabelsOrTokens) {
77
+ };
78
+
79
+ // tags 文本 → token 数组(空白分隔,与插件落库形态一致)
80
+ export const tokensOf = (tags) => String(tags ?? '').split(/\s+/).map((t) => t.trim()).filter(Boolean);
81
+
82
+ // 该条已经带了哪些域标签:token 级精确比较(`工作记录` 不算带了 `工作`)
83
+ export const domainTokensIn = (tags) => {
84
+ const present = new Set(tokensOf(tags));
85
+ return DOMAIN_TOKENS.filter((token) => present.has(token));
86
+ };
87
+
88
+ // 输入写法 → 规范 token;不在固定集合里的一律丢(模型自创词不落库)
89
+ const resolveDomainToken = (raw) => {
90
+ const label = String(raw ?? '').trim();
91
+ const direct = DOMAIN_TOKEN_BY_LABEL[label];
92
+ if (direct) return direct;
93
+ const normalized = normalizeTag(label);
94
+ return DOMAIN_TOKENS.find((token) => token === normalized) ?? null;
95
+ };
96
+
97
+ // 追加域标签(幂等):只往尾部补缺席的那些,返回新文本 + 实际新增 / 已存在的清单
98
+ export const appendDomainTags = (existingTags, domainLabelsOrTokens) => {
95
99
  const tokens = tokensOf(existingTags);
96
100
  const present = new Set(tokens);
97
101
  const wanted = [];
102
+ const picked = new Set();
98
103
  for (const raw of domainLabelsOrTokens) {
99
- const label = String(raw ?? '').trim();
100
- const token = DOMAIN_TOKEN_BY_LABEL[label] ?? DOMAIN_TOKENS.find((t) => t === normalizeTag(label));
101
- if (!token) continue; // 模型给了不在域标签集里的东西 ⇒ 丢弃(不写库)
102
- if (!wanted.includes(token)) wanted.push(token);
104
+ const token = resolveDomainToken(raw);
105
+ if (!token || picked.has(token)) continue;
106
+ picked.add(token);
107
+ wanted.push(token);
103
108
  }
104
- const added = wanted.filter((t) => !present.has(t));
105
- const next = [...tokens, ...added].join(' ');
106
- return { tags: next, added, alreadyPresent: wanted.filter((t) => present.has(t)), wanted };
107
- }
109
+ const added = wanted.filter((token) => !present.has(token));
110
+ const alreadyPresent = wanted.filter((token) => present.has(token));
111
+ return { tags: tokens.concat(added).join(' '), added, alreadyPresent, wanted };
112
+ };
108
113
 
109
- // ── 库操作(只读取样 + 带重试的写) ──────────────────────────────────────────
114
+ // ── 库操作 ──────────────────────────────────────────────────────────────────
110
115
 
111
- /** 打开库(默认只读;`write:true` 时带 busy_timeout)。 */
112
- export function openDb(dbPath, { write = false } = {}) {
113
- // ⚠️ node:sqlite 的第二参**不接受 undefined**("options argument must be an object")⇒ 分支显式构造
116
+ // 打开库:默认只读;write:true 时带 busy_timeout。node:sqlite 的第二参不吃 undefined ⇒ 分支显式构造
117
+ export const openDb = (dbPath, { write = false } = {}) => {
114
118
  const db = write ? new DatabaseSync(dbPath) : new DatabaseSync(dbPath, { readOnly: true });
115
119
  if (write) db.exec(`PRAGMA busy_timeout = ${RETRY.busyTimeoutMs}`);
116
120
  return db;
117
- }
121
+ };
122
+
123
+ // 一次事务尝试:BEGIN → 体 → COMMIT;体里或提交时出错都回滚,并把原始错误往外抛
124
+ const attemptTransaction = (db, fn) => {
125
+ db.exec('BEGIN IMMEDIATE');
126
+ try {
127
+ const value = fn();
128
+ db.exec('COMMIT');
129
+ return value;
130
+ } catch (err) {
131
+ try {
132
+ db.exec('ROLLBACK');
133
+ } catch {
134
+ // 回滚也失败就保持原始错误(第二个错误没有诊断价值)
135
+ }
136
+ throw err;
137
+ }
138
+ };
118
139
 
119
140
  /**
120
- * 带重试的写事务执行器(§3.6 / §7 Z2:`busy_timeout` + 重试 3 次、总退避上限 60 s)。
141
+ * 带重试的写事务执行器:busy 才等,其它错误立刻收手;超限不抛错,返回 {ok:false, error}。
121
142
  * @param {object} db - 写连接
122
143
  * @param {() => any} fn - 事务体
123
- * @param {{sleep?:Function, now?:Function}} [deps] - 便于单测注入(不真等)
124
- * @returns {{ok:boolean, value?:any, attempts:number, error?:string, waitedMs:number}}
144
+ * @param {{sleep?:Function, now?:Function}} [deps] - 单测注入用(不真等)
125
145
  */
126
- export async function withBusyRetry(db, fn, deps = {}) {
127
- const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
128
- const started = Date.now();
146
+ export const withBusyRetry = async (db, fn, deps = {}) => {
147
+ const nap = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
148
+ const t0 = Date.now();
129
149
  let waited = 0;
130
- let lastErr = null;
150
+ let lastError = null;
151
+
131
152
  for (let attempt = 1; attempt <= RETRY.maxAttempts; attempt += 1) {
132
153
  try {
133
- db.exec('BEGIN IMMEDIATE');
134
- try {
135
- const value = fn();
136
- db.exec('COMMIT');
137
- return { ok: true, value, attempts: attempt, waitedMs: waited };
138
- } catch (e) {
139
- try { db.exec('ROLLBACK'); } catch { /* 忽略回滚失败 */ }
140
- throw e;
141
- }
142
- } catch (e) {
143
- lastErr = e;
144
- const msg = String(e && e.message ? e.message : e);
145
- const busy = /busy|locked/i.test(msg);
146
- if (!busy || attempt === RETRY.maxAttempts) break;
154
+ return { ok: true, value: attemptTransaction(db, fn), attempts: attempt, waitedMs: waited };
155
+ } catch (err) {
156
+ lastError = err;
157
+ const text = String(err && err.message ? err.message : err);
158
+ if (!/busy|locked/i.test(text) || attempt === RETRY.maxAttempts) break;
159
+
147
160
  const backoff = RETRY.backoffMs[Math.min(attempt - 1, RETRY.backoffMs.length - 1)];
148
- if (waited + backoff > RETRY.totalCapMs || Date.now() - started + backoff > RETRY.totalCapMs) {
149
- return { ok: false, attempts: attempt, error: `${msg}(退避将超 ${RETRY.totalCapMs}ms 上限,停止重试)`, waitedMs: waited };
161
+ const wouldOverrun = waited + backoff > RETRY.totalCapMs || Date.now() - t0 + backoff > RETRY.totalCapMs;
162
+ if (wouldOverrun) {
163
+ return {
164
+ ok: false,
165
+ attempts: attempt,
166
+ error: `${text}(退避将超 ${RETRY.totalCapMs}ms 上限,停止重试)`,
167
+ waitedMs: waited,
168
+ };
150
169
  }
151
- await sleep(backoff);
170
+ await nap(backoff);
152
171
  waited += backoff;
153
172
  }
154
173
  }
155
- return { ok: false, attempts: RETRY.maxAttempts, error: String(lastErr && lastErr.message ? lastErr.message : lastErr), waitedMs: waited };
156
- }
157
174
 
158
- /** 读待处理条目(只读):id/text/tags/owner_user(若列不存在则 `ownerColumn:false`)。 */
159
- export function readCandidates(dbPath, { limit = 0, maxCharsPerItem = 240 } = {}) {
175
+ return {
176
+ ok: false,
177
+ attempts: RETRY.maxAttempts,
178
+ error: String(lastError && lastError.message ? lastError.message : lastError),
179
+ waitedMs: waited,
180
+ };
181
+ };
182
+
183
+ // 读待处理条目(只读连接):老库没有 owner_user 列时如实标记,由调用方按本机 owner 处理
184
+ export const readCandidates = (dbPath, { limit = 0, maxCharsPerItem = 240 } = {}) => {
160
185
  const db = openDb(dbPath);
161
186
  try {
162
- const cols = db.prepare('PRAGMA table_info(memories)').all().map((r) => r.name);
163
- const hasOwner = cols.includes('owner_user');
164
- const sel = ['id', 'text', 'tags', ...(hasOwner ? ['owner_user', 'visibility'] : [])].join(', ');
165
- const rows = db.prepare(`SELECT ${sel} FROM memories ORDER BY id`).all();
166
- const items = rows.map((r) => ({
167
- id: r.id,
168
- text: String(r.text ?? ''),
169
- tags: String(r.tags ?? ''),
170
- ownerUser: hasOwner ? r.owner_user : null,
171
- visibility: hasOwner ? r.visibility : null,
172
- textTruncated: String(r.text ?? '').length > maxCharsPerItem,
173
- textForModel: String(r.text ?? '').slice(0, maxCharsPerItem),
174
- existingDomains: domainTokensIn(r.tags),
175
- }));
176
- return { items: limit > 0 ? items.slice(0, limit) : items, ownerColumn: hasOwner, columns: cols };
187
+ const columns = db.prepare('PRAGMA table_info(memories)').all().map((row) => row.name);
188
+ const withOwner = columns.includes('owner_user');
189
+ const projection = withOwner ? 'id, text, tags, owner_user, visibility' : 'id, text, tags';
190
+ const rows = db.prepare(`SELECT ${projection} FROM memories ORDER BY id`).all();
191
+ const items = rows.map((row) => {
192
+ const text = String(row.text ?? '');
193
+ return {
194
+ id: row.id,
195
+ text,
196
+ tags: String(row.tags ?? ''),
197
+ ownerUser: withOwner ? row.owner_user : null,
198
+ visibility: withOwner ? row.visibility : null,
199
+ textTruncated: text.length > maxCharsPerItem,
200
+ textForModel: text.slice(0, maxCharsPerItem),
201
+ existingDomains: domainTokensIn(row.tags),
202
+ };
203
+ });
204
+ return { items: limit > 0 ? items.slice(0, limit) : items, ownerColumn: withOwner, columns };
177
205
  } finally {
178
206
  db.close();
179
207
  }
180
- }
208
+ };
181
209
 
182
- // ── LLM(DeepSeek 官方 OpenAI 兼容端点;key 读 .credentials.yaml) ─────────────
210
+ // ── 凭证与模型调用 ──────────────────────────────────────────────────────────
183
211
 
184
- /** 从 .credentials.yaml 读 key(去引号 + 去 flow-map 行尾逗号)。 */
185
- export function readCredential(keyName, credFile = path.join(os.homedir(), '.dsh', '.credentials.yaml')) {
212
+ // 从 .credentials.yaml 读一个键:去引号、去行尾注释与 flow-map 逗号
213
+ export const readCredential = (keyName, credFile = path.join(os.homedir(), '.dsh', '.credentials.yaml')) => {
214
+ let text;
186
215
  try {
187
- const text = fs.readFileSync(credFile, 'utf8');
188
- const m = text.match(new RegExp(`^\\s*${keyName}\\s*:\\s*(.+?)\\s*$`, 'm'));
189
- if (!m) return null;
190
- let v = m[1].trim();
191
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
192
- v = v.replace(/\s+#.*$/, '').replace(/,\s*$/, '').trim();
193
- return v.length > 0 ? v : null;
216
+ text = fs.readFileSync(credFile, 'utf8');
194
217
  } catch {
195
218
  return null;
196
219
  }
197
- }
198
-
199
- /** 分类提示词(system)。要求模型**只输出 JSON**,且只从给定集合里选。 */
200
- export function buildSystemPrompt() {
201
- return [
202
- '你是一个中文记忆条目的域分类器。给定若干条记忆(编号 + 正文),为每条从下列固定集合里选出适用的域标签:',
203
- `集合:${DOMAIN_TAGS.join(' / ')}`,
204
- '规则:',
205
- '1. 每条可给 0~3 个标签;不适用就返回空数组,不要硬凑。',
206
- '2. 只允许上面集合里的标签,逐字照抄(含大小写),不得自创、不得翻译、不得加解释。',
207
- '3. 判据看正文主旨:家人/家庭事务→家庭;工作/职场/公司/项目交付→工作;身体/睡眠/就医/健康习惯→健康;',
208
- ' 编程/系统/工具/踩坑/技术方案→技术;DSH 生态自身的机制/插件/大管家/同步/生图栈→DSH生态;',
209
- ' 段子/梗/玩笑/表情包/口癖→梗。一条可同时属技术与DSH生态。',
210
- `4. 输出**严格 JSON**,形如 {"items":[{"id":6,"domains":["技术"]}, {"id":7,"domains":[]}]};`,
211
- ' 不要输出 markdown 代码围栏、不要输出解释文字、不要遗漏输入的每一条。',
212
- ].join('\n');
213
- }
214
-
215
- /** 分类提示词(user 侧):批量正文。 */
216
- export function buildUserPrompt(items) {
217
- const lines = items.map((it) => `[${it.id}] ${it.textForModel.replace(/\s+/g, ' ').trim()}`);
220
+ const hit = text.match(new RegExp(`^\\s*${keyName}\\s*:\\s*(.+?)\\s*$`, 'm'));
221
+ if (!hit) return null;
222
+
223
+ let value = hit[1].trim();
224
+ const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"));
225
+ if (quoted) value = value.slice(1, -1);
226
+ value = value.replace(/\s+#.*$/, '').replace(/,\s*$/, '').trim();
227
+ return value.length > 0 ? value : null;
228
+ };
229
+
230
+ // 分类提示词(system):只许从固定集合里挑,只许输出 JSON
231
+ export const buildSystemPrompt = () => [
232
+ '你是一个中文记忆条目的域分类器。给定若干条记忆(编号 + 正文),为每条从下列固定集合里选出适用的域标签:',
233
+ `集合:${DOMAIN_TAGS.join(' / ')}`,
234
+ '规则:',
235
+ '1. 每条可给 0~3 个标签;不适用就返回空数组,不要硬凑。',
236
+ '2. 只允许上面集合里的标签,逐字照抄(含大小写),不得自创、不得翻译、不得加解释。',
237
+ '3. 判据看正文主旨:家人/家庭事务→家庭;工作/职场/公司/项目交付→工作;身体/睡眠/就医/健康习惯→健康;',
238
+ ' 编程/系统/工具/踩坑/技术方案→技术;DSH 生态自身的机制/插件/大管家/同步/生图栈→DSH生态;',
239
+ ' 段子/梗/玩笑/表情包/口癖→梗。一条可同时属技术与DSH生态。',
240
+ `4. 输出**严格 JSON**,形如 {"items":[{"id":6,"domains":["技术"]}, {"id":7,"domains":[]}]};`,
241
+ ' 不要输出 markdown 代码围栏、不要输出解释文字、不要遗漏输入的每一条。',
242
+ ].join('\n');
243
+
244
+ // 分类提示词(user 侧):批量正文,一行一条
245
+ export const buildUserPrompt = (items) => {
246
+ const lines = items.map((item) => `[${item.id}] ${item.textForModel.replace(/\s+/g, ' ').trim()}`);
218
247
  return `共 ${items.length} 条,请逐条分类并按要求输出 JSON:\n\n${lines.join('\n')}`;
219
- }
248
+ };
220
249
 
221
- /** 从模型回复里抠 JSON(容忍代码围栏 / 前后废话)。 */
222
- export function extractJson(content) {
250
+ // 从模型回复里抠 JSON:容忍代码围栏与前后废话;抠不出来就 null(不抛)
251
+ export const extractJson = (content) => {
223
252
  const text = String(content ?? '').trim();
224
253
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
225
- const body = fenced ? fenced[1] : text;
226
- const start = body.indexOf('{');
227
- const end = body.lastIndexOf('}');
228
- if (start < 0 || end <= start) return null;
254
+ const payload = fenced ? fenced[1] : text;
255
+ const head = payload.indexOf('{');
256
+ const tail = payload.lastIndexOf('}');
257
+ if (head < 0 || tail <= head) return null;
229
258
  try {
230
- return JSON.parse(body.slice(start, end + 1));
259
+ return JSON.parse(payload.slice(head, tail + 1));
231
260
  } catch {
232
261
  return null;
233
262
  }
234
- }
235
-
236
- /** 调一次分类模型。`fetchImpl` 可注入(单测不真发网络)。 */
237
- export async function callClassifier({ items, apiKey, baseUrl = DEFAULT_LLM.baseUrl, model = DEFAULT_LLM.model, fetchImpl = fetch, timeoutMs = DEFAULT_LLM.timeoutMs, maxTokens = DEFAULT_LLM.maxTokens }) {
238
- const body = {
239
- model,
240
- messages: [
241
- { role: 'system', content: buildSystemPrompt() },
242
- { role: 'user', content: buildUserPrompt(items) },
243
- ],
244
- // deepseek-flash 默认开思考(effort=high ⇒ 白烧 token);分类任务不需要思考 ⇒ 显式关
245
- thinking: { type: 'disabled' },
246
- temperature: 0.1,
247
- max_tokens: maxTokens,
248
- stream: false,
249
- };
250
- const ctrl = new AbortController();
251
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
263
+ };
264
+
265
+ // 呼叫体:分类任务不需要思考链,显式关掉省 token
266
+ const classifierRequestBody = ({ model, items, maxTokens }) => ({
267
+ model,
268
+ messages: [
269
+ { role: 'system', content: buildSystemPrompt() },
270
+ { role: 'user', content: buildUserPrompt(items) },
271
+ ],
272
+ thinking: { type: 'disabled' },
273
+ temperature: 0.1,
274
+ max_tokens: maxTokens,
275
+ stream: false,
276
+ });
277
+
278
+ /** 调一次分类模型。`fetchImpl` 可注入 ⇒ 单测不真发网络。 */
279
+ export const callClassifier = async ({
280
+ items,
281
+ apiKey,
282
+ baseUrl = DEFAULT_LLM.baseUrl,
283
+ model = DEFAULT_LLM.model,
284
+ fetchImpl = globalThis.fetch,
285
+ timeoutMs = DEFAULT_LLM.timeoutMs,
286
+ maxTokens = DEFAULT_LLM.maxTokens,
287
+ }) => {
288
+ const abort = new AbortController();
289
+ const timer = setTimeout(() => abort.abort(), timeoutMs);
290
+
252
291
  try {
253
292
  const res = await fetchImpl(`${baseUrl}/chat/completions`, {
254
293
  method: 'POST',
255
294
  headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
256
- body: JSON.stringify(body),
257
- signal: ctrl.signal,
295
+ body: JSON.stringify(classifierRequestBody({ model, items, maxTokens })),
296
+ signal: abort.signal,
258
297
  });
298
+
259
299
  const text = await res.text();
260
300
  if (!res.ok) return { ok: false, error: `HTTP ${res.status}: ${text.slice(0, 300)}` };
261
- let json;
262
- try { json = JSON.parse(text); } catch (e) { return { ok: false, error: `响应不是 JSON:${e.message}`, raw: text.slice(0, 300) }; }
263
- const content = json?.choices?.[0]?.message?.content ?? '';
264
- const usage = json?.usage ?? {};
301
+
302
+ let envelope;
303
+ try {
304
+ envelope = JSON.parse(text);
305
+ } catch (err) {
306
+ return { ok: false, error: `响应不是 JSON:${err.message}`, raw: text.slice(0, 300) };
307
+ }
308
+
309
+ const content = envelope?.choices?.[0]?.message?.content ?? '';
310
+ const usage = envelope?.usage ?? {};
265
311
  const parsed = extractJson(content);
266
312
  if (!parsed) return { ok: false, error: '模型输出里找不到可解析的 JSON', raw: String(content).slice(0, 300), usage };
267
- const list = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.items) ? parsed.items : null);
313
+
314
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.items) ? parsed.items : null;
268
315
  if (!list) return { ok: false, error: 'JSON 结构不含 items 数组', raw: String(content).slice(0, 300), usage };
269
316
  return { ok: true, items: list, usage, content };
270
- } catch (e) {
271
- return { ok: false, error: `请求失败:${e && e.message ? e.message : e}` };
317
+ } catch (err) {
318
+ return { ok: false, error: `请求失败:${err && err.message ? err.message : err}` };
272
319
  } finally {
273
320
  clearTimeout(timer);
274
321
  }
275
- }
276
-
277
- /** 估算一次批量调用的 token(保守),用于调用前预判成本闸。 */
278
- export function estimateTokens(items) {
279
- const chars = items.reduce((n, it) => n + it.textForModel.length + 8, 0) + buildSystemPrompt().length;
280
- return Math.ceil(chars / 1.4) + 300; // 中文 ≈1.4 字/token,另留出输出的余量
281
- }
282
-
283
- // ── state(可中断续跑 + 记账) ───────────────────────────────────────────────
284
-
285
- export function defaultState(dbPath, nowIso) {
286
- return {
287
- schemaVersion: STATE_SCHEMA,
288
- dbPath,
289
- updatedAt: nowIso,
290
- cost: { date: String(nowIso).slice(0, 10), calls: 0, promptTokens: 0, completionTokens: 0 },
291
- processed: {},
292
- skipped: {},
293
- stats: { batches: 0, tagged: 0, noTag: 0, skippedOwner: 0, skippedUnknownDomain: 0, runs: 0 },
294
- lastDailyLine: null,
295
- };
296
- }
322
+ };
323
+
324
+ // 保守估算一次批量调用的 token(中文约 1.4 字/token,另留输出余量),调用前用它预判成本闸
325
+ export const estimateTokens = (items) => {
326
+ const bodyChars = items.reduce((sum, item) => sum + item.textForModel.length + 8, 0);
327
+ return Math.ceil((bodyChars + buildSystemPrompt().length) / 1.4) + 300;
328
+ };
329
+
330
+ // ── state:可中断续跑 + 记账 ────────────────────────────────────────────────
331
+
332
+ const zeroCost = (day) => ({ date: day, calls: 0, promptTokens: 0, completionTokens: 0 });
333
+
334
+ export const defaultState = (dbPath, nowIso) => ({
335
+ schemaVersion: STATE_SCHEMA,
336
+ dbPath,
337
+ updatedAt: nowIso,
338
+ cost: zeroCost(String(nowIso).slice(0, 10)),
339
+ processed: {},
340
+ skipped: {},
341
+ stats: { batches: 0, tagged: 0, noTag: 0, skippedOwner: 0, skippedUnknownDomain: 0, runs: 0 },
342
+ lastDailyLine: null,
343
+ });
297
344
 
298
- export function readState(stateFile, dbPath, nowIso) {
345
+ export const readState = (stateFile, dbPath, nowIso) => {
346
+ const day = String(nowIso).slice(0, 10);
347
+ const fresh = defaultState(dbPath, nowIso);
348
+ let stored;
299
349
  try {
300
- if (!fs.existsSync(stateFile)) return defaultState(dbPath, nowIso);
301
- const s = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
302
- const base = defaultState(dbPath, nowIso);
303
- const merged = { ...base, ...s, cost: { ...base.cost, ...(s.cost ?? {}) }, stats: { ...base.stats, ...(s.stats ?? {}) } };
304
- // 跨日 ⇒ 当日成本归零(成本闸是"每日"口径)
305
- if (merged.cost.date !== String(nowIso).slice(0, 10)) merged.cost = { date: String(nowIso).slice(0, 10), calls: 0, promptTokens: 0, completionTokens: 0 };
306
- return merged;
350
+ if (!fs.existsSync(stateFile)) return fresh;
351
+ stored = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
307
352
  } catch {
308
- return defaultState(dbPath, nowIso);
353
+ return fresh; // 坏文件回落默认,绝不抛
309
354
  }
310
- }
355
+ const merged = {
356
+ ...fresh,
357
+ ...stored,
358
+ cost: { ...fresh.cost, ...(stored.cost ?? {}) },
359
+ stats: { ...fresh.stats, ...(stored.stats ?? {}) },
360
+ };
361
+ // 跨日 ⇒ 当日成本归零(成本闸是「每日」口径)
362
+ if (merged.cost.date !== day) merged.cost = zeroCost(day);
363
+ return merged;
364
+ };
311
365
 
312
- export function writeState(stateFile, state) {
366
+ export const writeState = (stateFile, state) => {
313
367
  fs.mkdirSync(path.dirname(stateFile), { recursive: true });
314
368
  const tmp = `${stateFile}.tmp-${process.pid}`;
315
369
  fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
@@ -317,27 +371,31 @@ export function writeState(stateFile, state) {
317
371
  fs.renameSync(tmp, stateFile);
318
372
  } catch {
319
373
  fs.copyFileSync(tmp, stateFile);
320
- try { fs.unlinkSync(tmp); } catch { /* 忽略 */ }
374
+ try {
375
+ fs.unlinkSync(tmp);
376
+ } catch {
377
+ // 临时件删不掉也无害
378
+ }
321
379
  }
322
380
  return stateFile;
323
- }
381
+ };
324
382
 
325
- /** 成本闸判定:还能不能再调一次(§3.5:≤20 次/日、≤150K token/日,超限即停不重试)。 */
326
- export function costGate(state, estimatedTokens, limits = COST_LIMITS) {
327
- const usedTokens = Number(state.cost.promptTokens ?? 0) + Number(state.cost.completionTokens ?? 0);
383
+ // 成本闸:还能不能再调一次(次数 / 已用 token / 本批估算三关)
384
+ export const costGate = (state, estimatedTokens, limits = COST_LIMITS) => {
328
385
  const calls = Number(state.cost.calls ?? 0);
386
+ const spent = Number(state.cost.promptTokens ?? 0) + Number(state.cost.completionTokens ?? 0);
329
387
  if (calls >= limits.callsPerDay) return { ok: false, reason: `当日调用数已达上限 ${limits.callsPerDay} 次` };
330
- if (usedTokens >= limits.tokensPerDay) return { ok: false, reason: `当日 token 已达上限 ${limits.tokensPerDay}` };
331
- if (usedTokens + estimatedTokens > limits.tokensPerDay) {
332
- return { ok: false, reason: `本批估算 ${estimatedTokens} token 会超过当日上限(已用 ${usedTokens}/${limits.tokensPerDay})` };
388
+ if (spent >= limits.tokensPerDay) return { ok: false, reason: `当日 token 已达上限 ${limits.tokensPerDay}` };
389
+ if (spent + estimatedTokens > limits.tokensPerDay) {
390
+ return { ok: false, reason: `本批估算 ${estimatedTokens} token 会超过当日上限(已用 ${spent}/${limits.tokensPerDay})` };
333
391
  }
334
392
  return { ok: true };
335
- }
393
+ };
336
394
 
337
395
  // ── 快照 ────────────────────────────────────────────────────────────────────
338
396
 
339
- /** `VACUUM INTO` 快照(只读连接;§3.6 前置)。 */
340
- export function vacuumInto(srcDbPath, destFile) {
397
+ // VACUUM INTO 快照(只读连接打开源库;目标存在就先删掉)
398
+ export const vacuumInto = (srcDbPath, destFile) => {
341
399
  fs.mkdirSync(path.dirname(destFile), { recursive: true });
342
400
  if (fs.existsSync(destFile)) fs.rmSync(destFile);
343
401
  const db = openDb(srcDbPath);
@@ -348,82 +406,105 @@ export function vacuumInto(srcDbPath, destFile) {
348
406
  }
349
407
  if (!fs.existsSync(destFile)) throw new Error(`VACUUM INTO 未产出快照:${destFile}`);
350
408
  return destFile;
351
- }
409
+ };
352
410
 
353
- // ── 单条写回(幂等) ─────────────────────────────────────────────────────────
411
+ // ── 写回 ────────────────────────────────────────────────────────────────────
354
412
 
355
413
  /**
356
- * 把一批分类结果写回(**幂等 + 事务 + 重试**)。
414
+ * 把一批分类结果写回(幂等 + 事务 + 重试)。只改 tags,不动 updated_at / created_at。
357
415
  * @returns {{written:number, unchanged:number, addedTokens:string[], details:Array}}
358
416
  */
359
- export async function applyResults(db, results, deps = {}) {
360
- const details = [];
361
- let written = 0;
362
- let unchanged = 0;
363
- const addedTokens = [];
364
- const r = await withBusyRetry(db, () => {
365
- const sel = db.prepare('SELECT tags FROM memories WHERE id = ?');
366
- const upd = db.prepare('UPDATE memories SET tags = ? WHERE id = ?');
367
- for (const res of results) {
368
- const row = sel.get(res.id);
369
- if (!row) { details.push({ id: res.id, action: 'missing' }); continue; }
370
- const { tags, added, alreadyPresent, wanted } = appendDomainTags(row.tags, res.domains);
371
- if (added.length === 0) {
417
+ export const applyResults = async (db, results, deps = {}) => {
418
+ const outcome = await withBusyRetry(db, () => {
419
+ const details = [];
420
+ const addedTokens = [];
421
+ let written = 0;
422
+ let unchanged = 0;
423
+ const readTags = db.prepare('SELECT tags FROM memories WHERE id = ?');
424
+ const writeTags = db.prepare('UPDATE memories SET tags = ? WHERE id = ?');
425
+
426
+ for (const result of results) {
427
+ const row = readTags.get(result.id);
428
+ if (!row) {
429
+ details.push({ id: result.id, action: 'missing' });
430
+ continue;
431
+ }
432
+ const merged = appendDomainTags(row.tags, result.domains);
433
+ if (!merged.added.length) {
372
434
  unchanged += 1;
373
- details.push({ id: res.id, action: wanted.length === 0 ? 'no-tag' : 'already', tags, alreadyPresent });
435
+ details.push({
436
+ id: result.id,
437
+ action: merged.wanted.length ? 'already' : 'no-tag',
438
+ tags: merged.tags,
439
+ alreadyPresent: merged.alreadyPresent,
440
+ });
374
441
  continue;
375
442
  }
376
- upd.run(tags, res.id);
443
+ writeTags.run(merged.tags, result.id);
377
444
  written += 1;
378
- addedTokens.push(...added);
379
- details.push({ id: res.id, action: 'tagged', tags, added });
445
+ addedTokens.push(...merged.added);
446
+ details.push({ id: result.id, action: 'tagged', tags: merged.tags, added: merged.added });
380
447
  }
381
- return { written, unchanged };
448
+ return { written, unchanged, details, addedTokens };
382
449
  }, deps);
383
- return { ...r, written: r.value?.written ?? 0, unchanged: r.value?.unchanged ?? 0, addedTokens, details };
384
- }
385
450
 
386
- // ── 日报一行(幂等段替换,口径同 report.mjs / doc-index.mjs) ─────────────────
451
+ const done = outcome.value;
452
+ return {
453
+ ...outcome,
454
+ written: done?.written ?? 0,
455
+ unchanged: done?.unchanged ?? 0,
456
+ addedTokens: done?.addedTokens ?? [],
457
+ details: done?.details ?? [],
458
+ };
459
+ };
460
+
461
+ // ── 日报一行(幂等段替换,口径同日报其它段) ─────────────────────────────────
387
462
 
388
463
  export const DAILY_SECTION_TITLE = '## 🏷 记忆域标签(memory-domain-tags)';
389
464
 
390
- export function renderDailyLine({ mode, stats, cost, state, limits = COST_LIMITS, stoppedReason = null }) {
391
- const used = Number(cost.promptTokens ?? 0) + Number(cost.completionTokens ?? 0);
392
- const parts = [
393
- `模式 ${mode}`,
394
- `本轮批次 ${stats.batches ?? 0}`,
395
- `新增标签条数 ${stats.tagged ?? 0}`,
396
- `无标签 ${stats.noTag ?? 0}`,
397
- `跳过(非本机owner) ${stats.skippedOwner ?? 0}`,
398
- `当日调用 ${cost.calls ?? 0}/${limits.callsPerDay}`,
399
- `当日 token ${used}/${limits.tokensPerDay}`,
465
+ // 一行摘要:模式 / 本轮批次 / 标签统计 / 跳过统计 / 当日成本,必要时补停机原因与台账条数
466
+ export const renderDailyLine = ({ mode, stats, cost, state, limits = COST_LIMITS, stoppedReason = null }) => {
467
+ const spent = Number(cost.promptTokens ?? 0) + Number(cost.completionTokens ?? 0);
468
+ const fields = [
469
+ ['模式', mode],
470
+ ['本轮批次', stats.batches ?? 0],
471
+ ['新增标签条数', stats.tagged ?? 0],
472
+ ['无标签', stats.noTag ?? 0],
473
+ ['跳过(非本机owner)', stats.skippedOwner ?? 0],
474
+ ['当日调用', `${cost.calls ?? 0}/${limits.callsPerDay}`],
475
+ ['当日 token', `${spent}/${limits.tokensPerDay}`],
400
476
  ];
477
+ const parts = fields.map(([label, value]) => `${label} ${value}`);
401
478
  if (stoppedReason) parts.push(`停机原因:${stoppedReason}`);
402
479
  if (state?.processedCount !== undefined) parts.push(`幂等台账 ${state.processedCount} 条`);
403
480
  return parts.join(' · ');
404
- }
481
+ };
405
482
 
406
- /** 在日报里做「段级替换」(找不到就追加到文件尾;文件不存在则不新建 —— 同 doc-index 口径)。 */
407
- export function updateDailySection({ file, line, dryRun = false }) {
483
+ /** 日报里做「段级替换」:有同名段就换掉段体,没有就追加到文件尾;文件不存在则不新建。 */
484
+ export const updateDailySection = ({ file, line, dryRun = false }) => {
408
485
  if (!fs.existsSync(file)) return { ok: false, reason: '日报不存在(不新建)', file };
409
- const text = fs.readFileSync(file, 'utf8');
410
- const lines = text.split('\n');
411
- const idx = lines.findIndex((l) => l.trim() === DAILY_SECTION_TITLE);
486
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
487
+ const at = lines.findIndex((row) => row.trim() === DAILY_SECTION_TITLE);
412
488
  const block = [DAILY_SECTION_TITLE, '', `> ${line}`, ''];
489
+
413
490
  let next;
414
- if (idx >= 0) {
415
- let end = idx + 1;
416
- while (end < lines.length && !(lines[end].startsWith('## ') && end > idx + 1)) end += 1;
417
- next = [...lines.slice(0, idx), ...block, ...lines.slice(end)];
491
+ if (at < 0) {
492
+ next = lines.concat(block);
418
493
  } else {
419
- next = [...lines, ...block];
494
+ // 段体 = 标题之后到下一个二级标题之前(紧跟标题的空行不算结束)
495
+ let stop = at + 1;
496
+ while (stop < lines.length && !(lines[stop].startsWith('## ') && stop > at + 1)) stop += 1;
497
+ next = [...lines.slice(0, at), ...block, ...lines.slice(stop)];
420
498
  }
421
- if (!dryRun) fs.writeFileSync(file, next.join('\n'), 'utf8');
422
- return { ok: true, replaced: idx >= 0, file };
423
- }
424
499
 
425
- export function dailyReportPath({ docsDir = path.join(process.cwd(), 'docs'), host = null, now = Date.now() } = {}) {
426
- const d = new Date(now);
427
- const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
500
+ if (!dryRun) fs.writeFileSync(file, next.join('\n'), 'utf8');
501
+ return { ok: true, replaced: at >= 0, file };
502
+ };
503
+
504
+ // 当日日报路径:<docsDir>/管家报告/<YYYY-MM-DD>-<host>.md(与日报其它段同口径)
505
+ export const dailyReportPath = ({ docsDir = path.join(process.cwd(), 'docs'), host = null, now = Date.now() } = {}) => {
506
+ const when = new Date(now);
507
+ const pad = (n) => String(n).padStart(2, '0');
508
+ const date = `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;
428
509
  return path.join(docsDir, '管家报告', `${date}-${host || os.hostname()}.md`);
429
- }
510
+ };