409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
/**
|
|
* Bridge to hive-mind-cli. Hook lifecycle saves and bounded empty-query
|
|
* recalls use `hook-call`; semantic/general tools keep the MCP handshake.
|
|
*
|
|
* Both paths spawn a short-lived child and return the same McpCallResult
|
|
* envelope (see hive-mind/packages/cli/src/commands/mcp-call.ts).
|
|
*
|
|
* IMPORTANT (Commit 1.4 — MCP surface alignment):
|
|
* - There is no `switch_workspace` MCP tool. Workspace targeting is
|
|
* per-call: `save_memory` and `recall_memory` accept a `workspace`
|
|
* argument naming a workspace id. The bridge tracks an "active
|
|
* workspace id" via `setWorkspaceById` so callers don't have to
|
|
* thread it through every call. Pass `undefined` to clear.
|
|
* - The MCP `save_memory.source` field is a four-value provenance
|
|
* enum (`'user_stated' | 'tool_verified' | 'agent_inferred' |
|
|
* 'system'`), NOT the IDE name. Hook captures use `'system'`.
|
|
* - `save_memory` does NOT accept `scope`, `parent`, or `metadata` —
|
|
* those fields on HookFrame are flattened into a content prefix
|
|
* by `frameToSavePayload` before reaching the wire.
|
|
* - `cleanup_frames` is the actual MCP tool name (was `compact_memory`
|
|
* in the original brief; alias removed in Commit 1.4).
|
|
*/
|
|
|
|
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
|
|
import { frameToSavePayload, type HookFrame, type SaveMemorySource } from './frame-encoder.js';
|
|
import { withRetry, type RetryOptions } from './retry-bridge.js';
|
|
import { createLogger, type Logger } from './logger.js';
|
|
|
|
export interface McpCallResultContent {
|
|
type: string;
|
|
text?: string;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
export interface McpCallResult {
|
|
ok: boolean;
|
|
tool: string;
|
|
content?: McpCallResultContent[];
|
|
isError?: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export type SpawnFn = (
|
|
command: string,
|
|
args: readonly string[],
|
|
options?: SpawnOptions,
|
|
) => ChildProcess;
|
|
|
|
export type { SaveMemorySource };
|
|
|
|
export interface CliBridgeOptions {
|
|
/** Path to (or PATH-resolved name of) the hive-mind-cli binary. Default 'hive-mind-cli'. */
|
|
cli_path?: string;
|
|
/** Default per-call timeout in ms. Default 5000. */
|
|
timeout_ms?: number;
|
|
/** Default retry count. Default 3. */
|
|
max_retries?: number;
|
|
/** Initial active workspace id (omit for personal mind). */
|
|
initial_workspace_id?: string;
|
|
/** Logger override. */
|
|
logger?: Logger;
|
|
/** Test hook — overrides child_process.spawn. */
|
|
spawnImpl?: SpawnFn;
|
|
}
|
|
|
|
export interface SaveMemoryResult {
|
|
/** Frame id as returned by the upstream save_memory tool, stringified. */
|
|
id: string;
|
|
success: boolean;
|
|
/** Workspace the frame was saved into ('personal' if no workspace was active). */
|
|
workspace: string;
|
|
}
|
|
|
|
/** Shape returned by `recall_memory` for a single hit. Matches upstream. */
|
|
export interface MemoryHit {
|
|
id: number;
|
|
content: string;
|
|
importance: string;
|
|
source: string;
|
|
score: number;
|
|
created_at: string;
|
|
/** 'personal' or `workspace:<id>` depending on origin. */
|
|
from: string;
|
|
}
|
|
|
|
export interface RecallMemoryOptions {
|
|
limit?: number;
|
|
/** Explicit workspace id; null forces personal memory even when a workspace is active. */
|
|
workspace?: string | null;
|
|
scope?: 'current' | 'personal' | 'all';
|
|
profile?: 'balanced' | 'recent' | 'important' | 'connected';
|
|
}
|
|
|
|
export interface CallMcpOptions {
|
|
timeoutMs?: number;
|
|
retry?: Partial<RetryOptions>;
|
|
}
|
|
|
|
export type CleanupMode = 'compact' | 'wipe_imports' | 'wipe_all' | 'reconcile';
|
|
|
|
export interface CleanupFramesOptions {
|
|
workspace?: string;
|
|
/** Default 'compact' (safe maintenance). 'wipe_all' is destructive. */
|
|
mode?: CleanupMode;
|
|
maxTempAgeDays?: number;
|
|
maxDeprecatedAgeDays?: number;
|
|
}
|
|
|
|
export interface CliBridge {
|
|
/** Generic escape hatch for any MCP tool not covered by a wrapper. */
|
|
callMcpTool<T = unknown>(
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
opts?: CallMcpOptions,
|
|
): Promise<T>;
|
|
/** Save a HookFrame; lossy fields (scope/parent) flatten into the content prefix. */
|
|
saveMemory(frame: HookFrame, opts?: { workspace?: string }): Promise<SaveMemoryResult>;
|
|
/** Hybrid-search recall against personal mind (default) or a named workspace. */
|
|
recallMemory(query: string, opts?: RecallMemoryOptions): Promise<MemoryHit[]>;
|
|
/** Trigger upstream's frame compaction maintenance pass. Default mode='compact'. */
|
|
cleanupFrames(opts?: CleanupFramesOptions): Promise<{ pruned: number }>;
|
|
/** Set / clear the active workspace id used by save+recall when caller doesn't specify. */
|
|
setWorkspaceById(workspaceId: string | undefined): void;
|
|
/** Read the active workspace id (undefined = personal mind). */
|
|
getActiveWorkspaceId(): string | undefined;
|
|
}
|
|
|
|
const DEFAULT_CLI_PATH = 'hive-mind-cli';
|
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
const DEFAULT_MAX_RETRIES = 3;
|
|
const SPAWN_GRACE_MS = 500;
|
|
|
|
interface CollectedOutput {
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number;
|
|
}
|
|
|
|
interface SpawnTarget {
|
|
command: string;
|
|
args: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* Resolve a cross-platform `(command, args)` tuple for invoking the
|
|
* hive-mind CLI. If `cliPath` ends with a JavaScript extension we
|
|
* route through Node directly (works everywhere, no shell quoting).
|
|
*
|
|
* Known limitation (Wave 1): on Windows, a bare `cli_path: 'hive-mind-cli'`
|
|
* will fail with ENOENT because npm's bin is a `.cmd` shim that requires
|
|
* a parent shell to launch. Workaround until Wave 1.5: pass an absolute
|
|
* path to `dist/index.js` via `cli_path` so we route through Node.
|
|
*/
|
|
function buildSpawnTarget(cliPath: string, args: readonly string[]): SpawnTarget {
|
|
const lowerPath = cliPath.toLowerCase();
|
|
if (lowerPath.endsWith('.js') || lowerPath.endsWith('.mjs') || lowerPath.endsWith('.cjs')) {
|
|
return { command: process.execPath, args: [cliPath, ...args] };
|
|
}
|
|
return { command: cliPath, args };
|
|
}
|
|
|
|
function spawnAndCollect(
|
|
cliPath: string,
|
|
args: readonly string[],
|
|
timeoutMs: number,
|
|
spawnImpl: SpawnFn,
|
|
): Promise<CollectedOutput> {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const target = buildSpawnTarget(cliPath, args);
|
|
const child = spawnImpl(target.command, target.args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
reject(new Error(`hive-mind-cli timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
|
|
child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
|
child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
|
child.on('error', (err) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
child.on('exit', (code) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({
|
|
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
|
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
|
|
code: code ?? 0,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseMcpCallOutput(stdout: string): McpCallResult {
|
|
const trimmed = stdout.trim();
|
|
if (!trimmed) {
|
|
return { ok: false, tool: '', error: 'empty CLI output' };
|
|
}
|
|
try {
|
|
return JSON.parse(trimmed) as McpCallResult;
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
tool: '',
|
|
error: `failed to parse CLI JSON output: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
function unwrapTextContent(result: McpCallResult): string {
|
|
if (!result.content || result.content.length === 0) return '';
|
|
const first = result.content.find((c) => c.type === 'text' && typeof c.text === 'string');
|
|
return typeof first?.text === 'string' ? first.text : '';
|
|
}
|
|
|
|
function tryParseJson<T>(text: string): T | undefined {
|
|
if (!text) return undefined;
|
|
try {
|
|
return JSON.parse(text) as T;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function createCliBridge(opts: CliBridgeOptions = {}): CliBridge {
|
|
const cliPath = opts.cli_path ?? DEFAULT_CLI_PATH;
|
|
const defaultTimeout = opts.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
|
const defaultMaxRetries = opts.max_retries ?? DEFAULT_MAX_RETRIES;
|
|
const log = opts.logger ?? createLogger({ name: 'shim-core/cli-bridge' });
|
|
const spawnImpl: SpawnFn = opts.spawnImpl ?? (spawn as unknown as SpawnFn);
|
|
let activeWorkspaceId: string | undefined = opts.initial_workspace_id ?? workspaceIdFromEnvironment();
|
|
|
|
async function callCliTool<T>(
|
|
mode: 'mcp' | 'hook',
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
callOpts: CallMcpOptions = {},
|
|
): Promise<T> {
|
|
const callTimeout = callOpts.timeoutMs ?? defaultTimeout;
|
|
const retryCfg: RetryOptions = {
|
|
maxRetries: defaultMaxRetries,
|
|
timeoutMs: callTimeout + SPAWN_GRACE_MS * 2,
|
|
...callOpts.retry,
|
|
};
|
|
|
|
return withRetry<T>(async () => {
|
|
const cliArgs = mode === 'hook'
|
|
? [
|
|
'hook-call', toolName,
|
|
'--args', JSON.stringify(args),
|
|
'--json',
|
|
]
|
|
: [
|
|
'mcp', 'call', toolName,
|
|
'--args', JSON.stringify(args),
|
|
'--json',
|
|
'--timeout-ms', String(callTimeout),
|
|
];
|
|
log.debug(`hive-mind-cli ${mode === 'hook' ? 'hook-call' : 'mcp call'}`, {
|
|
tool: toolName,
|
|
cliPath,
|
|
});
|
|
const { stdout, stderr, code } = await spawnAndCollect(
|
|
cliPath,
|
|
cliArgs,
|
|
callTimeout + SPAWN_GRACE_MS,
|
|
spawnImpl,
|
|
);
|
|
if (code !== 0) {
|
|
log.warn('hive-mind-cli exited non-zero', { code, stderr: stderr.slice(0, 500) });
|
|
throw new Error(`hive-mind-cli exited with code ${code}: ${stderr.slice(0, 200)}`);
|
|
}
|
|
const result = parseMcpCallOutput(stdout);
|
|
if (!result.ok) {
|
|
throw new Error(`${mode} tool ${toolName} failed: ${result.error ?? 'unknown error'}`);
|
|
}
|
|
if (result.isError) {
|
|
throw new Error(`${mode} tool ${toolName} reported isError: ${unwrapTextContent(result)}`);
|
|
}
|
|
const text = unwrapTextContent(result);
|
|
const parsed = tryParseJson<T>(text);
|
|
if (parsed !== undefined) return parsed;
|
|
return result as unknown as T;
|
|
}, retryCfg);
|
|
}
|
|
|
|
async function callMcpTool<T>(
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
callOpts: CallMcpOptions = {},
|
|
): Promise<T> {
|
|
return callCliTool<T>('mcp', toolName, args, callOpts);
|
|
}
|
|
|
|
async function callHookTool<T>(
|
|
toolName: 'save_memory' | 'recall_memory',
|
|
args: Record<string, unknown>,
|
|
): Promise<T> {
|
|
return callCliTool<T>('hook', toolName, args);
|
|
}
|
|
|
|
function setWorkspaceById(workspaceId: string | undefined): void {
|
|
activeWorkspaceId = workspaceId;
|
|
}
|
|
|
|
function getActiveWorkspaceId(): string | undefined {
|
|
return activeWorkspaceId;
|
|
}
|
|
|
|
async function saveMemory(
|
|
frame: HookFrame,
|
|
opts: { workspace?: string } = {},
|
|
): Promise<SaveMemoryResult> {
|
|
const payload = frameToSavePayload(frame);
|
|
const wireArgs: Record<string, unknown> = {
|
|
content: payload.content,
|
|
importance: payload.importance,
|
|
source: payload.source,
|
|
};
|
|
const targetWorkspace = opts.workspace ?? activeWorkspaceId;
|
|
if (targetWorkspace) wireArgs['workspace'] = targetWorkspace;
|
|
|
|
const result = await callHookTool<{
|
|
id?: number | string;
|
|
workspace?: string;
|
|
}>('save_memory', wireArgs);
|
|
const rawId = result.id;
|
|
const id = typeof rawId === 'string' ? rawId : (rawId === undefined || rawId === null ? '' : String(rawId));
|
|
return {
|
|
id,
|
|
success: true,
|
|
workspace: result.workspace ?? targetWorkspace ?? 'personal',
|
|
};
|
|
}
|
|
|
|
async function recallMemory(
|
|
query: string,
|
|
recallOpts: RecallMemoryOptions = {},
|
|
): Promise<MemoryHit[]> {
|
|
const wireArgs: Record<string, unknown> = { query };
|
|
if (recallOpts.limit !== undefined) wireArgs['limit'] = recallOpts.limit;
|
|
const targetWorkspace = Object.hasOwn(recallOpts, 'workspace')
|
|
? recallOpts.workspace ?? undefined
|
|
: activeWorkspaceId;
|
|
if (targetWorkspace) wireArgs['workspace'] = targetWorkspace;
|
|
if (recallOpts.scope !== undefined) wireArgs['scope'] = recallOpts.scope;
|
|
if (recallOpts.profile !== undefined) wireArgs['profile'] = recallOpts.profile;
|
|
|
|
const useHookPath = query === ''
|
|
&& recallOpts.profile === undefined
|
|
&& Number.isInteger(recallOpts.limit)
|
|
&& (recallOpts.limit as number) >= 1
|
|
&& (recallOpts.limit as number) <= 100
|
|
&& (
|
|
(recallOpts.scope === 'personal' && targetWorkspace === undefined)
|
|
|| (recallOpts.scope === 'current'
|
|
&& typeof targetWorkspace === 'string'
|
|
&& /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/.test(targetWorkspace))
|
|
);
|
|
const raw = useHookPath
|
|
? await callHookTool<unknown>('recall_memory', wireArgs)
|
|
: await callMcpTool<unknown>('recall_memory', wireArgs);
|
|
if (Array.isArray(raw)) {
|
|
return raw as MemoryHit[];
|
|
}
|
|
// Empty result case: upstream returns plain text "No memories found for query: ..."
|
|
// which our JSON parser falls through on, returning the raw McpCallResult.
|
|
return [];
|
|
}
|
|
|
|
async function cleanupFrames(
|
|
cleanupOpts: CleanupFramesOptions = {},
|
|
): Promise<{ pruned: number }> {
|
|
const wireArgs: Record<string, unknown> = {
|
|
mode: cleanupOpts.mode ?? 'compact',
|
|
};
|
|
const targetWorkspace = cleanupOpts.workspace ?? activeWorkspaceId;
|
|
if (targetWorkspace) wireArgs['workspace'] = targetWorkspace;
|
|
if (cleanupOpts.maxTempAgeDays !== undefined) wireArgs['max_temp_age_days'] = cleanupOpts.maxTempAgeDays;
|
|
if (cleanupOpts.maxDeprecatedAgeDays !== undefined) wireArgs['max_deprecated_age_days'] = cleanupOpts.maxDeprecatedAgeDays;
|
|
const result = await callMcpTool<{ pruned?: number; deleted?: number }>('cleanup_frames', wireArgs);
|
|
return { pruned: result.pruned ?? result.deleted ?? 0 };
|
|
}
|
|
|
|
return {
|
|
callMcpTool,
|
|
saveMemory,
|
|
recallMemory,
|
|
cleanupFrames,
|
|
setWorkspaceById,
|
|
getActiveWorkspaceId,
|
|
};
|
|
}
|
|
|
|
function workspaceIdFromEnvironment(): string | undefined {
|
|
const value = process.env.WAGGLE_WORKSPACE_ID?.trim();
|
|
if (!value || value.length > 200 || /[\0\r\n]/.test(value)) return undefined;
|
|
return value;
|
|
}
|