-
Notifications
You must be signed in to change notification settings - Fork 92
feat: per-directory .hivemind config (route org/workspace, opt out of capture) #302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
62b03bc
feat: per-directory .hivemind config (route org/workspace, opt out of…
khustup2 b5a7f1a
docs: de-em-dash the .hivemind README section
khustup2 0a5169d
refactor: address CodeRabbit review on .hivemind PR
khustup2 e1086f4
feat(pi): honor per-directory .hivemind (route + opt-out) in pi exten…
khustup2 514ab88
fix: openclaw bundle env-harvesting scan + CodeRabbit minors
khustup2 9964668
test: cover dir-gate process.cwd() fallback + lock coverage gate
khustup2 d7ba0c8
test: cover .hivemind branches in cursor + hermes session-start
khustup2 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }; | ||
| } | ||
|
|
||
| // 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). | ||
|
|
@@ -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 warningCode scanning / CodeQL File data in outbound network request Medium
Outbound network request depends on
file data Error loading related location Loading 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 warningCode scanning / CodeQL File data in outbound network request Medium
Outbound network request depends on
file data Error loading related location Loading Outbound network request depends on file data. |
||
|
|
||
| const MEMORY_TABLE = process.env.HIVEMIND_TABLE ?? "memory"; | ||
| const SESSIONS_TABLE = process.env.HIVEMIND_SESSIONS_TABLE ?? "sessions"; | ||
|
|
||
|
|
@@ -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) { | ||
|
|
@@ -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- | ||
|
|
@@ -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 | ||
|
|
@@ -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 }; | ||
| }); | ||
|
|
@@ -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(), | ||
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
@@ -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(), | ||
|
|
@@ -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"); | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.