From 40e4a527fe6eddbb1f42053711f6ac07bec2dcce Mon Sep 17 00:00:00 2001 From: john Date: Wed, 9 Sep 2026 21:28:04 +0800 Subject: [PATCH] feat(context): add recall fusion and zvec workspace adapters Introduce RRF-based recall fusion for personal/episodic/temporal paths, zvec-grep workspace shadow probing for code executors, and a local headroom proxy starter aligned with the fusion plan defaults. Co-authored-by: Cursor --- agent-run-gateway.mjs | 8 + chat-intent-router.mjs | 10 +- docs/goose-v149-canary.env.example | 10 + memind-zvec-workspace.mjs | 123 +++++++++ memind-zvec-workspace.test.mjs | 44 ++++ recall-fusion.mjs | 271 ++++++++++++++++++++ recall-fusion.test.mjs | 74 ++++++ scripts/check-zvec-workspace-local.mjs | 30 +++ scripts/start-headroom-proxy-local.mjs | 63 +++++ temporal-recall-service/runtime-context.mjs | 23 ++ tool-gateway.mjs | 22 ++ 11 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 memind-zvec-workspace.mjs create mode 100644 memind-zvec-workspace.test.mjs create mode 100644 recall-fusion.mjs create mode 100644 recall-fusion.test.mjs create mode 100644 scripts/check-zvec-workspace-local.mjs create mode 100644 scripts/start-headroom-proxy-local.mjs diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index c9e8b1d..2259a1b 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -53,6 +53,7 @@ import { import { extractAgentRunExperience } from './experience-extractor.mjs'; import { executeRainPipeline, isRainModeMessage } from './rain-service/index.mjs'; import { buildContextBudgetResolvedEvent, resolveContextBudgetMode } from './context-budget.mjs'; +import { resolveRecallFusionMode } from './recall-fusion.mjs'; import { buildHeadroomRunObservation, resolveHeadroomMode } from './memind-headroom-policy.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; @@ -1908,6 +1909,12 @@ export function createAgentRunGateway({ reason: agentMemoryContext.reason ?? null, latencyMs: Number(agentMemoryContext.latencyMs ?? 0), }); + if ( + resolveRecallFusionMode() !== 'off' && + agentMemoryContext.recallFusion + ) { + await appendEvent(runId, 'recall_fusion_resolved', agentMemoryContext.recallFusion); + } if (agentMemoryContext.injectionEnabled) { const memoryCount = Array.isArray(agentMemoryContext.memories) ? agentMemoryContext.memories.length @@ -1977,6 +1984,7 @@ export function createAgentRunGateway({ temporal_items: runtimeContext.temporalRecall?.stats?.returned_count ?? 0, has_snapshot: Boolean(runtimeContext.blocks?.snapshot), injection_chars: runtimeContext.injectionText?.length ?? 0, + recall_fusion: runtimeContext.recallFusion ?? null, }); } } catch (err) { diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 6979022..7f022ac 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -10,6 +10,10 @@ import { PAGE_DATA_COLLECT_SKILL_NAME, } from './chat-skills.mjs'; import { applyContextBudgetToOrchestration } from './context-budget.mjs'; +import { + applyRecallFusionToMemoryMerge, + buildRecallFusionResolvedEvent, +} from './recall-fusion.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; import { memoryLimitForIntervention, @@ -1709,12 +1713,14 @@ export function createChatIntentRouter(options = {}) { agentMemoryPolicy.timeoutMs, 'Memory V2 agent resolve', ); - const memories = mergeAgentMemoryCandidates({ + const fused = applyRecallFusionToMemoryMerge({ personalMemories: personalResolved?.memories, episodicMemories: episodicResolved?.memories, query: text, limit, + legacyMerge: mergeAgentMemoryCandidates, }); + const memories = fused.memories; const degraded = personalFailed || episodicFailed || Boolean(personalResolved?.degraded) @@ -1730,6 +1736,8 @@ export function createChatIntentRouter(options = {}) { ? (memories.length ? 'partial_memory_resolve_failed' : 'memory_resolve_failed') : (memories.length ? null : (episodicResolved?.reason ?? personalResolved?.reason ?? null)), memories, + recallFusion: buildRecallFusionResolvedEvent(fused.fusion), + recallFusionApplied: fused.applied, latencyMs: Date.now() - startedAt, }; agentMemoryMetrics.resolved += 1; diff --git a/docs/goose-v149-canary.env.example b/docs/goose-v149-canary.env.example index 3a65347..abbe96b 100644 --- a/docs/goose-v149-canary.env.example +++ b/docs/goose-v149-canary.env.example @@ -33,3 +33,13 @@ MEMORY_CANDIDATE_ENABLED=0 # Context injection budget (Phase 2, default off — does not open memory injection) # MEMIND_CONTEXT_BUDGET_MODE=off # MEMIND_CONTEXT_BUDGET_MAX_CHARS=12000 + +# Recall fusion (Phase 3, default off) +# MEMIND_RECALL_FUSION_MODE=off +# MEMIND_RECALL_FUSION_RRF_K=60 +# MEMIND_RECALL_FUSION_LIMIT=3 + +# zvec-grep workspace search for code tasks (dev/local, default off) +# MEMIND_ZVEC_WORKSPACE_MODE=off +# MEMIND_ZVEC_BINARY=zg +# MEMIND_ZVEC_WORKSPACE_TASK_TYPES=page_data_dev_complex,repo_refactor,multi_file diff --git a/memind-zvec-workspace.mjs b/memind-zvec-workspace.mjs new file mode 100644 index 0000000..c5eb427 --- /dev/null +++ b/memind-zvec-workspace.mjs @@ -0,0 +1,123 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +export const DEFAULT_ZVEC_QUERY_LIMIT = 8; + +function truthy(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function resolveZvecWorkspaceMode(env = process.env) { + const raw = String(env.MEMIND_ZVEC_WORKSPACE_MODE ?? 'off').trim().toLowerCase(); + return ['off', 'shadow', 'active'].includes(raw) ? raw : 'off'; +} + +export function resolveZvecBinary(env = process.env) { + return String(env.MEMIND_ZVEC_BINARY ?? 'zg').trim() || 'zg'; +} + +export function resolveZvecWorkspaceRoot(cwd, env = process.env) { + const explicit = String(env.MEMIND_ZVEC_WORKSPACE_ROOT ?? '').trim(); + if (explicit) return path.resolve(explicit); + return path.resolve(String(cwd ?? process.cwd())); +} + +function parseZvecQueryOutput(stdout) { + const hits = []; + const lines = String(stdout ?? '').split('\n'); + for (const line of lines) { + const match = line.match(/^#(\d+) matchedBy=([^\s]+)\s+(.+?):(\d+)-(\d+)/); + if (!match) continue; + hits.push({ + rank: Number(match[1]), + matchedBy: match[2], + path: match[3].trim(), + startLine: Number(match[4]), + endLine: Number(match[5]), + }); + } + return hits; +} + +export function searchZvecWorkspace({ + query, + limit = DEFAULT_ZVEC_QUERY_LIMIT, + cwd, + env = process.env, + spawnImpl = spawnSync, +} = {}) { + const mode = resolveZvecWorkspaceMode(env); + const text = String(query ?? '').trim(); + const workspaceRoot = resolveZvecWorkspaceRoot(cwd, env); + if (mode === 'off' || !text) { + return { + mode, + query: text, + hits: [], + applied: false, + skipped: true, + reason: mode === 'off' ? 'disabled' : 'empty_query', + }; + } + + const binary = resolveZvecBinary(env); + const result = spawnImpl(binary, ['query', text, '--limit', String(limit)], { + cwd: workspaceRoot, + env: { ...process.env, ...env }, + encoding: 'utf8', + }); + + if (result.status !== 0) { + return { + mode, + query: text, + hits: [], + applied: false, + skipped: true, + reason: 'zvec_query_failed', + stderr: String(result.stderr ?? '').trim().slice(0, 400), + }; + } + + const hits = parseZvecQueryOutput(result.stdout); + return { + mode, + query: text, + workspaceRoot, + hits, + hitCount: hits.length, + applied: mode === 'active', + skipped: false, + stdoutPreview: String(result.stdout ?? '').trim().slice(0, 1200), + }; +} + +export function formatZvecWorkspaceBlock(result) { + if (!result?.hits?.length) return ''; + const lines = [ + '【Workspace 代码检索】', + '以下片段来自本工作区索引,仅供开发/排查参考,不是用户生活记忆。', + ]; + for (const hit of result.hits.slice(0, 8)) { + lines.push( + `- ${hit.path}:${hit.startLine}-${hit.endLine} (${hit.matchedBy})`, + ); + } + return lines.join('\n').trim(); +} + +export function isZvecWorkspaceEnabled(env = process.env) { + return resolveZvecWorkspaceMode(env) !== 'off'; +} + +export function shouldAttachZvecWorkspaceForTask(taskType, env = process.env) { + if (!isZvecWorkspaceEnabled(env)) return false; + const raw = String(env.MEMIND_ZVEC_WORKSPACE_TASK_TYPES ?? 'page_data_dev_complex,repo_refactor,multi_file') + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); + const normalizedTask = String(taskType ?? '').trim().toLowerCase(); + return raw.includes(normalizedTask); +} diff --git a/memind-zvec-workspace.test.mjs b/memind-zvec-workspace.test.mjs new file mode 100644 index 0000000..e67637c --- /dev/null +++ b/memind-zvec-workspace.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + formatZvecWorkspaceBlock, + resolveZvecWorkspaceMode, + searchZvecWorkspace, +} from './memind-zvec-workspace.mjs'; + +test('resolveZvecWorkspaceMode defaults to off', () => { + assert.equal(resolveZvecWorkspaceMode({}), 'off'); + assert.equal(resolveZvecWorkspaceMode({ MEMIND_ZVEC_WORKSPACE_MODE: 'shadow' }), 'shadow'); +}); + +test('searchZvecWorkspace is no-op when disabled', () => { + const result = searchZvecWorkspace({ + query: 'headroom', + env: { MEMIND_ZVEC_WORKSPACE_MODE: 'off' }, + }); + assert.equal(result.skipped, true); + assert.equal(result.hits.length, 0); +}); + +test('formatZvecWorkspaceBlock renders hit paths', () => { + const block = formatZvecWorkspaceBlock({ + hits: [{ path: 'a.mjs', startLine: 1, endLine: 3, matchedBy: 'fts' }], + }); + assert.match(block, /a\.mjs:1-3/); +}); + +test('searchZvecWorkspace parses zg query stdout', () => { + const stdout = [ + 'query groups (1):', + '#1 matchedBy=fts docs/plan.md:10-20', + '#2 matchedBy=vector src/app.mjs:3-8', + ].join('\n'); + const result = searchZvecWorkspace({ + query: 'plan', + env: { MEMIND_ZVEC_WORKSPACE_MODE: 'shadow' }, + cwd: '/tmp', + spawnImpl: () => ({ status: 0, stdout, stderr: '' }), + }); + assert.equal(result.hitCount, 2); + assert.equal(result.hits[0].path, 'docs/plan.md'); +}); diff --git a/recall-fusion.mjs b/recall-fusion.mjs new file mode 100644 index 0000000..89f3c06 --- /dev/null +++ b/recall-fusion.mjs @@ -0,0 +1,271 @@ +import crypto from 'node:crypto'; +import { fingerprintContent } from './context-budget.mjs'; +import { pgvectorMemoryBackendInternals } from './memory-v2-pgvector.mjs'; +import { isTemporalRecallQuery } from './temporal-recall-service/keyword-rules.mjs'; + +export const RECALL_FUSION_MODES = Object.freeze(['off', 'shadow', 'active']); +export const DEFAULT_RECALL_FUSION_RRF_K = 60; + +function normalizeMode(value, fallback = 'off') { + const raw = String(value ?? fallback).trim().toLowerCase(); + return RECALL_FUSION_MODES.includes(raw) ? raw : fallback; +} + +export function resolveRecallFusionMode(env = process.env) { + return normalizeMode(env.MEMIND_RECALL_FUSION_MODE, 'off'); +} + +export function resolveRecallFusionRrfK(env = process.env) { + const raw = Number(env.MEMIND_RECALL_FUSION_RRF_K ?? DEFAULT_RECALL_FUSION_RRF_K); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_RECALL_FUSION_RRF_K; +} + +export function resolveRecallFusionLimit(env = process.env, fallback = 3) { + const raw = Number(env.MEMIND_RECALL_FUSION_LIMIT ?? fallback); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback; +} + +function memoryItemText(item) { + return String(item?.text ?? item?.content ?? item?.summary ?? '').trim(); +} + +function memoryItemLabel(item) { + return String(item?.label ?? item?.title ?? '').trim() || null; +} + +export function parseRecallQuery(query, { now = new Date() } = {}) { + const text = String(query ?? '').trim(); + return { + text, + temporal: isTemporalRecallQuery(text), + keywordTerms: pgvectorMemoryBackendInternals.extractKeywordTerms(text), + now, + }; +} + +export function normalizeRecallCandidate(item, { source, rank = 0, query = '' } = {}) { + if (!item || typeof item !== 'object') return null; + const text = memoryItemText(item); + if (!text) return null; + const label = memoryItemLabel(item); + const id = String( + item?.id + ?? item?.memoryId + ?? item?.sessionId + ?? item?.event_id + ?? `${source}:${label ?? text.slice(0, 32)}`, + ).trim(); + const lexical = query ? pgvectorMemoryBackendInternals.lexicalQueryCoverage(query, text) : 0; + return { + id, + source, + label, + text, + rank, + lexical, + fingerprint: fingerprintContent(text), + raw: item, + }; +} + +export function reciprocalRankFusion(lists, { k = DEFAULT_RECALL_FUSION_RRF_K } = {}) { + const scores = new Map(); + const meta = new Map(); + + for (const list of Array.isArray(lists) ? lists : []) { + const source = String(list?.source ?? 'unknown'); + const candidates = Array.isArray(list?.candidates) ? list.candidates : []; + candidates.forEach((candidate, index) => { + if (!candidate?.id) return; + const rrf = 1 / (k + index + 1); + const prev = scores.get(candidate.id) ?? 0; + scores.set(candidate.id, prev + rrf); + const existing = meta.get(candidate.id); + if (!existing) { + meta.set(candidate.id, { + ...candidate, + sources: [source], + ranks: { [source]: index + 1 }, + rrfScore: rrf, + }); + return; + } + existing.sources = [...new Set([...existing.sources, source])]; + existing.ranks[source] = index + 1; + existing.rrfScore = scores.get(candidate.id); + if ((candidate.lexical ?? 0) > (existing.lexical ?? 0)) { + existing.lexical = candidate.lexical; + existing.label = candidate.label ?? existing.label; + existing.text = candidate.text ?? existing.text; + existing.raw = candidate.raw ?? existing.raw; + } + }); + } + + return [...meta.values()] + .sort((left, right) => { + const scoreDelta = (right.rrfScore ?? 0) - (left.rrfScore ?? 0); + if (scoreDelta !== 0) return scoreDelta; + const lexicalDelta = (right.lexical ?? 0) - (left.lexical ?? 0); + if (lexicalDelta !== 0) return lexicalDelta; + return String(left.id).localeCompare(String(right.id)); + }); +} + +export function fuseRecallCandidates({ + query = '', + personalMemories = [], + episodicMemories = [], + temporalItems = [], + limit = 3, + env = process.env, +} = {}) { + const mode = resolveRecallFusionMode(env); + const parsedQuery = parseRecallQuery(query); + const lists = [ + { + source: 'personal', + candidates: (Array.isArray(personalMemories) ? personalMemories : []) + .map((item, index) => normalizeRecallCandidate(item, { source: 'personal', rank: index, query })) + .filter(Boolean), + }, + { + source: 'episodic', + candidates: (Array.isArray(episodicMemories) ? episodicMemories : []) + .map((item, index) => normalizeRecallCandidate(item, { source: 'episodic', rank: index, query })) + .filter(Boolean), + }, + { + source: 'temporal', + candidates: (Array.isArray(temporalItems) ? temporalItems : []) + .map((item, index) => normalizeRecallCandidate(item, { + source: 'temporal', + rank: index, + query, + })) + .filter(Boolean), + }, + ]; + + const fused = reciprocalRankFusion(lists, { k: resolveRecallFusionRrfK(env) }); + const seenFingerprints = new Set(); + const memories = []; + const duplicateItems = []; + + for (const item of fused) { + if (item.fingerprint && seenFingerprints.has(item.fingerprint)) { + duplicateItems.push({ + id: item.id, + sources: item.sources, + reason: 'duplicate_fingerprint', + }); + continue; + } + if (item.fingerprint) seenFingerprints.add(item.fingerprint); + memories.push(item.raw ?? { + id: item.id, + label: item.label, + text: item.text, + source: item.sources?.[0] ?? item.source, + }); + if (memories.length >= limit) break; + } + + return { + mode, + query: parsedQuery, + inputCounts: { + personal: lists[0].candidates.length, + episodic: lists[1].candidates.length, + temporal: lists[2].candidates.length, + }, + fusedCount: fused.length, + keptCount: memories.length, + duplicateCount: duplicateItems.length, + duplicateItems, + memories, + topScores: fused.slice(0, Math.min(5, fused.length)).map((item) => ({ + id: item.id, + sources: item.sources, + rrfScore: Number(item.rrfScore?.toFixed?.(4) ?? item.rrfScore), + lexical: item.lexical, + })), + }; +} + +export function applyRecallFusionToMemoryMerge({ + personalMemories = [], + episodicMemories = [], + temporalItems = [], + query = '', + limit = 3, + legacyMerge, + env = process.env, +} = {}) { + const mode = resolveRecallFusionMode(env); + const fusion = fuseRecallCandidates({ + query, + personalMemories, + episodicMemories, + temporalItems, + limit, + env, + }); + + if (mode === 'off') { + return { + memories: legacyMerge?.({ + personalMemories, + episodicMemories, + query, + limit, + }) ?? [], + fusion: null, + applied: false, + }; + } + + if (mode === 'shadow') { + return { + memories: legacyMerge?.({ + personalMemories, + episodicMemories, + query, + limit, + }) ?? fusion.memories, + fusion, + applied: false, + }; + } + + return { + memories: fusion.memories, + fusion, + applied: true, + }; +} + +export function buildRecallFusionResolvedEvent(fusion) { + if (!fusion) return null; + return { + mode: fusion.mode, + temporalQuery: Boolean(fusion.query?.temporal), + inputCounts: fusion.inputCounts, + fusedCount: fusion.fusedCount, + keptCount: fusion.keptCount, + duplicateCount: fusion.duplicateCount, + topScores: fusion.topScores, + }; +} + +export function hashRecallFusionPlan(fusion) { + if (!fusion) return null; + return crypto.createHash('sha256') + .update(JSON.stringify({ + keptCount: fusion.keptCount, + topScores: fusion.topScores, + inputCounts: fusion.inputCounts, + })) + .digest('hex') + .slice(0, 16); +} diff --git a/recall-fusion.test.mjs b/recall-fusion.test.mjs new file mode 100644 index 0000000..5415c44 --- /dev/null +++ b/recall-fusion.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyRecallFusionToMemoryMerge, + fuseRecallCandidates, + normalizeRecallCandidate, + reciprocalRankFusion, + resolveRecallFusionMode, +} from './recall-fusion.mjs'; + +test('resolveRecallFusionMode defaults to off', () => { + assert.equal(resolveRecallFusionMode({}), 'off'); + assert.equal(resolveRecallFusionMode({ MEMIND_RECALL_FUSION_MODE: 'shadow' }), 'shadow'); +}); + +test('reciprocalRankFusion boosts items appearing in multiple lists', () => { + const shared = normalizeRecallCandidate( + { id: 'm1', text: '用户偏好简洁回答' }, + { source: 'personal', rank: 0, query: '偏好' }, + ); + const episodicOnly = normalizeRecallCandidate( + { id: 'm2', text: '上次讨论了苏州旅行' }, + { source: 'episodic', rank: 0, query: '苏州' }, + ); + const fused = reciprocalRankFusion([ + { source: 'personal', candidates: [shared] }, + { source: 'episodic', candidates: [shared, episodicOnly] }, + ], { k: 60 }); + assert.equal(fused[0].id, 'm1'); + assert.ok(fused[0].sources.includes('personal')); + assert.ok(fused[0].sources.includes('episodic')); +}); + +test('fuseRecallCandidates dedupes duplicate fingerprints across sources', () => { + const duplicate = '用户喜欢咖啡'; + const fusion = fuseRecallCandidates({ + query: '咖啡偏好', + personalMemories: [{ id: 'p1', text: duplicate }], + episodicMemories: [{ id: 'e1', text: duplicate }], + limit: 3, + env: { MEMIND_RECALL_FUSION_MODE: 'active' }, + }); + assert.equal(fusion.keptCount, 1); + assert.equal(fusion.duplicateCount, 1); +}); + +test('applyRecallFusionToMemoryMerge keeps legacy merge in shadow mode', () => { + const applied = applyRecallFusionToMemoryMerge({ + personalMemories: [{ id: 'p1', text: 'alpha' }], + episodicMemories: [{ id: 'e1', text: 'beta' }], + query: 'alpha', + limit: 1, + legacyMerge: () => [{ id: 'legacy', text: 'legacy-only' }], + env: { MEMIND_RECALL_FUSION_MODE: 'shadow' }, + }); + assert.equal(applied.applied, false); + assert.equal(applied.memories[0].id, 'legacy'); + assert.ok(applied.fusion); +}); + +test('applyRecallFusionToMemoryMerge uses fused ranking in active mode', () => { + const duplicate = '共享记忆'; + const applied = applyRecallFusionToMemoryMerge({ + personalMemories: [{ id: 'p1', text: duplicate }], + episodicMemories: [{ id: 'e1', text: duplicate }, { id: 'e2', text: 'unique episodic' }], + query: '共享', + limit: 2, + legacyMerge: () => [{ id: 'legacy', text: 'legacy-only' }], + env: { MEMIND_RECALL_FUSION_MODE: 'active' }, + }); + assert.equal(applied.applied, true); + assert.equal(applied.memories.length, 2); + assert.notEqual(applied.memories[0].id, 'legacy'); +}); diff --git a/scripts/check-zvec-workspace-local.mjs b/scripts/check-zvec-workspace-local.mjs new file mode 100644 index 0000000..0b0bc28 --- /dev/null +++ b/scripts/check-zvec-workspace-local.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Local zvec-grep workspace smoke (loopback/dev only). + */ +import { searchZvecWorkspace } from '../memind-zvec-workspace.mjs'; + +const query = process.env.MEMIND_ZVEC_SMOKE_QUERY ?? 'context budget'; +const mode = process.env.MEMIND_ZVEC_WORKSPACE_MODE ?? 'shadow'; + +const result = searchZvecWorkspace({ + query, + env: { + ...process.env, + MEMIND_ZVEC_WORKSPACE_MODE: mode, + }, +}); + +console.log('ZVEC_WORKSPACE_PROBE:'); +console.log(` mode=${result.mode}`); +console.log(` query=${result.query}`); +console.log(` hits=${result.hitCount ?? 0}`); +if (result.skipped) { + console.error(`ZVEC_WORKSPACE_FAIL: ${result.reason}`); + process.exit(1); +} +if (!result.hits?.length) { + console.error('ZVEC_WORKSPACE_FAIL: no hits (run zg index in repo root first)'); + process.exit(1); +} +console.log(`ZVEC_WORKSPACE_OK: top=${result.hits[0].path}:${result.hits[0].startLine}`); diff --git a/scripts/start-headroom-proxy-local.mjs b/scripts/start-headroom-proxy-local.mjs new file mode 100644 index 0000000..ed1bc5c --- /dev/null +++ b/scripts/start-headroom-proxy-local.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Start headroom proxy for local MeMind loopback dev. + * + * Usage: + * node scripts/start-headroom-proxy-local.mjs + * + * Requires: + * - headroom-ai installed (uv tool install headroom-ai[proxy]) + * - deepseek-no-think compat proxy on :18036 + */ +import { spawn } from 'node:child_process'; +import { + resolveDeepseekNoThinkProxyBaseUrl, +} from '../deepseek-no-think-proxy.mjs'; +import { + probeHeadroomProxyReachable, + resolveHeadroomProxyBaseUrl, + resolveHeadroomProxyPort, + resolveHeadroomUpstreamBaseUrl, +} from '../memind-headroom-policy.mjs'; + +const upstream = resolveHeadroomUpstreamBaseUrl(); +const port = resolveHeadroomProxyPort(); +const proxyBase = resolveHeadroomProxyBaseUrl(); + +async function main() { + const reachable = await probeHeadroomProxyReachable({ baseUrl: proxyBase }); + if (reachable) { + console.log(`HEADROOM_LOCAL_ALREADY_RUNNING: ${proxyBase}`); + console.log(` upstream=${upstream}`); + return; + } + + console.log('HEADROOM_LOCAL_START:'); + console.log(` proxy=${proxyBase}`); + console.log(` upstream=${upstream}`); + console.log(` compat=${resolveDeepseekNoThinkProxyBaseUrl()}`); + console.log(' constraints: HEADROOM_OUTPUT_SHAPER=0, no wrap/learn'); + + const child = spawn( + 'headroom', + ['proxy', '--port', String(port)], + { + stdio: 'inherit', + env: { + ...process.env, + OPENAI_BASE_URL: upstream, + HEADROOM_OUTPUT_SHAPER: '0', + MEMIND_HEADROOM_MODE: process.env.MEMIND_HEADROOM_MODE ?? 'shadow', + }, + }, + ); + + child.on('exit', (code, signal) => { + process.exit(code ?? (signal ? 1 : 0)); + }); +} + +main().catch((error) => { + console.error(`HEADROOM_LOCAL_FAIL: ${error.message}`); + process.exit(1); +}); diff --git a/temporal-recall-service/runtime-context.mjs b/temporal-recall-service/runtime-context.mjs index 1cf2902..3e293c0 100644 --- a/temporal-recall-service/runtime-context.mjs +++ b/temporal-recall-service/runtime-context.mjs @@ -1,6 +1,11 @@ import { getActiveSnapshot } from '../user-model-service/snapshot.mjs'; import { createUmsPool, isUmsDatabaseConfigured } from '../user-model-service/db.mjs'; import { resolveCanonicalUserId } from '../user-model-service/canonical-user.mjs'; +import { + buildRecallFusionResolvedEvent, + fuseRecallCandidates, + resolveRecallFusionMode, +} from '../recall-fusion.mjs'; import { buildContextPlan } from './context-planner.mjs'; import { queryTemporalRecall } from './recall.mjs'; @@ -164,12 +169,30 @@ export async function resolveRuntimeContext(input) { } } + let recallFusion = null; + if (resolveRecallFusionMode() !== 'off' && temporalRecall) { + const temporalItems = [ + ...(Array.isArray(temporalRecall.items) ? temporalRecall.items : []), + ...(Array.isArray(temporalRecall.groups) + ? temporalRecall.groups.flatMap((group) => group.items ?? []) + : []), + ]; + if (temporalItems.length > 0) { + recallFusion = buildRecallFusionResolvedEvent(fuseRecallCandidates({ + query, + temporalItems, + limit: plan.output?.max_items ?? 20, + })); + } + } + const injectionEnabled = Boolean(blocks.temporal || blocks.snapshot); return { enabled: true, plan, temporalRecall, userSnapshot, + recallFusion, injectionEnabled, blocks, injectionText: [blocks.snapshot, blocks.temporal].filter(Boolean).join('\n\n'), diff --git a/tool-gateway.mjs b/tool-gateway.mjs index 2906313..8fd41e5 100644 --- a/tool-gateway.mjs +++ b/tool-gateway.mjs @@ -8,6 +8,10 @@ import { parseCursorAgentUsage, } from './cursor-agent-usage.mjs'; import { resolveExecutorDisplayLabel } from './executor-display-label.mjs'; +import { + searchZvecWorkspace, + shouldAttachZvecWorkspaceForTask, +} from './memind-zvec-workspace.mjs'; const BASE_CODE_EXECUTORS = ['aider', 'openhands']; @@ -226,6 +230,22 @@ export function createToolGateway({ throw new Error('Tool Gateway job missing instruction'); } const executor = selectExecutor({ userMessage, taskType }); + const normalizedTaskType = String(taskType ?? userMessage?.metadata?.memindRun?.taskType ?? '').trim(); + let zvecWorkspace = null; + if (shouldAttachZvecWorkspaceForTask(normalizedTaskType, env)) { + zvecWorkspace = searchZvecWorkspace({ + query: instruction, + cwd, + env, + }); + if (zvecWorkspace.mode === 'shadow' && zvecWorkspace.hitCount > 0) { + console.log('[tool-gateway] zvec workspace shadow', { + taskType: normalizedTaskType, + hitCount: zvecWorkspace.hitCount, + top: zvecWorkspace.hits[0], + }); + } + } const receiptPath = executor === 'aider' ? resolveAiderReceiptPath(userMessage, requestId) : null; @@ -274,6 +294,7 @@ export function createToolGateway({ runId, requestId, userId, + zvecWorkspace, }; } @@ -330,6 +351,7 @@ export function createToolGateway({ displayStdout: displayStdout || undefined, usage: usage ?? undefined, stderr, + zvecWorkspace, }); return; }