codemem 0.1.0 → 0.20.0-alpha.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/.opencode/lib/compat.js +171 -0
- package/.opencode/plugin/codemem.js +1851 -0
- package/dist/commands/claude-hook-ingest.d.ts +15 -0
- package/dist/commands/claude-hook-ingest.d.ts.map +1 -0
- package/dist/commands/db.d.ts +3 -0
- package/dist/commands/db.d.ts.map +1 -0
- package/dist/commands/enqueue-raw-event.d.ts +3 -0
- package/dist/commands/enqueue-raw-event.d.ts.map +1 -0
- package/dist/commands/export-memories.d.ts +3 -0
- package/dist/commands/export-memories.d.ts.map +1 -0
- package/dist/commands/import-memories.d.ts +3 -0
- package/dist/commands/import-memories.d.ts.map +1 -0
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/memory.d.ts +10 -0
- package/dist/commands/memory.d.ts.map +1 -0
- package/dist/commands/pack.d.ts +3 -0
- package/dist/commands/pack.d.ts.map +1 -0
- package/dist/commands/recent.d.ts +3 -0
- package/dist/commands/recent.d.ts.map +1 -0
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.d.ts.map +1 -0
- package/dist/commands/serve.d.ts +3 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/setup.d.ts +15 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/stats.d.ts +3 -0
- package/dist/commands/stats.d.ts.map +1 -0
- package/dist/commands/sync.d.ts +6 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/version.d.ts +3 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/help-style.d.ts +9 -0
- package/dist/help-style.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1076 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -24
- package/README.md +0 -5
- package/index.js +0 -6
package/dist/index.js
ADDED
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { MemoryStore, ObserverClient, RawEventSweeper, VERSION, buildRawEventEnvelopeFromHook, connect, ensureDeviceIdentity, exportMemories, getRawEventStatus, importMemories, initDatabase, loadSqliteVec, rawEventsGate, readCodememConfigFile, readImportPayload, resolveDbPath, resolveProject, retryRawEventFailures, runSyncDaemon, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, vacuumDatabase, writeCodememConfigFile } from "@codemem/core";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import omelette from "omelette";
|
|
5
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { styleText } from "node:util";
|
|
7
|
+
import * as p from "@clack/prompts";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { desc } from "drizzle-orm";
|
|
11
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
12
|
+
//#region src/help-style.ts
|
|
13
|
+
/**
|
|
14
|
+
* Shared Commander help style configuration.
|
|
15
|
+
*
|
|
16
|
+
* Applied to every Command instance so subcommand --help
|
|
17
|
+
* output gets the same colors as the root.
|
|
18
|
+
*/
|
|
19
|
+
var helpStyle = {
|
|
20
|
+
styleTitle: (str) => styleText("bold", str),
|
|
21
|
+
styleCommandText: (str) => styleText("cyan", str),
|
|
22
|
+
styleCommandDescription: (str) => str,
|
|
23
|
+
styleDescriptionText: (str) => styleText("dim", str),
|
|
24
|
+
styleOptionText: (str) => styleText("green", str),
|
|
25
|
+
styleOptionTerm: (str) => styleText("green", str),
|
|
26
|
+
styleSubcommandText: (str) => styleText("cyan", str),
|
|
27
|
+
styleArgumentText: (str) => styleText("yellow", str)
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/commands/claude-hook-ingest.ts
|
|
31
|
+
/**
|
|
32
|
+
* codemem claude-hook-ingest — read a single Claude Code hook payload
|
|
33
|
+
* from stdin and enqueue it for processing.
|
|
34
|
+
*
|
|
35
|
+
* Ports codemem/commands/claude_hook_runtime_cmds.py with an HTTP-first
|
|
36
|
+
* strategy: try POST /api/claude-hooks (viewer must be running), then
|
|
37
|
+
* fall back to direct raw-event enqueue via the local store.
|
|
38
|
+
*
|
|
39
|
+
* Usage (from Claude hooks config):
|
|
40
|
+
* echo '{"hook_event_name":"Stop","session_id":"...","last_assistant_message":"..."}' \
|
|
41
|
+
* | codemem claude-hook-ingest
|
|
42
|
+
*/
|
|
43
|
+
/** Try to POST the hook payload to the running viewer server. */
|
|
44
|
+
async function tryHttpIngest(payload, host, port) {
|
|
45
|
+
const url = `http://${host}:${port}/api/claude-hooks`;
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(url, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify(payload),
|
|
53
|
+
signal: controller.signal
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) return {
|
|
56
|
+
ok: false,
|
|
57
|
+
inserted: 0,
|
|
58
|
+
skipped: 0
|
|
59
|
+
};
|
|
60
|
+
const body = await res.json();
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
inserted: Number(body.inserted ?? 0),
|
|
64
|
+
skipped: Number(body.skipped ?? 0)
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
inserted: 0,
|
|
70
|
+
skipped: 0
|
|
71
|
+
};
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timeout);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Fall back to direct raw-event enqueue via the local SQLite store. */
|
|
77
|
+
function directEnqueue(payload, dbPath) {
|
|
78
|
+
const envelope = buildRawEventEnvelopeFromHook(payload);
|
|
79
|
+
if (!envelope) return {
|
|
80
|
+
inserted: 0,
|
|
81
|
+
skipped: 1
|
|
82
|
+
};
|
|
83
|
+
const db = connect(dbPath);
|
|
84
|
+
try {
|
|
85
|
+
try {
|
|
86
|
+
loadSqliteVec(db);
|
|
87
|
+
} catch {}
|
|
88
|
+
const strippedPayload = stripPrivateObj(envelope.payload);
|
|
89
|
+
if (db.prepare("SELECT 1 FROM raw_events WHERE source = ? AND stream_id = ? AND event_id = ? LIMIT 1").get(envelope.source, envelope.session_stream_id, envelope.event_id)) return {
|
|
90
|
+
inserted: 0,
|
|
91
|
+
skipped: 0
|
|
92
|
+
};
|
|
93
|
+
db.prepare(`INSERT INTO raw_events(
|
|
94
|
+
source, stream_id, opencode_session_id, event_id, event_seq,
|
|
95
|
+
event_type, ts_wall_ms, payload_json, created_at
|
|
96
|
+
) VALUES (?, ?, ?, ?, (
|
|
97
|
+
SELECT COALESCE(MAX(event_seq), 0) + 1
|
|
98
|
+
FROM raw_events WHERE source = ? AND stream_id = ?
|
|
99
|
+
), ?, ?, ?, datetime('now'))`).run(envelope.source, envelope.session_stream_id, envelope.opencode_session_id, envelope.event_id, envelope.source, envelope.session_stream_id, "claude.hook", envelope.ts_wall_ms, JSON.stringify(strippedPayload));
|
|
100
|
+
const currentMaxSeq = db.prepare("SELECT COALESCE(MAX(event_seq), 0) AS max_seq FROM raw_events WHERE source = ? AND stream_id = ?").get(envelope.source, envelope.session_stream_id).max_seq;
|
|
101
|
+
db.prepare(`INSERT INTO raw_event_sessions(
|
|
102
|
+
source, stream_id, opencode_session_id, cwd, project, started_at,
|
|
103
|
+
last_seen_ts_wall_ms, last_received_event_seq, last_flushed_event_seq, updated_at
|
|
104
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, -1, datetime('now'))
|
|
105
|
+
ON CONFLICT(source, stream_id) DO UPDATE SET
|
|
106
|
+
cwd = COALESCE(excluded.cwd, cwd),
|
|
107
|
+
project = COALESCE(excluded.project, project),
|
|
108
|
+
started_at = COALESCE(excluded.started_at, started_at),
|
|
109
|
+
last_seen_ts_wall_ms = MAX(COALESCE(excluded.last_seen_ts_wall_ms, 0), COALESCE(last_seen_ts_wall_ms, 0)),
|
|
110
|
+
last_received_event_seq = MAX(excluded.last_received_event_seq, last_received_event_seq),
|
|
111
|
+
updated_at = datetime('now')`).run(envelope.source, envelope.session_stream_id, envelope.opencode_session_id, envelope.cwd, envelope.project, envelope.started_at, envelope.ts_wall_ms, currentMaxSeq);
|
|
112
|
+
return {
|
|
113
|
+
inserted: 1,
|
|
114
|
+
skipped: 0
|
|
115
|
+
};
|
|
116
|
+
} finally {
|
|
117
|
+
db.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
var claudeHookIngestCommand = new Command("claude-hook-ingest").configureHelp(helpStyle).description("Ingest a Claude Code hook payload from stdin").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--host <host>", "viewer server host", "127.0.0.1").option("--port <port>", "viewer server port", "38888").action(async (opts) => {
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = readFileSync(0, "utf8").trim();
|
|
124
|
+
} catch {
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!raw) {
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let payload;
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(raw);
|
|
135
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
payload = parsed;
|
|
140
|
+
} catch {
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const port = Number.parseInt(opts.port, 10);
|
|
145
|
+
const host = opts.host;
|
|
146
|
+
const httpResult = await tryHttpIngest(payload, host, port);
|
|
147
|
+
if (httpResult.ok) {
|
|
148
|
+
console.log(JSON.stringify({
|
|
149
|
+
inserted: httpResult.inserted,
|
|
150
|
+
skipped: httpResult.skipped,
|
|
151
|
+
via: "http"
|
|
152
|
+
}));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const dbPath = resolveDbPath(opts.db);
|
|
157
|
+
const directResult = directEnqueue(payload, dbPath);
|
|
158
|
+
console.log(JSON.stringify({
|
|
159
|
+
...directResult,
|
|
160
|
+
via: "direct"
|
|
161
|
+
}));
|
|
162
|
+
} catch {
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/commands/db.ts
|
|
168
|
+
var dbCommand = new Command("db").configureHelp(helpStyle).description("Database maintenance");
|
|
169
|
+
dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("Verify the SQLite database is present and schema-ready").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").action((opts) => {
|
|
170
|
+
const result = initDatabase(opts.db);
|
|
171
|
+
p.intro("codemem db init");
|
|
172
|
+
p.log.success(`Database ready: ${result.path}`);
|
|
173
|
+
p.outro(`Size: ${result.sizeBytes.toLocaleString()} bytes`);
|
|
174
|
+
})).addCommand(new Command("vacuum").configureHelp(helpStyle).description("Run VACUUM on the SQLite database").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").action((opts) => {
|
|
175
|
+
const result = vacuumDatabase(opts.db);
|
|
176
|
+
p.intro("codemem db vacuum");
|
|
177
|
+
p.log.success(`Vacuumed: ${result.path}`);
|
|
178
|
+
p.outro(`Size: ${result.sizeBytes.toLocaleString()} bytes`);
|
|
179
|
+
})).addCommand(new Command("raw-events-status").configureHelp(helpStyle).description("Show pending raw-event backlog by source stream").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("-n, --limit <n>", "max rows to show", "25").option("--json", "output as JSON").action((opts) => {
|
|
180
|
+
const result = getRawEventStatus(opts.db, Number.parseInt(opts.limit, 10) || 25);
|
|
181
|
+
if (opts.json) {
|
|
182
|
+
console.log(JSON.stringify(result, null, 2));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
p.intro("codemem db raw-events-status");
|
|
186
|
+
p.log.info(`Totals: ${result.totals.pending.toLocaleString()} pending across ${result.totals.sessions.toLocaleString()} session(s)`);
|
|
187
|
+
if (result.items.length === 0) {
|
|
188
|
+
p.outro("No pending raw events");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const item of result.items) p.log.message(`${item.source}:${item.stream_id} pending=${Math.max(0, item.last_received_event_seq - item.last_flushed_event_seq)} received=${item.last_received_event_seq} flushed=${item.last_flushed_event_seq} project=${item.project ?? ""}`);
|
|
192
|
+
p.outro("done");
|
|
193
|
+
})).addCommand(new Command("raw-events-retry").configureHelp(helpStyle).description("Requeue failed raw-event flush batches").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("-n, --limit <n>", "max failed batches to requeue", "25").action((opts) => {
|
|
194
|
+
const result = retryRawEventFailures(opts.db, Number.parseInt(opts.limit, 10) || 25);
|
|
195
|
+
p.intro("codemem db raw-events-retry");
|
|
196
|
+
p.outro(`Requeued ${result.retried.toLocaleString()} failed batch(es)`);
|
|
197
|
+
})).addCommand(new Command("raw-events-gate").configureHelp(helpStyle).description("Validate raw-event reliability thresholds (non-zero exit on failure)").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--min-flush-success-rate <rate>", "minimum flush success rate", "0.95").option("--max-dropped-event-rate <rate>", "maximum dropped event rate", "0.05").option("--min-session-boundary-accuracy <rate>", "minimum session boundary accuracy", "0.9").option("--window-hours <hours>", "lookback window in hours", "24").option("--json", "output as JSON").action((opts) => {
|
|
198
|
+
const result = rawEventsGate(opts.db, {
|
|
199
|
+
minFlushSuccessRate: Number.parseFloat(opts.minFlushSuccessRate),
|
|
200
|
+
maxDroppedEventRate: Number.parseFloat(opts.maxDroppedEventRate),
|
|
201
|
+
minSessionBoundaryAccuracy: Number.parseFloat(opts.minSessionBoundaryAccuracy),
|
|
202
|
+
windowHours: Number.parseFloat(opts.windowHours)
|
|
203
|
+
});
|
|
204
|
+
if (opts.json) {
|
|
205
|
+
console.log(JSON.stringify(result, null, 2));
|
|
206
|
+
if (!result.passed) process.exitCode = 1;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
p.intro("codemem db raw-events-gate");
|
|
210
|
+
p.log.info([
|
|
211
|
+
`flush_success_rate: ${result.metrics.rates.flush_success_rate.toFixed(4)}`,
|
|
212
|
+
`dropped_event_rate: ${result.metrics.rates.dropped_event_rate.toFixed(4)}`,
|
|
213
|
+
`session_boundary_accuracy: ${result.metrics.rates.session_boundary_accuracy.toFixed(4)}`,
|
|
214
|
+
`window_hours: ${result.metrics.window_hours ?? "all"}`
|
|
215
|
+
].join("\n"));
|
|
216
|
+
if (result.passed) p.outro("reliability gate passed");
|
|
217
|
+
else {
|
|
218
|
+
for (const f of result.failures) p.log.error(f);
|
|
219
|
+
p.outro("reliability gate FAILED");
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
}
|
|
222
|
+
}));
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/commands/enqueue-raw-event.ts
|
|
225
|
+
var SESSION_ID_KEYS = [
|
|
226
|
+
"session_stream_id",
|
|
227
|
+
"session_id",
|
|
228
|
+
"stream_id",
|
|
229
|
+
"opencode_session_id"
|
|
230
|
+
];
|
|
231
|
+
function resolveSessionStreamId(payload) {
|
|
232
|
+
const values = /* @__PURE__ */ new Map();
|
|
233
|
+
for (const key of SESSION_ID_KEYS) {
|
|
234
|
+
const value = payload[key];
|
|
235
|
+
if (typeof value !== "string") continue;
|
|
236
|
+
const text = value.trim();
|
|
237
|
+
if (text) values.set(key, text);
|
|
238
|
+
}
|
|
239
|
+
if (values.size === 0) return null;
|
|
240
|
+
if (new Set(values.values()).size > 1) throw new Error("conflicting session id fields");
|
|
241
|
+
for (const key of SESSION_ID_KEYS) {
|
|
242
|
+
const value = values.get(key);
|
|
243
|
+
if (value) return value;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
async function readStdinJson() {
|
|
248
|
+
const chunks = [];
|
|
249
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
250
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
251
|
+
if (!raw) throw new Error("stdin JSON required");
|
|
252
|
+
const parsed = JSON.parse(raw);
|
|
253
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("payload must be an object");
|
|
254
|
+
return parsed;
|
|
255
|
+
}
|
|
256
|
+
var enqueueRawEventCommand = new Command("enqueue-raw-event").configureHelp(helpStyle).description("Enqueue one raw event from stdin into the durable queue").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").action(async (opts) => {
|
|
257
|
+
const payload = await readStdinJson();
|
|
258
|
+
const sessionId = resolveSessionStreamId(payload);
|
|
259
|
+
if (!sessionId) throw new Error("session id required");
|
|
260
|
+
if (sessionId.startsWith("msg_")) throw new Error("invalid session id");
|
|
261
|
+
const eventType = typeof payload.event_type === "string" ? payload.event_type.trim() : "";
|
|
262
|
+
if (!eventType) throw new Error("event_type required");
|
|
263
|
+
const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
|
|
264
|
+
const project = typeof payload.project === "string" ? payload.project : null;
|
|
265
|
+
const startedAt = typeof payload.started_at === "string" ? payload.started_at : null;
|
|
266
|
+
const tsWallMs = Number.isFinite(Number(payload.ts_wall_ms)) ? Math.floor(Number(payload.ts_wall_ms)) : null;
|
|
267
|
+
const tsMonoMs = Number.isFinite(Number(payload.ts_mono_ms)) ? Number(payload.ts_mono_ms) : null;
|
|
268
|
+
const eventId = typeof payload.event_id === "string" ? payload.event_id.trim() : "";
|
|
269
|
+
const eventPayload = payload.payload && typeof payload.payload === "object" && !Array.isArray(payload.payload) ? stripPrivateObj(payload.payload) : {};
|
|
270
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
271
|
+
try {
|
|
272
|
+
store.updateRawEventSessionMeta({
|
|
273
|
+
opencodeSessionId: sessionId,
|
|
274
|
+
source: "opencode",
|
|
275
|
+
cwd,
|
|
276
|
+
project,
|
|
277
|
+
startedAt,
|
|
278
|
+
lastSeenTsWallMs: tsWallMs
|
|
279
|
+
});
|
|
280
|
+
store.recordRawEventsBatch(sessionId, [{
|
|
281
|
+
event_id: eventId,
|
|
282
|
+
event_type: eventType,
|
|
283
|
+
payload: eventPayload,
|
|
284
|
+
ts_wall_ms: tsWallMs,
|
|
285
|
+
ts_mono_ms: tsMonoMs
|
|
286
|
+
}]);
|
|
287
|
+
} finally {
|
|
288
|
+
store.close();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/commands/export-memories.ts
|
|
293
|
+
function expandUserPath(value) {
|
|
294
|
+
return value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
|
|
295
|
+
}
|
|
296
|
+
var exportMemoriesCommand = new Command("export-memories").configureHelp(helpStyle).description("Export memories to a JSON file for sharing or backup").argument("<output>", "output file path (use '-' for stdout)").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--project <project>", "filter by project (defaults to git repo root)").option("--all-projects", "export all projects").option("--include-inactive", "include deactivated memories").option("--since <iso>", "only export memories created after this ISO timestamp").action((output, opts) => {
|
|
297
|
+
const payload = exportMemories({
|
|
298
|
+
dbPath: resolveDbPath(opts.db),
|
|
299
|
+
project: opts.project,
|
|
300
|
+
allProjects: opts.allProjects,
|
|
301
|
+
includeInactive: opts.includeInactive,
|
|
302
|
+
since: opts.since
|
|
303
|
+
});
|
|
304
|
+
const text = `${JSON.stringify(payload, null, 2)}\n`;
|
|
305
|
+
if (output === "-") {
|
|
306
|
+
process.stdout.write(text);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const outputPath = expandUserPath(output);
|
|
310
|
+
writeFileSync(outputPath, text, "utf8");
|
|
311
|
+
p.intro("codemem export-memories");
|
|
312
|
+
p.log.success([
|
|
313
|
+
`Output: ${outputPath}`,
|
|
314
|
+
`Sessions: ${payload.sessions.length.toLocaleString()}`,
|
|
315
|
+
`Memories: ${payload.memory_items.length.toLocaleString()}`,
|
|
316
|
+
`Summaries: ${payload.session_summaries.length.toLocaleString()}`,
|
|
317
|
+
`Prompts: ${payload.user_prompts.length.toLocaleString()}`
|
|
318
|
+
].join("\n"));
|
|
319
|
+
p.outro("done");
|
|
320
|
+
});
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/commands/import-memories.ts
|
|
323
|
+
var importMemoriesCommand = new Command("import-memories").configureHelp(helpStyle).description("Import memories from an exported JSON file").argument("<inputFile>", "input JSON file (use '-' for stdin)").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--remap-project <path>", "remap all projects to this path on import").option("--dry-run", "preview import without writing").action((inputFile, opts) => {
|
|
324
|
+
let payload;
|
|
325
|
+
try {
|
|
326
|
+
payload = readImportPayload(inputFile);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
p.log.error(error instanceof Error ? error.message : "Invalid import file");
|
|
329
|
+
process.exitCode = 1;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
p.intro("codemem import-memories");
|
|
333
|
+
p.log.info([
|
|
334
|
+
`Export version: ${payload.version}`,
|
|
335
|
+
`Exported at: ${payload.exported_at}`,
|
|
336
|
+
`Sessions: ${payload.sessions.length.toLocaleString()}`,
|
|
337
|
+
`Memories: ${payload.memory_items.length.toLocaleString()}`,
|
|
338
|
+
`Summaries: ${payload.session_summaries.length.toLocaleString()}`,
|
|
339
|
+
`Prompts: ${payload.user_prompts.length.toLocaleString()}`
|
|
340
|
+
].join("\n"));
|
|
341
|
+
const result = importMemories(payload, {
|
|
342
|
+
dbPath: resolveDbPath(opts.db),
|
|
343
|
+
remapProject: opts.remapProject,
|
|
344
|
+
dryRun: opts.dryRun
|
|
345
|
+
});
|
|
346
|
+
if (result.dryRun) {
|
|
347
|
+
p.outro("dry run complete");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
p.log.success([
|
|
351
|
+
`Imported sessions: ${result.sessions.toLocaleString()}`,
|
|
352
|
+
`Imported prompts: ${result.user_prompts.toLocaleString()}`,
|
|
353
|
+
`Imported memories: ${result.memory_items.toLocaleString()}`,
|
|
354
|
+
`Imported summaries: ${result.session_summaries.toLocaleString()}`
|
|
355
|
+
].join("\n"));
|
|
356
|
+
p.outro("done");
|
|
357
|
+
});
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/commands/mcp.ts
|
|
360
|
+
var mcpCommand = new Command("mcp").configureHelp(helpStyle).description("Start the MCP stdio server").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").action(async (opts) => {
|
|
361
|
+
if (opts.db) process.env.CODEMEM_DB = opts.db;
|
|
362
|
+
await import("@codemem/mcp-server");
|
|
363
|
+
});
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/commands/memory.ts
|
|
366
|
+
/**
|
|
367
|
+
* Memory management CLI commands — show, forget, remember.
|
|
368
|
+
*
|
|
369
|
+
* Ports codemem/commands/memory_cmds.py (show_cmd, forget_cmd, remember_cmd).
|
|
370
|
+
* Compact and inject are deferred — compact requires the summarizer pipeline,
|
|
371
|
+
* and inject is just pack_text output which the existing pack command covers.
|
|
372
|
+
*/
|
|
373
|
+
/** Parse a strict positive integer, rejecting prefixes like "12abc". */
|
|
374
|
+
function parseStrictPositiveId(value) {
|
|
375
|
+
if (!/^\d+$/.test(value.trim())) return null;
|
|
376
|
+
const n = Number(value.trim());
|
|
377
|
+
return Number.isFinite(n) && n >= 1 && Number.isInteger(n) ? n : null;
|
|
378
|
+
}
|
|
379
|
+
var memoryCommand = new Command("memory").configureHelp(helpStyle).description("Memory item management");
|
|
380
|
+
memoryCommand.addCommand(new Command("show").configureHelp(helpStyle).description("Print a memory item as JSON").argument("<id>", "memory ID").option("--db <path>", "database path").action((idStr, opts) => {
|
|
381
|
+
const memoryId = parseStrictPositiveId(idStr);
|
|
382
|
+
if (memoryId === null) {
|
|
383
|
+
p.log.error(`Invalid memory ID: ${idStr}`);
|
|
384
|
+
process.exitCode = 1;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
388
|
+
try {
|
|
389
|
+
const item = store.get(memoryId);
|
|
390
|
+
if (!item) {
|
|
391
|
+
p.log.error(`Memory ${memoryId} not found`);
|
|
392
|
+
process.exitCode = 1;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
console.log(JSON.stringify(item, null, 2));
|
|
396
|
+
} finally {
|
|
397
|
+
store.close();
|
|
398
|
+
}
|
|
399
|
+
}));
|
|
400
|
+
memoryCommand.addCommand(new Command("forget").configureHelp(helpStyle).description("Deactivate a memory item").argument("<id>", "memory ID").option("--db <path>", "database path").action((idStr, opts) => {
|
|
401
|
+
const memoryId = parseStrictPositiveId(idStr);
|
|
402
|
+
if (memoryId === null) {
|
|
403
|
+
p.log.error(`Invalid memory ID: ${idStr}`);
|
|
404
|
+
process.exitCode = 1;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
408
|
+
try {
|
|
409
|
+
store.forget(memoryId);
|
|
410
|
+
p.log.success(`Memory ${memoryId} marked inactive`);
|
|
411
|
+
} finally {
|
|
412
|
+
store.close();
|
|
413
|
+
}
|
|
414
|
+
}));
|
|
415
|
+
memoryCommand.addCommand(new Command("remember").configureHelp(helpStyle).description("Manually add a memory item").requiredOption("-k, --kind <kind>", "memory kind (discovery, decision, feature, bugfix, etc.)").requiredOption("-t, --title <title>", "memory title").requiredOption("-b, --body <body>", "memory body text").option("--tags <tags...>", "tags (space-separated)").option("--project <project>", "project name (defaults to git repo root)").option("--db <path>", "database path").action((opts) => {
|
|
416
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
417
|
+
let sessionId = null;
|
|
418
|
+
try {
|
|
419
|
+
const project = resolveProject(process.cwd(), opts.project ?? null);
|
|
420
|
+
sessionId = store.startSession({
|
|
421
|
+
cwd: process.cwd(),
|
|
422
|
+
project,
|
|
423
|
+
user: process.env.USER ?? "unknown",
|
|
424
|
+
toolVersion: "manual",
|
|
425
|
+
metadata: { manual: true }
|
|
426
|
+
});
|
|
427
|
+
const memId = store.remember(sessionId, opts.kind, opts.title, opts.body, .5, opts.tags);
|
|
428
|
+
store.endSession(sessionId, { manual: true });
|
|
429
|
+
p.log.success(`Stored memory ${memId}`);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
if (sessionId !== null) try {
|
|
432
|
+
store.endSession(sessionId, {
|
|
433
|
+
manual: true,
|
|
434
|
+
error: true
|
|
435
|
+
});
|
|
436
|
+
} catch {}
|
|
437
|
+
throw err;
|
|
438
|
+
} finally {
|
|
439
|
+
store.close();
|
|
440
|
+
}
|
|
441
|
+
}));
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/commands/pack.ts
|
|
444
|
+
var packCommand = new Command("pack").configureHelp(helpStyle).description("Build a context-aware memory pack").argument("<context>", "context string to search for").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("-n, --limit <n>", "max items", "10").option("--budget <tokens>", "token budget").option("--json", "output as JSON").action((context, opts) => {
|
|
445
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
446
|
+
try {
|
|
447
|
+
const limit = Number.parseInt(opts.limit, 10) || 10;
|
|
448
|
+
const budget = opts.budget ? Number.parseInt(opts.budget, 10) : void 0;
|
|
449
|
+
const result = store.buildMemoryPack(context, limit, budget);
|
|
450
|
+
if (opts.json) {
|
|
451
|
+
console.log(JSON.stringify(result, null, 2));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
p.intro(`Memory pack for "${context}"`);
|
|
455
|
+
if (result.items.length === 0) {
|
|
456
|
+
p.log.warn("No relevant memories found.");
|
|
457
|
+
p.outro("done");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const m = result.metrics;
|
|
461
|
+
p.log.info(`${m.total_items} items, ~${m.pack_tokens} tokens` + (m.fallback_used ? " (fallback)" : "") + ` [fts:${m.sources.fts} sem:${m.sources.semantic} fuzzy:${m.sources.fuzzy}]`);
|
|
462
|
+
for (const item of result.items) p.log.step(`#${item.id} ${item.kind} ${item.title}`);
|
|
463
|
+
p.note(result.pack_text, "pack_text");
|
|
464
|
+
p.outro("done");
|
|
465
|
+
} finally {
|
|
466
|
+
store.close();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/commands/recent.ts
|
|
471
|
+
var recentCommand = new Command("recent").configureHelp(helpStyle).description("Show recent memories").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--limit <n>", "max results", "5").option("--kind <kind>", "filter by memory kind").action((opts) => {
|
|
472
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
473
|
+
try {
|
|
474
|
+
const limit = Math.max(1, Number.parseInt(opts.limit, 10) || 5);
|
|
475
|
+
const filters = opts.kind ? { kind: opts.kind } : void 0;
|
|
476
|
+
const items = store.recent(limit, filters);
|
|
477
|
+
for (const item of items) console.log(`#${item.id} [${item.kind}] ${item.title}`);
|
|
478
|
+
} finally {
|
|
479
|
+
store.close();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/commands/search.ts
|
|
484
|
+
var searchCommand = new Command("search").configureHelp(helpStyle).description("Search memories by query").argument("<query>", "search query").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("-n, --limit <n>", "max results", "10").option("--kind <kind>", "filter by memory kind").option("--json", "output as JSON").action((query, opts) => {
|
|
485
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
486
|
+
try {
|
|
487
|
+
const limit = Number.parseInt(opts.limit, 10) || 10;
|
|
488
|
+
const filters = opts.kind ? { kind: opts.kind } : void 0;
|
|
489
|
+
const results = store.search(query, limit, filters);
|
|
490
|
+
if (opts.json) {
|
|
491
|
+
console.log(JSON.stringify(results, null, 2));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (results.length === 0) {
|
|
495
|
+
p.log.warn("No results found.");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
p.intro(`${results.length} result(s) for "${query}"`);
|
|
499
|
+
for (const item of results) {
|
|
500
|
+
const score = item.score.toFixed(3);
|
|
501
|
+
const age = timeSince(item.created_at);
|
|
502
|
+
const preview = item.body_text.length > 120 ? `${item.body_text.slice(0, 120)}…` : item.body_text;
|
|
503
|
+
p.log.message([
|
|
504
|
+
`#${item.id} ${item.kind} ${age} [${score}]`,
|
|
505
|
+
item.title,
|
|
506
|
+
preview
|
|
507
|
+
].join("\n"));
|
|
508
|
+
}
|
|
509
|
+
p.outro("done");
|
|
510
|
+
} finally {
|
|
511
|
+
store.close();
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
function timeSince(isoDate) {
|
|
515
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
516
|
+
const days = Math.floor(ms / 864e5);
|
|
517
|
+
if (days === 0) return "today";
|
|
518
|
+
if (days === 1) return "1d ago";
|
|
519
|
+
if (days < 30) return `${days}d ago`;
|
|
520
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region src/commands/serve.ts
|
|
524
|
+
function pidFilePath(dbPath) {
|
|
525
|
+
return join(dirname(dbPath), "viewer.pid");
|
|
526
|
+
}
|
|
527
|
+
function readViewerPidRecord(dbPath) {
|
|
528
|
+
const pidPath = pidFilePath(dbPath);
|
|
529
|
+
if (!existsSync(pidPath)) return null;
|
|
530
|
+
const raw = readFileSync(pidPath, "utf-8").trim();
|
|
531
|
+
try {
|
|
532
|
+
const parsed = JSON.parse(raw);
|
|
533
|
+
if (typeof parsed.pid === "number" && typeof parsed.host === "string" && typeof parsed.port === "number") return {
|
|
534
|
+
pid: parsed.pid,
|
|
535
|
+
host: parsed.host,
|
|
536
|
+
port: parsed.port
|
|
537
|
+
};
|
|
538
|
+
} catch {
|
|
539
|
+
const pid = Number.parseInt(raw, 10);
|
|
540
|
+
if (Number.isFinite(pid) && pid > 0) return {
|
|
541
|
+
pid,
|
|
542
|
+
host: "127.0.0.1",
|
|
543
|
+
port: 38888
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
async function respondsLikeCodememViewer(record) {
|
|
549
|
+
try {
|
|
550
|
+
const controller = new AbortController();
|
|
551
|
+
const timer = setTimeout(() => controller.abort(), 1e3);
|
|
552
|
+
const res = await fetch(`http://${record.host}:${record.port}/api/stats`, { signal: controller.signal });
|
|
553
|
+
clearTimeout(timer);
|
|
554
|
+
return res.ok;
|
|
555
|
+
} catch {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async function waitForProcessExit(pid, timeoutMs = 5e3) {
|
|
560
|
+
const deadline = Date.now() + timeoutMs;
|
|
561
|
+
while (Date.now() < deadline) try {
|
|
562
|
+
process.kill(pid, 0);
|
|
563
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
564
|
+
} catch {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async function stopExistingViewer(dbPath) {
|
|
569
|
+
const pidPath = pidFilePath(dbPath);
|
|
570
|
+
const record = readViewerPidRecord(dbPath);
|
|
571
|
+
if (!record) return;
|
|
572
|
+
if (await respondsLikeCodememViewer(record)) try {
|
|
573
|
+
process.kill(record.pid, "SIGTERM");
|
|
574
|
+
await waitForProcessExit(record.pid);
|
|
575
|
+
} catch {}
|
|
576
|
+
try {
|
|
577
|
+
rmSync(pidPath);
|
|
578
|
+
} catch {}
|
|
579
|
+
}
|
|
580
|
+
var serveCommand = new Command("serve").configureHelp(helpStyle).description("Start the viewer server").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--host <host>", "bind host", "127.0.0.1").option("--port <port>", "bind port", "38888").option("--background", "run under a caller-managed background process").option("--stop", "stop an existing viewer process").option("--restart", "restart an existing viewer process").action(async (opts) => {
|
|
581
|
+
const { createApp, closeStore, getStore } = await import("@codemem/viewer-server");
|
|
582
|
+
const { serve } = await import("@hono/node-server");
|
|
583
|
+
const dbPath = resolveDbPath(opts.db);
|
|
584
|
+
if (opts.stop || opts.restart) {
|
|
585
|
+
await stopExistingViewer(dbPath);
|
|
586
|
+
if (opts.stop && !opts.restart) return;
|
|
587
|
+
}
|
|
588
|
+
process.env.CODEMEM_DB = dbPath;
|
|
589
|
+
const port = Number.parseInt(opts.port, 10);
|
|
590
|
+
const observer = new ObserverClient();
|
|
591
|
+
const sweeper = new RawEventSweeper(getStore(), { observer });
|
|
592
|
+
sweeper.start();
|
|
593
|
+
const syncAbort = new AbortController();
|
|
594
|
+
let syncRunning = false;
|
|
595
|
+
const config = readCodememConfigFile();
|
|
596
|
+
if (config.sync_enabled === true || process.env.CODEMEM_SYNC_ENABLED?.toLowerCase() === "true" || process.env.CODEMEM_SYNC_ENABLED === "1") {
|
|
597
|
+
syncRunning = true;
|
|
598
|
+
runSyncDaemon({
|
|
599
|
+
dbPath,
|
|
600
|
+
intervalS: typeof config.sync_interval_s === "number" ? config.sync_interval_s : 120,
|
|
601
|
+
host: opts.host,
|
|
602
|
+
port,
|
|
603
|
+
signal: syncAbort.signal
|
|
604
|
+
}).catch((err) => {
|
|
605
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
606
|
+
p.log.error(`Sync daemon failed: ${msg}`);
|
|
607
|
+
}).finally(() => {
|
|
608
|
+
syncRunning = false;
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
const app = createApp({
|
|
612
|
+
storeFactory: getStore,
|
|
613
|
+
sweeper
|
|
614
|
+
});
|
|
615
|
+
const pidPath = pidFilePath(dbPath);
|
|
616
|
+
const server = serve({
|
|
617
|
+
fetch: app.fetch,
|
|
618
|
+
hostname: opts.host,
|
|
619
|
+
port
|
|
620
|
+
}, (info) => {
|
|
621
|
+
writeFileSync(pidPath, JSON.stringify({
|
|
622
|
+
pid: process.pid,
|
|
623
|
+
host: opts.host,
|
|
624
|
+
port
|
|
625
|
+
}), "utf-8");
|
|
626
|
+
p.intro("codemem viewer");
|
|
627
|
+
p.log.success(`Listening on http://${info.address}:${info.port}`);
|
|
628
|
+
p.log.info(`Database: ${dbPath}`);
|
|
629
|
+
p.log.step("Raw event sweeper started");
|
|
630
|
+
if (syncRunning) p.log.step("Sync daemon started");
|
|
631
|
+
});
|
|
632
|
+
const shutdown = async () => {
|
|
633
|
+
p.outro("shutting down");
|
|
634
|
+
syncAbort.abort();
|
|
635
|
+
await sweeper.stop();
|
|
636
|
+
server.close(() => {
|
|
637
|
+
try {
|
|
638
|
+
rmSync(pidPath);
|
|
639
|
+
} catch {}
|
|
640
|
+
closeStore();
|
|
641
|
+
process.exit(0);
|
|
642
|
+
});
|
|
643
|
+
setTimeout(() => {
|
|
644
|
+
try {
|
|
645
|
+
rmSync(pidPath);
|
|
646
|
+
} catch {}
|
|
647
|
+
closeStore();
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}, 5e3).unref();
|
|
650
|
+
};
|
|
651
|
+
process.on("SIGINT", () => {
|
|
652
|
+
shutdown();
|
|
653
|
+
});
|
|
654
|
+
process.on("SIGTERM", () => {
|
|
655
|
+
shutdown();
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/commands/setup.ts
|
|
660
|
+
/**
|
|
661
|
+
* codemem setup — one-command installation for OpenCode plugin + MCP config.
|
|
662
|
+
*
|
|
663
|
+
* Replaces Python's install_plugin_cmd + install_mcp_cmd.
|
|
664
|
+
*
|
|
665
|
+
* What it does:
|
|
666
|
+
* 1. Copies the plugin file to ~/.config/opencode/plugin/codemem.js
|
|
667
|
+
* 2. Adds/updates the MCP entry in ~/.config/opencode/opencode.json
|
|
668
|
+
* 3. Copies the compat lib to ~/.config/opencode/lib/compat.js
|
|
669
|
+
*
|
|
670
|
+
* Designed to be safe to run repeatedly (idempotent unless --force).
|
|
671
|
+
*/
|
|
672
|
+
function opencodeConfigDir() {
|
|
673
|
+
return join(homedir(), ".config", "opencode");
|
|
674
|
+
}
|
|
675
|
+
function claudeConfigDir() {
|
|
676
|
+
return join(homedir(), ".claude");
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Find the plugin source file — walk up from this module's location
|
|
680
|
+
* to find the .opencode/plugin/codemem.js in the package tree.
|
|
681
|
+
*/
|
|
682
|
+
function findPluginSource() {
|
|
683
|
+
let dir = dirname(import.meta.url.replace("file://", ""));
|
|
684
|
+
for (let i = 0; i < 6; i++) {
|
|
685
|
+
const candidate = join(dir, ".opencode", "plugin", "codemem.js");
|
|
686
|
+
if (existsSync(candidate)) return candidate;
|
|
687
|
+
const nmCandidate = join(dir, "node_modules", "codemem", ".opencode", "plugin", "codemem.js");
|
|
688
|
+
if (existsSync(nmCandidate)) return nmCandidate;
|
|
689
|
+
const legacyCandidate = join(dir, "node_modules", "@kunickiaj", "codemem", ".opencode", "plugin", "codemem.js");
|
|
690
|
+
if (existsSync(legacyCandidate)) return legacyCandidate;
|
|
691
|
+
const parent = dirname(dir);
|
|
692
|
+
if (parent === dir) break;
|
|
693
|
+
dir = parent;
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
function findCompatSource() {
|
|
698
|
+
let dir = dirname(import.meta.url.replace("file://", ""));
|
|
699
|
+
for (let i = 0; i < 6; i++) {
|
|
700
|
+
const candidate = join(dir, ".opencode", "lib", "compat.js");
|
|
701
|
+
if (existsSync(candidate)) return candidate;
|
|
702
|
+
const nmCandidate = join(dir, "node_modules", "codemem", ".opencode", "lib", "compat.js");
|
|
703
|
+
if (existsSync(nmCandidate)) return nmCandidate;
|
|
704
|
+
const legacyCandidate = join(dir, "node_modules", "@kunickiaj", "codemem", ".opencode", "lib", "compat.js");
|
|
705
|
+
if (existsSync(legacyCandidate)) return legacyCandidate;
|
|
706
|
+
const parent = dirname(dir);
|
|
707
|
+
if (parent === dir) break;
|
|
708
|
+
dir = parent;
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
function loadJsoncConfig(path) {
|
|
713
|
+
if (!existsSync(path)) return {};
|
|
714
|
+
const raw = readFileSync(path, "utf-8");
|
|
715
|
+
try {
|
|
716
|
+
return JSON.parse(raw);
|
|
717
|
+
} catch {
|
|
718
|
+
const cleaned = stripTrailingCommas(stripJsonComments(raw));
|
|
719
|
+
return JSON.parse(cleaned);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function writeJsonConfig(path, data) {
|
|
723
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
724
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
725
|
+
}
|
|
726
|
+
function installPlugin(force) {
|
|
727
|
+
const source = findPluginSource();
|
|
728
|
+
if (!source) {
|
|
729
|
+
p.log.error("Plugin file not found in package tree");
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
const destDir = join(opencodeConfigDir(), "plugin");
|
|
733
|
+
const dest = join(destDir, "codemem.js");
|
|
734
|
+
if (existsSync(dest) && !force) p.log.info(`Plugin already installed at ${dest}`);
|
|
735
|
+
else {
|
|
736
|
+
mkdirSync(destDir, { recursive: true });
|
|
737
|
+
copyFileSync(source, dest);
|
|
738
|
+
p.log.success(`Plugin installed: ${dest}`);
|
|
739
|
+
}
|
|
740
|
+
const compatSource = findCompatSource();
|
|
741
|
+
if (compatSource) {
|
|
742
|
+
const compatDir = join(opencodeConfigDir(), "lib");
|
|
743
|
+
mkdirSync(compatDir, { recursive: true });
|
|
744
|
+
copyFileSync(compatSource, join(compatDir, "compat.js"));
|
|
745
|
+
}
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
function installMcp(force) {
|
|
749
|
+
const configPath = join(opencodeConfigDir(), "opencode.json");
|
|
750
|
+
let config;
|
|
751
|
+
try {
|
|
752
|
+
config = loadJsoncConfig(configPath);
|
|
753
|
+
} catch (err) {
|
|
754
|
+
p.log.error(`Failed to parse ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
let mcpConfig = config.mcp;
|
|
758
|
+
if (mcpConfig == null || typeof mcpConfig !== "object" || Array.isArray(mcpConfig)) mcpConfig = {};
|
|
759
|
+
if ("codemem" in mcpConfig && !force) {
|
|
760
|
+
p.log.info(`MCP entry already exists in ${configPath}`);
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
mcpConfig.codemem = {
|
|
764
|
+
type: "local",
|
|
765
|
+
command: [
|
|
766
|
+
"npx",
|
|
767
|
+
"codemem",
|
|
768
|
+
"mcp"
|
|
769
|
+
],
|
|
770
|
+
enabled: true
|
|
771
|
+
};
|
|
772
|
+
config.mcp = mcpConfig;
|
|
773
|
+
try {
|
|
774
|
+
writeJsonConfig(configPath, config);
|
|
775
|
+
p.log.success(`MCP entry installed: ${configPath}`);
|
|
776
|
+
} catch (err) {
|
|
777
|
+
p.log.error(`Failed to write ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
function installClaudeMcp(force) {
|
|
783
|
+
const settingsPath = join(claudeConfigDir(), "settings.json");
|
|
784
|
+
let settings;
|
|
785
|
+
try {
|
|
786
|
+
settings = loadJsoncConfig(settingsPath);
|
|
787
|
+
} catch {
|
|
788
|
+
settings = {};
|
|
789
|
+
}
|
|
790
|
+
let mcpServers = settings.mcpServers;
|
|
791
|
+
if (mcpServers == null || typeof mcpServers !== "object" || Array.isArray(mcpServers)) mcpServers = {};
|
|
792
|
+
if ("codemem" in mcpServers && !force) {
|
|
793
|
+
p.log.info(`Claude MCP entry already exists in ${settingsPath}`);
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
mcpServers.codemem = {
|
|
797
|
+
command: "npx",
|
|
798
|
+
args: ["codemem", "mcp"]
|
|
799
|
+
};
|
|
800
|
+
settings.mcpServers = mcpServers;
|
|
801
|
+
try {
|
|
802
|
+
writeJsonConfig(settingsPath, settings);
|
|
803
|
+
p.log.success(`Claude MCP entry installed: ${settingsPath}`);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
p.log.error(`Failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
var setupCommand = new Command("setup").configureHelp(helpStyle).description("Install codemem plugin + MCP config for OpenCode and Claude Code").option("--force", "overwrite existing installations").option("--opencode-only", "only install for OpenCode").option("--claude-only", "only install for Claude Code").action((opts) => {
|
|
811
|
+
p.intro(`codemem setup v${VERSION}`);
|
|
812
|
+
const force = opts.force ?? false;
|
|
813
|
+
let ok = true;
|
|
814
|
+
if (!opts.claudeOnly) {
|
|
815
|
+
p.log.step("Installing OpenCode plugin...");
|
|
816
|
+
ok = installPlugin(force) && ok;
|
|
817
|
+
p.log.step("Installing OpenCode MCP config...");
|
|
818
|
+
ok = installMcp(force) && ok;
|
|
819
|
+
}
|
|
820
|
+
if (!opts.opencodeOnly) {
|
|
821
|
+
p.log.step("Installing Claude Code MCP config...");
|
|
822
|
+
ok = installClaudeMcp(force) && ok;
|
|
823
|
+
}
|
|
824
|
+
if (ok) p.outro("Setup complete — restart your editor to load the plugin");
|
|
825
|
+
else {
|
|
826
|
+
p.outro("Setup completed with warnings");
|
|
827
|
+
process.exitCode = 1;
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/commands/stats.ts
|
|
832
|
+
function fmtPct(n) {
|
|
833
|
+
return `${Math.round(n * 100)}%`;
|
|
834
|
+
}
|
|
835
|
+
function fmtTokens(n) {
|
|
836
|
+
if (n >= 1e9) return `~${(n / 1e9).toFixed(1)}B`;
|
|
837
|
+
if (n >= 1e6) return `~${(n / 1e6).toFixed(1)}M`;
|
|
838
|
+
if (n >= 1e3) return `~${(n / 1e3).toFixed(0)}K`;
|
|
839
|
+
return `${n}`;
|
|
840
|
+
}
|
|
841
|
+
var statsCommand = new Command("stats").configureHelp(helpStyle).description("Show database statistics").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--json", "output as JSON").action((opts) => {
|
|
842
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
843
|
+
try {
|
|
844
|
+
const result = store.stats();
|
|
845
|
+
if (opts.json) {
|
|
846
|
+
console.log(JSON.stringify(result, null, 2));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const db = result.database;
|
|
850
|
+
const sizeMb = (db.size_bytes / 1048576).toFixed(1);
|
|
851
|
+
p.intro("codemem stats");
|
|
852
|
+
p.log.info([`Path: ${db.path}`, `Size: ${sizeMb} MB`].join("\n"));
|
|
853
|
+
p.log.success([
|
|
854
|
+
`Sessions: ${db.sessions.toLocaleString()}`,
|
|
855
|
+
`Memories: ${db.active_memory_items.toLocaleString()} active / ${db.memory_items.toLocaleString()} total`,
|
|
856
|
+
`Tags: ${db.tags_filled.toLocaleString()} filled (${fmtPct(db.tags_coverage)} of active)`,
|
|
857
|
+
`Artifacts: ${db.artifacts.toLocaleString()}`,
|
|
858
|
+
`Vectors: ${db.vector_rows.toLocaleString()} (${fmtPct(db.vector_coverage)} coverage)`,
|
|
859
|
+
`Raw events: ${db.raw_events.toLocaleString()}`
|
|
860
|
+
].join("\n"));
|
|
861
|
+
if (result.usage.events.length > 0) {
|
|
862
|
+
const lines = result.usage.events.map((e) => {
|
|
863
|
+
const parts = [`${e.event}: ${e.count.toLocaleString()}`];
|
|
864
|
+
if (e.tokens_read > 0) parts.push(`read ${fmtTokens(e.tokens_read)} tokens`);
|
|
865
|
+
if (e.tokens_saved > 0) parts.push(`est. saved ${fmtTokens(e.tokens_saved)} tokens`);
|
|
866
|
+
return ` ${parts.join(", ")}`;
|
|
867
|
+
});
|
|
868
|
+
const t = result.usage.totals;
|
|
869
|
+
lines.push("");
|
|
870
|
+
lines.push(` Total: ${t.events.toLocaleString()} events, read ${fmtTokens(t.tokens_read)} tokens, est. saved ${fmtTokens(t.tokens_saved)} tokens`);
|
|
871
|
+
p.log.step(`Usage\n${lines.join("\n")}`);
|
|
872
|
+
}
|
|
873
|
+
p.outro("done");
|
|
874
|
+
} finally {
|
|
875
|
+
store.close();
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/commands/sync.ts
|
|
880
|
+
/**
|
|
881
|
+
* Sync CLI commands — enable/disable/status/peers/connect.
|
|
882
|
+
*/
|
|
883
|
+
var syncCommand = new Command("sync").configureHelp(helpStyle).description("Sync configuration and peer management");
|
|
884
|
+
syncCommand.addCommand(new Command("status").configureHelp(helpStyle).description("Show sync configuration and peer summary").option("--db <path>", "database path").option("--json", "output as JSON").action((opts) => {
|
|
885
|
+
const config = readCodememConfigFile();
|
|
886
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
887
|
+
try {
|
|
888
|
+
const d = drizzle(store.db, { schema });
|
|
889
|
+
const deviceRow = d.select({
|
|
890
|
+
device_id: schema.syncDevice.device_id,
|
|
891
|
+
fingerprint: schema.syncDevice.fingerprint
|
|
892
|
+
}).from(schema.syncDevice).limit(1).get();
|
|
893
|
+
const peers = d.select({
|
|
894
|
+
peer_device_id: schema.syncPeers.peer_device_id,
|
|
895
|
+
name: schema.syncPeers.name,
|
|
896
|
+
last_sync_at: schema.syncPeers.last_sync_at,
|
|
897
|
+
last_error: schema.syncPeers.last_error
|
|
898
|
+
}).from(schema.syncPeers).all();
|
|
899
|
+
if (opts.json) {
|
|
900
|
+
console.log(JSON.stringify({
|
|
901
|
+
enabled: config.sync_enabled === true,
|
|
902
|
+
host: config.sync_host ?? "0.0.0.0",
|
|
903
|
+
port: config.sync_port ?? 7337,
|
|
904
|
+
interval_s: config.sync_interval_s ?? 120,
|
|
905
|
+
device_id: deviceRow?.device_id ?? null,
|
|
906
|
+
fingerprint: deviceRow?.fingerprint ?? null,
|
|
907
|
+
coordinator_url: config.sync_coordinator_url ?? null,
|
|
908
|
+
peers: peers.map((peer) => ({
|
|
909
|
+
device_id: peer.peer_device_id,
|
|
910
|
+
name: peer.name,
|
|
911
|
+
last_sync: peer.last_sync_at,
|
|
912
|
+
status: peer.last_error ?? "ok"
|
|
913
|
+
}))
|
|
914
|
+
}, null, 2));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
p.intro("codemem sync status");
|
|
918
|
+
p.log.info([
|
|
919
|
+
`Enabled: ${config.sync_enabled === true ? "yes" : "no"}`,
|
|
920
|
+
`Host: ${config.sync_host ?? "0.0.0.0"}`,
|
|
921
|
+
`Port: ${config.sync_port ?? 7337}`,
|
|
922
|
+
`Interval: ${config.sync_interval_s ?? 120}s`,
|
|
923
|
+
`Coordinator: ${config.sync_coordinator_url ?? "(not configured)"}`
|
|
924
|
+
].join("\n"));
|
|
925
|
+
if (deviceRow) p.log.info(`Device ID: ${deviceRow.device_id}\nFingerprint: ${deviceRow.fingerprint}`);
|
|
926
|
+
else p.log.warn("Device identity not initialized (run `codemem sync enable`)");
|
|
927
|
+
if (peers.length === 0) p.log.info("Peers: none");
|
|
928
|
+
else for (const peer of peers) {
|
|
929
|
+
const label = peer.name || peer.peer_device_id;
|
|
930
|
+
p.log.message(` ${label}: last_sync=${peer.last_sync_at ?? "never"}, status=${peer.last_error ?? "ok"}`);
|
|
931
|
+
}
|
|
932
|
+
p.outro(`${peers.length} peer(s)`);
|
|
933
|
+
} finally {
|
|
934
|
+
store.close();
|
|
935
|
+
}
|
|
936
|
+
}));
|
|
937
|
+
syncCommand.addCommand(new Command("enable").configureHelp(helpStyle).description("Enable sync and initialize device identity").option("--db <path>", "database path").option("--host <host>", "sync listen host").option("--port <port>", "sync listen port").option("--interval <seconds>", "sync interval in seconds").action((opts) => {
|
|
938
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
939
|
+
try {
|
|
940
|
+
const [deviceId, fingerprint] = ensureDeviceIdentity(store.db);
|
|
941
|
+
const config = readCodememConfigFile();
|
|
942
|
+
config.sync_enabled = true;
|
|
943
|
+
if (opts.host) config.sync_host = opts.host;
|
|
944
|
+
if (opts.port) config.sync_port = Number.parseInt(opts.port, 10);
|
|
945
|
+
if (opts.interval) config.sync_interval_s = Number.parseInt(opts.interval, 10);
|
|
946
|
+
writeCodememConfigFile(config);
|
|
947
|
+
p.intro("codemem sync enable");
|
|
948
|
+
p.log.success([
|
|
949
|
+
`Device ID: ${deviceId}`,
|
|
950
|
+
`Fingerprint: ${fingerprint}`,
|
|
951
|
+
`Host: ${config.sync_host ?? "0.0.0.0"}`,
|
|
952
|
+
`Port: ${config.sync_port ?? 7337}`,
|
|
953
|
+
`Interval: ${config.sync_interval_s ?? 120}s`
|
|
954
|
+
].join("\n"));
|
|
955
|
+
p.outro("Sync enabled — restart `codemem serve` to activate");
|
|
956
|
+
} finally {
|
|
957
|
+
store.close();
|
|
958
|
+
}
|
|
959
|
+
}));
|
|
960
|
+
syncCommand.addCommand(new Command("disable").configureHelp(helpStyle).description("Disable sync without deleting keys or peers").action(() => {
|
|
961
|
+
const config = readCodememConfigFile();
|
|
962
|
+
config.sync_enabled = false;
|
|
963
|
+
writeCodememConfigFile(config);
|
|
964
|
+
p.intro("codemem sync disable");
|
|
965
|
+
p.outro("Sync disabled — restart `codemem serve` to take effect");
|
|
966
|
+
}));
|
|
967
|
+
syncCommand.addCommand(new Command("peers").configureHelp(helpStyle).description("List known sync peers").option("--db <path>", "database path").option("--json", "output as JSON").action((opts) => {
|
|
968
|
+
const store = new MemoryStore(resolveDbPath(opts.db));
|
|
969
|
+
try {
|
|
970
|
+
const peers = drizzle(store.db, { schema }).select({
|
|
971
|
+
peer_device_id: schema.syncPeers.peer_device_id,
|
|
972
|
+
name: schema.syncPeers.name,
|
|
973
|
+
addresses: schema.syncPeers.addresses_json,
|
|
974
|
+
last_sync_at: schema.syncPeers.last_sync_at,
|
|
975
|
+
last_error: schema.syncPeers.last_error
|
|
976
|
+
}).from(schema.syncPeers).orderBy(desc(schema.syncPeers.last_sync_at)).all();
|
|
977
|
+
if (opts.json) {
|
|
978
|
+
console.log(JSON.stringify(peers, null, 2));
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
p.intro("codemem sync peers");
|
|
982
|
+
if (peers.length === 0) {
|
|
983
|
+
p.outro("No peers configured");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
for (const peer of peers) {
|
|
987
|
+
const label = peer.name || peer.peer_device_id;
|
|
988
|
+
const addrs = peer.addresses || "(no addresses)";
|
|
989
|
+
p.log.message(`${label}\n addresses: ${addrs}\n last_sync: ${peer.last_sync_at ?? "never"}\n status: ${peer.last_error ?? "ok"}`);
|
|
990
|
+
}
|
|
991
|
+
p.outro(`${peers.length} peer(s)`);
|
|
992
|
+
} finally {
|
|
993
|
+
store.close();
|
|
994
|
+
}
|
|
995
|
+
}));
|
|
996
|
+
syncCommand.addCommand(new Command("connect").configureHelp(helpStyle).description("Configure coordinator URL for cloud sync").argument("<url>", "coordinator URL (e.g. https://coordinator.example.com)").option("--group <group>", "sync group ID").action((url, opts) => {
|
|
997
|
+
const config = readCodememConfigFile();
|
|
998
|
+
config.sync_coordinator_url = url.trim();
|
|
999
|
+
if (opts.group) config.sync_coordinator_group = opts.group.trim();
|
|
1000
|
+
writeCodememConfigFile(config);
|
|
1001
|
+
p.intro("codemem sync connect");
|
|
1002
|
+
p.log.success(`Coordinator: ${url.trim()}`);
|
|
1003
|
+
if (opts.group) p.log.info(`Group: ${opts.group.trim()}`);
|
|
1004
|
+
p.outro("Restart `codemem serve` to activate coordinator sync");
|
|
1005
|
+
}));
|
|
1006
|
+
//#endregion
|
|
1007
|
+
//#region src/commands/version.ts
|
|
1008
|
+
var versionCommand = new Command("version").configureHelp(helpStyle).description("Print codemem version").action(() => {
|
|
1009
|
+
console.log(VERSION);
|
|
1010
|
+
});
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/index.ts
|
|
1013
|
+
/**
|
|
1014
|
+
* @codemem/cli — CLI entry point.
|
|
1015
|
+
*
|
|
1016
|
+
* Commands:
|
|
1017
|
+
* codemem stats → database statistics
|
|
1018
|
+
* codemem search → FTS5 memory search
|
|
1019
|
+
* codemem pack → context-aware memory pack
|
|
1020
|
+
* codemem serve → viewer server
|
|
1021
|
+
* codemem mcp → MCP stdio server
|
|
1022
|
+
*/
|
|
1023
|
+
var completion = omelette("codemem <command>");
|
|
1024
|
+
completion.on("command", ({ reply }) => {
|
|
1025
|
+
reply([
|
|
1026
|
+
"claude-hook-ingest",
|
|
1027
|
+
"db",
|
|
1028
|
+
"export-memories",
|
|
1029
|
+
"memory",
|
|
1030
|
+
"import-memories",
|
|
1031
|
+
"setup",
|
|
1032
|
+
"sync",
|
|
1033
|
+
"stats",
|
|
1034
|
+
"recent",
|
|
1035
|
+
"search",
|
|
1036
|
+
"pack",
|
|
1037
|
+
"serve",
|
|
1038
|
+
"mcp",
|
|
1039
|
+
"enqueue-raw-event",
|
|
1040
|
+
"version",
|
|
1041
|
+
"help",
|
|
1042
|
+
"--help",
|
|
1043
|
+
"--version"
|
|
1044
|
+
]);
|
|
1045
|
+
});
|
|
1046
|
+
completion.init();
|
|
1047
|
+
if (process.argv.includes("--setup-completion")) {
|
|
1048
|
+
completion.setupShellInitFile();
|
|
1049
|
+
process.exit(0);
|
|
1050
|
+
}
|
|
1051
|
+
if (process.argv.includes("--cleanup-completion")) {
|
|
1052
|
+
completion.cleanupShellInitFile();
|
|
1053
|
+
process.exit(0);
|
|
1054
|
+
}
|
|
1055
|
+
var program = new Command();
|
|
1056
|
+
program.name("codemem").description("codemem — persistent memory for AI coding agents").version(VERSION).configureHelp(helpStyle);
|
|
1057
|
+
program.addCommand(serveCommand);
|
|
1058
|
+
program.addCommand(mcpCommand);
|
|
1059
|
+
program.addCommand(claudeHookIngestCommand);
|
|
1060
|
+
program.addCommand(dbCommand);
|
|
1061
|
+
program.addCommand(exportMemoriesCommand);
|
|
1062
|
+
program.addCommand(importMemoriesCommand);
|
|
1063
|
+
program.addCommand(statsCommand);
|
|
1064
|
+
program.addCommand(recentCommand);
|
|
1065
|
+
program.addCommand(searchCommand);
|
|
1066
|
+
program.addCommand(packCommand);
|
|
1067
|
+
program.addCommand(memoryCommand);
|
|
1068
|
+
program.addCommand(syncCommand);
|
|
1069
|
+
program.addCommand(setupCommand);
|
|
1070
|
+
program.addCommand(enqueueRawEventCommand);
|
|
1071
|
+
program.addCommand(versionCommand);
|
|
1072
|
+
program.parse();
|
|
1073
|
+
//#endregion
|
|
1074
|
+
export {};
|
|
1075
|
+
|
|
1076
|
+
//# sourceMappingURL=index.js.map
|