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,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
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Scene Block file format: parse and format the META-delimited Markdown files.
3
+ */
4
+ const META_START = "-----META-START-----";
5
+ const META_END = "-----META-END-----";
6
+ /**
7
+ * Parse a Scene Block file into structured data.
8
+ */
9
+ export function parseSceneBlock(raw, filename) {
10
+ const startIdx = raw.indexOf(META_START);
11
+ const endIdx = raw.indexOf(META_END);
12
+ if (startIdx === -1 || endIdx === -1) {
13
+ // No META section — treat entire file as content
14
+ return {
15
+ filename,
16
+ meta: { created: "", updated: "", summary: "", heat: 0 },
17
+ content: raw.trim(),
18
+ };
19
+ }
20
+ const metaBlock = raw.slice(startIdx + META_START.length, endIdx).trim();
21
+ const content = raw.slice(endIdx + META_END.length).trim();
22
+ const meta = {
23
+ created: extractMetaField(metaBlock, "created"),
24
+ updated: extractMetaField(metaBlock, "updated"),
25
+ summary: extractMetaField(metaBlock, "summary"),
26
+ heat: parseInt(extractMetaField(metaBlock, "heat"), 10) || 0,
27
+ };
28
+ return { filename, meta, content };
29
+ }
30
+ /**
31
+ * Format a Scene Block back into file content.
32
+ */
33
+ export function formatSceneBlock(meta, content) {
34
+ return `${formatMeta(meta)}\n\n${content}`;
35
+ }
36
+ /**
37
+ * Format the META section.
38
+ */
39
+ export function formatMeta(meta) {
40
+ return [
41
+ META_START,
42
+ `created: ${meta.created}`,
43
+ `updated: ${meta.updated}`,
44
+ `summary: ${meta.summary}`,
45
+ `heat: ${meta.heat}`,
46
+ META_END,
47
+ ].join("\n");
48
+ }
49
+ function extractMetaField(metaBlock, field) {
50
+ const re = new RegExp(`^${field}:\\s*(.*)$`, "m");
51
+ const m = metaBlock.match(re);
52
+ return m ? m[1].trim() : "";
53
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Scene Index: maintains a JSON index of all scene blocks for quick lookup.
3
+ */
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { parseSceneBlock } from "./scene-format.js";
7
+ /**
8
+ * Read the scene index from disk.
9
+ *
10
+ * The index is written exclusively by syncSceneIndex() (engineering side).
11
+ * The LLM is sandboxed to scene_blocks/ and cannot access this file.
12
+ */
13
+ export async function readSceneIndex(dataDir) {
14
+ const indexPath = path.join(dataDir, ".metadata", "scene_index.json");
15
+ try {
16
+ const raw = await fs.readFile(indexPath, "utf-8");
17
+ const parsed = JSON.parse(raw);
18
+ if (!Array.isArray(parsed))
19
+ return [];
20
+ const entries = [];
21
+ for (const item of parsed) {
22
+ if (!item || typeof item !== "object")
23
+ continue;
24
+ const filename = typeof item.filename === "string" ? item.filename : "";
25
+ if (!filename)
26
+ continue;
27
+ entries.push({
28
+ filename,
29
+ summary: typeof item.summary === "string" ? item.summary : "",
30
+ heat: typeof item.heat === "number" ? item.heat : 0,
31
+ created: typeof item.created === "string" ? item.created : "",
32
+ updated: typeof item.updated === "string" ? item.updated : "",
33
+ });
34
+ }
35
+ return entries;
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ }
41
+ /**
42
+ * Write the scene index to disk.
43
+ */
44
+ export async function writeSceneIndex(dataDir, entries) {
45
+ const indexPath = path.join(dataDir, ".metadata", "scene_index.json");
46
+ await fs.mkdir(path.dirname(indexPath), { recursive: true });
47
+ await fs.writeFile(indexPath, JSON.stringify(entries, null, 2), "utf-8");
48
+ }
49
+ /**
50
+ * Rebuild scene index by scanning all .md files in the scene_blocks directory.
51
+ */
52
+ export async function syncSceneIndex(dataDir) {
53
+ const blocksDir = path.join(dataDir, "scene_blocks");
54
+ let files;
55
+ try {
56
+ files = (await fs.readdir(blocksDir)).filter((f) => f.endsWith(".md"));
57
+ }
58
+ catch {
59
+ files = [];
60
+ }
61
+ const entries = [];
62
+ for (const file of files) {
63
+ try {
64
+ const raw = await fs.readFile(path.join(blocksDir, file), "utf-8");
65
+ const block = parseSceneBlock(raw, file);
66
+ entries.push({
67
+ filename: file,
68
+ summary: block.meta.summary,
69
+ heat: block.meta.heat,
70
+ created: block.meta.created,
71
+ updated: block.meta.updated,
72
+ });
73
+ }
74
+ catch {
75
+ // File may have been deleted between readdir and readFile (e.g. by concurrent
76
+ // SceneExtractor soft-delete). Skip it and continue syncing the rest.
77
+ continue;
78
+ }
79
+ }
80
+ await writeSceneIndex(dataDir, entries);
81
+ return entries;
82
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Scene navigation: generates a summary navigation section appended to persona.md.
3
+ *
4
+ * The navigation includes **absolute** file paths so the agent can directly
5
+ * use read_file for on-demand scene loading (progressive disclosure).
6
+ */
7
+ import path from "node:path";
8
+ const NAV_HEADER = "---\n## 🗺️ Scene Navigation (Scene Index)";
9
+ const NAV_FOOTER = `📌 使用说明:
10
+ - Path 是 scene block 的绝对路径,可直接使用 read_file 读取完整内容
11
+ - 热度:该场景被记忆命中的累计次数,越高越重要
12
+ - Summary:场景的核心要点摘要`;
13
+ /**
14
+ * Build a fire-emoji string based on heat value (visual priority cue for the agent).
15
+ */
16
+ function heatEmoji(heat) {
17
+ if (heat >= 1000)
18
+ return " 🔥🔥🔥🔥🔥";
19
+ if (heat >= 500)
20
+ return " 🔥🔥🔥🔥";
21
+ if (heat >= 200)
22
+ return " 🔥🔥🔥";
23
+ if (heat >= 100)
24
+ return " 🔥🔥";
25
+ if (heat >= 50)
26
+ return " 🔥";
27
+ return "";
28
+ }
29
+ /**
30
+ * Generate the scene navigation Markdown section.
31
+ *
32
+ * @param entries - Scene index entries
33
+ * @param dataDir - Absolute path to the plugin data directory; when provided,
34
+ * scene paths are rendered as absolute paths so the agent can
35
+ * call read_file directly without path concatenation.
36
+ */
37
+ export function generateSceneNavigation(entries, dataDir) {
38
+ if (entries.length === 0)
39
+ return "";
40
+ const sorted = [...entries].sort((a, b) => b.heat - a.heat);
41
+ const blocks = sorted.map((e) => {
42
+ const scenePath = dataDir
43
+ ? path.join(dataDir, "scene_blocks", e.filename)
44
+ : `scene_blocks/${e.filename}`;
45
+ const pathLine = `### Path: ${scenePath}`;
46
+ const heatLine = `**热度**: ${e.heat}${heatEmoji(e.heat)}${e.updated ? ` | **更新**: ${e.updated}` : ""}`;
47
+ const summaryLine = `Summary: ${e.summary}`;
48
+ return `${pathLine}\n${heatLine}\n${summaryLine}`;
49
+ });
50
+ return `${NAV_HEADER}\n*以下是当前场景记忆的索引,可根据需要 read_file 读取详细内容。*\n\n${blocks.join("\n\n")}\n\n${NAV_FOOTER}`;
51
+ }
52
+ /**
53
+ * Strip the scene navigation section from persona content.
54
+ */
55
+ export function stripSceneNavigation(personaContent) {
56
+ const idx = personaContent.indexOf(NAV_HEADER);
57
+ if (idx === -1)
58
+ return personaContent;
59
+ return personaContent.slice(0, idx).trimEnd();
60
+ }