opencode-mem-agents 0.3.1

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.
package/dist/index.js ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * opencode-mem — Self-contained memory plugin for OpenCode (Team-Optimized)
3
+ *
4
+ * Provides persistent cross-session memory via a standalone HTTP worker
5
+ * with SQLite + FTS5 full-text search. Fully self-contained — no external
6
+ * dependencies beyond better-sqlite3.
7
+ *
8
+ * Data: ~/.opencode-mem/opencode-mem.db
9
+ * Worker: localhost:37778 (configurable via OPENCODE_MEM_PORT)
10
+ *
11
+ * Team optimizations:
12
+ * - Smart auto-capture: HIGH signal (writes/edits) sent immediately,
13
+ * MEDIUM (bash/build) batched every 10s, LOW (reads/greps) skipped
14
+ * - Agent identity: observations tagged with agent name, workflow, task, phase
15
+ * - Role-scoped context: system prompt injection filtered by agent role
16
+ * - Team tools: mem-team-context and mem-share for cross-agent coordination
17
+ * - Batched capture: observations buffered and flushed periodically
18
+ *
19
+ * Architecture:
20
+ * OpenCode agents ←plugin→ opencode-mem ←HTTP→ worker (dist/worker.js)
21
+ * └── SQLite + FTS5
22
+ */
23
+ import { tool } from "@opencode-ai/plugin";
24
+ import { fileURLToPath } from "url";
25
+ import { dirname, join } from "path";
26
+ const z = tool.schema;
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = dirname(__filename);
29
+ // ---------------------------------------------------------------------------
30
+ // Configuration
31
+ // ---------------------------------------------------------------------------
32
+ const WORKER_PORT = parseInt(process.env.OPENCODE_MEM_PORT ?? "37778", 10);
33
+ const WORKER_HOST = process.env.OPENCODE_MEM_HOST ?? "127.0.0.1";
34
+ const WORKER_BASE = `http://${WORKER_HOST}:${WORKER_PORT}`;
35
+ const HEALTH_TIMEOUT_MS = 2000;
36
+ const STARTUP_TIMEOUT_MS = 15000;
37
+ const FLUSH_INTERVAL_MS = 10_000;
38
+ const MAX_BUFFER_SIZE = 20;
39
+ const CONTEXT_CACHE_TTL_MS = 60_000;
40
+ const TOOL_SIGNALS = {
41
+ // HIGH — mutations and explicit saves (sent immediately)
42
+ write: "high",
43
+ edit: "high",
44
+ notebook_edit: "high",
45
+ "mem-save": "high",
46
+ "mem-share": "high",
47
+ // MEDIUM — command results (batched every 10s)
48
+ bash: "medium",
49
+ // LOW — exploration noise (skipped entirely)
50
+ read: "low",
51
+ grep: "low",
52
+ glob: "low",
53
+ list_directory: "low",
54
+ "mem-search": "low",
55
+ "mem-timeline": "low",
56
+ "mem-get": "low",
57
+ "mem-team-context": "low",
58
+ ListMcpResourcesTool: "low",
59
+ AskUserQuestion: "low",
60
+ };
61
+ function getToolSignal(toolName) {
62
+ if (toolName in TOOL_SIGNALS)
63
+ return TOOL_SIGNALS[toolName];
64
+ return "medium";
65
+ }
66
+ function getAgentContext(agentName) {
67
+ return {
68
+ agentName: agentName ?? process.env.OPENCODE_AGENT_NAME ?? "default",
69
+ workflowId: process.env.HERMES_WORKFLOW_ID ?? "",
70
+ taskId: process.env.HERMES_TASK_ID ?? "",
71
+ phase: process.env.HERMES_PHASE ?? "",
72
+ };
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Role-scoped queries for context injection
76
+ // ---------------------------------------------------------------------------
77
+ const ROLE_QUERIES = {
78
+ lead: "architectural decisions contracts blockers progress status",
79
+ backend: "API contracts endpoints database models services routes backend",
80
+ frontend: "UI components views layouts state routing hooks frontend",
81
+ database: "schema models migrations queries indexes database",
82
+ test: "test patterns quality failures coverage assertions",
83
+ security: "security vulnerabilities auth validation injection",
84
+ reviewer: "spec requirements acceptance criteria issues findings",
85
+ default: "recent decisions patterns blockers contracts changes",
86
+ };
87
+ function getRoleQuery(agentName) {
88
+ return ROLE_QUERIES[agentName.toLowerCase()] ?? ROLE_QUERIES["default"];
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Worker lifecycle
92
+ // ---------------------------------------------------------------------------
93
+ async function isWorkerHealthy() {
94
+ try {
95
+ const controller = new AbortController();
96
+ const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
97
+ const res = await fetch(`${WORKER_BASE}/api/health`, {
98
+ signal: controller.signal,
99
+ });
100
+ clearTimeout(timeout);
101
+ return res.ok;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ const DATA_DIR = process.env.OPENCODE_MEM_DATA_DIR ?? `${process.env.HOME}/.opencode-mem`;
108
+ async function startWorker() {
109
+ if (await isWorkerHealthy())
110
+ return true;
111
+ const workerScript = join(__dirname, "worker.js");
112
+ const { spawn } = await import("child_process");
113
+ const { existsSync } = await import("fs");
114
+ if (!existsSync(workerScript)) {
115
+ console.error(`[opencode-mem] Worker script not found: ${workerScript}`);
116
+ return false;
117
+ }
118
+ const child = spawn("node", [workerScript], {
119
+ detached: true,
120
+ stdio: "ignore",
121
+ env: {
122
+ ...process.env,
123
+ OPENCODE_MEM_PORT: String(WORKER_PORT),
124
+ OPENCODE_MEM_HOST: WORKER_HOST,
125
+ OPENCODE_MEM_DATA_DIR: DATA_DIR,
126
+ },
127
+ });
128
+ child.unref();
129
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
130
+ while (Date.now() < deadline) {
131
+ if (await isWorkerHealthy())
132
+ return true;
133
+ await new Promise((r) => setTimeout(r, 500));
134
+ }
135
+ console.error("[opencode-mem] Worker failed to start within timeout");
136
+ return false;
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // HTTP helpers
140
+ // ---------------------------------------------------------------------------
141
+ async function workerGet(path, params) {
142
+ const url = new URL(path, WORKER_BASE);
143
+ if (params) {
144
+ for (const [k, v] of Object.entries(params)) {
145
+ if (v !== undefined && v !== "")
146
+ url.searchParams.set(k, v);
147
+ }
148
+ }
149
+ const controller = new AbortController();
150
+ const timeout = setTimeout(() => controller.abort(), 5000);
151
+ try {
152
+ const res = await fetch(url.toString(), { signal: controller.signal });
153
+ if (!res.ok)
154
+ return JSON.stringify({ error: `HTTP ${res.status}` });
155
+ return await res.text();
156
+ }
157
+ finally {
158
+ clearTimeout(timeout);
159
+ }
160
+ }
161
+ async function workerPost(path, body) {
162
+ const controller = new AbortController();
163
+ const timeout = setTimeout(() => controller.abort(), 5000);
164
+ try {
165
+ const res = await fetch(`${WORKER_BASE}${path}`, {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json" },
168
+ body: JSON.stringify(body),
169
+ signal: controller.signal,
170
+ });
171
+ if (!res.ok)
172
+ return JSON.stringify({ error: `HTTP ${res.status}` });
173
+ return await res.text();
174
+ }
175
+ finally {
176
+ clearTimeout(timeout);
177
+ }
178
+ }
179
+ function createObservationBuffer() {
180
+ const pending = [];
181
+ let timer = null;
182
+ let flushing = false;
183
+ let flushFailures = 0;
184
+ const MAX_FLUSH_FAILURES = 3;
185
+ const CIRCUIT_RESET_MS = 30_000;
186
+ let circuitOpenAt = 0;
187
+ async function flush() {
188
+ if (flushing || pending.length === 0)
189
+ return;
190
+ // Circuit breaker check
191
+ if (flushFailures >= MAX_FLUSH_FAILURES) {
192
+ if (Date.now() - circuitOpenAt < CIRCUIT_RESET_MS)
193
+ return;
194
+ // Half-open: try one flush to see if worker is back
195
+ }
196
+ flushing = true;
197
+ const batch = pending.splice(0);
198
+ try {
199
+ const results = await Promise.allSettled(batch.map((obs) => fetch(`${WORKER_BASE}/api/session/tool-result`, {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({
203
+ sessionId: obs.sessionId,
204
+ tool: obs.tool,
205
+ callId: obs.callId,
206
+ args: obs.args,
207
+ output: obs.output,
208
+ title: obs.title,
209
+ files_modified: obs.files_modified ?? [],
210
+ metadata: {
211
+ signal: obs.signal,
212
+ agent: obs.agent,
213
+ timestamp: obs.timestamp,
214
+ },
215
+ }),
216
+ })));
217
+ const failures = results.filter((r) => r.status === "rejected").length;
218
+ if (failures > 0) {
219
+ flushFailures++;
220
+ if (flushFailures >= MAX_FLUSH_FAILURES) {
221
+ circuitOpenAt = Date.now();
222
+ console.warn("[opencode-mem] Worker unreachable — memory capture paused (will retry in 30s)");
223
+ }
224
+ }
225
+ else {
226
+ flushFailures = 0;
227
+ }
228
+ // Don't re-queue batch — accept data loss over unbounded growth
229
+ }
230
+ finally {
231
+ flushing = false;
232
+ }
233
+ }
234
+ function start() {
235
+ if (timer)
236
+ return;
237
+ timer = setInterval(() => flush().catch(() => { }), FLUSH_INTERVAL_MS);
238
+ }
239
+ function add(obs) {
240
+ pending.push(obs);
241
+ if (pending.length >= MAX_BUFFER_SIZE)
242
+ flush().catch(() => { });
243
+ }
244
+ async function sendImmediate(obs) {
245
+ try {
246
+ await fetch(`${WORKER_BASE}/api/session/tool-result`, {
247
+ method: "POST",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({
250
+ sessionId: obs.sessionId,
251
+ tool: obs.tool,
252
+ callId: obs.callId,
253
+ args: obs.args,
254
+ output: obs.output,
255
+ title: obs.title,
256
+ files_modified: obs.files_modified ?? [],
257
+ metadata: {
258
+ signal: obs.signal,
259
+ agent: obs.agent,
260
+ timestamp: obs.timestamp,
261
+ },
262
+ }),
263
+ });
264
+ }
265
+ catch {
266
+ // Worker unavailable
267
+ }
268
+ }
269
+ return { start, add, flush, sendImmediate };
270
+ }
271
+ // ---------------------------------------------------------------------------
272
+ // Plugin entry point
273
+ // ---------------------------------------------------------------------------
274
+ const plugin = async (_input) => {
275
+ const healthy = await startWorker();
276
+ if (!healthy) {
277
+ console.warn("[opencode-mem] Worker not available. Memory tools will fail gracefully.");
278
+ }
279
+ const observationBuffer = createObservationBuffer();
280
+ observationBuffer.start();
281
+ let contextCache = null;
282
+ return {
283
+ // ------------------------------------------------------------------
284
+ // Tools
285
+ // ------------------------------------------------------------------
286
+ tool: {
287
+ "mem-search": tool({
288
+ description: "Step 1: Search memory. Returns compact index with IDs (~50-100 tokens/result). Use timeline() and get_observations() to drill into results.",
289
+ args: {
290
+ query: z.string().optional().describe("Semantic search query"),
291
+ limit: z.number().optional().describe("Max results (default 20)"),
292
+ project: z.string().optional().describe("Filter by project name"),
293
+ type: z
294
+ .string()
295
+ .optional()
296
+ .describe("Filter by type: decision, bugfix, feature, refactor, discovery, change"),
297
+ dateStart: z.string().optional().describe("ISO date start filter"),
298
+ dateEnd: z.string().optional().describe("ISO date end filter"),
299
+ since_id: z
300
+ .number()
301
+ .optional()
302
+ .describe("Only return observations with id > this value (incremental polling)"),
303
+ agent: z
304
+ .string()
305
+ .optional()
306
+ .describe("Filter by agent name"),
307
+ },
308
+ async execute(args) {
309
+ return await workerGet("/api/search", {
310
+ query: args.query ?? "",
311
+ limit: String(args.limit ?? 20),
312
+ project: args.project ?? "",
313
+ type: args.type ?? "",
314
+ dateStart: args.dateStart ?? "",
315
+ dateEnd: args.dateEnd ?? "",
316
+ since_id: args.since_id !== undefined ? String(args.since_id) : "",
317
+ agent: args.agent ?? "",
318
+ });
319
+ },
320
+ }),
321
+ "mem-timeline": tool({
322
+ description: "Step 2: Get chronological context around a result. Use anchor (observation ID) or query (auto-finds anchor).",
323
+ args: {
324
+ anchor: z
325
+ .number()
326
+ .optional()
327
+ .describe("Observation ID to center on"),
328
+ query: z
329
+ .string()
330
+ .optional()
331
+ .describe("Auto-find anchor from query"),
332
+ depth_before: z
333
+ .number()
334
+ .optional()
335
+ .describe("Records before anchor (default 3)"),
336
+ depth_after: z
337
+ .number()
338
+ .optional()
339
+ .describe("Records after anchor (default 3)"),
340
+ project: z.string().optional().describe("Filter by project"),
341
+ },
342
+ async execute(args) {
343
+ return await workerGet("/api/timeline", {
344
+ anchor: args.anchor !== undefined ? String(args.anchor) : "",
345
+ query: args.query ?? "",
346
+ depth_before: String(args.depth_before ?? 3),
347
+ depth_after: String(args.depth_after ?? 3),
348
+ project: args.project ?? "",
349
+ });
350
+ },
351
+ }),
352
+ "mem-get": tool({
353
+ description: "Step 3: Fetch full details for filtered observation IDs. ~500-1000 tokens per result. Only call after search/timeline filtering.",
354
+ args: {
355
+ ids: z
356
+ .array(z.number())
357
+ .describe("Array of observation IDs to fetch"),
358
+ project: z.string().optional().describe("Filter by project"),
359
+ },
360
+ async execute(args) {
361
+ return await workerPost("/api/observations/batch", {
362
+ ids: args.ids,
363
+ project: args.project,
364
+ });
365
+ },
366
+ }),
367
+ "mem-save": tool({
368
+ description: "Save a memory/observation for future semantic search. Use to remember decisions, discoveries, or context.",
369
+ args: {
370
+ text: z.string().describe("Content to remember"),
371
+ title: z
372
+ .string()
373
+ .optional()
374
+ .describe("Short title (auto-generated if omitted)"),
375
+ project: z
376
+ .string()
377
+ .optional()
378
+ .describe("Project name (auto-detected if omitted)"),
379
+ },
380
+ async execute(args, ctx) {
381
+ const agentCtx = getAgentContext(ctx.agent);
382
+ const project = args.project ??
383
+ (agentCtx.workflowId || ctx.directory.split("/").pop()) ??
384
+ "opencode-mem";
385
+ // Tag with agent identity for searchability
386
+ const taggedText = agentCtx.agentName !== "default"
387
+ ? `[agent:${agentCtx.agentName}] ${args.text}`
388
+ : args.text;
389
+ return await workerPost("/api/memory/save", {
390
+ text: taggedText,
391
+ title: args.title,
392
+ project,
393
+ agent: agentCtx.agentName,
394
+ workflow_id: project,
395
+ task_id: agentCtx.taskId,
396
+ phase: agentCtx.phase,
397
+ });
398
+ },
399
+ }),
400
+ "mem-team-context": tool({
401
+ description: "Get team context for the current workflow: decisions, contracts, blockers, and progress from all agents. Use before starting work to understand what the team has decided and discovered.",
402
+ args: {
403
+ workflowId: z
404
+ .string()
405
+ .optional()
406
+ .describe("Workflow ID (auto-detected from HERMES_WORKFLOW_ID env if omitted)"),
407
+ role: z
408
+ .string()
409
+ .optional()
410
+ .describe("Your agent role for relevance filtering (auto-detected from session)"),
411
+ },
412
+ async execute(args, ctx) {
413
+ const agentCtx = getAgentContext(ctx.agent);
414
+ const wfId = args.workflowId ?? agentCtx.workflowId;
415
+ const project = wfId || ctx.directory.split("/").pop() || "opencode-mem";
416
+ return await workerGet("/api/activity", {
417
+ project,
418
+ });
419
+ },
420
+ }),
421
+ "mem-activity": tool({
422
+ description: "Get team activity grouped by agent: who touched which files, what each agent did recently, and when they were last active. Use to coordinate work and avoid conflicts.",
423
+ args: {
424
+ project: z
425
+ .string()
426
+ .optional()
427
+ .describe("Project/workflow to query (auto-detected if omitted)"),
428
+ since_id: z
429
+ .number()
430
+ .optional()
431
+ .describe("Only show observations after this ID (for incremental polling)"),
432
+ },
433
+ async execute(args, ctx) {
434
+ const agentCtx = getAgentContext(ctx.agent);
435
+ const project = args.project ??
436
+ (agentCtx.workflowId || ctx.directory.split("/").pop() || "opencode-mem");
437
+ return await workerGet("/api/activity", {
438
+ project,
439
+ since_id: args.since_id !== undefined ? String(args.since_id) : "",
440
+ });
441
+ },
442
+ }),
443
+ "mem-share": tool({
444
+ description: "Share an important finding with the team. Tagged with your identity and observation type, scoped to the current workflow so other agents discover it via mem-team-context.",
445
+ args: {
446
+ text: z.string().describe("What to share with the team"),
447
+ type: z
448
+ .enum([
449
+ "decision",
450
+ "contract",
451
+ "blocker",
452
+ "discovery",
453
+ "test-result",
454
+ "review-finding",
455
+ ])
456
+ .describe("Type of observation"),
457
+ title: z
458
+ .string()
459
+ .optional()
460
+ .describe("Short title (auto-generated if omitted)"),
461
+ },
462
+ async execute(args, ctx) {
463
+ const agentCtx = getAgentContext(ctx.agent);
464
+ const project = agentCtx.workflowId ||
465
+ ctx.directory.split("/").pop() ||
466
+ "opencode-mem";
467
+ const parts = [
468
+ `[${args.type.toUpperCase()}]`,
469
+ `[agent:${agentCtx.agentName}]`,
470
+ agentCtx.phase ? `[phase:${agentCtx.phase}]` : null,
471
+ agentCtx.taskId ? `[task:${agentCtx.taskId}]` : null,
472
+ args.text,
473
+ ];
474
+ const enrichedText = parts.filter(Boolean).join(" ");
475
+ return await workerPost("/api/memory/save", {
476
+ text: enrichedText,
477
+ title: args.title ??
478
+ `${args.type}: ${args.text.substring(0, 60)}`,
479
+ project,
480
+ type: args.type,
481
+ agent: agentCtx.agentName,
482
+ workflow_id: project,
483
+ task_id: agentCtx.taskId,
484
+ phase: agentCtx.phase,
485
+ signal: "high",
486
+ });
487
+ },
488
+ }),
489
+ },
490
+ // ------------------------------------------------------------------
491
+ // Auto-capture with signal filtering + batching
492
+ // ------------------------------------------------------------------
493
+ "tool.execute.after": async (hookInput, hookOutput) => {
494
+ const signal = getToolSignal(hookInput.tool);
495
+ if (signal === "low")
496
+ return;
497
+ const agentCtx = getAgentContext();
498
+ const maxOutput = signal === "high" ? 5000 : 2000;
499
+ // Extract file paths from mutation tools
500
+ const filesModified = [];
501
+ const toolArgs = hookInput.args;
502
+ if (toolArgs) {
503
+ const toolName = hookInput.tool;
504
+ if (toolName === "write" || toolName === "edit") {
505
+ const fp = toolArgs.file_path;
506
+ if (typeof fp === "string" && fp)
507
+ filesModified.push(fp);
508
+ }
509
+ else if (toolName === "notebook_edit") {
510
+ const fp = toolArgs.notebook_path;
511
+ if (typeof fp === "string" && fp)
512
+ filesModified.push(fp);
513
+ }
514
+ }
515
+ const obs = {
516
+ sessionId: hookInput.sessionID,
517
+ tool: hookInput.tool,
518
+ callId: hookInput.callID,
519
+ args: hookInput.args,
520
+ output: hookOutput.output?.substring(0, maxOutput) ?? "",
521
+ title: hookOutput.title,
522
+ signal,
523
+ agent: agentCtx,
524
+ timestamp: Date.now(),
525
+ files_modified: filesModified.length > 0 ? filesModified : undefined,
526
+ };
527
+ if (signal === "high") {
528
+ await observationBuffer.sendImmediate(obs);
529
+ }
530
+ else {
531
+ observationBuffer.add(obs);
532
+ }
533
+ },
534
+ // ------------------------------------------------------------------
535
+ // Role-scoped context injection (cached 60s, bounded to 4KB)
536
+ // ------------------------------------------------------------------
537
+ "experimental.chat.system.transform": async (hookInput, hookOutput) => {
538
+ if (!(await isWorkerHealthy())) {
539
+ contextCache = null;
540
+ return;
541
+ }
542
+ const MAX_CONTEXT_BYTES = 4096;
543
+ const MAX_CONTEXT_ITEMS = 10;
544
+ const MAX_ITEM_LENGTH = 300;
545
+ const now = Date.now();
546
+ if (contextCache &&
547
+ now - contextCache.timestamp < CONTEXT_CACHE_TTL_MS) {
548
+ if (contextCache.content.length > 100) {
549
+ hookOutput.system.push(contextCache.content);
550
+ }
551
+ return;
552
+ }
553
+ try {
554
+ const agentCtx = getAgentContext();
555
+ const roleQuery = getRoleQuery(agentCtx.agentName);
556
+ const blocks = [];
557
+ const [roleResults, sessionContext] = await Promise.all([
558
+ workerGet("/api/search", {
559
+ query: roleQuery,
560
+ project: agentCtx.workflowId || "",
561
+ limit: String(MAX_CONTEXT_ITEMS),
562
+ }).catch(() => ""),
563
+ workerGet("/api/context/session", {
564
+ sessionId: hookInput.sessionID ?? "default",
565
+ limit: String(MAX_CONTEXT_ITEMS),
566
+ }).catch(() => ""),
567
+ ]);
568
+ if (agentCtx.workflowId) {
569
+ blocks.push(`[Memory] Workflow: ${agentCtx.workflowId} | Agent: ${agentCtx.agentName}` +
570
+ (agentCtx.phase ? ` | Phase: ${agentCtx.phase}` : ""));
571
+ }
572
+ if (roleResults && roleResults.length > 50)
573
+ blocks.push(roleResults);
574
+ if (sessionContext && sessionContext.length > 100)
575
+ blocks.push(sessionContext);
576
+ let content = blocks.join("\n\n");
577
+ // Enforce hard cap on injected context size
578
+ if (content.length > MAX_CONTEXT_BYTES) {
579
+ content = content.substring(0, MAX_CONTEXT_BYTES) + "\n[...truncated]";
580
+ }
581
+ contextCache = { content, timestamp: now };
582
+ if (content.length > 100)
583
+ hookOutput.system.push(content);
584
+ }
585
+ catch {
586
+ // Failed — no context injection
587
+ }
588
+ },
589
+ };
590
+ };
591
+ export default plugin;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * opencode-mem worker — Standalone HTTP server with SQLite + FTS5
3
+ *
4
+ * Runs as a daemon on port 37778 (configurable via OPENCODE_MEM_PORT).
5
+ * Stores observations in ~/.opencode-mem/opencode-mem.db.
6
+ *
7
+ * Endpoints:
8
+ * GET /api/health — Health check
9
+ * GET /api/search — FTS5 + metadata search
10
+ * GET /api/timeline — Chronological window around anchor
11
+ * POST /api/observations/batch — Get observations by IDs
12
+ * POST /api/memory/save — Save an observation
13
+ * POST /api/session/tool-result — Capture tool result as observation
14
+ * GET /api/context/session — Formatted context for system prompt
15
+ * GET /api/activity — Team activity grouped by agent
16
+ */
17
+ export {};