dsh-layered-memory 0.5.3

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/assets/readme/hero.svg +58 -0
  4. package/cordis.patch.yml +16 -0
  5. package/dist/client.js +1066 -0
  6. package/dist/config.d.ts +263 -0
  7. package/dist/config.js +65 -0
  8. package/dist/hooks/capture.d.ts +16 -0
  9. package/dist/hooks/capture.js +160 -0
  10. package/dist/hooks/recall.d.ts +31 -0
  11. package/dist/hooks/recall.js +201 -0
  12. package/dist/index.d.ts +190 -0
  13. package/dist/index.js +234 -0
  14. package/dist/llm.d.ts +32 -0
  15. package/dist/llm.js +128 -0
  16. package/dist/pipeline/l1.d.ts +19 -0
  17. package/dist/pipeline/l1.js +226 -0
  18. package/dist/pipeline/l2.d.ts +13 -0
  19. package/dist/pipeline/l2.js +77 -0
  20. package/dist/pipeline/l3.d.ts +15 -0
  21. package/dist/pipeline/l3.js +72 -0
  22. package/dist/pipeline/runner.d.ts +44 -0
  23. package/dist/pipeline/runner.js +132 -0
  24. package/dist/prompts/l1-dedup.d.ts +20 -0
  25. package/dist/prompts/l1-dedup.js +244 -0
  26. package/dist/prompts/l1-extraction.d.ts +18 -0
  27. package/dist/prompts/l1-extraction.js +434 -0
  28. package/dist/prompts/persona.d.ts +23 -0
  29. package/dist/prompts/persona.js +234 -0
  30. package/dist/prompts/scene.d.ts +33 -0
  31. package/dist/prompts/scene.js +408 -0
  32. package/dist/settings.d.ts +27 -0
  33. package/dist/settings.js +73 -0
  34. package/dist/stats.d.ts +46 -0
  35. package/dist/stats.js +234 -0
  36. package/dist/store/bm25.d.ts +19 -0
  37. package/dist/store/bm25.js +62 -0
  38. package/dist/store/embedding.d.ts +68 -0
  39. package/dist/store/embedding.js +133 -0
  40. package/dist/store/io.d.ts +13 -0
  41. package/dist/store/io.js +75 -0
  42. package/dist/store/l0.d.ts +28 -0
  43. package/dist/store/l0.js +148 -0
  44. package/dist/store/l1.d.ts +70 -0
  45. package/dist/store/l1.js +239 -0
  46. package/dist/store/persona.d.ts +15 -0
  47. package/dist/store/persona.js +60 -0
  48. package/dist/store/scenes.d.ts +23 -0
  49. package/dist/store/scenes.js +150 -0
  50. package/dist/store/search-utils.d.ts +22 -0
  51. package/dist/store/search-utils.js +71 -0
  52. package/dist/store/session-modes.d.ts +24 -0
  53. package/dist/store/session-modes.js +102 -0
  54. package/dist/store/sqlite.d.ts +121 -0
  55. package/dist/store/sqlite.js +922 -0
  56. package/dist/store/state.d.ts +35 -0
  57. package/dist/store/state.js +62 -0
  58. package/dist/tools/index.d.ts +21 -0
  59. package/dist/tools/index.js +169 -0
  60. package/dist/types.d.ts +86 -0
  61. package/dist/types.js +7 -0
  62. package/dist/util/filelog.d.ts +4 -0
  63. package/dist/util/filelog.js +43 -0
  64. package/dist/util/sanitize.d.ts +10 -0
  65. package/dist/util/sanitize.js +66 -0
  66. package/dist/util/text.d.ts +11 -0
  67. package/dist/util/text.js +43 -0
  68. package/package.json +80 -0
package/dist/llm.js ADDED
@@ -0,0 +1,128 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import { errDetail } from './util/filelog.js';
3
+ /** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
4
+ export async function resolveModelRoute(ctx, cfg) {
5
+ if (cfg.llm.provider && cfg.llm.model) {
6
+ return { provider: cfg.llm.provider, model: cfg.llm.model };
7
+ }
8
+ // agentDefaultModel 是可选服务(dsh-agent-default-model 提供),缺失时不得抛错。
9
+ const defaults = ctx.get('agentDefaultModel');
10
+ const sel = defaults?.currentSelection?.();
11
+ if (sel?.provider && sel?.model) {
12
+ return { provider: cfg.llm.provider || sel.provider, model: cfg.llm.model || sel.model };
13
+ }
14
+ throw new Error('无法解析蒸馏模型路由:请在插件 config 中配置 llm.provider / llm.model,或确保存在默认模型选择');
15
+ }
16
+ /**
17
+ * 一次完整蒸馏调用:流式收集文本,返回最终字符串。
18
+ * 失败(error/aborted finish)抛错,由调用方兜底。
19
+ *
20
+ * 文本只从 block-end(协议保证携带**组装完成的整块**)取;text-delta 仅在
21
+ * 适配器异常地没有发 block-end 时兜底。两者都累计会把输出翻倍。
22
+ */
23
+ export async function callLLM(ctx, cfg, opts) {
24
+ const { provider, model } = await resolveModelRoute(ctx, cfg);
25
+ const signal = opts.signal ?? AbortSignal.timeout(cfg.llm.timeoutMs);
26
+ // 输入预算兜底:任何蒸馏调用的用户 prompt 不超过 maxInputChars
27
+ // (L1 已在数据层分块,这里是 L2/L3 与异常场景的最后一道网)
28
+ const user = opts.user.length > cfg.llm.maxInputChars
29
+ ? `${opts.user.slice(0, cfg.llm.maxInputChars)}\n\n[输入超出 ${cfg.llm.maxInputChars} 字符预算,已截断]`
30
+ : opts.user;
31
+ const stream = ctx.llm.stream({
32
+ provider,
33
+ model,
34
+ system: opts.system,
35
+ messages: [createUserMessage({ content: [{ type: 'text', text: user }], source: { kind: 'user' } })],
36
+ temperature: opts.temperature ?? cfg.llm.temperature,
37
+ maxTokens: opts.maxTokens ?? cfg.llm.maxTokens,
38
+ signal,
39
+ });
40
+ const startedAt = Date.now();
41
+ let deltaText = '';
42
+ let blockText = '';
43
+ // 块级统计:空输出诊断的唯一现场(finish reason / token 计数 / reasoning 是否吃光预算)
44
+ let finishKind = '';
45
+ let outputTokens = 0;
46
+ let reasoningTokens = 0;
47
+ let deltaBlocks = 0;
48
+ let reasoningChars = 0;
49
+ let reasoningHead = '';
50
+ const blockEndTypes = new Map();
51
+ try {
52
+ for await (const chunk of stream) {
53
+ if (chunk.type === 'text-delta') {
54
+ deltaBlocks++;
55
+ deltaText += chunk.text;
56
+ }
57
+ else if (chunk.type === 'reasoning-delta') {
58
+ reasoningChars += chunk.text.length;
59
+ if (reasoningHead.length < 300)
60
+ reasoningHead += chunk.text.slice(0, 300 - reasoningHead.length);
61
+ }
62
+ else if (chunk.type === 'block-end') {
63
+ blockEndTypes.set(chunk.block.type, (blockEndTypes.get(chunk.block.type) ?? 0) + 1);
64
+ if (chunk.block.type === 'text')
65
+ blockText += chunk.block.text;
66
+ }
67
+ else if (chunk.type === 'usage') {
68
+ outputTokens = chunk.usage.outputTokens;
69
+ reasoningTokens = chunk.usage.reasoningTokens ?? 0;
70
+ }
71
+ else if (chunk.type === 'finish') {
72
+ finishKind = chunk.reason.kind;
73
+ if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted') {
74
+ const failure = chunk.reason.failure;
75
+ throw new Error(`llm ${chunk.reason.kind}: ${failure?.message ?? 'unknown failure'}`);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ catch (err) {
81
+ opts.logger?.warn(`[memory] LLM 调用失败 ${provider}/${model}(${((Date.now() - startedAt) / 1000).toFixed(1)}s): ${errDetail(err)}`);
82
+ throw err;
83
+ }
84
+ const out = (blockText || deltaText).trim();
85
+ if (out.length === 0) {
86
+ // 空输出是最难排查的失败:流正常结束但一个字没吐。必须记录 finish 原因、
87
+ // token 计数与块分布,才能区分"模型只产出了 reasoning"vs"服务端返回空响应"。
88
+ opts.logger?.warn(`[memory] LLM 空输出 ${provider}/${model}(${((Date.now() - startedAt) / 1000).toFixed(1)}s,finish=${finishKind || '无 finish 块'}` +
89
+ `,输出 tokens=${outputTokens}${reasoningTokens > 0 ? `/reasoning ${reasoningTokens}` : ''},` +
90
+ `text-delta ${deltaBlocks} 块/${deltaText.length} 字符,reasoning ${reasoningChars} 字符,` +
91
+ `block-end: ${[...blockEndTypes.entries()].map(([t, n]) => `${t}×${n}`).join(', ') || '无'})` +
92
+ (reasoningHead ? `,reasoning 摘录: ${reasoningHead}…` : ''));
93
+ }
94
+ opts.logger?.info(`[memory] LLM 调用 ${provider}/${model}:输入 ${user.length} 字符 → 输出 ${out.length} 字符(${((Date.now() - startedAt) / 1000).toFixed(1)}s,finish=${finishKind || '无'})`);
95
+ return out;
96
+ }
97
+ /** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息)。 */
98
+ export function parseJsonLogged(raw, what, logger) {
99
+ try {
100
+ return parseJson(raw);
101
+ }
102
+ catch (err) {
103
+ logger?.error(`[memory] ${what} JSON 解析失败(${errDetail(err)}),原始输出前 400 字符: ${raw.slice(0, 400)}`);
104
+ throw new Error(`${what} 输出无法解析为 JSON`);
105
+ }
106
+ }
107
+ /** 容错地解析 LLM 输出的 JSON(剥掉可能的 ```json 围栏)。 */
108
+ export function parseJson(raw) {
109
+ let s = raw.trim();
110
+ if (s.startsWith('```')) {
111
+ s = s.replace(/^```[a-zA-Z]*\n?/, '').replace(/```$/, '').trim();
112
+ }
113
+ const start = s.indexOf('[');
114
+ const brace = s.indexOf('{');
115
+ let begin = -1;
116
+ if (start === -1)
117
+ begin = brace;
118
+ else if (brace === -1)
119
+ begin = start;
120
+ else
121
+ begin = Math.min(start, brace);
122
+ if (begin > 0)
123
+ s = s.slice(begin);
124
+ const end = s.lastIndexOf(']') > s.lastIndexOf('}') ? s.lastIndexOf(']') + 1 : s.lastIndexOf('}') + 1;
125
+ if (end > 0)
126
+ s = s.slice(0, end);
127
+ return JSON.parse(s);
128
+ }
@@ -0,0 +1,19 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { MemoryConfig } from '../config.js';
3
+ import type { L1Store } from '../store/l1.js';
4
+ import type { MemoryState } from '../store/state.js';
5
+ import type { ConversationMessage, ExtractMode, MemoryFamily, MemoryLogger, MemoryRecord } from '../types.js';
6
+ export interface ExtractionResult {
7
+ stored: number;
8
+ skipped: boolean;
9
+ sceneName: string;
10
+ newRecords: MemoryRecord[];
11
+ }
12
+ /** 分族 checkpoint 桶(活引用,改动由调用方 save 落盘)。 */
13
+ export type FamilyStates = Record<MemoryFamily, MemoryState>;
14
+ /**
15
+ * 按字符预算把消息切成多块(保持顺序,单条超预算独占一块,由 callLLM 兜底截断)。
16
+ * 每条消息按 content 长度 + 64 字符脚手架开销(id/时间戳行)计。
17
+ */
18
+ export declare function chunkByCharBudget(messages: ConversationMessage[], budgetChars: number): ConversationMessage[][];
19
+ export declare function runExtraction(ctx: Context, cfg: MemoryConfig, store: L1Store, states: FamilyStates, pending: ConversationMessage[], background: ConversationMessage[], logger: MemoryLogger, mode: ExtractMode): Promise<ExtractionResult>;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * L1 蒸馏:抽取(情境切分 + 记忆提取)→ 去重(冲突检测 + 合并)→ 写入。
3
+ * 移植 MemoryCore 的 l1 runner 语义(抽取 prompt → batch 冲突检测 → apply)。
4
+ */
5
+ import { randomBytes } from 'node:crypto';
6
+ import { callLLM, parseJsonLogged } from '../llm.js';
7
+ import { formatExtractionPrompt, getExtractMemoriesSystemPrompt } from '../prompts/l1-extraction.js';
8
+ import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
9
+ import { familyForType } from '../types.js';
10
+ function newId(prefix) {
11
+ return `${prefix}_${Date.now()}_${randomBytes(3).toString('hex')}`;
12
+ }
13
+ /**
14
+ * 按字符预算把消息切成多块(保持顺序,单条超预算独占一块,由 callLLM 兜底截断)。
15
+ * 每条消息按 content 长度 + 64 字符脚手架开销(id/时间戳行)计。
16
+ */
17
+ export function chunkByCharBudget(messages, budgetChars) {
18
+ if (messages.length === 0)
19
+ return [];
20
+ const chunks = [];
21
+ let cur = [];
22
+ let curChars = 0;
23
+ for (const m of messages) {
24
+ const len = m.content.length + 64;
25
+ if (cur.length > 0 && curChars + len > budgetChars) {
26
+ chunks.push(cur);
27
+ cur = [];
28
+ curChars = 0;
29
+ }
30
+ cur.push(m);
31
+ curChars += len;
32
+ }
33
+ if (cur.length > 0)
34
+ chunks.push(cur);
35
+ return chunks;
36
+ }
37
+ export async function runExtraction(ctx, cfg, store, states, pending, background, logger, mode) {
38
+ if (!cfg.extract.enabled)
39
+ return { stored: 0, skipped: true, sceneName: chainHead(states, mode), newRecords: [] };
40
+ if (pending.length < cfg.extract.minMessages) {
41
+ return { stored: 0, skipped: true, sceneName: chainHead(states, mode), newRecords: [] };
42
+ }
43
+ // 纯档强制族 = 档位族;auto 档抽取后的记录族按 type 前缀判定
44
+ const forcedFamily = mode === 'auto' ? undefined : mode;
45
+ // 情境链锚点桶:纯档用本族;auto 用最近活跃的族(chainHead 同源)
46
+ const chainState = mode === 'auto' ? activeState(states) : states[mode];
47
+ // ── Step 1: 抽取(输入按 llm.maxInputChars 预算分块,情境链式衔接,不丢消息) ──
48
+ const backgroundMsgs = background.slice(-cfg.extract.backgroundMessages);
49
+ // 预留:背景消息(≤10 条 ×4000 字)+ prompt 脚手架
50
+ const perChunk = Math.max(20_000, cfg.llm.maxInputChars - 42_000);
51
+ const chunks = chunkByCharBudget(pending, perChunk);
52
+ if (chunks.length > 1) {
53
+ logger.info(`[memory] L1 输入超预算(${pending.length} 条消息),分 ${chunks.length} 块抽取(每块 ≤${perChunk} 字符)`);
54
+ }
55
+ const extracted = [];
56
+ let lastScene = chainState.lastSceneName;
57
+ let sceneCount = 0;
58
+ for (const chunk of chunks) {
59
+ const userPrompt = formatExtractionPrompt({
60
+ newMessages: chunk,
61
+ backgroundMessages: backgroundMsgs,
62
+ previousSceneName: lastScene || '无',
63
+ });
64
+ const raw = await callLLM(ctx, cfg, {
65
+ system: getExtractMemoriesSystemPrompt(mode),
66
+ user: userPrompt,
67
+ logger,
68
+ });
69
+ const scenes = parseJsonLogged(raw, 'L1 抽取', logger);
70
+ if (!Array.isArray(scenes))
71
+ throw new Error('L1 抽取输出不是 JSON 数组');
72
+ for (const scene of scenes) {
73
+ if (!scene || typeof scene.scene_name !== 'string')
74
+ continue;
75
+ lastScene = scene.scene_name;
76
+ sceneCount++;
77
+ for (const m of scene.memories ?? []) {
78
+ if (!m || typeof m.content !== 'string' || !m.content.trim())
79
+ continue;
80
+ extracted.push({
81
+ ...m,
82
+ record_id: newId('mem'),
83
+ scene_name: scene.scene_name,
84
+ family: forcedFamily ?? familyForType(m.type ?? ''),
85
+ });
86
+ }
87
+ }
88
+ }
89
+ if (extracted.length === 0) {
90
+ logger.info(`[memory] L1 抽取完成:无可提取记忆(mode=${mode},${pending.length} 条消息,${sceneCount} 个情境)`);
91
+ // 成功但零产出:同样推进时间戳/场景,与失败(lastExtractAt 保持 0)区分开
92
+ markExtracted(states, mode, lastScene);
93
+ return { stored: 0, skipped: false, sceneName: lastScene, newRecords: [] };
94
+ }
95
+ // ── Step 2: 去重(batch 冲突检测;候选只在本族内召回,去重永不跨族) ──
96
+ const matches = await Promise.all(extracted.map(async (m) => ({
97
+ newMemory: m,
98
+ candidates: await store.searchCandidates(m.content, cfg.extract.candidatePool, m.family),
99
+ })));
100
+ const dedupPrompt = formatBatchConflictPrompt(matches);
101
+ const dedupRaw = await callLLM(ctx, cfg, {
102
+ system: getConflictDetectionSystemPrompt(mode),
103
+ user: dedupPrompt,
104
+ logger,
105
+ });
106
+ const decisions = parseJsonLogged(dedupRaw, 'L1 去重判定', logger);
107
+ const byRecord = new Map();
108
+ for (const d of Array.isArray(decisions) ? decisions : []) {
109
+ if (d && typeof d.record_id === 'string')
110
+ byRecord.set(d.record_id, d);
111
+ }
112
+ // 去重决策统计:无决策的记录按 skip 处理,这里聚合成单行日志便于排查
113
+ const actionCount = {};
114
+ for (const m of extracted) {
115
+ const action = byRecord.get(m.record_id)?.action ?? 'skip(未返回)';
116
+ actionCount[action] = (actionCount[action] ?? 0) + 1;
117
+ }
118
+ const candidateTotal = matches.reduce((n, m) => n + m.candidates.length, 0);
119
+ logger.info(`[memory] L1 去重判定:${extracted.length} 条候选记忆召回 ${candidateTotal} 条已有记录,决策 ${Object.entries(actionCount)
120
+ .map(([k, v]) => `${k}=${v}`)
121
+ .join(' ')}`);
122
+ // ── Step 3: 应用决策(官方语义:新记录追加进事实源,被替换目标只从检索库删除) ──
123
+ // 只按需取决策涉及的记录(候选 + 目标 id 并集),避免每轮全表扫描
124
+ const relatedIds = new Set();
125
+ for (const d of byRecord.values()) {
126
+ for (const id of d.target_ids ?? [])
127
+ relatedIds.add(id);
128
+ }
129
+ for (const m of matches) {
130
+ for (const c of m.candidates)
131
+ relatedIds.add(c.id);
132
+ }
133
+ const byId = new Map(store.getByIds([...relatedIds]).map((r) => [r.id, r]));
134
+ const deletedIds = new Set();
135
+ const added = [];
136
+ const now = Date.now();
137
+ for (const m of extracted) {
138
+ const decision = byRecord.get(m.record_id);
139
+ if (!decision || decision.action === 'skip')
140
+ continue;
141
+ const action = decision.action;
142
+ const ts = m.metadata?.activity_start_time ? Date.parse(String(m.metadata.activity_start_time)) : now;
143
+ if (action === 'store') {
144
+ added.push({
145
+ id: m.record_id,
146
+ content: m.content,
147
+ type: m.type,
148
+ priority: Number(m.priority) || 60,
149
+ scene_name: m.scene_name,
150
+ timestamps: [Number.isNaN(ts) ? now : ts],
151
+ createdAt: now,
152
+ updatedAt: now,
153
+ version: 0,
154
+ source_message_ids: m.source_message_ids ?? [],
155
+ metadata: m.metadata ?? {},
156
+ family: m.family,
157
+ });
158
+ continue;
159
+ }
160
+ // update / merge:目标记录从检索库删除,合并结果作为新记录追加(版本 +1)
161
+ // 候选召回按族隔离,合并产物保持新记忆的族标签
162
+ const targets = (decision.target_ids ?? []).filter((id) => byId.has(id));
163
+ for (const id of targets)
164
+ deletedIds.add(id);
165
+ const targetVersion = targets.reduce((max, id) => Math.max(max, byId.get(id)?.version ?? 0), 0);
166
+ const mergedTs = (decision.merged_timestamps ?? [])
167
+ .map((t) => Date.parse(t))
168
+ .filter((t) => !Number.isNaN(t))
169
+ .concat([now]);
170
+ added.push({
171
+ id: m.record_id,
172
+ content: decision.merged_content && decision.merged_content.trim()
173
+ ? decision.merged_content
174
+ : m.content,
175
+ type: decision.merged_type || m.type,
176
+ priority: Number(decision.merged_priority) || Number(m.priority) || 60,
177
+ scene_name: m.scene_name,
178
+ timestamps: Array.from(new Set(mergedTs)).sort((a, b) => a - b),
179
+ createdAt: now,
180
+ updatedAt: now,
181
+ version: targetVersion + 1,
182
+ source_message_ids: m.source_message_ids ?? [],
183
+ metadata: m.metadata ?? {},
184
+ family: m.family,
185
+ });
186
+ }
187
+ await store.appendNew(added);
188
+ if (deletedIds.size > 0)
189
+ await store.deleteBatch([...deletedIds]);
190
+ // 状态按记录族分桶推进(阈值计数各自独立)
191
+ const addedByFamily = { chat: 0, work: 0 };
192
+ for (const r of added)
193
+ addedByFamily[r.family ?? familyForType(r.type)]++;
194
+ for (const f of ['chat', 'work']) {
195
+ if (addedByFamily[f] === 0)
196
+ continue;
197
+ states[f].totalExtracted += addedByFamily[f];
198
+ states[f].newMemoriesSinceL2 += addedByFamily[f];
199
+ states[f].memoriesSinceL3 += addedByFamily[f];
200
+ }
201
+ markExtracted(states, mode, lastScene);
202
+ logger.info(`[memory] L1 抽取完成(mode=${mode}):消息 ${pending.length} 条,抽取 ${extracted.length} 条,去重后新增 ${added.length} 条(替换 ${deletedIds.size} 条,chat=${addedByFamily.chat}/work=${addedByFamily.work}),累计 chat=${states.chat.totalExtracted}/work=${states.work.totalExtracted}`);
203
+ return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added };
204
+ }
205
+ /** auto 档取最近活跃的族 checkpoint(情境链/计数锚点)。 */
206
+ function activeState(states) {
207
+ return states.chat.lastExtractAt >= states.work.lastExtractAt ? states.chat : states.work;
208
+ }
209
+ /** 情境链读取:auto → 最近活跃族;纯档 → 本族。 */
210
+ function chainHead(states, mode) {
211
+ return (mode === 'auto' ? activeState(states) : states[mode]).lastSceneName;
212
+ }
213
+ /** 抽取成功后推进时间戳/情境链:auto → 两族都推进(链只写锚点桶);纯档 → 本族。 */
214
+ function markExtracted(states, mode, lastScene) {
215
+ const now = Date.now();
216
+ if (mode === 'auto') {
217
+ // 两族的 lastExtractAt 都推进(去重候选各自族内判断"新消息");情境链只维护锚点桶
218
+ states.chat.lastExtractAt = now;
219
+ states.work.lastExtractAt = now;
220
+ activeState(states).lastSceneName = lastScene;
221
+ }
222
+ else {
223
+ states[mode].lastExtractAt = now;
224
+ states[mode].lastSceneName = lastScene;
225
+ }
226
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * L2 场景整合:把新记忆交给 LLM,输出文件操作 JSON,工程侧执行。
3
+ * 移植 MemoryCore scene runner 的语义(UPDATE 优先 / MERGE / 上限预警 / heat 管理)。
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ import type { MemoryConfig } from '../config.js';
7
+ import type { SceneStore } from '../store/scenes.js';
8
+ import type { MemoryFamily, MemoryLogger, MemoryRecord } from '../types.js';
9
+ export interface SceneResult {
10
+ changed: number;
11
+ personaRequestedReason?: string;
12
+ }
13
+ export declare function runSceneConsolidation(ctx: Context, cfg: MemoryConfig, scenes: SceneStore, newMemories: MemoryRecord[], logger: MemoryLogger, family: MemoryFamily): Promise<SceneResult>;
@@ -0,0 +1,77 @@
1
+ import { callLLM, parseJsonLogged } from '../llm.js';
2
+ import { buildScenePrompt, formatSceneSummaries } from '../prompts/scene.js';
3
+ import { Bm25Index } from '../store/bm25.js';
4
+ const REQUEST_RE = /\[PERSONA_UPDATE_REQUEST\]\s*reason:\s*([\s\S]*?)\[\/PERSONA_UPDATE_REQUEST\]/;
5
+ export async function runSceneConsolidation(ctx, cfg, scenes, newMemories, logger, family) {
6
+ if (!cfg.l2.enabled || newMemories.length === 0) {
7
+ return { changed: 0 };
8
+ }
9
+ const summaries = await scenes.list();
10
+ // ── 组装 prompt ──
11
+ const memoriesJson = JSON.stringify(newMemories.map((m) => ({
12
+ record_id: m.id,
13
+ content: m.content,
14
+ type: m.type,
15
+ priority: m.priority,
16
+ scene_name: m.scene_name,
17
+ timestamps: m.timestamps.map((t) => new Date(t).toISOString()),
18
+ })), null, 2);
19
+ const sceneSummaries = formatSceneSummaries(summaries);
20
+ const sceneContents = await pickSceneContents(scenes, summaries, newMemories, cfg.l2.sceneContextLimit);
21
+ const { systemPrompt, userPrompt } = buildScenePrompt({
22
+ memoriesJson,
23
+ sceneSummaries,
24
+ sceneContents,
25
+ currentTimestamp: new Date().toISOString(),
26
+ existingSceneFiles: summaries.map((s) => s.path),
27
+ maxScenes: cfg.l2.maxScenes,
28
+ family,
29
+ });
30
+ const raw = await callLLM(ctx, cfg, { system: systemPrompt, user: userPrompt, logger });
31
+ // ── 解析:先取 PERSONA_UPDATE_REQUEST 标记,再解析操作数组 ──
32
+ const reqMatch = REQUEST_RE.exec(raw);
33
+ const personaRequestedReason = reqMatch?.[1]?.trim() || undefined;
34
+ const opsRaw = reqMatch ? raw.replace(REQUEST_RE, '') : raw;
35
+ const ops = parseJsonLogged(opsRaw, 'L2 场景操作', logger);
36
+ if (!Array.isArray(ops))
37
+ throw new Error('L2 输出不是操作数组');
38
+ let changed = 0;
39
+ for (const op of ops) {
40
+ if (!op || typeof op.path !== 'string')
41
+ continue;
42
+ try {
43
+ if (op.op === 'write' && typeof op.content === 'string') {
44
+ await scenes.write(op.path, op.content);
45
+ changed++;
46
+ logger.debug?.(`[memory] L2 写入场景 ${op.path} (${op.content.length} 字符)`);
47
+ }
48
+ else if (op.op === 'delete') {
49
+ await scenes.write(op.path, '[DELETED]');
50
+ changed++;
51
+ logger.debug?.(`[memory] L2 删除场景 ${op.path}`);
52
+ }
53
+ }
54
+ catch (err) {
55
+ logger.warn(`[memory] L2 操作失败 ${op.path}: ${err instanceof Error ? err.message : String(err)}`);
56
+ }
57
+ }
58
+ logger.info(`[memory] L2 场景整合完成(family=${family}):${newMemories.length} 条新记忆 → ${changed} 个文件操作(场景总数 ${summaries.length})` +
59
+ (personaRequestedReason ? `,请求 L3 更新:${personaRequestedReason}` : ''));
60
+ return { changed, personaRequestedReason };
61
+ }
62
+ /** 选出与本次新记忆最相关的场景全文(供 LLM 参考,避免全量塞入)。 */
63
+ async function pickSceneContents(scenes, summaries, newMemories, limit) {
64
+ if (summaries.length === 0 || limit <= 0)
65
+ return '';
66
+ const index = new Bm25Index();
67
+ index.rebuild(summaries.map((s) => ({ id: s.path, text: `${s.summary} ${s.path}` })));
68
+ const query = newMemories.map((m) => m.content).join(' ');
69
+ const hits = index.search(query, limit);
70
+ const parts = [];
71
+ for (const h of hits) {
72
+ const content = await scenes.read(h.id);
73
+ if (content)
74
+ parts.push(`### 文件: ${h.id}\n\`\`\`markdown\n${content}\n\`\`\``);
75
+ }
76
+ return parts.join('\n\n');
77
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * L3 画像蒸馏:把变化场景交给 LLM,直接产出 persona.md 完整内容。
3
+ * 触发条件移植自 MemoryCore persona-trigger(主动请求 / 冷启动 / 恢复 / 间隔阈值)。
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ import type { MemoryConfig } from '../config.js';
7
+ import type { PersonaStore } from '../store/persona.js';
8
+ import type { SceneStore } from '../store/scenes.js';
9
+ import type { MemoryState } from '../store/state.js';
10
+ import type { MemoryFamily, MemoryLogger } from '../types.js';
11
+ export interface PersonaResult {
12
+ generated: boolean;
13
+ reason: string;
14
+ }
15
+ export declare function runPersona(ctx: Context, cfg: MemoryConfig, scenes: SceneStore, persona: PersonaStore, state: MemoryState, logger: MemoryLogger, family: MemoryFamily): Promise<PersonaResult>;
@@ -0,0 +1,72 @@
1
+ import { callLLM } from '../llm.js';
2
+ import { buildPersonaPrompt } from '../prompts/persona.js';
3
+ export async function runPersona(ctx, cfg, scenes, persona, state, logger, family) {
4
+ if (!cfg.l3.enabled)
5
+ return { generated: false, reason: 'l3 disabled' };
6
+ // ── 触发判定(移植 persona-trigger) ──
7
+ const existingPersona = await persona.read();
8
+ let reason = '';
9
+ if (state.personaRequestedReason) {
10
+ reason = `主动请求: ${state.personaRequestedReason}`;
11
+ }
12
+ else if (!state.hasPersona || !existingPersona) {
13
+ reason = '冷启动/恢复:首次生成或画像缺失';
14
+ }
15
+ else if (state.memoriesSinceL3 >= cfg.l3.interval) {
16
+ reason = `达到阈值: ${state.memoriesSinceL3} >= ${cfg.l3.interval}`;
17
+ }
18
+ else {
19
+ logger.debug?.(`[memory] L3 未触发(自上次蒸馏以来 ${state.memoriesSinceL3}/${cfg.l3.interval} 条新记忆)`);
20
+ return { generated: false, reason: 'no trigger' };
21
+ }
22
+ // ── 收集变化场景(自上次蒸馏以来 updated 变化 / 首次则全部) ──
23
+ const all = await scenes.list();
24
+ const changed = state.hasPersona
25
+ ? all.filter((s) => {
26
+ const t = Date.parse(s.updated);
27
+ return !Number.isNaN(t) && t > state.lastL3At;
28
+ })
29
+ : all;
30
+ if (changed.length === 0 && state.hasPersona) {
31
+ logger.debug?.('[memory] L3 触发但无变化场景,跳过');
32
+ return { generated: false, reason: 'no changed scenes' };
33
+ }
34
+ const changedContents = [];
35
+ for (const s of changed) {
36
+ const content = await scenes.read(s.path);
37
+ if (content)
38
+ changedContents.push(`### 场景: ${s.path}\n\`\`\`markdown\n${content}\n\`\`\``);
39
+ }
40
+ const { systemPrompt, userPrompt } = buildPersonaPrompt({
41
+ mode: existingPersona ? 'incremental' : 'first',
42
+ family,
43
+ currentTime: new Date().toISOString(),
44
+ totalProcessed: state.totalExtracted,
45
+ sceneCount: all.length,
46
+ changedSceneCount: changed.length,
47
+ changedScenesContent: changedContents.join('\n\n'),
48
+ existingPersona,
49
+ triggerInfo: reason,
50
+ });
51
+ const raw = await callLLM(ctx, cfg, { system: systemPrompt, user: userPrompt, logger });
52
+ const body = unwrapFence(raw);
53
+ if (!body) {
54
+ logger.error(`[memory] L3 输出为空,原始输出前 400 字符: ${raw.slice(0, 400)}`);
55
+ throw new Error('L3 输出为空');
56
+ }
57
+ await persona.write(body);
58
+ state.hasPersona = true;
59
+ state.lastL3At = Date.now();
60
+ state.memoriesSinceL3 = 0;
61
+ state.personaRequestedReason = undefined;
62
+ logger.info(`[memory] L3 画像蒸馏完成(family=${family},${reason}):${body.length} 字符,场景 ${changed.length}/${all.length} 个`);
63
+ return { generated: true, reason };
64
+ }
65
+ /** 剥掉可能的 ```markdown 围栏。 */
66
+ function unwrapFence(text) {
67
+ let s = text.trim();
68
+ if (s.startsWith('```')) {
69
+ s = s.replace(/^```[a-zA-Z]*\n?/, '').replace(/```$/, '').trim();
70
+ }
71
+ return s;
72
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 管线 Runner:串行执行 L0 → L1 → L2 → L3。
3
+ * 所有阶段失败只记日志,绝不向 Agent 循环抛错(失败兜底)。
4
+ *
5
+ * 会话档位:enqueue 带 mode(off 在捕获侧已被拦截);L1 待重试缓冲按档分桶;
6
+ * L2/L3 按记录族各自跑各自的场景/画像存储与阈值计数(分族隔离不变量)。
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import type { MemoryConfig } from '../config.js';
10
+ import type { LiveSettingsHandle } from '../settings.js';
11
+ import type { L0Store } from '../store/l0.js';
12
+ import type { L1Store } from '../store/l1.js';
13
+ import type { PersonaStore } from '../store/persona.js';
14
+ import type { SceneStore } from '../store/scenes.js';
15
+ import type { StateStore } from '../store/state.js';
16
+ import type { ConversationMessage, ExtractMode, MemoryFamily, MemoryLogger } from '../types.js';
17
+ export interface MemoryStores {
18
+ l0: L0Store;
19
+ l1: L1Store;
20
+ scenes: Record<MemoryFamily, SceneStore>;
21
+ persona: Record<MemoryFamily, PersonaStore>;
22
+ state: StateStore;
23
+ }
24
+ export declare class MemoryRunner {
25
+ private readonly ctx;
26
+ private readonly cfg;
27
+ private readonly stores;
28
+ private readonly logger;
29
+ private readonly live;
30
+ private queue;
31
+ private pending;
32
+ private background;
33
+ private states;
34
+ private afterRun;
35
+ constructor(ctx: Context, cfg: MemoryConfig, stores: MemoryStores, logger: MemoryLogger, live: LiveSettingsHandle);
36
+ init(): Promise<void>;
37
+ /** L1 抽取待重试的消息条数(状态面板用)。 */
38
+ get pendingCount(): number;
39
+ /** 管线跑完一轮后的回调(用于召回缓存失效)。 */
40
+ setAfterRun(fn: () => void): void;
41
+ /** 一轮对话结束后入队(L0 落盘 + 蒸馏触发判定)。 */
42
+ enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode): void;
43
+ private runTurn;
44
+ }