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.
- package/README.md +9 -9
- package/assets/agents/ostacky.md +311 -310
- package/assets/commands/install-stack.md +510 -536
- package/assets/commands/opsx-sync.md +26 -26
- package/assets/mcp/openspec/index.js +1 -1
- package/assets/mcp/openspec/package.json +1 -1
- package/assets/mcp/ostacky-controller/index.js +989 -994
- package/assets/mcp/ostacky-controller/package.json +10 -10
- package/assets/plugins/engram.ts +537 -537
- package/assets/skills/brainstorming/SKILL.md +197 -197
- package/assets/skills/dispatching-parallel-agents/SKILL.md +182 -182
- package/assets/skills/execution-mode-evaluation/SKILL.md +189 -189
- package/assets/skills/graceful-degradation/SKILL.md +235 -235
- package/assets/skills/openspec-apply-change/SKILL.md +167 -167
- package/assets/skills/openspec-archive-change/SKILL.md +114 -114
- package/assets/skills/openspec-explore/SKILL.md +288 -288
- package/assets/skills/openspec-propose/SKILL.md +114 -114
- package/assets/skills/receiving-code-review/SKILL.md +213 -213
- package/assets/skills/review/SKILL.md +216 -216
- package/assets/skills/subagent-driven-development/SKILL.md +302 -302
- package/assets/skills/tdd/SKILL.md +371 -371
- package/assets/skills/using-git-worktrees/SKILL.md +212 -212
- package/assets/skills/using-superpowers/SKILL.md +111 -111
- package/assets/skills/writing-plans/SKILL.md +149 -149
- package/assets/skills/writing-skills/SKILL.md +655 -655
- package/dist/cli.js +2363 -2001
- package/manifest.json +158 -158
- package/package.json +1 -1
package/assets/plugins/engram.ts
CHANGED
|
@@ -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
|
+
}
|