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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion embedding-timeout.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
23 changes: 23 additions & 0 deletions high-signal-tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 14 additions & 4 deletions hooks/prompt-recall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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

Expand Down
16 changes: 12 additions & 4 deletions hooks/tool-recall-pre.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading