diff --git a/dist/index.js b/dist/index.js index 1b265f45..569bd0b9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -9,7 +9,7 @@ import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; -import { spawn } from "node:child_process"; +import { AsyncLocalStorage } from "node:async_hooks"; // Detect CLI mode: when running as a CLI subcommand (e.g. `openclaw memory-pro stats`), // OpenClaw sets OPENCLAW_CLI=1 in the process environment. Registration and // lifecycle logs are noisy in CLI context (printed to stderr before command output), @@ -474,22 +474,37 @@ export function getExtensionApiImportSpecifiers(options = {}) { return [...new Set(specifiers.filter(Boolean))]; } /** - * Layer 1: 新 SDK API — api.runtime.agent.runEmbeddedPiAgent (4.22+) + * Layer 1: SDK API — api.runtime.agent.runEmbeddedAgent (hosts before the + * rename expose runEmbeddedPiAgent; both names are accepted) * Layer 2: 舊 extensionAPI.js dynamic import(4.24-4.26 SDK 仍保留) * Layer 3: CLI fallback * * 遷移自 Bug 2(Issue #606):原本只使用 Layer 2,現改為 Try-New-First。 */ +const EMBEDDED_RUNNER_EXPORT_NAMES = ["runEmbeddedAgent", "runEmbeddedPiAgent"]; +export function resolveEmbeddedRunnerExportName(candidate) { + if (!candidate || typeof candidate !== "object") + return undefined; + const record = candidate; + return EMBEDDED_RUNNER_EXPORT_NAMES.find((name) => typeof record[name] === "function"); +} +let resolvedEmbeddedRunnerKind; +export function getEmbeddedRunnerExportName() { + return resolvedEmbeddedRunnerKind; +} +// The runner and its kind are cached together: a host surface seen later must +// not relabel an already cached runner (a legacy runner needs the transcript +// file, a current one refuses it). // eslint-disable-next-line import/export export async function loadEmbeddedPiRunner(api) { // Layer 1: 嘗試新 SDK API (with circuit breaker) - if (!isLayer1CircuitOpen()) { + if (!embeddedPiRunnerPromise && !isLayer1CircuitOpen()) { const newApi = (api.runtime?.agent); - if (typeof newApi?.runEmbeddedPiAgent === "function") { - const runner = newApi.runEmbeddedPiAgent.bind(newApi); - // Bug 2 fix: 將 Layer 1 結果寫入 cache,避免後續並發呼叫時 Layer 2 覆蓋掉 Layer 1 - embeddedPiRunnerPromise ??= Promise.resolve(runner); - return embeddedPiRunnerPromise; + const runnerName = resolveEmbeddedRunnerExportName(newApi); + if (newApi && runnerName) { + const runner = newApi[runnerName].bind(newApi); + resolvedEmbeddedRunnerKind = runnerName; + embeddedPiRunnerPromise = Promise.resolve({ runner, exportName: runnerName }); } } // Layer 2: Fallback 舊 extensionAPI.js @@ -499,10 +514,12 @@ export async function loadEmbeddedPiRunner(api) { for (const specifier of getExtensionApiImportSpecifiers()) { try { const mod = await import(specifier); - const runner = mod.runEmbeddedPiAgent; - if (typeof runner === "function") - return runner; - importErrors.push(`${specifier}: runEmbeddedPiAgent export not found`); + const runnerName = resolveEmbeddedRunnerExportName(mod); + if (runnerName) { + resolvedEmbeddedRunnerKind = runnerName; + return { runner: mod[runnerName], exportName: runnerName }; + } + importErrors.push(`${specifier}: runEmbeddedAgent export not found`); } catch (err) { importErrors.push(`${specifier}: ${err instanceof Error ? err.message : String(err)}`); @@ -519,15 +536,10 @@ export async function loadEmbeddedPiRunner(api) { } catch (err) { embeddedPiRunnerPromise = null; + resolvedEmbeddedRunnerKind = undefined; throw err; } } -function clipDiagnostic(text, maxLen = 400) { - const oneLine = text.replace(/\s+/g, " ").trim(); - if (oneLine.length <= maxLen) - return oneLine; - return `${oneLine.slice(0, maxLen - 3)}...`; -} function withTimeout(promise, timeoutMs, label) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -542,139 +554,6 @@ function withTimeout(promise, timeoutMs, label) { }); }); } -function tryParseJsonObject(raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed; - } - } - catch { - // ignore - } - return null; -} -function extractJsonObjectFromOutput(stdout) { - const trimmed = stdout.trim(); - if (!trimmed) - throw new Error("empty stdout"); - const direct = tryParseJsonObject(trimmed); - if (direct) - return direct; - const lines = trimmed.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - if (!lines[i].trim().startsWith("{")) - continue; - const candidate = lines.slice(i).join("\n"); - const parsed = tryParseJsonObject(candidate); - if (parsed) - return parsed; - } - throw new Error(`unable to parse JSON from CLI output: ${clipDiagnostic(trimmed, 280)}`); -} -function extractReflectionTextFromCliResult(resultObj) { - const result = resultObj.result; - const payloads = Array.isArray(resultObj.payloads) - ? resultObj.payloads - : Array.isArray(result?.payloads) - ? result.payloads - : []; - const firstWithText = payloads.find((p) => p && typeof p === "object" && typeof p.text === "string" && p.text.trim().length); - const text = typeof firstWithText?.text === "string" ? firstWithText.text.trim() : ""; - return text || null; -} -async function runReflectionViaCli(params) { - const cliBin = process.env.OPENCLAW_CLI_BIN?.trim() || "openclaw"; - const outerTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); - const agentTimeoutSec = Math.max(1, Math.ceil(params.timeoutMs / 1000)); - const sessionId = `memory-reflection-cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const args = [ - "agent", - "--local", - "--agent", - params.agentId, - "--message", - params.prompt, - "--json", - "--thinking", - params.thinkLevel, - "--timeout", - String(agentTimeoutSec), - "--session-id", - sessionId, - ]; - return await new Promise((resolve, reject) => { - const spawnCommand = buildReflectionCliSpawnCommand(cliBin, args); - const child = spawn(spawnCommand.command, spawnCommand.args, { - cwd: params.workspaceDir, - env: { ...process.env, NO_COLOR: "1" }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => child.kill("SIGKILL"), 1500).unref(); - }, outerTimeoutMs); - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.once("error", (err) => { - if (settled) - return; - settled = true; - clearTimeout(timer); - reject(new Error(`spawn ${cliBin} failed: ${err.message}`)); - }); - child.once("close", (code, signal) => { - if (settled) - return; - settled = true; - clearTimeout(timer); - if (timedOut) { - reject(new Error(`${cliBin} timed out after ${outerTimeoutMs}ms`)); - return; - } - if (signal) { - reject(new Error(`${cliBin} exited by signal ${signal}. stderr=${clipDiagnostic(stderr)}`)); - return; - } - if (code !== 0) { - reject(new Error(`${cliBin} exited with code ${code}. stderr=${clipDiagnostic(stderr)}`)); - return; - } - try { - const parsed = extractJsonObjectFromOutput(stdout); - const text = extractReflectionTextFromCliResult(parsed); - if (!text) { - reject(new Error(`CLI JSON returned no text payload. stdout=${clipDiagnostic(stdout)}`)); - return; - } - resolve(text); - } - catch (err) { - reject(err instanceof Error ? err : new Error(String(err))); - } - }); - }); -} -export function buildReflectionCliSpawnCommand(cliBin, args, platform = process.platform, comSpec = process.env.ComSpec?.trim()) { - if (platform === "win32") { - return { - command: comSpec || "cmd.exe", - args: ["/c", cliBin, ...args], - }; - } - return { command: cliBin, args }; -} async function loadSelfImprovementReminderContent(workspaceDir) { const baseDir = typeof workspaceDir === "string" && workspaceDir.trim().length ? workspaceDir.trim() : ""; if (!baseDir) @@ -1038,6 +917,36 @@ function summarizeRecentConversationMessages(messages, messageCount, format = "t } return formatConversationTranscript(recent); } +const SESSION_MEMORY_RECORD_RE = /^(user|assistant): (".*")$/; +/** + * Hosts on SQLite session storage no longer expose a transcript file to plugins; + * the command:new / command:reset hook context carries the departing session's + * recent messages instead (`previousSessionMemory`, one `role: ""` + * record per line). Parse those records back into turns so the same reflection + * pipeline runs on either host generation. + */ +function conversationFromHookSessionMemory(memory, messageCount, format = "tagged") { + if (!memory || typeof memory !== "object") + return null; + const record = memory; + if (record.status !== "available" || typeof record.content !== "string") + return null; + const messages = []; + for (const line of record.content.split("\n")) { + const matched = line.match(SESSION_MEMORY_RECORD_RE); + if (!matched) + continue; + try { + const text = JSON.parse(matched[2]); + if (typeof text === "string") + messages.push({ role: matched[1], content: text }); + } + catch { + // a malformed record is skipped; the remaining lines still count + } + } + return summarizeRecentConversationMessages(messages, messageCount, format); +} async function readSessionConversationForReflection(filePath, messageCount, format = "tagged") { try { const lines = (await readFile(filePath, "utf-8")).trim().split("\n"); @@ -1279,6 +1188,20 @@ function buildReflectionFallbackText() { "- Investigate why embedded reflection generation failed before trusting any next-run delta.", ].join("\n"); } +// Model resolution chain: explicit param > agent-specific primary model ref > global llm.model. +// Provider: parsed from the ref (e.g. "minimax/MiniMax-M2.7") > inferred from baseURL +// (inferProviderFromBaseURL uses .endsWith(".suffix") to prevent subdomain spoofing). +function resolveReflectionModelTarget(params) { + const cfg = params.cfg; + const llmConfig = cfg?.llm; + const modelRefFromConfig = llmConfig?.model; + const modelRef = params.model + ?? resolveAgentPrimaryModelRef(params.cfg, params.agentId) + ?? (typeof modelRefFromConfig === "string" ? modelRefFromConfig : undefined); + const split = modelRef ? splitProviderModel(modelRef) : { provider: undefined, model: undefined }; + const provider = split.provider ?? inferProviderFromBaseURL(llmConfig?.baseURL); + return { provider, model: split.model }; +} const REFLECTION_RUN_SLOTS = Symbol.for("openclaw.memory-lancedb-pro.reflection-run-slots"); const getReflectionRunSlotState = () => { const g = globalThis; @@ -1333,6 +1256,7 @@ async function generateReflectionTextUnbounded(params) { else params.logger?.info?.(message); }; + const { provider, model } = resolveReflectionModelTarget(params); try { const result = await runWithReflectionTransientRetryOnce({ scope: "reflection", @@ -1340,26 +1264,16 @@ async function generateReflectionTextUnbounded(params) { retryState, onLog: onRetryLog, execute: async () => { - const runEmbeddedPiAgent = await loadEmbeddedPiRunner(params.api); - const cfg = params.cfg; - const llmConfig = cfg?.llm; - const modelRefFromConfig = llmConfig?.model; - // Model resolution chain: agent-specific primary model ref > global llm.model fallback. - // The typeof guard ensures a non-string value (e.g. number) does not reach splitProviderModel as-is. - const modelRef = params.model - ?? resolveAgentPrimaryModelRef(params.cfg, params.agentId) - ?? (typeof modelRefFromConfig === "string" ? modelRefFromConfig : undefined); - // Provider resolution chain: parsed from modelRef (e.g. "minimax/MiniMax-M2.7") > inferred from baseURL. - // inferProviderFromBaseURL uses .endsWith(".suffix") to prevent subdomain spoofing. - const split = modelRef ? splitProviderModel(modelRef) : { provider: undefined, model: undefined }; - const provider = split.provider ?? inferProviderFromBaseURL(llmConfig?.baseURL); - const model = split.model; + const embedded = await loadEmbeddedPiRunner(params.api); + const runEmbeddedPiAgent = embedded.runner; const embeddedTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); return await withTimeout(runEmbeddedPiAgent({ sessionId: `reflection-${Date.now()}`, sessionKey: `temp:memory-reflection:${params.agentId}`, + // The distiller run is throwaway: keep it out of the host session store. + sessionPersistence: "detached", agentId: params.agentId, - sessionFile: tempSessionFile, + ...(embedded.exportName !== "runEmbeddedAgent" ? { sessionFile: tempSessionFile } : {}), workspaceDir: params.workspaceDir, config: params.cfg, prompt, @@ -1405,23 +1319,28 @@ async function generateReflectionTextUnbounded(params) { if (reflectionText) { return { text: reflectionText, usedFallback: false, promptHash, error: errors[0], runner: "embedded" }; } - try { - reflectionText = await runWithReflectionTransientRetryOnce({ - scope: "reflection", - runner: "cli", - retryState, - onLog: onRetryLog, - execute: async () => await runReflectionViaCli({ - prompt, - agentId: params.agentId, - workspaceDir: params.workspaceDir, - timeoutMs: params.timeoutMs, - thinkLevel: params.thinkLevel, - }), - }); + if (params.completeText) { + const completeText = params.completeText; + try { + reflectionText = await runWithReflectionTransientRetryOnce({ + scope: "reflection", + runner: "completion", + retryState, + onLog: onRetryLog, + execute: async () => { + const text = await completeText(reflectionSystemPrompt, reflectionUserPrompt); + if (!text) + throw new Error("completion returned no text"); + return text; + }, + }); + } + catch (err) { + errors.push(`completion: ${err instanceof Error ? err.message : String(err)}`); + } } - catch (err) { - errors.push(`cli: ${err instanceof Error ? err.message : String(err)}`); + else { + errors.push("completion: no tool-free completion client on this host"); } if (reflectionText) { return { @@ -1429,7 +1348,7 @@ async function generateReflectionTextUnbounded(params) { usedFallback: false, promptHash, error: errors.length > 0 ? errors.join(" | ") : undefined, - runner: "cli", + runner: "completion", }; } return { @@ -2096,6 +2015,10 @@ function _initPluginState(api) { const captureAdmissionController = () => admissionController; const captureAdmissionAudit = () => admissionController !== null && config.admissionControl?.auditMetadata !== false; const captureReflectionAdmissionController = () => admissionControllerReflectionLane; + const makeLaneLlmClient = (model, thinkLevel, modelExplicit) => { + const { makeClientForModel, llmModelExplicit } = buildMemoryLlmClient(); + return makeClientForModel(model, thinkLevel, modelExplicit ?? llmModelExplicit); + }; const extractionRateLimiter = createExtractionRateLimiter({ maxExtractionsPerHour: config.extractionThrottle?.maxExtractionsPerHour, }); @@ -2153,6 +2076,7 @@ function _initPluginState(api) { captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, + makeLaneLlmClient, admissionRejectionAuditWriter, }; } @@ -2262,7 +2186,7 @@ const memoryLanceDBProPlugin = { _registeredApisMap.delete(api); // dual-track rollback: Map un-claim throw err; } - const { config, resolvedDbPath, vectorDim, store, embedder, retriever, canonicalCorpusIndexer, dreamingEngine, dreamingScheduler, scopeManager, migrator, smartExtractor, manualEchoLedger, mdMirror, decayEngine, tierManager, extractionRateLimiter, reflectionErrorStateBySession, reflectionDerivedBySession, reflectionDerivedSuppressionBySession, reflectionByAgentCache, reflectionByAgentCacheGeneration, recallHistory, turnCounter, autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureCountedPendingCount, autoCaptureRecentTurns, autoCaptureDeferredFlushTurns, autoCaptureSessionIdToKey, autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, admissionRejectionAuditWriter, } = singleton; + const { config, resolvedDbPath, vectorDim, store, embedder, retriever, canonicalCorpusIndexer, dreamingEngine, dreamingScheduler, scopeManager, migrator, smartExtractor, manualEchoLedger, mdMirror, decayEngine, tierManager, extractionRateLimiter, reflectionErrorStateBySession, reflectionDerivedBySession, reflectionDerivedSuppressionBySession, reflectionByAgentCache, reflectionByAgentCacheGeneration, recallHistory, turnCounter, autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureCountedPendingCount, autoCaptureRecentTurns, autoCaptureDeferredFlushTurns, autoCaptureSessionIdToKey, autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, makeLaneLlmClient, admissionRejectionAuditWriter, } = singleton; const learnAutoCaptureSessionAlias = (sessionId, sessionKey) => { if (typeof sessionId !== "string" || !sessionId || typeof sessionKey !== "string" || !sessionKey @@ -4295,6 +4219,27 @@ const memoryLanceDBProPlugin = { const reflectionMaxConcurrentRuns = config.memoryReflection?.maxConcurrentRuns ?? DEFAULT_REFLECTION_MAX_CONCURRENT_RUNS; const reflectionAgentId = asNonEmptyString(config.memoryReflection?.agentId); const reflectionModel = asNonEmptyString(config.memoryReflection?.model); + // Tool-free fallback for the distiller when the embedded runner is + // unavailable: a plain completion on the plugin's own LLM lane, so the + // transcript never reaches an agent turn that could invoke tools. + let reflectionCompletionClient; + const reflectionCompleteText = async (systemPrompt, userPrompt) => { + if (reflectionCompletionClient === undefined) { + const model = reflectionModel ?? asNonEmptyString(config.llm?.model); + try { + reflectionCompletionClient = model + ? makeLaneLlmClient(config.llm?.transport === "host" ? model.trim() : normalizeDirectModelRef(model), reflectionThinkLevel, reflectionModel ? true : undefined) + : null; + } + catch (err) { + api.logger.warn(`memory-reflection: completion fallback unavailable: ${err instanceof Error ? err.message : String(err)}`); + reflectionCompletionClient = null; + } + } + if (!reflectionCompletionClient) + return null; + return reflectionCompletionClient.completeText(userPrompt, "memory-reflection", systemPrompt); + }; const reflectionErrorReminderMaxEntries = parsePositiveInt(config.memoryReflection?.errorReminderMaxEntries) ?? DEFAULT_REFLECTION_ERROR_REMINDER_MAX_ENTRIES; const reflectionDedupeErrorSignals = config.memoryReflection?.dedupeErrorSignals !== false; const reflectionInjectMode = config.memoryReflection?.injectMode ?? "inheritance+derived"; @@ -4483,15 +4428,44 @@ const memoryLanceDBProPlugin = { return g[REFLECTION_SERIAL_GUARD]; }; // SERIAL_GUARD_COOLDOWN_MS moved to DEFAULT_SERIAL_GUARD_COOLDOWN_MS - const runMemoryReflection = async (event) => { + // A command:new / command:reset hook that finds neither a hook transcript nor a + // session file parks here; the typed before_reset hook, which core fires right + // after the command hooks on every command path, carries the departing messages + // and finishes the reflection from this entry. + const REFLECTION_PENDING_BEFORE_RESET_TTL_MS = 60_000; + const pendingBeforeResetReflections = new Map(); + const rememberPendingBeforeResetReflection = (key, entry) => { + const now = Date.now(); + for (const [pendingKey, pending] of pendingBeforeResetReflections) { + if (now - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS) + pendingBeforeResetReflections.delete(pendingKey); + } + pendingBeforeResetReflections.set(key, { ...entry, at: now }); + }; + const takePendingBeforeResetReflection = (key) => { + const pending = pendingBeforeResetReflections.get(key); + if (!pending) + return undefined; + pendingBeforeResetReflections.delete(key); + return Date.now() - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS ? undefined : pending; + }; + // Captured at registration, outside any command's root-work context. The + // before_reset continuation would otherwise inherit the released /new root, + // and core refuses embedded sub-runs from a released root (subordinate work + // admission), which would push every /new reflection to the CLI runner. + const runOutsideCommandRootWork = typeof AsyncLocalStorage.snapshot === "function" + ? AsyncLocalStorage.snapshot() + : (fn) => fn(); + const runMemoryReflectionWith = async (event, options) => { const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : ""; const action = String(event?.action || "unknown"); + const resumedFromBeforeReset = options !== undefined && "beforeResetConversation" in options; // Validate sessionKey BEFORE dedup — invalid/empty keys must NOT pollute the dedup set if (!sessionKey) { // skip events without a valid sessionKey — they are not meaningful for reflection return; } - if (_dedupHookEvent("reflection", event)) + if (!resumedFromBeforeReset && _dedupHookEvent("reflection", event)) return; const context = (event.context || {}); const cfg = context.cfg; @@ -4564,7 +4538,7 @@ const memoryLanceDBProPlugin = { sessionFile: currentSessionFile, }); const guarded = getReflectionEmptyEventGuardMap().get(emptyEventGuardKey); - if (guarded && Date.now() - guarded.updatedAt <= DEFAULT_REFLECTION_EMPTY_EVENT_GUARD_TTL_MS) { + if (!resumedFromBeforeReset && guarded && Date.now() - guarded.updatedAt <= DEFAULT_REFLECTION_EMPTY_EVENT_GUARD_TTL_MS) { api.logger.info(`memory-reflection: command:${action} skipped repeated empty/unusable session; sessionKey=${sessionKey}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}; reason=${guarded.reason}`); return; } @@ -4613,39 +4587,64 @@ const memoryLanceDBProPlugin = { pruneReflectionSessionState(); const workspaceDir = resolveWorkspaceDirFromContext(context); api.logger.info(`memory-reflection: command:${action} hook start; sessionKey=${sessionKey || "(none)"}; source=${commandSource || "(unknown)"}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}`); - if (!currentSessionFile || currentSessionFile.includes(".reset.")) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId, - }); - api.logger.info(`memory-reflection: command:${action} session recovery start for session ${currentSessionId}; initial=${currentSessionFile || "(none)"}; dirs=${searchDirs.join(" | ") || "(none)"}`); - for (const sessionsDir of searchDirs) { - const recovered = await findPreviousSessionFile(sessionsDir, currentSessionFile, currentSessionId); - if (recovered) { - api.logger.info(`memory-reflection: command:${action} recovered session file ${recovered} from ${sessionsDir}`); - currentSessionFile = recovered; - break; + // Hosts with SQLite session storage hand the departing transcript to the + // hook itself; the session-file lookup below is the legacy path. + let conversation = resumedFromBeforeReset + ? options?.beforeResetConversation ?? null + : conversationFromHookSessionMemory(context.previousSessionMemory, reflectionMessageCount); + if (resumedFromBeforeReset) { + api.logger.info(`memory-reflection: command:${action} using the before_reset transcript for session ${currentSessionId}; messages=${conversation ? "present" : "empty"}`); + } + else if (conversation) { + api.logger.info(`memory-reflection: command:${action} using the hook-provided transcript for session ${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}`); + } + else { + if (!currentSessionFile || currentSessionFile.includes(".reset.")) { + const searchDirs = resolveReflectionSessionSearchDirs({ + context, + cfg, + workspaceDir, + currentSessionFile, + sourceAgentId, + }); + api.logger.info(`memory-reflection: command:${action} session recovery start for session ${currentSessionId}; initial=${currentSessionFile || "(none)"}; dirs=${searchDirs.join(" | ") || "(none)"}`); + for (const sessionsDir of searchDirs) { + const recovered = await findPreviousSessionFile(sessionsDir, currentSessionFile, currentSessionId); + if (recovered) { + api.logger.info(`memory-reflection: command:${action} recovered session file ${recovered} from ${sessionsDir}`); + currentSessionFile = recovered; + break; + } } } + if (!currentSessionFile) { + const searchDirs = resolveReflectionSessionSearchDirs({ + context, + cfg, + workspaceDir, + currentSessionFile, + sourceAgentId, + }); + if (isBoundaryAction) { + rememberPendingBeforeResetReflection(sessionKey, { event, sessionId: currentSessionId, action }); + api.logger.info(`memory-reflection: command:${action} no transcript in the hook context or on disk for session ${currentSessionId}; waiting for the typed before_reset messages`); + return; + } + api.logger.warn(`memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}`); + await rememberEmptyReflectionEvent("missing-session-file"); + return; + } + conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); } - if (!currentSessionFile) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId, - }); - api.logger.warn(`memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}`); - await rememberEmptyReflectionEvent("missing-session-file"); - return; - } - const conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); if (!conversation) { - api.logger.warn(`memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile}`); + if (isBoundaryAction && !resumedFromBeforeReset) { + // A stale transcript artifact on a migrated host must not hide the + // messages the typed hook is about to supply. + rememberPendingBeforeResetReflection(sessionKey, { event, sessionId: currentSessionId, action }); + api.logger.info(`memory-reflection: command:${action} transcript ${currentSessionFile || "(none)"} holds no usable conversation for session ${currentSessionId}; waiting for the typed before_reset messages`); + return; + } + api.logger.warn(`memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile || "(none)"}`); await rememberEmptyReflectionEvent("empty-conversation"); return; } @@ -4682,11 +4681,12 @@ const memoryLanceDBProPlugin = { toolErrorSignals, logger: api.logger, api, // SDK migration Bug 2: pass api for new runtime.agent API + completeText: reflectionCompleteText, }); api.logger.info(`memory-reflection: command:${action} reflection generation done for session ${currentSessionId}; runner=${reflectionGenerated.runner}; usedFallback=${reflectionGenerated.usedFallback ? "yes" : "no"}`); const reflectionText = reflectionGenerated.text; - if (reflectionGenerated.runner === "cli") { - api.logger.warn(`memory-reflection: embedded runner unavailable, used openclaw CLI fallback for session ${currentSessionId}` + + if (reflectionGenerated.runner === "completion") { + api.logger.warn(`memory-reflection: embedded runner unavailable, used the tool-free completion fallback for session ${currentSessionId}` + (reflectionGenerated.error ? ` (${reflectionGenerated.error})` : "")); } else if (reflectionGenerated.usedFallback) { @@ -5016,6 +5016,23 @@ const memoryLanceDBProPlugin = { pruneReflectionSessionState(); } }; + const runMemoryReflection = async (event) => runMemoryReflectionWith(event); + const runMemoryReflectionFromBeforeReset = async (event, ctx) => { + const reason = getCommandActionName(event?.reason); + if (reason !== "new" && reason !== "reset") + return; + const sessionKey = typeof ctx?.sessionKey === "string" ? ctx.sessionKey : ""; + if (!sessionKey) + return; + const pending = takePendingBeforeResetReflection(sessionKey); + if (!pending) + return; + const conversation = summarizeRecentConversationMessages(Array.isArray(event?.messages) ? event.messages : [], reflectionMessageCount); + // The command hook that parked this entry ran no reflection, so its serial-guard + // stamp must not count against the continuation. + getSerialGuardMap().delete(sessionKey); + await runOutsideCommandRootWork(() => runMemoryReflectionWith(pending.event, { beforeResetConversation: conversation })); + }; api.registerHook("command:new", runMemoryReflection, { name: "memory-lancedb-pro.memory-reflection.command-new", description: "Generate reflection log before /new", @@ -5024,7 +5041,8 @@ const memoryLanceDBProPlugin = { name: "memory-lancedb-pro.memory-reflection.command-reset", description: "Generate reflection log before /reset", }); - (isCliMode() ? api.logger.debug : api.logger.info)("memory-reflection: integrated hooks registered (command:new, command:reset, after_tool_call, before_prompt_build, session_end)"); + api.on("before_reset", runMemoryReflectionFromBeforeReset); + (isCliMode() ? api.logger.debug : api.logger.info)("memory-reflection: integrated hooks registered (command:new, command:reset, before_reset, after_tool_call, before_prompt_build, session_end)"); } if (config.sessionStrategy === "systemSessionMemory") { const sessionMessageCount = config.sessionMemory?.messageCount ?? 15; diff --git a/dist/src/llm-client.js b/dist/src/llm-client.js index ef07d62e..3e3b9bc4 100644 --- a/dist/src/llm-client.js +++ b/dist/src/llm-client.js @@ -293,6 +293,36 @@ function createHostClient(config, runtimeLlmComplete, log, warnLog) { return null; } }, + async completeText(prompt, label = "generic", systemPrompt, temperature) { + lastError = null; + const messages = []; + if (systemPrompt !== undefined) + messages.push({ role: "system", content: systemPrompt }); + messages.push({ role: "user", content: prompt }); + try { + const result = await raceWithTimeout(runtimeLlmComplete({ + messages, + ...(config.modelExplicit ? { model: config.model } : {}), + temperature: temperature ?? 0.1, + purpose: `memory-lancedb-pro:${label}`, + reasoning: config.thinkLevel?.trim() || DEFAULT_HOST_REASONING_EFFORT, + }), config.timeoutMs); + const text = typeof result?.text === "string" ? result.text.trim() : ""; + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty host-transport response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } + catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] host-transport request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError() { return lastError; }, @@ -394,6 +424,40 @@ function createApiKeyClient(config, log, warnLog) { return null; } }, + async completeText(prompt, label = "generic", systemPrompt, temperature) { + lastError = null; + try { + const request = { + model: config.model, + messages: [ + ...(systemPrompt !== undefined ? [{ role: "system", content: systemPrompt }] : []), + { role: "user", content: prompt }, + ], + temperature: temperature ?? 0.1, + ...(config.thinkLevel?.trim() + ? { reasoning: { effort: config.thinkLevel.trim() } } + : {}), + }; + const response = await client.chat.completions.create(request, { + headers: { "x-memory-call-label": sanitizeLabelHeader(label) }, + }); + const raw = response.choices?.[0]?.message?.content; + const text = typeof raw === "string" ? raw.trim() : ""; + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } + catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError() { return lastError; }, @@ -464,26 +528,7 @@ function createOauthClient(config, log, warnLog) { const detail = await response.text().catch(() => ""); throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 500)}`); } - const bodyText = await response.text(); - const raw = (response.headers.get("content-type")?.includes("text/event-stream") || - looksLikeSseResponse(bodyText)) - ? extractOutputTextFromSse(bodyText) - : (() => { - try { - const parsed = JSON.parse(bodyText); - const output = Array.isArray(parsed.output) ? parsed.output : []; - const first = output.find((item) => item && - typeof item === "object" && - Array.isArray(item.content)); - if (!first) - return null; - const content = first.content.find((part) => part?.type === "output_text" && typeof part.text === "string"); - return typeof content?.text === "string" ? content.text : null; - } - catch { - return null; - } - })(); + const raw = extractOauthOutputText(response, await response.text()); if (!raw) { lastError = `memory-lancedb-pro: llm-client [${label}] empty OAuth response content from model ${config.model}`; @@ -532,11 +577,79 @@ function createOauthClient(config, log, warnLog) { return null; } }, + async completeText(prompt, label = "generic", systemPrompt, _temperature) { + lastError = null; + try { + const session = await getSession(); + const { signal, dispose } = createTimeoutSignal(config.timeoutMs); + const endpoint = buildOauthEndpoint(config.baseURL, config.oauthProvider); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${session.accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + "OpenAI-Beta": "responses=experimental", + "chatgpt-account-id": session.accountId, + originator: "codex_cli_rs", + }, + signal, + body: JSON.stringify({ + model: normalizeOauthModel(config.model), + ...(systemPrompt !== undefined ? { instructions: systemPrompt } : {}), + input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], + store: false, + stream: true, + text: { format: { type: "text" } }, + }), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 500)}`); + } + const text = (extractOauthOutputText(response, await response.text()) ?? "").trim(); + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty OAuth response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } + finally { + dispose(); + } + } + catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] OAuth request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError() { return lastError; }, }; } +function extractOauthOutputText(response, bodyText) { + if (response.headers.get("content-type")?.includes("text/event-stream") || looksLikeSseResponse(bodyText)) { + return extractOutputTextFromSse(bodyText); + } + try { + const parsed = JSON.parse(bodyText); + const output = Array.isArray(parsed.output) ? parsed.output : []; + const first = output.find((item) => item && typeof item === "object" && Array.isArray(item.content)); + if (!first) + return null; + const content = first.content.find((part) => part?.type === "output_text" && typeof part.text === "string"); + return typeof content?.text === "string" ? content.text : null; + } + catch { + return null; + } +} /** OpenRouter's direct API base URL, used as the host->direct fallback's default when llm.baseURL is not configured. */ // Module-level (not per-client) so the "runtime surface unavailable" // warning is emitted once per process even though createLlmClient is diff --git a/dist/src/session-recovery.js b/dist/src/session-recovery.js index ee34a3b2..89f21d0a 100644 --- a/dist/src/session-recovery.js +++ b/dist/src/session-recovery.js @@ -29,27 +29,46 @@ function deriveOpenClawHomeFromSessionFilePath(sessionFilePath) { const home = matched[1].trim(); return home.length ? home : undefined; } -function listConfiguredAgentIds(cfg) { +/** + * Agent definitions come as `agents.list` (an array of `{id, workspace}`) on + * older hosts and as `agents.entries` (an object keyed by agent id) on newer + * ones; both shapes are read so neither generation loses its sessions dirs. + */ +function listConfiguredAgents(cfg) { try { const root = cfg; const agents = root.agents; + const out = []; const list = agents?.list; - if (!Array.isArray(list)) - return []; - const ids = []; - for (const item of list) { - if (!item || typeof item !== "object") - continue; - const id = asNonEmptyString(item.id); - if (id) - ids.push(id); + if (Array.isArray(list)) { + for (const item of list) { + if (!item || typeof item !== "object") + continue; + const record = item; + out.push({ id: asNonEmptyString(record.id), workspace: asNonEmptyString(record.workspace) }); + } + } + const entries = agents?.entries; + if (entries && typeof entries === "object" && !Array.isArray(entries)) { + for (const [key, item] of Object.entries(entries)) { + const record = item && typeof item === "object" ? item : {}; + out.push({ id: asNonEmptyString(record.id) ?? asNonEmptyString(key), workspace: asNonEmptyString(record.workspace) }); + } } - return ids; + return out; } catch { return []; } } +function listConfiguredAgentIds(cfg) { + const ids = []; + for (const agent of listConfiguredAgents(cfg)) { + if (agent.id && !ids.includes(agent.id)) + ids.push(agent.id); + } + return ids; +} export function resolveReflectionSessionSearchDirs(params) { const out = []; const seen = new Set(); @@ -103,15 +122,9 @@ export function resolveReflectionSessionSearchDirs(params) { const defaultWorkspace = asNonEmptyString(defaults?.workspace); if (defaultWorkspace) addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(defaultWorkspace)); - const list = agents?.list; - if (Array.isArray(list)) { - for (const item of list) { - if (!item || typeof item !== "object") - continue; - const workspace = asNonEmptyString(item.workspace); - if (workspace) - addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(workspace)); - } + for (const agent of listConfiguredAgents(params.cfg)) { + if (agent.workspace) + addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(agent.workspace)); } } catch { diff --git a/index.ts b/index.ts index d9d30616..7983ea29 100644 --- a/index.ts +++ b/index.ts @@ -11,7 +11,7 @@ import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; -import { spawn } from "node:child_process"; +import { AsyncLocalStorage } from "node:async_hooks"; // Detect CLI mode: when running as a CLI subcommand (e.g. `openclaw memory-pro stats`), // OpenClaw sets OPENCLAW_CLI=1 in the process environment. Registration and @@ -87,7 +87,7 @@ import { SmartExtractor, createExtractionRateLimiter, stripEnvelopeMetadata } fr import { compressTexts, estimateConversationValue } from "./src/session-compressor.js"; import { NoisePrototypeBank } from "./src/noise-prototypes.js"; import { createLlmClient, normalizeDirectModelRef } from "./src/llm-client.js"; -import type { RuntimeLlmCompleteFn } from "./src/llm-client.js"; +import type { LlmClient, RuntimeLlmCompleteFn } from "./src/llm-client.js"; import { createDecayEngine, DEFAULT_DECAY_CONFIG } from "./src/decay-engine.js"; import { createTierManager, DEFAULT_TIER_CONFIG } from "./src/tier-manager.js"; import { createMemoryUpgrader } from "./src/memory-upgrader.js"; @@ -793,7 +793,8 @@ type ReflectionEmptyEventGuardEntry = { type EmbeddedPiRunner = (params: Record) => Promise; const requireFromHere = createRequire(import.meta.url); -let embeddedPiRunnerPromise: Promise | null = null; +type ResolvedEmbeddedRunner = { runner: EmbeddedPiRunner; exportName: EmbeddedRunnerExportName }; +let embeddedPiRunnerPromise: Promise | null = null; // Circuit breaker for Layer 1: after 3 consecutive failures within 5min, skip Layer 1 const layer1FailureTimestamps: number[] = []; @@ -896,22 +897,41 @@ export function getExtensionApiImportSpecifiers( } /** - * Layer 1: 新 SDK API — api.runtime.agent.runEmbeddedPiAgent (4.22+) + * Layer 1: SDK API — api.runtime.agent.runEmbeddedAgent (hosts before the + * rename expose runEmbeddedPiAgent; both names are accepted) * Layer 2: 舊 extensionAPI.js dynamic import(4.24-4.26 SDK 仍保留) * Layer 3: CLI fallback * * 遷移自 Bug 2(Issue #606):原本只使用 Layer 2,現改為 Try-New-First。 */ +const EMBEDDED_RUNNER_EXPORT_NAMES = ["runEmbeddedAgent", "runEmbeddedPiAgent"] as const; +type EmbeddedRunnerExportName = (typeof EMBEDDED_RUNNER_EXPORT_NAMES)[number]; + +export function resolveEmbeddedRunnerExportName(candidate: unknown): EmbeddedRunnerExportName | undefined { + if (!candidate || typeof candidate !== "object") return undefined; + const record = candidate as Record; + return EMBEDDED_RUNNER_EXPORT_NAMES.find((name) => typeof record[name] === "function"); +} + +let resolvedEmbeddedRunnerKind: EmbeddedRunnerExportName | undefined; + +export function getEmbeddedRunnerExportName(): EmbeddedRunnerExportName | undefined { + return resolvedEmbeddedRunnerKind; +} + +// The runner and its kind are cached together: a host surface seen later must +// not relabel an already cached runner (a legacy runner needs the transcript +// file, a current one refuses it). // eslint-disable-next-line import/export -export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise { +export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise { // Layer 1: 嘗試新 SDK API (with circuit breaker) - if (!isLayer1CircuitOpen()) { + if (!embeddedPiRunnerPromise && !isLayer1CircuitOpen()) { const newApi = ((api as unknown as { runtime?: { agent?: Record } }).runtime?.agent); - if (typeof newApi?.runEmbeddedPiAgent === "function") { - const runner = newApi.runEmbeddedPiAgent.bind(newApi); - // Bug 2 fix: 將 Layer 1 結果寫入 cache,避免後續並發呼叫時 Layer 2 覆蓋掉 Layer 1 - embeddedPiRunnerPromise ??= Promise.resolve(runner as EmbeddedPiRunner); - return embeddedPiRunnerPromise; + const runnerName = resolveEmbeddedRunnerExportName(newApi); + if (newApi && runnerName) { + const runner = (newApi[runnerName] as EmbeddedPiRunner).bind(newApi); + resolvedEmbeddedRunnerKind = runnerName; + embeddedPiRunnerPromise = Promise.resolve({ runner, exportName: runnerName }); } } @@ -922,9 +942,12 @@ export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise).runEmbeddedPiAgent; - if (typeof runner === "function") return runner as EmbeddedPiRunner; - importErrors.push(`${specifier}: runEmbeddedPiAgent export not found`); + const runnerName = resolveEmbeddedRunnerExportName(mod); + if (runnerName) { + resolvedEmbeddedRunnerKind = runnerName; + return { runner: (mod as Record)[runnerName] as EmbeddedPiRunner, exportName: runnerName }; + } + importErrors.push(`${specifier}: runEmbeddedAgent export not found`); } catch (err) { importErrors.push(`${specifier}: ${err instanceof Error ? err.message : String(err)}`); } @@ -942,16 +965,11 @@ export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -971,163 +989,6 @@ function withTimeout(promise: Promise, timeoutMs: number, label: string): }); } -function tryParseJsonObject(raw: string): Record | null { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // ignore - } - return null; -} - -function extractJsonObjectFromOutput(stdout: string): Record { - const trimmed = stdout.trim(); - if (!trimmed) throw new Error("empty stdout"); - - const direct = tryParseJsonObject(trimmed); - if (direct) return direct; - - const lines = trimmed.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - if (!lines[i].trim().startsWith("{")) continue; - const candidate = lines.slice(i).join("\n"); - const parsed = tryParseJsonObject(candidate); - if (parsed) return parsed; - } - - throw new Error(`unable to parse JSON from CLI output: ${clipDiagnostic(trimmed, 280)}`); -} - -function extractReflectionTextFromCliResult(resultObj: Record): string | null { - const result = resultObj.result as Record | undefined; - const payloads = Array.isArray(resultObj.payloads) - ? resultObj.payloads - : Array.isArray(result?.payloads) - ? result.payloads - : []; - const firstWithText = payloads.find( - (p) => p && typeof p === "object" && typeof (p as Record).text === "string" && ((p as Record).text as string).trim().length - ) as Record | undefined; - const text = typeof firstWithText?.text === "string" ? firstWithText.text.trim() : ""; - return text || null; -} - -async function runReflectionViaCli(params: { - prompt: string; - agentId: string; - workspaceDir: string; - timeoutMs: number; - thinkLevel: ReflectionThinkLevel; -}): Promise { - const cliBin = process.env.OPENCLAW_CLI_BIN?.trim() || "openclaw"; - const outerTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); - const agentTimeoutSec = Math.max(1, Math.ceil(params.timeoutMs / 1000)); - const sessionId = `memory-reflection-cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const args = [ - "agent", - "--local", - "--agent", - params.agentId, - "--message", - params.prompt, - "--json", - "--thinking", - params.thinkLevel, - "--timeout", - String(agentTimeoutSec), - "--session-id", - sessionId, - ]; - - return await new Promise((resolve, reject) => { - const spawnCommand = buildReflectionCliSpawnCommand(cliBin, args); - const child = spawn(spawnCommand.command, spawnCommand.args, { - cwd: params.workspaceDir, - env: { ...process.env, NO_COLOR: "1" }, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - - const timer = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => child.kill("SIGKILL"), 1500).unref(); - }, outerTimeoutMs); - - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - - child.once("error", (err) => { - if (settled) return; - settled = true; - clearTimeout(timer); - reject(new Error(`spawn ${cliBin} failed: ${err.message}`)); - }); - - child.once("close", (code, signal) => { - if (settled) return; - settled = true; - clearTimeout(timer); - - if (timedOut) { - reject(new Error(`${cliBin} timed out after ${outerTimeoutMs}ms`)); - return; - } - if (signal) { - reject(new Error(`${cliBin} exited by signal ${signal}. stderr=${clipDiagnostic(stderr)}`)); - return; - } - if (code !== 0) { - reject(new Error(`${cliBin} exited with code ${code}. stderr=${clipDiagnostic(stderr)}`)); - return; - } - - try { - const parsed = extractJsonObjectFromOutput(stdout); - const text = extractReflectionTextFromCliResult(parsed); - if (!text) { - reject(new Error(`CLI JSON returned no text payload. stdout=${clipDiagnostic(stdout)}`)); - return; - } - resolve(text); - } catch (err) { - reject(err instanceof Error ? err : new Error(String(err))); - } - }); - }); -} - -export function buildReflectionCliSpawnCommand( - cliBin: string, - args: string[], - platform: NodeJS.Platform = process.platform, - comSpec = process.env.ComSpec?.trim(), -): { command: string; args: string[] } { - if (platform === "win32") { - return { - command: comSpec || "cmd.exe", - args: ["/c", cliBin, ...args], - }; - } - - return { command: cliBin, args }; -} - async function loadSelfImprovementReminderContent(workspaceDir?: string): Promise { const baseDir = typeof workspaceDir === "string" && workspaceDir.trim().length ? workspaceDir.trim() : ""; if (!baseDir) return DEFAULT_SELF_IMPROVEMENT_REMINDER; @@ -1515,6 +1376,37 @@ function summarizeRecentConversationMessages( return formatConversationTranscript(recent); } +const SESSION_MEMORY_RECORD_RE = /^(user|assistant): (".*")$/; + +/** + * Hosts on SQLite session storage no longer expose a transcript file to plugins; + * the command:new / command:reset hook context carries the departing session's + * recent messages instead (`previousSessionMemory`, one `role: ""` + * record per line). Parse those records back into turns so the same reflection + * pipeline runs on either host generation. + */ +function conversationFromHookSessionMemory( + memory: unknown, + messageCount: number, + format: ConversationTranscriptFormat = "tagged", +): string | null { + if (!memory || typeof memory !== "object") return null; + const record = memory as Record; + if (record.status !== "available" || typeof record.content !== "string") return null; + const messages: Array<{ role: string; content: string }> = []; + for (const line of record.content.split("\n")) { + const matched = line.match(SESSION_MEMORY_RECORD_RE); + if (!matched) continue; + try { + const text = JSON.parse(matched[2]); + if (typeof text === "string") messages.push({ role: matched[1], content: text }); + } catch { + // a malformed record is skipped; the remaining lines still count + } + } + return summarizeRecentConversationMessages(messages, messageCount, format); +} + async function readSessionConversationForReflection(filePath: string, messageCount: number, format: ConversationTranscriptFormat = "tagged"): Promise { try { const lines = (await readFile(filePath, "utf-8")).trim().split("\n"); @@ -1780,6 +1672,8 @@ type GenerateReflectionTextParams = { toolErrorSignals?: ReflectionErrorSignal[]; logger?: { info?: (message: string) => void; warn?: (message: string) => void }; api: OpenClawPluginApi; // SDK migration Bug 2: pass api to use new runtime.agent API + /** Tool-free completion used when the embedded runner is unavailable. */ + completeText?: (systemPrompt: string, userPrompt: string) => Promise; }; type GenerateReflectionTextResult = { @@ -1787,9 +1681,27 @@ type GenerateReflectionTextResult = { usedFallback: boolean; promptHash: string; error?: string; - runner: "embedded" | "cli" | "fallback"; + runner: "embedded" | "completion" | "fallback"; }; +// Model resolution chain: explicit param > agent-specific primary model ref > global llm.model. +// Provider: parsed from the ref (e.g. "minimax/MiniMax-M2.7") > inferred from baseURL +// (inferProviderFromBaseURL uses .endsWith(".suffix") to prevent subdomain spoofing). +function resolveReflectionModelTarget( + params: Pick, +): { provider?: string; model?: string } { + const cfg = params.cfg as Record | undefined; + const llmConfig = cfg?.llm as Record | undefined; + const modelRefFromConfig = llmConfig?.model; + const modelRef = + params.model + ?? (resolveAgentPrimaryModelRef(params.cfg, params.agentId) as string | undefined) + ?? (typeof modelRefFromConfig === "string" ? modelRefFromConfig : undefined); + const split = modelRef ? splitProviderModel(modelRef) : { provider: undefined, model: undefined }; + const provider = split.provider ?? inferProviderFromBaseURL(llmConfig?.baseURL as string | undefined); + return { provider, model: split.model }; +} + type ReflectionRunSlotState = { active: number; waiters: Array<() => void> }; const REFLECTION_RUN_SLOTS = Symbol.for("openclaw.memory-lancedb-pro.reflection-run-slots"); @@ -1852,6 +1764,7 @@ async function generateReflectionTextUnbounded( if (level === "warn") params.logger?.warn?.(message); else params.logger?.info?.(message); }; + const { provider, model } = resolveReflectionModelTarget(params); try { const result: unknown = await runWithReflectionTransientRetryOnce({ @@ -1860,31 +1773,18 @@ async function generateReflectionTextUnbounded( retryState, onLog: onRetryLog, execute: async () => { - const runEmbeddedPiAgent = await loadEmbeddedPiRunner(params.api); - const cfg = params.cfg as Record; - const llmConfig = cfg?.llm as Record | undefined; - const modelRefFromConfig = llmConfig?.model; - - // Model resolution chain: agent-specific primary model ref > global llm.model fallback. - // The typeof guard ensures a non-string value (e.g. number) does not reach splitProviderModel as-is. - const modelRef = - params.model - ?? (resolveAgentPrimaryModelRef(params.cfg, params.agentId) as string | undefined) - ?? (typeof modelRefFromConfig === "string" ? modelRefFromConfig : undefined); - - // Provider resolution chain: parsed from modelRef (e.g. "minimax/MiniMax-M2.7") > inferred from baseURL. - // inferProviderFromBaseURL uses .endsWith(".suffix") to prevent subdomain spoofing. - const split = modelRef ? splitProviderModel(modelRef) : { provider: undefined, model: undefined }; - const provider = split.provider ?? inferProviderFromBaseURL(llmConfig?.baseURL as string | undefined); - const model = split.model; + const embedded = await loadEmbeddedPiRunner(params.api); + const runEmbeddedPiAgent = embedded.runner; const embeddedTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); return await withTimeout( runEmbeddedPiAgent({ sessionId: `reflection-${Date.now()}`, sessionKey: `temp:memory-reflection:${params.agentId}`, + // The distiller run is throwaway: keep it out of the host session store. + sessionPersistence: "detached", agentId: params.agentId, - sessionFile: tempSessionFile, + ...(embedded.exportName !== "runEmbeddedAgent" ? { sessionFile: tempSessionFile } : {}), workspaceDir: params.workspaceDir, config: params.cfg, prompt, @@ -1933,22 +1833,25 @@ async function generateReflectionTextUnbounded( return { text: reflectionText, usedFallback: false, promptHash, error: errors[0], runner: "embedded" }; } - try { - reflectionText = await runWithReflectionTransientRetryOnce({ - scope: "reflection", - runner: "cli", - retryState, - onLog: onRetryLog, - execute: async () => await runReflectionViaCli({ - prompt, - agentId: params.agentId, - workspaceDir: params.workspaceDir, - timeoutMs: params.timeoutMs, - thinkLevel: params.thinkLevel, - }), - }); - } catch (err) { - errors.push(`cli: ${err instanceof Error ? err.message : String(err)}`); + if (params.completeText) { + const completeText = params.completeText; + try { + reflectionText = await runWithReflectionTransientRetryOnce({ + scope: "reflection", + runner: "completion", + retryState, + onLog: onRetryLog, + execute: async () => { + const text = await completeText(reflectionSystemPrompt, reflectionUserPrompt); + if (!text) throw new Error("completion returned no text"); + return text; + }, + }); + } catch (err) { + errors.push(`completion: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + errors.push("completion: no tool-free completion client on this host"); } if (reflectionText) { @@ -1957,7 +1860,7 @@ async function generateReflectionTextUnbounded( usedFallback: false, promptHash, error: errors.length > 0 ? errors.join(" | ") : undefined, - runner: "cli", + runner: "completion", }; } @@ -2513,6 +2416,7 @@ interface PluginSingletonState { captureAdmissionController: () => AdmissionController | null; captureAdmissionAudit: () => boolean; captureReflectionAdmissionController: () => AdmissionController | null; + makeLaneLlmClient: (model: string, thinkLevel?: string, modelExplicit?: boolean) => LlmClient; admissionRejectionAuditWriter: ((entry: AdmissionRejectionAuditEntry) => Promise) | null; } @@ -2829,6 +2733,10 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState { const captureAdmissionAudit = () => admissionController !== null && config.admissionControl?.auditMetadata !== false; const captureReflectionAdmissionController = () => admissionControllerReflectionLane; + const makeLaneLlmClient = (model: string, thinkLevel?: string, modelExplicit?: boolean) => { + const { makeClientForModel, llmModelExplicit } = buildMemoryLlmClient(); + return makeClientForModel(model, thinkLevel, modelExplicit ?? llmModelExplicit); + }; const extractionRateLimiter = createExtractionRateLimiter({ maxExtractionsPerHour: config.extractionThrottle?.maxExtractionsPerHour, @@ -2889,6 +2797,7 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState { captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, + makeLaneLlmClient, admissionRejectionAuditWriter, }; } @@ -3050,6 +2959,7 @@ const memoryLanceDBProPlugin = { captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, + makeLaneLlmClient, admissionRejectionAuditWriter, } = singleton; @@ -5487,6 +5397,29 @@ const memoryLanceDBProPlugin = { const reflectionMaxConcurrentRuns = config.memoryReflection?.maxConcurrentRuns ?? DEFAULT_REFLECTION_MAX_CONCURRENT_RUNS; const reflectionAgentId = asNonEmptyString(config.memoryReflection?.agentId); const reflectionModel = asNonEmptyString(config.memoryReflection?.model); + // Tool-free fallback for the distiller when the embedded runner is + // unavailable: a plain completion on the plugin's own LLM lane, so the + // transcript never reaches an agent turn that could invoke tools. + let reflectionCompletionClient: LlmClient | null | undefined; + const reflectionCompleteText = async (systemPrompt: string, userPrompt: string): Promise => { + if (reflectionCompletionClient === undefined) { + const model = reflectionModel ?? asNonEmptyString(config.llm?.model); + try { + reflectionCompletionClient = model + ? makeLaneLlmClient( + config.llm?.transport === "host" ? model.trim() : normalizeDirectModelRef(model), + reflectionThinkLevel, + reflectionModel ? true : undefined, + ) + : null; + } catch (err) { + api.logger.warn(`memory-reflection: completion fallback unavailable: ${err instanceof Error ? err.message : String(err)}`); + reflectionCompletionClient = null; + } + } + if (!reflectionCompletionClient) return null; + return reflectionCompletionClient.completeText(userPrompt, "memory-reflection", systemPrompt); + }; const reflectionErrorReminderMaxEntries = parsePositiveInt(config.memoryReflection?.errorReminderMaxEntries) ?? DEFAULT_REFLECTION_ERROR_REMINDER_MAX_ENTRIES; const reflectionDedupeErrorSignals = config.memoryReflection?.dedupeErrorSignals !== false; @@ -5686,10 +5619,40 @@ const memoryLanceDBProPlugin = { return g[REFLECTION_SERIAL_GUARD] as Map; }; // SERIAL_GUARD_COOLDOWN_MS moved to DEFAULT_SERIAL_GUARD_COOLDOWN_MS - - const runMemoryReflection = async (event: any) => { + // A command:new / command:reset hook that finds neither a hook transcript nor a + // session file parks here; the typed before_reset hook, which core fires right + // after the command hooks on every command path, carries the departing messages + // and finishes the reflection from this entry. + const REFLECTION_PENDING_BEFORE_RESET_TTL_MS = 60_000; + type PendingBeforeResetReflection = { event: any; sessionId: string; action: string; at: number }; + const pendingBeforeResetReflections = new Map(); + const rememberPendingBeforeResetReflection = (key: string, entry: Omit) => { + const now = Date.now(); + for (const [pendingKey, pending] of pendingBeforeResetReflections) { + if (now - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS) pendingBeforeResetReflections.delete(pendingKey); + } + pendingBeforeResetReflections.set(key, { ...entry, at: now }); + }; + const takePendingBeforeResetReflection = (key: string): PendingBeforeResetReflection | undefined => { + const pending = pendingBeforeResetReflections.get(key); + if (!pending) return undefined; + pendingBeforeResetReflections.delete(key); + return Date.now() - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS ? undefined : pending; + }; + // Captured at registration, outside any command's root-work context. The + // before_reset continuation would otherwise inherit the released /new root, + // and core refuses embedded sub-runs from a released root (subordinate work + // admission), which would push every /new reflection to the CLI runner. + const runOutsideCommandRootWork: (fn: () => R) => R = + typeof (AsyncLocalStorage as { snapshot?: unknown }).snapshot === "function" + ? (AsyncLocalStorage as unknown as { snapshot: () => (fn: () => R) => R }).snapshot() + : (fn) => fn(); + + type ReflectionRunOptions = { beforeResetConversation?: string | null }; + const runMemoryReflectionWith = async (event: any, options?: ReflectionRunOptions) => { const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : ""; const action = String(event?.action || "unknown"); + const resumedFromBeforeReset = options !== undefined && "beforeResetConversation" in options; // Validate sessionKey BEFORE dedup — invalid/empty keys must NOT pollute the dedup set if (!sessionKey) { @@ -5697,7 +5660,7 @@ const memoryLanceDBProPlugin = { return; } - if (_dedupHookEvent("reflection", event)) return; + if (!resumedFromBeforeReset && _dedupHookEvent("reflection", event)) return; const context = (event.context || {}) as Record; const cfg = context.cfg; const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record; @@ -5778,7 +5741,7 @@ const memoryLanceDBProPlugin = { sessionFile: currentSessionFile, }); const guarded = getReflectionEmptyEventGuardMap().get(emptyEventGuardKey); - if (guarded && Date.now() - guarded.updatedAt <= DEFAULT_REFLECTION_EMPTY_EVENT_GUARD_TTL_MS) { + if (!resumedFromBeforeReset && guarded && Date.now() - guarded.updatedAt <= DEFAULT_REFLECTION_EMPTY_EVENT_GUARD_TTL_MS) { api.logger.info( `memory-reflection: command:${action} skipped repeated empty/unusable session; sessionKey=${sessionKey}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}; reason=${guarded.reason}` ); @@ -5834,48 +5797,79 @@ const memoryLanceDBProPlugin = { `memory-reflection: command:${action} hook start; sessionKey=${sessionKey || "(none)"}; source=${commandSource || "(unknown)"}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}` ); - if (!currentSessionFile || currentSessionFile.includes(".reset.")) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId, - }); + // Hosts with SQLite session storage hand the departing transcript to the + // hook itself; the session-file lookup below is the legacy path. + let conversation = resumedFromBeforeReset + ? options?.beforeResetConversation ?? null + : conversationFromHookSessionMemory(context.previousSessionMemory, reflectionMessageCount); + if (resumedFromBeforeReset) { + api.logger.info( + `memory-reflection: command:${action} using the before_reset transcript for session ${currentSessionId}; messages=${conversation ? "present" : "empty"}` + ); + } else if (conversation) { api.logger.info( - `memory-reflection: command:${action} session recovery start for session ${currentSessionId}; initial=${currentSessionFile || "(none)"}; dirs=${searchDirs.join(" | ") || "(none)"}` + `memory-reflection: command:${action} using the hook-provided transcript for session ${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}` ); - for (const sessionsDir of searchDirs) { - const recovered = await findPreviousSessionFile(sessionsDir, currentSessionFile, currentSessionId); - if (recovered) { + } else { + if (!currentSessionFile || currentSessionFile.includes(".reset.")) { + const searchDirs = resolveReflectionSessionSearchDirs({ + context, + cfg, + workspaceDir, + currentSessionFile, + sourceAgentId, + }); + api.logger.info( + `memory-reflection: command:${action} session recovery start for session ${currentSessionId}; initial=${currentSessionFile || "(none)"}; dirs=${searchDirs.join(" | ") || "(none)"}` + ); + for (const sessionsDir of searchDirs) { + const recovered = await findPreviousSessionFile(sessionsDir, currentSessionFile, currentSessionId); + if (recovered) { + api.logger.info( + `memory-reflection: command:${action} recovered session file ${recovered} from ${sessionsDir}` + ); + currentSessionFile = recovered; + break; + } + } + } + + if (!currentSessionFile) { + const searchDirs = resolveReflectionSessionSearchDirs({ + context, + cfg, + workspaceDir, + currentSessionFile, + sourceAgentId, + }); + if (isBoundaryAction) { + rememberPendingBeforeResetReflection(sessionKey, { event, sessionId: currentSessionId, action }); api.logger.info( - `memory-reflection: command:${action} recovered session file ${recovered} from ${sessionsDir}` + `memory-reflection: command:${action} no transcript in the hook context or on disk for session ${currentSessionId}; waiting for the typed before_reset messages` ); - currentSessionFile = recovered; - break; + return; } + api.logger.warn( + `memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}` + ); + await rememberEmptyReflectionEvent("missing-session-file"); + return; } - } - if (!currentSessionFile) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId, - }); - api.logger.warn( - `memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}` - ); - await rememberEmptyReflectionEvent("missing-session-file"); - return; + conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); } - - const conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); if (!conversation) { + if (isBoundaryAction && !resumedFromBeforeReset) { + // A stale transcript artifact on a migrated host must not hide the + // messages the typed hook is about to supply. + rememberPendingBeforeResetReflection(sessionKey, { event, sessionId: currentSessionId, action }); + api.logger.info( + `memory-reflection: command:${action} transcript ${currentSessionFile || "(none)"} holds no usable conversation for session ${currentSessionId}; waiting for the typed before_reset messages` + ); + return; + } api.logger.warn( - `memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile}` + `memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile || "(none)"}` ); await rememberEmptyReflectionEvent("empty-conversation"); return; @@ -5918,14 +5912,15 @@ const memoryLanceDBProPlugin = { toolErrorSignals, logger: api.logger, api, // SDK migration Bug 2: pass api for new runtime.agent API + completeText: reflectionCompleteText, }); api.logger.info( `memory-reflection: command:${action} reflection generation done for session ${currentSessionId}; runner=${reflectionGenerated.runner}; usedFallback=${reflectionGenerated.usedFallback ? "yes" : "no"}` ); const reflectionText = reflectionGenerated.text; - if (reflectionGenerated.runner === "cli") { + if (reflectionGenerated.runner === "completion") { api.logger.warn( - `memory-reflection: embedded runner unavailable, used openclaw CLI fallback for session ${currentSessionId}` + + `memory-reflection: embedded runner unavailable, used the tool-free completion fallback for session ${currentSessionId}` + (reflectionGenerated.error ? ` (${reflectionGenerated.error})` : "") ); } else if (reflectionGenerated.usedFallback) { @@ -6296,6 +6291,25 @@ const memoryLanceDBProPlugin = { pruneReflectionSessionState(); } }; + const runMemoryReflection = async (event: any) => runMemoryReflectionWith(event); + const runMemoryReflectionFromBeforeReset = async (event: any, ctx: any) => { + const reason = getCommandActionName(event?.reason); + if (reason !== "new" && reason !== "reset") return; + const sessionKey = typeof ctx?.sessionKey === "string" ? ctx.sessionKey : ""; + if (!sessionKey) return; + const pending = takePendingBeforeResetReflection(sessionKey); + if (!pending) return; + const conversation = summarizeRecentConversationMessages( + Array.isArray(event?.messages) ? event.messages : [], + reflectionMessageCount, + ); + // The command hook that parked this entry ran no reflection, so its serial-guard + // stamp must not count against the continuation. + getSerialGuardMap().delete(sessionKey); + await runOutsideCommandRootWork(() => + runMemoryReflectionWith(pending.event, { beforeResetConversation: conversation }), + ); + }; api.registerHook("command:new", runMemoryReflection, { name: "memory-lancedb-pro.memory-reflection.command-new", @@ -6305,8 +6319,9 @@ const memoryLanceDBProPlugin = { name: "memory-lancedb-pro.memory-reflection.command-reset", description: "Generate reflection log before /reset", }); + api.on("before_reset", runMemoryReflectionFromBeforeReset); (isCliMode() ? api.logger.debug : api.logger.info)( - "memory-reflection: integrated hooks registered (command:new, command:reset, after_tool_call, before_prompt_build, session_end)" + "memory-reflection: integrated hooks registered (command:new, command:reset, before_reset, after_tool_call, before_prompt_build, session_end)" ); } diff --git a/package.json b/package.json index c406c8c9..71d854ce 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "skills/**/*.md" ], "scripts": { - "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs && node --test test/manual-store-supersede.test.mjs && node --test test/extraction-transcript-speaker-tags.test.mjs && node --test test/session-compressor.test.mjs && node --test test/reflection-derived-cache-invalidation.test.mjs && node --test test/reflection-tagged-input.test.mjs && node --test test/reflection-mapped-uniform-pipeline.test.mjs && node --test test/llm-host-transport.test.mjs && node --test test/llm-host-transport-composition.test.mjs && node --test test/admission-control-host-transport.test.mjs && node --test test/llm-transport-credential-hygiene.test.mjs && node --test test/memory-consolidate.test.mjs && node --test test/memory-consolidate-cost-gate.test.mjs && node --test test/memory-consolidate-two-phase-apply.test.mjs && node --test test/memory-consolidate-admission-independence.test.mjs && node --test test/memory-consolidate-polish.test.mjs && node --test test/consolidate-cli-settled-persistence.test.mjs && node --test test/consolidate-cli-apply-exit-status.test.mjs && node --test test/invalidated-rows-visibility.test.mjs && node --test test/store-excludeinactive-default.test.mjs && node --test test/manual-echo-guard.test.mjs", + "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs && node --test test/manual-store-supersede.test.mjs && node --test test/extraction-transcript-speaker-tags.test.mjs && node --test test/session-compressor.test.mjs && node --test test/reflection-derived-cache-invalidation.test.mjs && node --test test/reflection-tagged-input.test.mjs && node --test test/reflection-mapped-uniform-pipeline.test.mjs && node --test test/llm-host-transport.test.mjs && node --test test/llm-host-transport-composition.test.mjs && node --test test/admission-control-host-transport.test.mjs && node --test test/llm-transport-credential-hygiene.test.mjs && node --test test/memory-consolidate.test.mjs && node --test test/memory-consolidate-cost-gate.test.mjs && node --test test/memory-consolidate-two-phase-apply.test.mjs && node --test test/memory-consolidate-admission-independence.test.mjs && node --test test/memory-consolidate-polish.test.mjs && node --test test/consolidate-cli-settled-persistence.test.mjs && node --test test/consolidate-cli-apply-exit-status.test.mjs && node --test test/invalidated-rows-visibility.test.mjs && node --test test/store-excludeinactive-default.test.mjs && node --test test/manual-echo-guard.test.mjs && node --test test/reflection-hook-session-memory.test.mjs && node --test test/reflection-runner-embedded-agent.test.mjs && node --test test/reflection-before-reset-transcript.test.mjs", "test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke", "test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression", "test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema", diff --git a/scripts/ci-test-manifest.mjs b/scripts/ci-test-manifest.mjs index e58e038d..83e91518 100644 --- a/scripts/ci-test-manifest.mjs +++ b/scripts/ci-test-manifest.mjs @@ -99,6 +99,9 @@ export const CI_TEST_MANIFEST = [ // Issue #492 agentId validation tests { group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/command-reflection-guard.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/reflection-hook-session-memory.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/reflection-before-reset-transcript.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/reflection-runner-embedded-agent.test.mjs", args: ["--test"] }, // Tier 1 memory counter fix { group: "core-regression", runner: "node", file: "test/tier1-counters.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/memory-subsession-prompt-hooks.test.mjs", args: ["--test"] }, diff --git a/src/llm-client.ts b/src/llm-client.ts index 01a5056f..e3ff638d 100644 --- a/src/llm-client.ts +++ b/src/llm-client.ts @@ -127,6 +127,13 @@ export interface LlmClient { * and ignores this argument. */ completeJson(prompt: string, label?: string, systemPrompt?: string, temperature?: number): Promise; + /** + * Send a prompt and return the model's answer as trimmed text. No JSON + * expectation is attached and `systemPrompt` is sent verbatim (no system + * message at all when omitted). Returns null on a transport failure or an + * empty answer; `getLastError` carries the reason. + */ + completeText(prompt: string, label?: string, systemPrompt?: string, temperature?: number): Promise; /** Best-effort diagnostics for the most recent failure, if any. */ getLastError(): string | null; } @@ -425,6 +432,37 @@ function createHostClient( return null; } }, + async completeText(prompt: string, label = "generic", systemPrompt?: string, temperature?: number): Promise { + lastError = null; + const messages: RuntimeLlmCompleteMessage[] = []; + if (systemPrompt !== undefined) messages.push({ role: "system", content: systemPrompt }); + messages.push({ role: "user", content: prompt }); + try { + const result = await raceWithTimeout( + runtimeLlmComplete({ + messages, + ...(config.modelExplicit ? { model: config.model } : {}), + temperature: temperature ?? 0.1, + purpose: `memory-lancedb-pro:${label}`, + reasoning: config.thinkLevel?.trim() || DEFAULT_HOST_REASONING_EFFORT, + }), + config.timeoutMs, + ); + const text = typeof result?.text === "string" ? result.text.trim() : ""; + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty host-transport response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] host-transport request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError(): string | null { return lastError; }, @@ -534,6 +572,39 @@ function createApiKeyClient(config: LlmClientConfig, log: (msg: string) => void, return null; } }, + async completeText(prompt: string, label = "generic", systemPrompt?: string, temperature?: number): Promise { + lastError = null; + try { + const request = { + model: config.model, + messages: [ + ...(systemPrompt !== undefined ? [{ role: "system", content: systemPrompt }] : []), + { role: "user", content: prompt }, + ], + temperature: temperature ?? 0.1, + ...(config.thinkLevel?.trim() + ? { reasoning: { effort: config.thinkLevel.trim() } } + : {}), + }; + const response = await client.chat.completions.create(request as any, { + headers: { "x-memory-call-label": sanitizeLabelHeader(label) }, + }); + const raw = response.choices?.[0]?.message?.content; + const text = typeof raw === "string" ? raw.trim() : ""; + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError(): string | null { return lastError; }, @@ -610,31 +681,7 @@ function createOauthClient(config: LlmClientConfig, log: (msg: string) => void, throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 500)}`); } - const bodyText = await response.text(); - const raw = ( - response.headers.get("content-type")?.includes("text/event-stream") || - looksLikeSseResponse(bodyText) - ) - ? extractOutputTextFromSse(bodyText) - : (() => { - try { - const parsed = JSON.parse(bodyText) as Record; - const output = Array.isArray(parsed.output) ? parsed.output : []; - const first = output.find( - (item) => - item && - typeof item === "object" && - Array.isArray((item as Record).content), - ) as Record | undefined; - if (!first) return null; - const content = (first.content as Array>).find( - (part) => part?.type === "output_text" && typeof part.text === "string", - ); - return typeof content?.text === "string" ? content.text : null; - } catch { - return null; - } - })(); + const raw = extractOauthOutputText(response, await response.text()); if (!raw) { lastError = @@ -684,12 +731,81 @@ function createOauthClient(config: LlmClientConfig, log: (msg: string) => void, return null; } }, + async completeText(prompt: string, label = "generic", systemPrompt?: string, _temperature?: number): Promise { + lastError = null; + try { + const session = await getSession(); + const { signal, dispose } = createTimeoutSignal(config.timeoutMs); + const endpoint = buildOauthEndpoint(config.baseURL, config.oauthProvider); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${session.accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + "OpenAI-Beta": "responses=experimental", + "chatgpt-account-id": session.accountId, + originator: "codex_cli_rs", + }, + signal, + body: JSON.stringify({ + model: normalizeOauthModel(config.model), + ...(systemPrompt !== undefined ? { instructions: systemPrompt } : {}), + input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], + store: false, + stream: true, + text: { format: { type: "text" } }, + }), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 500)}`); + } + const text = (extractOauthOutputText(response, await response.text()) ?? "").trim(); + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty OAuth response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } finally { + dispose(); + } + } catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] OAuth request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError(): string | null { return lastError; }, }; } +function extractOauthOutputText(response: Response, bodyText: string): string | null { + if (response.headers.get("content-type")?.includes("text/event-stream") || looksLikeSseResponse(bodyText)) { + return extractOutputTextFromSse(bodyText); + } + try { + const parsed = JSON.parse(bodyText) as Record; + const output = Array.isArray(parsed.output) ? parsed.output : []; + const first = output.find( + (item) => item && typeof item === "object" && Array.isArray((item as Record).content), + ) as Record | undefined; + if (!first) return null; + const content = (first.content as Array>).find( + (part) => part?.type === "output_text" && typeof part.text === "string", + ); + return typeof content?.text === "string" ? content.text : null; + } catch { + return null; + } +} + /** OpenRouter's direct API base URL, used as the host->direct fallback's default when llm.baseURL is not configured. */ // Module-level (not per-client) so the "runtime surface unavailable" // warning is emitted once per process even though createLlmClient is diff --git a/src/session-recovery.ts b/src/session-recovery.ts index 4750305f..47deb385 100644 --- a/src/session-recovery.ts +++ b/src/session-recovery.ts @@ -29,25 +29,45 @@ function deriveOpenClawHomeFromSessionFilePath(sessionFilePath: string): string return home.length ? home : undefined; } -function listConfiguredAgentIds(cfg: unknown): string[] { +/** + * Agent definitions come as `agents.list` (an array of `{id, workspace}`) on + * older hosts and as `agents.entries` (an object keyed by agent id) on newer + * ones; both shapes are read so neither generation loses its sessions dirs. + */ +function listConfiguredAgents(cfg: unknown): Array<{ id?: string; workspace?: string }> { try { const root = cfg as Record; const agents = root.agents as Record | undefined; + const out: Array<{ id?: string; workspace?: string }> = []; const list = agents?.list as unknown; - if (!Array.isArray(list)) return []; - - const ids: string[] = []; - for (const item of list) { - if (!item || typeof item !== "object") continue; - const id = asNonEmptyString((item as Record).id); - if (id) ids.push(id); + if (Array.isArray(list)) { + for (const item of list) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + out.push({ id: asNonEmptyString(record.id), workspace: asNonEmptyString(record.workspace) }); + } + } + const entries = agents?.entries as unknown; + if (entries && typeof entries === "object" && !Array.isArray(entries)) { + for (const [key, item] of Object.entries(entries as Record)) { + const record = item && typeof item === "object" ? (item as Record) : {}; + out.push({ id: asNonEmptyString(record.id) ?? asNonEmptyString(key), workspace: asNonEmptyString(record.workspace) }); + } } - return ids; + return out; } catch { return []; } } +function listConfiguredAgentIds(cfg: unknown): string[] { + const ids: string[] = []; + for (const agent of listConfiguredAgents(cfg)) { + if (agent.id && !ids.includes(agent.id)) ids.push(agent.id); + } + return ids; +} + export function resolveReflectionSessionSearchDirs(params: { context: Record; cfg: unknown; @@ -104,13 +124,8 @@ export function resolveReflectionSessionSearchDirs(params: { const defaultWorkspace = asNonEmptyString(defaults?.workspace); if (defaultWorkspace) addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(defaultWorkspace)); - const list = agents?.list as unknown; - if (Array.isArray(list)) { - for (const item of list) { - if (!item || typeof item !== "object") continue; - const workspace = asNonEmptyString((item as Record).workspace); - if (workspace) addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(workspace)); - } + for (const agent of listConfiguredAgents(params.cfg)) { + if (agent.workspace) addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(agent.workspace)); } } catch { // ignore diff --git a/test/command-reflection-guard.test.mjs b/test/command-reflection-guard.test.mjs index ad248717..bed9dfcf 100644 --- a/test/command-reflection-guard.test.mjs +++ b/test/command-reflection-guard.test.mjs @@ -236,6 +236,11 @@ describe("runMemoryReflection — invalid agentId guard", () => { hook.meta?.name === "memory-lancedb-pro.memory-reflection.command-new" ); assert.ok(reflectionHook, "expected memory reflection command:new hook"); + // An empty transcript file parks the boundary for the typed before_reset + // hook; the empty guard is recorded when that hook brings no messages. + const beforeResetHooks = harness.eventHandlers.get("before_reset") || []; + assert.equal(beforeResetHooks.length, 1, "expected the reflection before_reset listener"); + const beforeResetHook = beforeResetHooks[0]; const emptySessionFile = path.join(workDir, "fresh-empty.jsonl"); writeFileSync(emptySessionFile, "", "utf-8"); @@ -258,14 +263,20 @@ describe("runMemoryReflection — invalid agentId guard", () => { }, }, }, { sessionKey: "agent:main:session:fresh", agentId: "main" }); + await beforeResetHook.handler( + { sessionFile: emptySessionFile, messages: [], reason: "new" }, + { agentId: "main", sessionKey: "agent:main:session:fresh", sessionId: "fresh-empty", workspaceDir: workDir }, + ); now += 10; } } finally { Date.now = originalDateNow; } + const parkedLogs = harness.logs.filter(([, msg]) => msg.includes("waiting for the typed before_reset messages")); + assert.equal(parkedLogs.length, 1, `only the first empty event should park for before_reset; got ${JSON.stringify(parkedLogs)}`); const emptyLogs = harness.logs.filter(([, msg]) => msg.includes("conversation empty/unusable")); - assert.equal(emptyLogs.length, 1, `only the first empty event should read the session; got ${JSON.stringify(emptyLogs)}`); + assert.equal(emptyLogs.length, 1, `only the first before_reset continuation should judge the session empty; got ${JSON.stringify(emptyLogs)}`); const skippedLogs = harness.logs.filter(([, msg]) => msg.includes("skipped repeated empty/unusable session")); assert.equal(skippedLogs.length, 2, `expected repeated empty events to hit the guard; got ${JSON.stringify(harness.logs)}`); diff --git a/test/memory-reflection.test.mjs b/test/memory-reflection.test.mjs index 111a442f..3cd40a41 100644 --- a/test/memory-reflection.test.mjs +++ b/test/memory-reflection.test.mjs @@ -299,7 +299,7 @@ describe("memory reflection", () => { await assert.rejects( runWithReflectionTransientRetryOnce({ scope: "reflection", - runner: "cli", + runner: "completion", retryState, execute: async () => { attempts += 1; @@ -322,7 +322,7 @@ describe("memory reflection", () => { await assert.rejects( runWithReflectionTransientRetryOnce({ scope: "distiller", - runner: "cli", + runner: "completion", retryState, execute: async () => { attempts += 1; diff --git a/test/reflection-before-reset-transcript.test.mjs b/test/reflection-before-reset-transcript.test.mjs new file mode 100644 index 00000000..60b7b677 --- /dev/null +++ b/test/reflection-before-reset-transcript.test.mjs @@ -0,0 +1,260 @@ +/** + * reflection-before-reset-transcript.test.mjs + * + * On hosts with SQLite session storage the command:new hook can arrive with no + * transcript at all (the Gateway command path emits it before it captures one), + * while the typed before_reset hook, fired right after it on every path, + * carries the departing messages. The reflection parks on the command hook and + * finishes from the before_reset messages; it must never run twice for one + * boundary and must ignore before_reset events that belong to no parked hook. + * Fixtures are synthetic. + * + * Run: node --test test/reflection-before-reset-transcript.test.mjs + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "os"; +import path from "path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; +import { AsyncLocalStorage } from "node:async_hooks"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); +const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + alias: { "openclaw/plugin-sdk": pluginSdkStubPath }, +}); + +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const resetRegistration = pluginModule.resetRegistration ?? (() => {}); + +function createPluginApiHarness({ pluginConfig, resolveRoot }) { + const eventHandlers = new Map(); + const logs = []; + const api = { + pluginConfig, + resolvePath(target) { + if (typeof target !== "string") return target; + return path.isAbsolute(target) ? target : path.join(resolveRoot, target); + }, + logger: { + info(message) { logs.push(String(message)); }, + warn(message) { logs.push(String(message)); }, + debug(message) { logs.push(String(message)); }, + error(message) { logs.push(String(message)); }, + }, + registerTool() {}, + registerCli() {}, + registerService() {}, + on(eventName, handler, meta) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta }); + eventHandlers.set(eventName, list); + }, + registerHook(eventName, handler, opts) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta: opts }); + eventHandlers.set(eventName, list); + }, + }; + return { api, eventHandlers, logs }; +} + +function makePluginConfig(workDir) { + return { + dbPath: path.join(workDir, "db"), + embedding: { apiKey: "test-api-key", dimensions: 4 }, + sessionStrategy: "memoryReflection", + smartExtraction: false, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + memoryReflection: { excludeAgents: [] }, + }; +} + +const DEPARTING_MESSAGES = [ + { role: "user", content: "Please remember that the rehearsal moved to Thursday." }, + { role: "assistant", content: "Noted, the rehearsal is on Thursday now." }, + { role: "user", content: "And the venue stays the same, the old town hall." }, +]; + +const HOOK_TRANSCRIPT = [ + 'user: "Please remember that the rehearsal moved to Thursday."', + 'assistant: "Noted, the rehearsal is on Thursday now."', +].join("\n"); + +describe("reflection finishes from the typed before_reset messages", () => { + let workDir; + let originalCliBin; + + beforeEach(() => { + workDir = mkdtempSync(path.join(tmpdir(), "reflect-before-reset-")); + resetRegistration(); + originalCliBin = process.env.OPENCLAW_CLI_BIN; + process.env.OPENCLAW_CLI_BIN = "/usr/bin/false"; + }); + + afterEach(() => { + resetRegistration(); + if (originalCliBin === undefined) delete process.env.OPENCLAW_CLI_BIN; + else process.env.OPENCLAW_CLI_BIN = originalCliBin; + rmSync(workDir, { recursive: true, force: true }); + }); + + function registered() { + const pluginConfig = makePluginConfig(workDir); + const harness = createPluginApiHarness({ resolveRoot: workDir, pluginConfig }); + memoryLanceDBProPlugin.register(harness.api); + const commandHooks = harness.eventHandlers.get("command:new") || []; + const beforeResetHooks = harness.eventHandlers.get("before_reset") || []; + assert.equal(commandHooks.length, 1, "one command:new reflection hook"); + assert.equal(beforeResetHooks.length, 1, "exactly one before_reset listener under the memoryReflection strategy"); + return { harness, pluginConfig, commandHook: commandHooks[0].handler, beforeResetHook: beforeResetHooks[0].handler }; + } + + async function fireCommandNew({ commandHook, pluginConfig }, sessionId, extraContext = {}) { + const sessionKey = `agent:main:session:${sessionId}`; + await commandHook( + { + sessionKey, + timestamp: 1000, + action: "command:new", + context: { cfg: pluginConfig, workspaceDir: workDir, previousSessionEntry: { sessionId }, ...extraContext }, + }, + { sessionKey, agentId: "main" }, + ); + return sessionKey; + } + + async function fireBeforeReset({ beforeResetHook }, sessionKey, sessionId, { reason = "new", messages = DEPARTING_MESSAGES } = {}) { + await beforeResetHook( + { sessionFile: `sqlite:${sessionId}`, messages, reason }, + { agentId: "main", sessionKey, sessionId, workspaceDir: workDir }, + ); + } + + it("parks on a command hook without any transcript and reflects from the before_reset messages", async () => { + const setup = registered(); + const sessionKey = await fireCommandNew(setup, "parked"); + const { logs } = setup.harness; + assert.ok( + logs.some((m) => m.includes("no transcript in the hook context or on disk for session parked; waiting for the typed before_reset messages")), + `got ${JSON.stringify(logs)}`, + ); + assert.ok(!logs.some((m) => m.includes("missing session file after recovery")), "no legacy missing-file warning"); + assert.ok(!logs.some((m) => m.includes("empty/unusable guard recorded")), "parking is not an empty session"); + assert.ok(!logs.some((m) => m.includes("reflection generation start")), "nothing ran yet"); + + await fireBeforeReset(setup, sessionKey, "parked"); + assert.ok( + logs.some((m) => m.includes("using the before_reset transcript for session parked; messages=present")), + `got ${JSON.stringify(logs)}`, + ); + assert.equal( + logs.filter((m) => m.includes("reflection generation start for session parked")).length, + 1, + `the parked boundary reflects exactly once: ${JSON.stringify(logs)}`, + ); + }); + + it("ignores a before_reset event that belongs to no parked command hook", async () => { + const setup = registered(); + await fireBeforeReset(setup, "agent:main:session:orphan", "orphan"); + const { logs } = setup.harness; + assert.ok(!logs.some((m) => m.includes("using the before_reset transcript")), JSON.stringify(logs)); + assert.ok(!logs.some((m) => m.includes("reflection generation start")), JSON.stringify(logs)); + }); + + it("does not reflect twice when the command hook already carried the transcript", async () => { + const setup = registered(); + const sessionKey = await fireCommandNew(setup, "carried", { + previousSessionMemory: { status: "available", content: HOOK_TRANSCRIPT, originClass: "agent" }, + }); + const { logs } = setup.harness; + assert.ok(logs.some((m) => m.includes("using the hook-provided transcript for session carried")), JSON.stringify(logs)); + await fireBeforeReset(setup, sessionKey, "carried"); + assert.equal(logs.filter((m) => m.includes("reflection generation start for session carried")).length, 1, JSON.stringify(logs)); + assert.ok(!logs.some((m) => m.includes("using the before_reset transcript")), "the continuation must not run"); + }); + + it("records the empty guard when the before_reset messages are empty", async () => { + const setup = registered(); + const sessionKey = await fireCommandNew(setup, "empty"); + await fireBeforeReset(setup, sessionKey, "empty", { messages: [] }); + const { logs } = setup.harness; + assert.ok(logs.some((m) => m.includes("using the before_reset transcript for session empty; messages=empty")), JSON.stringify(logs)); + assert.ok(logs.some((m) => m.includes("conversation empty/unusable for session empty")), JSON.stringify(logs)); + assert.ok(logs.some((m) => m.includes("empty/unusable guard recorded")), JSON.stringify(logs)); + assert.ok(!logs.some((m) => m.includes("reflection generation start")), "nothing to reflect on"); + }); + + it("leaves a parked hook alone for before_reset reasons that are not a session boundary", async () => { + const setup = registered(); + const sessionKey = await fireCommandNew(setup, "idle"); + await fireBeforeReset(setup, sessionKey, "idle", { reason: "idle" }); + const { logs } = setup.harness; + assert.ok(!logs.some((m) => m.includes("using the before_reset transcript")), JSON.stringify(logs)); + await fireBeforeReset(setup, sessionKey, "idle"); + assert.ok(logs.some((m) => m.includes("using the before_reset transcript for session idle; messages=present")), "the boundary reason finishes it"); + }); + + it("runs the continuation outside the command's async context so the embedded runner is admitted", async () => { + // Core refuses embedded sub-runs enqueued from a released root-work context + // (the /new command's), which is exactly where the fire-and-forget + // before_reset hook runs. The continuation must not inherit that context. + const callerContext = new AsyncLocalStorage(); + // The embedded-runner loader caches its result per module instance, and the + // earlier cases ran without a runtime; load a fresh plugin module here. + const freshModule = jitiFactory(import.meta.url, { + interopDefault: true, + moduleCache: false, + alias: { "openclaw/plugin-sdk": pluginSdkStubPath }, + })("../index.ts"); + const freshPlugin = freshModule.default || freshModule; + const pluginConfig = makePluginConfig(workDir); + const harness = createPluginApiHarness({ resolveRoot: workDir, pluginConfig }); + let storeSeenByRunner = "not invoked"; + harness.api.runtime = { + agent: { + runEmbeddedAgent: async () => { + storeSeenByRunner = callerContext.getStore(); + throw new Error("synthetic runner stop"); + }, + }, + }; + (freshModule.resetRegistration ?? (() => {}))(); + freshPlugin.register(harness.api); + const setup = { + harness, + pluginConfig, + commandHook: harness.eventHandlers.get("command:new")[0].handler, + beforeResetHook: harness.eventHandlers.get("before_reset")[0].handler, + }; + await callerContext.run({ released: true }, async () => { + const sessionKey = await fireCommandNew(setup, "escaped"); + await fireBeforeReset(setup, sessionKey, "escaped"); + }); + assert.equal(storeSeenByRunner, undefined, "the embedded runner must not see the caller's released root context"); + assert.ok(harness.logs.some((m) => m.includes("reflection generation start for session escaped")), JSON.stringify(harness.logs)); + }); + + it("parks when the recovered transcript file holds no usable conversation, then finishes from before_reset", async () => { + const setup = registered(); + const staleFile = path.join(workDir, "stale-session.jsonl"); + writeFileSync(staleFile, JSON.stringify({ type: "session-meta", version: 1 }) + "\n", "utf-8"); + const sessionKey = await fireCommandNew(setup, "stalefile", { previousSessionEntry: { sessionId: "stalefile", sessionFile: staleFile } }); + const { logs } = setup.harness; + assert.ok( + logs.some((m) => m.includes("holds no usable conversation for session stalefile; waiting for the typed before_reset messages")), + `got ${JSON.stringify(logs)}`, + ); + assert.ok(!logs.some((m) => m.includes("empty/unusable guard recorded")), "a stale artifact must not record the empty guard"); + await fireBeforeReset(setup, sessionKey, "stalefile"); + assert.ok(logs.some((m) => m.includes("using the before_reset transcript for session stalefile; messages=present")), JSON.stringify(logs)); + assert.equal(logs.filter((m) => m.includes("reflection generation start for session stalefile")).length, 1, JSON.stringify(logs)); + }); +}); diff --git a/test/reflection-hook-session-memory.test.mjs b/test/reflection-hook-session-memory.test.mjs new file mode 100644 index 00000000..2b77cf43 --- /dev/null +++ b/test/reflection-hook-session-memory.test.mjs @@ -0,0 +1,191 @@ +/** + * reflection-hook-session-memory.test.mjs + * + * Hosts with SQLite session storage no longer expose a transcript file to + * plugins; the command:new / command:reset hook context carries the departing + * session's recent messages as `previousSessionMemory` instead. Reflection must + * run from that transcript first and keep the session-file lookup only as the + * legacy fallback. Fixtures are synthetic. + * + * Run: node --test test/reflection-hook-session-memory.test.mjs + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "os"; +import path from "path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); +const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + alias: { "openclaw/plugin-sdk": pluginSdkStubPath }, +}); + +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const resetRegistration = pluginModule.resetRegistration ?? (() => {}); +const { resolveReflectionSessionSearchDirs } = jiti("../src/session-recovery.ts"); + +function createPluginApiHarness({ pluginConfig, resolveRoot }) { + const eventHandlers = new Map(); + const logs = []; + const api = { + pluginConfig, + resolvePath(target) { + if (typeof target !== "string") return target; + return path.isAbsolute(target) ? target : path.join(resolveRoot, target); + }, + logger: { + info(message) { logs.push(["info", String(message)]); }, + warn(message) { logs.push(["warn", String(message)]); }, + debug(message) { logs.push(["debug", String(message)]); }, + error(message) { logs.push(["error", String(message)]); }, + }, + registerTool() {}, + registerCli() {}, + registerService() {}, + on(eventName, handler, meta) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta }); + eventHandlers.set(eventName, list); + }, + registerHook(eventName, handler, opts) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta: opts }); + eventHandlers.set(eventName, list); + }, + }; + return { api, eventHandlers, logs }; +} + +function makePluginConfig(workDir) { + return { + dbPath: path.join(workDir, "db"), + embedding: { apiKey: "test-api-key", dimensions: 4 }, + sessionStrategy: "memoryReflection", + smartExtraction: false, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + memoryReflection: { excludeAgents: [] }, + }; +} + +const HOOK_TRANSCRIPT = [ + 'user: "Please remember that the rehearsal moved to Thursday."', + 'assistant: "Noted, the rehearsal is on Thursday now."', + 'user: "And the venue stays the same, the old town hall."', +].join("\n"); + +describe("runMemoryReflection reads the transcript the hook already carries", () => { + let workDir; + let originalCliBin; + + beforeEach(() => { + workDir = mkdtempSync(path.join(tmpdir(), "reflect-hook-memory-")); + resetRegistration(); + originalCliBin = process.env.OPENCLAW_CLI_BIN; + process.env.OPENCLAW_CLI_BIN = "/usr/bin/false"; + }); + + afterEach(() => { + resetRegistration(); + if (originalCliBin === undefined) delete process.env.OPENCLAW_CLI_BIN; + else process.env.OPENCLAW_CLI_BIN = originalCliBin; + rmSync(workDir, { recursive: true, force: true }); + }); + + async function invoke(context, sessionId) { + const pluginConfig = makePluginConfig(workDir); + const harness = createPluginApiHarness({ resolveRoot: workDir, pluginConfig }); + memoryLanceDBProPlugin.register(harness.api); + const hook = (harness.eventHandlers.get("command:new") || [])[0]; + assert.ok(hook, "the command:new reflection hook must be registered"); + const sessionKey = `agent:main:session:${sessionId}`; + await hook.handler( + { sessionKey, timestamp: 1000, action: "command:new", context: { cfg: pluginConfig, workspaceDir: workDir, ...context } }, + { sessionKey, agentId: "main" }, + ); + return harness.logs.map(([, message]) => message); + } + + it("reflects from previousSessionMemory when the host exposes no session file", async () => { + const logs = await invoke( + { + previousSessionEntry: { sessionId: "sqlite-session" }, + previousSessionMemory: { status: "available", content: HOOK_TRANSCRIPT, originClass: "agent" }, + }, + "sqlite-session", + ); + assert.ok( + logs.some((m) => m.includes("using the hook-provided transcript for session sqlite-session")), + `expected the hook transcript to be used; got ${JSON.stringify(logs)}`, + ); + assert.ok( + logs.some((m) => m.includes("reflection generation start for session sqlite-session")), + `reflection must reach the generation step; got ${JSON.stringify(logs)}`, + ); + assert.ok(!logs.some((m) => m.includes("missing session file after recovery")), "no file lookup failure may be logged"); + assert.ok(!logs.some((m) => m.includes("session recovery start")), "no file recovery may run when the hook carries the transcript"); + }); + + it("still reads the session file when the hook carries no transcript", async () => { + const sessionFile = path.join(workDir, "old-session.jsonl"); + writeFileSync( + sessionFile, + [ + JSON.stringify({ type: "message", message: { role: "user", content: "Please remember the old session." } }), + JSON.stringify({ type: "message", message: { role: "assistant", content: "I will reflect on the old session." } }), + ].join("\n") + "\n", + "utf-8", + ); + const logs = await invoke({ previousSessionEntry: { sessionId: "old-session", sessionFile } }, "old-session"); + assert.ok(logs.some((m) => m.includes("reflection generation start for session old-session")), `got ${JSON.stringify(logs)}`); + assert.ok(!logs.some((m) => m.includes("using the hook-provided transcript")), "the legacy file path must not claim a hook transcript"); + }); + + it("falls back to the file lookup when the hook transcript is empty or unavailable", async () => { + const logs = await invoke( + { + previousSessionEntry: { sessionId: "empty-hook" }, + previousSessionMemory: { status: "available", content: null, originClass: "agent" }, + }, + "empty-hook", + ); + assert.ok(logs.some((m) => m.includes("no transcript in the hook context or on disk for session empty-hook")), `got ${JSON.stringify(logs)}`); + assert.ok(!logs.some((m) => m.includes("reflection generation start")), "nothing to reflect on"); + + const unavailable = await invoke( + { + previousSessionEntry: { sessionId: "unavailable-hook" }, + previousSessionMemory: { status: "unavailable", reason: "capture failed" }, + }, + "unavailable-hook", + ); + assert.ok(unavailable.some((m) => m.includes("no transcript in the hook context or on disk for session unavailable-hook")), `got ${JSON.stringify(unavailable)}`); + }); +}); + +describe("resolveReflectionSessionSearchDirs reads both agent config shapes", () => { + it("enumerates agents.entries ids and workspaces like agents.list", () => { + const entriesDirs = resolveReflectionSessionSearchDirs({ + context: {}, + cfg: { agents: { entries: { alpha: { workspace: "/srv/openclaw/workspace" }, beta: {} } } }, + workspaceDir: "/srv/openclaw/workspace-alpha", + sourceAgentId: "alpha", + }); + assert.ok(entriesDirs.includes("/srv/openclaw/agents/alpha/sessions"), JSON.stringify(entriesDirs)); + assert.ok(entriesDirs.includes("/srv/openclaw/agents/beta/sessions"), JSON.stringify(entriesDirs)); + + const listDirs = resolveReflectionSessionSearchDirs({ + context: {}, + cfg: { agents: { list: [{ id: "gamma", workspace: "/srv/openclaw/workspace" }] } }, + workspaceDir: "/srv/openclaw/workspace-gamma", + sourceAgentId: "gamma", + }); + assert.ok(listDirs.includes("/srv/openclaw/agents/gamma/sessions"), JSON.stringify(listDirs)); + }); +}); diff --git a/test/reflection-runner-embedded-agent.test.mjs b/test/reflection-runner-embedded-agent.test.mjs new file mode 100644 index 00000000..e5e134ac --- /dev/null +++ b/test/reflection-runner-embedded-agent.test.mjs @@ -0,0 +1,240 @@ +/** + * reflection-runner-embedded-agent.test.mjs + * + * The host renamed api.runtime.agent.runEmbeddedPiAgent to runEmbeddedAgent + * and removed the alias; `openclaw agent --local` is refused while a gateway + * owns the state directory; and CLI startup banners pushed the real failure + * reason out of the clipped diagnostic. Reflection must pick the new runner + * name (still accepting the old one), keep the distiller run detached from the + * session store, drive the CLI fallback through `agent exec`, and report the + * tail of stderr. Fixtures are synthetic. + * + * Run: node --test test/reflection-runner-embedded-agent.test.mjs + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); + +function loadFreshIndex() { + const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + moduleCache: false, + alias: { "openclaw/plugin-sdk": pluginSdkStubPath }, + }); + return jiti("../index.ts"); +} + +const { resolveEmbeddedRunnerExportName } = loadFreshIndex(); + +const noop = async () => ({ payloads: [{ text: "noop" }] }); + +describe("embedded runner export resolution", () => { + it("prefers runEmbeddedAgent when the host exposes both names", () => { + const name = resolveEmbeddedRunnerExportName({ runEmbeddedAgent: noop, runEmbeddedPiAgent: noop }); + assert.equal(name, "runEmbeddedAgent"); + }); + + it("still accepts the legacy runEmbeddedPiAgent name", () => { + assert.equal(resolveEmbeddedRunnerExportName({ runEmbeddedPiAgent: noop }), "runEmbeddedPiAgent"); + }); + + it("returns undefined for hosts without a callable runner", () => { + assert.equal(resolveEmbeddedRunnerExportName({ runEmbeddedAgent: "not a function" }), undefined); + assert.equal(resolveEmbeddedRunnerExportName(undefined), undefined); + assert.equal(resolveEmbeddedRunnerExportName(null), undefined); + assert.equal(resolveEmbeddedRunnerExportName({}), undefined); + }); +}); + +describe("reflection distiller on a renamed-runner host", () => { + it("invokes runEmbeddedAgent with a detached, tool-free minimal model run", async () => { + const { generateReflectionText } = loadFreshIndex(); + let seenParams = null; + let legacyCalls = 0; + const api = { + runtime: { + agent: { + runEmbeddedAgent: async (params) => { + seenParams = params; + return { payloads: [{ text: "distilled reflection" }] }; + }, + runEmbeddedPiAgent: async () => { + legacyCalls += 1; + return { payloads: [{ text: "legacy" }] }; + }, + }, + }, + }; + + const result = await generateReflectionText({ + conversation: "user: the build is green\nassistant: noted", + maxInputChars: 1000, + cfg: { llm: { model: "openrouter/example/model-one" } }, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api, + }); + + assert.equal(result.runner, "embedded"); + assert.equal(result.text, "distilled reflection"); + assert.equal(legacyCalls, 0, "the legacy alias must not be called when the new name exists"); + assert.ok(seenParams, "runEmbeddedAgent must have been invoked"); + assert.equal(seenParams.sessionPersistence, "detached"); + assert.equal(seenParams.modelRun, true); + assert.equal(seenParams.promptMode, "minimal"); + assert.equal(seenParams.disableTools, true); + assert.equal(seenParams.provider, "openrouter"); + assert.equal(seenParams.model, "example/model-one"); + assert.equal(seenParams.sessionFile, undefined, "current hosts refuse a non-key sessionFile for plugin runs"); + }); + + it("still hands the legacy runner a transcript file path", async () => { + const { generateReflectionText } = loadFreshIndex(); + let seenParams = null; + const api = { + runtime: { + agent: { + runEmbeddedPiAgent: async (params) => { + seenParams = params; + return { payloads: [{ text: "legacy reflection" }] }; + }, + }, + }, + }; + + const result = await generateReflectionText({ + conversation: "user: the build is green\nassistant: noted", + maxInputChars: 1000, + cfg: {}, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api, + }); + + assert.equal(result.runner, "embedded"); + assert.equal(typeof seenParams.sessionFile, "string"); + assert.ok(seenParams.sessionFile.endsWith(".jsonl"), seenParams.sessionFile); + }); +}); + +describe("tool-free completion fallback", () => { + const conversation = "user: the build is green\nassistant: noted"; + const failingApi = { + runtime: { + agent: { + runEmbeddedAgent: async () => { + throw new Error("embedded runner refused"); + }, + }, + }, + }; + + it("hands the reflection prompts to the completion when the embedded runner fails", async () => { + const { generateReflectionText } = loadFreshIndex(); + const seen = []; + const result = await generateReflectionText({ + conversation, + maxInputChars: 1000, + cfg: {}, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api: failingApi, + completeText: async (systemPrompt, userPrompt) => { + seen.push({ systemPrompt, userPrompt }); + return " distilled by completion "; + }, + }); + assert.equal(result.runner, "completion"); + assert.equal(result.usedFallback, false); + assert.equal(result.text, " distilled by completion "); + assert.equal(seen.length, 1, "one completion call"); + assert.ok(seen[0].systemPrompt.length > 0, "the distiller system prompt travels as the system message"); + assert.ok(seen[0].userPrompt.includes("the build is green"), "the transcript travels in the user prompt"); + assert.match(result.error ?? "", /embedded runner refused/); + }); + + it("falls through to the static fallback text when the completion returns nothing", async () => { + const { generateReflectionText } = loadFreshIndex(); + const result = await generateReflectionText({ + conversation, + maxInputChars: 1000, + cfg: {}, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api: failingApi, + completeText: async () => null, + }); + assert.equal(result.runner, "fallback"); + assert.equal(result.usedFallback, true); + assert.match(result.error ?? "", /completion returned no text/); + }); + + it("reports the missing completion client instead of reaching for a CLI", async () => { + const { generateReflectionText } = loadFreshIndex(); + const result = await generateReflectionText({ + conversation, + maxInputChars: 1000, + cfg: {}, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api: failingApi, + }); + assert.equal(result.runner, "fallback"); + assert.match(result.error ?? "", /no tool-free completion client/); + }); +}); + +describe("embedded runner cache", () => { + it("keeps the runner and its kind together across host surfaces", async () => { + const { generateReflectionText, getEmbeddedRunnerExportName } = loadFreshIndex(); + const legacyParams = []; + let currentCalls = 0; + const legacyApi = { + runtime: { + agent: { + runEmbeddedPiAgent: async (params) => { + legacyParams.push(params); + return { payloads: [{ text: "legacy" }] }; + }, + }, + }, + }; + const currentApi = { + runtime: { + agent: { + runEmbeddedAgent: async () => { + currentCalls += 1; + return { payloads: [{ text: "current" }] }; + }, + }, + }, + }; + const base = { conversation: "user: hi\nassistant: hello", maxInputChars: 1000, cfg: {}, agentId: "agent-one", workspaceDir: "/tmp", timeoutMs: 2000, thinkLevel: "off" }; + + const first = await generateReflectionText({ ...base, api: legacyApi }); + assert.equal(first.text, "legacy"); + assert.equal(getEmbeddedRunnerExportName(), "runEmbeddedPiAgent"); + assert.equal(typeof legacyParams[0].sessionFile, "string", "the legacy runner gets its transcript file"); + + const second = await generateReflectionText({ ...base, api: currentApi }); + assert.equal(second.text, "legacy", "the cached runner keeps serving"); + assert.equal(currentCalls, 0, "a later host surface does not replace the cached runner"); + assert.equal(getEmbeddedRunnerExportName(), "runEmbeddedPiAgent", "the cached kind stays with the cached runner"); + assert.equal(typeof legacyParams[1].sessionFile, "string", "the second run is still labeled legacy and keeps the transcript file"); + }); +}); diff --git a/test/windows-reflection-fallback.test.mjs b/test/windows-reflection-fallback.test.mjs index 4e4615f8..d9f0ee6d 100644 --- a/test/windows-reflection-fallback.test.mjs +++ b/test/windows-reflection-fallback.test.mjs @@ -16,7 +16,6 @@ const jiti = jitiFactory(import.meta.url, { const { toImportSpecifier, getExtensionApiImportSpecifiers, - buildReflectionCliSpawnCommand, } = jiti("../index.ts"); describe("Windows reflection fallback helpers", () => { @@ -58,20 +57,5 @@ describe("Windows reflection fallback helpers", () => { assert.ok(specifiers.some((s) => s.includes("Program%20Files/nodejs")), `Expected Program Files fallback: ${JSON.stringify(specifiers)}`); }); - it("spawns the OpenClaw CLI directly on POSIX platforms", () => { - const command = buildReflectionCliSpawnCommand("openclaw", ["agent", "--json"], "linux"); - assert.equal(command.command, "openclaw"); - assert.deepEqual(command.args, ["agent", "--json"]); - }); - it("resolves the OpenClaw CLI through cmd on Windows", () => { - const command = buildReflectionCliSpawnCommand( - "openclaw", - ["agent", "--json"], - "win32", - "C:\\Windows\\System32\\cmd.exe", - ); - assert.equal(command.command, "C:\\Windows\\System32\\cmd.exe"); - assert.deepEqual(command.args, ["/c", "openclaw", "agent", "--json"]); - }); });