dsh-tdai-memory 0.4.2 → 0.4.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 (64) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/cordis.patch.yml +8 -8
  6. package/package.json +102 -100
  7. package/vendor/tdai/adapters/index.js +15 -15
  8. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  9. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  10. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  11. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  12. package/vendor/tdai/adapters/standalone/index.js +5 -5
  13. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  14. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  15. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  16. package/vendor/tdai/core/index.js +8 -8
  17. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  18. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  19. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  20. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  21. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  22. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  23. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  24. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  25. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  26. package/vendor/tdai/core/record/l1-reader.js +178 -178
  27. package/vendor/tdai/core/record/l1-writer.js +141 -141
  28. package/vendor/tdai/core/report/reporter.js +86 -86
  29. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  30. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  31. package/vendor/tdai/core/scene/scene-format.js +53 -53
  32. package/vendor/tdai/core/scene/scene-index.js +82 -82
  33. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  34. package/vendor/tdai/core/seed/input.js +404 -404
  35. package/vendor/tdai/core/seed/types.js +9 -9
  36. package/vendor/tdai/core/store/bm25-client.js +117 -117
  37. package/vendor/tdai/core/store/bm25-local.js +68 -68
  38. package/vendor/tdai/core/store/embedding.js +632 -632
  39. package/vendor/tdai/core/store/factory.js +95 -95
  40. package/vendor/tdai/core/store/search-utils.js +54 -54
  41. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  42. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  43. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  44. package/vendor/tdai/core/store/types.js +17 -17
  45. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  46. package/vendor/tdai/core/tools/memory-search.js +207 -207
  47. package/vendor/tdai/core/types.js +12 -12
  48. package/vendor/tdai/utils/backup.js +189 -189
  49. package/vendor/tdai/utils/checkpoint.js +360 -360
  50. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  51. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  52. package/vendor/tdai/utils/env.js +13 -13
  53. package/vendor/tdai/utils/managed-timer.js +127 -127
  54. package/vendor/tdai/utils/manifest.js +91 -91
  55. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  56. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  57. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  58. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  59. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  60. package/vendor/tdai/utils/sanitize.js +360 -360
  61. package/vendor/tdai/utils/serial-queue.js +103 -103
  62. package/vendor/tdai/utils/session-filter.js +88 -88
  63. package/vendor/tdai/utils/text-utils.js +29 -29
  64. package/vendor/tdai/utils/time.js +252 -252
@@ -1,200 +1,200 @@
1
- import { createHash } from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
4
- import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js";
5
- import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
6
- const PROFILE_SCOPE = "global";
7
- /** Check if an error is a rename race condition (another concurrent pull won). */
8
- function isRenameRaceError(err) {
9
- const code = err?.code;
10
- return code === "ENOTEMPTY" || code === "EEXIST";
11
- }
12
- export function buildProfileStableId(scope, type, filename) {
13
- const hash = createHash("sha256")
14
- .update(`${scope}\u0000${type}\u0000${filename}`)
15
- .digest("hex");
16
- return `profile:v1:${hash}`;
17
- }
18
- function md5(text) {
19
- return createHash("md5").update(text).digest("hex");
20
- }
21
- async function statTimes(filePath) {
22
- try {
23
- const stat = await fs.stat(filePath);
24
- return {
25
- createdAtMs: Math.floor(stat.birthtimeMs || stat.ctimeMs || Date.now()),
26
- updatedAtMs: Math.floor(stat.mtimeMs || Date.now()),
27
- };
28
- }
29
- catch {
30
- const now = Date.now();
31
- return { createdAtMs: now, updatedAtMs: now };
32
- }
33
- }
34
- async function refreshPersonaNavigation(dataDir) {
35
- const personaPath = path.join(dataDir, "persona.md");
36
- let body;
37
- try {
38
- body = stripSceneNavigation(await fs.readFile(personaPath, "utf-8")).trim();
39
- }
40
- catch {
41
- return;
42
- }
43
- if (!body)
44
- return;
45
- const index = await readSceneIndex(dataDir);
46
- const nav = generateSceneNavigation(index);
47
- const finalContent = nav ? `${body}\n\n${nav}\n` : `${body}\n`;
48
- await fs.writeFile(personaPath, finalContent, "utf-8");
49
- }
50
- export async function listLocalProfiles(dataDir) {
51
- const profiles = [];
52
- const blocksDir = path.join(dataDir, "scene_blocks");
53
- try {
54
- const files = (await fs.readdir(blocksDir)).filter((file) => file.endsWith(".md")).sort();
55
- for (const filename of files) {
56
- const filePath = path.join(blocksDir, filename);
57
- const content = await fs.readFile(filePath, "utf-8");
58
- const { createdAtMs, updatedAtMs } = await statTimes(filePath);
59
- profiles.push({
60
- id: buildProfileStableId(PROFILE_SCOPE, "l2", filename),
61
- type: "l2",
62
- filename,
63
- content,
64
- contentMd5: md5(content),
65
- version: 0,
66
- createdAtMs,
67
- updatedAtMs,
68
- });
69
- }
70
- }
71
- catch {
72
- // ignore missing scene_blocks directory
73
- }
74
- const personaPath = path.join(dataDir, "persona.md");
75
- try {
76
- const rawPersona = await fs.readFile(personaPath, "utf-8");
77
- const body = stripSceneNavigation(rawPersona).trim();
78
- if (body) {
79
- const { createdAtMs, updatedAtMs } = await statTimes(personaPath);
80
- profiles.push({
81
- id: buildProfileStableId(PROFILE_SCOPE, "l3", "persona.md"),
82
- type: "l3",
83
- filename: "persona.md",
84
- content: body,
85
- contentMd5: md5(body),
86
- version: 0,
87
- createdAtMs,
88
- updatedAtMs,
89
- });
90
- }
91
- }
92
- catch {
93
- // ignore missing persona file
94
- }
95
- return profiles;
96
- }
97
- export async function pullProfilesToLocal(dataDir, store, logger) {
98
- if (!store.pullProfiles)
99
- return new Map();
100
- const records = await store.pullProfiles();
101
- const baseline = new Map();
102
- const tempDir = await fs.mkdtemp(path.join(dataDir, ".profiles-pull-"));
103
- const tempBlocksDir = path.join(tempDir, "scene_blocks");
104
- await fs.mkdir(tempBlocksDir, { recursive: true });
105
- try {
106
- for (const record of records) {
107
- baseline.set(record.id, {
108
- version: record.version,
109
- contentMd5: record.contentMd5,
110
- createdAtMs: record.createdAtMs,
111
- });
112
- if (record.type === "l2") {
113
- const target = path.join(tempBlocksDir, record.filename);
114
- await fs.writeFile(target, record.content, "utf-8");
115
- if (md5(record.content) !== record.contentMd5) {
116
- await fs.rm(target, { force: true });
117
- logger.debug?.(`[memory-tdai][profile-sync] MD5 mismatch for ${record.filename} (will re-pull on next sync)`);
118
- }
119
- continue;
120
- }
121
- if (record.type === "l3") {
122
- const body = stripSceneNavigation(record.content).trim();
123
- await fs.writeFile(path.join(tempDir, "persona.md"), body, "utf-8");
124
- if (md5(body) !== record.contentMd5) {
125
- await fs.rm(path.join(tempDir, "persona.md"), { force: true });
126
- logger.debug?.(`[memory-tdai][profile-sync] MD5 mismatch for ${record.filename} (will re-pull on next sync)`);
127
- }
128
- }
129
- }
130
- const localBlocksDir = path.join(dataDir, "scene_blocks");
131
- await fs.rm(localBlocksDir, { recursive: true, force: true });
132
- await fs.mkdir(path.dirname(localBlocksDir), { recursive: true });
133
- try {
134
- await fs.rename(tempBlocksDir, localBlocksDir);
135
- }
136
- catch (err) {
137
- if (isRenameRaceError(err)) {
138
- // Another concurrent pull already wrote scene_blocks — ours is redundant.
139
- // Both pulls fetched the same remote snapshot, so the other result is equivalent.
140
- logger.debug?.(`[memory-tdai][profile-sync] scene_blocks rename lost race (${err.code}), using existing`);
141
- return baseline;
142
- }
143
- throw err;
144
- }
145
- const tempPersonaPath = path.join(tempDir, "persona.md");
146
- const localPersonaPath = path.join(dataDir, "persona.md");
147
- try {
148
- await fs.access(tempPersonaPath);
149
- await fs.rm(localPersonaPath, { force: true });
150
- try {
151
- await fs.rename(tempPersonaPath, localPersonaPath);
152
- }
153
- catch (err) {
154
- if (!isRenameRaceError(err))
155
- throw err;
156
- logger.debug?.(`[memory-tdai][profile-sync] persona.md rename lost race, using existing`);
157
- }
158
- }
159
- catch (err) {
160
- // No temp persona file → remove local persona (remote has none)
161
- if (err.code === "ENOENT") {
162
- await fs.rm(localPersonaPath, { force: true });
163
- }
164
- else if (!isRenameRaceError(err)) {
165
- throw err;
166
- }
167
- }
168
- await syncSceneIndex(dataDir);
169
- await refreshPersonaNavigation(dataDir);
170
- logger.debug?.(`[memory-tdai][profile-sync] Pulled ${records.length} profile(s) to local cache`);
171
- return baseline;
172
- }
173
- finally {
174
- await fs.rm(tempDir, { recursive: true, force: true });
175
- }
176
- }
177
- export async function syncLocalProfilesToStore(dataDir, store, baselineMap, logger) {
178
- const localProfiles = await listLocalProfiles(dataDir);
179
- const localIds = new Set(localProfiles.map((profile) => profile.id));
180
- const syncRecords = localProfiles
181
- .filter((profile) => baselineMap.get(profile.id)?.contentMd5 !== profile.contentMd5 || !baselineMap.has(profile.id))
182
- .map((profile) => ({
183
- ...profile,
184
- baselineVersion: baselineMap.get(profile.id)?.version,
185
- }));
186
- if (syncRecords.length > 0 && store.syncProfiles) {
187
- await store.syncProfiles(syncRecords);
188
- logger.info(`[memory-tdai][profile-sync] Synced ${syncRecords.length} changed profile(s)`);
189
- }
190
- const deletedIds = [...baselineMap.keys()].filter((id) => !localIds.has(id));
191
- if (deletedIds.length > 0 && store.deleteProfiles) {
192
- await store.deleteProfiles(deletedIds);
193
- logger.info(`[memory-tdai][profile-sync] Deleted ${deletedIds.length} stale profile(s)`);
194
- }
195
- }
196
- export async function ensureL2L3Local(dataDir, store, logger) {
197
- if (!store.pullProfiles)
198
- return new Map();
199
- return pullProfilesToLocal(dataDir, store, logger);
200
- }
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js";
5
+ import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
6
+ const PROFILE_SCOPE = "global";
7
+ /** Check if an error is a rename race condition (another concurrent pull won). */
8
+ function isRenameRaceError(err) {
9
+ const code = err?.code;
10
+ return code === "ENOTEMPTY" || code === "EEXIST";
11
+ }
12
+ export function buildProfileStableId(scope, type, filename) {
13
+ const hash = createHash("sha256")
14
+ .update(`${scope}\u0000${type}\u0000${filename}`)
15
+ .digest("hex");
16
+ return `profile:v1:${hash}`;
17
+ }
18
+ function md5(text) {
19
+ return createHash("md5").update(text).digest("hex");
20
+ }
21
+ async function statTimes(filePath) {
22
+ try {
23
+ const stat = await fs.stat(filePath);
24
+ return {
25
+ createdAtMs: Math.floor(stat.birthtimeMs || stat.ctimeMs || Date.now()),
26
+ updatedAtMs: Math.floor(stat.mtimeMs || Date.now()),
27
+ };
28
+ }
29
+ catch {
30
+ const now = Date.now();
31
+ return { createdAtMs: now, updatedAtMs: now };
32
+ }
33
+ }
34
+ async function refreshPersonaNavigation(dataDir) {
35
+ const personaPath = path.join(dataDir, "persona.md");
36
+ let body;
37
+ try {
38
+ body = stripSceneNavigation(await fs.readFile(personaPath, "utf-8")).trim();
39
+ }
40
+ catch {
41
+ return;
42
+ }
43
+ if (!body)
44
+ return;
45
+ const index = await readSceneIndex(dataDir);
46
+ const nav = generateSceneNavigation(index);
47
+ const finalContent = nav ? `${body}\n\n${nav}\n` : `${body}\n`;
48
+ await fs.writeFile(personaPath, finalContent, "utf-8");
49
+ }
50
+ export async function listLocalProfiles(dataDir) {
51
+ const profiles = [];
52
+ const blocksDir = path.join(dataDir, "scene_blocks");
53
+ try {
54
+ const files = (await fs.readdir(blocksDir)).filter((file) => file.endsWith(".md")).sort();
55
+ for (const filename of files) {
56
+ const filePath = path.join(blocksDir, filename);
57
+ const content = await fs.readFile(filePath, "utf-8");
58
+ const { createdAtMs, updatedAtMs } = await statTimes(filePath);
59
+ profiles.push({
60
+ id: buildProfileStableId(PROFILE_SCOPE, "l2", filename),
61
+ type: "l2",
62
+ filename,
63
+ content,
64
+ contentMd5: md5(content),
65
+ version: 0,
66
+ createdAtMs,
67
+ updatedAtMs,
68
+ });
69
+ }
70
+ }
71
+ catch {
72
+ // ignore missing scene_blocks directory
73
+ }
74
+ const personaPath = path.join(dataDir, "persona.md");
75
+ try {
76
+ const rawPersona = await fs.readFile(personaPath, "utf-8");
77
+ const body = stripSceneNavigation(rawPersona).trim();
78
+ if (body) {
79
+ const { createdAtMs, updatedAtMs } = await statTimes(personaPath);
80
+ profiles.push({
81
+ id: buildProfileStableId(PROFILE_SCOPE, "l3", "persona.md"),
82
+ type: "l3",
83
+ filename: "persona.md",
84
+ content: body,
85
+ contentMd5: md5(body),
86
+ version: 0,
87
+ createdAtMs,
88
+ updatedAtMs,
89
+ });
90
+ }
91
+ }
92
+ catch {
93
+ // ignore missing persona file
94
+ }
95
+ return profiles;
96
+ }
97
+ export async function pullProfilesToLocal(dataDir, store, logger) {
98
+ if (!store.pullProfiles)
99
+ return new Map();
100
+ const records = await store.pullProfiles();
101
+ const baseline = new Map();
102
+ const tempDir = await fs.mkdtemp(path.join(dataDir, ".profiles-pull-"));
103
+ const tempBlocksDir = path.join(tempDir, "scene_blocks");
104
+ await fs.mkdir(tempBlocksDir, { recursive: true });
105
+ try {
106
+ for (const record of records) {
107
+ baseline.set(record.id, {
108
+ version: record.version,
109
+ contentMd5: record.contentMd5,
110
+ createdAtMs: record.createdAtMs,
111
+ });
112
+ if (record.type === "l2") {
113
+ const target = path.join(tempBlocksDir, record.filename);
114
+ await fs.writeFile(target, record.content, "utf-8");
115
+ if (md5(record.content) !== record.contentMd5) {
116
+ await fs.rm(target, { force: true });
117
+ logger.debug?.(`[memory-tdai][profile-sync] MD5 mismatch for ${record.filename} (will re-pull on next sync)`);
118
+ }
119
+ continue;
120
+ }
121
+ if (record.type === "l3") {
122
+ const body = stripSceneNavigation(record.content).trim();
123
+ await fs.writeFile(path.join(tempDir, "persona.md"), body, "utf-8");
124
+ if (md5(body) !== record.contentMd5) {
125
+ await fs.rm(path.join(tempDir, "persona.md"), { force: true });
126
+ logger.debug?.(`[memory-tdai][profile-sync] MD5 mismatch for ${record.filename} (will re-pull on next sync)`);
127
+ }
128
+ }
129
+ }
130
+ const localBlocksDir = path.join(dataDir, "scene_blocks");
131
+ await fs.rm(localBlocksDir, { recursive: true, force: true });
132
+ await fs.mkdir(path.dirname(localBlocksDir), { recursive: true });
133
+ try {
134
+ await fs.rename(tempBlocksDir, localBlocksDir);
135
+ }
136
+ catch (err) {
137
+ if (isRenameRaceError(err)) {
138
+ // Another concurrent pull already wrote scene_blocks — ours is redundant.
139
+ // Both pulls fetched the same remote snapshot, so the other result is equivalent.
140
+ logger.debug?.(`[memory-tdai][profile-sync] scene_blocks rename lost race (${err.code}), using existing`);
141
+ return baseline;
142
+ }
143
+ throw err;
144
+ }
145
+ const tempPersonaPath = path.join(tempDir, "persona.md");
146
+ const localPersonaPath = path.join(dataDir, "persona.md");
147
+ try {
148
+ await fs.access(tempPersonaPath);
149
+ await fs.rm(localPersonaPath, { force: true });
150
+ try {
151
+ await fs.rename(tempPersonaPath, localPersonaPath);
152
+ }
153
+ catch (err) {
154
+ if (!isRenameRaceError(err))
155
+ throw err;
156
+ logger.debug?.(`[memory-tdai][profile-sync] persona.md rename lost race, using existing`);
157
+ }
158
+ }
159
+ catch (err) {
160
+ // No temp persona file → remove local persona (remote has none)
161
+ if (err.code === "ENOENT") {
162
+ await fs.rm(localPersonaPath, { force: true });
163
+ }
164
+ else if (!isRenameRaceError(err)) {
165
+ throw err;
166
+ }
167
+ }
168
+ await syncSceneIndex(dataDir);
169
+ await refreshPersonaNavigation(dataDir);
170
+ logger.debug?.(`[memory-tdai][profile-sync] Pulled ${records.length} profile(s) to local cache`);
171
+ return baseline;
172
+ }
173
+ finally {
174
+ await fs.rm(tempDir, { recursive: true, force: true });
175
+ }
176
+ }
177
+ export async function syncLocalProfilesToStore(dataDir, store, baselineMap, logger) {
178
+ const localProfiles = await listLocalProfiles(dataDir);
179
+ const localIds = new Set(localProfiles.map((profile) => profile.id));
180
+ const syncRecords = localProfiles
181
+ .filter((profile) => baselineMap.get(profile.id)?.contentMd5 !== profile.contentMd5 || !baselineMap.has(profile.id))
182
+ .map((profile) => ({
183
+ ...profile,
184
+ baselineVersion: baselineMap.get(profile.id)?.version,
185
+ }));
186
+ if (syncRecords.length > 0 && store.syncProfiles) {
187
+ await store.syncProfiles(syncRecords);
188
+ logger.info(`[memory-tdai][profile-sync] Synced ${syncRecords.length} changed profile(s)`);
189
+ }
190
+ const deletedIds = [...baselineMap.keys()].filter((id) => !localIds.has(id));
191
+ if (deletedIds.length > 0 && store.deleteProfiles) {
192
+ await store.deleteProfiles(deletedIds);
193
+ logger.info(`[memory-tdai][profile-sync] Deleted ${deletedIds.length} stale profile(s)`);
194
+ }
195
+ }
196
+ export async function ensureL2L3Local(dataDir, store, logger) {
197
+ if (!store.pullProfiles)
198
+ return new Map();
199
+ return pullProfilesToLocal(dataDir, store, logger);
200
+ }
@@ -1,13 +1,13 @@
1
- /**
2
- * L1 Conflict Detection Prompt (Batch Mode)
3
- *
4
- * Based on Kenty's validated prototype prompt (l1_conflict_detection_prompt.md).
5
- * Batch-compares multiple new memories against a unified candidate pool,
6
- * supporting cross-type merge and multi-target operations.
7
- */
8
- // ============================
9
- // System Prompt
10
- // ============================
1
+ /**
2
+ * L1 Conflict Detection Prompt (Batch Mode)
3
+ *
4
+ * Based on Kenty's validated prototype prompt (l1_conflict_detection_prompt.md).
5
+ * Batch-compares multiple new memories against a unified candidate pool,
6
+ * supporting cross-type merge and multi-target operations.
7
+ */
8
+ // ============================
9
+ // System Prompt
10
+ // ============================
11
11
  export const CONFLICT_DETECTION_SYSTEM_PROMPT = `你是记忆冲突检测器。批量比较多条【新记忆】与【统一候选记忆池】中的已有记忆,逐条决定如何处理。
12
12
 
13
13
  **输出语言**:\`merged_content\` 使用与候选池中已有记忆相同的语言;JSON 字段名、枚举值、record_id、ISO 时间戳保持英文。
@@ -62,66 +62,66 @@ export const CONFLICT_DETECTION_SYSTEM_PROMPT = `你是记忆冲突检测器。
62
62
  - merged_content:merge/update 时的最终记忆文本。store/skip 时省略。
63
63
  - merged_type:merge/update 后记忆应归属的 type。根据合并后内容本质判断。
64
64
  - merged_priority:merge/update 后的新优先级(0-100 整数,merge/update 时必填)。合并后信息更完整、更确定,通常应**酌情提升** priority(例如两条 priority 70 的记忆合并后可提升到 80)。参考标准:80-100(核心特质/重要事件),60-79(一般偏好/普通活动),<60(次要信息)。
65
- - merged_timestamps:合并后的时间戳数组。收集新记忆 + 所有被合并旧记忆的时间戳,去重排序。`;
66
- /**
67
- * Format the batch conflict detection prompt using a unified candidate pool.
68
- *
69
- * Format (aligned with prototype):
70
- * 1. Unified candidate pool: de-duplicated list of all existing candidates across all new memories
71
- * 2. Per new memory: content + list of related candidate IDs from the pool
72
- *
73
- * This approach lets the LLM see the global picture and handle cross-memory dedup in one pass.
74
- *
75
- * @param matches - Array of new memories with their candidate matches
76
- */
77
- export function formatBatchConflictPrompt(matches) {
78
- // Step 1: Build unified candidate pool (de-duplicate across all new memories)
79
- const unifiedPool = new Map();
80
- const perMemoryCandidateIds = new Map();
81
- for (const m of matches) {
82
- const candidateIds = [];
83
- for (const c of m.candidates) {
84
- if (!unifiedPool.has(c.id)) {
85
- unifiedPool.set(c.id, c);
86
- }
87
- candidateIds.push(c.id);
88
- }
89
- perMemoryCandidateIds.set(m.newMemory.record_id, candidateIds);
90
- }
91
- // Step 2: Format unified pool as JSON
92
- const poolList = Array.from(unifiedPool.values()).map((c) => ({
93
- record_id: c.id,
94
- content: c.content,
95
- type: c.type,
96
- priority: c.priority,
97
- scene_name: c.scene_name,
98
- timestamps: c.timestamps,
99
- }));
100
- let poolSection;
101
- if (poolList.length === 0) {
102
- poolSection = "## 统一候选记忆池\n\n(空,没有已有记忆,所有新记忆直接 store)";
103
- }
104
- else {
105
- const poolStr = JSON.stringify(poolList, null, 2);
106
- poolSection = `## 统一候选记忆池(共 ${poolList.length} 条已有记忆)\n\n${poolStr}`;
107
- }
108
- // Step 3: Format each new memory with its related candidate IDs
109
- const memoryParts = matches.map((m, idx) => {
110
- const relatedIds = perMemoryCandidateIds.get(m.newMemory.record_id) ?? [];
111
- const relatedNote = relatedIds.length > 0
112
- ? JSON.stringify(relatedIds)
113
- : "[](无相似候选,直接 store)";
114
- const memStr = JSON.stringify({
115
- record_id: m.newMemory.record_id,
116
- content: m.newMemory.content,
117
- type: m.newMemory.type,
118
- priority: m.newMemory.priority,
119
- scene_name: m.newMemory.scene_name,
120
- }, null, 2);
121
- return `### 第 ${idx + 1} 条新记忆 (record_id: ${m.newMemory.record_id})\n${memStr}\n\n【关联候选 ID】${relatedNote}`;
122
- });
123
- const newMemoriesText = memoryParts.join("\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
124
- // Step 4: Assemble final prompt
65
+ - merged_timestamps:合并后的时间戳数组。收集新记忆 + 所有被合并旧记忆的时间戳,去重排序。`;
66
+ /**
67
+ * Format the batch conflict detection prompt using a unified candidate pool.
68
+ *
69
+ * Format (aligned with prototype):
70
+ * 1. Unified candidate pool: de-duplicated list of all existing candidates across all new memories
71
+ * 2. Per new memory: content + list of related candidate IDs from the pool
72
+ *
73
+ * This approach lets the LLM see the global picture and handle cross-memory dedup in one pass.
74
+ *
75
+ * @param matches - Array of new memories with their candidate matches
76
+ */
77
+ export function formatBatchConflictPrompt(matches) {
78
+ // Step 1: Build unified candidate pool (de-duplicate across all new memories)
79
+ const unifiedPool = new Map();
80
+ const perMemoryCandidateIds = new Map();
81
+ for (const m of matches) {
82
+ const candidateIds = [];
83
+ for (const c of m.candidates) {
84
+ if (!unifiedPool.has(c.id)) {
85
+ unifiedPool.set(c.id, c);
86
+ }
87
+ candidateIds.push(c.id);
88
+ }
89
+ perMemoryCandidateIds.set(m.newMemory.record_id, candidateIds);
90
+ }
91
+ // Step 2: Format unified pool as JSON
92
+ const poolList = Array.from(unifiedPool.values()).map((c) => ({
93
+ record_id: c.id,
94
+ content: c.content,
95
+ type: c.type,
96
+ priority: c.priority,
97
+ scene_name: c.scene_name,
98
+ timestamps: c.timestamps,
99
+ }));
100
+ let poolSection;
101
+ if (poolList.length === 0) {
102
+ poolSection = "## 统一候选记忆池\n\n(空,没有已有记忆,所有新记忆直接 store)";
103
+ }
104
+ else {
105
+ const poolStr = JSON.stringify(poolList, null, 2);
106
+ poolSection = `## 统一候选记忆池(共 ${poolList.length} 条已有记忆)\n\n${poolStr}`;
107
+ }
108
+ // Step 3: Format each new memory with its related candidate IDs
109
+ const memoryParts = matches.map((m, idx) => {
110
+ const relatedIds = perMemoryCandidateIds.get(m.newMemory.record_id) ?? [];
111
+ const relatedNote = relatedIds.length > 0
112
+ ? JSON.stringify(relatedIds)
113
+ : "[](无相似候选,直接 store)";
114
+ const memStr = JSON.stringify({
115
+ record_id: m.newMemory.record_id,
116
+ content: m.newMemory.content,
117
+ type: m.newMemory.type,
118
+ priority: m.newMemory.priority,
119
+ scene_name: m.newMemory.scene_name,
120
+ }, null, 2);
121
+ return `### 第 ${idx + 1} 条新记忆 (record_id: ${m.newMemory.record_id})\n${memStr}\n\n【关联候选 ID】${relatedNote}`;
122
+ });
123
+ const newMemoriesText = memoryParts.join("\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
124
+ // Step 4: Assemble final prompt
125
125
  return `**输出语言**:\`merged_content\` 使用与候选池中已有记忆相同的语言。
126
126
 
127
127
  ${poolSection}
@@ -132,5 +132,5 @@ ${"═".repeat(50)}
132
132
 
133
133
  ${newMemoriesText}
134
134
 
135
- 请逐条判断并输出决策 JSON 数组。当某条新记忆的候选列表为空时,该条直接输出 action=store。`;
136
- }
135
+ 请逐条判断并输出决策 JSON 数组。当某条新记忆的候选列表为空时,该条直接输出 action=store。`;
136
+ }
@@ -1,14 +1,14 @@
1
- /**
2
- * L1 Extraction Prompt: 情境切分 + 记忆提取
3
- *
4
- * Based on Kenty's validated prototype prompt (l1_memory_extraction_prompt.md).
5
- * System prompt handles scene segmentation + memory extraction in a single LLM call.
6
- * User prompt template fills in previous_scene_name, background_messages, new_messages.
7
- */
8
- import { formatForLLM, describeTimeZoneForPrompt } from "../../utils/time.js";
9
- // ============================
10
- // System Prompt
11
- // ============================
1
+ /**
2
+ * L1 Extraction Prompt: 情境切分 + 记忆提取
3
+ *
4
+ * Based on Kenty's validated prototype prompt (l1_memory_extraction_prompt.md).
5
+ * System prompt handles scene segmentation + memory extraction in a single LLM call.
6
+ * User prompt template fills in previous_scene_name, background_messages, new_messages.
7
+ */
8
+ import { formatForLLM, describeTimeZoneForPrompt } from "../../utils/time.js";
9
+ // ============================
10
+ // System Prompt
11
+ // ============================
12
12
  export const EXTRACT_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与记忆提取专家"。
13
13
  你的任务是分析用户的对话,判断情境切换,并从中提取结构化的核心记忆(仅限 persona, episodic, instruction 三类)。
14
14
 
@@ -95,27 +95,27 @@ metadata 字段说明:
95
95
  }
96
96
  ]
97
97
 
98
- 请严格按上述 JSON 数组格式输出,不要输出任何额外的 Markdown 代码块修饰符(如 \`\`\`json)或解释文本。`;
99
- // ============================
100
- // Prompt Builder
101
- // ============================
102
- /**
103
- * Format the user prompt for L1 extraction.
104
- *
105
- * @param newMessages - Messages to extract memories from (with ids and timestamps)
106
- * @param backgroundMessages - Previous messages for context only (not for extraction)
107
- * @param previousSceneName - The last known scene name (for continuity)
108
- */
109
- export function formatExtractionPrompt(params) {
110
- const { newMessages, backgroundMessages = [], previousSceneName = "无" } = params;
111
- const bgText = backgroundMessages.length > 0
112
- ? backgroundMessages
113
- .map((m) => `[${m.id}] [${m.role}] [${formatForLLM(m.timestamp)}]: ${m.content}`)
114
- .join("\n\n")
115
- : "无";
116
- const newText = newMessages
117
- .map((m) => `[${m.id}] [${m.role}] [${formatForLLM(m.timestamp)}]: ${m.content}`)
118
- .join("\n\n");
98
+ 请严格按上述 JSON 数组格式输出,不要输出任何额外的 Markdown 代码块修饰符(如 \`\`\`json)或解释文本。`;
99
+ // ============================
100
+ // Prompt Builder
101
+ // ============================
102
+ /**
103
+ * Format the user prompt for L1 extraction.
104
+ *
105
+ * @param newMessages - Messages to extract memories from (with ids and timestamps)
106
+ * @param backgroundMessages - Previous messages for context only (not for extraction)
107
+ * @param previousSceneName - The last known scene name (for continuity)
108
+ */
109
+ export function formatExtractionPrompt(params) {
110
+ const { newMessages, backgroundMessages = [], previousSceneName = "无" } = params;
111
+ const bgText = backgroundMessages.length > 0
112
+ ? backgroundMessages
113
+ .map((m) => `[${m.id}] [${m.role}] [${formatForLLM(m.timestamp)}]: ${m.content}`)
114
+ .join("\n\n")
115
+ : "无";
116
+ const newText = newMessages
117
+ .map((m) => `[${m.id}] [${m.role}] [${formatForLLM(m.timestamp)}]: ${m.content}`)
118
+ .join("\n\n");
119
119
  return `**${describeTimeZoneForPrompt()}**
120
120
 
121
121
  **输出语言**:根据下方"待提取的新消息"中 user 发言的主导语言书写 \`scene_name\` 和 memory \`content\`。
@@ -128,5 +128,5 @@ ${bgText}
128
128
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
129
129
 
130
130
  【待提取的新消息】(务必结合 timestamp 推算时间,只从这里提取记忆!):
131
- ${newText}`;
132
- }
131
+ ${newText}`;
132
+ }