dsh-tdai-memory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/THIRD-PARTY-NOTICES.md +20 -0
  4. package/index.js +328 -0
  5. package/package.json +53 -0
  6. package/recall-inject.js +80 -0
  7. package/vendor/tdai/adapters/index.js +15 -0
  8. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
  9. package/vendor/tdai/adapters/openclaw/index.js +5 -0
  10. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
  11. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
  12. package/vendor/tdai/adapters/standalone/index.js +5 -0
  13. package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
  14. package/vendor/tdai/config.js +363 -0
  15. package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
  16. package/vendor/tdai/core/hooks/auto-capture.js +236 -0
  17. package/vendor/tdai/core/hooks/auto-recall.js +653 -0
  18. package/vendor/tdai/core/index.js +8 -0
  19. package/vendor/tdai/core/persona/persona-generator.js +184 -0
  20. package/vendor/tdai/core/persona/persona-trigger.js +94 -0
  21. package/vendor/tdai/core/profile/profile-sync.js +200 -0
  22. package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
  23. package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
  24. package/vendor/tdai/core/prompts/persona-generation.js +160 -0
  25. package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
  26. package/vendor/tdai/core/record/l1-dedup.js +318 -0
  27. package/vendor/tdai/core/record/l1-extractor.js +379 -0
  28. package/vendor/tdai/core/record/l1-reader.js +178 -0
  29. package/vendor/tdai/core/record/l1-writer.js +141 -0
  30. package/vendor/tdai/core/report/reporter.js +86 -0
  31. package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
  32. package/vendor/tdai/core/scene/scene-extractor.js +410 -0
  33. package/vendor/tdai/core/scene/scene-format.js +53 -0
  34. package/vendor/tdai/core/scene/scene-index.js +82 -0
  35. package/vendor/tdai/core/scene/scene-navigation.js +60 -0
  36. package/vendor/tdai/core/seed/input.js +404 -0
  37. package/vendor/tdai/core/seed/seed-runtime.js +314 -0
  38. package/vendor/tdai/core/seed/types.js +9 -0
  39. package/vendor/tdai/core/store/bm25-client.js +117 -0
  40. package/vendor/tdai/core/store/bm25-local.js +68 -0
  41. package/vendor/tdai/core/store/embedding.js +632 -0
  42. package/vendor/tdai/core/store/factory.js +95 -0
  43. package/vendor/tdai/core/store/search-utils.js +54 -0
  44. package/vendor/tdai/core/store/sqlite.js +1822 -0
  45. package/vendor/tdai/core/store/tcvdb-client.js +221 -0
  46. package/vendor/tdai/core/store/tcvdb.js +1047 -0
  47. package/vendor/tdai/core/store/types.js +17 -0
  48. package/vendor/tdai/core/tdai-core.js +446 -0
  49. package/vendor/tdai/core/tools/conversation-search.js +199 -0
  50. package/vendor/tdai/core/tools/memory-search.js +207 -0
  51. package/vendor/tdai/core/types.js +12 -0
  52. package/vendor/tdai/utils/backup.js +189 -0
  53. package/vendor/tdai/utils/checkpoint.js +356 -0
  54. package/vendor/tdai/utils/clean-context-runner.js +472 -0
  55. package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
  56. package/vendor/tdai/utils/env.js +13 -0
  57. package/vendor/tdai/utils/managed-timer.js +127 -0
  58. package/vendor/tdai/utils/manifest.js +91 -0
  59. package/vendor/tdai/utils/memory-cleaner.js +300 -0
  60. package/vendor/tdai/utils/no-think-fetch.js +109 -0
  61. package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
  62. package/vendor/tdai/utils/pipeline-factory.js +517 -0
  63. package/vendor/tdai/utils/pipeline-manager.js +892 -0
  64. package/vendor/tdai/utils/sanitize.js +360 -0
  65. package/vendor/tdai/utils/serial-queue.js +103 -0
  66. package/vendor/tdai/utils/session-filter.js +88 -0
  67. package/vendor/tdai/utils/text-utils.js +29 -0
  68. package/vendor/tdai/utils/time.js +252 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * ensure-hook-policy.ts
3
+ *
4
+ * Auto-patches openclaw.json to add `hooks.allowConversationAccess: true`
5
+ * for our plugin. Without it, the gateway silently blocks agent_end hooks
6
+ * for non-bundled plugins (v2026.4.23+, PR #70786).
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { getEnv } from "./env.js";
11
+ import JSON5 from "json5";
12
+ const PLUGIN_ID = "memory-tencentdb";
13
+ /**
14
+ * Minimum host version at which `hooks.allowConversationAccess` is both
15
+ * recognised by the schema and enforced. See header comment.
16
+ */
17
+ export const HOOK_POLICY_MIN_VERSION = [
18
+ 2026, 4, 24,
19
+ ];
20
+ /**
21
+ * Parse the leading `x.y.z` numeric prefix from a version string.
22
+ *
23
+ * Accepts:
24
+ * "2026.4.24" -> [2026, 4, 24]
25
+ * "2026.4.24-beta.1" -> [2026, 4, 24]
26
+ * "2026.5.3-1" -> [2026, 5, 3]
27
+ * "2026.4.24.4" -> [2026, 4, 24] (extra segments ignored)
28
+ *
29
+ * Rejects (returns null):
30
+ * - Non-string values (undefined / null / number / etc.)
31
+ * - "unknown" / "" (no clean numeric prefix)
32
+ * - "2026.4" (must have all three segments)
33
+ * - "v2026.4.24" (no leading non-digit allowed — keep strict)
34
+ */
35
+ export function parseVersionXYZ(v) {
36
+ if (typeof v !== "string") {
37
+ return null;
38
+ }
39
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:[-.].*)?$/);
40
+ if (!m) {
41
+ return null;
42
+ }
43
+ const [, a, b, c] = m;
44
+ return [Number(a), Number(b), Number(c)];
45
+ }
46
+ /**
47
+ * Compare two `[x, y, z]` tuples. Returns negative / 0 / positive like a
48
+ * standard comparator (a - b).
49
+ */
50
+ export function compareVersionXYZ(a, b) {
51
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
52
+ }
53
+ /**
54
+ * Decide whether we should apply the `allowConversationAccess` auto-patch
55
+ * for the given host version, returning a structured result that callers
56
+ * can log verbatim.
57
+ *
58
+ * Policy:
59
+ * - Extract the leading `x.y.z` prefix from `rawVersion` (ignoring any
60
+ * pre-release suffix like `-beta.N`, `-1`, `-alpha.N`, etc.).
61
+ * - If the prefix is >= {@link HOOK_POLICY_MIN_VERSION}, `apply = true`.
62
+ * - If the prefix cannot be parsed (unknown / empty / non-string /
63
+ * undefined — typical on hosts that don't expose `api.runtime.version`),
64
+ * `apply = false`. This is the safe default: old hosts don't have the
65
+ * gate and don't need patching.
66
+ *
67
+ * NOTE: Very early pre-releases of the MIN version itself (e.g.
68
+ * `2026.4.24-beta.1`) will satisfy the predicate. This is intentional —
69
+ * the field was already recognised in those builds and the usage base is
70
+ * negligible.
71
+ */
72
+ export function decideHookPolicy(rawVersion) {
73
+ const parsedXYZ = parseVersionXYZ(rawVersion);
74
+ const apply = parsedXYZ !== null &&
75
+ compareVersionXYZ(parsedXYZ, HOOK_POLICY_MIN_VERSION) >= 0;
76
+ return {
77
+ apply,
78
+ rawVersion,
79
+ parsedXYZ,
80
+ minXYZ: HOOK_POLICY_MIN_VERSION,
81
+ };
82
+ }
83
+ /**
84
+ * Thin boolean wrapper around {@link decideHookPolicy} for callers that
85
+ * only need the yes/no answer.
86
+ */
87
+ export function shouldApplyHookPolicy(rawVersion) {
88
+ return decideHookPolicy(rawVersion).apply;
89
+ }
90
+ function isObj(v) {
91
+ return v != null && typeof v === "object" && !Array.isArray(v);
92
+ }
93
+ function isGatewayStart() {
94
+ const args = process.argv.map((v) => String(v || "").toLowerCase());
95
+ const idx = args.findIndex((a) => a.endsWith("openclaw") || a.endsWith("openclaw.mjs") || a.endsWith("entry.js"));
96
+ if (idx < 0)
97
+ return true;
98
+ const cmd = args.slice(idx + 1).filter((a) => !a.startsWith("-"))[0];
99
+ if (!cmd)
100
+ return true;
101
+ const skip = ["plugins", "plugin", "install", "uninstall", "update", "doctor", "security", "config", "onboard", "setup", "status", "version", "help"];
102
+ return !skip.includes(cmd);
103
+ }
104
+ function resolveConfigPath() {
105
+ // 1. OPENCLAW_CONFIG_PATH env override (same as core uses)
106
+ const envPath = getEnv("OPENCLAW_CONFIG_PATH")?.trim();
107
+ if (envPath && fs.existsSync(envPath))
108
+ return envPath;
109
+ // 2. OPENCLAW_STATE_DIR override
110
+ const stateDir = getEnv("OPENCLAW_STATE_DIR")?.trim();
111
+ if (stateDir) {
112
+ const p = path.join(stateDir, "openclaw.json");
113
+ if (fs.existsSync(p))
114
+ return p;
115
+ }
116
+ // 3. Standard location: ~/.openclaw/openclaw.json
117
+ const home = getEnv("HOME") ?? getEnv("USERPROFILE") ?? "";
118
+ if (!home)
119
+ return null;
120
+ const p = path.join(home, ".openclaw", "openclaw.json");
121
+ return fs.existsSync(p) ? p : null;
122
+ }
123
+ function hasPolicyAlready(root) {
124
+ if (!isObj(root))
125
+ return false;
126
+ const entry = root?.plugins?.entries?.[PLUGIN_ID];
127
+ return isObj(entry) && isObj(entry.hooks) && entry.hooks.allowConversationAccess === true;
128
+ }
129
+ /**
130
+ * Call early in register(). Patches config if missing, triggers restart.
131
+ *
132
+ * Strategy:
133
+ * 1. Try SDK mutateConfigFile (handles path resolution, $include, atomic write,
134
+ * and triggers gateway restart via afterWrite).
135
+ * 2. Fallback to manual file write if SDK is unavailable or fails.
136
+ */
137
+ export function ensurePluginHookPolicy(params) {
138
+ const { logger } = params;
139
+ const TAG = "[memory-tdai] [hook-policy]";
140
+ if (!isGatewayStart())
141
+ return;
142
+ if (hasPolicyAlready(params.rootConfig))
143
+ return;
144
+ // Try SDK path first (handles everything + triggers restart)
145
+ if (params.runtimeConfig?.mutateConfigFile) {
146
+ logger.info(`${TAG} Missing allowConversationAccess, patching via SDK...`);
147
+ params.runtimeConfig.mutateConfigFile({
148
+ afterWrite: { mode: "restart", reason: "memory-tencentdb hook policy auto-patch" },
149
+ mutate: (draft) => {
150
+ if (!draft.plugins)
151
+ draft.plugins = {};
152
+ if (!draft.plugins.entries)
153
+ draft.plugins.entries = {};
154
+ if (!draft.plugins.entries[PLUGIN_ID])
155
+ draft.plugins.entries[PLUGIN_ID] = {};
156
+ if (!draft.plugins.entries[PLUGIN_ID].hooks)
157
+ draft.plugins.entries[PLUGIN_ID].hooks = {};
158
+ draft.plugins.entries[PLUGIN_ID].hooks.allowConversationAccess = true;
159
+ },
160
+ }).then(() => {
161
+ logger.info(`${TAG} ✅ Patched via SDK — gateway will restart automatically.`);
162
+ }).catch((err) => {
163
+ logger.warn(`${TAG} SDK mutateConfigFile failed: ${err instanceof Error ? err.message : String(err)}, trying manual fallback...`);
164
+ manualPatch(logger);
165
+ });
166
+ return;
167
+ }
168
+ // Fallback: manual file write
169
+ manualPatch(logger);
170
+ }
171
+ function manualPatch(logger) {
172
+ const TAG = "[memory-tdai] [hook-policy]";
173
+ const configPath = resolveConfigPath();
174
+ if (!configPath) {
175
+ logger.warn(`${TAG} Cannot locate openclaw.json — please add hooks.allowConversationAccess manually`);
176
+ return;
177
+ }
178
+ let parsed;
179
+ try {
180
+ const raw = fs.readFileSync(configPath, "utf-8");
181
+ parsed = JSON5.parse(raw);
182
+ }
183
+ catch {
184
+ logger.warn(`${TAG} Failed to parse ${configPath} — please add hooks.allowConversationAccess manually`);
185
+ return;
186
+ }
187
+ if (hasPolicyAlready(parsed))
188
+ return;
189
+ if ("$include" in parsed || (isObj(parsed.plugins) && "$include" in parsed.plugins)) {
190
+ logger.warn(`${TAG} Config uses $include — please add manually: plugins.entries.${PLUGIN_ID}.hooks.allowConversationAccess = true`);
191
+ return;
192
+ }
193
+ if (!isObj(parsed.plugins))
194
+ parsed.plugins = {};
195
+ const plugins = parsed.plugins;
196
+ if (!isObj(plugins.entries))
197
+ plugins.entries = {};
198
+ const entries = plugins.entries;
199
+ if (!isObj(entries[PLUGIN_ID]))
200
+ entries[PLUGIN_ID] = {};
201
+ const entry = entries[PLUGIN_ID];
202
+ if (!isObj(entry.hooks))
203
+ entry.hooks = {};
204
+ entry.hooks.allowConversationAccess = true;
205
+ try {
206
+ fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2) + "\n");
207
+ logger.info(`${TAG} ✅ Auto-added hooks.allowConversationAccess to ${configPath}`);
208
+ logger.warn(`${TAG} ⚠️ Gateway restart required. Run: openclaw gateway restart`);
209
+ }
210
+ catch (err) {
211
+ logger.warn(`${TAG} Failed to write ${configPath}: ${err instanceof Error ? err.message : String(err)}. Add manually.`);
212
+ }
213
+ }