diff --git a/index.mjs b/index.mjs index bc5a3ca..cbfe22d 100644 --- a/index.mjs +++ b/index.mjs @@ -934,6 +934,16 @@ export async function recallForClients(o = {}) { _out: out, }) + // Capture the trace BEFORE the filters. recallTrace rides on the returned + // array as a non-enumerable property, and Array.prototype.filter builds a new + // array — so reading it after any filter yields undefined and the response + // carries trace_id: null. The trace itself was persisted either way, which is + // what made this quiet: rows accumulate in recall_traces that no caller can + // ever name. Every real caller filters (min_importance and level are how the + // hooks call this), so in practice trace_id was null whenever it mattered and + // present only in the unfiltered case nobody uses. + const trace = memories.recallTrace + if (minImportance > 0) memories = memories.filter(m => (m.importance || 0) >= minImportance) if (levels.length > 0) memories = memories.filter(m => levels.includes(m.memory_level)) // requireVec: keep only rows with vector evidence, BEFORE the slice. Chinese @@ -941,7 +951,6 @@ export async function recallForClients(o = {}) { // callers need the vec rows to survive. With embedding down this yields 0 rows — // fail-closed is correct for inject paths. if (o.requireVec) memories = memories.filter(m => typeof m.vec_distance === 'number') - const trace = memories.recallTrace memories = memories.slice(0, limit) // hit_count is the raw pool; final_hit_count is what survived filtering and diff --git a/recall-endpoint.integration.test.mjs b/recall-endpoint.integration.test.mjs index d6629e9..bf70f54 100644 --- a/recall-endpoint.integration.test.mjs +++ b/recall-endpoint.integration.test.mjs @@ -83,6 +83,33 @@ const post = async (body, init = {}) => { check('capacity signals ride along', typeof j.requested_limit === 'number' && typeof j.effective_limit === 'number' && 'capped' in j) } +// ── the trace id survives the filters ── +// +// recallTrace rides on the returned array as a non-enumerable property, and +// Array.prototype.filter builds a new array. Reading it after the filters +// yielded undefined, so every filtered call answered trace_id: null — while +// still persisting the trace. Rows piled up in recall_traces that no caller +// could name, and get_recall_trace / validate_memory_references had nothing to +// be called with. +// +// It stayed quiet because the unfiltered path, which nobody uses, worked fine. +// Both hooks send min_importance and level on every call. +{ + const bare = await post({ query: 'omega calibration', limit: 3, source: 'trace-bare' }) + check('unfiltered call returns a trace id', typeof bare.j?.trace_id === 'string' && bare.j.trace_id.length > 0, + JSON.stringify(bare.j?.trace_id)) + + for (const [label, body] of [ + ['min_importance', { query: 'omega calibration', limit: 3, min_importance: 6, source: 'trace-imp' }], + ['level', { query: 'omega calibration', limit: 3, level: 'meta_knowledge,semi_abstract', source: 'trace-lvl' }], + ['the shape both hooks send', { query: 'omega calibration', limit: 3, min_importance: 6, level: 'meta_knowledge,semi_abstract', require_vec: false, source: 'trace-hook' }], + ]) { + const { j: r } = await post(body) + check(`trace id survives ${label}`, typeof r?.trace_id === 'string' && r.trace_id.length > 0, + `trace_id=${JSON.stringify(r?.trace_id)} hits=${r?.count}`) + } +} + // ── shape parity with the CLI: same query, same ids, same order ── { const args = ['index.mjs', '--recall', 'omega calibration', '--format', 'json',