This commit is contained in:
19
packages/hive-mind-shim-core/LICENSE
Normal file
19
packages/hive-mind-shim-core/LICENSE
Normal file
@@ -0,0 +1,19 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright 2026 Egzakta Group d.o.o. · waggle-os.ai
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
61
packages/hive-mind-shim-core/README.md
Normal file
61
packages/hive-mind-shim-core/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# @hive-mind/shim-core
|
||||
|
||||
Foundation utilities for the [hive-mind-clients](https://github.com/marolinik/hive-mind-clients) cross-IDE silent capture shim portfolio.
|
||||
|
||||
This package is shared by every per-IDE shim (`@hive-mind/claude-code-hooks`, `@hive-mind/cursor-hooks`, etc.). It exposes typed interfaces, deterministic helpers, and a single CLI bridge to the hive-mind core — so each shim only has to translate IDE-native hook events into a common shape, then hand off to shim-core.
|
||||
|
||||
**Status**: pre-1.0, Wave 1 in development.
|
||||
**Target**: Node >= 20, ESM-only.
|
||||
**Peer dependency**: hive-mind-cli >= 0.1.0 (installed via `npm install -g @hive-mind/cli` or a local link).
|
||||
|
||||
## What's in here
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `frame-encoder` | Translate `HookEvent` -> `HookFrame` (the I/P/B-style payload that ships to hive-mind). |
|
||||
| `workspace-resolver` | Pick the right `.mind` file: per-project marker if present, else `~/.hive-mind/global.mind`. |
|
||||
| `cli-bridge` | The single chokepoint. Spawns `hive-mind-cli mcp call <tool> --args <json> --json` and returns typed results. |
|
||||
| `hook-event-types` | Canonical `EventType`, `ShimSource`, `HookEvent` interfaces shared across shims. |
|
||||
| `importance-classifier` | Pure-functional rules that map content -> `temporary` / `important` / `critical`. |
|
||||
| `prompt-summarizer` | Deterministic extractive summarizer (no LLM call). Used by `Stop`-hook handlers to compress turns. |
|
||||
| `retry-bridge` | Exponential backoff with jitter and per-attempt timeout. Wraps every CLI call. |
|
||||
| `logger` | Zero-dep structured JSON logger. Writes to stderr so hook stdout stays clean. |
|
||||
|
||||
## Usage (from a shim)
|
||||
|
||||
```ts
|
||||
import {
|
||||
createCliBridge,
|
||||
encodeFrame,
|
||||
resolveWorkspace,
|
||||
type HookEvent,
|
||||
} from '@hive-mind/shim-core';
|
||||
|
||||
const bridge = createCliBridge();
|
||||
|
||||
const event: HookEvent = {
|
||||
eventType: 'user-prompt-submit',
|
||||
source: 'claude-code',
|
||||
cwd: process.cwd(),
|
||||
timestamp_iso: new Date().toISOString(),
|
||||
payload: { content: 'How do I X?', session_id: 'abc-123' },
|
||||
};
|
||||
|
||||
const workspace = await resolveWorkspace(event.cwd);
|
||||
await bridge.switchWorkspace(workspace.path);
|
||||
|
||||
const frame = encodeFrame(event);
|
||||
await bridge.saveMemory(frame);
|
||||
```
|
||||
|
||||
## Bridge architecture
|
||||
|
||||
Every operation goes through one transport: `hive-mind-cli mcp call <tool> --args <json> --json --timeout-ms N`. That's a deliberate chokepoint:
|
||||
|
||||
- New MCP tools added upstream are reachable immediately via `bridge.callMcpTool('<new_tool>', args)`.
|
||||
- Schema or arg changes in upstream MCP tools surface in one place, not 6 shims.
|
||||
- Failure modes (timeout, exit non-zero, malformed JSON, `isError=true`) are normalized into thrown `Error`s by the bridge.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [the repo root](https://github.com/marolinik/hive-mind-clients/blob/main/LICENSE) for the full text.
|
||||
46
packages/hive-mind-shim-core/package.json
Normal file
46
packages/hive-mind-shim-core/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@waggle/hive-mind-shim-core",
|
||||
"version": "0.1.0",
|
||||
"description": "Foundation utilities for hive-mind cross-IDE silent capture shims: frame encoding, workspace resolution, hive-mind-cli mcp call bridge, hook event types, importance classifier, deterministic prompt summarizer, retry-with-jitter, structured logger.",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"build:clean": "tsc --build --clean",
|
||||
"typecheck": "tsc --build && tsc --noEmit -p tsconfig.test.json",
|
||||
"test": "npm run build --workspace @waggle/hive-mind-cli && node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/hive-mind-shim-core/tests",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/marolinik/waggle-os.git",
|
||||
"directory": "packages/hive-mind-shim-core"
|
||||
},
|
||||
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-shim-core#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/marolinik/waggle-os/issues"
|
||||
},
|
||||
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
|
||||
"keywords": [
|
||||
"hive-mind",
|
||||
"memory",
|
||||
"ai",
|
||||
"mcp",
|
||||
"shim",
|
||||
"foundation"
|
||||
],
|
||||
"types": "dist/index.d.ts"
|
||||
}
|
||||
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);
|
||||
}
|
||||
292
packages/hive-mind-shim-core/tests/cli-bridge.test.ts
Normal file
292
packages/hive-mind-shim-core/tests/cli-bridge.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import { createCliBridge, type SpawnFn } from '../src/cli-bridge.js';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import type { HookFrame } from '../src/frame-encoder.js';
|
||||
|
||||
interface MockChildOptions {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number;
|
||||
emitError?: Error;
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
interface MockSpawnRecord {
|
||||
command: string;
|
||||
args: readonly string[];
|
||||
}
|
||||
|
||||
function mockChild(opts: MockChildOptions = {}): ChildProcess {
|
||||
const emitter = new EventEmitter();
|
||||
const stdout = Readable.from([Buffer.from(opts.stdout ?? '')]);
|
||||
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
|
||||
const child = Object.assign(emitter, {
|
||||
stdout,
|
||||
stderr,
|
||||
kill: vi.fn(() => true),
|
||||
}) as unknown as ChildProcess;
|
||||
|
||||
setImmediate(() => {
|
||||
if (opts.emitError) {
|
||||
emitter.emit('error', opts.emitError);
|
||||
return;
|
||||
}
|
||||
if (opts.delayMs && opts.delayMs > 0) {
|
||||
setTimeout(() => emitter.emit('exit', opts.exitCode ?? 0), opts.delayMs);
|
||||
return;
|
||||
}
|
||||
emitter.emit('exit', opts.exitCode ?? 0);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
function makeSpawnImpl(records: MockSpawnRecord[], childOpts: MockChildOptions): SpawnFn {
|
||||
return ((command, args) => {
|
||||
records.push({ command, args });
|
||||
return mockChild(childOpts);
|
||||
}) as SpawnFn;
|
||||
}
|
||||
|
||||
function jsonResultEnvelope(payload: unknown, opts: { isError?: boolean } = {}): string {
|
||||
const result = {
|
||||
ok: true,
|
||||
tool: 'test_tool',
|
||||
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
||||
isError: opts.isError ?? false,
|
||||
};
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
function plainTextEnvelope(text: string): string {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
tool: 'recall_memory',
|
||||
content: [{ type: 'text', text }],
|
||||
isError: false,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
const SAMPLE_FRAME: HookFrame = {
|
||||
content: 'hello world',
|
||||
importance: 'normal',
|
||||
scope: 'sess-7',
|
||||
source: 'claude-code',
|
||||
metadata: {
|
||||
cwd: '/proj',
|
||||
timestamp_iso: '2026-04-28T10:00:00.000Z',
|
||||
event_type: 'user-prompt-submit',
|
||||
},
|
||||
};
|
||||
|
||||
describe('createCliBridge.callMcpTool', () => {
|
||||
it('spawns hive-mind-cli with mcp call args and parses success', async () => {
|
||||
const records: MockSpawnRecord[] = [];
|
||||
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 1 }) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
|
||||
const result = await bridge.callMcpTool<{ id: number }>('save_memory', { content: 'x' });
|
||||
expect(result.id).toBe(1);
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].command).toBe('hive-mind-cli');
|
||||
expect(records[0].args).toEqual([
|
||||
'mcp', 'call', 'save_memory',
|
||||
'--args', JSON.stringify({ content: 'x' }),
|
||||
'--json',
|
||||
'--timeout-ms', '5000',
|
||||
]);
|
||||
});
|
||||
|
||||
it('honours custom cli_path', async () => {
|
||||
const records: MockSpawnRecord[] = [];
|
||||
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ ok: 1 }) });
|
||||
const bridge = createCliBridge({ spawnImpl, cli_path: '/usr/local/bin/hmc', max_retries: 0 });
|
||||
await bridge.callMcpTool('any_tool', {});
|
||||
expect(records[0].command).toBe('/usr/local/bin/hmc');
|
||||
});
|
||||
|
||||
it('throws when CLI exits non-zero', async () => {
|
||||
const spawnImpl = makeSpawnImpl([], {
|
||||
stdout: '',
|
||||
stderr: 'boom',
|
||||
exitCode: 2,
|
||||
});
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
await expect(bridge.callMcpTool('save_memory', {})).rejects.toThrow(/exited with code 2/);
|
||||
});
|
||||
|
||||
it('throws when stdout is malformed JSON', async () => {
|
||||
const spawnImpl = makeSpawnImpl([], { stdout: 'not-json{', exitCode: 0 });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
await expect(bridge.callMcpTool('any', {})).rejects.toThrow(/failed to parse CLI JSON output/);
|
||||
});
|
||||
|
||||
it('throws when result.ok is false', async () => {
|
||||
const stdout = JSON.stringify({ ok: false, tool: 'save_memory', error: 'tool missing' });
|
||||
const spawnImpl = makeSpawnImpl([], { stdout });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
await expect(bridge.callMcpTool('save_memory', {})).rejects.toThrow(/save_memory failed/);
|
||||
});
|
||||
|
||||
it('throws when result.isError is true (tool-reported error)', async () => {
|
||||
const stdout = JSON.stringify({
|
||||
ok: true,
|
||||
tool: 'recall_memory',
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'no such workspace' }],
|
||||
});
|
||||
const spawnImpl = makeSpawnImpl([], { stdout });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
await expect(bridge.callMcpTool('recall_memory', {})).rejects.toThrow(/no such workspace/);
|
||||
});
|
||||
|
||||
it('returns raw McpCallResult when content text is not JSON-parseable', async () => {
|
||||
const stdout = JSON.stringify({
|
||||
ok: true,
|
||||
tool: 'plain',
|
||||
content: [{ type: 'text', text: 'human-readable output' }],
|
||||
isError: false,
|
||||
});
|
||||
const spawnImpl = makeSpawnImpl([], { stdout });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
const result = await bridge.callMcpTool<{ ok: boolean; content?: unknown[] }>('plain', {});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCliBridge.saveMemory (Commit 1.4 wire format)', () => {
|
||||
it('passes only content + importance + source to save_memory; embeds scope/parent/source/event in content prefix', async () => {
|
||||
const records: MockSpawnRecord[] = [];
|
||||
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 9, workspace: 'personal' }) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
|
||||
const result = await bridge.saveMemory(SAMPLE_FRAME);
|
||||
expect(result).toEqual({ id: '9', success: true, workspace: 'personal' });
|
||||
|
||||
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
|
||||
expect(Object.keys(wireArgs).sort()).toEqual(['content', 'importance', 'source']);
|
||||
expect(wireArgs['source']).toBe('system');
|
||||
expect(wireArgs['importance']).toBe('normal');
|
||||
expect(wireArgs['content']).toContain('[hm session:sess-7 src:claude-code event:user-prompt-submit] ');
|
||||
expect(wireArgs['content']).toContain('hello world');
|
||||
});
|
||||
|
||||
it('includes workspace arg when active workspace id is set', async () => {
|
||||
const records: MockSpawnRecord[] = [];
|
||||
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 1, workspace: 'team-foo' }) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
bridge.setWorkspaceById('team-foo');
|
||||
|
||||
await bridge.saveMemory(SAMPLE_FRAME);
|
||||
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
|
||||
expect(wireArgs['workspace']).toBe('team-foo');
|
||||
});
|
||||
|
||||
it('per-call workspace override beats setWorkspaceById', async () => {
|
||||
const records: MockSpawnRecord[] = [];
|
||||
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 2 }) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
bridge.setWorkspaceById('default-ws');
|
||||
|
||||
await bridge.saveMemory(SAMPLE_FRAME, { workspace: 'override-ws' });
|
||||
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
|
||||
expect(wireArgs['workspace']).toBe('override-ws');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCliBridge.recallMemory', () => {
|
||||
it('returns MemoryHit[] when upstream replies with a JSON array', async () => {
|
||||
const hits = [{
|
||||
id: 1,
|
||||
content: 'past',
|
||||
importance: 'normal',
|
||||
source: 'system',
|
||||
score: 0.91,
|
||||
created_at: '2026-04-28T10:00:00.000Z',
|
||||
from: 'personal',
|
||||
}];
|
||||
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope(hits) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
const out = await bridge.recallMemory('past');
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].score).toBe(0.91);
|
||||
});
|
||||
|
||||
it('returns [] when upstream responds with the "No memories found" plain-text envelope', async () => {
|
||||
const spawnImpl = makeSpawnImpl([], { stdout: plainTextEnvelope('No memories found for query: "test"') });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
const out = await bridge.recallMemory('test');
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes query + limit + scope + profile through wire args', async () => {
|
||||
const records: MockSpawnRecord[] = [];
|
||||
const spawnImpl = makeSpawnImpl(records, { stdout: plainTextEnvelope('No memories found for query: "x"') });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
await bridge.recallMemory('x', { limit: 5, scope: 'all', profile: 'recent' });
|
||||
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
|
||||
expect(wireArgs).toMatchObject({ query: 'x', limit: 5, scope: 'all', profile: 'recent' });
|
||||
});
|
||||
|
||||
it('can force personal recall while a workspace is active', async () => {
|
||||
const records: MockSpawnRecord[] = [];
|
||||
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope([]) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0, initial_workspace_id: 'workspace-a' });
|
||||
await bridge.recallMemory('', { scope: 'personal', workspace: null });
|
||||
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
|
||||
expect(wireArgs).toEqual({ query: '', scope: 'personal' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCliBridge.cleanupFrames', () => {
|
||||
it('calls cleanup_frames and returns pruned count', async () => {
|
||||
const records: MockSpawnRecord[] = [];
|
||||
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ pruned: 7 }) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
const out = await bridge.cleanupFrames();
|
||||
expect(records[0].args[2]).toBe('cleanup_frames');
|
||||
expect(out.pruned).toBe(7);
|
||||
});
|
||||
|
||||
it('falls back to .deleted alias if upstream uses that field name', async () => {
|
||||
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({ deleted: 3 }) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
const out = await bridge.cleanupFrames();
|
||||
expect(out.pruned).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCliBridge workspace state', () => {
|
||||
it('setWorkspaceById + getActiveWorkspaceId round-trip', () => {
|
||||
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
expect(bridge.getActiveWorkspaceId()).toBeUndefined();
|
||||
bridge.setWorkspaceById('foo');
|
||||
expect(bridge.getActiveWorkspaceId()).toBe('foo');
|
||||
bridge.setWorkspaceById(undefined);
|
||||
expect(bridge.getActiveWorkspaceId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('initial_workspace_id seeds the active id', () => {
|
||||
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0, initial_workspace_id: 'startup-ws' });
|
||||
expect(bridge.getActiveWorkspaceId()).toBe('startup-ws');
|
||||
});
|
||||
|
||||
it('seeds the active workspace from WAGGLE_WORKSPACE_ID', () => {
|
||||
const previous = process.env.WAGGLE_WORKSPACE_ID;
|
||||
process.env.WAGGLE_WORKSPACE_ID = 'workspace-from-launch';
|
||||
try {
|
||||
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
|
||||
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
|
||||
expect(bridge.getActiveWorkspaceId()).toBe('workspace-from-launch');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.WAGGLE_WORKSPACE_ID;
|
||||
else process.env.WAGGLE_WORKSPACE_ID = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
42
packages/hive-mind-shim-core/tests/context-recall.test.ts
Normal file
42
packages/hive-mind-shim-core/tests/context-recall.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CliBridge, MemoryHit } from '../src/cli-bridge.js';
|
||||
import { recallPersonalAndWorkspace } from '../src/context-recall.js';
|
||||
|
||||
const personal: MemoryHit = {
|
||||
id: 1, content: 'personal preference', importance: 'important', source: 'system',
|
||||
score: 0.7, created_at: '2026-01-01T00:00:00.000Z', from: 'personal',
|
||||
};
|
||||
const workspace: MemoryHit = {
|
||||
id: 2, content: 'workspace decision', importance: 'important', source: 'system',
|
||||
score: 0.9, created_at: '2026-01-02T00:00:00.000Z', from: 'workspace:alpha',
|
||||
};
|
||||
|
||||
function bridge(activeWorkspaceId?: string): CliBridge & { recallMemory: ReturnType<typeof vi.fn> } {
|
||||
const recallMemory = vi.fn(async (_query: string, options?: { workspace?: string | null }) => {
|
||||
return options?.workspace === null ? [personal] : [workspace, { ...personal, id: 99 }];
|
||||
});
|
||||
return {
|
||||
recallMemory,
|
||||
getActiveWorkspaceId: () => activeWorkspaceId,
|
||||
} as unknown as CliBridge & { recallMemory: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
describe('recallPersonalAndWorkspace', () => {
|
||||
it('recalls only personal plus the selected workspace, ranks, and deduplicates', async () => {
|
||||
const value = bridge('alpha');
|
||||
const hits = await recallPersonalAndWorkspace(value, 'decision', { limit: 5 });
|
||||
expect(value.recallMemory.mock.calls).toEqual([
|
||||
['decision', { limit: 5, scope: 'personal', workspace: null }],
|
||||
['decision', { limit: 5, scope: 'current', workspace: 'alpha' }],
|
||||
]);
|
||||
expect(hits).toEqual([workspace, personal]);
|
||||
expect(value.recallMemory.mock.calls.flat().join(' ')).not.toContain("scope: 'all'");
|
||||
});
|
||||
|
||||
it('does not query another workspace when no workspace is active', async () => {
|
||||
const value = bridge();
|
||||
const hits = await recallPersonalAndWorkspace(value, '', { limit: 1 });
|
||||
expect(value.recallMemory).toHaveBeenCalledTimes(1);
|
||||
expect(hits).toEqual([personal]);
|
||||
});
|
||||
});
|
||||
143
packages/hive-mind-shim-core/tests/frame-encoder.test.ts
Normal file
143
packages/hive-mind-shim-core/tests/frame-encoder.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { encodeFrame, frameToSavePayload, type HookFrame } from '../src/frame-encoder.js';
|
||||
import type { HookEvent } from '../src/hook-event-types.js';
|
||||
|
||||
function event(overrides: Partial<HookEvent> = {}): HookEvent {
|
||||
return {
|
||||
eventType: 'user-prompt-submit',
|
||||
source: 'claude-code',
|
||||
cwd: '/proj/foo',
|
||||
timestamp_iso: '2026-04-28T10:00:00.000Z',
|
||||
payload: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('encodeFrame', () => {
|
||||
it('encodes a basic event with content from payload.content', () => {
|
||||
const frame = encodeFrame(event({ payload: { content: 'hi there' } }));
|
||||
expect(frame.content).toBe('hi there');
|
||||
expect(frame.source).toBe('claude-code');
|
||||
expect(frame.metadata.cwd).toBe('/proj/foo');
|
||||
expect(frame.metadata.event_type).toBe('user-prompt-submit');
|
||||
expect(frame.metadata.timestamp_iso).toBe('2026-04-28T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('falls back to payload.text / payload.prompt / payload.message', () => {
|
||||
expect(encodeFrame(event({ payload: { text: 't' } })).content).toBe('t');
|
||||
expect(encodeFrame(event({ payload: { prompt: 'p' } })).content).toBe('p');
|
||||
expect(encodeFrame(event({ payload: { message: 'm' } })).content).toBe('m');
|
||||
});
|
||||
|
||||
it('classifies importance from content by default', () => {
|
||||
expect(encodeFrame(event({ payload: { content: 'always run lint' } })).importance).toBe('critical');
|
||||
expect(encodeFrame(event({ payload: { content: 'we decided X' } })).importance).toBe('important');
|
||||
// Commit 1.4: substantive default raised from 'temporary' to 'normal'.
|
||||
expect(encodeFrame(event({ payload: { content: 'hello' } })).importance).toBe('normal');
|
||||
});
|
||||
|
||||
it('importance override beats classifier', () => {
|
||||
const frame = encodeFrame(event({ payload: { content: 'hello' } }), { importance: 'critical' });
|
||||
expect(frame.importance).toBe('critical');
|
||||
});
|
||||
|
||||
it('extracts scope from payload.session_id (or aliases)', () => {
|
||||
expect(encodeFrame(event({ payload: { session_id: 'abc' } })).scope).toBe('abc');
|
||||
expect(encodeFrame(event({ payload: { sessionId: 'def' } })).scope).toBe('def');
|
||||
});
|
||||
|
||||
it('falls back to scope="default" when no session id is present', () => {
|
||||
expect(encodeFrame(event({ payload: {} })).scope).toBe('default');
|
||||
});
|
||||
|
||||
it('scope option overrides payload-derived scope', () => {
|
||||
const frame = encodeFrame(event({ payload: { session_id: 'a' } }), { scope: 'b' });
|
||||
expect(frame.scope).toBe('b');
|
||||
});
|
||||
|
||||
it('includes optional metadata only when present', () => {
|
||||
const withProj = encodeFrame(event({ payload: { content: 'x', project: 'waggle' } }));
|
||||
expect(withProj.metadata.project).toBe('waggle');
|
||||
|
||||
const withoutProj = encodeFrame(event({ payload: { content: 'x' } }));
|
||||
expect(withoutProj.metadata.project).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attaches parent id when supplied', () => {
|
||||
const frame = encodeFrame(event({ payload: { content: 'x' } }), { parent: 'frame-7' });
|
||||
expect(frame.parent).toBe('frame-7');
|
||||
});
|
||||
|
||||
it('omits parent when not supplied', () => {
|
||||
const frame = encodeFrame(event({ payload: { content: 'x' } }));
|
||||
expect(frame.parent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('content option overrides payload extraction', () => {
|
||||
const frame = encodeFrame(event({ payload: { content: 'original' } }), { content: 'override' });
|
||||
expect(frame.content).toBe('override');
|
||||
});
|
||||
});
|
||||
|
||||
describe('frameToSavePayload (Commit 1.4)', () => {
|
||||
function makeFrame(overrides: Partial<HookFrame> = {}): HookFrame {
|
||||
return {
|
||||
content: 'the actual content',
|
||||
importance: 'normal',
|
||||
scope: 'sess-123',
|
||||
source: 'claude-code',
|
||||
metadata: {
|
||||
cwd: '/proj',
|
||||
timestamp_iso: '2026-04-28T10:00:00.000Z',
|
||||
event_type: 'user-prompt-submit',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('embeds session/source/event in a content prefix and defaults source to "system"', () => {
|
||||
const out = frameToSavePayload(makeFrame());
|
||||
expect(out.content).toBe('[hm session:sess-123 src:claude-code event:user-prompt-submit] the actual content');
|
||||
expect(out.importance).toBe('normal');
|
||||
expect(out.source).toBe('system');
|
||||
});
|
||||
|
||||
it('drops session token when scope is "default"', () => {
|
||||
const out = frameToSavePayload(makeFrame({ scope: 'default' }));
|
||||
expect(out.content).not.toContain('session:');
|
||||
expect(out.content).toContain('src:claude-code');
|
||||
});
|
||||
|
||||
it('includes parent token when present', () => {
|
||||
const out = frameToSavePayload(makeFrame({ parent: 'frame-99' }));
|
||||
expect(out.content).toContain('parent:frame-99');
|
||||
});
|
||||
|
||||
it('includes project token from metadata when present', () => {
|
||||
const out = frameToSavePayload(makeFrame({ metadata: {
|
||||
cwd: '/proj',
|
||||
timestamp_iso: '2026-04-28T10:00:00.000Z',
|
||||
event_type: 'stop',
|
||||
project: 'waggle-os',
|
||||
}}));
|
||||
expect(out.content).toContain('project:waggle-os');
|
||||
});
|
||||
|
||||
it('mcpSource override is honoured', () => {
|
||||
const out = frameToSavePayload(makeFrame(), { mcpSource: 'agent_inferred' });
|
||||
expect(out.source).toBe('agent_inferred');
|
||||
});
|
||||
|
||||
it('omits prefix entirely when no metadata tokens would be added', () => {
|
||||
const minimalFrame: HookFrame = {
|
||||
content: 'plain',
|
||||
importance: 'normal',
|
||||
scope: 'default',
|
||||
source: 'claude-code',
|
||||
metadata: { cwd: '/p', timestamp_iso: 't' }, // no event_type, no project
|
||||
};
|
||||
const out = frameToSavePayload(minimalFrame);
|
||||
// src token is always present, so we still get a prefix:
|
||||
expect(out.content).toBe('[hm src:claude-code] plain');
|
||||
});
|
||||
});
|
||||
53
packages/hive-mind-shim-core/tests/hook-event-types.test.ts
Normal file
53
packages/hive-mind-shim-core/tests/hook-event-types.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ALL_EVENT_TYPES,
|
||||
ALL_SOURCES,
|
||||
isEventType,
|
||||
isShimSource,
|
||||
} from '../src/hook-event-types.js';
|
||||
|
||||
describe('hook-event-types', () => {
|
||||
it('ALL_EVENT_TYPES contains the 7 canonical events', () => {
|
||||
expect([...ALL_EVENT_TYPES].sort()).toEqual([
|
||||
'post-tool-use',
|
||||
'pre-compact',
|
||||
'pre-tool-use',
|
||||
'session-end',
|
||||
'session-start',
|
||||
'stop',
|
||||
'user-prompt-submit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ALL_SOURCES contains the 6 supported IDEs', () => {
|
||||
expect([...ALL_SOURCES].sort()).toEqual([
|
||||
'claude-code',
|
||||
'codex',
|
||||
'cursor',
|
||||
'hermes',
|
||||
'openclaw',
|
||||
'opencode',
|
||||
]);
|
||||
});
|
||||
|
||||
it('isEventType narrows known strings', () => {
|
||||
expect(isEventType('session-start')).toBe(true);
|
||||
expect(isEventType('user-prompt-submit')).toBe(true);
|
||||
expect(isEventType('pre-tool-use')).toBe(true);
|
||||
});
|
||||
|
||||
it('isEventType rejects unknown / non-string values', () => {
|
||||
expect(isEventType('made-up-event')).toBe(false);
|
||||
expect(isEventType('')).toBe(false);
|
||||
expect(isEventType(null)).toBe(false);
|
||||
expect(isEventType(42)).toBe(false);
|
||||
expect(isEventType({ eventType: 'session-start' })).toBe(false);
|
||||
});
|
||||
|
||||
it('isShimSource narrows known and rejects unknown', () => {
|
||||
expect(isShimSource('claude-code')).toBe(true);
|
||||
expect(isShimSource('codex')).toBe(true);
|
||||
expect(isShimSource('not-a-real-ide')).toBe(false);
|
||||
expect(isShimSource(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyImportance,
|
||||
classifyWithRules,
|
||||
DEFAULT_RULES,
|
||||
} from '../src/importance-classifier.js';
|
||||
|
||||
describe('classifyImportance — default rules', () => {
|
||||
it('"always" pattern is critical', () => {
|
||||
expect(classifyImportance('always run lint before commit')).toBe('critical');
|
||||
});
|
||||
|
||||
it('"never" pattern is critical', () => {
|
||||
expect(classifyImportance('never commit secrets')).toBe('critical');
|
||||
});
|
||||
|
||||
it('MEMORY.md reference is critical', () => {
|
||||
expect(classifyImportance('see MEMORY.md for the full list')).toBe('critical');
|
||||
});
|
||||
|
||||
it('CLAUDE.md reference is critical', () => {
|
||||
expect(classifyImportance('this rule is documented in CLAUDE.md')).toBe('critical');
|
||||
});
|
||||
|
||||
it('"do not use X" prohibition is critical', () => {
|
||||
expect(classifyImportance('do not use the legacy adapter')).toBe('critical');
|
||||
expect(classifyImportance("don't run that command")).toBe('critical');
|
||||
});
|
||||
|
||||
it('decision verbs land at important', () => {
|
||||
expect(classifyImportance('we decided to use Postgres')).toBe('important');
|
||||
expect(classifyImportance('chose Option A over B')).toBe('important');
|
||||
});
|
||||
|
||||
it('action verbs land at important', () => {
|
||||
expect(classifyImportance('implement the auth middleware')).toBe('important');
|
||||
expect(classifyImportance('refactor the bridge module')).toBe('important');
|
||||
});
|
||||
|
||||
it('failure signals land at important', () => {
|
||||
expect(classifyImportance('the build failed on Windows')).toBe('important');
|
||||
});
|
||||
|
||||
it('TODO / FIXME markers land at important', () => {
|
||||
expect(classifyImportance('TODO: wire up retry')).toBe('important');
|
||||
expect(classifyImportance('FIXME parameter is wrong')).toBe('important');
|
||||
});
|
||||
|
||||
it('plain chatter floors at "normal" (Commit 1.4 — was "temporary")', () => {
|
||||
expect(classifyImportance('hi there')).toBe('normal');
|
||||
expect(classifyImportance('thanks')).toBe('normal');
|
||||
});
|
||||
|
||||
it('empty / whitespace-only input is still temporary (early return short-circuits floor)', () => {
|
||||
expect(classifyImportance('')).toBe('temporary');
|
||||
expect(classifyImportance(' \n\t')).toBe('temporary');
|
||||
});
|
||||
|
||||
it('session-start / session-end events floor at important', () => {
|
||||
expect(classifyImportance('hi', { eventType: 'session-start' })).toBe('important');
|
||||
expect(classifyImportance('thanks', { eventType: 'session-end' })).toBe('important');
|
||||
});
|
||||
|
||||
it('critical patterns still beat the session-start floor', () => {
|
||||
expect(classifyImportance('always test before push', { eventType: 'session-start' })).toBe('critical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyWithRules', () => {
|
||||
it('returns fallback when content is empty', () => {
|
||||
expect(classifyWithRules('', DEFAULT_RULES, 'temporary')).toBe('temporary');
|
||||
expect(classifyWithRules('', DEFAULT_RULES, 'normal')).toBe('normal');
|
||||
});
|
||||
|
||||
it('uses custom rule set (default fallback is "normal" in Commit 1.4)', () => {
|
||||
const rules = [{ pattern: /xyzzy/i, importance: 'critical' as const, reason: 'magic word' }];
|
||||
expect(classifyWithRules('the password is xyzzy', rules)).toBe('critical');
|
||||
expect(classifyWithRules('the password is hunter2', rules)).toBe('normal');
|
||||
// Explicit fallback override still works:
|
||||
expect(classifyWithRules('the password is hunter2', rules, 'temporary')).toBe('temporary');
|
||||
});
|
||||
|
||||
it('higher importance wins when multiple rules match', () => {
|
||||
const rules = [
|
||||
{ pattern: /always/i, importance: 'critical' as const, reason: 'directive' },
|
||||
{ pattern: /implement/i, importance: 'important' as const, reason: 'verb' },
|
||||
];
|
||||
expect(classifyWithRules('always implement tests first', rules)).toBe('critical');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Wire-format round-trip integration test.
|
||||
*
|
||||
* Exercises the cli-bridge against a REAL hive-mind-cli + real
|
||||
* hive-mind MCP server, in a freshly-init'd tmpdir-isolated mind so
|
||||
* the test never touches the user's actual ~/.hive-mind.
|
||||
*
|
||||
* Acts as a fitness function: if the upstream MCP surface drifts
|
||||
* (tool renames, schema field changes, importance enum changes, etc.)
|
||||
* this test fails before any shim ships. Mocked unit tests would
|
||||
* silently keep passing — that's exactly the gap that produced
|
||||
* Commit 1.4 in the first place.
|
||||
*
|
||||
* Skipped automatically when `hive-mind-cli` is not on PATH (CI
|
||||
* matrices without the upstream installed). Look for the
|
||||
* "integration: hive-mind-cli not on PATH" log line.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createCliBridge } from '../../src/cli-bridge.js';
|
||||
import { encodeFrame } from '../../src/frame-encoder.js';
|
||||
import type { HookEvent } from '../../src/hook-event-types.js';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5000;
|
||||
const INIT_TIMEOUT_MS = 15000;
|
||||
const TEST_TIMEOUT_MS = 30000;
|
||||
|
||||
const CLI_BIN = 'hive-mind-cli';
|
||||
|
||||
/**
|
||||
* Path resolution priority:
|
||||
* 1. HIVE_MIND_CLI_JS env override (CI / offline testing)
|
||||
* 2. In-monorepo package at packages/hive-mind-cli/dist/index.js
|
||||
* 3. Sibling checkout at ../hive-mind/packages/cli/dist/index.js
|
||||
* 4. Fall back to 'hive-mind-cli' on PATH (relies on cli-bridge's
|
||||
* Windows shell:true codepath; acceptable for unit/integration but
|
||||
* forced JS path keeps things hermetic).
|
||||
*/
|
||||
function resolveCliJsPath(): string | undefined {
|
||||
const envOverride = process.env['HIVE_MIND_CLI_JS'];
|
||||
if (envOverride && existsSync(envOverride)) return envOverride;
|
||||
const monorepo = resolve(import.meta.dirname, '..', '..', '..', 'hive-mind-cli', 'dist', 'index.js');
|
||||
if (existsSync(monorepo)) return monorepo;
|
||||
const sibling = resolve(process.cwd(), '..', 'hive-mind', 'packages', 'cli', 'dist', 'index.js');
|
||||
if (existsSync(sibling)) return sibling;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const RESOLVED_CLI_JS = resolveCliJsPath();
|
||||
const SPAWN_CMD = RESOLVED_CLI_JS ?? CLI_BIN;
|
||||
|
||||
function probeCli(): boolean {
|
||||
try {
|
||||
const probeArgs = RESOLVED_CLI_JS ? [RESOLVED_CLI_JS, '--help'] : ['--help'];
|
||||
const probeBin = RESOLVED_CLI_JS ? process.execPath : CLI_BIN;
|
||||
const probe = spawnSync(probeBin, probeArgs, {
|
||||
stdio: 'pipe',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
shell: !RESOLVED_CLI_JS,
|
||||
});
|
||||
return probe.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const cliReachable = probeCli();
|
||||
|
||||
let tmpHome: string | undefined;
|
||||
let priorDataDir: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!cliReachable) {
|
||||
|
||||
console.log('[integration] hive-mind-cli not on PATH — round-trip suite will skip.');
|
||||
return;
|
||||
}
|
||||
tmpHome = await mkdtemp(join(tmpdir(), 'hmc-integration-'));
|
||||
priorDataDir = process.env['HIVE_MIND_DATA_DIR'];
|
||||
process.env['HIVE_MIND_DATA_DIR'] = tmpHome;
|
||||
// Initialize an isolated mind file under the tmpdir.
|
||||
const initBin = RESOLVED_CLI_JS ? process.execPath : CLI_BIN;
|
||||
const initArgs = RESOLVED_CLI_JS ? [RESOLVED_CLI_JS, 'init'] : ['init'];
|
||||
const init = spawnSync(initBin, initArgs, {
|
||||
stdio: 'pipe',
|
||||
timeout: INIT_TIMEOUT_MS,
|
||||
shell: !RESOLVED_CLI_JS,
|
||||
env: process.env,
|
||||
});
|
||||
if (init.status !== 0) {
|
||||
throw new Error(`hive-mind-cli init failed: ${init.stderr?.toString() ?? '(no stderr)'}`);
|
||||
}
|
||||
}, INIT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!cliReachable) return;
|
||||
if (priorDataDir === undefined) {
|
||||
delete process.env['HIVE_MIND_DATA_DIR'];
|
||||
} else {
|
||||
process.env['HIVE_MIND_DATA_DIR'] = priorDataDir;
|
||||
}
|
||||
if (tmpHome) {
|
||||
await rm(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe.skipIf(!cliReachable)('integration: cli-bridge ↔ hive-mind-cli round-trip', () => {
|
||||
it('save_memory + recall_memory complete a wire-level round-trip with HookFrame inputs', async () => {
|
||||
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
|
||||
const marker = `roundtrip-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const event: HookEvent = {
|
||||
eventType: 'user-prompt-submit',
|
||||
source: 'claude-code',
|
||||
cwd: '/integration/test',
|
||||
timestamp_iso: new Date().toISOString(),
|
||||
payload: {
|
||||
content: `${marker} this is the integration save body`,
|
||||
session_id: `int-sess-${marker}`,
|
||||
},
|
||||
};
|
||||
|
||||
const frame = encodeFrame(event, { importance: 'normal' });
|
||||
const saveResult = await bridge.saveMemory(frame);
|
||||
expect(saveResult.success).toBe(true);
|
||||
expect(saveResult.id).not.toBe('');
|
||||
expect(Number(saveResult.id)).toBeGreaterThan(0);
|
||||
|
||||
// Recall by the unique marker — content prefix carries it through.
|
||||
const hits = await bridge.recallMemory(marker, { limit: 5 });
|
||||
expect(hits.length).toBeGreaterThanOrEqual(1);
|
||||
const hit = hits[0];
|
||||
expect(hit.content).toContain(marker);
|
||||
expect(hit.content).toContain('src:claude-code');
|
||||
expect(hit.importance).toBe('normal');
|
||||
expect(hit.from).toBe('personal');
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
it('cleanup_frames responds without error', async () => {
|
||||
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
|
||||
const out = await bridge.cleanupFrames();
|
||||
expect(typeof out.pruned).toBe('number');
|
||||
expect(out.pruned).toBeGreaterThanOrEqual(0);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
// NOTE: an "empty recall" integration check would be too brittle —
|
||||
// upstream hybrid search returns very-low-score hits even for
|
||||
// unrelated queries (semantic vector recall has no zero-score floor).
|
||||
// The plain-text "No memories found" envelope is exercised by the
|
||||
// unit test in cli-bridge.test.ts with a mocked spawn instead.
|
||||
|
||||
it('save_memory rejects an invalid source (regression: enum mismatch must surface, not be silently swallowed)', async () => {
|
||||
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
|
||||
// Bypass frameToSavePayload — use raw callMcpTool to send a bad source.
|
||||
await expect(bridge.callMcpTool('save_memory', {
|
||||
content: 'should fail',
|
||||
source: 'claude-code', // INVALID — must be one of the four provenance enum values
|
||||
})).rejects.toThrow(/Invalid enum value/);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
});
|
||||
84
packages/hive-mind-shim-core/tests/logger.test.ts
Normal file
84
packages/hive-mind-shim-core/tests/logger.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createLogger } from '../src/logger.js';
|
||||
|
||||
function captureLines(): { lines: string[]; write: (l: string) => void } {
|
||||
const lines: string[] = [];
|
||||
return { lines, write: (l) => lines.push(l) };
|
||||
}
|
||||
|
||||
const FROZEN_NOW = new Date('2026-04-28T10:00:00.000Z');
|
||||
|
||||
describe('createLogger', () => {
|
||||
it('emits a JSON line per call with name + level + msg + timestamp', () => {
|
||||
const cap = captureLines();
|
||||
const log = createLogger({ name: 'test', level: 'debug', write: cap.write, now: () => FROZEN_NOW });
|
||||
log.info('hello');
|
||||
expect(cap.lines).toHaveLength(1);
|
||||
const entry = JSON.parse(cap.lines[0]) as Record<string, unknown>;
|
||||
expect(entry).toMatchObject({
|
||||
timestamp: '2026-04-28T10:00:00.000Z',
|
||||
level: 'info',
|
||||
name: 'test',
|
||||
msg: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters levels below threshold', () => {
|
||||
const cap = captureLines();
|
||||
const log = createLogger({ level: 'warn', write: cap.write, now: () => FROZEN_NOW });
|
||||
log.debug('d');
|
||||
log.info('i');
|
||||
log.warn('w');
|
||||
log.error('e');
|
||||
expect(cap.lines.map((l) => (JSON.parse(l) as { level: string }).level))
|
||||
.toEqual(['warn', 'error']);
|
||||
});
|
||||
|
||||
it('merges meta fields into the entry without clobbering core fields', () => {
|
||||
const cap = captureLines();
|
||||
const log = createLogger({ level: 'info', write: cap.write, now: () => FROZEN_NOW });
|
||||
log.info('x', { tool: 'save_memory', latencyMs: 42 });
|
||||
const entry = JSON.parse(cap.lines[0]) as Record<string, unknown>;
|
||||
expect(entry['tool']).toBe('save_memory');
|
||||
expect(entry['latencyMs']).toBe(42);
|
||||
expect(entry['msg']).toBe('x');
|
||||
});
|
||||
|
||||
it('honours HIVE_MIND_SHIM_LOG_LEVEL when no explicit level is provided', () => {
|
||||
const prior = process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
|
||||
process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = 'error';
|
||||
try {
|
||||
const cap = captureLines();
|
||||
const log = createLogger({ write: cap.write, now: () => FROZEN_NOW });
|
||||
log.info('i');
|
||||
log.error('e');
|
||||
expect(cap.lines).toHaveLength(1);
|
||||
expect((JSON.parse(cap.lines[0]) as { level: string }).level).toBe('error');
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
|
||||
else process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it('explicit level option overrides env var', () => {
|
||||
const prior = process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
|
||||
process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = 'error';
|
||||
try {
|
||||
const cap = captureLines();
|
||||
const log = createLogger({ level: 'debug', write: cap.write, now: () => FROZEN_NOW });
|
||||
log.debug('d');
|
||||
expect(cap.lines).toHaveLength(1);
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
|
||||
else process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to default name "shim-core"', () => {
|
||||
const cap = captureLines();
|
||||
const log = createLogger({ write: cap.write, now: () => FROZEN_NOW });
|
||||
log.info('hi');
|
||||
const entry = JSON.parse(cap.lines[0]) as { name: string };
|
||||
expect(entry.name).toBe('shim-core');
|
||||
});
|
||||
});
|
||||
55
packages/hive-mind-shim-core/tests/prompt-summarizer.test.ts
Normal file
55
packages/hive-mind-shim-core/tests/prompt-summarizer.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { summarizeTurn } from '../src/prompt-summarizer.js';
|
||||
|
||||
describe('summarizeTurn', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(summarizeTurn('')).toBe('');
|
||||
expect(summarizeTurn(' \n ')).toBe('');
|
||||
});
|
||||
|
||||
it('returns input unchanged when within budget (collapsed whitespace)', () => {
|
||||
expect(summarizeTurn('Short reply.')).toBe('Short reply.');
|
||||
expect(summarizeTurn('Hello world.\n\n\nNice.')).toBe('Hello world. Nice.');
|
||||
});
|
||||
|
||||
it('truncates with ellipsis when over budget', () => {
|
||||
const long = 'A'.repeat(800);
|
||||
const out = summarizeTurn(long, { maxChars: 100 });
|
||||
expect(out.length).toBeLessThanOrEqual(100);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the leading sentence when possible', () => {
|
||||
const text = 'First sentence here. Second one is longer and contains more words. Third.';
|
||||
const out = summarizeTurn(text, { maxChars: 30 });
|
||||
expect(out.startsWith('First sentence here.')).toBe(true);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces fenced code blocks with [code]', () => {
|
||||
const text = 'Here is some code:\n```ts\nconst x = 1;\n```\nthat is all.';
|
||||
const out = summarizeTurn(text);
|
||||
expect(out).toContain('[code]');
|
||||
expect(out).not.toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('respects custom maxChars', () => {
|
||||
const text = 'A'.repeat(50) + '. ' + 'B'.repeat(50);
|
||||
expect(summarizeTurn(text, { maxChars: 200 })).toBe(text);
|
||||
const small = summarizeTurn(text, { maxChars: 30 });
|
||||
expect(small.length).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('hard-truncates when no sentence boundary fits', () => {
|
||||
const blob = 'A'.repeat(500);
|
||||
const out = summarizeTurn(blob, { maxChars: 50 });
|
||||
expect(out.length).toBe(50);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('is deterministic — same input -> same output', () => {
|
||||
const a = summarizeTurn('Repeatable. Same. Output.', { maxChars: 30 });
|
||||
const b = summarizeTurn('Repeatable. Same. Output.', { maxChars: 30 });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
91
packages/hive-mind-shim-core/tests/retry-bridge.test.ts
Normal file
91
packages/hive-mind-shim-core/tests/retry-bridge.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { computeBackoff, withRetry } from '../src/retry-bridge.js';
|
||||
|
||||
describe('computeBackoff', () => {
|
||||
it('exponentially increases with attempt count', () => {
|
||||
const fixedRandom = (): number => 0.5; // jitter = 0
|
||||
expect(computeBackoff(0, 100, 5000, 0, fixedRandom)).toBe(100);
|
||||
expect(computeBackoff(1, 100, 5000, 0, fixedRandom)).toBe(200);
|
||||
expect(computeBackoff(2, 100, 5000, 0, fixedRandom)).toBe(400);
|
||||
expect(computeBackoff(3, 100, 5000, 0, fixedRandom)).toBe(800);
|
||||
});
|
||||
|
||||
it('is clamped to maxBackoffMs', () => {
|
||||
const fixedRandom = (): number => 0.5;
|
||||
expect(computeBackoff(20, 100, 1000, 0, fixedRandom)).toBe(1000);
|
||||
});
|
||||
|
||||
it('jitter is bounded by jitterFactor on either side', () => {
|
||||
// random=0 -> -1 multiplier; random=1 -> +1 multiplier
|
||||
const lower = computeBackoff(2, 100, 5000, 0.25, () => 0);
|
||||
const upper = computeBackoff(2, 100, 5000, 0.25, () => 0.999999);
|
||||
expect(lower).toBeGreaterThanOrEqual(Math.round(400 * 0.75));
|
||||
expect(upper).toBeLessThanOrEqual(Math.round(400 * 1.25) + 1);
|
||||
});
|
||||
|
||||
it('returns >= 0', () => {
|
||||
expect(computeBackoff(0, 100, 5000, 5.0, () => 0)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRetry', () => {
|
||||
it('returns the value on first-attempt success', async () => {
|
||||
const fn = vi.fn(async () => 42);
|
||||
const result = await withRetry(fn, { maxRetries: 3, delay: async () => undefined });
|
||||
expect(result).toBe(42);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries until success', async () => {
|
||||
let calls = 0;
|
||||
const fn = vi.fn(async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error('flaky');
|
||||
return 'ok';
|
||||
});
|
||||
const result = await withRetry(fn, {
|
||||
maxRetries: 5,
|
||||
delay: async () => undefined,
|
||||
random: () => 0.5,
|
||||
});
|
||||
expect(result).toBe('ok');
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('throws the last error after exhausting retries', async () => {
|
||||
const fn = vi.fn(async () => { throw new Error('persistent'); });
|
||||
await expect(withRetry(fn, { maxRetries: 2, delay: async () => undefined })).rejects.toThrow('persistent');
|
||||
expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
||||
});
|
||||
|
||||
it('per-attempt timeout fires when fn never resolves', async () => {
|
||||
const fn = vi.fn(() => new Promise<never>(() => { /* hang */ }));
|
||||
await expect(withRetry(fn, {
|
||||
maxRetries: 1,
|
||||
timeoutMs: 20,
|
||||
delay: async () => undefined,
|
||||
})).rejects.toThrow(/timed out/);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes the configured delay through (test hook)', async () => {
|
||||
const delays: number[] = [];
|
||||
const fn = vi.fn(async () => { throw new Error('x'); });
|
||||
await expect(withRetry(fn, {
|
||||
maxRetries: 2,
|
||||
baseBackoffMs: 100,
|
||||
jitterFactor: 0,
|
||||
delay: async (ms) => { delays.push(ms); },
|
||||
random: () => 0.5,
|
||||
})).rejects.toThrow();
|
||||
expect(delays).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it('wraps non-Error rejections', async () => {
|
||||
const fn = vi.fn(async () => { throw 'plain string'; });
|
||||
await expect(withRetry(fn, {
|
||||
maxRetries: 0,
|
||||
delay: async () => undefined,
|
||||
})).rejects.toThrow('plain string');
|
||||
});
|
||||
});
|
||||
302
packages/hive-mind-shim-core/tests/signal-emitter.test.ts
Normal file
302
packages/hive-mind-shim-core/tests/signal-emitter.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* AI-OS Phase 1D — signal emitter tests.
|
||||
*
|
||||
* Verifies: URL resolution order, request shape, fail-open semantics
|
||||
* on network errors, timeout enforcement, the maybeEmitDiscovery
|
||||
* policy helper.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
emitSignalToWaggleDance,
|
||||
maybeEmitDiscovery,
|
||||
type EmittedSignal,
|
||||
} from '../src/signal-emitter.js';
|
||||
|
||||
/**
|
||||
* Build a fake fetch that captures the request and returns a stub
|
||||
* 201 response with a synthetic signal echo.
|
||||
*/
|
||||
function makeOkFetch(): typeof fetch & { calls: Array<{ url: string; init: RequestInit | undefined }> } {
|
||||
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
||||
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
const sig: EmittedSignal = {
|
||||
id: 'srv-' + Math.random().toString(36).slice(2),
|
||||
teamId: 'personal::test',
|
||||
senderId: 'test',
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
referenceId: null,
|
||||
routing: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return new Response(JSON.stringify({ dispatched: true, message: sig }), {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch & { calls: typeof calls };
|
||||
impl.calls = calls;
|
||||
return impl;
|
||||
}
|
||||
|
||||
describe('emitSignalToWaggleDance', () => {
|
||||
it('POSTs to the default sidecar URL when no override', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { topic: 'x' },
|
||||
fetchImpl: f,
|
||||
});
|
||||
expect(f.calls).toHaveLength(1);
|
||||
expect(f.calls[0].url).toBe('http://127.0.0.1:3333/api/waggle-dance/signal');
|
||||
});
|
||||
|
||||
it('honors the url option', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: f,
|
||||
url: 'http://127.0.0.1:4000',
|
||||
});
|
||||
expect(f.calls[0].url).toBe('http://127.0.0.1:4000/api/waggle-dance/signal');
|
||||
});
|
||||
|
||||
it('reads WAGGLE_SIDECAR_URL env when no url option', async () => {
|
||||
const prev = process.env.WAGGLE_SIDECAR_URL;
|
||||
process.env.WAGGLE_SIDECAR_URL = 'http://127.0.0.1:9999';
|
||||
try {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: f,
|
||||
});
|
||||
expect(f.calls[0].url).toBe('http://127.0.0.1:9999/api/waggle-dance/signal');
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.WAGGLE_SIDECAR_URL;
|
||||
else process.env.WAGGLE_SIDECAR_URL = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes the body with the expected shape', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { tool: 'claude-code', topic: 'rotation' },
|
||||
senderId: 'claude-code-hook',
|
||||
fetchImpl: f,
|
||||
});
|
||||
const body = JSON.parse(f.calls[0].init?.body as string);
|
||||
expect(body).toMatchObject({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
senderId: 'claude-code-hook',
|
||||
content: { tool: 'claude-code', topic: 'rotation' },
|
||||
});
|
||||
});
|
||||
|
||||
it('authenticates with the narrow run token without putting it in the body', async () => {
|
||||
const f = makeOkFetch();
|
||||
const token = 'run-token-with-at-least-thirty-two-bytes-1234';
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { topic: 'safe' },
|
||||
runToken: token,
|
||||
fetchImpl: f,
|
||||
});
|
||||
expect((f.calls[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(token);
|
||||
expect(f.calls[0].init?.body).not.toContain(token);
|
||||
});
|
||||
|
||||
it('reads WAGGLE_RUN_TOKEN for installed hook processes', async () => {
|
||||
const previous = process.env.WAGGLE_RUN_TOKEN;
|
||||
const token = 'environment-run-token-with-enough-entropy-1234';
|
||||
process.env.WAGGLE_RUN_TOKEN = token;
|
||||
try {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast', subtype: 'discovery', content: {}, fetchImpl: f,
|
||||
});
|
||||
expect((f.calls[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(token);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.WAGGLE_RUN_TOKEN;
|
||||
else process.env.WAGGLE_RUN_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults senderId to "hook" when not provided', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: f,
|
||||
});
|
||||
const body = JSON.parse(f.calls[0].init?.body as string);
|
||||
expect(body.senderId).toBe('hook');
|
||||
});
|
||||
|
||||
it('only includes optional fields when set', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'response',
|
||||
subtype: 'knowledge_match',
|
||||
content: { matched: 1 },
|
||||
senderId: 'cursor',
|
||||
referenceId: 'r-1',
|
||||
fetchImpl: f,
|
||||
});
|
||||
const body = JSON.parse(f.calls[0].init?.body as string);
|
||||
expect(body.referenceId).toBe('r-1');
|
||||
expect(body).not.toHaveProperty('routing');
|
||||
expect(body).not.toHaveProperty('teamId');
|
||||
});
|
||||
|
||||
it('returns the server message on 201', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: f,
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.id).toMatch(/^srv-/);
|
||||
});
|
||||
|
||||
it('returns null and warns on network errors (ECONNREFUSED simulation)', async () => {
|
||||
const warnCalls: string[] = [];
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('ECONNREFUSED 127.0.0.1:3333');
|
||||
}) as typeof fetch,
|
||||
onWarn: (m) => warnCalls.push(m),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(warnCalls).toHaveLength(1);
|
||||
expect(warnCalls[0]).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('returns null and warns on non-2xx response', async () => {
|
||||
const warnCalls: string[] = [];
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: (async () => new Response('bad request', { status: 400 })) as typeof fetch,
|
||||
onWarn: (m) => warnCalls.push(m),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(warnCalls[0]).toContain('400');
|
||||
});
|
||||
|
||||
it('returns null and warns on malformed response body', async () => {
|
||||
const warnCalls: string[] = [];
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: (async () =>
|
||||
new Response('{"no-message-field": true}', {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})) as typeof fetch,
|
||||
onWarn: (m) => warnCalls.push(m),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(warnCalls[0]).toContain('malformed');
|
||||
});
|
||||
|
||||
it('enforces a per-request timeout', async () => {
|
||||
const warnCalls: string[] = [];
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
timeoutMs: 50,
|
||||
fetchImpl: ((_url: string, init?: RequestInit) => {
|
||||
// Return a never-resolving promise; the abort signal should trigger.
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
});
|
||||
}) as typeof fetch,
|
||||
onWarn: (m) => warnCalls.push(m),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(warnCalls[0]).toContain('sidecar unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeEmitDiscovery', () => {
|
||||
it('emits when Stop + high importance', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery(
|
||||
'stop',
|
||||
'high',
|
||||
{ topic: 'rotation' },
|
||||
{ fetchImpl: f, senderId: 'cc' },
|
||||
);
|
||||
expect(out).not.toBeNull();
|
||||
expect(f.calls).toHaveLength(1);
|
||||
const body = JSON.parse(f.calls[0].init?.body as string);
|
||||
expect(body.subtype).toBe('discovery');
|
||||
expect(body.content.eventType).toBe('stop');
|
||||
expect(body.content.importance).toBe('high');
|
||||
expect(body.content.topic).toBe('rotation');
|
||||
});
|
||||
|
||||
it('emits when PreCompact + critical', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery(
|
||||
'pre-compact',
|
||||
'critical',
|
||||
{},
|
||||
{ fetchImpl: f },
|
||||
);
|
||||
expect(out).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not emit on low importance', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery('stop', 'low', {}, { fetchImpl: f });
|
||||
expect(out).toBeNull();
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not emit on normal importance', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery('stop', 'normal', {}, { fetchImpl: f });
|
||||
expect(out).toBeNull();
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not emit on non-stop/pre-compact events', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery(
|
||||
'user-prompt-submit',
|
||||
'high',
|
||||
{},
|
||||
{ fetchImpl: f },
|
||||
);
|
||||
expect(out).toBeNull();
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Note: the end-to-end integration test against a real Waggle sidecar
|
||||
// lives in packages/server/tests/signal-emitter-integration.test.ts —
|
||||
// that's the correct layer to depend on @waggle/server. This module
|
||||
// (shim-core) stays a leaf with no inbound deps from the server, which
|
||||
// is what lets hook packages consume it without dragging the sidecar
|
||||
// in.
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isAbsoluteWorkspacePath, resolveWorkspace } from '../src/workspace-resolver.js';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Resolve path fixtures against the current platform so tests work on
|
||||
// both POSIX (e.g. /fake/home) and Windows (D:\fake\home). The resolver
|
||||
// internally calls path.resolve on its cwd input — fixtures must match.
|
||||
const FAKE_HOME = resolve('/fake/home');
|
||||
|
||||
function makeExists(present: readonly string[]): (p: string) => Promise<boolean> {
|
||||
const set = new Set(present);
|
||||
return async (p: string) => set.has(p);
|
||||
}
|
||||
|
||||
describe('resolveWorkspace', () => {
|
||||
it('falls back to global mode when no project marker exists', async () => {
|
||||
const ws = await resolveWorkspace(resolve('/some/random/cwd'), {
|
||||
home: FAKE_HOME,
|
||||
exists: makeExists([]),
|
||||
walkUp: false,
|
||||
});
|
||||
expect(ws.mode).toBe('global');
|
||||
expect(ws.path).toBe(join(FAKE_HOME, '.hive-mind', 'global.mind'));
|
||||
});
|
||||
|
||||
it('returns per-project mode when marker exists at cwd', async () => {
|
||||
const cwd = resolve('/proj/foo');
|
||||
const marker = join(cwd, '.hive-mind', 'workspace.mind');
|
||||
const ws = await resolveWorkspace(cwd, {
|
||||
home: FAKE_HOME,
|
||||
exists: makeExists([marker]),
|
||||
walkUp: false,
|
||||
});
|
||||
expect(ws.mode).toBe('per-project');
|
||||
expect(ws.path).toBe(marker);
|
||||
expect(ws.cwd).toBe(cwd);
|
||||
});
|
||||
|
||||
it('walks up to a parent project marker', async () => {
|
||||
const projectRoot = resolve('/proj/foo');
|
||||
const cwd = join(projectRoot, 'src', 'deep', 'nested');
|
||||
const marker = join(projectRoot, '.hive-mind', 'workspace.mind');
|
||||
const ws = await resolveWorkspace(cwd, {
|
||||
home: FAKE_HOME,
|
||||
exists: makeExists([marker]),
|
||||
walkUp: true,
|
||||
});
|
||||
expect(ws.mode).toBe('per-project');
|
||||
expect(ws.path).toBe(marker);
|
||||
});
|
||||
|
||||
it('skips the walk when walkUp is false', async () => {
|
||||
const projectRoot = resolve('/proj/foo');
|
||||
const cwd = join(projectRoot, 'src', 'deep');
|
||||
const ancestorMarker = join(projectRoot, '.hive-mind', 'workspace.mind');
|
||||
const ws = await resolveWorkspace(cwd, {
|
||||
home: FAKE_HOME,
|
||||
exists: makeExists([ancestorMarker]),
|
||||
walkUp: false,
|
||||
});
|
||||
expect(ws.mode).toBe('global');
|
||||
});
|
||||
|
||||
it('records the original cwd even when resolving to global', async () => {
|
||||
const cwd = resolve('/proj/foo');
|
||||
const ws = await resolveWorkspace(cwd, {
|
||||
home: FAKE_HOME,
|
||||
exists: makeExists([]),
|
||||
walkUp: true,
|
||||
});
|
||||
expect(ws.cwd).toBe(cwd);
|
||||
expect(ws.mode).toBe('global');
|
||||
});
|
||||
|
||||
it('uses provided home override for the global fallback', async () => {
|
||||
const customHome = resolve('/custom/home');
|
||||
const ws = await resolveWorkspace(resolve('/proj/x'), {
|
||||
home: customHome,
|
||||
exists: makeExists([]),
|
||||
walkUp: false,
|
||||
});
|
||||
expect(ws.path).toBe(join(customHome, '.hive-mind', 'global.mind'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAbsoluteWorkspacePath', () => {
|
||||
it('detects absolute paths on the current platform', () => {
|
||||
expect(isAbsoluteWorkspacePath(resolve('/etc/foo'))).toBe(true);
|
||||
expect(isAbsoluteWorkspacePath('relative/path')).toBe(false);
|
||||
});
|
||||
});
|
||||
11
packages/hive-mind-shim-core/tsconfig.json
Normal file
11
packages/hive-mind-shim-core/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests/**", "dist/**", "node_modules/**"]
|
||||
}
|
||||
12
packages/hive-mind-shim-core/tsconfig.test.json
Normal file
12
packages/hive-mind-shim-core/tsconfig.test.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist/**", "node_modules/**"]
|
||||
}
|
||||
Reference in New Issue
Block a user