@deeplake/hivemind 0.6.48 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +244 -20
  4. package/bundle/cli.js +1369 -112
  5. package/codex/bundle/capture.js +546 -96
  6. package/codex/bundle/commands/auth-login.js +290 -81
  7. package/codex/bundle/embeddings/embed-daemon.js +243 -0
  8. package/codex/bundle/pre-tool-use.js +666 -111
  9. package/codex/bundle/session-start-setup.js +231 -64
  10. package/codex/bundle/session-start.js +52 -13
  11. package/codex/bundle/shell/deeplake-shell.js +716 -119
  12. package/codex/bundle/skilify-worker.js +907 -0
  13. package/codex/bundle/stop.js +819 -79
  14. package/codex/bundle/wiki-worker.js +312 -11
  15. package/cursor/bundle/capture.js +1116 -64
  16. package/cursor/bundle/commands/auth-login.js +290 -81
  17. package/cursor/bundle/embeddings/embed-daemon.js +243 -0
  18. package/cursor/bundle/pre-tool-use.js +598 -77
  19. package/cursor/bundle/session-end.js +520 -2
  20. package/cursor/bundle/session-start.js +257 -65
  21. package/cursor/bundle/shell/deeplake-shell.js +716 -119
  22. package/cursor/bundle/skilify-worker.js +907 -0
  23. package/cursor/bundle/wiki-worker.js +571 -0
  24. package/hermes/bundle/capture.js +1119 -65
  25. package/hermes/bundle/commands/auth-login.js +290 -81
  26. package/hermes/bundle/embeddings/embed-daemon.js +243 -0
  27. package/hermes/bundle/pre-tool-use.js +597 -76
  28. package/hermes/bundle/session-end.js +522 -1
  29. package/hermes/bundle/session-start.js +260 -65
  30. package/hermes/bundle/shell/deeplake-shell.js +716 -119
  31. package/hermes/bundle/skilify-worker.js +907 -0
  32. package/hermes/bundle/wiki-worker.js +572 -0
  33. package/mcp/bundle/server.js +290 -75
  34. package/openclaw/dist/chunks/auth-creds-AEKS6D3P.js +14 -0
  35. package/openclaw/dist/chunks/chunk-SRCBBT4H.js +37 -0
  36. package/openclaw/dist/chunks/config-ZLH6JFJS.js +34 -0
  37. package/openclaw/dist/chunks/index-marker-store-PGT5CW6T.js +33 -0
  38. package/openclaw/dist/chunks/setup-config-C35UK4LP.js +114 -0
  39. package/openclaw/dist/index.js +929 -710
  40. package/openclaw/dist/skilify-worker.js +907 -0
  41. package/openclaw/openclaw.plugin.json +1 -1
  42. package/openclaw/package.json +1 -1
  43. package/openclaw/skills/SKILL.md +19 -0
  44. package/package.json +7 -1
  45. package/pi/extension-source/hivemind.ts +603 -22
@@ -24,9 +24,35 @@
24
24
  // available to pi's compiler when this is loaded.
25
25
 
26
26
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
27
- import { readFileSync, existsSync } from "node:fs";
28
- import { homedir } from "node:os";
29
- import { join } from "node:path";
27
+ import {
28
+ readFileSync, existsSync, appendFileSync, mkdirSync, writeFileSync,
29
+ openSync, closeSync, constants as fsConstants,
30
+ } from "node:fs";
31
+ import { homedir, tmpdir } from "node:os";
32
+ import { join, dirname } from "node:path";
33
+ import { connect } from "node:net";
34
+ import { spawn, execSync } from "node:child_process";
35
+ import { createHash } from "node:crypto";
36
+
37
+ // ---------- diagnostic logging --------------------------------------------------
38
+ //
39
+ // The capture path is fully async + swallows errors (writeSessionRow's catch
40
+ // is intentionally non-fatal, so a transient deeplake outage never breaks pi).
41
+ // That means a buggy extension is silent: rows just don't appear, with no
42
+ // indication where things went wrong. When HIVEMIND_DEBUG=1 we dump a
43
+ // breadcrumb to ~/.deeplake/hivemind-pi.log at every meaningful step so the
44
+ // failure mode is observable. Off by default to keep `pi` quiet for normal
45
+ // users.
46
+
47
+ const LOG_PATH = join(homedir(), ".deeplake", "hivemind-pi.log");
48
+
49
+ function logHm(msg: string): void {
50
+ if (process.env.HIVEMIND_DEBUG !== "1") return;
51
+ try {
52
+ mkdirSync(dirname(LOG_PATH), { recursive: true });
53
+ appendFileSync(LOG_PATH, `${new Date().toISOString()} [pi] ${msg}\n`);
54
+ } catch { /* logging must never break the agent */ }
55
+ }
30
56
 
31
57
  // ---------- credentials / config -----------------------------------------------
32
58
 
@@ -111,6 +137,417 @@ async function dlQuery(creds: Creds, sql: string): Promise<unknown[]> {
111
137
  return json.rows.map((r) => Object.fromEntries(json.columns!.map((c, i) => [c, r[i]])));
112
138
  }
113
139
 
140
+ // ---------- embedding client (inline; reuses the shared daemon) ----------------
141
+ //
142
+ // Pi avoids importing EmbedClient (which is bundled into other agents but
143
+ // here would break the "raw .ts, zero deps" promise of pi extensions).
144
+ // Instead we open a Unix socket directly to the daemon at the same well-known
145
+ // path EmbedClient uses. If the socket isn't there yet, we spawn the
146
+ // canonical daemon at ~/.hivemind/embed-deps/embed-daemon.js (deposited by
147
+ // `hivemind embeddings install`) and wait for it to listen, mirroring the
148
+ // auto-spawn-on-miss logic in src/embeddings/client.ts. Subsequent agents
149
+ // (codex, CC, cursor, hermes, …) connect to the SAME daemon — pi pays the
150
+ // cold-start cost only when it's the first user on the box.
151
+ //
152
+ // Graceful fallback: any failure → return null → caller writes NULL into
153
+ // message_embedding. Embedding is never on the critical path.
154
+
155
+ const EMBED_DAEMON_ENTRY = join(homedir(), ".hivemind", "embed-deps", "embed-daemon.js");
156
+ const EMBED_SOCKET_PATH = (() => {
157
+ const uid = typeof process.getuid === "function" ? String(process.getuid()) : (process.env.USER ?? "default");
158
+ return `/tmp/hivemind-embed-${uid}.sock`;
159
+ })();
160
+
161
+ function tryEmbedOverSocket(text: string, kind: "document" | "query"): Promise<number[] | null> {
162
+ return new Promise((resolve) => {
163
+ let resolved = false;
164
+ const settle = (v: number[] | null) => { if (!resolved) { resolved = true; resolve(v); } };
165
+ const sock = connect(EMBED_SOCKET_PATH);
166
+ let buf = "";
167
+ const timer = setTimeout(() => { sock.destroy(); settle(null); }, 5000);
168
+ sock.on("connect", () => {
169
+ // Protocol shape comes from src/embeddings/protocol.ts: {op, id, kind, text}.
170
+ // id is a string ("1"), not a number, and the verb field is "op" not "type".
171
+ sock.write(JSON.stringify({ op: "embed", id: "1", kind, text }) + "\n");
172
+ });
173
+ sock.on("data", (chunk: Buffer) => {
174
+ buf += chunk.toString("utf-8");
175
+ const nl = buf.indexOf("\n");
176
+ if (nl !== -1) {
177
+ clearTimeout(timer);
178
+ try {
179
+ const resp = JSON.parse(buf.slice(0, nl));
180
+ settle(Array.isArray(resp.embedding) ? resp.embedding : null);
181
+ } catch { settle(null); }
182
+ sock.destroy();
183
+ }
184
+ });
185
+ sock.on("error", () => { clearTimeout(timer); settle(null); });
186
+ sock.on("close", () => { clearTimeout(timer); settle(null); });
187
+ });
188
+ }
189
+
190
+ // ---------- summary state + wiki-worker spawn ---------------------------------
191
+ //
192
+ // Mirror of src/hooks/summary-state.ts (same dir, same JSON shape, shared
193
+ // across CC/codex/cursor/hermes — session ids are UUIDs so collisions are
194
+ // impossible). The pi extension increments totalCount on every captured
195
+ // event and spawns the bundled wiki-worker (see pi/bundle/wiki-worker.js)
196
+ // when the threshold is hit. The worker, after generating the summary,
197
+ // calls finalizeSummary() / releaseLock() against this same dir. So the
198
+ // extension and the worker share state.
199
+
200
+ const SUMMARY_STATE_DIR = join(homedir(), ".claude", "hooks", "summary-state");
201
+ const PI_WIKI_WORKER_PATH = join(homedir(), ".pi", "agent", "hivemind", "wiki-worker.js");
202
+ // Skilify worker installed alongside wiki-worker by `hivemind pi install`.
203
+ // Spawned on session_shutdown to mine reusable Claude skills from the just-
204
+ // finished session. Same shared bundle used by CC/Codex/Cursor/Hermes.
205
+ const PI_SKILIFY_WORKER_PATH = join(homedir(), ".pi", "agent", "hivemind", "skilify-worker.js");
206
+ const SKILIFY_STATE_DIR = join(homedir(), ".deeplake", "state", "skilify");
207
+
208
+ interface SummaryState {
209
+ lastSummaryAt: number;
210
+ lastSummaryCount: number;
211
+ totalCount: number;
212
+ }
213
+ interface SummaryConfig {
214
+ everyNMessages: number;
215
+ everyHours: number;
216
+ }
217
+
218
+ function summaryStatePath(sessionId: string): string {
219
+ return join(SUMMARY_STATE_DIR, `${sessionId}.json`);
220
+ }
221
+ function summaryLockPath(sessionId: string): string {
222
+ return join(SUMMARY_STATE_DIR, `${sessionId}.lock`);
223
+ }
224
+
225
+ function loadSummaryConfig(): SummaryConfig {
226
+ const n = Number(process.env.HIVEMIND_SUMMARY_EVERY_N_MSGS ?? "");
227
+ const h = Number(process.env.HIVEMIND_SUMMARY_EVERY_HOURS ?? "");
228
+ return {
229
+ everyNMessages: Number.isInteger(n) && n > 0 ? n : 50,
230
+ everyHours: Number.isFinite(h) && h > 0 ? h : 2,
231
+ };
232
+ }
233
+
234
+ // Mirrors src/hooks/summary-state.ts — the very first summary fires at
235
+ // totalCount=10 (vs the steady-state N=50) so a fresh chat gets indexed
236
+ // quickly without waiting for ~50 messages.
237
+ const FIRST_SUMMARY_AT = 10;
238
+
239
+ function readSummaryState(sessionId: string): SummaryState | null {
240
+ try {
241
+ const p = summaryStatePath(sessionId);
242
+ if (!existsSync(p)) return null;
243
+ const raw = JSON.parse(readFileSync(p, "utf-8"));
244
+ return {
245
+ lastSummaryAt: Number(raw.lastSummaryAt) || 0,
246
+ lastSummaryCount: Number(raw.lastSummaryCount) || 0,
247
+ totalCount: Number(raw.totalCount) || 0,
248
+ };
249
+ } catch { return null; }
250
+ }
251
+
252
+ function writeSummaryState(sessionId: string, state: SummaryState): void {
253
+ try {
254
+ mkdirSync(SUMMARY_STATE_DIR, { recursive: true });
255
+ writeFileSync(summaryStatePath(sessionId), JSON.stringify(state));
256
+ } catch { /* non-fatal */ }
257
+ }
258
+
259
+ function bumpCounter(sessionId: string): SummaryState {
260
+ const cur = readSummaryState(sessionId) ?? { lastSummaryAt: 0, lastSummaryCount: 0, totalCount: 0 };
261
+ cur.totalCount += 1;
262
+ writeSummaryState(sessionId, cur);
263
+ return cur;
264
+ }
265
+
266
+ function shouldTriggerNow(state: SummaryState, cfg: SummaryConfig): boolean {
267
+ const msgsSince = state.totalCount - state.lastSummaryCount;
268
+ // First-chat trigger: index a fresh session quickly (10 events) instead of
269
+ // waiting until N=50. Mirrors summary-state.ts in CC/codex.
270
+ if (state.lastSummaryCount === 0 && state.totalCount >= FIRST_SUMMARY_AT) return true;
271
+ if (msgsSince >= cfg.everyNMessages) return true;
272
+ if (msgsSince > 0 && state.lastSummaryAt > 0
273
+ && Date.now() - state.lastSummaryAt >= cfg.everyHours * 3600 * 1000) return true;
274
+ return false;
275
+ }
276
+
277
+ function tryAcquireSummaryLock(sessionId: string): boolean {
278
+ try {
279
+ mkdirSync(SUMMARY_STATE_DIR, { recursive: true });
280
+ const fd = openSync(summaryLockPath(sessionId),
281
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
282
+ closeSync(fd);
283
+ return true;
284
+ } catch { return false; }
285
+ }
286
+
287
+ function findPiBin(): string {
288
+ try {
289
+ const out = execSync("which pi 2>/dev/null", { encoding: "utf-8" }).trim();
290
+ if (out) return out;
291
+ } catch { /* fall through */ }
292
+ return "pi";
293
+ }
294
+
295
+ // Same template the CC/codex spawn-wiki-worker.ts ships. Inlined here
296
+ // because the pi extension is raw .ts and can't import it.
297
+ const WIKI_PROMPT_TEMPLATE = `You are building a personal wiki from a coding session. Your goal is to extract every piece of knowledge — entities, decisions, relationships, and facts — into a structured, searchable wiki entry.
298
+
299
+ SESSION JSONL path: __JSONL__
300
+ SUMMARY FILE to write: __SUMMARY__
301
+ SESSION ID: __SESSION_ID__
302
+ PROJECT: __PROJECT__
303
+ PREVIOUS JSONL OFFSET (lines already processed): __PREV_OFFSET__
304
+ CURRENT JSONL LINES: __JSONL_LINES__
305
+
306
+ Steps:
307
+ 1. Read the session JSONL at the path above.
308
+ - If PREVIOUS JSONL OFFSET > 0, this is a resumed session. Read the existing summary file first,
309
+ then focus on lines AFTER the offset for new content. Merge new facts into the existing summary.
310
+ - If offset is 0, generate from scratch.
311
+
312
+ 2. Write the summary file at the path above with this EXACT format:
313
+
314
+ # Session __SESSION_ID__
315
+ - **Source**: __JSONL_SERVER_PATH__
316
+ - **Started**: <extract from JSONL>
317
+ - **Ended**: <now>
318
+ - **Project**: __PROJECT__
319
+ - **JSONL offset**: __JSONL_LINES__
320
+
321
+ ## What Happened
322
+ <2-3 dense sentences. What was the goal, what was accomplished, what's left.>
323
+
324
+ ## People
325
+ <For each person mentioned: name, role, what they did/said. Format: **Name** — role — action>
326
+
327
+ ## Entities
328
+ <Every named thing: repos, branches, files, APIs, tools, services, tables, features, bugs.
329
+ Format: **entity** (type) — what was done with it, its current state>
330
+
331
+ ## Decisions & Reasoning
332
+ <Every decision made and WHY.>
333
+
334
+ ## Key Facts
335
+ <Bullet list of atomic facts that could answer future questions.>
336
+
337
+ ## Files Modified
338
+ <bullet list: path (new/modified/deleted) — what changed>
339
+
340
+ ## Open Questions / TODO
341
+ <Anything unresolved, blocked, or explicitly deferred>
342
+
343
+ IMPORTANT: Be exhaustive. Extract EVERY entity, decision, and fact.
344
+ PRIVACY: Never include absolute filesystem paths in the summary.
345
+ LENGTH LIMIT: Keep the total summary under 4000 characters.`;
346
+
347
+ function spawnWikiWorker(
348
+ creds: Creds,
349
+ sessionId: string,
350
+ cwd: string,
351
+ reason: "periodic" | "final",
352
+ ): void {
353
+ if (!existsSync(PI_WIKI_WORKER_PATH)) {
354
+ logHm(`spawnWikiWorker(${reason}): no worker at ${PI_WIKI_WORKER_PATH} — install via 'hivemind pi install' or rebuild`);
355
+ return;
356
+ }
357
+ // Periodic: only one in-flight; lock prevents races between events.
358
+ // Final: also takes the lock — if a periodic was mid-flight at session_shutdown,
359
+ // skip the final to avoid two concurrent workers writing back to the same row.
360
+ if (!tryAcquireSummaryLock(sessionId)) {
361
+ logHm(`spawnWikiWorker(${reason}): lock held — skipping (a worker is already running)`);
362
+ return;
363
+ }
364
+ // tmp dir owned by the worker; it removes it on completion.
365
+ const tmpDir = join(tmpdir(), `deeplake-wiki-${sessionId}-${Date.now()}`);
366
+ try { mkdirSync(tmpDir, { recursive: true }); } catch { /* ignore */ }
367
+ const configPath = join(tmpDir, "config.json");
368
+ const project = (cwd ?? "").split("/").pop() || "unknown";
369
+ const config = {
370
+ apiUrl: creds.apiUrl,
371
+ token: creds.token,
372
+ orgId: creds.orgId,
373
+ workspaceId: creds.workspaceId,
374
+ memoryTable: MEMORY_TABLE,
375
+ sessionsTable: SESSIONS_TABLE,
376
+ sessionId,
377
+ userName: creds.userName,
378
+ project,
379
+ tmpDir,
380
+ piBin: findPiBin(),
381
+ piProvider: process.env.HIVEMIND_PI_PROVIDER ?? "google",
382
+ piModel: process.env.HIVEMIND_PI_MODEL ?? "gemini-2.5-flash",
383
+ wikiLog: join(homedir(), ".deeplake", "hivemind-pi.log"),
384
+ hooksDir: join(homedir(), ".pi", "agent", "hivemind"),
385
+ promptTemplate: WIKI_PROMPT_TEMPLATE,
386
+ };
387
+ try { writeFileSync(configPath, JSON.stringify(config)); }
388
+ catch (e: any) { logHm(`spawnWikiWorker(${reason}): writeFileSync failed: ${e?.message ?? e}`); return; }
389
+ logHm(`spawnWikiWorker(${reason}): spawning ${PI_WIKI_WORKER_PATH} session=${sessionId} provider=${config.piProvider} model=${config.piModel}`);
390
+ try {
391
+ spawn(process.execPath, [PI_WIKI_WORKER_PATH, configPath], {
392
+ detached: true,
393
+ stdio: "ignore",
394
+ env: { ...process.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" },
395
+ }).unref();
396
+ } catch (e: any) {
397
+ logHm(`spawnWikiWorker(${reason}): spawn failed: ${e?.message ?? e}`);
398
+ }
399
+ }
400
+
401
+ // ---------- skilify worker spawn ---------------------------------------------
402
+ //
403
+ // Mirror of src/skilify/spawn-skilify-worker.ts and src/skilify/triggers.ts —
404
+ // inlined here because pi/extension-source/hivemind.ts is shipped as raw .ts
405
+ // with zero non-builtin runtime dependencies (pi compiles + loads it at
406
+ // extension-load time). The shared TypeScript modules under src/skilify/
407
+ // can't be imported from this file.
408
+ //
409
+ // The skilify worker mines the just-finished session for reusable Claude
410
+ // skills, gates each cluster via a model call, and writes SKILL.md files +
411
+ // rows in the org's skills Deeplake table.
412
+
413
+ /** Stable project key — sha1(cwd) truncated, mirrors src/skilify/state.ts deriveProjectKey. */
414
+ function deriveSkilifyProjectKey(cwd: string): { key: string; project: string } {
415
+ const project = (cwd ?? "").split("/").pop() || "unknown";
416
+ // Pi's extension can't easily run `git config` synchronously here; use cwd
417
+ // as the signature. Two checkouts of the same repo at different paths get
418
+ // different project_keys, which is acceptable for pi (the other agents
419
+ // hash the git remote when available; pi falls back to cwd-only).
420
+ const key = createHash("sha1").update(cwd ?? "").digest("hex").slice(0, 16);
421
+ return { key, project };
422
+ }
423
+
424
+ function spawnPiSkilifyWorker(creds: Creds, sessionId: string, cwd: string): void {
425
+ if (!existsSync(PI_SKILIFY_WORKER_PATH)) {
426
+ logHm(`spawnPiSkilifyWorker: no worker at ${PI_SKILIFY_WORKER_PATH} — install via 'hivemind pi install' or rebuild`);
427
+ return;
428
+ }
429
+ const { key: projectKey, project } = deriveSkilifyProjectKey(cwd);
430
+
431
+ // No spawn-side lock: the worker itself acquires `<projectKey>.lock` via
432
+ // src/skilify/state.ts:tryAcquireWorkerLock and releases it on exit (with
433
+ // a 10-min stale-lock fallback). A spawn-side lock here would create a
434
+ // SECOND lockfile (`<projectKey>.worker.lock`) that nobody releases,
435
+ // permanently blocking subsequent spawns from the same Pi runtime
436
+ // instance. Let the worker's own lock be the single source of truth;
437
+ // back-to-back spawns where a worker is in flight cost only one extra
438
+ // node cold-start (~50ms) before the worker self-skips on the lock.
439
+
440
+ const tmpDir = join(tmpdir(), `deeplake-skilify-${projectKey}-${Date.now()}`);
441
+ try { mkdirSync(tmpDir, { recursive: true, mode: 0o700 }); }
442
+ catch (e: any) { logHm(`spawnPiSkilifyWorker: mkdir failed: ${e?.message ?? e}`); return; }
443
+ const configPath = join(tmpDir, "config.json");
444
+
445
+ // Same shape the spawn-skilify-worker.ts module writes for the other agents.
446
+ // Defaults match scope-config.ts: scope=me, install=project, no team list.
447
+ // Pi-specific: no per-agent gate binary (`gateBin: null`) — the worker's
448
+ // gate-runner falls back to its agent dispatch which for `agent: "pi"`
449
+ // resolves to the `pi --print` invocation we'd want for consistency.
450
+ const config = {
451
+ apiUrl: creds.apiUrl,
452
+ token: creds.token,
453
+ orgId: creds.orgId,
454
+ workspaceId: creds.workspaceId,
455
+ sessionsTable: SESSIONS_TABLE,
456
+ skillsTable: process.env.HIVEMIND_SKILLS_TABLE || "skills",
457
+ userName: creds.userName,
458
+ cwd,
459
+ projectKey,
460
+ project,
461
+ agent: "pi",
462
+ scope: "me" as const,
463
+ team: [] as string[],
464
+ install: "project" as const,
465
+ tmpDir,
466
+ gateBin: findPiBin(),
467
+ cursorModel: process.env.HIVEMIND_CURSOR_MODEL,
468
+ hermesProvider: process.env.HIVEMIND_HERMES_PROVIDER,
469
+ hermesModel: process.env.HIVEMIND_HERMES_MODEL,
470
+ // pi-specific gate args — match wikiWorker config defaults (google + gemini-2.5-flash)
471
+ piProvider: process.env.HIVEMIND_PI_PROVIDER ?? "google",
472
+ piModel: process.env.HIVEMIND_PI_MODEL ?? "gemini-2.5-flash",
473
+ skilifyLog: join(homedir(), ".deeplake", "hivemind-pi-skilify.log"),
474
+ currentSessionId: sessionId,
475
+ };
476
+ try { writeFileSync(configPath, JSON.stringify(config), { mode: 0o600 }); }
477
+ catch (e: any) { logHm(`spawnPiSkilifyWorker: config write failed: ${e?.message ?? e}`); return; }
478
+
479
+ logHm(`spawnPiSkilifyWorker: spawning ${PI_SKILIFY_WORKER_PATH} project=${project} key=${projectKey} session=${sessionId}`);
480
+ try {
481
+ spawn(process.execPath, [PI_SKILIFY_WORKER_PATH, configPath], {
482
+ detached: true,
483
+ stdio: "ignore",
484
+ env: { ...process.env, HIVEMIND_SKILIFY_WORKER: "1", HIVEMIND_CAPTURE: "false" },
485
+ }).unref();
486
+ } catch (e: any) {
487
+ logHm(`spawnPiSkilifyWorker: spawn failed: ${e?.message ?? e}`);
488
+ }
489
+ }
490
+
491
+ function maybeTriggerPeriodicSummary(creds: Creds, sessionId: string, cwd: string): void {
492
+ if (process.env.HIVEMIND_CAPTURE === "false") return;
493
+ const state = bumpCounter(sessionId);
494
+ const cfg = loadSummaryConfig();
495
+ if (!shouldTriggerNow(state, cfg)) return;
496
+ logHm(`periodic threshold hit (total=${state.totalCount}, since=${state.totalCount - state.lastSummaryCount}, N=${cfg.everyNMessages}, hours=${cfg.everyHours})`);
497
+ spawnWikiWorker(creds, sessionId, cwd, "periodic");
498
+ }
499
+
500
+ async function embed(text: string): Promise<number[] | null> {
501
+ if (process.env.HIVEMIND_EMBEDDINGS === "false") {
502
+ logHm(`embed: skipped (HIVEMIND_EMBEDDINGS=false)`);
503
+ return null;
504
+ }
505
+ if (!text || text.length === 0) {
506
+ logHm(`embed: skipped (empty text)`);
507
+ return null;
508
+ }
509
+ // 1) socket already up (another agent or us in a previous turn) → fast path
510
+ let v = await tryEmbedOverSocket(text, "document");
511
+ if (v !== null) {
512
+ logHm(`embed: ok via existing socket (dims=${v.length})`);
513
+ return v;
514
+ }
515
+ // 2) no daemon binary deposited → fallback NULL
516
+ if (!existsSync(EMBED_DAEMON_ENTRY)) {
517
+ logHm(`embed: no daemon at ${EMBED_DAEMON_ENTRY} — run 'hivemind embeddings install'`);
518
+ return null;
519
+ }
520
+ // 3) spawn the canonical daemon detached; daemon's own pidfile lock guards
521
+ // against double-spawn if multiple pi turns race.
522
+ logHm(`embed: spawning daemon at ${EMBED_DAEMON_ENTRY}`);
523
+ try {
524
+ spawn(process.execPath, [EMBED_DAEMON_ENTRY], { detached: true, stdio: "ignore" }).unref();
525
+ } catch (e: any) {
526
+ logHm(`embed: spawn failed: ${e?.message ?? e}`);
527
+ return null;
528
+ }
529
+ // 4) poll for the socket up to ~5s, then retry the embed once
530
+ for (let i = 0; i < 25; i++) {
531
+ await new Promise(r => setTimeout(r, 200));
532
+ if (existsSync(EMBED_SOCKET_PATH)) {
533
+ v = await tryEmbedOverSocket(text, "document");
534
+ if (v !== null) {
535
+ logHm(`embed: ok after spawn (dims=${v.length}, polls=${i + 1})`);
536
+ return v;
537
+ }
538
+ }
539
+ }
540
+ logHm(`embed: timed out after spawn (5s)`);
541
+ return null;
542
+ }
543
+
544
+ function embedSqlLiteral(emb: number[] | null): string {
545
+ if (!emb || emb.length === 0) return "NULL";
546
+ // FLOAT4[] literal. Numbers serialize without quotes; emb is a plain
547
+ // number[] from the daemon so JSON-style join is safe.
548
+ return `ARRAY[${emb.join(",")}]::FLOAT4[]`;
549
+ }
550
+
114
551
  // ---------- session-row writer -------------------------------------------------
115
552
 
116
553
  function buildSessionPath(creds: Creds, sessionId: string): string {
@@ -118,6 +555,13 @@ function buildSessionPath(creds: Creds, sessionId: string): string {
118
555
  return `/sessions/${creds.userName}/${filename}`;
119
556
  }
120
557
 
558
+ // Deeplake quirk: CREATE TABLE IF NOT EXISTS returns 200 before the table
559
+ // is queryable for INSERTs (the propagation can take 30+ seconds on a fresh
560
+ // table). Other agents don't hit this in steady state because they reuse
561
+ // existing tables; pi's e2e tests use fresh timestamped tables every run.
562
+ // Fix: tolerate "Table does not exist" specifically and retry with backoff.
563
+ const INSERT_RETRY_BACKOFFS_MS = [1000, 3000, 8000, 15000];
564
+
121
565
  async function writeSessionRow(
122
566
  creds: Creds,
123
567
  sessionId: string,
@@ -132,11 +576,33 @@ async function writeSessionRow(
132
576
  const projectName = (cwd ?? "").split("/").pop() || "unknown";
133
577
  const line = JSON.stringify(entry);
134
578
  const jsonForSql = sqlJsonb(line);
579
+ logHm(`writeSessionRow: event=${event} session=${sessionId} bytes=${line.length} table=${SESSIONS_TABLE}`);
580
+ const emb = await embed(line);
581
+ logHm(`writeSessionRow: embed=${emb ? `dims=${emb.length}` : "null"}`);
135
582
  const insertSql =
136
- `INSERT INTO "${SESSIONS_TABLE}" (id, path, filename, message, author, size_bytes, project, description, agent, creation_date, last_update_date) ` +
137
- `VALUES ('${crypto.randomUUID()}', '${sqlStr(sessionPath)}', '${sqlStr(filename)}', '${jsonForSql}'::jsonb, '${sqlStr(creds.userName)}', ` +
583
+ `INSERT INTO "${SESSIONS_TABLE}" (id, path, filename, message, message_embedding, author, size_bytes, project, description, agent, creation_date, last_update_date) ` +
584
+ `VALUES ('${crypto.randomUUID()}', '${sqlStr(sessionPath)}', '${sqlStr(filename)}', '${jsonForSql}'::jsonb, ${embedSqlLiteral(emb)}, '${sqlStr(creds.userName)}', ` +
138
585
  `${Buffer.byteLength(line, "utf-8")}, '${sqlStr(projectName)}', '${sqlStr(event)}', '${agent}', '${ts}', '${ts}')`;
139
- await dlQuery(creds, insertSql);
586
+ let lastErr: any = null;
587
+ for (let attempt = 0; attempt <= INSERT_RETRY_BACKOFFS_MS.length; attempt++) {
588
+ try {
589
+ await dlQuery(creds, insertSql);
590
+ logHm(`writeSessionRow: INSERT ok (event=${event}, attempt=${attempt + 1})`);
591
+ return;
592
+ } catch (e: any) {
593
+ lastErr = e;
594
+ const msg = e?.message ?? String(e);
595
+ const isPropagationDelay = /table does not exist|relation .* does not exist/i.test(msg);
596
+ if (!isPropagationDelay || attempt === INSERT_RETRY_BACKOFFS_MS.length) {
597
+ logHm(`writeSessionRow: INSERT FAILED (event=${event}, attempt=${attempt + 1}): ${msg}`);
598
+ throw e;
599
+ }
600
+ const delay = INSERT_RETRY_BACKOFFS_MS[attempt];
601
+ logHm(`writeSessionRow: table not yet visible, retrying in ${delay}ms (attempt=${attempt + 1}/${INSERT_RETRY_BACKOFFS_MS.length + 1})`);
602
+ await new Promise(r => setTimeout(r, delay));
603
+ }
604
+ }
605
+ throw lastErr;
140
606
  }
141
607
 
142
608
  // ---------- search primitive (used by hivemind_search) -------------------------
@@ -171,7 +637,32 @@ Three hivemind tools are registered:
171
637
  hivemind_read { path } read full content at a memory path
172
638
  hivemind_index { prefix?, limit? } list summary entries
173
639
 
174
- Prefer these tools — one call returns ranked hits across all summaries and sessions in a single SQL query. Different paths under /summaries/<username>/ are different users; do NOT merge or alias them. Fall back to grep on ~/.deeplake/memory/ only if tools are unavailable.`;
640
+ Prefer these tools — one call returns ranked hits across all summaries and sessions in a single SQL query. Different paths under /summaries/<username>/ are different users; do NOT merge or alias them. Fall back to grep on ~/.deeplake/memory/ only if tools are unavailable.
641
+
642
+ Organization management — each argument is SEPARATE (do NOT quote subcommands together):
643
+ - hivemind login — SSO login
644
+ - hivemind whoami — show current user/org
645
+ - hivemind org list — list organizations
646
+ - hivemind org switch <name-or-id> — switch organization
647
+ - hivemind workspaces — list workspaces
648
+ - hivemind workspace <id> — switch workspace
649
+ - hivemind invite <email> <ADMIN|WRITE|READ> — invite member (ALWAYS ask user which role before inviting)
650
+ - hivemind members — list members
651
+ - hivemind remove <user-id> — remove member
652
+
653
+ SKILLS (skilify) — mine + share reusable skills across the org. Run these in a terminal (or via shell if available):
654
+ - hivemind skilify — show scope/team/install + per-project state
655
+ - hivemind skilify pull — sync project skills from the org table
656
+ - hivemind skilify pull --user <email> — only that author's skills
657
+ - hivemind skilify pull --users a,b,c — multiple authors (CSV)
658
+ - hivemind skilify pull --all-users — explicit "no author filter"
659
+ - hivemind skilify pull --to project|global — install location
660
+ - hivemind skilify pull --dry-run — preview only
661
+ - hivemind skilify pull --force — overwrite local (creates .bak)
662
+ - hivemind skilify pull <skill-name> — pull only that skill (combines with --user)
663
+ - hivemind skilify scope <me|team|org> — sharing scope for new skills
664
+ - hivemind skilify install <project|global> — default install location
665
+ - hivemind skilify team add|remove|list <name> — manage team list`;
175
666
 
176
667
  export default function hivemindExtension(pi: ExtensionAPI): void {
177
668
  const captureEnabled = process.env.HIVEMIND_CAPTURE !== "false";
@@ -267,7 +758,62 @@ export default function hivemindExtension(pi: ExtensionAPI): void {
267
758
  // themselves don't carry them.
268
759
 
269
760
  pi.on("session_start", async (_event: any, _ctx: any) => {
761
+ logHm(`session_start: fired (capture=${captureEnabled}, embed=${process.env.HIVEMIND_EMBEDDINGS !== "false"}, table=${SESSIONS_TABLE})`);
270
762
  const creds = loadCreds();
763
+ if (!creds) {
764
+ logHm(`session_start: no credentials at ~/.deeplake/credentials.json — capture disabled this session`);
765
+ } else {
766
+ logHm(`session_start: creds org=${creds.orgName ?? creds.orgId} ws=${creds.workspaceId}`);
767
+ }
768
+ if (creds && captureEnabled) {
769
+ // Other agents' session-start hooks create the memory + sessions tables
770
+ // via DeeplakeApi.ensureTable / ensureSessionsTable. The pi extension is
771
+ // standalone (no shared lib import to keep it raw-.ts), so we issue the
772
+ // CREATE TABLE IF NOT EXISTS directly. Schema matches the canonical one
773
+ // in src/deeplake-api.ts so all agents read/write the same shape.
774
+ const memCreate = `CREATE TABLE IF NOT EXISTS "${MEMORY_TABLE}" (` +
775
+ `id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL DEFAULT '', ` +
776
+ `filename TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '', ` +
777
+ `summary_embedding FLOAT4[], author TEXT NOT NULL DEFAULT '', ` +
778
+ `mime_type TEXT NOT NULL DEFAULT 'text/plain', size_bytes BIGINT NOT NULL DEFAULT 0, ` +
779
+ `project TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', ` +
780
+ `agent TEXT NOT NULL DEFAULT '', creation_date TEXT NOT NULL DEFAULT '', ` +
781
+ `last_update_date TEXT NOT NULL DEFAULT ''` +
782
+ `) USING deeplake`;
783
+ const sessCreate = `CREATE TABLE IF NOT EXISTS "${SESSIONS_TABLE}" (` +
784
+ `id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL DEFAULT '', ` +
785
+ `filename TEXT NOT NULL DEFAULT '', message JSONB, message_embedding FLOAT4[], ` +
786
+ `author TEXT NOT NULL DEFAULT '', mime_type TEXT NOT NULL DEFAULT 'application/json', ` +
787
+ `size_bytes BIGINT NOT NULL DEFAULT 0, project TEXT NOT NULL DEFAULT '', ` +
788
+ `description TEXT NOT NULL DEFAULT '', agent TEXT NOT NULL DEFAULT '', ` +
789
+ `creation_date TEXT NOT NULL DEFAULT '', last_update_date TEXT NOT NULL DEFAULT ''` +
790
+ `) USING deeplake`;
791
+ try { await dlQuery(creds, memCreate); logHm(`session_start: memory CREATE TABLE ok (${MEMORY_TABLE})`); }
792
+ catch (e: any) { logHm(`session_start: memory CREATE failed: ${e?.message ?? e}`); }
793
+ try { await dlQuery(creds, sessCreate); logHm(`session_start: sessions CREATE TABLE ok (${SESSIONS_TABLE})`); }
794
+ catch (e: any) { logHm(`session_start: sessions CREATE failed: ${e?.message ?? e}`); }
795
+ // Proactively poll until the sessions table is queryable. CREATE TABLE
796
+ // returns 200 before propagation completes on Deeplake; the first INSERT
797
+ // can otherwise fail with "Table does not exist" for ~30s. Polling here
798
+ // amortises the delay before any event fires.
799
+ const probeSql = `SELECT 1 FROM "${SESSIONS_TABLE}" LIMIT 1`;
800
+ const start = Date.now();
801
+ let visible = false;
802
+ for (let i = 0; i < 30 && !visible; i++) {
803
+ try {
804
+ await dlQuery(creds, probeSql);
805
+ visible = true;
806
+ } catch (e: any) {
807
+ const msg = e?.message ?? String(e);
808
+ if (!/table does not exist|relation .* does not exist/i.test(msg)) {
809
+ logHm(`session_start: probe failed (non-propagation): ${msg}`);
810
+ break;
811
+ }
812
+ await new Promise(r => setTimeout(r, 1000));
813
+ }
814
+ }
815
+ logHm(`session_start: sessions table visible=${visible} (probe took ${Date.now() - start}ms)`);
816
+ }
271
817
  const additional = creds
272
818
  ? `${CONTEXT_PREAMBLE}\nLogged in to Deeplake as org: ${creds.orgName ?? creds.orgId} (workspace: ${creds.workspaceId}).`
273
819
  : `${CONTEXT_PREAMBLE}\nNot logged in to Deeplake. Run \`hivemind login\` to authenticate.`;
@@ -275,12 +821,13 @@ export default function hivemindExtension(pi: ExtensionAPI): void {
275
821
  });
276
822
 
277
823
  pi.on("input", async (event: any, ctx: any) => {
278
- if (!captureEnabled) return;
279
- if (event.source === "extension") return; // skip our own injected inputs
824
+ logHm(`input: fired source=${event?.source ?? "?"}`);
825
+ if (!captureEnabled) { logHm(`input: capture disabled, skipping`); return; }
826
+ if (event.source === "extension") { logHm(`input: extension-injected, skipping`); return; }
280
827
  const creds = loadCreds();
281
- if (!creds) return;
828
+ if (!creds) { logHm(`input: no creds, skipping`); return; }
282
829
  const text = typeof event.text === "string" ? event.text : "";
283
- if (!text) return;
830
+ if (!text) { logHm(`input: empty text, skipping`); return; }
284
831
  const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
285
832
  const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
286
833
  try {
@@ -291,13 +838,17 @@ export default function hivemindExtension(pi: ExtensionAPI): void {
291
838
  content: text,
292
839
  timestamp: new Date().toISOString(),
293
840
  });
294
- } catch { /* non-fatal */ }
841
+ } catch (e: any) {
842
+ logHm(`input: writeSessionRow swallowed: ${e?.message ?? e}`);
843
+ }
844
+ maybeTriggerPeriodicSummary(creds, sessionId, cwd);
295
845
  });
296
846
 
297
847
  pi.on("tool_result", async (event: any, ctx: any) => {
298
- if (!captureEnabled) return;
848
+ logHm(`tool_result: fired tool=${event?.toolName ?? "?"} isError=${event?.isError === true}`);
849
+ if (!captureEnabled) { logHm(`tool_result: capture disabled, skipping`); return; }
299
850
  const creds = loadCreds();
300
- if (!creds) return;
851
+ if (!creds) { logHm(`tool_result: no creds, skipping`); return; }
301
852
  const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
302
853
  const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
303
854
  // event.content is (TextContent | ImageContent)[]; extract text blocks.
@@ -318,24 +869,31 @@ export default function hivemindExtension(pi: ExtensionAPI): void {
318
869
  is_error: event.isError === true,
319
870
  timestamp: new Date().toISOString(),
320
871
  });
321
- } catch { /* non-fatal */ }
872
+ } catch (e: any) {
873
+ logHm(`tool_result: writeSessionRow swallowed: ${e?.message ?? e}`);
874
+ }
875
+ maybeTriggerPeriodicSummary(creds, sessionId, cwd);
322
876
  });
323
877
 
324
878
  pi.on("message_end", async (event: any, ctx: any) => {
325
- if (!captureEnabled) return;
879
+ logHm(`message_end: fired role=${event?.message?.role ?? "?"}`);
880
+ if (!captureEnabled) { logHm(`message_end: capture disabled, skipping`); return; }
326
881
  const creds = loadCreds();
327
- if (!creds) return;
882
+ if (!creds) { logHm(`message_end: no creds, skipping`); return; }
328
883
  const message = event.message ?? null;
329
884
  // AgentMessage is UserMessage | AssistantMessage | ToolResultMessage.
330
885
  // user is captured via `input`; toolResult via `tool_result`. Only assistant here.
331
- if (!message || message.role !== "assistant") return;
886
+ if (!message || message.role !== "assistant") {
887
+ logHm(`message_end: skipping (role=${message?.role ?? "null"} — only assistant rows are written here)`);
888
+ return;
889
+ }
332
890
  // AssistantMessage.content is (TextContent | ThinkingContent | ToolCall)[].
333
891
  const blocks: any[] = Array.isArray(message.content) ? message.content : [];
334
892
  const text = blocks
335
893
  .filter((b: any) => b?.type === "text" && typeof b.text === "string")
336
894
  .map((b: any) => b.text)
337
895
  .join("\n");
338
- if (!text) return;
896
+ if (!text) { logHm(`message_end: assistant message had no text blocks, skipping`); return; }
339
897
  const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
340
898
  const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
341
899
  try {
@@ -346,10 +904,33 @@ export default function hivemindExtension(pi: ExtensionAPI): void {
346
904
  content: text,
347
905
  timestamp: new Date().toISOString(),
348
906
  });
349
- } catch { /* non-fatal */ }
907
+ } catch (e: any) {
908
+ logHm(`message_end: writeSessionRow swallowed: ${e?.message ?? e}`);
909
+ }
910
+ maybeTriggerPeriodicSummary(creds, sessionId, cwd);
350
911
  });
351
912
 
352
- pi.on("session_shutdown", async (_event: any, _ctx: any) => {
353
- // No-op for now. Future: trigger wiki-worker for AI summary.
913
+ pi.on("session_shutdown", async (_event: any, ctx: any) => {
914
+ logHm(`session_shutdown: fired`);
915
+ if (process.env.HIVEMIND_CAPTURE === "false") return;
916
+ const creds = loadCreds();
917
+ if (!creds) { logHm(`session_shutdown: no creds, skipping final summary`); return; }
918
+ const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
919
+ if (!sessionId) { logHm(`session_shutdown: no sessionId, skipping final summary`); return; }
920
+ const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
921
+ // Always spawn for "final" — but the lock check inside spawnWikiWorker
922
+ // skips if a periodic worker is mid-flight. Non-fatal either way.
923
+ spawnWikiWorker(creds, sessionId, cwd, "final");
924
+
925
+ // Also kick off the skilify worker so this session's prompt+answer
926
+ // pairs become candidates for reusable skills. Lock keyed on
927
+ // projectKey, not sessionId — multiple sessions in the same project
928
+ // shouldn't race the gate. Non-fatal: failure here only loses the
929
+ // mining for this one session, never breaks the wiki summary above.
930
+ try { spawnPiSkilifyWorker(creds, sessionId, cwd); }
931
+ catch (e: any) { logHm(`session_shutdown: skilify spawn threw: ${e?.message ?? e}`); }
354
932
  });
933
+
934
+ // Module-load breadcrumb so we know the extension's default export ran at all.
935
+ logHm(`extension loaded (table=${SESSIONS_TABLE}, mem=${MEMORY_TABLE})`);
355
936
  }