@deeplake/hivemind 0.6.47 → 0.6.48

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.
@@ -23943,6 +23943,41 @@ function buildContentFilter(column, likeOp, patterns) {
23943
23943
  return ` AND (${patterns.map((pattern) => `${column} ${likeOp} '%${pattern}%'`).join(" OR ")})`;
23944
23944
  }
23945
23945
 
23946
+ // dist/src/cli/version.js
23947
+ import { readFileSync as readFileSync5 } from "node:fs";
23948
+ import { join as join6 } from "node:path";
23949
+
23950
+ // dist/src/cli/util.js
23951
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3, cpSync, symlinkSync, unlinkSync as unlinkSync2, lstatSync } from "node:fs";
23952
+ import { join as join5, dirname } from "node:path";
23953
+ import { homedir as homedir4 } from "node:os";
23954
+ import { fileURLToPath } from "node:url";
23955
+ var HOME = homedir4();
23956
+ function pkgRoot() {
23957
+ return fileURLToPath(new URL("..", import.meta.url));
23958
+ }
23959
+ var PLATFORM_MARKERS = [
23960
+ { id: "claude", markerDir: join5(HOME, ".claude") },
23961
+ { id: "codex", markerDir: join5(HOME, ".codex") },
23962
+ { id: "claw", markerDir: join5(HOME, ".openclaw") },
23963
+ { id: "cursor", markerDir: join5(HOME, ".cursor") },
23964
+ { id: "hermes", markerDir: join5(HOME, ".hermes") },
23965
+ // pi (badlogic/pi-mono coding-agent) — config at ~/.pi/agent/. pi exposes
23966
+ // a rich extension event API (session_start / input / tool_call /
23967
+ // tool_result / message_end / session_shutdown / etc.) — Tier 1 capable.
23968
+ { id: "pi", markerDir: join5(HOME, ".pi") }
23969
+ ];
23970
+
23971
+ // dist/src/cli/version.js
23972
+ function getVersion() {
23973
+ try {
23974
+ const pkg = JSON.parse(readFileSync5(join6(pkgRoot(), "package.json"), "utf-8"));
23975
+ return pkg.version ?? "0.0.0";
23976
+ } catch {
23977
+ return "0.0.0";
23978
+ }
23979
+ }
23980
+
23946
23981
  // dist/src/mcp/server.js
23947
23982
  function getContext() {
23948
23983
  const creds = loadCredentials();
@@ -23961,7 +23996,7 @@ function errorResult(text) {
23961
23996
  }
23962
23997
  var server = new McpServer({
23963
23998
  name: "hivemind",
23964
- version: "0.6.47"
23999
+ version: getVersion()
23965
24000
  });
23966
24001
  server.registerTool("hivemind_search", {
23967
24002
  description: "Search Hivemind shared memory (summaries + raw sessions) by keyword or multi-word phrase. Returns matching paths and snippets. Use this first when the user asks about prior work, conversations, or context that may exist in Hivemind. Different paths under /summaries/<username>/ are different users \u2014 do not merge them.",
@@ -24037,7 +24072,7 @@ server.registerTool("hivemind_index", {
24037
24072
  const ctx = getContext();
24038
24073
  if ("error" in ctx)
24039
24074
  return errorResult(ctx.error);
24040
- const where = prefix ? `WHERE path LIKE '${sqlStr(prefix)}%'` : `WHERE path LIKE '/summaries/%'`;
24075
+ const where = prefix ? `WHERE path LIKE '${sqlLike(prefix)}%' ESCAPE '\\'` : `WHERE path LIKE '/summaries/%'`;
24041
24076
  const sql = `SELECT path, description, project, last_update_date FROM "${ctx.memoryTable}" ${where} ORDER BY last_update_date DESC LIMIT ${limit ?? 50}`;
24042
24077
  try {
24043
24078
  const rows = await ctx.api.query(sql);
@@ -984,7 +984,7 @@ function extractLatestVersion(body) {
984
984
  return typeof v === "string" && v.length > 0 ? v : null;
985
985
  }
986
986
  function getInstalledVersion() {
987
- return "0.6.47".length > 0 ? "0.6.47" : null;
987
+ return "0.6.48".length > 0 ? "0.6.48" : null;
988
988
  }
989
989
  function isNewer(latest, current) {
990
990
  const parse = (v) => v.replace(/-.*$/, "").split(".").map(Number);
@@ -52,5 +52,5 @@
52
52
  }
53
53
  }
54
54
  },
55
- "version": "0.6.47"
55
+ "version": "0.6.48"
56
56
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeplake/hivemind",
3
- "version": "0.6.47",
3
+ "version": "0.6.48",
4
4
  "type": "module",
5
5
  "description": "Hivemind — cloud-backed persistent shared memory for AI agents, powered by DeepLake",
6
6
  "license": "Apache-2.0",
package/package.json CHANGED
@@ -1,21 +1,22 @@
1
1
  {
2
2
  "name": "@deeplake/hivemind",
3
- "version": "0.6.47",
3
+ "version": "0.6.48",
4
4
  "description": "Cloud-backed persistent shared memory for AI agents powered by Deeplake",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "bin": {
10
- "hivemind": "bundle/cli.js",
11
- "deeplake-shell": "bundle/deeplake-shell.js"
10
+ "hivemind": "bundle/cli.js"
12
11
  },
13
12
  "files": [
14
13
  "bundle",
15
14
  "codex/bundle",
16
15
  "codex/skills",
17
16
  "cursor/bundle",
17
+ "hermes/bundle",
18
18
  "mcp/bundle",
19
+ "pi/extension-source",
19
20
  "openclaw/dist",
20
21
  "openclaw/skills",
21
22
  "openclaw/openclaw.plugin.json",
@@ -47,11 +48,13 @@
47
48
  "dependencies": {
48
49
  "@modelcontextprotocol/sdk": "^1.29.0",
49
50
  "deeplake": "^0.3.30",
51
+ "js-yaml": "^4.1.1",
50
52
  "just-bash": "^2.14.0",
51
53
  "yargs-parser": "^22.0.0",
52
54
  "zod": "^4.3.6"
53
55
  },
54
56
  "devDependencies": {
57
+ "@types/js-yaml": "^4.0.9",
55
58
  "@types/node": "^25.0.0",
56
59
  "@types/yargs-parser": "^21.0.3",
57
60
  "@vitest/coverage-v8": "^4.1.3",
@@ -0,0 +1,355 @@
1
+ // @ts-nocheck — distributed as raw .ts; pi's runtime loads + compiles it.
2
+ // We ship this file verbatim into ~/.pi/agent/extensions/hivemind.ts.
3
+ //
4
+ // Hivemind extension for pi (badlogic/pi-mono coding-agent).
5
+ //
6
+ // Subscribes to the agent lifecycle events documented in
7
+ // `pi-mono/packages/coding-agent/src/core/extensions/types.ts` to:
8
+ // - inject deeplake memory context at session_start
9
+ // - capture user prompts (input event)
10
+ // - capture tool call results (tool_result event)
11
+ // - capture assistant messages (message_end event)
12
+ // - finalize on session_shutdown
13
+ //
14
+ // Plus registers three first-class pi tools (since pi has no MCP):
15
+ // - hivemind_search
16
+ // - hivemind_read
17
+ // - hivemind_index
18
+ //
19
+ // All deeplake interactions are inline `fetch` calls so this file has
20
+ // zero non-builtin runtime dependencies — it only needs Node 22+ globals.
21
+ //
22
+ // Type imports are erased at runtime so they don't need to be installed
23
+ // at our build time. pi's `@mariozechner/pi-coding-agent` types are
24
+ // available to pi's compiler when this is loaded.
25
+
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";
30
+
31
+ // ---------- credentials / config -----------------------------------------------
32
+
33
+ interface Creds {
34
+ token: string;
35
+ apiUrl: string;
36
+ orgId: string;
37
+ orgName?: string;
38
+ workspaceId: string;
39
+ userName: string;
40
+ }
41
+
42
+ function loadCreds(): Creds | null {
43
+ const path = join(homedir(), ".deeplake", "credentials.json");
44
+ if (!existsSync(path)) return null;
45
+ try {
46
+ const raw = readFileSync(path, "utf-8");
47
+ const parsed = JSON.parse(raw);
48
+ if (!parsed?.token) return null;
49
+ return {
50
+ token: parsed.token,
51
+ apiUrl: parsed.apiUrl ?? "https://api.deeplake.ai",
52
+ orgId: parsed.orgId,
53
+ orgName: parsed.orgName,
54
+ workspaceId: parsed.workspaceId ?? "default",
55
+ userName: parsed.userName ?? "unknown",
56
+ };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ const MEMORY_TABLE = process.env.HIVEMIND_TABLE ?? "memory";
63
+ const SESSIONS_TABLE = process.env.HIVEMIND_SESSIONS_TABLE ?? "sessions";
64
+
65
+ // ---------- SQL escape (matches src/utils/sql.ts) ------------------------------
66
+
67
+ function sqlStr(value: string): string {
68
+ return value
69
+ .replace(/\\/g, "\\\\")
70
+ .replace(/'/g, "''")
71
+ .replace(/\0/g, "")
72
+ .replace(/[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
73
+ }
74
+
75
+ // LIKE-pattern escape: sqlStr only handles SQL string quoting, NOT LIKE
76
+ // metacharacters. Without this, a tool arg containing `%` or `_` (which
77
+ // the LLM controls via the tool schema) would bypass the intended path
78
+ // filter — e.g. prefix='%' would match every row in the table. Wrap the
79
+ // resulting LIKE clause with `ESCAPE '\\'` so the engine honours the
80
+ // backslash escaping below.
81
+ function sqlLike(value: string): string {
82
+ return sqlStr(value)
83
+ .replace(/\\/g, "\\\\")
84
+ .replace(/%/g, "\\%")
85
+ .replace(/_/g, "\\_");
86
+ }
87
+
88
+ // JSONB column escape — only single-quote doubling, preserves JSON escape sequences.
89
+ function sqlJsonb(json: string): string {
90
+ return json.replace(/'/g, "''");
91
+ }
92
+
93
+ // ---------- deeplake api -------------------------------------------------------
94
+
95
+ async function dlQuery(creds: Creds, sql: string): Promise<unknown[]> {
96
+ const resp = await fetch(`${creds.apiUrl}/workspaces/${creds.workspaceId}/tables/query`, {
97
+ method: "POST",
98
+ headers: {
99
+ "Authorization": `Bearer ${creds.token}`,
100
+ "Content-Type": "application/json",
101
+ "X-Activeloop-Org-Id": creds.orgId,
102
+ },
103
+ body: JSON.stringify({ query: sql }),
104
+ });
105
+ if (!resp.ok) {
106
+ const text = await resp.text().catch(() => "");
107
+ throw new Error(`deeplake query failed: ${resp.status} ${text.slice(0, 200)}`);
108
+ }
109
+ const json = (await resp.json()) as { columns?: string[]; rows?: unknown[][] };
110
+ if (!json.rows || !json.columns) return [];
111
+ return json.rows.map((r) => Object.fromEntries(json.columns!.map((c, i) => [c, r[i]])));
112
+ }
113
+
114
+ // ---------- session-row writer -------------------------------------------------
115
+
116
+ function buildSessionPath(creds: Creds, sessionId: string): string {
117
+ const filename = `${creds.userName}_${creds.orgName ?? creds.orgId}_${creds.workspaceId}_${sessionId}.jsonl`;
118
+ return `/sessions/${creds.userName}/${filename}`;
119
+ }
120
+
121
+ async function writeSessionRow(
122
+ creds: Creds,
123
+ sessionId: string,
124
+ agent: string,
125
+ event: string,
126
+ cwd: string,
127
+ entry: Record<string, unknown>,
128
+ ): Promise<void> {
129
+ const ts = new Date().toISOString();
130
+ const sessionPath = buildSessionPath(creds, sessionId);
131
+ const filename = sessionPath.split("/").pop() ?? "";
132
+ const projectName = (cwd ?? "").split("/").pop() || "unknown";
133
+ const line = JSON.stringify(entry);
134
+ const jsonForSql = sqlJsonb(line);
135
+ 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)}', ` +
138
+ `${Buffer.byteLength(line, "utf-8")}, '${sqlStr(projectName)}', '${sqlStr(event)}', '${agent}', '${ts}', '${ts}')`;
139
+ await dlQuery(creds, insertSql);
140
+ }
141
+
142
+ // ---------- search primitive (used by hivemind_search) -------------------------
143
+
144
+ async function searchTables(creds: Creds, query: string, limit: number): Promise<string> {
145
+ // ILIKE pattern: escape both SQL quotes AND LIKE wildcards. ESCAPE '\\'
146
+ // tells the engine to treat backslash as the escape character so our
147
+ // \% / \_ are matched literally instead of as wildcards.
148
+ const pattern = sqlLike(query);
149
+ const memQuery = `SELECT path, summary::text AS content, 0 AS source_order FROM "${MEMORY_TABLE}" WHERE summary::text ILIKE '%${pattern}%' ESCAPE '\\' LIMIT ${limit}`;
150
+ const sessQuery = `SELECT path, message::text AS content, 1 AS source_order FROM "${SESSIONS_TABLE}" WHERE message::text ILIKE '%${pattern}%' ESCAPE '\\' LIMIT ${limit}`;
151
+ const sql = `SELECT path, content, source_order FROM ((${memQuery}) UNION ALL (${sessQuery})) AS combined ORDER BY path, source_order LIMIT ${limit}`;
152
+ const rows = await dlQuery(creds, sql);
153
+ if (rows.length === 0) return `No matches for "${query}".`;
154
+ return rows
155
+ .map((r: any) => `[${r.path}]\n${String(r.content ?? "").slice(0, 600)}`)
156
+ .join("\n\n---\n\n");
157
+ }
158
+
159
+ // pi tools must return AgentToolResult: { content: [{type:"text", text}], details }.
160
+ // Returning a raw string crashes pi's renderer (render-utils.js: result.content.filter).
161
+ function textResult(text: string) {
162
+ return { content: [{ type: "text" as const, text }], details: {} };
163
+ }
164
+
165
+ // ---------- main extension -----------------------------------------------------
166
+
167
+ const CONTEXT_PREAMBLE = `DEEPLAKE MEMORY: Persistent memory at ~/.deeplake/memory/ shared across sessions, users, and agents in your org.
168
+
169
+ Three hivemind tools are registered:
170
+ hivemind_search { query, limit? } keyword search across summaries + sessions
171
+ hivemind_read { path } read full content at a memory path
172
+ hivemind_index { prefix?, limit? } list summary entries
173
+
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.`;
175
+
176
+ export default function hivemindExtension(pi: ExtensionAPI): void {
177
+ const captureEnabled = process.env.HIVEMIND_CAPTURE !== "false";
178
+
179
+ // --- Tools (read path) -------------------------------------------------------
180
+
181
+ pi.registerTool({
182
+ name: "hivemind_search",
183
+ description: "Search Hivemind shared memory (summaries + raw sessions) by keyword. Use this first when the user asks about prior work or context that may exist in Hivemind. Different paths under /summaries/<username>/ are different users — do NOT merge them.",
184
+ parameters: {
185
+ type: "object",
186
+ properties: {
187
+ query: { type: "string", description: "Keyword or substring to search for." },
188
+ limit: { type: "number", description: "Max hits (default 10)." },
189
+ },
190
+ required: ["query"],
191
+ },
192
+ async execute(_toolCallId: string, params: { query: string; limit?: number }) {
193
+ const creds = loadCreds();
194
+ if (!creds) return textResult("Hivemind: not authenticated. Run `hivemind login` in a terminal.");
195
+ try {
196
+ return textResult(await searchTables(creds, params.query, params.limit ?? 10));
197
+ } catch (err: any) {
198
+ return textResult(`Hivemind search failed: ${err.message}`);
199
+ }
200
+ },
201
+ });
202
+
203
+ pi.registerTool({
204
+ name: "hivemind_read",
205
+ description: "Read the full content at a Hivemind memory path (e.g. /summaries/alice/abc.md or /sessions/alice/...jsonl). Use after hivemind_search to drill into a hit.",
206
+ parameters: {
207
+ type: "object",
208
+ properties: { path: { type: "string", description: "Absolute Hivemind memory path." } },
209
+ required: ["path"],
210
+ },
211
+ async execute(_toolCallId: string, params: { path: string }) {
212
+ const creds = loadCreds();
213
+ if (!creds) return textResult("Hivemind: not authenticated.");
214
+ const path = params.path;
215
+ const isSession = path.startsWith("/sessions/");
216
+ const table = isSession ? SESSIONS_TABLE : MEMORY_TABLE;
217
+ const col = isSession ? "message::text" : "summary::text";
218
+ const sql = `SELECT path, ${col} AS content FROM "${table}" WHERE path = '${sqlStr(path)}' LIMIT 200`;
219
+ try {
220
+ const rows = await dlQuery(creds, sql);
221
+ if (rows.length === 0) return textResult(`No content at ${path}.`);
222
+ return textResult(rows.map((r: any) => String(r.content ?? "")).join("\n"));
223
+ } catch (err: any) {
224
+ return textResult(`Hivemind read failed: ${err.message}`);
225
+ }
226
+ },
227
+ });
228
+
229
+ pi.registerTool({
230
+ name: "hivemind_index",
231
+ description: "List Hivemind summary entries (one row per session). Use to see what's in shared memory.",
232
+ parameters: {
233
+ type: "object",
234
+ properties: {
235
+ prefix: { type: "string", description: "Path prefix, e.g. '/summaries/alice/'." },
236
+ limit: { type: "number", description: "Max rows (default 50)." },
237
+ },
238
+ },
239
+ async execute(_toolCallId: string, params: { prefix?: string; limit?: number }) {
240
+ const creds = loadCreds();
241
+ if (!creds) return textResult("Hivemind: not authenticated.");
242
+ const where = params.prefix
243
+ ? `WHERE path LIKE '${sqlLike(params.prefix)}%' ESCAPE '\\'`
244
+ : `WHERE path LIKE '/summaries/%'`;
245
+ const sql = `SELECT path, description, project, last_update_date FROM "${MEMORY_TABLE}" ${where} ORDER BY last_update_date DESC LIMIT ${params.limit ?? 50}`;
246
+ try {
247
+ const rows = await dlQuery(creds, sql);
248
+ if (rows.length === 0) return textResult("No summaries.");
249
+ return textResult(rows
250
+ .map((r: any) => `${r.path}\t${r.last_update_date}\t${r.project ?? ""}\t${r.description ?? ""}`)
251
+ .join("\n"));
252
+ } catch (err: any) {
253
+ return textResult(`Hivemind index failed: ${err.message}`);
254
+ }
255
+ },
256
+ });
257
+
258
+ // --- Lifecycle hooks (capture path) -----------------------------------------
259
+ //
260
+ // Event shapes per pi-coding-agent/dist/core/extensions/types.d.ts:
261
+ // - SessionStartEvent: { type, reason, previousSessionFile? }
262
+ // - InputEvent: { type, text, images?, source }
263
+ // - ToolResultEvent: { type, toolCallId, toolName, input, content, isError, details }
264
+ // - MessageEndEvent: { type, message: AgentMessage }
265
+ // Every handler receives (event, ctx). ctx.sessionManager.getSessionId() and
266
+ // ctx.cwd are the canonical sources for session id + cwd — the events
267
+ // themselves don't carry them.
268
+
269
+ pi.on("session_start", async (_event: any, _ctx: any) => {
270
+ const creds = loadCreds();
271
+ const additional = creds
272
+ ? `${CONTEXT_PREAMBLE}\nLogged in to Deeplake as org: ${creds.orgName ?? creds.orgId} (workspace: ${creds.workspaceId}).`
273
+ : `${CONTEXT_PREAMBLE}\nNot logged in to Deeplake. Run \`hivemind login\` to authenticate.`;
274
+ return { additionalContext: additional };
275
+ });
276
+
277
+ pi.on("input", async (event: any, ctx: any) => {
278
+ if (!captureEnabled) return;
279
+ if (event.source === "extension") return; // skip our own injected inputs
280
+ const creds = loadCreds();
281
+ if (!creds) return;
282
+ const text = typeof event.text === "string" ? event.text : "";
283
+ if (!text) return;
284
+ const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
285
+ const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
286
+ try {
287
+ await writeSessionRow(creds, sessionId, "pi", "input", cwd, {
288
+ id: crypto.randomUUID(),
289
+ type: "user_message",
290
+ session_id: sessionId,
291
+ content: text,
292
+ timestamp: new Date().toISOString(),
293
+ });
294
+ } catch { /* non-fatal */ }
295
+ });
296
+
297
+ pi.on("tool_result", async (event: any, ctx: any) => {
298
+ if (!captureEnabled) return;
299
+ const creds = loadCreds();
300
+ if (!creds) return;
301
+ const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
302
+ const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
303
+ // event.content is (TextContent | ImageContent)[]; extract text blocks.
304
+ const contentBlocks: any[] = Array.isArray(event.content) ? event.content : [];
305
+ const responseText = contentBlocks
306
+ .filter((b: any) => b?.type === "text" && typeof b.text === "string")
307
+ .map((b: any) => b.text)
308
+ .join("\n");
309
+ try {
310
+ await writeSessionRow(creds, sessionId, "pi", "tool_result", cwd, {
311
+ id: crypto.randomUUID(),
312
+ type: "tool_call",
313
+ session_id: sessionId,
314
+ tool_call_id: event.toolCallId ?? null,
315
+ tool_name: event.toolName ?? "unknown",
316
+ tool_input: JSON.stringify(event.input ?? {}),
317
+ tool_response: responseText || JSON.stringify(contentBlocks),
318
+ is_error: event.isError === true,
319
+ timestamp: new Date().toISOString(),
320
+ });
321
+ } catch { /* non-fatal */ }
322
+ });
323
+
324
+ pi.on("message_end", async (event: any, ctx: any) => {
325
+ if (!captureEnabled) return;
326
+ const creds = loadCreds();
327
+ if (!creds) return;
328
+ const message = event.message ?? null;
329
+ // AgentMessage is UserMessage | AssistantMessage | ToolResultMessage.
330
+ // user is captured via `input`; toolResult via `tool_result`. Only assistant here.
331
+ if (!message || message.role !== "assistant") return;
332
+ // AssistantMessage.content is (TextContent | ThinkingContent | ToolCall)[].
333
+ const blocks: any[] = Array.isArray(message.content) ? message.content : [];
334
+ const text = blocks
335
+ .filter((b: any) => b?.type === "text" && typeof b.text === "string")
336
+ .map((b: any) => b.text)
337
+ .join("\n");
338
+ if (!text) return;
339
+ const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`;
340
+ const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd();
341
+ try {
342
+ await writeSessionRow(creds, sessionId, "pi", "message_end", cwd, {
343
+ id: crypto.randomUUID(),
344
+ type: "assistant_message",
345
+ session_id: sessionId,
346
+ content: text,
347
+ timestamp: new Date().toISOString(),
348
+ });
349
+ } catch { /* non-fatal */ }
350
+ });
351
+
352
+ pi.on("session_shutdown", async (_event: any, _ctx: any) => {
353
+ // No-op for now. Future: trigger wiki-worker for AI summary.
354
+ });
355
+ }