@geoql/mdr 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm](https://img.shields.io/npm/v/@geoql/mdr)](https://www.npmjs.com/package/@geoql/mdr)
6
6
  [![JSR](https://jsr.io/badges/@geoql/mdr)](https://jsr.io/@geoql/mdr)
7
7
  [![Pipeline](https://github.com/geoql/mdr/actions/workflows/pipeline.yml/badge.svg)](https://github.com/geoql/mdr/actions/workflows/pipeline.yml)
8
- [![codecov](https://codecov.io/gh/geoql/mdr/branch/main/graph/badge.svg)](https://codecov.io/gh/geoql/mdr)
8
+ [![codecov](https://codecov.io/gh/geoql/mdr/graph/badge.svg?token=GHLOD2ZG0N)](https://codecov.io/gh/geoql/mdr)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
10
10
 
11
11
  Macrodata gives an AI coding agent layered, local-first memory: a searchable journal, entity files for people and projects, always-injected state files, semantic search across every past session, a background daemon for scheduled reminders, and overnight self-maintenance. All memory is plain markdown and JSON on disk — nothing phones home, and the whole system runs inside the agent's existing permission model with no new APIs or attack surface.
@@ -65,7 +65,7 @@ Every session starts with context injection — identity, current projects, dail
65
65
  ## Architecture
66
66
 
67
67
  ```
68
- plugins/macrodata/
68
+ packages/macrodata/
69
69
  ├── .claude-plugin/plugin.json # Claude Code plugin manifest (hooks + MCP server)
70
70
  ├── opencode/ # OpenCode plugin variant
71
71
  │ ├── index.ts # Plugin entry: context injection + tools + daemon supervision
@@ -137,7 +137,7 @@ Macrodata runs inside the agent's existing permission model. It uses only the to
137
137
  pnpm install
138
138
  pnpm run lint # oxlint
139
139
  pnpm run typecheck # oxlint --type-aware --type-check
140
- pnpm run build # tsdown
140
+ pnpm run build # vite-plus (vp pack)
141
141
  pnpm run coverage # vitest run --coverage (100% gate)
142
142
  ```
143
143
 
File without changes
@@ -5,7 +5,7 @@
5
5
  * Called by hooks at session end / after compact to keep the conversation index fresh.
6
6
  */
7
7
 
8
- import { updateConversationIndex } from "../src/conversations.js";
8
+ import { updateConversationIndex } from '../src/conversations.js';
9
9
 
10
10
  export async function main(
11
11
  update: typeof updateConversationIndex = updateConversationIndex,
@@ -17,7 +17,7 @@ export async function main(
17
17
  );
18
18
  return 0;
19
19
  } catch (err) {
20
- console.error("Failed to index conversations:", err);
20
+ console.error('Failed to index conversations:', err);
21
21
  return 1;
22
22
  }
23
23
  }
@@ -14,7 +14,7 @@
14
14
  * MACRODATA_ROOT=/path/to/state
15
15
  */
16
16
 
17
- import { runDaemon } from "../src/daemon.js";
17
+ import { runDaemon } from '../src/daemon.js';
18
18
 
19
19
  export function isRunAsMain(argv1: string | undefined, moduleUrl: string): boolean {
20
20
  return Boolean(argv1) && moduleUrl === `file://${argv1}`;
@@ -152,7 +152,7 @@ get_usage() {
152
152
  - **flags.md** is how things reach the user across sessions — one line + pointer. Surface, don't hoard.
153
153
  - **journal** (`log_journal`) is durable and retrievable via `search_memory`. Writing it down is remembering, not forgetting — put detail here.
154
154
  - **entities/** hold persistent per-project/topic notes (searchable). Eviction is expected: resolved/aging items leave state for the journal/entity, leaving a pointer.
155
- - Search before claiming ignorance. Full guide: `plugins/macrodata/USAGE.md`.
155
+ - Search before claiming ignorance. Full guide: `packages/macrodata/USAGE.md`.
156
156
  USAGE
157
157
  }
158
158
 
File without changes
File without changes
@@ -148,7 +148,7 @@ macrodata_log_journal(topic="distill-summary", content="Processed N sessions. Ex
148
148
  "distilled_actions": [
149
149
  {
150
150
  "summary": "Added /distill skill to macrodata plugin",
151
- "files": ["plugins/macrodata/skills/distill/SKILL.md"],
151
+ "files": ["packages/macrodata/skills/distill/SKILL.md"],
152
152
  "outcome": "Skill extracts facts from conversations via sub-agents"
153
153
  }
154
154
  ],
@@ -4,21 +4,21 @@
4
4
  * Reads state files and formats them for injection into conversations
5
5
  */
6
6
 
7
- import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync } from "fs";
7
+ import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync } from 'fs';
8
8
 
9
- import { join } from "path";
10
- import { getStateRoot, getJournalDir, getRemindersDir } from "../src/config.js";
11
- import { detectUser } from "../src/detect-user.js";
9
+ import { join } from 'path';
10
+ import { getStateRoot, getJournalDir, getRemindersDir } from '../src/config.js';
11
+ import { detectUser } from '../src/detect-user.js';
12
12
 
13
13
  /**
14
14
  * Read and clear pending context from daemon
15
15
  */
16
16
  export function consumePendingContext(): string | null {
17
- const pendingPath = join(getStateRoot(), ".pending-context");
17
+ const pendingPath = join(getStateRoot(), '.pending-context');
18
18
  if (!existsSync(pendingPath)) return null;
19
19
 
20
20
  try {
21
- const content = readFileSync(pendingPath, "utf-8").trim();
21
+ const content = readFileSync(pendingPath, 'utf-8').trim();
22
22
  unlinkSync(pendingPath);
23
23
  return content || null;
24
24
  } catch {
@@ -27,7 +27,7 @@ export function consumePendingContext(): string | null {
27
27
  }
28
28
 
29
29
  // Re-export for compatibility
30
- export { getStateRoot } from "../src/config.js";
30
+ export { getStateRoot } from '../src/config.js';
31
31
 
32
32
  /**
33
33
  * Initialize state directory structure (directories only, no default files)
@@ -39,12 +39,12 @@ export function initializeStateRoot(): void {
39
39
  // Create directories only - files created during onboarding
40
40
  const dirs = [
41
41
  stateRoot,
42
- join(stateRoot, "state"),
43
- join(stateRoot, "journal"),
44
- join(stateRoot, "entities"),
45
- join(stateRoot, "entities", "people"),
46
- join(stateRoot, "entities", "projects"),
47
- join(stateRoot, "topics"),
42
+ join(stateRoot, 'state'),
43
+ join(stateRoot, 'journal'),
44
+ join(stateRoot, 'entities'),
45
+ join(stateRoot, 'entities', 'people'),
46
+ join(stateRoot, 'entities', 'projects'),
47
+ join(stateRoot, 'topics'),
48
48
  ];
49
49
 
50
50
  for (const dir of dirs) {
@@ -57,12 +57,12 @@ export function initializeStateRoot(): void {
57
57
  function readFileOrEmpty(path: string): string {
58
58
  try {
59
59
  if (existsSync(path)) {
60
- return readFileSync(path, "utf-8");
60
+ return readFileSync(path, 'utf-8');
61
61
  }
62
62
  } catch {
63
63
  // Ignore
64
64
  }
65
- return "";
65
+ return '';
66
66
  }
67
67
 
68
68
  interface JournalEntry {
@@ -80,15 +80,15 @@ function getRecentJournal(count: number): JournalEntry[] {
80
80
 
81
81
  try {
82
82
  const files = readdirSync(journalDir)
83
- .filter((f) => f.endsWith(".jsonl"))
83
+ .filter((f) => f.endsWith('.jsonl'))
84
84
  .sort()
85
85
  .reverse();
86
86
 
87
87
  for (const file of files) {
88
88
  if (entries.length >= count) break;
89
89
 
90
- const content = readFileSync(join(journalDir, file), "utf-8");
91
- const lines = content.trim().split("\n").filter(Boolean);
90
+ const content = readFileSync(join(journalDir, file), 'utf-8');
91
+ const lines = content.trim().split('\n').filter(Boolean);
92
92
 
93
93
  for (const line of lines.reverse()) {
94
94
  if (entries.length >= count) break;
@@ -108,7 +108,7 @@ function getRecentJournal(count: number): JournalEntry[] {
108
108
 
109
109
  interface Schedule {
110
110
  id: string;
111
- type: "cron" | "once";
111
+ type: 'cron' | 'once';
112
112
  expression: string;
113
113
  description: string;
114
114
  payload: string;
@@ -121,10 +121,10 @@ function getSchedules(): Schedule[] {
121
121
 
122
122
  const schedules: Schedule[] = [];
123
123
  try {
124
- const files = readdirSync(remindersDir).filter((f) => f.endsWith(".json"));
124
+ const files = readdirSync(remindersDir).filter((f) => f.endsWith('.json'));
125
125
  for (const file of files) {
126
126
  try {
127
- const content = readFileSync(join(remindersDir, file), "utf-8");
127
+ const content = readFileSync(join(remindersDir, file), 'utf-8');
128
128
  schedules.push(JSON.parse(content));
129
129
  } catch {
130
130
  // Skip malformed files
@@ -153,7 +153,7 @@ interface FormatOptions {
153
153
  export async function formatContextForPrompt(options: FormatOptions = {}): Promise<string | null> {
154
154
  const { forCompaction = false, client } = options;
155
155
  const stateRoot = getStateRoot();
156
- const identityPath = join(stateRoot, "state", "identity.md");
156
+ const identityPath = join(stateRoot, 'state', 'identity.md');
157
157
  const isFirstRun = !existsSync(identityPath);
158
158
 
159
159
  // First run - return minimal context with onboarding pointer and detected user info
@@ -179,54 +179,54 @@ Use this pre-detected info during onboarding instead of running detection script
179
179
  }
180
180
 
181
181
  const identity = readFileOrEmpty(identityPath);
182
- const today = readFileOrEmpty(join(stateRoot, "state", "today.md"));
183
- const human = readFileOrEmpty(join(stateRoot, "state", "human.md"));
184
- const workspace = readFileOrEmpty(join(stateRoot, "state", "workspace.md"));
182
+ const today = readFileOrEmpty(join(stateRoot, 'state', 'today.md'));
183
+ const human = readFileOrEmpty(join(stateRoot, 'state', 'human.md'));
184
+ const workspace = readFileOrEmpty(join(stateRoot, 'state', 'workspace.md'));
185
185
 
186
186
  // Get recent journal
187
187
  const journalEntries = getRecentJournal(forCompaction ? 10 : 5);
188
188
  const journalFormatted = journalEntries
189
189
  .map((e) => {
190
190
  const ts = new Date(e.timestamp);
191
- const date = isNaN(ts.getTime()) ? "unknown" : ts.toLocaleDateString();
192
- return `- [${e.topic}] ${e.content.split("\n")[0]} (${date})`;
191
+ const date = isNaN(ts.getTime()) ? 'unknown' : ts.toLocaleDateString();
192
+ return `- [${e.topic}] ${e.content.split('\n')[0]} (${date})`;
193
193
  })
194
- .join("\n");
194
+ .join('\n');
195
195
 
196
196
  // Get schedules
197
197
  const schedules = getSchedules();
198
198
  const schedulesFormatted =
199
199
  schedules.length > 0
200
- ? schedules.map((s) => `- ${s.description} (${s.type}: ${s.expression})`).join("\n")
201
- : "_No active schedules_";
200
+ ? schedules.map((s) => `- ${s.description} (${s.type}: ${s.expression})`).join('\n')
201
+ : '_No active schedules_';
202
202
 
203
203
  const sections = [
204
- `<macrodata-identity>\n${identity || "_Not configured_"}\n</macrodata-identity>`,
205
- `<macrodata-today>\n${today || "_Empty_"}\n</macrodata-today>`,
206
- `<macrodata-human>\n${human || "_Empty_"}\n</macrodata-human>`,
204
+ `<macrodata-identity>\n${identity || '_Not configured_'}\n</macrodata-identity>`,
205
+ `<macrodata-today>\n${today || '_Empty_'}\n</macrodata-today>`,
206
+ `<macrodata-human>\n${human || '_Empty_'}\n</macrodata-human>`,
207
207
  ];
208
208
 
209
209
  if (workspace) {
210
210
  sections.push(`<macrodata-workspace>\n${workspace}\n</macrodata-workspace>`);
211
211
  }
212
212
 
213
- sections.push(`<macrodata-journal>\n${journalFormatted || "_No entries_"}\n</macrodata-journal>`);
213
+ sections.push(`<macrodata-journal>\n${journalFormatted || '_No entries_'}\n</macrodata-journal>`);
214
214
 
215
215
  if (!forCompaction) {
216
216
  sections.push(`<macrodata-schedules>\n${schedulesFormatted}\n</macrodata-schedules>`);
217
217
 
218
218
  // List state files
219
- const stateDir = join(stateRoot, "state");
219
+ const stateDir = join(stateRoot, 'state');
220
220
  /* v8 ignore next -- unreachable: this path only runs post-first-run, which
221
221
  means identity.md exists under stateDir, so stateDir always exists. */
222
222
  const stateFiles = existsSync(stateDir)
223
223
  ? readdirSync(stateDir)
224
- .filter((f) => f.endsWith(".md"))
224
+ .filter((f) => f.endsWith('.md'))
225
225
  .map((f) => `state/${f}`)
226
226
  : [];
227
227
 
228
228
  // List entity files (scan all subdirs dynamically)
229
- const entitiesDir = join(stateRoot, "entities");
229
+ const entitiesDir = join(stateRoot, 'entities');
230
230
  const entityFiles: string[] = [];
231
231
  if (existsSync(entitiesDir)) {
232
232
  for (const subdir of readdirSync(entitiesDir)) {
@@ -235,7 +235,7 @@ Use this pre-detected info during onboarding instead of running detection script
235
235
  /* v8 ignore next -- redundant guard: subdir came from readdirSync so it
236
236
  exists, and a non-directory throws below and is caught, not skipped here. */
237
237
  if (!existsSync(dir) || !readdirSync(dir)) continue;
238
- for (const f of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
238
+ for (const f of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
239
239
  entityFiles.push(`entities/${subdir}/${f}`);
240
240
  }
241
241
  } catch {
@@ -248,13 +248,13 @@ Use this pre-detected info during onboarding instead of running detection script
248
248
  /* v8 ignore next -- unreachable: post-first-run always has state files
249
249
  (identity.md etc.), so allFiles is never empty here. */
250
250
  const filesFormatted =
251
- allFiles.length > 0 ? allFiles.map((f) => `- ${f}`).join("\n") : "_No files yet_";
251
+ allFiles.length > 0 ? allFiles.map((f) => `- ${f}`).join('\n') : '_No files yet_';
252
252
 
253
253
  // Read usage from shared file
254
- const usagePath = new URL("../USAGE.md", import.meta.url).pathname;
254
+ const usagePath = new URL('../USAGE.md', import.meta.url).pathname;
255
255
  /* v8 ignore next -- USAGE.md is always shipped alongside the built plugin,
256
256
  so the empty-usage fallback is defensive only. */
257
- const usage = existsSync(usagePath) ? readFileSync(usagePath, "utf-8").trim() : "";
257
+ const usage = existsSync(usagePath) ? readFileSync(usagePath, 'utf-8').trim() : '';
258
258
 
259
259
  /* v8 ignore next 3 -- usage is always populated (USAGE.md ships with the
260
260
  plugin), so the no-usage skip is defensive only. */
@@ -288,7 +288,7 @@ Use this pre-detected info during onboarding instead of running detection script
288
288
  allModels.push({
289
289
  fullId: `${provider.id}/${modelId}`,
290
290
  family: m.family || `${provider.id}/${modelId}`,
291
- releaseDate: m.release_date || "1970-01-01",
291
+ releaseDate: m.release_date || '1970-01-01',
292
292
  });
293
293
  }
294
294
  }
@@ -308,7 +308,7 @@ Use this pre-detected info during onboarding instead of running detection script
308
308
  .sort();
309
309
  if (models.length > 0) {
310
310
  sections.push(
311
- `<macrodata-models>\nAvailable models for scheduling: ${models.join(", ")}\n</macrodata-models>`,
311
+ `<macrodata-models>\nAvailable models for scheduling: ${models.join(', ')}\n</macrodata-models>`,
312
312
  );
313
313
  }
314
314
  }
@@ -318,5 +318,5 @@ Use this pre-detected info during onboarding instead of running detection script
318
318
  }
319
319
  }
320
320
 
321
- return `<macrodata>\n${sections.join("\n\n")}\n</macrodata>`;
321
+ return `<macrodata>\n${sections.join('\n\n')}\n</macrodata>`;
322
322
  }
@@ -11,18 +11,18 @@
11
11
  * - project: id, worktree
12
12
  */
13
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";
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
22
 
23
23
  const OPENCODE_DB_PATH =
24
24
  process.env.MACRODATA_OPENCODE_DB_PATH ||
25
- join(homedir(), ".local", "share", "opencode", "opencode.db");
25
+ join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
26
26
 
27
27
  // Conversation index singleton
28
28
  let convIndex: LocalIndex | null = null;
@@ -36,9 +36,9 @@ async function getConversationIndex(): Promise<LocalIndex> {
36
36
  if (convIndex) return convIndex;
37
37
 
38
38
  const stateRoot = getStateRoot();
39
- const indexPath = join(stateRoot, ".index", "oc-conversations");
39
+ const indexPath = join(stateRoot, '.index', 'oc-conversations');
40
40
 
41
- const indexDir = join(stateRoot, ".index");
41
+ const indexDir = join(stateRoot, '.index');
42
42
  if (!existsSync(indexDir)) {
43
43
  mkdirSync(indexDir, { recursive: true });
44
44
  }
@@ -46,7 +46,7 @@ async function getConversationIndex(): Promise<LocalIndex> {
46
46
  convIndex = new LocalIndex(indexPath);
47
47
 
48
48
  if (!(await convIndex.isIndexCreated())) {
49
- logger.log("Creating new conversation index...");
49
+ logger.log('Creating new conversation index...');
50
50
  await convIndex.createIndex();
51
51
  }
52
52
 
@@ -110,7 +110,7 @@ interface ExchangeRow {
110
110
  export function queryExchanges(db: DatabaseSync, sinceMs?: number): ExchangeRow[] {
111
111
  // Interpolated inside the user_messages CTE body, where only `m` and `s`
112
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 > ?" : "";
113
+ const whereClause = sinceMs ? 'AND m.time_created > ?' : '';
114
114
  const params = sinceMs ? [sinceMs] : [];
115
115
 
116
116
  // Get user-assistant pairs with their text content.
@@ -190,11 +190,11 @@ function rowsToExchanges(rows: ExchangeRow[]): ConversationExchange[] {
190
190
  return rows.map((row) => {
191
191
  // Use project worktree, but fall back to session directory for "global" sessions
192
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 || "";
193
+ const worktree = row.worktree && row.worktree !== '/' ? row.worktree : '';
194
+ const directory = row.directory || '';
195
195
  const projectPath = worktree || directory;
196
- const name = projectPath ? basename(projectPath) : "";
197
- const projectName = name || "unknown";
196
+ const name = projectPath ? basename(projectPath) : '';
197
+ const projectName = name || 'unknown';
198
198
 
199
199
  return {
200
200
  id: `oc-${row.session_id}-${row.user_msg_id}`,
@@ -217,7 +217,7 @@ let rebuildInProgress: Promise<{ exchangeCount: number }> | null = null;
217
217
  */
218
218
  export async function rebuildConversationIndex(): Promise<{ exchangeCount: number }> {
219
219
  if (rebuildInProgress) {
220
- logger.log("Conversation index rebuild already in progress, waiting...");
220
+ logger.log('Conversation index rebuild already in progress, waiting...');
221
221
  return rebuildInProgress;
222
222
  }
223
223
 
@@ -230,7 +230,7 @@ export async function rebuildConversationIndex(): Promise<{ exchangeCount: numbe
230
230
  }
231
231
 
232
232
  async function doRebuildConversationIndex(): Promise<{ exchangeCount: number }> {
233
- logger.log("Rebuilding OpenCode conversation index...");
233
+ logger.log('Rebuilding OpenCode conversation index...');
234
234
  const startTime = Date.now();
235
235
 
236
236
  const db = openDb();
@@ -392,7 +392,7 @@ export async function getConversationIndexStats(): Promise<{ exchangeCount: numb
392
392
  * Incrementally update conversation index (only new exchanges)
393
393
  */
394
394
  export async function updateConversationIndex(): Promise<{ newCount: number; totalCount: number }> {
395
- logger.log("Updating OpenCode conversation index...");
395
+ logger.log('Updating OpenCode conversation index...');
396
396
  const startTime = Date.now();
397
397
 
398
398
  const db = openDb();
package/opencode/index.ts CHANGED
@@ -7,19 +7,19 @@
7
7
  * - Custom `macrodata` tool for memory operations
8
8
  */
9
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";
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
16
  import {
17
17
  formatContextForPrompt,
18
18
  consumePendingContext,
19
19
  initializeStateRoot,
20
20
  getStateRoot,
21
- } from "./context.js";
22
- import { logger } from "./logger.js";
21
+ } from './context.js';
22
+ import { logger } from './logger.js';
23
23
 
24
24
  /**
25
25
  * Check if a process with given PID is running
@@ -37,13 +37,13 @@ function isProcessRunning(pid: number): boolean {
37
37
  * Send SIGHUP to the daemon to reload config
38
38
  */
39
39
  function signalDaemonReload(): void {
40
- const pidFile = join(homedir(), ".config", "macrodata", ".daemon.pid");
40
+ const pidFile = join(homedir(), '.config', 'macrodata', '.daemon.pid');
41
41
  if (!existsSync(pidFile)) return;
42
42
 
43
43
  try {
44
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
44
+ const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
45
45
  if (isProcessRunning(pid)) {
46
- process.kill(pid, "SIGHUP");
46
+ process.kill(pid, 'SIGHUP');
47
47
  }
48
48
  } catch {
49
49
  // Ignore errors
@@ -58,22 +58,22 @@ const HEARTBEAT_STALE_MS = 15 * 60_000;
58
58
  * the heartbeat file is stale (wedged daemon, see #25).
59
59
  */
60
60
  function ensureDaemonRunning(): void {
61
- const configDir = join(homedir(), ".config", "macrodata");
62
- const pidFile = join(configDir, ".daemon.pid");
61
+ const configDir = join(homedir(), '.config', 'macrodata');
62
+ const pidFile = join(configDir, '.daemon.pid');
63
63
  const stateRoot = getStateRoot();
64
- const heartbeatFile = join(stateRoot, ".daemon.heartbeat");
65
- const daemonScript = join(import.meta.dirname, "..", "bin", "macrodata-daemon.js");
64
+ const heartbeatFile = join(stateRoot, '.daemon.heartbeat');
65
+ const daemonScript = join(import.meta.dirname, '..', 'bin', 'macrodata-daemon.js');
66
66
 
67
67
  if (existsSync(pidFile)) {
68
68
  try {
69
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
69
+ const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
70
70
  if (isProcessRunning(pid)) {
71
71
  if (!isHeartbeatStale(heartbeatFile)) {
72
72
  return;
73
73
  }
74
74
  logger.warn(`Daemon PID ${pid} alive but heartbeat stale, restarting`);
75
75
  try {
76
- process.kill(pid, "SIGKILL");
76
+ process.kill(pid, 'SIGKILL');
77
77
  } catch {
78
78
  // Already gone
79
79
  }
@@ -88,13 +88,13 @@ function ensureDaemonRunning(): void {
88
88
  // Ensure config dir exists for PID file
89
89
  mkdirSync(configDir, { recursive: true });
90
90
 
91
- const logFile = join(getStateRoot(), ".daemon.log");
92
- const out = openSync(logFile, "a");
93
- const err = openSync(logFile, "a");
91
+ const logFile = join(getStateRoot(), '.daemon.log');
92
+ const out = openSync(logFile, 'a');
93
+ const err = openSync(logFile, 'a');
94
94
 
95
95
  const child = spawn(process.execPath, [daemonScript], {
96
96
  detached: true,
97
- stdio: ["ignore", out, err],
97
+ stdio: ['ignore', out, err],
98
98
  env: { ...process.env, MACRODATA_ROOT: stateRoot },
99
99
  });
100
100
  child.unref();
@@ -108,7 +108,7 @@ function isHeartbeatStale(heartbeatFile: string): boolean {
108
108
  return false;
109
109
  }
110
110
  try {
111
- const lastBeat = parseInt(readFileSync(heartbeatFile, "utf-8").trim(), 10);
111
+ const lastBeat = parseInt(readFileSync(heartbeatFile, 'utf-8').trim(), 10);
112
112
  return Number.isFinite(lastBeat) && Date.now() - lastBeat > HEARTBEAT_STALE_MS;
113
113
  } catch {
114
114
  return false;
@@ -120,9 +120,9 @@ function isHeartbeatStale(heartbeatFile: string): boolean {
120
120
  * Skills are copied from the plugin's skills directory on first load
121
121
  */
122
122
  function installSkills(): void {
123
- const globalSkillsDir = join(homedir(), ".config", "opencode", "skills");
123
+ const globalSkillsDir = join(homedir(), '.config', 'opencode', 'skills');
124
124
  // import.meta.dirname is the opencode/ folder
125
- const pluginSkillsDir = join(import.meta.dirname, "skills");
125
+ const pluginSkillsDir = join(import.meta.dirname, 'skills');
126
126
 
127
127
  /* v8 ignore next 3 -- the skills/ directory always ships next to the built
128
128
  plugin, so this missing-dir bail-out is defensive only. */
@@ -168,7 +168,7 @@ export const MacrodataPlugin: Plugin = async (ctx: PluginInput) => {
168
168
 
169
169
  return {
170
170
  // Inject memory context into system prompt
171
- "experimental.chat.system.transform": async (_input, output) => {
171
+ 'experimental.chat.system.transform': async (_input, output) => {
172
172
  try {
173
173
  const pendingContext = consumePendingContext();
174
174
  if (pendingContext) {
@@ -187,7 +187,7 @@ export const MacrodataPlugin: Plugin = async (ctx: PluginInput) => {
187
187
  },
188
188
 
189
189
  // Inject memory context before compaction
190
- "experimental.session.compacting": async (_input, output) => {
190
+ 'experimental.session.compacting': async (_input, output) => {
191
191
  try {
192
192
  const memoryContext = await formatContextForPrompt({ forCompaction: true });
193
193