opencode-mem-agents 0.3.1 → 0.3.2
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/README.md +97 -0
- package/dist/contracts.d.ts +175 -0
- package/dist/contracts.js +170 -0
- package/dist/dashboard-html.d.ts +1 -1
- package/dist/dashboard-html.js +12 -12
- package/dist/index.js +273 -73
- package/dist/worker/config.d.ts +14 -0
- package/dist/worker/config.js +44 -0
- package/dist/worker/http.d.ts +23 -0
- package/dist/worker/http.js +130 -0
- package/dist/worker/repository.d.ts +61 -0
- package/dist/worker/repository.js +535 -0
- package/dist/worker/routes.d.ts +31 -0
- package/dist/worker/routes.js +251 -0
- package/dist/worker/server.d.ts +1 -0
- package/dist/worker/server.js +177 -0
- package/dist/worker/utils.d.ts +8 -0
- package/dist/worker/utils.js +98 -0
- package/dist/worker.d.ts +0 -16
- package/dist/worker.js +2 -647
- package/package.json +4 -2
package/dist/worker.js
CHANGED
|
@@ -1,647 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
});
|
|
1
|
+
import { startWorkerServer } from "./worker/server.js";
|
|
2
|
+
startWorkerServer();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-mem-agents",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Self-contained memory plugin for OpenCode — cross-session persistence with SQLite + FTS5, signal-based capture, role-scoped context, and agent coordination",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"build:dashboard": "cd dashboard && npm run build",
|
|
19
19
|
"embed": "node scripts/embed-dashboard.js",
|
|
20
20
|
"build": "npm run build:dashboard && npm run embed && tsc",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
21
22
|
"dev": "tsc --watch",
|
|
22
23
|
"test": "node test/smoke.mjs",
|
|
23
24
|
"postinstall": "sed -i'' -e 's|from \"./tool\"|from \"./tool.js\"|' node_modules/@opencode-ai/plugin/dist/index.js 2>/dev/null || true",
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
28
|
"@opencode-ai/plugin": "^1.2.6",
|
|
28
|
-
"better-sqlite3": "^12.6.2"
|
|
29
|
+
"better-sqlite3": "^12.6.2",
|
|
30
|
+
"zod": "^4.3.6"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
31
33
|
"@types/better-sqlite3": "^7.6.13",
|