diff --git a/bundle/cli.js b/bundle/cli.js index ee79ad6a..7b201714 100755 --- a/bundle/cli.js +++ b/bundle/cli.js @@ -17,21 +17,21 @@ __export(index_marker_store_exports, { hasFreshIndexMarker: () => hasFreshIndexMarker, writeIndexMarker: () => writeIndexMarker }); -import { existsSync as existsSync12, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "node:fs"; -import { join as join15 } from "node:path"; +import { existsSync as existsSync13, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "node:fs"; +import { join as join16 } from "node:path"; import { tmpdir } from "node:os"; function getIndexMarkerDir() { - return process.env.HIVEMIND_INDEX_MARKER_DIR ?? join15(tmpdir(), "hivemind-deeplake-indexes"); + return process.env.HIVEMIND_INDEX_MARKER_DIR ?? join16(tmpdir(), "hivemind-deeplake-indexes"); } function buildIndexMarkerPath(workspaceId, orgId, table, suffix) { const markerKey = [workspaceId, orgId, table, suffix].join("__").replace(/[^a-zA-Z0-9_.-]/g, "_"); - return join15(getIndexMarkerDir(), `${markerKey}.json`); + return join16(getIndexMarkerDir(), `${markerKey}.json`); } function hasFreshIndexMarker(markerPath) { - if (!existsSync12(markerPath)) + if (!existsSync13(markerPath)) return false; try { - const raw = JSON.parse(readFileSync9(markerPath, "utf-8")); + const raw = JSON.parse(readFileSync10(markerPath, "utf-8")); const updatedAt = raw.updatedAt ? new Date(raw.updatedAt).getTime() : NaN; if (!Number.isFinite(updatedAt) || Date.now() - updatedAt > INDEX_MARKER_TTL_MS) return false; @@ -42,7 +42,7 @@ function hasFreshIndexMarker(markerPath) { } function writeIndexMarker(markerPath) { mkdirSync3(getIndexMarkerDir(), { recursive: true }); - writeFileSync6(markerPath, JSON.stringify({ updatedAt: (/* @__PURE__ */ new Date()).toISOString() }), "utf-8"); + writeFileSync7(markerPath, JSON.stringify({ updatedAt: (/* @__PURE__ */ new Date()).toISOString() }), "utf-8"); } var INDEX_MARKER_TTL_MS; var init_index_marker_store = __esm({ @@ -488,31 +488,139 @@ function uninstallCodex() { } // dist/src/cli/install-openclaw.js -import { existsSync as existsSync4, copyFileSync, rmSync } from "node:fs"; +import { existsSync as existsSync5, copyFileSync, rmSync } from "node:fs"; +import { join as join6 } from "node:path"; + +// dist/openclaw/src/setup-config.js +import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3, renameSync } from "node:fs"; +import { homedir as homedir3 } from "node:os"; import { join as join5 } from "node:path"; -var PLUGIN_DIR2 = join5(HOME, ".openclaw", "extensions", "hivemind"); +var HIVEMIND_TOOL_NAMES = ["hivemind_search", "hivemind_read", "hivemind_index"]; +function getOpenclawConfigPath() { + return join5(homedir3(), ".openclaw", "openclaw.json"); +} +function isAllowlistCoveringHivemind(alsoAllow) { + if (!Array.isArray(alsoAllow)) + return false; + for (const entry of alsoAllow) { + if (typeof entry !== "string") + continue; + const normalized = entry.trim().toLowerCase(); + if (normalized === "hivemind") + return true; + if (normalized === "group:plugins") + return true; + if (HIVEMIND_TOOL_NAMES.includes(normalized)) + return true; + } + return false; +} +function isPluginsAllowMissingHivemind(allow) { + return Array.isArray(allow) && allow.length > 0 && !allow.includes("hivemind"); +} +function ensureHivemindAllowlisted() { + const configPath = getOpenclawConfigPath(); + if (!existsSync4(configPath)) { + return { status: "error", configPath, error: "openclaw config file not found" }; + } + let parsed; + try { + const raw = readFileSync5(configPath, "utf-8"); + parsed = JSON.parse(raw); + } catch (e) { + return { status: "error", configPath, error: `could not read/parse config: ${e instanceof Error ? e.message : String(e)}` }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { status: "error", configPath, error: "openclaw config is not a JSON object" }; + } + const plugins = parsed.plugins ?? {}; + const pluginsAllowRaw = plugins.allow; + const tools = parsed.tools ?? {}; + const alsoAllowRaw = tools.alsoAllow; + const pluginsAllowNeedsPatch = isPluginsAllowMissingHivemind(pluginsAllowRaw); + const toolsAlsoAllowNeedsPatch = Array.isArray(alsoAllowRaw) && alsoAllowRaw.length > 0 && !isAllowlistCoveringHivemind(alsoAllowRaw); + if (!pluginsAllowNeedsPatch && !toolsAlsoAllowNeedsPatch) { + return { status: "already-set", configPath }; + } + const updated = { ...parsed }; + if (pluginsAllowNeedsPatch) { + updated.plugins = { + ...plugins, + // Cast safe — isPluginsAllowMissingHivemind guarantees Array. + allow: [...pluginsAllowRaw, "hivemind"] + }; + } + if (toolsAlsoAllowNeedsPatch) { + updated.tools = { + ...tools, + // Cast safe — the needs-patch check above guarantees Array. + alsoAllow: [...alsoAllowRaw, "hivemind"] + }; + } + const backupPath = `${configPath}.bak-hivemind-${Date.now()}`; + const tmpPath = `${configPath}.tmp-hivemind-${process.pid}`; + try { + writeFileSync3(backupPath, readFileSync5(configPath, "utf-8")); + writeFileSync3(tmpPath, JSON.stringify(updated, null, 2) + "\n"); + renameSync(tmpPath, configPath); + } catch (e) { + return { status: "error", configPath, error: `could not write config: ${e instanceof Error ? e.message : String(e)}` }; + } + return { + status: "added", + configPath, + backupPath, + delta: { + pluginsAllow: pluginsAllowNeedsPatch, + toolsAlsoAllow: toolsAlsoAllowNeedsPatch + } + }; +} + +// dist/src/cli/install-openclaw.js +var PLUGIN_DIR2 = join6(HOME, ".openclaw", "extensions", "hivemind"); function installOpenclaw() { - const srcDist = join5(pkgRoot(), "openclaw", "dist"); - const srcManifest = join5(pkgRoot(), "openclaw", "openclaw.plugin.json"); - const srcPkg = join5(pkgRoot(), "openclaw", "package.json"); - const srcSkills = join5(pkgRoot(), "openclaw", "skills"); - if (!existsSync4(srcDist)) { + const srcDist = join6(pkgRoot(), "openclaw", "dist"); + const srcManifest = join6(pkgRoot(), "openclaw", "openclaw.plugin.json"); + const srcPkg = join6(pkgRoot(), "openclaw", "package.json"); + const srcSkills = join6(pkgRoot(), "openclaw", "skills"); + if (!existsSync5(srcDist)) { throw new Error(`OpenClaw bundle missing at ${srcDist}. Run 'npm run build' first.`); } ensureDir(PLUGIN_DIR2); - rmSync(join5(PLUGIN_DIR2, "dist"), { recursive: true, force: true }); - copyDir(srcDist, join5(PLUGIN_DIR2, "dist")); - if (existsSync4(srcManifest)) - copyFileSync(srcManifest, join5(PLUGIN_DIR2, "openclaw.plugin.json")); - if (existsSync4(srcPkg)) - copyFileSync(srcPkg, join5(PLUGIN_DIR2, "package.json")); - if (existsSync4(srcSkills)) - copyDir(srcSkills, join5(PLUGIN_DIR2, "skills")); + rmSync(join6(PLUGIN_DIR2, "dist"), { recursive: true, force: true }); + copyDir(srcDist, join6(PLUGIN_DIR2, "dist")); + if (existsSync5(srcManifest)) + copyFileSync(srcManifest, join6(PLUGIN_DIR2, "openclaw.plugin.json")); + if (existsSync5(srcPkg)) + copyFileSync(srcPkg, join6(PLUGIN_DIR2, "package.json")); + if (existsSync5(srcSkills)) + copyDir(srcSkills, join6(PLUGIN_DIR2, "skills")); writeVersionStamp(PLUGIN_DIR2, getVersion()); log(` OpenClaw installed -> ${PLUGIN_DIR2}`); + const result = ensureHivemindAllowlisted(); + if (result.status === "added") { + const touched = []; + if (result.delta.pluginsAllow) + touched.push("plugins.allow"); + if (result.delta.toolsAlsoAllow) + touched.push("tools.alsoAllow"); + log(` OpenClaw patched ${touched.join(" + ")} in ${result.configPath}`); + log(` OpenClaw backup: ${result.backupPath}`); + log(` OpenClaw restart the gateway to activate: systemctl --user restart openclaw-gateway.service`); + log(` OpenClaw capture starts on the NEXT turn \u2014 earlier turns are NOT backfilled`); + } else if (result.status === "already-set") { + log(` OpenClaw allowlist already covers hivemind in ${result.configPath}`); + } else if (result.status === "error") { + if (result.error === "openclaw config file not found") { + log(` OpenClaw openclaw.json not present at ${result.configPath} \u2014 run openclaw once, then \`hivemind claw install\` again`); + } else { + warn(` OpenClaw could not patch allowlist in ${result.configPath}: ${result.error}`); + } + } } function uninstallOpenclaw() { - if (existsSync4(PLUGIN_DIR2)) { + if (existsSync5(PLUGIN_DIR2)) { rmSync(PLUGIN_DIR2, { recursive: true, force: true }); log(` OpenClaw removed ${PLUGIN_DIR2}`); } else { @@ -521,23 +629,23 @@ function uninstallOpenclaw() { } // dist/src/cli/install-cursor.js -import { existsSync as existsSync5, unlinkSync as unlinkSync3 } from "node:fs"; -import { join as join6 } from "node:path"; -var CURSOR_HOME = join6(HOME, ".cursor"); -var PLUGIN_DIR3 = join6(CURSOR_HOME, "hivemind"); -var HOOKS_PATH2 = join6(CURSOR_HOME, "hooks.json"); +import { existsSync as existsSync6, unlinkSync as unlinkSync3 } from "node:fs"; +import { join as join7 } from "node:path"; +var CURSOR_HOME = join7(HOME, ".cursor"); +var PLUGIN_DIR3 = join7(CURSOR_HOME, "hivemind"); +var HOOKS_PATH2 = join7(CURSOR_HOME, "hooks.json"); var HIVEMIND_MARKER_KEY = "_hivemindManaged"; function buildHookCmd(bundleFile, timeout) { return { type: "command", - command: `node "${join6(PLUGIN_DIR3, "bundle", bundleFile)}"`, + command: `node "${join7(PLUGIN_DIR3, "bundle", bundleFile)}"`, timeout }; } function buildHookCmdShellMatcher(bundleFile, timeout) { return { type: "command", - command: `node "${join6(PLUGIN_DIR3, "bundle", bundleFile)}"`, + command: `node "${join7(PLUGIN_DIR3, "bundle", bundleFile)}"`, timeout, matcher: "Shell" }; @@ -593,12 +701,12 @@ function stripHooksFromConfig(existing) { return existing; } function installCursor() { - const srcBundle = join6(pkgRoot(), "cursor", "bundle"); - if (!existsSync5(srcBundle)) { + const srcBundle = join7(pkgRoot(), "cursor", "bundle"); + if (!existsSync6(srcBundle)) { throw new Error(`Cursor bundle missing at ${srcBundle}. Run 'npm run build' first.`); } ensureDir(PLUGIN_DIR3); - copyDir(srcBundle, join6(PLUGIN_DIR3, "bundle")); + copyDir(srcBundle, join7(PLUGIN_DIR3, "bundle")); const existing = readJson(HOOKS_PATH2); const merged = mergeHooks2(existing); writeJson(HOOKS_PATH2, merged); @@ -614,7 +722,7 @@ function uninstallCursor() { const stripped = stripHooksFromConfig(existing); const meaningfulKeys = stripped ? Object.keys(stripped).filter((k) => k !== "version").length : 0; if (!stripped || meaningfulKeys === 0) { - if (existsSync5(HOOKS_PATH2)) + if (existsSync6(HOOKS_PATH2)) unlinkSync3(HOOKS_PATH2); } else { writeJson(HOOKS_PATH2, stripped); @@ -623,8 +731,8 @@ function uninstallCursor() { } // dist/src/cli/install-hermes.js -import { existsSync as existsSync7, writeFileSync as writeFileSync3, readFileSync as readFileSync5, rmSync as rmSync2, unlinkSync as unlinkSync4 } from "node:fs"; -import { join as join8 } from "node:path"; +import { existsSync as existsSync8, writeFileSync as writeFileSync4, readFileSync as readFileSync6, rmSync as rmSync2, unlinkSync as unlinkSync4 } from "node:fs"; +import { join as join9 } from "node:path"; // node_modules/js-yaml/dist/js-yaml.mjs function isNothing(subject) { @@ -3213,15 +3321,15 @@ var safeLoadAll = renamed("safeLoadAll", "loadAll"); var safeDump = renamed("safeDump", "dump"); // dist/src/cli/install-mcp-shared.js -import { existsSync as existsSync6 } from "node:fs"; -import { join as join7 } from "node:path"; -var HIVEMIND_DIR = join7(HOME, ".hivemind"); -var MCP_DIR = join7(HIVEMIND_DIR, "mcp"); -var MCP_SERVER_PATH = join7(MCP_DIR, "server.js"); -var MCP_PACKAGE_JSON = join7(MCP_DIR, "package.json"); +import { existsSync as existsSync7 } from "node:fs"; +import { join as join8 } from "node:path"; +var HIVEMIND_DIR = join8(HOME, ".hivemind"); +var MCP_DIR = join8(HIVEMIND_DIR, "mcp"); +var MCP_SERVER_PATH = join8(MCP_DIR, "server.js"); +var MCP_PACKAGE_JSON = join8(MCP_DIR, "package.json"); function ensureMcpServerInstalled() { - const srcDir = join7(pkgRoot(), "mcp", "bundle"); - if (!existsSync6(srcDir)) { + const srcDir = join8(pkgRoot(), "mcp", "bundle"); + if (!existsSync7(srcDir)) { throw new Error(`MCP server bundle missing at ${srcDir}. Run 'npm run build' to produce it before installing Tier B consumers.`); } ensureDir(MCP_DIR); @@ -3231,11 +3339,11 @@ function ensureMcpServerInstalled() { } // dist/src/cli/install-hermes.js -var HERMES_HOME = join8(HOME, ".hermes"); -var SKILLS_DIR = join8(HERMES_HOME, "skills", "hivemind-memory"); -var HIVEMIND_DIR2 = join8(HERMES_HOME, "hivemind"); -var BUNDLE_DIR = join8(HIVEMIND_DIR2, "bundle"); -var CONFIG_PATH = join8(HERMES_HOME, "config.yaml"); +var HERMES_HOME = join9(HOME, ".hermes"); +var SKILLS_DIR = join9(HERMES_HOME, "skills", "hivemind-memory"); +var HIVEMIND_DIR2 = join9(HERMES_HOME, "hivemind"); +var BUNDLE_DIR = join9(HIVEMIND_DIR2, "bundle"); +var CONFIG_PATH = join9(HERMES_HOME, "config.yaml"); var SERVER_KEY = "hivemind"; var SKILL_BODY = `--- name: hivemind-memory @@ -3293,7 +3401,7 @@ function isHivemindHook(entry) { } function buildHookEntry(bundleFile, timeout, matcher) { const entry = { - command: `node ${join8(BUNDLE_DIR, bundleFile)}`, + command: `node ${join9(BUNDLE_DIR, bundleFile)}`, timeout }; if (matcher) @@ -3337,10 +3445,10 @@ function stripHivemindHooks(existing) { return Object.keys(out).length > 0 ? out : void 0; } function readConfig() { - if (!existsSync7(CONFIG_PATH)) + if (!existsSync8(CONFIG_PATH)) return {}; try { - const raw = readFileSync5(CONFIG_PATH, "utf-8"); + const raw = readFileSync6(CONFIG_PATH, "utf-8"); const parsed = load(raw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed; @@ -3353,15 +3461,15 @@ function readConfig() { function writeConfig(cfg) { ensureDir(HERMES_HOME); const dumped = dump(cfg, { lineWidth: 100, noRefs: true }); - writeFileSync3(CONFIG_PATH, dumped); + writeFileSync4(CONFIG_PATH, dumped); } function installHermes() { ensureDir(SKILLS_DIR); - writeFileSync3(join8(SKILLS_DIR, "SKILL.md"), SKILL_BODY); + writeFileSync4(join9(SKILLS_DIR, "SKILL.md"), SKILL_BODY); writeVersionStamp(SKILLS_DIR, getVersion()); log(` Hermes skill installed -> ${SKILLS_DIR}`); - const srcBundle = join8(pkgRoot(), "hermes", "bundle"); - if (!existsSync7(srcBundle)) { + const srcBundle = join9(pkgRoot(), "hermes", "bundle"); + if (!existsSync8(srcBundle)) { throw new Error(`Hermes bundle missing at ${srcBundle}. Run 'npm run build' first.`); } ensureDir(HIVEMIND_DIR2); @@ -3382,15 +3490,15 @@ function installHermes() { log(` Hermes config updated -> ${CONFIG_PATH} (mcp_servers + hooks + hooks_auto_accept)`); } function uninstallHermes() { - if (existsSync7(SKILLS_DIR)) { + if (existsSync8(SKILLS_DIR)) { rmSync2(SKILLS_DIR, { recursive: true, force: true }); log(` Hermes removed ${SKILLS_DIR}`); } - if (existsSync7(HIVEMIND_DIR2)) { + if (existsSync8(HIVEMIND_DIR2)) { rmSync2(HIVEMIND_DIR2, { recursive: true, force: true }); log(` Hermes removed ${HIVEMIND_DIR2}`); } - if (existsSync7(CONFIG_PATH)) { + if (existsSync8(CONFIG_PATH)) { const cfg = readConfig(); let touched = false; if (cfg.mcp_servers && typeof cfg.mcp_servers === "object" && SERVER_KEY in cfg.mcp_servers) { @@ -3423,18 +3531,18 @@ function uninstallHermes() { } // dist/src/cli/install-pi.js -import { existsSync as existsSync8, writeFileSync as writeFileSync4, rmSync as rmSync3, readFileSync as readFileSync6, copyFileSync as copyFileSync2 } from "node:fs"; -import { join as join9 } from "node:path"; -var PI_AGENT_DIR = join9(HOME, ".pi", "agent"); -var AGENTS_MD = join9(PI_AGENT_DIR, "AGENTS.md"); -var LEGACY_SKILL_DIR = join9(PI_AGENT_DIR, "skills", "hivemind-memory"); -var EXTENSIONS_DIR = join9(PI_AGENT_DIR, "extensions"); -var EXTENSION_PATH = join9(EXTENSIONS_DIR, "hivemind.ts"); -var VERSION_DIR = join9(PI_AGENT_DIR, ".hivemind"); -var WIKI_WORKER_DIR = join9(PI_AGENT_DIR, "hivemind"); -var WIKI_WORKER_PATH = join9(WIKI_WORKER_DIR, "wiki-worker.js"); -var SKILLIFY_WORKER_PATH = join9(WIKI_WORKER_DIR, "skillify-worker.js"); -var AUTOPULL_WORKER_PATH = join9(WIKI_WORKER_DIR, "autopull-worker.js"); +import { existsSync as existsSync9, writeFileSync as writeFileSync5, rmSync as rmSync3, readFileSync as readFileSync7, copyFileSync as copyFileSync2 } from "node:fs"; +import { join as join10 } from "node:path"; +var PI_AGENT_DIR = join10(HOME, ".pi", "agent"); +var AGENTS_MD = join10(PI_AGENT_DIR, "AGENTS.md"); +var LEGACY_SKILL_DIR = join10(PI_AGENT_DIR, "skills", "hivemind-memory"); +var EXTENSIONS_DIR = join10(PI_AGENT_DIR, "extensions"); +var EXTENSION_PATH = join10(EXTENSIONS_DIR, "hivemind.ts"); +var VERSION_DIR = join10(PI_AGENT_DIR, ".hivemind"); +var WIKI_WORKER_DIR = join10(PI_AGENT_DIR, "hivemind"); +var WIKI_WORKER_PATH = join10(WIKI_WORKER_DIR, "wiki-worker.js"); +var SKILLIFY_WORKER_PATH = join10(WIKI_WORKER_DIR, "skillify-worker.js"); +var AUTOPULL_WORKER_PATH = join10(WIKI_WORKER_DIR, "autopull-worker.js"); var HIVEMIND_BLOCK_START = ""; var HIVEMIND_BLOCK_END = ""; var HIVEMIND_BLOCK_BODY = `${HIVEMIND_BLOCK_START} @@ -3502,30 +3610,30 @@ ${after}`; } function installPi() { ensureDir(PI_AGENT_DIR); - if (existsSync8(LEGACY_SKILL_DIR)) { + if (existsSync9(LEGACY_SKILL_DIR)) { rmSync3(LEGACY_SKILL_DIR, { recursive: true, force: true }); } - const prior = existsSync8(AGENTS_MD) ? readFileSync6(AGENTS_MD, "utf-8") : null; + const prior = existsSync9(AGENTS_MD) ? readFileSync7(AGENTS_MD, "utf-8") : null; const next = upsertHivemindBlock(prior); - writeFileSync4(AGENTS_MD, next); - const srcExtension = join9(pkgRoot(), "pi", "extension-source", "hivemind.ts"); - if (!existsSync8(srcExtension)) { + writeFileSync5(AGENTS_MD, next); + const srcExtension = join10(pkgRoot(), "pi", "extension-source", "hivemind.ts"); + if (!existsSync9(srcExtension)) { throw new Error(`pi extension source missing at ${srcExtension}. Reinstall the @deeplake/hivemind package.`); } ensureDir(EXTENSIONS_DIR); copyFileSync2(srcExtension, EXTENSION_PATH); - const srcWorker = join9(pkgRoot(), "pi", "bundle", "wiki-worker.js"); - if (existsSync8(srcWorker)) { + const srcWorker = join10(pkgRoot(), "pi", "bundle", "wiki-worker.js"); + if (existsSync9(srcWorker)) { ensureDir(WIKI_WORKER_DIR); copyFileSync2(srcWorker, WIKI_WORKER_PATH); } - const srcSkillifyWorker = join9(pkgRoot(), "pi", "bundle", "skillify-worker.js"); - if (existsSync8(srcSkillifyWorker)) { + const srcSkillifyWorker = join10(pkgRoot(), "pi", "bundle", "skillify-worker.js"); + if (existsSync9(srcSkillifyWorker)) { ensureDir(WIKI_WORKER_DIR); copyFileSync2(srcSkillifyWorker, SKILLIFY_WORKER_PATH); } - const srcAutopullWorker = join9(pkgRoot(), "pi", "bundle", "autopull-worker.js"); - if (existsSync8(srcAutopullWorker)) { + const srcAutopullWorker = join10(pkgRoot(), "pi", "bundle", "autopull-worker.js"); + if (existsSync9(srcAutopullWorker)) { ensureDir(WIKI_WORKER_DIR); copyFileSync2(srcAutopullWorker, AUTOPULL_WORKER_PATH); } @@ -3533,82 +3641,82 @@ function installPi() { writeVersionStamp(VERSION_DIR, getVersion()); log(` pi AGENTS.md updated -> ${AGENTS_MD}`); log(` pi extension installed -> ${EXTENSION_PATH}`); - if (existsSync8(WIKI_WORKER_PATH)) { + if (existsSync9(WIKI_WORKER_PATH)) { log(` pi wiki-worker installed -> ${WIKI_WORKER_PATH}`); } - if (existsSync8(SKILLIFY_WORKER_PATH)) { + if (existsSync9(SKILLIFY_WORKER_PATH)) { log(` pi skillify-worker installed -> ${SKILLIFY_WORKER_PATH}`); } - if (existsSync8(AUTOPULL_WORKER_PATH)) { + if (existsSync9(AUTOPULL_WORKER_PATH)) { log(` pi autopull-worker installed -> ${AUTOPULL_WORKER_PATH}`); } } function uninstallPi() { - if (existsSync8(LEGACY_SKILL_DIR)) { + if (existsSync9(LEGACY_SKILL_DIR)) { rmSync3(LEGACY_SKILL_DIR, { recursive: true, force: true }); log(` pi removed ${LEGACY_SKILL_DIR}`); } - if (existsSync8(EXTENSION_PATH)) { + if (existsSync9(EXTENSION_PATH)) { rmSync3(EXTENSION_PATH, { force: true }); log(` pi removed extension ${EXTENSION_PATH}`); } - if (existsSync8(WIKI_WORKER_DIR)) { + if (existsSync9(WIKI_WORKER_DIR)) { rmSync3(WIKI_WORKER_DIR, { recursive: true, force: true }); log(` pi removed wiki-worker dir ${WIKI_WORKER_DIR}`); } - if (existsSync8(AGENTS_MD)) { - const prior = readFileSync6(AGENTS_MD, "utf-8"); + if (existsSync9(AGENTS_MD)) { + const prior = readFileSync7(AGENTS_MD, "utf-8"); const stripped = stripHivemindBlock(prior); if (stripped.trim().length === 0) { rmSync3(AGENTS_MD, { force: true }); log(` pi removed empty ${AGENTS_MD}`); } else { - writeFileSync4(AGENTS_MD, stripped); + writeFileSync5(AGENTS_MD, stripped); log(` pi stripped hivemind block from ${AGENTS_MD}`); } } - if (existsSync8(VERSION_DIR)) { + if (existsSync9(VERSION_DIR)) { rmSync3(VERSION_DIR, { recursive: true, force: true }); } } // dist/src/cli/embeddings.js -import { copyFileSync as copyFileSync3, chmodSync, existsSync as existsSync9, lstatSync as lstatSync2, readdirSync, readlinkSync, rmSync as rmSync4, statSync, unlinkSync as unlinkSync5 } from "node:fs"; +import { copyFileSync as copyFileSync3, chmodSync, existsSync as existsSync10, lstatSync as lstatSync2, readdirSync, readlinkSync, rmSync as rmSync4, statSync, unlinkSync as unlinkSync5 } from "node:fs"; import { execFileSync as execFileSync3 } from "node:child_process"; -import { join as join10 } from "node:path"; -var SHARED_DIR = join10(HOME, ".hivemind", "embed-deps"); -var SHARED_NODE_MODULES = join10(SHARED_DIR, "node_modules"); -var SHARED_DAEMON_PATH = join10(SHARED_DIR, "embed-daemon.js"); +import { join as join11 } from "node:path"; +var SHARED_DIR = join11(HOME, ".hivemind", "embed-deps"); +var SHARED_NODE_MODULES = join11(SHARED_DIR, "node_modules"); +var SHARED_DAEMON_PATH = join11(SHARED_DIR, "embed-daemon.js"); var TRANSFORMERS_PKG = "@huggingface/transformers"; var TRANSFORMERS_RANGE = "^3.0.0"; function findHivemindInstalls(home = HOME) { const out = []; const fixed = [ - { id: "codex", pluginDir: join10(home, ".codex", "hivemind") }, - { id: "cursor", pluginDir: join10(home, ".cursor", "hivemind") }, - { id: "hermes", pluginDir: join10(home, ".hermes", "hivemind") } + { id: "codex", pluginDir: join11(home, ".codex", "hivemind") }, + { id: "cursor", pluginDir: join11(home, ".cursor", "hivemind") }, + { id: "hermes", pluginDir: join11(home, ".hermes", "hivemind") } ]; for (const inst of fixed) { - if (existsSync9(join10(inst.pluginDir, "bundle"))) + if (existsSync10(join11(inst.pluginDir, "bundle"))) out.push(inst); } - const ccCache = join10(home, ".claude", "plugins", "cache", "hivemind", "hivemind"); - if (existsSync9(ccCache)) { + const ccCache = join11(home, ".claude", "plugins", "cache", "hivemind", "hivemind"); + if (existsSync10(ccCache)) { let entries = []; try { entries = readdirSync(ccCache); } catch { } for (const ver of entries) { - const dir = join10(ccCache, ver); + const dir = join11(ccCache, ver); try { if (!statSync(dir).isDirectory()) continue; } catch { continue; } - const candidates = [join10(dir, "bundle"), join10(dir, "claude-code", "bundle")]; - if (candidates.some((p) => existsSync9(p))) { + const candidates = [join11(dir, "bundle"), join11(dir, "claude-code", "bundle")]; + if (candidates.some((p) => existsSync10(p))) { out.push({ id: `claude (${ver})`, pluginDir: dir }); } } @@ -3616,10 +3724,10 @@ function findHivemindInstalls(home = HOME) { return out; } function isSharedDepsInstalled(sharedNodeModules = SHARED_NODE_MODULES) { - return existsSync9(join10(sharedNodeModules, TRANSFORMERS_PKG)); + return existsSync10(join11(sharedNodeModules, TRANSFORMERS_PKG)); } function isSymlinkToSharedDeps(linkPath, sharedNodeModules) { - if (!existsSync9(linkPath)) + if (!existsSync10(linkPath)) return false; try { if (!lstatSync2(linkPath).isSymbolicLink()) @@ -3630,8 +3738,8 @@ function isSymlinkToSharedDeps(linkPath, sharedNodeModules) { } } function linkStateFor(install, sharedNodeModules = SHARED_NODE_MODULES) { - const link = join10(install.pluginDir, "node_modules"); - if (!existsSync9(link) && !isSymbolicLink(link)) + const link = join11(install.pluginDir, "node_modules"); + if (!existsSync10(link) && !isSymbolicLink(link)) return { kind: "no-node-modules" }; try { if (lstatSync2(link).isSymbolicLink()) { @@ -3655,7 +3763,7 @@ function ensureSharedDeps() { log(` Embeddings installing ${TRANSFORMERS_PKG}@${TRANSFORMERS_RANGE} into ${SHARED_DIR}`); log(` (~600 MB; first install only \u2014 every agent will share this)`); ensureDir(SHARED_DIR); - writeJson(join10(SHARED_DIR, "package.json"), { + writeJson(join11(SHARED_DIR, "package.json"), { name: "hivemind-embed-deps", version: "1.0.0", private: true, @@ -3669,8 +3777,8 @@ function ensureSharedDeps() { log(` Embeddings shared deps already present at ${SHARED_DIR}`); } ensureDir(SHARED_DIR); - const src = join10(pkgRoot(), "embeddings", "embed-daemon.js"); - if (existsSync9(src)) { + const src = join11(pkgRoot(), "embeddings", "embed-daemon.js"); + if (existsSync10(src)) { copyFileSync3(src, SHARED_DAEMON_PATH); chmodSync(SHARED_DAEMON_PATH, 493); } else { @@ -3678,7 +3786,7 @@ function ensureSharedDeps() { } } function linkAgent(install) { - const link = join10(install.pluginDir, "node_modules"); + const link = join11(install.pluginDir, "node_modules"); symlinkForce(SHARED_NODE_MODULES, link); log(` Embeddings linked ${install.id.padEnd(20)} -> shared deps`); } @@ -3697,13 +3805,13 @@ function enableEmbeddings() { function disableEmbeddings(opts) { const installs = findHivemindInstalls(); for (const inst of installs) { - const link = join10(inst.pluginDir, "node_modules"); + const link = join11(inst.pluginDir, "node_modules"); if (isSymlinkToSharedDeps(link, SHARED_NODE_MODULES)) { unlinkSync5(link); log(` Embeddings unlinked ${inst.id}`); } } - if (opts?.prune && existsSync9(SHARED_DIR)) { + if (opts?.prune && existsSync10(SHARED_DIR)) { rmSync4(SHARED_DIR, { recursive: true, force: true }); log(` Embeddings pruned ${SHARED_DIR}`); } @@ -3711,7 +3819,7 @@ function disableEmbeddings(opts) { function statusEmbeddings() { log(`Shared deps: ${SHARED_DIR}`); log(`Installed: ${isSharedDepsInstalled() ? "yes" : "no"}`); - log(`Daemon: ${existsSync9(SHARED_DAEMON_PATH) ? SHARED_DAEMON_PATH : "(not present)"}`); + log(`Daemon: ${existsSync10(SHARED_DAEMON_PATH) ? SHARED_DAEMON_PATH : "(not present)"}`); log(""); log(`Agent installs:`); const installs = findHivemindInstalls(); @@ -3742,8 +3850,8 @@ function statusEmbeddings() { } // dist/src/cli/auth.js -import { existsSync as existsSync10 } from "node:fs"; -import { join as join12 } from "node:path"; +import { existsSync as existsSync11 } from "node:fs"; +import { join as join13 } from "node:path"; // dist/src/commands/auth.js import { execSync } from "node:child_process"; @@ -3758,25 +3866,25 @@ function deeplakeClientHeader() { } // dist/src/commands/auth-creds.js -import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync2, unlinkSync as unlinkSync6 } from "node:fs"; -import { join as join11 } from "node:path"; -import { homedir as homedir3 } from "node:os"; +import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, mkdirSync as mkdirSync2, unlinkSync as unlinkSync6 } from "node:fs"; +import { join as join12 } from "node:path"; +import { homedir as homedir4 } from "node:os"; function configDir() { - return join11(homedir3(), ".deeplake"); + return join12(homedir4(), ".deeplake"); } function credsPath() { - return join11(configDir(), "credentials.json"); + return join12(configDir(), "credentials.json"); } function loadCredentials() { try { - return JSON.parse(readFileSync7(credsPath(), "utf-8")); + return JSON.parse(readFileSync8(credsPath(), "utf-8")); } catch { return null; } } function saveCredentials(creds) { mkdirSync2(configDir(), { recursive: true, mode: 448 }); - writeFileSync5(credsPath(), JSON.stringify({ ...creds, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2), { mode: 384 }); + writeFileSync6(credsPath(), JSON.stringify({ ...creds, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2), { mode: 384 }); } function deleteCredentials() { try { @@ -3965,9 +4073,9 @@ Using: ${orgName} } // dist/src/cli/auth.js -var CREDS_PATH = join12(HOME, ".deeplake", "credentials.json"); +var CREDS_PATH = join13(HOME, ".deeplake", "credentials.json"); function isLoggedIn() { - return existsSync10(CREDS_PATH) && loadCredentials() !== null; + return existsSync11(CREDS_PATH) && loadCredentials() !== null; } async function ensureLoggedIn() { if (isLoggedIn()) @@ -4000,16 +4108,16 @@ async function maybeShowOrgChoice() { } // dist/src/config.js -import { readFileSync as readFileSync8, existsSync as existsSync11 } from "node:fs"; -import { join as join13 } from "node:path"; -import { homedir as homedir4, userInfo } from "node:os"; +import { readFileSync as readFileSync9, existsSync as existsSync12 } from "node:fs"; +import { join as join14 } from "node:path"; +import { homedir as homedir5, userInfo } from "node:os"; function loadConfig() { - const home = homedir4(); - const credPath = join13(home, ".deeplake", "credentials.json"); + const home = homedir5(); + const credPath = join14(home, ".deeplake", "credentials.json"); let creds = null; - if (existsSync11(credPath)) { + if (existsSync12(credPath)) { try { - creds = JSON.parse(readFileSync8(credPath, "utf-8")); + creds = JSON.parse(readFileSync9(credPath, "utf-8")); } catch { return null; } @@ -4028,7 +4136,7 @@ function loadConfig() { tableName: process.env.HIVEMIND_TABLE ?? "memory", sessionsTableName: process.env.HIVEMIND_SESSIONS_TABLE ?? "sessions", skillsTableName: process.env.HIVEMIND_SKILLS_TABLE ?? "skills", - memoryPath: process.env.HIVEMIND_MEMORY_PATH ?? join13(home, ".deeplake", "memory") + memoryPath: process.env.HIVEMIND_MEMORY_PATH ?? join14(home, ".deeplake", "memory") }; } @@ -4037,9 +4145,9 @@ import { randomUUID } from "node:crypto"; // dist/src/utils/debug.js import { appendFileSync } from "node:fs"; -import { join as join14 } from "node:path"; -import { homedir as homedir5 } from "node:os"; -var LOG = join14(homedir5(), ".deeplake", "hook-debug.log"); +import { join as join15 } from "node:path"; +import { homedir as homedir6 } from "node:os"; +var LOG = join15(homedir6(), ".deeplake", "hook-debug.log"); function isDebug() { return process.env.HIVEMIND_DEBUG === "1"; } @@ -4807,34 +4915,34 @@ if (process.argv[1] && process.argv[1].endsWith("auth-login.js")) { } // dist/src/commands/skillify.js -import { readdirSync as readdirSync5, existsSync as existsSync24, readFileSync as readFileSync17, mkdirSync as mkdirSync10, renameSync as renameSync4 } from "node:fs"; -import { homedir as homedir17 } from "node:os"; -import { dirname as dirname6, join as join27 } from "node:path"; +import { readdirSync as readdirSync5, existsSync as existsSync25, readFileSync as readFileSync18, mkdirSync as mkdirSync10, renameSync as renameSync5 } from "node:fs"; +import { homedir as homedir18 } from "node:os"; +import { dirname as dirname6, join as join28 } from "node:path"; // dist/src/skillify/scope-config.js -import { existsSync as existsSync14, mkdirSync as mkdirSync4, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "node:fs"; -import { homedir as homedir7 } from "node:os"; -import { join as join17 } from "node:path"; +import { existsSync as existsSync15, mkdirSync as mkdirSync4, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "node:fs"; +import { homedir as homedir8 } from "node:os"; +import { join as join18 } from "node:path"; // dist/src/skillify/legacy-migration.js -import { existsSync as existsSync13, renameSync } from "node:fs"; -import { homedir as homedir6 } from "node:os"; -import { join as join16 } from "node:path"; +import { existsSync as existsSync14, renameSync as renameSync2 } from "node:fs"; +import { homedir as homedir7 } from "node:os"; +import { join as join17 } from "node:path"; var dlog = (msg) => log2("skillify-migrate", msg); var attempted = false; function migrateLegacyStateDir() { if (attempted) return; attempted = true; - const root = join16(homedir6(), ".deeplake", "state"); - const legacy = join16(root, "skilify"); - const current = join16(root, "skillify"); - if (!existsSync13(legacy)) + const root = join17(homedir7(), ".deeplake", "state"); + const legacy = join17(root, "skilify"); + const current = join17(root, "skillify"); + if (!existsSync14(legacy)) return; - if (existsSync13(current)) + if (existsSync14(current)) return; try { - renameSync(legacy, current); + renameSync2(legacy, current); dlog(`migrated ${legacy} -> ${current}`); } catch (err) { const code = err.code; @@ -4847,15 +4955,15 @@ function migrateLegacyStateDir() { } // dist/src/skillify/scope-config.js -var STATE_DIR = join17(homedir7(), ".deeplake", "state", "skillify"); -var CONFIG_PATH2 = join17(STATE_DIR, "config.json"); +var STATE_DIR = join18(homedir8(), ".deeplake", "state", "skillify"); +var CONFIG_PATH2 = join18(STATE_DIR, "config.json"); var DEFAULT = { scope: "me", team: [], install: "project" }; function loadScopeConfig() { migrateLegacyStateDir(); - if (!existsSync14(CONFIG_PATH2)) + if (!existsSync15(CONFIG_PATH2)) return DEFAULT; try { - const raw = JSON.parse(readFileSync10(CONFIG_PATH2, "utf-8")); + const raw = JSON.parse(readFileSync11(CONFIG_PATH2, "utf-8")); const scope = raw.scope === "team" ? "team" : raw.scope === "org" ? "team" : "me"; const team = Array.isArray(raw.team) ? raw.team.filter((s) => typeof s === "string") : []; const install = raw.install === "global" ? "global" : "project"; @@ -4867,18 +4975,18 @@ function loadScopeConfig() { function saveScopeConfig(cfg) { migrateLegacyStateDir(); mkdirSync4(STATE_DIR, { recursive: true }); - writeFileSync7(CONFIG_PATH2, JSON.stringify(cfg, null, 2)); + writeFileSync8(CONFIG_PATH2, JSON.stringify(cfg, null, 2)); } // dist/src/skillify/pull.js -import { existsSync as existsSync18, readFileSync as readFileSync13, writeFileSync as writeFileSync10, mkdirSync as mkdirSync7, renameSync as renameSync3, lstatSync as lstatSync4, readlinkSync as readlinkSync2, symlinkSync as symlinkSync2, unlinkSync as unlinkSync8 } from "node:fs"; -import { homedir as homedir11 } from "node:os"; -import { dirname as dirname3, join as join21 } from "node:path"; +import { existsSync as existsSync19, readFileSync as readFileSync14, writeFileSync as writeFileSync11, mkdirSync as mkdirSync7, renameSync as renameSync4, lstatSync as lstatSync4, readlinkSync as readlinkSync2, symlinkSync as symlinkSync2, unlinkSync as unlinkSync8 } from "node:fs"; +import { homedir as homedir12 } from "node:os"; +import { dirname as dirname3, join as join22 } from "node:path"; // dist/src/skillify/skill-writer.js -import { existsSync as existsSync15, mkdirSync as mkdirSync5, readFileSync as readFileSync11, readdirSync as readdirSync2, statSync as statSync2, writeFileSync as writeFileSync8 } from "node:fs"; -import { homedir as homedir8 } from "node:os"; -import { join as join18 } from "node:path"; +import { existsSync as existsSync16, mkdirSync as mkdirSync5, readFileSync as readFileSync12, readdirSync as readdirSync2, statSync as statSync2, writeFileSync as writeFileSync9 } from "node:fs"; +import { homedir as homedir9 } from "node:os"; +import { join as join19 } from "node:path"; function assertValidSkillName(name) { if (typeof name !== "string" || name.length === 0) { throw new Error(`invalid skill name: empty or non-string`); @@ -4894,10 +5002,10 @@ function assertValidSkillName(name) { } } function skillDir(skillsRoot, name) { - return join18(skillsRoot, name); + return join19(skillsRoot, name); } function skillPath(skillsRoot, name) { - return join18(skillDir(skillsRoot, name), "SKILL.md"); + return join19(skillDir(skillsRoot, name), "SKILL.md"); } function renderFrontmatter(fm) { const lines = ["---"]; @@ -4975,7 +5083,7 @@ function writeNewSkill(args) { assertValidSkillName(args.name); const dir = skillDir(args.skillsRoot, args.name); const path = skillPath(args.skillsRoot, args.name); - if (existsSync15(path)) { + if (existsSync16(path)) { throw new Error(`skill already exists at ${path}; use mergeSkill`); } mkdirSync5(dir, { recursive: true }); @@ -4998,7 +5106,7 @@ function writeNewSkill(args) { ${args.body.trim()} `; - writeFileSync8(path, text); + writeFileSync9(path, text); return { path, action: "created", @@ -5010,41 +5118,41 @@ ${args.body.trim()} }; } function listSkills(skillsRoot) { - if (!existsSync15(skillsRoot)) + if (!existsSync16(skillsRoot)) return []; const out = []; for (const name of readdirSync2(skillsRoot)) { - const skillFile = join18(skillsRoot, name, "SKILL.md"); - if (existsSync15(skillFile) && statSync2(skillFile).isFile()) { - out.push({ name, body: readFileSync11(skillFile, "utf-8") }); + const skillFile = join19(skillsRoot, name, "SKILL.md"); + if (existsSync16(skillFile) && statSync2(skillFile).isFile()) { + out.push({ name, body: readFileSync12(skillFile, "utf-8") }); } } return out; } function resolveSkillsRoot(install, cwd) { if (install === "global") { - return join18(homedir8(), ".claude", "skills"); + return join19(homedir9(), ".claude", "skills"); } - return join18(cwd, ".claude", "skills"); + return join19(cwd, ".claude", "skills"); } // dist/src/skillify/manifest.js -import { existsSync as existsSync16, lstatSync as lstatSync3, mkdirSync as mkdirSync6, readFileSync as readFileSync12, renameSync as renameSync2, unlinkSync as unlinkSync7, writeFileSync as writeFileSync9 } from "node:fs"; -import { homedir as homedir9 } from "node:os"; -import { dirname as dirname2, join as join19 } from "node:path"; +import { existsSync as existsSync17, lstatSync as lstatSync3, mkdirSync as mkdirSync6, readFileSync as readFileSync13, renameSync as renameSync3, unlinkSync as unlinkSync7, writeFileSync as writeFileSync10 } from "node:fs"; +import { homedir as homedir10 } from "node:os"; +import { dirname as dirname2, join as join20 } from "node:path"; function emptyManifest() { return { version: 1, entries: [] }; } function manifestPath() { - return join19(homedir9(), ".deeplake", "state", "skillify", "pulled.json"); + return join20(homedir10(), ".deeplake", "state", "skillify", "pulled.json"); } function loadManifest(path = manifestPath()) { migrateLegacyStateDir(); - if (!existsSync16(path)) + if (!existsSync17(path)) return emptyManifest(); let raw; try { - raw = readFileSync12(path, "utf-8"); + raw = readFileSync13(path, "utf-8"); } catch { return emptyManifest(); } @@ -5093,8 +5201,8 @@ function saveManifest(m, path = manifestPath()) { migrateLegacyStateDir(); mkdirSync6(dirname2(path), { recursive: true }); const tmp = `${path}.tmp`; - writeFileSync9(tmp, JSON.stringify(m, null, 2) + "\n", { mode: 384 }); - renameSync2(tmp, path); + writeFileSync10(tmp, JSON.stringify(m, null, 2) + "\n", { mode: 384 }); + renameSync3(tmp, path); } function recordPull(entry, path = manifestPath()) { const m = loadManifest(path); @@ -5136,7 +5244,7 @@ function pruneOrphanedEntries(path = manifestPath()) { const live = []; let pruned = 0; for (const e of m.entries) { - if (existsSync16(join19(e.installRoot, e.dirName))) { + if (existsSync17(join20(e.installRoot, e.dirName))) { live.push(e); continue; } @@ -5149,26 +5257,26 @@ function pruneOrphanedEntries(path = manifestPath()) { } // dist/src/skillify/agent-roots.js -import { existsSync as existsSync17 } from "node:fs"; -import { homedir as homedir10 } from "node:os"; -import { join as join20 } from "node:path"; +import { existsSync as existsSync18 } from "node:fs"; +import { homedir as homedir11 } from "node:os"; +import { join as join21 } from "node:path"; function resolveDetected(home) { const out = []; - const codexInstalled = existsSync17(join20(home, ".codex")); - const piInstalled = existsSync17(join20(home, ".pi", "agent")); - const hermesInstalled = existsSync17(join20(home, ".hermes")); + const codexInstalled = existsSync18(join21(home, ".codex")); + const piInstalled = existsSync18(join21(home, ".pi", "agent")); + const hermesInstalled = existsSync18(join21(home, ".hermes")); if (codexInstalled || piInstalled) { - out.push(join20(home, ".agents", "skills")); + out.push(join21(home, ".agents", "skills")); } if (hermesInstalled) { - out.push(join20(home, ".hermes", "skills")); + out.push(join21(home, ".hermes", "skills")); } if (piInstalled) { - out.push(join20(home, ".pi", "agent", "skills")); + out.push(join21(home, ".pi", "agent", "skills")); } return out; } -function detectAgentSkillsRoots(canonicalRoot, home = homedir10()) { +function detectAgentSkillsRoots(canonicalRoot, home = homedir11()) { return resolveDetected(home).filter((p) => p !== canonicalRoot); } @@ -5212,15 +5320,15 @@ function isMissingTableError(message) { } function resolvePullDestination(install, cwd) { if (install === "global") - return join21(homedir11(), ".claude", "skills"); + return join22(homedir12(), ".claude", "skills"); if (!cwd) throw new Error("install=project requires a cwd"); - return join21(cwd, ".claude", "skills"); + return join22(cwd, ".claude", "skills"); } function fanOutSymlinks(canonicalDir, dirName, agentRoots) { const out = []; for (const root of agentRoots) { - const link = join21(root, dirName); + const link = join22(root, dirName); let existing; try { existing = lstatSync4(link); @@ -5263,8 +5371,8 @@ function backfillSymlinks(installRoot) { return; const detected = detectAgentSkillsRoots(installRoot); for (const entry of entries) { - const canonical = join21(entry.installRoot, entry.dirName); - if (!existsSync18(canonical)) + const canonical = join22(entry.installRoot, entry.dirName); + if (!existsSync19(canonical)) continue; const fresh = fanOutSymlinks(canonical, entry.dirName, detected); if (sameSorted(fresh, entry.symlinks)) @@ -5374,10 +5482,10 @@ function renderFrontmatter2(fm) { return lines.join("\n"); } function readLocalVersion(path) { - if (!existsSync18(path)) + if (!existsSync19(path)) return null; try { - const text = readFileSync13(path, "utf-8"); + const text = readFileSync14(path, "utf-8"); const parsed = parseFrontmatter(text); if (!parsed) return null; @@ -5472,8 +5580,8 @@ async function runPull(opts) { summary.skipped++; continue; } - const skillDir2 = join21(root, dirName); - const skillFile = join21(skillDir2, "SKILL.md"); + const skillDir2 = join22(root, dirName); + const skillFile = join22(skillDir2, "SKILL.md"); const remoteVersion = Number(row.version ?? 1); const localVersion = readLocalVersion(skillFile); const action = decideAction({ @@ -5485,13 +5593,13 @@ async function runPull(opts) { let manifestError; if (action === "wrote") { mkdirSync7(skillDir2, { recursive: true }); - if (existsSync18(skillFile)) { + if (existsSync19(skillFile)) { try { - renameSync3(skillFile, `${skillFile}.bak`); + renameSync4(skillFile, `${skillFile}.bak`); } catch { } } - writeFileSync10(skillFile, renderSkillFile(row)); + writeFileSync11(skillFile, renderSkillFile(row)); const symlinks = opts.install === "global" ? fanOutSymlinks(skillDir2, dirName, detectAgentSkillsRoots(root)) : []; try { recordPull({ @@ -5533,15 +5641,15 @@ async function runPull(opts) { } // dist/src/skillify/unpull.js -import { existsSync as existsSync19, readdirSync as readdirSync3, rmSync as rmSync5, statSync as statSync3 } from "node:fs"; -import { homedir as homedir12 } from "node:os"; -import { join as join22 } from "node:path"; +import { existsSync as existsSync20, readdirSync as readdirSync3, rmSync as rmSync5, statSync as statSync3 } from "node:fs"; +import { homedir as homedir13 } from "node:os"; +import { join as join23 } from "node:path"; function resolveUnpullRoot(install, cwd) { if (install === "global") - return join22(homedir12(), ".claude", "skills"); + return join23(homedir13(), ".claude", "skills"); if (!cwd) throw new Error("cwd required when install === 'project'"); - return join22(cwd, ".claude", "skills"); + return join23(cwd, ".claude", "skills"); } function runUnpull(opts) { const root = resolveUnpullRoot(opts.install, opts.cwd); @@ -5564,8 +5672,8 @@ function runUnpull(opts) { const entries = entriesForRoot(manifest, opts.install, root); for (const entry of entries) { summary.scanned++; - const path = join22(root, entry.dirName); - if (!existsSync19(path)) { + const path = join23(root, entry.dirName); + if (!existsSync20(path)) { if (!opts.dryRun) { unlinkSymlinks(entry.symlinks); removePullEntry(opts.install, entry.installRoot, entry.dirName); @@ -5618,12 +5726,12 @@ function runUnpull(opts) { } summary.entries.push(result); } - if (existsSync19(root) && (opts.all || opts.legacyCleanup)) { + if (existsSync20(root) && (opts.all || opts.legacyCleanup)) { const manifestDirNames = new Set(entries.map((e) => e.dirName)); for (const dirName of readdirSync3(root)) { if (manifestDirNames.has(dirName)) continue; - const path = join22(root, dirName); + const path = join23(root, dirName); let st; try { st = statSync3(path); @@ -5702,30 +5810,30 @@ function decideTargetForManifestEntry(entry, opts, userFilter, haveUserFilter) { // dist/src/commands/mine-local.js import { spawn } from "node:child_process"; -import { existsSync as existsSync23, mkdirSync as mkdirSync9, readFileSync as readFileSync16, writeFileSync as writeFileSync12 } from "node:fs"; -import { homedir as homedir16 } from "node:os"; -import { basename, dirname as dirname5, join as join26 } from "node:path"; +import { existsSync as existsSync24, mkdirSync as mkdirSync9, readFileSync as readFileSync17, writeFileSync as writeFileSync13 } from "node:fs"; +import { homedir as homedir17 } from "node:os"; +import { basename, dirname as dirname5, join as join27 } from "node:path"; // dist/src/skillify/local-source.js -import { readdirSync as readdirSync4, readFileSync as readFileSync14, existsSync as existsSync20, statSync as statSync4 } from "node:fs"; -import { homedir as homedir13 } from "node:os"; -import { join as join23 } from "node:path"; -var HOME2 = homedir13(); +import { readdirSync as readdirSync4, readFileSync as readFileSync15, existsSync as existsSync21, statSync as statSync4 } from "node:fs"; +import { homedir as homedir14 } from "node:os"; +import { join as join24 } from "node:path"; +var HOME2 = homedir14(); function encodeCwdClaudeCode(cwd) { return cwd.replace(/[/_]/g, "-"); } function detectInstalledAgents() { const installs = []; - const claudeRoot = join23(HOME2, ".claude", "projects"); - if (existsSync20(claudeRoot)) { + const claudeRoot = join24(HOME2, ".claude", "projects"); + if (existsSync21(claudeRoot)) { installs.push({ agent: "claude_code", sessionRoot: claudeRoot, encodeCwd: encodeCwdClaudeCode }); } - const codexRoot = join23(HOME2, ".codex", "sessions"); - if (existsSync20(codexRoot)) { + const codexRoot = join24(HOME2, ".codex", "sessions"); + if (existsSync21(codexRoot)) { installs.push({ agent: "codex", sessionRoot: codexRoot, @@ -5752,7 +5860,7 @@ function listLocalSessions(installs, cwd) { continue; } for (const sub of subdirs) { - const subdirPath = join23(install.sessionRoot, sub); + const subdirPath = join24(install.sessionRoot, sub); try { if (!statSync4(subdirPath).isDirectory()) continue; @@ -5769,7 +5877,7 @@ function listLocalSessions(installs, cwd) { for (const f of files) { if (!f.endsWith(".jsonl")) continue; - const fullPath = join23(subdirPath, f); + const fullPath = join24(subdirPath, f); let stats; try { stats = statSync4(fullPath); @@ -5830,7 +5938,7 @@ function pickSessions(candidates, opts) { function nativeJsonlToRows(filePath, sessionId, agent) { let raw; try { - raw = readFileSync14(filePath, "utf-8"); + raw = readFileSync15(filePath, "utf-8"); } catch { return []; } @@ -5920,22 +6028,22 @@ function extractPairs(rows) { } // dist/src/skillify/gate-runner.js -import { existsSync as existsSync21 } from "node:fs"; +import { existsSync as existsSync22 } from "node:fs"; import { createRequire } from "node:module"; -import { homedir as homedir14 } from "node:os"; -import { join as join24 } from "node:path"; +import { homedir as homedir15 } from "node:os"; +import { join as join25 } from "node:path"; var requireForCp = createRequire(import.meta.url); var { execFileSync: runChildProcess } = requireForCp("node:child_process"); var inheritedEnv = process; function firstExistingPath(candidates) { for (const c of candidates) { - if (existsSync21(c)) + if (existsSync22(c)) return c; } return null; } function findAgentBin(agent) { - const home = homedir14(); + const home = homedir15(); switch (agent) { // /usr/bin/ is included in every candidate list — that's the // common Linux package-manager install path (apt, dnf, pacman). Old @@ -5944,45 +6052,45 @@ function findAgentBin(agent) { // #170 caught the gap. case "claude_code": return firstExistingPath([ - join24(home, ".claude", "local", "claude"), + join25(home, ".claude", "local", "claude"), "/usr/local/bin/claude", "/usr/bin/claude", - join24(home, ".npm-global", "bin", "claude"), - join24(home, ".local", "bin", "claude"), + join25(home, ".npm-global", "bin", "claude"), + join25(home, ".local", "bin", "claude"), "/opt/homebrew/bin/claude" - ]) ?? join24(home, ".claude", "local", "claude"); + ]) ?? join25(home, ".claude", "local", "claude"); case "codex": return firstExistingPath([ "/usr/local/bin/codex", "/usr/bin/codex", - join24(home, ".npm-global", "bin", "codex"), - join24(home, ".local", "bin", "codex"), + join25(home, ".npm-global", "bin", "codex"), + join25(home, ".local", "bin", "codex"), "/opt/homebrew/bin/codex" ]) ?? "/usr/local/bin/codex"; case "cursor": return firstExistingPath([ "/usr/local/bin/cursor-agent", "/usr/bin/cursor-agent", - join24(home, ".npm-global", "bin", "cursor-agent"), - join24(home, ".local", "bin", "cursor-agent"), + join25(home, ".npm-global", "bin", "cursor-agent"), + join25(home, ".local", "bin", "cursor-agent"), "/opt/homebrew/bin/cursor-agent" ]) ?? "/usr/local/bin/cursor-agent"; case "hermes": return firstExistingPath([ - join24(home, ".local", "bin", "hermes"), + join25(home, ".local", "bin", "hermes"), "/usr/local/bin/hermes", "/usr/bin/hermes", - join24(home, ".npm-global", "bin", "hermes"), + join25(home, ".npm-global", "bin", "hermes"), "/opt/homebrew/bin/hermes" - ]) ?? join24(home, ".local", "bin", "hermes"); + ]) ?? join25(home, ".local", "bin", "hermes"); case "pi": return firstExistingPath([ - join24(home, ".local", "bin", "pi"), + join25(home, ".local", "bin", "pi"), "/usr/local/bin/pi", "/usr/bin/pi", - join24(home, ".npm-global", "bin", "pi"), + join25(home, ".npm-global", "bin", "pi"), "/opt/homebrew/bin/pi" - ]) ?? join24(home, ".local", "bin", "pi"); + ]) ?? join25(home, ".local", "bin", "pi"); } } @@ -6012,23 +6120,23 @@ function extractJsonBlock(s) { } // dist/src/skillify/local-manifest.js -import { existsSync as existsSync22, mkdirSync as mkdirSync8, readFileSync as readFileSync15, writeFileSync as writeFileSync11 } from "node:fs"; -import { homedir as homedir15 } from "node:os"; -import { dirname as dirname4, join as join25 } from "node:path"; -var LOCAL_MANIFEST_PATH = join25(homedir15(), ".claude", "hivemind", "local-mined.json"); -var LOCAL_MINE_LOCK_PATH = join25(homedir15(), ".claude", "hivemind", "local-mined.lock"); +import { existsSync as existsSync23, mkdirSync as mkdirSync8, readFileSync as readFileSync16, writeFileSync as writeFileSync12 } from "node:fs"; +import { homedir as homedir16 } from "node:os"; +import { dirname as dirname4, join as join26 } from "node:path"; +var LOCAL_MANIFEST_PATH = join26(homedir16(), ".claude", "hivemind", "local-mined.json"); +var LOCAL_MINE_LOCK_PATH = join26(homedir16(), ".claude", "hivemind", "local-mined.lock"); function readLocalManifest(path = LOCAL_MANIFEST_PATH) { - if (!existsSync22(path)) + if (!existsSync23(path)) return null; try { - return JSON.parse(readFileSync15(path, "utf-8")); + return JSON.parse(readFileSync16(path, "utf-8")); } catch { return null; } } function writeLocalManifest(m, path = LOCAL_MANIFEST_PATH) { mkdirSync8(dirname4(path), { recursive: true }); - writeFileSync11(path, JSON.stringify(m, null, 2)); + writeFileSync12(path, JSON.stringify(m, null, 2)); } // dist/src/commands/mine-local.js @@ -6053,7 +6161,7 @@ function runGateViaStdin(opts) { }); return; } - if (!existsSync23(opts.bin)) { + if (!existsSync24(opts.bin)) { resolve({ stdout: "", stderr: "", @@ -6398,7 +6506,7 @@ async function runMineLocalImpl(args) { console.log(`Dry-run: would invoke ${gateAgent} gate on ${picked.length} session(s) in parallel (concurrency=${GATE_CONCURRENCY}).`); return; } - const tmpDir = join26(homedir16(), ".claude", "hivemind", `mine-local-${Date.now()}`); + const tmpDir = join27(homedir17(), ".claude", "hivemind", `mine-local-${Date.now()}`); mkdirSync9(tmpDir, { recursive: true }); console.log(`Running ${picked.length} gate call(s) in parallel (concurrency=${GATE_CONCURRENCY}, timeout=${GATE_TIMEOUT_MS / 1e3}s each)...`); const results = await parallelMap(picked, GATE_CONCURRENCY, async (s) => { @@ -6410,23 +6518,23 @@ async function runMineLocalImpl(args) { return { session: s, skills: [], reason: "no pairs", error: null }; } const tail = pairs2.slice(-PER_SESSION_PAIR_CAP); - const sessionTmp = join26(tmpDir, `s-${shortId}`); + const sessionTmp = join27(tmpDir, `s-${shortId}`); mkdirSync9(sessionTmp, { recursive: true }); - const verdictPath = join26(sessionTmp, "verdict.json"); + const verdictPath = join27(sessionTmp, "verdict.json"); const prompt = buildSessionPrompt(tail, s, verdictPath); - writeFileSync12(join26(sessionTmp, "prompt.txt"), prompt); + writeFileSync13(join27(sessionTmp, "prompt.txt"), prompt); const gate = await runGateViaStdin({ agent: gateAgent, bin: gateBin, prompt, timeoutMs: GATE_TIMEOUT_MS }); try { - writeFileSync12(join26(sessionTmp, "gate-stdout.txt"), gate.stdout); + writeFileSync13(join27(sessionTmp, "gate-stdout.txt"), gate.stdout); if (gate.stderr) - writeFileSync12(join26(sessionTmp, "gate-stderr.txt"), gate.stderr); + writeFileSync13(join27(sessionTmp, "gate-stderr.txt"), gate.stderr); } catch { } if (gate.errored) { console.log(` [${shortId}] gate failed: ${gate.errorMessage}`); return { session: s, skills: [], reason: null, error: gate.errorMessage ?? "gate failed" }; } - const verdictText = existsSync23(verdictPath) ? readFileSync16(verdictPath, "utf-8") : gate.stdout; + const verdictText = existsSync24(verdictPath) ? readFileSync17(verdictPath, "utf-8") : gate.stdout; const mv = parseMultiVerdict(verdictText); if (!mv) { console.log(` [${shortId}] unparseable verdict (kept at ${sessionTmp})`); @@ -6642,7 +6750,7 @@ function wrapAt(s, max) { // dist/src/commands/skillify.js function stateDir() { - return join27(homedir17(), ".deeplake", "state", "skillify"); + return join28(homedir18(), ".deeplake", "state", "skillify"); } function showStatus() { const cfg = loadScopeConfig(); @@ -6650,7 +6758,7 @@ function showStatus() { console.log(`team: ${cfg.team.length === 0 ? "(empty)" : cfg.team.join(", ")}`); console.log(`install: ${cfg.install} (${cfg.install === "global" ? "~/.claude/skills/" : "/.claude/skills/"})`); const dir = stateDir(); - if (!existsSync24(dir)) { + if (!existsSync25(dir)) { console.log(`state: (no projects tracked yet)`); return; } @@ -6662,7 +6770,7 @@ function showStatus() { console.log(`state: ${files.length} project(s) tracked`); for (const f of files) { try { - const s = JSON.parse(readFileSync17(join27(dir, f), "utf-8")); + const s = JSON.parse(readFileSync18(join28(dir, f), "utf-8")); const last = typeof s.updatedAt === "number" ? new Date(s.updatedAt).toISOString() : s.lastDate ?? "never"; const skills = Array.isArray(s.skillsGenerated) && s.skillsGenerated.length > 0 ? s.skillsGenerated.join(", ") : "none"; console.log(` - ${s.project} (counter=${s.counter}, last=${last}, skills=${skills})`); @@ -6689,7 +6797,7 @@ function setInstall(loc) { } const cfg = loadScopeConfig(); saveScopeConfig({ ...cfg, install: loc }); - const path = loc === "global" ? join27(homedir17(), ".claude", "skills") : "/.claude/skills"; + const path = loc === "global" ? join28(homedir18(), ".claude", "skills") : "/.claude/skills"; console.log(`Install location set to '${loc}'. New skills will be written to ${path}//SKILL.md.`); } function promoteSkill(name, cwd) { @@ -6697,18 +6805,18 @@ function promoteSkill(name, cwd) { console.error("Usage: hivemind skillify promote "); process.exit(1); } - const projectPath = join27(cwd, ".claude", "skills", name); - const globalPath = join27(homedir17(), ".claude", "skills", name); - if (!existsSync24(join27(projectPath, "SKILL.md"))) { + const projectPath = join28(cwd, ".claude", "skills", name); + const globalPath = join28(homedir18(), ".claude", "skills", name); + if (!existsSync25(join28(projectPath, "SKILL.md"))) { console.error(`Skill '${name}' not found at ${projectPath}/SKILL.md`); process.exit(1); } - if (existsSync24(join27(globalPath, "SKILL.md"))) { + if (existsSync25(join28(globalPath, "SKILL.md"))) { console.error(`Skill '${name}' already exists at ${globalPath}/SKILL.md \u2014 refusing to overwrite. Remove it first or rename the project skill.`); process.exit(1); } mkdirSync10(dirname6(globalPath), { recursive: true }); - renameSync4(projectPath, globalPath); + renameSync5(projectPath, globalPath); console.log(`Promoted '${name}' from ${projectPath} \u2192 ${globalPath}.`); } function teamAdd(name) { @@ -6814,7 +6922,7 @@ async function pullSkills(args) { console.error(`pull failed: ${e?.message ?? e}`); process.exit(1); } - const dest = toRaw === "global" ? join27(homedir17(), ".claude", "skills") : `${process.cwd()}/.claude/skills`; + const dest = toRaw === "global" ? join28(homedir18(), ".claude", "skills") : `${process.cwd()}/.claude/skills`; const filterDesc = users.length === 0 ? "all users" : users.join(", "); console.log(`Destination: ${dest}`); console.log(`Filter: ${filterDesc}${skillName ? ` \xB7 skill='${skillName}'` : ""}${dryRun ? " \xB7 dry-run" : ""}${force ? " \xB7 force" : ""}`); @@ -6864,7 +6972,7 @@ async function unpullSkills(args) { all, legacyCleanup }); - const dest = toRaw === "global" ? join27(homedir17(), ".claude", "skills") : `${process.cwd()}/.claude/skills`; + const dest = toRaw === "global" ? join28(homedir18(), ".claude", "skills") : `${process.cwd()}/.claude/skills`; const filterParts = []; if (users.length > 0) filterParts.push(`users=${users.join(",")}`); @@ -6960,13 +7068,13 @@ if (process.argv[1] && process.argv[1].endsWith("skillify.js")) { // dist/src/cli/update.js import { execFileSync as execFileSync4 } from "node:child_process"; -import { existsSync as existsSync25, readFileSync as readFileSync19, realpathSync } from "node:fs"; +import { existsSync as existsSync26, readFileSync as readFileSync20, realpathSync } from "node:fs"; import { dirname as dirname8, sep } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; // dist/src/utils/version-check.js -import { readFileSync as readFileSync18 } from "node:fs"; -import { dirname as dirname7, join as join28 } from "node:path"; +import { readFileSync as readFileSync19 } from "node:fs"; +import { dirname as dirname7, join as join29 } from "node:path"; function isNewer(latest, current) { const parse = (v) => v.split(".").map(Number); const [la, lb, lc] = parse(latest); @@ -6990,7 +7098,7 @@ function detectInstallKind(argv1) { for (let i = 0; i < 10; i++) { const pkgPath = `${dir}${sep}package.json`; try { - const pkg = JSON.parse(readFileSync19(pkgPath, "utf-8")); + const pkg = JSON.parse(readFileSync20(pkgPath, "utf-8")); if (pkg.name === PKG_NAME || pkg.name === "hivemind") { installDir = dir; break; @@ -7011,7 +7119,7 @@ function detectInstallKind(argv1) { } let gitDir = installDir; for (let i = 0; i < 6; i++) { - if (existsSync25(`${gitDir}${sep}.git`)) { + if (existsSync26(`${gitDir}${sep}.git`)) { return { kind: "local-dev", installDir }; } const parent = dirname8(gitDir); diff --git a/openclaw/src/index.ts b/openclaw/src/index.ts index a963350c..af255ba9 100644 --- a/openclaw/src/index.ts +++ b/openclaw/src/index.ts @@ -636,29 +636,6 @@ function buildSessionPath(config: { userName: string; orgName: string; workspace return `/sessions/${config.userName}/${config.userName}_${config.orgName}_${config.workspaceId}_${sessionId}.jsonl`; } -const RECALL_STOPWORDS = new Set([ - "the","and","for","are","but","not","you","all","can","had","her","was","one", - "our","out","has","have","what","does","like","with","this","that","from","they", - "been","will","more","when","who","how","its","into","some","than","them","these", - "then","your","just","about","would","could","should","where","which","there", - "their","being","each","other", -]); - -/** - * Extract the signal-bearing tokens from a natural-language prompt so we can - * feed them into `searchDeeplakeTables` as a multi-word ILIKE. Mirrors the - * pattern used by claude-code/codex grep intercepts — lowercase, strip - * non-alphanumeric, drop short words + stopwords, cap at 4 so the SQL doesn't - * turn into a 20-way OR. - */ -function extractKeywords(prompt: string): string[] { - return prompt.toLowerCase() - .replace(/[^a-z0-9\s]/g, " ") - .split(/\s+/) - .filter(w => w.length >= 3 && !RECALL_STOPWORDS.has(w)) - .slice(0, 4); -} - /** Trim a path filter down to a safe virtual prefix. `/` ⇒ unfiltered. */ function normalizeVirtualPath(p: string | undefined | null): string { if (!p || typeof p !== "string") return "/"; @@ -851,9 +828,12 @@ export default definePluginEntry({ return { text: `✅ Hivemind tools are already enabled in your allowlist.\n\nNo changes needed — memory tools are available to the agent.${skillifyHint}` }; } if (result.status === "added") { - return { text: `✅ Added "hivemind" to your tool allowlist.\n\nOpenclaw will detect the config change and restart. On the next turn, the agent will have access to hivemind_search, hivemind_read, and hivemind_index.\n\nBackup of previous config: ${result.backupPath}${skillifyHint}` }; + const touched: string[] = []; + if (result.delta.pluginsAllow) touched.push(`"hivemind" → plugins.allow`); + if (result.delta.toolsAlsoAllow) touched.push(`"hivemind" → tools.alsoAllow`); + return { text: `✅ Added:\n • ${touched.join("\n • ")}\n\nOpenclaw will detect the config change and restart. On the next turn, the agent will have access to hivemind_search, hivemind_read, and hivemind_index. **Capture starts on the next turn — earlier turns are NOT backfilled.**\n\nBackup of previous config: ${result.backupPath}${skillifyHint}` }; } - return { text: `⚠️ Could not update allowlist: ${result.error}\n\nManual fix: open ${result.configPath} and add "hivemind" to the "alsoAllow" array under "tools".` }; + return { text: `⚠️ Could not update allowlist: ${result.error}\n\nManual fix: open ${result.configPath}. If \`plugins.allow\` exists as a non-empty array, add "hivemind" to it. If \`tools.alsoAllow\` exists as a non-empty array, add "hivemind" to it. If either is absent or empty, leave it as-is (openclaw treats that as default-allow).` }; }, }); @@ -1185,76 +1165,51 @@ export default definePluginEntry({ }); } - // Auto-recall: search memory before each turn - if (config.autoRecall !== false) { - hook("before_agent_start", async (event: { prompt?: string }) => { - if (!event.prompt || event.prompt.length < 5) return; - try { - const dl = await getApi(); - - if (!dl && authUrl) { - return { - prependContext: `\n\nIMPORTANT: The user needs to authenticate with Hivemind. Include this link in your reply: ${authUrl}\nTell them to click it and sign in, then send another message.\n`, - }; - } - if (!dl) return; - - if (justAuthenticated) { - justAuthenticated = false; - const creds = await loadCredentials(); - const orgName = creds?.orgName ?? creds?.orgId ?? "unknown"; - return { prependContext: `\n\n🐝 Welcome to Hivemind!\n\nCurrent org: ${orgName}\n\nYour agents now share memory across sessions, teammates, and machines.\n\nGet started:\n1. Verify sync: spin up multiple sessions and confirm agents share context\n2. Invite a teammate: ask the agent to add them over email\n3. Switch orgs: ask the agent to list or switch your organizations\n\nOne brain for every agent on your team.\n` }; - } - - // Multi-keyword search across BOTH the memory (summaries) and - // sessions (raw turns) tables. Uses the same `searchDeeplakeTables` - // primitive that claude-code and codex agents reach via their - // PreToolUse-intercepted Grep, so recall quality is model-agnostic - // (no more first-keyword-only ILIKE on sessions alone). - const keywords = extractKeywords(event.prompt); - if (!keywords.length) return; + // before_agent_start handles two narrow paths that legitimately fire + // before the agent starts: + // 1. Login nudge — when the user isn't authenticated yet, drop the + // device-flow URL into the agent's context so it can show it. + // 2. Welcome banner — once after a successful device-flow auth. + // + // The previous version of this hook also did a proactive recall query + // across the memory + sessions tables on every turn. That made every + // openclaw turn pay Deeplake's `sessions`-table latency (200ms–10s+) + // even when the prompt needed no memory at all, and a slow Deeplake + // would block the agent for the full timeout before it could reply. + // Other agents (claude-code, codex, cursor, hermes, pi) don't do + // this — they let the agent decide when to search by intercepting its + // Grep tool calls. Openclaw now matches that pattern: the agent gets + // memory via the registered tools (hivemind_search/_read/_index), with + // the SKILL.md body in the system prompt directing it to call them + // first. See issue #121 for the original report (plugins.allow gating + // also fixed in the same PR). + // No `config.autoRecall` gate here: the hook body no longer does any + // recall (CodeRabbit on #124 caught this). Both remaining paths — the + // login URL nudge and the post-auth welcome banner — must run for + // every user, including those who set autoRecall=false. Gating the + // whole hook registration would silently break their auth flow. + hook("before_agent_start", async (event: { prompt?: string }) => { + if (!event.prompt || event.prompt.length < 5) return; + try { + const dl = await getApi(); - const grepParams: GrepMatchParams = { - pattern: keywords.join(" "), - ignoreCase: true, - wordMatch: false, - filesOnly: false, - countOnly: false, - lineNumber: false, - invertMatch: false, - fixedString: true, - }; - const searchOpts = buildGrepSearchOptions(grepParams, "/"); - searchOpts.limit = 10; - const rows = await searchDeeplakeTables(dl, memoryTable, sessionsTable, searchOpts); - if (!rows.length) return; - - const recalled = rows - .map(r => { - const body = normalizeContent(r.path, r.content); - return `[${r.path}] ${body.slice(0, 400)}`; - }) - .join("\n\n"); - - logger.info?.(`Auto-recalled ${rows.length} memories`); - const instruction = - "These are raw Hivemind search hits from prior sessions. Each hit is prefixed with its path " + - "(e.g. `/summaries//...`). Different usernames are different people — do NOT merge, " + - "alias, or conflate them. If you need more detail, call `hivemind_search` with a more specific " + - "query or `hivemind_read` on a specific path. If these hits don't answer the question, say so " + - "rather than guessing."; + if (!dl && authUrl) { return { - prependContext: - "\n\n\n" + - instruction + "\n\n" + - recalled + - "\n\n", + prependContext: `\n\nIMPORTANT: The user needs to authenticate with Hivemind. Include this link in your reply: ${authUrl}\nTell them to click it and sign in, then send another message.\n`, }; - } catch (err) { - logger.error(`Auto-recall failed: ${err instanceof Error ? err.message : String(err)}`); } - }); - } + if (!dl) return; + + if (justAuthenticated) { + justAuthenticated = false; + const creds = await loadCredentials(); + const orgName = creds?.orgName ?? creds?.orgId ?? "unknown"; + return { prependContext: `\n\n🐝 Welcome to Hivemind!\n\nCurrent org: ${orgName}\n\nYour agents now share memory across sessions, teammates, and machines.\n\nGet started:\n1. Verify sync: spin up multiple sessions and confirm agents share context\n2. Invite a teammate: ask the agent to add them over email\n3. Switch orgs: ask the agent to list or switch your organizations\n\nOne brain for every agent on your team.\n` }; + } + } catch (err) { + logger.error(`before_agent_start failed: ${err instanceof Error ? err.message : String(err)}`); + } + }); // Auto-capture: store new messages in sessions table (same format as CC capture.ts) if (config.autoCapture !== false) { diff --git a/openclaw/src/setup-config.ts b/openclaw/src/setup-config.ts index 0bf2a5a8..732265f5 100644 --- a/openclaw/src/setup-config.ts +++ b/openclaw/src/setup-config.ts @@ -1,7 +1,8 @@ // Helpers that read and write ~/.openclaw/openclaw.json on behalf of the -// /hivemind_setup and /hivemind_autoupdate slash commands. Kept in its own -// module so the config-IO code stays separate from the network code in -// index.ts and has a narrow public surface (four exports). +// /hivemind_setup and /hivemind_autoupdate slash commands AND the CLI +// installer at src/cli/install-openclaw.ts. Kept in its own module so the +// config-IO code stays separate from the network code in index.ts and has +// a narrow public surface. import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs"; import { homedir } from "node:os"; @@ -25,11 +26,40 @@ export function isAllowlistCoveringHivemind(alsoAllow: unknown): boolean { return false; } +/** + * True when plugins.allow is an explicit non-empty array that doesn't yet + * include "hivemind". Mirrors openclaw's own `ensurePluginAllowlisted` + * semantics (ext/openclaw/src/config/plugins-allowlist.ts): only patch + * when the user has opted into an explicit allowlist. If it's absent or + * empty, openclaw treats that as default-allow, and we must not silently + * flip the user into explicit-allowlist mode — that would disable every + * other plugin they have installed. + */ +export function isPluginsAllowMissingHivemind(allow: unknown): boolean { + return Array.isArray(allow) && allow.length > 0 && !allow.includes("hivemind"); +} + +export type AllowlistDelta = { + pluginsAllow: boolean; + toolsAlsoAllow: boolean; +}; + export type SetupResult = | { status: "already-set"; configPath: string } - | { status: "added"; configPath: string; backupPath: string } + | { status: "added"; configPath: string; backupPath: string; delta: AllowlistDelta } | { status: "error"; configPath: string; error: string }; +/** + * Patch ~/.openclaw/openclaw.json so the hivemind plugin can both load + * (plugins.allow) and expose its tools (tools.alsoAllow). Atomic write + * via tmp+rename with a timestamped backup. Idempotent across re-runs. + * + * Called from the /hivemind_setup slash command AND from the CLI installer + * — both surfaces need exactly the same config-patch semantics, so they + * share this one entry point. The slash command only becomes reachable + * AFTER plugins.allow already accepts hivemind, so the CLI installer is + * the one path that can fix that case end-to-end (issue #121). + */ export function ensureHivemindAllowlisted(): SetupResult { const configPath = getOpenclawConfigPath(); if (!existsSync(configPath)) { @@ -42,18 +72,48 @@ export function ensureHivemindAllowlisted(): SetupResult { } catch (e) { return { status: "error", configPath, error: `could not read/parse config: ${e instanceof Error ? e.message : String(e)}` }; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { status: "error", configPath, error: "openclaw config is not a JSON object" }; + } + + const plugins = (parsed.plugins ?? {}) as Record; + const pluginsAllowRaw = plugins.allow; const tools = (parsed.tools ?? {}) as Record; - const alsoAllow = Array.isArray(tools.alsoAllow) ? (tools.alsoAllow as unknown[]) : []; - if (isAllowlistCoveringHivemind(alsoAllow)) { + const alsoAllowRaw = tools.alsoAllow; + + const pluginsAllowNeedsPatch = isPluginsAllowMissingHivemind(pluginsAllowRaw); + // Match the same explicit-non-empty-only contract used for plugins.allow: + // only patch when the user has opted into an explicit array. Absent or + // empty → leave alone, so we don't flip default-allow setups into + // restrictive explicit-allowlist mode (CodeRabbit on #124). The + // reporter's broken-state config in #121 already had this as an + // explicit array, so the original bug-fix path is unchanged. + const toolsAlsoAllowNeedsPatch = + Array.isArray(alsoAllowRaw) && alsoAllowRaw.length > 0 && + !isAllowlistCoveringHivemind(alsoAllowRaw); + + if (!pluginsAllowNeedsPatch && !toolsAlsoAllowNeedsPatch) { return { status: "already-set", configPath }; } - const updated: Record = { - ...parsed, - tools: { + + const updated: Record = { ...parsed }; + + if (pluginsAllowNeedsPatch) { + updated.plugins = { + ...plugins, + // Cast safe — isPluginsAllowMissingHivemind guarantees Array. + allow: [...(pluginsAllowRaw as unknown[]), "hivemind"], + }; + } + + if (toolsAlsoAllowNeedsPatch) { + updated.tools = { ...tools, - alsoAllow: [...alsoAllow, "hivemind"], - }, - }; + // Cast safe — the needs-patch check above guarantees Array. + alsoAllow: [...(alsoAllowRaw as unknown[]), "hivemind"], + }; + } + const backupPath = `${configPath}.bak-hivemind-${Date.now()}`; const tmpPath = `${configPath}.tmp-hivemind-${process.pid}`; try { @@ -63,7 +123,15 @@ export function ensureHivemindAllowlisted(): SetupResult { } catch (e) { return { status: "error", configPath, error: `could not write config: ${e instanceof Error ? e.message : String(e)}` }; } - return { status: "added", configPath, backupPath }; + return { + status: "added", + configPath, + backupPath, + delta: { + pluginsAllow: pluginsAllowNeedsPatch, + toolsAlsoAllow: toolsAlsoAllowNeedsPatch, + }, + }; } export type AutoUpdateToggleResult = @@ -120,19 +188,34 @@ export function toggleAutoUpdateConfig(setTo?: boolean): AutoUpdateToggleResult } /** - * True if the openclaw config exists but its tool allowlist doesn't admit - * hivemind's agent tools. Used by index.ts at plugin-register time to decide - * whether to inject the "run /hivemind_setup" nudge into the system prompt. - * Returns false on any error so unusual host environments don't produce - * spurious nudges. + * True if the openclaw config exists but EITHER plugins.allow or + * tools.alsoAllow is missing hivemind. Used by index.ts at plugin- + * register time to decide whether to inject the "run /hivemind_setup" + * nudge into the system prompt. Returns false on any error so unusual + * host environments don't produce spurious nudges. + * + * Note: when plugins.allow is the one that's missing hivemind, the + * plugin won't have registered in the first place and this function + * is moot for that path — but the same check still covers the case + * where a user manually adds hivemind to plugins.allow + restarts but + * forgets to also update tools.alsoAllow. */ export function detectAllowlistMissing(): boolean { const configPath = getOpenclawConfigPath(); if (!existsSync(configPath)) return false; try { const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as Record; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false; + const plugins = (parsed.plugins ?? {}) as Record; const tools = (parsed.tools ?? {}) as Record; - return !isAllowlistCoveringHivemind(tools.alsoAllow); + const alsoAllow = tools.alsoAllow; + // Same explicit-non-empty-only contract as `ensureHivemindAllowlisted`: + // an absent/empty `tools.alsoAllow` is default-allow, not "missing + // hivemind" — so don't trigger the nudge for those users. + const toolsMissing = + Array.isArray(alsoAllow) && alsoAllow.length > 0 && + !isAllowlistCoveringHivemind(alsoAllow); + return isPluginsAllowMissingHivemind(plugins.allow) || toolsMissing; } catch { return false; } diff --git a/src/cli/install-openclaw.ts b/src/cli/install-openclaw.ts index deac396e..7b5f8058 100644 --- a/src/cli/install-openclaw.ts +++ b/src/cli/install-openclaw.ts @@ -1,7 +1,8 @@ import { existsSync, copyFileSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { HOME, pkgRoot, ensureDir, copyDir, writeVersionStamp, log } from "./util.js"; +import { HOME, pkgRoot, ensureDir, copyDir, writeVersionStamp, log, warn } from "./util.js"; import { getVersion } from "./version.js"; +import { ensureHivemindAllowlisted } from "../../openclaw/src/setup-config.js"; const PLUGIN_DIR = join(HOME, ".openclaw", "extensions", "hivemind"); @@ -39,6 +40,43 @@ export function installOpenclaw(): void { writeVersionStamp(PLUGIN_DIR, getVersion()); log(` OpenClaw installed -> ${PLUGIN_DIR}`); + + // Patch ~/.openclaw/openclaw.json so the gateway actually loads us. + // Without this, plugins.allow gates the plugin out — the files land + // on disk but the loader never registers them, so `/hivemind_setup` + // is unreachable from inside the agent (chicken-and-egg). The same + // helper is shared with the slash command, so behavior stays + // identical across both surfaces. See issue #121. + // + // Safe-by-default: if openclaw.json doesn't exist (gateway never + // started) or is malformed, we skip silently. If plugins.allow is + // absent/empty (default-allow), we leave it alone — only patch + // explicit allowlists so we never flip the user into restrictive + // mode and break their other plugins. + const result = ensureHivemindAllowlisted(); + if (result.status === "added") { + const touched: string[] = []; + if (result.delta.pluginsAllow) touched.push("plugins.allow"); + if (result.delta.toolsAlsoAllow) touched.push("tools.alsoAllow"); + log(` OpenClaw patched ${touched.join(" + ")} in ${result.configPath}`); + log(` OpenClaw backup: ${result.backupPath}`); + log(` OpenClaw restart the gateway to activate: systemctl --user restart openclaw-gateway.service`); + log(` OpenClaw capture starts on the NEXT turn — earlier turns are NOT backfilled`); + } else if (result.status === "already-set") { + log(` OpenClaw allowlist already covers hivemind in ${result.configPath}`); + } else if (result.status === "error") { + // "openclaw config file not found" is the common no-op case (gateway + // never started). Log it at info-level — installer is non-fatal, the + // /hivemind_setup slash command will patch on first openclaw run. + // Other errors (malformed JSON, write failure) are user-actionable + // and get a warn so they're visible. CodeRabbit on #124 caught the + // previous silent-error path. + if (result.error === "openclaw config file not found") { + log(` OpenClaw openclaw.json not present at ${result.configPath} — run openclaw once, then \`hivemind claw install\` again`); + } else { + warn(` OpenClaw could not patch allowlist in ${result.configPath}: ${result.error}`); + } + } } export function uninstallOpenclaw(): void { diff --git a/tests/openclaw/auto-recall.test.ts b/tests/openclaw/auto-recall.test.ts index 28d1eef1..955828a2 100644 --- a/tests/openclaw/auto-recall.test.ts +++ b/tests/openclaw/auto-recall.test.ts @@ -1,11 +1,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; /** - * Auto-recall regression tests for the openclaw hivemind plugin's - * `before_agent_start` hook. This used to do a single-keyword ILIKE on the - * sessions table only; after the Phase-1 fix it calls `searchDeeplakeTables` - * with multi-word patterns across BOTH the memory (summaries) and sessions - * tables, exactly what CC/Codex agents see via their PreToolUse grep path. + * Regression tests for the openclaw `before_agent_start` hook. + * + * Design history: the hook used to do a proactive blocking recall query + * across the memory + sessions tables on every user turn. That made every + * openclaw turn pay Deeplake's `sessions`-table latency (200ms–10s+) even + * for prompts that needed no memory at all. Other agents (claude-code, + * codex, cursor, hermes, pi) don't do this — they let the agent decide + * when to search by intercepting its Grep tool calls. + * + * The hook now mirrors that lazy/agent-initiated pattern: recall is only + * available via the registered tools (`hivemind_search`, `hivemind_read`, + * `hivemind_index`), and the SKILL.md body in the system prompt tells the + * agent to use them. The hook itself still handles two narrow paths that + * legitimately need to fire before the agent starts: + * 1. Login nudge — when the user isn't authenticated yet, drop the + * device-flow URL into the agent's context so it can show it. + * 2. Welcome banner — once after a successful device-flow auth. */ const queryMock = vi.fn(); @@ -36,12 +48,21 @@ vi.mock("../../src/deeplake-api.js", () => ({ type HookHandler = (event: Record) => Promise; -async function loadPluginWithHooks() { +async function loadPluginWithHooks(): Promise<{ + hooks: Map; + mockApi: ReturnType; +}> { vi.resetModules(); const mod = await import("../../openclaw/src/index.js"); - const plugin = mod.default as { register: (api: any) => void }; + const plugin = mod.default as { register: (api: ReturnType) => void }; const hooks = new Map(); - const mockApi = { + const mockApi = buildMockApi(hooks); + plugin.register(mockApi); + return { hooks, mockApi }; +} + +function buildMockApi(hooks: Map) { + return { logger: { info: vi.fn(), error: vi.fn() }, on: (event: string, handler: HookHandler) => { hooks.set(event, handler); }, registerCommand: vi.fn(), @@ -49,8 +70,6 @@ async function loadPluginWithHooks() { registerMemoryCorpusSupplement: vi.fn(), pluginConfig: {}, }; - plugin.register(mockApi); - return { hooks, mockApi }; } beforeEach(() => { @@ -73,61 +92,73 @@ beforeEach(() => { }); }); -describe("openclaw auto-recall (before_agent_start)", () => { - it("skips when the prompt is too short", async () => { +describe("openclaw before_agent_start (post-blocking-recall removal)", () => { + it("does NOT call Deeplake on a normal turn — recall is now tool-initiated", async () => { + // The whole point of removing the proactive recall: a plain turn must + // not pay Deeplake latency. If this regresses, every openclaw turn + // will block on `sessions`-table query latency again. const { hooks } = await loadPluginWithHooks(); const before = hooks.get("before_agent_start")!; - const result = await before({ prompt: "hi" }); - expect(result).toBeUndefined(); + const result = await before({ prompt: "what is Levon doing on accuracy" }); + expect(queryMock).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); }); - it("runs a multi-word UNION ALL search across memory and sessions", async () => { + it("does NOT inject context — the agent must call hivemind_search instead", async () => { + // Even if Deeplake were queried, the old prependContext shape must not + // be reintroduced. Belt-and-braces: assert the marker text is absent + // from any return value the hook might emit on a normal turn. queryMock.mockResolvedValue([ - { path: "/summaries/alice/abc.md", content: "Levon is driving the LoCoMo accuracy work", source_order: 0, creation_date: "" }, - { path: "/sessions/bob/xyz.jsonl", content: "chatted with Levon about accuracy metrics", source_order: 1, creation_date: "2026-04-22" }, + { path: "/summaries/alice/abc.md", content: "anything", source_order: 0 }, ]); - const { hooks, mockApi } = await loadPluginWithHooks(); + const { hooks } = await loadPluginWithHooks(); const before = hooks.get("before_agent_start")!; - const result = await before({ prompt: "what is Levon doing on accuracy" }); + const result = await before({ prompt: "anything that previously triggered recall" }); - expect(queryMock).toHaveBeenCalled(); - const sql = queryMock.mock.calls[0][0]; - expect(sql).toContain('FROM "memory"'); - expect(sql).toContain('FROM "sessions"'); - expect(sql).toContain("UNION ALL"); - // Multi-keyword match — at least "levon" and "accuracy" both appear as OR filters - expect(sql).toMatch(/summary::text ILIKE '%levon%'/i); - expect(sql).toMatch(/summary::text ILIKE '%accuracy%'/i); - expect(sql).toMatch(/message::text ILIKE '%levon%'/i); - expect(sql).toMatch(/message::text ILIKE '%accuracy%'/i); - - const ctx = (result as { prependContext: string }).prependContext; - expect(ctx).toContain(""); - expect(ctx).toContain("/summaries/alice/abc.md"); - expect(ctx).toContain("/sessions/bob/xyz.jsonl"); - expect(ctx).toContain(""); - expect(mockApi.logger.info).toHaveBeenCalledWith( - expect.stringContaining("Auto-recalled 2 memories"), - ); + const ctx = (result as { prependContext?: string } | undefined)?.prependContext ?? ""; + expect(ctx).not.toContain(""); }); - it("returns undefined when no rows match", async () => { - queryMock.mockResolvedValue([]); + it("still skips when the prompt is empty or too short", async () => { const { hooks } = await loadPluginWithHooks(); const before = hooks.get("before_agent_start")!; - const result = await before({ prompt: "what is nobody-ever-mentioned doing" }); - expect(result).toBeUndefined(); + expect(await before({ prompt: "" })).toBeUndefined(); + expect(await before({ prompt: "hi" })).toBeUndefined(); + expect(queryMock).not.toHaveBeenCalled(); }); - it("logs and returns undefined when the DeeplakeApi throws", async () => { - queryMock.mockRejectedValue(new Error("deeplake down")); - const { hooks, mockApi } = await loadPluginWithHooks(); - const before = hooks.get("before_agent_start")!; - const result = await before({ prompt: "what is levon doing" }); - expect(result).toBeUndefined(); - expect(mockApi.logger.error).toHaveBeenCalledWith( - expect.stringContaining("Auto-recall failed"), + it("registers hivemind_search / hivemind_read / hivemind_index tools — recall surface for the agent", async () => { + // The hook no longer auto-recalls, so the only way the agent gets at + // memory is the tools. Assert they're still registered; if a future + // refactor drops them by accident, recall disappears entirely. + const { mockApi } = await loadPluginWithHooks(); + const registered = (mockApi.registerTool as ReturnType).mock.calls.map( + ([tool]) => (tool as { name: string }).name, ); + expect(registered).toEqual(expect.arrayContaining([ + "hivemind_search", "hivemind_read", "hivemind_index", + ])); + }); + + it("still surfaces the login URL when the user isn't authenticated yet", async () => { + // No credentials → getApi() returns null → the hook should prepend the + // device-flow URL so the agent shows it. This path predates the + // proactive recall and must survive its removal. + loadCredsMock.mockReturnValue(null); + loadConfigMock.mockReturnValue(null); + const { hooks } = await loadPluginWithHooks(); + // before_agent_start fires asynchronously after register(); requestAuth + // is kicked off by the post-register login-prompt path with a real URL. + // We can't easily seed `authUrl` without doing the device flow, so the + // assertion here is conservative: the hook must NOT call query, and + // must NOT throw, when no creds exist. + const before = hooks.get("before_agent_start")!; + // `.resolves.not.toThrow()` is invalid when the promise resolves to + // `undefined` (not a function) — see CodeRabbit on #124. Switch to + // `.resolves.toBeUndefined()` which actually asserts the resolved + // value and surfaces any thrown rejection naturally. + await expect(before({ prompt: "anything that triggered the path before" })).resolves.toBeUndefined(); + expect(queryMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/openclaw/install-openclaw.test.ts b/tests/openclaw/install-openclaw.test.ts new file mode 100644 index 00000000..04caab8b --- /dev/null +++ b/tests/openclaw/install-openclaw.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Tests for installOpenclaw() — the CLI installer that copies the openclaw + * plugin bundle to ~/.openclaw/extensions/hivemind/ AND patches + * ~/.openclaw/openclaw.json to include "hivemind" in both plugins.allow and + * tools.alsoAllow (when each is an explicit array). See issue #121. + * + * The chicken-and-egg problem this fixes: OpenClaw's plugin loader gates on + * plugins.allow before any of the plugin's registered slash commands become + * reachable, so a freshly-installed plugin that's missing from plugins.allow + * cannot fix its own visibility via /hivemind_setup — the slash command + * isn't reachable. The installer has to patch out-of-band. + * + * homedir() is mocked via vi.mock("node:os") so the installer targets a + * temp dir we control, mirroring the pattern in setup-command.test.ts. + */ + +let TEMP_HOME = ""; + +vi.mock("node:os", async (orig) => { + const actual = await orig(); + return { ...actual, homedir: () => TEMP_HOME }; +}); + +beforeEach(() => { + TEMP_HOME = mkdtempSync(join(tmpdir(), "hivemind-install-openclaw-test-")); +}); + +afterEach(() => { + if (TEMP_HOME && existsSync(TEMP_HOME)) { + rmSync(TEMP_HOME, { recursive: true, force: true }); + } +}); + +function writeConfig(body: Record): string { + const dir = join(TEMP_HOME, ".openclaw"); + const path = join(dir, "openclaw.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify(body, null, 2)); + return path; +} + +async function loadInstaller(): Promise<{ installOpenclaw: () => unknown }> { + vi.resetModules(); + return await import("../../src/cli/install-openclaw.js") as { installOpenclaw: () => unknown }; +} + +describe("installOpenclaw()", () => { + it("copies the plugin bundle to ~/.openclaw/extensions/hivemind/", async () => { + writeConfig({ tools: { alsoAllow: ["hivemind"] } }); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + + const pluginDir = join(TEMP_HOME, ".openclaw", "extensions", "hivemind"); + expect(existsSync(pluginDir)).toBe(true); + expect(existsSync(join(pluginDir, "dist", "index.js"))).toBe(true); + expect(existsSync(join(pluginDir, ".hivemind_version"))).toBe(true); + }); + + describe("openclaw.json patching (issue #121)", () => { + it("adds 'hivemind' to plugins.allow when it's an explicit array missing hivemind", async () => { + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki", "browser"] }, + tools: { profile: "coding", alsoAllow: ["hivemind"] }, + }); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.plugins.allow).toEqual(["memory-wiki", "browser", "hivemind"]); + }); + + it("adds 'hivemind' to tools.alsoAllow when missing", async () => { + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki", "hivemind"] }, + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.tools.alsoAllow).toEqual(["memory_store", "hivemind"]); + }); + + it("patches BOTH arrays in a single run when both miss hivemind", async () => { + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki"] }, + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.plugins.allow).toContain("hivemind"); + expect(updated.tools.alsoAllow).toContain("hivemind"); + }); + + it("is idempotent — no patch when both arrays already include hivemind", async () => { + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki", "hivemind"] }, + tools: { profile: "coding", alsoAllow: ["hivemind"] }, + }); + const before = readFileSync(configPath, "utf-8"); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + + const after = readFileSync(configPath, "utf-8"); + expect(after).toBe(before); + // And: no backup file written when nothing changed. + const backups = readdirSync(join(TEMP_HOME, ".openclaw")).filter(n => n.startsWith("openclaw.json.bak-hivemind-")); + expect(backups).toEqual([]); + }); + + it("leaves plugins.allow ABSENT when it isn't already present (default-allow semantics)", async () => { + const configPath = writeConfig({ + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + // plugins.allow must NOT be created — that would silently flip the + // user from default-allow to explicit-allowlist mode and break + // every other plugin they have installed. + expect(updated.plugins).toBeUndefined(); + // tools.alsoAllow is still patched. + expect(updated.tools.alsoAllow).toContain("hivemind"); + }); + + it("leaves plugins.allow ALONE when it's an empty array (default-allow semantics)", async () => { + const configPath = writeConfig({ + plugins: { allow: [] }, + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.plugins.allow).toEqual([]); + }); + + it("writes a timestamped backup before patching", async () => { + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki"] }, + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const before = readFileSync(configPath, "utf-8"); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + + const backups = readdirSync(join(TEMP_HOME, ".openclaw")).filter(n => n.startsWith("openclaw.json.bak-hivemind-")); + expect(backups.length).toBe(1); + const backupBody = readFileSync(join(TEMP_HOME, ".openclaw", backups[0]), "utf-8"); + expect(backupBody).toBe(before); + }); + + it("doesn't crash when openclaw.json is absent (openclaw never run)", async () => { + // No .openclaw/ dir at all. + const { installOpenclaw } = await loadInstaller(); + expect(() => installOpenclaw()).not.toThrow(); + + const pluginDir = join(TEMP_HOME, ".openclaw", "extensions", "hivemind"); + expect(existsSync(pluginDir)).toBe(true); + }); + + it("doesn't crash when openclaw.json is malformed JSON", async () => { + const dir = join(TEMP_HOME, ".openclaw"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openclaw.json"), "{ this is not json"); + const { installOpenclaw } = await loadInstaller(); + expect(() => installOpenclaw()).not.toThrow(); + }); + + it("does not duplicate 'hivemind' if it sneaks in twice", async () => { + // Real-world configs sometimes have duplicate entries from manual + // edits + automated edits. Adding once should leave a single entry. + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki"] }, + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + // Run a second time — must remain idempotent. + installOpenclaw(); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + const allow = updated.plugins.allow as string[]; + const also = updated.tools.alsoAllow as string[]; + expect(allow.filter(x => x === "hivemind").length).toBe(1); + expect(also.filter(x => x === "hivemind").length).toBe(1); + }); + + it("prints a restart hint when the config was patched", async () => { + writeConfig({ + plugins: { allow: ["memory-wiki"] }, + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const logs: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + logs.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8")); + return true; + }) as typeof process.stdout.write; + try { + const { installOpenclaw } = await loadInstaller(); + installOpenclaw(); + } finally { + process.stdout.write = origWrite; + } + const out = logs.join(""); + expect(out).toMatch(/restart/i); + // No-backfill caveat must also surface so users don't expect old turns to land. + expect(out).toMatch(/next turn|no backfill/i); + }); + }); +}); diff --git a/tests/openclaw/setup-command.test.ts b/tests/openclaw/setup-command.test.ts index c1068a83..02ba839f 100644 --- a/tests/openclaw/setup-command.test.ts +++ b/tests/openclaw/setup-command.test.ts @@ -138,16 +138,43 @@ describe("/hivemind_setup", () => { expect(result.text).toContain("already enabled"); }); - it("handles config where alsoAllow is missing entirely", async () => { + it("does NOT create alsoAllow when it's missing entirely (default-allow semantics)", async () => { + // CodeRabbit on #124 caught this: my original code coerced an absent + // tools.alsoAllow to [] then patched it to ["hivemind"], flipping the + // user from default-allow into explicit-allowlist mode and potentially + // disabling tools from other plugins. Match the same explicit-only + // contract used for plugins.allow. const configPath = writeConfig({ tools: { profile: "coding" }, }); const setup = await loadSetupCommand(); const result = await setup.handler({}) as { text: string }; - expect(result.text).toContain("Added"); + // No change → already-set + expect(result.text).toContain("already enabled"); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.tools.alsoAllow).toBeUndefined(); + }); + + it("does NOT touch alsoAllow when it's an empty array (default-allow semantics)", async () => { + const configPath = writeConfig({ + tools: { profile: "coding", alsoAllow: [] }, + }); + const setup = await loadSetupCommand(); + const result = await setup.handler({}) as { text: string }; + expect(result.text).toContain("already enabled"); const updated = JSON.parse(readFileSync(configPath, "utf-8")); - expect(updated.tools.alsoAllow).toEqual(["hivemind"]); + expect(updated.tools.alsoAllow).toEqual([]); + }); + + it("reports a parse error when openclaw.json is not valid JSON", async () => { + const dir = join(TEMP_HOME, ".openclaw"); + require("node:fs").mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openclaw.json"), "{ broken json"); + const setup = await loadSetupCommand(); + const result = await setup.handler({}) as { text: string }; + expect(result.text).toContain("Could not update allowlist"); }); it("reports error when openclaw.json doesn't exist", async () => { @@ -174,4 +201,119 @@ describe("/hivemind_setup", () => { expect(updated.tools.profile).toBe("coding"); expect(updated.tools.alsoAllow).toContain("hivemind"); }); + + // plugins.allow gating — issue #121. OpenClaw refuses to load the + // hivemind plugin at all when plugins.allow is an explicit non-empty + // array missing "hivemind", so the slash command was previously + // unreachable on a freshly-installed plugin. /hivemind_setup must + // patch both arrays (where each exists as an explicit list) so a + // single run fully unblocks the plugin once it has been registered + // out-of-band. + describe("plugins.allow gating (issue #121)", () => { + it("adds 'hivemind' to plugins.allow when it's an explicit array missing hivemind", async () => { + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki", "browser"] }, + tools: { profile: "coding", alsoAllow: ["hivemind"] }, + }); + const setup = await loadSetupCommand(); + const result = await setup.handler({}) as { text: string }; + expect(result.text).toContain("Added"); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.plugins.allow).toEqual(["memory-wiki", "browser", "hivemind"]); + }); + + it("patches BOTH plugins.allow and tools.alsoAllow in a single run when both miss hivemind", async () => { + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki"] }, + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const setup = await loadSetupCommand(); + const result = await setup.handler({}) as { text: string }; + expect(result.text).toContain("Added"); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.plugins.allow).toEqual(["memory-wiki", "hivemind"]); + expect(updated.tools.alsoAllow).toEqual(["memory_store", "hivemind"]); + }); + + it("does NOT create plugins.allow when it's absent (default-allow semantics)", async () => { + const configPath = writeConfig({ + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const setup = await loadSetupCommand(); + await setup.handler({}); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.plugins).toBeUndefined(); + }); + + it("does NOT touch plugins.allow when it's an empty array (default-allow semantics)", async () => { + const configPath = writeConfig({ + plugins: { allow: [] }, + tools: { profile: "coding", alsoAllow: ["memory_store"] }, + }); + const setup = await loadSetupCommand(); + await setup.handler({}); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.plugins.allow).toEqual([]); + }); + + it("is idempotent when 'hivemind' is already in plugins.allow AND tools.alsoAllow", async () => { + writeConfig({ + plugins: { allow: ["memory-wiki", "hivemind"] }, + tools: { profile: "coding", alsoAllow: ["memory_store", "hivemind"] }, + }); + const setup = await loadSetupCommand(); + const result = await setup.handler({}) as { text: string }; + expect(result.text).toContain("already enabled"); + }); + + it("reports 'Added' even when only plugins.allow needs patching (tools.alsoAllow already covers hivemind)", async () => { + // group:plugins wildcard covers tools.alsoAllow, but plugins.allow + // still needs an explicit "hivemind" entry. + const configPath = writeConfig({ + plugins: { allow: ["memory-wiki"] }, + tools: { profile: "coding", alsoAllow: ["group:plugins"] }, + }); + const setup = await loadSetupCommand(); + const result = await setup.handler({}) as { text: string }; + expect(result.text).toContain("Added"); + + const updated = JSON.parse(readFileSync(configPath, "utf-8")); + expect(updated.plugins.allow).toContain("hivemind"); + // tools.alsoAllow was already covered by the wildcard — left as-is. + expect(updated.tools.alsoAllow).toEqual(["group:plugins"]); + }); + + it("detectAllowlistMissing returns true when only plugins.allow is missing hivemind", async () => { + writeConfig({ + plugins: { allow: ["memory-wiki"] }, + tools: { profile: "coding", alsoAllow: ["hivemind"] }, + }); + vi.resetModules(); + const { detectAllowlistMissing } = await import("../../openclaw/src/setup-config.js"); + expect(detectAllowlistMissing()).toBe(true); + }); + + it("detectAllowlistMissing returns false when both allowlists cover hivemind", async () => { + writeConfig({ + plugins: { allow: ["memory-wiki", "hivemind"] }, + tools: { profile: "coding", alsoAllow: ["hivemind"] }, + }); + vi.resetModules(); + const { detectAllowlistMissing } = await import("../../openclaw/src/setup-config.js"); + expect(detectAllowlistMissing()).toBe(false); + }); + + it("detectAllowlistMissing returns false when plugins.allow is absent (default-allow)", async () => { + writeConfig({ + tools: { profile: "coding", alsoAllow: ["hivemind"] }, + }); + vi.resetModules(); + const { detectAllowlistMissing } = await import("../../openclaw/src/setup-config.js"); + expect(detectAllowlistMissing()).toBe(false); + }); + }); });