Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
668 changes: 388 additions & 280 deletions bundle/cli.js

Large diffs are not rendered by default.

137 changes: 46 additions & 91 deletions openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/";
Expand Down Expand Up @@ -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).` };
},
});

Expand Down Expand Up @@ -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);
Comment on lines -1217 to -1234
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this part removed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@efenocchi yes — the entire before_agent_start recall block is gone. The hook handler now only runs two narrow paths: the login URL nudge (when no creds) and the post-auth welcome banner (once after device-flow). No more extractKeywords, no more searchDeeplakeTables call, no more <recalled-memories> injection.

Recall is now agent-initiated via the registered tools (hivemind_search, hivemind_read, hivemind_index) — matches how CC/Codex/Cursor/Hermes/Pi already work (their Grep PreToolUse intercept is the same pattern: lazy, on agent demand). The full diff for the removal is at openclaw/src/index.ts:991-1031 on the latest commit.

Note: CodeRabbit also caught (and I fixed) that I'd left the entire hook wrapped in if (config.autoRecall !== false) — that would have disabled the auth nudge + welcome banner for users with autoRecall: false. Now unconditional.

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/<username>/...`). 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<recalled-memories>\n" +
instruction + "\n\n" +
recalled +
"\n</recalled-memories>\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) {
Expand Down
121 changes: 102 additions & 19 deletions openclaw/src/setup-config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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)) {
Expand All @@ -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<string, unknown>;
const pluginsAllowRaw = plugins.allow;
const tools = (parsed.tools ?? {}) as Record<string, unknown>;
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<string, unknown> = {
...parsed,
tools: {

const updated: Record<string, unknown> = { ...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 {
Expand All @@ -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 =
Expand Down Expand Up @@ -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<string, unknown>;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
const plugins = (parsed.plugins ?? {}) as Record<string, unknown>;
const tools = (parsed.tools ?? {}) as Record<string, unknown>;
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;
}
Expand Down
40 changes: 39 additions & 1 deletion src/cli/install-openclaw.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading