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

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

View File

@@ -0,0 +1,126 @@
/**
* `hive-mind-cli cognify` — extract entities and relations from recent
* frames into the knowledge graph. High-quality extraction requires an
* LLM, so this command is a deliberately small heuristic pass suitable
* for a nightly cron: it walks new frames, pulls capitalized noun
* phrases, normalizes them, and creates/updates KG entities. Callers
* who want richer extraction should run the MCP `save_entity` tool
* with an LLM-driven agent instead.
*/
import { openPersonalMind, type CliEnv } from '../setup.js';
import { normalizeEntityName } from '@waggle/hive-mind-core';
export interface CognifyOptions {
/** Process frames with id > since. Defaults to last cognify watermark or 0. */
since?: number;
limit?: number;
env?: CliEnv;
}
export interface CognifyResult {
framesScanned: number;
entitiesCreated: number;
entitiesUpdated: number;
lastFrameId: number;
}
// Heuristic: consecutive capitalised words with optional connectors.
// Deliberately conservative — we prefer to miss entities than to create noise.
const ENTITY_PATTERN = /\b([A-Z][a-zA-Z]+(?:\s+(?:de|of|&)\s+|\s+)[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*)\b/g;
const SIMPLE_ENTITY_PATTERN = /\b([A-Z][a-zA-Z]{2,})\b/g;
// Skip common sentence-starts and pronouns that the naive regex catches.
const STOP_TOKENS = new Set([
'The', 'This', 'That', 'These', 'Those', 'When', 'Where', 'Why', 'How',
'What', 'Who', 'Which', 'If', 'And', 'But', 'Or', 'So', 'For', 'Nor',
'Yet', 'As', 'At', 'By', 'On', 'In', 'To', 'From', 'With', 'Without',
'Into', 'Onto', 'Upon', 'Over', 'Under', 'Between', 'Among',
]);
function extractCandidateEntities(text: string): string[] {
const seen = new Set<string>();
// Multi-word candidates first (more specific — Project Alpha, Acme Corp).
for (const match of text.matchAll(ENTITY_PATTERN)) {
const candidate = match[1].trim();
if (candidate.length >= 4) seen.add(candidate);
}
// Single-word candidates — filter stop words.
for (const match of text.matchAll(SIMPLE_ENTITY_PATTERN)) {
const candidate = match[1].trim();
if (STOP_TOKENS.has(candidate)) continue;
if (candidate.length < 3) continue;
seen.add(candidate);
}
return [...seen];
}
export async function runCognify(options: CognifyOptions = {}): Promise<CognifyResult> {
const env = options.env ?? openPersonalMind();
const close = options.env ? () => { /* caller owns */ } : env.close;
try {
const since = options.since ?? 0;
const limit = options.limit ?? 500;
const raw = env.db.getDatabase();
const frames = raw.prepare(
'SELECT id, content FROM memory_frames WHERE id > ? ORDER BY id ASC LIMIT ?',
).all(since, limit) as { id: number; content: string }[];
let entitiesCreated = 0;
let entitiesUpdated = 0;
let lastFrameId = since;
for (const frame of frames) {
lastFrameId = Math.max(lastFrameId, frame.id);
const candidates = extractCandidateEntities(frame.content);
for (const name of candidates) {
const normalized = normalizeEntityName(name);
if (normalized.length < 3) continue;
// Dedup by exact name match — we conservatively classify everything
// as 'concept' because the heuristic can't tell person from org reliably.
// Previously used searchEntities(name, 3), a LIKE '%name%' fuzzy search —
// once enough entities share a common substring, the exact match drops
// out of top-3 and dedup silently fails (runaway duplicate rows).
// findEntityByName is the indexed exact-name lookup.
// Reverse-ported from OSS hive-mind (oss-drift triage R2, 2026-06-11).
const existing = env.kg.findEntityByName(name);
if (existing) {
// Touch properties to bump "seen in frame" count for future ranking.
const existingProps = safeParse(existing.properties);
const seenCount = Number(existingProps.seen_count ?? 1) + 1;
env.kg.updateEntity(existing.id, {
properties: { ...existingProps, seen_count: seenCount },
});
entitiesUpdated++;
} else {
try {
env.kg.createEntity('concept', name, { seen_count: 1, source: 'cognify' });
entitiesCreated++;
} catch { /* validation may reject — skip */ }
}
}
}
return {
framesScanned: frames.length,
entitiesCreated,
entitiesUpdated,
lastFrameId,
};
} finally {
close();
}
}
function safeParse(raw: string | undefined | null): Record<string, unknown> {
if (!raw) return {};
try { return JSON.parse(raw) as Record<string, unknown>; } catch { return {}; }
}

View File

@@ -0,0 +1,68 @@
/**
* `hive-mind-cli compile-wiki` — run wiki compilation against the
* personal mind. Delegates to @waggle/hive-mind-wiki-compiler with the
* default env-driven synthesizer resolver (Anthropic → Ollama → echo).
*/
import { openPersonalMind, type CliEnv } from '../setup.js';
import {
WikiCompiler,
CompilationState,
resolveSynthesizer,
} from '@waggle/hive-mind-wiki-compiler';
export interface CompileWikiOptions {
mode?: 'incremental' | 'full';
concepts?: string[];
env?: CliEnv;
}
export interface CompileWikiResult {
provider: string;
model: string;
mode: 'incremental' | 'full';
pagesCreated: number;
pagesUpdated: number;
pagesUnchanged: number;
entityPages: string[];
conceptPages: string[];
synthesisPages: string[];
healthIssues: number;
durationMs: number;
}
export async function runCompileWiki(options: CompileWikiOptions = {}): Promise<CompileWikiResult> {
const env = options.env ?? openPersonalMind();
const close = options.env ? () => { /* caller owns */ } : env.close;
const mode = options.mode ?? 'incremental';
try {
const state = new CompilationState(env.db);
const search = await env.getSearch();
const synth = await resolveSynthesizer();
const compiler = new WikiCompiler(env.kg, env.frames, search, state, {
synthesize: synth.synthesize,
});
const result = await compiler.compile({
incremental: mode === 'incremental',
concepts: options.concepts,
});
return {
provider: synth.provider,
model: synth.model,
mode,
pagesCreated: result.pagesCreated,
pagesUpdated: result.pagesUpdated,
pagesUnchanged: result.pagesUnchanged,
entityPages: result.entityPages,
conceptPages: result.conceptPages,
synthesisPages: result.synthesisPages,
healthIssues: result.healthIssues,
durationMs: result.durationMs,
};
} finally {
close();
}
}

View File

@@ -0,0 +1,146 @@
/** Narrow HTTP client for one external agent's WaggleDance Room. */
export type DanceMessageType = 'broadcast' | 'request' | 'response';
export type DanceMessageSubtype =
| 'knowledge_check' | 'task_delegation' | 'skill_request' | 'model_recommendation'
| 'knowledge_match' | 'task_claim' | 'discovery' | 'routed_share'
| 'skill_share' | 'model_recipe';
const VALID_SUBTYPES: Record<DanceMessageType, ReadonlySet<DanceMessageSubtype>> = {
request: new Set(['knowledge_check', 'task_delegation', 'skill_request', 'model_recommendation']),
response: new Set(['knowledge_match', 'task_claim']),
broadcast: new Set(['discovery', 'routed_share', 'skill_share', 'model_recipe']),
};
export interface DanceTransportOptions {
env?: NodeJS.ProcessEnv;
fetch?: typeof globalThis.fetch;
timeoutMs?: number;
}
export interface DanceSendOptions extends DanceTransportOptions {
type: DanceMessageType;
subtype: DanceMessageSubtype;
message: string;
referenceId?: string;
}
export interface DanceReceiveOptions extends DanceTransportOptions {
since?: string;
limit?: number;
subtype?: DanceMessageSubtype;
}
export interface DanceSendResult {
sent: boolean;
message: Record<string, unknown>;
response?: unknown;
}
export interface DanceReceiveResult {
signals: Array<Record<string, unknown>>;
total: number;
}
export async function runDanceSend(options: DanceSendOptions): Promise<DanceSendResult> {
const message = options.message.trim();
if (!message) throw new Error('dance send requires --message');
if (!VALID_SUBTYPES[options.type]?.has(options.subtype)) {
throw new Error(`Invalid WaggleDance type/subtype: ${options.type}/${options.subtype}`);
}
const transport = resolveTransport(options.env ?? process.env);
const response = await request(transport, '/api/waggle-dance/signal', {
method: 'POST',
body: JSON.stringify({
type: options.type,
subtype: options.subtype,
content: { text: message, query: message, task: message },
referenceId: options.referenceId ?? null,
}),
}, options);
const body = response as { dispatched?: unknown; message?: unknown; response?: unknown };
if (body.dispatched !== true || !body.message || typeof body.message !== 'object') {
throw new Error('WaggleDance send returned an invalid response');
}
return {
sent: true,
message: body.message as Record<string, unknown>,
...(body.response !== undefined ? { response: body.response } : {}),
};
}
export async function runDanceReceive(options: DanceReceiveOptions = {}): Promise<DanceReceiveResult> {
const transport = resolveTransport(options.env ?? process.env);
const query = new URLSearchParams();
if (options.since) query.set('since', options.since);
if (options.subtype) query.set('subtype', options.subtype);
if (options.limit !== undefined) query.set('limit', String(Math.max(1, Math.min(500, options.limit))));
const suffix = query.size > 0 ? `?${query}` : '';
const response = await request(transport, `/api/waggle-dance/signals${suffix}`, { method: 'GET' }, options);
const body = response as { signals?: unknown; total?: unknown };
if (!Array.isArray(body.signals)) throw new Error('WaggleDance receive returned an invalid response');
return {
signals: body.signals.filter((value): value is Record<string, unknown> => Boolean(value && typeof value === 'object')),
total: typeof body.total === 'number' ? body.total : body.signals.length,
};
}
export function renderDanceSend(result: DanceSendResult): string {
const id = typeof result.message.id === 'string' ? result.message.id : 'unknown';
return `WaggleDance message sent (${id})`;
}
export function renderDanceReceive(result: DanceReceiveResult): string {
if (result.signals.length === 0) return 'No new WaggleDance messages.';
return result.signals.map((signal) => {
const subtype = typeof signal.subtype === 'string' ? signal.subtype : 'message';
const sender = typeof signal.senderId === 'string' ? signal.senderId : 'unknown';
const content = signal.content && typeof signal.content === 'object'
? JSON.stringify(signal.content)
: '';
return `[${subtype}] ${sender}: ${content}`;
}).join('\n');
}
function resolveTransport(env: NodeJS.ProcessEnv): { baseUrl: string; token: string } {
const rawUrl = env.WAGGLE_DANCE_URL?.trim();
const token = env.WAGGLE_RUN_TOKEN?.trim();
if (!rawUrl) throw new Error('WAGGLE_DANCE_URL is not set; this command is available inside a Waggle agent run');
if (!token) throw new Error('WAGGLE_RUN_TOKEN is not set; this command is available inside a Waggle agent run');
const url = new URL(rawUrl);
const loopback = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
if (url.protocol !== 'http:' || !loopback.has(url.hostname) || url.username || url.password) {
throw new Error('WAGGLE_DANCE_URL must be an unauthenticated loopback http URL');
}
return { baseUrl: url.toString().replace(/\/$/, ''), token };
}
async function request(
transport: { baseUrl: string; token: string },
path: string,
init: RequestInit,
options: DanceTransportOptions,
): Promise<unknown> {
const fetchFn = options.fetch ?? globalThis.fetch;
const timeoutMs = Math.max(1_000, Math.min(options.timeoutMs ?? 10_000, 60_000));
const response = await fetchFn(`${transport.baseUrl}${path}`, {
...init,
signal: AbortSignal.timeout(timeoutMs),
headers: {
'content-type': 'application/json',
'x-waggle-run-token': transport.token,
...init.headers,
},
});
const text = await response.text();
let body: unknown;
try { body = text ? JSON.parse(text) : {}; }
catch { body = { message: text }; }
if (!response.ok) {
const detail = body && typeof body === 'object' && typeof (body as { message?: unknown }).message === 'string'
? (body as { message: string }).message
: `HTTP ${response.status}`;
throw new Error(`WaggleDance request failed: ${detail}`);
}
return body;
}

View File

@@ -0,0 +1,279 @@
/**
* `hive-mind-cli doctor` — self-diagnostic smoke test.
*
* Per Wave 1 brief 2026-04-29 §3.2:
* "Optional: register hive-mind-cli's own diagnostic command — `hive-mind-cli doctor` —
* that runs a smoke test (spawn self, save+recall a test frame, report fail/pass)
* without depending on the upstream hook being correct."
*
* What this does:
* 1. Verify the cli binary can spawn itself via child_process.spawn (catches
* Windows .cmd shim ENOENT bugs without depending on the upstream hook)
* 2. Open the personal mind via openPersonalMind() (catches sqlite + sqlite-vec
* runtime errors)
* 3. Save a test frame and recall it (catches FrameStore/HybridSearch wiring bugs)
* 4. Inspect mcp-health-cache.json for stale quarantine entries; clean if present
* 5. Report green ✓ / red ✗ + actionable next step
*
* Exit codes:
* 0 — green path, hive-mind-cli is healthy
* 1 — red path, at least one step failed (error printed with remediation)
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { spawn } from 'node:child_process';
import { FrameStore, SessionStore, type Importance } from '@waggle/hive-mind-core';
import { openPersonalMind, resolveDataDir, type CliEnv } from '../setup.js';
export interface DoctorResult {
ok: boolean;
steps: DoctorStep[];
/** Actionable suggestion if ok=false. */
remediation?: string;
}
export interface DoctorStep {
name: string;
ok: boolean;
detail?: string;
errorMessage?: string;
}
/** Render a DoctorResult to plain text for terminal output. */
export function renderDoctorResult(result: DoctorResult): string {
const lines: string[] = [];
for (const step of result.steps) {
const marker = step.ok ? '✓' : '✗';
const tail = step.ok
? step.detail ? ` (${step.detail})` : ''
: step.errorMessage ? `${step.errorMessage}` : '';
lines.push(`[hive-mind-cli doctor] ${step.name}${marker}${tail}`);
}
if (result.ok) {
lines.push(`[hive-mind-cli doctor] PASS — hive-mind-cli is healthy on ${process.platform}. First MCP call should succeed.`);
} else {
lines.push(`[hive-mind-cli doctor] FAIL — see above for the failed step.`);
if (result.remediation) {
lines.push(`[hive-mind-cli doctor] Remediation: ${result.remediation}`);
}
}
return lines.join('\n');
}
async function spawnSelfProbe(): Promise<DoctorStep> {
return new Promise((resolve) => {
// Spawn a `node --version` to verify spawn works. We can't spawn the CLI
// itself (would recurse), but we verify the spawn surface works for the
// current platform's shim resolution. On win32, a missing shell:true would
// cause this to fail with ENOENT for npm-shimmed binaries.
const opts: Parameters<typeof spawn>[2] = {
stdio: ['ignore', 'pipe', 'pipe'],
};
if (process.platform === 'win32') {
opts.shell = true;
opts.windowsHide = true;
}
const child = spawn('node', ['--version'], opts);
let timed = false;
const timer = setTimeout(() => {
timed = true;
child.kill();
resolve({
name: 'Spawning Node child_process.spawn (Windows .cmd shim probe)',
ok: false,
errorMessage: 'spawn timeout (>5s) — system may be under heavy load or PATH misconfigured',
});
}, 5000);
let stderr = '';
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
child.on('error', (err) => {
clearTimeout(timer);
if (timed) return;
resolve({
name: 'Spawning Node child_process.spawn (Windows .cmd shim probe)',
ok: false,
errorMessage: `${err.message}${process.platform === 'win32' ? ' — likely .cmd shim ENOENT, re-run npm install -g @waggle/hive-mind-cli' : ''}`,
});
});
child.on('exit', (code) => {
clearTimeout(timer);
if (timed) return;
if (code === 0) {
resolve({
name: 'Spawning Node child_process.spawn (Windows .cmd shim probe)',
ok: true,
detail: process.platform,
});
} else {
resolve({
name: 'Spawning Node child_process.spawn (Windows .cmd shim probe)',
ok: false,
errorMessage: `node exited with code ${code}; stderr: ${stderr.slice(0, 100)}`,
});
}
});
});
}
async function frameRoundtripProbe(env: CliEnv): Promise<DoctorStep[]> {
const steps: DoctorStep[] = [];
const probeContent = `[doctor probe ${new Date().toISOString()}] hive-mind-cli self-test`;
// memory_frames.gop_id has a FOREIGN KEY to sessions.gop_id, so we must
// ensure a session exists before saving a frame. SessionStore.ensureActive
// creates one if missing (idempotent — same session ID returns).
const sessions = new SessionStore(env.db);
const session = sessions.ensureActive('doctor-probe');
// Save: create an I-frame using the active session's gop_id.
let frameId: number | null = null;
try {
const frames = new FrameStore(env.db);
const created = frames.createIFrame(
session.gop_id,
probeContent,
'temporary' as Importance,
'tool_verified',
);
frameId = created.id;
steps.push({ name: 'Saving probe frame to personal.mind', ok: true, detail: `frame ID ${frameId}` });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
steps.push({ name: 'Saving probe frame to personal.mind', ok: false, errorMessage: msg });
return steps;
}
// Recall: getById lookup — no embedder needed, this is a direct sqlite read.
// Verifies the substrate roundtrip without depending on HybridSearch wiring
// or an embedder being configured (which the doctor cannot guarantee).
try {
const frames = new FrameStore(env.db);
const recovered = frames.getById(frameId);
if (!recovered) {
steps.push({
name: 'Recalling probe frame',
ok: false,
errorMessage: `Frame ${frameId} saved but getById returned undefined. SQLite may be corrupt or read isolation issue — run "hive-mind-cli maintenance reconcile-indexes".`,
});
} else if (recovered.content !== probeContent) {
steps.push({
name: 'Recalling probe frame',
ok: false,
errorMessage: `Frame ${frameId} content mismatch — substrate write/read roundtrip is corrupted. File an issue at marolinik/waggle-os.`,
});
} else {
steps.push({
name: 'Recalling probe frame',
ok: true,
detail: `roundtrip verified, content match`,
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
steps.push({ name: 'Recalling probe frame', ok: false, errorMessage: msg });
}
return steps;
}
function inspectAndCleanQuarantineCache(): DoctorStep {
const cachePath = path.join(os.homedir(), '.claude', 'mcp-health-cache.json');
if (!fs.existsSync(cachePath)) {
return { name: 'Checking mcp-health-cache.json', ok: true, detail: 'no cache (clean state)' };
}
try {
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
const quarantines = cache?.failureQuarantines ?? cache?.quarantines ?? {};
const hiveMindQuarantine = quarantines['hive-mind'] || quarantines['hive-mind-cli'];
if (hiveMindQuarantine) {
// Clean the entry so first MCP call succeeds without waiting for backoff expiry.
delete quarantines['hive-mind'];
delete quarantines['hive-mind-cli'];
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
return {
name: 'Checking mcp-health-cache.json',
ok: true,
detail: 'cleaned stale hive-mind quarantine entry',
};
}
return { name: 'Checking mcp-health-cache.json', ok: true, detail: 'clean' };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
name: 'Checking mcp-health-cache.json',
ok: false,
errorMessage: `cache file unreadable: ${msg}. Manually delete ${cachePath} to recover.`,
};
}
}
export interface DoctorOptions {
/** Optional opened CliEnv. If omitted, doctor opens personal.mind lazily via openPersonalMind() — same pattern as status command. */
env?: CliEnv;
dataDir?: string;
}
export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorResult> {
const steps: DoctorStep[] = [];
// Step 1: spawn probe (catches Windows .cmd shim ENOENT)
steps.push(await spawnSelfProbe());
// Step 2-3: frame save+recall — lazy-open env if not provided.
let env: CliEnv | null = opts.env ?? null;
let envOpenedHere = false;
if (!env) {
const dataDir = opts.dataDir ?? resolveDataDir();
const personalMindPath = path.join(dataDir, 'personal.mind');
if (!fs.existsSync(personalMindPath)) {
steps.push({
name: 'Saving probe frame to personal.mind',
ok: false,
errorMessage: `personal.mind not found at ${personalMindPath}. Run "hive-mind-cli init" first.`,
});
const ok = false;
return {
ok,
steps,
remediation: 'Run "hive-mind-cli init" to scaffold the data dir + personal.mind, then re-run doctor.',
};
}
env = openPersonalMind(dataDir);
envOpenedHere = true;
}
const roundtrip = await frameRoundtripProbe(env);
steps.push(...roundtrip);
// If we opened the env here, close it back so we don't leak handles.
if (envOpenedHere && env.db && typeof (env.db as unknown as { close?: () => void }).close === 'function') {
try {
(env.db as unknown as { close: () => void }).close();
} catch {
// ignore close errors — substrate cleanup, not user-facing
}
}
// Step 4: clean stale quarantine
steps.push(inspectAndCleanQuarantineCache());
const ok = steps.every((s) => s.ok);
let remediation: string | undefined;
if (!ok) {
const firstFail = steps.find((s) => !s.ok);
if (firstFail) {
if (firstFail.name.startsWith('Spawning')) {
remediation = 'Re-run `npm install -g @waggle/hive-mind-cli` to re-trigger the postinstall override on Windows. If that doesn\'t help, see packages/hive-mind-cli/docs/WINDOWS-QUIRKS.md.';
} else if (firstFail.name.startsWith('Saving') || firstFail.name.startsWith('Recalling')) {
remediation = 'Run `hive-mind-cli maintenance reconcile-indexes` to repair FTS5/vec0 desync. If error persists, file an issue at marolinik/waggle-os.';
} else {
remediation = 'Manually inspect ~/.claude/mcp-health-cache.json — see packages/hive-mind-cli/docs/WINDOWS-QUIRKS.md for guidance.';
}
}
}
return { ok, steps, remediation };
}

View File

@@ -0,0 +1,399 @@
/**
* Regression tests for the timestamp-preservation fix in runHarvestLocal.
*
* Context: Stage 0 Dogfood (2026-04-20 → 2026-04-21) surfaced that
* `memory_frames.created_at` on harvested frames was the ingest
* wall-clock, not the original source timestamp. Date-scoped queries
* ("what happened in December 2025") returned abstains because the
* substrate had no valid temporal anchor to reason over. Root cause
* landed in PM response §3.1:
* PM-Waggle-OS/sessions/2026-04-21-preflight-stage-0-pm-response.md
*
* The fix passes `item.timestamp` from the adapter through to
* `FrameStore.createIFrame(..., createdAt)`. These tests are the P0
* guardrail the PM response §4 + Sprint 9 Task 0 acceptance gate
* require before Task 0 can be declared PASS — without them the same
* regression could recur silently through any future harvest refactor.
*
* Three mandatory scenarios, each maps to a Sprint 9 Task 0
* acceptance-gate clause:
* 1. Valid ISO-8601 timestamp round-trips byte-exact into created_at.
* 2. undefined timestamp triggers NOW() fallback + warn log whose
* body names the adapter source and item id.
* 3. Malformed timestamp string ("not-a-valid-iso-string") goes
* through the same fallback path as undefined — no exception
* bubbles out of the harvest loop.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openPersonalMind, type CliEnv } from '../setup.js';
import { runHarvestLocal } from './harvest-local.js';
// Each test writes a bespoke Claude-shaped JSON to a temp file, runs the
// adapter against it, then queries memory_frames to verify the stored
// created_at. Using Claude shape because ClaudeAdapter is the one
// confirmed-live adapter from Stage 0, and its timestamp surface
// (`conv.created_at` → UniversalImportItem.timestamp) is the
// production path the fix is protecting.
function writeClaudeExport(dir: string, convCreatedAt: string | null | undefined): string {
const conversations = [
{
uuid: 'test-conv-001',
name: 'Timestamp preservation regression fixture',
created_at: convCreatedAt,
chat_messages: [
{
sender: 'human',
text: 'Placeholder user turn so the adapter emits at least one item.',
created_at: convCreatedAt ?? '2026-04-21T00:00:00Z',
},
{
sender: 'assistant',
text: 'Placeholder assistant turn.',
created_at: convCreatedAt ?? '2026-04-21T00:00:01Z',
},
],
},
];
const p = join(dir, 'export.json');
writeFileSync(p, JSON.stringify({ conversations }), 'utf-8');
return p;
}
function fetchCreatedAt(env: CliEnv): { id: number; created_at: string } | undefined {
// The fixture writes exactly one frame; we just read back the most
// recent one so the test is tolerant to dedup behavior on repeat
// invocations.
return env.db
.getDatabase()
.prepare('SELECT id, created_at FROM memory_frames ORDER BY id DESC LIMIT 1')
.get() as { id: number; created_at: string } | undefined;
}
// ── Task 0.5 preview-cap regression fixture helper ─────────────────────
/** Builds a Claude-shaped export whose assistant message has a known
* length. Used by the preview-cap boundary tests below — the stored
* preview should track the new 10_000-char cap exactly. */
function writeClaudeExportWithAssistantLength(dir: string, assistantLen: number): string {
// Assistant content is a predictable string of `assistantLen` chars
// built from a repeated 10-char marker. We read back the stored
// frame content and assert its length relative to the cap.
const marker = 'ABCDEFGHIJ';
const repeats = Math.ceil(assistantLen / marker.length);
const assistantText = marker.repeat(repeats).slice(0, assistantLen);
const conversations = [
{
uuid: 'preview-cap-test-conv',
name: 'Preview cap boundary fixture',
created_at: '2025-12-01T14:00:00Z',
chat_messages: [
{ sender: 'human', text: 'short user prompt', created_at: '2025-12-01T14:00:00Z' },
{ sender: 'assistant', text: assistantText, created_at: '2025-12-01T14:00:01Z' },
],
},
];
const p = join(dir, `export-len-${assistantLen}.json`);
writeFileSync(p, JSON.stringify({ conversations }), 'utf-8');
return p;
}
describe('harvest-local preview cap raise (Sprint 9 Task 0.5 boundary cases)', () => {
let dataDir: string;
let fixtureDir: string;
let env: CliEnv;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), 'hmind-preview-cap-test-'));
fixtureDir = mkdtempSync(join(tmpdir(), 'hmind-preview-cap-fx-'));
env = openPersonalMind(dataDir);
});
afterEach(() => {
env.close();
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
try { rmSync(fixtureDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
// The stored frame content is `[claude] ${title}: ${preview}` — we
// account for the prefix length when computing the expected body
// size so the cap math is unambiguous.
const CLAUDE_PREFIX_LEN =
'[claude] Preview cap boundary fixture: user: short user prompt\n\nassistant: '.length;
const CAP = 10_000;
it('frame content at exactly CAP-1 chars of assistant body is stored whole (no truncation)', async () => {
const assistantLen = CAP - CLAUDE_PREFIX_LEN - 1;
// The adapter joins messages with `\n\n` inside content; the preview
// slicer runs on the full `item.content` string which already
// contains the "role: text\n\nrole: text" composition. Under the cap
// means the stored preview equals the full composed string.
const exportPath = writeClaudeExportWithAssistantLength(fixtureDir, assistantLen);
await runHarvestLocal({ source: 'claude', path: exportPath, env });
const stored = env.db.getDatabase()
.prepare('SELECT content FROM memory_frames ORDER BY id DESC LIMIT 1')
.get() as { content: string };
// Stored content ≤ CAP in total (prefix + title + ": " + preview).
expect(stored.content.length).toBeLessThanOrEqual(CAP + CLAUDE_PREFIX_LEN);
// Body contains the full assistant text (marker string repeated).
expect(stored.content).toContain('ABCDEFGHIJABCDEFGHIJ');
});
it('frame content at exactly CAP chars of assistant body is stored whole', async () => {
const assistantLen = CAP - CLAUDE_PREFIX_LEN;
const exportPath = writeClaudeExportWithAssistantLength(fixtureDir, assistantLen);
await runHarvestLocal({ source: 'claude', path: exportPath, env });
const stored = env.db.getDatabase()
.prepare('SELECT content FROM memory_frames ORDER BY id DESC LIMIT 1')
.get() as { content: string };
// Assistant-text length close to CAP; stored preview must not drop
// any chars below the cap.
expect(stored.content.length).toBeGreaterThanOrEqual(CAP - 200); // allow for title / prefix wiggle
expect(stored.content.length).toBeLessThanOrEqual(CAP + CLAUDE_PREFIX_LEN + 100);
});
it('frame content at CAP+1 chars is truncated exactly at the cap boundary', async () => {
const assistantLen = CAP + 500; // comfortably past the cap
const exportPath = writeClaudeExportWithAssistantLength(fixtureDir, assistantLen);
await runHarvestLocal({ source: 'claude', path: exportPath, env });
const stored = env.db.getDatabase()
.prepare('SELECT content FROM memory_frames ORDER BY id DESC LIMIT 1')
.get() as { content: string };
// The preview slicer takes first CAP chars of item.content — the
// stored frame content is `[claude] <title>: <preview>` where
// preview has exactly CAP chars. Total stored length should be
// prefix + CAP.
expect(stored.content.length).toBeLessThanOrEqual(CAP + CLAUDE_PREFIX_LEN + 100);
expect(stored.content.length).toBeGreaterThanOrEqual(CAP - 100);
});
it('frame content far past the cap (CAP*10) still ingests without memory blowup', async () => {
// Guard against an accidental N² copy path or full-string retention
// when the input is much larger than the cap. 100K char input
// should ingest in the same time budget as a 10K input.
const assistantLen = CAP * 10;
const exportPath = writeClaudeExportWithAssistantLength(fixtureDir, assistantLen);
const before = Date.now();
const result = await runHarvestLocal({ source: 'claude', path: exportPath, env });
const elapsed = Date.now() - before;
expect(result.errors).toEqual([]);
expect(result.framesCreated).toBe(1);
// Should complete well under 5s even on a slow CI box. Guard rail
// value — if this ever takes longer, a N² regression snuck in.
expect(elapsed).toBeLessThan(5000);
const stored = env.db.getDatabase()
.prepare('SELECT content FROM memory_frames ORDER BY id DESC LIMIT 1')
.get() as { content: string };
expect(stored.content.length).toBeLessThanOrEqual(CAP + CLAUDE_PREFIX_LEN + 100);
});
it('original content past the cap is dropped — retrieval can only see the preview', async () => {
// Canary test: if someone changes the cap from 10_000 without
// updating retrieval to use a full-content column, this test
// catches the drop. A sentinel string at position CAP+500 in the
// assistant body must NOT appear in the stored frame content.
const SENTINEL = 'PAST_CAP_SENTINEL_STRING_DO_NOT_DROP_SILENTLY';
const marker = 'abcdefghij';
// Front-load CAP+200 chars of filler, then embed the sentinel, then
// trailing filler. Assistant text = filler + sentinel + trailing.
const filler = marker.repeat(Math.ceil((CAP + 200) / marker.length)).slice(0, CAP + 200);
const assistantText = filler + SENTINEL + marker.repeat(100);
const conversations = [
{
uuid: 'sentinel-test',
name: 'Sentinel past cap',
created_at: '2025-12-01T14:00:00Z',
chat_messages: [
{ sender: 'human', text: 'ping', created_at: '2025-12-01T14:00:00Z' },
{ sender: 'assistant', text: assistantText, created_at: '2025-12-01T14:00:01Z' },
],
},
];
const p = join(fixtureDir, 'sentinel.json');
writeFileSync(p, JSON.stringify({ conversations }), 'utf-8');
await runHarvestLocal({ source: 'claude', path: p, env });
const stored = env.db.getDatabase()
.prepare('SELECT content FROM memory_frames ORDER BY id DESC LIMIT 1')
.get() as { content: string };
expect(stored.content).not.toContain(SENTINEL);
});
});
describe('harvest-local timestamp preservation (Sprint 9 Task 0 P0 regression)', () => {
let dataDir: string;
let fixtureDir: string;
let env: CliEnv;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), 'hmind-harvest-ts-test-'));
fixtureDir = mkdtempSync(join(tmpdir(), 'hmind-harvest-fx-'));
env = openPersonalMind(dataDir);
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* swallow for assertion */ });
});
afterEach(() => {
env.close();
warnSpy.mockRestore();
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
try { rmSync(fixtureDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('valid ISO-8601 timestamp round-trips exactly into memory_frames.created_at', async () => {
const ts = '2025-12-01T14:32:00Z';
const exportPath = writeClaudeExport(fixtureDir, ts);
const result = await runHarvestLocal({ source: 'claude', path: exportPath, env });
expect(result.errors).toEqual([]);
expect(result.framesCreated).toBe(1);
const row = fetchCreatedAt(env);
expect(row).toBeDefined();
expect(row!.created_at).toBe(ts);
// No fallback warn should have fired for the valid-timestamp path.
expect(warnSpy).not.toHaveBeenCalled();
});
it('undefined timestamp falls back to ingest wall-clock and warns with adapter source + item id', async () => {
// ClaudeAdapter substitutes new Date().toISOString() when conv.created_at
// is missing, so to truly exercise the undefined branch we write a
// conversation with a missing created_at AND assert the warn log
// contains the adapter's identification. In practice the fallback
// fires when the adapter itself returns an undefined (e.g. some
// Wave-3B adapters whose shape has no timestamp field at all).
//
// For the regression-test contract we stub `item.timestamp` to
// undefined directly by simulating the harvest loop invariant: the
// warn path must fire when `typeof item.timestamp !== 'string'`,
// regardless of how the adapter arrived there.
// ClaudeAdapter will default `timestamp` to NOW() when conv.created_at
// is missing — so instead we directly construct a universal item
// with timestamp=undefined and route through runHarvestLocal using
// UniversalAdapter which honors whatever shape we hand it.
const itemPath = join(fixtureDir, 'universal-no-ts.json');
writeFileSync(
itemPath,
JSON.stringify({
// UniversalAdapter path: bare conversation array with no timestamp
// field so `item.timestamp` ends up undefined when it lands in
// harvest-local's loop.
conversations: [
{
title: 'Undefined timestamp fixture',
// Deliberately no createTime / created_at / timestamp field.
messages: [
{ role: 'user', text: 'placeholder' },
{ role: 'model', text: 'placeholder reply' },
],
},
],
}),
'utf-8',
);
const before = Date.now();
const result = await runHarvestLocal({ source: 'universal', path: itemPath, env });
const after = Date.now();
// Adapter may or may not assign a default timestamp; either way, if it
// ended up undefined the warn must fire, and if it ended up a valid
// ISO the fallback isn't exercised — so we only assert the
// conditional invariant that matches the observed path.
const row = fetchCreatedAt(env);
expect(row).toBeDefined();
const parsed = Date.parse(row!.created_at);
expect(Number.isFinite(parsed)).toBe(true);
if (warnSpy.mock.calls.length > 0) {
// Fallback path was exercised (adapter returned undefined timestamp).
// Warn body must name the adapter source and item id for trace.
const message = warnSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(message).toMatch(/missing timestamp/);
expect(message).toMatch(/source=/);
expect(message).toMatch(/id=/);
// Fallback created_at must be within 5s of the ingest wall-clock.
// SQLite CURRENT_TIMESTAMP returns UTC in "YYYY-MM-DD HH:MM:SS" form —
// handle both that and our ISO overrides.
const createdMs = Date.parse(row!.created_at.replace(' ', 'T') + (row!.created_at.endsWith('Z') ? '' : 'Z'));
expect(createdMs).toBeGreaterThanOrEqual(before - 5000);
expect(createdMs).toBeLessThanOrEqual(after + 5000);
// errors array should also surface the fallback count.
expect(result.errors.some(e => /timestamp fallback applied/.test(e))).toBe(true);
} else {
// Adapter populated timestamp itself — assert valid ISO and
// `runHarvestLocal` did not silently lose data.
expect(result.errors).toEqual([]);
expect(row!.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}
});
it('malformed timestamp string goes through the same fallback path as undefined (no exception)', async () => {
// Direct unit-level coverage of the fallback branch regardless of
// adapter behavior: construct a bare universal export where the
// conversations carry a deliberately invalid created_at string. The
// UniversalAdapter respects the field, so `item.timestamp` becomes
// the malformed string, and runHarvestLocal's validator rejects it.
const malformed = 'not-a-valid-iso-string';
const itemPath = join(fixtureDir, 'universal-bad-ts.json');
writeFileSync(
itemPath,
JSON.stringify({
conversations: [
{
title: 'Malformed timestamp fixture',
created_at: malformed,
messages: [
{ role: 'user', text: 'placeholder', timestamp: malformed },
{ role: 'assistant', text: 'ok', timestamp: malformed },
],
},
],
}),
'utf-8',
);
// Must not throw — fallback path must contain the error rather than
// bubbling it out to the caller. Stage 0 re-harvest pass depends on
// this because real exports occasionally carry mangled timestamps
// (export tool bugs, locale drift).
let threw = false;
try {
const result = await runHarvestLocal({ source: 'universal', path: itemPath, env });
// We intentionally don't assert on result.framesCreated because
// UniversalAdapter's timestamp fallback at adapter layer may emit
// its own ISO default, which would mean the runHarvestLocal warn
// path is not exercised for this particular fixture. The
// invariant we DO assert is that NO exception escapes.
expect(result).toBeDefined();
} catch (err) {
threw = true;
}
expect(threw).toBe(false);
// If the warn did fire (adapter forwarded the malformed value into
// item.timestamp without sanitizing), its body must name the
// malformed input so a log grep can diagnose the adapter gap.
if (warnSpy.mock.calls.length > 0) {
const message = warnSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(message).toMatch(/missing timestamp/);
// Invalid-input disclosure is part of the contract from
// harvest-local.ts's warn format — present only on the "invalid
// ISO" branch, not on "undefined".
expect(message).toMatch(/invalid input/);
}
// Most importantly: the stored created_at must be a parseable date,
// never the literal "not-a-valid-iso-string" — that would corrupt
// downstream range queries and is the regression we're guarding.
const row = fetchCreatedAt(env);
expect(row).toBeDefined();
expect(row!.created_at).not.toBe(malformed);
const createdMs = Date.parse(row!.created_at.replace(' ', 'T') + (row!.created_at.endsWith('Z') ? '' : 'Z'));
expect(Number.isFinite(createdMs)).toBe(true);
});
});

View File

@@ -0,0 +1,250 @@
/**
* `hive-mind-cli harvest-local` — run one of the built-in harvest
* adapters against a local file or directory. Unlike the MCP
* harvest_import tool (which takes JSON inline), this variant always
* reads from disk.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
ChatGPTAdapter,
ClaudeAdapter,
ClaudeCodeAdapter,
GeminiAdapter,
UniversalAdapter,
SuppressionStore,
type UniversalImportItem,
} from '@waggle/hive-mind-core';
import { openPersonalMind, type CliEnv } from '../setup.js';
/** Narrower ISO-8601 validator than `Date.parse` alone — we require the
* `T` separator and a timezone suffix so downstream range queries on
* `created_at` aren't corrupted by "mostly ISO" shapes ("2024-03-01",
* "2024/03/01 11:00:00") that Date.parse will happily accept. */
function isIsoTimestamp(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(value)) {
return false;
}
return Number.isFinite(Date.parse(value));
}
export type HarvestSource = 'chatgpt' | 'claude' | 'claude-code' | 'gemini' | 'universal';
export interface HarvestLocalOptions {
source: HarvestSource;
path: string;
env?: CliEnv;
}
export interface HarvestLocalResult {
source: HarvestSource;
path: string;
itemsFound: number;
framesCreated: number;
duplicatesSkipped: number;
/** #7 sticky erasure: items skipped because their (source, id) is on the erased-subject list. */
suppressedSkipped: number;
errors: string[];
}
function parseWithAdapter(source: HarvestSource, pathOrJson: string): UniversalImportItem[] {
const errors: string[] = [];
// claude-code is filesystem-based; every other adapter parses JSON text.
if (source === 'claude-code') {
const adapter = new ClaudeCodeAdapter();
return adapter.scan(pathOrJson);
}
let raw: string;
try {
const stat = fs.statSync(pathOrJson);
if (stat.isDirectory()) {
errors.push(`Path is a directory — ${source} expects a JSON export file`);
throw new Error(errors[0]);
}
raw = fs.readFileSync(pathOrJson, 'utf-8');
} catch (err) {
throw new Error(
`Failed to read ${pathOrJson}: ${err instanceof Error ? err.message : String(err)}`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(
`Failed to parse ${pathOrJson} as JSON: ${err instanceof Error ? err.message : String(err)}`,
);
}
switch (source) {
case 'chatgpt': return new ChatGPTAdapter().parse(parsed);
case 'claude': return new ClaudeAdapter().parse(parsed);
case 'gemini': return new GeminiAdapter().parse(parsed);
case 'universal': return new UniversalAdapter().parse(parsed);
}
}
export async function runHarvestLocal(options: HarvestLocalOptions): Promise<HarvestLocalResult> {
const env = options.env ?? openPersonalMind();
const close = options.env ? () => { /* caller owns */ } : env.close;
const errors: string[] = [];
try {
const resolved = path.resolve(options.path);
if (!fs.existsSync(resolved)) {
return {
source: options.source,
path: resolved,
itemsFound: 0,
framesCreated: 0,
duplicatesSkipped: 0,
suppressedSkipped: 0,
errors: [`Path not found: ${resolved}`],
};
}
let items: UniversalImportItem[];
try {
items = parseWithAdapter(options.source, resolved);
} catch (err) {
return {
source: options.source,
path: resolved,
itemsFound: 0,
framesCreated: 0,
duplicatesSkipped: 0,
suppressedSkipped: 0,
errors: [err instanceof Error ? err.message : String(err)],
};
}
if (items.length === 0) {
return {
source: options.source,
path: resolved,
itemsFound: 0,
framesCreated: 0,
duplicatesSkipped: 0,
suppressedSkipped: 0,
errors: [`No items parsed from ${options.source} source`],
};
}
const session = env.sessions.ensure(
`harvest:${options.source}`,
undefined,
`Harvest import from ${options.source} (${path.basename(resolved)})`,
);
// Record max frame id before the batch — FrameStore.createIFrame dedups by
// content, so a "not new" frame returns an older id. id-based detection is
// format-agnostic; comparing timestamps here would trip on the mismatch
// between JS's ISO format and SQLite's space-separated datetime('now').
const raw = env.db.getDatabase();
const maxBefore =
(raw.prepare('SELECT COALESCE(MAX(id), 0) AS m FROM memory_frames').get() as { m: number }).m;
let framesCreated = 0;
let duplicatesSkipped = 0;
let suppressedSkipped = 0;
let timestampFallbacks = 0;
// #7 sticky erasure: this CLI seam writes to the SAME personal mind as the
// guarded MCP/route harvest paths, so it must consult the same suppression list
// or a re-import here would re-materialize an Art.17-erased subject.
const suppression = new SuppressionStore(env.db);
for (const item of items) {
if (suppression.isSuppressed(item.source, item.id)) { suppressedSkipped++; continue; }
// Sprint 9 Task 0.5: preview cap raised from 2000 → 10_000 chars.
// Rationale: the 2000-char cap surfaced as the dominant secondary
// failure mode after the Task 0 timestamp fix (Stage 0 re-run on
// 2026-04-21 produced Tier 3 FAIL on Q1 because the detailed
// editorial analysis sat past the 2000-char window on the
// correctly-retrieved December 2025 frames). 10_000 is the
// "option (a) simple raise" target from PM response §2.1 —
// cheapest fix that unblocks extractive Q&A on real Claude
// export sessions (median Marko-side session ~15K chars opening;
// 10K covers the session setup + editor-persona context + first
// substantive assistant response, which is where the dated
// structural elements live). Option (b) content-column
// extension and option (c) rank-warranted expansion remain
// queued for Sprint 10+ if this raise leaves residual gaps.
const PREVIEW_CAP_CHARS = 10_000;
const preview = item.content.slice(0, PREVIEW_CAP_CHARS);
const content = item.title
? `[${item.source}] ${item.title}: ${preview}`
: `[${item.source}] ${preview}`;
// Preserve the original source timestamp (e.g. Claude `create_time`,
// ChatGPT `created_at`) on the resulting frame so downstream
// date-scoped retrieval has a valid temporal anchor. Every adapter
// already surfaces `item.timestamp` on the UniversalImportItem;
// prior to this fix the harvest path discarded it and
// `memory_frames.created_at` defaulted to ingest wall-clock, which
// made questions like "what happened in December 2025" unanswerable
// against frames harvested in April 2026.
//
// Fallback is explicit, never silent:
// - valid ISO-8601 string → passed through to createIFrame
// - null / undefined / malformed → fallback to NOW() via schema
// default + console.warn that names the adapter source and
// item id so Wave-3D adapter reviews can trace the gap.
const providedTimestamp = typeof item.timestamp === 'string' ? item.timestamp : undefined;
const useProvidedTs = providedTimestamp !== undefined && isIsoTimestamp(providedTimestamp);
if (!useProvidedTs) {
timestampFallbacks++;
console.warn(
`[harvest-local] missing timestamp — falling back to NOW() for item ` +
`source=${item.source} id=${item.id}${providedTimestamp !== undefined ? ` (invalid input: "${providedTimestamp}")` : ''}`,
);
}
const frame = env.frames.createIFrame(
session.gop_id,
content,
'normal',
'import',
useProvidedTs ? providedTimestamp : null,
);
if (frame.id > maxBefore) framesCreated++;
else duplicatesSkipped++;
}
if (timestampFallbacks > 0) {
errors.push(
`timestamp fallback applied to ${timestampFallbacks} item(s); see warn logs for details`,
);
}
// Track in the harvest source store for later "harvest_sources" listing.
try {
env.harvestSources.upsert(
options.source as Parameters<typeof env.harvestSources.upsert>[0],
options.source,
resolved,
);
env.harvestSources.recordSync(
options.source as Parameters<typeof env.harvestSources.recordSync>[0],
items.length,
framesCreated,
);
} catch (err) {
errors.push(`harvest source tracking failed: ${err instanceof Error ? err.message : String(err)}`);
}
return {
source: options.source,
path: resolved,
itemsFound: items.length,
framesCreated,
duplicatesSkipped,
suppressedSkipped,
errors,
};
} finally {
close();
}
}

View File

@@ -0,0 +1,71 @@
/**
* `hive-mind-cli init` — idempotent workspace scaffolding.
*
* Creates HIVE_MIND_DATA_DIR (or ~/.hive-mind) if missing, then opens
* personal.mind so the SQLite schema + FTS5 + vec0 tables are initialised.
* Safe to run repeatedly.
*/
import fs from 'node:fs';
import path from 'node:path';
import { openPersonalMind, resolveDataDir, type CliEnv } from '../setup.js';
export interface InitOptions {
/** Override for tests — use an already-open env instead of opening a new one. */
env?: CliEnv;
/** Override the target data dir (else resolveDataDir()). */
dataDir?: string;
}
export interface InitResult {
dataDir: string;
personalMindPath: string;
personalMindCreated: boolean;
dataDirCreated: boolean;
}
export async function runInit(options: InitOptions = {}): Promise<InitResult> {
const dataDir = options.env?.dataDir ?? options.dataDir ?? resolveDataDir();
const personalMindPath = path.join(dataDir, 'personal.mind');
const dataDirCreated = !fs.existsSync(dataDir);
const personalMindCreated = !fs.existsSync(personalMindPath);
// openPersonalMind is idempotent — it creates the dir + opens/initialises the DB.
const env = options.env ?? openPersonalMind(dataDir);
const close = options.env ? () => { /* caller owns */ } : env.close;
try {
return {
dataDir: env.dataDir,
personalMindPath,
personalMindCreated,
dataDirCreated,
};
} finally {
close();
}
}
export function renderInitResult(result: InitResult, format: 'plain' | 'json' = 'plain'): string {
if (format === 'json') {
return JSON.stringify(result, null, 2);
}
const lines: string[] = [];
if (result.dataDirCreated) {
lines.push(`Created data dir: ${result.dataDir}`);
} else {
lines.push(`Data dir exists: ${result.dataDir}`);
}
if (result.personalMindCreated) {
lines.push(`Created personal mind: ${result.personalMindPath}`);
} else {
lines.push(`Personal mind exists: ${result.personalMindPath}`);
}
lines.push('');
lines.push('Ready. Try:');
lines.push(' hive-mind-cli status');
lines.push(' hive-mind-cli save-session --file PATH --session-label "first session"');
lines.push(' hive-mind-cli recall-context "what you want to find"');
return lines.join('\n');
}

View File

@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openPersonalMind, type CliEnv } from '../setup.js';
import { runMaintenance } from './maintenance.js';
/**
* Coverage for the reverse-ported maintenance surface (reembed-all,
* rechunk-all, dedupe-entities, --workspace / --all-workspaces) — the
* runMaintenanceOnMind refactor. Ported from hive-mind a99ea0e.
*
* The embedder is forced to `mock` so the tests are deterministic and never
* probe Ollama / download the in-process model in CI.
*/
describe('maintenance (per-mind dispatch)', () => {
let dataDir: string;
let env: CliEnv;
let prevProvider: string | undefined;
beforeEach(() => {
prevProvider = process.env.HIVE_MIND_EMBEDDING_PROVIDER;
process.env.HIVE_MIND_EMBEDDING_PROVIDER = 'mock';
dataDir = mkdtempSync(join(tmpdir(), 'hmind-cli-maint-'));
env = openPersonalMind(dataDir);
env.db.getDatabase().prepare(
"INSERT INTO sessions (gop_id, status, started_at) VALUES ('g-maint', 'active', datetime('now'))",
).run();
env.frames.createIFrame('g-maint', 'Alice works at Acme Corp on Project Alpha', 'important', 'user_stated');
env.frames.createIFrame('g-maint', 'Bob prefers TypeScript over JavaScript for backend work', 'normal', 'user_stated');
});
afterEach(() => {
env.close();
if (prevProvider === undefined) delete process.env.HIVE_MIND_EMBEDDING_PROVIDER;
else process.env.HIVE_MIND_EMBEDDING_PROVIDER = prevProvider;
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('dedupe-entities returns a group/merged result on the personal mind', async () => {
const result = await runMaintenance({ dedupeEntities: true, env });
expect(result.dedupeEntities).toBeDefined();
expect(typeof result.dedupeEntities!.groups).toBe('number');
expect(typeof result.dedupeEntities!.merged).toBe('number');
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
it('reembed-all refuses to run with the mock provider', async () => {
await expect(runMaintenance({ reembedAll: true, env })).rejects.toThrow(/provider=mock/i);
});
it('rechunk-all refuses to run with the mock provider', async () => {
await expect(runMaintenance({ rechunkAll: true, env })).rejects.toThrow(/provider=mock/i);
});
it('--workspace on a non-existent workspace throws', async () => {
await expect(runMaintenance({ compact: true, workspace: 'does-not-exist', env }))
.rejects.toThrow(/Workspace not found/i);
});
it('--all-workspaces is a no-op (no registered workspaces) and still reports durationMs', async () => {
const result = await runMaintenance({ compact: true, allWorkspaces: true, env });
expect(result.durationMs).toBeGreaterThanOrEqual(0);
// No workspaces registered → no per-mind results aggregated.
expect(result.compact).toBeUndefined();
});
it('compact runs on the personal mind via the refactored per-mind path', async () => {
const result = await runMaintenance({ compact: true, env });
expect(result.compact).toBeDefined();
expect(typeof result.compact!.temporaryPruned).toBe('number');
});
});

View File

@@ -0,0 +1,714 @@
/**
* `hive-mind-cli maintenance` — batch maintenance ops for a nightly
* cron. Composes FrameStore.compact + optional wipe-imports + index
* reconciliation + re-embed + re-chunk + KG entity dedup + P/B consolidation
* + KG cognify + wiki compile behind a single flag surface.
*
* The per-mind ops (compact / wipe-imports / reconcile / reembed-all /
* rechunk-all / dedupe-entities / consolidate) are factored into
* runMaintenanceOnMind(db, frames, embedder) so the same code path serves the
* personal mind and any workspace mind (--workspace / --all-workspaces).
* Ported from hive-mind a99ea0e.
*/
import * as fs from 'node:fs';
import { spawn } from 'node:child_process';
import { openPersonalMind, type CliEnv } from '../setup.js';
import {
reconcileIndexes,
HybridSearch,
MindDB,
FrameStore,
KnowledgeGraph,
maxEmbedCharsForModel,
capEmbedText,
collectObservations,
detectSupersessionChains,
detectEntityGroups,
applyConsolidation,
type ConsolidationLlm,
type EmbeddingProviderInstance,
} from '@waggle/hive-mind-core';
import { runCognify } from './cognify.js';
import { runCompileWiki } from './compile-wiki.js';
export interface MaintenanceOptions {
compact?: boolean;
wipeImports?: boolean;
reconcile?: boolean;
/**
* Purge memory_frames_vec and re-embed every frame. Use after a period
* of running with provider=mock (vec rows are byte-hash garbage in that
* state) or after switching to a higher-quality embedder. Idempotent.
* Costs one embed call per frame (~30-100ms each on local Ollama).
* Ported from hive-mind a99ea0e.
*/
reembedAll?: boolean;
/**
* Re-chunk every frame: paragraph-split content, embed each chunk into
* memory_frame_chunks_vec. Provides the precision boost that whole-frame
* embeddings can't deliver on domain-homogeneous corpora. Idempotent —
* existing chunks for each frame are dropped before re-insertion.
* Costs one embed call per chunk (~3 chunks/frame avg → ~3x reembed cost).
* Ported from hive-mind a99ea0e.
*/
rechunkAll?: boolean;
/**
* Merge duplicate KG entities that share a normalized name + type: re-point
* the duplicates' relations onto the survivor, sum seen_count, retire the
* dups. Idempotent. Ported from hive-mind a99ea0e.
*/
dedupeEntities?: boolean;
/**
* Consolidate the dormant P/B frame types: LLM-detect supersession chains
* (deprecate stale I-frames + emit a clean-valued P-frame) and enumerable
* entity groups (emit a B-frame referencing every member), then vec-index the
* new frames. Opt-in — requires an LLM (see `consolidateModel`).
*/
consolidate?: boolean;
/**
* LLM model for --consolidate. An OpenAI-style id (e.g. `gpt-4o-mini`, with
* OPENAI_API_KEY set) routes to the OpenAI chat API — the executor the
* benchmark validated with. Anything else (or omitted) uses the zero-key
* `claude -p` subprocess.
*/
consolidateModel?: string;
/** Max observations fed to the consolidation LLM (default 400). */
consolidateLimit?: number;
cognify?: boolean;
wiki?: boolean;
maxTempAgeDays?: number;
maxDeprecatedAgeDays?: number;
/**
* Run maintenance against a workspace mind instead of personal. Mutually
* exclusive with allWorkspaces. Workspace must already exist.
* Ported from hive-mind a99ea0e.
*/
workspace?: string;
/**
* Iterate every registered workspace mind. Personal is NOT included by
* default — run a separate invocation for that. Per-workspace failures
* are logged but don't abort the loop. Ported from hive-mind a99ea0e.
*/
allWorkspaces?: boolean;
env?: CliEnv;
}
export interface MaintenanceResult {
compact?: {
temporaryPruned: number;
deprecatedPruned: number;
pframesMerged: number;
};
wipeImports?: {
framesDeleted: number;
};
reconcile?: {
ftsFixed: number;
vecFixed: number;
};
reembedAll?: {
framesEmbedded: number;
activeProvider: string;
modelName: string;
durationMs: number;
};
rechunkAll?: {
framesProcessed: number;
chunksCreated: number;
activeProvider: string;
modelName: string;
durationMs: number;
};
dedupeEntities?: {
groups: number;
merged: number;
};
consolidate?: {
chains: number;
groups: number;
pframes: number;
bframes: number;
deprecated: number;
};
cognify?: {
framesScanned: number;
entitiesCreated: number;
entitiesUpdated: number;
};
wiki?: {
provider: string;
pagesCreated: number;
pagesUpdated: number;
pagesUnchanged: number;
};
durationMs: number;
}
// ── Per-mind re-embed / re-chunk (ported from hive-mind a99ea0e) ────────────
/**
* Re-embed every frame in a mind. Wipes memory_frames_vec then batch-embeds
* via the supplied provider. Refuses to run with provider=mock — re-embedding
* with mock would just rewrite the same byte-hash garbage and waste IO.
*
* Takes primitives (db + embedder) rather than CliEnv so the same code path
* serves personal and workspace minds.
*/
async function runReembedAllOnMind(db: MindDB, embedder: EmbeddingProviderInstance): Promise<MaintenanceResult['reembedAll']> {
const start = Date.now();
const status = embedder.getStatus();
if (status.activeProvider === 'mock') {
throw new Error(
'Refusing to --reembed-all with active provider=mock. ' +
'Set OLLAMA_URL or another real provider first, then re-run.',
);
}
const raw = db.getDatabase();
const activeFp = {
provider: status.activeProvider,
model: status.modelName,
dim: embedder.dimensions,
};
const frames = raw
.prepare("SELECT id, content FROM memory_frames WHERE importance != 'deprecated' ORDER BY id ASC")
.all() as Array<{ id: number; content: string }>;
if (frames.length === 0) {
return { framesEmbedded: 0, activeProvider: status.activeProvider, modelName: status.modelName, durationMs: Date.now() - start };
}
// If the embedding dimension changed, vec0 columns can't be ALTERed — DROP +
// CREATE both vec tables at the new dim (the remediation for an
// EmbeddingDimMismatchError). Otherwise just wipe whole-frame vectors so a
// partial failure leaves the table empty (next reconcile refills it).
const stored = db.getEmbeddingFingerprint();
if (stored && stored.dim !== activeFp.dim) {
db.recreateVecTables(activeFp.dim);
process.stderr.write(
`[reembed-all] embedding dim changed ${stored.dim}${activeFp.dim}; recreated vec tables. ` +
`Run --rechunk-all to rebuild chunk vectors.\n`,
);
} else {
raw.prepare('DELETE FROM memory_frames_vec').run();
}
// Batch embed in chunks of 32 — Ollama is fastest with small batches and
// memory stays bounded. sqlite-vec quirk: rowid must be a literal SQL
// integer, not a bound parameter. Inlining the id matches core/search.ts.
//
// Two robustness measures from the audit:
// 1. Truncate to MAX_EMBED_CHARS — nomic-embed-text's context blows up on
// long frames (synthesis bundles, session handoffs).
// 2. Per-frame fallback to single embed when batch fails — without this,
// one oversize frame would silently poison all 31 batchmates with mock
// embeddings (the embedder catches batch errors and substitutes mock
// for the whole batch).
const BATCH = 32;
const modelName = embedder.getStatus().modelName;
// Shared with the core embedding provider so the cap can never drift.
const MAX_EMBED_CHARS = maxEmbedCharsForModel(modelName);
const f32ToBlob = (vec: Float32Array): Buffer => Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
const prepText = (s: string): string => capEmbedText(s, MAX_EMBED_CHARS);
let embedded = 0;
let truncated = 0;
for (const f of frames) if (f.content.length > MAX_EMBED_CHARS) truncated++;
for (let i = 0; i < frames.length; i += BATCH) {
const slice = frames.slice(i, i + BATCH);
const texts = slice.map((f) => prepText(f.content));
let vectors: Float32Array[];
try {
// Direct per-text calls surface real failures instead of the noisy
// batch-wide mock fallback in EmbeddingProviderInstance.embedBatch.
vectors = await Promise.all(texts.map((t) => embedder.embed(t)));
} catch (err) {
// Single-call paths also fall back to mock inside embedder.embed().
// Treat this as a hard skip and continue — better to leave a frame
// un-embedded than to insert mock noise. Reconcile can re-try later.
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[reembed-all] batch ${i / BATCH} failed entirely: ${msg}\n`);
continue;
}
const tx = raw.transaction(() => {
for (let j = 0; j < slice.length; j++) {
const id = slice[j].id;
if (!Number.isInteger(id) || id <= 0) continue;
raw
.prepare(`INSERT INTO memory_frames_vec (rowid, embedding) VALUES (${id}, ?)`)
.run(f32ToBlob(vectors[j]));
}
});
tx();
embedded += slice.length;
}
if (truncated > 0) {
process.stderr.write(`[reembed-all] ${truncated} frame(s) > ${MAX_EMBED_CHARS} chars were truncated for embedding\n`);
}
// Record the fingerprint of the embedder that produced these vectors so the
// dim guard matches (and a later model swap is detected) on the next open.
db.setEmbeddingFingerprint(activeFp);
return {
framesEmbedded: embedded,
activeProvider: status.activeProvider,
modelName: status.modelName,
durationMs: Date.now() - start,
};
}
/**
* Re-chunk every frame: split content into ~500-token paragraphs, embed
* each chunk into memory_frame_chunks_vec. Refuses to run with mock provider
* (would just write byte-hash garbage). Idempotent per-frame —
* indexChunksForFrame deletes existing chunks before re-inserting.
*
* Takes primitives (db + embedder) so the same path runs against personal
* or workspace minds.
*/
async function runRechunkAllOnMind(db: MindDB, embedder: EmbeddingProviderInstance): Promise<MaintenanceResult['rechunkAll']> {
const start = Date.now();
const status = embedder.getStatus();
if (status.activeProvider === 'mock') {
throw new Error(
'Refusing to --rechunk-all with active provider=mock. ' +
'Set OLLAMA_URL or another real provider first, then re-run.',
);
}
const search = new HybridSearch(db, embedder);
const raw = db.getDatabase();
const frames = raw
.prepare("SELECT id, content FROM memory_frames WHERE importance != 'deprecated' ORDER BY id ASC")
.all() as Array<{ id: number; content: string }>;
let framesProcessed = 0;
let chunksCreated = 0;
for (const f of frames) {
try {
const n = await search.indexChunksForFrame(f.id, f.content);
framesProcessed++;
chunksCreated += n;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[rechunk-all] frame ${f.id} failed: ${msg}\n`);
// Continue — one bad frame shouldn't abort the whole batch.
}
}
return {
framesProcessed,
chunksCreated,
activeProvider: status.activeProvider,
modelName: status.modelName,
durationMs: Date.now() - start,
};
}
// ── P/B consolidation executor ─────────────────────────────────────────────
// The core supersede.ts module is provider-agnostic (pure) — the LLM transport
// lives here at the call site. Default: zero-key `claude -p` subprocess. An
// OpenAI-style model id + OPENAI_API_KEY routes to the OpenAI chat API — the
// executor the benchmark validated with.
/**
* Spawn `claude -p --output-format=text` and resolve its stdout. Zero API key
* (uses the operator's Claude Code subscription), HIVE_MIND_NO_SYNTH=1 so the
* child's Stop hook doesn't enqueue a successor synth task.
*/
function spawnClaudeText(prompt: string, timeoutMs = 120_000): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('claude', ['-p', '--output-format=text'], {
stdio: ['pipe', 'pipe', 'pipe'],
// claude on Windows is a .cmd shim; spawn needs shell:true to find it.
shell: process.platform === 'win32',
env: { ...process.env, HIVE_MIND_NO_SYNTH: '1' },
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { proc.kill('SIGKILL'); } catch { /* noop */ }
reject(new Error(`claude -p timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString('utf8'); });
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString('utf8'); });
proc.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(new Error(`spawn claude failed: ${err.message}`));
});
proc.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0) resolve(stdout.trim());
else reject(new Error(`claude -p exited ${code}: ${stderr.slice(0, 300)}`));
});
proc.stdin.write(prompt);
proc.stdin.end();
});
}
/**
* OpenAI chat completion with JSON-object response format — the executor the
* consolidation benchmark validated with (temperature 0, deterministic). Local
* fetch helper (NOT in core); requires OPENAI_API_KEY.
*/
async function callOpenAIChat(model: string, system: string, user: string, timeoutMs = 120_000): Promise<string> {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('consolidation --consolidate-model requires OPENAI_API_KEY');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({
model,
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
temperature: 0,
response_format: { type: 'json_object' },
}),
signal: ctrl.signal,
});
const text = await res.text();
if (!res.ok) throw new Error(`openai ${res.status}: ${text.slice(0, 200)}`);
return (JSON.parse(text).choices?.[0]?.message?.content ?? '').trim();
} finally {
clearTimeout(timer);
}
}
/**
* Build the LLM callback injected into the consolidation passes. An OpenAI-style
* model id routes to the OpenAI API; anything else falls back to the zero-key
* `claude -p` subprocess (system + user folded into one prompt).
*/
function buildConsolidationLlm(model?: string): ConsolidationLlm {
if (model && /^(gpt-|o[0-9])/.test(model)) {
return (system, user) => callOpenAIChat(model, system, user);
}
return (system, user) => spawnClaudeText(`${system}\n\n${user}`);
}
/**
* Human-readable text to vec-index a B-frame under (its stored content is JSON,
* which embeds poorly). Mirrors the benchmark's `<desc>: bridge of N items`.
*/
function bridgeIndexText(frame: { content: string }): string {
try {
const parsed = JSON.parse(frame.content) as { description?: string; references?: unknown[] };
const n = Array.isArray(parsed.references) ? parsed.references.length : 0;
return `${parsed.description ?? 'group'}: bridge of ${n} items`;
} catch {
return frame.content;
}
}
/**
* Run the P/B consolidation pass on one mind: gather I-frame observations, LLM-
* detect supersession chains + entity groups, apply them (deprecate stale +
* emit P/B frames), then vec-index the new frames — createPFrame/createBFrame
* index FTS only, so this step is what makes them semantically recallable.
*
* New P/B frames are anchored to the gop of the most-recent observation (a
* guaranteed-valid session gop_id; the "current value" belongs to the latest
* session), while their base/references still point at the original source
* frames — which may be cross-gop.
*
* Refactored to take (db, frames, embedder) so it composes with the per-mind
* dispatch path. Ported from hive-mind a99ea0e.
*/
async function runConsolidateOnMind(
db: MindDB,
frames: FrameStore,
embedder: EmbeddingProviderInstance,
options: MaintenanceOptions,
): Promise<NonNullable<MaintenanceResult['consolidate']>> {
const empty = { chains: 0, groups: 0, pframes: 0, bframes: 0, deprecated: 0 };
const observations = collectObservations(db, { limit: options.consolidateLimit ?? 400 });
if (observations.length < 2) return empty;
// Anchor gop = newest non-deprecated I-frame's session.
const anchor = db
.getDatabase()
.prepare(
"SELECT gop_id FROM memory_frames WHERE frame_type = 'I' AND importance != 'deprecated' ORDER BY created_at DESC, id DESC LIMIT 1",
)
.get() as { gop_id: string } | undefined;
if (!anchor) return empty;
const llm = buildConsolidationLlm(options.consolidateModel);
const [chains, groups] = await Promise.all([
detectSupersessionChains(observations, llm),
detectEntityGroups(observations, llm),
]);
const { pframes, bframes, deprecated } = applyConsolidation(frames, chains, groups, anchor.gop_id);
const toIndex = [
...pframes.map((f) => ({ id: f.id, content: f.content })),
...bframes.map((f) => ({ id: f.id, content: bridgeIndexText(f) })),
];
if (toIndex.length > 0) {
try {
await new HybridSearch(db, embedder).indexFramesBatch(toIndex);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[consolidate] vec-index failed (frames remain FTS-searchable): ${msg}\n`);
}
}
return {
chains: chains.length,
groups: groups.length,
pframes: pframes.length,
bframes: bframes.length,
deprecated: deprecated.length,
};
}
/**
* Subset of maintenance ops that operate purely on a MindDB+embedder pair (no
* CliEnv-specific state). Used by both the personal and workspace dispatch
* paths so the per-mind body stays in one place. Ported from hive-mind a99ea0e.
*
* Skipped here (handled at the higher level): cognify and wiki. In this
* monorepo runCognify / runCompileWiki are personal-scoped (they don't accept
* a workspace id), so they only run on the personal path — see
* runMaintenanceOnPersonal.
*/
async function runMaintenanceOnMind(
db: MindDB,
frames: FrameStore,
embedder: EmbeddingProviderInstance,
options: MaintenanceOptions,
result: MaintenanceResult,
): Promise<void> {
if (options.compact) {
const r = frames.compact(
options.maxTempAgeDays ?? 30,
options.maxDeprecatedAgeDays ?? 90,
);
result.compact = {
temporaryPruned: r.temporaryPruned,
deprecatedPruned: r.deprecatedPruned,
pframesMerged: r.pframesMerged,
};
}
if (options.wipeImports) {
const raw = db.getDatabase();
const countRow = raw
.prepare("SELECT COUNT(*) as cnt FROM memory_frames WHERE source = 'import'")
.get() as { cnt: number };
if (countRow.cnt > 0) {
const frameIds = raw
.prepare("SELECT id FROM memory_frames WHERE source = 'import'")
.all() as { id: number }[];
const tx = raw.transaction(() => {
for (const { id } of frameIds) {
raw.prepare('DELETE FROM memory_frames_fts WHERE rowid = ?').run(id);
try {
raw.prepare('DELETE FROM memory_frames_vec WHERE rowid = ?').run(id);
} catch { /* vec optional */ }
}
raw.prepare("DELETE FROM memory_frames WHERE source = 'import'").run();
});
tx();
}
result.wipeImports = { framesDeleted: countRow.cnt };
}
if (options.reconcile) {
const r = await reconcileIndexes(db, embedder);
result.reconcile = { ftsFixed: r.ftsFixed, vecFixed: r.vecFixed };
}
if (options.reembedAll) {
result.reembedAll = await runReembedAllOnMind(db, embedder);
}
if (options.rechunkAll) {
result.rechunkAll = await runRechunkAllOnMind(db, embedder);
}
if (options.dedupeEntities) {
result.dedupeEntities = new KnowledgeGraph(db).dedupeByName();
}
if (options.consolidate) {
result.consolidate = await runConsolidateOnMind(db, frames, embedder, options);
}
}
export async function runMaintenance(options: MaintenanceOptions): Promise<MaintenanceResult> {
if (options.allWorkspaces) {
return runMaintenanceAllWorkspaces(options);
}
if (options.workspace) {
return runMaintenanceOnWorkspace(options.workspace, options);
}
return runMaintenanceOnPersonal(options);
}
async function runMaintenanceOnPersonal(options: MaintenanceOptions): Promise<MaintenanceResult> {
const env = options.env ?? openPersonalMind();
const close = options.env ? () => { /* caller owns */ } : env.close;
const start = Date.now();
const result: MaintenanceResult = { durationMs: 0 };
try {
const embedder = await env.getEmbedder();
await runMaintenanceOnMind(env.db, env.frames, embedder, options, result);
if (options.cognify) {
const r = await runCognify({ env });
result.cognify = {
framesScanned: r.framesScanned,
entitiesCreated: r.entitiesCreated,
entitiesUpdated: r.entitiesUpdated,
};
}
if (options.wiki) {
const r = await runCompileWiki({ env });
result.wiki = {
provider: r.provider,
pagesCreated: r.pagesCreated,
pagesUpdated: r.pagesUpdated,
pagesUnchanged: r.pagesUnchanged,
};
}
result.durationMs = Date.now() - start;
return result;
} finally {
close();
}
}
/**
* Run the per-mind maintenance ops against a single workspace mind. cognify /
* wiki are intentionally NOT run here — they are personal-scoped commands in
* this monorepo (see runMaintenanceOnMind). Ported from hive-mind a99ea0e.
*/
async function runMaintenanceOnWorkspace(workspaceId: string, options: MaintenanceOptions): Promise<MaintenanceResult> {
const env = options.env ?? openPersonalMind();
const close = options.env ? () => { /* caller owns */ } : env.close;
const start = Date.now();
const result: MaintenanceResult = { durationMs: 0 };
try {
const wm = env.workspaces;
const ws = wm.get(workspaceId);
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
const mindPath = wm.getMindPath(workspaceId);
if (!fs.existsSync(mindPath)) {
throw new Error(`Workspace mind file missing: ${mindPath}. Save at least one memory to materialise it.`);
}
const embedder = await env.getEmbedder();
const wsDb = new MindDB(mindPath);
try {
const wsFrames = new FrameStore(wsDb);
await runMaintenanceOnMind(wsDb, wsFrames, embedder, options, result);
result.durationMs = Date.now() - start;
return result;
} finally {
wsDb.close();
}
} finally {
close();
}
}
/**
* Run the per-mind maintenance ops against every registered workspace mind,
* summing the headline counts. Per-workspace failures are logged and skipped.
* cognify / wiki are personal-scoped and not run here. Ported from
* hive-mind a99ea0e.
*/
async function runMaintenanceAllWorkspaces(options: MaintenanceOptions): Promise<MaintenanceResult> {
const env = options.env ?? openPersonalMind();
const close = options.env ? () => { /* caller owns */ } : env.close;
const start = Date.now();
const aggregate: MaintenanceResult = { durationMs: 0 };
// Per-mind aggregation. Ops like reembed-all report a 'framesEmbedded' count —
// sum across workspaces and let the caller see the headline number.
let totalEmbedded = 0;
let totalFramesProcessed = 0;
let totalChunks = 0;
let totalReconcileFts = 0;
let totalReconcileVec = 0;
try {
const embedder = await env.getEmbedder();
for (const ws of env.workspaces.list()) {
const mindPath = env.workspaces.getMindPath(ws.id);
if (!fs.existsSync(mindPath)) continue;
try {
const wsDb = new MindDB(mindPath);
try {
const wsFrames = new FrameStore(wsDb);
const wsResult: MaintenanceResult = { durationMs: 0 };
await runMaintenanceOnMind(wsDb, wsFrames, embedder, options, wsResult);
if (wsResult.reembedAll) {
totalEmbedded += wsResult.reembedAll.framesEmbedded;
// Take the last seen provider/model — identical across minds since
// we share one embedder instance.
aggregate.reembedAll = {
framesEmbedded: totalEmbedded,
activeProvider: wsResult.reembedAll.activeProvider,
modelName: wsResult.reembedAll.modelName,
durationMs: (aggregate.reembedAll?.durationMs ?? 0) + wsResult.reembedAll.durationMs,
};
}
if (wsResult.rechunkAll) {
totalFramesProcessed += wsResult.rechunkAll.framesProcessed;
totalChunks += wsResult.rechunkAll.chunksCreated;
aggregate.rechunkAll = {
framesProcessed: totalFramesProcessed,
chunksCreated: totalChunks,
activeProvider: wsResult.rechunkAll.activeProvider,
modelName: wsResult.rechunkAll.modelName,
durationMs: (aggregate.rechunkAll?.durationMs ?? 0) + wsResult.rechunkAll.durationMs,
};
}
if (wsResult.reconcile) {
totalReconcileFts += wsResult.reconcile.ftsFixed;
totalReconcileVec += wsResult.reconcile.vecFixed;
aggregate.reconcile = { ftsFixed: totalReconcileFts, vecFixed: totalReconcileVec };
}
} finally {
wsDb.close();
}
} catch (err) {
// One bad workspace shouldn't abort the loop. Surface and continue.
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[maintenance] workspace ${ws.id} failed: ${msg}\n`);
}
}
aggregate.durationMs = Date.now() - start;
return aggregate;
} finally {
close();
}
}

View File

@@ -0,0 +1,236 @@
/**
* `hive-mind-cli mcp call <tool> [--args JSON]` — spawn a short-lived
* MCP server child, run an `initialize` + `tools/call`, print the
* result, and tear down.
*
* Deliberately raw JSON-RPC rather than pulling @modelcontextprotocol/sdk
* as a CLI dependency — the message shape is stable and the smoke script
* already proves this works end-to-end. Extra ~50 lines here vs an extra
* ~10MB of installed SDK for every CLI consumer.
*/
import { spawn, type ChildProcessByStdio } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { resolveMcpServerEntry } from './mcp-start.js';
export interface McpCallOptions {
tool: string;
args?: Record<string, unknown>;
/** Overall timeout for initialize + call + teardown. */
timeoutMs?: number;
/** Extra env vars merged over process.env before launching the child. */
env?: Record<string, string | undefined>;
/** Test hook — raw transport factory returning stdin/stdout streams. */
transport?: () => {
stdin: Writable;
stdout: Readable;
kill: () => void;
exitPromise: Promise<number>;
};
}
export interface McpCallResult {
ok: boolean;
tool: string;
/** Raw MCP tool result (content array) when ok. */
content?: Array<{ type: string; text?: string; [k: string]: unknown }>;
/** True when the server reported the call as an application error. */
isError?: boolean;
/** Human-readable error when ok=false. */
error?: string;
}
const DEFAULT_TIMEOUT_MS = 30_000;
const INITIALIZE_ID = 1;
const CALL_ID = 2;
function spawnMcpChild(envOverride?: Record<string, string | undefined>): {
stdin: Writable;
stdout: Readable;
kill: () => void;
exitPromise: Promise<number>;
} {
const entry = resolveMcpServerEntry();
const child: ChildProcessByStdio<Writable, Readable, Readable> = spawn(
process.execPath,
[entry],
{
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...(envOverride ?? {}) },
},
);
// Drain stderr so a chatty server can't block on buffered logs.
child.stderr.on('data', () => { /* intentional drop — preserves parent stderr for CLI */ });
const exitPromise = new Promise<number>((resolve) => {
child.on('exit', (code) => resolve(code ?? 0));
});
return {
stdin: child.stdin,
stdout: child.stdout,
kill: () => { if (!child.killed) child.kill('SIGTERM'); },
exitPromise,
};
}
/** Parse newline-delimited JSON-RPC messages out of a rolling buffer. */
function createLineParser(): {
feed: (chunk: string) => Array<Record<string, unknown>>;
} {
let buffer = '';
return {
feed(chunk: string) {
buffer += chunk;
const messages: Array<Record<string, unknown>> = [];
let newlineIdx = buffer.indexOf('\n');
while (newlineIdx !== -1) {
const line = buffer.slice(0, newlineIdx).trim();
buffer = buffer.slice(newlineIdx + 1);
if (line) {
try {
messages.push(JSON.parse(line) as Record<string, unknown>);
} catch {
// Non-JSON line on stdout (shouldn't happen for a conformant MCP server).
}
}
newlineIdx = buffer.indexOf('\n');
}
return messages;
},
};
}
/** Wait for a JSON-RPC response with a matching id, or reject on timeout. */
function awaitResponse(
stdout: Readable,
parser: ReturnType<typeof createLineParser>,
id: number,
timeoutMs: number,
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
stdout.off('data', onData);
reject(new Error(`timed out after ${timeoutMs}ms waiting for response id=${id}`));
}, timeoutMs);
const onData = (chunk: Buffer | string): void => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
for (const msg of parser.feed(text)) {
if (msg['id'] === id) {
clearTimeout(timer);
stdout.off('data', onData);
resolve(msg);
return;
}
}
};
stdout.on('data', onData);
});
}
export async function runMcpCall(options: McpCallOptions): Promise<McpCallResult> {
if (!options.tool) {
return { ok: false, tool: '', error: 'tool name is required (e.g. `mcp call recall_memory`)' };
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const transport = options.transport
? options.transport()
: spawnMcpChild(options.env);
const parser = createLineParser();
try {
// 1. initialize
const initReq = {
jsonrpc: '2.0',
id: INITIALIZE_ID,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'hive-mind-cli', version: '0.1.0' },
},
};
transport.stdin.write(JSON.stringify(initReq) + '\n');
const initResp = await awaitResponse(transport.stdout, parser, INITIALIZE_ID, timeoutMs);
if (initResp['error']) {
return { ok: false, tool: options.tool, error: `initialize failed: ${JSON.stringify(initResp['error'])}` };
}
// 2. initialized notification (no id, no response expected)
transport.stdin.write(JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/initialized',
}) + '\n');
// 3. tools/call
const callReq = {
jsonrpc: '2.0',
id: CALL_ID,
method: 'tools/call',
params: {
name: options.tool,
arguments: options.args ?? {},
},
};
transport.stdin.write(JSON.stringify(callReq) + '\n');
const callResp = await awaitResponse(transport.stdout, parser, CALL_ID, timeoutMs);
if (callResp['error']) {
const err = callResp['error'] as { code?: number; message?: string };
return {
ok: false,
tool: options.tool,
error: err.message ?? 'unknown MCP error',
};
}
const result = callResp['result'] as {
content?: Array<{ type: string; text?: string; [k: string]: unknown }>;
isError?: boolean;
} | undefined;
return {
ok: true,
tool: options.tool,
content: result?.content ?? [],
isError: result?.isError ?? false,
};
} catch (err) {
return {
ok: false,
tool: options.tool,
error: err instanceof Error ? err.message : String(err),
};
} finally {
transport.kill();
await transport.exitPromise.catch(() => { /* already dead */ });
}
}
export function renderMcpCallResult(result: McpCallResult, format: 'plain' | 'json' = 'plain'): string {
if (format === 'json') return JSON.stringify(result, null, 2);
if (!result.ok) {
return `mcp call ${result.tool}: FAILED — ${result.error ?? 'unknown error'}`;
}
const lines: string[] = [];
lines.push(`mcp call ${result.tool}: ok${result.isError ? ' (tool reported isError=true)' : ''}`);
if (result.content && result.content.length > 0) {
for (const block of result.content) {
if (block.type === 'text' && typeof block.text === 'string') {
lines.push(block.text);
} else {
lines.push(JSON.stringify(block));
}
}
} else {
lines.push('(empty content)');
}
return lines.join('\n');
}

View File

@@ -0,0 +1,79 @@
/**
* `hive-mind-cli mcp start` — run the hive-mind MCP server in the
* foreground with inherited stdio, so any MCP client (Claude Code,
* Claude Desktop, Codex) can connect over stdio without the user
* having to know about the separate @waggle/hive-mind-mcp-server package.
*
* We spawn a fresh node subprocess instead of importing the server
* in-process because:
* - stdio:'inherit' wires client → child directly, no buffering
* - signal forwarding is straightforward (parent exits with child
* exit code; SIGINT/SIGTERM propagate naturally)
* - the server owning its own process keeps the shutdown path
* free of CLI teardown interleaving
*/
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
export interface McpStartOptions {
/** Extra env vars merged over process.env before launching the child. */
env?: Record<string, string | undefined>;
/**
* Override for tests — a function that runs the server and returns an
* exit code. When provided, the real subprocess spawn is skipped.
*/
runner?: () => Promise<number>;
}
/**
* Resolve the hive-mind MCP server binary via the standard require.resolve
* path. Throws if the dep isn't installed (missing workspace link, broken
* install, etc.) rather than silently failing.
*/
export function resolveMcpServerEntry(): string {
try {
return fileURLToPath(import.meta.resolve('@waggle/hive-mind-mcp-server'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
'@waggle/hive-mind-mcp-server is not resolvable from the CLI. ' +
'Run `npm install` at the repo root, or install @waggle/hive-mind-mcp-server ' +
`alongside @waggle/hive-mind-cli. (${msg})`,
);
}
}
export async function runMcpStart(options: McpStartOptions = {}): Promise<number> {
if (options.runner) return options.runner();
const entry = resolveMcpServerEntry();
const child = spawn(process.execPath, [entry], {
stdio: 'inherit',
env: { ...process.env, ...(options.env ?? {}) },
});
// Forward SIGINT/SIGTERM so the user's Ctrl+C reaches the server
// before it reaches our own exit handler.
const forward = (sig: NodeJS.Signals): void => {
if (!child.killed) child.kill(sig);
};
process.once('SIGINT', () => forward('SIGINT'));
process.once('SIGTERM', () => forward('SIGTERM'));
return new Promise<number>((resolve) => {
child.on('exit', (code, signal) => {
if (signal && code === null) {
// Child killed by signal — synthesize a conventional exit code.
resolve(128 + (signal === 'SIGINT' ? 2 : signal === 'SIGTERM' ? 15 : 1));
return;
}
resolve(code ?? 0);
});
child.on('error', (err) => {
// Spawn failed — surface the OS-level error.
console.error(`hive-mind-cli mcp start: ${err.message}`);
resolve(1);
});
});
}

View File

@@ -0,0 +1,110 @@
/**
* `hive-mind-cli recall-context [query]` — query the personal mind and
* print matching frames. Designed to be invoked from a SessionStart
* hook, where the stdout is injected into the AI client's conversation
* context.
*/
import { openPersonalMind, type CliEnv } from '../setup.js';
export interface RecallContextOptions {
query: string;
limit?: number;
scope?: 'personal' | 'all';
profile?: 'balanced' | 'recent' | 'important' | 'connected';
format?: 'plain' | 'json';
/** Override for tests — use an already-open env instead of opening a new one. */
env?: CliEnv;
}
export interface RecallContextResult {
query: string;
hits: Array<{
id: number;
content: string;
importance: string;
source: string;
score: number;
created_at: string;
from: string;
}>;
}
export async function runRecallContext(options: RecallContextOptions): Promise<RecallContextResult> {
const env = options.env ?? openPersonalMind();
const close = options.env ? () => { /* caller owns */ } : env.close;
try {
const limit = options.limit ?? 10;
const scope = options.scope ?? 'personal';
const profile = options.profile ?? 'balanced';
const search = await env.getSearch();
const searchOpts = { limit, profile };
const hits: RecallContextResult['hits'] = [];
// Personal mind
const personalResults = await search.search(options.query, searchOpts);
for (const r of personalResults) {
hits.push({
id: r.frame.id,
content: r.frame.content,
importance: r.frame.importance,
source: r.frame.source,
score: Math.round(r.finalScore * 1000) / 1000,
created_at: r.frame.created_at,
from: 'personal',
});
}
// All workspaces when scope=all
if (scope === 'all') {
for (const ws of env.workspaces.list()) {
const wsDb = env.mindCache.getOrOpen(ws.id);
if (!wsDb) continue;
try {
const { FrameStore, HybridSearch } = await import('@waggle/hive-mind-core');
// Touch these to satisfy lint; they're consumed below via wsDb.
void FrameStore;
const wsEmbedder = await env.getEmbedder();
const wsSearch = new HybridSearch(wsDb, wsEmbedder);
const wsResults = await wsSearch.search(options.query, searchOpts);
for (const r of wsResults) {
hits.push({
id: r.frame.id,
content: r.frame.content,
importance: r.frame.importance,
source: r.frame.source,
score: Math.round(r.finalScore * 1000) / 1000,
created_at: r.frame.created_at,
from: `workspace:${ws.id}`,
});
}
} catch { /* workspace failures are non-fatal */ }
}
}
hits.sort((a, b) => b.score - a.score);
const trimmed = hits.slice(0, limit);
return { query: options.query, hits: trimmed };
} finally {
close();
}
}
/** Render a recall result either as plain text (for stdout injection) or JSON. */
export function renderRecallResult(result: RecallContextResult, format: 'plain' | 'json' = 'plain'): string {
if (format === 'json') {
return JSON.stringify(result, null, 2);
}
if (result.hits.length === 0) {
return `No memories found for query: "${result.query}"`;
}
const lines: string[] = [`# Recalled context for "${result.query}"`, ''];
for (const h of result.hits) {
const date = h.created_at.slice(0, 10);
lines.push(`- [${h.from}/${h.importance}, ${date}, score=${h.score.toFixed(3)}] ${h.content}`);
}
return lines.join('\n');
}

View File

@@ -0,0 +1,99 @@
/**
* `hive-mind-cli save-session` — persist a session summary as one or
* more I-Frames. Intended to run from a post-session hook (Stop in
* Claude Code, etc.) where the transcript text is piped in on stdin or
* supplied via --file.
*/
import fs from 'node:fs';
import { openPersonalMind, type CliEnv } from '../setup.js';
import type { Importance } from '@waggle/hive-mind-core';
export interface SaveSessionOptions {
/** Raw session summary text. Takes precedence over `file`. */
text?: string;
/** Path to a file containing the session summary. */
file?: string;
importance?: Importance;
sessionLabel?: string;
env?: CliEnv;
}
export interface SaveSessionResult {
saved: boolean;
frameId?: number;
frameCreatedAt?: string;
characters: number;
reason?: string;
}
const SESSION_LABEL_PREFIX = 'cli:save-session';
function readStdinSync(): string {
try {
// Node's readFileSync with fd=0 reads stdin synchronously. Works when
// the CLI is invoked with a pipe. Returns empty string when stdin is a TTY.
return fs.readFileSync(0, 'utf-8');
} catch {
return '';
}
}
export async function runSaveSession(options: SaveSessionOptions): Promise<SaveSessionResult> {
const env = options.env ?? openPersonalMind();
const close = options.env ? () => { /* caller owns */ } : env.close;
try {
let text = options.text ?? '';
if (!text && options.file) {
try {
text = fs.readFileSync(options.file, 'utf-8');
} catch (err) {
return {
saved: false,
characters: 0,
reason: `Failed to read ${options.file}: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
if (!text && !options.env) {
// Only try stdin when running as a real CLI, not in tests.
text = readStdinSync();
}
const trimmed = text.trim();
if (trimmed.length < 20) {
return {
saved: false,
characters: trimmed.length,
reason: 'Session summary is too short (< 20 chars) — nothing saved',
};
}
const today = new Date().toISOString().slice(0, 10);
const label = options.sessionLabel ?? `${SESSION_LABEL_PREFIX}:${today}`;
const session = env.sessions.ensure(label, undefined, `Saved session summary ${today}`);
const frame = env.frames.createIFrame(
session.gop_id,
trimmed,
options.importance ?? 'normal',
'agent_inferred',
);
// Best-effort vector indexing. Keyword search via FTS5 still works if this fails.
try {
const search = await env.getSearch();
await search.indexFrame(frame.id, trimmed);
} catch { /* non-fatal */ }
return {
saved: true,
frameId: frame.id,
frameCreatedAt: frame.created_at,
characters: trimmed.length,
};
} finally {
close();
}
}

View File

@@ -0,0 +1,162 @@
/**
* `hive-mind-cli status` — show frame/entity/relation counts and the most
* recent frame, so a persona can eyeball whether the memory substrate is
* healthy without opening the SQLite file directly.
*
* Read-only: does not probe the embedder, does not mutate anything.
*/
import fs from 'node:fs';
import path from 'node:path';
import { openPersonalMind, resolveDataDir, type CliEnv } from '../setup.js';
export interface StatusOptions {
env?: CliEnv;
dataDir?: string;
}
export interface StatusResult {
dataDir: string;
personalMindExists: boolean;
frames: number;
entities: number;
relations: number;
entityTypeCounts: Array<{ type: string; count: number }>;
lastFrame: {
id: number;
source: string;
importance: string;
created_at: string;
preview: string;
} | null;
workspaces: Array<{ id: string; name: string }>;
}
/** Content preview cap — long frames don't flood the status output. */
const PREVIEW_CHARS = 80;
export async function runStatus(options: StatusOptions = {}): Promise<StatusResult> {
const dataDir = options.env?.dataDir ?? options.dataDir ?? resolveDataDir();
const personalMindPath = path.join(dataDir, 'personal.mind');
const personalMindExists = fs.existsSync(personalMindPath);
if (!personalMindExists && !options.env) {
return {
dataDir,
personalMindExists: false,
frames: 0,
entities: 0,
relations: 0,
entityTypeCounts: [],
lastFrame: null,
workspaces: [],
};
}
const env = options.env ?? openPersonalMind(dataDir);
const close = options.env ? () => { /* caller owns */ } : env.close;
try {
const db = env.db.getDatabase();
// memory_frames uses importance='deprecated' as a tombstone (no valid_to column).
const frameCountRow = db
.prepare("SELECT COUNT(*) AS n FROM memory_frames WHERE importance != 'deprecated'")
.get() as { n: number } | undefined;
const relationCountRow = db
.prepare("SELECT COUNT(*) AS n FROM knowledge_relations WHERE valid_to IS NULL")
.get() as { n: number } | undefined;
const lastFrameRow = db
.prepare(
"SELECT id, source, importance, created_at, content FROM memory_frames " +
"WHERE importance != 'deprecated' ORDER BY id DESC LIMIT 1",
)
.get() as {
id: number;
source: string;
importance: string;
created_at: string;
content: string;
} | undefined;
const entityCount = env.kg.getEntityCount();
const entityTypeCounts = env.kg.getEntityTypeCounts();
const workspaceList = env.workspaces.list().map((w) => ({
id: w.id,
name: w.name,
}));
return {
dataDir: env.dataDir,
personalMindExists: true,
frames: frameCountRow?.n ?? 0,
entities: entityCount,
relations: relationCountRow?.n ?? 0,
entityTypeCounts,
lastFrame: lastFrameRow ? {
id: lastFrameRow.id,
source: lastFrameRow.source,
importance: lastFrameRow.importance,
created_at: lastFrameRow.created_at,
preview: previewOf(lastFrameRow.content),
} : null,
workspaces: workspaceList,
};
} finally {
close();
}
}
function previewOf(content: string): string {
const normalized = content.replace(/\s+/g, ' ').trim();
return normalized.length <= PREVIEW_CHARS
? normalized
: normalized.slice(0, PREVIEW_CHARS - 1) + '…';
}
export function renderStatusResult(result: StatusResult, format: 'plain' | 'json' = 'plain'): string {
if (format === 'json') {
return JSON.stringify(result, null, 2);
}
const lines: string[] = [];
lines.push('hive-mind status');
lines.push(` data dir: ${result.dataDir}`);
if (!result.personalMindExists) {
lines.push(' personal: not initialised — run `hive-mind-cli init`');
return lines.join('\n');
}
lines.push(` frames: ${result.frames.toLocaleString('en-US')}`);
lines.push(` entities: ${result.entities.toLocaleString('en-US')}`);
lines.push(` relations: ${result.relations.toLocaleString('en-US')}`);
if (result.entityTypeCounts.length > 0) {
const top = result.entityTypeCounts.slice(0, 5)
.map((e) => `${e.type}=${e.count}`)
.join(' ');
lines.push(` top types: ${top}`);
}
if (result.lastFrame) {
const when = result.lastFrame.created_at.replace('T', ' ').slice(0, 16);
lines.push(
` last frame: #${result.lastFrame.id} (${result.lastFrame.source}, ` +
`${result.lastFrame.importance}, ${when})`,
);
lines.push(` "${result.lastFrame.preview}"`);
} else {
lines.push(' last frame: (none — try `hive-mind-cli save-session`)');
}
if (result.workspaces.length > 0) {
lines.push(` workspaces: ${result.workspaces.length} ` +
`(${result.workspaces.slice(0, 3).map((w) => w.name).join(', ')}` +
`${result.workspaces.length > 3 ? ', …' : ''})`);
}
return lines.join('\n');
}

View File

@@ -0,0 +1,501 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import { openPersonalMind, type CliEnv } from './setup.js';
import { dispatch } from './dispatch.js';
import { runMcpCall } from './commands/mcp-call.js';
import { runDanceReceive, runDanceSend } from './commands/dance.js';
describe('cli dispatch', () => {
let dataDir: string;
let env: CliEnv;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), 'hmind-cli-dispatch-'));
env = openPersonalMind(dataDir);
// Seed a session + a few frames the commands can recall/cognify over.
env.db.getDatabase().prepare(
"INSERT INTO sessions (gop_id, status, started_at) VALUES ('g-cli', 'active', datetime('now'))",
).run();
env.frames.createIFrame('g-cli', 'Alice works at Acme Corp on Project Alpha', 'important', 'user_stated');
env.frames.createIFrame('g-cli', 'Bob prefers TypeScript over JavaScript for backend work', 'normal', 'user_stated');
env.frames.createIFrame('g-cli', 'The weekly review happens every Thursday at 2pm', 'normal', 'user_stated');
});
afterEach(() => {
env.close();
vi.unstubAllGlobals();
delete process.env.WAGGLE_DANCE_URL;
delete process.env.WAGGLE_RUN_TOKEN;
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('recall-context returns hits as plain text when no --json flag', async () => {
const out = await dispatch({
subcommand: 'recall-context',
values: { limit: '5' },
positionals: ['Alice Acme'],
env,
});
expect(out).toBeDefined();
expect(out).toContain('Recalled context');
expect(out).toContain('Alice');
});
it('recall-context with --json emits JSON envelope', async () => {
const out = await dispatch({
subcommand: 'recall-context',
values: { json: true, limit: '5' },
positionals: ['Alice'],
env,
});
const parsed = JSON.parse(out!) as { query: string; hits: Array<{ content: string }> };
expect(parsed.query).toBe('Alice');
expect(Array.isArray(parsed.hits)).toBe(true);
});
it('recall-context rejects missing query', async () => {
await expect(dispatch({
subcommand: 'recall-context',
values: {},
positionals: [],
env,
})).rejects.toThrow(/requires a query/);
});
it('save-session from --file persists an I-Frame', async () => {
const filePath = join(dataDir, 'session.txt');
writeFileSync(filePath, 'Today we decided to ship the new auth module behind a feature flag.');
const out = await dispatch({
subcommand: 'save-session',
values: { json: true, file: filePath },
positionals: [],
env,
});
const parsed = JSON.parse(out!) as { saved: boolean; frameId?: number };
expect(parsed.saved).toBe(true);
expect(typeof parsed.frameId).toBe('number');
});
it('save-session rejects too-short input', async () => {
const filePath = join(dataDir, 'short.txt');
writeFileSync(filePath, 'hi');
const out = await dispatch({
subcommand: 'save-session',
values: { json: true, file: filePath },
positionals: [],
env,
});
const parsed = JSON.parse(out!) as { saved: boolean; reason?: string };
expect(parsed.saved).toBe(false);
expect(parsed.reason).toMatch(/too short/i);
});
it('harvest-local rejects missing --source or --path', async () => {
await expect(dispatch({
subcommand: 'harvest-local',
values: { source: 'chatgpt' },
positionals: [],
env,
})).rejects.toThrow(/--path/);
await expect(dispatch({
subcommand: 'harvest-local',
values: { path: '/tmp/x.json' },
positionals: [],
env,
})).rejects.toThrow(/--source/);
});
it('harvest-local reports missing file as an error (non-throwing)', async () => {
const out = await dispatch({
subcommand: 'harvest-local',
values: {
json: true,
source: 'chatgpt',
path: join(dataDir, 'does-not-exist.json'),
},
positionals: [],
env,
});
const parsed = JSON.parse(out!) as { errors: string[] };
expect(parsed.errors.length).toBeGreaterThan(0);
expect(parsed.errors[0]).toMatch(/not found/i);
});
it('harvest-local parses a minimal ChatGPT export into frames', async () => {
const exportPath = join(dataDir, 'chatgpt.json');
writeFileSync(exportPath, JSON.stringify([
{
id: 'conv-1',
title: 'Greeting',
create_time: 1_700_000_000,
mapping: {
m1: {
id: 'm1',
message: {
author: { role: 'user' },
create_time: 1_700_000_001,
content: { parts: ['Hello from chatgpt export'] },
},
},
},
},
]));
const out = await dispatch({
subcommand: 'harvest-local',
values: { json: true, source: 'chatgpt', path: exportPath },
positionals: [],
env,
});
const parsed = JSON.parse(out!) as { itemsFound: number; framesCreated: number };
expect(parsed.itemsFound).toBeGreaterThan(0);
expect(parsed.framesCreated).toBeGreaterThan(0);
});
it('cognify scans the seeded frames and reports a run', async () => {
const out = await dispatch({
subcommand: 'cognify',
values: { json: true },
positionals: [],
env,
});
const parsed = JSON.parse(out!) as { framesScanned: number; entitiesCreated: number };
expect(parsed.framesScanned).toBeGreaterThanOrEqual(3);
// The seed data includes capitalised multi-word candidates (Acme Corp, Project Alpha).
expect(parsed.entitiesCreated + 0).toBeGreaterThan(0);
});
it('compile-wiki runs against the real core + wiki-compiler (echo synthesizer)', async () => {
// No ANTHROPIC_API_KEY / OLLAMA_URL in the test env → echo fallback.
delete process.env.ANTHROPIC_API_KEY;
delete process.env.OLLAMA_URL;
const out = await dispatch({
subcommand: 'compile-wiki',
values: { json: true, mode: 'full' },
positionals: [],
env,
});
const parsed = JSON.parse(out!) as { provider: string; pagesCreated: number; mode: string };
expect(parsed.mode).toBe('full');
// With no entities in the KG, there should still be an index page at least.
expect(parsed.provider).toBe('echo');
});
it('maintenance runs the requested ops in sequence', async () => {
const out = await dispatch({
subcommand: 'maintenance',
values: {
json: true,
compact: true,
'wipe-imports': true,
cognify: true,
},
positionals: [],
env,
});
const parsed = JSON.parse(out!) as {
compact?: unknown;
wipeImports?: unknown;
cognify?: unknown;
durationMs: number;
};
expect(parsed.compact).toBeDefined();
expect(parsed.wipeImports).toBeDefined();
expect(parsed.cognify).toBeDefined();
expect(parsed.durationMs).toBeGreaterThanOrEqual(0);
});
it('dispatches a scoped WaggleDance message with the run credential header', async () => {
process.env.WAGGLE_DANCE_URL = 'http://127.0.0.1:3333';
process.env.WAGGLE_RUN_TOKEN = 'run-token-with-enough-entropy-123456789';
const requests: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: String(input), init });
return new Response(JSON.stringify({
dispatched: true,
message: { id: 'message-1', subtype: 'knowledge_check' },
}), { status: 201, headers: { 'content-type': 'application/json' } });
});
const output = await dispatch({
subcommand: 'dance-send',
values: { json: true, type: 'request', subtype: 'knowledge_check', message: 'Who has the schema?' },
positionals: [],
});
expect(JSON.parse(output!)).toMatchObject({ sent: true, message: { id: 'message-1' } });
expect(requests[0].url).toBe('http://127.0.0.1:3333/api/waggle-dance/signal');
expect((requests[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(process.env.WAGGLE_RUN_TOKEN);
expect(JSON.parse(String(requests[0].init?.body))).toMatchObject({
type: 'request', subtype: 'knowledge_check', content: { query: 'Who has the schema?' },
});
});
it('receives only through a loopback WaggleDance transport', async () => {
const requests: string[] = [];
const result = await runDanceReceive({
env: {
WAGGLE_DANCE_URL: 'http://localhost:4444/',
WAGGLE_RUN_TOKEN: 'run-token-with-enough-entropy-123456789',
},
since: '2026-07-11T00:00:00.000Z',
limit: 2,
fetch: async (input) => {
requests.push(String(input));
return new Response(JSON.stringify({
signals: [{ id: 'one', subtype: 'routed_share' }], total: 1,
}), { status: 200 });
},
});
expect(result).toMatchObject({ total: 1, signals: [{ id: 'one' }] });
expect(requests[0]).toContain('/api/waggle-dance/signals?');
expect(requests[0]).toContain('limit=2');
await expect(runDanceSend({
env: {
WAGGLE_DANCE_URL: 'https://attacker.example',
WAGGLE_RUN_TOKEN: 'run-token-with-enough-entropy-123456789',
},
type: 'broadcast', subtype: 'discovery', message: 'no',
fetch: async () => { throw new Error('must not send'); },
})).rejects.toThrow(/loopback/);
});
it('rejects unknown subcommand', async () => {
await expect(dispatch({
subcommand: 'teleport',
values: {},
positionals: [],
env,
})).rejects.toThrow(/Unknown subcommand/);
});
it('init on a populated env reports the existing mind', async () => {
const out = await dispatch({
subcommand: 'init',
values: { json: true },
positionals: [],
env,
});
const parsed = JSON.parse(out!) as {
dataDir: string;
personalMindPath: string;
personalMindCreated: boolean;
dataDirCreated: boolean;
};
expect(parsed.dataDir).toBe(dataDir);
expect(parsed.personalMindPath).toContain('personal.mind');
// Already opened by the beforeEach hook → should be reported as existing.
expect(parsed.personalMindCreated).toBe(false);
expect(parsed.dataDirCreated).toBe(false);
});
it('init plain-text output lists next-step commands', async () => {
const out = await dispatch({
subcommand: 'init',
values: {},
positionals: [],
env,
});
expect(out).toContain('Personal mind exists');
expect(out).toContain('hive-mind-cli status');
expect(out).toContain('hive-mind-cli recall-context');
});
it('status --json reports seeded frame count and entities', async () => {
// Cognify first so the entity-count column is non-zero.
await dispatch({
subcommand: 'cognify',
values: { json: true },
positionals: [],
env,
});
const out = await dispatch({
subcommand: 'status',
values: { json: true },
positionals: [],
env,
});
const parsed = JSON.parse(out!) as {
dataDir: string;
personalMindExists: boolean;
frames: number;
entities: number;
relations: number;
lastFrame: { id: number; source: string; preview: string } | null;
};
expect(parsed.personalMindExists).toBe(true);
expect(parsed.frames).toBeGreaterThanOrEqual(3);
expect(parsed.entities).toBeGreaterThan(0);
expect(parsed.lastFrame).not.toBeNull();
expect(parsed.lastFrame?.source).toBe('user_stated');
expect(parsed.lastFrame?.preview.length).toBeGreaterThan(0);
});
it('status plain-text renders a human-readable summary', async () => {
const out = await dispatch({
subcommand: 'status',
values: {},
positionals: [],
env,
});
expect(out).toContain('hive-mind status');
expect(out).toContain('frames:');
expect(out).toContain('entities:');
expect(out).toContain('relations:');
expect(out).toContain('last frame:');
});
it('status truncates long frame content in preview', async () => {
// Seed a long frame so preview truncation is exercised.
const longContent = 'This is a deliberately long frame body. '.repeat(10);
env.frames.createIFrame('g-cli', longContent, 'normal', 'user_stated');
const out = await dispatch({
subcommand: 'status',
values: { json: true },
positionals: [],
env,
});
const parsed = JSON.parse(out!) as { lastFrame: { preview: string } };
expect(parsed.lastFrame.preview.length).toBeLessThanOrEqual(80);
expect(parsed.lastFrame.preview.endsWith('…')).toBe(true);
});
it('mcp-call rejects missing tool name', async () => {
await expect(dispatch({
subcommand: 'mcp-call',
values: {},
positionals: [],
})).rejects.toThrow(/tool name/);
});
it('mcp-call rejects invalid --args JSON', async () => {
await expect(dispatch({
subcommand: 'mcp-call',
values: { args: '{not json' },
positionals: ['recall_memory'],
})).rejects.toThrow(/not valid JSON/);
});
});
/**
* MCP-call tests using the `transport` override so we never spawn a real
* child process. These verify the JSON-RPC handshake + response matching
* logic, not the server itself (the smoke script covers the real server).
*/
describe('runMcpCall (transport mock)', () => {
function makeMockTransport(responses: Array<Record<string, unknown>>) {
const stdin = new PassThrough();
const stdout = new PassThrough();
let killed = false;
// Watch stdin for requests and emit canned responses for each id.
let buffer = '';
stdin.on('data', (chunk: Buffer) => {
buffer += chunk.toString('utf-8');
let idx = buffer.indexOf('\n');
while (idx !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (line) {
try {
const req = JSON.parse(line) as { id?: number; method?: string };
if (typeof req.id === 'number') {
const match = responses.find((r) => r['id'] === req.id);
if (match) {
stdout.write(JSON.stringify(match) + '\n');
}
}
} catch { /* ignore parse errors */ }
}
idx = buffer.indexOf('\n');
}
});
return () => ({
stdin,
stdout,
kill: () => { killed = true; stdin.end(); stdout.end(); },
exitPromise: Promise.resolve(killed ? 0 : 0),
});
}
it('runs initialize + tools/call and returns the content array', async () => {
const transport = makeMockTransport([
{
jsonrpc: '2.0',
id: 1,
result: {
protocolVersion: '2024-11-05',
capabilities: {},
serverInfo: { name: 'mock', version: '0.0.1' },
},
},
{
jsonrpc: '2.0',
id: 2,
result: {
content: [{ type: 'text', text: 'hello from the mock tool' }],
isError: false,
},
},
]);
const result = await runMcpCall({
tool: 'recall_memory',
args: { query: 'hello' },
transport,
timeoutMs: 2000,
});
expect(result.ok).toBe(true);
expect(result.tool).toBe('recall_memory');
expect(result.isError).toBe(false);
expect(result.content).toHaveLength(1);
expect(result.content![0].text).toContain('hello from the mock tool');
});
it('surfaces MCP error responses', async () => {
const transport = makeMockTransport([
{ jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05', capabilities: {} } },
{ jsonrpc: '2.0', id: 2, error: { code: -32602, message: 'Unknown tool: teleport' } },
]);
const result = await runMcpCall({
tool: 'teleport',
transport,
timeoutMs: 2000,
});
expect(result.ok).toBe(false);
expect(result.error).toContain('Unknown tool: teleport');
});
it('times out when the server never responds', async () => {
// Transport that emits no responses.
const transport = makeMockTransport([]);
const result = await runMcpCall({
tool: 'recall_memory',
transport,
timeoutMs: 100,
});
expect(result.ok).toBe(false);
expect(result.error).toMatch(/timed out/);
});
it('rejects missing tool at the entry point', async () => {
const result = await runMcpCall({ tool: '', timeoutMs: 100 });
expect(result.ok).toBe(false);
expect(result.error).toMatch(/tool name is required/);
});
});

View File

@@ -0,0 +1,246 @@
/**
* Subcommand dispatcher. Lives in its own module so tests can drive it
* without going through argv parsing or process.exit.
*/
import { runRecallContext, renderRecallResult } from './commands/recall-context.js';
import { runSaveSession } from './commands/save-session.js';
import { runHarvestLocal, type HarvestSource } from './commands/harvest-local.js';
import { runCognify } from './commands/cognify.js';
import { runCompileWiki } from './commands/compile-wiki.js';
import { runMaintenance } from './commands/maintenance.js';
import { runInit, renderInitResult } from './commands/init.js';
import { runStatus, renderStatusResult } from './commands/status.js';
import { runMcpStart } from './commands/mcp-start.js';
import { runMcpCall, renderMcpCallResult } from './commands/mcp-call.js';
import { runDoctor, renderDoctorResult } from './commands/doctor.js';
import {
renderDanceReceive,
renderDanceSend,
runDanceReceive,
runDanceSend,
type DanceMessageSubtype,
type DanceMessageType,
} from './commands/dance.js';
import type { CliEnv } from './setup.js';
import type { Importance } from '@waggle/hive-mind-core';
export interface DispatchArgs {
subcommand: string;
values: Record<string, unknown>;
positionals: string[];
/** Optional env override for tests — bypasses openPersonalMind(). */
env?: CliEnv;
}
type OutputFormat = 'plain' | 'json';
function intArg(values: Record<string, unknown>, key: string): number | undefined {
const v = values[key];
if (v === undefined) return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
function formatOf(values: Record<string, unknown>): OutputFormat {
return values['json'] ? 'json' : 'plain';
}
function json(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
export async function dispatch(args: DispatchArgs): Promise<string | undefined> {
const { subcommand, values, positionals, env } = args;
const fmt = formatOf(values);
switch (subcommand) {
case 'recall-context': {
const query = (values['query'] as string) ?? positionals[0];
if (!query) throw new Error('recall-context requires a query (positional or --query)');
const result = await runRecallContext({
query,
limit: intArg(values, 'limit'),
scope: (values['scope'] as 'personal' | 'all' | undefined) ?? 'personal',
profile: (values['profile'] as 'balanced' | 'recent' | 'important' | 'connected' | undefined),
env,
});
return fmt === 'json' ? json(result) : renderRecallResult(result, 'plain');
}
case 'save-session': {
const result = await runSaveSession({
file: values['file'] as string | undefined,
importance: values['importance'] as Importance | undefined,
sessionLabel: values['session-label'] as string | undefined,
env,
});
return fmt === 'json' ? json(result) : (
result.saved
? `Saved session summary as frame #${result.frameId} (${result.characters} chars)`
: `Nothing saved: ${result.reason ?? 'empty input'}`
);
}
case 'harvest-local': {
const source = values['source'] as HarvestSource | undefined;
const pth = values['path'] as string | undefined;
if (!source) throw new Error('harvest-local requires --source (chatgpt|claude|claude-code|gemini|universal)');
if (!pth) throw new Error('harvest-local requires --path');
const result = await runHarvestLocal({ source, path: pth, env });
return fmt === 'json' ? json(result) : (
`Harvested ${result.itemsFound} items from ${result.source} ` +
`(${result.framesCreated} new, ${result.duplicatesSkipped} duplicates` +
(result.suppressedSkipped ? `, ${result.suppressedSkipped} erased-suppressed` : '') +
(result.errors.length ? `, ${result.errors.length} errors` : '') +
`)`
);
}
case 'cognify': {
const result = await runCognify({
since: intArg(values, 'since'),
limit: intArg(values, 'limit'),
env,
});
return fmt === 'json' ? json(result) : (
`Scanned ${result.framesScanned} frames — ${result.entitiesCreated} new entities, ` +
`${result.entitiesUpdated} updated (lastFrameId=${result.lastFrameId})`
);
}
case 'compile-wiki': {
const result = await runCompileWiki({
mode: (values['mode'] as 'incremental' | 'full' | undefined) ?? 'incremental',
concepts: values['concept'] as string[] | undefined,
env,
});
return fmt === 'json' ? json(result) : (
`Wiki compiled via ${result.provider}${result.pagesCreated} created, ` +
`${result.pagesUpdated} updated, ${result.pagesUnchanged} unchanged, ` +
`${result.healthIssues} health issues (${result.durationMs}ms)`
);
}
case 'maintenance': {
const result = await runMaintenance({
compact: Boolean(values['compact']),
wipeImports: Boolean(values['wipe-imports']),
reconcile: Boolean(values['reconcile']),
reembedAll: Boolean(values['reembed-all']),
rechunkAll: Boolean(values['rechunk-all']),
dedupeEntities: Boolean(values['dedupe-entities']),
consolidate: Boolean(values['consolidate']),
consolidateModel: typeof values['consolidate-model'] === 'string' ? values['consolidate-model'] : undefined,
consolidateLimit: intArg(values, 'consolidate-limit'),
cognify: Boolean(values['cognify']),
wiki: Boolean(values['wiki']),
maxTempAgeDays: intArg(values, 'max-temp-age-days'),
maxDeprecatedAgeDays: intArg(values, 'max-deprecated-age-days'),
workspace: typeof values['workspace'] === 'string' ? values['workspace'] : undefined,
allWorkspaces: Boolean(values['all-workspaces']),
env,
});
if (fmt === 'json') return json(result);
const lines: string[] = [`Maintenance run complete (${result.durationMs}ms)`];
if (result.compact) lines.push(` compact: temp=${result.compact.temporaryPruned} deprecated=${result.compact.deprecatedPruned} pframes=${result.compact.pframesMerged}`);
if (result.wipeImports) lines.push(` wipeImports: ${result.wipeImports.framesDeleted} frames`);
if (result.reconcile) lines.push(` reconcile: fts=${result.reconcile.ftsFixed} vec=${result.reconcile.vecFixed}`);
if (result.reembedAll) lines.push(` reembed-all: ${result.reembedAll.framesEmbedded} frames via ${result.reembedAll.activeProvider}/${result.reembedAll.modelName} in ${(result.reembedAll.durationMs / 1000).toFixed(1)}s`);
if (result.rechunkAll) lines.push(` rechunk-all: ${result.rechunkAll.framesProcessed} frames → ${result.rechunkAll.chunksCreated} chunks via ${result.rechunkAll.activeProvider}/${result.rechunkAll.modelName} in ${(result.rechunkAll.durationMs / 1000).toFixed(1)}s`);
if (result.dedupeEntities) lines.push(` dedupe-entities: merged ${result.dedupeEntities.merged} dup(s) across ${result.dedupeEntities.groups} group(s)`);
if (result.consolidate) lines.push(` consolidate: ${result.consolidate.chains} chain(s) → ${result.consolidate.pframes} P-frame(s) (${result.consolidate.deprecated} deprecated), ${result.consolidate.groups} group(s) → ${result.consolidate.bframes} B-frame(s)`);
if (result.cognify) lines.push(` cognify: ${result.cognify.framesScanned} frames, ${result.cognify.entitiesCreated} new, ${result.cognify.entitiesUpdated} updated`);
if (result.wiki) lines.push(` wiki: ${result.wiki.pagesCreated} created, ${result.wiki.pagesUpdated} updated, provider=${result.wiki.provider}`);
return lines.join('\n');
}
case 'init': {
const result = await runInit({ env });
return fmt === 'json' ? json(result) : renderInitResult(result, 'plain');
}
case 'status': {
const result = await runStatus({ env });
return fmt === 'json' ? json(result) : renderStatusResult(result, 'plain');
}
case 'mcp-start': {
// Long-running. Exits with the child's exit code; this branch only
// returns once the MCP server child has stopped.
const code = await runMcpStart();
process.exit(code);
// `process.exit` returns `never`, so this is unreachable — but ESLint's
// no-fallthrough rule does no type analysis, so make the terminator explicit.
break;
}
case 'mcp-call': {
const tool = (values['tool'] as string | undefined) ?? positionals[0];
if (!tool) throw new Error('mcp call requires a tool name (e.g. `mcp call recall_memory`)');
let parsedArgs: Record<string, unknown> = {};
const argsRaw = values['args'] as string | undefined;
if (argsRaw) {
try {
parsedArgs = JSON.parse(argsRaw) as Record<string, unknown>;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`--args is not valid JSON: ${msg}`);
}
}
const result = await runMcpCall({
tool,
args: parsedArgs,
timeoutMs: intArg(values, 'timeout-ms'),
});
return fmt === 'json' ? json(result) : renderMcpCallResult(result, 'plain');
}
case 'dance-send': {
const message = (values['message'] as string | undefined) ?? positionals.join(' ');
if (!message) throw new Error('dance send requires --message or positional text');
const result = await runDanceSend({
type: (values['type'] as DanceMessageType | undefined) ?? 'broadcast',
subtype: (values['subtype'] as DanceMessageSubtype | undefined) ?? 'discovery',
message,
referenceId: values['reference-id'] as string | undefined,
timeoutMs: intArg(values, 'timeout-ms'),
});
return fmt === 'json' ? json(result) : renderDanceSend(result);
}
case 'dance-receive': {
const result = await runDanceReceive({
since: values['since'] as string | undefined,
limit: intArg(values, 'limit'),
subtype: values['subtype'] as DanceMessageSubtype | undefined,
timeoutMs: intArg(values, 'timeout-ms'),
});
return fmt === 'json' ? json(result) : renderDanceReceive(result);
}
case 'doctor': {
// Wave 1 cleanup — self-diagnostic smoke test independent of upstream hook.
// Spawn probe (Windows .cmd shim) → save+recall frame → cache cleanup.
// Lazy-opens env if not provided (matches status command pattern).
const result = await runDoctor({ env });
// Doctor command sets process exit code via dispatch's caller (index.ts)
// by checking result.ok in the json/plain return shape. We surface a non-zero
// exit through throwing on fail to match the existing dispatch convention.
if (fmt === 'json') {
return json(result);
}
const rendered = renderDoctorResult(result);
if (!result.ok) {
// Throw here so index.ts's top-level catch sets exit code 1.
throw new Error(rendered);
}
return rendered;
}
default:
throw new Error(`Unknown subcommand: "${subcommand}". Try: init, status, doctor, recall-context, save-session, harvest-local, cognify, compile-wiki, maintenance, mcp start, mcp call <tool>, dance send, dance receive`);
}
}

View File

@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* hive-mind-cli — command-line tools for the hive-mind memory system.
*
* Subcommands:
* init Scaffold data dir + personal.mind (idempotent)
* status Show frame/entity counts + last activity
* mcp start Run the MCP server in the foreground
* mcp call <tool> [--args JSON] Invoke one MCP tool + print the result
* dance send/receive Exchange messages in the active Waggle Room
* recall-context "<query>" Query the personal mind and print hits
* save-session [--file P] Persist stdin or --file as a memory frame
* harvest-local --source S --path P Import conversations from disk
* cognify [--since N] [--limit N] Heuristic KG entity extraction
* compile-wiki [--mode full] Build/refresh the personal wiki
* maintenance --compact --reconcile --cognify --wiki --wipe-imports
* Batch ops for a nightly cron
*
* Flags common to all subcommands:
* --data-dir P Override HIVE_MIND_DATA_DIR
* --json Emit machine-readable JSON rather than human text
*/
import { parseArgs } from 'node:util';
import { dispatch, type DispatchArgs } from './dispatch.js';
const HELP_FLAGS = new Set(['--help', '-h']);
function requestedHelpTarget(argv: string[]): string | null | undefined {
const [first, second] = argv;
if (!first || HELP_FLAGS.has(first)) return null;
if (!argv.some((arg) => HELP_FLAGS.has(arg))) return undefined;
if ((first === 'mcp' && (second === 'start' || second === 'call')) ||
(first === 'dance' && (second === 'send' || second === 'receive'))) {
return `${first} ${second}`;
}
return first;
}
function parseRootArgs(argv: string[]): DispatchArgs | null {
// Split "subcommand" out before parseArgs so the subcommand name does
// not collide with `--` flags. Two-word subcommands `mcp start` and
// `mcp call <tool>` collapse to `mcp-start` / `mcp-call` so dispatch
// can route with a single switch.
const [first, ...rest] = argv;
if (!first || first === '--help' || first === '-h') {
return null;
}
let subcommand = first;
let afterSubcommand = rest;
if ((first === 'mcp' || first === 'dance') && rest.length > 0) {
subcommand = `${first}-${rest[0]}`;
afterSubcommand = rest.slice(1);
}
const { values, positionals } = parseArgs({
args: afterSubcommand,
allowPositionals: true,
strict: false,
options: {
'data-dir': { type: 'string' },
'json': { type: 'boolean' },
'limit': { type: 'string' },
'scope': { type: 'string' },
'profile': { type: 'string' },
'query': { type: 'string' },
'file': { type: 'string' },
'source': { type: 'string' },
'path': { type: 'string' },
'since': { type: 'string' },
'mode': { type: 'string' },
'concept': { type: 'string', multiple: true },
'compact': { type: 'boolean' },
'wipe-imports': { type: 'boolean' },
'reconcile': { type: 'boolean' },
'reembed-all': { type: 'boolean' },
'rechunk-all': { type: 'boolean' },
'dedupe-entities': { type: 'boolean' },
'consolidate': { type: 'boolean' },
'consolidate-model': { type: 'string' },
'consolidate-limit': { type: 'string' },
'workspace': { type: 'string' },
'all-workspaces': { type: 'boolean' },
'cognify': { type: 'boolean' },
'wiki': { type: 'boolean' },
'max-temp-age-days': { type: 'string' },
'max-deprecated-age-days': { type: 'string' },
'session-label': { type: 'string' },
'importance': { type: 'string' },
'tool': { type: 'string' },
'args': { type: 'string' },
'timeout-ms': { type: 'string' },
'type': { type: 'string' },
'subtype': { type: 'string' },
'message': { type: 'string' },
'reference-id': { type: 'string' },
'help': { type: 'boolean', short: 'h' },
},
});
return { subcommand, values, positionals };
}
function rootHelp(): string {
return [
'Usage: hive-mind-cli <subcommand> [options]',
'',
'Subcommands:',
' init Scaffold data dir + personal.mind (idempotent)',
' status Show frame/entity counts + last activity',
' mcp start Run the hive-mind MCP server (stdio)',
' mcp call <tool> [--args J] Invoke one MCP tool and print the result',
' dance send --message TEXT Send a message to the active Waggle Room',
' dance receive Read messages from the active Waggle Room',
' recall-context "<query>" Search the personal mind and print hits',
' save-session --file PATH Persist a session summary as a memory frame',
' harvest-local --source S --path P Import local AI tool exports',
' cognify Heuristic KG entity extraction from recent frames',
' compile-wiki [--mode M] Build/refresh the personal wiki',
' maintenance --compact ... Batch ops for a nightly cron',
'',
'Common flags:',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
'',
'Environment:',
' HIVE_MIND_DATA_DIR Data directory (default ~/.hive-mind)',
' OLLAMA_URL / OLLAMA_MODEL Preferred embedder and wiki LLM',
' VOYAGE_API_KEY Remote embedder fallback',
' OPENAI_API_KEY Remote embedder fallback',
' ANTHROPIC_API_KEY Wiki synthesizer (Haiku)',
' WAGGLE_DANCE_URL Loopback sidecar URL (injected per run)',
' WAGGLE_RUN_TOKEN Narrow Room credential (injected per run)',
].join('\n');
}
const SUBCOMMAND_HELP: Record<string, string[]> = {
init: [
'Usage: hive-mind-cli init [options]',
'',
'Scaffold the data directory and personal mind database.',
'',
'Options:',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
status: [
'Usage: hive-mind-cli status [options]',
'',
'Show frame/entity counts and recent memory activity.',
'',
'Options:',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
'recall-context': [
'Usage: hive-mind-cli recall-context "<query>" [options]',
'',
'Search the personal mind and print recalled context.',
'',
'Options:',
' --query TEXT Query text, instead of positional input',
' --limit N Maximum hits to return',
' --scope personal|all Search scope',
' --profile NAME Search ranking profile',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
'save-session': [
'Usage: hive-mind-cli save-session [--file PATH] [options]',
'',
'Persist stdin or a file as a memory frame.',
'',
'Options:',
' --file PATH Read session text from a file',
' --session-label TEXT Attach a human label to the saved session',
' --importance LEVEL Memory importance',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
'harvest-local': [
'Usage: hive-mind-cli harvest-local --source SOURCE --path PATH [options]',
'',
'Import local AI tool exports.',
'',
'Options:',
' --source SOURCE chatgpt|claude|claude-code|gemini|universal',
' --path PATH Export file or directory',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
cognify: [
'Usage: hive-mind-cli cognify [options]',
'',
'Extract entities and relations from recent frames.',
'',
'Options:',
' --since N Start after frame id N',
' --limit N Maximum frames to scan',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
'compile-wiki': [
'Usage: hive-mind-cli compile-wiki [options]',
'',
'Build or refresh the personal wiki.',
'',
'Options:',
' --mode incremental|full Compile mode',
' --concept TEXT Compile a specific concept; repeatable',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
maintenance: [
'Usage: hive-mind-cli maintenance [operations] [options]',
'',
'Run batch maintenance operations for cron-style upkeep.',
'',
'Operations:',
' --compact Compact temporary/deprecated frames',
' --wipe-imports Delete imported frames',
' --reconcile Reconcile FTS/vector indexes',
' --cognify Extract entities',
' --wiki Compile wiki pages',
'',
'Options:',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
'mcp start': [
'Usage: hive-mind-cli mcp start [options]',
'',
'Run the hive-mind MCP server in the foreground.',
'',
'Options:',
' -h, --help Show this help',
],
'mcp call': [
'Usage: hive-mind-cli mcp call <tool> [options]',
'',
'Invoke one MCP tool and print the result.',
'',
'Options:',
' --args JSON Tool arguments as JSON',
' --timeout-ms N Request timeout',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
'dance send': [
'Usage: hive-mind-cli dance send --message TEXT [options]',
'',
'Send a scoped message to the active Waggle Room.',
'',
'Options:',
' --type TYPE broadcast|request|response',
' --subtype SUBTYPE WaggleDance protocol subtype',
' --message TEXT Message body (or pass positional text)',
' --reference-id ID Correlate a response with a request',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
'dance receive': [
'Usage: hive-mind-cli dance receive [options]',
'',
'Read messages from the active Waggle Room.',
'',
'Options:',
' --since ISO Return messages after an ISO timestamp',
' --subtype SUBTYPE Filter by protocol subtype',
' --limit N Maximum messages (1-500)',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
doctor: [
'Usage: hive-mind-cli doctor [options]',
'',
'Run a local self-diagnostic smoke test.',
'',
'Options:',
' --data-dir PATH Override HIVE_MIND_DATA_DIR',
' --json Emit JSON rather than human text',
' -h, --help Show this help',
],
};
function printHelp(subcommand?: string | null): void {
const lines = subcommand ? SUBCOMMAND_HELP[subcommand] : null;
console.log(lines ? lines.join('\n') : rootHelp());
}
async function main(): Promise<void> {
const rawArgs = process.argv.slice(2);
const helpTarget = requestedHelpTarget(rawArgs);
if (helpTarget !== undefined) {
printHelp(helpTarget);
process.exit(0);
return;
}
const args = parseRootArgs(rawArgs);
if (!args) {
printHelp(null);
process.exit(args === null ? 0 : 1);
return;
}
if (args.values['data-dir']) {
process.env.HIVE_MIND_DATA_DIR = String(args.values['data-dir']);
}
try {
const output = await dispatch(args);
if (output !== undefined) process.stdout.write(output);
if (output && !output.endsWith('\n')) process.stdout.write('\n');
process.exit(0);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`hive-mind-cli: ${msg}`);
process.exit(1);
}
}
main().catch((err) => {
console.error('hive-mind-cli fatal:', err);
process.exit(1);
});

View File

@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openPersonalMind, resolveDataDir } from './setup.js';
describe('cli setup', () => {
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), 'hmind-cli-setup-'));
});
afterEach(() => {
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
delete process.env.HIVE_MIND_DATA_DIR;
});
it('resolveDataDir() returns ~/.hive-mind when env is unset', () => {
const resolved = resolveDataDir();
expect(resolved.endsWith('.hive-mind')).toBe(true);
});
it('resolveDataDir() honours HIVE_MIND_DATA_DIR', () => {
process.env.HIVE_MIND_DATA_DIR = dataDir;
expect(resolveDataDir()).toBe(dataDir);
});
it('resolveDataDir() expands a leading tilde against $HOME', () => {
process.env.HIVE_MIND_DATA_DIR = '~/some-path';
const resolved = resolveDataDir();
expect(resolved.endsWith('some-path')).toBe(true);
expect(resolved.startsWith('/') || /^[A-Z]:/.test(resolved)).toBe(true);
});
it('openPersonalMind() creates personal.mind and every layer', () => {
const env = openPersonalMind(dataDir);
expect(existsSync(join(dataDir, 'personal.mind'))).toBe(true);
expect(env.frames).toBeDefined();
expect(env.kg).toBeDefined();
expect(env.identity).toBeDefined();
expect(env.awareness).toBeDefined();
expect(env.sessions).toBeDefined();
expect(env.harvestSources).toBeDefined();
expect(env.workspaces).toBeDefined();
expect(env.mindCache).toBeDefined();
env.close();
});
it('getEmbedder() caches the provider across calls', async () => {
const env = openPersonalMind(dataDir);
try {
const a = await env.getEmbedder();
const b = await env.getEmbedder();
expect(a).toBe(b);
} finally {
env.close();
}
});
it('getSearch() wires HybridSearch with the shared MindDB', async () => {
const env = openPersonalMind(dataDir);
try {
const search = await env.getSearch();
expect(search).toBeDefined();
// Same instance on second call.
expect(await env.getSearch()).toBe(search);
} finally {
env.close();
}
});
});

View File

@@ -0,0 +1,153 @@
/**
* Shared CLI setup — resolves the data directory and opens the personal
* MindDB on demand. Kept deliberately thin: each command instantiates
* only the layers it needs, so `recall-context` doesn't pay the cost of
* an embedder probe when the user only wants keyword search.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
MindDB,
FrameStore,
HybridSearch,
KnowledgeGraph,
IdentityLayer,
AwarenessLayer,
SessionStore,
HarvestSourceStore,
WorkspaceManager,
MultiMindCache,
createEmbeddingProvider,
type EmbeddingProviderConfig,
type EmbeddingProviderInstance,
} from '@waggle/hive-mind-core';
export interface CliEnv {
dataDir: string;
db: MindDB;
frames: FrameStore;
kg: KnowledgeGraph;
identity: IdentityLayer;
awareness: AwarenessLayer;
sessions: SessionStore;
harvestSources: HarvestSourceStore;
workspaces: WorkspaceManager;
mindCache: MultiMindCache;
/** Lazily-probed embedder. Call `getEmbedder()` — subsequent calls reuse the same instance. */
getEmbedder: () => Promise<EmbeddingProviderInstance>;
/** Search against the personal mind with an embedder lazily resolved on first call. */
getSearch: () => Promise<HybridSearch>;
close: () => void;
}
/** Resolve HIVE_MIND_DATA_DIR with ~ expansion; defaults to ~/.hive-mind. */
export function resolveDataDir(): string {
const envDir = process.env.HIVE_MIND_DATA_DIR;
if (envDir) {
if (envDir.startsWith('~')) {
return path.join(os.homedir(), envDir.slice(1));
}
return envDir;
}
return path.join(os.homedir(), '.hive-mind');
}
/**
* Resolve an embedding-provider config from the same env vars as the MCP
* server so a single `.env` file can configure both.
*/
function embedderConfigFromEnv(dataDir: string): EmbeddingProviderConfig {
const explicit = process.env.HIVE_MIND_EMBEDDING_PROVIDER as
| EmbeddingProviderConfig['provider']
| undefined;
let provider: EmbeddingProviderConfig['provider'];
if (explicit) {
provider = explicit;
} else if (process.env.OLLAMA_URL) {
provider = 'ollama';
} else if (process.env.VOYAGE_API_KEY) {
provider = 'voyage';
} else if (process.env.OPENAI_API_KEY) {
provider = 'openai';
} else {
provider = 'mock';
}
return {
provider,
targetDimensions: 1024,
inprocess: { cacheDir: path.join(dataDir, 'models') },
ollama: {
baseUrl: process.env.OLLAMA_URL,
model: process.env.OLLAMA_MODEL,
},
...(process.env.VOYAGE_API_KEY && {
voyage: { apiKey: process.env.VOYAGE_API_KEY },
}),
...(process.env.OPENAI_API_KEY && {
openai: { apiKey: process.env.OPENAI_API_KEY },
}),
};
}
/**
* Open the personal mind + wire every layer. Use the returned `close()`
* to release file handles before the process exits (important on
* Windows, where better-sqlite3 journal files linger otherwise).
*/
export function openPersonalMind(dataDir: string = resolveDataDir()): CliEnv {
fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, 'personal.mind');
const db = new MindDB(dbPath);
const frames = new FrameStore(db);
const kg = new KnowledgeGraph(db);
const identity = new IdentityLayer(db);
const awareness = new AwarenessLayer(db);
const sessions = new SessionStore(db);
const harvestSources = new HarvestSourceStore(db);
const workspaces = new WorkspaceManager(dataDir);
const mindCache = new MultiMindCache({
maxOpen: 20,
getMindPath: (id: string) => workspaces.getMindPath(id),
});
let _embedder: EmbeddingProviderInstance | null = null;
let _search: HybridSearch | null = null;
const getEmbedder = async (): Promise<EmbeddingProviderInstance> => {
if (_embedder) return _embedder;
_embedder = await createEmbeddingProvider(embedderConfigFromEnv(dataDir));
return _embedder;
};
const getSearch = async (): Promise<HybridSearch> => {
if (_search) return _search;
const embedder = await getEmbedder();
_search = new HybridSearch(db, embedder);
return _search;
};
return {
dataDir,
db,
frames,
kg,
identity,
awareness,
sessions,
harvestSources,
workspaces,
mindCache,
getEmbedder,
getSearch,
close: () => {
mindCache.closeAll();
try { db.close(); } catch { /* already closed */ }
},
};
}