greprag 0.7.0 → 0.7.3

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.
@@ -0,0 +1,182 @@
1
+ /** GrepRAG memory plugin for opencode.
2
+ *
3
+ * Two responsibilities:
4
+ * 1. Inject a recap of recent memory + unread inbox count into the system
5
+ * prompt at the start of each session.
6
+ * 2. Capture every completed (user, assistant) turn pair to /v1/memory/turn
7
+ * for episodic compaction.
8
+ *
9
+ * Loaded by opencode from ~/.config/opencode/plugins/greprag-memory.js
10
+ * (installed by `greprag init --opencode`). All types are declared inline so
11
+ * the file has zero external runtime imports — the package source must remain
12
+ * self-contained because opencode's loader hands the compiled module straight
13
+ * to Bun, with no resolution step against our dependency tree.
14
+ *
15
+ * Hook surface used (verified against sst/opencode dev branch):
16
+ * - experimental.chat.system.transform — fires before each LLM call. We
17
+ * push recap text on the FIRST fire per sessionID and ignore later fires.
18
+ * - event — receives every bus event. We listen for "message.updated" with
19
+ * role="assistant" and info.time.completed set (assistant message
20
+ * finalized), then fetch the session's full message list via the supplied
21
+ * client and capture the (parent user, this assistant) pair.
22
+ *
23
+ * The bus surface (event hook + message.updated) is the stable signal for
24
+ * "turn complete". experimental.chat.messages.transform fires BEFORE each
25
+ * LLM call — mid-turn during tool loops — so it cannot deliver finalized
26
+ * assistant content and is wrong for episodic capture.
27
+ */
28
+ interface OpenCodeProject {
29
+ name?: string;
30
+ path?: string;
31
+ }
32
+ interface OpenCodeClient {
33
+ session: {
34
+ messages: (args: {
35
+ path: {
36
+ id: string;
37
+ };
38
+ }) => Promise<WithParts[]>;
39
+ };
40
+ }
41
+ interface OpenCodePluginContext {
42
+ client: OpenCodeClient;
43
+ project: OpenCodeProject;
44
+ directory: string;
45
+ worktree: string;
46
+ serverUrl: URL;
47
+ $: unknown;
48
+ }
49
+ interface UserInfo {
50
+ id: string;
51
+ sessionID: string;
52
+ role: 'user';
53
+ time: {
54
+ created: number;
55
+ };
56
+ }
57
+ interface AssistantInfo {
58
+ id: string;
59
+ sessionID: string;
60
+ role: 'assistant';
61
+ parentID: string;
62
+ modelID: string;
63
+ providerID: string;
64
+ time: {
65
+ created: number;
66
+ completed?: number;
67
+ };
68
+ agent: string;
69
+ path: {
70
+ cwd: string;
71
+ root: string;
72
+ };
73
+ cost: number;
74
+ error?: unknown;
75
+ }
76
+ type MessageInfo = UserInfo | AssistantInfo;
77
+ interface WithParts {
78
+ info: MessageInfo;
79
+ parts: Part[];
80
+ }
81
+ interface TextPart {
82
+ type: 'text';
83
+ text: string;
84
+ synthetic?: boolean;
85
+ ignored?: boolean;
86
+ }
87
+ type ToolState = {
88
+ status: 'pending';
89
+ input: Record<string, unknown>;
90
+ } | {
91
+ status: 'running';
92
+ input: Record<string, unknown>;
93
+ title?: string;
94
+ } | {
95
+ status: 'completed';
96
+ input: Record<string, unknown>;
97
+ output: string;
98
+ title: string;
99
+ } | {
100
+ status: 'error';
101
+ input: Record<string, unknown>;
102
+ error: string;
103
+ };
104
+ interface ToolPart {
105
+ type: 'tool';
106
+ callID: string;
107
+ tool: string;
108
+ state: ToolState;
109
+ }
110
+ interface GenericPart {
111
+ type: string;
112
+ [key: string]: unknown;
113
+ }
114
+ type Part = TextPart | ToolPart | GenericPart;
115
+ interface BusEvent {
116
+ id?: string;
117
+ type: string;
118
+ properties: Record<string, unknown>;
119
+ }
120
+ type Hooks = {
121
+ 'experimental.chat.system.transform'?: (input: {
122
+ sessionID: string;
123
+ model: unknown;
124
+ }, output: {
125
+ system: string[];
126
+ }) => Promise<void> | void;
127
+ event?: (input: {
128
+ event: BusEvent;
129
+ }) => Promise<void> | void;
130
+ };
131
+ interface ProjectAnchor {
132
+ projectId: string;
133
+ projectName: string;
134
+ memoryCapture: boolean;
135
+ sessionStartRecap: boolean;
136
+ inboxNotify: 'every_turn' | 'session_start_only' | 'off';
137
+ }
138
+ /** Resolve the project anchor for `worktree` using the same 4-level cascade
139
+ * the Claude Code CLI uses (packages/cli/src/project-anchor.ts). Settings
140
+ * always come from the nearest repo-level file when one exists, regardless
141
+ * of which identity level resolved.
142
+ *
143
+ * 1. Anchor file with explicit `project_id` → file-based identity
144
+ * 2. Git repo with at least one commit → root-commit-derived UUID
145
+ * 3. Ephemeral cwd + ~/.claude/project.json exists → global anchor
146
+ * 4. Path-hash fallback (never returns null; lets capture flow until
147
+ * `greprag init` runs and `greprag doctor` consolidates) */
148
+ declare function readAnchor(worktree: string): ProjectAnchor;
149
+ declare function extractText(parts: Part[]): string;
150
+ interface ToolCallSummary {
151
+ name: string;
152
+ target?: string;
153
+ brief?: string;
154
+ }
155
+ /** Pull a one-line summary out of each tool call: name + a target string + an
156
+ * optional brief for shell commands. Mirrors the shape the Claude Code hook
157
+ * posts so server-side compaction sees identical structure regardless of
158
+ * client origin. */
159
+ declare function extractToolCalls(parts: Part[]): ToolCallSummary[];
160
+ declare function extractFilesTouched(parts: Part[]): string[];
161
+ interface Envelope {
162
+ userPrompt: string;
163
+ agentResponse: string;
164
+ toolCalls: ToolCallSummary[];
165
+ filesTouched: string[];
166
+ status: 'completed' | 'errored';
167
+ }
168
+ declare function buildEnvelope(userParts: Part[], assistantParts: Part[], errored: boolean): Envelope;
169
+ declare const GrepRAGMemoryPlugin: (ctx: OpenCodePluginContext) => Promise<Hooks>;
170
+ declare const _default: {
171
+ server: (ctx: OpenCodePluginContext) => Promise<Hooks>;
172
+ id: string;
173
+ };
174
+ export default _default;
175
+ export { GrepRAGMemoryPlugin };
176
+ export declare const __test: {
177
+ extractText: typeof extractText;
178
+ extractToolCalls: typeof extractToolCalls;
179
+ extractFilesTouched: typeof extractFilesTouched;
180
+ buildEnvelope: typeof buildEnvelope;
181
+ readAnchor: typeof readAnchor;
182
+ };
@@ -0,0 +1,490 @@
1
+ "use strict";
2
+ /** GrepRAG memory plugin for opencode.
3
+ *
4
+ * Two responsibilities:
5
+ * 1. Inject a recap of recent memory + unread inbox count into the system
6
+ * prompt at the start of each session.
7
+ * 2. Capture every completed (user, assistant) turn pair to /v1/memory/turn
8
+ * for episodic compaction.
9
+ *
10
+ * Loaded by opencode from ~/.config/opencode/plugins/greprag-memory.js
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.
15
+ *
16
+ * Hook surface used (verified against sst/opencode dev branch):
17
+ * - experimental.chat.system.transform — fires before each LLM call. We
18
+ * push recap text on the FIRST fire per sessionID and ignore later fires.
19
+ * - event — receives every bus event. We listen for "message.updated" with
20
+ * role="assistant" and info.time.completed set (assistant message
21
+ * finalized), then fetch the session's full message list via the supplied
22
+ * client and capture the (parent user, this assistant) pair.
23
+ *
24
+ * The bus surface (event hook + message.updated) is the stable signal for
25
+ * "turn complete". experimental.chat.messages.transform fires BEFORE each
26
+ * LLM call — mid-turn during tool loops — so it cannot deliver finalized
27
+ * assistant content and is wrong for episodic capture.
28
+ */
29
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ var desc = Object.getOwnPropertyDescriptor(m, k);
32
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
33
+ desc = { enumerable: true, get: function() { return m[k]; } };
34
+ }
35
+ Object.defineProperty(o, k2, desc);
36
+ }) : (function(o, m, k, k2) {
37
+ if (k2 === undefined) k2 = k;
38
+ o[k2] = m[k];
39
+ }));
40
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
41
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
42
+ }) : function(o, v) {
43
+ o["default"] = v;
44
+ });
45
+ var __importStar = (this && this.__importStar) || (function () {
46
+ var ownKeys = function(o) {
47
+ ownKeys = Object.getOwnPropertyNames || function (o) {
48
+ var ar = [];
49
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
50
+ return ar;
51
+ };
52
+ return ownKeys(o);
53
+ };
54
+ return function (mod) {
55
+ if (mod && mod.__esModule) return mod;
56
+ var result = {};
57
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
58
+ __setModuleDefault(result, mod);
59
+ return result;
60
+ };
61
+ })();
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
+ const crypto = __importStar(require("crypto"));
68
+ const fs = __importStar(require("fs"));
69
+ const path = __importStar(require("path"));
70
+ const child_process_1 = require("child_process");
71
+ const API_URL = 'https://api.greprag.com';
72
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
73
+ /** Load env vars from ~/.claude/settings.json so the plugin sees the same
74
+ * GREPRAG_API_KEY + MEMORY_HOOK_ENABLED that the Claude Code hook sees,
75
+ * without forcing the user to set them in opencode's own env. */
76
+ function loadClaudeEnv() {
77
+ try {
78
+ const p = path.join(HOME, '.claude', 'settings.json');
79
+ if (!fs.existsSync(p))
80
+ return;
81
+ const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
82
+ if (data && data.env && typeof data.env === 'object') {
83
+ for (const [key, val] of Object.entries(data.env)) {
84
+ if (!process.env[key] && typeof val === 'string') {
85
+ process.env[key] = val;
86
+ }
87
+ }
88
+ }
89
+ }
90
+ catch {
91
+ // settings.json absent or malformed — silently fall through
92
+ }
93
+ }
94
+ loadClaudeEnv();
95
+ function getEnv(key) {
96
+ return process.env[key] || '';
97
+ }
98
+ function readAnchorFile(filePath) {
99
+ try {
100
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
101
+ if (!raw || typeof raw !== 'object')
102
+ return null;
103
+ const notify = raw.inbox_notify;
104
+ const inboxNotify = notify === 'off' || notify === 'session_start_only' ? notify : 'every_turn';
105
+ return {
106
+ projectId: typeof raw.project_id === 'string' ? raw.project_id : undefined,
107
+ projectName: typeof raw.project_name === 'string' ? raw.project_name : undefined,
108
+ memoryCapture: raw.memory_capture !== false,
109
+ sessionStartRecap: raw.session_start_recap !== false,
110
+ inboxNotify,
111
+ };
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ /** Walk up from cwd looking for an anchor file. Checks both `.claude/` and
118
+ * `.opencode/` at each level. Returns the first hit. Skips `~/.claude/` (the
119
+ * global anchor is reserved for the ephemeral-cwd path of the cascade). */
120
+ function findExistingAnchorFile(startDir) {
121
+ const globalAnchorPath = path.join(HOME, '.claude', 'project.json');
122
+ let dir = path.resolve(startDir);
123
+ while (true) {
124
+ for (const subdir of ['.claude', '.opencode']) {
125
+ const candidate = path.join(dir, subdir, 'project.json');
126
+ if (candidate !== globalAnchorPath && fs.existsSync(candidate))
127
+ return candidate;
128
+ }
129
+ const parent = path.dirname(dir);
130
+ if (parent === dir)
131
+ return null;
132
+ dir = parent;
133
+ }
134
+ }
135
+ /** Format the SHA-256 of an input string into a deterministic UUID v4-shape
136
+ * string. Must match the algorithm in packages/cli/src/project-anchor.ts so
137
+ * the plugin resolves identical IDs to the Claude Code CLI. */
138
+ function formatUuid(hashHex) {
139
+ return [
140
+ hashHex.slice(0, 8),
141
+ hashHex.slice(8, 12),
142
+ '4' + hashHex.slice(13, 16),
143
+ '8' + hashHex.slice(17, 20),
144
+ hashHex.slice(20, 32),
145
+ ].join('-');
146
+ }
147
+ /** Derive a stable project_id from the repo's root commit SHA. Sorted +
148
+ * hashed so disjoint-history merges produce one deterministic id everywhere.
149
+ * Returns null when cwd isn't a git repo or has no commits yet. */
150
+ function computeGitDerivedProjectId(cwd) {
151
+ try {
152
+ const out = (0, child_process_1.execSync)('git rev-list --max-parents=0 HEAD', {
153
+ cwd,
154
+ encoding: 'utf-8',
155
+ stdio: ['pipe', 'pipe', 'pipe'],
156
+ });
157
+ const roots = out.trim().split(/\s+/).filter(Boolean).sort();
158
+ if (roots.length === 0)
159
+ return null;
160
+ const hash = crypto.createHash('sha256').update(roots.join('\n')).digest('hex');
161
+ return formatUuid(hash);
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ /** Hash the cwd path into a stable UUID. Last-resort fallback — when no git
168
+ * history and no anchor file exist, this keeps capture flowing under a
169
+ * per-path identity that `greprag doctor` can later consolidate. */
170
+ function deterministicProjectId(cwd) {
171
+ const normalized = path.resolve(cwd).toLowerCase();
172
+ const hash = crypto.createHash('sha256').update(normalized).digest('hex');
173
+ return formatUuid(hash);
174
+ }
175
+ /** True when cwd is an ephemeral session path (Cowork, tmp dirs). The
176
+ * deterministic-hash fallback would mint a fresh id every session in those
177
+ * paths, so we prefer the global anchor instead. */
178
+ function isEphemeralCwd(cwd) {
179
+ const norm = path.resolve(cwd).replace(/\\/g, '/').toLowerCase();
180
+ if (norm.includes('/appdata/roaming/claude/local-agent-mode-sessions/'))
181
+ return true;
182
+ if (norm.includes('/appdata/local/claude/local-agent-mode-sessions/'))
183
+ return true;
184
+ if (norm.startsWith('/tmp/'))
185
+ return true;
186
+ if (norm.startsWith('/var/tmp/'))
187
+ return true;
188
+ if (norm.startsWith('/private/tmp/'))
189
+ return true;
190
+ return false;
191
+ }
192
+ /** Resolve the project anchor for `worktree` using the same 4-level cascade
193
+ * the Claude Code CLI uses (packages/cli/src/project-anchor.ts). Settings
194
+ * always come from the nearest repo-level file when one exists, regardless
195
+ * of which identity level resolved.
196
+ *
197
+ * 1. Anchor file with explicit `project_id` → file-based identity
198
+ * 2. Git repo with at least one commit → root-commit-derived UUID
199
+ * 3. Ephemeral cwd + ~/.claude/project.json exists → global anchor
200
+ * 4. Path-hash fallback (never returns null; lets capture flow until
201
+ * `greprag init` runs and `greprag doctor` consolidates) */
202
+ function readAnchor(worktree) {
203
+ const filePath = findExistingAnchorFile(worktree);
204
+ const file = filePath ? readAnchorFile(filePath) : null;
205
+ const root = path.resolve(worktree);
206
+ // 1. File with explicit project_id
207
+ if (file && file.projectId && file.projectName) {
208
+ return {
209
+ projectId: file.projectId,
210
+ projectName: file.projectName,
211
+ memoryCapture: file.memoryCapture,
212
+ sessionStartRecap: file.sessionStartRecap,
213
+ inboxNotify: file.inboxNotify,
214
+ };
215
+ }
216
+ // 2. Git-derived identity. Settings layer in from the settings-only file
217
+ // when one exists; otherwise defaults apply.
218
+ const gitId = computeGitDerivedProjectId(worktree);
219
+ if (gitId) {
220
+ return {
221
+ projectId: gitId,
222
+ projectName: file?.projectName || path.basename(root).toLowerCase(),
223
+ memoryCapture: file?.memoryCapture ?? true,
224
+ sessionStartRecap: file?.sessionStartRecap ?? true,
225
+ inboxNotify: file?.inboxNotify ?? 'every_turn',
226
+ };
227
+ }
228
+ // 3. Ephemeral cwd + global anchor
229
+ if (isEphemeralCwd(worktree)) {
230
+ const globalPath = path.join(HOME, '.claude', 'project.json');
231
+ const globalFile = fs.existsSync(globalPath) ? readAnchorFile(globalPath) : null;
232
+ if (globalFile && globalFile.projectId && globalFile.projectName) {
233
+ return {
234
+ projectId: globalFile.projectId,
235
+ projectName: globalFile.projectName,
236
+ memoryCapture: globalFile.memoryCapture,
237
+ sessionStartRecap: globalFile.sessionStartRecap,
238
+ inboxNotify: globalFile.inboxNotify,
239
+ };
240
+ }
241
+ }
242
+ // 4. Path-hash fallback — keep capture flowing for uninitialized repos.
243
+ return {
244
+ projectId: deterministicProjectId(worktree),
245
+ projectName: file?.projectName || path.basename(root).toLowerCase(),
246
+ memoryCapture: file?.memoryCapture ?? true,
247
+ sessionStartRecap: file?.sessionStartRecap ?? true,
248
+ inboxNotify: file?.inboxNotify ?? 'every_turn',
249
+ };
250
+ }
251
+ // ============================================================================
252
+ // Envelope construction (real opencode Part schema)
253
+ // ============================================================================
254
+ function extractText(parts) {
255
+ const out = [];
256
+ for (const p of parts) {
257
+ if (p.type !== 'text')
258
+ continue;
259
+ const tp = p;
260
+ if (tp.synthetic || tp.ignored)
261
+ continue;
262
+ if (tp.text)
263
+ out.push(tp.text);
264
+ }
265
+ return out.join('\n').trim();
266
+ }
267
+ /** Pull a one-line summary out of each tool call: name + a target string + an
268
+ * optional brief for shell commands. Mirrors the shape the Claude Code hook
269
+ * posts so server-side compaction sees identical structure regardless of
270
+ * client origin. */
271
+ function extractToolCalls(parts) {
272
+ const calls = [];
273
+ for (const p of parts) {
274
+ if (p.type !== 'tool')
275
+ continue;
276
+ const tp = p;
277
+ const name = tp.tool || 'unknown';
278
+ const input = (tp.state && tp.state.input) || {};
279
+ const call = { name };
280
+ if (input.command !== undefined) {
281
+ const desc = input.description;
282
+ call.target =
283
+ typeof desc === 'string' && desc
284
+ ? desc
285
+ : String(input.command).split(/\s+/)[0] || '';
286
+ const cmd = String(input.command);
287
+ call.brief = cmd.length > 800 ? cmd.slice(0, 800) + '…' : cmd;
288
+ }
289
+ else if (typeof input.file_path === 'string') {
290
+ call.target = input.file_path;
291
+ }
292
+ else if (typeof input.filePath === 'string') {
293
+ call.target = input.filePath;
294
+ }
295
+ else if (typeof input.pattern === 'string') {
296
+ call.target = input.pattern;
297
+ }
298
+ else if (typeof input.url === 'string') {
299
+ call.target = input.url;
300
+ }
301
+ else if (typeof input.query === 'string') {
302
+ call.target = input.query;
303
+ }
304
+ calls.push(call);
305
+ }
306
+ return calls;
307
+ }
308
+ function extractFilesTouched(parts) {
309
+ const files = new Set();
310
+ for (const p of parts) {
311
+ if (p.type !== 'tool')
312
+ continue;
313
+ const tp = p;
314
+ const input = (tp.state && tp.state.input) || {};
315
+ if (typeof input.file_path === 'string')
316
+ files.add(input.file_path);
317
+ if (typeof input.filePath === 'string')
318
+ files.add(input.filePath);
319
+ }
320
+ return Array.from(files).sort();
321
+ }
322
+ function buildEnvelope(userParts, assistantParts, errored) {
323
+ return {
324
+ userPrompt: extractText(userParts),
325
+ agentResponse: extractText(assistantParts),
326
+ toolCalls: extractToolCalls(assistantParts),
327
+ filesTouched: extractFilesTouched(assistantParts),
328
+ status: errored ? 'errored' : 'completed',
329
+ };
330
+ }
331
+ // ============================================================================
332
+ // API calls (fire-and-forget; never block the LLM)
333
+ // ============================================================================
334
+ async function storeTurn(anchor, sessionID, envelope, workingDir) {
335
+ const apiKey = getEnv('GREPRAG_API_KEY');
336
+ if (!apiKey)
337
+ return;
338
+ const body = JSON.stringify({
339
+ projectId: anchor.projectId,
340
+ projectName: anchor.projectName,
341
+ branch: null,
342
+ workingDir,
343
+ sessionIdExternal: sessionID,
344
+ turnId: crypto.randomUUID(),
345
+ model: null,
346
+ status: envelope.status,
347
+ userPrompt: envelope.userPrompt,
348
+ agentResponse: envelope.agentResponse,
349
+ toolCalls: envelope.toolCalls,
350
+ filesTouched: envelope.filesTouched,
351
+ artifacts: [],
352
+ source: 'opencode',
353
+ });
354
+ try {
355
+ await fetch(`${API_URL}/v1/memory/turn`, {
356
+ method: 'POST',
357
+ headers: {
358
+ Authorization: `Bearer ${apiKey}`,
359
+ 'Content-Type': 'application/json',
360
+ },
361
+ body,
362
+ });
363
+ }
364
+ catch {
365
+ // Silent — fire-and-forget
366
+ }
367
+ }
368
+ async function fetchRecapInbox(anchor) {
369
+ const apiKey = getEnv('GREPRAG_API_KEY');
370
+ if (!apiKey)
371
+ return [];
372
+ const lines = [];
373
+ const now = new Date();
374
+ const weekAgo = new Date(now.getTime() - 7 * 86400000).toISOString();
375
+ const toIso = now.toISOString();
376
+ if (anchor.sessionStartRecap) {
377
+ try {
378
+ const res = await fetch(`${API_URL}/v1/memory/by-period?projectId=${anchor.projectId}&from=${encodeURIComponent(weekAgo)}&to=${encodeURIComponent(toIso)}&type=episodic-daily&limit=7`, { headers: { Authorization: `Bearer ${apiKey}` } });
379
+ if (res.ok) {
380
+ const data = (await res.json());
381
+ if (data.memories && data.memories.length > 0) {
382
+ lines.push(`[GrepRAG memory: ${anchor.projectName}]`);
383
+ for (const m of data.memories) {
384
+ const date = m.windowStart ? m.windowStart.slice(0, 10) : '';
385
+ lines.push(`[${date}] ${m.content}`);
386
+ }
387
+ }
388
+ }
389
+ }
390
+ catch { }
391
+ }
392
+ if (anchor.inboxNotify !== 'off') {
393
+ try {
394
+ const res = await fetch(`${API_URL}/v1/inbox?count_only=1&projectId=${anchor.projectId}`, { headers: { Authorization: `Bearer ${apiKey}` } });
395
+ if (res.ok) {
396
+ const data = (await res.json());
397
+ if (typeof data.unread_count === 'number' && data.unread_count > 0) {
398
+ lines.push(`[${data.unread_count} unread in inbox]`);
399
+ }
400
+ }
401
+ }
402
+ catch { }
403
+ }
404
+ return lines;
405
+ }
406
+ // ============================================================================
407
+ // Per-session state. Plugin function runs once at opencode boot, so this state
408
+ // lives for the lifetime of the opencode process and is shared across every
409
+ // session that process handles. Both sets key on opencode's canonical IDs
410
+ // (sessionID and assistant-message id) so they're correct across sessions.
411
+ // ============================================================================
412
+ const recapInjectedBySession = new Set();
413
+ const storedAssistantMessageIds = new Set();
414
+ // ============================================================================
415
+ // Plugin entry point
416
+ // ============================================================================
417
+ const GrepRAGMemoryPlugin = async (ctx) => {
418
+ const apiKey = getEnv('GREPRAG_API_KEY');
419
+ const enabled = getEnv('MEMORY_HOOK_ENABLED') === 'true';
420
+ if (!enabled || !apiKey)
421
+ return {};
422
+ const anchor = readAnchor(ctx.worktree);
423
+ const client = ctx.client;
424
+ const workingDir = ctx.directory || ctx.worktree;
425
+ return {
426
+ 'experimental.chat.system.transform': async (input, output) => {
427
+ const sid = input && input.sessionID;
428
+ if (!sid)
429
+ return;
430
+ if (recapInjectedBySession.has(sid))
431
+ return;
432
+ recapInjectedBySession.add(sid);
433
+ const recap = await fetchRecapInbox(anchor);
434
+ if (recap.length > 0) {
435
+ output.system.push(recap.join('\n'));
436
+ }
437
+ },
438
+ event: async ({ event }) => {
439
+ if (!anchor.memoryCapture)
440
+ return;
441
+ if (!event || event.type !== 'message.updated')
442
+ return;
443
+ const info = event.properties && event.properties.info;
444
+ if (!info || info.role !== 'assistant')
445
+ return;
446
+ const completed = info.time && typeof info.time.completed === 'number';
447
+ if (!completed)
448
+ return;
449
+ if (storedAssistantMessageIds.has(info.id))
450
+ return;
451
+ storedAssistantMessageIds.add(info.id);
452
+ let allMessages;
453
+ try {
454
+ allMessages = await client.session.messages({
455
+ path: { id: info.sessionID },
456
+ });
457
+ }
458
+ catch {
459
+ return;
460
+ }
461
+ const assistantWithParts = allMessages.find((m) => m.info.id === info.id);
462
+ if (!assistantWithParts)
463
+ return;
464
+ const userWithParts = allMessages.find((m) => m.info.id === info.parentID);
465
+ if (!userWithParts)
466
+ return;
467
+ const errored = !!info.error;
468
+ const envelope = buildEnvelope(userWithParts.parts, assistantWithParts.parts, errored);
469
+ if (!envelope.userPrompt &&
470
+ !envelope.agentResponse &&
471
+ envelope.toolCalls.length === 0) {
472
+ return;
473
+ }
474
+ await storeTurn(anchor, info.sessionID, envelope, workingDir);
475
+ },
476
+ };
477
+ };
478
+ exports.GrepRAGMemoryPlugin = GrepRAGMemoryPlugin;
479
+ // V1 plugin export shape (preferred by opencode's loader). The legacy named
480
+ // export is kept as a fallback for older loader code paths.
481
+ exports.default = { server: GrepRAGMemoryPlugin, id: 'greprag-memory' };
482
+ // Exposed for unit tests — never imported by opencode at runtime.
483
+ exports.__test = {
484
+ extractText,
485
+ extractToolCalls,
486
+ extractFilesTouched,
487
+ buildEnvelope,
488
+ readAnchor,
489
+ };
490
+ //# sourceMappingURL=opencode-plugin.js.map