dsh-tdai-memory 0.1.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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/THIRD-PARTY-NOTICES.md +20 -0
  4. package/index.js +328 -0
  5. package/package.json +53 -0
  6. package/recall-inject.js +80 -0
  7. package/vendor/tdai/adapters/index.js +15 -0
  8. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
  9. package/vendor/tdai/adapters/openclaw/index.js +5 -0
  10. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
  11. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
  12. package/vendor/tdai/adapters/standalone/index.js +5 -0
  13. package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
  14. package/vendor/tdai/config.js +363 -0
  15. package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
  16. package/vendor/tdai/core/hooks/auto-capture.js +236 -0
  17. package/vendor/tdai/core/hooks/auto-recall.js +653 -0
  18. package/vendor/tdai/core/index.js +8 -0
  19. package/vendor/tdai/core/persona/persona-generator.js +184 -0
  20. package/vendor/tdai/core/persona/persona-trigger.js +94 -0
  21. package/vendor/tdai/core/profile/profile-sync.js +200 -0
  22. package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
  23. package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
  24. package/vendor/tdai/core/prompts/persona-generation.js +160 -0
  25. package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
  26. package/vendor/tdai/core/record/l1-dedup.js +318 -0
  27. package/vendor/tdai/core/record/l1-extractor.js +379 -0
  28. package/vendor/tdai/core/record/l1-reader.js +178 -0
  29. package/vendor/tdai/core/record/l1-writer.js +141 -0
  30. package/vendor/tdai/core/report/reporter.js +86 -0
  31. package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
  32. package/vendor/tdai/core/scene/scene-extractor.js +410 -0
  33. package/vendor/tdai/core/scene/scene-format.js +53 -0
  34. package/vendor/tdai/core/scene/scene-index.js +82 -0
  35. package/vendor/tdai/core/scene/scene-navigation.js +60 -0
  36. package/vendor/tdai/core/seed/input.js +404 -0
  37. package/vendor/tdai/core/seed/seed-runtime.js +314 -0
  38. package/vendor/tdai/core/seed/types.js +9 -0
  39. package/vendor/tdai/core/store/bm25-client.js +117 -0
  40. package/vendor/tdai/core/store/bm25-local.js +68 -0
  41. package/vendor/tdai/core/store/embedding.js +632 -0
  42. package/vendor/tdai/core/store/factory.js +95 -0
  43. package/vendor/tdai/core/store/search-utils.js +54 -0
  44. package/vendor/tdai/core/store/sqlite.js +1822 -0
  45. package/vendor/tdai/core/store/tcvdb-client.js +221 -0
  46. package/vendor/tdai/core/store/tcvdb.js +1047 -0
  47. package/vendor/tdai/core/store/types.js +17 -0
  48. package/vendor/tdai/core/tdai-core.js +446 -0
  49. package/vendor/tdai/core/tools/conversation-search.js +199 -0
  50. package/vendor/tdai/core/tools/memory-search.js +207 -0
  51. package/vendor/tdai/core/types.js +12 -0
  52. package/vendor/tdai/utils/backup.js +189 -0
  53. package/vendor/tdai/utils/checkpoint.js +356 -0
  54. package/vendor/tdai/utils/clean-context-runner.js +472 -0
  55. package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
  56. package/vendor/tdai/utils/env.js +13 -0
  57. package/vendor/tdai/utils/managed-timer.js +127 -0
  58. package/vendor/tdai/utils/manifest.js +91 -0
  59. package/vendor/tdai/utils/memory-cleaner.js +300 -0
  60. package/vendor/tdai/utils/no-think-fetch.js +109 -0
  61. package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
  62. package/vendor/tdai/utils/pipeline-factory.js +517 -0
  63. package/vendor/tdai/utils/pipeline-manager.js +892 -0
  64. package/vendor/tdai/utils/sanitize.js +360 -0
  65. package/vendor/tdai/utils/serial-queue.js +103 -0
  66. package/vendor/tdai/utils/session-filter.js +88 -0
  67. package/vendor/tdai/utils/text-utils.js +29 -0
  68. package/vendor/tdai/utils/time.js +252 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * PersonaGenerator: generates or updates user persona using the four-layer
3
+ * deep scan model via CleanContextRunner.
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { formatForLLM } from "../../utils/time.js";
8
+ import { CleanContextRunner } from "../../utils/clean-context-runner.js";
9
+ import { CheckpointManager } from "../../utils/checkpoint.js";
10
+ import { readSceneIndex } from "../scene/scene-index.js";
11
+ import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
12
+ import { buildPersonaPrompt } from "../prompts/persona-generation.js";
13
+ import { BackupManager } from "../../utils/backup.js";
14
+ import { escapeXmlTags } from "../../utils/sanitize.js";
15
+ import { report } from "../report/reporter.js";
16
+ const TAG = "[memory-tdai] [persona]";
17
+ export class PersonaGenerator {
18
+ dataDir;
19
+ runner;
20
+ logger;
21
+ backupCount;
22
+ instanceId;
23
+ constructor(opts) {
24
+ this.dataDir = opts.dataDir;
25
+ this.logger = opts.logger;
26
+ this.backupCount = opts.backupCount ?? 3;
27
+ this.instanceId = opts.instanceId;
28
+ // Use injected LLMRunner if available, otherwise fall back to CleanContextRunner
29
+ this.runner = opts.llmRunner ?? new CleanContextRunner({
30
+ config: opts.config,
31
+ modelRef: opts.model,
32
+ enableTools: true,
33
+ logger: opts.logger,
34
+ });
35
+ this.logger?.debug?.(`${TAG} Generator created: model=${opts.model ?? "(default)"}, dataDir=${opts.dataDir}`);
36
+ }
37
+ /**
38
+ * Execute local persona generation without advancing checkpoint.
39
+ */
40
+ async generateLocalPersona(triggerReason) {
41
+ const startMs = Date.now();
42
+ this.logger?.debug?.(`${TAG} Starting generation: reason="${triggerReason ?? "none"}"`);
43
+ const cpManager = new CheckpointManager(this.dataDir);
44
+ const cp = await cpManager.read();
45
+ this.logger?.debug?.(`${TAG} Checkpoint: total_processed=${cp.total_processed}, last_persona_at=${cp.last_persona_at}`);
46
+ const personaPath = path.join(this.dataDir, "persona.md");
47
+ // 1. Read existing persona (strip navigation)
48
+ let existingPersona;
49
+ try {
50
+ const raw = await fs.readFile(personaPath, "utf-8");
51
+ existingPersona = stripSceneNavigation(raw).trim() || undefined;
52
+ this.logger?.debug?.(`${TAG} Existing persona: ${existingPersona ? `${existingPersona.length} chars` : "empty"}`);
53
+ }
54
+ catch {
55
+ this.logger?.debug?.(`${TAG} No existing persona file`);
56
+ }
57
+ // 2. Load scene index + identify changed scenes
58
+ const index = await readSceneIndex(this.dataDir);
59
+ const changedScenes = index.filter((e) => {
60
+ if (!cp.last_persona_time)
61
+ return true;
62
+ const updatedMs = new Date(e.updated).getTime();
63
+ const personaMs = new Date(cp.last_persona_time).getTime();
64
+ // If either date is unparseable (NaN), treat as changed (conservative)
65
+ if (Number.isNaN(updatedMs) || Number.isNaN(personaMs))
66
+ return true;
67
+ return updatedMs > personaMs;
68
+ });
69
+ this.logger?.debug?.(`${TAG} Scene index: ${index.length} total, ${changedScenes.length} changed since last persona`);
70
+ // 3. Read changed scene contents (full raw content including META, matching Python reference)
71
+ const blocksDir = path.join(this.dataDir, "scene_blocks");
72
+ const changedSceneContents = [];
73
+ for (const entry of changedScenes) {
74
+ try {
75
+ const raw = await fs.readFile(path.join(blocksDir, entry.filename), "utf-8");
76
+ changedSceneContents.push(`### [${changedSceneContents.length + 1}] ${entry.filename}\n\n\`\`\`markdown\n${raw}\n\`\`\``);
77
+ }
78
+ catch {
79
+ this.logger?.warn(`${TAG} Could not read scene block: ${entry.filename}`);
80
+ }
81
+ }
82
+ if (changedSceneContents.length === 0 && existingPersona) {
83
+ this.logger?.debug?.(`${TAG} No scene changes and persona exists, skipping generation`);
84
+ return false;
85
+ }
86
+ // 4. Determine mode
87
+ const mode = existingPersona ? "incremental" : "first";
88
+ this.logger?.debug?.(`${TAG} Generation mode: ${mode}, ${changedSceneContents.length} scene blocks to process`);
89
+ // 5. Build changed scenes section with guidance (matching Python reference format)
90
+ let changedScenesContent;
91
+ if (changedSceneContents.length > 0) {
92
+ changedScenesContent =
93
+ `\n\n## 📄 变化场景完整内容\n\n` +
94
+ `*自上次 Persona 更新后,以下 ${changedSceneContents.length} 个场景发生了变化。工程已为你预加载完整内容:*\n\n` +
95
+ changedSceneContents.join("\n\n") +
96
+ `\n\n---\n\n` +
97
+ `⚠️ **重点分析变化场景**:上述场景是自上次更新后的**新增/修改内容**,请**重点分析**这些场景中的新信息。\n`;
98
+ }
99
+ else {
100
+ changedScenesContent = `\n\n⚠️ **无变化场景**:所有场景均已在上次 Persona 更新中分析过,本次可直接读取所有场景进行全局审视。\n`;
101
+ }
102
+ // 6. Build prompt
103
+ const { systemPrompt, userPrompt } = buildPersonaPrompt({
104
+ mode,
105
+ currentTime: formatForLLM(new Date()),
106
+ totalProcessed: cp.total_processed,
107
+ sceneCount: index.length,
108
+ changedSceneCount: changedScenes.length,
109
+ changedScenesContent,
110
+ existingPersona,
111
+ triggerInfo: triggerReason,
112
+ personaFilePath: personaPath,
113
+ checkpointPath: path.join(this.dataDir, ".metadata", "recall_checkpoint.json"),
114
+ });
115
+ // 7. Backup before LLM run (LLM writes persona.md via tools)
116
+ const bm = new BackupManager(path.join(this.dataDir, ".backup"));
117
+ await bm.backupFile(personaPath, "persona", `offset${cp.total_processed}`, this.backupCount);
118
+ // 8. Run LLM agent (sandboxed to dataDir, tools enabled — LLM writes persona.md directly)
119
+ try {
120
+ this.logger?.debug?.(`${TAG} Calling LLM for persona generation (timeout=180s, tools=enabled, workspaceDir=${this.dataDir})...`);
121
+ await this.runner.run({
122
+ systemPrompt,
123
+ prompt: userPrompt,
124
+ taskId: "persona-generation",
125
+ timeoutMs: 180_000,
126
+ // maxTokens omitted → core uses the resolved model's maxTokens from catalog
127
+ workspaceDir: this.dataDir,
128
+ });
129
+ this.logger?.debug?.(`${TAG} LLM runner completed`);
130
+ }
131
+ catch (err) {
132
+ const elapsedMs = Date.now() - startMs;
133
+ this.logger?.error(`${TAG} Persona generation failed after ${elapsedMs}ms: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
134
+ return false;
135
+ }
136
+ // 9. Read LLM-written persona.md and apply post-processing
137
+ let personaText;
138
+ try {
139
+ personaText = await fs.readFile(personaPath, "utf-8");
140
+ }
141
+ catch {
142
+ // LLM failed to write persona.md — treat as failure
143
+ this.logger?.error(`${TAG} LLM did not write persona.md — file not found after runner completed`);
144
+ return false;
145
+ }
146
+ // 10. Strip any navigation the LLM might have added + sanitize for safe injection
147
+ personaText = escapeXmlTags(stripSceneNavigation(personaText).trim());
148
+ if (!personaText) {
149
+ this.logger?.error(`${TAG} LLM wrote empty persona.md — skipping`);
150
+ return false;
151
+ }
152
+ // 11. Append fresh scene navigation and write final content
153
+ const nav = generateSceneNavigation(index);
154
+ const finalContent = nav ? `${personaText}\n\n${nav}\n` : personaText;
155
+ await fs.writeFile(personaPath, finalContent, "utf-8");
156
+ const elapsedMs = Date.now() - startMs;
157
+ this.logger?.info(`${TAG} Persona written (${finalContent.length} chars) in ${elapsedMs}ms`);
158
+ // ── l3_persona_generation metric ──
159
+ if (this.instanceId && this.logger) {
160
+ report("l3_persona_generation", {
161
+ triggerReason: triggerReason ?? "unknown",
162
+ mode: existingPersona ? "incremental" : "initial",
163
+ newPersonaContent: personaText,
164
+ newPersonaLength: personaText.length,
165
+ totalDurationMs: elapsedMs,
166
+ success: true,
167
+ error: null,
168
+ });
169
+ }
170
+ return true;
171
+ }
172
+ /**
173
+ * Backward-compatible wrapper: local generation + checkpoint advance.
174
+ */
175
+ async generate(triggerReason) {
176
+ const updated = await this.generateLocalPersona(triggerReason);
177
+ if (!updated)
178
+ return false;
179
+ const cpManager = new CheckpointManager(this.dataDir);
180
+ const cp = await cpManager.read();
181
+ await cpManager.markPersonaGenerated(cp.total_processed);
182
+ return true;
183
+ }
184
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * PersonaTrigger: determines whether to trigger persona generation.
3
+ * Implements the 5 trigger conditions from the legacy system.
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { CheckpointManager } from "../../utils/checkpoint.js";
8
+ import { stripSceneNavigation } from "../scene/scene-navigation.js";
9
+ const TAG = "[memory-tdai] [trigger]";
10
+ export class PersonaTrigger {
11
+ dataDir;
12
+ interval;
13
+ logger;
14
+ constructor(opts) {
15
+ this.dataDir = opts.dataDir;
16
+ this.interval = opts.interval;
17
+ this.logger = opts.logger;
18
+ }
19
+ async shouldGenerate() {
20
+ const cpManager = new CheckpointManager(this.dataDir);
21
+ const cp = await cpManager.read();
22
+ this.logger?.debug?.(`${TAG} Evaluating: total_processed=${cp.total_processed}, last_persona_at=${cp.last_persona_at}, memories_since=${cp.memories_since_last_persona}, scenes=${cp.scenes_processed}`);
23
+ // Priority 1: Agent explicitly requested persona update
24
+ if (cp.request_persona_update) {
25
+ const result = {
26
+ should: true,
27
+ reason: `主动请求: ${cp.persona_update_reason || "Agent 请求更新"}`,
28
+ };
29
+ this.logger?.debug?.(`${TAG} Trigger P1 (explicit request): ${result.reason}`);
30
+ return result;
31
+ }
32
+ // Priority 2: Cold start — first extraction done, no persona yet, has scene files
33
+ if (cp.scenes_processed > 0 &&
34
+ cp.last_persona_at === 0 &&
35
+ (await this.hasSceneFiles())) {
36
+ const result = { should: true, reason: "首次冷启动:首次提取完成且有场景文件" };
37
+ this.logger?.debug?.(`${TAG} Trigger P2 (cold start): scenes_processed=${cp.scenes_processed}, total_processed=${cp.total_processed}`);
38
+ return result;
39
+ }
40
+ // Priority 2.5: Recovery — persona was generated before but persona.md body
41
+ // is now empty (corrupted/missing). Regenerate to restore.
42
+ if (cp.last_persona_at > 0 &&
43
+ (await this.hasSceneFiles()) &&
44
+ !(await this.hasPersonaBody())) {
45
+ const result = { should: true, reason: "恢复:persona.md 正文丢失或为空,需要重新生成" };
46
+ this.logger?.debug?.(`${TAG} Trigger P2.5 (recovery): last_persona_at=${cp.last_persona_at}, persona body missing`);
47
+ return result;
48
+ }
49
+ // Priority 3: First scene block extraction
50
+ if (cp.scenes_processed === 1 && cp.memories_since_last_persona > 0) {
51
+ const result = { should: true, reason: "首次 Scene Block 提取完成" };
52
+ this.logger?.debug?.(`${TAG} Trigger P3 (first scene): scenes_processed=${cp.scenes_processed}`);
53
+ return result;
54
+ }
55
+ // Priority 4: Reached threshold
56
+ if (cp.memories_since_last_persona >= this.interval) {
57
+ const result = {
58
+ should: true,
59
+ reason: `达到阈值: ${cp.memories_since_last_persona} >= ${this.interval}`,
60
+ };
61
+ this.logger?.debug?.(`${TAG} Trigger P4 (threshold): ${result.reason}`);
62
+ return result;
63
+ }
64
+ this.logger?.debug?.(`${TAG} No trigger conditions met`);
65
+ return { should: false, reason: "" };
66
+ }
67
+ async hasSceneFiles() {
68
+ const blocksDir = path.join(this.dataDir, "scene_blocks");
69
+ try {
70
+ const files = await fs.readdir(blocksDir);
71
+ const hasFiles = files.some((f) => f.endsWith(".md"));
72
+ return hasFiles;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * Check whether persona.md has a non-empty body (excluding scene navigation).
80
+ * Returns false if the file doesn't exist, is empty, or only contains
81
+ * scene navigation (no actual persona content).
82
+ */
83
+ async hasPersonaBody() {
84
+ const personaPath = path.join(this.dataDir, "persona.md");
85
+ try {
86
+ const raw = await fs.readFile(personaPath, "utf-8");
87
+ const body = stripSceneNavigation(raw).trim();
88
+ return body.length > 0;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,136 @@
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
+ export const CONFLICT_DETECTION_SYSTEM_PROMPT = `你是记忆冲突检测器。批量比较多条【新记忆】与【统一候选记忆池】中的已有记忆,逐条决定如何处理。
12
+
13
+ **输出语言**:\`merged_content\` 使用与候选池中已有记忆相同的语言;JSON 字段名、枚举值、record_id、ISO 时间戳保持英文。
14
+
15
+ ## 核心规则
16
+
17
+ - **跨 type 合并**:不同 type(persona / episodic / instruction)的记忆如果语义上描述同一事实/事件,**可以合并**。
18
+ - **多对多合并**:一条新记忆可以同时替换/合并候选池中的**多条**已有记忆(通过 target_ids 数组指定)。
19
+ - 合并后你必须判断新记忆的最佳 type(merged_type)。
20
+
21
+ ## 判断逻辑
22
+
23
+ 1. **分辨记忆性质**:
24
+ - **状态类**(persona/instruction):偏好、特质、长期设定、相对稳定的事实、行为规则
25
+ - **事件类**(episodic):一次性经历、带时间点的客观记录,建议合并同一件事的前因后果
26
+
27
+ 2. **判断是否同一事实/事件**:主体相同、主题一致、时间接近、scene_name 相似
28
+
29
+ 3. **选择动作**:
30
+ - "store":视为新信息,新增当前记忆。
31
+ - "skip":已有记忆更好,新记忆无增量或更模糊,忽略当前记忆。
32
+ - "update":同一事实/事件,新记忆在内容或时间上更优(更具体、更晚或纠错),以新记忆为主覆盖旧记忆,可保留旧记忆中仍正确的细节。
33
+ - "merge":同一事实或同一演化过程,多条记忆信息互补且不矛盾,合并成一条更完整记忆,信息尽量不冗余。
34
+
35
+ 4. **策略倾向**:
36
+ - 状态类:多条描述同一偏好/特质 → 倾向 merge;无增量 → skip;明确更新 → update
37
+ - 事件类:同一事件的前因后果、不同阶段 → 倾向 merge 为一条完整叙述;完全相同 → skip
38
+ - 跨类型示例:一条 episodic "用户在 2018 年开始做播客" + 一条 persona "用户有播客制作经验" → 可 merge 为一条 persona 或 episodic(取决于信息侧重)
39
+
40
+ 5. **timestamp 处理**:
41
+ - merge / update 时,merged_timestamps 应包含**所有相关记忆的时间戳并集**(去重排序)
42
+ - 这样可以保留事件发生的完整时间线
43
+
44
+ ## 输出格式
45
+
46
+ 严格输出 JSON 数组,每个元素对应一条新记忆的决策。不输出任何其他内容:
47
+
48
+ [
49
+ {
50
+ "record_id": "新记忆的 record_id",
51
+ "action": "store|update|skip|merge",
52
+ "target_ids": ["要删除的候选记忆 record_id 1", "record_id 2"],
53
+ "merged_content": "合并/更新后的记忆内容(merge/update 时必填)",
54
+ "merged_type": "合并后的最佳 type:persona|episodic|instruction(merge/update 时必填)",
55
+ "merged_priority": 85,
56
+ "merged_timestamps": ["合并后的时间戳数组,包含所有新旧记忆时间戳的并集(merge/update 时必填)"]
57
+ }
58
+ ]
59
+
60
+ 字段说明:
61
+ - target_ids:要删除替换的旧记忆 ID **数组**(可以 1 条或多条)。store/skip 时省略或为空。
62
+ - merged_content:merge/update 时的最终记忆文本。store/skip 时省略。
63
+ - merged_type:merge/update 后记忆应归属的 type。根据合并后内容本质判断。
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
125
+ return `**输出语言**:\`merged_content\` 使用与候选池中已有记忆相同的语言。
126
+
127
+ ${poolSection}
128
+
129
+ ${"═".repeat(50)}
130
+
131
+ ## 待判断的新记忆(共 ${matches.length} 条)
132
+
133
+ ${newMemoriesText}
134
+
135
+ 请逐条判断并输出决策 JSON 数组。当某条新记忆的候选列表为空时,该条直接输出 action=store。`;
136
+ }