ostacky 0.8.8 → 0.9.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.
@@ -1,646 +1,648 @@
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
- import { join, dirname, basename } from "path"
19
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from "fs"
20
-
21
- // ─── Configuration ───────────────────────────────────────────────────────────
22
-
23
- const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
24
- const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
25
- // C3/H2 fix: resolve ENGRAM_BIN per ctx.directory with win32 .exe and absolute fallback
26
- function resolveEngramBin(directory: string): string {
27
- if (process.env.ENGRAM_BIN) {
28
- const p = process.env.ENGRAM_BIN
29
- const isAbs = p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)
30
- return isAbs ? p : join(directory, p)
31
- }
32
- const which = Bun.which("engram")
33
- if (which) return which
34
- const suffix = process.platform === "win32" ? ".exe" : ""
35
- return join(directory, ".opencode", "tools", "engram", "bin", `engram${suffix}`)
36
- }
37
- // ENGRAM_BIN eliminado: reemplazado por resolveEngramBin(ctx.directory) que maneja .exe+absolutización correctamente
38
-
39
- // Engram's own MCP tools — don't count these as "tool calls" for session stats
40
- const ENGRAM_TOOLS = new Set([
41
- "mem_search",
42
- "mem_save",
43
- "mem_update",
44
- "mem_delete",
45
- "mem_suggest_topic_key",
46
- "mem_save_prompt",
47
- "mem_session_summary",
48
- "mem_context",
49
- "mem_stats",
50
- "mem_timeline",
51
- "mem_get_observation",
52
- "mem_session_start",
53
- "mem_session_end",
54
- ])
55
-
56
- // ─── Memory Instructions ─────────────────────────────────────────────────────
57
- // Lazy: full protocol lives in assets/docs/engram-protocol.md (on-demand via Read).
58
- // System injects only pointer + nudge, not full 1.2k. Source-of-truth: src/tiered.ts for isTrivial.
59
-
60
- const MEMORY_POINTER = "Engram disponible — para formato mem_save/mem_search lee assets/docs/engram-protocol.md (on-demand). Usa mem_search proactivamente si el tema pudo verse antes."
61
- const MEMORY_INSTRUCTIONS_LAZY = `## Engram — pointer (lazy)
62
-
63
- ${MEMORY_POINTER}
64
-
65
- Cuando necesites guardar/buscar, lee el protocolo completo con Read. No alucines formato.`
66
- // Compat: keep full for fallback if file missing — but never inject full in system.transform
67
- const MEMORY_INSTRUCTIONS = MEMORY_INSTRUCTIONS_LAZY
68
-
69
- // ─── HTTP Client ─────────────────────────────────────────────────────────────
70
-
71
- async function engramFetch(
72
- path: string,
73
- opts: { method?: string; body?: any } = {}
74
- ): Promise<any> {
75
- try {
76
- const res = await fetch(`${ENGRAM_URL}${path}`, {
77
- method: opts.method ?? "GET",
78
- headers: opts.body ? { "Content-Type": "application/json" } : undefined,
79
- body: opts.body ? JSON.stringify(opts.body) : undefined,
80
- })
81
- return await res.json()
82
- } catch {
83
- // Engram server not running — silently fail
84
- return null
85
- }
86
- }
87
-
88
- async function isEngramRunning(): Promise<boolean> {
89
- try {
90
- const res = await fetch(`${ENGRAM_URL}/health`, {
91
- signal: AbortSignal.timeout(500),
92
- })
93
- return res.ok
94
- } catch {
95
- return false
96
- }
97
- }
98
-
99
- // ─── Helpers ─────────────────────────────────────────────────────────────────
100
-
101
- function extractProjectName(directory: string): string {
102
- // Try git remote origin URL
103
- try {
104
- const result = Bun.spawnSync(["git", "-C", directory, "remote", "get-url", "origin"])
105
- if (result.exitCode === 0) {
106
- const url = result.stdout?.toString().trim()
107
- if (url) {
108
- const name = url.replace(/\.git$/, "").split(/[/:]/).pop()
109
- if (name) return name
110
- }
111
- }
112
- } catch {}
113
-
114
- // Fallback: git root directory name (works in worktrees)
115
- try {
116
- const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--show-toplevel"])
117
- if (result.exitCode === 0) {
118
- const root = result.stdout?.toString().trim()
119
- if (root) return basename(root.replace(/\\/g, "/")) ?? "unknown"
120
- }
121
- } catch {}
122
-
123
- // Final fallback: cwd basename (cross-platform)
124
- return basename(directory.replace(/\\/g, "/")) ?? "unknown"
125
- }
126
-
127
- function truncate(str: string, max: number): string {
128
- if (!str) return ""
129
- return str.length > max ? str.slice(0, max) + "..." : str
130
- }
131
-
132
- /**
133
- * Strip <private>...</private> tags before sending to engram.
134
- * Double safety: the Go binary also strips, but we strip here too
135
- * so sensitive data never even hits the wire.
136
- */
137
- function stripPrivateTags(str: string): string {
138
- if (!str) return ""
139
- return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
140
- }
141
-
142
- function stripJsoncComments(text: string): string {
143
- let result = ""
144
- let i = 0
145
- let inString = false
146
- while (i < text.length) {
147
- const char = text[i]
148
- const next = text[i + 1]
149
- if (inString) {
150
- if (char === "\\") {
151
- result += char + (next ?? "")
152
- i += 2
153
- continue
154
- }
155
- if (char === '"') inString = false
156
- result += char
157
- i++
158
- continue
159
- }
160
- if (char === '"') {
161
- inString = true
162
- result += char
163
- i++
164
- continue
165
- }
166
- if (char === "/" && next === "/") {
167
- while (i < text.length && text[i] !== "\n") i++
168
- continue
169
- }
170
- if (char === "/" && next === "*") {
171
- i += 2
172
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
173
- i += 2
174
- continue
175
- }
176
- result += char
177
- i++
178
- }
179
- return result.replace(/,\s*([}\]])/g, "$1")
180
- }
181
-
182
- // ─── Plugin Export ───────────────────────────────────────────────────────────
183
-
184
- export const Engram: Plugin = async (ctx) => {
185
- // T4: basename multiplataforma — split("/") producía keys basura con backslashes en Windows nativo
186
- const oldProject = basename(ctx.directory.replace(/\\/g, "/")) ?? "unknown"
187
- const project = extractProjectName(ctx.directory)
188
-
189
- // Track tool counts per session (in-memory only, not critical)
190
- const toolCounts = new Map<string, number>()
191
-
192
- // Track last nudge time per session to debounce save reminders
193
- const lastNudgeTime = new Map<string, number>() // sessionID -> epoch seconds
194
-
195
- // Track which sessions we've already ensured exist in engram
196
- const knownSessions = new Set<string>()
197
-
198
- // Track sub-agent session IDs so we can suppress their tool-hook registrations.
199
- // Sub-agents (Task() calls) have a parentID or a title ending in " subagent)".
200
- // We must not register them as top-level Engram sessions — they cause session
201
- // inflation (e.g. 170 sessions for 1 real conversation, issue #116).
202
- const subAgentSessions = new Set<string>()
203
-
204
- // Tiered cache-friendly: single source of truth via src/tiered.ts
205
- const trivialBySession = new Map<string, boolean>()
206
- // isTrivial y getControllerState importados lógicamente desde src/tiered.ts
207
- // Inlined para evitar import dinámico en plugin bundle — mantener regex idéntico a src/tiered.ts
208
- function isTrivialMessage(msg: string, state: string): boolean {
209
- if (!msg || state !== "DONE") return false
210
- if (msg.trim().length >= 30) return false
211
- if (!/^(hola|hey|gracias|buenas|hi|hello)\b/i.test(msg.trim())) return false
212
- if (/(necesito|quiero|agregá|fix|bug|feature|auth|spec|implementar)/i.test(msg)) return false
213
- return true
214
- }
215
- function getControllerState(directory: string): string {
216
- try {
217
- const statePath = process.env.OSTACKY_STATE_PATH || join(directory, ".opencode", "ostacky-state.json")
218
- const raw = readFileSync(statePath, "utf-8")
219
- const j = JSON.parse(raw)
220
- return j.state ?? "DONE"
221
- } catch { return "DONE" }
222
- }
223
-
224
- /**
225
- * Ensure a session exists in engram. Idempotent — calls POST /sessions
226
- * which uses INSERT OR IGNORE. Safe to call multiple times.
227
- *
228
- * Silently skips sub-agent sessions (tracked in `subAgentSessions`).
229
- */
230
- async function ensureSession(sessionId: string): Promise<void> {
231
- if (!sessionId || knownSessions.has(sessionId)) return
232
- // Do not register sub-agent sessions in Engram (issue #116).
233
- if (subAgentSessions.has(sessionId)) return
234
- knownSessions.add(sessionId)
235
- await engramFetch("/sessions", {
236
- method: "POST",
237
- body: {
238
- id: sessionId,
239
- project,
240
- directory: ctx.directory,
241
- },
242
- })
243
- }
244
-
245
- // Try to start engram server if not running — use per-directory resolved bin (win32 .exe + absolute)
246
- const engramBin = resolveEngramBin(ctx.directory)
247
- const running = await isEngramRunning()
248
- if (!running) {
249
- try {
250
- Bun.spawn([engramBin, "serve"], {
251
- stdout: "ignore",
252
- stderr: "ignore",
253
- stdin: "ignore",
254
- })
255
- await new Promise((r) => setTimeout(r, 500))
256
- } catch {
257
- // Binary not found or can't start — plugin will silently no-op
258
- }
259
- }
260
-
261
- // Migrate project name if it changed (one-time, idempotent)
262
- // Must run AFTER server startup to ensure the endpoint is available
263
- if (oldProject !== project) {
264
- await engramFetch("/projects/migrate", {
265
- method: "POST",
266
- body: { old_project: oldProject, new_project: project },
267
- })
268
- }
269
-
270
- // Auto-import: if .engram/manifest.json exists in the project repo,
271
- // run `engram sync --import` to load any new chunks into the local DB.
272
- // This is how git-synced memories get loaded when cloning a repo or
273
- // pulling changes. Each chunk is imported only once (tracked by ID).
274
- try {
275
- const manifestFile = `${ctx.directory}/.engram/manifest.json`
276
- const file = Bun.file(manifestFile)
277
- if (await file.exists()) {
278
- Bun.spawn([engramBin, "sync", "--import"], {
279
- cwd: ctx.directory,
280
- stdout: "ignore",
281
- stderr: "ignore",
282
- stdin: "ignore",
283
- })
284
- }
285
- } catch {
286
- // Manifest doesn't exist or binary not found — silently skip
287
- }
288
-
289
- return {
290
- // ─── Event Listeners ───────────────────────────────────────────
291
-
292
- event: async ({ event }) => {
293
- // --- Session Created ---
294
- if (event.type === "session.created") {
295
- // Bug fix (#116): session data is nested under event.properties.info,
296
- // not event.properties directly.
297
- const info = (event.properties as any)?.info
298
- const sessionId = info?.id
299
- const parentID = info?.parentID
300
- const title: string = info?.title ?? ""
301
-
302
- // Sub-agent sessions (created via Task()) must NOT be registered as
303
- // top-level Engram sessions. They cause massive session inflation
304
- // (e.g. 170 sessions for 1 real conversation).
305
- //
306
- // Detection heuristics:
307
- // - parentID is set on all Task() sub-agent sessions
308
- // - title ends with " subagent)" as a secondary signal
309
- const isSubAgent = !!parentID || title.endsWith(" subagent)")
310
-
311
- if (sessionId && !isSubAgent) {
312
- await ensureSession(sessionId)
313
- } else if (sessionId && isSubAgent) {
314
- // Remember this as a sub-agent session so tool-hook calls
315
- // to ensureSession() are also suppressed for it.
316
- subAgentSessions.add(sessionId)
317
- }
318
- }
319
-
320
- // --- Session Deleted ---
321
- if (event.type === "session.deleted") {
322
- // Same properties.info path as session.created.
323
- const info = (event.properties as any)?.info
324
- const sessionId = info?.id
325
- if (sessionId) {
326
- toolCounts.delete(sessionId)
327
- knownSessions.delete(sessionId)
328
- subAgentSessions.delete(sessionId)
329
- lastNudgeTime.delete(sessionId)
330
- }
331
- }
332
-
333
- },
334
-
335
- // ─── User Prompt Capture ──────────────────────────────────────
336
- // chat.message is called once per user message, before the LLM sees it.
337
- // input.sessionID is always reliable here (no knownSessions workaround).
338
- // output.message is typed as UserMessage (role:"user" already guaranteed).
339
- // output.parts contains TextPart[] with the actual message text.
340
-
341
- "chat.message": async (input, output) => {
342
- // Skip sub-agent sessions — they inflate session counts (issue #116)
343
- if (subAgentSessions.has(input.sessionID)) return
344
-
345
- const sessionId = input.sessionID
346
-
347
- // Extract text from parts (type:"text")
348
- const content = output.parts
349
- .filter((p) => p.type === "text")
350
- .map((p) => (p as any).text ?? "")
351
- .join("\n")
352
- .trim()
353
-
354
- // Also fallback to summary if parts yield nothing
355
- const fallback = !content && output.message.summary
356
- ? `${output.message.summary.title ?? ""}\n${output.message.summary.body ?? ""}`.trim()
357
- : ""
358
-
359
- const finalContent = content || fallback
360
-
361
- // Tiered: set trivial flag for system.transform lazy
362
- try {
363
- const state = getControllerState(ctx.directory)
364
- trivialBySession.set(sessionId, isTrivialMessage(finalContent, state))
365
- } catch {}
366
-
367
- // Only capture non-trivial prompts (>10 chars)
368
- if (finalContent.length > 10) {
369
- await ensureSession(sessionId)
370
- await engramFetch("/prompts", {
371
- method: "POST",
372
- body: {
373
- session_id: sessionId,
374
- content: stripPrivateTags(truncate(finalContent, 2000)),
375
- project,
376
- },
377
- })
378
- }
379
- },
380
-
381
- // ─── Tool Execution Hook ─────────────────────────────────────
382
- // Count tool calls per session (for session end stats).
383
- // Also ensures the session exists — handles plugin reload / reconnect.
384
- // Passive capture: when a Task tool completes, POST its output to
385
- // the passive capture endpoint so the server extracts learnings.
386
-
387
- "tool.execute.after": async (input, output) => {
388
- if (ENGRAM_TOOLS.has(input.tool.toLowerCase())) return
389
-
390
- // input.sessionID comes from OpenCode — always available
391
- const sessionId = input.sessionID
392
- if (sessionId) {
393
- await ensureSession(sessionId)
394
- toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1)
395
- }
396
-
397
- // Passive capture: extract learnings from Task tool output
398
- if (input.tool === "Task" && output && sessionId) {
399
- const text = typeof output === "string" ? output : JSON.stringify(output)
400
- if (text.length > 50) {
401
- await engramFetch("/observations/passive", {
402
- method: "POST",
403
- body: {
404
- session_id: sessionId,
405
- content: stripPrivateTags(text),
406
- project,
407
- source: "task-complete",
408
- },
409
- })
410
- }
411
- }
412
- },
413
-
414
- // ─── System Prompt: Always-on memory instructions ──────────
415
- // Injects MEMORY_INSTRUCTIONS into the system prompt of every message.
416
- // This ensures the agent ALWAYS knows about Engram, even after compaction.
417
- //
418
- // We append to the last existing system entry instead of pushing a new one.
419
- // Some models (Qwen3.5, Mistral/Ministral via llama.cpp) reject multiple
420
- // system messages — their Jinja chat templates only allow a single system
421
- // block at the beginning. By concatenating, we avoid adding extra system
422
- // messages that would break these models. See: GitHub issue #23.
423
-
424
- "experimental.chat.system.transform": async (input, output) => {
425
- // Tiered lazy: SIEMPRE pointer (cache-friendly, ~1 línea). Full vive en assets/docs/engram-protocol.md on-demand.
426
- const sessionId: string = (input as any).sessionID ?? ""
427
- const isTrivial = trivialBySession.get(sessionId) ?? false
428
- const state = getControllerState(ctx.directory)
429
- const shouldBeTrivial = isTrivial && state === "DONE"
430
- const pointer = shouldBeTrivial
431
- ? "Engram disponible — detalles a demanda (usa mem_search si necesitas recordar)."
432
- : MEMORY_POINTER
433
- if (output.system.length > 0) {
434
- output.system[output.system.length - 1] += "\n\n" + pointer
435
- } else {
436
- output.system.push(pointer)
437
- }
438
- // No inyectar MEMORY_INSTRUCTIONS completo nunca — se lee on-demand via Read
439
-
440
- // harden-compaction-resume: auto-inject recovery hint when pending (no depende del modelo)
441
- if (!shouldBeTrivial) {
442
- try {
443
- const statePath = process.env.OSTACKY_STATE_PATH || join(ctx.directory, ".opencode", "ostacky-state.json")
444
- const raw = readFileSync(statePath, "utf-8")
445
- const st = JSON.parse(raw)
446
- const curState = st?.state ?? "DONE"
447
- if (!["DONE", "INTERPRETATION_PENDING"].includes(curState)) {
448
- let pending: string[] = Array.isArray(st?.lastHandoff?.pendingTasks)
449
- ? st.lastHandoff.pendingTasks.filter((id: string) => !st.tasks?.[id] || st.tasks[id].status !== "COMPLETED")
450
- : []
451
- if (pending.length === 0) {
452
- try {
453
- const fbPath = join(dirname(statePath), ".ostacky-handoff-compaction.json")
454
- const fbRaw = readFileSync(fbPath, "utf-8")
455
- const fb = JSON.parse(fbRaw)
456
- if (fb && Array.isArray(fb.pendingTasks) && typeof fb.ts === "number" && Date.now() - fb.ts < 24 * 60 * 60 * 1000) {
457
- const fbPend = fb.pendingTasks.filter((id: string) => !st.tasks?.[id] || st.tasks[id].status !== "COMPLETED")
458
- if (fbPend.length > 0) pending = fbPend
459
- }
460
- } catch {}
461
- }
462
- if (pending.length > 0) {
463
- const hint = `\n\n[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
464
- if (output.system.length > 0) output.system[output.system.length - 1] += hint
465
- else output.system.push(hint.trim())
466
- }
467
- }
468
- } catch {}
469
- }
470
-
471
- // ── Save nudge ──────────────────────────────────────────────────────────
472
- // Skip nudge for trivial greeting (cache-friendly, no extra injection)
473
- if (shouldBeTrivial) return
474
- // If it has been a long time since the last mem_save, append a reminder
475
- // to the system prompt so the agent notices. All fetches are fire-and-
476
- // forget with short timeouts — any failure silently skips the nudge.
477
- try {
478
- const sessionID: string = input.sessionID ?? ""
479
- if (!sessionID || subAgentSessions.has(sessionID)) return
480
-
481
- // SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" in UTC with no
482
- // zone suffix; new Date() would parse that as local time. Normalize to
483
- // UTC first so the thresholds are correct in every timezone.
484
- const toEpochSecs = (ts: string): number => {
485
- if (!ts) return 0
486
- const normalized = ts.includes("T") ? ts : ts.replace(" ", "T") + "Z"
487
- const ms = new Date(normalized).getTime()
488
- return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
489
- }
490
-
491
- const cooldownSecs = parseInt(process.env.ENGRAM_NUDGE_COOLDOWN_SECS ?? "900", 10)
492
- const nowSecs = Math.floor(Date.now() / 1000)
493
-
494
- // Debounce: skip if we nudged recently this session
495
- const lastNudge = lastNudgeTime.get(sessionID)
496
- if (lastNudge !== undefined && nowSecs - lastNudge < cooldownSecs) return
497
-
498
- // Skip if the session is too young (< 5 minutes)
499
- let sessionStartEpoch = 0
500
- try {
501
- const sessionRes = await fetch(`${ENGRAM_URL}/sessions/${encodeURIComponent(sessionID)}`, {
502
- signal: AbortSignal.timeout(200),
503
- })
504
- if (sessionRes.ok) {
505
- const sessionData = await sessionRes.json()
506
- const startedAt: string = sessionData?.started_at ?? ""
507
- if (startedAt) {
508
- sessionStartEpoch = toEpochSecs(startedAt)
509
- }
510
- }
511
- } catch {
512
- // Server unreachable or timed out — skip nudge
513
- return
514
- }
515
- if (sessionStartEpoch > 0 && nowSecs - sessionStartEpoch < 300) return
516
-
517
- // Check when the last observation was saved for this project
518
- let lastObsEpoch = 0
519
- try {
520
- const obsRes = await fetch(
521
- `${ENGRAM_URL}/observations?project=${encodeURIComponent(project)}&limit=1&sort=created_at:desc`,
522
- { signal: AbortSignal.timeout(200) }
523
- )
524
- if (obsRes.ok) {
525
- const obsData = await obsRes.json()
526
- const createdAt: string = obsData?.[0]?.created_at ?? ""
527
- if (createdAt) {
528
- lastObsEpoch = toEpochSecs(createdAt)
529
- }
530
- }
531
- } catch {
532
- // Server unreachable or timed out — skip nudge
533
- return
534
- }
535
-
536
- // No observations yet — nothing to nudge about
537
- if (lastObsEpoch === 0) return
538
-
539
- // Only nudge if last save was more than 15 minutes ago
540
- if (nowSecs - lastObsEpoch < 900) return
541
-
542
- // Append the nudge to the last system message
543
- const nudge =
544
- "\n\nMEMORY REMINDER: It's been over 15 minutes since your last memory save. " +
545
- "If you've made decisions, discoveries, completed significant work, or found non-obvious things, " +
546
- "call mem_save now."
547
- if (output.system.length > 0) {
548
- output.system[output.system.length - 1] += nudge
549
- } else {
550
- output.system.push(nudge)
551
- }
552
- lastNudgeTime.set(sessionID, nowSecs)
553
- } catch {
554
- // Any unexpected error — silently skip the nudge, never crash the hook
555
- }
556
- },
557
-
558
- // ─── Compaction Hook: Persist memory + inject context ──────────
559
- // Compaction is triggered by the system (not the agent) when context
560
- // gets too long. The old agent "dies" and a new one starts with the
561
- // compacted summary. This is our chance to:
562
- // 1. Auto-save a session checkpoint (the agent can't do this itself)
563
- // 2. Inject context from previous sessions into the compaction prompt
564
- // 3. Tell the compressor to remind the new agent to save memories
565
-
566
- "experimental.session.compacting": async (input, output) => {
567
- if (input.sessionID) {
568
- await ensureSession(input.sessionID)
569
- }
570
-
571
- // C3: Compaction fallback file — write directly to same anchor as controller's get_handoff
572
- // Resolves statePath from opencode.json (local) or global config, default .opencode/ostacky-state.json
573
- try {
574
- let statePath: string | null = null
575
- // 1) env var if set
576
- if (process.env.OSTACKY_STATE_PATH) {
577
- statePath = process.env.OSTACKY_STATE_PATH
578
- }
579
- // 2) try local opencode.json / jsonc in project
580
- if (!statePath) {
581
- const candidates = [join(ctx.directory, "opencode.json"), join(ctx.directory, "opencode.jsonc")]
582
- // also try global config (XDG / APPDATA)
583
- try {
584
- const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
585
- if (home) {
586
- const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config")
587
- candidates.push(join(xdg, "opencode", "opencode.json"))
588
- candidates.push(join(xdg, "opencode", "opencode.jsonc"))
589
- if (process.platform === "win32" && process.env.APPDATA) {
590
- candidates.push(join(process.env.APPDATA, "opencode", "opencode.json"))
591
- candidates.push(join(process.env.APPDATA, "opencode", "opencode.jsonc"))
592
- }
593
- }
594
- } catch {}
595
- for (const cand of candidates) {
596
- try {
597
- const raw = readFileSync(cand, "utf-8")
598
- const j = stripJsoncComments(raw)
599
- const cfg = JSON.parse(j)
600
- const envPath = (cfg as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
601
- if (typeof envPath === "string" && envPath) {
602
- statePath = envPath
603
- break
604
- }
605
- } catch {}
606
- }
607
- }
608
- if (!statePath) statePath = join(ctx.directory, ".opencode", "ostacky-state.json")
609
- const fallbackPath = join(dirname(statePath), ".ostacky-handoff-compaction.json")
610
- try { mkdirSync(dirname(fallbackPath), { recursive: true }) } catch {}
611
- const payload = {
612
- summary: `Compaction fallback for session ${input.sessionID ?? "unknown"} — project ${project}`,
613
- nextSteps: [] as string[],
614
- pendingTasks: [] as string[],
615
- ts: Date.now(),
616
- contextSnippet: output.context?.slice(0, 2).join("\n\n").slice(0, 1000) ?? "",
617
- }
618
- const tmp = `${fallbackPath}.tmp.${process.pid}`
619
- writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
620
- renameSync(tmp, fallbackPath)
621
- } catch {
622
- // fallback is best-effort — never crash compacting
623
- }
624
-
625
- // Inject context from previous sessions
626
- const data = await engramFetch(
627
- `/context?project=${encodeURIComponent(project)}`
628
- )
629
- if (data?.context) {
630
- output.context.push(data.context)
631
- }
632
-
633
- // Tell the compressor to instruct the new agent to persist the
634
- // compacted summary to Engram. The new agent reads the compacted
635
- // summary and this instruction, then saves it as a session summary.
636
- output.context.push(
637
- `CRITICAL INSTRUCTION FOR COMPACTED SUMMARY:\n` +
638
- `The agent has access to Engram persistent memory via MCP tools.\n` +
639
- `You MUST include the following instruction at the TOP of the compacted summary:\n\n` +
640
- `"FIRST ACTION REQUIRED: Call mem_session_summary with the content of this compacted summary. ` +
641
- `Use project: '${project}'. This preserves what was accomplished before compaction. Do this BEFORE any other work."\n\n` +
642
- `This is NOT optional. Without this, everything done before compaction is lost from memory.`
643
- )
644
- },
645
- }
646
- }
1
+ /**
2
+ * Engram — OpenCode plugin adapter (OpenCode V2)
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 (event.sessionID) rather than relying on a session.created event.
15
+ */
16
+
17
+ import { Plugin } from "@opencode/plugin"
18
+ import { join, dirname, basename, delimiter } from "node:path"
19
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs"
20
+ import { spawn, spawnSync } from "node:child_process"
21
+
22
+ // ─── Configuration ───────────────────────────────────────────────────────────
23
+
24
+ const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
25
+ const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
26
+ // C3/H2 fix: resolve ENGRAM_BIN per directory with win32 .exe and absolute fallback
27
+ function lookupOnPath(name: string): string | null {
28
+ try {
29
+ const pathEnv = process.env.PATH ?? ""
30
+ const suffix = process.platform === "win32" ? ".exe" : ""
31
+ for (const dir of pathEnv.split(delimiter)) {
32
+ if (!dir) continue
33
+ try {
34
+ const cand = join(dir, name + suffix)
35
+ if (existsSync(cand)) return cand
36
+ if (suffix && existsSync(join(dir, name))) return join(dir, name)
37
+ } catch {}
38
+ }
39
+ } catch {}
40
+ return null
41
+ }
42
+ function resolveEngramBin(directory: string): string {
43
+ if (process.env.ENGRAM_BIN) {
44
+ const p = process.env.ENGRAM_BIN
45
+ const isAbs = p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)
46
+ return isAbs ? p : join(directory, p)
47
+ }
48
+ const which = lookupOnPath("engram")
49
+ if (which) return which
50
+ const suffix = process.platform === "win32" ? ".exe" : ""
51
+ return join(directory, ".opencode", "tools", "engram", "bin", `engram${suffix}`)
52
+ }
53
+ // ENGRAM_BIN eliminado: reemplazado por resolveEngramBin(directory) que maneja .exe+absolutización correctamente
54
+
55
+ // Engram's own MCP tools — don't count these as "tool calls" for session stats
56
+ const ENGRAM_TOOLS = new Set([
57
+ "mem_search",
58
+ "mem_save",
59
+ "mem_update",
60
+ "mem_delete",
61
+ "mem_suggest_topic_key",
62
+ "mem_save_prompt",
63
+ "mem_session_summary",
64
+ "mem_context",
65
+ "mem_stats",
66
+ "mem_timeline",
67
+ "mem_get_observation",
68
+ "mem_session_start",
69
+ "mem_session_end",
70
+ ])
71
+
72
+ // ─── Memory Instructions ─────────────────────────────────────────────────────
73
+ // Lazy: full protocol lives in assets/docs/engram-protocol.md (on-demand via Read).
74
+ // System injects only pointer + nudge, not full 1.2k. Source-of-truth: src/tiered.ts for isTrivial.
75
+
76
+ const MEMORY_POINTER = "Engram disponible — para formato mem_save/mem_search lee assets/docs/engram-protocol.md (on-demand). Usa mem_search proactivamente si el tema pudo verse antes."
77
+ const MEMORY_INSTRUCTIONS_LAZY = `## Engram — pointer (lazy)
78
+
79
+ ${MEMORY_POINTER}
80
+
81
+ Cuando necesites guardar/buscar, lee el protocolo completo con Read. No alucines formato.`
82
+ // Compat: keep full for fallback if file missing — but never inject full in system.transform
83
+ const MEMORY_INSTRUCTIONS = MEMORY_INSTRUCTIONS_LAZY
84
+
85
+ // ─── HTTP Client ─────────────────────────────────────────────────────────────
86
+
87
+ async function engramFetch(
88
+ path: string,
89
+ opts: { method?: string; body?: any } = {}
90
+ ): Promise<any> {
91
+ try {
92
+ const res = await fetch(`${ENGRAM_URL}${path}`, {
93
+ method: opts.method ?? "GET",
94
+ headers: opts.body ? { "Content-Type": "application/json" } : undefined,
95
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
96
+ })
97
+ return await res.json()
98
+ } catch {
99
+ // Engram server not running — silently fail
100
+ return null
101
+ }
102
+ }
103
+
104
+ async function isEngramRunning(): Promise<boolean> {
105
+ try {
106
+ const res = await fetch(`${ENGRAM_URL}/health`, {
107
+ signal: AbortSignal.timeout(500),
108
+ })
109
+ return res.ok
110
+ } catch {
111
+ return false
112
+ }
113
+ }
114
+
115
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
116
+
117
+ function extractProjectName(directory: string): string {
118
+ // Try git remote origin URL
119
+ try {
120
+ const result = spawnSync("git", ["-C", directory, "remote", "get-url", "origin"], { encoding: "utf-8" })
121
+ if (result.status === 0) {
122
+ const url = (result.stdout ?? "").trim()
123
+ if (url) {
124
+ const name = url.replace(/\.git$/, "").split(/[/:]/).pop()
125
+ if (name) return name
126
+ }
127
+ }
128
+ } catch {}
129
+
130
+ // Fallback: git root directory name (works in worktrees)
131
+ try {
132
+ const result = spawnSync("git", ["-C", directory, "rev-parse", "--show-toplevel"], { encoding: "utf-8" })
133
+ if (result.status === 0) {
134
+ const root = (result.stdout ?? "").trim()
135
+ if (root) return basename(root.replace(/\\/g, "/")) ?? "unknown"
136
+ }
137
+ } catch {}
138
+
139
+ // Final fallback: cwd basename (cross-platform)
140
+ return basename(directory.replace(/\\/g, "/")) ?? "unknown"
141
+ }
142
+
143
+ function truncate(str: string, max: number): string {
144
+ if (!str) return ""
145
+ return str.length > max ? str.slice(0, max) + "..." : str
146
+ }
147
+
148
+ /**
149
+ * Strip <private>...</private> tags before sending to engram.
150
+ * Double safety: the Go binary also strips, but we strip here too
151
+ * so sensitive data never even hits the wire.
152
+ */
153
+ function stripPrivateTags(str: string): string {
154
+ if (!str) return ""
155
+ return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
156
+ }
157
+
158
+ function stripJsoncComments(text: string): string {
159
+ let result = ""
160
+ let i = 0
161
+ let inString = false
162
+ while (i < text.length) {
163
+ const char = text[i]
164
+ const next = text[i + 1]
165
+ if (inString) {
166
+ if (char === "\\") {
167
+ result += char + (next ?? "")
168
+ i += 2
169
+ continue
170
+ }
171
+ if (char === '"') inString = false
172
+ result += char
173
+ i++
174
+ continue
175
+ }
176
+ if (char === '"') {
177
+ inString = true
178
+ result += char
179
+ i++
180
+ continue
181
+ }
182
+ if (char === "/" && next === "/") {
183
+ while (i < text.length && text[i] !== "\n") i++
184
+ continue
185
+ }
186
+ if (char === "/" && next === "*") {
187
+ i += 2
188
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
189
+ i += 2
190
+ continue
191
+ }
192
+ result += char
193
+ i++
194
+ }
195
+ return result.replace(/,\s*([}\]])/g, "$1")
196
+ }
197
+
198
+ // ─── Plugin ──────────────────────────────────────────────────────────────────
199
+
200
+ export default Plugin.define({
201
+ id: "engram",
202
+ async setup(ctx) {
203
+ const directory = ctx.location.directory
204
+ // T4: basename multiplataforma — split("/") producía keys basura con backslashes en Windows nativo
205
+ const oldProject = basename(directory.replace(/\\/g, "/")) ?? "unknown"
206
+ const project = extractProjectName(directory)
207
+
208
+ // Track tool counts per session (in-memory only, not critical)
209
+ const toolCounts = new Map<string, number>()
210
+
211
+ // Track last nudge time per session to debounce save reminders
212
+ const lastNudgeTime = new Map<string, number>() // sessionID -> epoch seconds
213
+
214
+ // Track which sessions we've already ensured exist in engram
215
+ const knownSessions = new Set<string>()
216
+
217
+ // Track sub-agent session IDs so we can suppress their tool-hook registrations.
218
+ // Sub-agents (subagent tool calls) have a parentID or a title ending in " subagent)".
219
+ // We must not register them as top-level Engram sessions — they cause session
220
+ // inflation (e.g. 170 sessions for 1 real conversation, issue #116).
221
+ const subAgentSessions = new Set<string>()
222
+
223
+ // Tiered cache-friendly: single source of truth via src/tiered.ts
224
+ const trivialBySession = new Map<string, boolean>()
225
+ // isTrivial y getControllerState importados lógicamente desde src/tiered.ts
226
+ // Inlined para evitar import dinámico en plugin bundle — mantener regex idéntico a src/tiered.ts
227
+ function isTrivialMessage(msg: string, state: string): boolean {
228
+ if (!msg || state !== "DONE") return false
229
+ if (msg.trim().length >= 30) return false
230
+ if (!/^(hola|hey|gracias|buenas|hi|hello)\b/i.test(msg.trim())) return false
231
+ if (/(necesito|quiero|agregá|fix|bug|feature|auth|spec|implementar)/i.test(msg)) return false
232
+ return true
233
+ }
234
+ function getControllerState(dir: string): string {
235
+ try {
236
+ const statePath = process.env.OSTACKY_STATE_PATH || join(dir, ".opencode", "ostacky-state.json")
237
+ const raw = readFileSync(statePath, "utf-8")
238
+ const j = JSON.parse(raw)
239
+ return j.state ?? "DONE"
240
+ } catch { return "DONE" }
241
+ }
242
+
243
+ /**
244
+ * Ensure a session exists in engram. Idempotent — calls POST /sessions
245
+ * which uses INSERT OR IGNORE. Safe to call multiple times.
246
+ *
247
+ * Silently skips sub-agent sessions (tracked in `subAgentSessions`).
248
+ */
249
+ async function ensureSession(sessionId: string): Promise<void> {
250
+ if (!sessionId || knownSessions.has(sessionId)) return
251
+ // Do not register sub-agent sessions in Engram (issue #116).
252
+ if (subAgentSessions.has(sessionId)) return
253
+ knownSessions.add(sessionId)
254
+ await engramFetch("/sessions", {
255
+ method: "POST",
256
+ body: {
257
+ id: sessionId,
258
+ project,
259
+ directory,
260
+ },
261
+ })
262
+ }
263
+
264
+ // Try to start engram server if not running — use per-directory resolved bin (win32 .exe + absolute)
265
+ const engramBin = resolveEngramBin(directory)
266
+ const running = await isEngramRunning()
267
+ if (!running) {
268
+ try {
269
+ const child = spawn(engramBin, ["serve"], {
270
+ detached: true,
271
+ stdio: "ignore",
272
+ cwd: directory,
273
+ })
274
+ child.unref()
275
+ await new Promise((r) => setTimeout(r, 500))
276
+ } catch {
277
+ // Binary not found or can't start — plugin will silently no-op
278
+ }
279
+ }
280
+
281
+ // Migrate project name if it changed (one-time, idempotent)
282
+ // Must run AFTER server startup to ensure the endpoint is available
283
+ if (oldProject !== project) {
284
+ await engramFetch("/projects/migrate", {
285
+ method: "POST",
286
+ body: { old_project: oldProject, new_project: project },
287
+ })
288
+ }
289
+
290
+ // Auto-import: if .engram/manifest.json exists in the project repo,
291
+ // run `engram sync --import` to load any new chunks into the local DB.
292
+ // This is how git-synced memories get loaded when cloning a repo or
293
+ // pulling changes. Each chunk is imported only once (tracked by ID).
294
+ try {
295
+ const manifestFile = join(directory, ".engram", "manifest.json")
296
+ if (existsSync(manifestFile)) {
297
+ const child = spawn(engramBin, ["sync", "--import"], {
298
+ detached: true,
299
+ stdio: "ignore",
300
+ cwd: directory,
301
+ })
302
+ child.unref()
303
+ }
304
+ } catch {
305
+ // Manifest doesn't exist or binary not found — silently skip
306
+ }
307
+
308
+ // ─── Event subscription (session lifecycle) ──────────────────────
309
+ const eventController = new AbortController()
310
+ void (async () => {
311
+ for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
312
+ // --- Session Created ---
313
+ if (event.type === "session.created") {
314
+ // V2: session data is flat under event.data (id/parentID/title),
315
+ // not nested under properties.info like V1.
316
+ const data = event.data as { sessionID?: string; parentID?: string | null; title?: string }
317
+ const sessionId = data?.sessionID
318
+ const parentID = data?.parentID
319
+ const title: string = data?.title ?? ""
320
+
321
+ // Sub-agent sessions (created via subagent tool) must NOT be registered as
322
+ // top-level Engram sessions. They cause massive session inflation
323
+ // (e.g. 170 sessions for 1 real conversation).
324
+ //
325
+ // Detection heuristics:
326
+ // - parentID is set on all sub-agent sessions
327
+ // - title ends with " subagent)" as a secondary signal
328
+ const isSubAgent = !!parentID || title.endsWith(" subagent)")
329
+
330
+ if (sessionId && !isSubAgent) {
331
+ await ensureSession(sessionId)
332
+ } else if (sessionId && isSubAgent) {
333
+ // Remember this as a sub-agent session so tool-hook calls
334
+ // to ensureSession() are also suppressed for it.
335
+ subAgentSessions.add(sessionId)
336
+ }
337
+ }
338
+
339
+ // --- Session Deleted ---
340
+ if (event.type === "session.deleted") {
341
+ // V2: event.data.sessionID (flat), not properties.info.id.
342
+ const sessionId = (event.data as { sessionID?: string })?.sessionID
343
+ if (sessionId) {
344
+ toolCounts.delete(sessionId)
345
+ knownSessions.delete(sessionId)
346
+ subAgentSessions.delete(sessionId)
347
+ lastNudgeTime.delete(sessionId)
348
+ }
349
+ }
350
+ }
351
+ })()
352
+
353
+ // ─── User Prompt Capture ──────────────────────────────────────
354
+ // session prompt hook runs once per user message at admission, before the LLM sees it.
355
+ // event.sessionID is always reliable here. event.prompt.text holds the message text.
356
+
357
+ await ctx.session.hook("prompt", async (event) => {
358
+ // Skip sub-agent sessions — they inflate session counts (issue #116)
359
+ if (subAgentSessions.has(event.sessionID)) return
360
+
361
+ const sessionId = event.sessionID
362
+
363
+ // Prompt text is the source (V2 prompt admission carries the full text)
364
+ const finalContent = (event.prompt.text ?? "").trim()
365
+
366
+ // Tiered: set trivial flag for context-hook pointer selection
367
+ try {
368
+ const state = getControllerState(directory)
369
+ trivialBySession.set(sessionId, isTrivialMessage(finalContent, state))
370
+ } catch {}
371
+
372
+ // Only capture non-trivial prompts (>10 chars)
373
+ if (finalContent.length > 10) {
374
+ await ensureSession(sessionId)
375
+ await engramFetch("/prompts", {
376
+ method: "POST",
377
+ body: {
378
+ session_id: sessionId,
379
+ content: stripPrivateTags(truncate(finalContent, 2000)),
380
+ project,
381
+ },
382
+ })
383
+ }
384
+ })
385
+
386
+ // ─── Tool Execution Hook ─────────────────────────────────────
387
+ // Count tool calls per session (for session end stats).
388
+ // Also ensures the session exists — handles plugin reload / reconnect.
389
+ // Passive capture: when a subagent tool completes, POST its output to
390
+ // the passive capture endpoint so the server extracts learnings.
391
+
392
+ await ctx.tool.hook("execute.after", async (event) => {
393
+ if (ENGRAM_TOOLS.has(event.tool.toLowerCase())) return
394
+
395
+ // event.sessionID comes from OpenCode — always available
396
+ const sessionId = event.sessionID
397
+ if (sessionId) {
398
+ await ensureSession(sessionId)
399
+ toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1)
400
+ }
401
+
402
+ // Passive capture: extract learnings from subagent tool output
403
+ // (V2 tool name is "subagent"; V1 "Task" no longer exists — see v2/docs/tools)
404
+ if (event.tool === "subagent" && event.status === "completed" && event.result && sessionId) {
405
+ const text = JSON.stringify(event.result)
406
+ if (text.length > 50) {
407
+ await engramFetch("/observations/passive", {
408
+ method: "POST",
409
+ body: {
410
+ session_id: sessionId,
411
+ content: stripPrivateTags(text),
412
+ project,
413
+ source: "task-complete",
414
+ },
415
+ })
416
+ }
417
+ }
418
+ })
419
+
420
+ // ─── System Pointer: Always-on memory instructions ──────────
421
+ // Injects the lazy pointer into model-visible context before each agent request.
422
+ // This ensures the agent ALWAYS knows about Engram, even after compaction.
423
+ //
424
+ // We append to system (not a new message) so prompt caching stays stable.
425
+ // Tiered lazy: SIEMPRE pointer (cache-friendly, ~1 línea). Full vive en assets/docs/engram-protocol.md on-demand.
426
+
427
+ await ctx.session.hook("context", async (event) => {
428
+ const sessionId: string = event.sessionID ?? ""
429
+ const isTrivial = trivialBySession.get(sessionId) ?? false
430
+ const state = getControllerState(directory)
431
+ const shouldBeTrivial = isTrivial && state === "DONE"
432
+ const pointer = shouldBeTrivial
433
+ ? "Engram disponible — detalles a demanda (usa mem_search si necesitas recordar)."
434
+ : MEMORY_POINTER
435
+ event.system.push({ type: "text", text: pointer })
436
+ // No inyectar MEMORY_INSTRUCTIONS completo nunca — se lee on-demand via Read
437
+
438
+ // harden-compaction-resume: auto-inject recovery hint when pending (no depende del modelo)
439
+ if (!shouldBeTrivial) {
440
+ try {
441
+ const statePath = process.env.OSTACKY_STATE_PATH || join(directory, ".opencode", "ostacky-state.json")
442
+ const raw = readFileSync(statePath, "utf-8")
443
+ const st = JSON.parse(raw)
444
+ const curState = st?.state ?? "DONE"
445
+ if (!["DONE", "INTERPRETATION_PENDING"].includes(curState)) {
446
+ let pending: string[] = Array.isArray(st?.lastHandoff?.pendingTasks)
447
+ ? st.lastHandoff.pendingTasks.filter((id: string) => !st.tasks?.[id] || st.tasks[id].status !== "COMPLETED")
448
+ : []
449
+ if (pending.length === 0) {
450
+ try {
451
+ const fbPath = join(dirname(statePath), ".ostacky-handoff-compaction.json")
452
+ const fbRaw = readFileSync(fbPath, "utf-8")
453
+ const fb = JSON.parse(fbRaw)
454
+ if (fb && Array.isArray(fb.pendingTasks) && typeof fb.ts === "number" && Date.now() - fb.ts < 24 * 60 * 60 * 1000) {
455
+ const fbPend = fb.pendingTasks.filter((id: string) => !st.tasks?.[id] || st.tasks[id].status !== "COMPLETED")
456
+ if (fbPend.length > 0) pending = fbPend
457
+ }
458
+ } catch {}
459
+ }
460
+ if (pending.length > 0) {
461
+ const hint = `[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
462
+ event.system.push({ type: "text", text: hint })
463
+ }
464
+ }
465
+ } catch {}
466
+ }
467
+
468
+ // ── Save nudge ──────────────────────────────────────────────────────────
469
+ // Skip nudge for trivial greeting (cache-friendly, no extra injection)
470
+ if (shouldBeTrivial) return
471
+ // If it has been a long time since the last mem_save, append a reminder
472
+ // to the system context so the agent notices. All fetches are fire-and-
473
+ // forget with short timeouts — any failure silently skips the nudge.
474
+ try {
475
+ if (!sessionId || subAgentSessions.has(sessionId)) return
476
+
477
+ // SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" in UTC with no
478
+ // zone suffix; new Date() would parse that as local time. Normalize to
479
+ // UTC first so the thresholds are correct in every timezone.
480
+ const toEpochSecs = (ts: string): number => {
481
+ if (!ts) return 0
482
+ const normalized = ts.includes("T") ? ts : ts.replace(" ", "T") + "Z"
483
+ const ms = new Date(normalized).getTime()
484
+ return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
485
+ }
486
+
487
+ const cooldownSecs = parseInt(process.env.ENGRAM_NUDGE_COOLDOWN_SECS ?? "900", 10)
488
+ const nowSecs = Math.floor(Date.now() / 1000)
489
+
490
+ // Debounce: skip if we nudged recently this session
491
+ const lastNudge = lastNudgeTime.get(sessionId)
492
+ if (lastNudge !== undefined && nowSecs - lastNudge < cooldownSecs) return
493
+
494
+ // Skip if the session is too young (< 5 minutes)
495
+ let sessionStartEpoch = 0
496
+ try {
497
+ const sessionRes = await fetch(`${ENGRAM_URL}/sessions/${encodeURIComponent(sessionId)}`, {
498
+ signal: AbortSignal.timeout(200),
499
+ })
500
+ if (sessionRes.ok) {
501
+ const sessionData = await sessionRes.json()
502
+ const startedAt: string = sessionData?.started_at ?? ""
503
+ if (startedAt) {
504
+ sessionStartEpoch = toEpochSecs(startedAt)
505
+ }
506
+ }
507
+ } catch {
508
+ // Server unreachable or timed out — skip nudge
509
+ return
510
+ }
511
+ if (sessionStartEpoch > 0 && nowSecs - sessionStartEpoch < 300) return
512
+
513
+ // Check when the last observation was saved for this project
514
+ let lastObsEpoch = 0
515
+ try {
516
+ const obsRes = await fetch(
517
+ `${ENGRAM_URL}/observations?project=${encodeURIComponent(project)}&limit=1&sort=created_at:desc`,
518
+ { signal: AbortSignal.timeout(200) }
519
+ )
520
+ if (obsRes.ok) {
521
+ const obsData = await obsRes.json()
522
+ const createdAt: string = obsData?.[0]?.created_at ?? ""
523
+ if (createdAt) {
524
+ lastObsEpoch = toEpochSecs(createdAt)
525
+ }
526
+ }
527
+ } catch {
528
+ // Server unreachable or timed out — skip nudge
529
+ return
530
+ }
531
+
532
+ // No observations yet — nothing to nudge about
533
+ if (lastObsEpoch === 0) return
534
+
535
+ // Only nudge if last save was more than 15 minutes ago
536
+ if (nowSecs - lastObsEpoch < 900) return
537
+
538
+ // Append the nudge to system context
539
+ const nudge =
540
+ "MEMORY REMINDER: It's been over 15 minutes since your last memory save. " +
541
+ "If you've made decisions, discoveries, completed significant work, or found non-obvious things, " +
542
+ "call mem_save now."
543
+ event.system.push({ type: "text", text: nudge })
544
+ lastNudgeTime.set(sessionId, nowSecs)
545
+ } catch {
546
+ // Any unexpected error — silently skip the nudge, never crash the hook
547
+ }
548
+ })
549
+
550
+ // ─── Compaction Hook: Persist memory + inject context ──────────
551
+ // Compaction is triggered by the system (not the agent) when context
552
+ // gets too long. The old agent "dies" and a new one starts with the
553
+ // compacted summary. This is our chance to:
554
+ // 1. Auto-save a session checkpoint (the agent can't do this itself)
555
+ // 2. Inject context from previous sessions into the compaction prompt
556
+ // 3. Tell the compressor to remind the new agent to save memories
557
+
558
+ await ctx.session.hook("compaction", async (event) => {
559
+ if (event.sessionID) {
560
+ await ensureSession(event.sessionID)
561
+ }
562
+
563
+ // C3: Compaction fallback file — write directly to same anchor as controller's get_handoff
564
+ // Resolves statePath from opencode.json (local) or global config, default .opencode/ostacky-state.json
565
+ try {
566
+ let statePath: string | null = null
567
+ // 1) env var if set
568
+ if (process.env.OSTACKY_STATE_PATH) {
569
+ statePath = process.env.OSTACKY_STATE_PATH
570
+ }
571
+ // 2) try local opencode.json / jsonc in project
572
+ if (!statePath) {
573
+ const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
574
+ // also try global config (XDG / APPDATA)
575
+ try {
576
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
577
+ if (home) {
578
+ const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config")
579
+ candidates.push(join(xdg, "opencode", "opencode.json"))
580
+ candidates.push(join(xdg, "opencode", "opencode.jsonc"))
581
+ if (process.platform === "win32" && process.env.APPDATA) {
582
+ candidates.push(join(process.env.APPDATA, "opencode", "opencode.json"))
583
+ candidates.push(join(process.env.APPDATA, "opencode", "opencode.jsonc"))
584
+ }
585
+ }
586
+ } catch {}
587
+ for (const cand of candidates) {
588
+ try {
589
+ const raw = readFileSync(cand, "utf-8")
590
+ const j = stripJsoncComments(raw)
591
+ const cfg = JSON.parse(j)
592
+ const envPath = (cfg as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
593
+ ?? (cfg as any)?.mcp?.servers?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
594
+ if (typeof envPath === "string" && envPath) {
595
+ statePath = envPath
596
+ break
597
+ }
598
+ } catch {}
599
+ }
600
+ }
601
+ if (!statePath) statePath = join(directory, ".opencode", "ostacky-state.json")
602
+ const fallbackPath = join(dirname(statePath), ".ostacky-handoff-compaction.json")
603
+ try { mkdirSync(dirname(fallbackPath), { recursive: true }) } catch {}
604
+ const contextSnippet = (() => {
605
+ try {
606
+ return JSON.stringify(event.messages.slice(0, 2)).slice(0, 1000)
607
+ } catch { return "" }
608
+ })()
609
+ const payload = {
610
+ summary: `Compaction fallback for session ${event.sessionID ?? "unknown"} — project ${project}`,
611
+ nextSteps: [] as string[],
612
+ pendingTasks: [] as string[],
613
+ ts: Date.now(),
614
+ contextSnippet,
615
+ }
616
+ const tmp = `${fallbackPath}.tmp.${process.pid}`
617
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
618
+ renameSync(tmp, fallbackPath)
619
+ } catch {
620
+ // fallback is best-effort — never crash compacting
621
+ }
622
+
623
+ // Inject context from previous sessions into the summarizer's system
624
+ const data = await engramFetch(
625
+ `/context?project=${encodeURIComponent(project)}`
626
+ )
627
+ if (data?.context) {
628
+ event.system.push({ type: "text", text: data.context })
629
+ }
630
+
631
+ // Tell the compressor to instruct the new agent to persist the
632
+ // compacted summary to Engram. The new agent reads the compacted
633
+ // summary and this instruction, then saves it as a session summary.
634
+ event.system.push({
635
+ type: "text",
636
+ text:
637
+ `CRITICAL INSTRUCTION FOR COMPACTED SUMMARY:\n` +
638
+ `The agent has access to Engram persistent memory via MCP tools.\n` +
639
+ `You MUST include the following instruction at the TOP of the compacted summary:\n\n` +
640
+ `"FIRST ACTION REQUIRED: Call mem_session_summary with the content of this compacted summary. ` +
641
+ `Use project: '${project}'. This preserves what was accomplished before compaction. Do this BEFORE any other work."\n\n` +
642
+ `This is NOT optional. Without this, everything done before compaction is lost from memory.`,
643
+ })
644
+ })
645
+
646
+ return () => eventController.abort()
647
+ },
648
+ })