ai-helper-agent 2.1.0 → 2.3.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 (57) hide show
  1. package/README.md +114 -2
  2. package/README.zh-CN.md +114 -2
  3. package/bin/agent.js +150 -0
  4. package/dist/assets/{agent-store-C4hZkQT5.js → agent-store-qLZ_Nmmz.js} +2 -2
  5. package/dist/assets/{agent-store-C4hZkQT5.js.map → agent-store-qLZ_Nmmz.js.map} +1 -1
  6. package/dist/assets/background.js +106 -113
  7. package/dist/assets/background.js.map +1 -1
  8. package/dist/assets/content.js +82 -80
  9. package/dist/assets/{i18n-CAh-vB_T.js → i18n-sOTxZemV.js} +7 -7
  10. package/dist/assets/i18n-sOTxZemV.js.map +1 -0
  11. package/dist/assets/model-profiles-DcTdflVP.js +25 -0
  12. package/dist/assets/model-profiles-DcTdflVP.js.map +1 -0
  13. package/dist/assets/options-B6ca-cwx.css +1 -0
  14. package/dist/assets/options.js +218 -197
  15. package/dist/assets/options.js.map +1 -1
  16. package/dist/assets/page-snapshot-renderer-C-4bo-cW.js +4 -0
  17. package/dist/assets/page-snapshot-renderer-C-4bo-cW.js.map +1 -0
  18. package/dist/assets/{session-store-uZ_LNasH.js → session-store-U8WKrdGX.js} +4 -4
  19. package/dist/assets/session-store-U8WKrdGX.js.map +1 -0
  20. package/dist/assets/side_panel.css +1 -1
  21. package/dist/assets/side_panel.js +344 -304
  22. package/dist/assets/side_panel.js.map +1 -1
  23. package/dist/assets/version-DzUoXKcK.js +2 -0
  24. package/dist/assets/version-DzUoXKcK.js.map +1 -0
  25. package/dist/manifest.json +7 -4
  26. package/dist/offscreen/offscreen.js +7 -7
  27. package/dist/options.html +111 -12
  28. package/dist/side_panel.html +29 -6
  29. package/package.json +1 -1
  30. package/src/config.js +72 -24
  31. package/src/crypto-lite.js +31 -0
  32. package/src/install-mode.js +128 -0
  33. package/src/locales/cli.json +24 -0
  34. package/src/locales/en.json +55 -0
  35. package/src/locales/zh.json +55 -0
  36. package/src/memory/embedding-config.js +95 -0
  37. package/src/memory/fusion.js +60 -0
  38. package/src/memory/injection.js +118 -0
  39. package/src/memory/routes.js +193 -0
  40. package/src/memory/service.js +957 -0
  41. package/src/memory/tags.js +62 -0
  42. package/src/memory/tier.js +37 -0
  43. package/src/memory/tokens.js +158 -0
  44. package/src/memory/vectors.js +128 -0
  45. package/src/rag/embedding/index.js +7 -0
  46. package/src/rag/embedding/local.js +25 -0
  47. package/src/rag/install.js +4 -0
  48. package/src/rag/keyword-match.js +112 -0
  49. package/src/rag/manager.js +15 -0
  50. package/src/rag/searcher.js +15 -87
  51. package/src/server.js +9 -1
  52. package/dist/assets/i18n-CAh-vB_T.js.map +0 -1
  53. package/dist/assets/model-profiles-CGxULM3O.js +0 -25
  54. package/dist/assets/model-profiles-CGxULM3O.js.map +0 -1
  55. package/dist/assets/options-Df_1Dtn8.css +0 -1
  56. package/dist/assets/session-store-uZ_LNasH.js.map +0 -1
  57. /package/dist/_locales/{zh → zh_CN}/messages.json +0 -0
@@ -0,0 +1,957 @@
1
+ // agent/src/memory/service.js - 长期记忆业务逻辑(自扩展端 tool-memory.js 下推)
2
+ // 召回有两条通道:关键词通道(缺省即全部)+ 可选向量通道(memoryEmbedding.enabled)。
3
+ // 关键词通道自 2026-10-13 起与知识库共用 rag/keyword-match.js 的片段化匹配(见 memory/tokens.js)。
4
+ // 逻辑与数据文件同进程:fs 直接读写 MEMORY_DIR/global-memory.json,互斥锁进程内生效。
5
+ // store/recall/manage 按 action 分发;文案经调用方传入的请求语言 t 闭包渲染。
6
+
7
+ import { readFile, writeFile } from 'fs/promises';
8
+ import { join } from 'path';
9
+ import { MEMORY_DIR } from '../config.js';
10
+ import { calcMemoryValue, selectMemoriesForInjection } from './injection.js';
11
+ import { extractMemoryTerms, keywordScore, buildDocFreqFilter, memorySearchText, KEYWORD_MIN_UNITS } from './tokens.js';
12
+ import { getMemoryEmbeddingConfig, toEmbeddingProviderConfig } from './embedding-config.js';
13
+ import { createVectorStoreFacade, memoryModelKey, cosine } from './vectors.js';
14
+ import { resolveMemoryTier, isVectorTier } from './tier.js';
15
+ import { selectSemanticHits, rrfFuse } from './fusion.js';
16
+ import { hasTagRequest, filterByTags, applyTagRankLift } from './tags.js';
17
+ import { isRagAvailable } from '../rag/detect.js';
18
+ import { EMBED_BATCH_SIZE } from '../rag/config.js';
19
+ import { createEmbeddingProvider } from '../rag/embedding/index.js';
20
+
21
+ // 记忆数据结构默认值(迁移自 tool-memory.js:137-144,字段与版本结构保持一致)
22
+ const DEFAULT_MEMORY_DATA = () => ({
23
+ version: 1,
24
+ updatedAt: new Date().toISOString(),
25
+ stats: { totalFacts: 0, totalSummaries: 0, lastReviewAt: null },
26
+ facts: [],
27
+ summaries: [],
28
+ meta: { maxFacts: 50, maxSummaries: 20, reviewThreshold: 0.8 }
29
+ });
30
+
31
+ /**
32
+ * 生成记忆ID(迁移自 tool-memory.js:190-195)
33
+ */
34
+ function generateMemoryId(type) {
35
+ const prefix = type === 'fact' ? 'fact' : 'sum';
36
+ const timestamp = Date.now().toString(36);
37
+ const random = Math.random().toString(36).substring(2, 6);
38
+ return `${prefix}_${timestamp}_${random}`;
39
+ }
40
+
41
+ // 查询向量的一次嵌入上限(与 embedding 探活端点同口径):超时即降级到关键词单通道
42
+ const MEMORY_EMBED_TIMEOUT_MS = 8000;
43
+
44
+ // recall 锁外补齐失败后的冷却窗口:坏端点下每次召回都为补齐重付一次超时(默认 8s),
45
+ // 冷却期内直接跳过补齐、只用手上已有向量算语义名次,60s 后自动再试
46
+ const RECALL_BACKFILL_COOLDOWN_MS = 60_000;
47
+
48
+ // 记忆类型白名单。旧实现用 type === "fact" ? facts : summaries 分桶,于是任何非 fact 的值
49
+ // (包括模型打错的 note / Fact / fact1)都会被当成摘要写进 summaries,还抢走它的配额
50
+ // (maxSummaries 只有 20)。校验必须显式,不能靠三元表达式的 else 兜底。
51
+ const MEMORY_TYPES = ["fact", "summary"];
52
+
53
+ // recall 的过滤维度比写入多一个 all
54
+ const RECALL_MEMORY_TYPES = ["all", ...MEMORY_TYPES];
55
+
56
+ // importance 的量纲由展示文案定:locales 里写的是 {importance}/10;注入侧 Tier1 常驻阈值 8、
57
+ // 摘要默认 6、缺省 5 全落在 1..10 内,因此钳制区间取 1..10。
58
+ const IMPORTANCE_MIN = 1;
59
+ const IMPORTANCE_MAX = 10;
60
+ const IMPORTANCE_DEFAULT = 5;
61
+
62
+ // 0 是越界值而不是"未传"。旧代码 add 用 importance || 5(0 被静默吞成 5),update 用
63
+ // !== undefined(0 原样写入)——同一字段两条路径口径不一致;本函数把两边收到同一处。
64
+ // 返回 null 表示"未提供或不可解析",由调用方区分这两种情况。
65
+ function normalizeImportance(value) {
66
+ const n = Number(value);
67
+ if (!Number.isFinite(n)) return null;
68
+ return Math.min(IMPORTANCE_MAX, Math.max(IMPORTANCE_MIN, Math.round(n)));
69
+ }
70
+
71
+ /**
72
+ * 词面命中门槛:整词/整段命中可直接成命中;只碰碎块时需单元素 ≥ 2 才算。
73
+ * 这一条是碎 bigram 噪声的真正出口(旧实现里「写网」「个国」这类随机 2 字块也能凑出分)。
74
+ * 门槛只看 units/full 不看 score:整句原样命中的 +5 不能单独救回一条无区分度的条目
75
+ *(否则 query 'rag' 又会因 'storage' 的子串包含而误伤)。
76
+ */
77
+ function isKeywordHit(s) {
78
+ return s.full || s.units >= KEYWORD_MIN_UNITS;
79
+ }
80
+
81
+ /**
82
+ * 深度命中:整词/整段,或某个查询单元**自己**就过了门槛。
83
+ * 与 isKeywordHit 的区别只在来源——多个 2 字碎块凑起来的命中能过门槛但不算深度,
84
+ * 它必须排在一次长片段命中之后(2026-10-13 文件头就写着「排序方向是反的」这笔账,
85
+ * 单元制修了分值,名次上还没修:浅碎块靠多字段重复照样能顶到前面)。
86
+ */
87
+ function isDeepKeywordHit(s) {
88
+ return s.full || s.best >= KEYWORD_MIN_UNITS;
89
+ }
90
+
91
+ /**
92
+ * 词面通道唯一比较器:深度命中优先,其后按分数。
93
+ * A 档单通道与融合档的 kwRanked 必须共用它——两处的注释本来就要求口径逐字一致,
94
+ * 各写一份会让「开没开向量档」改变碎块条目的地位,那是比噪声更难查的行为分叉。
95
+ */
96
+ function byKeywordRank(a, b) {
97
+ return (isDeepKeywordHit(b) ? 1 : 0) - (isDeepKeywordHit(a) ? 1 : 0) || b.score - a.score;
98
+ }
99
+
100
+ /**
101
+ * 创建记忆服务实例
102
+ * @param {object} [options] 注入点:filePath / vectorsFilePath 指定数据与向量缓存路径(测试用),
103
+ * embeddingConfigProvider / tierResolver / embedderFactory / embedTimeoutMs 用于关闭网络与真实配置依赖。
104
+ * 缺省全部走真实实现:向量通道只在 memoryEmbedding 显式启用后开启,未启用时 recall 与历史行为一致。
105
+ * @returns {{store: Function, recall: Function, manage: Function, injectSelect: Function, backfillVectors: Function, readAll: Function}}
106
+ */
107
+ export function createMemoryService({
108
+ filePath = join(MEMORY_DIR, 'global-memory.json'),
109
+ vectorsFilePath,
110
+ embeddingConfigProvider = getMemoryEmbeddingConfig,
111
+ embedderFactory = (cfg) => createEmbeddingProvider(cfg),
112
+ tierResolver = (cfg) => resolveMemoryTier(cfg, isRagAvailable()),
113
+ embedTimeoutMs = MEMORY_EMBED_TIMEOUT_MS
114
+ } = {}) {
115
+ const vectors = createVectorStoreFacade(vectorsFilePath ? { filePath: vectorsFilePath } : {});
116
+ let cachedProvider = null;
117
+ let cachedProviderKey = null;
118
+
119
+ // 补齐失败的冷却记录(按 modelKey 记账:换模型/改配置视为新一次尝试,不继承旧端的失败)
120
+ const backfillFail = { key: null, until: 0 };
121
+
122
+ /**
123
+ * provider 按**全部生效配置**复用:缓存键取最终交给工厂的完整配置。
124
+ * 此前键里只有 modelKey(mode|modelName|dimensions)——改 baseUrl / apiKey / queryPrefix
125
+ * 都不改变它,旧 provider(旧端点/旧 key/旧前缀)会一直用到重启为止,与「改配置即重建」相悖。
126
+ */
127
+ function getProvider() {
128
+ const cfg = embeddingConfigProvider();
129
+ const providerCfg = toEmbeddingProviderConfig(cfg);
130
+ const key = JSON.stringify(providerCfg);
131
+ if (!cachedProvider || cachedProviderKey !== key) {
132
+ cachedProvider = embedderFactory(providerCfg);
133
+ cachedProviderKey = key;
134
+ }
135
+ return { provider: cachedProvider, cfg };
136
+ }
137
+
138
+ /** 嵌入调用带超时上限:外部 embedding 端点挂起不能把一次召回无限拖住(超时即退回关键词通道) */
139
+ async function embedWithTimeout(promise) {
140
+ let timer;
141
+ try {
142
+ return await Promise.race([
143
+ Promise.resolve(promise),
144
+ new Promise((_, reject) => {
145
+ timer = setTimeout(() => reject(new Error('embedding_timeout')), embedTimeoutMs);
146
+ })
147
+ ]);
148
+ } finally {
149
+ clearTimeout(timer);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * 新增/改写记忆后异步补一个向量(懒补)。
155
+ * 绝不 await:嵌入是外部网络调用,等它就等于给每次 store 多加一个往返。
156
+ * 失败只 warn——派生缓存缺项不影响正确性,recall 命中该条目时还会再补一次。
157
+ */
158
+ function queueVectorBackfill(id, memory) {
159
+ let cfg;
160
+ try {
161
+ cfg = embeddingConfigProvider();
162
+ if (!isVectorTier(tierResolver(cfg))) return;
163
+ } catch (err) {
164
+ console.warn('[Memory] tier resolve failed, skip lazy vector backfill:', err.message);
165
+ return;
166
+ }
167
+ queueMicrotask(async () => {
168
+ try {
169
+ const vecs = await embedWithTimeout(getProvider().provider.embedDocuments([memorySearchText(memory)]));
170
+ const vec = Array.isArray(vecs) ? vecs[0] : null;
171
+ if (Array.isArray(vec) && vec.length) await vectors.upsertVectors([[id, vec]], memoryModelKey(cfg));
172
+ } catch (err) {
173
+ console.warn('[Memory] lazy vector backfill failed:', err.message);
174
+ }
175
+ });
176
+ }
177
+
178
+ // 进程内互斥锁:保护 read-modify-write(替代扩展端 SW 内存锁,tool-memory.js:111-130 语义)
179
+ let lockChain = Promise.resolve();
180
+ function withMemoryLock(task) {
181
+ let release;
182
+ const gate = new Promise((r) => { release = r; });
183
+ const run = lockChain.then(task).finally(release);
184
+ lockChain = lockChain.then(() => gate);
185
+ return run;
186
+ }
187
+
188
+ // 读取记忆文件(迁移自 readMemoryFile tool-memory.js:150-173;文件不存在返回默认结构)
189
+ async function readMemoryFile() {
190
+ let content;
191
+ try {
192
+ content = await readFile(filePath, 'utf8');
193
+ } catch (err) {
194
+ if (err.code === 'ENOENT') {
195
+ return { success: true, data: DEFAULT_MEMORY_DATA() };
196
+ }
197
+ return { success: false, error: err.message };
198
+ }
199
+ try {
200
+ const data = JSON.parse(content);
201
+ const defaults = DEFAULT_MEMORY_DATA();
202
+ return {
203
+ success: true,
204
+ data: {
205
+ ...defaults,
206
+ ...data,
207
+ stats: { ...defaults.stats, ...(data.stats || {}) },
208
+ meta: { ...defaults.meta, ...(data.meta || {}) }
209
+ }
210
+ };
211
+ } catch (e) {
212
+ return { success: false, error: e.message };
213
+ }
214
+ }
215
+
216
+ // 写入记忆文件(迁移自 writeMemoryFile tool-memory.js:179-185:stats/version/updatedAt 维护逻辑不变)
217
+ async function writeMemoryFile(data) {
218
+ data.updatedAt = new Date().toISOString();
219
+ data.stats.totalFacts = (data.facts || []).length;
220
+ data.stats.totalSummaries = (data.summaries || []).length;
221
+ data.version = data.version || 1;
222
+ try {
223
+ await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
224
+ return { success: true };
225
+ } catch (e) {
226
+ return { success: false, error: e.message };
227
+ }
228
+ }
229
+
230
+ /**
231
+ * store:新增/更新/删除记忆(迁移自 executeAgentMemoryStore tool-memory.js:215-347)
232
+ * args.action 为子动作 add/update/delete(扩展转发层已将 subAction 映射到 action)
233
+ */
234
+ async function store(args, t) {
235
+ const { action, type, category, content, title, tags, importance, memoryId, sourceSessionId } = args;
236
+
237
+ if (!action) return { success: false, error: t('memory.missingAction') };
238
+ // delete 不需要 type(memoryId 全局唯一),add/update 需要
239
+ if (!type && action !== 'delete') return { success: false, error: t('memory.missingType') };
240
+ if (!content && action !== 'delete') return { success: false, error: t('memory.missingContent') };
241
+
242
+ // 只校验 add / update:delete 按全局唯一 id 查找本就不需要 type,未知 action 仍由下面的
243
+ // unsupportedAction 报错,不让类型校验抢在它前面把错误换成误导性的 invalidType
244
+ if ((action === 'add' || action === 'update') && !MEMORY_TYPES.includes(type)) {
245
+ return { success: false, error: t('memory.invalidType', { type: String(type) }) };
246
+ }
247
+ // importance 只解析一次:add 的默认值、去重分支的覆盖、update 的写入共用同一个结果,
248
+ // 这是三条路径此前口径不一致的根因
249
+ const importanceGiven = importance !== undefined;
250
+ const importanceValue = importanceGiven ? normalizeImportance(importance) : null;
251
+ if (importanceGiven && importanceValue === null) {
252
+ return { success: false, error: t('memory.invalidImportance', { value: String(importance) }) };
253
+ }
254
+
255
+ return withMemoryLock(async () => {
256
+ const readResult = await readMemoryFile();
257
+ if (!readResult.success) return { success: false, error: t('memory.readFailed', { error: readResult.error }) };
258
+
259
+ const memoryData = readResult.data;
260
+ const now = new Date().toISOString();
261
+ const targetArray = type === 'fact' ? memoryData.facts : memoryData.summaries;
262
+
263
+ if (action === 'add') {
264
+ const newMemory = {
265
+ id: generateMemoryId(type),
266
+ type,
267
+ category: category || 'custom',
268
+ content,
269
+ tags: tags || [],
270
+ importance: importanceValue ?? IMPORTANCE_DEFAULT,
271
+ accessCount: 0,
272
+ lastAccessAt: null,
273
+ createdAt: now,
274
+ updatedAt: now,
275
+ sourceSessionId: sourceSessionId || null
276
+ };
277
+ if (type === 'summary' && title) {
278
+ newMemory.title = title;
279
+ }
280
+
281
+ // 简单去重:检查是否有内容完全相同的记忆
282
+ const duplicate = targetArray.find(m => m.content === content && m.type === type);
283
+ if (duplicate) {
284
+ duplicate.updatedAt = now;
285
+ duplicate.tags = tags || duplicate.tags;
286
+ duplicate.importance = importanceValue ?? duplicate.importance;
287
+ duplicate.sourceSessionId = sourceSessionId || duplicate.sourceSessionId;
288
+ const writeResult = await writeMemoryFile(memoryData);
289
+ if (!writeResult.success) return { success: false, error: t('memory.writeFailed', { error: writeResult.error }) };
290
+ queueVectorBackfill(duplicate.id, duplicate);
291
+ return {
292
+ success: true,
293
+ message: t('memory.updatedDuplicate', { id: duplicate.id }),
294
+ memory: duplicate,
295
+ action: 'updated',
296
+ stats: memoryData.stats
297
+ };
298
+ }
299
+
300
+ targetArray.push(newMemory);
301
+ const writeResult = await writeMemoryFile(memoryData);
302
+ if (!writeResult.success) return { success: false, error: t('memory.writeFailed', { error: writeResult.error }) };
303
+
304
+ // 检查是否接近上限
305
+ const maxFacts = memoryData.meta.maxFacts;
306
+ const maxSummaries = memoryData.meta.maxSummaries;
307
+ const factRatio = memoryData.facts.length / maxFacts;
308
+ const summaryRatio = memoryData.summaries.length / maxSummaries;
309
+ let warning = '';
310
+ if (factRatio >= 0.8 || summaryRatio >= 0.8) {
311
+ warning = t('memory.nearLimitWarning', { facts: memoryData.facts.length, maxFacts, summaries: memoryData.summaries.length, maxSummaries });
312
+ }
313
+
314
+ queueVectorBackfill(newMemory.id, newMemory);
315
+ return {
316
+ success: true,
317
+ message: t('memory.added', { id: newMemory.id, type, warning }),
318
+ memory: newMemory,
319
+ action: 'added',
320
+ stats: memoryData.stats
321
+ };
322
+ }
323
+
324
+ if (action === 'update') {
325
+ if (!memoryId) return { success: false, error: t('memory.updateNeedsId') };
326
+
327
+ const idx = targetArray.findIndex(m => m.id === memoryId);
328
+ if (idx === -1) {
329
+ // 尝试在另一个数组中查找
330
+ const otherArray = type === 'fact' ? memoryData.summaries : memoryData.facts;
331
+ const otherIdx = otherArray.findIndex(m => m.id === memoryId);
332
+ if (otherIdx !== -1) {
333
+ return { success: false, error: t('memory.typeMismatch', { id: memoryId, type: otherArray[otherIdx].type }) };
334
+ }
335
+ // code 供扩展端机器判定(压缩摘要锚点被淘汰后降级为新增重建),不让调用方拿人话文案当协议
336
+ return { success: false, error: t('memory.notFound', { id: memoryId }), code: 'not_found' };
337
+ }
338
+
339
+ const existing = targetArray[idx];
340
+ if (content !== undefined) existing.content = content;
341
+ if (tags !== undefined) existing.tags = tags;
342
+ if (importanceValue !== null) existing.importance = importanceValue;
343
+ if (category !== undefined) existing.category = category;
344
+ if (type === 'summary' && title !== undefined) existing.title = title;
345
+ existing.updatedAt = now;
346
+
347
+ const writeResult = await writeMemoryFile(memoryData);
348
+ if (!writeResult.success) return { success: false, error: t('memory.writeFailed', { error: writeResult.error }) };
349
+
350
+ queueVectorBackfill(memoryId, existing);
351
+ return {
352
+ success: true,
353
+ message: t('memory.updated', { id: memoryId }),
354
+ memory: existing,
355
+ action: 'updated',
356
+ stats: memoryData.stats
357
+ };
358
+ }
359
+
360
+ if (action === 'delete') {
361
+ if (!memoryId) return { success: false, error: t('memory.deleteNeedsId') };
362
+
363
+ // 在 facts 和 summaries 两个数组中查找(不依赖 type 参数)
364
+ const arrays = [memoryData.facts, memoryData.summaries];
365
+ for (const arr of arrays) {
366
+ const idx = arr.findIndex(m => m.id === memoryId);
367
+ if (idx !== -1) {
368
+ const removed = arr.splice(idx, 1)[0];
369
+ removeFromRecalledCache(memoryId);
370
+ const writeResult = await writeMemoryFile(memoryData);
371
+ if (!writeResult.success) return { success: false, error: t('memory.writeFailed', { error: writeResult.error }) };
372
+ // 记忆真源已删,向量缓存必须同步摘除(否则孤儿向量会在下次召回里指向不存在的条目)
373
+ await vectors.dropVectors([memoryId]);
374
+ return {
375
+ success: true,
376
+ message: t('memory.deleted', { id: memoryId }),
377
+ removed,
378
+ stats: memoryData.stats
379
+ };
380
+ }
381
+ }
382
+
383
+ return { success: false, error: t('memory.notFound', { id: memoryId }) };
384
+ }
385
+
386
+ return { success: false, error: t('memory.unsupportedAction', { action }) };
387
+ });
388
+ }
389
+
390
+ // ==================== 会话召回缓存(迁移自 tool-memory.js:350-364) ====================
391
+ // 每 session 已召回的记忆 ID 集合,防止同一对话重复返回相同记忆
392
+ const sessionRecalledMemoryIds = new Map();
393
+
394
+ function removeFromRecalledCache(memoryId) {
395
+ for (const [sessionId, recalledSet] of sessionRecalledMemoryIds.entries()) {
396
+ if (recalledSet.has(memoryId)) {
397
+ recalledSet.delete(memoryId);
398
+ if (recalledSet.size === 0) {
399
+ sessionRecalledMemoryIds.delete(sessionId);
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+
406
+ /**
407
+ * recall:从长期记忆中检索相关信息(迁移自 executeAgentMemoryRecall tool-memory.js:369-608)
408
+ *
409
+ * 三段式(2026-10-14 修复「锁内嵌入拖垮全进程」):锁是进程内 Promise 链、没有超时逃生口,
410
+ * 外部嵌入在锁内挂起(最长 embedTimeoutMs)会让同进程所有记忆写入排队干等。
411
+ * 阶段 1(锁内只读):读真源、定候选、校验向量缓存指纹、收集缺失项——锁里只放本地文件读;
412
+ * 阶段 2(锁外):嵌入缺失项并写入派生缓存,失败按 modelKey 冷却 60s 再试;
413
+ * 阶段 3(锁内收尾):融合排序、会话去重、访问计数 RMW(重读真源防覆盖并发写)、格式化。
414
+ * @param {object} args { query, tags, memoryType='all', limit=10 }
415
+ * @param {string|null} sessionId 会话 ID,用于 session 级召回去重
416
+ * @param {Function} t 请求语言翻译函数
417
+ */
418
+ async function recall(args, sessionId, t) {
419
+ const { query, tags, memoryType = 'all', limit = 10 } = args;
420
+
421
+ // 档位判定与查询向量在取锁之前完成:embedding 是外部网络调用,放进互斥锁里会让一次慢请求
422
+ // 阻塞同进程的所有记忆写入(锁是进程内 Promise 链,没有超时逃生口)。
423
+ if (!RECALL_MEMORY_TYPES.includes(memoryType)) {
424
+ return { success: false, error: t('memory.invalidMemoryType', { type: String(memoryType) }) };
425
+ }
426
+
427
+ const tier = tierResolver(embeddingConfigProvider());
428
+ let queryVec = null;
429
+ if (isVectorTier(tier) && query && query.trim()) {
430
+ try {
431
+ const { provider } = getProvider();
432
+ const vec = await embedWithTimeout(provider.embedQuery(query));
433
+ queryVec = Array.isArray(vec) && vec.length ? vec : null;
434
+ } catch (err) {
435
+ console.warn('[Memory] embedQuery failed, fallback to keyword-only:', err.message);
436
+ queryVec = null;
437
+ }
438
+ }
439
+ // fused = 本次确实拿到了可用的查询向量(queryVec 仅在向量档且嵌入成功时非空)
440
+ const fused = queryVec !== null;
441
+
442
+ // ==================== 阶段 1:锁内只读 ====================
443
+ const snap = await withMemoryLock(async () => {
444
+ const readResult = await readMemoryFile();
445
+ if (!readResult.success) {
446
+ return { fail: { success: false, error: t('memory.readFailed', { error: readResult.error }) } };
447
+ }
448
+
449
+ const memoryData = readResult.data;
450
+
451
+ // 收集所有记忆
452
+ let candidates = [];
453
+ if (memoryType === 'all' || memoryType === 'fact') {
454
+ candidates.push(...memoryData.facts.map(m => ({ ...m, _source: 'facts' })));
455
+ }
456
+ if (memoryType === 'all' || memoryType === 'summary') {
457
+ candidates.push(...memoryData.summaries.map(m => ({ ...m, _source: 'summaries' })));
458
+ }
459
+
460
+ if (candidates.length === 0) return { empty: true };
461
+
462
+ // 标签的角色(2026-10-14 口径变更,动机见 memory/tags.js 文件头):无 query 时标签是唯一
463
+ // 选择器(枚举语义),保持硬过滤;有 query 时不再剔除候选,只在最后按重叠比例上抬名次,
464
+ // 以免「标签 + 词面门槛 + 语义地板」构成三个硬门槛的 AND 叠加把正确条目整条抹掉。
465
+ const tagRequested = hasTagRequest(tags);
466
+ const hasQuery = Boolean(query && query.trim());
467
+ if (tagRequested && !hasQuery) {
468
+ candidates = filterByTags(candidates, tags);
469
+ }
470
+
471
+ // 向量缓存先过指纹校验(H1):缓存属于另一套模型时**整份视空**,所有候选按缺失处理,
472
+ // 由阶段 2 用当前模型重建。旧实现不校验:换模型后旧空间的向量会被拿去和新查询向量
473
+ // 算 cos,名次全是噪声,还会被 margin 门采信进语义路(派生缓存要能自愈,不能劫持名次)。
474
+ const vecMap = {};
475
+ let missing = [];
476
+ let modelKey = null;
477
+ if (fused) {
478
+ const cfg = embeddingConfigProvider();
479
+ modelKey = memoryModelKey(cfg);
480
+ const store = await vectors.read();
481
+ if (store.modelKey === modelKey) Object.assign(vecMap, store.vectors);
482
+ missing = candidates.filter(m => m.id && !(m.id in vecMap));
483
+ }
484
+ return { candidates, tagRequested, hasQuery, vecMap, missing, modelKey };
485
+ });
486
+
487
+ if (snap.fail) return snap.fail;
488
+ if (snap.empty) {
489
+ return {
490
+ success: true,
491
+ message: t('memory.memoryEmpty'),
492
+ results: [],
493
+ total: 0,
494
+ query,
495
+ tier,
496
+ vectorMatched: 0
497
+ };
498
+ }
499
+
500
+ // ==================== 阶段 2:锁外补齐缺失向量 ====================
501
+ const { tagRequested, hasQuery, vecMap, missing, modelKey } = snap;
502
+ let candidates = snap.candidates;
503
+ if (missing.length) {
504
+ const cooling = backfillFail.key === modelKey && Date.now() < backfillFail.until;
505
+ if (!cooling) {
506
+ // 派生缓存按需补齐:真源是 global-memory.json,补不上只是这次少一条语义名次,不影响正确性
507
+ try {
508
+ const { provider } = getProvider();
509
+ const vecs = await embedWithTimeout(provider.embedDocuments(missing.map(memorySearchText)));
510
+ const pairs = missing.map((m, i) => [m.id, Array.isArray(vecs) ? vecs[i] : undefined]).filter(([, v]) => Array.isArray(v) && v.length);
511
+ if (pairs.length) {
512
+ // 门面自身带串行链(读-改-写不交叉),与懒补/重建等其它写的先后安全
513
+ await vectors.upsertVectors(pairs, modelKey);
514
+ for (const [id, v] of pairs) vecMap[id] = v;
515
+ }
516
+ } catch (err) {
517
+ // 冷却按 modelKey 记账:换模型/改配置视为新一次尝试,不继承旧端的失败记忆
518
+ backfillFail.key = modelKey;
519
+ backfillFail.until = Date.now() + RECALL_BACKFILL_COOLDOWN_MS;
520
+ console.warn('[Memory] vector backfill skipped:', err.message);
521
+ }
522
+ }
523
+ }
524
+
525
+ // ==================== 阶段 3:锁内收尾 ====================
526
+ return withMemoryLock(async () => {
527
+ let vectorMatched = 0;
528
+
529
+ // 按关键词搜索
530
+ if (hasQuery) {
531
+ const terms = extractMemoryTerms(query);
532
+ const originalQuery = query.toLowerCase().trim();
533
+
534
+ if (terms.latin.length > 0 || terms.cjk.length > 0) {
535
+ // df 过滤必须在计分前对全候选扫一遍:单条记忆自身无法判断某个单元是否无区分度
536
+ const { keep: dfKeep, hasDiscriminative } = buildDocFreqFilter(candidates, terms);
537
+ // 全塌兜底:查询里所有单元都被判为无区分度时忽略 df——宁可按词面强度排,也不要把用户
538
+ // 明确输入的那个词整条判死(实测「ai」在本库 41 条里出现 30 次,按现行尺子必被剔)。只要还有
539
+ // 任一单元幸存就不兜底,高频单元照旧被剔,否则高频词会把返回窗口填满、挤掉有效信号。
540
+ const dfFilter = hasDiscriminative ? dfKeep : () => true;
541
+
542
+ if (!fused) {
543
+ // 关键词单通道:A 档(未启用向量)走这条,向量档但本次查询向量不可用时也退回这条,
544
+ // 因此融合失败的最坏情况就是今天的行为,不会更差。
545
+ const scored = candidates.map(m => ({ memory: m, ...keywordScore(m, terms, originalQuery, dfFilter) }));
546
+
547
+ // 只留下过了命中门槛的条目,按分数排序
548
+ candidates = scored
549
+ .filter(isKeywordHit)
550
+ .sort(byKeywordRank)
551
+ .map(s => s.memory);
552
+
553
+ // 迁移说明:原扩展端(tool-memory.js:499-513)的"全文 fallback"分支过滤的是已被
554
+ // 上一行赋值清空的 candidates 数组,恒返回空,属死代码,未随迁——行为等价。
555
+ } else {
556
+ // 双通道排名融合(RRF):关键词与向量各排各的名次,只按名次合并。
557
+ // 不再叠加绝对相似度分数的原因见 memory/fusion.js 文件头——cos 绝对值在条目级不可判别。
558
+ // 向量来自阶段 1 指纹校验通过的缓存 + 阶段 2 当轮补齐,跨模型向量不会混入。
559
+ // 关键词路:口径与 A 档逐字一致(同一门槛、同一排序),只是取名次
560
+ const kwRanked = candidates
561
+ .map(m => ({ memory: m, ...keywordScore(m, terms, originalQuery, dfFilter) }))
562
+ .filter(isKeywordHit)
563
+ .sort(byKeywordRank);
564
+
565
+ // 语义路(纯 top-K):全候选算 cos 后过两道相对门(条目级落差 + 条数封顶),无绝对地板。
566
+ // 词面零命中但语义居前的条目由此能被召回;与全库无关的查询也会返回至多 maxTake 条头部(已知代价)。
567
+ const cosPairs = candidates
568
+ .map(m => (m.id && vecMap[m.id] ? [m.id, cosine(queryVec, vecMap[m.id])] : null))
569
+ .filter(Boolean);
570
+ const semantic = selectSemanticHits(cosPairs);
571
+ const vecRank = new Map(semantic.map(([id], i) => [id, i + 1]));
572
+ // vectorMatched 语义变更:由「过了绝对阈值的条数」变为「被采信进语义路的条数」(上限 SEMANTIC_MAX_TAKE)
573
+ vectorMatched = semantic.length;
574
+
575
+ const byId = new Map(candidates.map(m => [m.id, m]));
576
+ const kwRank = new Map(kwRanked.map((s, i) => [s.memory.id, i + 1]));
577
+ candidates = rrfFuse([
578
+ [...kwRank.entries()],
579
+ [...vecRank.entries()]
580
+ ]).map(([id]) => byId.get(id)).filter(Boolean);
581
+ }
582
+ }
583
+ }
584
+
585
+ // 标签名次加成:作用于两条通道的最终序(词面单通道序或 RRF 融合序),只上抬不删除。
586
+ // df 统计池保持全候选不变:标签不得改变词形区分度的分母(判据含 d > 0,缩池会误杀)。
587
+ if (tagRequested && hasQuery) {
588
+ candidates = applyTagRankLift(candidates, tags);
589
+ }
590
+
591
+ // 去重(同一记忆 id 全局唯一,但仍处理一下)
592
+ const seen = new Set();
593
+ let results = candidates.filter(m => {
594
+ if (seen.has(m.id)) return false;
595
+ seen.add(m.id);
596
+ return true;
597
+ }).slice(0, limit);
598
+
599
+ // Session 级去重:优先返回本对话未召回过的记忆
600
+ let recallNote = '';
601
+ if (sessionId) {
602
+ const recalledSet = sessionRecalledMemoryIds.get(sessionId);
603
+ if (recalledSet && recalledSet.size > 0) {
604
+ const newResults = [];
605
+ const oldResults = [];
606
+ for (const m of results) {
607
+ if (recalledSet.has(m.id)) {
608
+ oldResults.push(m);
609
+ } else {
610
+ newResults.push(m);
611
+ }
612
+ }
613
+
614
+ // 优先返回新记忆;如果没有新记忆,但有旧记忆,则返回旧记忆并提示
615
+ if (newResults.length === 0 && oldResults.length > 0) {
616
+ recallNote = t('memory.recallNoteAllOld');
617
+ results = oldResults;
618
+ } else if (newResults.length > 0) {
619
+ if (oldResults.length > 0) {
620
+ recallNote = t('memory.recallNoteMixed', { oldCount: oldResults.length, newCount: newResults.length });
621
+ }
622
+ results = newResults;
623
+ }
624
+ }
625
+ }
626
+
627
+ // 记录本次召回的记忆 ID
628
+ if (sessionId && results.length > 0) {
629
+ if (!sessionRecalledMemoryIds.has(sessionId)) {
630
+ sessionRecalledMemoryIds.set(sessionId, new Set());
631
+ }
632
+ const recalledSet = sessionRecalledMemoryIds.get(sessionId);
633
+ for (const m of results) {
634
+ recalledSet.add(m.id);
635
+ }
636
+ }
637
+
638
+ // 访问计数 RMW 必须重读真源:阶段 1 到现在的窗口里可能已有 store/compact/delete 落盘
639
+ // (阶段 2 的嵌入最长可到超时上限),拿阶段 1 的快照写回会把那些变更整段抹掉。
640
+ const fresh = await readMemoryFile();
641
+ if (fresh.success) {
642
+ for (const m of results) {
643
+ const source = m._source === 'facts' ? fresh.data.facts : fresh.data.summaries;
644
+ const found = source.find(sm => sm.id === m.id);
645
+ if (found) {
646
+ found.accessCount = (found.accessCount || 0) + 1;
647
+ found.lastAccessAt = new Date().toISOString();
648
+ }
649
+ }
650
+
651
+ // 写回更新后的访问计数(非关键操作,失败不阻塞返回)
652
+ try {
653
+ await writeMemoryFile(fresh.data);
654
+ } catch (e) {
655
+ console.warn('[Memory] update visitcount failed:', e.message);
656
+ }
657
+ }
658
+
659
+ // 格式化输出
660
+ const resultText = results.length === 0
661
+ ? t('memory.noMatch')
662
+ : t('memory.foundResults', { count: results.length, note: recallNote }) + '\n\n' + results.map((m, i) => {
663
+ let text = t('memory.memoryItemHeader', { index: i + 1, type: m.type === 'fact' ? t('memory.factLabel') : t('memory.summaryLabel'), id: m.id }) + '\n';
664
+ text += t('memory.memoryItemContent', { content: m.content }) + '\n';
665
+ if (m.title) text += t('memory.memoryItemTitle', { title: m.title }) + '\n';
666
+ if (m.tags && m.tags.length > 0) text += t('memory.memoryItemTags', { tags: m.tags.join(', ') }) + '\n';
667
+ text += t('memory.memoryItemStats', { importance: m.importance, accessCount: m.accessCount, createdAt: m.createdAt });
668
+ return text;
669
+ }).join('\n\n');
670
+
671
+ return {
672
+ success: true,
673
+ message: resultText,
674
+ results: results.map(m => {
675
+ const { _source, ...rest } = m;
676
+ return rest;
677
+ }),
678
+ total: results.length,
679
+ query,
680
+ // 召回档位与语义命中数:供调用方(转发层/侧栏)展示"这次用到哪一档",不改变其余字段
681
+ tier,
682
+ vectorMatched
683
+ };
684
+ });
685
+ }
686
+
687
+ /**
688
+ * manage:审查(review)与压缩淘汰(compact)
689
+ * (迁移自 executeAgentMemoryManage tool-memory.js:613-720;淘汰线 value<10 数值不变)
690
+ * @param {object} args { action: 'review' | 'compact' }(扩展转发层已将 subAction 映射到 action)
691
+ * @param {Function} t 请求语言翻译函数
692
+ */
693
+ async function manage(args, t) {
694
+ const { action } = args;
695
+
696
+ if (!action) return { success: false, error: t('memory.missingAction') };
697
+
698
+ return withMemoryLock(async () => {
699
+ const readResult = await readMemoryFile();
700
+ if (!readResult.success) return { success: false, error: t('memory.readFailed', { error: readResult.error }) };
701
+
702
+ const memoryData = readResult.data;
703
+ const now = Date.now();
704
+
705
+ if (action === 'review') {
706
+ // 审查所有记忆,计算价值分数,返回淘汰建议
707
+ const allMemories = [
708
+ ...memoryData.facts.map(m => ({ ...m, _source: 'facts' })),
709
+ ...memoryData.summaries.map(m => ({ ...m, _source: 'summaries' }))
710
+ ];
711
+
712
+ const scored = allMemories.map(m => ({
713
+ id: m.id,
714
+ type: m.type,
715
+ category: m.category,
716
+ content: m.content,
717
+ title: m.title,
718
+ tags: m.tags,
719
+ importance: m.importance,
720
+ accessCount: m.accessCount || 0,
721
+ lastAccessAt: m.lastAccessAt,
722
+ createdAt: m.createdAt,
723
+ value: calcMemoryValue(m, now).toFixed(2),
724
+ _source: m._source
725
+ })).sort((a, b) => parseFloat(a.value) - parseFloat(b.value));
726
+
727
+ const factsCount = memoryData.facts.length;
728
+ const summariesCount = memoryData.summaries.length;
729
+ const maxFacts = memoryData.meta.maxFacts;
730
+ const maxSummaries = memoryData.meta.maxSummaries;
731
+
732
+ const lowValueThreshold = 10; // 价值分数低于此值标记为候选淘汰
733
+ const candidates = scored.filter(s => parseFloat(s.value) < lowValueThreshold);
734
+
735
+ const reviewText =
736
+ t('memory.reviewTitle') + '\n\n' +
737
+ t('memory.reviewOverview', { facts: factsCount, maxFacts, summaries: summariesCount, maxSummaries }) + '\n' +
738
+ t('memory.reviewLastReview', { date: memoryData.stats.lastReviewAt || t('memory.reviewNeverReviewed') }) + '\n\n' +
739
+ t('memory.reviewCandidates', { threshold: lowValueThreshold }) + '\n\n' +
740
+ (candidates.length === 0
741
+ ? t('memory.reviewNoCandidates') + '\n'
742
+ : candidates.map((c, i) =>
743
+ `${i + 1}. ` + t('memory.memoryItemHeader', { index: i + 1, type: c.type, id: c.id }) + ` (value: ${c.value})\n` +
744
+ t('memory.memoryItemContent', { content: c.content }) + '\n' +
745
+ t('memory.reviewItemCreated', { createdAt: c.createdAt, lastAccess: c.lastAccessAt || t('memory.neverAccessed'), accessCount: c.accessCount }) + '\n'
746
+ ).join('\n')
747
+ ) +
748
+ '\n' + t('memory.reviewSuggestion') + '\n' +
749
+ t('memory.reviewSuggestionDelete') + '\n' +
750
+ t('memory.reviewSuggestionMerge') + '\n' +
751
+ t('memory.reviewSuggestionKeep');
752
+
753
+ return {
754
+ success: true,
755
+ message: reviewText,
756
+ scored: scored.map(s => { const { _source, ...rest } = s; return rest; }),
757
+ candidates: candidates.map(s => { const { _source, ...rest } = s; return rest; }),
758
+ stats: memoryData.stats
759
+ };
760
+ }
761
+
762
+ if (action === 'compact') {
763
+ // 压缩:移除低价值记忆
764
+ const threshold = 10;
765
+ let removedFacts = 0;
766
+ let removedSummaries = 0;
767
+
768
+ const compactRemoved = [];
769
+ memoryData.facts = memoryData.facts.filter(m => {
770
+ const value = calcMemoryValue(m, now);
771
+ if (value < threshold) {
772
+ removedFacts++;
773
+ compactRemoved.push(m.id);
774
+ return false;
775
+ }
776
+ return true;
777
+ });
778
+
779
+ memoryData.summaries = memoryData.summaries.filter(m => {
780
+ const value = calcMemoryValue(m, now);
781
+ if (value < threshold) {
782
+ removedSummaries++;
783
+ compactRemoved.push(m.id);
784
+ return false;
785
+ }
786
+ return true;
787
+ });
788
+
789
+ memoryData.stats.lastReviewAt = new Date().toISOString();
790
+
791
+ const writeResult = await writeMemoryFile(memoryData);
792
+ if (!writeResult.success) return { success: false, error: t('memory.writeFailed', { error: writeResult.error }) };
793
+
794
+ // 已淘汰记忆从会话召回缓存中移除,避免后续会话永久占位
795
+ for (const id of compactRemoved) removeFromRecalledCache(id);
796
+
797
+ // 淘汰项的向量一并摘除:派生缓存可以整删重建,但不能指向已被压缩掉的条目
798
+ if (compactRemoved.length) await vectors.dropVectors(compactRemoved);
799
+
800
+ return {
801
+ success: true,
802
+ message: t('memory.compactDone', { facts: removedFacts, summaries: removedSummaries, threshold, totalFacts: memoryData.stats.totalFacts, totalSummaries: memoryData.stats.totalSummaries }),
803
+ removedFacts,
804
+ removedSummaries,
805
+ stats: memoryData.stats
806
+ };
807
+ }
808
+
809
+ return { success: false, error: t('memory.unsupportedAction', { action }) };
810
+ });
811
+ }
812
+
813
+ /**
814
+ * injectSelect:为系统提示词“永久注意事项”选出分级注入记忆(迁移自扩展端 GET_PERMANENT_NOTES handler)
815
+ * 三级策略与预算参数由 injection.js 的 MEMORY_INJECTION_CONFIG 固定控制。
816
+ * @param {Function} t 请求语言翻译函数
817
+ */
818
+ async function injectSelect(t) {
819
+ return withMemoryLock(async () => {
820
+ const readResult = await readMemoryFile();
821
+ if (!readResult.success) return { success: false, error: t('memory.readFailed', { error: readResult.error }) };
822
+
823
+ const memoryData = readResult.data;
824
+ const selection = selectMemoriesForInjection(memoryData.facts, memoryData.summaries);
825
+
826
+ return {
827
+ success: true,
828
+ injected: selection.injected,
829
+ remainingCount: selection.remainingCount,
830
+ remainingTags: selection.remainingTags
831
+ };
832
+ });
833
+ }
834
+
835
+ /**
836
+ * 显式为全部记忆重建向量缓存(模型变更后或首次启用向量档时调用一次)。
837
+ * 派生缓存:失败不影响正确性——recall 会按需补齐,最坏情况是这次少一条语义分。
838
+ * @param {Function} t 请求语言翻译函数
839
+ */
840
+ async function backfillVectors(t) {
841
+ const cfg = embeddingConfigProvider();
842
+ const tier = tierResolver(cfg);
843
+ if (!isVectorTier(tier)) {
844
+ return { success: true, skipped: true, reason: t ? t('memory.embeddingDisabled') : 'embedding_disabled' };
845
+ }
846
+ const readResult = await readMemoryFile();
847
+ if (!readResult.success) return { success: false, error: readResult.error };
848
+ const data = readResult.data;
849
+ const items = [...(data.facts || []), ...(data.summaries || [])].filter(m => m && m.id);
850
+ if (!items.length) return { success: true, indexed: 0 };
851
+
852
+ let provider;
853
+ try {
854
+ provider = getProvider().provider;
855
+ } catch (err) {
856
+ return { success: false, error: err.message };
857
+ }
858
+
859
+ const pairs = [];
860
+ const errors = [];
861
+ for (let i = 0; i < items.length; i += EMBED_BATCH_SIZE) {
862
+ const batch = items.slice(i, i + EMBED_BATCH_SIZE);
863
+ try {
864
+ const vecs = await embedWithTimeout(provider.embedDocuments(batch.map(memorySearchText)));
865
+ batch.forEach((m, j) => {
866
+ if (Array.isArray(vecs) && Array.isArray(vecs[j]) && vecs[j].length) pairs.push([m.id, vecs[j]]);
867
+ });
868
+ } catch (err) {
869
+ // 单批失败不中断整体:能建多少算多少,剩余条目后续 recall 时再补。
870
+ // daemon 的 stdout 不可见,原因必须同时落日志与响应:只报 failedBatches 计数的话,
871
+ // 用户看到「8 批失败」仍然无从判断是长度、限流还是模型不可达。
872
+ errors.push(err.message);
873
+ console.warn('[Memory] reindex batch failed:', err.message);
874
+ }
875
+ }
876
+ if (!pairs.length) {
877
+ return { success: false, indexed: 0, error: errors[0] || 'no_vectors' };
878
+ }
879
+ await vectors.upsertVectors(pairs, memoryModelKey(cfg));
880
+
881
+ // 本地档的 pipeline 在 tokenizer.model_max_length(实测 512)处静默截断:超出部分根本不进向量,
882
+ // 原先零信号(实测真实库 41 条中 12 条已被砍尾,最短的一条只有 726 字)。
883
+ // 本轮只赢在「可见」:不改向量内容、不动索引成败,把条数与点名交给响应与日志。
884
+ // 按能力探测而非 tier 判:远端档不提供 tokenCounter,它的厂商截断行为不可观测,不得臆造。
885
+ let overWindow = null;
886
+ if (typeof provider.tokenCounter === 'function') {
887
+ try {
888
+ const { counts, window } = await embedWithTimeout(provider.tokenCounter(items.map(memorySearchText)));
889
+ const ids = items.filter((m, i) => window > 0 && counts[i] > window).map((m) => m.id);
890
+ overWindow = { count: ids.length, window, ids };
891
+ if (ids.length) {
892
+ // daemon stdout 不可见,与批失败同口径:响应给计数,日志给是谁
893
+ const head = ids.slice(0, 10).join(', ');
894
+ console.warn(`[Memory] ${ids.length} items exceed embedding context window (${window} tokens); tail not indexed: ${head}${ids.length > 10 ? ' …' : ''}`);
895
+ }
896
+ } catch {
897
+ // 计数抛错/超时一律不报:误报「没超窗」比拿不到数字更坏,但绝不能因此把索引报成失败
898
+ overWindow = null;
899
+ }
900
+ }
901
+
902
+ // 响应只留首条原因(不把端点报错串堆成一片);全量原因看进程日志
903
+ return {
904
+ success: true,
905
+ indexed: pairs.length,
906
+ total: items.length,
907
+ failedBatches: errors.length,
908
+ ...(errors.length ? { lastError: errors[0] } : {}),
909
+ ...(overWindow ? { overWindow } : {})
910
+ };
911
+ }
912
+
913
+ /**
914
+ * 向量索引状态(只读):条数 + 指纹陈旧判定,供配置界面自证「改了模型有没有重建」。
915
+ *
916
+ * 刻意不碰 provider:拿超窗计数要 await _getPipeline(),那会触发模型下载——
917
+ * 一个只读状态查询不许有这种副作用,所以 overWindow 只在 backfillVectors 里给。
918
+ * stale 只在「真存过向量」时成立:空库 + 任意配置不算陈旧(首次启用是正常态)。
919
+ * @returns {Promise<object>} {indexed,total,storedModelKey,currentModelKey,stale,tier}
920
+ */
921
+ async function vectorStatus() {
922
+ const cfg = embeddingConfigProvider();
923
+ const tier = tierResolver(cfg);
924
+ const currentModelKey = memoryModelKey(cfg);
925
+ // 外层 vectors 是 facade,局部量换了名字避开遮蔽
926
+ const [vecStore, readResult] = await Promise.all([vectors.read(), readMemoryFile()]);
927
+ if (!readResult.success) return { success: false, error: readResult.error };
928
+
929
+ const data = readResult.data;
930
+ const total = (data.facts || []).length + (data.summaries || []).length;
931
+ const storedModelKey = typeof vecStore.modelKey === 'string' ? vecStore.modelKey : '';
932
+ return {
933
+ indexed: Object.keys(vecStore.vectors || {}).length,
934
+ total,
935
+ storedModelKey,
936
+ currentModelKey,
937
+ stale: Boolean(storedModelKey) && storedModelKey !== currentModelKey,
938
+ tier
939
+ };
940
+ }
941
+
942
+ return {
943
+ store,
944
+ recall,
945
+ manage,
946
+ injectSelect,
947
+ backfillVectors,
948
+ vectorStatus,
949
+ // 读全部记忆数据对象(失败抛错;仅供测试与内部逻辑使用,业务方法内部用 readMemoryFile)
950
+ readAll: async () => {
951
+ const r = await readMemoryFile();
952
+ if (!r.success) throw new Error(r.error);
953
+ return r.data;
954
+ },
955
+ _internals: { withMemoryLock, sessionRecalledMemoryIds, removeFromRecalledCache, writeMemoryFile }
956
+ };
957
+ }