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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ tmp/
*.d.ts.map
.env
.env.*
# Personal per-directory Hivemind override (routing / opt-out). The committed
# `.hivemind` is shared; `.hivemind.local` is yours alone — never commit it.
.hivemind.local
coverage/
jscpd-report/
bench/
Expand Down
5 changes: 5 additions & 0 deletions .hivemind.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"orgId": "your-org-id-or-name",
"workspaceId": "your-workspace",
"collect": true
}
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ Disable capture entirely:
HIVEMIND_CAPTURE=false claude
```

Disable capture for a specific directory tree (persistent, travels with the repo) by dropping a `.hivemind` file with `{ "collect": false }`. See [Per-directory config](#per-directory-config-hivemind).

Enable debug logging:

```bash
Expand Down Expand Up @@ -323,6 +325,85 @@ This plugin captures session activity and stores it in your Deeplake workspace:
| `HIVEMIND_RECALL_TIMEOUT_MS` | `1000` | Proactive recall: hard cap on the synchronous search path; on timeout it skips rather than delay the turn. |
| `HIVEMIND_DEBUG` | _(none)_ | Set to `1` for verbose hook debug logs |

## Per-directory config (`.hivemind`)

The variables above set **one global identity** for the whole machine. A `.hivemind` file lets a specific directory tree override that: either **route** its traces to a different org/workspace, or **opt out** of capture entirely.

Drop a `.hivemind` JSON file at the root of the tree you want to configure:

```json
{
"orgId": "acme-corp",
"workspaceId": "client-work",
"collect": true
}
```

| Field | Effect |
|---------------|-------------------------------------------------------------------------------|
| `orgId` | Route captured traces from this tree to this org. |
| `workspaceId` | Route to this workspace. |
| `collect` | `false` → **never** capture traces from this tree. |

Any field may be omitted; omitted fields fall back to your global identity.

**Two common recipes:**

```jsonc
// route this repo's traces to a client org/workspace
{ "orgId": "acme-corp", "workspaceId": "client-work" }

// never collect traces from this folder (e.g. a personal or sensitive repo)
{ "collect": false }
```

### Committed vs local

Two filenames are recognized, mirroring the `.env` / `.env.local` convention every dev already knows:

| File | Commit it? | For |
|------|-----------|-----|
| `.hivemind` | **Yes** (like `.editorconfig`) | The repo declaring where *its* traces belong (or that it's off-limits). Teammates who clone inherit it. |
| `.hivemind.local` | **No** (gitignore it) | *Your personal* override or opt-out, not imposed on teammates. Wins over `.hivemind` in the same directory. |

There's nothing to hide: a `.hivemind` can't carry a token (see below), so committing one is safe. It just declares intent. Use `.hivemind.local` only when a choice is yours alone (add it to your repo's `.gitignore`, like `.env.local`).

A copy-ready template lives at [`.hivemind.example`](.hivemind.example). Run `cp .hivemind.example .hivemind` and edit. (The `.example` file is inert; Hivemind only reads `.hivemind` and `.hivemind.local`.)

### How it resolves

When a session starts, Hivemind walks **up** from the working directory (`cwd`, its parent, its grandparent, and so on) and uses the **first** file it finds (a `.hivemind.local` beats a `.hivemind` in the same directory). Nearest wins; ancestors above it are ignored. This is the `.git`/`.gitconfig` model, **not** `.gitignore`-style merging. There is no inheritance: a leaf file that wants both its parent's org and its own workspace must state both.

```
~/work/.hivemind { "orgId": "acme-corp" }
~/work/client/.hivemind { "workspaceId": "sensitive", "collect": false }

session in ~/work/client/svc/ → uses client/.hivemind ONLY
(collect:false wins; the org above is NOT inherited)
session in ~/work/other/ → no .hivemind found → global identity
```

**Precedence** is the conventional `env > file > login`: an explicitly-set `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` overrides a `.hivemind` routing value (that field is left untouched), which in turn overrides your logged-in default. `collect: false` is a fail-safe opt-out and is always honored.

### Safety: routing is disclosed, not hidden

Because a `.hivemind` travels with a repo, cloning someone's repo could in principle point *your* traces at a different org. Two things keep that safe, with no approval step or ceremony:

- **`.hivemind` never contains a token.** Auth stays in `~/.deeplake/credentials.json`, so a routing override can only ever target orgs your existing login **already** authorizes; the API rejects anything else. It can't leak your traces to a stranger's org. At worst it misfiles them into another of *your own* orgs.
- **Every session tells you where its traces go.** The session-start banner prints the **effective** org/workspace after any `.hivemind` overlay, e.g. `org: acme-corp (workspace: client-work) · routed by ./.hivemind`, or `capture is disabled for this directory` when you've opted out. So a redirect is never silent; if it's not what you want, delete the file or add `.hivemind.local`.

### Interaction with `org switch` / `workspace switch`

`hivemind org switch` and `hivemind workspace switch` change your **global default** (they write `~/.deeplake/credentials.json`). A `.hivemind` is a **pin on top of that default**:

| Location | Where traces go |
|------------------------------|------------------------------------------------------------|
| Dir with a routing `.hivemind` | The pinned org/workspace, **unaffected** by `org switch`. |
| Dir with **no** `.hivemind` | Follows your current global default (i.e. `org switch`). |
| Dir with `collect: false` | Nothing captured, regardless of the global default. |

So `org switch` moves everything that *isn't* explicitly pinned; a pin stays put by design (that's the point of routing a client repo to a fixed org). The session-start banner always shows the **effective** identity for your current directory, so a pinned tree never silently surprises you.

## Semantic search (optional)

Hivemind ships with a local embedding daemon (nomic-embed-text-v1.5) for hybrid semantic + lexical search over `~/.deeplake/memory/`. **Off by default** because the dependency footprint is ~600 MB. Enable with `hivemind embeddings install` (or `hivemind install --with-embeddings`). Without it, search degrades silently to BM25/lexical-only.
Expand Down
97 changes: 90 additions & 7 deletions harnesses/pi/extension-source/hivemind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,62 @@
}
}

// Per-directory `.hivemind` (route org/workspace, or opt out of capture).
// Self-contained mirror of src/dir-config.ts — pi extensions ship as raw .ts
// with no shared-module imports, so keep in lockstep with that file. Walk up
// from cwd for the nearest `.hivemind.local` / `.hivemind` (nearest wins,
// `.local` beats committed), and overlay org/workspace onto creds. Precedence
// is env > file > login: HIVEMIND_ORG_ID / HIVEMIND_WORKSPACE_ID lock a field.
interface PiDirConfig { orgId?: string; orgName?: string; workspaceId?: string; collect?: boolean; }

function findHivemindDir(startDir: string): PiDirConfig | null {
let dir = startDir || process.cwd();
for (;;) {
for (const name of [".hivemind.local", ".hivemind"]) {
try {
const raw = JSON.parse(readFileSync(join(dir, name), "utf-8"));
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
const out: PiDirConfig = {};
if (typeof raw.orgId === "string") out.orgId = raw.orgId;
if (typeof raw.orgName === "string") out.orgName = raw.orgName;
if (typeof raw.workspaceId === "string") out.workspaceId = raw.workspaceId;
if (typeof raw.collect === "boolean") out.collect = raw.collect;
return out;
}
} catch { /* absent / unparseable — keep walking up */ }
}
const parent = dirname(dir);
if (parent === dir) return null; // filesystem root
dir = parent;
}
}

/** Overlay env + the nearest `.hivemind` onto `creds` for `cwd`, in the
* conventional env > file > login order. Returns the effective creds, whether
* capture is enabled here, and whether a `.hivemind` routed the identity. */
function applyDirConfig(creds: Creds, cwd: string): { creds: Creds; collect: boolean; routed: boolean } {
// Env wins over both the file and login (mirrors src/config.ts's
// `process.env.HIVEMIND_ORG_ID ?? creds.orgId`). Fold it into the base first
// so it holds whether or not a `.hivemind` is present, and so the lock below
// reflects an actually-applied value rather than a no-op.
const envOrgId = process.env.HIVEMIND_ORG_ID;
const envWs = process.env.HIVEMIND_WORKSPACE_ID;
const baseOrgId = envOrgId || creds.orgId;
const baseOrgName = envOrgId ? (creds.orgName ?? envOrgId) : creds.orgName;
const baseWs = envWs || creds.workspaceId;
const withEnv: Creds = { ...creds, orgId: baseOrgId, orgName: baseOrgName, workspaceId: baseWs };

const dir = findHivemindDir(cwd || process.cwd());
if (!dir) return { creds: withEnv, collect: true, routed: false };
if (dir.collect === false) return { creds: withEnv, collect: false, routed: false };
// The file may fill only fields NOT pinned by an env var.
const orgId = envOrgId ? baseOrgId : (dir.orgId ?? baseOrgId);
const orgName = envOrgId ? baseOrgName : (dir.orgName ?? dir.orgId ?? baseOrgName);
const workspaceId = envWs ? baseWs : (dir.workspaceId ?? baseWs);
const routed = orgId !== baseOrgId || workspaceId !== baseWs; // .hivemind changed it
return { creds: { ...withEnv, orgId, orgName, workspaceId }, collect: true, routed };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Inline copies of decodeJwtPayload + healDriftedOrgToken (the shared helpers
// live in src/commands/auth.ts, but pi extensions ship as raw .ts with no
// shared-module imports — kept in lockstep with that file).
Expand Down Expand Up @@ -131,14 +187,14 @@
const raw = JSON.parse(readFileSync(path, "utf-8"));
raw.token = newToken;
writeFileSync(path, JSON.stringify(raw, null, 2), { mode: 0o600 });
logHm(`session_start: token re-minted for org=${creds.orgId}`);

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
Outbound network request depends on file data.
return { ...creds, token: newToken };
} catch (e: any) {
logHm(`session_start: token re-mint failed (continuing with stale token): ${e?.message ?? e}`);
return creds;
}
}

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
Outbound network request depends on file data.

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
Outbound network request depends on file data.
Outbound network request depends on file data.
const MEMORY_TABLE = process.env.HIVEMIND_TABLE ?? "memory";
const SESSIONS_TABLE = process.env.HIVEMIND_SESSIONS_TABLE ?? "sessions";

Expand Down Expand Up @@ -1214,7 +1270,7 @@
// ctx.cwd are the canonical sources for session id + cwd — the events
// themselves don't carry them.

pi.on("session_start", async (_event: any, _ctx: any) => {
pi.on("session_start", async (_event: any, ctx: any) => {
logHm(`session_start: fired (capture=${captureEnabled}, embed=${process.env.HIVEMIND_EMBEDDINGS !== "false"}, table=${SESSIONS_TABLE})`);
let creds = loadCreds();
if (!creds) {
Expand All @@ -1224,6 +1280,18 @@
creds = await healDriftedOrgTokenInline(creds);
}

// Per-directory `.hivemind`: opt out of capture (collect:false), or route
// to a configured org/workspace, based on the session's cwd.
const sessionCwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
let dirCollect = true;
let dirRouted = false;
if (creds) {
const dr = applyDirConfig(creds, sessionCwd);
dirCollect = dr.collect;
dirRouted = dr.routed;
if (dr.collect) creds = dr.creds; // route only when we're capturing here
}

// Centralized autoupdate: shells out to `hivemind update` (npm-based,
// refreshes every detected agent in one shot). Best-effort, fully
// self-contained because the pi extension ships as raw .ts (no shared-
Expand All @@ -1249,7 +1317,7 @@
} catch { /* network down / which missing — silent */ }
}

if (creds && captureEnabled) {
if (creds && captureEnabled && dirCollect) {
// Other agents' session-start hooks create the memory + sessions tables
// via DeeplakeApi.ensureTable / ensureSessionsTable. The pi extension is
// standalone (no shared lib import to keep it raw-.ts), so we issue the
Expand Down Expand Up @@ -1357,8 +1425,11 @@
const localMinedNote = localMined > 0
? `\n${localMined} local skill${localMined === 1 ? "" : "s"} from past 'hivemind skillify mine-local' run(s) live in ~/.claude/skills/. Run 'hivemind login' to start sharing new mining results with your team.`
: "";
const identityLine = !dirCollect
? `Deeplake capture is disabled for this directory (.hivemind); memory search still uses org: ${creds?.orgName ?? creds?.orgId}.`
: `Logged in to Deeplake as org: ${creds?.orgName ?? creds?.orgId} (workspace: ${creds?.workspaceId})${dirRouted ? " · routed by .hivemind" : ""}.`;
const additional = creds
? `${CONTEXT_PREAMBLE}\nLogged in to Deeplake as org: ${creds.orgName ?? creds.orgId} (workspace: ${creds.workspaceId}).`
? `${CONTEXT_PREAMBLE}\n${identityLine}`
: `${CONTEXT_PREAMBLE}\nNot logged in to Deeplake. Run \`hivemind login\` to authenticate.${localMinedNote}`;
return { additionalContext: additional };
});
Expand All @@ -1367,12 +1438,15 @@
logHm(`input: fired source=${event?.source ?? "?"}`);
if (!captureEnabled) { logHm(`input: capture disabled, skipping`); return; }
if (event.source === "extension") { logHm(`input: extension-injected, skipping`); return; }
const creds = loadCreds();
let creds = loadCreds();
if (!creds) { logHm(`input: no creds, skipping`); return; }
const text = typeof event.text === "string" ? event.text : "";
if (!text) { logHm(`input: empty text, skipping`); return; }
const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
const dirRes = applyDirConfig(creds, cwd);
if (!dirRes.collect) { logHm(`input: capture disabled for cwd=${cwd} via .hivemind`); return; }
creds = dirRes.creds;
try {
await writeSessionRow(creds, sessionId, "pi", "input", cwd, {
id: crypto.randomUUID(),
Expand All @@ -1392,10 +1466,13 @@
pi.on("tool_result", async (event: any, ctx: any) => {
logHm(`tool_result: fired tool=${event?.toolName ?? "?"} isError=${event?.isError === true}`);
if (!captureEnabled) { logHm(`tool_result: capture disabled, skipping`); return; }
const creds = loadCreds();
let creds = loadCreds();
if (!creds) { logHm(`tool_result: no creds, skipping`); return; }
const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
const dirRes = applyDirConfig(creds, cwd);
if (!dirRes.collect) { logHm(`tool_result: capture disabled for cwd=${cwd} via .hivemind`); return; }
creds = dirRes.creds;
// event.content is (TextContent | ImageContent)[]; extract text blocks.
const contentBlocks: any[] = Array.isArray(event.content) ? event.content : [];
const responseText = contentBlocks
Expand Down Expand Up @@ -1426,7 +1503,7 @@
pi.on("message_end", async (event: any, ctx: any) => {
logHm(`message_end: fired role=${event?.message?.role ?? "?"}`);
if (!captureEnabled) { logHm(`message_end: capture disabled, skipping`); return; }
const creds = loadCreds();
let creds = loadCreds();
if (!creds) { logHm(`message_end: no creds, skipping`); return; }
const message = event.message ?? null;
// AgentMessage is UserMessage | AssistantMessage | ToolResultMessage.
Expand All @@ -1444,6 +1521,9 @@
if (!text) { logHm(`message_end: assistant message had no text blocks, skipping`); return; }
const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
const dirRes = applyDirConfig(creds, cwd);
if (!dirRes.collect) { logHm(`message_end: capture disabled for cwd=${cwd} via .hivemind`); return; }
creds = dirRes.creds;
try {
await writeSessionRow(creds, sessionId, "pi", "message_end", cwd, {
id: crypto.randomUUID(),
Expand All @@ -1461,11 +1541,14 @@
pi.on("session_shutdown", async (_event: any, ctx: any) => {
logHm(`session_shutdown: fired`);
if (process.env.HIVEMIND_CAPTURE === "false") return;
const creds = loadCreds();
let creds = loadCreds();
if (!creds) { logHm(`session_shutdown: no creds, skipping final summary`); return; }
const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
if (!sessionId) { logHm(`session_shutdown: no sessionId, skipping final summary`); return; }
const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
const dirRes = applyDirConfig(creds, cwd);
if (!dirRes.collect) { logHm(`session_shutdown: capture disabled for cwd=${cwd} via .hivemind`); return; }
creds = dirRes.creds;
// Always spawn for "final" — but the lock check inside spawnWikiWorker
// skips if a periodic worker is mid-flight. Non-fatal either way.
spawnWikiWorker(creds, sessionId, cwd, "final");
Expand Down
7 changes: 6 additions & 1 deletion src/commands/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,9 @@ export async function runBuildCommand(args: string[]): Promise<void> {
// logs and returns; the local snapshot is the source of truth. Skips
// silently when not authenticated (loadConfig returns null).
// worktreeId already computed above for the writeSnapshot call.
const pushOutcome = await pushSnapshot(snapshot, worktreeId);
// Pass the resolved build cwd so `.hivemind` resolves against the target
// tree (honors `--cwd`), not the process's invocation directory.
const pushOutcome = await pushSnapshot(snapshot, worktreeId, { cwd });
switch (pushOutcome.kind) {
case "inserted":
console.log(`Cloud: pushed to codebase table (commit ${pushOutcome.commitSha.slice(0, 7)})`);
Expand All @@ -559,6 +561,9 @@ export async function runBuildCommand(args: string[]): Promise<void> {
case "skipped-disabled":
console.log(`Cloud: skipped (HIVEMIND_GRAPH_PUSH=0)`);
break;
case "skipped-collect-disabled":
console.log(`Cloud: skipped (.hivemind collect:false for this directory)`);
break;
case "drift":
console.warn(`Cloud: DRIFT — commit ${pushOutcome.commitSha.slice(0, 7)} is in cloud with`);
console.warn(` sha256=${pushOutcome.cloudSha256.slice(0, 12)}... but local rebuild produced`);
Expand Down
Loading
Loading