diff --git a/dist/index.js b/dist/index.js index 1069fc30..ca7c192b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -38,7 +38,7 @@ import { extractReflectionLearningGovernanceCandidates, extractInjectableReflect import { createReflectionEventId } from "./src/reflection-event-store.js"; import { buildReflectionMappedMetadata, getReflectionMappedStorageCategory } from "./src/reflection-mapped-metadata.js"; import { buildFallbackCandidate, gateRegexFallbackCapture } from "./src/autocapture-fallback-admission.js"; -import { gateMappedReflectionEntries } from "./src/reflection-mapped-admission.js"; +import { gateMappedReflectionEntries, resolveMappedRowAdmissionController } from "./src/reflection-mapped-admission.js"; import { createMemoryCLI } from "./cli.js"; import { isNoise } from "./src/noise-filter.js"; import { normalizeAutoCaptureText } from "./src/auto-capture-cleanup.js"; @@ -46,14 +46,14 @@ import { normalizeAutoCaptureText } from "./src/auto-capture-cleanup.js"; import { SmartExtractor, createExtractionRateLimiter } from "./src/smart-extractor.js"; import { compressTexts, estimateConversationValue } from "./src/session-compressor.js"; import { NoisePrototypeBank } from "./src/noise-prototypes.js"; -import { createLlmClient } from "./src/llm-client.js"; +import { createLlmClient, normalizeDirectModelRef } from "./src/llm-client.js"; import { createDecayEngine, DEFAULT_DECAY_CONFIG } from "./src/decay-engine.js"; import { createTierManager, DEFAULT_TIER_CONFIG } from "./src/tier-manager.js"; import { createMemoryUpgrader } from "./src/memory-upgrader.js"; import { buildSmartMetadata, parseSmartMetadata, stringifySmartMetadata, toLifecycleMemory, } from "./src/smart-metadata.js"; import { computeTier1Patch, isSuppressed as isTier1Suppressed, TIER1_DEFAULT_BAD_RECALL_DECAY_MS, TIER1_DEFAULT_SUPPRESSION_DURATION_MS, } from "./src/auto-recall-tier1.js"; import { filterUserMdExclusiveRecallResults, isUserMdExclusiveMemory, } from "./src/workspace-boundary.js"; -import { normalizeAdmissionControlConfig, resolveRejectedAuditFilePath, AdmissionController, } from "./src/admission-control.js"; +import { createAdmissionController, normalizeAdmissionControlConfig, resolveAdmissionModel, resolveRejectedAuditFilePath, } from "./src/admission-control.js"; import { analyzeIntent, applyCategoryBoost } from "./src/intent-analyzer.js"; import { createOpenClawMemoryCapability } from "./src/openclaw-memory-capability.js"; import { CanonicalCorpusIndexer, parseCanonicalCorpusConfig, } from "./src/corpus-indexer.js"; @@ -1846,69 +1846,119 @@ function _initPluginState(api) { : undefined; const llmOauthProvider = llmAuth === "oauth" ? config.llm?.oauthProvider : undefined; const llmTimeoutMs = resolveLlmTimeoutMs(config); + const makeClientForModel = (model, thinkLevel = config.llm?.thinkLevel) => createLlmClient({ + auth: llmAuth, + apiKey: llmApiKey, + model, + baseURL: llmBaseURL, + oauthProvider: llmOauthProvider, + oauthPath: llmOauthPath, + timeoutMs: llmTimeoutMs, + log: (msg) => api.logger.debug(msg), + warnLog: (msg) => api.logger.warn(msg), + thinkLevel, + }); return { llmModel, llmTimeoutMs, - llmClient: createLlmClient({ - auth: llmAuth, - apiKey: llmApiKey, - model: llmModel, - baseURL: llmBaseURL, - oauthProvider: llmOauthProvider, - oauthPath: llmOauthPath, - timeoutMs: llmTimeoutMs, - log: (msg) => api.logger.debug(msg), - warnLog: (msg) => api.logger.warn(msg), - }), + llmClient: makeClientForModel(llmModel), + makeClientForModel, }; }; + // Admission control is constructed independently of SmartExtractor (one + // controller, injected) so gating works the same for extraction, the regex + // fallback, and mapped-reflection rows whether or not smart extraction is + // enabled. admissionControl.enabled remains a supported configuration on + // its own. let smartExtractor = null; - if (config.smartExtraction !== false) { + let admissionController = null; + let admissionControllerReflectionLane = null; + if (config.smartExtraction !== false || config.admissionControl?.enabled === true) { try { - const { llmClient, llmModel, llmTimeoutMs } = buildMemoryLlmClient(); - const noiseBank = new NoisePrototypeBank((msg) => api.logger.debug(msg)); - noiseBank.init(embedder).catch((err) => api.logger.debug(`memory-lancedb-pro: noise bank init: ${String(err)}`)); - smartExtractor = new SmartExtractor(store, embedder, llmClient, { - user: "User", - extractMinMessages: config.extractMinMessages ?? 4, - extractMaxChars: config.extractMaxChars ?? 8000, - batchChunkSize: config.batchChunkSize, - defaultScope: config.scopes?.default ?? "global", - workspaceBoundary: config.workspaceBoundary, + const { llmClient, llmModel, llmTimeoutMs, makeClientForModel } = buildMemoryLlmClient(); + // Model resolution for admission calls: explicit admissionControl.model + // override > lane affinity (the reflection lane resolves the + // memoryReflection model and, with affinity on, its thinkLevel) > + // global default. See resolveAdmissionModel(). + const reflectionModelForAdmission = asNonEmptyString(config.memoryReflection?.model); + const admissionModelExtraction = resolveAdmissionModel({ admissionControl: config.admissionControl, - onAdmissionRejected: admissionRejectionAuditWriter ?? undefined, - onPersisted: mdMirror ?? undefined, - log: (msg) => api.logger.info(msg), - debugLog: (msg) => api.logger.debug(msg), - noiseBank, + lane: "other", + globalModel: llmModel, + reflectionModel: reflectionModelForAdmission, }); - (isCliMode() ? api.logger.debug : api.logger.info)("memory-lancedb-pro: smart extraction enabled (LLM model: " - + llmModel - + ", timeoutMs: " - + llmTimeoutMs - + ", noise bank: ON)"); - } - catch (err) { - api.logger.warn(`memory-lancedb-pro: smart extraction init failed, falling back to regex: ${String(err)}`); - } - } - // admissionControl.enabled is a supported configuration on its own: without - // this, disabling smart extraction (or its init failing) silently dropped the - // admission gate from the regex fallback and mapped-reflection paths. - let fallbackAdmissionController = null; - let fallbackPersistAdmissionAudit = false; - if (!smartExtractor && config.admissionControl?.enabled === true) { - try { - fallbackAdmissionController = new AdmissionController(store, buildMemoryLlmClient().llmClient, config.admissionControl, (msg) => api.logger.debug(msg)); - fallbackPersistAdmissionAudit = config.admissionControl.auditMetadata !== false; - api.logger.info("memory-lancedb-pro: admission control constructed for capture fallbacks (smart extraction inactive)"); + const admissionModelReflection = resolveAdmissionModel({ + admissionControl: config.admissionControl, + lane: "reflection", + globalModel: llmModel, + reflectionModel: reflectionModelForAdmission, + }); + const globalThinkLevel = config.llm?.thinkLevel; + const laneAffinity = config.admissionControl?.modelAffinity === "lane"; + const reflectionThinkLevel = laneAffinity + ? (asNonEmptyString(config.memoryReflection?.thinkLevel) ?? globalThinkLevel) + : globalThinkLevel; + const admissionClientFor = (model, thinkLevel) => { + const directModel = normalizeDirectModelRef(model); + return directModel === llmModel && thinkLevel === globalThinkLevel + ? llmClient + : makeClientForModel(directModel, thinkLevel); + }; + // The plugin-level batchChunkSize knob bounds the batch-utility stage + // too; it is injected here rather than parsed from the admissionControl + // section so one knob governs every batched stage. + const admissionConfigWithChunk = { + ...config.admissionControl, + batchChunkSize: config.batchChunkSize, + }; + admissionController = createAdmissionController(store, admissionClientFor(admissionModelExtraction, globalThinkLevel), admissionConfigWithChunk, (msg) => api.logger.debug(msg)); + // modelAffinity "lane": the mapped-reflection admission judge rides the + // reflection lane's model (and thinkLevel); "global" keeps every lane + // on the plugin llm, judge included, sharing one controller instance. + admissionControllerReflectionLane = + admissionModelReflection === admissionModelExtraction && reflectionThinkLevel === globalThinkLevel + ? admissionController + : createAdmissionController(store, admissionClientFor(admissionModelReflection, reflectionThinkLevel), admissionConfigWithChunk, (msg) => api.logger.debug(msg)); + if (admissionController && config.smartExtraction === false) { + api.logger.info("memory-lancedb-pro: admission control constructed for capture fallbacks (smart extraction inactive)"); + } + if (config.smartExtraction !== false) { + const noiseBank = new NoisePrototypeBank((msg) => api.logger.debug(msg)); + noiseBank.init(embedder).catch((err) => api.logger.debug(`memory-lancedb-pro: noise bank init: ${String(err)}`)); + smartExtractor = new SmartExtractor(store, embedder, llmClient, { + user: "User", + extractMinMessages: config.extractMinMessages ?? 4, + extractMaxChars: config.extractMaxChars ?? 8000, + batchChunkSize: config.batchChunkSize, + defaultScope: config.scopes?.default ?? "global", + workspaceBoundary: config.workspaceBoundary, + admissionControl: config.admissionControl, + admissionController, + onAdmissionRejected: admissionRejectionAuditWriter ?? undefined, + onPersisted: mdMirror ?? undefined, + log: (msg) => api.logger.info(msg), + debugLog: (msg) => api.logger.debug(msg), + noiseBank, + }); + (isCliMode() ? api.logger.debug : api.logger.info)("memory-lancedb-pro: smart extraction enabled (LLM model: " + + llmModel + + ", timeoutMs: " + + llmTimeoutMs + + ", noise bank: ON)"); + } } catch (err) { - api.logger.error(`memory-lancedb-pro: fallback admission init failed; admission-gated captures FAIL CLOSED until init succeeds: ${String(err)}`); + if (config.smartExtraction !== false) { + api.logger.warn(`memory-lancedb-pro: smart extraction init failed, falling back to regex: ${String(err)}`); + } + else { + api.logger.error(`memory-lancedb-pro: fallback admission init failed; admission-gated captures FAIL CLOSED until init succeeds: ${String(err)}`); + } } } - const captureAdmissionController = () => smartExtractor?.getAdmissionController() ?? fallbackAdmissionController; - const captureAdmissionAudit = () => smartExtractor ? smartExtractor.shouldPersistAdmissionAudit() : fallbackPersistAdmissionAudit; + const captureAdmissionController = () => admissionController; + const captureAdmissionAudit = () => admissionController !== null && config.admissionControl?.auditMetadata !== false; + const captureReflectionAdmissionController = () => admissionControllerReflectionLane; const extractionRateLimiter = createExtractionRateLimiter({ maxExtractionsPerHour: config.extractionThrottle?.maxExtractionsPerHour, }); @@ -1964,6 +2014,7 @@ function _initPluginState(api) { autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, + captureReflectionAdmissionController, admissionRejectionAuditWriter, }; } @@ -2073,7 +2124,7 @@ const memoryLanceDBProPlugin = { _registeredApisMap.delete(api); // dual-track rollback: Map un-claim throw err; } - const { config, resolvedDbPath, vectorDim, store, embedder, retriever, canonicalCorpusIndexer, dreamingEngine, dreamingScheduler, scopeManager, migrator, smartExtractor, mdMirror, decayEngine, tierManager, extractionRateLimiter, reflectionErrorStateBySession, reflectionDerivedBySession, reflectionDerivedSuppressionBySession, reflectionByAgentCache, reflectionByAgentCacheGeneration, recallHistory, turnCounter, autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureCountedPendingCount, autoCaptureRecentTexts, autoCaptureDeferredFlushTexts, autoCaptureSessionIdToKey, autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, admissionRejectionAuditWriter, } = singleton; + const { config, resolvedDbPath, vectorDim, store, embedder, retriever, canonicalCorpusIndexer, dreamingEngine, dreamingScheduler, scopeManager, migrator, smartExtractor, mdMirror, decayEngine, tierManager, extractionRateLimiter, reflectionErrorStateBySession, reflectionDerivedBySession, reflectionDerivedSuppressionBySession, reflectionByAgentCache, reflectionByAgentCacheGeneration, recallHistory, turnCounter, autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureCountedPendingCount, autoCaptureRecentTexts, autoCaptureDeferredFlushTexts, autoCaptureSessionIdToKey, autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, admissionRejectionAuditWriter, } = singleton; const learnAutoCaptureSessionAlias = (sessionId, sessionKey) => { if (typeof sessionId !== "string" || !sessionId || typeof sessionKey !== "string" || !sessionKey @@ -2327,7 +2378,7 @@ const memoryLanceDBProPlugin = { const pendingRecall = new Map(); const logReg = isCliMode() ? api.logger.debug : api.logger.info; if (isFirstRegistration) { - logReg(`memory-lancedb-pro@${pluginVersion}: plugin registered (db: ${resolvedDbPath}, model: ${config.embedding.model || "text-embedding-3-small"}, smartExtraction: ${smartExtractor ? 'ON' : 'OFF'})`); + logReg(`memory-lancedb-pro@${pluginVersion}: plugin registered (db: ${resolvedDbPath}, model: ${config.embedding.model || "text-embedding-3-small"}, smartExtraction: ${smartExtractor ? 'ON' : 'OFF'}, admissionControl: ${captureAdmissionController() ? 'ON' : 'OFF'})`); logReg(`memory-lancedb-pro: diagnostic build tag loaded (${DIAG_BUILD_TAG})`); } // Dual-memory model warning: help users understand the two-layer architecture @@ -4330,7 +4381,7 @@ const memoryLanceDBProPlugin = { // historical per-row path otherwise; passthrough when admission // control (or smart extraction) is disabled. const mappedGateResults = await gateMappedReflectionEntries({ - admissionController: captureAdmissionController(), + admissionController: resolveMappedRowAdmissionController(captureReflectionAdmissionController(), captureAdmissionController()), admissionRequired: config.admissionControl?.enabled === true, attachAudit: captureAdmissionAudit(), rows: gateEligible.map(({ mapped, vector }) => ({ diff --git a/dist/src/admission-control.js b/dist/src/admission-control.js index 53b9902b..0b414fc9 100644 --- a/dist/src/admission-control.js +++ b/dist/src/admission-control.js @@ -213,6 +213,10 @@ export function normalizeAdmissionControlConfig(raw) { obj.rejectedAuditFilePath.trim().length > 0 ? obj.rejectedAuditFilePath.trim() : undefined, + model: typeof obj.model === "string" && obj.model.trim().length > 0 + ? obj.model.trim() + : undefined, + modelAffinity: obj.modelAffinity === "lane" ? "lane" : "global", }; } export function resolveRejectedAuditFilePath(dbPath, config) { @@ -466,6 +470,53 @@ function parseBatchUtilityResponse(response, expectedCount) { } return out; } +/** + * The admission-control LLM client talks directly to OpenRouter, so it needs + * the bare "/" id OpenRouter's chat-completions API expects. + * Model refs sourced from memoryReflection.model (or an explicit override) + * may instead be in the core-style "openrouter//" form the + * reflection distiller's own embedded runner accepts — that runner picks a + * backend from the leading segment, then forwards the rest as the model id. + * Strip that literal "openrouter/" prefix so both forms reach this plugin's + * direct client correctly; a bare "/" or an "@preset/" + * alias already work against OpenRouter unchanged, so they pass through. + */ +export function normalizeAdmissionModelRef(modelRef) { + const trimmed = modelRef.trim(); + const idx = trimmed.indexOf("/"); + if (idx <= 0) + return trimmed; + const provider = trimmed.slice(0, idx).trim().toLowerCase(); + if (provider !== "openrouter") + return trimmed; + const rest = trimmed.slice(idx + 1).trim(); + return rest || trimmed; +} +/** + * Resolves which LLM model an admission call should use, in order: + * 1. An explicit admissionControl.model override always wins, on every lane. + * 2. When modelAffinity is "lane", the reflection lane resolves the + * memoryReflection model (falling back to the global model if none is + * configured) — the judge is never dumber than the author whose rows it + * audits. Every other lane stays on the global model. + * 3. Default ("global", or the knob absent): every lane uses the global + * model — today's behavior, unchanged. + * Every returned model passes through normalizeAdmissionModelRef so a + * core-style provider-prefixed string reaches this plugin's OpenRouter-direct + * client in the form it requires, regardless of which of the three paths + * above produced it. + */ +export function resolveAdmissionModel(params) { + const explicit = params.admissionControl.model?.trim(); + if (explicit) { + return normalizeAdmissionModelRef(explicit); + } + if (params.admissionControl.modelAffinity === "lane" && params.lane === "reflection") { + const reflectionModel = params.reflectionModel?.trim(); + return normalizeAdmissionModelRef(reflectionModel || params.globalModel); + } + return normalizeAdmissionModelRef(params.globalModel); +} function buildReason(details) { const scoreText = details.score.toFixed(3); const similarityText = details.maxSimilarity.toFixed(3); diff --git a/dist/src/llm-client.js b/dist/src/llm-client.js index f772dcb8..3bea16d6 100644 --- a/dist/src/llm-client.js +++ b/dist/src/llm-client.js @@ -4,6 +4,22 @@ */ import OpenAI from "openai"; import { buildOauthEndpoint, extractOutputTextFromSse, loadOAuthSession, needsRefresh, normalizeOauthModel, refreshOAuthSession, saveOAuthSession, } from "./llm-oauth.js"; +/** + * Strips a core-style provider prefix (e.g. "openrouter/anthropic/claude-...") + * down to the bare "/" form a direct OpenRouter-compatible API + * needs. Any other prefix, or a string with no "/", passes through unchanged. + */ +export function normalizeDirectModelRef(modelRef) { + const trimmed = modelRef.trim(); + const idx = trimmed.indexOf("/"); + if (idx <= 0) + return trimmed; + const provider = trimmed.slice(0, idx).trim().toLowerCase(); + if (provider !== "openrouter") + return trimmed; + const rest = trimmed.slice(idx + 1).trim(); + return rest || trimmed; +} const DEFAULT_SYSTEM_PROMPT = "You are a memory extraction assistant. Always respond with valid JSON only."; /** * Extract JSON from an LLM response that may be wrapped in markdown fences @@ -202,6 +218,9 @@ function createApiKeyClient(config, log, warnLog) { ...(shouldDisableReasoningForJson(config.model) ? { chat_template_kwargs: { enable_thinking: false } } : {}), + ...(config.thinkLevel?.trim() + ? { reasoning: { effort: config.thinkLevel.trim() } } + : {}), }; // Transmit the internal call label as a request header so gateway-side // observability (tracing UIs, proxy logs) can distinguish call sites @@ -411,7 +430,16 @@ function createOauthClient(config, log, warnLog) { }, }; } +/** + * Resolves the canonical llm.thinkLevel value. Blank/whitespace-only values + * count as unset, so an accidentally-materialized empty string can never + * masquerade as "the user actually set it". + */ +export function resolveThinkLevel(config) { + return config.thinkLevel?.trim() || undefined; +} export function createLlmClient(config) { + config = { ...config, thinkLevel: resolveThinkLevel(config) }; const log = config.log ?? (() => { }); const warnLog = config.warnLog; if (config.auth === "oauth") { diff --git a/dist/src/reflection-mapped-admission.js b/dist/src/reflection-mapped-admission.js index 577060f1..663f879f 100644 --- a/dist/src/reflection-mapped-admission.js +++ b/dist/src/reflection-mapped-admission.js @@ -18,6 +18,15 @@ * reasons, and audit records are identical either way — only the LLM call * topology differs. */ +/** + * Which AdmissionController gates a mapped reflection row: the dedicated + * reflection-lane controller when lane affinity built one, otherwise the + * base controller shared with extraction. Both may be null (admission + * disabled), which the gate treats as passthrough. + */ +export function resolveMappedRowAdmissionController(reflectionLaneController, baseController) { + return reflectionLaneController ?? baseController; +} import { getReflectionMappedMemoryCategory, } from "./reflection-mapped-metadata.js"; function buildGateItem(row, conversationText, scopeFilter) { return { diff --git a/dist/src/retriever.js b/dist/src/retriever.js index be9f1704..17a29954 100644 --- a/dist/src/retriever.js +++ b/dist/src/retriever.js @@ -1129,7 +1129,11 @@ export class MemoryRetriever { this.decayEngine.applySearchBoost(scored); const reranked = results.map((result, index) => ({ ...result, - score: clamp01(scored[index].score, result.score * 0.3), + // Corpus rows carry the source file's mtime as their timestamp; + // reference material must not decay like conversation memory. + score: (result.entry.id ?? "").startsWith("corpus:") + ? result.score + : clamp01(scored[index].score, result.score * 0.3), })); return reranked.sort((a, b) => b.score - a.score); } @@ -1145,6 +1149,12 @@ export class MemoryRetriever { if (!anchor || anchor <= 0) return results; const normalized = results.map((r) => { + // Canonical corpus chunks are line-span document chunks: their length is + // a property of the chunker, not of entry quality. Normalising them by + // length double-penalises reference material (chunk size is already + // bounded by the indexer). + if ((r.entry.id ?? "").startsWith("corpus:")) + return r; const charLen = r.entry.text.length; const ratio = charLen / anchor; // No penalty for entries at or below anchor length. @@ -1177,6 +1187,9 @@ export class MemoryRetriever { return results; const now = Date.now(); const decayed = results.map((r) => { + // Reference chunks keep file mtimes — do not age them. + if ((r.entry.id ?? "").startsWith("corpus:")) + return r; const ts = r.entry.timestamp && r.entry.timestamp > 0 ? r.entry.timestamp : now; const ageDays = (now - ts) / 86_400_000; // Access reinforcement: frequently recalled memories decay slower diff --git a/dist/src/smart-extractor.js b/dist/src/smart-extractor.js index 01346c80..c3294c59 100644 --- a/dist/src/smart-extractor.js +++ b/dist/src/smart-extractor.js @@ -7,7 +7,6 @@ */ import { buildExtractionPrompt, buildDedupPrompt, buildGroundingRejudgePrompt, buildMergePrompt, buildBatchDedupPrompt, buildBatchMergePrompt, } from "./extraction-prompts.js"; import { formatExistingMemoryEntry } from "./prompt-blocks.js"; -import { AdmissionController, } from "./admission-control.js"; import { ALWAYS_MERGE_CATEGORIES, DURABLE_CATEGORIES, FICTION_JUDGED_CATEGORIES, REGISTER_STRICTNESS, getStorageCategoryForMemoryCategory, MERGE_SUPPORTED_CATEGORIES, TEMPORAL_VERSIONED_CATEGORIES, normalizeCategory, } from "./memory-categories.js"; import { isMetaFrustrationNoise, isNoise } from "./noise-filter.js"; import { appendRelation, buildSmartMetadata, deriveFactKey, parseSmartMetadata, stringifySmartMetadata, parseSupportInfo, updateSupportStats, } from "./smart-metadata.js"; @@ -264,15 +263,7 @@ export class SmartExtractor { config.admissionControl.auditMetadata !== false; this.onAdmissionRejected = config.onAdmissionRejected; this.onPersisted = config.onPersisted; - this.admissionController = - config.admissionControl?.enabled === true - ? new AdmissionController(this.store, this.llm, - // The plugin-level batchChunkSize knob bounds the batch-utility - // stage too; it is injected here rather than parsed from the - // admissionControl section so one knob governs every batched - // stage. - { ...config.admissionControl, batchChunkSize: config.batchChunkSize }, this.debugLog) - : null; + this.admissionController = config.admissionController ?? null; } /** * Expose the admission controller so sibling write paths (reflection diff --git a/index.ts b/index.ts index 12b5fa75..2938baad 100644 --- a/index.ts +++ b/index.ts @@ -68,7 +68,7 @@ import { import { createReflectionEventId } from "./src/reflection-event-store.js"; import { buildReflectionMappedMetadata, getReflectionMappedMemoryCategory, getReflectionMappedStorageCategory } from "./src/reflection-mapped-metadata.js"; import { buildFallbackCandidate, gateRegexFallbackCapture } from "./src/autocapture-fallback-admission.js"; -import { gateMappedReflectionEntries } from "./src/reflection-mapped-admission.js"; +import { gateMappedReflectionEntries, resolveMappedRowAdmissionController } from "./src/reflection-mapped-admission.js"; import { createMemoryCLI } from "./cli.js"; import { isNoise } from "./src/noise-filter.js"; import { normalizeAutoCaptureText } from "./src/auto-capture-cleanup.js"; @@ -77,7 +77,7 @@ import { normalizeAutoCaptureText } from "./src/auto-capture-cleanup.js"; import { SmartExtractor, createExtractionRateLimiter } from "./src/smart-extractor.js"; import { compressTexts, estimateConversationValue } from "./src/session-compressor.js"; import { NoisePrototypeBank } from "./src/noise-prototypes.js"; -import { createLlmClient } from "./src/llm-client.js"; +import { createLlmClient, normalizeDirectModelRef } from "./src/llm-client.js"; import { createDecayEngine, DEFAULT_DECAY_CONFIG } from "./src/decay-engine.js"; import { createTierManager, DEFAULT_TIER_CONFIG } from "./src/tier-manager.js"; import { createMemoryUpgrader } from "./src/memory-upgrader.js"; @@ -99,7 +99,9 @@ import { type WorkspaceBoundaryConfig, } from "./src/workspace-boundary.js"; import { + createAdmissionController, normalizeAdmissionControlConfig, + resolveAdmissionModel, resolveRejectedAuditFilePath, type AdmissionControlConfig, type AdmissionRejectionAuditEntry, @@ -263,6 +265,8 @@ interface PluginConfig { oauthProvider?: string; oauthPath?: string; timeoutMs?: number; + /** Reasoning effort for memory LLM calls (e.g. low | medium | high). Sent only when set; unset leaves the provider default. */ + thinkLevel?: string; }; extractMinMessages?: number; extractMaxChars?: number; @@ -2375,6 +2379,7 @@ interface PluginSingletonState { autoCaptureInFlightRuns: Map>>; captureAdmissionController: () => AdmissionController | null; captureAdmissionAudit: () => boolean; + captureReflectionAdmissionController: () => AdmissionController | null; admissionRejectionAuditWriter: ((entry: AdmissionRejectionAuditEntry) => Promise) | null; } @@ -2515,87 +2520,146 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState { : undefined; const llmOauthProvider = llmAuth === "oauth" ? config.llm?.oauthProvider : undefined; const llmTimeoutMs = resolveLlmTimeoutMs(config); - return { - llmModel, - llmTimeoutMs, - llmClient: createLlmClient({ + const makeClientForModel = ( + model: string, + thinkLevel: string | undefined = config.llm?.thinkLevel, + ) => + createLlmClient({ auth: llmAuth, apiKey: llmApiKey, - model: llmModel, + model, baseURL: llmBaseURL, oauthProvider: llmOauthProvider, oauthPath: llmOauthPath, timeoutMs: llmTimeoutMs, log: (msg: string) => api.logger.debug(msg), warnLog: (msg: string) => api.logger.warn(msg), - }), + thinkLevel, + }); + return { + llmModel, + llmTimeoutMs, + llmClient: makeClientForModel(llmModel), + makeClientForModel, }; }; + // Admission control is constructed independently of SmartExtractor (one + // controller, injected) so gating works the same for extraction, the regex + // fallback, and mapped-reflection rows whether or not smart extraction is + // enabled. admissionControl.enabled remains a supported configuration on + // its own. let smartExtractor: SmartExtractor | null = null; - if (config.smartExtraction !== false) { + let admissionController: AdmissionController | null = null; + let admissionControllerReflectionLane: AdmissionController | null = null; + if (config.smartExtraction !== false || config.admissionControl?.enabled === true) { try { - const { llmClient, llmModel, llmTimeoutMs } = buildMemoryLlmClient(); - - const noiseBank = new NoisePrototypeBank((msg: string) => api.logger.debug(msg)); - noiseBank.init(embedder).catch((err) => - api.logger.debug(`memory-lancedb-pro: noise bank init: ${String(err)}`), - ); - - smartExtractor = new SmartExtractor(store, embedder, llmClient, { - user: "User", - extractMinMessages: config.extractMinMessages ?? 4, - extractMaxChars: config.extractMaxChars ?? 8000, - batchChunkSize: config.batchChunkSize, - defaultScope: config.scopes?.default ?? "global", - workspaceBoundary: config.workspaceBoundary, + const { llmClient, llmModel, llmTimeoutMs, makeClientForModel } = buildMemoryLlmClient(); + + // Model resolution for admission calls: explicit admissionControl.model + // override > lane affinity (the reflection lane resolves the + // memoryReflection model and, with affinity on, its thinkLevel) > + // global default. See resolveAdmissionModel(). + const reflectionModelForAdmission = asNonEmptyString(config.memoryReflection?.model); + const admissionModelExtraction = resolveAdmissionModel({ admissionControl: config.admissionControl, - onAdmissionRejected: admissionRejectionAuditWriter ?? undefined, - onPersisted: mdMirror ?? undefined, - log: (msg: string) => api.logger.info(msg), - debugLog: (msg: string) => api.logger.debug(msg), - noiseBank, + lane: "other", + globalModel: llmModel, + reflectionModel: reflectionModelForAdmission, }); + const admissionModelReflection = resolveAdmissionModel({ + admissionControl: config.admissionControl, + lane: "reflection", + globalModel: llmModel, + reflectionModel: reflectionModelForAdmission, + }); + const globalThinkLevel = config.llm?.thinkLevel; + const laneAffinity = config.admissionControl?.modelAffinity === "lane"; + const reflectionThinkLevel = laneAffinity + ? (asNonEmptyString(config.memoryReflection?.thinkLevel) ?? globalThinkLevel) + : globalThinkLevel; + const admissionClientFor = (model: string, thinkLevel: string | undefined) => { + const directModel = normalizeDirectModelRef(model); + return directModel === llmModel && thinkLevel === globalThinkLevel + ? llmClient + : makeClientForModel(directModel, thinkLevel); + }; - (isCliMode() ? api.logger.debug : api.logger.info)( - "memory-lancedb-pro: smart extraction enabled (LLM model: " - + llmModel - + ", timeoutMs: " - + llmTimeoutMs - + ", noise bank: ON)", - ); - } catch (err) { - api.logger.warn(`memory-lancedb-pro: smart extraction init failed, falling back to regex: ${String(err)}`); - } - } - - // admissionControl.enabled is a supported configuration on its own: without - // this, disabling smart extraction (or its init failing) silently dropped the - // admission gate from the regex fallback and mapped-reflection paths. - let fallbackAdmissionController: AdmissionController | null = null; - let fallbackPersistAdmissionAudit = false; - if (!smartExtractor && config.admissionControl?.enabled === true) { - try { - fallbackAdmissionController = new AdmissionController( + // The plugin-level batchChunkSize knob bounds the batch-utility stage + // too; it is injected here rather than parsed from the admissionControl + // section so one knob governs every batched stage. + const admissionConfigWithChunk = { + ...config.admissionControl, + batchChunkSize: config.batchChunkSize, + }; + admissionController = createAdmissionController( store, - buildMemoryLlmClient().llmClient, - config.admissionControl, + admissionClientFor(admissionModelExtraction, globalThinkLevel), + admissionConfigWithChunk, (msg: string) => api.logger.debug(msg), ); - fallbackPersistAdmissionAudit = config.admissionControl.auditMetadata !== false; - api.logger.info( - "memory-lancedb-pro: admission control constructed for capture fallbacks (smart extraction inactive)", - ); + // modelAffinity "lane": the mapped-reflection admission judge rides the + // reflection lane's model (and thinkLevel); "global" keeps every lane + // on the plugin llm, judge included, sharing one controller instance. + admissionControllerReflectionLane = + admissionModelReflection === admissionModelExtraction && reflectionThinkLevel === globalThinkLevel + ? admissionController + : createAdmissionController( + store, + admissionClientFor(admissionModelReflection, reflectionThinkLevel), + admissionConfigWithChunk, + (msg: string) => api.logger.debug(msg), + ); + if (admissionController && config.smartExtraction === false) { + api.logger.info( + "memory-lancedb-pro: admission control constructed for capture fallbacks (smart extraction inactive)", + ); + } + + if (config.smartExtraction !== false) { + const noiseBank = new NoisePrototypeBank((msg: string) => api.logger.debug(msg)); + noiseBank.init(embedder).catch((err) => + api.logger.debug(`memory-lancedb-pro: noise bank init: ${String(err)}`), + ); + + smartExtractor = new SmartExtractor(store, embedder, llmClient, { + user: "User", + extractMinMessages: config.extractMinMessages ?? 4, + extractMaxChars: config.extractMaxChars ?? 8000, + batchChunkSize: config.batchChunkSize, + defaultScope: config.scopes?.default ?? "global", + workspaceBoundary: config.workspaceBoundary, + admissionControl: config.admissionControl, + admissionController, + onAdmissionRejected: admissionRejectionAuditWriter ?? undefined, + onPersisted: mdMirror ?? undefined, + log: (msg: string) => api.logger.info(msg), + debugLog: (msg: string) => api.logger.debug(msg), + noiseBank, + }); + + (isCliMode() ? api.logger.debug : api.logger.info)( + "memory-lancedb-pro: smart extraction enabled (LLM model: " + + llmModel + + ", timeoutMs: " + + llmTimeoutMs + + ", noise bank: ON)", + ); + } } catch (err) { - api.logger.error( - `memory-lancedb-pro: fallback admission init failed; admission-gated captures FAIL CLOSED until init succeeds: ${String(err)}`, - ); + if (config.smartExtraction !== false) { + api.logger.warn(`memory-lancedb-pro: smart extraction init failed, falling back to regex: ${String(err)}`); + } else { + api.logger.error( + `memory-lancedb-pro: fallback admission init failed; admission-gated captures FAIL CLOSED until init succeeds: ${String(err)}`, + ); + } } } - const captureAdmissionController = () => - smartExtractor?.getAdmissionController() ?? fallbackAdmissionController; + const captureAdmissionController = () => admissionController; const captureAdmissionAudit = () => - smartExtractor ? smartExtractor.shouldPersistAdmissionAudit() : fallbackPersistAdmissionAudit; + admissionController !== null && config.admissionControl?.auditMetadata !== false; + const captureReflectionAdmissionController = () => admissionControllerReflectionLane; const extractionRateLimiter = createExtractionRateLimiter({ maxExtractionsPerHour: config.extractionThrottle?.maxExtractionsPerHour, @@ -2654,6 +2718,7 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState { autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, + captureReflectionAdmissionController, admissionRejectionAuditWriter, }; } @@ -2813,6 +2878,7 @@ const memoryLanceDBProPlugin = { autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, + captureReflectionAdmissionController, admissionRejectionAuditWriter, } = singleton; @@ -3130,7 +3196,7 @@ const memoryLanceDBProPlugin = { const logReg = isCliMode() ? api.logger.debug : api.logger.info; if (isFirstRegistration) { logReg( - `memory-lancedb-pro@${pluginVersion}: plugin registered (db: ${resolvedDbPath}, model: ${config.embedding.model || "text-embedding-3-small"}, smartExtraction: ${smartExtractor ? 'ON' : 'OFF'})` + `memory-lancedb-pro@${pluginVersion}: plugin registered (db: ${resolvedDbPath}, model: ${config.embedding.model || "text-embedding-3-small"}, smartExtraction: ${smartExtractor ? 'ON' : 'OFF'}, admissionControl: ${captureAdmissionController() ? 'ON' : 'OFF'})` ); logReg(`memory-lancedb-pro: diagnostic build tag loaded (${DIAG_BUILD_TAG})`); } @@ -5497,7 +5563,10 @@ const memoryLanceDBProPlugin = { // historical per-row path otherwise; passthrough when admission // control (or smart extraction) is disabled. const mappedGateResults = await gateMappedReflectionEntries({ - admissionController: captureAdmissionController(), + admissionController: resolveMappedRowAdmissionController( + captureReflectionAdmissionController(), + captureAdmissionController(), + ), admissionRequired: config.admissionControl?.enabled === true, attachAudit: captureAdmissionAudit(), rows: gateEligible.map(({ mapped, vector }) => ({ diff --git a/openclaw.plugin.json b/openclaw.plugin.json index a88d6009..8376a5c2 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -466,6 +466,19 @@ "default": "balanced", "description": "Named admission tuning preset. Explicit admissionControl fields still override the selected preset." }, + "model": { + "type": "string", + "description": "Optional absolute override: when set, every admission LLM call uses this model, regardless of modelAffinity." + }, + "modelAffinity": { + "type": "string", + "enum": [ + "global", + "lane" + ], + "default": "global", + "description": "lane routes reflection-mapped admission calls to memoryReflection.model instead of the global llm model; global uses the plugin's global llm model for every lane." + }, "utilityMode": { "type": "string", "enum": [ @@ -1585,6 +1598,10 @@ "type": "integer", "minimum": 500, "default": 30000 + }, + "thinkLevel": { + "type": "string", + "description": "Reasoning effort requested from the model, e.g. low, medium, high. Sent only when explicitly configured (as the OpenRouter-compatible reasoning effort field); when unset, no reasoning parameter is sent and the provider's own default applies. Deliberately no JSON-schema default: a schema default gets materialized into the plugin config upstream on at least one OpenClaw host config-loading path, indistinguishably from a genuine user value." } } }, diff --git a/package.json b/package.json index 5456af07..17956b46 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "skills/**/*.md" ], "scripts": { - "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs", + "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs", "test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke", "test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression", "test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema", diff --git a/scripts/ci-test-manifest.mjs b/scripts/ci-test-manifest.mjs index ca2261f1..b5d55b43 100644 --- a/scripts/ci-test-manifest.mjs +++ b/scripts/ci-test-manifest.mjs @@ -3,6 +3,7 @@ export const CI_TEST_GROUPS = [ "core-regression", "storage-and-schema", "llm-clients-and-auth", + { group: "llm-clients-and-auth", runner: "node", file: "test/llm-thinklevel.test.mjs", args: ["--test"] }, "packaging-and-workflow", ]; @@ -131,6 +132,11 @@ export const CI_TEST_MANIFEST = [ { group: "core-regression", runner: "node", file: "test/admission-control-prompt-shape.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/smart-extractor-merge-accounting.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/admission-utility-veto.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/admission-lane-model-affinity.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/admission-model-resolution.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/admission-controller-standalone.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/smart-extractor-admission-controller-injection.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/admission-without-smart-extraction.test.mjs", args: ["--test"] }, { group: "cli-smoke", runner: "node", file: "test/cli-subcommand-attachment.test.mjs", args: ["--test"] }, ]; diff --git a/src/admission-control.ts b/src/admission-control.ts index f5191868..94cf1ff1 100644 --- a/src/admission-control.ts +++ b/src/admission-control.ts @@ -59,6 +59,16 @@ export interface AdmissionControlConfig { auditMetadata: boolean; persistRejectedAudits: boolean; rejectedAuditFilePath?: string; + /** Absolute override: when set, every admission LLM call uses this model, regardless of lane. */ + model?: string; + /** + * "global" (default, also when absent): every admission call uses the + * plugin's global llm model. "lane": the reflection-mapped admission gate + * resolves the memoryReflection model instead, so the judge is never + * dumber than the author whose rows it audits; extraction and fallback + * admission stay on the global model. + */ + modelAffinity?: "global" | "lane"; } export interface AdmissionFeatureScores { @@ -364,6 +374,11 @@ export function normalizeAdmissionControlConfig(raw: unknown): AdmissionControlC obj.rejectedAuditFilePath.trim().length > 0 ? obj.rejectedAuditFilePath.trim() : undefined, + model: + typeof obj.model === "string" && obj.model.trim().length > 0 + ? obj.model.trim() + : undefined, + modelAffinity: obj.modelAffinity === "lane" ? "lane" : "global", }; } @@ -660,6 +675,63 @@ function parseBatchUtilityResponse( return out; } +/** Which admission call site is resolving a model. */ +export type AdmissionLane = "reflection" | "other"; + +/** + * The admission-control LLM client talks directly to OpenRouter, so it needs + * the bare "/" id OpenRouter's chat-completions API expects. + * Model refs sourced from memoryReflection.model (or an explicit override) + * may instead be in the core-style "openrouter//" form the + * reflection distiller's own embedded runner accepts — that runner picks a + * backend from the leading segment, then forwards the rest as the model id. + * Strip that literal "openrouter/" prefix so both forms reach this plugin's + * direct client correctly; a bare "/" or an "@preset/" + * alias already work against OpenRouter unchanged, so they pass through. + */ +export function normalizeAdmissionModelRef(modelRef: string): string { + const trimmed = modelRef.trim(); + const idx = trimmed.indexOf("/"); + if (idx <= 0) return trimmed; + const provider = trimmed.slice(0, idx).trim().toLowerCase(); + if (provider !== "openrouter") return trimmed; + const rest = trimmed.slice(idx + 1).trim(); + return rest || trimmed; +} + +/** + * Resolves which LLM model an admission call should use, in order: + * 1. An explicit admissionControl.model override always wins, on every lane. + * 2. When modelAffinity is "lane", the reflection lane resolves the + * memoryReflection model (falling back to the global model if none is + * configured) — the judge is never dumber than the author whose rows it + * audits. Every other lane stays on the global model. + * 3. Default ("global", or the knob absent): every lane uses the global + * model — today's behavior, unchanged. + * Every returned model passes through normalizeAdmissionModelRef so a + * core-style provider-prefixed string reaches this plugin's OpenRouter-direct + * client in the form it requires, regardless of which of the three paths + * above produced it. + */ +export function resolveAdmissionModel(params: { + admissionControl: Pick; + lane: AdmissionLane; + globalModel: string; + reflectionModel?: string; +}): string { + const explicit = params.admissionControl.model?.trim(); + if (explicit) { + return normalizeAdmissionModelRef(explicit); + } + + if (params.admissionControl.modelAffinity === "lane" && params.lane === "reflection") { + const reflectionModel = params.reflectionModel?.trim(); + return normalizeAdmissionModelRef(reflectionModel || params.globalModel); + } + + return normalizeAdmissionModelRef(params.globalModel); +} + function buildReason(details: { decision: "reject" | "pass_to_dedup"; hint?: "add" | "update_or_merge"; diff --git a/src/llm-client.ts b/src/llm-client.ts index 911f2b8d..5228f4fa 100644 --- a/src/llm-client.ts +++ b/src/llm-client.ts @@ -14,6 +14,21 @@ import { saveOAuthSession, } from "./llm-oauth.js"; +/** + * Strips a core-style provider prefix (e.g. "openrouter/anthropic/claude-...") + * down to the bare "/" form a direct OpenRouter-compatible API + * needs. Any other prefix, or a string with no "/", passes through unchanged. + */ +export function normalizeDirectModelRef(modelRef: string): string { + const trimmed = modelRef.trim(); + const idx = trimmed.indexOf("/"); + if (idx <= 0) return trimmed; + const provider = trimmed.slice(0, idx).trim().toLowerCase(); + if (provider !== "openrouter") return trimmed; + const rest = trimmed.slice(idx + 1).trim(); + return rest || trimmed; +} + export interface LlmClientConfig { apiKey?: string; model: string; @@ -25,6 +40,16 @@ export interface LlmClientConfig { log?: (msg: string) => void; /** Warn-level logger for user-visible failures (timeouts, retries, network errors). */ warnLog?: (msg: string) => void; + /** + * Reasoning effort requested from the model, e.g. "low" | "medium" | + * "high". Canonical config key (llm.thinkLevel), named for consistency + * with memoryReflection.thinkLevel. Sent only when explicitly configured + * (as reasoning: {effort: ...}, the OpenRouter-compatible shape); when + * unset, no reasoning parameter is sent at all, letting the provider's + * own default apply. Resolved from raw config by resolveThinkLevel before + * the client reads it. + */ + thinkLevel?: string; } const DEFAULT_SYSTEM_PROMPT = @@ -255,6 +280,9 @@ function createApiKeyClient(config: LlmClientConfig, log: (msg: string) => void, ...(shouldDisableReasoningForJson(config.model) ? { chat_template_kwargs: { enable_thinking: false } } : {}), + ...(config.thinkLevel?.trim() + ? { reasoning: { effort: config.thinkLevel.trim() } } + : {}), }; // Transmit the internal call label as a request header so gateway-side @@ -482,7 +510,19 @@ function createOauthClient(config: LlmClientConfig, log: (msg: string) => void, }; } +/** + * Resolves the canonical llm.thinkLevel value. Blank/whitespace-only values + * count as unset, so an accidentally-materialized empty string can never + * masquerade as "the user actually set it". + */ +export function resolveThinkLevel( + config: Pick, +): string | undefined { + return config.thinkLevel?.trim() || undefined; +} + export function createLlmClient(config: LlmClientConfig): LlmClient { + config = { ...config, thinkLevel: resolveThinkLevel(config) }; const log = config.log ?? (() => {}); const warnLog = config.warnLog; if (config.auth === "oauth") { diff --git a/src/reflection-mapped-admission.ts b/src/reflection-mapped-admission.ts index 67ab9c63..a0f3b58a 100644 --- a/src/reflection-mapped-admission.ts +++ b/src/reflection-mapped-admission.ts @@ -19,7 +19,20 @@ * topology differs. */ -import type { AdmissionEvaluation } from "./admission-control.js"; +import type { AdmissionEvaluation, AdmissionController } from "./admission-control.js"; + +/** + * Which AdmissionController gates a mapped reflection row: the dedicated + * reflection-lane controller when lane affinity built one, otherwise the + * base controller shared with extraction. Both may be null (admission + * disabled), which the gate treats as passthrough. + */ +export function resolveMappedRowAdmissionController( + reflectionLaneController: AdmissionController | null, + baseController: AdmissionController | null, +): AdmissionController | null { + return reflectionLaneController ?? baseController; +} import type { CandidateMemory } from "./memory-categories.js"; import { getReflectionMappedMemoryCategory, diff --git a/src/smart-extractor.ts b/src/smart-extractor.ts index 0dc67371..1f7bfd5d 100644 --- a/src/smart-extractor.ts +++ b/src/smart-extractor.ts @@ -397,6 +397,14 @@ export interface SmartExtractorConfig { workspaceBoundary?: WorkspaceBoundaryConfig; /** Optional admission-control governance layer before downstream dedup/persistence. */ admissionControl?: AdmissionControlConfig; + /** + * Pre-built admission controller, constructed independently of the + * extractor (e.g. by createAdmissionController) so admission gating works + * the same whether or not smart extraction itself is enabled. When + * provided, this instance is used as-is; the extractor never builds its + * own. Null/omitted means admission control is unavailable. + */ + admissionController?: AdmissionController | null; /** Optional scope-glob -> extraction policy map (Option C). Unmatched scopes default to "full". */ extractionPolicy?: Record; /** Optional sink for durable reject-audit logging. */ @@ -458,19 +466,7 @@ export class SmartExtractor { config.admissionControl.auditMetadata !== false; this.onAdmissionRejected = config.onAdmissionRejected; this.onPersisted = config.onPersisted; - this.admissionController = - config.admissionControl?.enabled === true - ? new AdmissionController( - this.store, - this.llm, - // The plugin-level batchChunkSize knob bounds the batch-utility - // stage too; it is injected here rather than parsed from the - // admissionControl section so one knob governs every batched - // stage. - { ...config.admissionControl, batchChunkSize: config.batchChunkSize }, - this.debugLog, - ) - : null; + this.admissionController = config.admissionController ?? null; } /** diff --git a/test/admission-controller-standalone.test.mjs b/test/admission-controller-standalone.test.mjs new file mode 100644 index 00000000..4c57e113 --- /dev/null +++ b/test/admission-controller-standalone.test.mjs @@ -0,0 +1,85 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import jitiFactory from "jiti"; + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); +const { createAdmissionController, normalizeAdmissionControlConfig, AdmissionController } = + jiti("../src/admission-control.ts"); + +describe("createAdmissionController", () => { + it("returns null when admission control is disabled", () => { + const config = normalizeAdmissionControlConfig({ enabled: false }); + const controller = createAdmissionController({}, {}, config); + assert.equal(controller, null); + }); + + it("returns a usable AdmissionController instance when enabled, without any extractor involved", async () => { + const config = normalizeAdmissionControlConfig({ enabled: true, utilityMode: "off" }); + const store = { + async vectorSearch() { + return []; + }, + }; + const llm = {}; + + const controller = createAdmissionController(store, llm, config); + + assert.ok(controller instanceof AdmissionController); + + const evaluation = await controller.evaluate({ + candidate: { + category: "events", + abstract: "user mentioned a fact", + overview: "## Event", + content: "the user mentioned a fact", + }, + candidateVector: [], + conversationText: "the user mentioned a fact today", + scopeFilter: ["global"], + }); + + assert.ok(evaluation.decision === "reject" || evaluation.decision === "pass_to_dedup"); + assert.equal(evaluation.audit.version, "amac-v1"); + }); + + // Live-fleet trace: llm-client.ts's completeJson() never throws on an HTTP + // failure (e.g. a 400 from a bad model id) -- it catches internally and + // resolves null. This pins the intended behavior for that contract: a null + // utility response must not abort or throw the evaluation; it degrades to + // a neutral utility score with an explicit, non-genuine reason string, and + // the overall decision still comes from the other (non-LLM) features. + it("degrades to a neutral utility score, not a thrown error, when the LLM client resolves null (e.g. an upstream request failure)", async () => { + const config = normalizeAdmissionControlConfig({ enabled: true, utilityMode: "standalone" }); + const store = { + async vectorSearch() { + return []; + }, + }; + const llm = { + async completeJson() { + return null; + }, + }; + + const controller = createAdmissionController(store, llm, config); + + const evaluation = await controller.evaluate({ + candidate: { + category: "profile", + abstract: "User is a backend engineer", + overview: "## Profile", + content: "The user is a backend engineer.", + }, + candidateVector: [], + conversationText: "I've been doing backend engineering for years", + scopeFilter: ["global"], + }); + + assert.equal(evaluation.audit.feature_scores.utility, 0.5, "utility score neutrally degrades, not zero/thrown"); + assert.equal(evaluation.audit.utility_reason, "Utility scoring unavailable"); + assert.ok( + evaluation.audit.reason.includes("Utility scoring unavailable"), + `expected the overall reason to surface the degraded utility call, got: ${evaluation.audit.reason}`, + ); + }); +}); diff --git a/test/admission-lane-model-affinity.test.mjs b/test/admission-lane-model-affinity.test.mjs new file mode 100644 index 00000000..2874c11e --- /dev/null +++ b/test/admission-lane-model-affinity.test.mjs @@ -0,0 +1,240 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); +const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + alias: { + "openclaw/plugin-sdk": pluginSdkStubPath, + }, +}); + +const retrieverModuleForMock = jiti("../src/retriever.js"); +const embedderModuleForMock = jiti("../src/embedder.js"); +const llmClientModuleForMock = jiti("../src/llm-client.js"); +const origCreateRetriever = retrieverModuleForMock.createRetriever; +const origCreateEmbedder = embedderModuleForMock.createEmbedder; +const origCreateLlmClient = llmClientModuleForMock.createLlmClient; + +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const { resetRegistration } = pluginModule; + +function mockCreateRetriever() { + return function mockCreateRetrieverImpl() { + return { + async retrieve() { + return []; + }, + getConfig() { + return { mode: "hybrid" }; + }, + setAccessTracker() {}, + setStatsCollector() {}, + }; + }; +} + +function mockCreateEmbedder() { + return function mockCreateEmbedderImpl() { + return { + async embedQuery() { + return new Float32Array(384).fill(0); + }, + async embedPassage() { + return new Float32Array(384).fill(0); + }, + }; + }; +} + +function mockCreateLlmClient(requestedModels) { + return function mockCreateLlmClientImpl(config) { + requestedModels.push(config.model); + return { + async completeJson() { + return null; + }, + }; + }; +} + +function createPluginApiHarness({ pluginConfig, resolveRoot }) { + const eventHandlers = new Map(); + const logs = { info: [], warn: [], debug: [] }; + + const api = { + pluginConfig, + resolvePath(target) { + if (typeof target !== "string") return target; + if (path.isAbsolute(target)) return target; + return path.join(resolveRoot, target); + }, + logger: { + info(message) { + logs.info.push(String(message)); + }, + warn(message) { + logs.warn.push(String(message)); + }, + debug(message) { + logs.debug.push(String(message)); + }, + error(message) { + logs.info.push(String(message)); + }, + }, + registerTool() {}, + registerCli() {}, + registerService() {}, + on(eventName, handler, meta) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta }); + eventHandlers.set(eventName, list); + }, + registerHook(eventName, handler, opts) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta: opts }); + eventHandlers.set(eventName, list); + }, + }; + + return { api, eventHandlers, logs }; +} + +function baseConfig(workspaceDir, overrides = {}) { + return { + dbPath: path.join(workspaceDir, "db"), + embedding: { apiKey: "test-api-key" }, + llm: { model: "global-model" }, + smartExtraction: true, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + ...overrides, + }; +} + +describe("admission lane model affinity", () => { + let workspaceDir; + let requestedModels; + + beforeEach(() => { + workspaceDir = mkdtempSync(path.join(tmpdir(), "admission-lane-model-")); + requestedModels = []; + retrieverModuleForMock.createRetriever = mockCreateRetriever(); + embedderModuleForMock.createEmbedder = mockCreateEmbedder(); + llmClientModuleForMock.createLlmClient = mockCreateLlmClient(requestedModels); + resetRegistration(); + }); + + afterEach(() => { + retrieverModuleForMock.createRetriever = origCreateRetriever; + embedderModuleForMock.createEmbedder = origCreateEmbedder; + llmClientModuleForMock.createLlmClient = origCreateLlmClient; + resetRegistration(); + rmSync(workspaceDir, { recursive: true, force: true }); + }); + + it("uses only the global model when modelAffinity is absent (default, zero change)", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: baseConfig(workspaceDir, { + admissionControl: { enabled: true }, + }), + }); + + memoryLanceDBProPlugin.register(harness.api); + + // The CLI command wrapper eagerly builds its own llmClient bound to the + // same global model, independent of admission control — that's expected + // and unrelated to this feature, so assert "never any other model" here + // rather than an exact call count. + assert.ok(requestedModels.length >= 1); + assert.ok( + requestedModels.every((m) => m === "global-model"), + `expected every requested model to be the global model, got: ${requestedModels.join(", ")}`, + ); + }); + + it("builds a second client bound to the memoryReflection model when modelAffinity is 'lane'", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: baseConfig(workspaceDir, { + admissionControl: { enabled: true, modelAffinity: "lane" }, + memoryReflection: { model: "reflection-model" }, + }), + }); + + memoryLanceDBProPlugin.register(harness.api); + + assert.ok(requestedModels.includes("global-model"), "extraction lane still resolves the global model"); + assert.ok(requestedModels.includes("reflection-model"), "reflection lane resolves the memoryReflection model"); + }); + + it("uses the same global-model client for both lanes when modelAffinity is 'lane' but no memoryReflection model is configured", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: baseConfig(workspaceDir, { + admissionControl: { enabled: true, modelAffinity: "lane" }, + }), + }); + + memoryLanceDBProPlugin.register(harness.api); + + assert.ok( + requestedModels.every((m) => m === "global-model"), + `expected every requested model to be the global model, got: ${requestedModels.join(", ")}`, + ); + }); + + it("normalizes a core-style provider-prefixed reflection model for the plugin's direct client (live 400 catch, 2026-07-18)", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: baseConfig(workspaceDir, { + admissionControl: { enabled: true, modelAffinity: "lane" }, + memoryReflection: { model: "openrouter/anthropic/claude-opus-4-8" }, + }), + }); + + memoryLanceDBProPlugin.register(harness.api); + + assert.ok( + requestedModels.includes("anthropic/claude-opus-4-8"), + "the lane clients must get the provider-stripped id a direct OpenRouter call accepts", + ); + assert.ok( + !requestedModels.includes("openrouter/anthropic/claude-opus-4-8"), + "the raw core-style catalog ref must never reach a direct client", + ); + }); + + it("lets an explicit admissionControl.model override beat lane affinity on every admission lane", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: baseConfig(workspaceDir, { + admissionControl: { enabled: true, modelAffinity: "lane", model: "override-model" }, + memoryReflection: { model: "reflection-model" }, + }), + }); + + memoryLanceDBProPlugin.register(harness.api); + + assert.ok(requestedModels.includes("global-model"), "the plain extraction client is still built"); + assert.ok(requestedModels.includes("override-model"), "admission calls use the explicit override"); + // An explicit admissionControl.model override governs every admission + // lane, so the reflection lane resolves to the same model as the + // extraction lane and both share one controller: no separate + // reflection-model client is ever built. + assert.ok( + !requestedModels.includes("reflection-model"), + "an explicit override beats lane affinity on the reflection lane too; no reflection-model client is built", + ); + }); +}); diff --git a/test/admission-model-resolution.test.mjs b/test/admission-model-resolution.test.mjs new file mode 100644 index 00000000..b23ee57f --- /dev/null +++ b/test/admission-model-resolution.test.mjs @@ -0,0 +1,153 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import jitiFactory from "jiti"; + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); +const { resolveAdmissionModel, normalizeAdmissionControlConfig } = jiti("../src/admission-control.ts"); + +describe("resolveAdmissionModel", () => { + it("defaults to the global model for both lanes when modelAffinity is absent", () => { + const admissionControl = normalizeAdmissionControlConfig({ enabled: true }); + + const other = resolveAdmissionModel({ + admissionControl, + lane: "other", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + + assert.equal(other, "global-model"); + assert.equal(reflection, "global-model"); + }); + + it("routes the reflection lane to the memoryReflection model when modelAffinity is 'lane'", () => { + const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" }); + + const other = resolveAdmissionModel({ + admissionControl, + lane: "other", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + + assert.equal(other, "global-model", "extraction/fallback lane stays on the global model"); + assert.equal(reflection, "reflection-model", "reflection lane resolves to the memoryReflection model"); + }); + + it("falls back to the global model on the reflection lane when no memoryReflection model is configured", () => { + const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" }); + + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: undefined, + }); + + assert.equal(reflection, "global-model"); + }); + + it("lets an explicit admissionControl.model override beat lane affinity on both lanes", () => { + const admissionControl = normalizeAdmissionControlConfig({ + enabled: true, + modelAffinity: "lane", + model: "explicit-override-model", + }); + + const other = resolveAdmissionModel({ + admissionControl, + lane: "other", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + + assert.equal(other, "explicit-override-model"); + assert.equal(reflection, "explicit-override-model"); + }); + + // Live-fleet bug: memoryReflection.model is core-style provider-prefixed + // ("openrouter/anthropic/claude-opus-4-8", understood by the reflection + // distiller's embedded runner) but the admission-control LLM client talks + // directly to OpenRouter, which needs the bare "anthropic/claude-opus-4-8" + // form. Every reflection-lane admission call 400'd against the real fleet. + it("normalizes a core-style openrouter// reflection model to the bare / form the OpenRouter-direct client needs", () => { + const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" }); + + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "openrouter/anthropic/claude-opus-4-8", + }); + + assert.equal(reflection, "anthropic/claude-opus-4-8"); + }); + + it("passes a bare / reflection model through unchanged", () => { + const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" }); + + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "anthropic/claude-opus-4-8", + }); + + assert.equal(reflection, "anthropic/claude-opus-4-8"); + }); + + it("passes an @preset/ reflection model through unchanged", () => { + const admissionControl = normalizeAdmissionControlConfig({ enabled: true, modelAffinity: "lane" }); + + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "@preset/gpt-oss-120b-gold", + }); + + assert.equal(reflection, "@preset/gpt-oss-120b-gold"); + }); + + it("normalizes an explicit admissionControl.model override the same way as lane-resolved models", () => { + const admissionControl = normalizeAdmissionControlConfig({ + enabled: true, + modelAffinity: "lane", + model: "openrouter/anthropic/claude-opus-4-8", + }); + + const other = resolveAdmissionModel({ + admissionControl, + lane: "other", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + const reflection = resolveAdmissionModel({ + admissionControl, + lane: "reflection", + globalModel: "global-model", + reflectionModel: "reflection-model", + }); + + assert.equal(other, "anthropic/claude-opus-4-8"); + assert.equal(reflection, "anthropic/claude-opus-4-8"); + }); +}); diff --git a/test/admission-without-smart-extraction.test.mjs b/test/admission-without-smart-extraction.test.mjs new file mode 100644 index 00000000..59472bc7 --- /dev/null +++ b/test/admission-without-smart-extraction.test.mjs @@ -0,0 +1,184 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); +const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + alias: { + "openclaw/plugin-sdk": pluginSdkStubPath, + }, +}); + +const retrieverModuleForMock = jiti("../src/retriever.js"); +const embedderModuleForMock = jiti("../src/embedder.js"); +const origCreateRetriever = retrieverModuleForMock.createRetriever; +const origCreateEmbedder = embedderModuleForMock.createEmbedder; + +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const { resetRegistration } = pluginModule; + +function mockCreateRetriever() { + return function mockCreateRetrieverImpl() { + return { + async retrieve() { + return []; + }, + getConfig() { + return { mode: "hybrid" }; + }, + setAccessTracker() {}, + setStatsCollector() {}, + }; + }; +} + +function mockCreateEmbedder() { + return function mockCreateEmbedderImpl() { + return { + async embedQuery() { + return new Float32Array(384).fill(0); + }, + async embedPassage() { + return new Float32Array(384).fill(0); + }, + }; + }; +} + +function createPluginApiHarness({ pluginConfig, resolveRoot }) { + const eventHandlers = new Map(); + const logs = { info: [], warn: [], debug: [] }; + + const api = { + pluginConfig, + resolvePath(target) { + if (typeof target !== "string") return target; + if (path.isAbsolute(target)) return target; + return path.join(resolveRoot, target); + }, + logger: { + info(message) { + logs.info.push(String(message)); + }, + warn(message) { + logs.warn.push(String(message)); + }, + debug(message) { + logs.debug.push(String(message)); + }, + error(message) { + logs.info.push(String(message)); + }, + }, + registerTool() {}, + registerCli() {}, + registerService() {}, + on(eventName, handler, meta) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta }); + eventHandlers.set(eventName, list); + }, + registerHook(eventName, handler, opts) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta: opts }); + eventHandlers.set(eventName, list); + }, + }; + + return { api, eventHandlers, logs }; +} + +function findRegisteredLog(logs) { + return [...logs.info, ...logs.debug].find((l) => l.includes("plugin registered")); +} + +describe("admission control availability without smart extraction", () => { + let workspaceDir; + + beforeEach(() => { + workspaceDir = mkdtempSync(path.join(tmpdir(), "admission-no-extraction-")); + retrieverModuleForMock.createRetriever = mockCreateRetriever(); + embedderModuleForMock.createEmbedder = mockCreateEmbedder(); + resetRegistration(); + }); + + afterEach(() => { + retrieverModuleForMock.createRetriever = origCreateRetriever; + embedderModuleForMock.createEmbedder = origCreateEmbedder; + resetRegistration(); + rmSync(workspaceDir, { recursive: true, force: true }); + }); + + it("constructs a standalone admission controller when smartExtraction is off but admissionControl is enabled", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: { + dbPath: path.join(workspaceDir, "db"), + embedding: { apiKey: "test-api-key" }, + smartExtraction: false, + admissionControl: { enabled: true }, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + }, + }); + + memoryLanceDBProPlugin.register(harness.api); + + const registeredLog = findRegisteredLog(harness.logs); + assert.ok(registeredLog, "expected a 'plugin registered' log line"); + assert.match(registeredLog, /smartExtraction: OFF/); + assert.match(registeredLog, /admissionControl: ON/); + }); + + it("leaves admission control unavailable when it is disabled, even with smart extraction off (configured off means off)", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: { + dbPath: path.join(workspaceDir, "db"), + embedding: { apiKey: "test-api-key" }, + smartExtraction: false, + admissionControl: { enabled: false }, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + }, + }); + + memoryLanceDBProPlugin.register(harness.api); + + const registeredLog = findRegisteredLog(harness.logs); + assert.ok(registeredLog); + assert.match(registeredLog, /smartExtraction: OFF/); + assert.match(registeredLog, /admissionControl: OFF/); + }); + + it("keeps smartExtraction: true behavior unchanged (both ON when admission is enabled)", () => { + const harness = createPluginApiHarness({ + resolveRoot: workspaceDir, + pluginConfig: { + dbPath: path.join(workspaceDir, "db"), + embedding: { apiKey: "test-api-key" }, + smartExtraction: true, + admissionControl: { enabled: true }, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + }, + }); + + memoryLanceDBProPlugin.register(harness.api); + + const registeredLog = findRegisteredLog(harness.logs); + assert.ok(registeredLog); + assert.match(registeredLog, /smartExtraction: ON/); + assert.match(registeredLog, /admissionControl: ON/); + }); +}); diff --git a/test/llm-thinklevel.test.mjs b/test/llm-thinklevel.test.mjs new file mode 100644 index 00000000..3a0931c9 --- /dev/null +++ b/test/llm-thinklevel.test.mjs @@ -0,0 +1,96 @@ +/** + * Regression tests for llm.thinkLevel, the sole reasoning-effort config key. + * The formerly-deprecated llm.reasoningEffort alias has been removed + * entirely (it never shipped upstream, so there is no deprecation + * constituency to preserve) -- resolveThinkLevel is now a plain presence + * check on llm.thinkLevel with no alias resolution or warn path. + * + * Fixtures are entirely synthetic -- no real fleet data. + */ + +import assert from "node:assert/strict"; +import http from "node:http"; +import { afterEach, describe, it } from "node:test"; +import jitiFactory from "jiti"; + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); +const { createLlmClient, resolveThinkLevel } = jiti("../src/llm-client.ts"); + +describe("llm.thinkLevel", () => { + describe("resolveThinkLevel", () => { + it("returns thinkLevel when configured", () => { + const result = resolveThinkLevel({ thinkLevel: "high" }); + assert.equal(result, "high"); + }); + + it("returns undefined when unconfigured", () => { + const result = resolveThinkLevel({}); + assert.equal(result, undefined); + }); + + it("treats a blank/whitespace-only thinkLevel as unset", () => { + const result = resolveThinkLevel({ thinkLevel: " " }); + assert.equal(result, undefined); + }); + }); + + describe("createLlmClient wiring (direct transport, wire-level)", () => { + let server; + + afterEach(async () => { + if (server) { + await new Promise((resolve) => server.close(resolve)); + server = null; + } + }); + + it("sends the configured llm.thinkLevel value as the reasoning effort on a direct-transport request", async () => { + let requestBody; + server = http.createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requestBody = JSON.parse(body); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ choices: [{ message: { content: "{\"memories\":[]}" } }] })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + + const llm = createLlmClient({ + auth: "api-key", + apiKey: "test-api-key", + model: "anthropic/claude-opus-4-8", + baseURL: `http://127.0.0.1:${port}/v1`, + thinkLevel: "high", + }); + + await llm.completeJson("hello", "thinklevel-probe"); + + assert.deepEqual(requestBody.reasoning, { effort: "high" }); + }); + + it("omits the reasoning field on a direct-transport request when llm.thinkLevel is not configured", async () => { + let requestBody; + server = http.createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requestBody = JSON.parse(body); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ choices: [{ message: { content: "{\"memories\":[]}" } }] })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + + const llm = createLlmClient({ + auth: "api-key", + apiKey: "test-api-key", + model: "anthropic/claude-opus-4-8", + baseURL: `http://127.0.0.1:${port}/v1`, + }); + + await llm.completeJson("hello", "thinklevel-unconfigured-probe"); + + assert.equal(requestBody.reasoning, undefined); + }); + }); +}); diff --git a/test/smart-extractor-admission-controller-injection.test.mjs b/test/smart-extractor-admission-controller-injection.test.mjs new file mode 100644 index 00000000..5a8f0c08 --- /dev/null +++ b/test/smart-extractor-admission-controller-injection.test.mjs @@ -0,0 +1,147 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import jitiFactory from "jiti"; + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); +const { SmartExtractor } = jiti("../src/smart-extractor.ts"); + +function makeStore(overrides = {}) { + return { + async vectorSearch() { + return []; + }, + async store() {}, + async bulkStore() {}, + ...overrides, + }; +} + +function makeEmbedder() { + return { + async embed() { + return Array(8).fill(0.1); + }, + async embedBatch(texts) { + return (texts || []).map(() => Array(8).fill(0.1)); + }, + }; +} + +function makeLlm() { + return { + async completeJson(_prompt, mode) { + if (mode === "extract-candidates") { + return { + memories: [ + { + category: "events", + abstract: "user did something notable", + overview: "## Event", + content: "the user did something notable", + }, + ], + }; + } + throw new Error(`unexpected mode: ${mode}`); + }, + }; +} + +function baseAudit(decision, hint) { + return { + version: "amac-v1", + decision, + hint, + score: decision === "reject" ? 0 : 0.9, + reason: `test-${decision}`, + thresholds: { reject: 0.45, admit: 0.6 }, + weights: { utility: 0.1, confidence: 0.1, novelty: 0.1, recency: 0.1, typePrior: 0.6 }, + feature_scores: { utility: 0, confidence: 0, novelty: 0, recency: 0, typePrior: 0 }, + matched_existing_memory_ids: [], + compared_existing_memory_ids: [], + max_similarity: 0, + evaluated_at: Date.now(), + }; +} + +describe("SmartExtractor admission controller injection", () => { + it("gates candidates through an externally-constructed admission controller", async () => { + let evaluateCalls = 0; + const injectedController = { + async evaluate() { + evaluateCalls++; + return { decision: "reject", audit: baseAudit("reject") }; + }, + }; + + const extractor = new SmartExtractor(makeStore(), makeEmbedder(), makeLlm(), { + user: "User", + extractMinMessages: 1, + extractMaxChars: 8000, + defaultScope: "global", + admissionController: injectedController, + log() {}, + debugLog() {}, + }); + + const stats = await extractor.extractAndPersist( + "the user did something notable today", + "session-1", + { scope: "global" }, + ); + + assert.equal(evaluateCalls, 1, "expected the injected controller's evaluate() to be called"); + assert.equal(stats.rejected, 1); + assert.equal(stats.created, 0); + }); + + it("stores admitted candidates when the injected controller passes them", async () => { + let evaluateCalls = 0; + const injectedController = { + async evaluate() { + evaluateCalls++; + return { decision: "pass_to_dedup", hint: "add", audit: baseAudit("pass_to_dedup", "add") }; + }, + }; + + const extractor = new SmartExtractor(makeStore(), makeEmbedder(), makeLlm(), { + user: "User", + extractMinMessages: 1, + extractMaxChars: 8000, + defaultScope: "global", + admissionController: injectedController, + log() {}, + debugLog() {}, + }); + + const stats = await extractor.extractAndPersist( + "the user did something notable today", + "session-2", + { scope: "global" }, + ); + + assert.equal(evaluateCalls, 1); + assert.equal(stats.created, 1); + assert.equal(stats.rejected ?? 0, 0); + }); + + it("skips admission gating entirely when no controller is configured (today's off-behavior)", async () => { + const extractor = new SmartExtractor(makeStore(), makeEmbedder(), makeLlm(), { + user: "User", + extractMinMessages: 1, + extractMaxChars: 8000, + defaultScope: "global", + log() {}, + debugLog() {}, + }); + + const stats = await extractor.extractAndPersist( + "the user did something notable today", + "session-3", + { scope: "global" }, + ); + + assert.equal(stats.rejected ?? 0, 0); + assert.equal(stats.created, 1); + }); +});