greprag 5.32.0 → 5.35.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.
Files changed (52) hide show
  1. package/dist/codex-hook-events.d.ts +20 -0
  2. package/dist/codex-hook-events.js +156 -0
  3. package/dist/codex-hook-events.js.map +1 -0
  4. package/dist/commands/codex-app-server.d.ts +1 -0
  5. package/dist/commands/codex-app-server.js +179 -0
  6. package/dist/commands/codex-app-server.js.map +1 -0
  7. package/dist/commands/codex-doctor.js +3 -1
  8. package/dist/commands/codex-doctor.js.map +1 -1
  9. package/dist/commands/codex.js +6 -0
  10. package/dist/commands/codex.js.map +1 -1
  11. package/dist/commands/corpus/index.d.ts +1 -0
  12. package/dist/commands/corpus/index.js +5 -0
  13. package/dist/commands/corpus/index.js.map +1 -1
  14. package/dist/commands/corpus/refresh.d.ts +1 -0
  15. package/dist/commands/corpus/refresh.js +60 -0
  16. package/dist/commands/corpus/refresh.js.map +1 -1
  17. package/dist/commands/desk-line.d.ts +36 -0
  18. package/dist/commands/desk-line.js +248 -0
  19. package/dist/commands/desk-line.js.map +1 -0
  20. package/dist/commands/inbox-spool.d.ts +27 -0
  21. package/dist/commands/inbox-spool.js +151 -0
  22. package/dist/commands/inbox-spool.js.map +1 -0
  23. package/dist/commands/inbox-watch.js +59 -8
  24. package/dist/commands/inbox-watch.js.map +1 -1
  25. package/dist/commands/init.js +75 -7
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/commands/opencode-relay.d.ts +136 -0
  28. package/dist/commands/opencode-relay.js +529 -0
  29. package/dist/commands/opencode-relay.js.map +1 -0
  30. package/dist/commands/opencode-watch.d.ts +17 -0
  31. package/dist/commands/opencode-watch.js +493 -0
  32. package/dist/commands/opencode-watch.js.map +1 -0
  33. package/dist/commands/status.d.ts +2 -0
  34. package/dist/commands/status.js +5 -1
  35. package/dist/commands/status.js.map +1 -1
  36. package/dist/commands/watcher-registry.d.ts +8 -0
  37. package/dist/commands/watcher-registry.js +19 -0
  38. package/dist/commands/watcher-registry.js.map +1 -1
  39. package/dist/hook.js +54 -83
  40. package/dist/hook.js.map +1 -1
  41. package/dist/index.js +220 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/opencode-plugin-helpers.d.ts +200 -0
  44. package/dist/opencode-plugin-helpers.js +512 -0
  45. package/dist/opencode-plugin-helpers.js.map +1 -0
  46. package/dist/opencode-plugin.d.ts +37 -134
  47. package/dist/opencode-plugin.js +648 -364
  48. package/dist/opencode-plugin.js.map +1 -1
  49. package/dist/session-id.d.ts +8 -6
  50. package/dist/session-id.js +10 -9
  51. package/dist/session-id.js.map +1 -1
  52. package/package.json +8 -4
@@ -7,15 +7,35 @@
7
7
  * 2. Capture every completed (user, assistant) turn pair to /v1/memory/turn
8
8
  * for episodic compaction.
9
9
  *
10
- * Loaded by OpenCode from ~/.config/opencode/plugins/greprag-memory.js
10
+ * Loaded by opencode from ~/.config/opencode/plugins/greprag-memory.js
11
11
  * (installed by `greprag init --opencode`). All types are declared inline so
12
- * the file has zero external runtime imports the package source must remain
13
- * self-contained because opencode's loader hands the compiled module straight
14
- * to Bun, with no resolution step against our dependency tree.
12
+ * the file has zero external runtime imports beyond the helpers module and
13
+ * Node built-ins opencode's loader hands the compiled module straight to
14
+ * Bun with no resolution step against our dependency tree.
15
+ *
16
+ * adr: adr/opencode-monitor-relay.md — see entries 2026-06-06 (f) for the
17
+ * `export = { id, server }` rationale (single V1 plugin module, no
18
+ * `__esModule` wrapper, no `__test` helper export) and (g) for the recap
19
+ * renderer alignment with `greprag-hook` (single source of truth for the
20
+ * recap body pushed at session start), and (h) for the defensive
21
+ * `output.system` / optional-`sessionID` handling.
22
+ *
23
+ * Opencode SessionStart model:
24
+ * Opencode has no true SessionStart hook (confirmed against sst/opencode
25
+ * docs and corroborated by other plugins — Superpowers, codexfi,
26
+ * opencode-rules, context-mode — all of which use
27
+ * `experimental.chat.system.transform` as a turn-1 surrogate). The
28
+ * experimental hook fires before each LLM call; we push the recap on the
29
+ * first fire per session and skip later fires. This is a turn-1 model,
30
+ * not a true session-start model, but it's the closest opencode has and
31
+ * it's what other working plugins do.
15
32
  *
16
33
  * Hook surface used (verified against sst/opencode dev branch):
17
34
  * - experimental.chat.system.transform — fires before each LLM call. We
18
- * push recap text on the FIRST fire per sessionID and ignore later fires.
35
+ * push recap text on the FIRST fire per sessionID and ignore later
36
+ * fires. Defensive: sessionID is optional (opencode issue #6142), and
37
+ * output.system may arrive as `string | string[]` depending on
38
+ * runtime/version — we handle both shapes.
19
39
  * - event — receives every bus event. We listen for "message.updated" with
20
40
  * role="assistant" and info.time.completed set (assistant message
21
41
  * finalized), then fetch the session's full message list via the supplied
@@ -59,17 +79,76 @@ var __importStar = (this && this.__importStar) || (function () {
59
79
  return result;
60
80
  };
61
81
  })();
62
- Object.defineProperty(exports, "__esModule", { value: true });
63
- exports.__test = exports.GrepRAGMemoryPlugin = void 0;
64
- // ============================================================================
65
- // Module-level setup: env + anchor (loaded once per opencode process)
66
- // ============================================================================
67
82
  const crypto = __importStar(require("crypto"));
68
83
  const fs = __importStar(require("fs"));
84
+ const os = __importStar(require("os"));
69
85
  const path = __importStar(require("path"));
70
86
  const child_process_1 = require("child_process");
87
+ // ============================================================================
88
+ // File-based debug log. opencode's plugin loader (Bun) does NOT route plugin
89
+ // stderr into the opencode log file at ~/.local/share/opencode/log/*.log —
90
+ // the `process.stderr.write` calls earlier were silently dropped. The watch
91
+ // process is its own spawned daemon so its stderr reaches the user's shell
92
+ // (which is why it appeared in the test bash output), but the plugin runs
93
+ // in-process and has no such pipe. We write to disk at a known location so
94
+ // the operator can `Get-Content ~/.greprag/opencode-plugin-debug.log -Tail
95
+ // 50` after a session to see exactly what the plugin did.
96
+ // ============================================================================
97
+ const DEBUG_LOG_PATH = path.join(os.homedir(), '.greprag', 'opencode-plugin-debug.log');
98
+ let _debugLogReady = false;
99
+ function dlogInit() {
100
+ if (_debugLogReady)
101
+ return;
102
+ _debugLogReady = true;
103
+ try {
104
+ fs.writeFileSync(DEBUG_LOG_PATH, '');
105
+ }
106
+ catch { /* disk full / permission denied — best effort */ }
107
+ }
108
+ function dlog(msg) {
109
+ dlogInit();
110
+ const line = `[${new Date().toISOString()}] [greprag-memory] ${msg}\n`;
111
+ try {
112
+ fs.appendFileSync(DEBUG_LOG_PATH, line);
113
+ }
114
+ catch { /* swallow */ }
115
+ }
116
+ dlog(`module top reached: pid=${process.pid} argv0=${process.argv[0]} distPath=${__filename}`);
117
+ // Helpers are deployed at ~/.greprag/opencode-plugin-helpers.js, NOT inside
118
+ // the opencode plugins dir. opencode auto-loads every `.js` file in
119
+ // `~/.config/opencode/plugins/` as a plugin, and the helpers file (a barrel
120
+ // of named function exports with `__esModule: true` tsc marker) would trip
121
+ // the same `getLegacyPlugins` shape check that the main file used to. Using
122
+ // an absolute require via os.homedir() sidesteps the auto-load entirely.
123
+ // The plugin registry config doesn't need a "helpers" entry — only the
124
+ // main file is registered with opencode. See adr/opencode-monitor-relay.md
125
+ // 2026-06-06 (f) for the location rationale.
126
+ const HELPERS_PATH = path.join(os.homedir(), '.greprag', 'opencode-plugin-helpers.js');
127
+ dlog(`requiring helpers from ${HELPERS_PATH}`);
128
+ let HOME;
129
+ let isPidAlive;
130
+ let readAnchor;
131
+ let relayLockPath;
132
+ let tryClaimRelayLock;
133
+ let buildRecapBody;
134
+ try {
135
+ const helpers = require(HELPERS_PATH);
136
+ HOME = helpers.HOME;
137
+ isPidAlive = helpers.isPidAlive;
138
+ readAnchor = helpers.readAnchor;
139
+ relayLockPath = helpers.relayLockPath;
140
+ tryClaimRelayLock = helpers.tryClaimRelayLock;
141
+ buildRecapBody = helpers.buildRecapBody;
142
+ dlog(`helpers loaded: HOME=${HOME} keys=${Object.keys(helpers).length}`);
143
+ }
144
+ catch (e) {
145
+ dlog(`FATAL: helpers require threw: ${e.message}\n${e.stack}`);
146
+ throw e;
147
+ }
148
+ // ============================================================================
149
+ // Module-level setup: env + anchor (loaded once per opencode process)
150
+ // ============================================================================
71
151
  const API_URL = 'https://api.greprag.com';
72
- const HOME = process.env.HOME || process.env.USERPROFILE || '';
73
152
  function loadEnvFile(filePath) {
74
153
  try {
75
154
  if (!fs.existsSync(filePath))
@@ -97,7 +176,7 @@ function loadEnvFile(filePath) {
97
176
  }
98
177
  }
99
178
  /** Load env vars from ~/.greprag/.env first, then the legacy Claude settings
100
- * env block. OpenCode is not Claude-specific, so the shared GrepRAG env file
179
+ * env block. opencode is not Claude-specific, so the shared GrepRAG env file
101
180
  * is canonical for new installs while ~/.claude/settings.json remains a
102
181
  * compatibility fallback. */
103
182
  function loadGrepragEnv() {
@@ -120,284 +199,286 @@ function loadGrepragEnv() {
120
199
  }
121
200
  }
122
201
  loadGrepragEnv();
123
- function getEnv(key) {
124
- return process.env[key] || '';
125
- }
126
- function readAnchorFile(filePath) {
202
+ dlog(`env loaded: MEMORY_HOOK_ENABLED=${process.env.MEMORY_HOOK_ENABLED || '<unset>'} GREPRAG_API_KEY=${process.env.GREPRAG_API_KEY ? 'set' : '<unset>'} GREPRAG_OPENCODE_CAPTURE=${process.env.GREPRAG_OPENCODE_CAPTURE || '<unset>'}`);
203
+ // ============================================================================
204
+ // Capture watcher spawner — "just works" UX
205
+ // ============================================================================
206
+ // The opencode event bus does not reliably fire for the user's active
207
+ // session, so a hook-based capture path is fragile. The on-disk SQLite DB
208
+ // at ~/.local/share/opencode/opencode.db IS reliable — opencode writes
209
+ // every (user, assistant) pair to it the moment a turn completes.
210
+ //
211
+ // This block spawns `greprag opencode watch` as a long-lived child process
212
+ // at module-load time. With this in place, the install flow becomes:
213
+ // 1. `npm install -g @greprag/cli`
214
+ // 2. `greprag init --opencode` (writes API key, plugin file, opencode.json)
215
+ // 3. Open opencode. Captures flow.
216
+ // No separate `opencode watch` invocation required.
217
+ const WATCHER_LOCK = path.join(HOME || os.homedir(), '.greprag', 'opencode-watch.lock');
218
+ const WATCHER_STALE_MS = 30_000;
219
+ function findGrepragBinary() {
220
+ const override = process.env.GREPRAG_BIN;
221
+ if (override && fs.existsSync(override))
222
+ return override;
223
+ const binaryName = process.platform === 'win32' ? 'greprag.cmd' : 'greprag';
127
224
  try {
128
- const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
129
- if (!raw || typeof raw !== 'object')
130
- return null;
131
- const notify = raw.inbox_notify;
132
- const inboxNotify = notify === 'off' || notify === 'session_start_only' ? notify : 'every_turn';
133
- return {
134
- projectId: typeof raw.project_id === 'string' ? raw.project_id : undefined,
135
- projectName: typeof raw.project_name === 'string' ? raw.project_name : undefined,
136
- memoryCapture: raw.memory_capture !== false,
137
- sessionStartRecap: raw.session_start_recap !== false,
138
- inboxNotify,
139
- };
225
+ const out = (0, child_process_1.execSync)(`where ${binaryName}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
226
+ for (const line of out.split(/\r?\n/)) {
227
+ const candidate = line.trim();
228
+ if (!candidate)
229
+ continue;
230
+ if (!fs.existsSync(candidate))
231
+ continue;
232
+ if (process.platform === 'win32') {
233
+ const ext = path.extname(candidate).toLowerCase();
234
+ if (ext === '.cmd' || ext === '.exe' || ext === '.bat')
235
+ return candidate;
236
+ const cmdSibling = candidate + '.cmd';
237
+ if (fs.existsSync(cmdSibling))
238
+ return cmdSibling;
239
+ continue;
240
+ }
241
+ return candidate;
242
+ }
140
243
  }
141
- catch {
142
- return null;
244
+ catch { /* not in PATH */ }
245
+ const candidates = process.platform === 'win32'
246
+ ? [
247
+ path.join(HOME, 'AppData', 'Roaming', 'npm', 'greprag.cmd'),
248
+ path.join(HOME, 'AppData', 'Roaming', 'npm', 'greprag'),
249
+ path.join(HOME, 'AppData', 'Local', 'Yarn', 'bin', 'greprag.cmd'),
250
+ path.join(HOME, 'AppData', 'Local', 'pnpm', 'bin', 'greprag.cmd'),
251
+ ]
252
+ : [
253
+ '/usr/local/bin/greprag',
254
+ '/opt/homebrew/bin/greprag',
255
+ path.join(HOME || '', '.local', 'bin', 'greprag'),
256
+ path.join(HOME || '', '.yarn', 'bin', 'greprag'),
257
+ ];
258
+ for (const c of candidates) {
259
+ try {
260
+ if (c && fs.existsSync(c))
261
+ return c;
262
+ }
263
+ catch { /* skip */ }
143
264
  }
265
+ return null;
144
266
  }
145
- /** Walk up from cwd looking for an anchor file. Checks canonical `.greprag/`
146
- * first, then legacy `.claude/` and `.opencode/` at each level. Returns the
147
- * first hit. Skips home-level globals; those are reserved for the
148
- * ephemeral-cwd path of the cascade. */
149
- function findExistingAnchorFile(startDir) {
150
- const globalAnchorPath = path.join(HOME, '.greprag', 'project.json');
151
- const legacyGlobalAnchorPath = path.join(HOME, '.claude', 'project.json');
152
- let dir = path.resolve(startDir);
153
- while (true) {
154
- for (const subdir of ['.greprag', '.claude', '.opencode']) {
155
- const candidate = path.join(dir, subdir, 'project.json');
156
- if (candidate !== globalAnchorPath && candidate !== legacyGlobalAnchorPath && fs.existsSync(candidate)) {
157
- return candidate;
267
+ function tryClaimWatcherLock() {
268
+ try {
269
+ const fd = fs.openSync(WATCHER_LOCK, 'wx');
270
+ return { ok: true, fd };
271
+ }
272
+ catch (err) {
273
+ if (err.code !== 'EEXIST') {
274
+ return { ok: false, reason: 'lock-create-failed' };
275
+ }
276
+ // Lockfile exists. Stale if mtime is old OR the recorded PID is dead.
277
+ try {
278
+ const stat = fs.statSync(WATCHER_LOCK);
279
+ const ageMs = Date.now() - stat.mtimeMs;
280
+ let pidAlive = false;
281
+ try {
282
+ const pid = parseInt(fs.readFileSync(WATCHER_LOCK, 'utf-8').trim(), 10);
283
+ pidAlive = pid > 0 && isPidAlive(pid);
158
284
  }
285
+ catch { /* unreadable — treat as stale */ }
286
+ if (ageMs < WATCHER_STALE_MS && pidAlive) {
287
+ return { ok: false, reason: 'busy' };
288
+ }
289
+ // Stale — remove and retry once.
290
+ try {
291
+ fs.unlinkSync(WATCHER_LOCK);
292
+ }
293
+ catch { /* raced */ }
294
+ const fd = fs.openSync(WATCHER_LOCK, 'wx');
295
+ return { ok: true, fd };
296
+ }
297
+ catch (err2) {
298
+ return { ok: false, reason: 'lock-stale-replace-failed' };
159
299
  }
160
- const parent = path.dirname(dir);
161
- if (parent === dir)
162
- return null;
163
- dir = parent;
164
300
  }
165
301
  }
166
- /** Format the SHA-256 of an input string into a deterministic UUID v4-shape
167
- * string. Must match the algorithm in packages/cli/src/project-anchor.ts so
168
- * the plugin resolves identical IDs to the Claude Code CLI. */
169
- function formatUuid(hashHex) {
170
- return [
171
- hashHex.slice(0, 8),
172
- hashHex.slice(8, 12),
173
- '4' + hashHex.slice(13, 16),
174
- '8' + hashHex.slice(17, 20),
175
- hashHex.slice(20, 32),
176
- ].join('-');
177
- }
178
- /** Derive a stable project_id from the repo's root commit SHA. Sorted +
179
- * hashed so disjoint-history merges produce one deterministic id everywhere.
180
- * Returns null when cwd isn't a git repo or has no commits yet. */
181
- function computeGitDerivedProjectId(cwd) {
302
+ function startCaptureWatcher() {
303
+ if (process.env.GREPRAG_OPENCODE_CAPTURE === '0')
304
+ return;
305
+ if (process.env.MEMORY_HOOK_ENABLED !== 'true')
306
+ return;
307
+ if (!process.env.GREPRAG_API_KEY)
308
+ return;
309
+ const grepragBin = findGrepragBinary();
310
+ if (!grepragBin) {
311
+ process.stderr.write('[greprag-memory] opencode watch could not start: greprag binary not in PATH. ' +
312
+ 'Run `npm install -g @greprag/cli` or set GREPRAG_BIN.\n');
313
+ return;
314
+ }
315
+ const claim = tryClaimWatcherLock();
316
+ if (!claim.ok) {
317
+ if (claim.reason === 'busy') {
318
+ process.stderr.write('[greprag-memory] opencode watch already running; not spawning\n');
319
+ }
320
+ else {
321
+ process.stderr.write(`[greprag-memory] opencode watch could not claim lock: ${claim.reason}\n`);
322
+ }
323
+ return;
324
+ }
325
+ // Seed the lockfile with the parent PID; spawn() will rewrite it with the
326
+ // child PID once the watcher is alive. If spawn itself throws, release.
182
327
  try {
183
- const out = (0, child_process_1.execSync)('git rev-list --max-parents=0 HEAD', {
184
- cwd,
185
- encoding: 'utf-8',
186
- stdio: ['pipe', 'pipe', 'pipe'],
187
- });
188
- const roots = out.trim().split(/\s+/).filter(Boolean).sort();
189
- if (roots.length === 0)
190
- return null;
191
- const hash = crypto.createHash('sha256').update(roots.join('\n')).digest('hex');
192
- return formatUuid(hash);
328
+ fs.writeSync(claim.fd, String(process.pid));
329
+ fs.closeSync(claim.fd);
193
330
  }
194
331
  catch {
195
- return null;
332
+ try {
333
+ fs.unlinkSync(WATCHER_LOCK);
334
+ }
335
+ catch { /* raced */ }
336
+ return;
196
337
  }
197
- }
198
- /** Hash the cwd path into a stable UUID. Last-resort fallback — when no git
199
- * history and no anchor file exist, this keeps capture flowing under a
200
- * per-path identity that `greprag doctor` can later consolidate. */
201
- function deterministicProjectId(cwd) {
202
- const normalized = path.resolve(cwd).toLowerCase();
203
- const hash = crypto.createHash('sha256').update(normalized).digest('hex');
204
- return formatUuid(hash);
205
- }
206
- /** True when cwd is an ephemeral session path (Cowork, tmp dirs). The
207
- * deterministic-hash fallback would mint a fresh id every session in those
208
- * paths, so we prefer the global anchor instead. */
209
- function isEphemeralCwd(cwd) {
210
- const norm = path.resolve(cwd).replace(/\\/g, '/').toLowerCase();
211
- if (norm.includes('/appdata/roaming/claude/local-agent-mode-sessions/'))
212
- return true;
213
- if (norm.includes('/appdata/local/claude/local-agent-mode-sessions/'))
214
- return true;
215
- if (norm.startsWith('/tmp/'))
216
- return true;
217
- if (norm.startsWith('/var/tmp/'))
218
- return true;
219
- if (norm.startsWith('/private/tmp/'))
220
- return true;
221
- return false;
222
- }
223
- /** Resolve the project anchor for `worktree` using the same 4-level cascade
224
- * the Claude Code CLI uses (packages/cli/src/project-anchor.ts). Settings
225
- * always come from the nearest repo-level file when one exists, regardless
226
- * of which identity level resolved.
227
- *
228
- * 1. Anchor file with explicit `project_id` → file-based identity
229
- * 2. Git repo with at least one commit → root-commit-derived UUID
230
- * 3. Ephemeral cwd + ~/.greprag/project.json exists → global anchor
231
- * 4. Path-hash fallback (never returns null; lets capture flow until
232
- * `greprag init` runs and `greprag doctor` consolidates) */
233
- function readAnchor(worktree) {
234
- const filePath = findExistingAnchorFile(worktree);
235
- const file = filePath ? readAnchorFile(filePath) : null;
236
- const root = path.resolve(worktree);
237
- // 1. File with explicit project_id
238
- if (file && file.projectId && file.projectName) {
239
- return {
240
- projectId: file.projectId,
241
- projectName: file.projectName,
242
- memoryCapture: file.memoryCapture,
243
- sessionStartRecap: file.sessionStartRecap,
244
- inboxNotify: file.inboxNotify,
245
- };
338
+ let stopped = false;
339
+ let currentChildPid = null;
340
+ let currentChild = null;
341
+ let respawnCount = 0;
342
+ let childStartTime = 0;
343
+ const MAX_RESPAWNS = 10;
344
+ const MAX_BACKOFF_MS = 30_000;
345
+ function tryReclaimLockForRespawn() {
346
+ // Re-claim via the same stale-detection path the first claim used. Handles
347
+ // (a) another opencode instance owning the lock now yield; (b) a stale
348
+ // lockfile left by our just-killed child (SIGKILL bypasses the watcher's
349
+ // own releaseLockFile) remove and reclaim.
350
+ const claim = tryClaimWatcherLock();
351
+ if (claim.ok) {
352
+ try {
353
+ fs.writeSync(claim.fd, String(process.pid));
354
+ fs.closeSync(claim.fd);
355
+ }
356
+ catch {
357
+ try {
358
+ fs.unlinkSync(WATCHER_LOCK);
359
+ }
360
+ catch { /* raced */ }
361
+ return false;
362
+ }
363
+ return true;
364
+ }
365
+ if (claim.reason === 'busy') {
366
+ process.stderr.write('[greprag-memory] lockfile claimed by another opencode instance; not respawning\n');
367
+ }
368
+ else {
369
+ process.stderr.write(`[greprag-memory] respawn lockfile error: ${claim.reason}\n`);
370
+ }
371
+ return false;
246
372
  }
247
- // 2. Git-derived identity. Settings layer in from the settings-only file
248
- // when one exists; otherwise defaults apply.
249
- const gitId = computeGitDerivedProjectId(worktree);
250
- if (gitId) {
251
- return {
252
- projectId: gitId,
253
- projectName: file?.projectName || path.basename(root).toLowerCase(),
254
- memoryCapture: file?.memoryCapture ?? true,
255
- sessionStartRecap: file?.sessionStartRecap ?? true,
256
- inboxNotify: file?.inboxNotify ?? 'every_turn',
257
- };
373
+ function spawnWatcher() {
374
+ if (stopped)
375
+ return;
376
+ if (!grepragBin)
377
+ return; // type narrow for closure capture
378
+ let child;
379
+ try {
380
+ child = (0, child_process_1.spawn)(grepragBin, ['opencode', 'watch'], {
381
+ stdio: ['ignore', 'pipe', 'pipe'],
382
+ env: process.env,
383
+ windowsHide: true,
384
+ shell: process.platform === 'win32',
385
+ });
386
+ }
387
+ catch (err) {
388
+ process.stderr.write(`[greprag-memory] spawn failed: ${err.message}\n`);
389
+ scheduleRespawn();
390
+ return;
391
+ }
392
+ currentChild = child;
393
+ currentChildPid = child.pid ?? null;
394
+ childStartTime = Date.now();
395
+ try {
396
+ fs.writeFileSync(WATCHER_LOCK, String(child.pid));
397
+ }
398
+ catch { /* best effort */ }
399
+ child.stderr?.on('data', (chunk) => {
400
+ process.stderr.write(`[greprag-watch] ${chunk.toString('utf-8')}`);
401
+ });
402
+ child.stdout?.on('data', (chunk) => {
403
+ process.stderr.write(`[greprag-watch] ${chunk.toString('utf-8')}`);
404
+ });
405
+ child.on('exit', (code, signal) => {
406
+ // Only release the lock if it still names OUR child — never yank
407
+ // a lockfile that another opencode instance now owns (mtime-vs-pid
408
+ // race window).
409
+ try {
410
+ const current = fs.readFileSync(WATCHER_LOCK, 'utf-8').trim();
411
+ if (current === String(child.pid))
412
+ fs.unlinkSync(WATCHER_LOCK);
413
+ }
414
+ catch { /* raced */ }
415
+ currentChild = null;
416
+ currentChildPid = null;
417
+ if (stopped)
418
+ return;
419
+ const cleanShutdown = code === 0 || signal === 'SIGTERM' || signal === 'SIGKILL';
420
+ if (!cleanShutdown) {
421
+ process.stderr.write(`[greprag-memory] opencode watch exited code=${code} signal=${signal || 'none'}, respawning\n`);
422
+ }
423
+ scheduleRespawn();
424
+ });
258
425
  }
259
- // 3. Ephemeral cwd + global anchor
260
- if (isEphemeralCwd(worktree)) {
261
- const canonicalGlobal = path.join(HOME, '.greprag', 'project.json');
262
- const legacyGlobal = path.join(HOME, '.claude', 'project.json');
263
- const globalPath = fs.existsSync(canonicalGlobal) ? canonicalGlobal : legacyGlobal;
264
- const globalFile = fs.existsSync(globalPath) ? readAnchorFile(globalPath) : null;
265
- if (globalFile && globalFile.projectId && globalFile.projectName) {
266
- return {
267
- projectId: globalFile.projectId,
268
- projectName: globalFile.projectName,
269
- memoryCapture: globalFile.memoryCapture,
270
- sessionStartRecap: globalFile.sessionStartRecap,
271
- inboxNotify: globalFile.inboxNotify,
272
- };
273
- }
274
- }
275
- // 4. Path-hash fallback — keep capture flowing for uninitialized repos.
276
- return {
277
- projectId: deterministicProjectId(worktree),
278
- projectName: file?.projectName || path.basename(root).toLowerCase(),
279
- memoryCapture: file?.memoryCapture ?? true,
280
- sessionStartRecap: file?.sessionStartRecap ?? true,
281
- inboxNotify: file?.inboxNotify ?? 'every_turn',
426
+ function scheduleRespawn() {
427
+ if (stopped)
428
+ return;
429
+ // Reset the backoff counter if the child ran for a meaningful interval
430
+ // before dying that's a healthy watcher, not a tight crash loop.
431
+ if (Date.now() - childStartTime > 30_000)
432
+ respawnCount = 0;
433
+ respawnCount++;
434
+ if (respawnCount > MAX_RESPAWNS) {
435
+ process.stderr.write(`[greprag-memory] opencode watch respawned ${respawnCount} times; giving up. ` +
436
+ 'Run `greprag doctor` to investigate.\n');
437
+ try {
438
+ fs.unlinkSync(WATCHER_LOCK);
439
+ }
440
+ catch { /* raced */ }
441
+ return;
442
+ }
443
+ if (!tryReclaimLockForRespawn())
444
+ return;
445
+ const backoff = Math.min(MAX_BACKOFF_MS, 1000 * Math.pow(2, Math.min(respawnCount, 5)));
446
+ process.stderr.write(`[greprag-memory] respawning opencode watch in ${Math.round(backoff / 1000)}s ` +
447
+ `(attempt ${respawnCount}/${MAX_RESPAWNS})\n`);
448
+ setTimeout(spawnWatcher, backoff).unref();
449
+ }
450
+ spawnWatcher();
451
+ // Clean teardown when opencode (the parent) exits. Only kill OUR child
452
+ // — never a child owned by another opencode instance that may now hold
453
+ // the lock.
454
+ const cleanup = () => {
455
+ if (stopped)
456
+ return;
457
+ stopped = true;
458
+ if (currentChild) {
459
+ try {
460
+ currentChild.kill('SIGTERM');
461
+ }
462
+ catch { /* already dead */ }
463
+ setTimeout(() => {
464
+ try {
465
+ currentChild?.kill('SIGKILL');
466
+ }
467
+ catch { /* already dead */ }
468
+ }, 2000).unref();
469
+ }
470
+ try {
471
+ fs.unlinkSync(WATCHER_LOCK);
472
+ }
473
+ catch { /* raced */ }
282
474
  };
475
+ process.on('exit', cleanup);
476
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
477
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
283
478
  }
284
- // ============================================================================
285
- // Envelope construction (real opencode Part schema)
286
- // ============================================================================
287
- function extractText(parts) {
288
- const out = [];
289
- for (const p of parts) {
290
- if (p.type !== 'text')
291
- continue;
292
- const tp = p;
293
- if (tp.synthetic || tp.ignored)
294
- continue;
295
- if (tp.text)
296
- out.push(tp.text);
297
- }
298
- return out.join('\n').trim();
299
- }
300
- /** Pull a one-line summary out of each tool call: name + a target string + an
301
- * optional brief for shell commands. Mirrors the shape the Claude Code hook
302
- * posts so server-side compaction sees identical structure regardless of
303
- * client origin. */
304
- function extractToolCalls(parts) {
305
- const calls = [];
306
- for (const p of parts) {
307
- if (p.type !== 'tool')
308
- continue;
309
- const tp = p;
310
- const name = tp.tool || 'unknown';
311
- const input = (tp.state && tp.state.input) || {};
312
- const call = { name };
313
- if (input.command !== undefined) {
314
- const desc = input.description;
315
- call.target =
316
- typeof desc === 'string' && desc
317
- ? desc
318
- : String(input.command).split(/\s+/)[0] || '';
319
- const cmd = String(input.command);
320
- call.brief = cmd.length > 800 ? cmd.slice(0, 800) + '…' : cmd;
321
- }
322
- else if (typeof input.file_path === 'string') {
323
- call.target = input.file_path;
324
- }
325
- else if (typeof input.filePath === 'string') {
326
- call.target = input.filePath;
327
- }
328
- else if (typeof input.pattern === 'string') {
329
- call.target = input.pattern;
330
- }
331
- else if (typeof input.url === 'string') {
332
- call.target = input.url;
333
- }
334
- else if (typeof input.query === 'string') {
335
- call.target = input.query;
336
- }
337
- calls.push(call);
338
- }
339
- return calls;
340
- }
341
- function extractFilesTouched(parts) {
342
- const files = new Set();
343
- for (const p of parts) {
344
- if (p.type !== 'tool')
345
- continue;
346
- const tp = p;
347
- const input = (tp.state && tp.state.input) || {};
348
- if (typeof input.file_path === 'string')
349
- files.add(input.file_path);
350
- if (typeof input.filePath === 'string')
351
- files.add(input.filePath);
352
- }
353
- return Array.from(files).sort();
354
- }
355
- function classifyUserText(text) {
356
- const t = (text || '').trimStart();
357
- if (!t)
358
- return 'session-turn';
359
- if (/^Base directory for this skill:/.test(t))
360
- return 'skill-injection';
361
- if (/^This session is being continued from a previous conversation/.test(t)) {
362
- return 'continuation-summary';
363
- }
364
- if (/^You are a .{0,60}\bchip\b/.test(t) ||
365
- (/\bgit worktree add\b/.test(t) &&
366
- /(^|\n)\s*(\*\*Setup\b|Chip:|#\s*Chip\b|report back\b)/m.test(t))) {
367
- return 'chip-prompt';
368
- }
369
- return 'session-turn';
370
- }
371
- function provenanceElisionMarker(p, text) {
372
- const label = p === 'skill-injection' ? 'skill body' :
373
- p === 'continuation-summary' ? 'context-continuation summary' :
374
- p === 'chip-prompt' ? 'chip task prompt' : 'injected text';
375
- let hint = '';
376
- if (p === 'skill-injection') {
377
- const m = (text || '').match(/skills[/\\]([A-Za-z0-9._-]+)/) ||
378
- (text || '').match(/^#\s+(.+)$/m);
379
- if (m)
380
- hint = ` (${m[1].trim().slice(0, 40)})`;
381
- }
382
- return `[greprag: harness-injected ${label}${hint} elided from episodic capture]`;
383
- }
384
- function buildEnvelope(userParts, assistantParts, errored) {
385
- // Elide harness-injected user text pre-LLM at capture (see the classifier
386
- // above). The marker replaces the 1-6k-word block so it never reaches the
387
- // server / content_tsv.
388
- const rawUser = extractText(userParts);
389
- const provenance = classifyUserText(rawUser);
390
- const userPrompt = provenance === 'session-turn'
391
- ? rawUser
392
- : provenanceElisionMarker(provenance, rawUser);
393
- return {
394
- userPrompt,
395
- agentResponse: extractText(assistantParts),
396
- toolCalls: extractToolCalls(assistantParts),
397
- filesTouched: extractFilesTouched(assistantParts),
398
- status: errored ? 'errored' : 'completed',
399
- provenance,
400
- };
479
+ startCaptureWatcher();
480
+ function getEnv(key) {
481
+ return process.env[key] || '';
401
482
  }
402
483
  // ============================================================================
403
484
  // API calls (fire-and-forget; never block the LLM)
@@ -439,124 +520,327 @@ async function storeTurn(anchor, sessionID, envelope, workingDir) {
439
520
  }
440
521
  async function fetchRecapInbox(anchor) {
441
522
  const apiKey = getEnv('GREPRAG_API_KEY');
442
- if (!apiKey)
523
+ if (!apiKey) {
524
+ dlog('fetchRecapInbox: no API key — returning empty');
525
+ return [];
526
+ }
527
+ if (!anchor.sessionStartRecap) {
528
+ dlog(`sessionStartRecap=false on anchor — skipping memory fetch (per-project opt-out)`);
443
529
  return [];
444
- const lines = [];
445
- const now = new Date();
446
- const weekAgo = new Date(now.getTime() - 7 * 86400000).toISOString();
447
- const toIso = now.toISOString();
448
- if (anchor.sessionStartRecap) {
530
+ }
531
+ // Recap body is the SAME renderer hook.ts uses at SessionStart. Single
532
+ // source of truth lives in opencode-plugin-helpers.buildRecapBody the
533
+ // type=hourly / 2d window / 24h cutoff / HOURLY_CAP / "Recent sessions:"
534
+ // framing all come from there. Opencode has no SessionStart hook, so the
535
+ // closest equivalent is the system-transform pre-LLM fire; we push the
536
+ // rendered body straight onto output.system. No parallel implementation.
537
+ const body = await buildRecapBody(API_URL, apiKey, anchor);
538
+ dlog(`buildRecapBody returned ${body.length} chars (projectId=${anchor.projectId})`);
539
+ if (!body)
540
+ return [];
541
+ return [body];
542
+ }
543
+ // ============================================================================
544
+ // Per-session state. Plugin function runs once at opencode boot, so this state
545
+ // lives for the lifetime of the opencode process and is shared across every
546
+ // session that process handles.
547
+ // ============================================================================
548
+ const relayArmedBySession = new Set();
549
+ /** Cached recap body per projectId. opencode fires
550
+ * `experimental.chat.system.transform` before EVERY LLM call (not just the
551
+ * first), and each call has a fresh `output.system` — so the previous
552
+ * "first fire per session" dedup meant the recap was in fire 1's system
553
+ * prompt only, leaving later turns with no recap. (Dlog would show one
554
+ * `pushed 1 recap lines`; assistant would still report "no recap" in
555
+ * later turns because the system prompt had been reset.) New behavior:
556
+ * push on every fire. The fetch only runs on the first fire per project
557
+ * (or when the cache expires / projectId changes); subsequent fires use
558
+ * the cached body. Trades a one-time ~150ms fetch for a ~700-char push on
559
+ * every LLM call — small enough to be cheap, big enough to be useful. */
560
+ let cachedRecap = null;
561
+ const RECAP_CACHE_TTL_MS = 5 * 60_000;
562
+ // ============================================================================
563
+ // Per-session relay spawner — "anytime opencode is opened and being used"
564
+ // ============================================================================
565
+ // The opencode plugin surface has no explicit "session_start" hook —
566
+ // `experimental.chat.system.transform` fires before every LLM call. We arm
567
+ // the relay on the first fire per session (`relayArmedBySession` Set) so
568
+ // the spawn runs at most once per session per opencode boot.
569
+ //
570
+ // Per-session lockfile at ~/.greprag/relay.<session8hex>.pid — same shape as
571
+ // WATCHER_LOCK, with a 30s mtime + dead-PID sweep. Multiple opencode windows
572
+ // pointing at the same session naturally dedup to one relay.
573
+ //
574
+ // The spawned relay is detached (stdio: 'ignore', windowsHide: true) and
575
+ // wrapped in the same respawn-with-backoff pattern as startCaptureWatcher.
576
+ // On opencode exit, the SIGTERM + SIGKILL cleanup runs the same way. The
577
+ // relay's own `process.exit(1)` after 5 consecutive delivery failures gives
578
+ // the respawner an honest signal to stop trying when opencode is gone for
579
+ // real. Max 10 respawns; the user sees a "run `greprag doctor`" hint at the
580
+ // end.
581
+ function startSessionRelay(sessionId, serverUrl) {
582
+ if (process.env.GREPRAG_OPENCODE_RELAY === '0')
583
+ return;
584
+ if (process.env.MEMORY_HOOK_ENABLED !== 'true')
585
+ return;
586
+ if (!process.env.GREPRAG_API_KEY)
587
+ return;
588
+ if (relayArmedBySession.has(sessionId))
589
+ return;
590
+ relayArmedBySession.add(sessionId);
591
+ const grepragBin = findGrepragBinary();
592
+ if (!grepragBin) {
593
+ process.stderr.write('[greprag-memory] relay could not start: greprag binary not in PATH. ' +
594
+ 'Run `npm install -g @greprag/cli` or set GREPRAG_BIN.\n');
595
+ return;
596
+ }
597
+ const lockPath = relayLockPath(sessionId);
598
+ const claim = tryClaimRelayLock(lockPath);
599
+ if (!claim.ok) {
600
+ if (claim.reason === 'busy') {
601
+ process.stderr.write(`[greprag-memory] relay for session ${sessionId} already running; not spawning\n`);
602
+ }
603
+ else {
604
+ process.stderr.write(`[greprag-memory] relay lockfile error: ${claim.reason}\n`);
605
+ }
606
+ return;
607
+ }
608
+ try {
609
+ fs.writeSync(claim.fd, String(process.pid));
610
+ fs.closeSync(claim.fd);
611
+ }
612
+ catch {
449
613
  try {
450
- const res = await fetch(`${API_URL}/v1/memory/by-period?projectId=${anchor.projectId}&from=${encodeURIComponent(weekAgo)}&to=${encodeURIComponent(toIso)}&type=daily&limit=7`, { headers: { Authorization: `Bearer ${apiKey}` } });
451
- if (res.ok) {
452
- const data = (await res.json());
453
- if (data.memories && data.memories.length > 0) {
454
- lines.push(`[GrepRAG memory: ${anchor.projectName}]`);
455
- for (const m of data.memories) {
456
- const date = m.windowStart ? m.windowStart.slice(0, 10) : '';
457
- lines.push(`[${date}] ${m.content}`);
458
- }
614
+ fs.unlinkSync(lockPath);
615
+ }
616
+ catch { /* raced */ }
617
+ return;
618
+ }
619
+ let stopped = false;
620
+ let currentChild = null;
621
+ let respawnCount = 0;
622
+ let childStartTime = 0;
623
+ const MAX_RESPAWNS = 10;
624
+ const MAX_BACKOFF_MS = 30_000;
625
+ function tryReclaimLockForRespawn() {
626
+ const claim = tryClaimRelayLock(lockPath);
627
+ if (claim.ok) {
628
+ try {
629
+ fs.writeSync(claim.fd, String(process.pid));
630
+ fs.closeSync(claim.fd);
631
+ }
632
+ catch {
633
+ try {
634
+ fs.unlinkSync(lockPath);
459
635
  }
636
+ catch { /* raced */ }
637
+ return false;
460
638
  }
639
+ return true;
461
640
  }
462
- catch { }
641
+ if (claim.reason === 'busy') {
642
+ process.stderr.write(`[greprag-memory] relay lockfile claimed by another process; not respawning\n`);
643
+ }
644
+ return false;
463
645
  }
464
- if (anchor.inboxNotify !== 'off') {
646
+ function spawnRelay() {
647
+ if (stopped)
648
+ return;
649
+ let child;
465
650
  try {
466
- const res = await fetch(`${API_URL}/v1/inbox?count_only=1&projectId=${anchor.projectId}`, { headers: { Authorization: `Bearer ${apiKey}` } });
467
- if (res.ok) {
468
- const data = (await res.json());
469
- if (typeof data.unread_count === 'number' && data.unread_count > 0) {
470
- lines.push(`[${data.unread_count} unread in inbox]`);
471
- }
651
+ child = (0, child_process_1.spawn)(grepragBin, ['opencode', 'relay', '--session', sessionId, '--opencode-url', serverUrl], {
652
+ stdio: ['ignore', 'pipe', 'pipe'],
653
+ env: process.env,
654
+ windowsHide: true,
655
+ shell: process.platform === 'win32',
656
+ });
657
+ }
658
+ catch (err) {
659
+ process.stderr.write(`[greprag-relay] spawn failed: ${err.message}\n`);
660
+ scheduleRespawn();
661
+ return;
662
+ }
663
+ currentChild = child;
664
+ childStartTime = Date.now();
665
+ try {
666
+ fs.writeFileSync(lockPath, String(child.pid));
667
+ }
668
+ catch { /* best effort */ }
669
+ child.stderr?.on('data', (chunk) => {
670
+ process.stderr.write(`[greprag-relay] ${chunk.toString('utf-8')}`);
671
+ });
672
+ child.stdout?.on('data', (chunk) => {
673
+ process.stderr.write(`[greprag-relay] ${chunk.toString('utf-8')}`);
674
+ });
675
+ child.on('exit', (code, signal) => {
676
+ try {
677
+ const current = fs.readFileSync(lockPath, 'utf-8').trim();
678
+ if (current === String(child.pid))
679
+ fs.unlinkSync(lockPath);
680
+ }
681
+ catch { /* raced */ }
682
+ currentChild = null;
683
+ if (stopped)
684
+ return;
685
+ if (code !== 0 && signal !== 'SIGTERM' && signal !== 'SIGKILL') {
686
+ process.stderr.write(`[greprag-memory] relay for ${sessionId} exited code=${code} signal=${signal || 'none'}, respawning\n`);
472
687
  }
688
+ scheduleRespawn();
689
+ });
690
+ }
691
+ function scheduleRespawn() {
692
+ if (stopped)
693
+ return;
694
+ if (Date.now() - childStartTime > 30_000)
695
+ respawnCount = 0;
696
+ respawnCount++;
697
+ if (respawnCount > MAX_RESPAWNS) {
698
+ process.stderr.write(`[greprag-memory] relay for ${sessionId} respawned ${respawnCount} times; giving up. ` +
699
+ 'Run `greprag doctor` to investigate.\n');
700
+ try {
701
+ fs.unlinkSync(lockPath);
702
+ }
703
+ catch { /* raced */ }
704
+ return;
473
705
  }
474
- catch { }
706
+ if (!tryReclaimLockForRespawn())
707
+ return;
708
+ const backoff = Math.min(MAX_BACKOFF_MS, 1000 * Math.pow(2, Math.min(respawnCount, 5)));
709
+ process.stderr.write(`[greprag-memory] respawning relay for ${sessionId} in ${Math.round(backoff / 1000)}s ` +
710
+ `(attempt ${respawnCount}/${MAX_RESPAWNS})\n`);
711
+ setTimeout(spawnRelay, backoff).unref();
475
712
  }
476
- return lines;
713
+ spawnRelay();
714
+ const cleanup = () => {
715
+ if (stopped)
716
+ return;
717
+ stopped = true;
718
+ if (currentChild) {
719
+ try {
720
+ currentChild.kill('SIGTERM');
721
+ }
722
+ catch { /* already dead */ }
723
+ setTimeout(() => {
724
+ try {
725
+ currentChild?.kill('SIGKILL');
726
+ }
727
+ catch { /* already dead */ }
728
+ }, 2000).unref();
729
+ }
730
+ try {
731
+ fs.unlinkSync(lockPath);
732
+ }
733
+ catch { /* raced */ }
734
+ };
735
+ process.on('exit', cleanup);
736
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
737
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
477
738
  }
478
739
  // ============================================================================
479
- // Per-session state. Plugin function runs once at opencode boot, so this state
480
- // lives for the lifetime of the opencode process and is shared across every
481
- // session that process handles. Both sets key on opencode's canonical IDs
482
- // (sessionID and assistant-message id) so they're correct across sessions.
483
- // ============================================================================
484
- const recapInjectedBySession = new Set();
485
- const storedAssistantMessageIds = new Set();
486
- // ============================================================================
487
740
  // Plugin entry point
488
741
  // ============================================================================
489
742
  const GrepRAGMemoryPlugin = async (ctx) => {
743
+ dlog(`plugin function INVOKED (this is the entry opencode calls)`);
490
744
  const apiKey = getEnv('GREPRAG_API_KEY');
491
745
  const enabled = getEnv('MEMORY_HOOK_ENABLED') === 'true';
492
- if (!enabled || !apiKey)
746
+ dlog(`plugin invoked: enabled=${enabled} apiKeySet=${!!apiKey} worktree=${ctx.worktree} directory=${ctx.directory}`);
747
+ if (!enabled || !apiKey) {
748
+ dlog(`bailing: env gate closed (enabled=${enabled} apiKeySet=${!!apiKey}). No hooks will register.`);
493
749
  return {};
494
- const anchor = readAnchor(ctx.worktree);
750
+ }
495
751
  const client = ctx.client;
496
- const workingDir = ctx.directory || ctx.worktree;
752
+ const fallbackAnchor = readAnchor(ctx.worktree);
753
+ const fallbackWorkingDir = ctx.directory || ctx.worktree;
754
+ /** Resolve the project anchor + workingDir for a given session. Uses the
755
+ * session's `directory` (which is the workspace path, e.g. C:\greprag)
756
+ * rather than the sidecar's worktree (which is the home dir when the
757
+ * user opens opencode Desktop from ~). Falls back to the boot context
758
+ * if the session fetch fails. */
759
+ async function resolveSessionContext(sessionID) {
760
+ try {
761
+ const session = await client.session.get({ path: { id: sessionID } });
762
+ const dir = session && (session.directory || session.path);
763
+ if (typeof dir === 'string' && dir) {
764
+ return { anchor: readAnchor(dir), workingDir: dir };
765
+ }
766
+ }
767
+ catch {
768
+ // session not reachable — fall through to fallback
769
+ }
770
+ return { anchor: fallbackAnchor, workingDir: fallbackWorkingDir };
771
+ }
497
772
  return {
498
773
  'experimental.chat.system.transform': async (input, output) => {
499
774
  const sid = input && input.sessionID;
500
- if (!sid)
501
- return;
502
- if (recapInjectedBySession.has(sid))
503
- return;
504
- recapInjectedBySession.add(sid);
505
- const recap = await fetchRecapInbox(anchor);
506
- if (recap.length > 0) {
507
- output.system.push(recap.join('\n'));
775
+ const sidShort = sid ? (sid.replace(/[^0-9a-f]/gi, '').slice(0, 8) || sid.slice(0, 8)) : '<none>';
776
+ dlog(`hook fired sid=${sidShort}… model=${input?.model?.modelID || input?.model?.id || '?'}`);
777
+ // Arm the inbox-to-opencode relay for this session on the first LLM
778
+ // call. The Set dedup means this runs at most once per session per
779
+ // opencode boot. Failures (no greprag binary, lock contention) log
780
+ // to stderr and are non-fatal — the plugin's other responsibilities
781
+ // continue. Skipped when sessionID is missing (relay lockfile is
782
+ // keyed on the 8-hex session id).
783
+ if (sid && !relayArmedBySession.has(sid)) {
784
+ relayArmedBySession.add(sid);
785
+ startSessionRelay(sid, ctx.serverUrl.toString());
508
786
  }
509
- },
510
- event: async ({ event }) => {
511
- if (!anchor.memoryCapture)
512
- return;
513
- if (!event || event.type !== 'message.updated')
514
- return;
515
- const info = event.properties && event.properties.info;
516
- if (!info || info.role !== 'assistant')
517
- return;
518
- const completed = info.time && typeof info.time.completed === 'number';
519
- if (!completed)
520
- return;
521
- if (storedAssistantMessageIds.has(info.id))
522
- return;
523
- storedAssistantMessageIds.add(info.id);
524
- let allMessages;
525
- try {
526
- allMessages = await client.session.messages({
527
- path: { id: info.sessionID },
528
- });
787
+ // Resolve anchor — the session's directory is canonical,
788
+ // fallbackAnchor is the closure's boot-time directory (used when sid
789
+ // is absent)
790
+ const anchor = sid ? (await resolveSessionContext(sid)).anchor : fallbackAnchor;
791
+ // Recap body is per-project (not per-session) and changes slowly, so
792
+ // we cache it. Push on every fire so the LLM sees the recap in ALL
793
+ // turns, not just the first. See module-level `cachedRecap` comment
794
+ // for the dedup regression this replaces.
795
+ let body;
796
+ if (cachedRecap &&
797
+ cachedRecap.projectId === anchor.projectId &&
798
+ Date.now() - cachedRecap.fetchedAt < RECAP_CACHE_TTL_MS) {
799
+ body = cachedRecap.body;
800
+ dlog(`recap cache hit for projectId=${anchor.projectId} (${body.length} chars, age=${Math.round((Date.now() - cachedRecap.fetchedAt) / 1000)}s)`);
529
801
  }
530
- catch {
531
- return;
802
+ else {
803
+ dlog(`resolving recap: projectId=${anchor.projectId} ` +
804
+ `projectName=${anchor.projectName} ` +
805
+ `sessionStartRecap=${anchor.sessionStartRecap} ` +
806
+ `inboxNotify=${anchor.inboxNotify}`);
807
+ const recap = await fetchRecapInbox(anchor);
808
+ body = recap.join('\n');
809
+ cachedRecap = { projectId: anchor.projectId || '', body, fetchedAt: Date.now() };
810
+ dlog(`buildRecapBody returned ${body.length} chars (projectId=${anchor.projectId})`);
811
+ if (body) {
812
+ dlog(`recap content (${body.length} chars):\n---\n${body}\n---`);
813
+ }
532
814
  }
533
- const assistantWithParts = allMessages.find((m) => m.info.id === info.id);
534
- if (!assistantWithParts)
535
- return;
536
- const userWithParts = allMessages.find((m) => m.info.id === info.parentID);
537
- if (!userWithParts)
538
- return;
539
- const errored = !!info.error;
540
- const envelope = buildEnvelope(userWithParts.parts, assistantWithParts.parts, errored);
541
- if (!envelope.userPrompt &&
542
- !envelope.agentResponse &&
543
- envelope.toolCalls.length === 0) {
544
- return;
815
+ if (body) {
816
+ pushSystemPrompt(output, body);
817
+ dlog(`pushed recap to system prompt for sid=${sidShort}… (output.system is ${Array.isArray(output.system) ? 'string[]' : typeof output.system}, body=${body.length} chars)`);
818
+ }
819
+ else {
820
+ dlog(`recap empty for sid=${sidShort}… (no memories in window, no unread inbox)`);
545
821
  }
546
- await storeTurn(anchor, info.sessionID, envelope, workingDir);
547
822
  },
548
823
  };
549
824
  };
550
- exports.GrepRAGMemoryPlugin = GrepRAGMemoryPlugin;
551
- // V1 plugin export shape (preferred by opencode's loader). The legacy named
552
- // export is kept as a fallback for older loader code paths.
553
- exports.default = { server: GrepRAGMemoryPlugin, id: 'greprag-memory' };
554
- // Exposed for unit tests never imported by opencode at runtime.
555
- exports.__test = {
556
- extractText,
557
- extractToolCalls,
558
- extractFilesTouched,
559
- buildEnvelope,
560
- readAnchor,
825
+ /** Append `body` to `output.system` defensively. opencode's hook contract
826
+ * types `output.system` as `string[]`, but other plugins (opencode-rules)
827
+ * report it can also arrive as a `string` depending on runtime/version. We
828
+ * handle both shapes — push for arrays, concatenate with `\n\n` separator
829
+ * for strings, replace when empty/undefined. Mirrors opencode-rules's
830
+ * pattern; see adr/opencode-monitor-relay.md 2026-06-06 (h). */
831
+ function pushSystemPrompt(output, body) {
832
+ if (Array.isArray(output.system)) {
833
+ output.system.push(body);
834
+ }
835
+ else if (typeof output.system === 'string' && output.system.length > 0) {
836
+ output.system = `${output.system}\n\n${body}`;
837
+ }
838
+ else {
839
+ output.system = body;
840
+ }
841
+ }
842
+ module.exports = {
843
+ id: 'greprag-memory',
844
+ server: GrepRAGMemoryPlugin,
561
845
  };
562
846
  //# sourceMappingURL=opencode-plugin.js.map