greprag 5.32.0 → 5.34.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/dist/codex-hook-events.d.ts +20 -0
- package/dist/codex-hook-events.js +156 -0
- package/dist/codex-hook-events.js.map +1 -0
- package/dist/commands/codex-app-server.d.ts +1 -0
- package/dist/commands/codex-app-server.js +179 -0
- package/dist/commands/codex-app-server.js.map +1 -0
- package/dist/commands/codex-doctor.js +3 -1
- package/dist/commands/codex-doctor.js.map +1 -1
- package/dist/commands/codex.js +6 -0
- package/dist/commands/codex.js.map +1 -1
- package/dist/commands/corpus/index.d.ts +1 -0
- package/dist/commands/corpus/index.js +5 -0
- package/dist/commands/corpus/index.js.map +1 -1
- package/dist/commands/corpus/refresh.d.ts +1 -0
- package/dist/commands/corpus/refresh.js +60 -0
- package/dist/commands/corpus/refresh.js.map +1 -1
- package/dist/commands/desk-line.d.ts +36 -0
- package/dist/commands/desk-line.js +230 -0
- package/dist/commands/desk-line.js.map +1 -0
- package/dist/commands/init.js +75 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/opencode-relay.d.ts +136 -0
- package/dist/commands/opencode-relay.js +529 -0
- package/dist/commands/opencode-relay.js.map +1 -0
- package/dist/commands/opencode-watch.d.ts +17 -0
- package/dist/commands/opencode-watch.js +493 -0
- package/dist/commands/opencode-watch.js.map +1 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +5 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/watcher-registry.d.ts +8 -0
- package/dist/commands/watcher-registry.js +19 -0
- package/dist/commands/watcher-registry.js.map +1 -1
- package/dist/hook.js +54 -83
- package/dist/hook.js.map +1 -1
- package/dist/index.js +220 -1
- package/dist/index.js.map +1 -1
- package/dist/opencode-plugin-helpers.d.ts +200 -0
- package/dist/opencode-plugin-helpers.js +512 -0
- package/dist/opencode-plugin-helpers.js.map +1 -0
- package/dist/opencode-plugin.d.ts +37 -134
- package/dist/opencode-plugin.js +648 -364
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/session-id.d.ts +8 -6
- package/dist/session-id.js +10 -9
- package/dist/session-id.js.map +1 -1
- package/package.json +8 -4
package/dist/opencode-plugin.js
CHANGED
|
@@ -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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
332
|
+
try {
|
|
333
|
+
fs.unlinkSync(WATCHER_LOCK);
|
|
334
|
+
}
|
|
335
|
+
catch { /* raced */ }
|
|
336
|
+
return;
|
|
196
337
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
* 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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
646
|
+
function spawnRelay() {
|
|
647
|
+
if (stopped)
|
|
648
|
+
return;
|
|
649
|
+
let child;
|
|
465
650
|
try {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
750
|
+
}
|
|
495
751
|
const client = ctx.client;
|
|
496
|
-
const
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|