This commit is contained in:
370
packages/hive-mind-shim-core/src/cli-bridge.ts
Normal file
370
packages/hive-mind-shim-core/src/cli-bridge.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Bridge to hive-mind-cli — uses `mcp call <tool>` for all MCP tools
|
||||
* uniformly. Single chokepoint, no per-command CLI surface drift.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* 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 {
|
||||
if (cliPath.endsWith('.js') || cliPath.endsWith('.mjs') || cliPath.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 callMcpTool<T>(
|
||||
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 = [
|
||||
'mcp', 'call', toolName,
|
||||
'--args', JSON.stringify(args),
|
||||
'--json',
|
||||
'--timeout-ms', String(callTimeout),
|
||||
];
|
||||
log.debug('hive-mind-cli 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(`mcp tool ${toolName} failed: ${result.error ?? 'unknown error'}`);
|
||||
}
|
||||
if (result.isError) {
|
||||
throw new Error(`mcp 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);
|
||||
}
|
||||
|
||||
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 callMcpTool<{
|
||||
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 raw = 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;
|
||||
}
|
||||
47
packages/hive-mind-shim-core/src/context-recall.ts
Normal file
47
packages/hive-mind-shim-core/src/context-recall.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { CliBridge, MemoryHit, RecallMemoryOptions } from './cli-bridge.js';
|
||||
|
||||
export interface RecallContextOptions {
|
||||
limit: number;
|
||||
workspaceId?: string;
|
||||
profile?: RecallMemoryOptions['profile'];
|
||||
}
|
||||
|
||||
/** Recall personal and current-workspace memory without ever widening to all workspaces. */
|
||||
export async function recallPersonalAndWorkspace(
|
||||
bridge: CliBridge,
|
||||
query: string,
|
||||
options: RecallContextOptions,
|
||||
): Promise<MemoryHit[]> {
|
||||
const limit = Math.max(1, Math.floor(options.limit));
|
||||
const workspaceId = options.workspaceId ?? bridge.getActiveWorkspaceId();
|
||||
const personal = bridge.recallMemory(query, {
|
||||
limit,
|
||||
scope: 'personal',
|
||||
workspace: null,
|
||||
...(options.profile ? { profile: options.profile } : {}),
|
||||
});
|
||||
const workspace = workspaceId
|
||||
? bridge.recallMemory(query, {
|
||||
limit,
|
||||
scope: 'current',
|
||||
workspace: workspaceId,
|
||||
...(options.profile ? { profile: options.profile } : {}),
|
||||
})
|
||||
: Promise.resolve([]);
|
||||
const [personalHits, workspaceHits] = await Promise.all([personal, workspace]);
|
||||
return mergeRankedHits([...personalHits, ...workspaceHits], limit);
|
||||
}
|
||||
|
||||
function mergeRankedHits(hits: MemoryHit[], limit: number): MemoryHit[] {
|
||||
const ranked = [...hits].sort((left, right) => right.score - left.score);
|
||||
const seen = new Set<string>();
|
||||
const merged: MemoryHit[] = [];
|
||||
for (const hit of ranked) {
|
||||
const key = hit.content.trim().replace(/\s+/g, ' ').toLocaleLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(hit);
|
||||
if (merged.length >= limit) break;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
137
packages/hive-mind-shim-core/src/frame-encoder.ts
Normal file
137
packages/hive-mind-shim-core/src/frame-encoder.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Encode a HookEvent into a HookFrame, and flatten a HookFrame into
|
||||
* the lossy SavePayload that survives the upstream save_memory wire.
|
||||
*
|
||||
* Frame model maps to hive-mind core:
|
||||
* - temporary -> ephemeral chatter, decays fast
|
||||
* - normal -> default for substantive content (upstream default)
|
||||
* - important -> turns containing decisions / failures / actions
|
||||
* - critical -> user-facing rules / preferences, retained indefinitely
|
||||
*
|
||||
* IMPORTANT (Commit 1.4): the upstream `save_memory` schema only accepts
|
||||
* `{ content, importance, source, workspace }` — `scope`, `parent`, and
|
||||
* `metadata` from HookFrame are not part of that schema. We preserve them
|
||||
* as a tagged content prefix (`[session:X parent:Y src:claude-code event:Z]`)
|
||||
* so a future search/recall can still attribute frames even though the
|
||||
* upstream search index treats the prefix as plain text.
|
||||
*/
|
||||
|
||||
import type { HookEvent, ShimSource } from './hook-event-types.js';
|
||||
import { classifyImportance, type Importance } from './importance-classifier.js';
|
||||
|
||||
/**
|
||||
* Provenance enum required by the upstream `save_memory.source` field.
|
||||
* Distinct from `HookFrame.source` (which is the IDE name like
|
||||
* 'claude-code'). Hook captures default to `'system'`.
|
||||
*/
|
||||
export type SaveMemorySource = 'user_stated' | 'tool_verified' | 'agent_inferred' | 'system';
|
||||
|
||||
export interface HookFrame {
|
||||
content: string;
|
||||
importance: Importance;
|
||||
scope: string;
|
||||
source: ShimSource;
|
||||
parent?: string;
|
||||
metadata: HookFrameMetadata;
|
||||
}
|
||||
|
||||
export interface HookFrameMetadata {
|
||||
cwd: string;
|
||||
timestamp_iso: string;
|
||||
event_type?: string;
|
||||
project?: string;
|
||||
target_version?: string;
|
||||
}
|
||||
|
||||
export interface EncodeOptions {
|
||||
/** Override the auto-classified importance. */
|
||||
importance?: Importance;
|
||||
/** Override scope (defaults to payload.session_id or 'default'). */
|
||||
scope?: string;
|
||||
/** Parent frame id (links responses to prompts). */
|
||||
parent?: string;
|
||||
/** Override extracted content (defaults to first non-empty payload field). */
|
||||
content?: string;
|
||||
}
|
||||
|
||||
const CONTENT_KEYS = ['content', 'text', 'prompt', 'response', 'message'] as const;
|
||||
const SCOPE_KEYS = ['session_id', 'sessionId', 'session', 'scope'] as const;
|
||||
const PROJECT_KEYS = ['project', 'workspace', 'project_name'] as const;
|
||||
const VERSION_KEYS = ['target_version', 'targetVersion', 'version'] as const;
|
||||
|
||||
function pickString(payload: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const v = payload[key];
|
||||
if (typeof v === 'string' && v.length > 0) return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function encodeFrame(event: HookEvent, opts: EncodeOptions = {}): HookFrame {
|
||||
const content = opts.content ?? pickString(event.payload, CONTENT_KEYS) ?? '';
|
||||
const importance = opts.importance
|
||||
?? classifyImportance(content, { eventType: event.eventType, source: event.source });
|
||||
const scope = opts.scope ?? pickString(event.payload, SCOPE_KEYS) ?? 'default';
|
||||
const project = pickString(event.payload, PROJECT_KEYS);
|
||||
const target_version = pickString(event.payload, VERSION_KEYS);
|
||||
|
||||
const metadata: HookFrameMetadata = {
|
||||
cwd: event.cwd,
|
||||
timestamp_iso: event.timestamp_iso,
|
||||
event_type: event.eventType,
|
||||
};
|
||||
if (project !== undefined) metadata.project = project;
|
||||
if (target_version !== undefined) metadata.target_version = target_version;
|
||||
|
||||
const frame: HookFrame = {
|
||||
content,
|
||||
importance,
|
||||
scope,
|
||||
source: event.source,
|
||||
metadata,
|
||||
};
|
||||
if (opts.parent !== undefined) frame.parent = opts.parent;
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lossy projection of HookFrame onto upstream save_memory's schema.
|
||||
* Fields that aren't in the schema (scope, parent, IDE source, event_type)
|
||||
* are tagged into a content prefix so they survive as searchable text.
|
||||
*/
|
||||
export interface SavePayload {
|
||||
content: string;
|
||||
importance: Importance;
|
||||
source: SaveMemorySource;
|
||||
}
|
||||
|
||||
const SAVE_MEMORY_DEFAULT_SOURCE: SaveMemorySource = 'system';
|
||||
|
||||
function buildPrefix(frame: HookFrame): string {
|
||||
const tokens: string[] = [];
|
||||
if (frame.scope && frame.scope !== 'default') tokens.push(`session:${frame.scope}`);
|
||||
if (frame.parent !== undefined) tokens.push(`parent:${frame.parent}`);
|
||||
tokens.push(`src:${frame.source}`);
|
||||
if (frame.metadata.event_type) tokens.push(`event:${frame.metadata.event_type}`);
|
||||
if (frame.metadata.project) tokens.push(`project:${frame.metadata.project}`);
|
||||
return tokens.length > 0 ? `[hm ${tokens.join(' ')}] ` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a HookFrame for the save_memory wire. The IDE-provenance
|
||||
* `frame.source` (e.g. 'claude-code') gets embedded in the content
|
||||
* prefix, while the MCP-level `source` field is set to the provenance
|
||||
* enum value passed in opts (default 'system' — hooks aren't user-stated
|
||||
* or agent-inferred; they're system-captured).
|
||||
*/
|
||||
export function frameToSavePayload(
|
||||
frame: HookFrame,
|
||||
opts: { mcpSource?: SaveMemorySource } = {},
|
||||
): SavePayload {
|
||||
const prefix = buildPrefix(frame);
|
||||
return {
|
||||
content: prefix + frame.content,
|
||||
importance: frame.importance,
|
||||
source: opts.mcpSource ?? SAVE_MEMORY_DEFAULT_SOURCE,
|
||||
};
|
||||
}
|
||||
61
packages/hive-mind-shim-core/src/hook-event-types.ts
Normal file
61
packages/hive-mind-shim-core/src/hook-event-types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Canonical hook event surface — shared by every per-IDE shim.
|
||||
*
|
||||
* Each shim translates its IDE's native event into one of these types
|
||||
* before handing off to shim-core encoders/bridges. This is the
|
||||
* contract that keeps the rest of shim-core IDE-agnostic.
|
||||
*/
|
||||
|
||||
export type ShimSource =
|
||||
| 'claude-code'
|
||||
| 'cursor'
|
||||
| 'hermes'
|
||||
| 'codex'
|
||||
| 'opencode'
|
||||
| 'openclaw';
|
||||
|
||||
export type EventType =
|
||||
| 'session-start'
|
||||
| 'session-end'
|
||||
| 'user-prompt-submit'
|
||||
| 'pre-compact'
|
||||
| 'stop'
|
||||
| 'pre-tool-use'
|
||||
| 'post-tool-use';
|
||||
|
||||
export interface HookEvent {
|
||||
eventType: EventType;
|
||||
source: ShimSource;
|
||||
cwd: string;
|
||||
timestamp_iso: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const ALL_EVENT_TYPES: readonly EventType[] = [
|
||||
'session-start',
|
||||
'session-end',
|
||||
'user-prompt-submit',
|
||||
'pre-compact',
|
||||
'stop',
|
||||
'pre-tool-use',
|
||||
'post-tool-use',
|
||||
] as const;
|
||||
|
||||
export const ALL_SOURCES: readonly ShimSource[] = [
|
||||
'claude-code',
|
||||
'cursor',
|
||||
'hermes',
|
||||
'codex',
|
||||
'opencode',
|
||||
'openclaw',
|
||||
] as const;
|
||||
|
||||
export function isEventType(value: unknown): value is EventType {
|
||||
return typeof value === 'string'
|
||||
&& (ALL_EVENT_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isShimSource(value: unknown): value is ShimSource {
|
||||
return typeof value === 'string'
|
||||
&& (ALL_SOURCES as readonly string[]).includes(value);
|
||||
}
|
||||
99
packages/hive-mind-shim-core/src/importance-classifier.ts
Normal file
99
packages/hive-mind-shim-core/src/importance-classifier.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Rule-based importance classifier. Pure-functional, no LLM calls.
|
||||
*
|
||||
* Four importance levels matching hive-mind core's Importance type:
|
||||
* - temporary: ephemeral chatter explicitly marked for fast decay
|
||||
* - normal: default for substantive content with no special signal
|
||||
* (this is the upstream MCP default — was 'temporary' in
|
||||
* pre-1.4 shim, raised to 'normal' in Commit 1.4 to align
|
||||
* with the upstream save_memory schema)
|
||||
* - important: turns containing decisions / failures / actions
|
||||
* - critical: user-facing rules / preferences / "always"/"never"
|
||||
* directives, retained indefinitely
|
||||
*
|
||||
* Higher importance wins when multiple rules match.
|
||||
*/
|
||||
|
||||
export type Importance = 'temporary' | 'normal' | 'important' | 'critical';
|
||||
|
||||
export interface ImportanceRule {
|
||||
pattern: RegExp | string;
|
||||
importance: Importance;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ClassifyContext {
|
||||
eventType?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const TIER: Record<Importance, number> = {
|
||||
temporary: 0,
|
||||
normal: 1,
|
||||
important: 2,
|
||||
critical: 3,
|
||||
};
|
||||
|
||||
const CRITICAL_PATTERNS: readonly ImportanceRule[] = [
|
||||
{ pattern: /\balways\b/i, importance: 'critical', reason: 'directive: always' },
|
||||
{ pattern: /\bnever\b/i, importance: 'critical', reason: 'directive: never' },
|
||||
{ pattern: /\bMEMORY\.md\b/i, importance: 'critical', reason: 'memory rule reference' },
|
||||
{ pattern: /\bCLAUDE\.md\b/i, importance: 'critical', reason: 'rules document reference' },
|
||||
{ pattern: /\b(my preference|i prefer|please always|please never)\b/i, importance: 'critical', reason: 'user preference' },
|
||||
{ pattern: /\b(do not|don'?t)\s+(use|do|run|invoke|call)\b/i, importance: 'critical', reason: 'prohibition' },
|
||||
];
|
||||
|
||||
const IMPORTANT_PATTERNS: readonly ImportanceRule[] = [
|
||||
{ pattern: /\b(decided|decision|conclusion|resolved|chose)\b/i, importance: 'important', reason: 'decision statement' },
|
||||
{ pattern: /\b(implement|fix|refactor|migrate|deploy|ship)\b/i, importance: 'important', reason: 'action verb' },
|
||||
{ pattern: /\b(error|bug|issue|broke|broken|fails?|failed)\b/i, importance: 'important', reason: 'failure signal' },
|
||||
{ pattern: /\bTODO\b|\bFIXME\b/, importance: 'important', reason: 'work marker' },
|
||||
];
|
||||
|
||||
export const DEFAULT_RULES: readonly ImportanceRule[] = [
|
||||
...CRITICAL_PATTERNS,
|
||||
...IMPORTANT_PATTERNS,
|
||||
];
|
||||
|
||||
function compilePattern(p: RegExp | string): RegExp {
|
||||
return p instanceof RegExp ? p : new RegExp(p);
|
||||
}
|
||||
|
||||
function applyRules(content: string, rules: readonly ImportanceRule[], floor: Importance): Importance {
|
||||
let best: Importance = floor;
|
||||
for (const rule of rules) {
|
||||
const re = compilePattern(rule.pattern);
|
||||
if (re.test(content) && TIER[rule.importance] > TIER[best]) {
|
||||
best = rule.importance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function classifyImportance(
|
||||
content: string,
|
||||
context: ClassifyContext = {},
|
||||
): Importance {
|
||||
if (!content || content.trim().length === 0) {
|
||||
return 'temporary';
|
||||
}
|
||||
|
||||
// Session boundaries always retain at least 'important' importance:
|
||||
// start/end establish project context that we don't want to decay.
|
||||
// Everything else floors at 'normal' to align with the upstream
|
||||
// save_memory default and avoid silent decay of substantive turns.
|
||||
const floor: Importance = (context.eventType === 'session-start' || context.eventType === 'session-end')
|
||||
? 'important'
|
||||
: 'normal';
|
||||
|
||||
return applyRules(content, DEFAULT_RULES, floor);
|
||||
}
|
||||
|
||||
export function classifyWithRules(
|
||||
content: string,
|
||||
rules: readonly ImportanceRule[],
|
||||
fallback: Importance = 'normal',
|
||||
): Importance {
|
||||
if (!content || content.trim().length === 0) return fallback;
|
||||
return applyRules(content, rules, fallback);
|
||||
}
|
||||
81
packages/hive-mind-shim-core/src/index.ts
Normal file
81
packages/hive-mind-shim-core/src/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @hive-mind/shim-core — barrel export.
|
||||
*
|
||||
* Foundation utilities for the cross-IDE silent-capture shim portfolio.
|
||||
*/
|
||||
|
||||
export type {
|
||||
HookEvent,
|
||||
EventType,
|
||||
ShimSource,
|
||||
} from './hook-event-types.js';
|
||||
export {
|
||||
ALL_EVENT_TYPES,
|
||||
ALL_SOURCES,
|
||||
isEventType,
|
||||
isShimSource,
|
||||
} from './hook-event-types.js';
|
||||
|
||||
export type {
|
||||
HookFrame,
|
||||
HookFrameMetadata,
|
||||
EncodeOptions,
|
||||
SavePayload,
|
||||
SaveMemorySource,
|
||||
} from './frame-encoder.js';
|
||||
export { encodeFrame, frameToSavePayload } from './frame-encoder.js';
|
||||
|
||||
export type {
|
||||
Workspace,
|
||||
WorkspaceMode,
|
||||
ResolveOptions,
|
||||
} from './workspace-resolver.js';
|
||||
export { resolveWorkspace, isAbsoluteWorkspacePath } from './workspace-resolver.js';
|
||||
|
||||
export type {
|
||||
CliBridge,
|
||||
CliBridgeOptions,
|
||||
McpCallResult,
|
||||
McpCallResultContent,
|
||||
SaveMemoryResult,
|
||||
MemoryHit,
|
||||
RecallMemoryOptions,
|
||||
CleanupFramesOptions,
|
||||
CleanupMode,
|
||||
CallMcpOptions,
|
||||
SpawnFn,
|
||||
} from './cli-bridge.js';
|
||||
export { createCliBridge } from './cli-bridge.js';
|
||||
export type { RecallContextOptions } from './context-recall.js';
|
||||
export { recallPersonalAndWorkspace } from './context-recall.js';
|
||||
|
||||
export type {
|
||||
Importance,
|
||||
ImportanceRule,
|
||||
ClassifyContext,
|
||||
} from './importance-classifier.js';
|
||||
export {
|
||||
classifyImportance,
|
||||
classifyWithRules,
|
||||
DEFAULT_RULES,
|
||||
} from './importance-classifier.js';
|
||||
|
||||
export type { SummarizeOptions } from './prompt-summarizer.js';
|
||||
export { summarizeTurn } from './prompt-summarizer.js';
|
||||
|
||||
export type { RetryOptions } from './retry-bridge.js';
|
||||
export { withRetry, computeBackoff } from './retry-bridge.js';
|
||||
|
||||
export type { Logger, LogLevel, CreateLoggerOptions } from './logger.js';
|
||||
export { createLogger } from './logger.js';
|
||||
|
||||
export type {
|
||||
EmitSignalOptions,
|
||||
EmittedSignal,
|
||||
SignalSubtype,
|
||||
SignalType,
|
||||
} from './signal-emitter.js';
|
||||
export {
|
||||
emitSignalToWaggleDance,
|
||||
maybeEmitDiscovery,
|
||||
} from './signal-emitter.js';
|
||||
76
packages/hive-mind-shim-core/src/logger.ts
Normal file
76
packages/hive-mind-shim-core/src/logger.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Zero-dependency structured logger. Emits one JSON object per line on
|
||||
* stderr (so hook scripts can keep stdout clean for return values).
|
||||
*
|
||||
* Levels: debug=10, info=20, warn=30, error=40. Default level is 'info';
|
||||
* override via constructor opts.level or env HIVE_MIND_SHIM_LOG_LEVEL.
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function isLogLevel(value: string): value is LogLevel {
|
||||
return value === 'debug' || value === 'info' || value === 'warn' || value === 'error';
|
||||
}
|
||||
|
||||
function parseLevel(value: string | undefined): LogLevel | undefined {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.toLowerCase();
|
||||
return isLogLevel(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(msg: string, meta?: Record<string, unknown>): void;
|
||||
info(msg: string, meta?: Record<string, unknown>): void;
|
||||
warn(msg: string, meta?: Record<string, unknown>): void;
|
||||
error(msg: string, meta?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface CreateLoggerOptions {
|
||||
/** Optional component name attached to every log line. */
|
||||
name?: string;
|
||||
/** Filter threshold; defaults to env HIVE_MIND_SHIM_LOG_LEVEL or 'info'. */
|
||||
level?: LogLevel;
|
||||
/** Custom write target for tests; defaults to process.stderr.write. */
|
||||
write?: (line: string) => void;
|
||||
/** Override clock for deterministic tests. */
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export function createLogger(opts: CreateLoggerOptions = {}): Logger {
|
||||
const name = opts.name ?? 'shim-core';
|
||||
const envLevel = parseLevel(process.env['HIVE_MIND_SHIM_LOG_LEVEL']);
|
||||
const level = opts.level ?? envLevel ?? 'info';
|
||||
const threshold = LEVEL_ORDER[level];
|
||||
const write = opts.write ?? ((line: string): void => { process.stderr.write(line); });
|
||||
const now = opts.now ?? ((): Date => new Date());
|
||||
|
||||
function emit(lvl: LogLevel, msg: string, meta?: Record<string, unknown>): void {
|
||||
if (LEVEL_ORDER[lvl] < threshold) return;
|
||||
const entry: Record<string, unknown> = {
|
||||
timestamp: now().toISOString(),
|
||||
level: lvl,
|
||||
name,
|
||||
msg,
|
||||
};
|
||||
if (meta) {
|
||||
for (const k of Object.keys(meta)) {
|
||||
entry[k] = meta[k];
|
||||
}
|
||||
}
|
||||
write(JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
return {
|
||||
debug: (msg, meta): void => emit('debug', msg, meta),
|
||||
info: (msg, meta): void => emit('info', msg, meta),
|
||||
warn: (msg, meta): void => emit('warn', msg, meta),
|
||||
error: (msg, meta): void => emit('error', msg, meta),
|
||||
};
|
||||
}
|
||||
56
packages/hive-mind-shim-core/src/prompt-summarizer.ts
Normal file
56
packages/hive-mind-shim-core/src/prompt-summarizer.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Deterministic extractive turn summarizer. Pure text reduction; NO LLM.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Replace fenced code blocks with the literal token "[code]" so a
|
||||
* large embedded snippet doesn't blow the budget.
|
||||
* 2. Collapse whitespace + newlines to a single space.
|
||||
* 3. Split on sentence boundaries.
|
||||
* 4. Take leading sentences until maxChars is consumed (with a
|
||||
* one-char reservation for the ellipsis).
|
||||
* 5. Append a single Unicode ellipsis if anything was dropped.
|
||||
*
|
||||
* This is intentionally NOT an LLM-grade summary. Its job is to make
|
||||
* Stop-hook traffic small enough for `save_memory` while still carrying
|
||||
* the leading sentence verbatim — the highest-signal part of any turn.
|
||||
*/
|
||||
|
||||
export interface SummarizeOptions {
|
||||
/** Maximum output length in characters. Defaults to 500. */
|
||||
maxChars?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_CHARS = 500;
|
||||
const ELLIPSIS = '…';
|
||||
const SENTENCE_BOUNDARY = /(?<=[.!?])\s+(?=[A-Z([])/g;
|
||||
|
||||
export function summarizeTurn(content: string, opts: SummarizeOptions = {}): string {
|
||||
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
|
||||
if (!content || content.trim().length === 0) return '';
|
||||
|
||||
const collapsed = content
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/```[\s\S]*?```/g, '[code]')
|
||||
.replace(/\n+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (collapsed.length <= maxChars) return collapsed;
|
||||
|
||||
const sentences = collapsed.split(SENTENCE_BOUNDARY);
|
||||
const parts: string[] = [];
|
||||
let used = 0;
|
||||
for (const s of sentences) {
|
||||
const sep = parts.length === 0 ? 0 : 1;
|
||||
if (used + s.length + sep > maxChars - 1) break;
|
||||
parts.push(s);
|
||||
used += s.length + sep;
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return collapsed.slice(0, Math.max(0, maxChars - 1)) + ELLIPSIS;
|
||||
}
|
||||
|
||||
const out = parts.join(' ');
|
||||
return out.length < collapsed.length ? out + ELLIPSIS : out;
|
||||
}
|
||||
88
packages/hive-mind-shim-core/src/retry-bridge.ts
Normal file
88
packages/hive-mind-shim-core/src/retry-bridge.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Exponential-backoff retry with jitter and per-attempt timeout.
|
||||
*
|
||||
* Each attempt is wrapped in a Promise.race against a timeout. On
|
||||
* failure, the helper sleeps backoff = clamp(base * 2^attempt, max)
|
||||
* +/- jitterFactor * exp before retrying. Total attempts capped at
|
||||
* (maxRetries + 1): one initial + N retries.
|
||||
*/
|
||||
|
||||
export interface RetryOptions {
|
||||
/** Number of retries after the initial attempt. Default 3 (so 4 attempts total). */
|
||||
maxRetries?: number;
|
||||
/** Base backoff in ms. Default 200. */
|
||||
baseBackoffMs?: number;
|
||||
/** Cap on per-attempt backoff. Default 5000. */
|
||||
maxBackoffMs?: number;
|
||||
/** Jitter as a fraction of the computed backoff (+/-). Default 0.25. */
|
||||
jitterFactor?: number;
|
||||
/** Per-attempt timeout in ms. Default 5000. */
|
||||
timeoutMs?: number;
|
||||
/** Test hook — overrides setTimeout-based delay. */
|
||||
delay?: (ms: number) => Promise<void>;
|
||||
/** Test hook — overrides Math.random for deterministic jitter. */
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<Pick<
|
||||
RetryOptions,
|
||||
'maxRetries' | 'baseBackoffMs' | 'maxBackoffMs' | 'jitterFactor' | 'timeoutMs'
|
||||
>> = {
|
||||
maxRetries: 3,
|
||||
baseBackoffMs: 200,
|
||||
maxBackoffMs: 5000,
|
||||
jitterFactor: 0.25,
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
function defaultDelay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`operation timed out after ${ms}ms`));
|
||||
}, ms);
|
||||
promise.then(
|
||||
(value) => { clearTimeout(timer); resolve(value); },
|
||||
(err) => { clearTimeout(timer); reject(err instanceof Error ? err : new Error(String(err))); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function computeBackoff(
|
||||
attempt: number,
|
||||
baseMs: number,
|
||||
maxMs: number,
|
||||
jitterFactor: number,
|
||||
random: () => number = Math.random,
|
||||
): number {
|
||||
const exp = Math.min(baseMs * Math.pow(2, attempt), maxMs);
|
||||
// random() in [0,1); shift to [-1,1) for symmetric jitter.
|
||||
const jitter = exp * jitterFactor * (random() * 2 - 1);
|
||||
return Math.max(0, Math.round(exp + jitter));
|
||||
}
|
||||
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
opts: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const cfg = { ...DEFAULTS, ...opts };
|
||||
const delay = opts.delay ?? defaultDelay;
|
||||
const random = opts.random ?? Math.random;
|
||||
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= cfg.maxRetries; attempt += 1) {
|
||||
try {
|
||||
return await withTimeout(fn(), cfg.timeoutMs);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt === cfg.maxRetries) break;
|
||||
const backoff = computeBackoff(attempt, cfg.baseBackoffMs, cfg.maxBackoffMs, cfg.jitterFactor, random);
|
||||
await delay(backoff);
|
||||
}
|
||||
}
|
||||
if (lastErr instanceof Error) throw lastErr;
|
||||
throw new Error(typeof lastErr === 'string' ? lastErr : 'retry failed without error message');
|
||||
}
|
||||
222
packages/hive-mind-shim-core/src/signal-emitter.ts
Normal file
222
packages/hive-mind-shim-core/src/signal-emitter.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* AI-OS Phase 1D — Signal emitter shim.
|
||||
*
|
||||
* Hook packages (claude-code / cursor / claude-desktop / …) call
|
||||
* `emitSignalToWaggleDance()` to push a WaggleDance v2 signal into
|
||||
* the user's local Waggle sidecar. The sidecar persists the signal
|
||||
* to its in-memory bus and re-emits it through the legacy UI stream
|
||||
* so the WaggleDanceApp shows cross-tool activity in real time.
|
||||
*
|
||||
* Design contract:
|
||||
*
|
||||
* 1. **Fail open.** The hook chain must never block on a sidecar
|
||||
* that's not running. Any error (ENOTFOUND, ECONNREFUSED,
|
||||
* non-2xx, JSON parse) returns null and logs a single warning
|
||||
* to stderr. The host AI tool (Claude Code, Cursor) never sees
|
||||
* a hook failure.
|
||||
*
|
||||
* 2. **No external deps.** This module uses only Node 20's built-in
|
||||
* `fetch` — no node-fetch / undici install. Hook packages run
|
||||
* in the user's environment with whatever lockfile they have;
|
||||
* we minimize footprint.
|
||||
*
|
||||
* 3. **Timeout-bounded.** A 2-second default timeout protects
|
||||
* against a hung sidecar. The hook chain has its own outer
|
||||
* timeout; this is defense in depth.
|
||||
*
|
||||
* 4. **Opt-in by default.** The emitter is a library function
|
||||
* (not a side effect on import). Hooks must call it explicitly.
|
||||
* Phase 1E wires the claude-code Stop hook to invoke this when
|
||||
* the importance classifier returns 'high' or 'critical'.
|
||||
*
|
||||
* URL resolution:
|
||||
*
|
||||
* options.url > env.WAGGLE_SIDECAR_URL > http://127.0.0.1:3333
|
||||
*
|
||||
* The default port matches packages/launcher/src/cli.ts.
|
||||
*/
|
||||
|
||||
import type { EventType } from './hook-event-types.js';
|
||||
|
||||
/** Allowed v2 subtypes — matches the protocol enum on the server. */
|
||||
export type SignalSubtype =
|
||||
| 'knowledge_check'
|
||||
| 'task_delegation'
|
||||
| 'skill_request'
|
||||
| 'model_recommendation'
|
||||
| 'knowledge_match'
|
||||
| 'task_claim'
|
||||
| 'discovery'
|
||||
| 'routed_share'
|
||||
| 'skill_share'
|
||||
| 'model_recipe';
|
||||
|
||||
/** Allowed types — matches the protocol enum. */
|
||||
export type SignalType = 'broadcast' | 'request' | 'response';
|
||||
|
||||
export interface EmitSignalOptions {
|
||||
/** Top-level Waggle Dance protocol type. */
|
||||
type: SignalType;
|
||||
/** Protocol subtype. Must be valid for the chosen type. */
|
||||
subtype: SignalSubtype;
|
||||
/** Free-form structured content. The server preserves this verbatim. */
|
||||
content: Record<string, unknown>;
|
||||
/** Logical sender ('claude-code-hook', 'cursor-hook', …). Defaults to 'hook'. */
|
||||
senderId?: string;
|
||||
/** Optional team id. Defaults server-side to `personal::<senderId>`. */
|
||||
teamId?: string;
|
||||
/** Optional reference to a prior message (for responses). */
|
||||
referenceId?: string;
|
||||
/** Optional routing list (for routed_share). */
|
||||
routing?: Array<{ userId: string; reason: string }>;
|
||||
/**
|
||||
* Override the sidecar URL. Resolution order:
|
||||
* options.url > env.WAGGLE_SIDECAR_URL > http://127.0.0.1:3333
|
||||
*/
|
||||
url?: string;
|
||||
/** Narrow collaboration credential. Defaults to env.WAGGLE_RUN_TOKEN. */
|
||||
runToken?: string;
|
||||
/** Override the request timeout (default 2000ms). */
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Test hook — injects a custom fetch implementation. Production
|
||||
* uses Node 20's global fetch.
|
||||
*/
|
||||
fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* Test hook — captures stderr lines instead of writing to
|
||||
* process.stderr. Returns the emitted message.
|
||||
*/
|
||||
onWarn?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface EmittedSignal {
|
||||
/** Server-assigned message id (UUID). */
|
||||
id: string;
|
||||
/** Resolved team id (`personal::<senderId>` if not provided). */
|
||||
teamId: string;
|
||||
/** Resolved sender id. */
|
||||
senderId: string;
|
||||
/** Echoed type. */
|
||||
type: SignalType;
|
||||
/** Echoed subtype. */
|
||||
subtype: SignalSubtype;
|
||||
/** Echoed content. */
|
||||
content: Record<string, unknown>;
|
||||
/** Echoed referenceId. */
|
||||
referenceId: string | null;
|
||||
/** Echoed routing. */
|
||||
routing: Array<{ userId: string; reason: string }> | null;
|
||||
/** Server-assigned ISO timestamp. */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function resolveUrl(opts: EmitSignalOptions): string {
|
||||
if (opts.url) return opts.url;
|
||||
const fromEnv = typeof process !== 'undefined' && process.env
|
||||
? process.env.WAGGLE_SIDECAR_URL
|
||||
: undefined;
|
||||
return fromEnv && fromEnv.length > 0 ? fromEnv : 'http://127.0.0.1:3333';
|
||||
}
|
||||
|
||||
function resolveRunToken(opts: EmitSignalOptions): string | undefined {
|
||||
const value = opts.runToken ?? (
|
||||
typeof process !== 'undefined' && process.env ? process.env.WAGGLE_RUN_TOKEN : undefined
|
||||
);
|
||||
if (!value || value.length < 32 || value.length > 200 || /[\r\n]/.test(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function warn(opts: EmitSignalOptions, message: string): void {
|
||||
if (opts.onWarn) {
|
||||
opts.onWarn(message);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.stderr.write(`[waggle-signal-emitter] ${message}\n`);
|
||||
} catch {
|
||||
/* nothing we can do; bail */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a v2 signal to the local Waggle sidecar.
|
||||
*
|
||||
* Returns the server-persisted signal on success, or null on any
|
||||
* failure (network, timeout, non-2xx, parse). Always fail open —
|
||||
* the host AI tool's hook chain must continue.
|
||||
*/
|
||||
export async function emitSignalToWaggleDance(
|
||||
opts: EmitSignalOptions,
|
||||
): Promise<EmittedSignal | null> {
|
||||
const url = resolveUrl(opts);
|
||||
const timeoutMs = opts.timeoutMs ?? 2000;
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const runToken = resolveRunToken(opts);
|
||||
|
||||
const body = {
|
||||
type: opts.type,
|
||||
subtype: opts.subtype,
|
||||
content: opts.content,
|
||||
senderId: opts.senderId ?? 'hook',
|
||||
...(opts.teamId !== undefined ? { teamId: opts.teamId } : {}),
|
||||
...(opts.referenceId !== undefined ? { referenceId: opts.referenceId } : {}),
|
||||
...(opts.routing !== undefined ? { routing: opts.routing } : {}),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetchFn(`${url}/api/waggle-dance/signal`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(runToken ? { 'x-waggle-run-token': runToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
warn(opts, `non-2xx response (${res.status}) — signal dropped`);
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as { message?: unknown };
|
||||
if (!json || typeof json !== 'object' || !('message' in json) || !json.message) {
|
||||
warn(opts, 'malformed response body — signal dropped');
|
||||
return null;
|
||||
}
|
||||
return json.message as EmittedSignal;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
warn(opts, `sidecar unreachable (${reason}) — signal dropped`);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: classify a hook lifecycle event and decide whether
|
||||
* to emit a discovery signal. Returns the emitted signal or null
|
||||
* (no emission necessary, or emission failed).
|
||||
*
|
||||
* The default policy is: emit on `stop` and `pre-compact` if
|
||||
* `importance` is 'high' or 'critical'. Hook packages override
|
||||
* with their own policy when they need to emit other subtypes.
|
||||
*/
|
||||
export async function maybeEmitDiscovery(
|
||||
eventType: EventType,
|
||||
importance: 'low' | 'normal' | 'high' | 'critical',
|
||||
payload: Record<string, unknown>,
|
||||
opts: Omit<EmitSignalOptions, 'type' | 'subtype' | 'content'>,
|
||||
): Promise<EmittedSignal | null> {
|
||||
if (importance !== 'high' && importance !== 'critical') return null;
|
||||
if (eventType !== 'stop' && eventType !== 'pre-compact') return null;
|
||||
return emitSignalToWaggleDance({
|
||||
...opts,
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { ...payload, eventType, importance },
|
||||
});
|
||||
}
|
||||
85
packages/hive-mind-shim-core/src/workspace-resolver.ts
Normal file
85
packages/hive-mind-shim-core/src/workspace-resolver.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Resolve which .mind file a hook event should write to.
|
||||
*
|
||||
* Resolution rules:
|
||||
* 1. If `<cwd>/.hive-mind/workspace.mind` exists, return per-project
|
||||
* mode using that file.
|
||||
* 2. Else, walk up looking for `.hive-mind/workspace.mind` (project
|
||||
* root marker).
|
||||
* 3. Else, fall back to global mode at `~/.hive-mind/global.mind`.
|
||||
*
|
||||
* The candidate path is constructed from controlled inputs (resolved
|
||||
* cwd + hardcoded `.hive-mind/workspace.mind`), so there is no path-
|
||||
* injection vector through this function. Defense-in-depth against
|
||||
* malicious symlinks pointing outside the workspace is the caller's
|
||||
* responsibility (e.g. via fs.realpath if the threat model requires).
|
||||
*/
|
||||
|
||||
import { access, constants } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, resolve as pathResolve } from 'node:path';
|
||||
|
||||
export type WorkspaceMode = 'global' | 'per-project';
|
||||
|
||||
export interface Workspace {
|
||||
path: string;
|
||||
cwd: string;
|
||||
mode: WorkspaceMode;
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Override $HOME for tests. */
|
||||
home?: string;
|
||||
/** Custom existence probe; defaults to fs.access. */
|
||||
exists?: (p: string) => Promise<boolean>;
|
||||
/** Whether to walk up the directory tree to find a project marker. Default true. */
|
||||
walkUp?: boolean;
|
||||
}
|
||||
|
||||
const HIVE_DIR = '.hive-mind';
|
||||
const PROJECT_DB = 'workspace.mind';
|
||||
const GLOBAL_DB = 'global.mind';
|
||||
|
||||
async function defaultExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await access(p, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWorkspace(
|
||||
cwd?: string,
|
||||
opts: ResolveOptions = {},
|
||||
): Promise<Workspace> {
|
||||
const startCwd = pathResolve(cwd ?? process.cwd());
|
||||
const home = opts.home ?? homedir();
|
||||
const exists = opts.exists ?? defaultExists;
|
||||
const walkUp = opts.walkUp ?? true;
|
||||
|
||||
if (walkUp) {
|
||||
let dir = startCwd;
|
||||
while (true) {
|
||||
const candidate = join(dir, HIVE_DIR, PROJECT_DB);
|
||||
if (await exists(candidate)) {
|
||||
return { path: candidate, cwd: startCwd, mode: 'per-project' };
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
} else {
|
||||
const candidate = join(startCwd, HIVE_DIR, PROJECT_DB);
|
||||
if (await exists(candidate)) {
|
||||
return { path: candidate, cwd: startCwd, mode: 'per-project' };
|
||||
}
|
||||
}
|
||||
|
||||
const globalPath = join(home, HIVE_DIR, GLOBAL_DB);
|
||||
return { path: globalPath, cwd: startCwd, mode: 'global' };
|
||||
}
|
||||
|
||||
export function isAbsoluteWorkspacePath(p: string): boolean {
|
||||
return isAbsolute(p);
|
||||
}
|
||||
Reference in New Issue
Block a user