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
14 changes: 9 additions & 5 deletions embeddings/embed-daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

// dist/src/embeddings/daemon.js
import { createServer } from "node:net";
import { unlinkSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
import { unlinkSync, writeFileSync, existsSync, mkdirSync as mkdirSync2, chmodSync } from "node:fs";

// dist/src/embeddings/nomic.js
import { createRequire } from "node:module";
Expand Down Expand Up @@ -133,8 +133,8 @@ var NomicEmbedder = class {
};

// dist/src/utils/debug.js
import { appendFileSync } from "node:fs";
import { join as join2 } from "node:path";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname, join as join2 } from "node:path";
import { homedir as homedir2 } from "node:os";
var LOG = join2(homedir2(), ".deeplake", "hook-debug.log");
function isDebug() {
Expand All @@ -143,8 +143,12 @@ function isDebug() {
function log(tag, msg) {
if (!isDebug())
return;
appendFileSync(LOG, `${(/* @__PURE__ */ new Date()).toISOString()} [${tag}] ${msg}
try {
mkdirSync(dirname(LOG), { recursive: true });
appendFileSync(LOG, `${(/* @__PURE__ */ new Date()).toISOString()} [${tag}] ${msg}
`);
} catch {
}
}

// dist/src/embeddings/daemon.js
Expand All @@ -171,7 +175,7 @@ var EmbedDaemon = class {
this.daemonPath = opts.daemonPath ?? process.argv[1] ?? "";
}
async start() {
mkdirSync(this.socketPath.replace(/\/[^/]+$/, ""), { recursive: true });
mkdirSync2(this.socketPath.replace(/\/[^/]+$/, ""), { recursive: true });
writeFileSync(this.pidPath, String(process.pid), { mode: 384 });
if (existsSync(this.socketPath)) {
try {
Expand Down
16 changes: 15 additions & 1 deletion src/cli/install-codex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { existsSync, lstatSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { HOME, pkgRoot, ensureDir, copyDir, writeJson, writeJsonIfChanged, symlinkForce, writeVersionStamp, log, warn } from "./util.js";
Expand Down Expand Up @@ -257,6 +257,20 @@ export function installCodex(): void {
warn(` Codex skill source missing at ${skillTarget}; skipping symlink`);
}

// Link node_modules to embed-deps so graph-on-stop.js (which uses the
// external tree-sitter native module) can resolve it. The capture hook's
// ensurePluginNodeModulesLink skips existing real directories — so we
// replace an empty placeholder dir with a symlink here at install time.
const pluginNm = join(PLUGIN_DIR, "node_modules");
const embedDepsNm = join(HOME, ".hivemind", "embed-deps", "node_modules");
if (existsSync(embedDepsNm)) {
try {
const st = lstatSync(pluginNm);
if (st.isDirectory() && !st.isSymbolicLink()) rmSync(pluginNm, { recursive: true });
} catch { /* not found — ok */ }
symlinkForce(embedDepsNm, pluginNm);
}

writeVersionStamp(PLUGIN_DIR, getVersion());
log(` Codex installed -> ${PLUGIN_DIR}`);
}
Expand Down
11 changes: 9 additions & 2 deletions src/cli/install-cursor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, unlinkSync } from "node:fs";
import { existsSync, lstatSync, rmSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { HOME, pkgRoot, ensureDir, copyDir, readJson, writeJson, writeJsonIfChanged, writeVersionStamp, log } from "./util.js";
import { HOME, pkgRoot, ensureDir, copyDir, readJson, writeJson, writeJsonIfChanged, symlinkForce, writeVersionStamp, log } from "./util.js";
import { getVersion } from "./version.js";

// Cursor 1.7+ hooks API: https://cursor.com/docs/agent/hooks
Expand Down Expand Up @@ -113,6 +113,13 @@ export function installCursor(): void {
// we don't perturb the hooks.json Cursor/Codex-style trust fingerprints.
writeJsonIfChanged(HOOKS_PATH, merged);

const pluginNm = join(PLUGIN_DIR, "node_modules");
const embedDepsNm = join(HOME, ".hivemind", "embed-deps", "node_modules");
if (existsSync(embedDepsNm)) {
try { const st = lstatSync(pluginNm); if (st.isDirectory() && !st.isSymbolicLink()) rmSync(pluginNm, { recursive: true }); } catch { /* ok */ }
symlinkForce(embedDepsNm, pluginNm);
}

writeVersionStamp(PLUGIN_DIR, getVersion());
log(` Cursor installed -> ${PLUGIN_DIR}`);
}
Expand Down
10 changes: 8 additions & 2 deletions src/cli/install-hermes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, writeFileSync, readFileSync, rmSync, unlinkSync } from "node:fs";
import { existsSync, lstatSync, writeFileSync, readFileSync, rmSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import * as yaml from "js-yaml";
import { HOME, pkgRoot, ensureDir, copyDir, writeVersionStamp, log } from "./util.js";
import { HOME, pkgRoot, ensureDir, copyDir, symlinkForce, writeVersionStamp, log } from "./util.js";
import { getVersion } from "./version.js";
import { ensureMcpServerInstalled, MCP_SERVER_PATH } from "./install-mcp-shared.js";

Expand Down Expand Up @@ -189,6 +189,12 @@ export function installHermes(): void {
}
ensureDir(HIVEMIND_DIR);
copyDir(srcBundle, BUNDLE_DIR);
const pluginNm = join(HIVEMIND_DIR, "node_modules");
const embedDepsNm = join(HOME, ".hivemind", "embed-deps", "node_modules");
if (existsSync(embedDepsNm)) {
try { const st = lstatSync(pluginNm); if (st.isDirectory() && !st.isSymbolicLink()) rmSync(pluginNm, { recursive: true }); } catch { /* ok */ }
symlinkForce(embedDepsNm, pluginNm);
}
writeVersionStamp(HIVEMIND_DIR, getVersion());
log(` Hermes bundle installed -> ${BUNDLE_DIR}`);

Expand Down
2 changes: 2 additions & 0 deletions src/hooks/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { reactSkillOpt } from "./shared/skillopt-hook.js";
import { EmbedClient } from "../embeddings/client.js";
import { embeddingSqlLiteral } from "../embeddings/sql.js";
import { embeddingsDisabled } from "../embeddings/disable.js";
import { isHivemindPluginEnabled } from "../utils/plugin-state.js";
import { ensurePluginNodeModulesLink } from "../embeddings/self-heal.js";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
Expand Down Expand Up @@ -76,6 +77,7 @@ const CAPTURE = process.env.HIVEMIND_CAPTURE !== "false";

async function main(): Promise<void> {
if (!CAPTURE) return;
if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; }
if (!entrypointPassesOnlyCliGate()) return;
const input = await readStdin<HookInput>();
const config = loadConfig();
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/codex/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "../summary-state.js";
import { bundleDirFromImportMeta, spawnCodexWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
import { getInstalledVersion } from "../../utils/version-check.js";
import { isHivemindPluginEnabled } from "../../utils/plugin-state.js";
import { reactSkillOpt } from "../shared/skillopt-hook.js";
const log = (msg: string) => _log("codex-capture", msg);

Expand Down Expand Up @@ -72,6 +73,7 @@ const CAPTURE = process.env.HIVEMIND_CAPTURE !== "false";

async function main(): Promise<void> {
if (!CAPTURE) return;
if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; }
const input = await readStdin<CodexHookInput>();
const config = loadConfig();
if (!config) { log("no config"); return; }
Expand Down
54 changes: 40 additions & 14 deletions src/hooks/codex/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
* spawning the bundled script in a subprocess.
*/

import { join } from "node:path";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { readStdin } from "../../utils/stdin.js";
import { loadConfig } from "../../config.js";
import { DeeplakeApi } from "../../deeplake-api.js";
Expand All @@ -38,6 +40,8 @@ import { armSkillOptOnSkillUse } from "../shared/skillopt-hook.js";

export { isSafe, touchesMemory, rewritePaths };

const __bundleDir = dirname(fileURLToPath(import.meta.url));

const log = (msg: string) => _log("codex-pre", msg);

export interface CodexPreToolUseInput {
Expand Down Expand Up @@ -351,19 +355,41 @@ export async function processCodexPreToolUse(
}
}

// Nothing matched: no config, an unhandled command shape, or a direct-query
// error. Do NOT run the command through just-bash or the host shell — it only
// passed isSafe(), and just-bash can invoke real host binaries, which is the
// exact code-execution surface this hook exists to remove. BLOCK (exit 2) so
// the command never reaches the host, and inject the guidance as the result.
// (We can't use "guide" here: guide exits 0, which lets Codex run the original
// command on the host — the very thing we're preventing.)
logFn(`unroutable memory command, blocking with guidance: ${rewritten}`);
return {
action: "block",
output: buildUnsupportedGuidance(),
rewrittenCommand: rewritten,
};
// Nothing matched by the inline fast-path. Route through the VFS shell bundle
// — a sandboxed Node.js interpreter against the SQL backend, no host access.
// We run it synchronously here (spawnSync) so the output is available before
// returning the decision.
//
// Action choice:
// "guide" (exit 0) — Codex treats the command as successful and also runs
// the original on the host. Safe ONLY for write-redirect patterns
// (echo/printf/tee … > /file) where the side-effect on the real
// ~/.deeplake/memory/ disk dir is harmless — VFS reads always query SQL.
// "block" (exit 2) — Codex treats the command as rejected. Used for
// everything else (pipes, finds, reads) to prevent host execution.
// Safe to return "guide" (Codex also runs original on host) ONLY for pure
// output commands: echo/printf/tee writing to a VFS path. A generic ">>"
// check would match mixed commands like `sort /etc/passwd > /vfs/out` which
// would then execute on the host and read real files.
const isWriteRedirect = /^\s*(echo|printf|tee)\b/.test(rewritten) && /\s>>?\s/.test(rewritten);
const shellBundle = join(__bundleDir, "shell", "deeplake-shell.js");
logFn(`unroutable memory command, falling back to VFS shell: ${rewritten}`);
try {
const proc = spawnSync("node", [shellBundle, "-c", rewritten], {
encoding: "utf-8",
timeout: 10_000,
});
if (proc.status === 0 || (proc.stdout && proc.stdout.trim())) {
const output = (proc.stdout?.trim() ?? "") || "(done)";
// Write redirects: use "guide" so Codex reports success (not "blocked").
// Other commands: keep "block" so the host shell never runs them.
return { action: isWriteRedirect ? "guide" : "block", output, rewrittenCommand: rewritten };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
// Shell exited non-zero (bundle missing or command failed) — fall back to guidance.
return { action: "block", output: buildUnsupportedGuidance(), rewrittenCommand: rewritten };
} catch {
return { action: "block", output: buildUnsupportedGuidance(), rewrittenCommand: rewritten };
}
}

/* c8 ignore start */
Expand Down
27 changes: 14 additions & 13 deletions src/hooks/codex/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,19 @@ async function main(): Promise<void> {
// Coordinate with the periodic worker: if one is already running for this
// session, skip. Two workers writing the same summary row trip the
// Deeplake UPDATE-coalescing quirk (see CLAUDE.md) and drop one write.
const cwd = input.cwd || process.cwd();

// Skillify has its own per-project lock — fire before the wiki-worker lock
// check so a Periodic trigger that already holds the lock doesn't suppress
// skill mining.
forceSessionEndTrigger({
config,
cwd,
bundleDir: bundleDirFromImportMeta(import.meta.url),
agent: "codex",
sessionId,
});

if (!tryAcquireLock(sessionId)) {
wikiLog(`Stop: periodic worker already running for ${sessionId}, skipping`);
return;
Expand All @@ -155,7 +168,7 @@ async function main(): Promise<void> {
spawnCodexWikiWorker({
config,
sessionId,
cwd: input.cwd ?? "",
cwd,
bundleDir: bundleDirFromImportMeta(import.meta.url),
reason: "Stop",
});
Expand All @@ -171,18 +184,6 @@ async function main(): Promise<void> {
}
throw e;
}

// Skillify: Codex Stop is the end-of-session signal (no separate SessionEnd
// hook). Always force-fire — same shape as Claude Code's SessionEnd path.
// The forceSessionEndTrigger helper resets the counter internally so the
// mid-session Stop counter doesn't double-fire on the same window.
forceSessionEndTrigger({
config,
cwd: input.cwd ?? "",
bundleDir: bundleDirFromImportMeta(import.meta.url),
agent: "codex",
sessionId,
});
}

main().catch((e) => { log(`fatal: ${e.message}`); process.exit(0); });
2 changes: 2 additions & 0 deletions src/hooks/cursor/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { bundleDirFromImportMeta, spawnCursorWikiWorker, wikiLog } from "./spawn
import { tryStopCounterTrigger } from "../../skillify/triggers.js";
import type { Config } from "../../config.js";
import { getInstalledVersion } from "../../utils/version-check.js";
import { isHivemindPluginEnabled } from "../../utils/plugin-state.js";
const log = (msg: string) => _log("cursor-capture", msg);

function resolveEmbedDaemonPath(): string {
Expand Down Expand Up @@ -87,6 +88,7 @@ function resolveCwd(input: CursorCaptureInput): string {

async function main(): Promise<void> {
if (!CAPTURE) return;
if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; }
const input = await readStdin<CursorCaptureInput>();
const config = loadConfig();
if (!config) { log("no config"); return; }
Expand Down
33 changes: 18 additions & 15 deletions src/hooks/cursor/session-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,31 @@ async function main(): Promise<void> {
const sessionId = input.conversation_id ?? input.session_id ?? "";
log(`session=${sessionId || "?"} reason=${input.reason ?? "?"} status=${input.final_status ?? "?"}`);
if (!sessionId) return;
const config = loadConfig();
if (!config) { wikiLog(`SessionEnd: no config, skipping summary`); return; }

// Skillify has its own per-project lock — fire before the wiki-worker lock
// check so a Periodic trigger that already holds the lock doesn't suppress
// skill mining.
try {
forceSessionEndTrigger({
config,
cwd: process.cwd(),
bundleDir: bundleDirFromImportMeta(import.meta.url),
agent: "cursor",
sessionId,
});
} catch (e: any) {
wikiLog(`SessionEnd: skillify trigger failed: ${e?.message ?? e}`);
}

// Coordinate with the periodic worker: skip the final spawn if a periodic
// is mid-flight. Lock TTL covers crashed workers.
if (!tryAcquireLock(sessionId)) {
wikiLog(`SessionEnd: periodic worker already running for ${sessionId}, skipping final`);
return;
}
const config = loadConfig();
if (!config) { wikiLog(`SessionEnd: no config, skipping summary`); return; }

// Spawn the wiki and skillify workers independently — a failure of one
// must not suppress the other. Each is wrapped in its own try.
try {
spawnCursorWikiWorker({
config,
Expand All @@ -55,17 +69,6 @@ async function main(): Promise<void> {
} catch (e: any) {
wikiLog(`SessionEnd: wiki spawn failed: ${e?.message ?? e}`);
}
try {
forceSessionEndTrigger({
config,
cwd: process.cwd(),
bundleDir: bundleDirFromImportMeta(import.meta.url),
agent: "cursor",
sessionId,
});
} catch (e: any) {
wikiLog(`SessionEnd: skillify trigger failed: ${e?.message ?? e}`);
}
}

main().catch((e) => { log(`fatal: ${e.message}`); process.exit(0); });
2 changes: 2 additions & 0 deletions src/hooks/hermes/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { bundleDirFromImportMeta, spawnHermesWikiWorker, wikiLog } from "./spawn
import { tryStopCounterTrigger } from "../../skillify/triggers.js";
import type { Config } from "../../config.js";
import { getInstalledVersion } from "../../utils/version-check.js";
import { isHivemindPluginEnabled } from "../../utils/plugin-state.js";
import { reactSkillOpt } from "../shared/skillopt-hook.js";
const log = (msg: string) => _log("hermes-capture", msg);

Expand Down Expand Up @@ -76,6 +77,7 @@ function pickString(...candidates: unknown[]): string | undefined {

async function main(): Promise<void> {
if (!CAPTURE) return;
if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; }
const input = await readStdin<HermesCaptureInput>();
const config = loadConfig();
if (!config) { log("no config"); return; }
Expand Down
Loading
Loading