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
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,9 @@ This plugin captures session activity and stores it in your Deeplake workspace:

## 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.
The variables above set **one global identity** for the whole machine. A `.hivemind` file lets a specific directory tree override that: either **route** it to a different org/workspace, or **opt out** of capture entirely.

Routing is symmetric — a routed directory both writes its traces to that workspace **and reads memory from it**. Sessions started under it search, recall, and browse `~/.deeplake/memory` in the routed workspace, and `hivemind whoami` reports it.

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

Expand All @@ -341,22 +343,29 @@ Drop a `.hivemind` JSON file at the root of the tree you want to configure:

| Field | Effect |
|---------------|-------------------------------------------------------------------------------|
| `orgId` | Route captured traces from this tree to this org. |
| `orgId` | Route this tree to this org — captured traces **and** memory reads. |
| `workspaceId` | Route to this workspace. |
| `collect` | `false` → **never** capture traces from this tree. |
| `collect` | `false` → **never** capture traces from this tree. Reads still route. |

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

**Two common recipes:**
`orgId` / `workspaceId` are **identity** (they apply to reads and writes alike); `collect` is a **capture switch** (writes only). The two are independent, which is what makes the read-only recipe below work.

**Three common recipes:**

```jsonc
// route this repo's traces to a client org/workspace
// route this repo to a client org/workspace — reads and writes both land there
{ "orgId": "acme-corp", "workspaceId": "client-work" }

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

// read a shared workspace's memory, but never write to it
{ "workspaceId": "client-work", "collect": false }
```

Routing never carries a token — auth stays in `~/.deeplake/credentials.json`, so a `.hivemind` only ever takes effect against orgs your existing login already authorizes. An `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` set in your environment **wins over** a `.hivemind` for that field; `hivemind whoami` discloses which one is in effect.

### Committed vs local

Two filenames are recognized, mirroring the `.env` / `.env.local` convention every dev already knows:
Expand Down
10 changes: 7 additions & 3 deletions src/commands/auth-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
inviteMember, listMembers, removeMember,
} from "./auth.js";
import { sessionPrune } from "./session-prune.js";
import { loadConfig } from "../config.js";
import { renderWhoami } from "./whoami.js";

/**
* Dispatch one auth subcommand.
Expand All @@ -48,9 +50,11 @@ export async function runAuthCommand(args: string[]): Promise<void> {

case "whoami": {
if (!creds) { console.log("Not logged in. Run: hivemind login"); break; }
console.log(`User org: ${creds.orgName ?? creds.orgId}`);
console.log(`Workspace: ${creds.workspaceId ?? "default"}`);
console.log(`API: ${creds.apiUrl ?? "https://api.deeplake.ai"}`);
// Report the EFFECTIVE identity for the cwd, not the raw stored creds:
// env vars and a per-directory `.hivemind` both override them, and this
// is the surface users (and agents) ask "what am I connected to?".
// Reading creds directly here made `whoami` misreport under either.
console.log(renderWhoami(loadConfig(), creds, process.cwd()));
break;
}

Expand Down
63 changes: 63 additions & 0 deletions src/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* `hivemind whoami` rendering.
*
* Reports the EFFECTIVE identity for a given cwd — the one capture and memory
* search actually use — rather than the raw contents of
* `~/.deeplake/credentials.json`. Two things override the stored creds:
*
* - `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` in the environment
* - the nearest `.hivemind` for this directory (see src/dir-config.ts)
*
* Whenever the effective identity differs from what's stored, the reason and
* the stored values are both disclosed — a user asking "what am I connected
* to?" must never be told a value that isn't the one in use.
*/

import type { Config } from "../config.js";
import type { Credentials } from "./auth.js";
import { resolveDirConfig } from "../dir-config.js";

const DEFAULT_API = "https://api.deeplake.ai";

export function renderWhoami(config: Config | null, creds: Credentials, cwd: string): string {
const storedOrg = creds.orgName ?? creds.orgId;
const storedWs = creds.workspaceId ?? "default";

// No usable Config (no token/orgId resolvable) — report what's stored.
if (!config) {
return [
`User org: ${storedOrg}`,
`Workspace: ${storedWs}`,
`API: ${creds.apiUrl ?? DEFAULT_API}`,
].join("\n");
}

const res = resolveDirConfig(config, cwd);
const eff = res.config;

// Attribute each override precisely: `config` has already folded the env in,
// so a diff against `creds` is the env's doing, and a diff between `eff` and
// `config` is the .hivemind's.
const envMoved = config.orgId !== creds.orgId || config.workspaceId !== storedWs;
const dirMoved = !!res.found &&
(eff.orgId !== config.orgId || eff.workspaceId !== config.workspaceId);

const lines = [
`User org: ${eff.orgName ?? eff.orgId}`,
`Workspace: ${eff.workspaceId}`,
`API: ${eff.apiUrl}`,
Comment on lines +38 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

An environment-selected org can be printed with the stored org’s name. orgId reflects HIVEMIND_ORG_ID, while orgName can still originate from credentials.

  • src/commands/whoami.ts#L38-L48: display the effective org ID when the environment changed the organization.
  • tests/shared/dir-config-read-routing.test.ts#L144-L149: add a concrete HIVEMIND_ORG_ID assertion.
📍 Affects 2 files
  • src/commands/whoami.ts#L38-L48 (this comment)
  • tests/shared/dir-config-read-routing.test.ts#L144-L149
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/whoami.ts` around lines 38 - 48, The whoami output can pair an
environment-selected org ID with a stale credential-derived org name. In
src/commands/whoami.ts lines 38-48, update the org display in the lines array to
show the effective org ID whenever the environment changed the organization,
using envMoved while preserving the existing name display otherwise. In
tests/shared/dir-config-read-routing.test.ts lines 144-149, add a concrete
HIVEMIND_ORG_ID assertion covering this behavior.

];

const notes: string[] = [];
if (dirMoved) notes.push(`Routed by ${res.found?.path}`);
if (envMoved) notes.push("Overridden by HIVEMIND_* environment variables");
if (notes.length) {
notes.push(`Stored identity: ${storedOrg} / ${storedWs}`);
}
if (res.found && !res.collect) {
notes.push(`Capture: disabled for this directory by ${res.found.path}`);
}
if (notes.length) lines.push("", ...notes);

return lines.join("\n");
}
19 changes: 13 additions & 6 deletions src/dir-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,18 @@ export interface ResolvedDirConfig {

/**
* Overlay the nearest `.hivemind` onto `base` for a session in `cwd`.
* `collect: false` suppresses capture; org/workspace fields (when present)
* redirect it. Omitted fields fall back to the global identity in `base`.
*
* The two concerns are INDEPENDENT:
* - `orgId` / `workspaceId` are IDENTITY — they apply to reads (memory
* search, recall, the VFS) as well as capture. Omitted fields fall back to
* the global identity in `base`.
* - `collect` is the CAPTURE switch — writes only. It never suppresses the
* identity overlay, so `{ "collect": false, "workspaceId": "x" }` reads
* from `x` while writing nothing. (Reads are still authorized by the
* caller's existing token; the API rejects anything it doesn't grant.)
*
* Callers on the capture path must therefore gate on `collect`; callers on a
* read path use `config` unconditionally.
*
* Precedence follows the conventional `env > config-file > stored-creds` order:
* an explicitly-set `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` LOCKS that field
Expand All @@ -125,9 +135,6 @@ export function resolveDirConfig(
const found = findDirConfig(cwd);
if (!found) return { config: base, collect: true, found: null };

if (found.raw.collect === false) {
return { config: base, collect: false, found };
}
const orgLocked = !!(envOverride ? envOverride.HIVEMIND_ORG_ID : process.env.HIVEMIND_ORG_ID);
const wsLocked = !!(envOverride ? envOverride.HIVEMIND_WORKSPACE_ID : process.env.HIVEMIND_WORKSPACE_ID);
const config: Config = {
Expand All @@ -136,5 +143,5 @@ export function resolveDirConfig(
orgName: orgLocked ? base.orgName : (found.raw.orgName ?? found.raw.orgId ?? base.orgName),
workspaceId: wsLocked ? base.workspaceId : (found.raw.workspaceId ?? base.workspaceId),
};
return { config, collect: true, found };
return { config, collect: found.raw.collect !== false, found };
}
11 changes: 9 additions & 2 deletions src/hooks/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { join, dirname, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { readStdin } from "../utils/stdin.js";
import { loadConfig } from "../config.js";
import { resolveDirConfig } from "../dir-config.js";
import { armSkillOptOnSkillUse } from "./shared/skillopt-hook.js";
import { DeeplakeApi } from "../deeplake-api.js";
import { sqlLike } from "../utils/sql.js";
Expand Down Expand Up @@ -266,7 +267,7 @@ interface ClaudePreToolDeps {

export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreToolDeps = {}): Promise<ClaudePreToolDecision | null> {
const {
config = loadConfig(),
config: baseConfig = loadConfig(),
createApi = (table, activeConfig) => new DeeplakeApi(
activeConfig.token,
activeConfig.apiUrl,
Expand Down Expand Up @@ -322,7 +323,13 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT
// unreachable. Do NOT return null here — that hands the original command to
// the host shell. Return the retry guidance instead so the command never
// touches the real filesystem.
if (!config) return buildRetryGuidanceDecision(input.tool_name);
if (!baseConfig) return buildRetryGuidanceDecision(input.tool_name);

// Reads are routed by the nearest `.hivemind` exactly like capture is: a
// directory pinned to another org/workspace must SEE that workspace's memory,
// not the global one. `collect` is a capture switch and deliberately does not
// gate reads (see src/dir-config.ts).
const config = resolveDirConfig(baseConfig, input.cwd ?? process.cwd()).config;
Comment on lines +328 to +332

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Directory routing does not isolate the session index cache. The effective identity changes by cwd, but /index.md remains cached only by session.

  • src/hooks/pre-tool-use.ts#L328-L332: namespace cached indexes by session, organization, and workspace.
  • tests/shared/dir-config-read-routing.test.ts#L52-L74: test two routed reads in one session using a stateful cache.
📍 Affects 2 files
  • src/hooks/pre-tool-use.ts#L328-L332 (this comment)
  • tests/shared/dir-config-read-routing.test.ts#L52-L74
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/pre-tool-use.ts` around lines 328 - 332, Namespace the cached index
used by the read-routing flow around resolveDirConfig by session, organization,
and workspace rather than session alone, using the effective identity derived
from the requested cwd. Add coverage in
tests/shared/dir-config-read-routing.test.ts lines 52-74 that performs two
routed reads in one session with a stateful cache and verifies each
organization/workspace receives its own index.


const table = process.env["HIVEMIND_TABLE"] ?? "memory";
const sessionsTable = process.env["HIVEMIND_SESSIONS_TABLE"] ?? "sessions";
Expand Down
8 changes: 6 additions & 2 deletions src/hooks/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

import { readStdin } from "../utils/stdin.js";
import { loadConfig } from "../config.js";
import { resolveDirConfig } from "../dir-config.js";
import { DeeplakeApi } from "../deeplake-api.js";
import { EmbedClient } from "../embeddings/client.js";
import { embedSummaryWithWarmup } from "../embeddings/embed-summary.js";
Expand Down Expand Up @@ -198,12 +199,15 @@ async function main(): Promise<void> {
if (!recall) { log(`skip gate=${reason}`); return; }

const session = input.session_id;
const config = loadConfig();
if (!config?.token) {
const baseConfig = loadConfig();
if (!baseConfig?.token) {
log("skip no-config");
recordRecallEvent({ event: "no-config", gate: reason, session });
return;
}
// Route recall by the nearest `.hivemind`, like capture and memory search:
// a routed directory must recall from ITS workspace, not the global one.
const config = resolveDirConfig(baseConfig, input.cwd ?? process.cwd()).config;

// Bound the whole search path so the turn never stalls beyond the budget.
// On timeout we ABORT the controller so the in-flight query is cancelled
Expand Down
12 changes: 9 additions & 3 deletions src/hooks/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,13 +333,19 @@ async function main(): Promise<void> {
// Disclose the EFFECTIVE identity (after any `.hivemind` overlay), so a
// directory that routes elsewhere (or opts out) is never silent.
const effConfig = dirRes?.config ?? baseConfig;
const routed = !!(dirRes?.found && dirRes.collect && baseConfig &&
// NOT gated on `dirRes.collect`: the identity overlay now applies to reads
// whether or not capture is on, so a `collect:false` directory can still be
// routed — and must say so.
const routed = !!(dirRes?.found && baseConfig &&
(dirRes.config.orgId !== baseConfig.orgId || dirRes.config.workspaceId !== baseConfig.workspaceId));
const effOrg = effConfig ? (effConfig.orgName ?? effConfig.orgId) : (creds?.orgName ?? creds?.orgId);
const effWs = effConfig ? effConfig.workspaceId : (creds?.workspaceId ?? "default");
// `routed` covers reads AND capture — both resolve through the same overlay —
// so the disclosure must never imply one moved without the other.
const routedNote = routed ? ` · routed by ${dirRes?.found?.path}` : "";
const identityLine = dirRes && !dirRes.collect
? `Deeplake capture is disabled for this directory (${dirRes.found?.path}); memory search still uses org: ${effOrg}`
: `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routed ? ` · routed by ${dirRes?.found?.path}` : ""}`;
? `Deeplake capture is disabled for this directory (${dirRes.found?.path}); memory search uses org: ${effOrg} (workspace: ${effWs})${routedNote}`
: `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routedNote}`;
const baseContext = creds?.token
? `${resolvedContext}\n\n${identityLine}${updateNotice}`
: `${resolvedContext}\n\nNot logged in to Deeplake; memory search is unavailable this session.${localMinedNote}${updateNotice}`;
Expand Down
9 changes: 7 additions & 2 deletions src/shell/deeplake-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createInterface } from "node:readline";
import { deriveProjectKey } from "../utils/repo-identity.js";
import { Bash } from "just-bash";
import { loadConfig } from "../config.js";
import { resolveDirConfig } from "../dir-config.js";
import { DeeplakeApi } from "../deeplake-api.js";
import { DeeplakeFs } from "./deeplake-fs.js";
import { createGrepCommand } from "./grep-interceptor.js";
Expand All @@ -42,15 +43,19 @@ async function main(): Promise<void> {
delete process.env.HIVEMIND_DEBUG;
}

const config = loadConfig();
if (!config) {
const baseConfig = loadConfig();
if (!baseConfig) {
process.stderr.write(
"Deeplake credentials not found.\n" +
"Set HIVEMIND_TOKEN + HIVEMIND_ORG_ID in environment, or create ~/.deeplake/credentials.json\n"
);
process.exit(1);
}

// The VFS resolves against the nearest `.hivemind` for the invoking cwd, so a
// routed directory browses ITS workspace's files rather than the global one.
const config = resolveDirConfig(baseConfig, process.cwd()).config;

const table = process.env["HIVEMIND_TABLE"] ?? "memory";
const sessionsTable = process.env["HIVEMIND_SESSIONS_TABLE"] ?? "sessions";
const goalsTable = process.env["HIVEMIND_GOALS_TABLE"] ?? config.goalsTableName;
Expand Down
23 changes: 23 additions & 0 deletions tests/claude-code/auth-login-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
*/

const loadCredentialsMock = vi.fn();
const loadConfigMock = vi.fn();
const loginMock = vi.fn();
const saveCredentialsMock = vi.fn();
const deleteCredentialsMock = vi.fn();
Expand Down Expand Up @@ -40,6 +41,13 @@ vi.mock("../../src/commands/auth.js", () => ({
vi.mock("../../src/commands/session-prune.js", () => ({
sessionPrune: (...a: unknown[]) => sessionPruneMock(...a),
}));
// `whoami` reports the EFFECTIVE identity, so it resolves through loadConfig()
// (which folds in HIVEMIND_* env vars) rather than reading creds directly.
// Mock it at the boundary too — unmocked it would read the developer's real
// ~/.deeplake/credentials.json and make these assertions machine-dependent.
vi.mock("../../src/config.js", () => ({
loadConfig: (...a: unknown[]) => loadConfigMock(...a),
}));

const validCreds = {
token: "tok",
Expand All @@ -51,8 +59,20 @@ const validCreds = {
savedAt: "2024-01-01",
};

/** What loadConfig() builds from validCreds when no env var overrides apply. */
const validConfig = {
token: "tok",
orgId: "org-1",
orgName: "acme",
userName: "u",
workspaceId: "default",
apiUrl: "https://api.example",
tableName: "memory",
};

beforeEach(() => {
loadCredentialsMock.mockReset().mockReturnValue(validCreds);
loadConfigMock.mockReset().mockReturnValue(validConfig);
loginMock.mockReset().mockResolvedValue(undefined);
saveCredentialsMock.mockReset();
deleteCredentialsMock.mockReset().mockReturnValue(true);
Expand Down Expand Up @@ -114,7 +134,10 @@ describe("runAuthCommand — whoami", () => {
});

it("falls back to orgId when orgName is missing", async () => {
// loadConfig() owns this fallback (orgName: creds?.orgName ?? orgId), so a
// creds row without orgName reaches whoami as a config carrying orgId.
loadCredentialsMock.mockReturnValue({ ...validCreds, orgName: undefined });
loadConfigMock.mockReturnValue({ ...validConfig, orgName: "org-1" });
await run(["whoami"]);
expect(consoleText()).toContain("User org: org-1");
});
Expand Down
Loading