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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-09 21:28:04 +08:00
parent 49a1a68a29
commit 40e4a527fe
11 changed files with 677 additions and 1 deletions
+8
View File
@@ -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) {
+9 -1
View File
@@ -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;
+10
View File
@@ -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
+123
View File
@@ -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);
}
+44
View File
@@ -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');
});
+271
View File
@@ -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);
}
+74
View File
@@ -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');
});
+30
View File
@@ -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}`);
+63
View File
@@ -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);
});
@@ -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'),
+22
View File
@@ -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;
}