@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.1

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 (132) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +661 -0
  3. package/README.en.md +110 -0
  4. package/README.md +102 -2
  5. package/dsh-memory-local/FORK-BASE.json +24 -0
  6. package/dsh-memory-local/README.md +113 -0
  7. package/dsh-memory-local/build.mjs +101 -0
  8. package/dsh-memory-local/cordis.patch.yml +20 -0
  9. package/dsh-memory-local/deploy.mjs +306 -0
  10. package/dsh-memory-local/entity-status.mjs +75 -0
  11. package/dsh-memory-local/lib/bridge.mjs +108 -0
  12. package/dsh-memory-local/lib/host-adapter.js +1171 -0
  13. package/dsh-memory-local/lib/index.d.ts +128 -0
  14. package/dsh-memory-local/lib/index.js +944 -0
  15. package/dsh-memory-local/lib/injector.mjs +400 -0
  16. package/dsh-memory-local/lib/memory-read.mjs +116 -0
  17. package/dsh-memory-local/lib/module-gate.mjs +68 -0
  18. package/dsh-memory-local/lib/pointer.mjs +152 -0
  19. package/dsh-memory-local/lib/prod-gate.mjs +121 -0
  20. package/dsh-memory-local/lib/recall-log.mjs +118 -0
  21. package/dsh-memory-local/lib/recall.mjs +192 -0
  22. package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
  23. package/dsh-memory-local/lib/user-message.mjs +32 -0
  24. package/dsh-memory-local/lib/vectors-read.mjs +110 -0
  25. package/dsh-memory-local/package.json +51 -0
  26. package/dsh-memory-local/restore.mjs +259 -0
  27. package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
  28. package/dsh-memory-local/src/bridge.mjs +108 -0
  29. package/dsh-memory-local/src/host-adapter.js +1171 -0
  30. package/dsh-memory-local/src/index.d.ts +128 -0
  31. package/dsh-memory-local/src/index.js +944 -0
  32. package/dsh-memory-local/src/injector.mjs +400 -0
  33. package/dsh-memory-local/src/memory-read.mjs +116 -0
  34. package/dsh-memory-local/src/module-gate.mjs +68 -0
  35. package/dsh-memory-local/src/pointer.mjs +152 -0
  36. package/dsh-memory-local/src/recall-log.mjs +118 -0
  37. package/dsh-memory-local/src/recall.mjs +192 -0
  38. package/dsh-memory-local/src/schema-migrate.mjs +365 -0
  39. package/dsh-memory-local/src/user-message.mjs +32 -0
  40. package/dsh-memory-local/src/vectors-read.mjs +110 -0
  41. package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
  42. package/dsh-memory-local/test/probe-entity.mjs +170 -0
  43. package/dsh-memory-local/test/regression.mjs +270 -0
  44. package/dsh-memory-local/test/s2b.test.mjs +568 -0
  45. package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
  46. package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
  47. package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
  48. package/dsh-memory-local/verify-after-restart.mjs +181 -0
  49. package/lib/deploy-guard.mjs +391 -0
  50. package/lib/guard-http.mjs +93 -0
  51. package/lib/pitfall-form-map.mjs +130 -0
  52. package/lib/pitfall-score.mjs +185 -0
  53. package/lib/session-file-name.mjs +160 -0
  54. package/lib/zstd-frames.mjs +78 -0
  55. package/memory-db-tools/README.md +68 -0
  56. package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
  57. package/memory-db-tools/audit-analyze.mjs +352 -0
  58. package/memory-db-tools/audit-dump-ids.mjs +245 -0
  59. package/memory-db-tools/audit-export.mjs +270 -0
  60. package/memory-db-tools/audit-hit-probe.mjs +227 -0
  61. package/memory-db-tools/audit-ledger-probe.mjs +236 -0
  62. package/memory-db-tools/audit-persona-overlap.mjs +277 -0
  63. package/memory-db-tools/audit-struct-probe.mjs +213 -0
  64. package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
  65. package/memory-db-tools/check-memory-db.mjs +8 -0
  66. package/memory-db-tools/dump-memory-db.mjs +12 -0
  67. package/memory-db-tools/lib/memory-scope.mjs +446 -0
  68. package/memory-db-tools/list-memory-summary.mjs +477 -0
  69. package/memory-db-tools/memory-identity-check.mjs +101 -0
  70. package/memory-db-tools/memory-schema-migrate.mjs +290 -0
  71. package/memory-db-tools/memory-scope-fixture.mjs +197 -0
  72. package/memory-db-tools/read-memories.mjs +26 -0
  73. package/memory-db-tools/read-memory-db.mjs +20 -0
  74. package/memory-db-tools/refine-memories.mjs +247 -0
  75. package/memory-db-tools/show-mem-times.mjs +22 -0
  76. package/memory-engine/README.md +70 -0
  77. package/memory-engine/bench/bench.mjs +221 -0
  78. package/memory-engine/cli/distill-rollback.mjs +86 -0
  79. package/memory-engine/cli/distill.mjs +156 -0
  80. package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
  81. package/memory-engine/cli/doc-index.mjs +48 -0
  82. package/memory-engine/cli/domain-tags.mjs +251 -0
  83. package/memory-engine/cli/pitfall-hook.mjs +91 -0
  84. package/memory-engine/cli/recall.mjs +132 -0
  85. package/memory-engine/cli/selfcheck.mjs +57 -0
  86. package/memory-engine/cli/serve-start.mjs +247 -0
  87. package/memory-engine/cli/sweep.mjs +58 -0
  88. package/memory-engine/cli/sync.mjs +69 -0
  89. package/memory-engine/lib/cli-util.mjs +197 -0
  90. package/memory-engine/lib/config.mjs +135 -0
  91. package/memory-engine/lib/dirty-ledger.mjs +174 -0
  92. package/memory-engine/lib/distill-rollback.mjs +178 -0
  93. package/memory-engine/lib/distill.mjs +898 -0
  94. package/memory-engine/lib/doc-index-store.mjs +325 -0
  95. package/memory-engine/lib/doc-index.mjs +529 -0
  96. package/memory-engine/lib/doc-scan.mjs +271 -0
  97. package/memory-engine/lib/doc-summarize.mjs +305 -0
  98. package/memory-engine/lib/domain-tags.mjs +429 -0
  99. package/memory-engine/lib/embedder.mjs +242 -0
  100. package/memory-engine/lib/hashes.mjs +12 -0
  101. package/memory-engine/lib/memory-source.mjs +127 -0
  102. package/memory-engine/lib/pitfall-source.mjs +170 -0
  103. package/memory-engine/lib/policy-source.mjs +371 -0
  104. package/memory-engine/lib/recall.mjs +331 -0
  105. package/memory-engine/lib/selfcheck.mjs +131 -0
  106. package/memory-engine/lib/sweep.mjs +159 -0
  107. package/memory-engine/lib/sync.mjs +180 -0
  108. package/memory-engine/lib/tick-hook.mjs +218 -0
  109. package/memory-engine/lib/vectors-store.mjs +292 -0
  110. package/memory-engine/package-lock.json +1086 -0
  111. package/memory-engine/package.json +22 -0
  112. package/memory-engine/serve.mjs +171 -0
  113. package/memory-engine/test/01-store.test.mjs +127 -0
  114. package/memory-engine/test/02-sync.test.mjs +196 -0
  115. package/memory-engine/test/03-sweep.test.mjs +145 -0
  116. package/memory-engine/test/04-recall.test.mjs +214 -0
  117. package/memory-engine/test/05-selfcheck.test.mjs +175 -0
  118. package/memory-engine/test/06-doc-index.test.mjs +928 -0
  119. package/memory-engine/test/07-domain-tags.test.mjs +298 -0
  120. package/memory-engine/test/08-distill.test.mjs +686 -0
  121. package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
  122. package/memory-engine/test/10-tick-hook.test.mjs +252 -0
  123. package/memory-engine/test/11-policy-source.test.mjs +224 -0
  124. package/memory-engine/test/_tickhook-worker.mjs +56 -0
  125. package/memory-engine/test/fixtures.mjs +205 -0
  126. package/memory-engine/test/harness.mjs +73 -0
  127. package/memory-engine/test/run.mjs +35 -0
  128. package/memory-engine/tools/bridge-smoke.mjs +80 -0
  129. package/memory-engine/tools/fetch-model.mjs +128 -0
  130. package/memory-engine/tools/smoke-recall.mjs +76 -0
  131. package/package.json +53 -4
  132. package/persona-memory-audit.mjs +217 -0
@@ -0,0 +1,429 @@
1
+ // 记忆引擎 v2 · S3b —— 域标签补齐(方案 §3.6 幂等键改「去重键」 / §5.4 / §7 / §3.9 D2 从严)
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
+ // 判不出归属的条 **跳过并记账**(宁可漏,不可错送)
14
+ //
15
+ // 两个刻意的设计决定(写在这里防将来"照字面实现"踩坑):
16
+ // ① 分类模型输出只用来**追加标签**,不产生任何其它写入 ⇒ 半完成态无害(§5.4 域标签行);
17
+ // ② 追加标签**不动 `updated_at`** —— 否则会把这批老条顶进 `memory:recall` 段最前,
18
+ // 并让提炼窗口(按 updated_at 取变更)误判为"新内容"(§3.5 输入行)。
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import os from 'node:os';
22
+ import { DatabaseSync } from 'node:sqlite';
23
+
24
+ // ── 常量 ────────────────────────────────────────────────────────────────────
25
+
26
+ /** 域标签集(显示名 = 方案 §3.6 原文)。 */
27
+ export const DOMAIN_TAGS = Object.freeze(['家庭', '工作', '健康', '技术', 'DSH生态', '梗']);
28
+
29
+ /** 显示名 → 落库 token。`tags` 一律按插件 `normalizeTags` 的口径小写化(`DSH生态` → `dsh生态`)。 */
30
+ export function normalizeTag(s) {
31
+ return String(s ?? '').trim().toLowerCase().replace(/\s+/g, '-');
32
+ }
33
+
34
+ /** 显示名 → 规范 token 的映射(兼容大小写/空白写法的输入)。 */
35
+ export const DOMAIN_TOKEN_BY_LABEL = Object.freeze(
36
+ Object.fromEntries(DOMAIN_TAGS.map((label) => [label, normalizeTag(label)])),
37
+ );
38
+ export const DOMAIN_TOKENS = Object.freeze(DOMAIN_TAGS.map((l) => normalizeTag(l)));
39
+
40
+ /** 成本闸默认值(§3.5,与提炼 job 同一档)。 */
41
+ export const COST_LIMITS = Object.freeze({ callsPerDay: 20, tokensPerDay: 150000 });
42
+
43
+ /** 并发保护默认值(§3.6 / §7)。 */
44
+ export const RETRY = Object.freeze({ busyTimeoutMs: 5000, maxAttempts: 3, backoffMs: [1000, 4000, 16000], totalCapMs: 60000 });
45
+
46
+ /**
47
+ * 分类模型默认档(**2026-09-21 主人拍板:记忆线一律走本鱼链**;真源 = 方案 §3.5 订正注 2)。
48
+ * 不用 `deepseek-v4-pro`(贵 4.4 倍,分类任务不需要强推理);不许改回第三方(D2 延长效:新增外发面须先拍)。
49
+ */
50
+ export const DEFAULT_LLM = Object.freeze({
51
+ provider: 'deepseek',
52
+ baseUrl: 'https://api.deepseek.com',
53
+ model: 'deepseek-flash',
54
+ credentialKey: 'DEEPSEEK_API_KEY',
55
+ maxTokens: 2048,
56
+ timeoutMs: 60000,
57
+ });
58
+
59
+ export const STATE_SCHEMA = 1;
60
+
61
+ // ── 小工具 ──────────────────────────────────────────────────────────────────
62
+
63
+ /** 稳定哈希(域标签集 → 短哈希;幂等键的右半)。 */
64
+ export function tagSetHash(tokens) {
65
+ 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;
72
+ }
73
+ h ^= 0x2c; // 分隔符(防 "ab"+"c" 与 "a"+"bc" 撞)
74
+ h = Math.imul(h, 0x01000193) >>> 0;
75
+ }
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) {
95
+ const tokens = tokensOf(existingTags);
96
+ const present = new Set(tokens);
97
+ const wanted = [];
98
+ 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);
103
+ }
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
+ }
108
+
109
+ // ── 库操作(只读取样 + 带重试的写) ──────────────────────────────────────────
110
+
111
+ /** 打开库(默认只读;`write:true` 时带 busy_timeout)。 */
112
+ export function openDb(dbPath, { write = false } = {}) {
113
+ // ⚠️ node:sqlite 的第二参**不接受 undefined**("options argument must be an object")⇒ 分支显式构造
114
+ const db = write ? new DatabaseSync(dbPath) : new DatabaseSync(dbPath, { readOnly: true });
115
+ if (write) db.exec(`PRAGMA busy_timeout = ${RETRY.busyTimeoutMs}`);
116
+ return db;
117
+ }
118
+
119
+ /**
120
+ * 带重试的写事务执行器(§3.6 / §7 Z2:`busy_timeout` + 重试 3 次、总退避上限 60 s)。
121
+ * @param {object} db - 写连接
122
+ * @param {() => any} fn - 事务体
123
+ * @param {{sleep?:Function, now?:Function}} [deps] - 便于单测注入(不真等)
124
+ * @returns {{ok:boolean, value?:any, attempts:number, error?:string, waitedMs:number}}
125
+ */
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();
129
+ let waited = 0;
130
+ let lastErr = null;
131
+ for (let attempt = 1; attempt <= RETRY.maxAttempts; attempt += 1) {
132
+ 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;
147
+ 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 };
150
+ }
151
+ await sleep(backoff);
152
+ waited += backoff;
153
+ }
154
+ }
155
+ return { ok: false, attempts: RETRY.maxAttempts, error: String(lastErr && lastErr.message ? lastErr.message : lastErr), waitedMs: waited };
156
+ }
157
+
158
+ /** 读待处理条目(只读):id/text/tags/owner_user(若列不存在则 `ownerColumn:false`)。 */
159
+ export function readCandidates(dbPath, { limit = 0, maxCharsPerItem = 240 } = {}) {
160
+ const db = openDb(dbPath);
161
+ 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 };
177
+ } finally {
178
+ db.close();
179
+ }
180
+ }
181
+
182
+ // ── LLM(DeepSeek 官方 OpenAI 兼容端点;key 读 .credentials.yaml) ─────────────
183
+
184
+ /** 从 .credentials.yaml 读 key(去引号 + 去 flow-map 行尾逗号)。 */
185
+ export function readCredential(keyName, credFile = path.join(os.homedir(), '.dsh', '.credentials.yaml')) {
186
+ 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;
194
+ } catch {
195
+ return null;
196
+ }
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()}`);
218
+ return `共 ${items.length} 条,请逐条分类并按要求输出 JSON:\n\n${lines.join('\n')}`;
219
+ }
220
+
221
+ /** 从模型回复里抠 JSON(容忍代码围栏 / 前后废话)。 */
222
+ export function extractJson(content) {
223
+ const text = String(content ?? '').trim();
224
+ 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;
229
+ try {
230
+ return JSON.parse(body.slice(start, end + 1));
231
+ } catch {
232
+ return null;
233
+ }
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);
252
+ try {
253
+ const res = await fetchImpl(`${baseUrl}/chat/completions`, {
254
+ method: 'POST',
255
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
256
+ body: JSON.stringify(body),
257
+ signal: ctrl.signal,
258
+ });
259
+ const text = await res.text();
260
+ 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 ?? {};
265
+ const parsed = extractJson(content);
266
+ 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);
268
+ if (!list) return { ok: false, error: 'JSON 结构不含 items 数组', raw: String(content).slice(0, 300), usage };
269
+ return { ok: true, items: list, usage, content };
270
+ } catch (e) {
271
+ return { ok: false, error: `请求失败:${e && e.message ? e.message : e}` };
272
+ } finally {
273
+ clearTimeout(timer);
274
+ }
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
+ }
297
+
298
+ export function readState(stateFile, dbPath, nowIso) {
299
+ 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;
307
+ } catch {
308
+ return defaultState(dbPath, nowIso);
309
+ }
310
+ }
311
+
312
+ export function writeState(stateFile, state) {
313
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
314
+ const tmp = `${stateFile}.tmp-${process.pid}`;
315
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
316
+ try {
317
+ fs.renameSync(tmp, stateFile);
318
+ } catch {
319
+ fs.copyFileSync(tmp, stateFile);
320
+ try { fs.unlinkSync(tmp); } catch { /* 忽略 */ }
321
+ }
322
+ return stateFile;
323
+ }
324
+
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);
328
+ const calls = Number(state.cost.calls ?? 0);
329
+ 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})` };
333
+ }
334
+ return { ok: true };
335
+ }
336
+
337
+ // ── 快照 ────────────────────────────────────────────────────────────────────
338
+
339
+ /** `VACUUM INTO` 快照(只读连接;§3.6 前置)。 */
340
+ export function vacuumInto(srcDbPath, destFile) {
341
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
342
+ if (fs.existsSync(destFile)) fs.rmSync(destFile);
343
+ const db = openDb(srcDbPath);
344
+ try {
345
+ db.exec(`VACUUM INTO '${String(destFile).replace(/\\/g, '/').replace(/'/g, "''")}'`);
346
+ } finally {
347
+ db.close();
348
+ }
349
+ if (!fs.existsSync(destFile)) throw new Error(`VACUUM INTO 未产出快照:${destFile}`);
350
+ return destFile;
351
+ }
352
+
353
+ // ── 单条写回(幂等) ─────────────────────────────────────────────────────────
354
+
355
+ /**
356
+ * 把一批分类结果写回(**幂等 + 事务 + 重试**)。
357
+ * @returns {{written:number, unchanged:number, addedTokens:string[], details:Array}}
358
+ */
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) {
372
+ unchanged += 1;
373
+ details.push({ id: res.id, action: wanted.length === 0 ? 'no-tag' : 'already', tags, alreadyPresent });
374
+ continue;
375
+ }
376
+ upd.run(tags, res.id);
377
+ written += 1;
378
+ addedTokens.push(...added);
379
+ details.push({ id: res.id, action: 'tagged', tags, added });
380
+ }
381
+ return { written, unchanged };
382
+ }, deps);
383
+ return { ...r, written: r.value?.written ?? 0, unchanged: r.value?.unchanged ?? 0, addedTokens, details };
384
+ }
385
+
386
+ // ── 日报一行(幂等段替换,口径同 report.mjs / doc-index.mjs) ─────────────────
387
+
388
+ export const DAILY_SECTION_TITLE = '## 🏷 记忆域标签(memory-domain-tags)';
389
+
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}`,
400
+ ];
401
+ if (stoppedReason) parts.push(`停机原因:${stoppedReason}`);
402
+ if (state?.processedCount !== undefined) parts.push(`幂等台账 ${state.processedCount} 条`);
403
+ return parts.join(' · ');
404
+ }
405
+
406
+ /** 在日报里做「段级替换」(找不到就追加到文件尾;文件不存在则不新建 —— 同 doc-index 口径)。 */
407
+ export function updateDailySection({ file, line, dryRun = false }) {
408
+ 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);
412
+ const block = [DAILY_SECTION_TITLE, '', `> ${line}`, ''];
413
+ 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)];
418
+ } else {
419
+ next = [...lines, ...block];
420
+ }
421
+ if (!dryRun) fs.writeFileSync(file, next.join('\n'), 'utf8');
422
+ return { ok: true, replaced: idx >= 0, file };
423
+ }
424
+
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')}`;
428
+ return path.join(docsDir, '管家报告', `${date}-${host || os.hostname()}.md`);
429
+ }
@@ -0,0 +1,242 @@
1
+ // 记忆引擎 v2 · 离线层 —— 编码器(可注入)
2
+ //
3
+ // 三种实现,接口一致(encode / encodeBatch / health / describe):
4
+ // 1. createLocalEmbedder —— 本地 ONNX(@huggingface/transformers + bge-small-zh-v1.5)
5
+ // 2. createHttpEmbedder —— 走回环编码桥(serve.mjs,127.0.0.1:8201)
6
+ // 3. createStubEmbedder —— 零依赖确定性哈希编码器:**仅供测试骨架**,无任何语义质量
7
+ //
8
+ // 设计要点:同步/召回核心一律依赖本接口,不直接 import 模型运行时
9
+ // ⇒ 模型拉不动时,骨架与单测仍可完整跑通(本卡 §二.6 的退路)。
10
+
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ /** 确定性 stub:char n-gram 哈希到 dim 维 + L2 归一化(测试用,禁止用于生产召回) */
16
+ export function createStubEmbedder({ dim = 512, modelId = 'stub-ngram', sleepMs = 0 } = {}) {
17
+ let encodeCount = 0;
18
+ const encodeOne = (text) => {
19
+ const v = new Float32Array(dim);
20
+ const s = String(text ?? '').toLowerCase();
21
+ const grams = [];
22
+ for (let n = 1; n <= 3; n += 1) {
23
+ for (let i = 0; i + n <= s.length; i += 1) grams.push(s.slice(i, i + n));
24
+ }
25
+ for (const g of grams) {
26
+ const h = crypto.createHash('md5').update(g).digest();
27
+ const idx = ((h[0] << 8) | h[1]) % dim;
28
+ const sign = h[2] % 2 === 0 ? 1 : -1;
29
+ v[idx] += sign;
30
+ }
31
+ let norm = 0;
32
+ for (let i = 0; i < dim; i += 1) norm += v[i] * v[i];
33
+ norm = Math.sqrt(norm) || 1;
34
+ for (let i = 0; i < dim; i += 1) v[i] /= norm;
35
+ return v;
36
+ };
37
+ return {
38
+ kind: 'stub',
39
+ modelId,
40
+ dim,
41
+ describe: () => ({ kind: 'stub', modelId, dim, note: '确定性哈希编码器,仅用于骨架自测' }),
42
+ async health() {
43
+ return { ready: true, modelLoading: false, model: modelId, dim };
44
+ },
45
+ async encode(text) {
46
+ if (sleepMs) await new Promise((r) => setTimeout(r, sleepMs));
47
+ encodeCount += 1;
48
+ return encodeOne(text);
49
+ },
50
+ async encodeBatch(texts) {
51
+ const out = [];
52
+ for (const t of texts) {
53
+ if (sleepMs) await new Promise((r) => setTimeout(r, sleepMs));
54
+ encodeCount += 1;
55
+ out.push(encodeOne(t));
56
+ }
57
+ return out;
58
+ },
59
+ stats: () => ({ encodeCount }),
60
+ };
61
+ }
62
+
63
+ /** 回环编码桥客户端(插件侧 / CLI 侧共用;方案 §3.1 编码通道契约:超时 250ms) */
64
+ export function createHttpEmbedder({
65
+ host = '127.0.0.1',
66
+ port = 8201,
67
+ timeoutMs = 250,
68
+ modelId = null,
69
+ dim = null,
70
+ } = {}) {
71
+ const base = `http://${host}:${port}`;
72
+ let healthCache = null;
73
+ const fetchJson = async (url, init = {}, ms = timeoutMs) => {
74
+ const ac = new AbortController();
75
+ const t = setTimeout(() => ac.abort(), ms);
76
+ try {
77
+ const res = await fetch(url, { ...init, signal: ac.signal });
78
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
79
+ return await res.json();
80
+ } finally {
81
+ clearTimeout(t);
82
+ }
83
+ };
84
+ return {
85
+ kind: 'http',
86
+ get modelId() {
87
+ return modelId || (healthCache && healthCache.model) || 'unknown';
88
+ },
89
+ get dim() {
90
+ return dim || (healthCache && healthCache.dim) || 0;
91
+ },
92
+ describe: () => ({ kind: 'http', base, modelId, dim, timeoutMs }),
93
+ async health() {
94
+ try {
95
+ healthCache = await fetchJson(`${base}/health`, {}, 2000);
96
+ return { ...healthCache, bridge: base };
97
+ } catch (e) {
98
+ return { ready: false, modelLoading: false, error: String(e && e.message ? e.message : e), bridge: base };
99
+ }
100
+ },
101
+ async encode(text) {
102
+ const out = await fetchJson(
103
+ `${base}/encode`,
104
+ {
105
+ method: 'POST',
106
+ headers: { 'content-type': 'application/json' },
107
+ body: JSON.stringify({ texts: [String(text ?? '')] }),
108
+ },
109
+ timeoutMs,
110
+ );
111
+ return Float32Array.from(out.vectors[0]);
112
+ },
113
+ async encodeBatch(texts) {
114
+ if (!texts.length) return [];
115
+ const out = await fetchJson(
116
+ `${base}/encode`,
117
+ {
118
+ method: 'POST',
119
+ headers: { 'content-type': 'application/json' },
120
+ body: JSON.stringify({ texts: texts.map((t) => String(t ?? '')) }),
121
+ },
122
+ Math.max(timeoutMs, 30000),
123
+ );
124
+ return out.vectors.map((v) => Float32Array.from(v));
125
+ },
126
+ stats: () => ({ encodeCount: -1 }),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * 本地 ONNX 编码器(bge-small-zh-v1.5,512 维,CPU)。
132
+ * 动态 import 模型运行时 ⇒ 未安装依赖时本模块仍可被 import(骨架/单测不受影响)。
133
+ */
134
+ export async function createLocalEmbedder({
135
+ modelDir,
136
+ modelId = 'bge-small-zh-v1.5',
137
+ dim = 512,
138
+ quantized = false,
139
+ dtype = null,
140
+ maxLength = 512,
141
+ pooling = 'cls',
142
+ queryPrefix = '',
143
+ } = {}) {
144
+ const mod = await import('@huggingface/transformers');
145
+ const { env, AutoTokenizer, AutoModel } = mod;
146
+ // 布局自适应:transformers.js 的 env.localModelPath 是「模型根目录」(其下按 modelId 建子目录),
147
+ // 而本项目的 modelDir 语义是「直接放 config.json 的权重目录」⇒ 两种布局都支持。
148
+ let localRoot = modelDir;
149
+ let localId = modelId;
150
+ if (fs.existsSync(path.join(modelDir, 'config.json'))) {
151
+ localRoot = path.dirname(modelDir);
152
+ localId = path.basename(modelDir);
153
+ }
154
+ env.allowRemoteModels = false;
155
+ env.allowLocalModels = true;
156
+ env.localModelPath = localRoot;
157
+ env.useBrowserCache = false;
158
+
159
+ const loadOpts = dtype
160
+ ? { dtype }
161
+ : quantized
162
+ ? { dtype: 'q8' }
163
+ : {};
164
+ const tokenizer = await AutoTokenizer.from_pretrained(localId, { local_files_only: true });
165
+ const model = await AutoModel.from_pretrained(localId, { local_files_only: true, ...loadOpts });
166
+
167
+ const encodeOne = async (text) => {
168
+ const input = String(text ?? '');
169
+ const inputs = await tokenizer(input, { padding: true, truncation: true, max_length: maxLength });
170
+ const out = await model(inputs);
171
+ const hidden = out.last_hidden_state ?? out[Object.keys(out)[0]];
172
+ const [, seq, d] = hidden.dims;
173
+ const data = hidden.data;
174
+ const v = new Float32Array(d);
175
+ if (pooling === 'cls') {
176
+ for (let i = 0; i < d; i += 1) v[i] = data[i];
177
+ } else {
178
+ // mean pooling(按 attention_mask 加权)
179
+ const mask = inputs.attention_mask?.data;
180
+ let n = 0;
181
+ for (let t = 0; t < seq; t += 1) {
182
+ const m = mask ? Number(mask[t]) : 1;
183
+ if (!m) continue;
184
+ n += 1;
185
+ for (let i = 0; i < d; i += 1) v[i] += data[t * d + i];
186
+ }
187
+ n = n || 1;
188
+ for (let i = 0; i < d; i += 1) v[i] /= n;
189
+ }
190
+ let norm = 0;
191
+ for (let i = 0; i < d; i += 1) norm += v[i] * v[i];
192
+ norm = Math.sqrt(norm) || 1;
193
+ for (let i = 0; i < d; i += 1) v[i] /= norm;
194
+ return v;
195
+ };
196
+
197
+ const actualDim = await (async () => {
198
+ const probe = await encodeOne('维度自检');
199
+ return probe.length;
200
+ })();
201
+
202
+ return {
203
+ kind: 'local',
204
+ modelId,
205
+ dim: actualDim,
206
+ configuredDim: dim,
207
+ describe: () => ({
208
+ kind: 'local',
209
+ modelId,
210
+ modelDir,
211
+ dim: actualDim,
212
+ pooling,
213
+ quantized,
214
+ dtype: dtype || (quantized ? 'q8' : 'fp32'),
215
+ queryPrefix,
216
+ }),
217
+ async health() {
218
+ return {
219
+ ready: true,
220
+ modelLoading: false,
221
+ model: modelId,
222
+ dim: actualDim,
223
+ modelDir,
224
+ };
225
+ },
226
+ async encode(text) {
227
+ return encodeOne(String(queryPrefix || '') + String(text ?? ''));
228
+ },
229
+ async encodeBatch(texts) {
230
+ const out = [];
231
+ for (const t of texts) out.push(await encodeOne(t));
232
+ return out;
233
+ },
234
+ stats: () => ({ encodeCount: -1 }),
235
+ };
236
+ }
237
+
238
+ /** 按配置挑一个可用编码器(桥优先 → 本地 → 报错) */
239
+ export async function createAutoEmbedder({ prefer = 'local', ...opts } = {}) {
240
+ if (prefer === 'http') return createHttpEmbedder(opts);
241
+ return createLocalEmbedder(opts);
242
+ }
@@ -0,0 +1,12 @@
1
+ // 记忆引擎 v2 · 离线层 —— 哈希工具
2
+ import crypto from 'node:crypto';
3
+
4
+ /** 记忆正文哈希(方案 §3.1:text_hash = sha256(text)) */
5
+ export function textHash(text) {
6
+ return crypto.createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex');
7
+ }
8
+
9
+ /** 稳定摘要(日志/报告用) */
10
+ export function shortHash(h, n = 12) {
11
+ return String(h || '').slice(0, n);
12
+ }