diff --git a/README.md b/README.md index e42d897..4b5538d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ When configured with an embedding API, mneme now runs **dual-path retrieval**: Falls back gracefully to FTS5-only when sqlite-vec or embedding API is not configured. +**Caller deadlines (v2.11).** `POST /recall` accepts `deadline_ms`: the caller's remaining budget. The server bounds its embedding wait to `deadline_ms − 150` (floor 200 ms, never above `EMBEDDING_TIMEOUT_MS`) and degrades to FTS-only *inside* that budget. Without it, a hook that aborts at 1.5 s while the server waits 2.5 s for the embedding throws the whole call away and re-runs it cold — on one 10-day log that was 49% of hook-side hybrid work. Both bundled hooks send it (their HTTP default is now 1500 ms, up from the FTS-era 800: a longer wait can no longer become a zombie call). `GET /stats` (`embedding.clamped`) shows how often it bites; `/health` is liveness-only since v2.11 and the full census lives at `/stats`. One operational note: the server reads `.env.local` fill-only, so a supervisor that respawns it must pass the *current* file's values, not its own startup snapshot — otherwise a raised `EMBEDDING_TIMEOUT_MS` never reaches the process. + +**Query memo (v2.11).** Query embeddings are memoised for 10 minutes (512 entries, opt-in on the recall path only). 23% of recall queries recur within that window — the same prompt fanning out to several hooks and sessions — and embeddings are deterministic, so the repeat is pure latency. `/stats` (`embedding.memoHits`) counts them. + **Performance**: ~150ms total (FTS5 <10ms + one embedding API call ~120ms). sqlite-vec KNN is sub-millisecond locally. ### Compression Pipeline diff --git a/embedding-timeout.integration.test.mjs b/embedding-timeout.integration.test.mjs index aa70c74..1db83fd 100644 --- a/embedding-timeout.integration.test.mjs +++ b/embedding-timeout.integration.test.mjs @@ -24,7 +24,7 @@ // sqlite-vec extension (CI): that path early-bails to FTS before it ever // embeds, so it would pass whether or not the timeout exists. -import { initMemory, generateEmbedding, recallMemoriesHybrid, storeMemory, closeMemory } from './index.mjs' +import { initMemory, generateEmbedding, recallMemoriesHybrid, storeMemory, closeMemory, getEmbeddingStats } from './index.mjs' import http from 'node:http' let pass = 0, fail = 0 @@ -104,6 +104,71 @@ if (rows?._degradeReason === 'vec-extension-not-loaded') { `_degradeReason=${rows?._degradeReason}`) } +// ── A caller's deadline clamps the wait below the env timeout ──────────────── +// The hook that calls /recall gives up at ~1.5 s. If the server waits its own +// 2.5 s for the embedding, the hook has already aborted (and, before this, +// re-ran the recall in a cold spawned process) while the server's call runs on +// as a zombie. Measured on 10 days of recall_log: 49% of hook-side hybrid work +// was thrown away that way. Passing the remaining budget lets the server +// degrade to FTS inside it. +{ + mode = 'stall' + const before = getEmbeddingStats() + const t0 = Date.now() + const r = await generateEmbedding('deadline clamp probe', { timeoutMs: 250 }) + const elapsed = Date.now() - t0 + ok('a per-call deadline below EMBEDDING_TIMEOUT_MS is honoured', elapsed < TIMEOUT_MS, `took ${elapsed}ms, env timeout ${TIMEOUT_MS}ms, deadline 250ms`) + ok('a clamped timeout still returns null (or EMBED_TIMEOUT when asked), never throws', r === null) + ok('the clamp is counted', getEmbeddingStats().clamped === before.clamped + 1) + + const t1 = Date.now() + await generateEmbedding('deadline larger than env', { timeoutMs: 60_000 }) + const elapsed2 = Date.now() - t1 + ok('a deadline above the env timeout does not extend the wait (min, not max)', elapsed2 < TIMEOUT_MS + 1500, `took ${elapsed2}ms`) + + // A clamped call that succeeds is still counted as clamped (the counter says + // "ran under a caller deadline", not "timed out"). + mode = 'fast' + const c = getEmbeddingStats().clamped + const fast = await generateEmbedding('clamped but fast', { timeoutMs: 250 }) + ok('a clamped call that succeeds returns the vector', Array.isArray(fast) && fast.length === DIM) + ok('…and is counted as clamped without being counted as a timeout', getEmbeddingStats().clamped === c + 1) + mode = 'stall' + + // Through the recall path: deadlineMs -> embedding budget = deadline - 150 (floor 200). + const t2 = Date.now() + const rows2 = await recallMemoriesHybrid({ query: 'deadline via recall', limit: 3, deadlineMs: 400 }) + const elapsed3 = Date.now() - t2 + if (rows2?._degradeReason === 'vec-extension-not-loaded') { + console.log('~ skipped: recall-path deadline timing needs the sqlite-vec extension (absent here)') + } else { + ok('recallMemoriesHybrid honours deadlineMs (returns well before the env timeout)', elapsed3 < TIMEOUT_MS, `took ${elapsed3}ms, deadline 400ms`) + ok('…and reports the timeout as its degradation reason', rows2?._degradeReason === 'embedding-timeout', `_degradeReason=${rows2?._degradeReason}`) + } +} + +// ── The query memo turns a repeat into zero upstream calls ────────────────── +// 23% of recall queries recur within 10 minutes (same prompt fanning out to +// several hooks/sessions). Embeddings are deterministic, so the repeat is pure +// latency and upstream load. +{ + mode = 'fast' + const c0 = calls + const a = await generateEmbedding('memo probe alpha', { memo: true }) + const c1 = calls + const b = await generateEmbedding('memo probe alpha', { memo: true }) + const c2 = calls + ok('first memoised call reaches upstream', c1 === c0 + 1 && Array.isArray(a) && a.length === DIM) + ok('identical text within the TTL is served from the memo (no upstream call)', c2 === c1 && b === a) + ok('the hit is counted', getEmbeddingStats().memoHits >= 1) + await generateEmbedding('memo probe beta', { memo: true }) + ok('different text is a miss', calls === c2 + 1) + const c3 = calls + await generateEmbedding('memo probe alpha') // memo not requested + ok('memo is opt-in: the same text without memo:true calls upstream', calls === c3 + 1) + ok('stats expose the memo size', getEmbeddingStats().memoSize >= 2) +} + server.close() closeMemory() diff --git a/high-signal-tokens.mjs b/high-signal-tokens.mjs index 2ea093f..634b72a 100644 --- a/high-signal-tokens.mjs +++ b/high-signal-tokens.mjs @@ -71,15 +71,38 @@ export function extractHighSignalTokens(text) { // (a different file), while `memory/index.mjs` is carried by `E:/x/memory/index.mjs`. export function isStillCarried(token, newTokens) { if (newTokens.has(token)) return true + // A path the new text spells more precisely (longer, same trailing + // segments, a separator at the boundary) is still carried. for (const t of newTokens) { if (t.length > token.length && t.endsWith(token)) { const boundary = t[t.length - token.length - 1] if (boundary === '/' || boundary === '\\') return true } } + // The reverse: the new text refers to the same file by its bare name. + // "scripts/pull-qishe-river-assets.py" -> "pull-qishe-river-assets.py" is a + // rewording, not a dropped identifier (2026-09-14: that exact false positive + // flagged an actively-recalled chain, which is how a guard gets ignored). + // Only for distinctive, file-shaped basenames — a bare "a.js" names too much. + const cut = Math.max(token.lastIndexOf('/'), token.lastIndexOf('\\')) + if (cut > 0) { + const base = token.slice(cut + 1) + if (base.length >= 8 && /[.-]/.test(base) && !GENERIC_BASENAMES.has(base.toLowerCase()) && newTokens.has(base)) return true + } return false } +// Long enough and file-shaped is not the same as distinctive. "frontend/package.json" +// superseded by text that only names "backend/package.json" has dropped a real fact, +// and every Node project has one of each of these — so the bare name proves nothing. +const GENERIC_BASENAMES = new Set([ + 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'tsconfig.json', + 'readme.md', 'changelog.md', 'license.md', 'dockerfile', 'makefile', '.env.local', '.env.example', + 'index.js', 'index.mjs', 'index.cjs', 'index.ts', 'index.tsx', 'index.html', + 'main.js', 'main.mjs', 'main.ts', 'main.py', 'app.js', 'app.ts', 'app.py', + 'config.json', 'config.js', 'config.mjs', 'config.ts', 'settings.json', 'schema.sql', +]) + export function checkSupersedeShrink(newContent, olds) { const warnings = [] const newTokens = new Set(extractHighSignalTokens(newContent)) diff --git a/hooks/prompt-recall.mjs b/hooks/prompt-recall.mjs index 4079481..64bd80f 100644 --- a/hooks/prompt-recall.mjs +++ b/hooks/prompt-recall.mjs @@ -88,10 +88,14 @@ const CFG = { // // null when the caller pinned a DB but not a URL — see resolveHttpUrl(). httpUrl: resolveHttpUrl(), - // Deliberately much shorter than the CLI budget: a healthy server answers in - // single-digit ms, so anything slower means it is unwell and we should be - // spawning already rather than paying both costs. - httpTimeoutMs: intEnv('MNEME_HTTP_TIMEOUT_MS', 800), + // 1500 (was 800). The 800 came from the FTS-only era, when a healthy server + // answered in single-digit ms and anything slower meant it was unwell. With + // hybrid recall a healthy answer includes one embedding round trip + // (170–480 ms measured), and since v2.11 we send this budget as deadline_ms + // so the server degrades to FTS *inside* it — a longer wait can no longer + // turn into a zombie call, so there is no reason to keep it tight. Still + // well under the CLI spawn budget below, so the fallback fits after it. + httpTimeoutMs: intEnv('MNEME_HTTP_TIMEOUT_MS', 1500), indexPath: process.env.MNEME_INDEX_PATH || resolve(__dirname, '..', 'index.mjs'), minImportance: intEnv('MNEME_MIN_IMPORTANCE', 6), level: process.env.MNEME_LEVEL || 'meta_knowledge', @@ -164,6 +168,12 @@ async function runRecall(query, sessionId) { level: CFG.level, source: 'mneme-prompt-recall', session_id: sessionId, + // Tell the server how long we will actually wait, so a slow embedding + // degrades to an FTS answer inside our budget instead of us aborting and + // re-doing the whole recall in a cold spawned CLI. 100 ms covers transit; + // the server takes its own 150 ms for fusion (see recallMemoriesHybrid), + // so the embedding gets httpTimeoutMs − 250 — 1250 ms at the default. + deadline_ms: Math.max(300, CFG.httpTimeoutMs - 100), }) if (viaHttp) return viaHttp diff --git a/hooks/tool-recall-pre.mjs b/hooks/tool-recall-pre.mjs index 70ea0e3..771a44f 100644 --- a/hooks/tool-recall-pre.mjs +++ b/hooks/tool-recall-pre.mjs @@ -82,10 +82,14 @@ const CFG = { // // null when the caller pinned a DB but not a URL — see resolveHttpUrl(). httpUrl: resolveHttpUrl(), - // Deliberately much shorter than the CLI budget: a healthy server answers in - // single-digit ms, so anything slower means it is unwell and we should be - // spawning already rather than paying both costs. - httpTimeoutMs: intEnv('MNEME_HTTP_TIMEOUT_MS', 800), + // 1500 (was 800). The 800 came from the FTS-only era, when a healthy server + // answered in single-digit ms and anything slower meant it was unwell. With + // hybrid recall a healthy answer includes one embedding round trip + // (170–480 ms measured), and since v2.11 we send this budget as deadline_ms + // so the server degrades to FTS *inside* it — a longer wait can no longer + // turn into a zombie call. Still under the CLI spawn budget, so the + // fallback fits after it. Kept in sync with prompt-recall.mjs. + httpTimeoutMs: intEnv('MNEME_HTTP_TIMEOUT_MS', 1500), indexPath: process.env.MNEME_INDEX_PATH || resolve(__dirname, '..', 'index.mjs'), minImportance: intEnv('MNEME_TOOL_MIN_IMPORTANCE', 6), level: process.env.MNEME_TOOL_LEVEL || 'meta_knowledge,semi_abstract', @@ -203,6 +207,10 @@ async function runRecall(query, sessionId) { level: CFG.level, source: 'mneme-tool-recall', session_id: sessionId, + // Same contract as prompt-recall.mjs: our remaining budget, so the server + // degrades inside it rather than us aborting into a cold spawn. This hook + // fires per tool call, so it mattered at least as much as the prompt one. + deadline_ms: Math.max(300, CFG.httpTimeoutMs - 100), }) if (viaHttp) return viaHttp diff --git a/index.mjs b/index.mjs index e731267..b0937bc 100644 --- a/index.mjs +++ b/index.mjs @@ -665,8 +665,49 @@ const EMBED_TIMEOUT = Symbol('embedding-timeout') * instead of null specifically when the deadline was hit, so a caller that * cares can record *why* it degraded rather than reporting a normal result. */ -export async function generateEmbedding(text, { signalTimeout = false } = {}) { +// Query-embedding memo. Measured on a 10-day recall_log (2026-09-14): 23% of +// recall queries recur within 10 minutes — the same question asked again, a +// prompt re-sent, a second session asking what the first just asked. These are +// sequential repeats: a hit only exists once an earlier call has resolved. +// Two calls for the same text in flight at the same instant both go upstream +// (no coalescing; the window that matters is minutes, not milliseconds). +// Embeddings are deterministic for a given model, so the repeat is pure +// latency and upstream load. Opt-in (`memo: true`); the recall path uses it, +// the store paths do not (content rarely repeats and the map should stay +// small). Entries carry no model tag: _embeddingConfig is set once per +// process, so a model change means a restart, which empties the map. +const EMBED_MEMO_TTL_MS = 10 * 60_000 +const EMBED_MEMO_MAX = 512 +const _embedMemo = new Map() // text -> { vec, at }; Map keeps insertion order for FIFO eviction +const _embedStats = { calls: 0, memoHits: 0, timeouts: 0, clamped: 0, failures: 0 } + +/** Counters since process start — surfaced by getMemoryStats() so a caller can see, not guess, whether the memo and deadline clamp are doing anything. */ +export function getEmbeddingStats() { return { ..._embedStats, memoSize: _embedMemo.size } } + +/** + * @param {string} text + * @param {object} [o] + * @param {boolean} [o.signalTimeout=false] return EMBED_TIMEOUT (not null) when the deadline hit + * @param {number|null} [o.timeoutMs=null] per-call ceiling; the effective timeout is + * min(EMBEDDING_TIMEOUT_MS, timeoutMs). A caller with a hard budget (a hook with + * 1.5 s before it gives up) passes its remaining time so the server degrades + * to FTS *inside* that budget instead of the caller aborting and re-doing the + * work cold while this call runs on as a zombie. + * @param {boolean} [o.memo=false] serve from / fill the 10-minute query memo + */ +export async function generateEmbedding(text, { signalTimeout = false, timeoutMs = null, memo = false } = {}) { if (!_embeddingConfig) return null + const input = text.slice(0, 8000) + if (memo) { + const hit = _embedMemo.get(input) + if (hit && Date.now() - hit.at < EMBED_MEMO_TTL_MS) { _embedStats.memoHits++; return hit.vec } + if (hit) _embedMemo.delete(input) + } + const envTimeout = embeddingTimeoutMs() + const clamped = Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs < envTimeout + const effectiveTimeout = clamped ? Math.max(100, Math.floor(timeoutMs)) : envTimeout + _embedStats.calls++ + if (clamped) _embedStats.clamped++ try { const res = await fetch(`${_embeddingConfig.baseUrl}/embeddings`, { method: 'POST', @@ -676,19 +717,30 @@ export async function generateEmbedding(text, { signalTimeout = false } = {}) { }, body: JSON.stringify({ model: _embeddingConfig.model, - input: text.slice(0, 8000), + input, dimensions: _embeddingConfig.dimension, encoding_format: 'float', }), - signal: AbortSignal.timeout(embeddingTimeoutMs()), + signal: AbortSignal.timeout(effectiveTimeout), }) const data = await res.json() - return data?.data?.[0]?.embedding || null + const vec = data?.data?.[0]?.embedding || null + // Only a well-formed vector is worth remembering for ten minutes: an empty + // array or a wrong-dimension reply is a one-off upstream hiccup today and + // must not become sticky for that query text. + const wellFormed = Array.isArray(vec) && vec.length > 0 + && (!_embeddingConfig.dimension || vec.length === _embeddingConfig.dimension) + if (memo && wellFormed) { + if (_embedMemo.size >= EMBED_MEMO_MAX) _embedMemo.delete(_embedMemo.keys().next().value) + _embedMemo.set(input, { vec, at: Date.now() }) + } + return vec } catch (e) { // A stalling upstream is the common case and should not read as a broken one. const timedOut = e?.name === 'TimeoutError' || e?.name === 'AbortError' + if (timedOut) _embedStats.timeouts++; else _embedStats.failures++ log(timedOut - ? `Embedding timed out after ${embeddingTimeoutMs()}ms — degrading to FTS for this call` + ? `Embedding timed out after ${effectiveTimeout}ms${clamped ? ' (caller deadline)' : ''} — degrading to FTS for this call` : `Embedding failed: ${e.message}`) return timedOut && signalTimeout ? EMBED_TIMEOUT : null } @@ -970,6 +1022,8 @@ function _parseEventTime(v) { * @param {boolean} [o.requireVec] keep only rows with vector evidence * @param {string} [o.source] recall_log label * @param {string} [o.sessionId] recall_log session + * @param {number} [o.deadlineMs] the caller's remaining budget in ms; bounds the + * embedding wait so the call degrades to FTS inside it (see recallMemoriesHybrid) * @returns {Promise} { hits, count, requested_limit, effective_limit, candidate_limit, capped, trace_id } */ export async function recallForClients(o = {}) { @@ -989,6 +1043,7 @@ export async function recallForClients(o = {}) { limit: candidatePoolSize, _source: o.source || 'unknown', _sessionId: o.sessionId || null, + deadlineMs: Number.isFinite(o.deadlineMs) && o.deadlineMs > 0 ? o.deadlineMs : null, _filterLevel: levels.length ? levels.join(',') : null, _minImportance: minImportance > 0 ? minImportance : null, _out: out, @@ -2194,6 +2249,14 @@ function findEntityMatchedMemories(db, queryText, limit) { return rows.map(r => ({ ...r, tags: safeJsonParse(r.tags, []), metadata: safeJsonParse(r.metadata, {}) })) } +/** + * Hybrid recall: FTS5 + vector KNN (+ entity path) fused by RRF. + * Accepts every recallMemories option plus: + * @param {number} [opts.deadlineMs] the caller's remaining budget in ms. The + * embedding wait is bounded to deadlineMs − 150 (floor 200, never above + * EMBEDDING_TIMEOUT_MS); on timeout the call degrades to FTS-only and marks + * the result `_degradeReason = 'embedding-timeout'`. Absent → env timeout. + */ export async function recallMemoriesHybrid(opts = {}) { const { query: queryText, limit: requestedLimit = 10 } = opts @@ -2254,8 +2317,25 @@ export async function recallMemoriesHybrid(opts = {}) { // Parallel: vector query (get embedding) + FTS query // _internal=true so the FTS path doesn't also surface random records (hybrid surfaces once at the end) + // The caller's remaining budget bounds the embedding wait. A hook that will + // abort at 1.5 s must not wait 2.5 s here: on the 10-day recall_log before + // this, 49% of hook-side hybrid work finished after the hook had already + // given up, and 47% of those were followed by the hook re-embedding the same + // query in a cold spawned process. + // + // Budget accounting, in one place so the two margins are seen together: + // caller: deadline_ms = its own timeout − 100 (transit, hooks/*.mjs) + // here: embedding = deadline_ms − 150 (fusion + serialisation + + // the synchronous SQLite work of *other* requests on this single + // event loop; FTS itself runs concurrently below and is sub-10 ms) + // With the bundled hooks' 1500 ms default that leaves 1250 ms for the + // embedding — comfortably above the 170–480 ms the API takes. Below 200 ms + // an embedding cannot succeed, so that is the floor. + const embedTimeoutMs = Number.isFinite(opts.deadlineMs) && opts.deadlineMs > 0 + ? Math.max(200, Math.floor(opts.deadlineMs) - 150) + : null const [rawEmbedding, ftsRows] = await Promise.all([ - generateEmbedding(queryText, { signalTimeout: true }), + generateEmbedding(queryText, { signalTimeout: true, timeoutMs: embedTimeoutMs, memo: true }), Promise.resolve(recallMemories({ ...opts, limit: candidateLimit, @@ -3203,6 +3283,10 @@ export function getMemoryStats() { recentSearchMisses: recentMisses, embeddingConfigured: !!_embeddingConfig, vectorCoverage, + // Since process start: calls / memoHits / timeouts / clamped / failures. + // Lets an operator see whether the query memo and caller deadlines are + // doing anything, instead of inferring it from latency distributions. + embedding: getEmbeddingStats(), } } catch (e) { return { error: e.message } diff --git a/mcp-server.mjs b/mcp-server.mjs index 588403c..a86d4df 100644 --- a/mcp-server.mjs +++ b/mcp-server.mjs @@ -455,6 +455,9 @@ function createServer(hostId = DEFAULT_HOST) { `Dead knowledge (30d unaccessed): ${stats.deadKnowledge}${stats.deadKnowledge > 10 ? ' (consider cleanup)' : ''}`, `Search misses (7d): ${stats.recentSearchMisses}${stats.recentSearchMisses > 5 ? ' (knowledge blind spots detected)' : ''}`, `Vector search: ${stats.embeddingConfigured ? 'configured' : 'not configured (FTS5 only)'}`, + stats.embedding + ? `Embedding calls since start: ${stats.embedding.calls} (memo hits ${stats.embedding.memoHits}, deadline-clamped ${stats.embedding.clamped}, timeouts ${stats.embedding.timeouts}, failures ${stats.embedding.failures})` + : 'Embedding calls since start: n/a', ].join('\n') return { content: [{ type: 'text', text }] } } @@ -588,18 +591,19 @@ if (useHttp) { for (const entry of sessions.values()) { if (now - entry.lastUsed > SESSION_IDLE_MS) idleCount++ } - // Expose embedding config + vector coverage so watchdogs can alert - // proactively instead of waiting for someone to run memory_stats. - let embeddingConfigured = null, vectorCoverage = null - try { const st = getMemoryStats(); embeddingConfigured = st.embeddingConfigured; vectorCoverage = st.vectorCoverage } catch {} + // Liveness only. This used to call getMemoryStats() to expose + // embeddingConfigured + vectorCoverage — three full scans, one of which + // reads every embedding blob, so the cost grew with the file. At 581 MB + // /health measured 3.4–3.6 s and crossed a supervisor's 2.5 s budget: a + // live server kept being declared dead and respawned (6 false restarts in + // one day). A liveness probe must be O(1). The census moved to GET /stats. res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, transport: 'http', active_sessions: sessions.size, idle_pending_cleanup: idleCount, idle_timeout_ms: SESSION_IDLE_MS, - embeddingConfigured, - vectorCoverage, + statsEndpoint: '/stats', })) return } @@ -615,6 +619,20 @@ if (useHttp) { // // Deliberately NOT MCP: a hook is a 30-line script that should not have to // speak a session-oriented protocol to ask one question. + // Full census — intentionally NOT on /health (see the note there). Cost + // scales with DB size (three full scans, one of them reads every embedding + // blob). Poll this on a slow cadence, never on a liveness path. Includes + // `embedding` (calls / memoHits / timeouts / clamped / failures since start) + // so the deadline clamp and the query memo are countable, not inferred. + if (req.url === '/stats' && req.method === 'GET') { + const t0 = Date.now() + let stats = null, error = null + try { stats = getMemoryStats() } catch (e) { error = String(e && e.message || e) } + res.writeHead(error ? 500 : 200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: !error, tookMs: Date.now() - t0, error, ...(stats || {}) })) + return + } + if (req.url === '/recall' && req.method === 'POST') { const auth = resolveHost(req.headers['authorization'], HOST_TOKENS, { mode: AUTH_MODE, defaultHost: DEFAULT_HOST }) if (!auth.ok) { @@ -645,6 +663,11 @@ if (useHttp) { minImportance: Number.isFinite(p.min_importance) ? p.min_importance : 0, levels: Array.isArray(p.levels) ? p.levels : (typeof p.level === 'string' && p.level ? p.level.split(',') : []), requireVec: !!p.require_vec, + // The caller's remaining budget. The server clamps its embedding + // wait to fit, degrading to FTS *inside* the budget instead of the + // caller aborting first and re-doing the work cold. See + // recallMemoriesHybrid for the accounting. + deadlineMs: Number.isFinite(p.deadline_ms) && p.deadline_ms > 0 ? Math.floor(p.deadline_ms) : null, // Provenance stays channel-derived: the caller may label WHICH hook // it is, but the host comes from the token, never from the body. source: typeof p.source === 'string' ? p.source.slice(0, 64) : 'http', diff --git a/package.json b/package.json index 4abc6b0..abb2fc2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mneme", - "version": "2.10.0", + "version": "2.11.0", "description": "Token-efficient persistent memory for AI agents — SQLite + FTS5 + sqlite-vec hybrid search + MCP on-demand recall. Save 80-90% memory-related token costs.", "type": "module", "main": "index.mjs", diff --git a/supersede-shrink.integration.test.mjs b/supersede-shrink.integration.test.mjs index a556a24..c7b3749 100644 --- a/supersede-shrink.integration.test.mjs +++ b/supersede-shrink.integration.test.mjs @@ -253,6 +253,23 @@ dbRead.close() check('a bare substring is not carried either', !isStillCarried('index.mjs', new Set(['reindex.mjs']))) + // The reverse of the path-suffix rule: a path now mentioned by its bare file + // name is a rewording, not a dropped identifier. Real case (2026-09-14): a + // supersede that replaced a 5-step recipe with a 1-step one still named every + // script it retired — by basename — and the guard flagged all four as lost. + check('a path referred to by its bare file name is still carried', + isStillCarried('scripts/pull-qishe-river-assets.py', new Set(['pull-qishe-river-assets.py']))) + check('a Windows-separated path is carried by its bare file name too', + isStillCarried('scripts\\pull-render-model-packs.mjs', new Set(['pull-render-model-packs.mjs']))) + check('a short or generic basename does not count as carried', + !isStillCarried('lib/a.js', new Set(['a.js'])) && !isStillCarried('src/helpers', new Set(['helpers']))) + // Long and file-shaped is not distinctive: every Node project has one of these. + // "frontend/package.json" -> "package.json" (which may be backend/) is a dropped fact. + check('a universally generic basename is NOT carried even when long enough', + !isStillCarried('frontend/package.json', new Set(['package.json'])) && + !isStillCarried('memory/index.mjs', new Set(['index.mjs'])) && + !isStillCarried('docs/README.md', new Set(['README.md']))) + const oldContent = 'runner lives at memory/index.mjs and the log rotates via scripts/run.sh, token in API_TOKEN, see https://ops.example.com/dash' const newContent = 'runner lives at C:/work/ws/memory/index.mjs and the log rotates via C:/tools/scripts/run.sh, token in API_TOKEN, see https://ops.example.com/dash — now also covers the nightly path' const clean = checkSupersedeShrink(newContent, [{ id: '1', content: oldContent }])