openclaw-hybrid-memory 2026.7.53 → 2026.7.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -365,6 +365,11 @@ async function runPassiveObserver(factsDb, vectorDb, embeddings, openai, config,
365
365
  if (!chunk.trim()) continue;
366
366
  chunksAttempted++;
367
367
  result.chunksProcessed++;
368
+ const chunkProgressNowMs = Date.now();
369
+ if (chunkProgressNowMs - lastProgressLogMs >= PASSIVE_OBSERVER_PROGRESS_LOG_INTERVAL_MS) {
370
+ lastProgressLogMs = chunkProgressNowMs;
371
+ logger.info(`memory-hybrid: passive-observer — progress: session ${sessionIdx + 1}/${sessions.length} (${sessionId}) chunk ${chunksAttempted}/${chunks.length} chunksProcessed=${result.chunksProcessed} factsExtracted=${result.factsExtracted} factsStored=${result.factsStored}`);
372
+ }
368
373
  const filledPrompt = fillPrompt(prompt, {
369
374
  categories: allCategories.join(", "),
370
375
  transcript: chunk
@@ -1 +1 @@
1
- {"version":3,"file":"passive-observer.js","names":[],"sources":["../../services/passive-observer.ts"],"sourcesContent":["/**\n * Passive Observer Service — background fact extraction from session transcripts.\n *\n * Tails session JSONL logs, extracts facts via a cheap LLM, deduplicates against\n * recent stored facts (embedding similarity), and inserts to SQLite + LanceDB.\n *\n * Design differences from reflection:\n * - Trigger: automatic (interval) vs agent-initiated\n * - Input: raw transcripts vs already-stored facts\n * - Purpose: capture missed facts vs synthesize patterns\n * - Model: cheap nano tier vs session model\n */\n\nimport { existsSync, readdirSync } from \"node:fs\";\nimport { mkdir, open, readFile, stat, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type OpenAI from \"openai\";\nimport type { EventLog } from \"../backends/event-log.js\";\nimport { categoryToEventType } from \"../backends/event-log.js\";\nimport type { FactsDB } from \"../backends/facts-db.js\";\nimport type { VectorDB } from \"../backends/vector-db.js\";\nimport type { MemoryCategory, ReinforcementConfig } from \"../config.js\";\nimport { nowIso } from \"../utils/dates.js\";\nimport {\n capTimeoutByMaintenanceRunDeadline,\n getMaintenanceRunAbortSignal,\n maintenanceRunDeadlineReached,\n} from \"../utils/maintenance-run-deadline.js\";\nimport { fillPrompt, loadPrompt } from \"../utils/prompt-loader.js\";\nimport { chunkTextByChars } from \"../utils/text.js\";\nimport { runCaptureStoreWithDedupWindow } from \"./capture-dedup.js\";\nimport { chatCompleteWithRetry, LLMRetryError, resolveMaintenanceChatTimeoutMs } from \"./chat.js\";\nimport { CostFeature } from \"./cost-feature-labels.js\";\nimport type { EmbeddingProvider } from \"./embeddings.js\";\nimport { shouldSuppressEmbeddingError } from \"./embeddings.js\";\nimport { capturePluginError } from \"./error-reporter.js\";\nimport type { ProvenanceService } from \"./provenance.js\";\nimport {\n isSubstantiveMemoryText,\n prepareMemoryMetadataForStorage,\n prepareMemoryTextForStorage,\n} from \"./recalled-context-assembler.js\";\nimport { dotProductSimilarity, normalizeVector } from \"./reflection.js\";\nimport { cleanupEvictedVector } from \"./vector-maintenance.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PassiveObserverConfig {\n enabled: boolean;\n intervalMinutes: number;\n model?: string;\n maxCharsPerChunk: number;\n minImportance: number;\n deduplicationThreshold: number;\n sessionsDir?: string;\n}\n\n/** One extracted fact from the LLM response. */\nexport interface ExtractedFact {\n text: string;\n category: string;\n importance: number;\n}\n\n/** Per-session cursor: tracks byte offset into the session file. */\ntype SessionCursors = Record<string, number>;\n\ninterface ObserverRunResult {\n sessionsScanned: number;\n chunksProcessed: number;\n factsExtracted: number;\n factsStored: number;\n factsReinforced: number;\n errors: number;\n}\n\n// Track consecutive failures across runs to prevent infinite retries on bad session files.\nconst consecutiveFailures = new Map<string, number>();\n\n/** Returns true when the error is an ENOENT (file not found) OS error. */\nconst isEnoent = (err: unknown): boolean => (err as NodeJS.ErrnoException).code === \"ENOENT\";\n\n/**\n * Whether a basename under the OpenClaw sessions dir should be scanned for passive extraction.\n * Excludes `.checkpoint.` sidecars (often a single multi‑MB JSON line — not chat transcripts)\n * and `.deleted*` tombstones (same convention as procedure-extractor).\n */\nexport function isPassiveObserverTranscriptCandidate(basename: string): boolean {\n return (\n basename.endsWith(\".jsonl\") &&\n !basename.startsWith(\".deleted\") &&\n !basename.includes(\".checkpoint.\") &&\n !basename.includes(\".trajectory.\")\n );\n}\n\n// ---------------------------------------------------------------------------\n// JSONL text extraction\n// ---------------------------------------------------------------------------\n\n/** Maximum length per message when building the transcript block. */\nconst MAX_MSG_LENGTH = 500;\n/** Hard cap on bytes read per session per run to avoid unbounded JSONL reads. */\nconst MAX_JSONL_BYTES_PER_RUN = 2_000_000;\n/** Per-session progress log throttle, matching the orchestrator's per-step heartbeat cadence (#2032). */\nconst PASSIVE_OBSERVER_PROGRESS_LOG_INTERVAL_MS = 60_000;\n\n/**\n * Extract readable text from a raw JSONL transcript chunk.\n * Pulls user messages and assistant text blocks — skips tool calls and results\n * to keep the prompt focused on natural language content.\n */\nexport function extractTextFromJsonlChunk(chunk: string): string {\n const lines = chunk.split(\"\\n\").filter((l) => l.trim());\n const parts: string[] = [];\n\n for (const line of lines) {\n let obj: unknown;\n try {\n obj = JSON.parse(line);\n } catch {\n continue;\n }\n if (!obj || typeof obj !== \"object\") continue;\n\n const msg = (obj as Record<string, unknown>).message as Record<string, unknown> | undefined;\n if (!msg || typeof msg !== \"object\") continue;\n\n const role = msg.role as string | undefined;\n const rawContent = msg.content;\n\n // Plain string user message\n if (role === \"user\" && typeof rawContent === \"string\" && rawContent.trim()) {\n parts.push(`user: ${rawContent.trim().slice(0, MAX_MSG_LENGTH)}`);\n continue;\n }\n\n // Plain string assistant message\n if (role === \"assistant\" && typeof rawContent === \"string\" && rawContent.trim()) {\n parts.push(`assistant: ${rawContent.trim().slice(0, MAX_MSG_LENGTH)}`);\n continue;\n }\n\n if (!Array.isArray(rawContent)) continue;\n\n const blocks = rawContent as Array<Record<string, unknown>>;\n for (const block of blocks) {\n if (!block || typeof block !== \"object\") continue;\n const type = block.type as string | undefined;\n\n // User text block\n if (role === \"user\" && type === \"text\" && typeof block.text === \"string\" && block.text.trim()) {\n parts.push(`user: ${block.text.trim().slice(0, MAX_MSG_LENGTH)}`);\n }\n\n // Assistant text block (not tool calls)\n if (role === \"assistant\" && type === \"text\" && typeof block.text === \"string\" && block.text.trim()) {\n parts.push(`assistant: ${block.text.trim().slice(0, MAX_MSG_LENGTH)}`);\n }\n }\n }\n\n return parts.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Cursor management\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_CURSORS_FILENAME = \".passive-observer-cursors.json\";\n\nexport function getCursorsPath(dbDir: string): string {\n return join(dbDir, DEFAULT_CURSORS_FILENAME);\n}\n\nexport async function loadCursors(cursorsPath: string): Promise<SessionCursors> {\n try {\n const raw = await readFile(cursorsPath, \"utf-8\");\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n const cursors: SessionCursors = {};\n for (const [k, v] of Object.entries(parsed)) {\n if (typeof v === \"number\" && v >= 0) {\n cursors[k] = v;\n }\n }\n return cursors;\n }\n return {};\n } catch {\n return {};\n }\n}\n\nexport async function saveCursors(cursorsPath: string, cursors: SessionCursors): Promise<void> {\n const dir = dirname(cursorsPath);\n await mkdir(dir, { recursive: true });\n await writeFile(cursorsPath, JSON.stringify(cursors, null, 2), \"utf-8\");\n}\n\n// ---------------------------------------------------------------------------\n// LLM extraction\n// ---------------------------------------------------------------------------\n\nconst OBSERVER_TEMPERATURE = 0.15;\nconst OBSERVER_MAX_TOKENS = 1200;\n\n/**\n * Parse the LLM JSON response into extracted facts.\n * Expects a JSON array of { text, category, importance } objects.\n */\nexport function parseObserverResponse(raw: string, categories: string[]): ExtractedFact[] {\n const validCategories = new Set<string>(categories.map((c) => c.toLowerCase()));\n\n // Extract JSON from response (may be wrapped in markdown code fence)\n const jsonMatch = raw.match(/```(?:json)?\\s*([\\s\\S]*?)```/);\n const jsonStr = jsonMatch ? jsonMatch[1].trim() : raw.trim();\n\n let parsed: unknown;\n try {\n // Find the JSON array portion\n const start = jsonStr.indexOf(\"[\");\n const end = jsonStr.lastIndexOf(\"]\");\n if (start === -1 || end === -1) return [];\n parsed = JSON.parse(jsonStr.slice(start, end + 1));\n } catch {\n return [];\n }\n\n if (!Array.isArray(parsed)) return [];\n\n const facts: ExtractedFact[] = [];\n for (const item of parsed) {\n if (!item || typeof item !== \"object\") continue;\n const obj = item as Record<string, unknown>;\n\n const text = typeof obj.text === \"string\" ? obj.text.trim() : \"\";\n if (!text || text.length < 10) continue;\n\n const importanceRaw =\n typeof obj.importance === \"number\" ? obj.importance : Number.parseFloat(String(obj.importance));\n // Default to 0.0 when importance is missing/invalid — forces the LLM to explicitly assign\n // importance rather than having invalid/missing values silently pass the minImportance filter.\n const importance = Number.isFinite(importanceRaw) ? Math.max(0, Math.min(1, importanceRaw)) : 0.0;\n\n const categoryRaw = typeof obj.category === \"string\" ? obj.category.toLowerCase().trim() : \"fact\";\n const category = validCategories.has(categoryRaw) ? categoryRaw : \"fact\";\n\n facts.push({ text, category, importance });\n }\n return facts;\n}\n\n// ---------------------------------------------------------------------------\n// Identity fact detection (Issue #306)\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when a fact describes the agent's own identity (email, name, role, etc.).\n * These facts should be stored as global/permanent instead of session-scoped.\n *\n * @param text - The fact text to classify.\n * @param agentName - Optional known agent name. When provided, adds a\n * name-specific pattern so \"[AgentName]'s email is …\" is also detected.\n */\nexport function isIdentityFact(text: string, agentName?: string): boolean {\n const patterns: RegExp[] = [\n /(?:my|your|the (?:agent|assistant|bot)(?:'s)?)\\s+(?:email|name|role|account|address|phone|number)/i,\n /(?<!(?:user|customer|their|his|her|[a-z]+)'s\\s)(?<!(?:their|his|her)\\s)(?:email|account|address|role)\\s+(?:is|was|:)\\s/i,\n ];\n if (agentName) {\n const escaped = agentName.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n patterns.push(new RegExp(`${escaped}(?:'s)?\\\\s+(?:email|name|role|account)`, \"i\"));\n }\n return patterns.some((p) => p.test(text));\n}\n\nconst OPENCLAW_AGENTS_MARKER = \"/.openclaw/agents/\";\n\n/** List session JSONL paths for passive observer (single configured dir or all agents). */\nexport function listPassiveObserverSessionFilePaths(\n sessionsDir: string | undefined,\n proceduresSessionsDir: string | undefined,\n): { filePaths: string[]; multiAgentScan: boolean } {\n const explicitDir = sessionsDir ?? proceduresSessionsDir;\n if (explicitDir) {\n if (!existsSync(explicitDir)) return { filePaths: [], multiAgentScan: false };\n try {\n const filePaths = readdirSync(explicitDir)\n .filter(isPassiveObserverTranscriptCandidate)\n .sort()\n .map((f) => join(explicitDir, f));\n return { filePaths, multiAgentScan: false };\n } catch {\n return { filePaths: [], multiAgentScan: false };\n }\n }\n\n const agentsDir = join(homedir(), \".openclaw\", \"agents\");\n if (!existsSync(agentsDir)) return { filePaths: [], multiAgentScan: true };\n const filePaths: string[] = [];\n try {\n for (const agentEntry of readdirSync(agentsDir, { withFileTypes: true })) {\n if (!agentEntry.isDirectory()) continue;\n const dir = join(agentsDir, agentEntry.name, \"sessions\");\n if (!existsSync(dir)) continue;\n for (const f of readdirSync(dir)) {\n if (isPassiveObserverTranscriptCandidate(f)) {\n filePaths.push(join(dir, f));\n }\n }\n }\n } catch {\n return { filePaths: [], multiAgentScan: true };\n }\n filePaths.sort();\n return { filePaths, multiAgentScan: true };\n}\n\n/** Stable cursor / scope key for a session file (agent-relative when scanning all agents). */\nexport function deriveObserverSessionKey(filePath: string, multiAgentScan: boolean): string | null {\n const normalized = filePath.replace(/\\\\/g, \"/\");\n if (multiAgentScan) {\n const markerIdx = normalized.indexOf(OPENCLAW_AGENTS_MARKER);\n if (markerIdx >= 0) {\n const rel = normalized.slice(markerIdx + OPENCLAW_AGENTS_MARKER.length);\n return rel.replace(/\\.jsonl$/, \"\");\n }\n }\n const base = normalized.split(\"/\").pop()?.replace(\".jsonl\", \"\");\n return base ?? null;\n}\n\n// ---------------------------------------------------------------------------\n// Core run\n// ---------------------------------------------------------------------------\n\nexport async function runPassiveObserver(\n factsDb: FactsDB,\n vectorDb: VectorDB,\n embeddings: EmbeddingProvider,\n openai: OpenAI,\n config: PassiveObserverConfig,\n allCategories: string[],\n opts: {\n model: string;\n fallbackModels?: string[];\n dbDir: string;\n dryRun?: boolean;\n /** Fallback sessions dir from procedures config (used when config.sessionsDir is not set). */\n proceduresSessionsDir?: string;\n /** Confidence reinforcement config (Issue #147). When set and enabled, similar facts get confidence boost instead of silent skip. */\n reinforcement?: ReinforcementConfig;\n /** Provenance tracking (Issue #163). */\n provenanceService?: ProvenanceService | null;\n /** Episodic event log (Issue #150). When set, each stored fact is also appended to Layer 1. */\n eventLog?: EventLog | null;\n /** Agent's own name. Used by isIdentityFact() for name-specific detection. */\n agentName?: string;\n /** Observation dedup window in minutes (#1913). */\n dedupWindowMinutes?: number;\n },\n logger: { info: (msg: string) => void; warn: (msg: string) => void },\n): Promise<ObserverRunResult> {\n const result: ObserverRunResult = {\n sessionsScanned: 0,\n chunksProcessed: 0,\n factsExtracted: 0,\n factsStored: 0,\n factsReinforced: 0,\n errors: 0,\n };\n\n const explicitSessionsDir = config.sessionsDir ?? opts.proceduresSessionsDir;\n const { filePaths, multiAgentScan } = listPassiveObserverSessionFilePaths(\n config.sessionsDir,\n opts.proceduresSessionsDir,\n );\n\n if (filePaths.length === 0 && explicitSessionsDir && !existsSync(explicitSessionsDir)) {\n logger.info(`memory-hybrid: passive-observer — sessions dir not found: ${explicitSessionsDir}`);\n return result;\n }\n\n if (filePaths.length === 0 && !multiAgentScan) {\n return result;\n }\n\n if (filePaths.length === 0 && multiAgentScan) {\n logger.info(\"memory-hybrid: passive-observer — no session files found under ~/.openclaw/agents/*/sessions\");\n return result;\n }\n\n // Prune stale consecutiveFailures entries before any early returns, so sessions that\n // disappear from disk (or when there are no session files at all) get cleaned up every tick.\n {\n const activeIds = new Set(\n filePaths.map((fp) => deriveObserverSessionKey(fp, multiAgentScan)).filter((id): id is string => id != null),\n );\n for (const id of consecutiveFailures.keys()) {\n if (!activeIds.has(id)) consecutiveFailures.delete(id);\n }\n }\n\n if (filePaths.length === 0) return result;\n\n const cursorsPath = getCursorsPath(opts.dbDir);\n const cursors = await loadCursors(cursorsPath);\n let cursorsChanged = false;\n // Separate in-memory map for consecutive failure counts — not persisted to the cursors file\n // to avoid mixing byte-offset semantics with failure-count semantics in the same structure.\n\n // ---------------------------------------------------------------------------\n // Phase 1: scan all session files, count sessions, detect whether any have\n // new content. We use stat() to get file sizes without loading entire files\n // into memory (files are read lazily in Phase 3 only when needed).\n // ---------------------------------------------------------------------------\n interface SessionInfo {\n filePath: string;\n sessionId: string;\n fileBytelen: number;\n cursor: number;\n }\n\n const sessions: SessionInfo[] = [];\n const activeSessionIds = new Set<string>();\n let hasNewContent = false;\n\n for (const filePath of filePaths) {\n const sessionId = deriveObserverSessionKey(filePath, multiAgentScan);\n if (!sessionId) continue;\n activeSessionIds.add(sessionId);\n let fileBytelen: number;\n try {\n const stats = await stat(filePath);\n fileBytelen = stats.size;\n } catch (err) {\n if (isEnoent(err)) {\n // File was pruned by session.maintenance between readdirSync and stat — skip silently.\n logger.info(`memory-hybrid: passive-observer — session ${sessionId} was pruned, skipping`);\n continue;\n }\n logger.warn(`memory-hybrid: passive-observer — failed to stat session ${sessionId}: ${err}`);\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-stat\",\n subsystem: \"passive-observer\",\n });\n result.errors++;\n continue;\n }\n\n // sessionsScanned counts sessions whose stat succeeded. A later open ENOENT (Phase 3)\n // will still leave this counter incremented, so operators may observe\n // sessionsScanned > factsStored with no errors — this is intentional and expected.\n result.sessionsScanned++;\n const cursor = cursors[sessionId] ?? 0;\n\n if (cursor < fileBytelen) {\n hasNewContent = true;\n }\n\n sessions.push({ filePath, sessionId, fileBytelen, cursor });\n }\n\n if (!hasNewContent) return result;\n\n const reinforcementEnabled = opts.reinforcement?.enabled !== false && opts.reinforcement != null;\n const passiveBoost = opts.reinforcement?.passiveBoost ?? 0.1;\n const maxConfidence = opts.reinforcement?.maxConfidence ?? 1.0;\n const cosineSimilarityThreshold = opts.reinforcement?.similarityThreshold ?? config.deduplicationThreshold;\n // Convert cosine similarity threshold to L2-based score for vectorDb.search().\n // VectorDB uses score = 1/(1+L2_distance). For normalized vectors, L2 = sqrt(2*(1-cosine)).\n // This conversion ensures the dedup threshold behaves as originally calibrated (issue #499).\n const similarityThreshold = 1 / (1 + Math.sqrt(2 * (1 - cosineSimilarityThreshold)));\n\n const prompt = loadPrompt(\"passive-observer\");\n\n // In-memory dedup pool for dry-run mode (Issue #499): during dry-run, facts are not written\n // to LanceDB, so vectorDb.search() cannot find facts extracted earlier in the same batch.\n // This array tracks embeddings of facts that would be stored in the current run, enabling\n // intra-batch deduplication so dry-run accurately previews real-run behavior.\n const dryRunVectors: number[][] = [];\n\n // In-memory dedup pool for non-dry-run mode: when vectorDb.store() fails, the fact is\n // committed to SQLite but not to LanceDB. This array provides a fallback intra-batch\n // dedup mechanism so subsequent identical facts within the same batch are still detected.\n // Track fact IDs alongside vectors to enable confidence reinforcement (Issue #147).\n const recentVectors: Array<{ vector: number[]; factId: string }> = [];\n\n // ---------------------------------------------------------------------------\n // Phase 3: process each session that has new content.\n // ---------------------------------------------------------------------------\n // Progress is throttled to the same cadence as the orchestrator's per-step heartbeat (#2026's\n // ORCHESTRATOR_STEP_HEARTBEAT_MS) so a large multi-agent backlog logs a couple dozen lines over\n // the default 15-minute step budget instead of one line per session (#2032).\n let lastProgressLogMs = 0;\n for (const [sessionIdx, { filePath, sessionId, fileBytelen, cursor }] of sessions.entries()) {\n if (maintenanceRunDeadlineReached()) {\n // Deadline stops mid-run are counted as an error so the caller's summary (and\n // assertPassiveObserverSummaryDoesNotBlock) surface a truncated run as a failure\n // instead of a silent \"ok\" that hides unprocessed sessions (#2032).\n result.errors++;\n logger.warn(\n \"memory-hybrid: passive-observer — maintenance run deadline reached; stopping session scan \" +\n `(processed ${sessionIdx}/${sessions.length} sessions, sessionsScanned=${result.sessionsScanned}, ` +\n `chunksProcessed=${result.chunksProcessed}, factsStored=${result.factsStored})`,\n );\n break;\n }\n if (cursor >= fileBytelen) continue; // Nothing new\n const progressNowMs = Date.now();\n if (lastProgressLogMs === 0 || progressNowMs - lastProgressLogMs >= PASSIVE_OBSERVER_PROGRESS_LOG_INTERVAL_MS) {\n lastProgressLogMs = progressNowMs;\n logger.info(\n `memory-hybrid: passive-observer — progress: session ${sessionIdx + 1}/${sessions.length} (${sessionId}) ` +\n `chunksProcessed=${result.chunksProcessed} factsExtracted=${result.factsExtracted} factsStored=${result.factsStored}`,\n );\n }\n\n let rawBuf: Buffer;\n let segmentEnd = fileBytelen;\n try {\n const maxBytes = Math.min(MAX_JSONL_BYTES_PER_RUN, Math.max(200_000, config.maxCharsPerChunk * 8));\n const endOffset = Math.min(fileBytelen, cursor + maxBytes);\n const length = endOffset - cursor;\n if (length <= 0) continue;\n const handle = await open(filePath, \"r\");\n try {\n rawBuf = Buffer.alloc(length);\n const { bytesRead } = await handle.read(rawBuf, 0, length, cursor);\n if (bytesRead === 0) {\n continue;\n }\n if (bytesRead < length) {\n rawBuf = rawBuf.subarray(0, bytesRead);\n }\n } finally {\n await handle.close();\n }\n const lastNewlineIdx = rawBuf.lastIndexOf(0x0a);\n if (lastNewlineIdx === -1 && endOffset < fileBytelen) {\n logger.warn(`memory-hybrid: passive-observer — skipping oversized JSONL line in session ${sessionId}`);\n cursors[sessionId] = endOffset;\n cursorsChanged = true;\n continue;\n }\n const sliceEnd = lastNewlineIdx === -1 ? rawBuf.length : lastNewlineIdx + 1;\n rawBuf = rawBuf.subarray(0, sliceEnd);\n segmentEnd = cursor + sliceEnd;\n } catch (err) {\n if (isEnoent(err)) {\n // File was pruned by session.maintenance between stat and open — skip silently.\n logger.info(\n `memory-hybrid: passive-observer — session ${sessionId} was pruned between scan and read, skipping`,\n );\n continue;\n }\n logger.warn(`memory-hybrid: passive-observer — failed to read session ${sessionId}: ${err}`);\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-read\",\n subsystem: \"passive-observer\",\n });\n result.errors++;\n continue;\n }\n\n const newContent = rawBuf.toString(\"utf-8\");\n if (!newContent.trim()) {\n cursors[sessionId] = segmentEnd;\n cursorsChanged = true;\n continue;\n }\n\n // Extract human-readable text from JSONL\n const textBlock = extractTextFromJsonlChunk(newContent);\n if (!textBlock.trim()) {\n cursors[sessionId] = segmentEnd;\n cursorsChanged = true;\n continue;\n }\n\n // Chunk the text block\n const chunks = chunkTextByChars(textBlock, config.maxCharsPerChunk, Math.floor(config.maxCharsPerChunk * 0.05));\n\n let chunksAttempted = 0;\n let chunksSucceeded = 0;\n\n let deadlineStopped = false;\n\n for (const chunk of chunks) {\n if (maintenanceRunDeadlineReached()) {\n logger.warn(\n `memory-hybrid: passive-observer — maintenance run deadline reached; deferring session ${sessionId}`,\n );\n deadlineStopped = true;\n result.errors++;\n break;\n }\n if (!chunk.trim()) continue;\n chunksAttempted++;\n result.chunksProcessed++;\n\n const filledPrompt = fillPrompt(prompt, {\n categories: allCategories.join(\", \"),\n transcript: chunk,\n });\n\n let rawResponse: string;\n try {\n rawResponse = await chatCompleteWithRetry({\n model: opts.model,\n content: filledPrompt,\n temperature: OBSERVER_TEMPERATURE,\n maxTokens: OBSERVER_MAX_TOKENS,\n openai,\n fallbackModels: opts.fallbackModels ?? [],\n label: \"memory-hybrid: passive-observer\",\n feature: CostFeature.passiveObserver,\n timeoutMs: capTimeoutByMaintenanceRunDeadline(resolveMaintenanceChatTimeoutMs(opts.model)),\n signal: getMaintenanceRunAbortSignal(),\n });\n } catch (err) {\n logger.warn(`memory-hybrid: passive-observer — LLM failed for session ${sessionId}: ${err}`);\n const retryAttempt = err instanceof LLMRetryError ? err.attemptNumber : 1;\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-llm\",\n subsystem: \"passive-observer\",\n retryAttempt,\n });\n result.errors++;\n continue;\n }\n\n chunksSucceeded++;\n\n const facts = parseObserverResponse(rawResponse, allCategories);\n const filtered = facts.filter((f) => f.importance >= config.minImportance);\n result.factsExtracted += filtered.length;\n\n for (const fact of filtered) {\n const storedText = prepareMemoryTextForStorage(fact.text, config.maxCharsPerChunk);\n if (!storedText || !isSubstantiveMemoryText(storedText)) continue;\n const storedCategory =\n (prepareMemoryMetadataForStorage(fact.category) as MemoryCategory | undefined) ?? fact.category;\n const identity = isIdentityFact(storedText, opts.agentName);\n\n // Embed new fact for dedup check\n let vec: number[];\n try {\n vec = await embeddings.embed(storedText);\n } catch (err) {\n logger.warn(\n `memory-hybrid: passive-observer — embed failed for fact: ${storedText.slice(0, 80)}... (${err})`,\n );\n // AllEmbeddingProvidersFailed is expected when all providers are unavailable — don't report (#486)\n if (!shouldSuppressEmbeddingError(err)) {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-embed\",\n severity: \"info\",\n subsystem: \"passive-observer\",\n });\n }\n result.errors++;\n continue;\n }\n\n // Normalize the vector to ensure the L2-to-cosine conversion is valid.\n // The conversion formula (1 / (1 + sqrt(2*(1-cosine)))) assumes unit-length vectors.\n // While OpenAI embeddings are pre-normalized, Ollama and some other providers return\n // unnormalized vectors, causing the L2 distance to be scaled by vector magnitude.\n const normalizedVec = normalizeVector(vec);\n const dedupScope = identity ? \"global\" : \"session\";\n const dedupScopeTarget = identity ? null : sessionId;\n\n // Dedup check via LanceDB ANN search — O(log n) instead of O(n*m) brute-force.\n // Replaces the old recentVectors[] linear scan and eliminates the embedBatch()\n // call on the recent-facts pool. LanceDB is the single source of truth.\n let isDuplicate = false;\n try {\n const dupeResults = await vectorDb.search(normalizedVec, 1, similarityThreshold);\n if (dupeResults.length > 0) {\n const matchedId = dupeResults[0].entry.id;\n const matched = matchedId ? factsDb.getById(matchedId) : null;\n const nowSec = Math.floor(Date.now() / 1000);\n const scopeMatches =\n matched != null &&\n matched.supersededAt == null &&\n (matched.expiresAt == null || matched.expiresAt > nowSec) &&\n (matched.scope ?? \"global\") === dedupScope &&\n (dedupScope === \"global\" ? null : (matched.scopeTarget ?? null)) === dedupScopeTarget;\n if (scopeMatches) {\n isDuplicate = true;\n // Confidence reinforcement: boost the matched fact instead of silently skipping (Issue #147)\n if (reinforcementEnabled && !opts.dryRun) {\n if (matchedId) {\n try {\n const boosted = factsDb.boostConfidence(matchedId, passiveBoost, maxConfidence);\n if (boosted) result.factsReinforced++;\n } catch {\n // Non-fatal — don't fail passive observer because of boost error\n }\n }\n }\n }\n }\n } catch {\n // On search failure, proceed without dedup — a few duplicates are acceptable\n }\n\n // Intra-batch dedup: check against facts stored/attempted in this run.\n // In dry-run mode, vectorDb.store() is never called, so vectorDb.search() won't find\n // facts extracted earlier in the same batch. In non-dry-run mode, if vectorDb.store()\n // fails, the fact is in SQLite but not LanceDB, so vectorDb.search() also won't find it.\n // Compare against dryRunVectors[] (dry-run) or recentVectors[] (non-dry-run) to ensure\n // accurate intra-batch deduplication (Issue #499).\n if (!isDuplicate) {\n if (opts.dryRun) {\n for (const recentVec of dryRunVectors) {\n const cosineSim = dotProductSimilarity(normalizedVec, recentVec);\n if (cosineSim >= cosineSimilarityThreshold) {\n isDuplicate = true;\n break;\n }\n }\n } else {\n for (const recent of recentVectors) {\n const cosineSim = dotProductSimilarity(normalizedVec, recent.vector);\n if (cosineSim >= cosineSimilarityThreshold) {\n isDuplicate = true;\n // Confidence reinforcement: boost the matched fact (Issue #147)\n if (reinforcementEnabled) {\n try {\n const boosted = factsDb.boostConfidence(recent.factId, passiveBoost, maxConfidence);\n if (boosted) result.factsReinforced++;\n } catch {\n // Non-fatal — don't fail passive observer because of boost error\n }\n }\n break;\n }\n }\n }\n }\n\n if (isDuplicate) continue;\n\n if (opts.dryRun) {\n logger.info(\n `memory-hybrid: passive-observer [dry-run] would store: ${storedText.slice(0, 60)}... (importance=${fact.importance.toFixed(2)}, category=${storedCategory})`,\n );\n result.factsStored++;\n dryRunVectors.push(normalizedVec);\n continue;\n }\n\n // Write episodic event FIRST (Issue #150, Layer 1): record before factsDb so that if\n // factsDb fails we still have the event record. If eventLog fails we continue to store\n // the fact — event log unavailability must never block fact writes.\n let eventId: string | null = null;\n if (opts.eventLog) {\n try {\n eventId = opts.eventLog.append({\n sessionId,\n timestamp: nowIso(),\n eventType: categoryToEventType(fact.category),\n content: {\n text: storedText,\n category: storedCategory,\n importance: fact.importance,\n source: \"passive-observer\",\n },\n });\n } catch {\n // Non-fatal — event log write failure must never block fact storage\n }\n }\n\n // Identity fact promotion (Issue #306): if this fact describes the agent itself,\n // store it as global/permanent so it persists across sessions.\n if (identity) {\n logger.info(`passive-observer: promoting identity fact to global/permanent: \"${storedText.slice(0, 60)}...\"`);\n }\n\n const dedupWindow = opts.dedupWindowMinutes ?? 30;\n const dedupInput = {\n text: storedText,\n entity: null,\n key: null,\n scope: dedupScope,\n scopeTarget: dedupScopeTarget,\n };\n\n const txResult = runCaptureStoreWithDedupWindow(factsDb, dedupWindow, dedupInput, () =>\n factsDb.storeWithResult({\n text: storedText,\n category: storedCategory,\n importance: identity ? Math.max(fact.importance, 0.9) : fact.importance,\n confidence: 0.6,\n entity: null,\n key: null,\n value: null,\n source: \"passive-observer\",\n decayClass: identity ? \"permanent\" : \"session\",\n scope: identity ? \"global\" : \"session\",\n scopeTarget: identity ? undefined : sessionId,\n tags: [\"passive-observer\"],\n provenanceSession: sessionId,\n extractionMethod: \"passive\",\n extractionConfidence: fact.importance,\n }),\n );\n if (txResult.skipped) {\n continue;\n }\n\n const storeResult = txResult.storeResult!;\n if (storeResult.skipped) {\n continue;\n }\n if (storeResult.newlyStored === false && !storeResult.embeddingStale) {\n continue;\n }\n const stored = storeResult.entry;\n await cleanupEvictedVector({\n vectorDb,\n evictedFactId: storeResult.evictedFactId,\n logger,\n context: \"passive-observer\",\n });\n\n if (opts.provenanceService && storeResult.newlyStored) {\n try {\n opts.provenanceService.addEdge(stored.id, {\n edgeType: \"DERIVED_FROM\",\n sourceType: \"event_log\",\n sourceId: eventId ?? sessionId,\n sourceText: storedText,\n });\n } catch (err) {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-provenance\",\n subsystem: \"provenance\",\n factId: stored.id,\n });\n }\n }\n\n // Contradiction detection (Issue #142): check for same entity+key with different value.\n // For global/permanent identity facts, pass null scope so detection spans all scopes.\n if (storeResult.newlyStored) {\n factsDb.detectContradictions(stored.id, null, null, null, stored.scope ?? null, stored.scopeTarget ?? null);\n }\n\n // Store to LanceDB (use normalized vector for consistent L2 distance metric)\n try {\n const vectorText = storeResult.embeddingStale ? stored.text : fact.text;\n const vectorForStore = storeResult.embeddingStale ? await embeddings.embed(stored.text) : normalizedVec;\n await vectorDb.store({\n text: vectorText,\n vector: vectorForStore,\n importance: identity ? Math.max(fact.importance, 0.9) : fact.importance,\n category: fact.category,\n id: stored.id,\n });\n factsDb.setEmbeddingModel(stored.id, embeddings.modelName);\n } catch (err) {\n logger.warn(`memory-hybrid: passive-observer vector store failed: ${err}`);\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-vector-store\",\n subsystem: \"vector\",\n factId: stored.id,\n });\n }\n\n // Track vector for intra-batch dedup: whether vectorDb.store() succeeded or failed,\n // add to recentVectors[] so subsequent identical facts in this batch are detected.\n if (storeResult.newlyStored) {\n recentVectors.push({ vector: normalizedVec, factId: stored.id });\n result.factsStored++;\n }\n }\n }\n\n if (deadlineStopped) {\n break;\n }\n\n // Advance cursor only when every attempted chunk in this segment succeeded (or there were none).\n // Partial success must not advance — failed chunks would be skipped permanently.\n if (chunksAttempted === 0 || chunksSucceeded === chunksAttempted) {\n cursors[sessionId] = segmentEnd;\n consecutiveFailures.delete(sessionId);\n cursorsChanged = true;\n } else if (chunksSucceeded === 0) {\n const failures = (consecutiveFailures.get(sessionId) ?? 0) + 1;\n consecutiveFailures.set(sessionId, failures);\n if (failures >= 3) {\n // Advance past the problematic content after 3 consecutive failures to prevent\n // an infinite retry loop that wastes LLM tokens on permanently-bad session files.\n logger.warn(\n `memory-hybrid: passive-observer — advancing cursor for session ${sessionId} after ${failures} consecutive failures`,\n );\n cursors[sessionId] = segmentEnd;\n consecutiveFailures.delete(sessionId);\n cursorsChanged = true;\n }\n }\n }\n\n if (cursorsChanged) {\n try {\n await saveCursors(cursorsPath, cursors);\n } catch (err) {\n logger.warn(`memory-hybrid: passive-observer — failed to save cursors: ${err}`);\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-save-cursors\",\n subsystem: \"passive-observer\",\n });\n }\n }\n\n return result;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"passive-observer.js","names":[],"sources":["../../services/passive-observer.ts"],"sourcesContent":["/**\n * Passive Observer Service — background fact extraction from session transcripts.\n *\n * Tails session JSONL logs, extracts facts via a cheap LLM, deduplicates against\n * recent stored facts (embedding similarity), and inserts to SQLite + LanceDB.\n *\n * Design differences from reflection:\n * - Trigger: automatic (interval) vs agent-initiated\n * - Input: raw transcripts vs already-stored facts\n * - Purpose: capture missed facts vs synthesize patterns\n * - Model: cheap nano tier vs session model\n */\n\nimport { existsSync, readdirSync } from \"node:fs\";\nimport { mkdir, open, readFile, stat, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type OpenAI from \"openai\";\nimport type { EventLog } from \"../backends/event-log.js\";\nimport { categoryToEventType } from \"../backends/event-log.js\";\nimport type { FactsDB } from \"../backends/facts-db.js\";\nimport type { VectorDB } from \"../backends/vector-db.js\";\nimport type { MemoryCategory, ReinforcementConfig } from \"../config.js\";\nimport { nowIso } from \"../utils/dates.js\";\nimport {\n capTimeoutByMaintenanceRunDeadline,\n getMaintenanceRunAbortSignal,\n maintenanceRunDeadlineReached,\n} from \"../utils/maintenance-run-deadline.js\";\nimport { fillPrompt, loadPrompt } from \"../utils/prompt-loader.js\";\nimport { chunkTextByChars } from \"../utils/text.js\";\nimport { runCaptureStoreWithDedupWindow } from \"./capture-dedup.js\";\nimport { chatCompleteWithRetry, LLMRetryError, resolveMaintenanceChatTimeoutMs } from \"./chat.js\";\nimport { CostFeature } from \"./cost-feature-labels.js\";\nimport type { EmbeddingProvider } from \"./embeddings.js\";\nimport { shouldSuppressEmbeddingError } from \"./embeddings.js\";\nimport { capturePluginError } from \"./error-reporter.js\";\nimport type { ProvenanceService } from \"./provenance.js\";\nimport {\n isSubstantiveMemoryText,\n prepareMemoryMetadataForStorage,\n prepareMemoryTextForStorage,\n} from \"./recalled-context-assembler.js\";\nimport { dotProductSimilarity, normalizeVector } from \"./reflection.js\";\nimport { cleanupEvictedVector } from \"./vector-maintenance.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PassiveObserverConfig {\n enabled: boolean;\n intervalMinutes: number;\n model?: string;\n maxCharsPerChunk: number;\n minImportance: number;\n deduplicationThreshold: number;\n sessionsDir?: string;\n}\n\n/** One extracted fact from the LLM response. */\nexport interface ExtractedFact {\n text: string;\n category: string;\n importance: number;\n}\n\n/** Per-session cursor: tracks byte offset into the session file. */\ntype SessionCursors = Record<string, number>;\n\ninterface ObserverRunResult {\n sessionsScanned: number;\n chunksProcessed: number;\n factsExtracted: number;\n factsStored: number;\n factsReinforced: number;\n errors: number;\n}\n\n// Track consecutive failures across runs to prevent infinite retries on bad session files.\nconst consecutiveFailures = new Map<string, number>();\n\n/** Returns true when the error is an ENOENT (file not found) OS error. */\nconst isEnoent = (err: unknown): boolean => (err as NodeJS.ErrnoException).code === \"ENOENT\";\n\n/**\n * Whether a basename under the OpenClaw sessions dir should be scanned for passive extraction.\n * Excludes `.checkpoint.` sidecars (often a single multi‑MB JSON line — not chat transcripts)\n * and `.deleted*` tombstones (same convention as procedure-extractor).\n */\nexport function isPassiveObserverTranscriptCandidate(basename: string): boolean {\n return (\n basename.endsWith(\".jsonl\") &&\n !basename.startsWith(\".deleted\") &&\n !basename.includes(\".checkpoint.\") &&\n !basename.includes(\".trajectory.\")\n );\n}\n\n// ---------------------------------------------------------------------------\n// JSONL text extraction\n// ---------------------------------------------------------------------------\n\n/** Maximum length per message when building the transcript block. */\nconst MAX_MSG_LENGTH = 500;\n/** Hard cap on bytes read per session per run to avoid unbounded JSONL reads. */\nconst MAX_JSONL_BYTES_PER_RUN = 2_000_000;\n/** Per-session progress log throttle, matching the orchestrator's per-step heartbeat cadence (#2032). */\nconst PASSIVE_OBSERVER_PROGRESS_LOG_INTERVAL_MS = 60_000;\n\n/**\n * Extract readable text from a raw JSONL transcript chunk.\n * Pulls user messages and assistant text blocks — skips tool calls and results\n * to keep the prompt focused on natural language content.\n */\nexport function extractTextFromJsonlChunk(chunk: string): string {\n const lines = chunk.split(\"\\n\").filter((l) => l.trim());\n const parts: string[] = [];\n\n for (const line of lines) {\n let obj: unknown;\n try {\n obj = JSON.parse(line);\n } catch {\n continue;\n }\n if (!obj || typeof obj !== \"object\") continue;\n\n const msg = (obj as Record<string, unknown>).message as Record<string, unknown> | undefined;\n if (!msg || typeof msg !== \"object\") continue;\n\n const role = msg.role as string | undefined;\n const rawContent = msg.content;\n\n // Plain string user message\n if (role === \"user\" && typeof rawContent === \"string\" && rawContent.trim()) {\n parts.push(`user: ${rawContent.trim().slice(0, MAX_MSG_LENGTH)}`);\n continue;\n }\n\n // Plain string assistant message\n if (role === \"assistant\" && typeof rawContent === \"string\" && rawContent.trim()) {\n parts.push(`assistant: ${rawContent.trim().slice(0, MAX_MSG_LENGTH)}`);\n continue;\n }\n\n if (!Array.isArray(rawContent)) continue;\n\n const blocks = rawContent as Array<Record<string, unknown>>;\n for (const block of blocks) {\n if (!block || typeof block !== \"object\") continue;\n const type = block.type as string | undefined;\n\n // User text block\n if (role === \"user\" && type === \"text\" && typeof block.text === \"string\" && block.text.trim()) {\n parts.push(`user: ${block.text.trim().slice(0, MAX_MSG_LENGTH)}`);\n }\n\n // Assistant text block (not tool calls)\n if (role === \"assistant\" && type === \"text\" && typeof block.text === \"string\" && block.text.trim()) {\n parts.push(`assistant: ${block.text.trim().slice(0, MAX_MSG_LENGTH)}`);\n }\n }\n }\n\n return parts.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Cursor management\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_CURSORS_FILENAME = \".passive-observer-cursors.json\";\n\nexport function getCursorsPath(dbDir: string): string {\n return join(dbDir, DEFAULT_CURSORS_FILENAME);\n}\n\nexport async function loadCursors(cursorsPath: string): Promise<SessionCursors> {\n try {\n const raw = await readFile(cursorsPath, \"utf-8\");\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n const cursors: SessionCursors = {};\n for (const [k, v] of Object.entries(parsed)) {\n if (typeof v === \"number\" && v >= 0) {\n cursors[k] = v;\n }\n }\n return cursors;\n }\n return {};\n } catch {\n return {};\n }\n}\n\nexport async function saveCursors(cursorsPath: string, cursors: SessionCursors): Promise<void> {\n const dir = dirname(cursorsPath);\n await mkdir(dir, { recursive: true });\n await writeFile(cursorsPath, JSON.stringify(cursors, null, 2), \"utf-8\");\n}\n\n// ---------------------------------------------------------------------------\n// LLM extraction\n// ---------------------------------------------------------------------------\n\nconst OBSERVER_TEMPERATURE = 0.15;\nconst OBSERVER_MAX_TOKENS = 1200;\n\n/**\n * Parse the LLM JSON response into extracted facts.\n * Expects a JSON array of { text, category, importance } objects.\n */\nexport function parseObserverResponse(raw: string, categories: string[]): ExtractedFact[] {\n const validCategories = new Set<string>(categories.map((c) => c.toLowerCase()));\n\n // Extract JSON from response (may be wrapped in markdown code fence)\n const jsonMatch = raw.match(/```(?:json)?\\s*([\\s\\S]*?)```/);\n const jsonStr = jsonMatch ? jsonMatch[1].trim() : raw.trim();\n\n let parsed: unknown;\n try {\n // Find the JSON array portion\n const start = jsonStr.indexOf(\"[\");\n const end = jsonStr.lastIndexOf(\"]\");\n if (start === -1 || end === -1) return [];\n parsed = JSON.parse(jsonStr.slice(start, end + 1));\n } catch {\n return [];\n }\n\n if (!Array.isArray(parsed)) return [];\n\n const facts: ExtractedFact[] = [];\n for (const item of parsed) {\n if (!item || typeof item !== \"object\") continue;\n const obj = item as Record<string, unknown>;\n\n const text = typeof obj.text === \"string\" ? obj.text.trim() : \"\";\n if (!text || text.length < 10) continue;\n\n const importanceRaw =\n typeof obj.importance === \"number\" ? obj.importance : Number.parseFloat(String(obj.importance));\n // Default to 0.0 when importance is missing/invalid — forces the LLM to explicitly assign\n // importance rather than having invalid/missing values silently pass the minImportance filter.\n const importance = Number.isFinite(importanceRaw) ? Math.max(0, Math.min(1, importanceRaw)) : 0.0;\n\n const categoryRaw = typeof obj.category === \"string\" ? obj.category.toLowerCase().trim() : \"fact\";\n const category = validCategories.has(categoryRaw) ? categoryRaw : \"fact\";\n\n facts.push({ text, category, importance });\n }\n return facts;\n}\n\n// ---------------------------------------------------------------------------\n// Identity fact detection (Issue #306)\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when a fact describes the agent's own identity (email, name, role, etc.).\n * These facts should be stored as global/permanent instead of session-scoped.\n *\n * @param text - The fact text to classify.\n * @param agentName - Optional known agent name. When provided, adds a\n * name-specific pattern so \"[AgentName]'s email is …\" is also detected.\n */\nexport function isIdentityFact(text: string, agentName?: string): boolean {\n const patterns: RegExp[] = [\n /(?:my|your|the (?:agent|assistant|bot)(?:'s)?)\\s+(?:email|name|role|account|address|phone|number)/i,\n /(?<!(?:user|customer|their|his|her|[a-z]+)'s\\s)(?<!(?:their|his|her)\\s)(?:email|account|address|role)\\s+(?:is|was|:)\\s/i,\n ];\n if (agentName) {\n const escaped = agentName.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n patterns.push(new RegExp(`${escaped}(?:'s)?\\\\s+(?:email|name|role|account)`, \"i\"));\n }\n return patterns.some((p) => p.test(text));\n}\n\nconst OPENCLAW_AGENTS_MARKER = \"/.openclaw/agents/\";\n\n/** List session JSONL paths for passive observer (single configured dir or all agents). */\nexport function listPassiveObserverSessionFilePaths(\n sessionsDir: string | undefined,\n proceduresSessionsDir: string | undefined,\n): { filePaths: string[]; multiAgentScan: boolean } {\n const explicitDir = sessionsDir ?? proceduresSessionsDir;\n if (explicitDir) {\n if (!existsSync(explicitDir)) return { filePaths: [], multiAgentScan: false };\n try {\n const filePaths = readdirSync(explicitDir)\n .filter(isPassiveObserverTranscriptCandidate)\n .sort()\n .map((f) => join(explicitDir, f));\n return { filePaths, multiAgentScan: false };\n } catch {\n return { filePaths: [], multiAgentScan: false };\n }\n }\n\n const agentsDir = join(homedir(), \".openclaw\", \"agents\");\n if (!existsSync(agentsDir)) return { filePaths: [], multiAgentScan: true };\n const filePaths: string[] = [];\n try {\n for (const agentEntry of readdirSync(agentsDir, { withFileTypes: true })) {\n if (!agentEntry.isDirectory()) continue;\n const dir = join(agentsDir, agentEntry.name, \"sessions\");\n if (!existsSync(dir)) continue;\n for (const f of readdirSync(dir)) {\n if (isPassiveObserverTranscriptCandidate(f)) {\n filePaths.push(join(dir, f));\n }\n }\n }\n } catch {\n return { filePaths: [], multiAgentScan: true };\n }\n filePaths.sort();\n return { filePaths, multiAgentScan: true };\n}\n\n/** Stable cursor / scope key for a session file (agent-relative when scanning all agents). */\nexport function deriveObserverSessionKey(filePath: string, multiAgentScan: boolean): string | null {\n const normalized = filePath.replace(/\\\\/g, \"/\");\n if (multiAgentScan) {\n const markerIdx = normalized.indexOf(OPENCLAW_AGENTS_MARKER);\n if (markerIdx >= 0) {\n const rel = normalized.slice(markerIdx + OPENCLAW_AGENTS_MARKER.length);\n return rel.replace(/\\.jsonl$/, \"\");\n }\n }\n const base = normalized.split(\"/\").pop()?.replace(\".jsonl\", \"\");\n return base ?? null;\n}\n\n// ---------------------------------------------------------------------------\n// Core run\n// ---------------------------------------------------------------------------\n\nexport async function runPassiveObserver(\n factsDb: FactsDB,\n vectorDb: VectorDB,\n embeddings: EmbeddingProvider,\n openai: OpenAI,\n config: PassiveObserverConfig,\n allCategories: string[],\n opts: {\n model: string;\n fallbackModels?: string[];\n dbDir: string;\n dryRun?: boolean;\n /** Fallback sessions dir from procedures config (used when config.sessionsDir is not set). */\n proceduresSessionsDir?: string;\n /** Confidence reinforcement config (Issue #147). When set and enabled, similar facts get confidence boost instead of silent skip. */\n reinforcement?: ReinforcementConfig;\n /** Provenance tracking (Issue #163). */\n provenanceService?: ProvenanceService | null;\n /** Episodic event log (Issue #150). When set, each stored fact is also appended to Layer 1. */\n eventLog?: EventLog | null;\n /** Agent's own name. Used by isIdentityFact() for name-specific detection. */\n agentName?: string;\n /** Observation dedup window in minutes (#1913). */\n dedupWindowMinutes?: number;\n },\n logger: { info: (msg: string) => void; warn: (msg: string) => void },\n): Promise<ObserverRunResult> {\n const result: ObserverRunResult = {\n sessionsScanned: 0,\n chunksProcessed: 0,\n factsExtracted: 0,\n factsStored: 0,\n factsReinforced: 0,\n errors: 0,\n };\n\n const explicitSessionsDir = config.sessionsDir ?? opts.proceduresSessionsDir;\n const { filePaths, multiAgentScan } = listPassiveObserverSessionFilePaths(\n config.sessionsDir,\n opts.proceduresSessionsDir,\n );\n\n if (filePaths.length === 0 && explicitSessionsDir && !existsSync(explicitSessionsDir)) {\n logger.info(`memory-hybrid: passive-observer — sessions dir not found: ${explicitSessionsDir}`);\n return result;\n }\n\n if (filePaths.length === 0 && !multiAgentScan) {\n return result;\n }\n\n if (filePaths.length === 0 && multiAgentScan) {\n logger.info(\"memory-hybrid: passive-observer — no session files found under ~/.openclaw/agents/*/sessions\");\n return result;\n }\n\n // Prune stale consecutiveFailures entries before any early returns, so sessions that\n // disappear from disk (or when there are no session files at all) get cleaned up every tick.\n {\n const activeIds = new Set(\n filePaths.map((fp) => deriveObserverSessionKey(fp, multiAgentScan)).filter((id): id is string => id != null),\n );\n for (const id of consecutiveFailures.keys()) {\n if (!activeIds.has(id)) consecutiveFailures.delete(id);\n }\n }\n\n if (filePaths.length === 0) return result;\n\n const cursorsPath = getCursorsPath(opts.dbDir);\n const cursors = await loadCursors(cursorsPath);\n let cursorsChanged = false;\n // Separate in-memory map for consecutive failure counts — not persisted to the cursors file\n // to avoid mixing byte-offset semantics with failure-count semantics in the same structure.\n\n // ---------------------------------------------------------------------------\n // Phase 1: scan all session files, count sessions, detect whether any have\n // new content. We use stat() to get file sizes without loading entire files\n // into memory (files are read lazily in Phase 3 only when needed).\n // ---------------------------------------------------------------------------\n interface SessionInfo {\n filePath: string;\n sessionId: string;\n fileBytelen: number;\n cursor: number;\n }\n\n const sessions: SessionInfo[] = [];\n const activeSessionIds = new Set<string>();\n let hasNewContent = false;\n\n for (const filePath of filePaths) {\n const sessionId = deriveObserverSessionKey(filePath, multiAgentScan);\n if (!sessionId) continue;\n activeSessionIds.add(sessionId);\n let fileBytelen: number;\n try {\n const stats = await stat(filePath);\n fileBytelen = stats.size;\n } catch (err) {\n if (isEnoent(err)) {\n // File was pruned by session.maintenance between readdirSync and stat — skip silently.\n logger.info(`memory-hybrid: passive-observer — session ${sessionId} was pruned, skipping`);\n continue;\n }\n logger.warn(`memory-hybrid: passive-observer — failed to stat session ${sessionId}: ${err}`);\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-stat\",\n subsystem: \"passive-observer\",\n });\n result.errors++;\n continue;\n }\n\n // sessionsScanned counts sessions whose stat succeeded. A later open ENOENT (Phase 3)\n // will still leave this counter incremented, so operators may observe\n // sessionsScanned > factsStored with no errors — this is intentional and expected.\n result.sessionsScanned++;\n const cursor = cursors[sessionId] ?? 0;\n\n if (cursor < fileBytelen) {\n hasNewContent = true;\n }\n\n sessions.push({ filePath, sessionId, fileBytelen, cursor });\n }\n\n if (!hasNewContent) return result;\n\n const reinforcementEnabled = opts.reinforcement?.enabled !== false && opts.reinforcement != null;\n const passiveBoost = opts.reinforcement?.passiveBoost ?? 0.1;\n const maxConfidence = opts.reinforcement?.maxConfidence ?? 1.0;\n const cosineSimilarityThreshold = opts.reinforcement?.similarityThreshold ?? config.deduplicationThreshold;\n // Convert cosine similarity threshold to L2-based score for vectorDb.search().\n // VectorDB uses score = 1/(1+L2_distance). For normalized vectors, L2 = sqrt(2*(1-cosine)).\n // This conversion ensures the dedup threshold behaves as originally calibrated (issue #499).\n const similarityThreshold = 1 / (1 + Math.sqrt(2 * (1 - cosineSimilarityThreshold)));\n\n const prompt = loadPrompt(\"passive-observer\");\n\n // In-memory dedup pool for dry-run mode (Issue #499): during dry-run, facts are not written\n // to LanceDB, so vectorDb.search() cannot find facts extracted earlier in the same batch.\n // This array tracks embeddings of facts that would be stored in the current run, enabling\n // intra-batch deduplication so dry-run accurately previews real-run behavior.\n const dryRunVectors: number[][] = [];\n\n // In-memory dedup pool for non-dry-run mode: when vectorDb.store() fails, the fact is\n // committed to SQLite but not to LanceDB. This array provides a fallback intra-batch\n // dedup mechanism so subsequent identical facts within the same batch are still detected.\n // Track fact IDs alongside vectors to enable confidence reinforcement (Issue #147).\n const recentVectors: Array<{ vector: number[]; factId: string }> = [];\n\n // ---------------------------------------------------------------------------\n // Phase 3: process each session that has new content.\n // ---------------------------------------------------------------------------\n // Progress is throttled to the same cadence as the orchestrator's per-step heartbeat (#2026's\n // ORCHESTRATOR_STEP_HEARTBEAT_MS) so a large multi-agent backlog logs a couple dozen lines over\n // the default 15-minute step budget instead of one line per session (#2032).\n let lastProgressLogMs = 0;\n for (const [sessionIdx, { filePath, sessionId, fileBytelen, cursor }] of sessions.entries()) {\n if (maintenanceRunDeadlineReached()) {\n // Deadline stops mid-run are counted as an error so the caller's summary (and\n // assertPassiveObserverSummaryDoesNotBlock) surface a truncated run as a failure\n // instead of a silent \"ok\" that hides unprocessed sessions (#2032).\n result.errors++;\n logger.warn(\n \"memory-hybrid: passive-observer — maintenance run deadline reached; stopping session scan \" +\n `(processed ${sessionIdx}/${sessions.length} sessions, sessionsScanned=${result.sessionsScanned}, ` +\n `chunksProcessed=${result.chunksProcessed}, factsStored=${result.factsStored})`,\n );\n break;\n }\n if (cursor >= fileBytelen) continue; // Nothing new\n const progressNowMs = Date.now();\n if (lastProgressLogMs === 0 || progressNowMs - lastProgressLogMs >= PASSIVE_OBSERVER_PROGRESS_LOG_INTERVAL_MS) {\n lastProgressLogMs = progressNowMs;\n logger.info(\n `memory-hybrid: passive-observer — progress: session ${sessionIdx + 1}/${sessions.length} (${sessionId}) ` +\n `chunksProcessed=${result.chunksProcessed} factsExtracted=${result.factsExtracted} factsStored=${result.factsStored}`,\n );\n }\n\n let rawBuf: Buffer;\n let segmentEnd = fileBytelen;\n try {\n const maxBytes = Math.min(MAX_JSONL_BYTES_PER_RUN, Math.max(200_000, config.maxCharsPerChunk * 8));\n const endOffset = Math.min(fileBytelen, cursor + maxBytes);\n const length = endOffset - cursor;\n if (length <= 0) continue;\n const handle = await open(filePath, \"r\");\n try {\n rawBuf = Buffer.alloc(length);\n const { bytesRead } = await handle.read(rawBuf, 0, length, cursor);\n if (bytesRead === 0) {\n continue;\n }\n if (bytesRead < length) {\n rawBuf = rawBuf.subarray(0, bytesRead);\n }\n } finally {\n await handle.close();\n }\n const lastNewlineIdx = rawBuf.lastIndexOf(0x0a);\n if (lastNewlineIdx === -1 && endOffset < fileBytelen) {\n logger.warn(`memory-hybrid: passive-observer — skipping oversized JSONL line in session ${sessionId}`);\n cursors[sessionId] = endOffset;\n cursorsChanged = true;\n continue;\n }\n const sliceEnd = lastNewlineIdx === -1 ? rawBuf.length : lastNewlineIdx + 1;\n rawBuf = rawBuf.subarray(0, sliceEnd);\n segmentEnd = cursor + sliceEnd;\n } catch (err) {\n if (isEnoent(err)) {\n // File was pruned by session.maintenance between stat and open — skip silently.\n logger.info(\n `memory-hybrid: passive-observer — session ${sessionId} was pruned between scan and read, skipping`,\n );\n continue;\n }\n logger.warn(`memory-hybrid: passive-observer — failed to read session ${sessionId}: ${err}`);\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-read\",\n subsystem: \"passive-observer\",\n });\n result.errors++;\n continue;\n }\n\n const newContent = rawBuf.toString(\"utf-8\");\n if (!newContent.trim()) {\n cursors[sessionId] = segmentEnd;\n cursorsChanged = true;\n continue;\n }\n\n // Extract human-readable text from JSONL\n const textBlock = extractTextFromJsonlChunk(newContent);\n if (!textBlock.trim()) {\n cursors[sessionId] = segmentEnd;\n cursorsChanged = true;\n continue;\n }\n\n // Chunk the text block\n const chunks = chunkTextByChars(textBlock, config.maxCharsPerChunk, Math.floor(config.maxCharsPerChunk * 0.05));\n\n let chunksAttempted = 0;\n let chunksSucceeded = 0;\n\n let deadlineStopped = false;\n\n for (const chunk of chunks) {\n if (maintenanceRunDeadlineReached()) {\n logger.warn(\n `memory-hybrid: passive-observer — maintenance run deadline reached; deferring session ${sessionId}`,\n );\n deadlineStopped = true;\n result.errors++;\n break;\n }\n if (!chunk.trim()) continue;\n chunksAttempted++;\n result.chunksProcessed++;\n // Re-check the throttle here too, not just at the top of the session loop (#2032 follow-up):\n // a single session with many chunks (each a full LLM round-trip via chatCompleteWithRetry\n // plus an embedding call) can otherwise run for its entire duration between two per-session\n // log lines, silently reproducing the \"live run indistinguishable from a hung one\" problem\n // this progress logging was added to close.\n const chunkProgressNowMs = Date.now();\n if (chunkProgressNowMs - lastProgressLogMs >= PASSIVE_OBSERVER_PROGRESS_LOG_INTERVAL_MS) {\n lastProgressLogMs = chunkProgressNowMs;\n logger.info(\n `memory-hybrid: passive-observer — progress: session ${sessionIdx + 1}/${sessions.length} (${sessionId}) ` +\n `chunk ${chunksAttempted}/${chunks.length} chunksProcessed=${result.chunksProcessed} ` +\n `factsExtracted=${result.factsExtracted} factsStored=${result.factsStored}`,\n );\n }\n\n const filledPrompt = fillPrompt(prompt, {\n categories: allCategories.join(\", \"),\n transcript: chunk,\n });\n\n let rawResponse: string;\n try {\n rawResponse = await chatCompleteWithRetry({\n model: opts.model,\n content: filledPrompt,\n temperature: OBSERVER_TEMPERATURE,\n maxTokens: OBSERVER_MAX_TOKENS,\n openai,\n fallbackModels: opts.fallbackModels ?? [],\n label: \"memory-hybrid: passive-observer\",\n feature: CostFeature.passiveObserver,\n timeoutMs: capTimeoutByMaintenanceRunDeadline(resolveMaintenanceChatTimeoutMs(opts.model)),\n signal: getMaintenanceRunAbortSignal(),\n });\n } catch (err) {\n logger.warn(`memory-hybrid: passive-observer — LLM failed for session ${sessionId}: ${err}`);\n const retryAttempt = err instanceof LLMRetryError ? err.attemptNumber : 1;\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-llm\",\n subsystem: \"passive-observer\",\n retryAttempt,\n });\n result.errors++;\n continue;\n }\n\n chunksSucceeded++;\n\n const facts = parseObserverResponse(rawResponse, allCategories);\n const filtered = facts.filter((f) => f.importance >= config.minImportance);\n result.factsExtracted += filtered.length;\n\n for (const fact of filtered) {\n const storedText = prepareMemoryTextForStorage(fact.text, config.maxCharsPerChunk);\n if (!storedText || !isSubstantiveMemoryText(storedText)) continue;\n const storedCategory =\n (prepareMemoryMetadataForStorage(fact.category) as MemoryCategory | undefined) ?? fact.category;\n const identity = isIdentityFact(storedText, opts.agentName);\n\n // Embed new fact for dedup check\n let vec: number[];\n try {\n vec = await embeddings.embed(storedText);\n } catch (err) {\n logger.warn(\n `memory-hybrid: passive-observer — embed failed for fact: ${storedText.slice(0, 80)}... (${err})`,\n );\n // AllEmbeddingProvidersFailed is expected when all providers are unavailable — don't report (#486)\n if (!shouldSuppressEmbeddingError(err)) {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-embed\",\n severity: \"info\",\n subsystem: \"passive-observer\",\n });\n }\n result.errors++;\n continue;\n }\n\n // Normalize the vector to ensure the L2-to-cosine conversion is valid.\n // The conversion formula (1 / (1 + sqrt(2*(1-cosine)))) assumes unit-length vectors.\n // While OpenAI embeddings are pre-normalized, Ollama and some other providers return\n // unnormalized vectors, causing the L2 distance to be scaled by vector magnitude.\n const normalizedVec = normalizeVector(vec);\n const dedupScope = identity ? \"global\" : \"session\";\n const dedupScopeTarget = identity ? null : sessionId;\n\n // Dedup check via LanceDB ANN search — O(log n) instead of O(n*m) brute-force.\n // Replaces the old recentVectors[] linear scan and eliminates the embedBatch()\n // call on the recent-facts pool. LanceDB is the single source of truth.\n let isDuplicate = false;\n try {\n const dupeResults = await vectorDb.search(normalizedVec, 1, similarityThreshold);\n if (dupeResults.length > 0) {\n const matchedId = dupeResults[0].entry.id;\n const matched = matchedId ? factsDb.getById(matchedId) : null;\n const nowSec = Math.floor(Date.now() / 1000);\n const scopeMatches =\n matched != null &&\n matched.supersededAt == null &&\n (matched.expiresAt == null || matched.expiresAt > nowSec) &&\n (matched.scope ?? \"global\") === dedupScope &&\n (dedupScope === \"global\" ? null : (matched.scopeTarget ?? null)) === dedupScopeTarget;\n if (scopeMatches) {\n isDuplicate = true;\n // Confidence reinforcement: boost the matched fact instead of silently skipping (Issue #147)\n if (reinforcementEnabled && !opts.dryRun) {\n if (matchedId) {\n try {\n const boosted = factsDb.boostConfidence(matchedId, passiveBoost, maxConfidence);\n if (boosted) result.factsReinforced++;\n } catch {\n // Non-fatal — don't fail passive observer because of boost error\n }\n }\n }\n }\n }\n } catch {\n // On search failure, proceed without dedup — a few duplicates are acceptable\n }\n\n // Intra-batch dedup: check against facts stored/attempted in this run.\n // In dry-run mode, vectorDb.store() is never called, so vectorDb.search() won't find\n // facts extracted earlier in the same batch. In non-dry-run mode, if vectorDb.store()\n // fails, the fact is in SQLite but not LanceDB, so vectorDb.search() also won't find it.\n // Compare against dryRunVectors[] (dry-run) or recentVectors[] (non-dry-run) to ensure\n // accurate intra-batch deduplication (Issue #499).\n if (!isDuplicate) {\n if (opts.dryRun) {\n for (const recentVec of dryRunVectors) {\n const cosineSim = dotProductSimilarity(normalizedVec, recentVec);\n if (cosineSim >= cosineSimilarityThreshold) {\n isDuplicate = true;\n break;\n }\n }\n } else {\n for (const recent of recentVectors) {\n const cosineSim = dotProductSimilarity(normalizedVec, recent.vector);\n if (cosineSim >= cosineSimilarityThreshold) {\n isDuplicate = true;\n // Confidence reinforcement: boost the matched fact (Issue #147)\n if (reinforcementEnabled) {\n try {\n const boosted = factsDb.boostConfidence(recent.factId, passiveBoost, maxConfidence);\n if (boosted) result.factsReinforced++;\n } catch {\n // Non-fatal — don't fail passive observer because of boost error\n }\n }\n break;\n }\n }\n }\n }\n\n if (isDuplicate) continue;\n\n if (opts.dryRun) {\n logger.info(\n `memory-hybrid: passive-observer [dry-run] would store: ${storedText.slice(0, 60)}... (importance=${fact.importance.toFixed(2)}, category=${storedCategory})`,\n );\n result.factsStored++;\n dryRunVectors.push(normalizedVec);\n continue;\n }\n\n // Write episodic event FIRST (Issue #150, Layer 1): record before factsDb so that if\n // factsDb fails we still have the event record. If eventLog fails we continue to store\n // the fact — event log unavailability must never block fact writes.\n let eventId: string | null = null;\n if (opts.eventLog) {\n try {\n eventId = opts.eventLog.append({\n sessionId,\n timestamp: nowIso(),\n eventType: categoryToEventType(fact.category),\n content: {\n text: storedText,\n category: storedCategory,\n importance: fact.importance,\n source: \"passive-observer\",\n },\n });\n } catch {\n // Non-fatal — event log write failure must never block fact storage\n }\n }\n\n // Identity fact promotion (Issue #306): if this fact describes the agent itself,\n // store it as global/permanent so it persists across sessions.\n if (identity) {\n logger.info(`passive-observer: promoting identity fact to global/permanent: \"${storedText.slice(0, 60)}...\"`);\n }\n\n const dedupWindow = opts.dedupWindowMinutes ?? 30;\n const dedupInput = {\n text: storedText,\n entity: null,\n key: null,\n scope: dedupScope,\n scopeTarget: dedupScopeTarget,\n };\n\n const txResult = runCaptureStoreWithDedupWindow(factsDb, dedupWindow, dedupInput, () =>\n factsDb.storeWithResult({\n text: storedText,\n category: storedCategory,\n importance: identity ? Math.max(fact.importance, 0.9) : fact.importance,\n confidence: 0.6,\n entity: null,\n key: null,\n value: null,\n source: \"passive-observer\",\n decayClass: identity ? \"permanent\" : \"session\",\n scope: identity ? \"global\" : \"session\",\n scopeTarget: identity ? undefined : sessionId,\n tags: [\"passive-observer\"],\n provenanceSession: sessionId,\n extractionMethod: \"passive\",\n extractionConfidence: fact.importance,\n }),\n );\n if (txResult.skipped) {\n continue;\n }\n\n const storeResult = txResult.storeResult!;\n if (storeResult.skipped) {\n continue;\n }\n if (storeResult.newlyStored === false && !storeResult.embeddingStale) {\n continue;\n }\n const stored = storeResult.entry;\n await cleanupEvictedVector({\n vectorDb,\n evictedFactId: storeResult.evictedFactId,\n logger,\n context: \"passive-observer\",\n });\n\n if (opts.provenanceService && storeResult.newlyStored) {\n try {\n opts.provenanceService.addEdge(stored.id, {\n edgeType: \"DERIVED_FROM\",\n sourceType: \"event_log\",\n sourceId: eventId ?? sessionId,\n sourceText: storedText,\n });\n } catch (err) {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-provenance\",\n subsystem: \"provenance\",\n factId: stored.id,\n });\n }\n }\n\n // Contradiction detection (Issue #142): check for same entity+key with different value.\n // For global/permanent identity facts, pass null scope so detection spans all scopes.\n if (storeResult.newlyStored) {\n factsDb.detectContradictions(stored.id, null, null, null, stored.scope ?? null, stored.scopeTarget ?? null);\n }\n\n // Store to LanceDB (use normalized vector for consistent L2 distance metric)\n try {\n const vectorText = storeResult.embeddingStale ? stored.text : fact.text;\n const vectorForStore = storeResult.embeddingStale ? await embeddings.embed(stored.text) : normalizedVec;\n await vectorDb.store({\n text: vectorText,\n vector: vectorForStore,\n importance: identity ? Math.max(fact.importance, 0.9) : fact.importance,\n category: fact.category,\n id: stored.id,\n });\n factsDb.setEmbeddingModel(stored.id, embeddings.modelName);\n } catch (err) {\n logger.warn(`memory-hybrid: passive-observer vector store failed: ${err}`);\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-vector-store\",\n subsystem: \"vector\",\n factId: stored.id,\n });\n }\n\n // Track vector for intra-batch dedup: whether vectorDb.store() succeeded or failed,\n // add to recentVectors[] so subsequent identical facts in this batch are detected.\n if (storeResult.newlyStored) {\n recentVectors.push({ vector: normalizedVec, factId: stored.id });\n result.factsStored++;\n }\n }\n }\n\n if (deadlineStopped) {\n break;\n }\n\n // Advance cursor only when every attempted chunk in this segment succeeded (or there were none).\n // Partial success must not advance — failed chunks would be skipped permanently.\n if (chunksAttempted === 0 || chunksSucceeded === chunksAttempted) {\n cursors[sessionId] = segmentEnd;\n consecutiveFailures.delete(sessionId);\n cursorsChanged = true;\n } else if (chunksSucceeded === 0) {\n const failures = (consecutiveFailures.get(sessionId) ?? 0) + 1;\n consecutiveFailures.set(sessionId, failures);\n if (failures >= 3) {\n // Advance past the problematic content after 3 consecutive failures to prevent\n // an infinite retry loop that wastes LLM tokens on permanently-bad session files.\n logger.warn(\n `memory-hybrid: passive-observer — advancing cursor for session ${sessionId} after ${failures} consecutive failures`,\n );\n cursors[sessionId] = segmentEnd;\n consecutiveFailures.delete(sessionId);\n cursorsChanged = true;\n }\n }\n }\n\n if (cursorsChanged) {\n try {\n await saveCursors(cursorsPath, cursors);\n } catch (err) {\n logger.warn(`memory-hybrid: passive-observer — failed to save cursors: ${err}`);\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"passive-observer-save-cursors\",\n subsystem: \"passive-observer\",\n });\n }\n }\n\n return result;\n}\n"],"mappings":"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"}
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "openclaw-hybrid-memory",
3
- "version": "2026.7.53",
3
+ "version": "2026.7.54",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "openclaw-hybrid-memory",
9
- "version": "2026.7.53",
9
+ "version": "2026.7.54",
10
10
  "hasInstallScript": true,
11
11
  "dependencies": {
12
12
  "@lancedb/lancedb": "^0.30.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-hybrid-memory",
3
3
  "kind": "memory",
4
- "version": "2026.7.53",
4
+ "version": "2026.7.54",
5
5
  "contracts": {
6
6
  "tools": [
7
7
  "active_task_checkpoint",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-hybrid-memory",
3
- "version": "2026.7.53",
3
+ "version": "2026.7.54",
4
4
  "type": "module",
5
5
  "description": "Give your OpenClaw agent lasting memory: structured facts, semantic search, auto-capture & recall, decay, optional credential vault. Part of Hybrid Memory v3.",
6
6
  "files": [
@@ -602,6 +602,20 @@ export async function runPassiveObserver(
602
602
  if (!chunk.trim()) continue;
603
603
  chunksAttempted++;
604
604
  result.chunksProcessed++;
605
+ // Re-check the throttle here too, not just at the top of the session loop (#2032 follow-up):
606
+ // a single session with many chunks (each a full LLM round-trip via chatCompleteWithRetry
607
+ // plus an embedding call) can otherwise run for its entire duration between two per-session
608
+ // log lines, silently reproducing the "live run indistinguishable from a hung one" problem
609
+ // this progress logging was added to close.
610
+ const chunkProgressNowMs = Date.now();
611
+ if (chunkProgressNowMs - lastProgressLogMs >= PASSIVE_OBSERVER_PROGRESS_LOG_INTERVAL_MS) {
612
+ lastProgressLogMs = chunkProgressNowMs;
613
+ logger.info(
614
+ `memory-hybrid: passive-observer — progress: session ${sessionIdx + 1}/${sessions.length} (${sessionId}) ` +
615
+ `chunk ${chunksAttempted}/${chunks.length} chunksProcessed=${result.chunksProcessed} ` +
616
+ `factsExtracted=${result.factsExtracted} factsStored=${result.factsStored}`,
617
+ );
618
+ }
605
619
 
606
620
  const filledPrompt = fillPrompt(prompt, {
607
621
  categories: allCategories.join(", "),