diff --git a/dist/index.js b/dist/index.js index 4411046d..d3238fc2 100644 --- a/dist/index.js +++ b/dist/index.js @@ -190,19 +190,59 @@ function parseAstChunkingConfig(value) { } // Like parsePositiveInt but allows 0. Used for fields where 0 is a meaningful // "disabled" sentinel (e.g. autoRecallBadRecallDecayMs=0 disables decay). -function parseNonNegativeInt(value) { - if (typeof value === "number" && Number.isFinite(value) && value >= 0) { - return Math.floor(value); +function parseNonNegativeInt(value, fieldPath) { + // Missing: optional field — return undefined so caller's ?? default applies + if (value === undefined) + return undefined; + if (typeof value === "number") { + if (!Number.isFinite(value)) { + const msg = `must be a finite number, got ${String(value)}`; + if (fieldPath) + throw new Error(`${fieldPath}: ${msg}`); + return undefined; + } + if (!Number.isInteger(value)) { + const msg = `must be an integer, got ${value}`; + if (fieldPath) + throw new Error(`${fieldPath}: ${msg}`); + return undefined; + } + if (value < 0) { + const msg = `must be >= 0, got ${value}`; + if (fieldPath) + throw new Error(`${fieldPath}: ${msg}`); + return undefined; + } + return value; } if (typeof value === "string") { const s = value.trim(); - if (!s) + if (!s) { + if (fieldPath) + throw new Error(`${fieldPath}: must be a non-negative integer`); return undefined; + } const resolved = resolveEnvVars(s); + // When fieldPath is provided, only accept env-var reference strings + // (e.g. "${MY_VAR}"). Plain numeric strings are rejected — the config + // schema already catches them at the gateway level. + if (fieldPath && resolved === s) { + const n = Number(resolved); + if (Number.isFinite(n) && n >= 0 && Number.isInteger(n)) + throw new Error(`${fieldPath}: must be a number, got string "${s}"`); + throw new Error(`${fieldPath}: must be a non-negative integer`); + } const n = Number(resolved); - if (Number.isFinite(n) && n >= 0) - return Math.floor(n); + if (Number.isFinite(n) && n >= 0 && Number.isInteger(n)) + return n; + if (fieldPath) + throw new Error(`${fieldPath}: must be a non-negative integer`); + return undefined; } + // Reject all other types when fieldPath provided (null, boolean, object, array) + const msg = `must be a non-negative integer, got ${typeof value}`; + if (fieldPath) + throw new Error(`${fieldPath}: ${msg}`); return undefined; } function clampInt(value, min, max) { @@ -4983,7 +5023,7 @@ export function parsePluginConfig(value) { const storageAutoCleanupRaw = typeof storageMaintenanceRaw?.autoCleanup === "object" && storageMaintenanceRaw.autoCleanup !== null ? storageMaintenanceRaw.autoCleanup : null; - const readConsistencyIntervalSecondsRaw = parseNonNegativeInt(storageMaintenanceRaw?.readConsistencyIntervalSeconds); + const readConsistencyIntervalSecondsRaw = parseNonNegativeInt(storageMaintenanceRaw?.readConsistencyIntervalSeconds, "plugins.entries.memory-lancedb-pro.config.storageMaintenance.readConsistencyIntervalSeconds"); const lockingRaw = typeof cfg.locking === "object" && cfg.locking !== null ? cfg.locking : null; diff --git a/index.ts b/index.ts index 1eb6f5d6..2be2575d 100644 --- a/index.ts +++ b/index.ts @@ -522,17 +522,54 @@ function parseAstChunkingConfig(value: unknown): ChunkerAstConfig | undefined { // Like parsePositiveInt but allows 0. Used for fields where 0 is a meaningful // "disabled" sentinel (e.g. autoRecallBadRecallDecayMs=0 disables decay). -function parseNonNegativeInt(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value) && value >= 0) { - return Math.floor(value); +function parseNonNegativeInt(value: unknown, fieldPath?: string): number | undefined { + // Missing: optional field — return undefined so caller's ?? default applies + if (value === undefined) return undefined; + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + const msg = `must be a finite number, got ${String(value)}`; + if (fieldPath) throw new Error(`${fieldPath}: ${msg}`); + return undefined; + } + if (!Number.isInteger(value)) { + const msg = `must be an integer, got ${value}`; + if (fieldPath) throw new Error(`${fieldPath}: ${msg}`); + return undefined; + } + if (value < 0) { + const msg = `must be >= 0, got ${value}`; + if (fieldPath) throw new Error(`${fieldPath}: ${msg}`); + return undefined; + } + return value; } + if (typeof value === "string") { const s = value.trim(); - if (!s) return undefined; + if (!s) { + if (fieldPath) throw new Error(`${fieldPath}: must be a non-negative integer`); + return undefined; + } const resolved = resolveEnvVars(s); + // When fieldPath is provided, only accept env-var reference strings + // (e.g. "${MY_VAR}"). Plain numeric strings are rejected — the config + // schema already catches them at the gateway level. + if (fieldPath && resolved === s) { + const n = Number(resolved); + if (Number.isFinite(n) && n >= 0 && Number.isInteger(n)) + throw new Error(`${fieldPath}: must be a number, got string "${s}"`); + throw new Error(`${fieldPath}: must be a non-negative integer`); + } const n = Number(resolved); - if (Number.isFinite(n) && n >= 0) return Math.floor(n); + if (Number.isFinite(n) && n >= 0 && Number.isInteger(n)) return n; + if (fieldPath) throw new Error(`${fieldPath}: must be a non-negative integer`); + return undefined; } + + // Reject all other types when fieldPath provided (null, boolean, object, array) + const msg = `must be a non-negative integer, got ${typeof value}`; + if (fieldPath) throw new Error(`${fieldPath}: ${msg}`); return undefined; } @@ -5418,256 +5455,256 @@ const memoryLanceDBProPlugin = { } } } - - const reflectionEventId = createReflectionEventId({ - runAt: nowTs, - sessionKey, - sessionId: currentSessionId || "unknown", - agentId: sourceAgentId, - command: String(event.action || "unknown"), - }); - - // Persistence-path embeds share the generation path's transient-retry - // policy: one transient abort must not fail the whole hook after the - // reflection md is already on disk. - const embedForReflectionPersistence = (text: string, runner: string) => - embedWithReflectionTransientRetry( - (value) => embedder.embedPassage(value), - text, - runner, - (level, message) => api.logger[level](message), - ); - - const MAX_MAPPED_ENTRIES = 100; - const mappedReflectionMemories = extractInjectableReflectionMappedMemoryItems(reflectionText); - const mappedEntries: Array<{ text: string; vector: number[]; importance: number; category: string; scope: string; metadata: string }> = []; - // Per-row embed + near-duplicate pre-check first, collecting the - // gate-eligible rows so the whole burst can share one admission call. - const gateEligible: Array<{ mapped: (typeof mappedReflectionMemories)[number]; vector: number[] }> = []; - for (const mapped of mappedReflectionMemories) { - if (gateEligible.length >= MAX_MAPPED_ENTRIES) { - api.logger.warn(`memory-reflection: mapped entries cap (${MAX_MAPPED_ENTRIES}) reached, skipping remaining items`); - break; - } - let vector: number[]; - try { - vector = await embedForReflectionPersistence(mapped.text, "mapped-row-embedding"); - } catch (embedErr) { - api.logger.warn( - `memory-reflection: mapped row embedding failed after retry, skipping row: ${String(embedErr)}`, - ); - continue; - } - let existing: Awaited> = []; - let searchFailed = false; - try { - existing = await store.vectorSearch(vector, 1, 0.1, [targetScope]); - } catch (err) { - api.logger.warn( - `memory-reflection: mapped memory duplicate pre-check failed, skip store: ${String(err)}`, - ); - searchFailed = true; - } - if (searchFailed) { - continue; - } - // Near-duplicate pre-check ahead of admission gating. This is the only dedup mapped - // rows get: a single vector-similarity threshold, direct skip, no LLM-mediated - // merge/contextualize/contradict decision. Extraction candidates own deduplicate() - // (src/smart-extractor.ts) is a genuinely different, richer pipeline (a 0.7 - // pre-filter feeding an LLM decision, not a single hard cutoff) - deliberately not - // reused here yet. AdmissionController's "pass_to_dedup" decision for a mapped row - // is therefore always treated as "admit, subject to this cheaper pre-check" below, - // not "route through the same merge pipeline extraction candidates get". - if (existing.length > 0 && existing[0].score > 0.95) { - continue; - } - gateEligible.push({ mapped, vector }); - } - - // Writer-1 admission routing: mapped rows previously bypassed - // admission control entirely. Gate the whole burst through the same - // AdmissionController as extraction candidates: one batched judge - // call per burst when the controller supports evaluateBatch, the - // historical per-row path otherwise; passthrough when admission - // control (or smart extraction) is disabled. - const mappedGateResults = await gateMappedReflectionEntries({ + + const reflectionEventId = createReflectionEventId({ + runAt: nowTs, + sessionKey, + sessionId: currentSessionId || "unknown", + agentId: sourceAgentId, + command: String(event.action || "unknown"), + }); + + // Persistence-path embeds share the generation path's transient-retry + // policy: one transient abort must not fail the whole hook after the + // reflection md is already on disk. + const embedForReflectionPersistence = (text: string, runner: string) => + embedWithReflectionTransientRetry( + (value) => embedder.embedPassage(value), + text, + runner, + (level, message) => api.logger[level](message), + ); + + const MAX_MAPPED_ENTRIES = 100; + const mappedReflectionMemories = extractInjectableReflectionMappedMemoryItems(reflectionText); + const mappedEntries: Array<{ text: string; vector: number[]; importance: number; category: string; scope: string; metadata: string }> = []; + // Per-row embed + near-duplicate pre-check first, collecting the + // gate-eligible rows so the whole burst can share one admission call. + const gateEligible: Array<{ mapped: (typeof mappedReflectionMemories)[number]; vector: number[] }> = []; + for (const mapped of mappedReflectionMemories) { + if (gateEligible.length >= MAX_MAPPED_ENTRIES) { + api.logger.warn(`memory-reflection: mapped entries cap (${MAX_MAPPED_ENTRIES}) reached, skipping remaining items`); + break; + } + let vector: number[]; + try { + vector = await embedForReflectionPersistence(mapped.text, "mapped-row-embedding"); + } catch (embedErr) { + api.logger.warn( + `memory-reflection: mapped row embedding failed after retry, skipping row: ${String(embedErr)}`, + ); + continue; + } + let existing: Awaited> = []; + let searchFailed = false; + try { + existing = await store.vectorSearch(vector, 1, 0.1, [targetScope]); + } catch (err) { + api.logger.warn( + `memory-reflection: mapped memory duplicate pre-check failed, skip store: ${String(err)}`, + ); + searchFailed = true; + } + if (searchFailed) { + continue; + } + // Near-duplicate pre-check ahead of admission gating. This is the only dedup mapped + // rows get: a single vector-similarity threshold, direct skip, no LLM-mediated + // merge/contextualize/contradict decision. Extraction candidates own deduplicate() + // (src/smart-extractor.ts) is a genuinely different, richer pipeline (a 0.7 + // pre-filter feeding an LLM decision, not a single hard cutoff) - deliberately not + // reused here yet. AdmissionController's "pass_to_dedup" decision for a mapped row + // is therefore always treated as "admit, subject to this cheaper pre-check" below, + // not "route through the same merge pipeline extraction candidates get". + if (existing.length > 0 && existing[0].score > 0.95) { + continue; + } + gateEligible.push({ mapped, vector }); + } + + // Writer-1 admission routing: mapped rows previously bypassed + // admission control entirely. Gate the whole burst through the same + // AdmissionController as extraction candidates: one batched judge + // call per burst when the controller supports evaluateBatch, the + // historical per-row path otherwise; passthrough when admission + // control (or smart extraction) is disabled. + const mappedGateResults = await gateMappedReflectionEntries({ admissionController: captureAdmissionController(), admissionRequired: config.admissionControl?.enabled === true, attachAudit: captureAdmissionAudit(), - rows: gateEligible.map(({ mapped, vector }) => ({ - text: mapped.text, - mappedKind: mapped.mappedKind, - heading: mapped.heading, - vector, - })), - // The real transcript, not reflectionText (the distiller's own generated - // output mapped rows are parsed FROM): using the distillate as its own - // grounding evidence would let a hallucinated line appear self-grounded. - conversationText: conversation, - scopeFilter: [targetScope], - warnLog: (msg: string) => api.logger.warn(msg), - }); - - // Consume the per-row gate results in input order. - for (let gateIndex = 0; gateIndex < gateEligible.length; gateIndex++) { - const { mapped, vector } = gateEligible[gateIndex]; - const mappedGate = mappedGateResults[gateIndex]; - if (!mappedGate.admit) { - api.logger.info( - `memory-reflection: admission rejected mapped row heading=${JSON.stringify(mapped.heading)} provenance=memory-reflection-mapped: ${mappedGate.reason ?? "no reason"}`, - ); - continue; - } - - const importance = mapped.mappedKind === "decision" ? 0.85 : 0.8; - const baseMetadata = buildReflectionMappedMetadata({ - mappedItem: mapped, - eventId: reflectionEventId, - agentId: ownerAgentId, - sessionKey, - sessionId: currentSessionId || "unknown", - runAt: nowTs, - usedFallback: reflectionGenerated.usedFallback, - toolErrorSignals, - sourceReflectionPath: relPath, - }); - // embed heading in metadata JSON so it survives bulkStore round-trip to LanceDB - baseMetadata._reflectionHeading = mapped.heading; - if (mappedGate.auditJson) { - baseMetadata.admission_audit = mappedGate.auditJson; - } - const metadata = JSON.stringify(baseMetadata); - - mappedEntries.push({ - text: mapped.text, - vector, - importance, + rows: gateEligible.map(({ mapped, vector }) => ({ + text: mapped.text, + mappedKind: mapped.mappedKind, + heading: mapped.heading, + vector, + })), + // The real transcript, not reflectionText (the distiller's own generated + // output mapped rows are parsed FROM): using the distillate as its own + // grounding evidence would let a hallucinated line appear self-grounded. + conversationText: conversation, + scopeFilter: [targetScope], + warnLog: (msg: string) => api.logger.warn(msg), + }); + + // Consume the per-row gate results in input order. + for (let gateIndex = 0; gateIndex < gateEligible.length; gateIndex++) { + const { mapped, vector } = gateEligible[gateIndex]; + const mappedGate = mappedGateResults[gateIndex]; + if (!mappedGate.admit) { + api.logger.info( + `memory-reflection: admission rejected mapped row heading=${JSON.stringify(mapped.heading)} provenance=memory-reflection-mapped: ${mappedGate.reason ?? "no reason"}`, + ); + continue; + } + + const importance = mapped.mappedKind === "decision" ? 0.85 : 0.8; + const baseMetadata = buildReflectionMappedMetadata({ + mappedItem: mapped, + eventId: reflectionEventId, + agentId: ownerAgentId, + sessionKey, + sessionId: currentSessionId || "unknown", + runAt: nowTs, + usedFallback: reflectionGenerated.usedFallback, + toolErrorSignals, + sourceReflectionPath: relPath, + }); + // embed heading in metadata JSON so it survives bulkStore round-trip to LanceDB + baseMetadata._reflectionHeading = mapped.heading; + if (mappedGate.auditJson) { + baseMetadata.admission_audit = mappedGate.auditJson; + } + const metadata = JSON.stringify(baseMetadata); + + mappedEntries.push({ + text: mapped.text, + vector, + importance, category: getReflectionMappedStorageCategory(mapped.mappedKind), - scope: targetScope, - metadata, - }); - } - if (mappedEntries.length > 0) { - const storedEntries = await store.bulkStore(mappedEntries, ({ index, reason }) => { - api.logger.warn( - `memory-lancedb-pro: import bulkStore dropped entry ${index}: ${reason}`, - ); - }); - if (mdMirror) { - for (const stored of storedEntries) { - // retrieve heading from metadata JSON — critical when bulkStore filters entries - // because storedEntries[i] may not correspond to mappedEntries[i] - let heading = "unknown"; - try { - const storedMeta = stored.metadata ? JSON.parse(stored.metadata) : {}; - heading = storedMeta._reflectionHeading ?? "unknown"; - } catch { - api.logger.warn(`memory-reflection: failed to parse stored metadata for entry ${stored.id}, using "unknown"`); - } - await mdMirror( - { text: stored.text, category: stored.category, scope: stored.scope, timestamp: stored.timestamp }, - { source: `reflection:${heading}`, agentId: sourceAgentId }, - ); - } - } - } - - if (reflectionStoreToLanceDB) { - const stored = await storeReflectionToLanceDB({ - reflectionText, - sessionKey, - sessionId: currentSessionId || "unknown", - agentId: ownerAgentId, - command: String(event.action || "unknown"), - scope: targetScope, - toolErrorSignals, - runAt: nowTs, - usedFallback: reflectionGenerated.usedFallback, - eventId: reflectionEventId, - sourceReflectionPath: relPath, - writeLegacyCombined: reflectionWriteLegacyCombined, - embedPassage: (text) => embedForReflectionPersistence(text, "slice-embedding"), - vectorSearch: (vector, limit, minScore, scopeFilter) => - store.vectorSearch(vector, limit, minScore, scopeFilter), - store: (entry) => store.store(entry), - onPersisted: mdMirror - ? async (entry, kind) => { + scope: targetScope, + metadata, + }); + } + if (mappedEntries.length > 0) { + const storedEntries = await store.bulkStore(mappedEntries, ({ index, reason }) => { + api.logger.warn( + `memory-lancedb-pro: import bulkStore dropped entry ${index}: ${reason}`, + ); + }); + if (mdMirror) { + for (const stored of storedEntries) { + // retrieve heading from metadata JSON — critical when bulkStore filters entries + // because storedEntries[i] may not correspond to mappedEntries[i] + let heading = "unknown"; + try { + const storedMeta = stored.metadata ? JSON.parse(stored.metadata) : {}; + heading = storedMeta._reflectionHeading ?? "unknown"; + } catch { + api.logger.warn(`memory-reflection: failed to parse stored metadata for entry ${stored.id}, using "unknown"`); + } + await mdMirror( + { text: stored.text, category: stored.category, scope: stored.scope, timestamp: stored.timestamp }, + { source: `reflection:${heading}`, agentId: sourceAgentId }, + ); + } + } + } + + if (reflectionStoreToLanceDB) { + const stored = await storeReflectionToLanceDB({ + reflectionText, + sessionKey, + sessionId: currentSessionId || "unknown", + agentId: ownerAgentId, + command: String(event.action || "unknown"), + scope: targetScope, + toolErrorSignals, + runAt: nowTs, + usedFallback: reflectionGenerated.usedFallback, + eventId: reflectionEventId, + sourceReflectionPath: relPath, + writeLegacyCombined: reflectionWriteLegacyCombined, + embedPassage: (text) => embedForReflectionPersistence(text, "slice-embedding"), + vectorSearch: (vector, limit, minScore, scopeFilter) => + store.vectorSearch(vector, limit, minScore, scopeFilter), + store: (entry) => store.store(entry), + onPersisted: mdMirror + ? async (entry, kind) => { // The event row is a run-marker (kv stamp, no semantic content); the // daily journal already records the run via its "Reflection generated" // line, so mirroring the stamp only adds a content-less entry. - if (kind === "event") return; - const source = - kind === "item-invariant" ? "reflection-slice:invariant" - : "reflection-slice:derived"; - await mdMirror( - { text: entry.text, category: entry.category, scope: entry.scope, timestamp: entry.timestamp }, - { source, agentId: sourceAgentId }, - ); - } - : undefined, - }); - if (sessionKey && stored.slices.derived.length > 0 && !isSessionBoundaryReflectionAction(action)) { - reflectionDerivedBySession.set(sessionKey, { - // Deliberately Date.now(), not nowTs (which mirrors the host-supplied - // event.timestamp and can be skewed/future-dated): this field is a TTL - // bookkeeping mark, and DEFAULT_REFLECTION_CACHE_TTL_MS above compares it - // against a fresh Date.now() on every read. A skewed updatedAt can make - // "Date.now() - updatedAt" go negative, which is always < the TTL, so the - // cache would read as fresh indefinitely until wall-clock time caught up. - updatedAt: Date.now(), - derived: stored.slices.derived, - }); - } - for (const cacheKey of reflectionByAgentCache.keys()) { - if (cacheKey.startsWith(`${sourceAgentId}::`)) reflectionByAgentCache.delete(cacheKey); - } - } else if (sessionKey && reflectionGenerated.usedFallback) { - reflectionDerivedBySession.delete(sessionKey); - } - - const dailyPath = join(workspaceDir, "memory", `${dateStr}.md`); - await ensureDailyLogFile(dailyPath, dateStr); - await appendFile(dailyPath, `- [${timeHms} UTC] Reflection generated: \`${relPath}\`\n`, "utf-8"); - - api.logger.info(`memory-reflection: wrote ${relPath} for session ${currentSessionId}`); - } catch (err) { - api.logger.warn(`memory-reflection: hook failed: ${String(err)}`); - } finally { - if (sessionKey) { - reflectionErrorStateBySession.delete(sessionKey); - if (isSessionBoundaryReflectionAction(action)) { - const now = Date.now(); - reflectionDerivedBySession.delete(sessionKey); - reflectionDerivedSuppressionBySession.set(sessionKey, { - updatedAt: now, - until: now + DEFAULT_REFLECTION_BOUNDARY_DERIVED_SUPPRESSION_MS, - reason: action, - }); - } - getGlobalReflectionLock().delete(sessionKey); - getSerialGuardMap().set(sessionKey, Date.now()); - // NOTE: This guard is tested via inline simulation in - // test/memory-reflection-issue680-tdd.test.mjs "Bug #1: serial guard on early throw". - // The test verifies this runs unconditionally in finally (not gated by reflectionRan). - } - pruneReflectionSessionState(); - } - }; - - api.registerHook("command:new", runMemoryReflection, { - name: "memory-lancedb-pro.memory-reflection.command-new", - description: "Generate reflection log before /new", - }); - api.registerHook("command:reset", runMemoryReflection, { - 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)" - ); - } - + if (kind === "event") return; + const source = + kind === "item-invariant" ? "reflection-slice:invariant" + : "reflection-slice:derived"; + await mdMirror( + { text: entry.text, category: entry.category, scope: entry.scope, timestamp: entry.timestamp }, + { source, agentId: sourceAgentId }, + ); + } + : undefined, + }); + if (sessionKey && stored.slices.derived.length > 0 && !isSessionBoundaryReflectionAction(action)) { + reflectionDerivedBySession.set(sessionKey, { + // Deliberately Date.now(), not nowTs (which mirrors the host-supplied + // event.timestamp and can be skewed/future-dated): this field is a TTL + // bookkeeping mark, and DEFAULT_REFLECTION_CACHE_TTL_MS above compares it + // against a fresh Date.now() on every read. A skewed updatedAt can make + // "Date.now() - updatedAt" go negative, which is always < the TTL, so the + // cache would read as fresh indefinitely until wall-clock time caught up. + updatedAt: Date.now(), + derived: stored.slices.derived, + }); + } + for (const cacheKey of reflectionByAgentCache.keys()) { + if (cacheKey.startsWith(`${sourceAgentId}::`)) reflectionByAgentCache.delete(cacheKey); + } + } else if (sessionKey && reflectionGenerated.usedFallback) { + reflectionDerivedBySession.delete(sessionKey); + } + + const dailyPath = join(workspaceDir, "memory", `${dateStr}.md`); + await ensureDailyLogFile(dailyPath, dateStr); + await appendFile(dailyPath, `- [${timeHms} UTC] Reflection generated: \`${relPath}\`\n`, "utf-8"); + + api.logger.info(`memory-reflection: wrote ${relPath} for session ${currentSessionId}`); + } catch (err) { + api.logger.warn(`memory-reflection: hook failed: ${String(err)}`); + } finally { + if (sessionKey) { + reflectionErrorStateBySession.delete(sessionKey); + if (isSessionBoundaryReflectionAction(action)) { + const now = Date.now(); + reflectionDerivedBySession.delete(sessionKey); + reflectionDerivedSuppressionBySession.set(sessionKey, { + updatedAt: now, + until: now + DEFAULT_REFLECTION_BOUNDARY_DERIVED_SUPPRESSION_MS, + reason: action, + }); + } + getGlobalReflectionLock().delete(sessionKey); + getSerialGuardMap().set(sessionKey, Date.now()); + // NOTE: This guard is tested via inline simulation in + // test/memory-reflection-issue680-tdd.test.mjs "Bug #1: serial guard on early throw". + // The test verifies this runs unconditionally in finally (not gated by reflectionRan). + } + pruneReflectionSessionState(); + } + }; + + api.registerHook("command:new", runMemoryReflection, { + name: "memory-lancedb-pro.memory-reflection.command-new", + description: "Generate reflection log before /new", + }); + api.registerHook("command:reset", runMemoryReflection, { + 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)" + ); + } + if (config.sessionStrategy === "systemSessionMemory") { const sessionMessageCount = config.sessionMemory?.messageCount ?? 15; const SESSION_SUMMARY_GUARD = Symbol.for("openclaw.memory-lancedb-pro.session-summary-guard"); @@ -6285,7 +6322,10 @@ export function parsePluginConfig(value: unknown): PluginConfig { const storageAutoCleanupRaw = typeof storageMaintenanceRaw?.autoCleanup === "object" && storageMaintenanceRaw.autoCleanup !== null ? storageMaintenanceRaw.autoCleanup as Record : null; - const readConsistencyIntervalSecondsRaw = parseNonNegativeInt(storageMaintenanceRaw?.readConsistencyIntervalSeconds); + const readConsistencyIntervalSecondsRaw = parseNonNegativeInt( + storageMaintenanceRaw?.readConsistencyIntervalSeconds, + "plugins.entries.memory-lancedb-pro.config.storageMaintenance.readConsistencyIntervalSeconds" + ); const lockingRaw = typeof cfg.locking === "object" && cfg.locking !== null ? cfg.locking as Record : null;