This commit is contained in:
200
packages/hive-mind-cli/LICENSE
Normal file
200
packages/hive-mind-cli/LICENSE
Normal file
@@ -0,0 +1,200 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an appropriate
|
||||
"Contributors" file going in your project.
|
||||
|
||||
Copyright 2026 Egzakta Group d.o.o.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
36
packages/hive-mind-cli/NOTICE
Normal file
36
packages/hive-mind-cli/NOTICE
Normal file
@@ -0,0 +1,36 @@
|
||||
hive-mind
|
||||
Copyright 2026 Egzakta Group d.o.o.
|
||||
|
||||
This product includes software developed by Egzakta Group d.o.o.
|
||||
(https://egzakta.com).
|
||||
|
||||
Portions of this software were extracted from Waggle OS
|
||||
(https://waggle-os.ai) — specifically the memory substrate layers
|
||||
(frames, hybrid search, knowledge graph, identity, awareness, sessions),
|
||||
the harvest pipeline, and the wiki compiler.
|
||||
|
||||
The following Waggle OS subsystems are intentionally NOT part of this
|
||||
distribution and remain proprietary to Egzakta Group:
|
||||
|
||||
- EU AI Act compliance reporting and audit trail (compliance/*)
|
||||
- Agent runtime, personas, and behavioral specifications (packages/agent/*)
|
||||
- Self-evolution engine: GEPA iterative optimization, EvolveSchema,
|
||||
execution trace storage, evolution run storage, improvement signals
|
||||
- Encrypted secret vault (mind/vault.ts)
|
||||
- Tier and billing system (Stripe integration, feature gating)
|
||||
- Tauri desktop shell and Waggle web UI
|
||||
- Multi-agent coordination (WaggleDance, subagent orchestration)
|
||||
|
||||
See EXTRACTION.md in the repository root for the full source mapping.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
not use this file except in compliance with the License. You may obtain
|
||||
a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
55
packages/hive-mind-cli/README.md
Normal file
55
packages/hive-mind-cli/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# @hive-mind/cli
|
||||
|
||||
[](https://www.npmjs.com/package/@hive-mind/cli)
|
||||
[](./LICENSE)
|
||||
|
||||
Command-line tools for hive-mind memory. Designed to run from `SessionStart` / `Stop` hooks and nightly cron jobs — wherever you need a shell-driven way to read from, write to, or maintain a hive-mind workspace.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @hive-mind/cli
|
||||
```
|
||||
|
||||
or run without installing:
|
||||
|
||||
```bash
|
||||
npx @hive-mind/cli recall-context "what do I know about X"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `recall-context <query>` | Hybrid FTS5 + vector search over memory frames |
|
||||
| `save-session` | Persist current session transcript as memory frames |
|
||||
| `harvest-local` | Ingest ChatGPT / Claude / Gemini / PDF / Markdown / URL exports |
|
||||
| `cognify` | Extract entities and relations into the knowledge graph |
|
||||
| `compile-wiki` | Compile memory into interlinked wiki pages |
|
||||
| `maintenance` | Compact superseded frames, clean up deprecated data |
|
||||
|
||||
Every command supports `--json` for machine-readable output and `--help` for flags.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Recall
|
||||
hive-mind-cli recall-context "deployment pipeline decisions"
|
||||
|
||||
# Save a Claude Code session transcript
|
||||
hive-mind-cli save-session --file ./claude-session.md
|
||||
|
||||
# Harvest a folder of ChatGPT exports
|
||||
hive-mind-cli harvest-local --source chatgpt --path ./exports
|
||||
|
||||
# Nightly maintenance
|
||||
hive-mind-cli maintenance --compact --cleanup
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
|
||||
|
||||
## Part of hive-mind
|
||||
|
||||
Full docs at the [monorepo root](https://github.com/marolinik/hive-mind).
|
||||
651
packages/hive-mind-cli/assets/mcp-health-check-fixed.js
Normal file
651
packages/hive-mind-cli/assets/mcp-health-check-fixed.js
Normal file
@@ -0,0 +1,651 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* BUNDLED ASSET — mcp-health-check.js for hive-mind-cli postinstall override.
|
||||
*
|
||||
* Source: everything-claude-code marketplace plugin
|
||||
* (https://github.com/Affaan-Mustafa/everything-claude-code)
|
||||
* commit cf6e6c5d, MIT License (Copyright (c) 2026 Affaan Mustafa)
|
||||
* Patch authored by Marko Markovic <marko.markovic@egzakta.com> at Egzakta Group.
|
||||
*
|
||||
* Patch summary:
|
||||
* probeCommandServer() now sets `shell: true` and `windowsHide: true` on the
|
||||
* spawnOptions when process.platform === 'win32'. This lets npm-installed
|
||||
* .cmd / .bat shims (e.g., hive-mind-cli) resolve via cmd.exe instead of
|
||||
* failing with `spawn ENOENT` and getting quarantined for the entire backoff
|
||||
* window.
|
||||
*
|
||||
* Distribution: Apache-2.0 wrapper around MIT-licensed upstream. The MIT notice
|
||||
* above is preserved per the original LICENSE in everything-claude-code repo.
|
||||
* MIT and Apache-2.0 are bidirectionally compatible for redistribution.
|
||||
*
|
||||
* Lifecycle: this asset is bundled by @waggle/hive-mind-cli and dropped to
|
||||
* ~/.claude/scripts/hooks/mcp-health-check.js (user override path) on win32 by
|
||||
* the package's postinstall script. Once upstream merges the fix (PR materials
|
||||
* in packages/hive-mind-hooks-claude-code/upstream-pr/), this override is no
|
||||
* longer needed and can be removed.
|
||||
*
|
||||
* MCP health-check hook.
|
||||
*
|
||||
* Compatible with Claude Code's existing hook events:
|
||||
* - PreToolUse: probe MCP server health before MCP tool execution
|
||||
* - PostToolUseFailure: mark unhealthy servers, attempt reconnect, and re-probe
|
||||
*
|
||||
* The hook persists health state outside the conversation context so it
|
||||
* survives compaction and later turns.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_BACKOFF_MS = 30 * 1000;
|
||||
const MAX_BACKOFF_MS = 10 * 60 * 1000;
|
||||
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 405]);
|
||||
const RECONNECT_STATUS_CODES = new Set([401, 403, 429, 503]);
|
||||
const FAILURE_PATTERNS = [
|
||||
{ code: 401, pattern: /\b401\b|unauthori[sz]ed|auth(?:entication)?\s+(?:failed|expired|invalid)/i },
|
||||
{ code: 403, pattern: /\b403\b|forbidden|permission denied/i },
|
||||
{ code: 429, pattern: /\b429\b|rate limit|too many requests/i },
|
||||
{ code: 503, pattern: /\b503\b|service unavailable|overloaded|temporarily unavailable/i },
|
||||
{ code: 'transport', pattern: /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed? out|socket hang up|connection (?:failed|lost|reset|closed)/i }
|
||||
];
|
||||
|
||||
function envNumber(name, fallback) {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function stateFilePath() {
|
||||
if (process.env.ECC_MCP_HEALTH_STATE_PATH) {
|
||||
return path.resolve(process.env.ECC_MCP_HEALTH_STATE_PATH);
|
||||
}
|
||||
return path.join(os.homedir(), '.claude', 'mcp-health-cache.json');
|
||||
}
|
||||
|
||||
function configPaths() {
|
||||
if (process.env.ECC_MCP_CONFIG_PATH) {
|
||||
return process.env.ECC_MCP_CONFIG_PATH
|
||||
.split(path.delimiter)
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map(entry => path.resolve(entry));
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const home = os.homedir();
|
||||
|
||||
return [
|
||||
path.join(cwd, '.claude.json'),
|
||||
path.join(cwd, '.claude', 'settings.json'),
|
||||
path.join(home, '.claude.json'),
|
||||
path.join(home, '.claude', 'settings.json')
|
||||
];
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadState(filePath) {
|
||||
const state = readJsonFile(filePath);
|
||||
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
||||
return { version: 1, servers: {} };
|
||||
}
|
||||
|
||||
if (!state.servers || typeof state.servers !== 'object' || Array.isArray(state.servers)) {
|
||||
state.servers = {};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function saveState(filePath, state) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||
} catch {
|
||||
// Never block the hook on state persistence errors.
|
||||
}
|
||||
}
|
||||
|
||||
function readRawStdin() {
|
||||
return new Promise(resolve => {
|
||||
let raw = '';
|
||||
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
if (chunk.length > remaining) {
|
||||
truncated = true;
|
||||
}
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => resolve({ raw, truncated }));
|
||||
process.stdin.on('error', () => resolve({ raw, truncated }));
|
||||
});
|
||||
}
|
||||
|
||||
function safeParse(raw) {
|
||||
try {
|
||||
return raw.trim() ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function extractMcpTarget(input) {
|
||||
const toolName = String(input.tool_name || input.name || '');
|
||||
const explicitServer = input.server
|
||||
|| input.mcp_server
|
||||
|| input.tool_input?.server
|
||||
|| input.tool_input?.mcp_server
|
||||
|| input.tool_input?.connector
|
||||
|| null;
|
||||
const explicitTool = input.tool
|
||||
|| input.mcp_tool
|
||||
|| input.tool_input?.tool
|
||||
|| input.tool_input?.mcp_tool
|
||||
|| null;
|
||||
|
||||
if (explicitServer) {
|
||||
return {
|
||||
server: String(explicitServer),
|
||||
tool: explicitTool ? String(explicitTool) : toolName
|
||||
};
|
||||
}
|
||||
|
||||
if (!toolName.startsWith('mcp__')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = toolName.slice(5).split('__');
|
||||
if (segments.length < 2 || !segments[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
server: segments[0],
|
||||
tool: segments.slice(1).join('__')
|
||||
};
|
||||
}
|
||||
|
||||
function extractMcpTargetFromRaw(raw) {
|
||||
const toolNameMatch = raw.match(/"(?:tool_name|name)"\s*:\s*"([^"]+)"/);
|
||||
const serverMatch = raw.match(/"(?:server|mcp_server|connector)"\s*:\s*"([^"]+)"/);
|
||||
const toolMatch = raw.match(/"(?:tool|mcp_tool)"\s*:\s*"([^"]+)"/);
|
||||
|
||||
return extractMcpTarget({
|
||||
tool_name: toolNameMatch ? toolNameMatch[1] : '',
|
||||
server: serverMatch ? serverMatch[1] : undefined,
|
||||
tool: toolMatch ? toolMatch[1] : undefined
|
||||
});
|
||||
}
|
||||
|
||||
function resolveServerConfig(serverName) {
|
||||
for (const filePath of configPaths()) {
|
||||
const data = readJsonFile(filePath);
|
||||
const server = data?.mcpServers?.[serverName]
|
||||
|| data?.mcp_servers?.[serverName]
|
||||
|| null;
|
||||
|
||||
if (server && typeof server === 'object' && !Array.isArray(server)) {
|
||||
return {
|
||||
config: server,
|
||||
source: filePath
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function markHealthy(state, serverName, now, details = {}) {
|
||||
state.servers[serverName] = {
|
||||
status: 'healthy',
|
||||
checkedAt: now,
|
||||
expiresAt: now + envNumber('ECC_MCP_HEALTH_TTL_MS', DEFAULT_TTL_MS),
|
||||
failureCount: 0,
|
||||
lastError: null,
|
||||
lastFailureCode: null,
|
||||
nextRetryAt: now,
|
||||
lastRestoredAt: now,
|
||||
...details
|
||||
};
|
||||
}
|
||||
|
||||
function markUnhealthy(state, serverName, now, failureCode, errorMessage) {
|
||||
const previous = state.servers[serverName] || {};
|
||||
const failureCount = Number(previous.failureCount || 0) + 1;
|
||||
const backoffBase = envNumber('ECC_MCP_HEALTH_BACKOFF_MS', DEFAULT_BACKOFF_MS);
|
||||
const nextRetryDelay = Math.min(backoffBase * (2 ** Math.max(failureCount - 1, 0)), MAX_BACKOFF_MS);
|
||||
|
||||
state.servers[serverName] = {
|
||||
status: 'unhealthy',
|
||||
checkedAt: now,
|
||||
expiresAt: now,
|
||||
failureCount,
|
||||
lastError: errorMessage || null,
|
||||
lastFailureCode: failureCode || null,
|
||||
nextRetryAt: now + nextRetryDelay,
|
||||
lastRestoredAt: previous.lastRestoredAt || null
|
||||
};
|
||||
}
|
||||
|
||||
function failureSummary(input) {
|
||||
const output = input.tool_output;
|
||||
const pieces = [
|
||||
typeof input.error === 'string' ? input.error : '',
|
||||
typeof input.message === 'string' ? input.message : '',
|
||||
typeof input.tool_response === 'string' ? input.tool_response : '',
|
||||
typeof output === 'string' ? output : '',
|
||||
typeof output?.output === 'string' ? output.output : '',
|
||||
typeof output?.stderr === 'string' ? output.stderr : '',
|
||||
typeof input.tool_input?.error === 'string' ? input.tool_input.error : ''
|
||||
].filter(Boolean);
|
||||
|
||||
return pieces.join('\n');
|
||||
}
|
||||
|
||||
function detectFailureCode(text) {
|
||||
const summary = String(text || '');
|
||||
for (const entry of FAILURE_PATTERNS) {
|
||||
if (entry.pattern.test(summary)) {
|
||||
return entry.code;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestHttp(urlString, headers, timeoutMs) {
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
|
||||
const url = new URL(urlString);
|
||||
const client = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const req = client.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
},
|
||||
res => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
res.resume();
|
||||
resolve({
|
||||
ok: HEALTHY_HTTP_CODES.has(res.statusCode),
|
||||
statusCode: res.statusCode,
|
||||
reason: `HTTP ${res.statusCode}`
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
timedOut = true;
|
||||
req.destroy(new Error('timeout'));
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: timedOut ? 'request timed out' : error.message
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function probeCommandServer(serverName, config) {
|
||||
return new Promise(resolve => {
|
||||
const command = config.command;
|
||||
const args = Array.isArray(config.args) ? config.args.map(arg => String(arg)) : [];
|
||||
const timeoutMs = envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
|
||||
const mergedEnv = {
|
||||
...process.env,
|
||||
...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {})
|
||||
};
|
||||
|
||||
let stderr = '';
|
||||
let done = false;
|
||||
|
||||
function finish(result) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
const spawnOptions = {
|
||||
env: mergedEnv,
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'ignore', 'pipe']
|
||||
};
|
||||
if (process.platform === 'win32') {
|
||||
// Required so .cmd/.bat shims (e.g., npm-installed CLIs like hive-mind-cli)
|
||||
// resolve via cmd.exe instead of failing with spawn ENOENT.
|
||||
spawnOptions.shell = true;
|
||||
spawnOptions.windowsHide = true;
|
||||
}
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawn(command, args, spawnOptions);
|
||||
} catch (error) {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
child.stderr.on('data', chunk => {
|
||||
if (stderr.length < 4000) {
|
||||
const remaining = 4000 - stderr.length;
|
||||
stderr += String(chunk).slice(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', error => {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: code,
|
||||
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
|
||||
});
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 200).unref?.();
|
||||
|
||||
finish({
|
||||
ok: true,
|
||||
statusCode: null,
|
||||
reason: `${serverName} accepted a new stdio process`
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function probeServer(serverName, resolvedConfig) {
|
||||
const config = resolvedConfig.config;
|
||||
|
||||
if (config.type === 'http' || config.url) {
|
||||
const result = await requestHttp(config.url, config.headers || {}, envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS));
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
failureCode: RECONNECT_STATUS_CODES.has(result.statusCode) ? result.statusCode : null,
|
||||
reason: result.reason,
|
||||
source: resolvedConfig.source
|
||||
};
|
||||
}
|
||||
|
||||
if (config.command) {
|
||||
const result = await probeCommandServer(serverName, config);
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
failureCode: RECONNECT_STATUS_CODES.has(result.statusCode) ? result.statusCode : null,
|
||||
reason: result.reason,
|
||||
source: resolvedConfig.source
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: null,
|
||||
reason: 'unsupported MCP server config',
|
||||
source: resolvedConfig.source
|
||||
};
|
||||
}
|
||||
|
||||
function reconnectCommand(serverName) {
|
||||
const key = `ECC_MCP_RECONNECT_${String(serverName).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
||||
const command = process.env[key] || process.env.ECC_MCP_RECONNECT_COMMAND || '';
|
||||
if (!command.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return command.includes('{server}')
|
||||
? command.replace(/\{server\}/g, serverName)
|
||||
: command;
|
||||
}
|
||||
|
||||
function attemptReconnect(serverName) {
|
||||
const command = reconnectCommand(serverName);
|
||||
if (!command) {
|
||||
return { attempted: false, success: false, reason: 'no reconnect command configured' };
|
||||
}
|
||||
|
||||
const result = spawnSync(command, {
|
||||
shell: true,
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
timeout: envNumber('ECC_MCP_RECONNECT_TIMEOUT_MS', DEFAULT_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { attempted: true, success: false, reason: result.error.message };
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
return {
|
||||
attempted: true,
|
||||
success: false,
|
||||
reason: (result.stderr || result.stdout || `reconnect exited ${result.status}`).trim()
|
||||
};
|
||||
}
|
||||
|
||||
return { attempted: true, success: true, reason: 'reconnect command completed' };
|
||||
}
|
||||
|
||||
function shouldFailOpen() {
|
||||
return /^(1|true|yes)$/i.test(String(process.env.ECC_MCP_HEALTH_FAIL_OPEN || ''));
|
||||
}
|
||||
|
||||
function emitLogs(logs) {
|
||||
for (const line of logs) {
|
||||
process.stderr.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreToolUse(rawInput, input, target, statePathValue, now) {
|
||||
const logs = [];
|
||||
const state = loadState(statePathValue);
|
||||
const previous = state.servers[target.server] || {};
|
||||
|
||||
if (previous.status === 'healthy' && Number(previous.expiresAt || 0) > now) {
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
if (previous.status === 'unhealthy' && Number(previous.nextRetryAt || 0) > now) {
|
||||
logs.push(
|
||||
`[MCPHealthCheck] ${target.server} is marked unhealthy until ${new Date(previous.nextRetryAt).toISOString()}; skipping ${target.tool || 'tool'}`
|
||||
);
|
||||
return { rawInput, exitCode: shouldFailOpen() ? 0 : 2, logs };
|
||||
}
|
||||
|
||||
const resolvedConfig = resolveServerConfig(target.server);
|
||||
if (!resolvedConfig) {
|
||||
logs.push(`[MCPHealthCheck] No MCP config found for ${target.server}; skipping preflight probe`);
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
const probe = await probeServer(target.server, resolvedConfig);
|
||||
if (probe.ok) {
|
||||
markHealthy(state, target.server, now, { source: resolvedConfig.source });
|
||||
saveState(statePathValue, state);
|
||||
|
||||
if (previous.status === 'unhealthy') {
|
||||
logs.push(`[MCPHealthCheck] ${target.server} connection restored`);
|
||||
}
|
||||
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
let reconnect = { attempted: false, success: false, reason: 'probe failed' };
|
||||
if (probe.failureCode || previous.status === 'unhealthy') {
|
||||
reconnect = attemptReconnect(target.server);
|
||||
if (reconnect.success) {
|
||||
const reprobe = await probeServer(target.server, resolvedConfig);
|
||||
if (reprobe.ok) {
|
||||
markHealthy(state, target.server, now, {
|
||||
source: resolvedConfig.source,
|
||||
restoredBy: 'reconnect-command'
|
||||
});
|
||||
saveState(statePathValue, state);
|
||||
logs.push(`[MCPHealthCheck] ${target.server} connection restored after reconnect`);
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
probe.reason = `${probe.reason}; reconnect reprobe failed: ${reprobe.reason}`;
|
||||
}
|
||||
}
|
||||
|
||||
markUnhealthy(state, target.server, now, probe.failureCode, probe.reason);
|
||||
saveState(statePathValue, state);
|
||||
|
||||
const reconnectSuffix = reconnect.attempted
|
||||
? ` Reconnect attempt: ${reconnect.success ? 'ok' : reconnect.reason}.`
|
||||
: '';
|
||||
logs.push(
|
||||
`[MCPHealthCheck] ${target.server} is unavailable (${probe.reason}). Blocking ${target.tool || 'tool'} so Claude can fall back to non-MCP tools.${reconnectSuffix}`
|
||||
);
|
||||
|
||||
return { rawInput, exitCode: shouldFailOpen() ? 0 : 2, logs };
|
||||
}
|
||||
|
||||
async function handlePostToolUseFailure(rawInput, input, target, statePathValue, now) {
|
||||
const logs = [];
|
||||
const summary = failureSummary(input);
|
||||
const failureCode = detectFailureCode(summary);
|
||||
|
||||
if (!failureCode) {
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
const state = loadState(statePathValue);
|
||||
markUnhealthy(state, target.server, now, failureCode, summary.slice(0, 500));
|
||||
saveState(statePathValue, state);
|
||||
|
||||
logs.push(`[MCPHealthCheck] ${target.server} reported ${failureCode}; marking server unhealthy and attempting reconnect`);
|
||||
|
||||
const reconnect = attemptReconnect(target.server);
|
||||
if (!reconnect.attempted) {
|
||||
logs.push(`[MCPHealthCheck] ${target.server} reconnect skipped: ${reconnect.reason}`);
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
if (!reconnect.success) {
|
||||
logs.push(`[MCPHealthCheck] ${target.server} reconnect failed: ${reconnect.reason}`);
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
const resolvedConfig = resolveServerConfig(target.server);
|
||||
if (!resolvedConfig) {
|
||||
logs.push(`[MCPHealthCheck] ${target.server} reconnect completed but no config was available for a follow-up probe`);
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
const reprobe = await probeServer(target.server, resolvedConfig);
|
||||
if (!reprobe.ok) {
|
||||
logs.push(`[MCPHealthCheck] ${target.server} reconnect command ran, but health probe still failed: ${reprobe.reason}`);
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
const refreshed = loadState(statePathValue);
|
||||
markHealthy(refreshed, target.server, now, {
|
||||
source: resolvedConfig.source,
|
||||
restoredBy: 'post-failure-reconnect'
|
||||
});
|
||||
saveState(statePathValue, refreshed);
|
||||
logs.push(`[MCPHealthCheck] ${target.server} connection restored`);
|
||||
return { rawInput, exitCode: 0, logs };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { raw: rawInput, truncated } = await readRawStdin();
|
||||
const input = safeParse(rawInput);
|
||||
const target = extractMcpTarget(input) || (truncated ? extractMcpTargetFromRaw(rawInput) : null);
|
||||
|
||||
if (!target) {
|
||||
process.stdout.write(rawInput);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
const limit = Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN;
|
||||
const logs = [
|
||||
shouldFailOpen()
|
||||
? `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; allowing ${target.tool || 'tool'} because fail-open mode is enabled`
|
||||
: `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; blocking ${target.tool || 'tool'} to avoid bypassing MCP health checks`
|
||||
];
|
||||
emitLogs(logs);
|
||||
process.stdout.write(rawInput);
|
||||
process.exit(shouldFailOpen() ? 0 : 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventName = process.env.CLAUDE_HOOK_EVENT_NAME || 'PreToolUse';
|
||||
const now = Date.now();
|
||||
const statePathValue = stateFilePath();
|
||||
|
||||
const result = eventName === 'PostToolUseFailure'
|
||||
? await handlePostToolUseFailure(rawInput, input, target, statePathValue, now)
|
||||
: await handlePreToolUse(rawInput, input, target, statePathValue, now);
|
||||
|
||||
emitLogs(result.logs);
|
||||
process.stdout.write(result.rawInput);
|
||||
process.exit(result.exitCode);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
process.stderr.write(`[MCPHealthCheck] Unexpected error: ${error.message}\n`);
|
||||
process.exit(0);
|
||||
});
|
||||
115
packages/hive-mind-cli/docs/WINDOWS-QUIRKS.md
Normal file
115
packages/hive-mind-cli/docs/WINDOWS-QUIRKS.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Windows Quirks — hive-mind-cli on Windows 10/11
|
||||
|
||||
> **TL;DR:** Install with `npm install -g @waggle/hive-mind-cli`. The package's
|
||||
> postinstall script handles all the Windows-specific quirks automatically. If
|
||||
> you ever see `spawn ENOENT` errors when Claude Code tries to use hive-mind,
|
||||
> run `hive-mind-cli doctor` — it will diagnose and recover automatically.
|
||||
|
||||
## What's different on Windows
|
||||
|
||||
Windows command-line behavior diverges from POSIX (Linux + macOS) in three ways
|
||||
that affect how Claude Code talks to `hive-mind-cli`:
|
||||
|
||||
### 1. npm-global CLIs are `.cmd` shims, not symlinks
|
||||
|
||||
When you run `npm install -g <package>` on Windows, npm creates a small `.cmd`
|
||||
file in `%APPDATA%\npm\<package>.cmd` (or `~\AppData\Roaming\npm\<package>.cmd`).
|
||||
That `.cmd` file invokes Node with the package's actual JS entry point.
|
||||
|
||||
POSIX systems use symlinks to the real binary, which `child_process.spawn`
|
||||
resolves natively without any extra config. Windows requires `cmd.exe` to
|
||||
resolve the `.cmd` shim — meaning callers must either:
|
||||
|
||||
- Pass `shell: true` to `child_process.spawn(...)`, OR
|
||||
- Add the explicit `.cmd` extension to the binary name (`hive-mind-cli.cmd`)
|
||||
|
||||
Most Node code targets POSIX and forgets `shell: true`. When that code runs on
|
||||
Windows, it hits `spawn <name> ENOENT` even though the binary IS on PATH.
|
||||
|
||||
### 2. The Claude Code MCP health-check hook had this bug
|
||||
|
||||
Until commit `cf6e6c5d` (April 2026) on the
|
||||
[`everything-claude-code` marketplace plugin](https://github.com/Affaan-Mustafa/everything-claude-code),
|
||||
`scripts/hooks/mcp-health-check.js::probeCommandServer` called `spawn(command, args, ...)`
|
||||
without `shell: true`. Result: every MCP server backed by an npm-installed
|
||||
CLI (hive-mind-cli, chrome-devtools, etc.) failed the health check on Windows,
|
||||
got marked "unhealthy," and was quarantined for the backoff window — blocking
|
||||
every subsequent MCP tool call until quarantine expired.
|
||||
|
||||
`@waggle/hive-mind-cli` ships a corrected `mcp-health-check.js` and drops it to
|
||||
the user override path (`~/.claude/scripts/hooks/mcp-health-check.js`) at install
|
||||
time on Windows. That override takes precedence over the plugin path in Claude
|
||||
Code's hook resolution order, so the fix is effective even if the marketplace
|
||||
plugin still has the bug.
|
||||
|
||||
### 3. Quarantine state file must be cleared after first install
|
||||
|
||||
If you ran into this bug BEFORE installing `@waggle/hive-mind-cli`, your Claude
|
||||
Code may have a stale quarantine state for `hive-mind` cached at:
|
||||
|
||||
```
|
||||
~/.claude/mcp-health-cache.json
|
||||
```
|
||||
|
||||
If a server is in `failureQuarantines` or `lastFailureCode = 'spawn ENOENT'`
|
||||
in that file, Claude Code will continue refusing to call its tools even after
|
||||
the postinstall fix is in place. Workaround:
|
||||
|
||||
```powershell
|
||||
# Edit the file and remove the hive-mind entry (or any quarantined entries):
|
||||
notepad $env:USERPROFILE\.claude\mcp-health-cache.json
|
||||
```
|
||||
|
||||
…or simply delete the cache file entirely:
|
||||
|
||||
```powershell
|
||||
Remove-Item $env:USERPROFILE\.claude\mcp-health-cache.json
|
||||
```
|
||||
|
||||
…or run the doctor command (see below) which clears it automatically.
|
||||
|
||||
## Verifying hive-mind-cli is healthy in <10 seconds
|
||||
|
||||
```powershell
|
||||
hive-mind-cli doctor
|
||||
```
|
||||
|
||||
Expected output (green path):
|
||||
|
||||
```
|
||||
[hive-mind-cli doctor] Spawning self via Node child_process.spawn… ✓
|
||||
[hive-mind-cli doctor] Saving probe frame to personal.mind… ✓
|
||||
[hive-mind-cli doctor] Recalling probe frame… ✓
|
||||
[hive-mind-cli doctor] Checked mcp-health-cache.json: clean
|
||||
[hive-mind-cli doctor] PASS — hive-mind-cli is healthy on win32. First MCP call should succeed.
|
||||
```
|
||||
|
||||
If any step fails, the doctor command prints an actionable error message
|
||||
suggesting the next step (e.g., "Re-run `npm install -g @waggle/hive-mind-cli`
|
||||
to re-trigger the postinstall override").
|
||||
|
||||
## Unsupported Windows configurations
|
||||
|
||||
Tested on:
|
||||
|
||||
- Windows 10 21H2+ (build 19044+)
|
||||
- Windows 11 22H2+ (build 22621+)
|
||||
- Node.js 20.x via official MSI installer
|
||||
|
||||
Untested / may need additional config:
|
||||
|
||||
- WSL1 (use WSL2 instead — POSIX behavior, no quirks)
|
||||
- Cygwin / MSYS2 — should work like POSIX but `~` path resolution may differ
|
||||
- npm with PowerShell 7 default executor change — should work, but test if you
|
||||
see spawn errors
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
If `hive-mind-cli doctor` fails on a configuration not listed above, file an
|
||||
issue at [`marolinik/waggle-os/issues`](https://github.com/marolinik/waggle-os/issues)
|
||||
with:
|
||||
|
||||
- Output of `hive-mind-cli doctor` (full)
|
||||
- Output of `node --version` and `npm --version`
|
||||
- Windows version (`winver` from Run dialog)
|
||||
- Whether `everything-claude-code` marketplace plugin is installed (`Get-ChildItem $env:USERPROFILE\.claude\plugins\marketplaces`)
|
||||
67
packages/hive-mind-cli/package.json
Normal file
67
packages/hive-mind-cli/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@waggle/hive-mind-cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Command-line tools for hive-mind: recall, save, harvest, compile-wiki, maintenance, doctor. Designed to run from SessionStart/Stop hooks and nightly cron. Includes Windows .cmd shim postinstall fix for Claude Code MCP integration.",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"hive-mind-cli": "dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"assets",
|
||||
"docs",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"postinstall.cjs"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"clean": "tsc --build --clean",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"start": "node dist/index.js",
|
||||
"postinstall": "node postinstall.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waggle/hive-mind-core": "*",
|
||||
"@waggle/hive-mind-mcp-server": "*",
|
||||
"@waggle/hive-mind-wiki-compiler": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/marolinik/waggle-os.git",
|
||||
"directory": "packages/hive-mind-cli"
|
||||
},
|
||||
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-cli#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/marolinik/waggle-os/issues"
|
||||
},
|
||||
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
|
||||
"keywords": [
|
||||
"cli",
|
||||
"memory",
|
||||
"hive-mind",
|
||||
"harvest",
|
||||
"wiki-compiler",
|
||||
"ai",
|
||||
"claude-code",
|
||||
"mcp",
|
||||
"windows-installer",
|
||||
"doctor"
|
||||
],
|
||||
"types": "dist/index.d.ts"
|
||||
}
|
||||
135
packages/hive-mind-cli/postinstall.cjs
Normal file
135
packages/hive-mind-cli/postinstall.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @waggle/hive-mind-cli postinstall — Wave 1 cleanup workaround.
|
||||
*
|
||||
* Per Wave 1 brief 2026-04-29 (LOCKED 2026-04-30) + feedback_memory_install_dead_simple binding:
|
||||
* Solo $19/mo Waggle users must NOT face shell debug or .cmd shim awareness on first install.
|
||||
*
|
||||
* What this does:
|
||||
* - On non-win32 (Linux + macOS): exits 0 immediately. No-op. Hook resolution works
|
||||
* natively because POSIX resolves shebangs without `shell: true`.
|
||||
* - On win32: detects whether the everything-claude-code marketplace plugin's
|
||||
* mcp-health-check.js hook has the Wave 1 fix (presence of `spawnOptions.shell = true`
|
||||
* in the win32 branch of probeCommandServer). If the fix is missing or the override
|
||||
* at ~/.claude/scripts/hooks/mcp-health-check.js is missing/stale, drop a corrected
|
||||
* copy at ~/.claude/scripts/hooks/mcp-health-check.js (user-override path takes
|
||||
* precedence over plugin path in Claude Code's hook resolution order).
|
||||
*
|
||||
* Why: without this fix, child_process.spawn("hive-mind-cli", …) returns ENOENT on
|
||||
* Windows because Windows requires cmd.exe to resolve npm-installed `.cmd` shims.
|
||||
* The hook would mark hive-mind-cli "unhealthy" and quarantine it for the entire
|
||||
* backoff window, blocking every MCP tool call until the quarantine expired —
|
||||
* a zero-engagement failure mode that's unacceptable for a paid tier.
|
||||
*
|
||||
* Once upstream everything-claude-code merges the fix (PR materials in
|
||||
* packages/hive-mind-hooks-claude-code/upstream-pr/), this override is no longer
|
||||
* needed and can be cleaned up by deleting ~/.claude/scripts/hooks/mcp-health-check.js.
|
||||
*
|
||||
* License: Apache-2.0 (this script). The bundled mcp-health-check-fixed.js asset
|
||||
* is MIT (originally by Affaan Mustafa, modified by Marko Markovic for the Wave 1
|
||||
* win32 fix per commit cf6e6c5d on the everything-claude-code marketplace clone).
|
||||
* MIT notice preserved in the asset's header.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// POSIX: zero-touch. Hook resolution works natively.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const HOME = os.homedir();
|
||||
const CLAUDE_DIR = path.join(HOME, '.claude');
|
||||
const MARKETPLACE_HOOK_PATH = path.join(
|
||||
CLAUDE_DIR,
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'everything-claude-code',
|
||||
'scripts',
|
||||
'hooks',
|
||||
'mcp-health-check.js'
|
||||
);
|
||||
const USER_OVERRIDE_DIR = path.join(CLAUDE_DIR, 'scripts', 'hooks');
|
||||
const USER_OVERRIDE_PATH = path.join(USER_OVERRIDE_DIR, 'mcp-health-check.js');
|
||||
const BUNDLED_FIX_PATH = path.join(__dirname, 'assets', 'mcp-health-check-fixed.js');
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[hive-mind-cli postinstall] ${msg}`);
|
||||
}
|
||||
|
||||
function fileContains(filePath, needle) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8').includes(needle);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function dropOverride(reason) {
|
||||
if (!fs.existsSync(BUNDLED_FIX_PATH)) {
|
||||
log(`Bundled fix not found at ${BUNDLED_FIX_PATH}; skipping. Re-install hive-mind-cli to recover.`);
|
||||
return;
|
||||
}
|
||||
ensureDir(USER_OVERRIDE_DIR);
|
||||
|
||||
// If a user override already exists with the fix, do nothing (idempotent).
|
||||
if (fs.existsSync(USER_OVERRIDE_PATH) && fileContains(USER_OVERRIDE_PATH, 'spawnOptions.shell = true')) {
|
||||
log('User override at ~/.claude/scripts/hooks/mcp-health-check.js already has the win32 .cmd fix. No changes needed.');
|
||||
return;
|
||||
}
|
||||
|
||||
// If a user override exists WITHOUT the fix, back it up so we don't blow it away.
|
||||
if (fs.existsSync(USER_OVERRIDE_PATH)) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `${USER_OVERRIDE_PATH}.backup-${ts}`;
|
||||
fs.copyFileSync(USER_OVERRIDE_PATH, backupPath);
|
||||
log(`Existing user override backed up to ${backupPath}`);
|
||||
}
|
||||
|
||||
fs.copyFileSync(BUNDLED_FIX_PATH, USER_OVERRIDE_PATH);
|
||||
log(`Wrote win32 .cmd-shim fix to ${USER_OVERRIDE_PATH} (${reason}).`);
|
||||
log('This override takes precedence over the everything-claude-code plugin hook.');
|
||||
log('Once upstream merges the fix (PR pending), you can delete this override.');
|
||||
}
|
||||
|
||||
function detectAndPatch() {
|
||||
// Case A: marketplace plugin hook present + already patched → no-op.
|
||||
if (
|
||||
fs.existsSync(MARKETPLACE_HOOK_PATH) &&
|
||||
fileContains(MARKETPLACE_HOOK_PATH, 'spawnOptions.shell = true')
|
||||
) {
|
||||
log('everything-claude-code mcp-health-check.js already has the win32 .cmd fix. No override needed.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Case B: marketplace plugin hook present but NOT patched → drop user override.
|
||||
if (fs.existsSync(MARKETPLACE_HOOK_PATH)) {
|
||||
dropOverride('detected unpatched everything-claude-code marketplace hook');
|
||||
return;
|
||||
}
|
||||
|
||||
// Case C: marketplace plugin not detected — drop override anyway as a safety net.
|
||||
// The override only activates if Claude Code uses the user-hooks path; harmless
|
||||
// if Claude Code is not installed or uses a different plugin layout.
|
||||
dropOverride('preventive — no everything-claude-code marketplace detected, override dropped as safety net');
|
||||
}
|
||||
|
||||
try {
|
||||
detectAndPatch();
|
||||
} catch (err) {
|
||||
// Postinstall MUST NOT fail the npm install. Log + continue.
|
||||
log(`Failed to apply win32 hook fix: ${err && err.message ? err.message : err}`);
|
||||
log('hive-mind-cli is installed but you may see "spawn ENOENT" errors on first MCP call.');
|
||||
log('Workaround: copy node_modules/@waggle/hive-mind-cli/assets/mcp-health-check-fixed.js to ~/.claude/scripts/hooks/mcp-health-check.js manually.');
|
||||
log('Or run: hive-mind-cli doctor — to diagnose and self-recover.');
|
||||
}
|
||||
126
packages/hive-mind-cli/src/commands/cognify.ts
Normal file
126
packages/hive-mind-cli/src/commands/cognify.ts
Normal 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 {}; }
|
||||
}
|
||||
68
packages/hive-mind-cli/src/commands/compile-wiki.ts
Normal file
68
packages/hive-mind-cli/src/commands/compile-wiki.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
146
packages/hive-mind-cli/src/commands/dance.ts
Normal file
146
packages/hive-mind-cli/src/commands/dance.ts
Normal 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;
|
||||
}
|
||||
279
packages/hive-mind-cli/src/commands/doctor.ts
Normal file
279
packages/hive-mind-cli/src/commands/doctor.ts
Normal 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 };
|
||||
}
|
||||
399
packages/hive-mind-cli/src/commands/harvest-local.test.ts
Normal file
399
packages/hive-mind-cli/src/commands/harvest-local.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
250
packages/hive-mind-cli/src/commands/harvest-local.ts
Normal file
250
packages/hive-mind-cli/src/commands/harvest-local.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
71
packages/hive-mind-cli/src/commands/init.ts
Normal file
71
packages/hive-mind-cli/src/commands/init.ts
Normal 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');
|
||||
}
|
||||
73
packages/hive-mind-cli/src/commands/maintenance.test.ts
Normal file
73
packages/hive-mind-cli/src/commands/maintenance.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
714
packages/hive-mind-cli/src/commands/maintenance.ts
Normal file
714
packages/hive-mind-cli/src/commands/maintenance.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
236
packages/hive-mind-cli/src/commands/mcp-call.ts
Normal file
236
packages/hive-mind-cli/src/commands/mcp-call.ts
Normal 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');
|
||||
}
|
||||
79
packages/hive-mind-cli/src/commands/mcp-start.ts
Normal file
79
packages/hive-mind-cli/src/commands/mcp-start.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
110
packages/hive-mind-cli/src/commands/recall-context.ts
Normal file
110
packages/hive-mind-cli/src/commands/recall-context.ts
Normal 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');
|
||||
}
|
||||
99
packages/hive-mind-cli/src/commands/save-session.ts
Normal file
99
packages/hive-mind-cli/src/commands/save-session.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
162
packages/hive-mind-cli/src/commands/status.ts
Normal file
162
packages/hive-mind-cli/src/commands/status.ts
Normal 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');
|
||||
}
|
||||
501
packages/hive-mind-cli/src/dispatch.test.ts
Normal file
501
packages/hive-mind-cli/src/dispatch.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
246
packages/hive-mind-cli/src/dispatch.ts
Normal file
246
packages/hive-mind-cli/src/dispatch.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
336
packages/hive-mind-cli/src/index.ts
Normal file
336
packages/hive-mind-cli/src/index.ts
Normal 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);
|
||||
});
|
||||
72
packages/hive-mind-cli/src/setup.test.ts
Normal file
72
packages/hive-mind-cli/src/setup.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
153
packages/hive-mind-cli/src/setup.ts
Normal file
153
packages/hive-mind-cli/src/setup.ts
Normal 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 */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
164
packages/hive-mind-cli/tests/cli-help.test.ts
Normal file
164
packages/hive-mind-cli/tests/cli-help.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
||||
const HIVE_MIND_CLI_DIR = path.join(ROOT, 'packages', 'hive-mind-cli');
|
||||
|
||||
function bin(name: string): string {
|
||||
return process.platform === 'win32' ? `${name}.cmd` : name;
|
||||
}
|
||||
|
||||
function makeDataDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hive-mind-cli-help-'));
|
||||
}
|
||||
|
||||
interface AsyncRunResult {
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function run(command: string, args: string[], dataDir: string): Promise<AsyncRunResult> {
|
||||
return runInCwd(command, args, ROOT, dataDir);
|
||||
}
|
||||
|
||||
function runInCwd(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
dataDir: string,
|
||||
): Promise<AsyncRunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HIVE_MIND_DATA_DIR: dataDir,
|
||||
},
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('error', reject);
|
||||
child.on('close', (status, signal) => resolve({ status, signal, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function personalMindExists(dataDir: string): boolean {
|
||||
return fs.existsSync(path.join(dataDir, 'personal.mind'));
|
||||
}
|
||||
|
||||
const HIVE_MIND_CLI_PACKAGE_CLOSURE = [
|
||||
'@waggle/shared',
|
||||
'@waggle/hive-mind-core',
|
||||
'@waggle/hive-mind-wiki-compiler',
|
||||
'@waggle/hive-mind-mcp-server',
|
||||
'@waggle/hive-mind-cli',
|
||||
] as const;
|
||||
|
||||
describe('hive-mind CLI subcommand help', () => {
|
||||
it('prints init help without creating a personal mind', async () => {
|
||||
const dataDir = makeDataDir();
|
||||
try {
|
||||
const result = await run(bin('npx'), [
|
||||
'tsx',
|
||||
'packages/hive-mind-cli/src/index.ts',
|
||||
'init',
|
||||
'--help',
|
||||
], dataDir);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Usage: hive-mind-cli init');
|
||||
expect(result.stdout).toContain('--data-dir PATH');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(personalMindExists(dataDir)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('prints built status help without creating a personal mind', async () => {
|
||||
const dataDir = makeDataDir();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/hive-mind-cli'], dataDir);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const result = await run(process.execPath, [
|
||||
path.join(HIVE_MIND_CLI_DIR, 'dist', 'index.js'),
|
||||
'status',
|
||||
'--help',
|
||||
], dataDir);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Usage: hive-mind-cli status');
|
||||
expect(result.stdout).toContain('--json');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(personalMindExists(dataDir)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the local package closure and runs npx subcommand help', async () => {
|
||||
const dataDir = makeDataDir();
|
||||
try {
|
||||
const packsDir = path.join(dataDir, 'packs');
|
||||
const projectDir = path.join(dataDir, 'project');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const workspace of HIVE_MIND_CLI_PACKAGE_CLOSURE) {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', workspace], dataDir);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', workspace, '--pack-destination', packsDir, '--json'],
|
||||
dataDir,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(packsDir, packResult.filename).replace(/\\/g, '/');
|
||||
dependencies[workspace] = `file:${tarball}`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwd(
|
||||
bin('npm'),
|
||||
['install', '--no-audit', '--no-fund', '--prefer-offline'],
|
||||
projectDir,
|
||||
dataDir,
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const result = await runInCwd(
|
||||
bin('npx'),
|
||||
['hive-mind-cli', 'status', '--help'],
|
||||
projectDir,
|
||||
dataDir,
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Usage: hive-mind-cli status');
|
||||
expect(result.stdout).toContain('--json');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(personalMindExists(dataDir)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
16
packages/hive-mind-cli/tsconfig.json
Normal file
16
packages/hive-mind-cli/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../hive-mind-core" },
|
||||
{ "path": "../hive-mind-mcp-server" },
|
||||
{ "path": "../hive-mind-wiki-compiler" }
|
||||
],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
8
packages/hive-mind-cli/vitest.config.ts
Normal file
8
packages/hive-mind-cli/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user