@geoql/mdr 0.0.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.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +152 -0
  3. package/USAGE.md +59 -0
  4. package/bin/detect-user.sh +53 -0
  5. package/bin/index-conversations.ts +34 -0
  6. package/bin/macrodata-daemon.ts +28 -0
  7. package/bin/macrodata-hook.sh +277 -0
  8. package/dist/bin/index-conversations.js +31 -0
  9. package/dist/bin/macrodata-daemon.js +30 -0
  10. package/dist/opencode/context.js +210 -0
  11. package/dist/opencode/conversations.js +367 -0
  12. package/dist/opencode/index.js +155 -0
  13. package/dist/opencode/journal.js +108 -0
  14. package/dist/opencode/logger.js +29 -0
  15. package/dist/opencode/search.js +210 -0
  16. package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
  17. package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  18. package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  19. package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  20. package/dist/opencode/tools.js +367 -0
  21. package/dist/src/config.js +55 -0
  22. package/dist/src/conversations.js +513 -0
  23. package/dist/src/daemon.js +582 -0
  24. package/dist/src/detect-user.js +73 -0
  25. package/dist/src/embeddings.js +190 -0
  26. package/dist/src/index.js +413 -0
  27. package/dist/src/indexer.js +286 -0
  28. package/opencode/context.ts +322 -0
  29. package/opencode/conversations.ts +467 -0
  30. package/opencode/index.ts +208 -0
  31. package/opencode/journal.ts +153 -0
  32. package/opencode/logger.ts +32 -0
  33. package/opencode/search.ts +288 -0
  34. package/opencode/skills/macrodata-distill/SKILL.md +171 -0
  35. package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  36. package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  37. package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  38. package/opencode/tools.ts +453 -0
  39. package/package.json +87 -0
  40. package/src/config.ts +66 -0
  41. package/src/conversations.ts +709 -0
  42. package/src/daemon.ts +785 -0
  43. package/src/detect-user.ts +97 -0
  44. package/src/embeddings.ts +262 -0
  45. package/src/index.ts +726 -0
  46. package/src/indexer.ts +394 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * OpenCode Conversation Indexer
3
+ *
4
+ * Indexes past OpenCode sessions for semantic search.
5
+ * Reads from the OpenCode SQLite database at ~/.local/share/opencode/opencode.db
6
+ *
7
+ * Schema (relevant tables):
8
+ * - session: id, project_id, title, time_created, time_updated, parent_id
9
+ * - message: id, session_id, time_created, data (JSON with role, agent, etc.)
10
+ * - part: id, message_id, session_id, data (JSON with type, text, etc.)
11
+ * - project: id, worktree
12
+ */
13
+
14
+ import { existsSync, mkdirSync } from "fs";
15
+ import { join, basename } from "path";
16
+ import { homedir } from "os";
17
+ import { DatabaseSync } from "node:sqlite";
18
+ import { LocalIndex } from "vectra";
19
+ import { embedBatch, embedQuery } from "../src/embeddings.js";
20
+ import { getStateRoot } from "./context.js";
21
+ import { logger } from "./logger.js";
22
+
23
+ const OPENCODE_DB_PATH =
24
+ process.env.MACRODATA_OPENCODE_DB_PATH ||
25
+ join(homedir(), ".local", "share", "opencode", "opencode.db");
26
+
27
+ // Conversation index singleton
28
+ let convIndex: LocalIndex | null = null;
29
+
30
+ // Test seam: drop the cached index so a new MACRODATA_ROOT is picked up.
31
+ export function resetConversationIndexForTests(): void {
32
+ convIndex = null;
33
+ }
34
+
35
+ async function getConversationIndex(): Promise<LocalIndex> {
36
+ if (convIndex) return convIndex;
37
+
38
+ const stateRoot = getStateRoot();
39
+ const indexPath = join(stateRoot, ".index", "oc-conversations");
40
+
41
+ const indexDir = join(stateRoot, ".index");
42
+ if (!existsSync(indexDir)) {
43
+ mkdirSync(indexDir, { recursive: true });
44
+ }
45
+
46
+ convIndex = new LocalIndex(indexPath);
47
+
48
+ if (!(await convIndex.isIndexCreated())) {
49
+ logger.log("Creating new conversation index...");
50
+ await convIndex.createIndex();
51
+ }
52
+
53
+ return convIndex;
54
+ }
55
+
56
+ export interface ConversationExchange {
57
+ id: string;
58
+ userPrompt: string;
59
+ assistantSummary: string;
60
+ project: string;
61
+ projectPath: string;
62
+ timestamp: string;
63
+ sessionId: string;
64
+ messageId: string;
65
+ }
66
+
67
+ export interface ConversationSearchResult {
68
+ exchange: ConversationExchange;
69
+ score: number;
70
+ adjustedScore: number;
71
+ }
72
+
73
+ /**
74
+ * Open the OpenCode SQLite database (read-only)
75
+ */
76
+ function openDb(): DatabaseSync | null {
77
+ if (!existsSync(OPENCODE_DB_PATH)) {
78
+ logger.log(`OpenCode database not found at ${OPENCODE_DB_PATH}`);
79
+ return null;
80
+ }
81
+
82
+ try {
83
+ return new DatabaseSync(OPENCODE_DB_PATH, { readOnly: true });
84
+ } catch (err) {
85
+ logger.error(`Failed to open OpenCode database: ${String(err)}`);
86
+ return null;
87
+ }
88
+ }
89
+
90
+ interface ExchangeRow {
91
+ user_msg_id: string;
92
+ session_id: string;
93
+ user_time: number;
94
+ user_text: string;
95
+ assistant_text: string;
96
+ worktree: string | null;
97
+ directory: string | null;
98
+ }
99
+
100
+ /**
101
+ * Query exchanges from the SQLite database.
102
+ *
103
+ * This runs a single query that:
104
+ * 1. Finds user messages (role = 'user') that aren't compaction summaries
105
+ * 2. Finds the next assistant message in the same session
106
+ * 3. Aggregates text parts for both user and assistant messages
107
+ * 4. Joins to project for worktree path
108
+ * 5. Excludes subtask sessions (parent_id IS NULL)
109
+ */
110
+ export function queryExchanges(db: DatabaseSync, sinceMs?: number): ExchangeRow[] {
111
+ // Interpolated inside the user_messages CTE body, where only `m` and `s`
112
+ // are in scope (`um` is the outer query's alias and must not be used here).
113
+ const whereClause = sinceMs ? "AND m.time_created > ?" : "";
114
+ const params = sinceMs ? [sinceMs] : [];
115
+
116
+ // Get user-assistant pairs with their text content.
117
+ // We use a CTE to match each user message with its subsequent assistant message,
118
+ // then aggregate text parts for both.
119
+ const sql = `
120
+ WITH user_messages AS (
121
+ SELECT
122
+ m.id AS user_msg_id,
123
+ m.session_id,
124
+ m.time_created AS user_time,
125
+ m.data AS user_data,
126
+ -- Find the next assistant message by time in the same session
127
+ (
128
+ SELECT am.id FROM message am
129
+ WHERE am.session_id = m.session_id
130
+ AND am.time_created > m.time_created
131
+ AND json_extract(am.data, '$.role') = 'assistant'
132
+ ORDER BY am.time_created ASC
133
+ LIMIT 1
134
+ ) AS assistant_msg_id
135
+ FROM message m
136
+ JOIN session s ON s.id = m.session_id
137
+ WHERE json_extract(m.data, '$.role') = 'user'
138
+ AND s.parent_id IS NULL
139
+ ${whereClause}
140
+ )
141
+ SELECT
142
+ um.user_msg_id,
143
+ um.session_id,
144
+ um.user_time,
145
+ COALESCE(
146
+ GROUP_CONCAT(
147
+ CASE WHEN up.message_id = um.user_msg_id AND json_extract(up.data, '$.type') = 'text'
148
+ THEN json_extract(up.data, '$.text')
149
+ END,
150
+ '\n'
151
+ ),
152
+ ''
153
+ ) AS user_text,
154
+ COALESCE(
155
+ GROUP_CONCAT(
156
+ CASE WHEN up.message_id = um.assistant_msg_id AND json_extract(up.data, '$.type') = 'text'
157
+ THEN json_extract(up.data, '$.text')
158
+ END,
159
+ '\n'
160
+ ),
161
+ ''
162
+ ) AS assistant_text,
163
+ p.worktree,
164
+ s.directory
165
+ FROM user_messages um
166
+ LEFT JOIN part up ON up.message_id IN (um.user_msg_id, um.assistant_msg_id)
167
+ LEFT JOIN session s ON s.id = um.session_id
168
+ LEFT JOIN project p ON p.id = s.project_id
169
+ WHERE um.assistant_msg_id IS NOT NULL
170
+ GROUP BY um.user_msg_id
171
+ HAVING user_text != ''
172
+ ORDER BY um.user_time ASC
173
+ `;
174
+
175
+ try {
176
+ return db.prepare(sql).all(...params) as unknown as ExchangeRow[];
177
+ } catch (err) {
178
+ // Propagate instead of returning [] so callers can't mistake a schema
179
+ // mismatch for "no new exchanges" (a silent no-op that previously
180
+ // disabled indexing for weeks, see #25).
181
+ logger.error(`Query failed: ${String(err)}`);
182
+ throw err;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Convert raw DB rows to ConversationExchange objects
188
+ */
189
+ function rowsToExchanges(rows: ExchangeRow[]): ConversationExchange[] {
190
+ return rows.map((row) => {
191
+ // Use project worktree, but fall back to session directory for "global" sessions
192
+ // where worktree is "/" (the root filesystem, not a real project)
193
+ const worktree = row.worktree && row.worktree !== "/" ? row.worktree : "";
194
+ const directory = row.directory || "";
195
+ const projectPath = worktree || directory;
196
+ const name = projectPath ? basename(projectPath) : "";
197
+ const projectName = name || "unknown";
198
+
199
+ return {
200
+ id: `oc-${row.session_id}-${row.user_msg_id}`,
201
+ userPrompt: row.user_text.slice(0, 1000),
202
+ assistantSummary: row.assistant_text.slice(0, 500),
203
+ project: projectName,
204
+ projectPath,
205
+ timestamp: new Date(row.user_time).toISOString(),
206
+ sessionId: row.session_id,
207
+ messageId: row.user_msg_id,
208
+ };
209
+ });
210
+ }
211
+
212
+ // Guard against concurrent rebuilds
213
+ let rebuildInProgress: Promise<{ exchangeCount: number }> | null = null;
214
+
215
+ /**
216
+ * Rebuild conversation index from scratch
217
+ */
218
+ export async function rebuildConversationIndex(): Promise<{ exchangeCount: number }> {
219
+ if (rebuildInProgress) {
220
+ logger.log("Conversation index rebuild already in progress, waiting...");
221
+ return rebuildInProgress;
222
+ }
223
+
224
+ rebuildInProgress = doRebuildConversationIndex();
225
+ try {
226
+ return await rebuildInProgress;
227
+ } finally {
228
+ rebuildInProgress = null;
229
+ }
230
+ }
231
+
232
+ async function doRebuildConversationIndex(): Promise<{ exchangeCount: number }> {
233
+ logger.log("Rebuilding OpenCode conversation index...");
234
+ const startTime = Date.now();
235
+
236
+ const db = openDb();
237
+ if (!db) return { exchangeCount: 0 };
238
+
239
+ try {
240
+ const rows = queryExchanges(db);
241
+ const exchanges = rowsToExchanges(rows);
242
+
243
+ logger.log(`Found ${exchanges.length} exchanges`);
244
+ if (exchanges.length === 0) return { exchangeCount: 0 };
245
+
246
+ // Generate all embeddings BEFORE touching the index
247
+ const texts = exchanges.map((e) => e.userPrompt);
248
+ logger.log(`Generating embeddings for ${texts.length} exchanges...`);
249
+ const vectors = await embedBatch(texts);
250
+ logger.log(`Embeddings generated, inserting into index...`);
251
+
252
+ // Only delete after embeddings succeed
253
+ // Reset singleton since deleteIndex invalidates the cached instance
254
+ convIndex = null;
255
+ const idx = await getConversationIndex();
256
+ if (await idx.isIndexCreated()) {
257
+ await idx.deleteIndex();
258
+ }
259
+ await idx.createIndex();
260
+
261
+ // Batch inside a single update transaction: without it, vectra rewrites
262
+ // the entire index.json on every upsert, which is O(n^2) and takes hours
263
+ // for tens of thousands of items.
264
+ await idx.beginUpdate();
265
+ try {
266
+ for (let i = 0; i < exchanges.length; i++) {
267
+ const ex = exchanges[i];
268
+ await idx.upsertItem({
269
+ id: ex.id,
270
+ vector: vectors[i],
271
+ metadata: {
272
+ userPrompt: ex.userPrompt,
273
+ assistantSummary: ex.assistantSummary,
274
+ project: ex.project,
275
+ projectPath: ex.projectPath,
276
+ timestamp: ex.timestamp,
277
+ sessionId: ex.sessionId,
278
+ messageId: ex.messageId,
279
+ },
280
+ });
281
+
282
+ /* v8 ignore next 3 -- progress log that only fires past 500 indexed
283
+ exchanges; seeding 500 real embedded rows per test is impractical and
284
+ the line has no behavioural effect. */
285
+ if (i > 0 && i % 500 === 0) {
286
+ logger.log(` ...inserted ${i}/${exchanges.length}`);
287
+ }
288
+ }
289
+ await idx.endUpdate();
290
+ } catch (err) {
291
+ idx.cancelUpdate();
292
+ throw err;
293
+ }
294
+
295
+ const duration = Date.now() - startTime;
296
+ logger.log(`Conversation index rebuilt: ${exchanges.length} exchanges in ${duration}ms`);
297
+ return { exchangeCount: exchanges.length };
298
+ } catch (err) {
299
+ logger.error(`Conversation index rebuild failed: ${String(err)}`);
300
+ throw err;
301
+ } finally {
302
+ db.close();
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Time-based weight for scoring
308
+ */
309
+ function getTimeWeight(timestamp: string): number {
310
+ const ts = new Date(timestamp);
311
+ if (isNaN(ts.getTime())) return 0.5;
312
+
313
+ const age = Date.now() - ts.getTime();
314
+ const dayMs = 24 * 60 * 60 * 1000;
315
+
316
+ if (age < 7 * dayMs) return 1.0;
317
+ if (age < 30 * dayMs) return 0.9;
318
+ if (age < 90 * dayMs) return 0.7;
319
+ if (age < 365 * dayMs) return 0.5;
320
+ return 0.3;
321
+ }
322
+
323
+ /**
324
+ * Search past conversations
325
+ */
326
+ export async function searchConversations(
327
+ query: string,
328
+ options: {
329
+ currentProject?: string;
330
+ limit?: number;
331
+ projectOnly?: boolean;
332
+ } = {},
333
+ ): Promise<ConversationSearchResult[]> {
334
+ const { currentProject, limit = 5, projectOnly = false } = options;
335
+
336
+ const idx = await getConversationIndex();
337
+ const stats = await idx.listItems();
338
+
339
+ if (stats.length === 0) {
340
+ return [];
341
+ }
342
+
343
+ const queryVector = await embedQuery(query);
344
+ const results = await idx.queryItems(queryVector, query, limit * 3);
345
+
346
+ const searchResults: ConversationSearchResult[] = results.map((r) => {
347
+ const meta = r.item.metadata as Record<string, string>;
348
+
349
+ const exchange: ConversationExchange = {
350
+ id: r.item.id,
351
+ userPrompt: meta.userPrompt,
352
+ assistantSummary: meta.assistantSummary,
353
+ project: meta.project,
354
+ projectPath: meta.projectPath,
355
+ timestamp: meta.timestamp,
356
+ sessionId: meta.sessionId,
357
+ messageId: meta.messageId,
358
+ };
359
+
360
+ let adjustedScore = r.score;
361
+ adjustedScore *= getTimeWeight(exchange.timestamp);
362
+
363
+ if (currentProject && exchange.projectPath === currentProject) {
364
+ adjustedScore *= 1.5;
365
+ }
366
+
367
+ return {
368
+ exchange,
369
+ score: r.score,
370
+ adjustedScore,
371
+ };
372
+ });
373
+
374
+ let filtered = searchResults;
375
+ if (projectOnly && currentProject) {
376
+ filtered = searchResults.filter((r) => r.exchange.projectPath === currentProject);
377
+ }
378
+
379
+ return filtered.sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, limit);
380
+ }
381
+
382
+ /**
383
+ * Get conversation index stats
384
+ */
385
+ export async function getConversationIndexStats(): Promise<{ exchangeCount: number }> {
386
+ const idx = await getConversationIndex();
387
+ const items = await idx.listItems();
388
+ return { exchangeCount: items.length };
389
+ }
390
+
391
+ /**
392
+ * Incrementally update conversation index (only new exchanges)
393
+ */
394
+ export async function updateConversationIndex(): Promise<{ newCount: number; totalCount: number }> {
395
+ logger.log("Updating OpenCode conversation index...");
396
+ const startTime = Date.now();
397
+
398
+ const db = openDb();
399
+ if (!db) return { newCount: 0, totalCount: 0 };
400
+
401
+ try {
402
+ const idx = await getConversationIndex();
403
+ const existingItems = await idx.listItems();
404
+ const existingIds = new Set(existingItems.map((item) => item.id));
405
+
406
+ // Find the most recent timestamp in the index to narrow the query
407
+ let latestMs = 0;
408
+ for (const item of existingItems) {
409
+ const meta = item.metadata as Record<string, string>;
410
+ if (meta.timestamp) {
411
+ const ms = new Date(meta.timestamp).getTime();
412
+ if (ms > latestMs) latestMs = ms;
413
+ }
414
+ }
415
+
416
+ // Query only exchanges after the latest indexed timestamp (with some overlap for safety)
417
+ const sinceMs = latestMs > 0 ? latestMs - 60_000 : undefined;
418
+ const rows = queryExchanges(db, sinceMs);
419
+ const allExchanges = rowsToExchanges(rows);
420
+
421
+ // Filter to truly new exchanges
422
+ const newExchanges = allExchanges.filter((ex) => !existingIds.has(ex.id));
423
+
424
+ logger.log(`Found ${newExchanges.length} new exchanges (${existingIds.size} already indexed)`);
425
+
426
+ if (newExchanges.length === 0) {
427
+ return { newCount: 0, totalCount: existingIds.size };
428
+ }
429
+
430
+ const texts = newExchanges.map((e) => e.userPrompt);
431
+ logger.log(`Generating embeddings for ${texts.length} new exchanges...`);
432
+ const vectors = await embedBatch(texts);
433
+
434
+ // Single update transaction: one index.json write for the whole batch
435
+ await idx.beginUpdate();
436
+ try {
437
+ for (let i = 0; i < newExchanges.length; i++) {
438
+ const ex = newExchanges[i];
439
+ await idx.upsertItem({
440
+ id: ex.id,
441
+ vector: vectors[i],
442
+ metadata: {
443
+ userPrompt: ex.userPrompt,
444
+ assistantSummary: ex.assistantSummary,
445
+ project: ex.project,
446
+ projectPath: ex.projectPath,
447
+ timestamp: ex.timestamp,
448
+ sessionId: ex.sessionId,
449
+ messageId: ex.messageId,
450
+ },
451
+ });
452
+ }
453
+ await idx.endUpdate();
454
+ } catch (err) {
455
+ idx.cancelUpdate();
456
+ throw err;
457
+ }
458
+
459
+ const duration = Date.now() - startTime;
460
+ const totalCount = existingIds.size + newExchanges.length;
461
+ logger.log(`Added ${newExchanges.length} exchanges in ${duration}ms (total: ${totalCount})`);
462
+
463
+ return { newCount: newExchanges.length, totalCount };
464
+ } finally {
465
+ db.close();
466
+ }
467
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * OpenCode Macrodata Plugin
3
+ *
4
+ * Provides persistent local memory for OpenCode agents:
5
+ * - Context injection via system prompt transform
6
+ * - Compaction hook to preserve memory context
7
+ * - Custom `macrodata` tool for memory operations
8
+ */
9
+
10
+ import type { Plugin, PluginInput } from "@opencode-ai/plugin";
11
+ import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, openSync } from "fs";
12
+ import { join } from "path";
13
+ import { homedir } from "os";
14
+ import { spawn } from "child_process";
15
+ import { memoryTools } from "./tools.js";
16
+ import {
17
+ formatContextForPrompt,
18
+ consumePendingContext,
19
+ initializeStateRoot,
20
+ getStateRoot,
21
+ } from "./context.js";
22
+ import { logger } from "./logger.js";
23
+
24
+ /**
25
+ * Check if a process with given PID is running
26
+ */
27
+ function isProcessRunning(pid: number): boolean {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Send SIGHUP to the daemon to reload config
38
+ */
39
+ function signalDaemonReload(): void {
40
+ const pidFile = join(homedir(), ".config", "macrodata", ".daemon.pid");
41
+ if (!existsSync(pidFile)) return;
42
+
43
+ try {
44
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
45
+ if (isProcessRunning(pid)) {
46
+ process.kill(pid, "SIGHUP");
47
+ }
48
+ } catch {
49
+ // Ignore errors
50
+ }
51
+ }
52
+
53
+ const HEARTBEAT_STALE_MS = 15 * 60_000;
54
+
55
+ /**
56
+ * Ensure the macrodata daemon is running and healthy.
57
+ * Starts it when the PID is dead, and restarts it when the PID is alive but
58
+ * the heartbeat file is stale (wedged daemon, see #25).
59
+ */
60
+ function ensureDaemonRunning(): void {
61
+ const configDir = join(homedir(), ".config", "macrodata");
62
+ const pidFile = join(configDir, ".daemon.pid");
63
+ const stateRoot = getStateRoot();
64
+ const heartbeatFile = join(stateRoot, ".daemon.heartbeat");
65
+ const daemonScript = join(import.meta.dirname, "..", "bin", "macrodata-daemon.js");
66
+
67
+ if (existsSync(pidFile)) {
68
+ try {
69
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
70
+ if (isProcessRunning(pid)) {
71
+ if (!isHeartbeatStale(heartbeatFile)) {
72
+ return;
73
+ }
74
+ logger.warn(`Daemon PID ${pid} alive but heartbeat stale, restarting`);
75
+ try {
76
+ process.kill(pid, "SIGKILL");
77
+ } catch {
78
+ // Already gone
79
+ }
80
+ }
81
+ } catch {
82
+ // Invalid PID file, continue to start daemon
83
+ }
84
+ }
85
+
86
+ // Start daemon - it writes its own PID file
87
+ try {
88
+ // Ensure config dir exists for PID file
89
+ mkdirSync(configDir, { recursive: true });
90
+
91
+ const logFile = join(getStateRoot(), ".daemon.log");
92
+ const out = openSync(logFile, "a");
93
+ const err = openSync(logFile, "a");
94
+
95
+ const child = spawn(process.execPath, [daemonScript], {
96
+ detached: true,
97
+ stdio: ["ignore", out, err],
98
+ env: { ...process.env, MACRODATA_ROOT: stateRoot },
99
+ });
100
+ child.unref();
101
+ } catch (err) {
102
+ logger.error(`Failed to start daemon: ${String(err)}`);
103
+ }
104
+ }
105
+
106
+ function isHeartbeatStale(heartbeatFile: string): boolean {
107
+ if (!existsSync(heartbeatFile)) {
108
+ return false;
109
+ }
110
+ try {
111
+ const lastBeat = parseInt(readFileSync(heartbeatFile, "utf-8").trim(), 10);
112
+ return Number.isFinite(lastBeat) && Date.now() - lastBeat > HEARTBEAT_STALE_MS;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Install plugin skills to ~/.config/opencode/skills/
120
+ * Skills are copied from the plugin's skills directory on first load
121
+ */
122
+ function installSkills(): void {
123
+ const globalSkillsDir = join(homedir(), ".config", "opencode", "skills");
124
+ // import.meta.dirname is the opencode/ folder
125
+ const pluginSkillsDir = join(import.meta.dirname, "skills");
126
+
127
+ /* v8 ignore next 3 -- the skills/ directory always ships next to the built
128
+ plugin, so this missing-dir bail-out is defensive only. */
129
+ if (!existsSync(pluginSkillsDir)) {
130
+ return;
131
+ }
132
+
133
+ // Ensure global skills directory exists
134
+ if (!existsSync(globalSkillsDir)) {
135
+ mkdirSync(globalSkillsDir, { recursive: true });
136
+ }
137
+
138
+ // Copy each skill directory
139
+ const skills = readdirSync(pluginSkillsDir, { withFileTypes: true })
140
+ .filter((d) => d.isDirectory())
141
+ .map((d) => d.name);
142
+
143
+ for (const skill of skills) {
144
+ const src = join(pluginSkillsDir, skill);
145
+ const dest = join(globalSkillsDir, skill);
146
+
147
+ // Always update skills (overwrite existing)
148
+ try {
149
+ cpSync(src, dest, { recursive: true });
150
+ } catch {
151
+ // Silently fail - non-critical
152
+ }
153
+ }
154
+ }
155
+
156
+ export const MacrodataPlugin: Plugin = async (ctx: PluginInput) => {
157
+ // Initialize state directories
158
+ initializeStateRoot();
159
+
160
+ // Ensure daemon is running for scheduled reminders
161
+ ensureDaemonRunning();
162
+
163
+ // Signal daemon to reload config (in case it was started with old config)
164
+ signalDaemonReload();
165
+
166
+ // Install skills to global config on plugin load
167
+ installSkills();
168
+
169
+ return {
170
+ // Inject memory context into system prompt
171
+ "experimental.chat.system.transform": async (_input, output) => {
172
+ try {
173
+ const pendingContext = consumePendingContext();
174
+ if (pendingContext) {
175
+ output.system.push(pendingContext);
176
+ }
177
+
178
+ const memoryContext = await formatContextForPrompt({ client: ctx.client });
179
+ /* v8 ignore next 3 -- outside compaction formatContextForPrompt always
180
+ returns a string (onboarding or full context), never null. */
181
+ if (memoryContext) {
182
+ output.system.push(memoryContext);
183
+ }
184
+ } catch (err) {
185
+ logger.error(`System context injection error: ${String(err)}`);
186
+ }
187
+ },
188
+
189
+ // Inject memory context before compaction
190
+ "experimental.session.compacting": async (_input, output) => {
191
+ try {
192
+ const memoryContext = await formatContextForPrompt({ forCompaction: true });
193
+
194
+ if (memoryContext) {
195
+ output.context.push(memoryContext);
196
+ }
197
+ } catch (err) {
198
+ logger.error(`Compaction hook error: ${String(err)}`);
199
+ }
200
+ },
201
+
202
+ // Provide memory tools
203
+ tool: memoryTools,
204
+ };
205
+ };
206
+
207
+ // Default export for OpenCode plugin system
208
+ export default MacrodataPlugin;