From 2a65c7495611812e085004b829ab36728c1da6c7 Mon Sep 17 00:00:00 2001 From: MXAntian Date: Tue, 4 Aug 2026 03:11:02 +0800 Subject: [PATCH] fix(recall): keep the trace id when the endpoint filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /recall answered trace_id: null on every filtered call, which is every call any real caller makes. recallTrace rides on the returned array as a non-enumerable property, and Array.prototype.filter builds a new array. Reading it after min_importance / level / require_vec yields undefined. Measured against a live server before the fix: no filters trace_id = "7defe884-7ecf-4a54-85d1-6e4c0c94e291" min_importance trace_id = null hook-shaped trace_id = null Both hooks send min_importance and level on every call, so the id was present only in the unfiltered case nobody uses. What made it quiet is that the trace was persisted either way. recall_traces fills up with rows no caller can name — the data looks healthy from inside the DB, and get_recall_trace / validate_memory_references have nothing to be called with, so the citation contract's second half cannot run at all. The first half ships in every recall: allowed-ids and "validate generated citations against this trace before publishing." Fix is to capture the trace before the filters rather than after. Test asserts the id survives min_importance, level, and the exact shape both hooks send; without the fix those three go red and the unfiltered case still passes, which is the asymmetry that hid it. 284 passed / 0 failed. Co-authored-by: 千夏 Co-Authored-By: Claude Opus 5 --- index.mjs | 11 ++++++++++- recall-endpoint.integration.test.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) 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',