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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user