moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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;
}

View 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;
}

View 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,
};
}

View 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);
}

View 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);
}

View 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';

View 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),
};
}

View 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;
}

View 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');
}

View 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 },
});
}

View 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);
}