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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20 -0
- package/index.js +328 -0
- package/package.json +53 -0
- package/recall-inject.js +80 -0
- package/vendor/tdai/adapters/index.js +15 -0
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
- package/vendor/tdai/adapters/openclaw/index.js +5 -0
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
- package/vendor/tdai/adapters/standalone/index.js +5 -0
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
- package/vendor/tdai/config.js +363 -0
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
- package/vendor/tdai/core/hooks/auto-capture.js +236 -0
- package/vendor/tdai/core/hooks/auto-recall.js +653 -0
- package/vendor/tdai/core/index.js +8 -0
- package/vendor/tdai/core/persona/persona-generator.js +184 -0
- package/vendor/tdai/core/persona/persona-trigger.js +94 -0
- package/vendor/tdai/core/profile/profile-sync.js +200 -0
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
- package/vendor/tdai/core/prompts/persona-generation.js +160 -0
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
- package/vendor/tdai/core/record/l1-dedup.js +318 -0
- package/vendor/tdai/core/record/l1-extractor.js +379 -0
- package/vendor/tdai/core/record/l1-reader.js +178 -0
- package/vendor/tdai/core/record/l1-writer.js +141 -0
- package/vendor/tdai/core/report/reporter.js +86 -0
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
- package/vendor/tdai/core/scene/scene-extractor.js +410 -0
- package/vendor/tdai/core/scene/scene-format.js +53 -0
- package/vendor/tdai/core/scene/scene-index.js +82 -0
- package/vendor/tdai/core/scene/scene-navigation.js +60 -0
- package/vendor/tdai/core/seed/input.js +404 -0
- package/vendor/tdai/core/seed/seed-runtime.js +314 -0
- package/vendor/tdai/core/seed/types.js +9 -0
- package/vendor/tdai/core/store/bm25-client.js +117 -0
- package/vendor/tdai/core/store/bm25-local.js +68 -0
- package/vendor/tdai/core/store/embedding.js +632 -0
- package/vendor/tdai/core/store/factory.js +95 -0
- package/vendor/tdai/core/store/search-utils.js +54 -0
- package/vendor/tdai/core/store/sqlite.js +1822 -0
- package/vendor/tdai/core/store/tcvdb-client.js +221 -0
- package/vendor/tdai/core/store/tcvdb.js +1047 -0
- package/vendor/tdai/core/store/types.js +17 -0
- package/vendor/tdai/core/tdai-core.js +446 -0
- package/vendor/tdai/core/tools/conversation-search.js +199 -0
- package/vendor/tdai/core/tools/memory-search.js +207 -0
- package/vendor/tdai/core/types.js +12 -0
- package/vendor/tdai/utils/backup.js +189 -0
- package/vendor/tdai/utils/checkpoint.js +356 -0
- package/vendor/tdai/utils/clean-context-runner.js +472 -0
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
- package/vendor/tdai/utils/env.js +13 -0
- package/vendor/tdai/utils/managed-timer.js +127 -0
- package/vendor/tdai/utils/manifest.js +91 -0
- package/vendor/tdai/utils/memory-cleaner.js +300 -0
- package/vendor/tdai/utils/no-think-fetch.js +109 -0
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
- package/vendor/tdai/utils/pipeline-factory.js +517 -0
- package/vendor/tdai/utils/pipeline-manager.js +892 -0
- package/vendor/tdai/utils/sanitize.js +360 -0
- package/vendor/tdai/utils/serial-queue.js +103 -0
- package/vendor/tdai/utils/session-filter.js +88 -0
- package/vendor/tdai/utils/text-utils.js +29 -0
- 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
|
+
}
|