dsh-tdai-memory 0.3.2 → 0.3.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 (65) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +9 -1
  3. package/README.zh.md +8 -1
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/cordis.patch.yml +8 -8
  6. package/package.json +16 -4
  7. package/recall-inject.js +86 -86
  8. package/vendor/tdai/adapters/index.js +15 -15
  9. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  10. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  11. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  12. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  13. package/vendor/tdai/adapters/standalone/index.js +5 -5
  14. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  15. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  16. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  17. package/vendor/tdai/core/index.js +8 -8
  18. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  19. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  20. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  21. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  22. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  23. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  24. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  25. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  26. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  27. package/vendor/tdai/core/record/l1-reader.js +178 -178
  28. package/vendor/tdai/core/record/l1-writer.js +141 -141
  29. package/vendor/tdai/core/report/reporter.js +86 -86
  30. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  31. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  32. package/vendor/tdai/core/scene/scene-format.js +53 -53
  33. package/vendor/tdai/core/scene/scene-index.js +82 -82
  34. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  35. package/vendor/tdai/core/seed/input.js +404 -404
  36. package/vendor/tdai/core/seed/types.js +9 -9
  37. package/vendor/tdai/core/store/bm25-client.js +117 -117
  38. package/vendor/tdai/core/store/bm25-local.js +68 -68
  39. package/vendor/tdai/core/store/embedding.js +632 -632
  40. package/vendor/tdai/core/store/factory.js +95 -95
  41. package/vendor/tdai/core/store/search-utils.js +54 -54
  42. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  43. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  44. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  45. package/vendor/tdai/core/store/types.js +17 -17
  46. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  47. package/vendor/tdai/core/tools/memory-search.js +207 -207
  48. package/vendor/tdai/core/types.js +12 -12
  49. package/vendor/tdai/utils/backup.js +189 -189
  50. package/vendor/tdai/utils/checkpoint.js +360 -360
  51. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  52. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  53. package/vendor/tdai/utils/env.js +13 -13
  54. package/vendor/tdai/utils/managed-timer.js +127 -127
  55. package/vendor/tdai/utils/manifest.js +91 -91
  56. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  57. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  58. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  59. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  60. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  61. package/vendor/tdai/utils/sanitize.js +360 -360
  62. package/vendor/tdai/utils/serial-queue.js +103 -103
  63. package/vendor/tdai/utils/session-filter.js +88 -88
  64. package/vendor/tdai/utils/text-utils.js +29 -29
  65. package/vendor/tdai/utils/time.js +252 -252
@@ -1,410 +1,410 @@
1
- /**
2
- * SceneExtractor: LLM-driven memory extraction into scene blocks.
3
- *
4
- * Replaces the keyword-based SceneManager.processNewMemories() with an
5
- * LLM agent that autonomously reads/writes scene block files using tools.
6
- *
7
- * Security: The LLM is sandboxed — workspaceDir is set to scene_blocks/
8
- * so it can ONLY operate on .md scene files. System files (checkpoint,
9
- * scene_index, persona.md) are physically invisible to the LLM.
10
- *
11
- * Flow:
12
- * 1. Backup + load scene index + build summaries
13
- * 2. Assemble extraction prompt with memories + scene context
14
- * 3. Run via CleanContextRunner (tools enabled, sandboxed to scene_blocks/)
15
- * 4. Cleanup: remove soft-deletes, sync index, update navigation
16
- * 5. Parse LLM text output for out-of-band persona update signals
17
- */
18
- import fs from "node:fs/promises";
19
- import path from "node:path";
20
- import { formatForLLM } from "../../utils/time.js";
21
- import { CleanContextRunner } from "../../utils/clean-context-runner.js";
22
- import { CheckpointManager } from "../../utils/checkpoint.js";
23
- import { BackupManager } from "../../utils/backup.js";
24
- import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js";
25
- import { parseSceneBlock } from "../scene/scene-format.js";
26
- import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
27
- import { normalizeSceneFilenames } from "./filename-normalizer.js";
28
- import { buildSceneExtractionPrompt } from "../prompts/scene-extraction.js";
29
- import { report } from "../report/reporter.js";
30
- const TAG = "[memory-tdai] [extractor]";
31
- /**
32
- * Parse LLM text output for a persona update request signal.
33
- *
34
- * Supports multiple formats for robustness:
35
- * - Block: [PERSONA_UPDATE_REQUEST]reason: xxx[/PERSONA_UPDATE_REQUEST]
36
- * - Inline: PERSONA_UPDATE_REQUEST: xxx
37
- */
38
- export function parsePersonaUpdateSignal(text) {
39
- // Block format: [PERSONA_UPDATE_REQUEST]...[/PERSONA_UPDATE_REQUEST]
40
- const blockMatch = text.match(/\[PERSONA_UPDATE_REQUEST\]\s*(?:reason:\s*)?(.+?)\s*\[\/PERSONA_UPDATE_REQUEST\]/s);
41
- if (blockMatch)
42
- return { reason: blockMatch[1].trim() };
43
- // Inline format: PERSONA_UPDATE_REQUEST: reason text
44
- const inlineMatch = text.match(/PERSONA_UPDATE_REQUEST:\s*(.+?)(?:\n|$)/);
45
- if (inlineMatch)
46
- return { reason: inlineMatch[1].trim() };
47
- return null;
48
- }
49
- export class SceneExtractor {
50
- dataDir;
51
- runner;
52
- maxScenes;
53
- sceneBackupCount;
54
- timeoutMs;
55
- logger;
56
- instanceId;
57
- constructor(opts) {
58
- this.dataDir = opts.dataDir;
59
- this.maxScenes = opts.maxScenes ?? 15;
60
- this.sceneBackupCount = opts.sceneBackupCount ?? 10;
61
- this.timeoutMs = opts.timeoutMs ?? 300_000; // 5 min — LLM may do multiple tool calls
62
- this.logger = opts.logger;
63
- this.instanceId = opts.instanceId;
64
- // Use injected LLMRunner if available, otherwise fall back to CleanContextRunner
65
- this.runner = opts.llmRunner ?? new CleanContextRunner({
66
- config: opts.config,
67
- modelRef: opts.model,
68
- enableTools: true,
69
- logger: opts.logger,
70
- });
71
- this.logger?.debug?.(`${TAG} Created: dataDir=${opts.dataDir}, model=${opts.model ?? "(default)"}, maxScenes=${this.maxScenes}, timeout=${this.timeoutMs}ms`);
72
- }
73
- /**
74
- * Extract a batch of memories into scene blocks using the LLM agent.
75
- *
76
- * @param memories - Array of raw memory records from the API
77
- * @returns Extraction result with count and success flag
78
- */
79
- async extract(memories) {
80
- const extractStartMs = Date.now();
81
- this.logger?.info(`${TAG} extract() start: ${memories.length} memories`);
82
- if (memories.length === 0) {
83
- this.logger?.debug?.(`${TAG} extract() skipped: no memories`);
84
- return { memoriesProcessed: 0, success: true };
85
- }
86
- const sceneBlocksDir = path.join(this.dataDir, "scene_blocks");
87
- const metadataDir = path.join(this.dataDir, ".metadata");
88
- // Ensure directories exist
89
- await fs.mkdir(sceneBlocksDir, { recursive: true });
90
- await fs.mkdir(metadataDir, { recursive: true });
91
- // Phase 1: Backup
92
- const backupStartMs = Date.now();
93
- const cpManager = new CheckpointManager(this.dataDir);
94
- const cp = await cpManager.read();
95
- const bm = new BackupManager(path.join(this.dataDir, ".backup"));
96
- await bm.backupDirectory(sceneBlocksDir, "scene_blocks", `offset${cp.total_processed}`, this.sceneBackupCount);
97
- this.logger?.debug?.(`${TAG} extract() backup phase: ${Date.now() - backupStartMs}ms`);
98
- // Phase 2: Load scene index
99
- const indexStartMs = Date.now();
100
- const index = await readSceneIndex(this.dataDir);
101
- this.logger?.debug?.(`${TAG} extract() scene index loaded: ${index.length} entries (${Date.now() - indexStartMs}ms)`);
102
- // Build scene summaries for the prompt (relative filenames only)
103
- const { summaries: sceneSummaries, filenames: existingSceneFiles } = this.buildSceneSummaries(index);
104
- // Build scene count warning (tiered system)
105
- let sceneCountWarning;
106
- const sceneCount = index.length;
107
- if (sceneCount >= this.maxScenes) {
108
- sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,已达到或超过 ${this.maxScenes} 个上限!\n**你必须先执行 MERGE 操作**,将最相似的 2-4 个场景合并为 1 个,然后再处理新记忆。\n参考合并对象:热度最低或主题高度重叠的场景。`;
109
- this.logger?.warn(`${TAG} extract() scene count at limit: ${sceneCount}/${this.maxScenes}`);
110
- }
111
- else if (sceneCount === this.maxScenes - 1) {
112
- sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,距离上限只差 1 个!\n本次处理**只能 UPDATE 现有场景,不能 CREATE 新场景**。`;
113
- this.logger?.warn(`${TAG} extract() scene count near limit (CREATE blocked): ${sceneCount}/${this.maxScenes}`);
114
- }
115
- else if (sceneCount >= this.maxScenes - 3) {
116
- sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,建议优先考虑 UPDATE 或主动 MERGE 相似场景。`;
117
- this.logger?.debug?.(`${TAG} extract() scene count approaching limit: ${sceneCount}/${this.maxScenes}`);
118
- }
119
- // Snapshot scene index + content before LLM — used later to diff created/updated/deleted
120
- const preExtractIndex = new Map(index.map((e) => [e.filename, e.summary]));
121
- // Also snapshot scene content so we can detect content-only changes vs metadata-only changes
122
- const preExtractContent = new Map();
123
- for (const e of index) {
124
- try {
125
- const raw = await fs.readFile(path.join(sceneBlocksDir, e.filename), "utf-8");
126
- const block = parseSceneBlock(raw, e.filename);
127
- preExtractContent.set(e.filename, block.content);
128
- }
129
- catch { /* non-fatal */ }
130
- }
131
- // Phase 3: Build prompt
132
- const promptStartMs = Date.now();
133
- const memoriesJson = JSON.stringify(memories.map((m) => ({
134
- content: m.content,
135
- created_at: m.created_at ? formatForLLM(m.created_at) : m.created_at,
136
- id: m.id ?? "",
137
- })), null, 2);
138
- const currentTimestamp = formatTimestamp(new Date());
139
- const { systemPrompt, userPrompt } = buildSceneExtractionPrompt({
140
- memoriesJson,
141
- sceneSummaries: sceneSummaries || "(无已有场景)",
142
- currentTimestamp,
143
- sceneCountWarning,
144
- existingSceneFiles,
145
- maxScenes: this.maxScenes,
146
- });
147
- this.logger?.debug?.(`${TAG} extract() prompt built: ${userPrompt.length} chars (${Date.now() - promptStartMs}ms)`);
148
- // Phase 4: Run LLM agent (sandboxed to scene_blocks/)
149
- let llmOutput = "";
150
- let llmDurationMs = 0;
151
- try {
152
- this.logger?.debug?.(`${TAG} extract() starting LLM runner (timeout=${this.timeoutMs}ms, maxTokens=model default)...`);
153
- const runnerStartMs = Date.now();
154
- llmOutput = await this.runner.run({
155
- systemPrompt,
156
- prompt: userPrompt,
157
- taskId: `scene-extract-${Date.now()}`,
158
- timeoutMs: this.timeoutMs,
159
- // maxTokens omitted → core uses the resolved model's maxTokens from catalog
160
- workspaceDir: sceneBlocksDir,
161
- }) ?? "";
162
- llmDurationMs = Date.now() - runnerStartMs;
163
- this.logger?.debug?.(`${TAG} extract() LLM runner completed: ${llmDurationMs}ms`);
164
- }
165
- catch (err) {
166
- const errMsg = err instanceof Error ? err.message : String(err);
167
- const totalMs = Date.now() - extractStartMs;
168
- this.logger?.error(`${TAG} extract() LLM runner failed after ${totalMs}ms: ${errMsg}`);
169
- // Restore scene_blocks/ from the Phase 1 backup so partial LLM writes
170
- // (or a wiped sandbox) don't leak into the next recall cycle.
171
- // Fail-soft: a restore failure must never mask the original LLM error.
172
- try {
173
- const result = await bm.restoreLatestDirectory("scene_blocks", sceneBlocksDir);
174
- if (result.restored) {
175
- this.logger?.warn(`${TAG} extract() restored scene_blocks/ from backup: ${result.from}`);
176
- }
177
- else {
178
- this.logger?.debug?.(`${TAG} extract() no scene_blocks backup to restore from (first run or empty)`);
179
- }
180
- }
181
- catch (restoreErr) {
182
- const rMsg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
183
- this.logger?.warn(`${TAG} extract() restore failed (non-fatal, original LLM error preserved): ${rMsg}`);
184
- }
185
- return { memoriesProcessed: 0, success: false, error: errMsg };
186
- }
187
- // Phase 5: Subsequent processing — safe cleanup of soft-deleted files
188
- //
189
- // Security: The LLM has no `exec` tool and cannot run shell commands.
190
- // Instead, it "deletes" files by writing the marker `[DELETED]` to the file
191
- // (writing empty/whitespace-only content is rejected by core's write tool
192
- // parameter validation). Here we detect and remove those soft-deleted files
193
- // before syncing the index, so syncSceneIndex won't re-index stale entries.
194
- //
195
- // We also detect "META-only" files — files that contain only a META header
196
- // (e.g. [ARCHIVE] or [CONSOLIDATED] markers) but no actual scene content.
197
- // These are artifacts of LLM merges that didn't properly delete old files.
198
- const cleanupStartMs = Date.now();
199
- let cleanedCount = 0;
200
- try {
201
- const allFiles = (await fs.readdir(sceneBlocksDir)).filter((f) => f.endsWith(".md"));
202
- for (const file of allFiles) {
203
- const filePath = path.join(sceneBlocksDir, file);
204
- const raw = await fs.readFile(filePath, "utf-8");
205
- if (raw.trim().length === 0 || raw.trim() === "[DELETED]") {
206
- // Empty file or [DELETED] marker — soft-delete
207
- await fs.unlink(filePath);
208
- cleanedCount++;
209
- this.logger?.debug?.(`${TAG} extract() removed soft-deleted file: ${file}`);
210
- }
211
- else {
212
- // Check if file has only META header but no actual content
213
- const block = parseSceneBlock(raw, file);
214
- if (!block.content || block.content.trim().length === 0) {
215
- await fs.unlink(filePath);
216
- cleanedCount++;
217
- this.logger?.debug?.(`${TAG} extract() removed META-only file (no content): ${file}`);
218
- }
219
- }
220
- }
221
- }
222
- catch (cleanupErr) {
223
- // Non-fatal — log and continue to index sync
224
- this.logger?.warn(`${TAG} extract() soft-delete cleanup error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
225
- }
226
- this.logger?.debug?.(`${TAG} extract() soft-delete cleanup: removed ${cleanedCount} empty files (${Date.now() - cleanupStartMs}ms)`);
227
- // Phase 5b: Normalize filenames (defensive — LLM occasionally produces names
228
- // with spaces / punctuation despite the prompt forbidding them, e.g.
229
- // "Daily Rhythm in Shanghai.md". Such names break downstream consumers
230
- // that parse Markdown navigation refs with `\S+\.md` style regexes
231
- // (health-checker), shell tools, and URL-encoded path consumers.
232
- //
233
- // Renaming here — *before* syncSceneIndex — means scene_index.json and
234
- // every downstream reader (PersonaGenerator, recall, profile-sync) only
235
- // ever sees canonical filenames. Idempotent and safe to run repeatedly.
236
- const normStartMs = Date.now();
237
- try {
238
- const normResult = await normalizeSceneFilenames(sceneBlocksDir, this.logger);
239
- if (normResult.renamed > 0) {
240
- this.logger?.info(`${TAG} extract() filename normalization: renamed ${normResult.renamed}, skipped ${normResult.skipped} (${Date.now() - normStartMs}ms)`);
241
- }
242
- else {
243
- this.logger?.debug?.(`${TAG} extract() filename normalization: skipped ${normResult.skipped} (${Date.now() - normStartMs}ms)`);
244
- }
245
- }
246
- catch (normErr) {
247
- // Non-fatal — log and continue. Index sync below will simply pick up
248
- // whatever names are present on disk.
249
- this.logger?.warn(`${TAG} extract() filename normalization error: ${normErr instanceof Error ? normErr.message : String(normErr)}`);
250
- }
251
- // Phase 6: Sync scene index (rebuilds from remaining non-empty files)
252
- const syncStartMs = Date.now();
253
- await syncSceneIndex(this.dataDir);
254
- this.logger?.debug?.(`${TAG} extract() scene index synced: ${Date.now() - syncStartMs}ms`);
255
- // Phase 7: Update persona.md navigation (GAP-4 fix)
256
- const navStartMs = Date.now();
257
- try {
258
- await this.updateSceneNavigation();
259
- this.logger?.debug?.(`${TAG} extract() persona.md navigation updated: ${Date.now() - navStartMs}ms`);
260
- }
261
- catch (navErr) {
262
- // Non-fatal — log and continue
263
- this.logger?.warn(`${TAG} extract() failed to update persona navigation: ${navErr instanceof Error ? navErr.message : String(navErr)}`);
264
- }
265
- // Phase 8: Parse LLM output for out-of-band persona update signal
266
- if (llmOutput) {
267
- const signal = parsePersonaUpdateSignal(llmOutput);
268
- if (signal) {
269
- await cpManager.setPersonaUpdateRequest(signal.reason);
270
- this.logger?.debug?.(`${TAG} extract() persona update requested by LLM: ${signal.reason}`);
271
- }
272
- }
273
- const totalMs = Date.now() - extractStartMs;
274
- this.logger?.info(`${TAG} extract() completed: ${memories.length} memories processed in ${totalMs}ms`);
275
- // ── l2_extraction metric ──
276
- if (this.instanceId && this.logger) {
277
- // Read updated scene index to report final state + diff against pre-extract snapshot
278
- let resultScenes = [];
279
- let scenesCreated = 0;
280
- let scenesUpdated = 0;
281
- let scenesDeleted = 0;
282
- try {
283
- const finalIndex = await readSceneIndex(this.dataDir);
284
- const postFilenames = new Set();
285
- for (const e of finalIndex) {
286
- postFilenames.add(e.filename);
287
- const oldSummary = preExtractIndex.get(e.filename);
288
- // Read scene block content from disk
289
- let content = "";
290
- try {
291
- const blockPath = path.join(sceneBlocksDir, e.filename);
292
- const raw = await fs.readFile(blockPath, "utf-8");
293
- const block = parseSceneBlock(raw, e.filename);
294
- content = block.content;
295
- }
296
- catch { /* file read failure is non-fatal */ }
297
- if (oldSummary === undefined) {
298
- // New scene
299
- scenesCreated++;
300
- resultScenes.push({
301
- title: e.filename.replace(/\.md$/, ""),
302
- summary: e.summary,
303
- content,
304
- status: "created",
305
- });
306
- }
307
- else {
308
- // Existing scene — check if content actually changed (not just metadata)
309
- const oldContent = preExtractContent.get(e.filename) ?? "";
310
- if (content !== oldContent) {
311
- scenesUpdated++;
312
- resultScenes.push({
313
- title: e.filename.replace(/\.md$/, ""),
314
- summary: e.summary,
315
- content,
316
- status: "updated",
317
- });
318
- }
319
- // If only metadata (summary/heat) changed but content is the same, skip
320
- }
321
- }
322
- // Scenes in pre-extract but missing from post-extract = deleted
323
- for (const [filename] of preExtractIndex) {
324
- if (!postFilenames.has(filename)) {
325
- scenesDeleted++;
326
- }
327
- }
328
- }
329
- catch { /* non-fatal */ }
330
- report("l2_extraction", {
331
- inputMemoryCount: memories.length,
332
- resultSceneCount: resultScenes.length,
333
- resultScenes,
334
- scenesCreated,
335
- scenesUpdated,
336
- scenesDeleted,
337
- llmDurationMs,
338
- totalDurationMs: totalMs,
339
- success: true,
340
- error: null,
341
- });
342
- }
343
- return { memoriesProcessed: memories.length, success: true };
344
- }
345
- /**
346
- * Build human-readable scene summaries for the prompt,
347
- * and collect the list of existing scene filenames (relative).
348
- *
349
- * Includes a capacity counter at the top (e.g. "当前场景总数:5 / 15")
350
- * so the LLM can immediately see how close it is to the limit.
351
- */
352
- buildSceneSummaries(index) {
353
- if (index.length === 0)
354
- return { summaries: "", filenames: [] };
355
- const lines = [];
356
- const filenames = [];
357
- // Inject capacity counter at the top — LLM sees this first
358
- lines.push(`**当前场景总数:${index.length} / ${this.maxScenes}**`);
359
- lines.push("");
360
- for (const entry of index) {
361
- filenames.push(entry.filename);
362
- lines.push(`### ${entry.filename}`);
363
- lines.push(`**热度**: ${entry.heat} | **更新**: ${entry.updated}`);
364
- lines.push(`**summary**: ${entry.summary}`);
365
- lines.push("");
366
- }
367
- return { summaries: lines.join("\n"), filenames };
368
- }
369
- /**
370
- * Update the scene navigation section at the end of persona.md.
371
- *
372
- * Reads the current scene index, generates the navigation block, then
373
- * strips any existing navigation from persona.md and appends the new one.
374
- *
375
- * IMPORTANT: If the persona body is empty (PersonaGenerator hasn't run yet),
376
- * we skip writing to avoid creating a persona.md that only contains the
377
- * scene navigation. PersonaGenerator.generate() will write the full
378
- * persona + navigation when it runs.
379
- */
380
- async updateSceneNavigation() {
381
- const personaPath = path.join(this.dataDir, "persona.md");
382
- const index = await readSceneIndex(this.dataDir);
383
- const nav = generateSceneNavigation(index);
384
- let existing = "";
385
- try {
386
- existing = await fs.readFile(personaPath, "utf-8");
387
- }
388
- catch {
389
- // No persona file yet — PersonaGenerator will create it with navigation.
390
- // Don't write a navigation-only file.
391
- this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: no persona file yet, waiting for PersonaGenerator`);
392
- return;
393
- }
394
- if (!existing.trim() && !nav)
395
- return;
396
- const stripped = stripSceneNavigation(existing).trimEnd();
397
- // If the persona body is empty (only navigation existed), don't overwrite
398
- // with a navigation-only file. Let PersonaGenerator handle full generation.
399
- if (!stripped) {
400
- this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: persona body is empty, waiting for PersonaGenerator`);
401
- return;
402
- }
403
- const updated = nav ? `${stripped}\n\n${nav}\n` : `${stripped}\n`;
404
- // persona.md is at dataDir root, no subdir needed
405
- await fs.writeFile(personaPath, updated, "utf-8");
406
- }
407
- }
408
- function formatTimestamp(d) {
409
- return formatForLLM(d);
410
- }
1
+ /**
2
+ * SceneExtractor: LLM-driven memory extraction into scene blocks.
3
+ *
4
+ * Replaces the keyword-based SceneManager.processNewMemories() with an
5
+ * LLM agent that autonomously reads/writes scene block files using tools.
6
+ *
7
+ * Security: The LLM is sandboxed — workspaceDir is set to scene_blocks/
8
+ * so it can ONLY operate on .md scene files. System files (checkpoint,
9
+ * scene_index, persona.md) are physically invisible to the LLM.
10
+ *
11
+ * Flow:
12
+ * 1. Backup + load scene index + build summaries
13
+ * 2. Assemble extraction prompt with memories + scene context
14
+ * 3. Run via CleanContextRunner (tools enabled, sandboxed to scene_blocks/)
15
+ * 4. Cleanup: remove soft-deletes, sync index, update navigation
16
+ * 5. Parse LLM text output for out-of-band persona update signals
17
+ */
18
+ import fs from "node:fs/promises";
19
+ import path from "node:path";
20
+ import { formatForLLM } from "../../utils/time.js";
21
+ import { CleanContextRunner } from "../../utils/clean-context-runner.js";
22
+ import { CheckpointManager } from "../../utils/checkpoint.js";
23
+ import { BackupManager } from "../../utils/backup.js";
24
+ import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js";
25
+ import { parseSceneBlock } from "../scene/scene-format.js";
26
+ import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
27
+ import { normalizeSceneFilenames } from "./filename-normalizer.js";
28
+ import { buildSceneExtractionPrompt } from "../prompts/scene-extraction.js";
29
+ import { report } from "../report/reporter.js";
30
+ const TAG = "[memory-tdai] [extractor]";
31
+ /**
32
+ * Parse LLM text output for a persona update request signal.
33
+ *
34
+ * Supports multiple formats for robustness:
35
+ * - Block: [PERSONA_UPDATE_REQUEST]reason: xxx[/PERSONA_UPDATE_REQUEST]
36
+ * - Inline: PERSONA_UPDATE_REQUEST: xxx
37
+ */
38
+ export function parsePersonaUpdateSignal(text) {
39
+ // Block format: [PERSONA_UPDATE_REQUEST]...[/PERSONA_UPDATE_REQUEST]
40
+ const blockMatch = text.match(/\[PERSONA_UPDATE_REQUEST\]\s*(?:reason:\s*)?(.+?)\s*\[\/PERSONA_UPDATE_REQUEST\]/s);
41
+ if (blockMatch)
42
+ return { reason: blockMatch[1].trim() };
43
+ // Inline format: PERSONA_UPDATE_REQUEST: reason text
44
+ const inlineMatch = text.match(/PERSONA_UPDATE_REQUEST:\s*(.+?)(?:\n|$)/);
45
+ if (inlineMatch)
46
+ return { reason: inlineMatch[1].trim() };
47
+ return null;
48
+ }
49
+ export class SceneExtractor {
50
+ dataDir;
51
+ runner;
52
+ maxScenes;
53
+ sceneBackupCount;
54
+ timeoutMs;
55
+ logger;
56
+ instanceId;
57
+ constructor(opts) {
58
+ this.dataDir = opts.dataDir;
59
+ this.maxScenes = opts.maxScenes ?? 15;
60
+ this.sceneBackupCount = opts.sceneBackupCount ?? 10;
61
+ this.timeoutMs = opts.timeoutMs ?? 300_000; // 5 min — LLM may do multiple tool calls
62
+ this.logger = opts.logger;
63
+ this.instanceId = opts.instanceId;
64
+ // Use injected LLMRunner if available, otherwise fall back to CleanContextRunner
65
+ this.runner = opts.llmRunner ?? new CleanContextRunner({
66
+ config: opts.config,
67
+ modelRef: opts.model,
68
+ enableTools: true,
69
+ logger: opts.logger,
70
+ });
71
+ this.logger?.debug?.(`${TAG} Created: dataDir=${opts.dataDir}, model=${opts.model ?? "(default)"}, maxScenes=${this.maxScenes}, timeout=${this.timeoutMs}ms`);
72
+ }
73
+ /**
74
+ * Extract a batch of memories into scene blocks using the LLM agent.
75
+ *
76
+ * @param memories - Array of raw memory records from the API
77
+ * @returns Extraction result with count and success flag
78
+ */
79
+ async extract(memories) {
80
+ const extractStartMs = Date.now();
81
+ this.logger?.info(`${TAG} extract() start: ${memories.length} memories`);
82
+ if (memories.length === 0) {
83
+ this.logger?.debug?.(`${TAG} extract() skipped: no memories`);
84
+ return { memoriesProcessed: 0, success: true };
85
+ }
86
+ const sceneBlocksDir = path.join(this.dataDir, "scene_blocks");
87
+ const metadataDir = path.join(this.dataDir, ".metadata");
88
+ // Ensure directories exist
89
+ await fs.mkdir(sceneBlocksDir, { recursive: true });
90
+ await fs.mkdir(metadataDir, { recursive: true });
91
+ // Phase 1: Backup
92
+ const backupStartMs = Date.now();
93
+ const cpManager = new CheckpointManager(this.dataDir);
94
+ const cp = await cpManager.read();
95
+ const bm = new BackupManager(path.join(this.dataDir, ".backup"));
96
+ await bm.backupDirectory(sceneBlocksDir, "scene_blocks", `offset${cp.total_processed}`, this.sceneBackupCount);
97
+ this.logger?.debug?.(`${TAG} extract() backup phase: ${Date.now() - backupStartMs}ms`);
98
+ // Phase 2: Load scene index
99
+ const indexStartMs = Date.now();
100
+ const index = await readSceneIndex(this.dataDir);
101
+ this.logger?.debug?.(`${TAG} extract() scene index loaded: ${index.length} entries (${Date.now() - indexStartMs}ms)`);
102
+ // Build scene summaries for the prompt (relative filenames only)
103
+ const { summaries: sceneSummaries, filenames: existingSceneFiles } = this.buildSceneSummaries(index);
104
+ // Build scene count warning (tiered system)
105
+ let sceneCountWarning;
106
+ const sceneCount = index.length;
107
+ if (sceneCount >= this.maxScenes) {
108
+ sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,已达到或超过 ${this.maxScenes} 个上限!\n**你必须先执行 MERGE 操作**,将最相似的 2-4 个场景合并为 1 个,然后再处理新记忆。\n参考合并对象:热度最低或主题高度重叠的场景。`;
109
+ this.logger?.warn(`${TAG} extract() scene count at limit: ${sceneCount}/${this.maxScenes}`);
110
+ }
111
+ else if (sceneCount === this.maxScenes - 1) {
112
+ sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,距离上限只差 1 个!\n本次处理**只能 UPDATE 现有场景,不能 CREATE 新场景**。`;
113
+ this.logger?.warn(`${TAG} extract() scene count near limit (CREATE blocked): ${sceneCount}/${this.maxScenes}`);
114
+ }
115
+ else if (sceneCount >= this.maxScenes - 3) {
116
+ sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,建议优先考虑 UPDATE 或主动 MERGE 相似场景。`;
117
+ this.logger?.debug?.(`${TAG} extract() scene count approaching limit: ${sceneCount}/${this.maxScenes}`);
118
+ }
119
+ // Snapshot scene index + content before LLM — used later to diff created/updated/deleted
120
+ const preExtractIndex = new Map(index.map((e) => [e.filename, e.summary]));
121
+ // Also snapshot scene content so we can detect content-only changes vs metadata-only changes
122
+ const preExtractContent = new Map();
123
+ for (const e of index) {
124
+ try {
125
+ const raw = await fs.readFile(path.join(sceneBlocksDir, e.filename), "utf-8");
126
+ const block = parseSceneBlock(raw, e.filename);
127
+ preExtractContent.set(e.filename, block.content);
128
+ }
129
+ catch { /* non-fatal */ }
130
+ }
131
+ // Phase 3: Build prompt
132
+ const promptStartMs = Date.now();
133
+ const memoriesJson = JSON.stringify(memories.map((m) => ({
134
+ content: m.content,
135
+ created_at: m.created_at ? formatForLLM(m.created_at) : m.created_at,
136
+ id: m.id ?? "",
137
+ })), null, 2);
138
+ const currentTimestamp = formatTimestamp(new Date());
139
+ const { systemPrompt, userPrompt } = buildSceneExtractionPrompt({
140
+ memoriesJson,
141
+ sceneSummaries: sceneSummaries || "(无已有场景)",
142
+ currentTimestamp,
143
+ sceneCountWarning,
144
+ existingSceneFiles,
145
+ maxScenes: this.maxScenes,
146
+ });
147
+ this.logger?.debug?.(`${TAG} extract() prompt built: ${userPrompt.length} chars (${Date.now() - promptStartMs}ms)`);
148
+ // Phase 4: Run LLM agent (sandboxed to scene_blocks/)
149
+ let llmOutput = "";
150
+ let llmDurationMs = 0;
151
+ try {
152
+ this.logger?.debug?.(`${TAG} extract() starting LLM runner (timeout=${this.timeoutMs}ms, maxTokens=model default)...`);
153
+ const runnerStartMs = Date.now();
154
+ llmOutput = await this.runner.run({
155
+ systemPrompt,
156
+ prompt: userPrompt,
157
+ taskId: `scene-extract-${Date.now()}`,
158
+ timeoutMs: this.timeoutMs,
159
+ // maxTokens omitted → core uses the resolved model's maxTokens from catalog
160
+ workspaceDir: sceneBlocksDir,
161
+ }) ?? "";
162
+ llmDurationMs = Date.now() - runnerStartMs;
163
+ this.logger?.debug?.(`${TAG} extract() LLM runner completed: ${llmDurationMs}ms`);
164
+ }
165
+ catch (err) {
166
+ const errMsg = err instanceof Error ? err.message : String(err);
167
+ const totalMs = Date.now() - extractStartMs;
168
+ this.logger?.error(`${TAG} extract() LLM runner failed after ${totalMs}ms: ${errMsg}`);
169
+ // Restore scene_blocks/ from the Phase 1 backup so partial LLM writes
170
+ // (or a wiped sandbox) don't leak into the next recall cycle.
171
+ // Fail-soft: a restore failure must never mask the original LLM error.
172
+ try {
173
+ const result = await bm.restoreLatestDirectory("scene_blocks", sceneBlocksDir);
174
+ if (result.restored) {
175
+ this.logger?.warn(`${TAG} extract() restored scene_blocks/ from backup: ${result.from}`);
176
+ }
177
+ else {
178
+ this.logger?.debug?.(`${TAG} extract() no scene_blocks backup to restore from (first run or empty)`);
179
+ }
180
+ }
181
+ catch (restoreErr) {
182
+ const rMsg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
183
+ this.logger?.warn(`${TAG} extract() restore failed (non-fatal, original LLM error preserved): ${rMsg}`);
184
+ }
185
+ return { memoriesProcessed: 0, success: false, error: errMsg };
186
+ }
187
+ // Phase 5: Subsequent processing — safe cleanup of soft-deleted files
188
+ //
189
+ // Security: The LLM has no `exec` tool and cannot run shell commands.
190
+ // Instead, it "deletes" files by writing the marker `[DELETED]` to the file
191
+ // (writing empty/whitespace-only content is rejected by core's write tool
192
+ // parameter validation). Here we detect and remove those soft-deleted files
193
+ // before syncing the index, so syncSceneIndex won't re-index stale entries.
194
+ //
195
+ // We also detect "META-only" files — files that contain only a META header
196
+ // (e.g. [ARCHIVE] or [CONSOLIDATED] markers) but no actual scene content.
197
+ // These are artifacts of LLM merges that didn't properly delete old files.
198
+ const cleanupStartMs = Date.now();
199
+ let cleanedCount = 0;
200
+ try {
201
+ const allFiles = (await fs.readdir(sceneBlocksDir)).filter((f) => f.endsWith(".md"));
202
+ for (const file of allFiles) {
203
+ const filePath = path.join(sceneBlocksDir, file);
204
+ const raw = await fs.readFile(filePath, "utf-8");
205
+ if (raw.trim().length === 0 || raw.trim() === "[DELETED]") {
206
+ // Empty file or [DELETED] marker — soft-delete
207
+ await fs.unlink(filePath);
208
+ cleanedCount++;
209
+ this.logger?.debug?.(`${TAG} extract() removed soft-deleted file: ${file}`);
210
+ }
211
+ else {
212
+ // Check if file has only META header but no actual content
213
+ const block = parseSceneBlock(raw, file);
214
+ if (!block.content || block.content.trim().length === 0) {
215
+ await fs.unlink(filePath);
216
+ cleanedCount++;
217
+ this.logger?.debug?.(`${TAG} extract() removed META-only file (no content): ${file}`);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ catch (cleanupErr) {
223
+ // Non-fatal — log and continue to index sync
224
+ this.logger?.warn(`${TAG} extract() soft-delete cleanup error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
225
+ }
226
+ this.logger?.debug?.(`${TAG} extract() soft-delete cleanup: removed ${cleanedCount} empty files (${Date.now() - cleanupStartMs}ms)`);
227
+ // Phase 5b: Normalize filenames (defensive — LLM occasionally produces names
228
+ // with spaces / punctuation despite the prompt forbidding them, e.g.
229
+ // "Daily Rhythm in Shanghai.md". Such names break downstream consumers
230
+ // that parse Markdown navigation refs with `\S+\.md` style regexes
231
+ // (health-checker), shell tools, and URL-encoded path consumers.
232
+ //
233
+ // Renaming here — *before* syncSceneIndex — means scene_index.json and
234
+ // every downstream reader (PersonaGenerator, recall, profile-sync) only
235
+ // ever sees canonical filenames. Idempotent and safe to run repeatedly.
236
+ const normStartMs = Date.now();
237
+ try {
238
+ const normResult = await normalizeSceneFilenames(sceneBlocksDir, this.logger);
239
+ if (normResult.renamed > 0) {
240
+ this.logger?.info(`${TAG} extract() filename normalization: renamed ${normResult.renamed}, skipped ${normResult.skipped} (${Date.now() - normStartMs}ms)`);
241
+ }
242
+ else {
243
+ this.logger?.debug?.(`${TAG} extract() filename normalization: skipped ${normResult.skipped} (${Date.now() - normStartMs}ms)`);
244
+ }
245
+ }
246
+ catch (normErr) {
247
+ // Non-fatal — log and continue. Index sync below will simply pick up
248
+ // whatever names are present on disk.
249
+ this.logger?.warn(`${TAG} extract() filename normalization error: ${normErr instanceof Error ? normErr.message : String(normErr)}`);
250
+ }
251
+ // Phase 6: Sync scene index (rebuilds from remaining non-empty files)
252
+ const syncStartMs = Date.now();
253
+ await syncSceneIndex(this.dataDir);
254
+ this.logger?.debug?.(`${TAG} extract() scene index synced: ${Date.now() - syncStartMs}ms`);
255
+ // Phase 7: Update persona.md navigation (GAP-4 fix)
256
+ const navStartMs = Date.now();
257
+ try {
258
+ await this.updateSceneNavigation();
259
+ this.logger?.debug?.(`${TAG} extract() persona.md navigation updated: ${Date.now() - navStartMs}ms`);
260
+ }
261
+ catch (navErr) {
262
+ // Non-fatal — log and continue
263
+ this.logger?.warn(`${TAG} extract() failed to update persona navigation: ${navErr instanceof Error ? navErr.message : String(navErr)}`);
264
+ }
265
+ // Phase 8: Parse LLM output for out-of-band persona update signal
266
+ if (llmOutput) {
267
+ const signal = parsePersonaUpdateSignal(llmOutput);
268
+ if (signal) {
269
+ await cpManager.setPersonaUpdateRequest(signal.reason);
270
+ this.logger?.debug?.(`${TAG} extract() persona update requested by LLM: ${signal.reason}`);
271
+ }
272
+ }
273
+ const totalMs = Date.now() - extractStartMs;
274
+ this.logger?.info(`${TAG} extract() completed: ${memories.length} memories processed in ${totalMs}ms`);
275
+ // ── l2_extraction metric ──
276
+ if (this.instanceId && this.logger) {
277
+ // Read updated scene index to report final state + diff against pre-extract snapshot
278
+ let resultScenes = [];
279
+ let scenesCreated = 0;
280
+ let scenesUpdated = 0;
281
+ let scenesDeleted = 0;
282
+ try {
283
+ const finalIndex = await readSceneIndex(this.dataDir);
284
+ const postFilenames = new Set();
285
+ for (const e of finalIndex) {
286
+ postFilenames.add(e.filename);
287
+ const oldSummary = preExtractIndex.get(e.filename);
288
+ // Read scene block content from disk
289
+ let content = "";
290
+ try {
291
+ const blockPath = path.join(sceneBlocksDir, e.filename);
292
+ const raw = await fs.readFile(blockPath, "utf-8");
293
+ const block = parseSceneBlock(raw, e.filename);
294
+ content = block.content;
295
+ }
296
+ catch { /* file read failure is non-fatal */ }
297
+ if (oldSummary === undefined) {
298
+ // New scene
299
+ scenesCreated++;
300
+ resultScenes.push({
301
+ title: e.filename.replace(/\.md$/, ""),
302
+ summary: e.summary,
303
+ content,
304
+ status: "created",
305
+ });
306
+ }
307
+ else {
308
+ // Existing scene — check if content actually changed (not just metadata)
309
+ const oldContent = preExtractContent.get(e.filename) ?? "";
310
+ if (content !== oldContent) {
311
+ scenesUpdated++;
312
+ resultScenes.push({
313
+ title: e.filename.replace(/\.md$/, ""),
314
+ summary: e.summary,
315
+ content,
316
+ status: "updated",
317
+ });
318
+ }
319
+ // If only metadata (summary/heat) changed but content is the same, skip
320
+ }
321
+ }
322
+ // Scenes in pre-extract but missing from post-extract = deleted
323
+ for (const [filename] of preExtractIndex) {
324
+ if (!postFilenames.has(filename)) {
325
+ scenesDeleted++;
326
+ }
327
+ }
328
+ }
329
+ catch { /* non-fatal */ }
330
+ report("l2_extraction", {
331
+ inputMemoryCount: memories.length,
332
+ resultSceneCount: resultScenes.length,
333
+ resultScenes,
334
+ scenesCreated,
335
+ scenesUpdated,
336
+ scenesDeleted,
337
+ llmDurationMs,
338
+ totalDurationMs: totalMs,
339
+ success: true,
340
+ error: null,
341
+ });
342
+ }
343
+ return { memoriesProcessed: memories.length, success: true };
344
+ }
345
+ /**
346
+ * Build human-readable scene summaries for the prompt,
347
+ * and collect the list of existing scene filenames (relative).
348
+ *
349
+ * Includes a capacity counter at the top (e.g. "当前场景总数:5 / 15")
350
+ * so the LLM can immediately see how close it is to the limit.
351
+ */
352
+ buildSceneSummaries(index) {
353
+ if (index.length === 0)
354
+ return { summaries: "", filenames: [] };
355
+ const lines = [];
356
+ const filenames = [];
357
+ // Inject capacity counter at the top — LLM sees this first
358
+ lines.push(`**当前场景总数:${index.length} / ${this.maxScenes}**`);
359
+ lines.push("");
360
+ for (const entry of index) {
361
+ filenames.push(entry.filename);
362
+ lines.push(`### ${entry.filename}`);
363
+ lines.push(`**热度**: ${entry.heat} | **更新**: ${entry.updated}`);
364
+ lines.push(`**summary**: ${entry.summary}`);
365
+ lines.push("");
366
+ }
367
+ return { summaries: lines.join("\n"), filenames };
368
+ }
369
+ /**
370
+ * Update the scene navigation section at the end of persona.md.
371
+ *
372
+ * Reads the current scene index, generates the navigation block, then
373
+ * strips any existing navigation from persona.md and appends the new one.
374
+ *
375
+ * IMPORTANT: If the persona body is empty (PersonaGenerator hasn't run yet),
376
+ * we skip writing to avoid creating a persona.md that only contains the
377
+ * scene navigation. PersonaGenerator.generate() will write the full
378
+ * persona + navigation when it runs.
379
+ */
380
+ async updateSceneNavigation() {
381
+ const personaPath = path.join(this.dataDir, "persona.md");
382
+ const index = await readSceneIndex(this.dataDir);
383
+ const nav = generateSceneNavigation(index);
384
+ let existing = "";
385
+ try {
386
+ existing = await fs.readFile(personaPath, "utf-8");
387
+ }
388
+ catch {
389
+ // No persona file yet — PersonaGenerator will create it with navigation.
390
+ // Don't write a navigation-only file.
391
+ this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: no persona file yet, waiting for PersonaGenerator`);
392
+ return;
393
+ }
394
+ if (!existing.trim() && !nav)
395
+ return;
396
+ const stripped = stripSceneNavigation(existing).trimEnd();
397
+ // If the persona body is empty (only navigation existed), don't overwrite
398
+ // with a navigation-only file. Let PersonaGenerator handle full generation.
399
+ if (!stripped) {
400
+ this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: persona body is empty, waiting for PersonaGenerator`);
401
+ return;
402
+ }
403
+ const updated = nav ? `${stripped}\n\n${nav}\n` : `${stripped}\n`;
404
+ // persona.md is at dataDir root, no subdir needed
405
+ await fs.writeFile(personaPath, updated, "utf-8");
406
+ }
407
+ }
408
+ function formatTimestamp(d) {
409
+ return formatForLLM(d);
410
+ }