ai-helper-agent 2.2.0 → 2.3.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 (53) hide show
  1. package/README.md +94 -1
  2. package/README.zh-CN.md +94 -1
  3. package/dist/assets/{agent-store-DOyMdB_7.js → agent-store-DcAs6YyA.js} +2 -2
  4. package/dist/assets/{agent-store-DOyMdB_7.js.map → agent-store-DcAs6YyA.js.map} +1 -1
  5. package/dist/assets/background.js +83 -89
  6. package/dist/assets/background.js.map +1 -1
  7. package/dist/assets/content.js +81 -65
  8. package/dist/assets/{i18n-MmYITlVj.js → i18n-C4jSRoCH.js} +5 -5
  9. package/dist/assets/i18n-C4jSRoCH.js.map +1 -0
  10. package/dist/assets/{model-profiles-CV_sUuPf.js → model-profiles-Dl_MsXWa.js} +5 -5
  11. package/dist/assets/model-profiles-Dl_MsXWa.js.map +1 -0
  12. package/dist/assets/options-8-nu3P5K.css +1 -0
  13. package/dist/assets/options.js +264 -197
  14. package/dist/assets/options.js.map +1 -1
  15. package/dist/assets/{page-snapshot-renderer-CE53ibi-.js → page-snapshot-renderer-CVL6r6Kf.js} +2 -2
  16. package/dist/assets/{page-snapshot-renderer-CE53ibi-.js.map → page-snapshot-renderer-CVL6r6Kf.js.map} +1 -1
  17. package/dist/assets/{session-store-B0ZPGm7d.js → session-store-CzVQCgQ_.js} +4 -4
  18. package/dist/assets/session-store-CzVQCgQ_.js.map +1 -0
  19. package/dist/assets/side_panel.css +1 -1
  20. package/dist/assets/side_panel.js +327 -293
  21. package/dist/assets/side_panel.js.map +1 -1
  22. package/dist/assets/version-CNG2KGnJ.js +2 -0
  23. package/dist/assets/version-CNG2KGnJ.js.map +1 -0
  24. package/dist/libs/jspdf.min.js +1 -1
  25. package/dist/manifest.json +2 -2
  26. package/dist/offscreen/offscreen.js +4 -4
  27. package/dist/options.html +140 -3
  28. package/dist/side_panel.html +27 -4
  29. package/package.json +1 -1
  30. package/src/config.js +72 -24
  31. package/src/crypto-lite.js +31 -0
  32. package/src/locales/en.json +55 -0
  33. package/src/locales/zh.json +55 -0
  34. package/src/memory/embedding-config.js +95 -0
  35. package/src/memory/fusion.js +60 -0
  36. package/src/memory/injection.js +118 -0
  37. package/src/memory/routes.js +193 -0
  38. package/src/memory/service.js +957 -0
  39. package/src/memory/tags.js +62 -0
  40. package/src/memory/tier.js +37 -0
  41. package/src/memory/tokens.js +158 -0
  42. package/src/memory/vectors.js +128 -0
  43. package/src/rag/embedding/index.js +7 -0
  44. package/src/rag/embedding/local.js +25 -0
  45. package/src/rag/keyword-match.js +112 -0
  46. package/src/rag/manager.js +15 -0
  47. package/src/rag/searcher.js +15 -87
  48. package/src/server.js +6 -0
  49. package/dist/assets/i18n-MmYITlVj.js.map +0 -1
  50. package/dist/assets/model-profiles-CV_sUuPf.js.map +0 -1
  51. package/dist/assets/options-Df_1Dtn8.css +0 -1
  52. package/dist/assets/session-store-B0ZPGm7d.js.map +0 -1
  53. /package/dist/_locales/{zh → zh_CN}/messages.json +0 -0
@@ -0,0 +1,95 @@
1
+ // agent/src/memory/embedding-config.js - 记忆语义召回的 embedding 配置读写与脱敏
2
+ // 单一配置出口:只读写 agent config.json 的 memoryEmbedding 字段,请求体永不回显明文 key。
3
+ // 缺省 enabled=false:recall 只走关键词通道(Tier A),不新增任何默认网络调用。
4
+
5
+ import { loadConfig, updateConfigField } from '../config.js';
6
+
7
+ export const DEFAULT_MEMORY_EMBEDDING_CONFIG = {
8
+ enabled: false,
9
+ mode: 'openai-compat',
10
+ baseUrl: '',
11
+ modelName: '',
12
+ apiKey: '',
13
+ dimensions: 0,
14
+ queryPrefix: ''
15
+ };
16
+
17
+ // mode 白名单:远端 OpenAI 兼容端点(零依赖)与本地 transformers 模型(需 RAG 依赖)
18
+ export const MEMORY_EMBEDDING_MODES = ['openai-compat', 'local'];
19
+
20
+ /** 原始配置(含 apiKey 明文,仅进程内使用,绝不出响应体) */
21
+ export function getMemoryEmbeddingConfig() {
22
+ const cfg = loadConfig().memoryEmbedding || {};
23
+ return { ...DEFAULT_MEMORY_EMBEDDING_CONFIG, ...cfg };
24
+ }
25
+
26
+ /** 对外响应形态:隐藏明文 key,附 hasApiKey 供前端「已配置」提示(沿用 rag/routes.js 约定) */
27
+ export function maskMemoryEmbeddingConfig(cfg = getMemoryEmbeddingConfig()) {
28
+ const { apiKey, ...rest } = cfg;
29
+ return { ...rest, apiKey: '', hasApiKey: Boolean(apiKey) };
30
+ }
31
+
32
+ /**
33
+ * 保存配置:mode 白名单校验;空白 apiKey 表示不修改(前端表单不回填明文时不能抹掉已存 key);
34
+ * clearApiKey===true 是唯一显式清除通道,且优先于同传的 apiKey;
35
+ * dimensions 规整为非负整数;enabled 只认真 true(字符串/数字一律视为关闭)。
36
+ * @param {object} input
37
+ * @returns {Promise<object>} 脱敏后的最新配置
38
+ * @throws {Error} message = 'invalid_embedding_mode'
39
+ */
40
+ export async function saveMemoryEmbeddingConfig(input = {}) {
41
+ const current = getMemoryEmbeddingConfig();
42
+ const next = { ...current };
43
+
44
+ if (input.mode !== undefined) {
45
+ if (!MEMORY_EMBEDDING_MODES.includes(input.mode)) {
46
+ throw new Error('invalid_embedding_mode');
47
+ }
48
+ next.mode = input.mode;
49
+ }
50
+ if (input.enabled !== undefined) next.enabled = input.enabled === true;
51
+ if (typeof input.baseUrl === 'string') next.baseUrl = input.baseUrl.trim();
52
+ if (typeof input.modelName === 'string') next.modelName = input.modelName.trim();
53
+ if (typeof input.queryPrefix === 'string') next.queryPrefix = input.queryPrefix;
54
+ if (typeof input.apiKey === 'string' && input.apiKey.trim()) next.apiKey = input.apiKey.trim();
55
+ // L1(评审):明文不回显 + 空白=不修改 ⇒ 用户没有清除路径。clearApiKey 放在赋值之后:
56
+ // 清除是显式动作,赢过同传的新 key(界面不会同传,但 API 语义不能含糊)。
57
+ if (input.clearApiKey === true) next.apiKey = '';
58
+ if (input.dimensions !== undefined) {
59
+ const n = Number(input.dimensions);
60
+ next.dimensions = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
61
+ }
62
+
63
+ await updateConfigField('memoryEmbedding', next);
64
+ return maskMemoryEmbeddingConfig(next);
65
+ }
66
+
67
+ /**
68
+ * 探活/向量派生用的 provider 配置:请求体可覆盖尚未保存的草稿值,
69
+ * 但明文 key 缺省时回落到已保存的 key(前端不会回传明文)。
70
+ * @param {object} [override]
71
+ */
72
+ export function resolveMemoryEmbeddingConfig(override = {}) {
73
+ const saved = getMemoryEmbeddingConfig();
74
+ const merged = { ...saved, ...(override || {}) };
75
+ if (!merged.apiKey) merged.apiKey = saved.apiKey;
76
+ return merged;
77
+ }
78
+
79
+ /**
80
+ * 转成 rag/embedding provider 需要的形态:配置面统一叫 baseUrl(与扩展端表单一致),
81
+ * 而 OpenAI 兼容后端读的是 endpoint。缺这一映射会让远端档永远 fetch('/embeddings')。
82
+ * 单一出口:所有把记忆 embedding 配置交给 provider 的地方都必须过这里。
83
+ * @param {object} [cfg]
84
+ */
85
+ export function toEmbeddingProviderConfig(cfg = {}) {
86
+ const baseUrl = cfg.baseUrl || '';
87
+ const endpoint = cfg.endpoint || '';
88
+ const out = { ...cfg, endpoint: endpoint || baseUrl, baseUrl: baseUrl || endpoint };
89
+ // 空串在本配置面表示「未设置」(DEFAULT_MEMORY_EMBEDDING_CONFIG.queryPrefix 就是 ''),
90
+ // 而 provider 用 `??` 判回落——空串不是 nullish,原样交出会让本地 BGE 的查询前缀永远加不上
91
+ // (实测:mode=local 时 provider 日志 queryPrefix: off)。非对称检索漏前缀对中文召回是实质质量损失,
92
+ // 所以这里交出「不带这个键」,把回落权还给 provider。显式非空前缀照常透传。
93
+ if (!String(out.queryPrefix || '').trim()) delete out.queryPrefix;
94
+ return out;
95
+ }
@@ -0,0 +1,60 @@
1
+ // agent/src/memory/fusion.js - 语义采信门与排名融合(纯函数,无 IO)
2
+ //
3
+ // 【2026-10-14 计划五:记忆语义档转「纯 top-K」,删掉查询级绝对地板】
4
+ // 本模块原有三道采信门,现只剩后两道(都是**相对量**,随模型自动平移,无需标定):
5
+ // ② 条目级相对落差:cos ≥ top1 - margin,砍高分查询的长尾。
6
+ // ③ 条数封顶 maxTake:防止一次语义命中填满 limit。
7
+ // 第①道「查询级绝对地板」(原 SEMANTIC_QUERY_FLOOR=0.48,top1<floor 整通道否决)**已移除**。
8
+ // 移除理由:那个地板是在 BAAI/bge-m3 上标定的绝对值,不可跨模型平移;而记忆向量允许用户任选
9
+ // 远端/本地任意模型,套同一个 0.48 会在别的模型上失配(本地档实测:噪声 top1=0.5599 逃过地板
10
+ // 被误返、真相关 top1=0.4888 被地板绞杀)。绝对值判「本库有无答案」这件事绕不开按模型标定,与
11
+ // 「用户不必懂向量、开箱即用」根本冲突 —— 故放弃「无匹配空返」能力,换取任意模型开箱可用。
12
+ // 代价(已知并被接受):与全库无关的查询也会返回至多 maxTake 条语义头部。护栏随之从「空返」
13
+ // 降级为「至多 maxTake 条、不填满 limit」,由评测集守住。
14
+ //
15
+ // 【两个仍然成立的结论,不要回退】
16
+ // • 条目级绝对值不可用:问「这条记忆算不算命中」用绝对阈值放过的太多(≥0.45 仍放过 20/41),
17
+ // 所以条目级只看相对落差。
18
+ // • 分离度(top1-top2)不可加回:它不可跨库规模外推(库越大无关查询 top1-top2 越可能偶然变大),
19
+ // 且真实库目标查询 Q1 的 gap 0.0214 低于无关查询的 0.0361/0.0397,在 gap 轴上分不开。
20
+ //
21
+ // SEMANTIC_QUERY_FLOOR 常量保留仅供离线标定/参考(换模型时可用 test/eval/recalibrate-semantic-floor.mjs
22
+ // 量一个地板),**但线上 selectSemanticHits 不再施加它**——本文件的采信门里没有绝对值。
23
+
24
+ export const SEMANTIC_QUERY_FLOOR = 0.48; // 【离线参考】仅供标定脚本;线上 selectSemanticHits 不施加
25
+ export const SEMANTIC_MARGIN = 0.05; // 条目级相对落差:只采信距 top1 不超过此值的头部
26
+ export const SEMANTIC_MAX_TAKE = 2; // 每次查询最多采信几条语义命中
27
+ export const RRF_K = 60; // 排名融合的平滑常数(业界默认值,本计划不重调)
28
+
29
+ /**
30
+ * 语义通道采信哪些条目(纯 top-K:只按相对远近取头部,不含任何随模型漂移的绝对值)。
31
+ * 两道门:① 条目级相对落差 cos ≥ top1 - margin(砍高分查询的长尾);② 条数封顶 maxTake。
32
+ * 与全库无关的查询也会返回至多 maxTake 条头部 —— 这是「纯 top-K 换任意模型开箱即用」的已知代价。
33
+ * 注意:**既不看绝对地板、也不看 top1-top2 分离度**(理由见文件头)。
34
+ * @param {Array<[string, number]>} pairs [id, cos],顺序任意
35
+ * @param {{margin:number, maxTake:number}} gate
36
+ * @returns {Array<[string, number]>} 按 cos 降序的采信结果(候选为空时返回空表)
37
+ */
38
+ export function selectSemanticHits(pairs = [], gate = { margin: SEMANTIC_MARGIN, maxTake: SEMANTIC_MAX_TAKE }) {
39
+ const ranked = [...pairs].filter(([, c]) => Number.isFinite(c)).sort((a, b) => b[1] - a[1]);
40
+ if (!ranked.length) return [];
41
+ const floor = ranked[0][1] - gate.margin;
42
+ return ranked.filter(([, c]) => c >= floor).slice(0, gate.maxTake);
43
+ }
44
+
45
+ /**
46
+ * Reciprocal Rank Fusion:只消费名次,不消费分数,因此两路刻度不可比也不再是问题。
47
+ * @param {Array<Array<[string, number]>>} lists 每路的 [id, rank](rank 从 1 起)
48
+ * @param {number} k
49
+ * @returns {Array<[string, number]>} [id, 融合分] 降序
50
+ */
51
+ export function rrfFuse(lists = [], k = RRF_K) {
52
+ const acc = new Map();
53
+ for (const list of lists) {
54
+ for (const [id, rank] of list || []) {
55
+ if (!id || !(rank >= 1)) continue;
56
+ acc.set(id, (acc.get(id) || 0) + 1 / (k + rank));
57
+ }
58
+ }
59
+ return [...acc.entries()].sort((a, b) => b[1] - a[1]);
60
+ }
@@ -0,0 +1,118 @@
1
+ // agent/src/memory/injection.js - 记忆分级注入策略(纯函数、零依赖,便于单测)
2
+ // 三级加载:
3
+ // Tier1 常驻 importance ≥ alwaysInjectImportance 无条件注入(不占预算)
4
+ // Tier2 预算内 其余按 calcMemoryValue 降序在预算内填充(装不下的跳过继续尝试)
5
+ // Tier3 按需 未注入的转由系统提示词索引行引导 agent_memory recall 检索
6
+
7
+ export const MEMORY_INJECTION_CONFIG = {
8
+ alwaysInjectImportance: 8, // Tier1 常驻阈值(importance ≥ 此值不占预算)
9
+ budgetChars: 1500, // Tier2 字符预算(content + 标签 + 渲染开销近似)
10
+ budgetCount: 12, // Tier2 条数预算
11
+ itemOverheadChars: 50, // 每条渲染开销近似(序号、[重要性: N]、(标签: ...))
12
+ maxTopicTags: 8, // 索引提示最多展示的主题标签数
13
+ maxTagLength: 12, // 单个主题标签最大长度(超出截断)
14
+ };
15
+
16
+ /**
17
+ * 计算记忆价值分数(用于淘汰判断与注入排序)
18
+ * 迁移自 tool-memory.js(单一来源),行为不变:
19
+ * value = importance × (1 + ln(访问次数 + 1)) × 时间衰减
20
+ */
21
+ export function calcMemoryValue(memory, now) {
22
+ const importance = memory.importance || 5;
23
+ const accessCount = memory.accessCount || 0;
24
+ const createdAt = new Date(memory.createdAt).getTime();
25
+ const ageDays = (now - createdAt) / (1000 * 60 * 60 * 24);
26
+
27
+ // 时间衰减因子
28
+ let decay;
29
+ if (ageDays <= 7) decay = 1.0;
30
+ else if (ageDays <= 30) decay = 0.8;
31
+ else if (ageDays <= 90) decay = 0.5;
32
+ else decay = 0.2;
33
+
34
+ return importance * (1 + Math.log(accessCount + 1)) * decay;
35
+ }
36
+
37
+ /**
38
+ * 单条记忆注入成本近似(字符)
39
+ */
40
+ function estimateInjectionCost(memory, config) {
41
+ const contentLen = typeof memory.content === 'string' ? memory.content.length : 0;
42
+ const tagsLen = Array.isArray(memory.tags) ? memory.tags.join(', ').length : 0;
43
+ return contentLen + tagsLen + config.itemOverheadChars;
44
+ }
45
+
46
+ /**
47
+ * 收集未注入记忆的主题标签索引:
48
+ * 去重 → 频率降序(同频按首现顺序)→ 截断数量与单标签长度
49
+ */
50
+ function collectRemainingTags(remainingFacts, summaries, config) {
51
+ const counts = new Map();
52
+ const firstSeen = new Map();
53
+ let seq = 0;
54
+ const addTags = (item) => {
55
+ if (!Array.isArray(item.tags)) return;
56
+ for (const raw of item.tags) {
57
+ const tag = String(raw ?? '').trim();
58
+ if (!tag) continue;
59
+ if (!counts.has(tag)) {
60
+ counts.set(tag, 0);
61
+ firstSeen.set(tag, seq++);
62
+ }
63
+ counts.set(tag, counts.get(tag) + 1);
64
+ }
65
+ };
66
+ for (const m of remainingFacts) addTags(m);
67
+ for (const m of summaries) addTags(m);
68
+
69
+ return [...counts.keys()]
70
+ .sort((a, b) => (counts.get(b) - counts.get(a)) || (firstSeen.get(a) - firstSeen.get(b)))
71
+ .slice(0, config.maxTopicTags)
72
+ .map(tag => (tag.length > config.maxTagLength ? tag.slice(0, config.maxTagLength) : tag));
73
+ }
74
+
75
+ /**
76
+ * 三级筛选:选出注入系统提示词的记忆
77
+ * @param {Array} facts - 事实记忆数组(原文件顺序)
78
+ * @param {Array} summaries - 摘要记忆数组(永不注入,仅计入按需召回)
79
+ * @param {object} [config] - 注入配置
80
+ * @param {number} [now] - 当前时间戳(测试注入用)
81
+ * @returns {{injected: Array, remainingCount: number, remainingTags: string[]}}
82
+ */
83
+ export function selectMemoriesForInjection(facts, summaries, config = MEMORY_INJECTION_CONFIG, now = Date.now()) {
84
+ const factList = Array.isArray(facts) ? facts : [];
85
+ const summaryList = Array.isArray(summaries) ? summaries : [];
86
+ const threshold = config.alwaysInjectImportance;
87
+
88
+ // Tier1:常驻(不占预算),内部按价值降序
89
+ const tier1 = factList
90
+ .filter(m => (m.importance || 5) >= threshold)
91
+ .sort((a, b) => calcMemoryValue(b, now) - calcMemoryValue(a, now));
92
+ const tier1Set = new Set(tier1);
93
+
94
+ // Tier2:剩余按价值降序,在预算内贪心填充(装不下的跳过,继续尝试后续小条目)
95
+ const tier2Pool = factList
96
+ .filter(m => !tier1Set.has(m))
97
+ .sort((a, b) => calcMemoryValue(b, now) - calcMemoryValue(a, now));
98
+
99
+ const injected = [...tier1];
100
+ let usedChars = 0;
101
+ let usedCount = 0;
102
+ for (const m of tier2Pool) {
103
+ if (usedCount >= config.budgetCount) break;
104
+ const cost = estimateInjectionCost(m, config);
105
+ if (usedChars + cost > config.budgetChars) continue;
106
+ injected.push(m);
107
+ usedChars += cost;
108
+ usedCount++;
109
+ }
110
+
111
+ // Tier3:未注入的 facts + 全部 summaries 转按需召回
112
+ const injectedSet = new Set(injected);
113
+ const remainingFacts = factList.filter(m => !injectedSet.has(m));
114
+ const remainingCount = remainingFacts.length + summaryList.length;
115
+ const remainingTags = collectRemainingTags(remainingFacts, summaryList, config);
116
+
117
+ return { injected, remainingCount, remainingTags };
118
+ }
@@ -0,0 +1,193 @@
1
+ // agent/src/memory/routes.js - /api/memory/* 业务端点(认证与语言解析由 server.js 完成后转发)
2
+ // 零外部依赖:与 service 同进程,文件锁、session 召回缓存复用进程级单例。
3
+
4
+ import { createMemoryService } from './service.js';
5
+ import {
6
+ resolveMemoryEmbeddingConfig,
7
+ maskMemoryEmbeddingConfig,
8
+ saveMemoryEmbeddingConfig,
9
+ toEmbeddingProviderConfig
10
+ } from './embedding-config.js';
11
+ import { createEmbeddingProvider } from '../rag/embedding/index.js';
12
+
13
+ // 探活超时上限:语义通道绝不能拖垮调用方
14
+ const DEFAULT_EMBEDDING_TIMEOUT_MS = 8000;
15
+
16
+ // 进程级单例(默认路径 MEMORY_DIR/global-memory.json)
17
+ const defaultService = createMemoryService();
18
+
19
+ // 子路径 → service 方法映射
20
+ const ROUTE_METHODS = {
21
+ store: 'store',
22
+ recall: 'recall',
23
+ manage: 'manage',
24
+ inject: 'injectSelect',
25
+ // 重建向量缓存:派生缓存的显式重建入口(模型变更/首次启用语义通道后调一次)
26
+ reindex: 'backfillVectors'
27
+ };
28
+
29
+ /**
30
+ * 判断请求来源是否允许跨域读取响应(与 server.js 的 getAllowedOrigin 行为一致)
31
+ */
32
+ function getAllowedOrigin(req) {
33
+ const origin = req?.headers?.origin;
34
+ if (typeof origin === 'string' && origin.startsWith('chrome-extension://')) {
35
+ return origin;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * JSON 响应辅助(与 server.js 的 jsonResponse 行为一致)
42
+ */
43
+ function jsonResponse(res, status, data) {
44
+ const headers = {
45
+ 'Content-Type': 'application/json; charset=utf-8',
46
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
47
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS'
48
+ };
49
+ const allowedOrigin = getAllowedOrigin(res.req);
50
+ if (allowedOrigin) {
51
+ headers['Access-Control-Allow-Origin'] = allowedOrigin;
52
+ headers['Vary'] = 'Origin';
53
+ }
54
+ res.writeHead(status, headers);
55
+ res.end(JSON.stringify(data));
56
+ }
57
+
58
+ /**
59
+ * embed 调用超时保护。到期后必须 clearTimeout:否则 daemon 里每调一次就多挂一个定时器,
60
+ * 测试进程也会因为残留句柄延迟退出。
61
+ */
62
+ function withTimeout(promise, ms) {
63
+ let timer = null;
64
+ return Promise.race([
65
+ promise,
66
+ new Promise((_, reject) => {
67
+ timer = setTimeout(() => reject(new Error('embedding_timeout')), ms);
68
+ })
69
+ ]).finally(() => clearTimeout(timer));
70
+ }
71
+
72
+ /**
73
+ * 记忆 embedding 的配置/探活端点。配置类不走 ROUTE_METHODS,也不受「非 POST 即 404」约束
74
+ * (GET 是合法读操作);返回 true 表示已响应,false 则继续走业务分发/404。
75
+ * @param {{createProvider?: Function, embeddingTimeoutMs?: number}} [options] 测试接缝:注入假 provider
76
+ */
77
+ async function handleEmbeddingEndpoints(req, res, key, t, requestBody, options = {}) {
78
+ const body = requestBody || {};
79
+
80
+ if (key === 'embedding-config') {
81
+ if (req.method === 'GET') {
82
+ jsonResponse(res, 200, { success: true, config: maskMemoryEmbeddingConfig() });
83
+ return true;
84
+ }
85
+ if (req.method === 'POST') {
86
+ try {
87
+ const config = await saveMemoryEmbeddingConfig(body);
88
+ jsonResponse(res, 200, { success: true, config });
89
+ } catch (err) {
90
+ const error = err.message === 'invalid_embedding_mode'
91
+ ? t('memory.invalidEmbeddingMode')
92
+ : (err.message || 'embedding_config_error');
93
+ jsonResponse(res, 400, { success: false, error });
94
+ }
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ if (key === 'test-embedding') {
101
+ if (req.method !== 'POST') return false;
102
+ // 允许用请求体覆盖尚未保存的草稿配置,明文 key 缺省时回落已存 key
103
+ const cfg = resolveMemoryEmbeddingConfig(body);
104
+ const createProvider = options.createProvider || createEmbeddingProvider;
105
+ const timeoutMs = Number(options.embeddingTimeoutMs) > 0
106
+ ? Number(options.embeddingTimeoutMs)
107
+ : DEFAULT_EMBEDDING_TIMEOUT_MS;
108
+ try {
109
+ const provider = createProvider(toEmbeddingProviderConfig(cfg));
110
+ const vec = await withTimeout(Promise.resolve(provider.embedQuery('ping')), timeoutMs);
111
+ jsonResponse(res, 200, { success: true, dimensions: Array.isArray(vec) ? vec.length : 0 });
112
+ } catch (err) {
113
+ jsonResponse(res, 400, { success: false, error: err.message || 'embedding_test_failed' });
114
+ }
115
+ return true;
116
+ }
117
+
118
+ // 只读状态:条数 + 指纹陈旧。走 GET(配置类端点同例,不受「非 POST 即 404」约束)。
119
+ // 放在这里而不是 ROUTE_METHODS:后者只收 POST 业务动作,且需要请求体。
120
+ if (key === 'vector-status') {
121
+ if (req.method !== 'GET') return false;
122
+ const service = options.service || defaultService;
123
+ try {
124
+ const data = await service.vectorStatus();
125
+ if (data && data.success === false) {
126
+ jsonResponse(res, 400, data);
127
+ } else {
128
+ jsonResponse(res, 200, { success: true, ...data });
129
+ }
130
+ } catch (err) {
131
+ jsonResponse(res, 400, { success: false, error: err.message || 'vector_status_failed' });
132
+ }
133
+ return true;
134
+ }
135
+
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * /api/memory/* 分发
141
+ * @param {import('http').IncomingMessage} req
142
+ * @param {import('http').ServerResponse} res
143
+ * @param {string} pathname 形如 /api/memory/store
144
+ * @param {URL} url
145
+ * @param {(key: string, params?: object) => string} t 请求级翻译函数
146
+ * @param {object} requestBody 已解析的请求体
147
+ * @param {{service?: object}} [options] 可注入 service 实例(测试用),缺省使用进程级单例
148
+ */
149
+ export async function memoryDispatch(req, res, pathname, url, t, requestBody = {}, options = {}) {
150
+ const service = options.service || defaultService;
151
+ const key = pathname.slice('/api/memory/'.length).replace(/\/+$/, '');
152
+ const method = ROUTE_METHODS[key];
153
+
154
+ // 配置与探活端点先分流(GET 合法,且不进业务 404 判定)
155
+ if (await handleEmbeddingEndpoints(req, res, key, t, requestBody, options)) return;
156
+
157
+ // 404 仅表示路径/方法不匹配(扩展端据此探测旧版本 agent 无记忆端点)
158
+ if (!method || req.method !== 'POST') {
159
+ return jsonResponse(res, 404, { success: false, error: t('error.unknownApiPath') });
160
+ }
161
+
162
+ const body = requestBody || {};
163
+
164
+ try {
165
+ let data;
166
+ if (method === 'recall') {
167
+ data = await service.recall(body, body.sessionId ?? null, t);
168
+ } else if (method === 'injectSelect') {
169
+ data = await service.injectSelect(t);
170
+ } else if (method === 'backfillVectors') {
171
+ // 不接受请求体:只做「按当前配置把全部记忆补进向量缓存」,t 用于渲染跳过原因
172
+ data = await service.backfillVectors(t);
173
+ } else {
174
+ data = await service[method](body, t);
175
+ }
176
+
177
+ // 业务失败(参数缺失、文件读写失败):HTTP 400 + 原样 error,扩展端仍读 body
178
+ if (data && data.success === false) {
179
+ return jsonResponse(res, 400, data);
180
+ }
181
+ return jsonResponse(res, 200, { success: true, ...data });
182
+ } catch (err) {
183
+ console.error('[Memory] route error:', err);
184
+ return jsonResponse(res, 400, { success: false, error: err.message || 'memory_error' });
185
+ }
186
+ }
187
+
188
+ /**
189
+ * server.js 接线入口(签名与 ragRouter 一致)
190
+ */
191
+ export async function memoryRouter(req, res, pathname, url, t, requestBody) {
192
+ return memoryDispatch(req, res, pathname, url, t, requestBody);
193
+ }