This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -1,10 +1,9 @@
/**
* Bridge to hive-mind-cli — uses `mcp call <tool>` for all MCP tools
* uniformly. Single chokepoint, no per-command CLI surface drift.
* Bridge to hive-mind-cli. Hook lifecycle saves and bounded empty-query
* recalls use `hook-call`; semantic/general tools keep the MCP handshake.
*
* Wire format: spawn `hive-mind-cli mcp call <tool> --args <json> --json`
* as a short-lived child process; parse stdout JSON. The CLI prints the
* McpCallResult shape (see hive-mind/packages/cli/src/commands/mcp-call.ts).
* 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
@@ -153,7 +152,8 @@ interface SpawnTarget {
* path to `dist/index.js` via `cli_path` so we route through Node.
*/
function buildSpawnTarget(cliPath: string, args: readonly string[]): SpawnTarget {
if (cliPath.endsWith('.js') || cliPath.endsWith('.mjs') || cliPath.endsWith('.cjs')) {
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 };
@@ -239,7 +239,8 @@ export function createCliBridge(opts: CliBridgeOptions = {}): CliBridge {
const spawnImpl: SpawnFn = opts.spawnImpl ?? (spawn as unknown as SpawnFn);
let activeWorkspaceId: string | undefined = opts.initial_workspace_id ?? workspaceIdFromEnvironment();
async function callMcpTool<T>(
async function callCliTool<T>(
mode: 'mcp' | 'hook',
toolName: string,
args: Record<string, unknown>,
callOpts: CallMcpOptions = {},
@@ -252,13 +253,22 @@ export function createCliBridge(opts: CliBridgeOptions = {}): CliBridge {
};
return withRetry<T>(async () => {
const cliArgs = [
'mcp', 'call', toolName,
'--args', JSON.stringify(args),
'--json',
'--timeout-ms', String(callTimeout),
];
log.debug('hive-mind-cli mcp call', { tool: toolName, cliPath });
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,
@@ -271,10 +281,10 @@ export function createCliBridge(opts: CliBridgeOptions = {}): CliBridge {
}
const result = parseMcpCallOutput(stdout);
if (!result.ok) {
throw new Error(`mcp tool ${toolName} failed: ${result.error ?? 'unknown error'}`);
throw new Error(`${mode} tool ${toolName} failed: ${result.error ?? 'unknown error'}`);
}
if (result.isError) {
throw new Error(`mcp tool ${toolName} reported isError: ${unwrapTextContent(result)}`);
throw new Error(`${mode} tool ${toolName} reported isError: ${unwrapTextContent(result)}`);
}
const text = unwrapTextContent(result);
const parsed = tryParseJson<T>(text);
@@ -283,6 +293,21 @@ export function createCliBridge(opts: CliBridgeOptions = {}): CliBridge {
}, 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;
}
@@ -304,7 +329,7 @@ export function createCliBridge(opts: CliBridgeOptions = {}): CliBridge {
const targetWorkspace = opts.workspace ?? activeWorkspaceId;
if (targetWorkspace) wireArgs['workspace'] = targetWorkspace;
const result = await callMcpTool<{
const result = await callHookTool<{
id?: number | string;
workspace?: string;
}>('save_memory', wireArgs);
@@ -330,7 +355,20 @@ export function createCliBridge(opts: CliBridgeOptions = {}): CliBridge {
if (recallOpts.scope !== undefined) wireArgs['scope'] = recallOpts.scope;
if (recallOpts.profile !== undefined) wireArgs['profile'] = recallOpts.profile;
const raw = await callMcpTool<unknown>('recall_memory', wireArgs);
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[];
}

View File

@@ -69,6 +69,8 @@ export { withRetry, computeBackoff } from './retry-bridge.js';
export type { Logger, LogLevel, CreateLoggerOptions } from './logger.js';
export { createLogger } from './logger.js';
export { isDirectExecution } from './main-module.js';
export type {
EmitSignalOptions,
EmittedSignal,

View File

@@ -0,0 +1,19 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Return true when a module is the process entrypoint on the current OS. */
export function isDirectExecution(
moduleUrl: string,
argvPath: string | undefined = process.argv[1],
): boolean {
if (!argvPath) return false;
try {
const modulePath = resolve(fileURLToPath(moduleUrl));
const entryPath = resolve(argvPath);
return process.platform === 'win32'
? modulePath.toLowerCase() === entryPath.toLowerCase()
: modulePath === entryPath;
} catch {
return false;
}
}

View File

@@ -167,7 +167,9 @@ describe('createCliBridge.saveMemory (Commit 1.4 wire format)', () => {
const result = await bridge.saveMemory(SAMPLE_FRAME);
expect(result).toEqual({ id: '9', success: true, workspace: 'personal' });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(records[0].args.slice(0, 3)).toEqual(['hook-call', 'save_memory', '--args']);
expect(records[0].args.at(-1)).toBe('--json');
const wireArgs = JSON.parse(records[0].args[3] as string) as Record<string, unknown>;
expect(Object.keys(wireArgs).sort()).toEqual(['content', 'importance', 'source']);
expect(wireArgs['source']).toBe('system');
expect(wireArgs['importance']).toBe('normal');
@@ -182,7 +184,7 @@ describe('createCliBridge.saveMemory (Commit 1.4 wire format)', () => {
bridge.setWorkspaceById('team-foo');
await bridge.saveMemory(SAMPLE_FRAME);
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
const wireArgs = JSON.parse(records[0].args[3] as string) as Record<string, unknown>;
expect(wireArgs['workspace']).toBe('team-foo');
});
@@ -193,13 +195,67 @@ describe('createCliBridge.saveMemory (Commit 1.4 wire format)', () => {
bridge.setWorkspaceById('default-ws');
await bridge.saveMemory(SAMPLE_FRAME, { workspace: 'override-ws' });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
const wireArgs = JSON.parse(records[0].args[3] as string) as Record<string, unknown>;
expect(wireArgs['workspace']).toBe('override-ws');
});
it('runs an uppercase JavaScript CLI path with spaces directly through Node', async () => {
const records: MockSpawnRecord[] = [];
const cliPath = 'C:\\Program Files\\Hive Mind\\CLI.JS';
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 7 }) });
const bridge = createCliBridge({ cli_path: cliPath, spawnImpl, max_retries: 0 });
await bridge.saveMemory(SAMPLE_FRAME);
expect(records[0].command).toBe(process.execPath);
expect(records[0].args).toEqual([
cliPath,
'hook-call', 'save_memory',
'--args', expect.any(String),
'--json',
]);
});
it('surfaces fast-path non-zero exits', async () => {
const spawnImpl = makeSpawnImpl([], { stderr: 'fast failure', exitCode: 2 });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.saveMemory(SAMPLE_FRAME)).rejects.toThrow(/exited with code 2/);
});
it('surfaces fast-path ok:false envelopes', async () => {
const stdout = JSON.stringify({ ok: false, tool: 'save_memory', error: 'write denied' });
const spawnImpl = makeSpawnImpl([], { stdout });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.saveMemory(SAMPLE_FRAME)).rejects.toThrow(/hook tool save_memory failed: write denied/);
});
it('surfaces fast-path isError envelopes', async () => {
const stdout = JSON.stringify({
ok: true,
tool: 'save_memory',
isError: true,
content: [{ type: 'text', text: 'workspace unavailable' }],
});
const spawnImpl = makeSpawnImpl([], { stdout });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.saveMemory(SAMPLE_FRAME)).rejects.toThrow(/workspace unavailable/);
});
it('bounds fast-path timeout when retries are disabled', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, {
stdout: jsonResultEnvelope({ id: 1 }),
delayMs: 1_000,
});
const bridge = createCliBridge({ spawnImpl, timeout_ms: 5, max_retries: 0 });
await expect(bridge.saveMemory(SAMPLE_FRAME)).rejects.toThrow(/timed out after 505ms/);
expect(records).toHaveLength(1);
});
});
describe('createCliBridge.recallMemory', () => {
it('returns MemoryHit[] when upstream replies with a JSON array', async () => {
const records: MockSpawnRecord[] = [];
const hits = [{
id: 1,
content: 'past',
@@ -209,11 +265,12 @@ describe('createCliBridge.recallMemory', () => {
created_at: '2026-04-28T10:00:00.000Z',
from: 'personal',
}];
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope(hits) });
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope(hits) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.recallMemory('past');
expect(out).toHaveLength(1);
expect(out[0].score).toBe(0.91);
expect(records[0].args.slice(0, 3)).toEqual(['mcp', 'call', 'recall_memory']);
});
it('returns [] when upstream responds with the "No memories found" plain-text envelope', async () => {
@@ -240,6 +297,54 @@ describe('createCliBridge.recallMemory', () => {
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs).toEqual({ query: '', scope: 'personal' });
});
it('routes only eligible empty personal/current recalls through hook-call', async () => {
const records: MockSpawnRecord[] = [];
const hits = [{
id: 1,
content: 'fast context',
importance: 'important',
source: 'system',
score: 0.85,
created_at: '2026-07-20T00:00:00.000Z',
from: 'personal',
}];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope(hits) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await bridge.recallMemory('', { limit: 5, scope: 'personal', workspace: null });
await bridge.recallMemory('', { limit: 5, scope: 'current', workspace: 'project-one' });
expect(records).toHaveLength(2);
expect(records[0].args).toEqual([
'hook-call', 'recall_memory',
'--args', JSON.stringify({ query: '', limit: 5, scope: 'personal' }),
'--json',
]);
expect(records[1].args).toEqual([
'hook-call', 'recall_memory',
'--args', JSON.stringify({ query: '', limit: 5, workspace: 'project-one', scope: 'current' }),
'--json',
]);
});
it.each([
{ query: 'semantic', opts: { limit: 5, scope: 'personal' as const } },
{ query: '', opts: { limit: 5, scope: 'all' as const } },
{ query: '', opts: { limit: 5, scope: 'personal' as const, profile: 'recent' as const } },
{ query: '', opts: { scope: 'personal' as const } },
{ query: '', opts: { limit: 0, scope: 'personal' as const } },
{ query: '', opts: { limit: 1.5, scope: 'personal' as const } },
{ query: '', opts: { limit: 101, scope: 'personal' as const } },
{ query: '', opts: { limit: 5, scope: 'current' as const } },
{ query: '', opts: { limit: 5, scope: 'current' as const, workspace: 'bad/path' } },
])('keeps ineligible recall on the MCP path: $query/$opts', async ({ query, opts }) => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope([]) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await bridge.recallMemory(query, opts);
expect(records[0].args.slice(0, 3)).toEqual(['mcp', 'call', 'recall_memory']);
});
});
describe('createCliBridge.cleanupFrames', () => {

View File

@@ -0,0 +1,17 @@
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
import { isDirectExecution } from '../src/main-module.js';
describe('isDirectExecution', () => {
it('matches an argv filesystem path to its canonical file URL', () => {
const scriptPath = resolve('fixtures', 'hook #1.js');
expect(isDirectExecution(pathToFileURL(scriptPath).href, scriptPath)).toBe(true);
});
it('rejects a different entrypoint or missing argv path', () => {
const modulePath = resolve('fixtures', 'hook.js');
expect(isDirectExecution(pathToFileURL(modulePath).href, resolve('fixtures', 'other.js'))).toBe(false);
expect(isDirectExecution(pathToFileURL(modulePath).href, '')).toBe(false);
});
});