diff --git a/bridge/memory.js b/bridge/memory.js index ef5ba83..064113d 100644 --- a/bridge/memory.js +++ b/bridge/memory.js @@ -297,7 +297,9 @@ function query(state, args) { throw new Error('unknown op: ' + op); } -function getInjection(state, chatId, workspaceKey) { +// computeInjection — pure function. Does NOT mutate state, does NOT touch recallCount. +// Returns the block to inject plus the entry ids so applyInjection can account for them. +function computeInjection(state, chatId, workspaceKey) { const entries = []; for (const e of state.entries) { if (e.key && e.key.indexOf('directive_') === 0 && e.scope === 'workspace' && e.state === 'active') { @@ -310,18 +312,35 @@ function getInjection(state, chatId, workspaceKey) { if (e.state === 'done') continue; if (e.track === 'agent' && e.scope === 'user-global') { entries.push(e); continue; } } - // Recall accounting: every entry that goes into the injected block counts. - const nowTs = nowIso(); - for (const e of entries) { - e.lastRecalled = nowTs; - e.recallCount = (e.recallCount || 0) + 1; - } const texts = entries.map(function (e) { return String(e.text); }); const fpBase = entries.map(function (e) { return e.id + ':' + (e.version || 1); }).sort().join('|'); const fp = crypto.createHash('sha256').update(fpBase).digest('hex').slice(0, 16); const block = texts.length ? '[DSHMEM v2]\n' + texts.join('\n\n') : ''; - if (chatId && state.chats && state.chats[chatId]) state.chats[chatId].lastInjectAt = nowIso(); - return { block: block, fingerprint: fp, count: entries.length, bytes: byteLen(block) }; + const entryIds = entries.map(function (e) { return e.id; }); + return { block: block, fingerprint: fp, count: entries.length, bytes: byteLen(block), entryIds: entryIds }; +} + +// applyInjection — explicit mutation. Call ONLY when the block is actually delivered +// to the model (fingerprint differs from what this chat already received). +function applyInjection(state, inj, chatId) { + if (!inj || !Array.isArray(inj.entryIds)) return { applied: 0 }; + const nowTs = nowIso(); + const ids = new Set(inj.entryIds); + let applied = 0; + for (const e of state.entries) { + if (ids.has(e.id)) { + e.lastRecalled = nowTs; + e.recallCount = (e.recallCount || 0) + 1; + applied++; + } + } + if (chatId) { + if (!state.chats) state.chats = {}; + if (!state.chats[chatId]) state.chats[chatId] = {}; + state.chats[chatId].lastInjectAt = nowTs; + state.chats[chatId].lastInjFp = inj.fingerprint; + } + return { applied: applied }; } -module.exports = { loadState, saveState, query, getInjection, emptyState, stateFile, templateFile, newId, byteLen, ALWAYS_LIMIT, TAGS_LIMIT, DIRECTIVES_PER_CHAT }; +module.exports = { loadState, saveState, query, computeInjection, applyInjection, emptyState, stateFile, templateFile, newId, byteLen, ALWAYS_LIMIT, TAGS_LIMIT, DIRECTIVES_PER_CHAT }; diff --git a/bridge/server.js b/bridge/server.js index 779f5cc..21d69c7 100644 --- a/bridge/server.js +++ b/bridge/server.js @@ -245,7 +245,13 @@ async function handle(req, res, pathname, body) { const st = memoryModule.loadState(memDir); const chatId = body && body.chatId ? String(body.chatId) : ''; const wsKey = body && body.workspaceKey ? String(body.workspaceKey) : String(config.allowedRoots[0] || ''); - const inj = memoryModule.getInjection(st, chatId, wsKey); + const inj = memoryModule.computeInjection(st, chatId, wsKey); + const chat = (chatId && st.chats) ? st.chats[chatId] : null; + const lastFp = chat ? String(chat.lastInjFp || '') : ''; + if (lastFp && lastFp === inj.fingerprint) { + return { ok: true, data: { block: '', fingerprint: inj.fingerprint, count: 0, bytes: 0, deduped: true } }; + } + memoryModule.applyInjection(st, inj, chatId); memoryModule.saveState(memDir, st); return { ok: true, data: inj }; }