-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmnemo.ts
More file actions
383 lines (343 loc) · 12.1 KB
/
Copy pathmnemo.ts
File metadata and controls
383 lines (343 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
// Mnemo plugin for OpenCode — persistent memory for AI coding agents.
//
// This plugin connects to the Mnemo HTTP server to provide cross-session
// memory, semantic search, and context recovery for OpenCode sessions.
//
// Mnemo exposes 21 MCP tools (17 agent + 3 admin + 1 sync):
//
// Agent tools:
// mem_save - Save a memory to persistent storage
// mem_search - Hybrid full-text and semantic search
// mem_context - Get formatted context summary (memories, skills, workflows)
// mem_session_start - Start a coding session
// mem_session_end - End a coding session
// mem_session_summary - Save session summary and end session
// mem_get - Retrieve a memory by ID
// mem_update - Update an existing memory
// mem_capture_passive - Passively capture context from the session
// mem_save_prompt - Save a user prompt for future reference
// mem_suggest_topic - Suggest a topic_key for a memory
// skill_load - Load a reusable skill by name
// persona_get - Get active persona configuration
// workflow_phases - Get phases of a named workflow
// workflow_start - Start a new workflow run
// workflow_execute - Execute current phase of a workflow
// workflow_complete - Mark a workflow phase as complete
//
// Admin tools:
// mem_delete - Delete a memory by ID
// mem_stats - Get memory store statistics
// mem_timeline - Get memory timeline
//
// Sync:
// mem_sync_status - Get cloud sync status
interface MnemoConfig {
host: string;
port: number;
project: string;
}
interface MnemoSession {
id: string;
project: string;
agent: string;
}
interface MnemoMemory {
id: string;
type: string;
title: string;
content: string;
project: string;
created_at: string;
}
interface PassiveCapture {
type: string;
content: string;
}
// Resolve configuration from environment with sensible defaults.
function resolveConfig(): MnemoConfig {
const host = process.env.MNEMO_HOST ?? "127.0.0.1";
const port = parseInt(process.env.MNEMO_PORT ?? "7437", 10);
const project =
process.env.CLAUDE_PROJECT ??
process.env.OPENCODE_PROJECT ??
detectProject(process.cwd());
return { host, port, project };
}
// detectProject walks up from startDir looking for .mnemorc (matches the
// Go-side ProjectConfigService.Detect logic) and falls back to the CWD
// basename. This keeps project detection consistent across all Mnemo agents.
function detectProject(startDir: string): string {
const fromMnemorc = walkUpMnemorc(startDir);
if (fromMnemorc) return fromMnemorc;
return basename(startDir);
}
// Regex matches internal/domain/projectconfig.go projectNameRe.
const PROJECT_NAME_RE = /^[a-z][a-z0-9._-]{0,63}$/;
// walkUpMnemorc walks up the directory tree looking for a .mnemorc file and
// returns its `project:` field. Returns empty string if not found or invalid.
function walkUpMnemorc(startDir: string): string {
// Lazy require so the plugin still loads if node:fs is unavailable.
let fs: typeof import("fs");
let path: typeof import("path");
try {
fs = require("fs");
path = require("path");
} catch {
return "";
}
let dir = path.resolve(startDir);
while (true) {
const candidate = path.join(dir, ".mnemorc");
try {
if (fs.statSync(candidate).isFile()) {
const content = fs.readFileSync(candidate, "utf8");
const project = parseMnemorcProject(content);
if (project && PROJECT_NAME_RE.test(project)) {
return project;
}
// Found .mnemorc but project invalid/missing — stop walking.
return "";
}
} catch {
// Not a file or not readable — keep walking.
}
const parent = path.dirname(dir);
if (parent === dir) return "";
dir = parent;
}
}
// parseMnemorcProject extracts the `project:` field from a .mnemorc YAML.
// Handles surrounding whitespace, trailing comments, and optional quotes.
function parseMnemorcProject(content: string): string {
for (const line of content.split(/\r?\n/)) {
const match = line.match(/^\s*project\s*:\s*(.+?)\s*$/);
if (!match) continue;
let value = match[1];
// Strip trailing comments (not inside quotes — keep it simple).
const hashIdx = value.indexOf("#");
if (hashIdx >= 0) value = value.slice(0, hashIdx).trim();
// Strip surrounding quotes.
value = value.replace(/^["']|["']$/g, "");
return value.trim();
}
return "";
}
function basename(p: string): string {
const parts = p.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || "unknown";
}
function baseURL(cfg: MnemoConfig): string {
return `http://${cfg.host}:${cfg.port}`;
}
// Generate a session ID from the current timestamp and process ID.
// OpenCode does not expose a stable per-conversation id the way Claude Code
// does via hook stdin, so we synthesize our own. Keep the "oc-" prefix so the
// dashboard can still tell opencode sessions apart from Claude Code ones.
function generateSessionID(): string {
const now = new Date();
const ts = now
.toISOString()
.replace(/[-:T]/g, "")
.slice(0, 15);
return `oc-${ts}-${process.pid}`;
}
// ─── Session handoff (CWD-keyed file, mirrors the bash/Go helpers) ─────────
// Writes the session ID to ~/.mnemo/sessions/by-cwd/<hash>.txt so the Mnemo
// MCP subprocess (which does not share opencode's address space) can bind
// mem_save() calls to this same session on the next tool invocation. Must
// stay byte-for-byte compatible with the Go readSessionHandoff() in
// internal/transport/mcp/session_handoff.go and the bash helper in
// plugin/claude-code/scripts/_helpers.sh — any change here requires both
// other sides to change too.
async function writeSessionHandoff(sessionID: string, cwd: string): Promise<void> {
if (!sessionID) return;
try {
const fs = await import("fs/promises");
const path = await import("path");
const os = await import("os");
const crypto = await import("crypto");
const dataDir = process.env.MNEMO_DATA_DIR ?? path.join(os.homedir(), ".mnemo");
const handoffDir = path.join(dataDir, "sessions", "by-cwd");
await fs.mkdir(handoffDir, { recursive: true });
// Resolve symlinks so both sides land on the same canonical path.
let absCwd = path.resolve(cwd);
try {
absCwd = await fs.realpath(absCwd);
} catch {
// Path may not exist — use the unresolved absolute form. Matches the
// Go side which also falls back to the literal on EvalSymlinks error.
}
const hash = crypto
.createHash("sha256")
.update(absCwd)
.digest("hex")
.slice(0, 16);
const target = path.join(handoffDir, `${hash}.txt`);
const tmp = `${target}.${process.pid}`;
await fs.writeFile(tmp, `${sessionID}\n`, "utf8");
await fs.rename(tmp, target);
} catch {
// Best-effort — the MCP will fall back to auto-generating a session if
// the handoff is missing.
}
}
async function clearSessionHandoff(cwd: string): Promise<void> {
try {
const fs = await import("fs/promises");
const path = await import("path");
const os = await import("os");
const crypto = await import("crypto");
const dataDir = process.env.MNEMO_DATA_DIR ?? path.join(os.homedir(), ".mnemo");
let absCwd = path.resolve(cwd);
try {
absCwd = await fs.realpath(absCwd);
} catch {
/* ignore */
}
const hash = crypto
.createHash("sha256")
.update(absCwd)
.digest("hex")
.slice(0, 16);
const target = path.join(dataDir, "sessions", "by-cwd", `${hash}.txt`);
await fs.unlink(target);
} catch {
/* file already gone — fine */
}
}
// Check if the Mnemo server is healthy.
async function checkHealth(cfg: MnemoConfig): Promise<boolean> {
try {
const resp = await fetch(`${baseURL(cfg)}/health`, {
signal: AbortSignal.timeout(2000),
});
return resp.ok;
} catch {
return false;
}
}
// Start the Mnemo server in the background if it is not already running.
async function ensureServer(cfg: MnemoConfig): Promise<void> {
if (await checkHealth(cfg)) return;
const mnemo = process.env.MNEMO_BIN ?? "mnemo";
const { spawn } = await import("child_process");
const child = spawn(mnemo, ["serve", String(cfg.port)], {
detached: true,
stdio: "ignore",
});
child.unref();
// Wait for the server to become ready (up to 10 seconds).
for (let i = 0; i < 20; i++) {
if (await checkHealth(cfg)) return;
await sleep(500);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Create a new session on the Mnemo server.
async function createSession(
cfg: MnemoConfig,
sessionID: string
): Promise<void> {
try {
await fetch(`${baseURL(cfg)}/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: sessionID,
project: cfg.project,
agent: "opencode",
} satisfies MnemoSession),
signal: AbortSignal.timeout(3000),
});
} catch {
// Best-effort: server may not support this endpoint yet.
}
}
// End a session on the Mnemo server.
async function endSession(
cfg: MnemoConfig,
sessionID: string
): Promise<void> {
try {
await fetch(`${baseURL(cfg)}/sessions/${sessionID}/end`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(2000),
});
} catch {
// Best-effort cleanup.
}
}
// Load memory context for the current project.
// Returns a formatted summary including memories, loaded skills, and active
// workflow state so the agent can resume where it left off.
async function loadContext(cfg: MnemoConfig): Promise<string> {
try {
const resp = await fetch(
`${baseURL(cfg)}/context?project=${encodeURIComponent(cfg.project)}`,
{ signal: AbortSignal.timeout(5000) }
);
if (!resp.ok) return "";
return await resp.text();
} catch {
return "";
}
}
// Send a passive capture to the Mnemo server (learning extraction).
async function capturePassive(
cfg: MnemoConfig,
capture: PassiveCapture
): Promise<void> {
try {
await fetch(`${baseURL(cfg)}/memories/passive`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(capture),
signal: AbortSignal.timeout(3000),
});
} catch {
// Fire-and-forget.
}
}
// ─── Plugin Lifecycle ───────────────────────────────────────────────────────
let _cfg: MnemoConfig;
let _sessionID: string;
// setup is called when the plugin is loaded by OpenCode.
export async function setup(): Promise<void> {
_cfg = resolveConfig();
_sessionID = generateSessionID();
await ensureServer(_cfg);
await createSession(_cfg, _sessionID);
// Hand off the session ID to the MCP subprocess via a CWD-keyed file so
// its resolveSession() returns the same id we just created instead of
// auto-spawning a parallel session.
await writeSessionHandoff(_sessionID, process.cwd());
}
// activate is called when the plugin becomes active in a session.
// Returns memory context (including skills and workflows) that OpenCode can
// inject into the system prompt.
export async function activate(): Promise<string> {
const context = await loadContext(_cfg);
return context;
}
// deactivate is called when the session ends.
export async function deactivate(): Promise<void> {
await endSession(_cfg, _sessionID);
await clearSessionHandoff(process.cwd());
}
// teardown is called when the plugin is unloaded.
export async function teardown(): Promise<void> {
await endSession(_cfg, _sessionID);
await clearSessionHandoff(process.cwd());
}
// capture sends a passive learning event to Mnemo.
// Can be called by OpenCode when it detects significant agent output.
export async function capture(
type: string,
content: string
): Promise<void> {
await capturePassive(_cfg, { type, content: content.slice(0, 8192) });
}
export default { setup, activate, deactivate, teardown, capture };