ostacky 0.6.0 → 0.6.2

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.
@@ -1,537 +1,537 @@
1
- /**
2
- * Engram — OpenCode plugin adapter
3
- *
4
- * Thin layer that connects OpenCode's event system to the Engram Go binary.
5
- * The Go binary runs as a local HTTP server and handles all persistence.
6
- *
7
- * Flow:
8
- * OpenCode events → this plugin → HTTP calls → engram serve → SQLite
9
- *
10
- * Session resilience:
11
- * Uses `ensureSession()` before any DB write. This means sessions are
12
- * created on-demand — even if the plugin was loaded after the session
13
- * started (restart, reconnect, etc.). The session ID comes from OpenCode's
14
- * hooks (input.sessionID) rather than relying on a session.created event.
15
- */
16
-
17
- import type { Plugin } from "@opencode-ai/plugin"
18
-
19
- // ─── Configuration ───────────────────────────────────────────────────────────
20
-
21
- const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
22
- const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
23
- const ENGRAM_BIN = process.env.ENGRAM_BIN ?? Bun.which("engram") ?? ".opencode/tools/engram/bin/engram"
24
-
25
- // Engram's own MCP tools — don't count these as "tool calls" for session stats
26
- const ENGRAM_TOOLS = new Set([
27
- "mem_search",
28
- "mem_save",
29
- "mem_update",
30
- "mem_delete",
31
- "mem_suggest_topic_key",
32
- "mem_save_prompt",
33
- "mem_session_summary",
34
- "mem_context",
35
- "mem_stats",
36
- "mem_timeline",
37
- "mem_get_observation",
38
- "mem_session_start",
39
- "mem_session_end",
40
- ])
41
-
42
- // ─── Memory Instructions ─────────────────────────────────────────────────────
43
- // These get injected into the agent's context so it knows to call mem_save.
44
-
45
- const MEMORY_INSTRUCTIONS = `## Engram Persistent Memory — Protocol
46
-
47
- You have access to Engram, a persistent memory system that survives across sessions and compactions.
48
-
49
- ### WHEN TO SAVE (mandatory — not optional)
50
-
51
- Call \`mem_save\` IMMEDIATELY after any of these:
52
- - Bug fix completed
53
- - Architecture or design decision made
54
- - Non-obvious discovery about the codebase
55
- - Configuration change or environment setup
56
- - Pattern established (naming, structure, convention)
57
- - User preference or constraint learned
58
-
59
- Format for \`mem_save\`:
60
- - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux")
61
- - **type**: bugfix | decision | architecture | discovery | pattern | config | preference
62
- - **scope**: \`project\` (default) | \`personal\`
63
- - **topic_key** (optional, recommended for evolving decisions): stable key like \`architecture/auth-model\`
64
- - **content**:
65
- **What**: One sentence — what was done
66
- **Why**: What motivated it (user request, bug, performance, etc.)
67
- **Where**: Files or paths affected
68
- **Learned**: Gotchas, edge cases, things that surprised you (omit if none)
69
-
70
- Topic rules:
71
- - Different topics must not overwrite each other (e.g. architecture vs bugfix)
72
- - Reuse the same \`topic_key\` to update an evolving topic instead of creating new observations
73
- - If unsure about the key, call \`mem_suggest_topic_key\` first and then reuse it
74
- - Use \`mem_update\` when you have an exact observation ID to correct
75
-
76
- ### WHEN TO SEARCH MEMORY
77
-
78
- When the user asks to recall something — any variation of "remember", "recall", "what did we do",
79
- "how did we solve", or the equivalent in the user's language, or references to past work:
80
- 1. First call \`mem_context\` — checks recent session history (fast, cheap)
81
- 2. If not found, call \`mem_search\` with relevant keywords (FTS5 full-text search)
82
- 3. If you find a match, use \`mem_get_observation\` for full untruncated content
83
-
84
- Also search memory PROACTIVELY when:
85
- - Starting work on something that might have been done before
86
- - The user mentions a topic you have no context on — check if past sessions covered it
87
- - The user's FIRST message references the project, a feature, or a problem — call \`mem_search\` with keywords from their message to check for prior work before responding
88
-
89
- ### SESSION CLOSE PROTOCOL (mandatory)
90
-
91
- Before ending a session or saying "done" / "that's it", you MUST:
92
- 1. Call \`mem_session_summary\` with this structure:
93
-
94
- ## Goal
95
- [What we were working on this session]
96
-
97
- ## Instructions
98
- [User preferences or constraints discovered — skip if none]
99
-
100
- ## Discoveries
101
- - [Technical findings, gotchas, non-obvious learnings]
102
-
103
- ## Accomplished
104
- - [Completed items with key details]
105
-
106
- ## Next Steps
107
- - [What remains to be done — for the next session]
108
-
109
- ## Relevant Files
110
- - path/to/file — [what it does or what changed]
111
-
112
- This is NOT optional. If you skip this, the next session starts blind.
113
-
114
- ### AFTER COMPACTION
115
-
116
- If you see a message about compaction or context reset, or if you see "FIRST ACTION REQUIRED" in your context:
117
- 1. IMMEDIATELY call \`mem_session_summary\` with the compacted summary content — this persists what was done before compaction
118
- 2. Then call \`mem_context\` to recover any additional context from previous sessions
119
- 3. Only THEN continue working
120
-
121
- Do not skip step 1. Without it, everything done before compaction is lost from memory.
122
- `
123
-
124
- // ─── HTTP Client ─────────────────────────────────────────────────────────────
125
-
126
- async function engramFetch(
127
- path: string,
128
- opts: { method?: string; body?: any } = {}
129
- ): Promise<any> {
130
- try {
131
- const res = await fetch(`${ENGRAM_URL}${path}`, {
132
- method: opts.method ?? "GET",
133
- headers: opts.body ? { "Content-Type": "application/json" } : undefined,
134
- body: opts.body ? JSON.stringify(opts.body) : undefined,
135
- })
136
- return await res.json()
137
- } catch {
138
- // Engram server not running — silently fail
139
- return null
140
- }
141
- }
142
-
143
- async function isEngramRunning(): Promise<boolean> {
144
- try {
145
- const res = await fetch(`${ENGRAM_URL}/health`, {
146
- signal: AbortSignal.timeout(500),
147
- })
148
- return res.ok
149
- } catch {
150
- return false
151
- }
152
- }
153
-
154
- // ─── Helpers ─────────────────────────────────────────────────────────────────
155
-
156
- function extractProjectName(directory: string): string {
157
- // Try git remote origin URL
158
- try {
159
- const result = Bun.spawnSync(["git", "-C", directory, "remote", "get-url", "origin"])
160
- if (result.exitCode === 0) {
161
- const url = result.stdout?.toString().trim()
162
- if (url) {
163
- const name = url.replace(/\.git$/, "").split(/[/:]/).pop()
164
- if (name) return name
165
- }
166
- }
167
- } catch {}
168
-
169
- // Fallback: git root directory name (works in worktrees)
170
- try {
171
- const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--show-toplevel"])
172
- if (result.exitCode === 0) {
173
- const root = result.stdout?.toString().trim()
174
- if (root) return root.split("/").pop() ?? "unknown"
175
- }
176
- } catch {}
177
-
178
- // Final fallback: cwd basename
179
- return directory.split("/").pop() ?? "unknown"
180
- }
181
-
182
- function truncate(str: string, max: number): string {
183
- if (!str) return ""
184
- return str.length > max ? str.slice(0, max) + "..." : str
185
- }
186
-
187
- /**
188
- * Strip <private>...</private> tags before sending to engram.
189
- * Double safety: the Go binary also strips, but we strip here too
190
- * so sensitive data never even hits the wire.
191
- */
192
- function stripPrivateTags(str: string): string {
193
- if (!str) return ""
194
- return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
195
- }
196
-
197
- // ─── Plugin Export ───────────────────────────────────────────────────────────
198
-
199
- export const Engram: Plugin = async (ctx) => {
200
- const oldProject = ctx.directory.split("/").pop() ?? "unknown"
201
- const project = extractProjectName(ctx.directory)
202
-
203
- // Track tool counts per session (in-memory only, not critical)
204
- const toolCounts = new Map<string, number>()
205
-
206
- // Track last nudge time per session to debounce save reminders
207
- const lastNudgeTime = new Map<string, number>() // sessionID -> epoch seconds
208
-
209
- // Track which sessions we've already ensured exist in engram
210
- const knownSessions = new Set<string>()
211
-
212
- // Track sub-agent session IDs so we can suppress their tool-hook registrations.
213
- // Sub-agents (Task() calls) have a parentID or a title ending in " subagent)".
214
- // We must not register them as top-level Engram sessions — they cause session
215
- // inflation (e.g. 170 sessions for 1 real conversation, issue #116).
216
- const subAgentSessions = new Set<string>()
217
-
218
- /**
219
- * Ensure a session exists in engram. Idempotent — calls POST /sessions
220
- * which uses INSERT OR IGNORE. Safe to call multiple times.
221
- *
222
- * Silently skips sub-agent sessions (tracked in `subAgentSessions`).
223
- */
224
- async function ensureSession(sessionId: string): Promise<void> {
225
- if (!sessionId || knownSessions.has(sessionId)) return
226
- // Do not register sub-agent sessions in Engram (issue #116).
227
- if (subAgentSessions.has(sessionId)) return
228
- knownSessions.add(sessionId)
229
- await engramFetch("/sessions", {
230
- method: "POST",
231
- body: {
232
- id: sessionId,
233
- project,
234
- directory: ctx.directory,
235
- },
236
- })
237
- }
238
-
239
- // Try to start engram server if not running
240
- const running = await isEngramRunning()
241
- if (!running) {
242
- try {
243
- Bun.spawn([ENGRAM_BIN, "serve"], {
244
- stdout: "ignore",
245
- stderr: "ignore",
246
- stdin: "ignore",
247
- })
248
- await new Promise((r) => setTimeout(r, 500))
249
- } catch {
250
- // Binary not found or can't start — plugin will silently no-op
251
- }
252
- }
253
-
254
- // Migrate project name if it changed (one-time, idempotent)
255
- // Must run AFTER server startup to ensure the endpoint is available
256
- if (oldProject !== project) {
257
- await engramFetch("/projects/migrate", {
258
- method: "POST",
259
- body: { old_project: oldProject, new_project: project },
260
- })
261
- }
262
-
263
- // Auto-import: if .engram/manifest.json exists in the project repo,
264
- // run `engram sync --import` to load any new chunks into the local DB.
265
- // This is how git-synced memories get loaded when cloning a repo or
266
- // pulling changes. Each chunk is imported only once (tracked by ID).
267
- try {
268
- const manifestFile = `${ctx.directory}/.engram/manifest.json`
269
- const file = Bun.file(manifestFile)
270
- if (await file.exists()) {
271
- Bun.spawn([ENGRAM_BIN, "sync", "--import"], {
272
- cwd: ctx.directory,
273
- stdout: "ignore",
274
- stderr: "ignore",
275
- stdin: "ignore",
276
- })
277
- }
278
- } catch {
279
- // Manifest doesn't exist or binary not found — silently skip
280
- }
281
-
282
- return {
283
- // ─── Event Listeners ───────────────────────────────────────────
284
-
285
- event: async ({ event }) => {
286
- // --- Session Created ---
287
- if (event.type === "session.created") {
288
- // Bug fix (#116): session data is nested under event.properties.info,
289
- // not event.properties directly.
290
- const info = (event.properties as any)?.info
291
- const sessionId = info?.id
292
- const parentID = info?.parentID
293
- const title: string = info?.title ?? ""
294
-
295
- // Sub-agent sessions (created via Task()) must NOT be registered as
296
- // top-level Engram sessions. They cause massive session inflation
297
- // (e.g. 170 sessions for 1 real conversation).
298
- //
299
- // Detection heuristics:
300
- // - parentID is set on all Task() sub-agent sessions
301
- // - title ends with " subagent)" as a secondary signal
302
- const isSubAgent = !!parentID || title.endsWith(" subagent)")
303
-
304
- if (sessionId && !isSubAgent) {
305
- await ensureSession(sessionId)
306
- } else if (sessionId && isSubAgent) {
307
- // Remember this as a sub-agent session so tool-hook calls
308
- // to ensureSession() are also suppressed for it.
309
- subAgentSessions.add(sessionId)
310
- }
311
- }
312
-
313
- // --- Session Deleted ---
314
- if (event.type === "session.deleted") {
315
- // Same properties.info path as session.created.
316
- const info = (event.properties as any)?.info
317
- const sessionId = info?.id
318
- if (sessionId) {
319
- toolCounts.delete(sessionId)
320
- knownSessions.delete(sessionId)
321
- subAgentSessions.delete(sessionId)
322
- lastNudgeTime.delete(sessionId)
323
- }
324
- }
325
-
326
- },
327
-
328
- // ─── User Prompt Capture ──────────────────────────────────────
329
- // chat.message is called once per user message, before the LLM sees it.
330
- // input.sessionID is always reliable here (no knownSessions workaround).
331
- // output.message is typed as UserMessage (role:"user" already guaranteed).
332
- // output.parts contains TextPart[] with the actual message text.
333
-
334
- "chat.message": async (input, output) => {
335
- // Skip sub-agent sessions — they inflate session counts (issue #116)
336
- if (subAgentSessions.has(input.sessionID)) return
337
-
338
- const sessionId = input.sessionID
339
-
340
- // Extract text from parts (type:"text")
341
- const content = output.parts
342
- .filter((p) => p.type === "text")
343
- .map((p) => (p as any).text ?? "")
344
- .join("\n")
345
- .trim()
346
-
347
- // Also fallback to summary if parts yield nothing
348
- const fallback = !content && output.message.summary
349
- ? `${output.message.summary.title ?? ""}\n${output.message.summary.body ?? ""}`.trim()
350
- : ""
351
-
352
- const finalContent = content || fallback
353
-
354
- // Only capture non-trivial prompts (>10 chars)
355
- if (finalContent.length > 10) {
356
- await ensureSession(sessionId)
357
- await engramFetch("/prompts", {
358
- method: "POST",
359
- body: {
360
- session_id: sessionId,
361
- content: stripPrivateTags(truncate(finalContent, 2000)),
362
- project,
363
- },
364
- })
365
- }
366
- },
367
-
368
- // ─── Tool Execution Hook ─────────────────────────────────────
369
- // Count tool calls per session (for session end stats).
370
- // Also ensures the session exists — handles plugin reload / reconnect.
371
- // Passive capture: when a Task tool completes, POST its output to
372
- // the passive capture endpoint so the server extracts learnings.
373
-
374
- "tool.execute.after": async (input, output) => {
375
- if (ENGRAM_TOOLS.has(input.tool.toLowerCase())) return
376
-
377
- // input.sessionID comes from OpenCode — always available
378
- const sessionId = input.sessionID
379
- if (sessionId) {
380
- await ensureSession(sessionId)
381
- toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1)
382
- }
383
-
384
- // Passive capture: extract learnings from Task tool output
385
- if (input.tool === "Task" && output && sessionId) {
386
- const text = typeof output === "string" ? output : JSON.stringify(output)
387
- if (text.length > 50) {
388
- await engramFetch("/observations/passive", {
389
- method: "POST",
390
- body: {
391
- session_id: sessionId,
392
- content: stripPrivateTags(text),
393
- project,
394
- source: "task-complete",
395
- },
396
- })
397
- }
398
- }
399
- },
400
-
401
- // ─── System Prompt: Always-on memory instructions ──────────
402
- // Injects MEMORY_INSTRUCTIONS into the system prompt of every message.
403
- // This ensures the agent ALWAYS knows about Engram, even after compaction.
404
- //
405
- // We append to the last existing system entry instead of pushing a new one.
406
- // Some models (Qwen3.5, Mistral/Ministral via llama.cpp) reject multiple
407
- // system messages — their Jinja chat templates only allow a single system
408
- // block at the beginning. By concatenating, we avoid adding extra system
409
- // messages that would break these models. See: GitHub issue #23.
410
-
411
- "experimental.chat.system.transform": async (input, output) => {
412
- if (output.system.length > 0) {
413
- output.system[output.system.length - 1] += "\n\n" + MEMORY_INSTRUCTIONS
414
- } else {
415
- output.system.push(MEMORY_INSTRUCTIONS)
416
- }
417
-
418
- // ── Save nudge ──────────────────────────────────────────────────────────
419
- // If it has been a long time since the last mem_save, append a reminder
420
- // to the system prompt so the agent notices. All fetches are fire-and-
421
- // forget with short timeouts — any failure silently skips the nudge.
422
- try {
423
- const sessionID: string = input.sessionID ?? ""
424
- if (!sessionID || subAgentSessions.has(sessionID)) return
425
-
426
- // SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" in UTC with no
427
- // zone suffix; new Date() would parse that as local time. Normalize to
428
- // UTC first so the thresholds are correct in every timezone.
429
- const toEpochSecs = (ts: string): number => {
430
- if (!ts) return 0
431
- const normalized = ts.includes("T") ? ts : ts.replace(" ", "T") + "Z"
432
- const ms = new Date(normalized).getTime()
433
- return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
434
- }
435
-
436
- const cooldownSecs = parseInt(process.env.ENGRAM_NUDGE_COOLDOWN_SECS ?? "900", 10)
437
- const nowSecs = Math.floor(Date.now() / 1000)
438
-
439
- // Debounce: skip if we nudged recently this session
440
- const lastNudge = lastNudgeTime.get(sessionID)
441
- if (lastNudge !== undefined && nowSecs - lastNudge < cooldownSecs) return
442
-
443
- // Skip if the session is too young (< 5 minutes)
444
- let sessionStartEpoch = 0
445
- try {
446
- const sessionRes = await fetch(`${ENGRAM_URL}/sessions/${encodeURIComponent(sessionID)}`, {
447
- signal: AbortSignal.timeout(200),
448
- })
449
- if (sessionRes.ok) {
450
- const sessionData = await sessionRes.json()
451
- const startedAt: string = sessionData?.started_at ?? ""
452
- if (startedAt) {
453
- sessionStartEpoch = toEpochSecs(startedAt)
454
- }
455
- }
456
- } catch {
457
- // Server unreachable or timed out — skip nudge
458
- return
459
- }
460
- if (sessionStartEpoch > 0 && nowSecs - sessionStartEpoch < 300) return
461
-
462
- // Check when the last observation was saved for this project
463
- let lastObsEpoch = 0
464
- try {
465
- const obsRes = await fetch(
466
- `${ENGRAM_URL}/observations?project=${encodeURIComponent(project)}&limit=1&sort=created_at:desc`,
467
- { signal: AbortSignal.timeout(200) }
468
- )
469
- if (obsRes.ok) {
470
- const obsData = await obsRes.json()
471
- const createdAt: string = obsData?.[0]?.created_at ?? ""
472
- if (createdAt) {
473
- lastObsEpoch = toEpochSecs(createdAt)
474
- }
475
- }
476
- } catch {
477
- // Server unreachable or timed out — skip nudge
478
- return
479
- }
480
-
481
- // No observations yet — nothing to nudge about
482
- if (lastObsEpoch === 0) return
483
-
484
- // Only nudge if last save was more than 15 minutes ago
485
- if (nowSecs - lastObsEpoch < 900) return
486
-
487
- // Append the nudge to the last system message
488
- const nudge =
489
- "\n\nMEMORY REMINDER: It's been over 15 minutes since your last memory save. " +
490
- "If you've made decisions, discoveries, completed significant work, or found non-obvious things, " +
491
- "call mem_save now."
492
- if (output.system.length > 0) {
493
- output.system[output.system.length - 1] += nudge
494
- } else {
495
- output.system.push(nudge)
496
- }
497
- lastNudgeTime.set(sessionID, nowSecs)
498
- } catch {
499
- // Any unexpected error — silently skip the nudge, never crash the hook
500
- }
501
- },
502
-
503
- // ─── Compaction Hook: Persist memory + inject context ──────────
504
- // Compaction is triggered by the system (not the agent) when context
505
- // gets too long. The old agent "dies" and a new one starts with the
506
- // compacted summary. This is our chance to:
507
- // 1. Auto-save a session checkpoint (the agent can't do this itself)
508
- // 2. Inject context from previous sessions into the compaction prompt
509
- // 3. Tell the compressor to remind the new agent to save memories
510
-
511
- "experimental.session.compacting": async (input, output) => {
512
- if (input.sessionID) {
513
- await ensureSession(input.sessionID)
514
- }
515
-
516
- // Inject context from previous sessions
517
- const data = await engramFetch(
518
- `/context?project=${encodeURIComponent(project)}`
519
- )
520
- if (data?.context) {
521
- output.context.push(data.context)
522
- }
523
-
524
- // Tell the compressor to instruct the new agent to persist the
525
- // compacted summary to Engram. The new agent reads the compacted
526
- // summary and this instruction, then saves it as a session summary.
527
- output.context.push(
528
- `CRITICAL INSTRUCTION FOR COMPACTED SUMMARY:\n` +
529
- `The agent has access to Engram persistent memory via MCP tools.\n` +
530
- `You MUST include the following instruction at the TOP of the compacted summary:\n\n` +
531
- `"FIRST ACTION REQUIRED: Call mem_session_summary with the content of this compacted summary. ` +
532
- `Use project: '${project}'. This preserves what was accomplished before compaction. Do this BEFORE any other work."\n\n` +
533
- `This is NOT optional. Without this, everything done before compaction is lost from memory.`
534
- )
535
- },
536
- }
537
- }
1
+ /**
2
+ * Engram — OpenCode plugin adapter
3
+ *
4
+ * Thin layer that connects OpenCode's event system to the Engram Go binary.
5
+ * The Go binary runs as a local HTTP server and handles all persistence.
6
+ *
7
+ * Flow:
8
+ * OpenCode events → this plugin → HTTP calls → engram serve → SQLite
9
+ *
10
+ * Session resilience:
11
+ * Uses `ensureSession()` before any DB write. This means sessions are
12
+ * created on-demand — even if the plugin was loaded after the session
13
+ * started (restart, reconnect, etc.). The session ID comes from OpenCode's
14
+ * hooks (input.sessionID) rather than relying on a session.created event.
15
+ */
16
+
17
+ import type { Plugin } from "@opencode-ai/plugin"
18
+
19
+ // ─── Configuration ───────────────────────────────────────────────────────────
20
+
21
+ const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
22
+ const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
23
+ const ENGRAM_BIN = process.env.ENGRAM_BIN ?? Bun.which("engram") ?? ".opencode/tools/engram/bin/engram"
24
+
25
+ // Engram's own MCP tools — don't count these as "tool calls" for session stats
26
+ const ENGRAM_TOOLS = new Set([
27
+ "mem_search",
28
+ "mem_save",
29
+ "mem_update",
30
+ "mem_delete",
31
+ "mem_suggest_topic_key",
32
+ "mem_save_prompt",
33
+ "mem_session_summary",
34
+ "mem_context",
35
+ "mem_stats",
36
+ "mem_timeline",
37
+ "mem_get_observation",
38
+ "mem_session_start",
39
+ "mem_session_end",
40
+ ])
41
+
42
+ // ─── Memory Instructions ─────────────────────────────────────────────────────
43
+ // These get injected into the agent's context so it knows to call mem_save.
44
+
45
+ const MEMORY_INSTRUCTIONS = `## Engram Persistent Memory — Protocol
46
+
47
+ You have access to Engram, a persistent memory system that survives across sessions and compactions.
48
+
49
+ ### WHEN TO SAVE (mandatory — not optional)
50
+
51
+ Call \`mem_save\` IMMEDIATELY after any of these:
52
+ - Bug fix completed
53
+ - Architecture or design decision made
54
+ - Non-obvious discovery about the codebase
55
+ - Configuration change or environment setup
56
+ - Pattern established (naming, structure, convention)
57
+ - User preference or constraint learned
58
+
59
+ Format for \`mem_save\`:
60
+ - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux")
61
+ - **type**: bugfix | decision | architecture | discovery | pattern | config | preference
62
+ - **scope**: \`project\` (default) | \`personal\`
63
+ - **topic_key** (optional, recommended for evolving decisions): stable key like \`architecture/auth-model\`
64
+ - **content**:
65
+ **What**: One sentence — what was done
66
+ **Why**: What motivated it (user request, bug, performance, etc.)
67
+ **Where**: Files or paths affected
68
+ **Learned**: Gotchas, edge cases, things that surprised you (omit if none)
69
+
70
+ Topic rules:
71
+ - Different topics must not overwrite each other (e.g. architecture vs bugfix)
72
+ - Reuse the same \`topic_key\` to update an evolving topic instead of creating new observations
73
+ - If unsure about the key, call \`mem_suggest_topic_key\` first and then reuse it
74
+ - Use \`mem_update\` when you have an exact observation ID to correct
75
+
76
+ ### WHEN TO SEARCH MEMORY
77
+
78
+ When the user asks to recall something — any variation of "remember", "recall", "what did we do",
79
+ "how did we solve", or the equivalent in the user's language, or references to past work:
80
+ 1. First call \`mem_context\` — checks recent session history (fast, cheap)
81
+ 2. If not found, call \`mem_search\` with relevant keywords (FTS5 full-text search)
82
+ 3. If you find a match, use \`mem_get_observation\` for full untruncated content
83
+
84
+ Also search memory PROACTIVELY when:
85
+ - Starting work on something that might have been done before
86
+ - The user mentions a topic you have no context on — check if past sessions covered it
87
+ - The user's FIRST message references the project, a feature, or a problem — call \`mem_search\` with keywords from their message to check for prior work before responding
88
+
89
+ ### SESSION CLOSE PROTOCOL (mandatory)
90
+
91
+ Before ending a session or saying "done" / "that's it", you MUST:
92
+ 1. Call \`mem_session_summary\` with this structure:
93
+
94
+ ## Goal
95
+ [What we were working on this session]
96
+
97
+ ## Instructions
98
+ [User preferences or constraints discovered — skip if none]
99
+
100
+ ## Discoveries
101
+ - [Technical findings, gotchas, non-obvious learnings]
102
+
103
+ ## Accomplished
104
+ - [Completed items with key details]
105
+
106
+ ## Next Steps
107
+ - [What remains to be done — for the next session]
108
+
109
+ ## Relevant Files
110
+ - path/to/file — [what it does or what changed]
111
+
112
+ This is NOT optional. If you skip this, the next session starts blind.
113
+
114
+ ### AFTER COMPACTION
115
+
116
+ If you see a message about compaction or context reset, or if you see "FIRST ACTION REQUIRED" in your context:
117
+ 1. IMMEDIATELY call \`mem_session_summary\` with the compacted summary content — this persists what was done before compaction
118
+ 2. Then call \`mem_context\` to recover any additional context from previous sessions
119
+ 3. Only THEN continue working
120
+
121
+ Do not skip step 1. Without it, everything done before compaction is lost from memory.
122
+ `
123
+
124
+ // ─── HTTP Client ─────────────────────────────────────────────────────────────
125
+
126
+ async function engramFetch(
127
+ path: string,
128
+ opts: { method?: string; body?: any } = {}
129
+ ): Promise<any> {
130
+ try {
131
+ const res = await fetch(`${ENGRAM_URL}${path}`, {
132
+ method: opts.method ?? "GET",
133
+ headers: opts.body ? { "Content-Type": "application/json" } : undefined,
134
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
135
+ })
136
+ return await res.json()
137
+ } catch {
138
+ // Engram server not running — silently fail
139
+ return null
140
+ }
141
+ }
142
+
143
+ async function isEngramRunning(): Promise<boolean> {
144
+ try {
145
+ const res = await fetch(`${ENGRAM_URL}/health`, {
146
+ signal: AbortSignal.timeout(500),
147
+ })
148
+ return res.ok
149
+ } catch {
150
+ return false
151
+ }
152
+ }
153
+
154
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
155
+
156
+ function extractProjectName(directory: string): string {
157
+ // Try git remote origin URL
158
+ try {
159
+ const result = Bun.spawnSync(["git", "-C", directory, "remote", "get-url", "origin"])
160
+ if (result.exitCode === 0) {
161
+ const url = result.stdout?.toString().trim()
162
+ if (url) {
163
+ const name = url.replace(/\.git$/, "").split(/[/:]/).pop()
164
+ if (name) return name
165
+ }
166
+ }
167
+ } catch {}
168
+
169
+ // Fallback: git root directory name (works in worktrees)
170
+ try {
171
+ const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--show-toplevel"])
172
+ if (result.exitCode === 0) {
173
+ const root = result.stdout?.toString().trim()
174
+ if (root) return root.split("/").pop() ?? "unknown"
175
+ }
176
+ } catch {}
177
+
178
+ // Final fallback: cwd basename
179
+ return directory.split("/").pop() ?? "unknown"
180
+ }
181
+
182
+ function truncate(str: string, max: number): string {
183
+ if (!str) return ""
184
+ return str.length > max ? str.slice(0, max) + "..." : str
185
+ }
186
+
187
+ /**
188
+ * Strip <private>...</private> tags before sending to engram.
189
+ * Double safety: the Go binary also strips, but we strip here too
190
+ * so sensitive data never even hits the wire.
191
+ */
192
+ function stripPrivateTags(str: string): string {
193
+ if (!str) return ""
194
+ return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
195
+ }
196
+
197
+ // ─── Plugin Export ───────────────────────────────────────────────────────────
198
+
199
+ export const Engram: Plugin = async (ctx) => {
200
+ const oldProject = ctx.directory.split("/").pop() ?? "unknown"
201
+ const project = extractProjectName(ctx.directory)
202
+
203
+ // Track tool counts per session (in-memory only, not critical)
204
+ const toolCounts = new Map<string, number>()
205
+
206
+ // Track last nudge time per session to debounce save reminders
207
+ const lastNudgeTime = new Map<string, number>() // sessionID -> epoch seconds
208
+
209
+ // Track which sessions we've already ensured exist in engram
210
+ const knownSessions = new Set<string>()
211
+
212
+ // Track sub-agent session IDs so we can suppress their tool-hook registrations.
213
+ // Sub-agents (Task() calls) have a parentID or a title ending in " subagent)".
214
+ // We must not register them as top-level Engram sessions — they cause session
215
+ // inflation (e.g. 170 sessions for 1 real conversation, issue #116).
216
+ const subAgentSessions = new Set<string>()
217
+
218
+ /**
219
+ * Ensure a session exists in engram. Idempotent — calls POST /sessions
220
+ * which uses INSERT OR IGNORE. Safe to call multiple times.
221
+ *
222
+ * Silently skips sub-agent sessions (tracked in `subAgentSessions`).
223
+ */
224
+ async function ensureSession(sessionId: string): Promise<void> {
225
+ if (!sessionId || knownSessions.has(sessionId)) return
226
+ // Do not register sub-agent sessions in Engram (issue #116).
227
+ if (subAgentSessions.has(sessionId)) return
228
+ knownSessions.add(sessionId)
229
+ await engramFetch("/sessions", {
230
+ method: "POST",
231
+ body: {
232
+ id: sessionId,
233
+ project,
234
+ directory: ctx.directory,
235
+ },
236
+ })
237
+ }
238
+
239
+ // Try to start engram server if not running
240
+ const running = await isEngramRunning()
241
+ if (!running) {
242
+ try {
243
+ Bun.spawn([ENGRAM_BIN, "serve"], {
244
+ stdout: "ignore",
245
+ stderr: "ignore",
246
+ stdin: "ignore",
247
+ })
248
+ await new Promise((r) => setTimeout(r, 500))
249
+ } catch {
250
+ // Binary not found or can't start — plugin will silently no-op
251
+ }
252
+ }
253
+
254
+ // Migrate project name if it changed (one-time, idempotent)
255
+ // Must run AFTER server startup to ensure the endpoint is available
256
+ if (oldProject !== project) {
257
+ await engramFetch("/projects/migrate", {
258
+ method: "POST",
259
+ body: { old_project: oldProject, new_project: project },
260
+ })
261
+ }
262
+
263
+ // Auto-import: if .engram/manifest.json exists in the project repo,
264
+ // run `engram sync --import` to load any new chunks into the local DB.
265
+ // This is how git-synced memories get loaded when cloning a repo or
266
+ // pulling changes. Each chunk is imported only once (tracked by ID).
267
+ try {
268
+ const manifestFile = `${ctx.directory}/.engram/manifest.json`
269
+ const file = Bun.file(manifestFile)
270
+ if (await file.exists()) {
271
+ Bun.spawn([ENGRAM_BIN, "sync", "--import"], {
272
+ cwd: ctx.directory,
273
+ stdout: "ignore",
274
+ stderr: "ignore",
275
+ stdin: "ignore",
276
+ })
277
+ }
278
+ } catch {
279
+ // Manifest doesn't exist or binary not found — silently skip
280
+ }
281
+
282
+ return {
283
+ // ─── Event Listeners ───────────────────────────────────────────
284
+
285
+ event: async ({ event }) => {
286
+ // --- Session Created ---
287
+ if (event.type === "session.created") {
288
+ // Bug fix (#116): session data is nested under event.properties.info,
289
+ // not event.properties directly.
290
+ const info = (event.properties as any)?.info
291
+ const sessionId = info?.id
292
+ const parentID = info?.parentID
293
+ const title: string = info?.title ?? ""
294
+
295
+ // Sub-agent sessions (created via Task()) must NOT be registered as
296
+ // top-level Engram sessions. They cause massive session inflation
297
+ // (e.g. 170 sessions for 1 real conversation).
298
+ //
299
+ // Detection heuristics:
300
+ // - parentID is set on all Task() sub-agent sessions
301
+ // - title ends with " subagent)" as a secondary signal
302
+ const isSubAgent = !!parentID || title.endsWith(" subagent)")
303
+
304
+ if (sessionId && !isSubAgent) {
305
+ await ensureSession(sessionId)
306
+ } else if (sessionId && isSubAgent) {
307
+ // Remember this as a sub-agent session so tool-hook calls
308
+ // to ensureSession() are also suppressed for it.
309
+ subAgentSessions.add(sessionId)
310
+ }
311
+ }
312
+
313
+ // --- Session Deleted ---
314
+ if (event.type === "session.deleted") {
315
+ // Same properties.info path as session.created.
316
+ const info = (event.properties as any)?.info
317
+ const sessionId = info?.id
318
+ if (sessionId) {
319
+ toolCounts.delete(sessionId)
320
+ knownSessions.delete(sessionId)
321
+ subAgentSessions.delete(sessionId)
322
+ lastNudgeTime.delete(sessionId)
323
+ }
324
+ }
325
+
326
+ },
327
+
328
+ // ─── User Prompt Capture ──────────────────────────────────────
329
+ // chat.message is called once per user message, before the LLM sees it.
330
+ // input.sessionID is always reliable here (no knownSessions workaround).
331
+ // output.message is typed as UserMessage (role:"user" already guaranteed).
332
+ // output.parts contains TextPart[] with the actual message text.
333
+
334
+ "chat.message": async (input, output) => {
335
+ // Skip sub-agent sessions — they inflate session counts (issue #116)
336
+ if (subAgentSessions.has(input.sessionID)) return
337
+
338
+ const sessionId = input.sessionID
339
+
340
+ // Extract text from parts (type:"text")
341
+ const content = output.parts
342
+ .filter((p) => p.type === "text")
343
+ .map((p) => (p as any).text ?? "")
344
+ .join("\n")
345
+ .trim()
346
+
347
+ // Also fallback to summary if parts yield nothing
348
+ const fallback = !content && output.message.summary
349
+ ? `${output.message.summary.title ?? ""}\n${output.message.summary.body ?? ""}`.trim()
350
+ : ""
351
+
352
+ const finalContent = content || fallback
353
+
354
+ // Only capture non-trivial prompts (>10 chars)
355
+ if (finalContent.length > 10) {
356
+ await ensureSession(sessionId)
357
+ await engramFetch("/prompts", {
358
+ method: "POST",
359
+ body: {
360
+ session_id: sessionId,
361
+ content: stripPrivateTags(truncate(finalContent, 2000)),
362
+ project,
363
+ },
364
+ })
365
+ }
366
+ },
367
+
368
+ // ─── Tool Execution Hook ─────────────────────────────────────
369
+ // Count tool calls per session (for session end stats).
370
+ // Also ensures the session exists — handles plugin reload / reconnect.
371
+ // Passive capture: when a Task tool completes, POST its output to
372
+ // the passive capture endpoint so the server extracts learnings.
373
+
374
+ "tool.execute.after": async (input, output) => {
375
+ if (ENGRAM_TOOLS.has(input.tool.toLowerCase())) return
376
+
377
+ // input.sessionID comes from OpenCode — always available
378
+ const sessionId = input.sessionID
379
+ if (sessionId) {
380
+ await ensureSession(sessionId)
381
+ toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1)
382
+ }
383
+
384
+ // Passive capture: extract learnings from Task tool output
385
+ if (input.tool === "Task" && output && sessionId) {
386
+ const text = typeof output === "string" ? output : JSON.stringify(output)
387
+ if (text.length > 50) {
388
+ await engramFetch("/observations/passive", {
389
+ method: "POST",
390
+ body: {
391
+ session_id: sessionId,
392
+ content: stripPrivateTags(text),
393
+ project,
394
+ source: "task-complete",
395
+ },
396
+ })
397
+ }
398
+ }
399
+ },
400
+
401
+ // ─── System Prompt: Always-on memory instructions ──────────
402
+ // Injects MEMORY_INSTRUCTIONS into the system prompt of every message.
403
+ // This ensures the agent ALWAYS knows about Engram, even after compaction.
404
+ //
405
+ // We append to the last existing system entry instead of pushing a new one.
406
+ // Some models (Qwen3.5, Mistral/Ministral via llama.cpp) reject multiple
407
+ // system messages — their Jinja chat templates only allow a single system
408
+ // block at the beginning. By concatenating, we avoid adding extra system
409
+ // messages that would break these models. See: GitHub issue #23.
410
+
411
+ "experimental.chat.system.transform": async (input, output) => {
412
+ if (output.system.length > 0) {
413
+ output.system[output.system.length - 1] += "\n\n" + MEMORY_INSTRUCTIONS
414
+ } else {
415
+ output.system.push(MEMORY_INSTRUCTIONS)
416
+ }
417
+
418
+ // ── Save nudge ──────────────────────────────────────────────────────────
419
+ // If it has been a long time since the last mem_save, append a reminder
420
+ // to the system prompt so the agent notices. All fetches are fire-and-
421
+ // forget with short timeouts — any failure silently skips the nudge.
422
+ try {
423
+ const sessionID: string = input.sessionID ?? ""
424
+ if (!sessionID || subAgentSessions.has(sessionID)) return
425
+
426
+ // SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" in UTC with no
427
+ // zone suffix; new Date() would parse that as local time. Normalize to
428
+ // UTC first so the thresholds are correct in every timezone.
429
+ const toEpochSecs = (ts: string): number => {
430
+ if (!ts) return 0
431
+ const normalized = ts.includes("T") ? ts : ts.replace(" ", "T") + "Z"
432
+ const ms = new Date(normalized).getTime()
433
+ return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
434
+ }
435
+
436
+ const cooldownSecs = parseInt(process.env.ENGRAM_NUDGE_COOLDOWN_SECS ?? "900", 10)
437
+ const nowSecs = Math.floor(Date.now() / 1000)
438
+
439
+ // Debounce: skip if we nudged recently this session
440
+ const lastNudge = lastNudgeTime.get(sessionID)
441
+ if (lastNudge !== undefined && nowSecs - lastNudge < cooldownSecs) return
442
+
443
+ // Skip if the session is too young (< 5 minutes)
444
+ let sessionStartEpoch = 0
445
+ try {
446
+ const sessionRes = await fetch(`${ENGRAM_URL}/sessions/${encodeURIComponent(sessionID)}`, {
447
+ signal: AbortSignal.timeout(200),
448
+ })
449
+ if (sessionRes.ok) {
450
+ const sessionData = await sessionRes.json()
451
+ const startedAt: string = sessionData?.started_at ?? ""
452
+ if (startedAt) {
453
+ sessionStartEpoch = toEpochSecs(startedAt)
454
+ }
455
+ }
456
+ } catch {
457
+ // Server unreachable or timed out — skip nudge
458
+ return
459
+ }
460
+ if (sessionStartEpoch > 0 && nowSecs - sessionStartEpoch < 300) return
461
+
462
+ // Check when the last observation was saved for this project
463
+ let lastObsEpoch = 0
464
+ try {
465
+ const obsRes = await fetch(
466
+ `${ENGRAM_URL}/observations?project=${encodeURIComponent(project)}&limit=1&sort=created_at:desc`,
467
+ { signal: AbortSignal.timeout(200) }
468
+ )
469
+ if (obsRes.ok) {
470
+ const obsData = await obsRes.json()
471
+ const createdAt: string = obsData?.[0]?.created_at ?? ""
472
+ if (createdAt) {
473
+ lastObsEpoch = toEpochSecs(createdAt)
474
+ }
475
+ }
476
+ } catch {
477
+ // Server unreachable or timed out — skip nudge
478
+ return
479
+ }
480
+
481
+ // No observations yet — nothing to nudge about
482
+ if (lastObsEpoch === 0) return
483
+
484
+ // Only nudge if last save was more than 15 minutes ago
485
+ if (nowSecs - lastObsEpoch < 900) return
486
+
487
+ // Append the nudge to the last system message
488
+ const nudge =
489
+ "\n\nMEMORY REMINDER: It's been over 15 minutes since your last memory save. " +
490
+ "If you've made decisions, discoveries, completed significant work, or found non-obvious things, " +
491
+ "call mem_save now."
492
+ if (output.system.length > 0) {
493
+ output.system[output.system.length - 1] += nudge
494
+ } else {
495
+ output.system.push(nudge)
496
+ }
497
+ lastNudgeTime.set(sessionID, nowSecs)
498
+ } catch {
499
+ // Any unexpected error — silently skip the nudge, never crash the hook
500
+ }
501
+ },
502
+
503
+ // ─── Compaction Hook: Persist memory + inject context ──────────
504
+ // Compaction is triggered by the system (not the agent) when context
505
+ // gets too long. The old agent "dies" and a new one starts with the
506
+ // compacted summary. This is our chance to:
507
+ // 1. Auto-save a session checkpoint (the agent can't do this itself)
508
+ // 2. Inject context from previous sessions into the compaction prompt
509
+ // 3. Tell the compressor to remind the new agent to save memories
510
+
511
+ "experimental.session.compacting": async (input, output) => {
512
+ if (input.sessionID) {
513
+ await ensureSession(input.sessionID)
514
+ }
515
+
516
+ // Inject context from previous sessions
517
+ const data = await engramFetch(
518
+ `/context?project=${encodeURIComponent(project)}`
519
+ )
520
+ if (data?.context) {
521
+ output.context.push(data.context)
522
+ }
523
+
524
+ // Tell the compressor to instruct the new agent to persist the
525
+ // compacted summary to Engram. The new agent reads the compacted
526
+ // summary and this instruction, then saves it as a session summary.
527
+ output.context.push(
528
+ `CRITICAL INSTRUCTION FOR COMPACTED SUMMARY:\n` +
529
+ `The agent has access to Engram persistent memory via MCP tools.\n` +
530
+ `You MUST include the following instruction at the TOP of the compacted summary:\n\n` +
531
+ `"FIRST ACTION REQUIRED: Call mem_session_summary with the content of this compacted summary. ` +
532
+ `Use project: '${project}'. This preserves what was accomplished before compaction. Do this BEFORE any other work."\n\n` +
533
+ `This is NOT optional. Without this, everything done before compaction is lost from memory.`
534
+ )
535
+ },
536
+ }
537
+ }