dsh-tdai-memory 0.2.13 → 0.3.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 (72) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +15 -13
  3. package/README.zh.md +9 -7
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/client.js +21 -4
  6. package/cordis.patch.yml +8 -8
  7. package/index.js +36 -14
  8. package/package.json +77 -78
  9. package/recall-inject.js +86 -86
  10. package/vendor/tdai/adapters/index.js +15 -15
  11. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  12. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  13. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  14. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  15. package/vendor/tdai/adapters/standalone/index.js +5 -5
  16. package/vendor/tdai/adapters/standalone/llm-runner.js +34 -6
  17. package/vendor/tdai/config.js +3 -0
  18. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  19. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  20. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  21. package/vendor/tdai/core/index.js +8 -8
  22. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  23. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  24. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  25. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  26. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  27. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  28. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  29. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  30. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  31. package/vendor/tdai/core/record/l1-reader.js +178 -178
  32. package/vendor/tdai/core/record/l1-writer.js +141 -141
  33. package/vendor/tdai/core/report/reporter.js +86 -86
  34. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  35. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  36. package/vendor/tdai/core/scene/scene-format.js +53 -53
  37. package/vendor/tdai/core/scene/scene-index.js +82 -82
  38. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  39. package/vendor/tdai/core/seed/input.js +404 -404
  40. package/vendor/tdai/core/seed/seed-runtime.js +4 -0
  41. package/vendor/tdai/core/seed/types.js +9 -9
  42. package/vendor/tdai/core/store/bm25-client.js +117 -117
  43. package/vendor/tdai/core/store/bm25-local.js +68 -68
  44. package/vendor/tdai/core/store/embedding.js +632 -632
  45. package/vendor/tdai/core/store/factory.js +95 -95
  46. package/vendor/tdai/core/store/search-utils.js +54 -54
  47. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  48. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  49. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  50. package/vendor/tdai/core/store/types.js +17 -17
  51. package/vendor/tdai/core/tdai-core.js +4 -0
  52. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  53. package/vendor/tdai/core/tools/memory-search.js +207 -207
  54. package/vendor/tdai/core/types.js +12 -12
  55. package/vendor/tdai/utils/backup.js +189 -189
  56. package/vendor/tdai/utils/checkpoint.js +360 -360
  57. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  58. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  59. package/vendor/tdai/utils/env.js +13 -13
  60. package/vendor/tdai/utils/managed-timer.js +127 -127
  61. package/vendor/tdai/utils/manifest.js +91 -91
  62. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  63. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  64. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  65. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  66. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  67. package/vendor/tdai/utils/sanitize.js +360 -360
  68. package/vendor/tdai/utils/serial-queue.js +103 -103
  69. package/vendor/tdai/utils/session-filter.js +88 -88
  70. package/vendor/tdai/utils/session-header-fetch.js +88 -0
  71. package/vendor/tdai/utils/text-utils.js +29 -29
  72. package/vendor/tdai/utils/time.js +252 -252
@@ -1,472 +1,472 @@
1
- /**
2
- * CleanContextRunner: executes LLM calls in a fully isolated context
3
- * using runEmbeddedPiAgent (same mechanism as the llm-task extension).
4
- *
5
- * Guarantees:
6
- * 1. Blank conversation history (temporary session file)
7
- * 2. Independent system prompt (only the task prompt)
8
- * 3. No tool calls when enableTools=false (disableTools:true — no tool definitions sent to API)
9
- * 4. No contamination from the main agent's context
10
- */
11
- import fs from "node:fs/promises";
12
- import fsSync from "node:fs";
13
- import path from "node:path";
14
- import os from "node:os";
15
- import { fileURLToPath, pathToFileURL } from "node:url";
16
- import { getEnv } from "./env.js";
17
- import { report } from "../core/report/reporter.js";
18
- /**
19
- * Resolve a preferred temporary directory for memory-tdai operations.
20
- *
21
- * Previously imported from `openclaw/plugin-sdk` as `resolvePreferredOpenClawTmpDir`,
22
- * but that export was removed in openclaw 2026.2.23+. This local implementation
23
- * provides equivalent behavior:
24
- * 1. Try `/tmp/openclaw` (if writable)
25
- * 2. Fall back to `os.tmpdir()/openclaw-<uid>`
26
- */
27
- function resolveOpenClawTmpDir() {
28
- const POSIX_DIR = "/tmp/openclaw";
29
- try {
30
- if (fsSync.existsSync(POSIX_DIR)) {
31
- fsSync.accessSync(POSIX_DIR, fsSync.constants.W_OK | fsSync.constants.X_OK);
32
- return POSIX_DIR;
33
- }
34
- // Try to create it
35
- fsSync.mkdirSync(POSIX_DIR, { recursive: true, mode: 0o700 });
36
- return POSIX_DIR;
37
- }
38
- catch {
39
- // Fall back to os.tmpdir()
40
- const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
41
- const suffix = uid === undefined ? "openclaw" : `openclaw-${uid}`;
42
- const fallback = path.join(os.tmpdir(), suffix);
43
- fsSync.mkdirSync(fallback, { recursive: true });
44
- return fallback;
45
- }
46
- }
47
- const TAG = "[memory-tdai] [runner]";
48
- let _preferredAgentRuntime;
49
- export function setPreferredEmbeddedAgentRuntime(agentRuntime) {
50
- _preferredAgentRuntime = agentRuntime;
51
- }
52
- function resolveInjectedRunEmbeddedPiAgent(agentRuntime) {
53
- const candidate = agentRuntime?.runEmbeddedPiAgent ?? _preferredAgentRuntime?.runEmbeddedPiAgent;
54
- return typeof candidate === "function" ? candidate : undefined;
55
- }
56
- async function resolveRunEmbeddedPiAgent(agentRuntime, logger) {
57
- const injected = resolveInjectedRunEmbeddedPiAgent(agentRuntime);
58
- if (injected) {
59
- logger?.debug?.(`${TAG} resolveRunEmbeddedPiAgent: using injected runtime.agent.runEmbeddedPiAgent`);
60
- logger?.debug?.(`${TAG} [l1-debug] RESOLVE source=injected`);
61
- return injected;
62
- }
63
- logger?.debug?.(`${TAG} [l1-debug] RESOLVE source=dist-fallback`);
64
- return loadRunEmbeddedPiAgent(logger);
65
- }
66
- // ── Core import (mirrors voice-call/core-bridge.ts — dist/ only, no jiti) ──
67
- let _rootCache = null;
68
- function findPackageRoot(startDir, name) {
69
- let dir = startDir;
70
- for (;;) {
71
- const pkgPath = path.join(dir, "package.json");
72
- try {
73
- if (fsSync.existsSync(pkgPath)) {
74
- const raw = fsSync.readFileSync(pkgPath, "utf8");
75
- const pkg = JSON.parse(raw);
76
- if (pkg.name === name)
77
- return dir;
78
- }
79
- }
80
- catch { /* ignore */ }
81
- const parent = path.dirname(dir);
82
- if (parent === dir)
83
- return null;
84
- dir = parent;
85
- }
86
- }
87
- function resolveOpenClawRoot() {
88
- if (_rootCache)
89
- return _rootCache;
90
- const override = getEnv("OPENCLAW_ROOT")?.trim();
91
- if (override) {
92
- _rootCache = override;
93
- return override;
94
- }
95
- const candidates = new Set();
96
- if (process.argv[1])
97
- candidates.add(path.dirname(process.argv[1]));
98
- candidates.add(process.cwd());
99
- try {
100
- candidates.add(path.dirname(fileURLToPath(import.meta.url)));
101
- }
102
- catch { /* ignore */ }
103
- for (const start of candidates) {
104
- const found = findPackageRoot(start, "openclaw");
105
- if (found) {
106
- _rootCache = found;
107
- return found;
108
- }
109
- }
110
- throw new Error("Unable to resolve OpenClaw root. Set OPENCLAW_ROOT or run `pnpm build`.");
111
- }
112
- let _loadPromise = null;
113
- function loadRunEmbeddedPiAgent(logger) {
114
- if (_loadPromise)
115
- return _loadPromise;
116
- _loadPromise = (async () => {
117
- const t0 = Date.now();
118
- const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
119
- if (!fsSync.existsSync(distPath)) {
120
- throw new Error(`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`);
121
- }
122
- const mod = await import(pathToFileURL(distPath).href);
123
- if (typeof mod.runEmbeddedPiAgent !== "function") {
124
- throw new Error("runEmbeddedPiAgent not exported from dist/extensionAPI.js");
125
- }
126
- logger?.info(`${TAG} loadRunEmbeddedPiAgent: dist/ import OK (${Date.now() - t0}ms)`);
127
- return mod.runEmbeddedPiAgent;
128
- })();
129
- _loadPromise.catch(() => { _loadPromise = null; });
130
- return _loadPromise;
131
- }
132
- /**
133
- * Pre-warm the embedded agent import. Call this during plugin init to avoid
134
- * the cold-start penalty on the first actual extraction run.
135
- * Returns immediately (fire-and-forget) — errors are swallowed.
136
- */
137
- export function prewarmEmbeddedAgent(logger, agentRuntime) {
138
- if (resolveInjectedRunEmbeddedPiAgent(agentRuntime)) {
139
- logger?.debug?.(`${TAG} prewarmEmbeddedAgent: runtime capability already available, skipping legacy preload`);
140
- return;
141
- }
142
- loadRunEmbeddedPiAgent(logger).catch((err) => {
143
- logger?.warn(`${TAG} prewarmEmbeddedAgent: failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
144
- });
145
- }
146
- function collectText(payloads) {
147
- const texts = (payloads ?? [])
148
- .filter((p) => !p.isError && typeof p.text === "string")
149
- .map((p) => p.text ?? "");
150
- return texts.join("\n").trim();
151
- }
152
- /**
153
- * Parse a "provider/model" string into its components.
154
- * Returns undefined if the input is empty or doesn't contain a "/".
155
- *
156
- * Examples:
157
- * "azure/gpt-5.2-chat" → { provider: "azure", model: "gpt-5.2-chat" }
158
- * "custom-host/org/model-v2" → { provider: "custom-host", model: "org/model-v2" }
159
- * "" → undefined
160
- * "bare-model-name" → undefined (no "/" — may be an alias)
161
- */
162
- export function parseModelRef(raw) {
163
- if (!raw)
164
- return undefined;
165
- const trimmed = raw.trim();
166
- if (!trimmed)
167
- return undefined;
168
- const slashIdx = trimmed.indexOf("/");
169
- if (slashIdx <= 0 || slashIdx === trimmed.length - 1)
170
- return undefined;
171
- return {
172
- provider: trimmed.slice(0, slashIdx),
173
- model: trimmed.slice(slashIdx + 1),
174
- };
175
- }
176
- /**
177
- * Resolve the user's default model from the main OpenClaw config.
178
- *
179
- * Resolution order:
180
- * 1. Read `agents.defaults.model` (string or { primary })
181
- * 2. If the value contains "/", parse directly
182
- * 3. If not (may be an alias), look up in `agents.defaults.models` alias table
183
- * 4. Return undefined if nothing resolves — let the core use its built-in default
184
- */
185
- export function resolveModelFromMainConfig(config) {
186
- if (!config || typeof config !== "object")
187
- return undefined;
188
- const cfg = config;
189
- const agents = cfg.agents;
190
- if (!agents || typeof agents !== "object")
191
- return undefined;
192
- const defaults = agents.defaults;
193
- if (!defaults || typeof defaults !== "object")
194
- return undefined;
195
- // Step 1: extract raw model value (string | { primary?: string })
196
- const modelCfg = defaults.model;
197
- let raw;
198
- if (typeof modelCfg === "string") {
199
- raw = modelCfg.trim();
200
- }
201
- else if (modelCfg && typeof modelCfg === "object") {
202
- const primary = modelCfg.primary;
203
- raw = typeof primary === "string" ? primary.trim() : undefined;
204
- }
205
- if (!raw)
206
- return undefined;
207
- // Step 2: try direct "provider/model" parse
208
- const direct = parseModelRef(raw);
209
- if (direct)
210
- return direct;
211
- // Step 3: alias lookup — raw doesn't contain "/", check agents.defaults.models
212
- const models = defaults.models;
213
- if (!models || typeof models !== "object")
214
- return undefined;
215
- const rawLower = raw.toLowerCase();
216
- for (const [key, entry] of Object.entries(models)) {
217
- if (!entry || typeof entry !== "object")
218
- continue;
219
- const alias = entry.alias;
220
- if (typeof alias !== "string")
221
- continue;
222
- if (alias.trim().toLowerCase() !== rawLower)
223
- continue;
224
- // key is "provider/model" format
225
- const resolved = parseModelRef(key);
226
- if (resolved)
227
- return resolved;
228
- }
229
- return undefined;
230
- }
231
- // Stable empty directory used as default workspaceDir so that:
232
- // 1. Bootstrap/skills scans find nothing → clean LLM context
233
- // 2. The path is constant → plugin cacheKey stays stable (no re-registration)
234
- let _cleanWorkspaceDir;
235
- async function getCleanWorkspaceDir() {
236
- if (_cleanWorkspaceDir)
237
- return _cleanWorkspaceDir;
238
- const dir = path.join(resolveOpenClawTmpDir(), "memory-tdai-clean-workspace");
239
- await fs.mkdir(dir, { recursive: true });
240
- _cleanWorkspaceDir = dir;
241
- return dir;
242
- }
243
- export class CleanContextRunner {
244
- options;
245
- logger;
246
- /** Resolved provider after modelRef / config fallback */
247
- resolvedProvider;
248
- /** Resolved model after modelRef / config fallback */
249
- resolvedModel;
250
- constructor(options) {
251
- this.options = options;
252
- this.logger = options.logger;
253
- // Model resolution priority:
254
- // 1. modelRef ("provider/model" string) — highest
255
- // 2. explicit provider + model fields
256
- // 3. main config agents.defaults.model — automatic fallback
257
- // 4. undefined (let core use built-in default)
258
- const fromRef = parseModelRef(options.modelRef);
259
- if (fromRef) {
260
- this.resolvedProvider = fromRef.provider;
261
- this.resolvedModel = fromRef.model;
262
- }
263
- else if (options.provider || options.model) {
264
- this.resolvedProvider = options.provider;
265
- this.resolvedModel = options.model;
266
- }
267
- else {
268
- // No explicit model specified — fall back to main config
269
- const fromConfig = resolveModelFromMainConfig(options.config);
270
- if (fromConfig) {
271
- this.resolvedProvider = fromConfig.provider;
272
- this.resolvedModel = fromConfig.model;
273
- this.logger?.debug?.(`${TAG} Using model from main config: ${fromConfig.provider}/${fromConfig.model}`);
274
- }
275
- // else: both undefined → core will use its built-in default (anthropic/claude-opus-4-6)
276
- }
277
- }
278
- /**
279
- * Run a prompt in a fully isolated clean context.
280
- * Returns the LLM's text output.
281
- *
282
- * When `workspaceDir` is provided it overrides the default `process.cwd()`,
283
- * letting the LLM's file-tool calls resolve paths relative to a custom root.
284
- */
285
- async run(params) {
286
- const runStartMs = Date.now();
287
- this.logger?.debug?.(`${TAG} run() start: taskId=${params.taskId}, timeout=${params.timeoutMs ?? 120_000}ms, tools=${this.options.enableTools ? "enabled" : "disabled"}, workspaceDir=${params.workspaceDir ?? "(default)"}`);
288
- const tmpDir = await fs.mkdtemp(path.join(resolveOpenClawTmpDir(), `memory-tdai-${params.taskId}-`));
289
- const cleanWorkspace = params.workspaceDir ?? await getCleanWorkspaceDir();
290
- this.logger?.debug?.(`${TAG} run() tmpDir=${tmpDir}, cleanWorkspace=${cleanWorkspace}`);
291
- try {
292
- const sessionFile = path.join(tmpDir, "session.json");
293
- // Phase 1: Resolve runEmbeddedPiAgent (prefer runtime, fallback to legacy dist bridge)
294
- const importStartMs = Date.now();
295
- const runEmbeddedPiAgent = await resolveRunEmbeddedPiAgent(this.options.agentRuntime, this.logger);
296
- const importElapsedMs = Date.now() - importStartMs;
297
- this.logger?.debug?.(`${TAG} run() runner resolution phase: ${importElapsedMs}ms`);
298
- // Derive a config with plugins disabled to prevent loadOpenClawPlugins
299
- // from re-registering plugins when the workspaceDir differs from the
300
- // gateway's original workspace (cacheKey mismatch triggers full reload).
301
- //
302
- // Security: restrict available tools to the minimal set needed for
303
- // scene extraction (read/write/edit). This prevents the LLM from
304
- // accessing exec, sessions, browser, cron, or any other powerful tools.
305
- // File deletion is handled via "soft-delete" (write empty) + cleanup afterward.
306
- const cleanConfig = {
307
- ...this.options.config,
308
- plugins: {
309
- ...this.options.config?.plugins,
310
- enabled: false,
311
- },
312
- tools: {
313
- ...this.options.config?.tools,
314
- // When enableTools=true, restrict to the minimal set needed for
315
- // scene extraction (read/write/edit).
316
- // When enableTools=false, pass an empty allow list — disableTools:true
317
- // will prevent tools from being sent to the API entirely.
318
- allow: this.options.enableTools ? ["read", "write", "edit"] : [],
319
- },
320
- // Override the full agent system prompt with the caller's extraction-specific
321
- // system prompt. This replaces OpenClaw's default system prompt (identity,
322
- // AGENTS.md, workspace context, tool guidance, etc.) to:
323
- // 1. Save ~5000 tokens per LLM call
324
- // 2. Avoid instruction interference with extraction prompts
325
- agents: {
326
- ...this.options.config?.agents,
327
- defaults: {
328
- ...this.options.config?.agents?.defaults,
329
- systemPromptOverride: params.systemPrompt ||
330
- "You are a precise data extraction and generation assistant. Follow the user instructions exactly. Respond only with the requested output format.",
331
- },
332
- },
333
- };
334
- // systemPrompt is now in config.agents.defaults.systemPromptOverride
335
- // (actual [system] role), so user prompt only contains the actual content.
336
- const effectivePrompt = params.prompt;
337
- const ts = Date.now();
338
- const sessionId = `memory-${params.taskId}-session-${ts}`;
339
- const runId = `memory-${params.taskId}-run-${ts}`;
340
- this.logger?.debug?.(`${TAG} run() starting embedded agent: sessionId=${sessionId}, runId=${runId}, provider=${this.resolvedProvider ?? "(default)"}, model=${this.resolvedModel ?? "(default)"}`);
341
- // [l1-debug] INVOKE — what are we about to send to the embedded agent?
342
- const sysPromptOverrideLen = cleanConfig.agents?.defaults?.systemPromptOverride
343
- ? String(cleanConfig.agents.defaults.systemPromptOverride).length
344
- : 0;
345
- const toolsAllow = cleanConfig.tools?.allow ?? [];
346
- this.logger?.debug?.(`${TAG} [l1-debug] INVOKE taskId=${params.taskId}, provider=${this.resolvedProvider ?? "(default)"}, model=${this.resolvedModel ?? "(default)"}, promptLen=${effectivePrompt.length}, sysPromptOverrideLen=${sysPromptOverrideLen}, toolsAllow=${JSON.stringify(toolsAllow)}, timeoutMs=${params.timeoutMs ?? 120_000}`);
347
- // Phase 2: Embedded agent run (LLM call + tool calls)
348
- const agentStartMs = Date.now();
349
- // extraSystemPrompt: fallback for openclaw < 2026.4.7 which does not support
350
- // config.agents.defaults.systemPromptOverride. On newer versions the
351
- // override takes precedence and this becomes a no-op append.
352
- const effectiveSystemPrompt = params.systemPrompt ||
353
- "You are a precise data extraction and generation assistant. Follow the user instructions exactly. Respond only with the requested output format.";
354
- const result = await runEmbeddedPiAgent({
355
- sessionId,
356
- sessionFile,
357
- workspaceDir: cleanWorkspace,
358
- config: cleanConfig,
359
- prompt: effectivePrompt,
360
- timeoutMs: params.timeoutMs ?? 120_000,
361
- runId,
362
- provider: this.resolvedProvider,
363
- model: this.resolvedModel,
364
- // When enableTools=false, pass disableTools:true so that no tool
365
- // definitions are sent to the API. This avoids polluting the LLM
366
- // context with tool schemas and prevents the model from attempting
367
- // tool calls during pure text extraction tasks.
368
- // If a provider (e.g. qwencode) rejects empty tools[], users should
369
- // switch to StandaloneLLMRunner via LLM configuration instead.
370
- disableTools: !this.options.enableTools,
371
- extraSystemPrompt: effectiveSystemPrompt,
372
- streamParams: {
373
- maxTokens: params.maxTokens,
374
- },
375
- });
376
- const agentElapsedMs = Date.now() - agentStartMs;
377
- this.logger?.debug?.(`${TAG} run() embedded agent completed: ${agentElapsedMs}ms`);
378
- // [l1-debug] RESULT — what did the embedded agent return?
379
- {
380
- const payloadsRaw = result?.payloads;
381
- const payloads = Array.isArray(payloadsRaw)
382
- ? payloadsRaw
383
- : [];
384
- const payloadKinds = payloads.map((p) => {
385
- if (typeof p?.type === "string")
386
- return p.type;
387
- if (typeof p?.kind === "string")
388
- return p.kind;
389
- return Object.keys(p ?? {}).slice(0, 3).join("|") || "unknown";
390
- });
391
- const errorPayloadCount = payloads.filter((p) => p?.isError === true).length;
392
- const joinedText = payloads
393
- .filter((p) => !p?.isError && typeof p?.text === "string")
394
- .map((p) => String(p.text ?? ""))
395
- .join("\n");
396
- const textPreview = joinedText.replace(/\s+/g, " ").slice(0, 200);
397
- this.logger?.debug?.(`${TAG} [l1-debug] RESULT taskId=${params.taskId}, elapsedMs=${agentElapsedMs}, payloadCount=${payloads.length}, payloadKinds=${JSON.stringify(payloadKinds)}, errorPayloadCount=${errorPayloadCount}, textLen=${joinedText.length}, textPreview=${JSON.stringify(textPreview)}`);
398
- }
399
- // Phase 3: Collect output
400
- const text = collectText(result.payloads);
401
- const totalMs = Date.now() - runStartMs;
402
- if (!text) {
403
- // Empty output is normal when the LLM decides there is nothing to
404
- // extract (e.g. trivial greetings). Log a warning instead of
405
- // throwing so the caller can handle it gracefully.
406
- this.logger?.warn?.(`${TAG} run() empty output after ${totalMs}ms (import=${importElapsedMs}ms, agent=${agentElapsedMs}ms) — treating as empty result`);
407
- // [l1-debug] EMPTY_DUMP — dump the full result shape so we can see where text went
408
- try {
409
- const dump = JSON.stringify(result, (_k, v) => {
410
- if (typeof v === "string" && v.length > 500)
411
- return v.slice(0, 500) + `…(+${v.length - 500})`;
412
- return v;
413
- }).slice(0, 2048);
414
- this.logger?.warn?.(`${TAG} [l1-debug] EMPTY_DUMP taskId=${params.taskId}, resultJson=${dump}`);
415
- }
416
- catch (dumpErr) {
417
- this.logger?.warn?.(`${TAG} [l1-debug] EMPTY_DUMP taskId=${params.taskId}, dumpFailed=${dumpErr instanceof Error ? dumpErr.message : String(dumpErr)}`);
418
- }
419
- // llm_call metric (empty output)
420
- if (params.instanceId && this.logger) {
421
- report("llm_call", {
422
- taskId: params.taskId,
423
- provider: this.resolvedProvider ?? "default",
424
- model: this.resolvedModel ?? "default",
425
- inputLength: params.prompt.length,
426
- outputLength: 0,
427
- totalDurationMs: totalMs,
428
- success: true,
429
- error: "empty_output",
430
- });
431
- }
432
- return "";
433
- }
434
- this.logger?.debug?.(`${TAG} run() completed: ${totalMs}ms total (import=${importElapsedMs}ms, agent=${agentElapsedMs}ms), output=${text.length} chars`);
435
- // ── llm_call metric (success) ──
436
- if (params.instanceId && this.logger) {
437
- report("llm_call", {
438
- taskId: params.taskId,
439
- provider: this.resolvedProvider ?? "default",
440
- model: this.resolvedModel ?? "default",
441
- inputLength: params.prompt.length,
442
- outputLength: text.length,
443
- totalDurationMs: totalMs,
444
- success: true,
445
- error: null,
446
- });
447
- }
448
- return text;
449
- }
450
- catch (err) {
451
- const totalMs = Date.now() - runStartMs;
452
- this.logger?.error(`${TAG} run() failed after ${totalMs}ms: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
453
- // ── llm_call metric (failure) ──
454
- if (params.instanceId && this.logger) {
455
- report("llm_call", {
456
- taskId: params.taskId,
457
- provider: this.resolvedProvider ?? "default",
458
- model: this.resolvedModel ?? "default",
459
- inputLength: params.prompt.length,
460
- outputLength: 0,
461
- totalDurationMs: totalMs,
462
- success: false,
463
- error: err instanceof Error ? err.message : String(err),
464
- });
465
- }
466
- throw err;
467
- }
468
- finally {
469
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
470
- }
471
- }
472
- }
1
+ /**
2
+ * CleanContextRunner: executes LLM calls in a fully isolated context
3
+ * using runEmbeddedPiAgent (same mechanism as the llm-task extension).
4
+ *
5
+ * Guarantees:
6
+ * 1. Blank conversation history (temporary session file)
7
+ * 2. Independent system prompt (only the task prompt)
8
+ * 3. No tool calls when enableTools=false (disableTools:true — no tool definitions sent to API)
9
+ * 4. No contamination from the main agent's context
10
+ */
11
+ import fs from "node:fs/promises";
12
+ import fsSync from "node:fs";
13
+ import path from "node:path";
14
+ import os from "node:os";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import { getEnv } from "./env.js";
17
+ import { report } from "../core/report/reporter.js";
18
+ /**
19
+ * Resolve a preferred temporary directory for memory-tdai operations.
20
+ *
21
+ * Previously imported from `openclaw/plugin-sdk` as `resolvePreferredOpenClawTmpDir`,
22
+ * but that export was removed in openclaw 2026.2.23+. This local implementation
23
+ * provides equivalent behavior:
24
+ * 1. Try `/tmp/openclaw` (if writable)
25
+ * 2. Fall back to `os.tmpdir()/openclaw-<uid>`
26
+ */
27
+ function resolveOpenClawTmpDir() {
28
+ const POSIX_DIR = "/tmp/openclaw";
29
+ try {
30
+ if (fsSync.existsSync(POSIX_DIR)) {
31
+ fsSync.accessSync(POSIX_DIR, fsSync.constants.W_OK | fsSync.constants.X_OK);
32
+ return POSIX_DIR;
33
+ }
34
+ // Try to create it
35
+ fsSync.mkdirSync(POSIX_DIR, { recursive: true, mode: 0o700 });
36
+ return POSIX_DIR;
37
+ }
38
+ catch {
39
+ // Fall back to os.tmpdir()
40
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
41
+ const suffix = uid === undefined ? "openclaw" : `openclaw-${uid}`;
42
+ const fallback = path.join(os.tmpdir(), suffix);
43
+ fsSync.mkdirSync(fallback, { recursive: true });
44
+ return fallback;
45
+ }
46
+ }
47
+ const TAG = "[memory-tdai] [runner]";
48
+ let _preferredAgentRuntime;
49
+ export function setPreferredEmbeddedAgentRuntime(agentRuntime) {
50
+ _preferredAgentRuntime = agentRuntime;
51
+ }
52
+ function resolveInjectedRunEmbeddedPiAgent(agentRuntime) {
53
+ const candidate = agentRuntime?.runEmbeddedPiAgent ?? _preferredAgentRuntime?.runEmbeddedPiAgent;
54
+ return typeof candidate === "function" ? candidate : undefined;
55
+ }
56
+ async function resolveRunEmbeddedPiAgent(agentRuntime, logger) {
57
+ const injected = resolveInjectedRunEmbeddedPiAgent(agentRuntime);
58
+ if (injected) {
59
+ logger?.debug?.(`${TAG} resolveRunEmbeddedPiAgent: using injected runtime.agent.runEmbeddedPiAgent`);
60
+ logger?.debug?.(`${TAG} [l1-debug] RESOLVE source=injected`);
61
+ return injected;
62
+ }
63
+ logger?.debug?.(`${TAG} [l1-debug] RESOLVE source=dist-fallback`);
64
+ return loadRunEmbeddedPiAgent(logger);
65
+ }
66
+ // ── Core import (mirrors voice-call/core-bridge.ts — dist/ only, no jiti) ──
67
+ let _rootCache = null;
68
+ function findPackageRoot(startDir, name) {
69
+ let dir = startDir;
70
+ for (;;) {
71
+ const pkgPath = path.join(dir, "package.json");
72
+ try {
73
+ if (fsSync.existsSync(pkgPath)) {
74
+ const raw = fsSync.readFileSync(pkgPath, "utf8");
75
+ const pkg = JSON.parse(raw);
76
+ if (pkg.name === name)
77
+ return dir;
78
+ }
79
+ }
80
+ catch { /* ignore */ }
81
+ const parent = path.dirname(dir);
82
+ if (parent === dir)
83
+ return null;
84
+ dir = parent;
85
+ }
86
+ }
87
+ function resolveOpenClawRoot() {
88
+ if (_rootCache)
89
+ return _rootCache;
90
+ const override = getEnv("OPENCLAW_ROOT")?.trim();
91
+ if (override) {
92
+ _rootCache = override;
93
+ return override;
94
+ }
95
+ const candidates = new Set();
96
+ if (process.argv[1])
97
+ candidates.add(path.dirname(process.argv[1]));
98
+ candidates.add(process.cwd());
99
+ try {
100
+ candidates.add(path.dirname(fileURLToPath(import.meta.url)));
101
+ }
102
+ catch { /* ignore */ }
103
+ for (const start of candidates) {
104
+ const found = findPackageRoot(start, "openclaw");
105
+ if (found) {
106
+ _rootCache = found;
107
+ return found;
108
+ }
109
+ }
110
+ throw new Error("Unable to resolve OpenClaw root. Set OPENCLAW_ROOT or run `pnpm build`.");
111
+ }
112
+ let _loadPromise = null;
113
+ function loadRunEmbeddedPiAgent(logger) {
114
+ if (_loadPromise)
115
+ return _loadPromise;
116
+ _loadPromise = (async () => {
117
+ const t0 = Date.now();
118
+ const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
119
+ if (!fsSync.existsSync(distPath)) {
120
+ throw new Error(`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`);
121
+ }
122
+ const mod = await import(pathToFileURL(distPath).href);
123
+ if (typeof mod.runEmbeddedPiAgent !== "function") {
124
+ throw new Error("runEmbeddedPiAgent not exported from dist/extensionAPI.js");
125
+ }
126
+ logger?.info(`${TAG} loadRunEmbeddedPiAgent: dist/ import OK (${Date.now() - t0}ms)`);
127
+ return mod.runEmbeddedPiAgent;
128
+ })();
129
+ _loadPromise.catch(() => { _loadPromise = null; });
130
+ return _loadPromise;
131
+ }
132
+ /**
133
+ * Pre-warm the embedded agent import. Call this during plugin init to avoid
134
+ * the cold-start penalty on the first actual extraction run.
135
+ * Returns immediately (fire-and-forget) — errors are swallowed.
136
+ */
137
+ export function prewarmEmbeddedAgent(logger, agentRuntime) {
138
+ if (resolveInjectedRunEmbeddedPiAgent(agentRuntime)) {
139
+ logger?.debug?.(`${TAG} prewarmEmbeddedAgent: runtime capability already available, skipping legacy preload`);
140
+ return;
141
+ }
142
+ loadRunEmbeddedPiAgent(logger).catch((err) => {
143
+ logger?.warn(`${TAG} prewarmEmbeddedAgent: failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
144
+ });
145
+ }
146
+ function collectText(payloads) {
147
+ const texts = (payloads ?? [])
148
+ .filter((p) => !p.isError && typeof p.text === "string")
149
+ .map((p) => p.text ?? "");
150
+ return texts.join("\n").trim();
151
+ }
152
+ /**
153
+ * Parse a "provider/model" string into its components.
154
+ * Returns undefined if the input is empty or doesn't contain a "/".
155
+ *
156
+ * Examples:
157
+ * "azure/gpt-5.2-chat" → { provider: "azure", model: "gpt-5.2-chat" }
158
+ * "custom-host/org/model-v2" → { provider: "custom-host", model: "org/model-v2" }
159
+ * "" → undefined
160
+ * "bare-model-name" → undefined (no "/" — may be an alias)
161
+ */
162
+ export function parseModelRef(raw) {
163
+ if (!raw)
164
+ return undefined;
165
+ const trimmed = raw.trim();
166
+ if (!trimmed)
167
+ return undefined;
168
+ const slashIdx = trimmed.indexOf("/");
169
+ if (slashIdx <= 0 || slashIdx === trimmed.length - 1)
170
+ return undefined;
171
+ return {
172
+ provider: trimmed.slice(0, slashIdx),
173
+ model: trimmed.slice(slashIdx + 1),
174
+ };
175
+ }
176
+ /**
177
+ * Resolve the user's default model from the main OpenClaw config.
178
+ *
179
+ * Resolution order:
180
+ * 1. Read `agents.defaults.model` (string or { primary })
181
+ * 2. If the value contains "/", parse directly
182
+ * 3. If not (may be an alias), look up in `agents.defaults.models` alias table
183
+ * 4. Return undefined if nothing resolves — let the core use its built-in default
184
+ */
185
+ export function resolveModelFromMainConfig(config) {
186
+ if (!config || typeof config !== "object")
187
+ return undefined;
188
+ const cfg = config;
189
+ const agents = cfg.agents;
190
+ if (!agents || typeof agents !== "object")
191
+ return undefined;
192
+ const defaults = agents.defaults;
193
+ if (!defaults || typeof defaults !== "object")
194
+ return undefined;
195
+ // Step 1: extract raw model value (string | { primary?: string })
196
+ const modelCfg = defaults.model;
197
+ let raw;
198
+ if (typeof modelCfg === "string") {
199
+ raw = modelCfg.trim();
200
+ }
201
+ else if (modelCfg && typeof modelCfg === "object") {
202
+ const primary = modelCfg.primary;
203
+ raw = typeof primary === "string" ? primary.trim() : undefined;
204
+ }
205
+ if (!raw)
206
+ return undefined;
207
+ // Step 2: try direct "provider/model" parse
208
+ const direct = parseModelRef(raw);
209
+ if (direct)
210
+ return direct;
211
+ // Step 3: alias lookup — raw doesn't contain "/", check agents.defaults.models
212
+ const models = defaults.models;
213
+ if (!models || typeof models !== "object")
214
+ return undefined;
215
+ const rawLower = raw.toLowerCase();
216
+ for (const [key, entry] of Object.entries(models)) {
217
+ if (!entry || typeof entry !== "object")
218
+ continue;
219
+ const alias = entry.alias;
220
+ if (typeof alias !== "string")
221
+ continue;
222
+ if (alias.trim().toLowerCase() !== rawLower)
223
+ continue;
224
+ // key is "provider/model" format
225
+ const resolved = parseModelRef(key);
226
+ if (resolved)
227
+ return resolved;
228
+ }
229
+ return undefined;
230
+ }
231
+ // Stable empty directory used as default workspaceDir so that:
232
+ // 1. Bootstrap/skills scans find nothing → clean LLM context
233
+ // 2. The path is constant → plugin cacheKey stays stable (no re-registration)
234
+ let _cleanWorkspaceDir;
235
+ async function getCleanWorkspaceDir() {
236
+ if (_cleanWorkspaceDir)
237
+ return _cleanWorkspaceDir;
238
+ const dir = path.join(resolveOpenClawTmpDir(), "memory-tdai-clean-workspace");
239
+ await fs.mkdir(dir, { recursive: true });
240
+ _cleanWorkspaceDir = dir;
241
+ return dir;
242
+ }
243
+ export class CleanContextRunner {
244
+ options;
245
+ logger;
246
+ /** Resolved provider after modelRef / config fallback */
247
+ resolvedProvider;
248
+ /** Resolved model after modelRef / config fallback */
249
+ resolvedModel;
250
+ constructor(options) {
251
+ this.options = options;
252
+ this.logger = options.logger;
253
+ // Model resolution priority:
254
+ // 1. modelRef ("provider/model" string) — highest
255
+ // 2. explicit provider + model fields
256
+ // 3. main config agents.defaults.model — automatic fallback
257
+ // 4. undefined (let core use built-in default)
258
+ const fromRef = parseModelRef(options.modelRef);
259
+ if (fromRef) {
260
+ this.resolvedProvider = fromRef.provider;
261
+ this.resolvedModel = fromRef.model;
262
+ }
263
+ else if (options.provider || options.model) {
264
+ this.resolvedProvider = options.provider;
265
+ this.resolvedModel = options.model;
266
+ }
267
+ else {
268
+ // No explicit model specified — fall back to main config
269
+ const fromConfig = resolveModelFromMainConfig(options.config);
270
+ if (fromConfig) {
271
+ this.resolvedProvider = fromConfig.provider;
272
+ this.resolvedModel = fromConfig.model;
273
+ this.logger?.debug?.(`${TAG} Using model from main config: ${fromConfig.provider}/${fromConfig.model}`);
274
+ }
275
+ // else: both undefined → core will use its built-in default (anthropic/claude-opus-4-6)
276
+ }
277
+ }
278
+ /**
279
+ * Run a prompt in a fully isolated clean context.
280
+ * Returns the LLM's text output.
281
+ *
282
+ * When `workspaceDir` is provided it overrides the default `process.cwd()`,
283
+ * letting the LLM's file-tool calls resolve paths relative to a custom root.
284
+ */
285
+ async run(params) {
286
+ const runStartMs = Date.now();
287
+ this.logger?.debug?.(`${TAG} run() start: taskId=${params.taskId}, timeout=${params.timeoutMs ?? 120_000}ms, tools=${this.options.enableTools ? "enabled" : "disabled"}, workspaceDir=${params.workspaceDir ?? "(default)"}`);
288
+ const tmpDir = await fs.mkdtemp(path.join(resolveOpenClawTmpDir(), `memory-tdai-${params.taskId}-`));
289
+ const cleanWorkspace = params.workspaceDir ?? await getCleanWorkspaceDir();
290
+ this.logger?.debug?.(`${TAG} run() tmpDir=${tmpDir}, cleanWorkspace=${cleanWorkspace}`);
291
+ try {
292
+ const sessionFile = path.join(tmpDir, "session.json");
293
+ // Phase 1: Resolve runEmbeddedPiAgent (prefer runtime, fallback to legacy dist bridge)
294
+ const importStartMs = Date.now();
295
+ const runEmbeddedPiAgent = await resolveRunEmbeddedPiAgent(this.options.agentRuntime, this.logger);
296
+ const importElapsedMs = Date.now() - importStartMs;
297
+ this.logger?.debug?.(`${TAG} run() runner resolution phase: ${importElapsedMs}ms`);
298
+ // Derive a config with plugins disabled to prevent loadOpenClawPlugins
299
+ // from re-registering plugins when the workspaceDir differs from the
300
+ // gateway's original workspace (cacheKey mismatch triggers full reload).
301
+ //
302
+ // Security: restrict available tools to the minimal set needed for
303
+ // scene extraction (read/write/edit). This prevents the LLM from
304
+ // accessing exec, sessions, browser, cron, or any other powerful tools.
305
+ // File deletion is handled via "soft-delete" (write empty) + cleanup afterward.
306
+ const cleanConfig = {
307
+ ...this.options.config,
308
+ plugins: {
309
+ ...this.options.config?.plugins,
310
+ enabled: false,
311
+ },
312
+ tools: {
313
+ ...this.options.config?.tools,
314
+ // When enableTools=true, restrict to the minimal set needed for
315
+ // scene extraction (read/write/edit).
316
+ // When enableTools=false, pass an empty allow list — disableTools:true
317
+ // will prevent tools from being sent to the API entirely.
318
+ allow: this.options.enableTools ? ["read", "write", "edit"] : [],
319
+ },
320
+ // Override the full agent system prompt with the caller's extraction-specific
321
+ // system prompt. This replaces OpenClaw's default system prompt (identity,
322
+ // AGENTS.md, workspace context, tool guidance, etc.) to:
323
+ // 1. Save ~5000 tokens per LLM call
324
+ // 2. Avoid instruction interference with extraction prompts
325
+ agents: {
326
+ ...this.options.config?.agents,
327
+ defaults: {
328
+ ...this.options.config?.agents?.defaults,
329
+ systemPromptOverride: params.systemPrompt ||
330
+ "You are a precise data extraction and generation assistant. Follow the user instructions exactly. Respond only with the requested output format.",
331
+ },
332
+ },
333
+ };
334
+ // systemPrompt is now in config.agents.defaults.systemPromptOverride
335
+ // (actual [system] role), so user prompt only contains the actual content.
336
+ const effectivePrompt = params.prompt;
337
+ const ts = Date.now();
338
+ const sessionId = `memory-${params.taskId}-session-${ts}`;
339
+ const runId = `memory-${params.taskId}-run-${ts}`;
340
+ this.logger?.debug?.(`${TAG} run() starting embedded agent: sessionId=${sessionId}, runId=${runId}, provider=${this.resolvedProvider ?? "(default)"}, model=${this.resolvedModel ?? "(default)"}`);
341
+ // [l1-debug] INVOKE — what are we about to send to the embedded agent?
342
+ const sysPromptOverrideLen = cleanConfig.agents?.defaults?.systemPromptOverride
343
+ ? String(cleanConfig.agents.defaults.systemPromptOverride).length
344
+ : 0;
345
+ const toolsAllow = cleanConfig.tools?.allow ?? [];
346
+ this.logger?.debug?.(`${TAG} [l1-debug] INVOKE taskId=${params.taskId}, provider=${this.resolvedProvider ?? "(default)"}, model=${this.resolvedModel ?? "(default)"}, promptLen=${effectivePrompt.length}, sysPromptOverrideLen=${sysPromptOverrideLen}, toolsAllow=${JSON.stringify(toolsAllow)}, timeoutMs=${params.timeoutMs ?? 120_000}`);
347
+ // Phase 2: Embedded agent run (LLM call + tool calls)
348
+ const agentStartMs = Date.now();
349
+ // extraSystemPrompt: fallback for openclaw < 2026.4.7 which does not support
350
+ // config.agents.defaults.systemPromptOverride. On newer versions the
351
+ // override takes precedence and this becomes a no-op append.
352
+ const effectiveSystemPrompt = params.systemPrompt ||
353
+ "You are a precise data extraction and generation assistant. Follow the user instructions exactly. Respond only with the requested output format.";
354
+ const result = await runEmbeddedPiAgent({
355
+ sessionId,
356
+ sessionFile,
357
+ workspaceDir: cleanWorkspace,
358
+ config: cleanConfig,
359
+ prompt: effectivePrompt,
360
+ timeoutMs: params.timeoutMs ?? 120_000,
361
+ runId,
362
+ provider: this.resolvedProvider,
363
+ model: this.resolvedModel,
364
+ // When enableTools=false, pass disableTools:true so that no tool
365
+ // definitions are sent to the API. This avoids polluting the LLM
366
+ // context with tool schemas and prevents the model from attempting
367
+ // tool calls during pure text extraction tasks.
368
+ // If a provider (e.g. qwencode) rejects empty tools[], users should
369
+ // switch to StandaloneLLMRunner via LLM configuration instead.
370
+ disableTools: !this.options.enableTools,
371
+ extraSystemPrompt: effectiveSystemPrompt,
372
+ streamParams: {
373
+ maxTokens: params.maxTokens,
374
+ },
375
+ });
376
+ const agentElapsedMs = Date.now() - agentStartMs;
377
+ this.logger?.debug?.(`${TAG} run() embedded agent completed: ${agentElapsedMs}ms`);
378
+ // [l1-debug] RESULT — what did the embedded agent return?
379
+ {
380
+ const payloadsRaw = result?.payloads;
381
+ const payloads = Array.isArray(payloadsRaw)
382
+ ? payloadsRaw
383
+ : [];
384
+ const payloadKinds = payloads.map((p) => {
385
+ if (typeof p?.type === "string")
386
+ return p.type;
387
+ if (typeof p?.kind === "string")
388
+ return p.kind;
389
+ return Object.keys(p ?? {}).slice(0, 3).join("|") || "unknown";
390
+ });
391
+ const errorPayloadCount = payloads.filter((p) => p?.isError === true).length;
392
+ const joinedText = payloads
393
+ .filter((p) => !p?.isError && typeof p?.text === "string")
394
+ .map((p) => String(p.text ?? ""))
395
+ .join("\n");
396
+ const textPreview = joinedText.replace(/\s+/g, " ").slice(0, 200);
397
+ this.logger?.debug?.(`${TAG} [l1-debug] RESULT taskId=${params.taskId}, elapsedMs=${agentElapsedMs}, payloadCount=${payloads.length}, payloadKinds=${JSON.stringify(payloadKinds)}, errorPayloadCount=${errorPayloadCount}, textLen=${joinedText.length}, textPreview=${JSON.stringify(textPreview)}`);
398
+ }
399
+ // Phase 3: Collect output
400
+ const text = collectText(result.payloads);
401
+ const totalMs = Date.now() - runStartMs;
402
+ if (!text) {
403
+ // Empty output is normal when the LLM decides there is nothing to
404
+ // extract (e.g. trivial greetings). Log a warning instead of
405
+ // throwing so the caller can handle it gracefully.
406
+ this.logger?.warn?.(`${TAG} run() empty output after ${totalMs}ms (import=${importElapsedMs}ms, agent=${agentElapsedMs}ms) — treating as empty result`);
407
+ // [l1-debug] EMPTY_DUMP — dump the full result shape so we can see where text went
408
+ try {
409
+ const dump = JSON.stringify(result, (_k, v) => {
410
+ if (typeof v === "string" && v.length > 500)
411
+ return v.slice(0, 500) + `…(+${v.length - 500})`;
412
+ return v;
413
+ }).slice(0, 2048);
414
+ this.logger?.warn?.(`${TAG} [l1-debug] EMPTY_DUMP taskId=${params.taskId}, resultJson=${dump}`);
415
+ }
416
+ catch (dumpErr) {
417
+ this.logger?.warn?.(`${TAG} [l1-debug] EMPTY_DUMP taskId=${params.taskId}, dumpFailed=${dumpErr instanceof Error ? dumpErr.message : String(dumpErr)}`);
418
+ }
419
+ // llm_call metric (empty output)
420
+ if (params.instanceId && this.logger) {
421
+ report("llm_call", {
422
+ taskId: params.taskId,
423
+ provider: this.resolvedProvider ?? "default",
424
+ model: this.resolvedModel ?? "default",
425
+ inputLength: params.prompt.length,
426
+ outputLength: 0,
427
+ totalDurationMs: totalMs,
428
+ success: true,
429
+ error: "empty_output",
430
+ });
431
+ }
432
+ return "";
433
+ }
434
+ this.logger?.debug?.(`${TAG} run() completed: ${totalMs}ms total (import=${importElapsedMs}ms, agent=${agentElapsedMs}ms), output=${text.length} chars`);
435
+ // ── llm_call metric (success) ──
436
+ if (params.instanceId && this.logger) {
437
+ report("llm_call", {
438
+ taskId: params.taskId,
439
+ provider: this.resolvedProvider ?? "default",
440
+ model: this.resolvedModel ?? "default",
441
+ inputLength: params.prompt.length,
442
+ outputLength: text.length,
443
+ totalDurationMs: totalMs,
444
+ success: true,
445
+ error: null,
446
+ });
447
+ }
448
+ return text;
449
+ }
450
+ catch (err) {
451
+ const totalMs = Date.now() - runStartMs;
452
+ this.logger?.error(`${TAG} run() failed after ${totalMs}ms: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
453
+ // ── llm_call metric (failure) ──
454
+ if (params.instanceId && this.logger) {
455
+ report("llm_call", {
456
+ taskId: params.taskId,
457
+ provider: this.resolvedProvider ?? "default",
458
+ model: this.resolvedModel ?? "default",
459
+ inputLength: params.prompt.length,
460
+ outputLength: 0,
461
+ totalDurationMs: totalMs,
462
+ success: false,
463
+ error: err instanceof Error ? err.message : String(err),
464
+ });
465
+ }
466
+ throw err;
467
+ }
468
+ finally {
469
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
470
+ }
471
+ }
472
+ }