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/worker.js ADDED
@@ -0,0 +1,647 @@
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
+ import http from "http";
18
+ import Database from "better-sqlite3";
19
+ import { homedir } from "os";
20
+ import { join } from "path";
21
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
22
+ import { DASHBOARD_HTML } from "./dashboard-html.js";
23
+ // ---------------------------------------------------------------------------
24
+ // Config
25
+ // ---------------------------------------------------------------------------
26
+ const VERSION = "0.3.1";
27
+ const PORT = parseInt(process.env.OPENCODE_MEM_PORT ?? "37778", 10);
28
+ const HOST = process.env.OPENCODE_MEM_HOST ?? "127.0.0.1";
29
+ const DATA_DIR = process.env.OPENCODE_MEM_DATA_DIR ?? join(homedir(), ".opencode-mem");
30
+ const DB_PATH = join(DATA_DIR, "opencode-mem.db");
31
+ const startedAt = Date.now();
32
+ if (!existsSync(DATA_DIR))
33
+ mkdirSync(DATA_DIR, { recursive: true });
34
+ // ---------------------------------------------------------------------------
35
+ // Singleton: PID file lock
36
+ // ---------------------------------------------------------------------------
37
+ const PID_FILE = join(DATA_DIR, "worker.pid");
38
+ if (existsSync(PID_FILE)) {
39
+ const existingPid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
40
+ try {
41
+ process.kill(existingPid, 0); // check if alive
42
+ console.log(`[opencode-mem] Worker already running (pid ${existingPid}), exiting`);
43
+ process.exit(0);
44
+ }
45
+ catch {
46
+ // Process dead, stale PID file — continue startup
47
+ }
48
+ }
49
+ writeFileSync(PID_FILE, String(process.pid));
50
+ // ---------------------------------------------------------------------------
51
+ // Database
52
+ // ---------------------------------------------------------------------------
53
+ const db = new Database(DB_PATH);
54
+ db.pragma("journal_mode = WAL");
55
+ db.pragma("foreign_keys = ON");
56
+ db.exec(`
57
+ CREATE TABLE IF NOT EXISTS observations (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ session_id TEXT DEFAULT '',
60
+ project TEXT NOT NULL DEFAULT '',
61
+ type TEXT NOT NULL DEFAULT 'discovery',
62
+ title TEXT DEFAULT '',
63
+ text TEXT DEFAULT '',
64
+ facts TEXT DEFAULT '[]',
65
+ narrative TEXT DEFAULT '',
66
+ concepts TEXT DEFAULT '[]',
67
+ files_read TEXT DEFAULT '[]',
68
+ files_modified TEXT DEFAULT '[]',
69
+ agent TEXT DEFAULT '',
70
+ workflow_id TEXT DEFAULT '',
71
+ task_id TEXT DEFAULT '',
72
+ phase TEXT DEFAULT '',
73
+ signal TEXT DEFAULT 'medium',
74
+ created_at TEXT NOT NULL,
75
+ created_at_epoch INTEGER NOT NULL
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_obs_project ON observations(project);
79
+ CREATE INDEX IF NOT EXISTS idx_obs_type ON observations(type);
80
+ CREATE INDEX IF NOT EXISTS idx_obs_created ON observations(created_at_epoch DESC);
81
+ CREATE INDEX IF NOT EXISTS idx_obs_session ON observations(session_id);
82
+ CREATE INDEX IF NOT EXISTS idx_obs_workflow ON observations(workflow_id);
83
+ CREATE INDEX IF NOT EXISTS idx_obs_agent ON observations(agent);
84
+ CREATE INDEX IF NOT EXISTS idx_obs_signal ON observations(signal);
85
+ `);
86
+ // FTS5 — created separately so "IF NOT EXISTS" works for virtual tables
87
+ try {
88
+ db.exec(`
89
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
90
+ title, text, narrative, facts, concepts,
91
+ content='observations',
92
+ content_rowid='id'
93
+ );
94
+ `);
95
+ }
96
+ catch {
97
+ // Already exists
98
+ }
99
+ // FTS5 sync triggers
100
+ const triggers = [
101
+ `CREATE TRIGGER IF NOT EXISTS obs_fts_ai AFTER INSERT ON observations BEGIN
102
+ INSERT INTO observations_fts(rowid, title, text, narrative, facts, concepts)
103
+ VALUES (new.id, new.title, new.text, new.narrative, new.facts, new.concepts);
104
+ END`,
105
+ `CREATE TRIGGER IF NOT EXISTS obs_fts_ad AFTER DELETE ON observations BEGIN
106
+ INSERT INTO observations_fts(observations_fts, rowid, title, text, narrative, facts, concepts)
107
+ VALUES('delete', old.id, old.title, old.text, old.narrative, old.facts, old.concepts);
108
+ END`,
109
+ `CREATE TRIGGER IF NOT EXISTS obs_fts_au AFTER UPDATE ON observations BEGIN
110
+ INSERT INTO observations_fts(observations_fts, rowid, title, text, narrative, facts, concepts)
111
+ VALUES('delete', old.id, old.title, old.text, old.narrative, old.facts, old.concepts);
112
+ INSERT INTO observations_fts(rowid, title, text, narrative, facts, concepts)
113
+ VALUES (new.id, new.title, new.text, new.narrative, new.facts, new.concepts);
114
+ END`,
115
+ ];
116
+ for (const t of triggers) {
117
+ try {
118
+ db.exec(t);
119
+ }
120
+ catch { /* already exists */ }
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // Prepared statements
124
+ // ---------------------------------------------------------------------------
125
+ const insertObs = db.prepare(`
126
+ INSERT INTO observations
127
+ (session_id, project, type, title, text, facts, narrative, concepts,
128
+ files_read, files_modified, agent, workflow_id, task_id, phase, signal,
129
+ created_at, created_at_epoch)
130
+ VALUES
131
+ (@session_id, @project, @type, @title, @text, @facts, @narrative, @concepts,
132
+ @files_read, @files_modified, @agent, @workflow_id, @task_id, @phase, @signal,
133
+ @created_at, @created_at_epoch)
134
+ `);
135
+ const getObsById = db.prepare(`SELECT * FROM observations WHERE id = ?`);
136
+ const getObsByIds = db.prepare(`
137
+ SELECT * FROM observations WHERE id IN (SELECT value FROM json_each(?))
138
+ ORDER BY created_at_epoch DESC
139
+ `);
140
+ const searchFts = db.prepare(`
141
+ SELECT o.* FROM observations o
142
+ JOIN observations_fts fts ON o.id = fts.rowid
143
+ WHERE observations_fts MATCH ?
144
+ ORDER BY rank
145
+ LIMIT ?
146
+ `);
147
+ const searchByProject = db.prepare(`
148
+ SELECT * FROM observations
149
+ WHERE project = ?
150
+ ORDER BY created_at_epoch DESC
151
+ LIMIT ?
152
+ `);
153
+ const searchByType = db.prepare(`
154
+ SELECT * FROM observations
155
+ WHERE type = ? AND (project = ? OR ? = '')
156
+ ORDER BY created_at_epoch DESC
157
+ LIMIT ?
158
+ `);
159
+ const searchRecent = db.prepare(`
160
+ SELECT * FROM observations
161
+ WHERE (project = ? OR ? = '')
162
+ ORDER BY created_at_epoch DESC
163
+ LIMIT ?
164
+ `);
165
+ const getTimeline = db.prepare(`
166
+ SELECT * FROM observations
167
+ WHERE (project = ? OR ? = '')
168
+ ORDER BY created_at_epoch ASC
169
+ LIMIT 1000
170
+ `);
171
+ const getRecentForContext = db.prepare(`
172
+ SELECT * FROM observations
173
+ WHERE (project = ? OR ? = '')
174
+ ORDER BY created_at_epoch DESC
175
+ LIMIT ?
176
+ `);
177
+ const getActivityByProject = db.prepare(`
178
+ SELECT * FROM observations
179
+ WHERE (project = ? OR ? = '')
180
+ ORDER BY created_at_epoch DESC
181
+ LIMIT ?
182
+ `);
183
+ // ---------------------------------------------------------------------------
184
+ // Helpers
185
+ // ---------------------------------------------------------------------------
186
+ function json(res, data, status = 200) {
187
+ res.writeHead(status, { "Content-Type": "application/json" });
188
+ res.end(JSON.stringify(data));
189
+ }
190
+ function text(res, data, status = 200) {
191
+ res.writeHead(status, { "Content-Type": "text/plain" });
192
+ res.end(data);
193
+ }
194
+ async function readBody(req, maxBytes = 65536) {
195
+ const chunks = [];
196
+ let total = 0;
197
+ for await (const chunk of req) {
198
+ total += chunk.length;
199
+ if (total > maxBytes)
200
+ throw new Error("Payload too large");
201
+ chunks.push(chunk);
202
+ }
203
+ return Buffer.concat(chunks).toString("utf-8");
204
+ }
205
+ function parseQuery(url) {
206
+ const q = {};
207
+ url.searchParams.forEach((v, k) => { q[k] = v; });
208
+ return q;
209
+ }
210
+ function sanitizeFtsQuery(query) {
211
+ // Escape special FTS5 characters, wrap terms for prefix matching
212
+ return query
213
+ .replace(/[*"():^]/g, " ")
214
+ .trim()
215
+ .split(/\s+/)
216
+ .filter(Boolean)
217
+ .map((term) => `"${term}"`)
218
+ .join(" ");
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Dashboard
222
+ // ---------------------------------------------------------------------------
223
+ function handleDashboard(res) {
224
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
225
+ res.end(DASHBOARD_HTML);
226
+ }
227
+ // ---------------------------------------------------------------------------
228
+ // Route handlers
229
+ // ---------------------------------------------------------------------------
230
+ function handleHealth(res) {
231
+ json(res, {
232
+ status: "ok",
233
+ version: VERSION,
234
+ port: PORT,
235
+ dataDir: DATA_DIR,
236
+ uptime: Date.now() - startedAt,
237
+ pid: process.pid,
238
+ });
239
+ }
240
+ function handleSearch(res, params) {
241
+ const query = params.query ?? "";
242
+ const project = params.project ?? "";
243
+ const type = params.type ?? "";
244
+ const limit = Math.min(parseInt(params.limit ?? "20", 10), 100);
245
+ const dateStart = params.dateStart ?? "";
246
+ const dateEnd = params.dateEnd ?? "";
247
+ const sinceId = parseInt(params.since_id ?? "0", 10) || 0;
248
+ const agentFilter = params.agent ?? "";
249
+ let rows;
250
+ if (query) {
251
+ const sanitized = sanitizeFtsQuery(query);
252
+ if (!sanitized) {
253
+ rows = searchRecent.all(project, project, limit);
254
+ }
255
+ else {
256
+ try {
257
+ rows = searchFts.all(sanitized, limit);
258
+ // Post-filter by project if specified
259
+ if (project) {
260
+ rows = rows.filter((r) => r.project === project);
261
+ }
262
+ }
263
+ catch {
264
+ // FTS query failed — fall back to recent
265
+ rows = searchRecent.all(project, project, limit);
266
+ }
267
+ }
268
+ }
269
+ else if (type) {
270
+ rows = searchByType.all(type, project, project, limit);
271
+ }
272
+ else if (project) {
273
+ rows = searchByProject.all(project, limit);
274
+ }
275
+ else {
276
+ rows = searchRecent.all("", "", limit);
277
+ }
278
+ // Post-filters: since_id, agent, date
279
+ if (sinceId > 0) {
280
+ rows = rows.filter((r) => r.id > sinceId);
281
+ }
282
+ if (agentFilter) {
283
+ rows = rows.filter((r) => r.agent === agentFilter);
284
+ }
285
+ if (dateStart || dateEnd) {
286
+ const start = dateStart ? new Date(dateStart).getTime() : 0;
287
+ const end = dateEnd ? new Date(dateEnd).getTime() : Infinity;
288
+ rows = rows.filter((r) => r.created_at_epoch >= start && r.created_at_epoch <= end);
289
+ }
290
+ json(res, {
291
+ observations: rows.slice(0, limit),
292
+ totalResults: rows.length,
293
+ query,
294
+ });
295
+ }
296
+ function handleTimeline(res, params) {
297
+ const anchorStr = params.anchor ?? "";
298
+ const query = params.query ?? "";
299
+ const depthBefore = Math.min(parseInt(params.depth_before ?? "10", 10), 50);
300
+ const depthAfter = Math.min(parseInt(params.depth_after ?? "10", 10), 50);
301
+ const project = params.project ?? "";
302
+ const allObs = getTimeline.all(project, project);
303
+ if (allObs.length === 0) {
304
+ text(res, "No observations found.");
305
+ return;
306
+ }
307
+ // Find anchor position
308
+ let anchorIdx = -1;
309
+ if (anchorStr) {
310
+ const anchorId = parseInt(anchorStr, 10);
311
+ if (!isNaN(anchorId)) {
312
+ anchorIdx = allObs.findIndex((o) => o.id === anchorId);
313
+ }
314
+ }
315
+ if (anchorIdx === -1 && query) {
316
+ // Search for anchor via FTS
317
+ const sanitized = sanitizeFtsQuery(query);
318
+ if (sanitized) {
319
+ try {
320
+ const match = searchFts.get(sanitized, 1);
321
+ if (match) {
322
+ anchorIdx = allObs.findIndex((o) => o.id === match.id);
323
+ }
324
+ }
325
+ catch { /* ignore */ }
326
+ }
327
+ }
328
+ if (anchorIdx === -1) {
329
+ anchorIdx = allObs.length - 1; // default to most recent
330
+ }
331
+ // Extract window
332
+ const start = Math.max(0, anchorIdx - depthBefore);
333
+ const end = Math.min(allObs.length, anchorIdx + depthAfter + 1);
334
+ const window = allObs.slice(start, end);
335
+ // Format as text
336
+ const typeEmoji = {
337
+ bugfix: "bugfix",
338
+ feature: "feature",
339
+ refactor: "refactor",
340
+ discovery: "discovery",
341
+ decision: "decision",
342
+ change: "change",
343
+ contract: "contract",
344
+ blocker: "blocker",
345
+ "test-result": "test-result",
346
+ "review-finding": "review",
347
+ };
348
+ const lines = [
349
+ `# Timeline`,
350
+ `Anchor: #${allObs[anchorIdx]?.id ?? "?"} | Window: ${depthBefore} before, ${depthAfter} after | Items: ${window.length}`,
351
+ "",
352
+ ];
353
+ let currentDay = "";
354
+ for (const obs of window) {
355
+ const d = new Date(obs.created_at_epoch);
356
+ const day = d.toISOString().slice(0, 10);
357
+ if (day !== currentDay) {
358
+ currentDay = day;
359
+ lines.push(`## ${day}`, "");
360
+ }
361
+ const time = d.toISOString().slice(11, 16);
362
+ const marker = obs.id === allObs[anchorIdx]?.id ? " <-- anchor" : "";
363
+ const t = typeEmoji[obs.type] ?? obs.type;
364
+ lines.push(`- **${time}** [${t}] #${obs.id}: ${obs.title || obs.text?.substring(0, 80) || "(no title)"}${marker}`);
365
+ }
366
+ text(res, lines.join("\n"));
367
+ }
368
+ function handleObservationsBatch(res, body) {
369
+ const ids = (body.ids ?? []).filter((id) => typeof id === 'number' && Number.isInteger(id));
370
+ if (ids.length === 0) {
371
+ json(res, { observations: [] });
372
+ return;
373
+ }
374
+ const rows = getObsByIds.all(JSON.stringify(ids));
375
+ json(res, { observations: rows });
376
+ }
377
+ const VALID_TYPES = new Set([
378
+ "discovery", "decision", "bugfix", "feature", "refactor", "change",
379
+ "contract", "blocker", "test-result", "review-finding",
380
+ ]);
381
+ const MAX_TEXT_LENGTH = 8192;
382
+ const MAX_NARRATIVE_LENGTH = 4096;
383
+ const MAX_TITLE_LENGTH = 500;
384
+ function sanitizeText(s, max) {
385
+ return s.substring(0, max).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
386
+ }
387
+ function handleMemorySave(res, body) {
388
+ const now = Date.now();
389
+ const type = VALID_TYPES.has(body.type) ? body.type : "discovery";
390
+ let result;
391
+ try {
392
+ result = insertObs.run({
393
+ session_id: body.sessionId ?? "",
394
+ project: body.project ?? "",
395
+ type,
396
+ title: sanitizeText(String(body.title ?? ""), MAX_TITLE_LENGTH),
397
+ text: sanitizeText(String(body.text ?? ""), MAX_TEXT_LENGTH),
398
+ facts: JSON.stringify(body.facts ?? []),
399
+ narrative: sanitizeText(String(body.narrative ?? ""), MAX_NARRATIVE_LENGTH),
400
+ concepts: JSON.stringify(body.concepts ?? []),
401
+ files_read: JSON.stringify(body.files_read ?? []),
402
+ files_modified: JSON.stringify(body.files_modified ?? []),
403
+ agent: body.agent ?? body.metadata?.agent?.agentName ?? "",
404
+ workflow_id: body.workflow_id ?? body.metadata?.agent?.workflowId ?? "",
405
+ task_id: body.task_id ?? body.metadata?.agent?.taskId ?? "",
406
+ phase: body.phase ?? body.metadata?.agent?.phase ?? "",
407
+ signal: body.signal ?? body.metadata?.signal ?? "medium",
408
+ created_at: new Date(now).toISOString(),
409
+ created_at_epoch: now,
410
+ });
411
+ }
412
+ catch (err) {
413
+ const msg = err instanceof Error ? err.message : "DB write failed";
414
+ json(res, { error: `Failed to save observation: ${msg}` }, 503);
415
+ return;
416
+ }
417
+ json(res, { id: result.lastInsertRowid, status: "saved" });
418
+ }
419
+ function handleToolResult(res, body) {
420
+ const now = Date.now();
421
+ const toolName = sanitizeText(String(body.tool ?? "unknown"), 100);
422
+ const argsStr = typeof body.args === "string"
423
+ ? body.args.substring(0, 200)
424
+ : JSON.stringify(body.args ?? {}).substring(0, 200);
425
+ // Extract files_modified from body if provided (populated by plugin auto-capture)
426
+ const filesModified = Array.isArray(body.files_modified)
427
+ ? JSON.stringify(body.files_modified)
428
+ : "[]";
429
+ let result;
430
+ try {
431
+ result = insertObs.run({
432
+ session_id: body.sessionId ?? "",
433
+ project: body.project ?? "",
434
+ type: "change",
435
+ title: sanitizeText(`[tool] ${toolName}`, MAX_TITLE_LENGTH),
436
+ text: sanitizeText(String(body.output ?? ""), MAX_TEXT_LENGTH),
437
+ facts: "[]",
438
+ narrative: sanitizeText(`Tool ${toolName} called with: ${argsStr}`, MAX_NARRATIVE_LENGTH),
439
+ concepts: "[]",
440
+ files_read: "[]",
441
+ files_modified: filesModified,
442
+ agent: body.metadata?.agent?.agentName ?? "",
443
+ workflow_id: body.metadata?.agent?.workflowId ?? "",
444
+ task_id: body.metadata?.agent?.taskId ?? "",
445
+ phase: body.metadata?.agent?.phase ?? "",
446
+ signal: body.metadata?.signal ?? "medium",
447
+ created_at: new Date(now).toISOString(),
448
+ created_at_epoch: now,
449
+ });
450
+ }
451
+ catch (err) {
452
+ const msg = err instanceof Error ? err.message : "DB write failed";
453
+ json(res, { error: `Failed to capture tool result: ${msg}` }, 503);
454
+ return;
455
+ }
456
+ json(res, { id: result.lastInsertRowid, status: "captured" });
457
+ }
458
+ function handleContextSession(res, params) {
459
+ const project = params.project ?? "";
460
+ const limit = Math.min(parseInt(params.limit ?? "10", 10), 30);
461
+ const agentFilter = params.agent ?? "";
462
+ let rows = getRecentForContext.all(project, project, limit);
463
+ if (agentFilter) {
464
+ rows = rows.filter((r) => r.agent === agentFilter);
465
+ }
466
+ if (rows.length === 0) {
467
+ text(res, "");
468
+ return;
469
+ }
470
+ const MAX_ITEM_LENGTH = 300;
471
+ const lines = [
472
+ `# [opencode-mem] Recent context (${rows.length} observations)`,
473
+ "",
474
+ ];
475
+ // Group by type
476
+ const byType = {};
477
+ for (const r of rows) {
478
+ (byType[r.type] ??= []).push(r);
479
+ }
480
+ for (const [type, obs] of Object.entries(byType)) {
481
+ lines.push(`## ${type} (${obs.length})`);
482
+ for (const o of obs) {
483
+ const agentTag = o.agent ? ` [${o.agent}]` : "";
484
+ const content = o.title || o.text?.substring(0, MAX_ITEM_LENGTH) || "(empty)";
485
+ lines.push(`- #${o.id}${agentTag}: ${content.substring(0, MAX_ITEM_LENGTH)}`);
486
+ }
487
+ lines.push("");
488
+ }
489
+ text(res, lines.join("\n"));
490
+ }
491
+ function handleActivity(res, params) {
492
+ const project = params.project ?? "";
493
+ const sinceId = parseInt(params.since_id ?? "0", 10) || 0;
494
+ const limit = Math.min(parseInt(params.limit ?? "100", 10), 500);
495
+ let rows = getActivityByProject.all(project, project, limit);
496
+ // Filter by since_id for incremental polling
497
+ if (sinceId > 0) {
498
+ rows = rows.filter((r) => r.id > sinceId);
499
+ }
500
+ // Group by agent
501
+ const agentSets = {};
502
+ for (const r of rows) {
503
+ const agentName = r.agent || "unknown";
504
+ if (!agentSets[agentName]) {
505
+ agentSets[agentName] = { observations: [], files_modified: new Set(), last_active: 0 };
506
+ }
507
+ const agent = agentSets[agentName];
508
+ agent.observations.push({
509
+ id: r.id,
510
+ type: r.type,
511
+ title: r.title,
512
+ signal: r.signal,
513
+ phase: r.phase,
514
+ task_id: r.task_id,
515
+ created_at_epoch: r.created_at_epoch,
516
+ });
517
+ if (r.created_at_epoch > agent.last_active) {
518
+ agent.last_active = r.created_at_epoch;
519
+ }
520
+ // Roll up files_modified from JSON arrays
521
+ try {
522
+ const files = JSON.parse(r.files_modified || "[]");
523
+ if (Array.isArray(files)) {
524
+ for (const f of files) {
525
+ if (f)
526
+ agent.files_modified.add(f);
527
+ }
528
+ }
529
+ }
530
+ catch { /* malformed JSON — skip */ }
531
+ }
532
+ // Convert Sets to arrays for JSON serialization
533
+ const agents = {};
534
+ for (const [name, data] of Object.entries(agentSets)) {
535
+ agents[name] = {
536
+ observations: data.observations,
537
+ files_modified: Array.from(data.files_modified),
538
+ last_active: data.last_active,
539
+ };
540
+ }
541
+ json(res, {
542
+ project,
543
+ since_id: sinceId,
544
+ total_observations: rows.length,
545
+ agents,
546
+ });
547
+ }
548
+ // ---------------------------------------------------------------------------
549
+ // HTTP Server
550
+ // ---------------------------------------------------------------------------
551
+ const server = http.createServer(async (req, res) => {
552
+ // CORS for local tools
553
+ res.setHeader("Access-Control-Allow-Origin", "*");
554
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
555
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
556
+ if (req.method === "OPTIONS") {
557
+ res.writeHead(204);
558
+ res.end();
559
+ return;
560
+ }
561
+ const url = new URL(req.url ?? "/", `http://${HOST}:${PORT}`);
562
+ const path = url.pathname;
563
+ const params = parseQuery(url);
564
+ try {
565
+ // GET routes
566
+ if (req.method === "GET") {
567
+ if (path === "/" || path === "/dashboard") {
568
+ return handleDashboard(res);
569
+ }
570
+ if (path === "/api/health" || path === "/api/readiness") {
571
+ return handleHealth(res);
572
+ }
573
+ if (path === "/api/search") {
574
+ return handleSearch(res, params);
575
+ }
576
+ if (path === "/api/timeline") {
577
+ return handleTimeline(res, params);
578
+ }
579
+ if (path === "/api/context/session") {
580
+ return handleContextSession(res, params);
581
+ }
582
+ if (path === "/api/activity") {
583
+ return handleActivity(res, params);
584
+ }
585
+ }
586
+ // POST routes
587
+ if (req.method === "POST") {
588
+ let rawBody;
589
+ try {
590
+ rawBody = await readBody(req);
591
+ }
592
+ catch (err) {
593
+ json(res, { error: "Payload too large" }, 413);
594
+ return;
595
+ }
596
+ let body;
597
+ try {
598
+ body = rawBody ? JSON.parse(rawBody) : {};
599
+ }
600
+ catch {
601
+ json(res, { error: "Invalid JSON body" }, 400);
602
+ return;
603
+ }
604
+ if (path === "/api/observations/batch") {
605
+ return handleObservationsBatch(res, body);
606
+ }
607
+ if (path === "/api/memory/save") {
608
+ return handleMemorySave(res, body);
609
+ }
610
+ if (path === "/api/session/tool-result") {
611
+ return handleToolResult(res, body);
612
+ }
613
+ }
614
+ // 404
615
+ json(res, { error: "Not found" }, 404);
616
+ }
617
+ catch (err) {
618
+ const msg = err instanceof Error ? err.message : "Internal error";
619
+ console.error(`[opencode-mem] Error on ${path}:`, msg);
620
+ json(res, { error: msg }, 500);
621
+ }
622
+ });
623
+ server.listen(PORT, HOST, () => {
624
+ console.log(`[opencode-mem] Worker listening on http://${HOST}:${PORT}`);
625
+ console.log(`[opencode-mem] Database: ${DB_PATH}`);
626
+ });
627
+ // Graceful shutdown
628
+ function cleanup() {
629
+ try {
630
+ unlinkSync(PID_FILE);
631
+ }
632
+ catch { }
633
+ db.close();
634
+ }
635
+ process.on("SIGTERM", () => {
636
+ console.log("[opencode-mem] Shutting down...");
637
+ server.close(() => {
638
+ cleanup();
639
+ process.exit(0);
640
+ });
641
+ });
642
+ process.on("SIGINT", () => {
643
+ server.close(() => {
644
+ cleanup();
645
+ process.exit(0);
646
+ });
647
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "opencode-mem-agents",
3
+ "version": "0.3.1",
4
+ "description": "Self-contained memory plugin for OpenCode — cross-session persistence with SQLite + FTS5, signal-based capture, role-scoped context, and agent coordination",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build:dashboard": "cd dashboard && npm run build",
19
+ "embed": "node scripts/embed-dashboard.js",
20
+ "build": "npm run build:dashboard && npm run embed && tsc",
21
+ "dev": "tsc --watch",
22
+ "test": "node test/smoke.mjs",
23
+ "postinstall": "sed -i'' -e 's|from \"./tool\"|from \"./tool.js\"|' node_modules/@opencode-ai/plugin/dist/index.js 2>/dev/null || true",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "dependencies": {
27
+ "@opencode-ai/plugin": "^1.2.6",
28
+ "better-sqlite3": "^12.6.2"
29
+ },
30
+ "devDependencies": {
31
+ "@types/better-sqlite3": "^7.6.13",
32
+ "@types/node": "^22.0.0",
33
+ "typescript": "^5.8.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "license": "MIT"
39
+ }