dsh-tdai-memory 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +4 -0
- package/README.zh.md +4 -0
- package/THIRD-PARTY-NOTICES.md +20 -20
- package/cordis.patch.yml +8 -8
- package/package.json +102 -100
- package/vendor/tdai/adapters/index.js +15 -15
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
- package/vendor/tdai/adapters/openclaw/index.js +5 -5
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
- package/vendor/tdai/adapters/standalone/index.js +5 -5
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
- package/vendor/tdai/core/hooks/auto-capture.js +236 -236
- package/vendor/tdai/core/hooks/auto-recall.js +640 -640
- package/vendor/tdai/core/index.js +8 -8
- package/vendor/tdai/core/persona/persona-generator.js +184 -184
- package/vendor/tdai/core/persona/persona-trigger.js +94 -94
- package/vendor/tdai/core/profile/profile-sync.js +200 -200
- package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
- package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
- package/vendor/tdai/core/prompts/persona-generation.js +35 -35
- package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
- package/vendor/tdai/core/record/l1-dedup.js +318 -318
- package/vendor/tdai/core/record/l1-extractor.js +379 -379
- package/vendor/tdai/core/record/l1-reader.js +178 -178
- package/vendor/tdai/core/record/l1-writer.js +141 -141
- package/vendor/tdai/core/report/reporter.js +86 -86
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
- package/vendor/tdai/core/scene/scene-extractor.js +410 -410
- package/vendor/tdai/core/scene/scene-format.js +53 -53
- package/vendor/tdai/core/scene/scene-index.js +82 -82
- package/vendor/tdai/core/scene/scene-navigation.js +57 -57
- package/vendor/tdai/core/seed/input.js +404 -404
- package/vendor/tdai/core/seed/types.js +9 -9
- package/vendor/tdai/core/store/bm25-client.js +117 -117
- package/vendor/tdai/core/store/bm25-local.js +68 -68
- package/vendor/tdai/core/store/embedding.js +632 -632
- package/vendor/tdai/core/store/factory.js +95 -95
- package/vendor/tdai/core/store/search-utils.js +54 -54
- package/vendor/tdai/core/store/sqlite.js +1632 -1632
- package/vendor/tdai/core/store/tcvdb-client.js +221 -221
- package/vendor/tdai/core/store/tcvdb.js +1047 -1047
- package/vendor/tdai/core/store/types.js +17 -17
- package/vendor/tdai/core/tools/conversation-search.js +199 -199
- package/vendor/tdai/core/tools/memory-search.js +207 -207
- package/vendor/tdai/core/types.js +12 -12
- package/vendor/tdai/utils/backup.js +189 -189
- package/vendor/tdai/utils/checkpoint.js +360 -360
- package/vendor/tdai/utils/clean-context-runner.js +472 -472
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
- package/vendor/tdai/utils/env.js +13 -13
- package/vendor/tdai/utils/managed-timer.js +127 -127
- package/vendor/tdai/utils/manifest.js +91 -91
- package/vendor/tdai/utils/memory-cleaner.js +300 -300
- package/vendor/tdai/utils/no-think-fetch.js +109 -109
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
- package/vendor/tdai/utils/pipeline-factory.js +517 -517
- package/vendor/tdai/utils/pipeline-manager.js +892 -892
- package/vendor/tdai/utils/sanitize.js +360 -360
- package/vendor/tdai/utils/serial-queue.js +103 -103
- package/vendor/tdai/utils/session-filter.js +88 -88
- package/vendor/tdai/utils/text-utils.js +29 -29
- 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
|
+
}
|