Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 107 additions & 56 deletions dist/index.js

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions dist/src/admission-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -466,6 +470,53 @@ function parseBatchUtilityResponse(response, expectedCount) {
}
return out;
}
/**
* The admission-control LLM client talks directly to OpenRouter, so it needs
* the bare "<vendor>/<model>" 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/<vendor>/<model>" 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 "<vendor>/<model>" or an "@preset/<name>"
* 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);
Expand Down
28 changes: 28 additions & 0 deletions dist/src/llm-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<vendor>/<model>" 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down
9 changes: 9 additions & 0 deletions dist/src/reflection-mapped-admission.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion dist/src/retriever.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 1 addition & 10 deletions dist/src/smart-extractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading