jorgex-stack 1.9.57 → 1.9.59
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 +12 -2
- package/dist/chunk-GOLJ5PA3.js +711 -0
- package/dist/chunk-KB5IQEKL.js +1400 -0
- package/dist/chunk-M6QX2F3A.js +782 -0
- package/dist/{chunk-IO2FFZOH.js → chunk-N5IC2JBJ.js} +0 -29
- package/dist/chunk-NNPFC6QV.js +563 -0
- package/dist/chunk-NQZVJFV2.js +33 -0
- package/dist/claude-code-WY4JJAXP.js +11 -0
- package/dist/cli.js +880 -2725
- package/dist/codex-WSWHXYJ3.js +11 -0
- package/dist/opencode-NUDGK6J7.js +31 -0
- package/dist/quality-verifier.js +4 -2
- package/package.json +1 -1
- package/stack/plugins/opencode/package.json +1 -1
- package/upstreams.json +22 -15
- package/stack/plugins/opencode/engram.ts +0 -444
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
checkOpencodeDuplicates,
|
|
4
|
+
checkOpencodeOfficialMcp,
|
|
5
|
+
checkOpencodeOfficialStatusline,
|
|
6
|
+
collectUnverifiableOpencodeConfigs,
|
|
7
|
+
inspectOpencodeEngramPlugin,
|
|
8
|
+
inspectOpencodePluginFile,
|
|
9
|
+
isOfficialOpencodePluginContent,
|
|
10
|
+
opencodeAdapter,
|
|
11
|
+
readOpencodeConfigFile,
|
|
12
|
+
shouldRetireLegacyEngram,
|
|
13
|
+
transferEngramOwnership,
|
|
14
|
+
verifyOfficialSetup
|
|
15
|
+
} from "./chunk-M6QX2F3A.js";
|
|
16
|
+
import "./chunk-KB5IQEKL.js";
|
|
17
|
+
import "./chunk-NQZVJFV2.js";
|
|
18
|
+
export {
|
|
19
|
+
checkOpencodeDuplicates,
|
|
20
|
+
checkOpencodeOfficialMcp,
|
|
21
|
+
checkOpencodeOfficialStatusline,
|
|
22
|
+
collectUnverifiableOpencodeConfigs,
|
|
23
|
+
inspectOpencodeEngramPlugin,
|
|
24
|
+
inspectOpencodePluginFile,
|
|
25
|
+
isOfficialOpencodePluginContent,
|
|
26
|
+
opencodeAdapter,
|
|
27
|
+
readOpencodeConfigFile,
|
|
28
|
+
shouldRetireLegacyEngram,
|
|
29
|
+
transferEngramOwnership,
|
|
30
|
+
verifyOfficialSetup
|
|
31
|
+
};
|
package/dist/quality-verifier.js
CHANGED
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
QUALITY_PROFILES,
|
|
4
4
|
canonicalJson,
|
|
5
5
|
evaluateQualityPolicy,
|
|
6
|
-
samePath,
|
|
7
6
|
sha256,
|
|
8
7
|
validateQualityReceipt
|
|
9
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-N5IC2JBJ.js";
|
|
9
|
+
import {
|
|
10
|
+
samePath
|
|
11
|
+
} from "./chunk-NQZVJFV2.js";
|
|
10
12
|
|
|
11
13
|
// src/lib/quality-verifier.ts
|
|
12
14
|
import { createHash } from "crypto";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jorgex-stack",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.59",
|
|
4
4
|
"description": "Harness multi-agente portable: instala la config JorgeX (agentes, skills, hooks, Engram, MCPs) en Claude Code, Codex CLI, OpenCode y Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jorgex/opencode-plugins",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"description": "OpenCode plugins del stack:
|
|
4
|
+
"description": "OpenCode plugins del stack: hooks (puente de scripts por evento/tool) y worktree (gestión de git worktrees). Engram lo provee `engram setup opencode` (plugin oficial).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "hooks.ts",
|
|
7
7
|
"dependencies": {
|
package/upstreams.json
CHANGED
|
@@ -9,48 +9,55 @@
|
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
11
|
"complements": {
|
|
12
|
-
"$comment": "Complementos oficiales externos (ejecutan código en el runtime): pin
|
|
12
|
+
"$comment": "Complementos oficiales externos (ejecutan código en el runtime): `strategy` distingue `exact` (pin obligatorio, ausencia es error; al instalar se usa el pin, nunca latest ni rangos) de `provider-managed` (el provider gestiona la versión: rolling/null/main/latest aceptados sin warning por ausencia de pin; update --check informa la versión observada/drift sin bloquear). `reviewed` = última revisión aceptada; `update --check` es discovery-only, nunca auto-update; la adopción exact es re-pin deliberado con verificación.",
|
|
13
13
|
"gentle-engram": {
|
|
14
14
|
"source": "npm:gentle-engram",
|
|
15
|
-
"version": "0.1.
|
|
16
|
-
"
|
|
15
|
+
"version": "0.1.14",
|
|
16
|
+
"strategy": "provider-managed",
|
|
17
|
+
"reviewed": "2026-09-20",
|
|
17
18
|
"status": "pendiente-adopcion",
|
|
18
|
-
"note": "
|
|
19
|
+
"note": "publicado/latest en npm y usado por el setup oficial en main: provider gestiona la versión. Incluye auto-cierre en session_shutdown. Issue: #155"
|
|
19
20
|
},
|
|
20
21
|
"pi-mcp-adapter": {
|
|
21
22
|
"source": "npm:pi-mcp-adapter",
|
|
22
23
|
"version": null,
|
|
23
|
-
"
|
|
24
|
+
"strategy": "provider-managed",
|
|
25
|
+
"reviewed": "2026-09-20",
|
|
24
26
|
"status": "pendiente-pin",
|
|
25
|
-
"note": "upstream lo referencia sin versión (setup.go main)
|
|
27
|
+
"note": "provider-managed: upstream lo referencia sin versión (setup.go main); rolling aceptado, sin warning por ausencia de pin. Último observado latest 2.34.0 como referencia informativa, sin fijarlo. Issue: #155"
|
|
26
28
|
},
|
|
27
29
|
"engram-claude-plugin": {
|
|
28
30
|
"source": "github:Gentleman-Programming/engram#plugin/claude-code",
|
|
29
|
-
"version": "
|
|
30
|
-
"
|
|
31
|
-
"
|
|
31
|
+
"version": "2.0.0",
|
|
32
|
+
"strategy": "provider-managed",
|
|
33
|
+
"reviewed": "2026-09-20",
|
|
34
|
+
"note": "provider setup según upstream (2.0.0 solo referencia observada, no pin de instalación); autocierre vía hook Stop (v2.0.0) / SessionEnd (main). Stack convive (detección + allowlists duales); falta instalación/verificación por Stack. Issue: #155"
|
|
32
35
|
},
|
|
33
36
|
"engram-codex-plugin": {
|
|
34
37
|
"source": "github:Gentleman-Programming/engram#plugin/codex",
|
|
35
|
-
"version": "
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
+
"version": "main",
|
|
39
|
+
"strategy": "provider-managed",
|
|
40
|
+
"reviewed": "2026-09-20",
|
|
41
|
+
"note": "main rolling aprobado (provider-managed): autocierre vía hook Stop. Stack convive; falta instalación/verificación por Stack. Issue: #155"
|
|
38
42
|
},
|
|
39
43
|
"engram-opencode-plugin": {
|
|
40
44
|
"source": "github:Gentleman-Programming/engram#plugin/opencode",
|
|
41
|
-
"version": "
|
|
42
|
-
"
|
|
43
|
-
"
|
|
45
|
+
"version": "2.0.0",
|
|
46
|
+
"strategy": "provider-managed",
|
|
47
|
+
"reviewed": "2026-09-20",
|
|
48
|
+
"note": "provider setup según upstream (2.0.0 solo referencia observada, no pin de instalación); auto-cierre (endSessionInEngram) solo en main; la copia vendorizada de Stack no lo tiene — migrar con transferencia de ownership de plugins/engram.ts. Issue: #155"
|
|
44
49
|
},
|
|
45
50
|
"chrome-devtools-mcp": {
|
|
46
51
|
"source": "npm:chrome-devtools-mcp",
|
|
47
52
|
"version": "1.6.0",
|
|
53
|
+
"strategy": "exact",
|
|
48
54
|
"reviewed": "2026-09-17",
|
|
49
55
|
"note": "opt-in, default-off; flags --isolated --redact-network-headers --no-performance-crux --no-usage-statistics"
|
|
50
56
|
},
|
|
51
57
|
"playwright-cli": {
|
|
52
58
|
"source": "npm:@playwright/cli",
|
|
53
59
|
"version": "0.1.18",
|
|
60
|
+
"strategy": "exact",
|
|
54
61
|
"reviewed": "2026-09-17",
|
|
55
62
|
"note": "CLI global compartido por la máquina; camino recomendado opt-in"
|
|
56
63
|
}
|
|
@@ -1,444 +0,0 @@
|
|
|
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
|
-
declare const Bun: {
|
|
20
|
-
which?: (bin: string) => string | null
|
|
21
|
-
spawnSync: (args: string[], options?: Record<string, unknown>) => { exitCode: number; stdout?: { toString(): string } | string }
|
|
22
|
-
spawn: (args: string[], options?: Record<string, unknown>) => unknown
|
|
23
|
-
file: (path: string) => { exists: () => Promise<boolean> }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
|
|
29
|
-
const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
|
|
30
|
-
// "{{ENGRAM_BIN}}" lo resuelve el instalador con el binario detectado (D7).
|
|
31
|
-
const ENGRAM_BIN = "{{ENGRAM_BIN}}"
|
|
32
|
-
|
|
33
|
-
export function resolveEngramBin(installerBin = ENGRAM_BIN): string {
|
|
34
|
-
const envBin = process.env.ENGRAM_BIN
|
|
35
|
-
if (envBin) return envBin
|
|
36
|
-
|
|
37
|
-
const bun = globalThis as typeof globalThis & { Bun?: { which?: (bin: string) => string | null } }
|
|
38
|
-
const bunBin = bun.Bun?.which?.("engram")
|
|
39
|
-
if (bunBin) return bunBin
|
|
40
|
-
|
|
41
|
-
return installerBin !== "{{ENGRAM_BIN}}" ? installerBin : "engram"
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Engram's own MCP tools — don't count these as "tool calls" for session stats
|
|
45
|
-
const ENGRAM_TOOLS = new Set([
|
|
46
|
-
"mem_search",
|
|
47
|
-
"mem_save",
|
|
48
|
-
"mem_update",
|
|
49
|
-
"mem_delete",
|
|
50
|
-
"mem_suggest_topic_key",
|
|
51
|
-
"mem_save_prompt",
|
|
52
|
-
"mem_session_summary",
|
|
53
|
-
"mem_context",
|
|
54
|
-
"mem_stats",
|
|
55
|
-
"mem_timeline",
|
|
56
|
-
"mem_get_observation",
|
|
57
|
-
"mem_session_start",
|
|
58
|
-
"mem_session_end",
|
|
59
|
-
])
|
|
60
|
-
|
|
61
|
-
// ─── Memory Instructions ─────────────────────────────────────────────────────
|
|
62
|
-
// These get injected into the agent's context so it knows to call mem_save.
|
|
63
|
-
// El placeholder ENGRAM_PROTOCOL lo resuelve el instalador con el contenido
|
|
64
|
-
// del protocolo canónico (stack/system-prompt/engram-protocol.md) — única
|
|
65
|
-
// fuente, alineada automáticamente con lo que reciben Claude Code y Codex.
|
|
66
|
-
|
|
67
|
-
const MEMORY_INSTRUCTIONS = "{{ENGRAM_PROTOCOL}}"
|
|
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
|
-
if (!res.ok) return null
|
|
82
|
-
return await res.json()
|
|
83
|
-
} catch {
|
|
84
|
-
// Engram server not running — silently fail
|
|
85
|
-
return null
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function isEngramRunning(): Promise<boolean> {
|
|
90
|
-
try {
|
|
91
|
-
const res = await fetch(`${ENGRAM_URL}/health`, {
|
|
92
|
-
signal: AbortSignal.timeout(500),
|
|
93
|
-
})
|
|
94
|
-
return res.ok
|
|
95
|
-
} catch {
|
|
96
|
-
return false
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
function extractProjectName(directory: string): string {
|
|
103
|
-
// Try git remote origin URL
|
|
104
|
-
try {
|
|
105
|
-
const result = Bun.spawnSync(["git", "-C", directory, "remote", "get-url", "origin"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" })
|
|
106
|
-
if (result.exitCode === 0) {
|
|
107
|
-
const url = result.stdout?.toString().trim()
|
|
108
|
-
if (url) {
|
|
109
|
-
const name = url.replace(/\.git$/, "").split(/[/:]/).pop()
|
|
110
|
-
if (name) return name
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
} catch {}
|
|
114
|
-
|
|
115
|
-
// Fallback: git root directory name (works in worktrees)
|
|
116
|
-
try {
|
|
117
|
-
const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" })
|
|
118
|
-
if (result.exitCode === 0) {
|
|
119
|
-
const root = result.stdout?.toString().trim()
|
|
120
|
-
if (root) return root.split(/[\\/]/).pop() ?? "unknown"
|
|
121
|
-
}
|
|
122
|
-
} catch {}
|
|
123
|
-
|
|
124
|
-
// Final fallback: cwd basename
|
|
125
|
-
return directory.split(/[\\/]/).pop() ?? "unknown"
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function truncate(str: string, max: number): string {
|
|
129
|
-
if (!str) return ""
|
|
130
|
-
return str.length > max ? str.slice(0, max) + "..." : str
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Strip <private>...</private> tags before sending to engram.
|
|
135
|
-
* Double safety: the Go binary also strips, but we strip here too
|
|
136
|
-
* so sensitive data never even hits the wire.
|
|
137
|
-
*/
|
|
138
|
-
function stripPrivateTags(str: string): string {
|
|
139
|
-
if (!str) return ""
|
|
140
|
-
return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Log errors and warnings to OpenCode's TUI log.
|
|
145
|
-
* Errors in hooks must not propagate to the plugin surface — they are silently
|
|
146
|
-
* swallowed here so the TUI does not crash. The error is visible only in OpenCode's
|
|
147
|
-
* log stream (visible to the user via `opencode logs`).
|
|
148
|
-
*/
|
|
149
|
-
async function logToOpenCode(client: unknown, level: string, message: string, extra?: unknown): Promise<void> {
|
|
150
|
-
if (typeof client !== "object" || client === null) return;
|
|
151
|
-
const app = (client as { app?: unknown }).app;
|
|
152
|
-
if (typeof app !== "object" || app === null) return;
|
|
153
|
-
const log = (app as { log?: unknown }).log;
|
|
154
|
-
if (typeof log !== "function") return;
|
|
155
|
-
const safeExtra = extra instanceof Error ? { message: extra.message, stack: extra.stack } : extra;
|
|
156
|
-
try {
|
|
157
|
-
await log.call(app, {
|
|
158
|
-
body: {
|
|
159
|
-
service: "engram",
|
|
160
|
-
level,
|
|
161
|
-
message,
|
|
162
|
-
extra: safeExtra,
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
} catch {
|
|
166
|
-
// Logging must never break the plugin path.
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ─── Plugin Export ───────────────────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
export const Engram: Plugin = async (ctx) => {
|
|
173
|
-
const oldProject = ctx.directory.split(/[\\/]/).pop() ?? "unknown"
|
|
174
|
-
const project = extractProjectName(ctx.directory)
|
|
175
|
-
const client = (ctx as { client?: unknown }).client
|
|
176
|
-
const engramBin = resolveEngramBin()
|
|
177
|
-
|
|
178
|
-
// Track tool counts per session (in-memory only, not critical)
|
|
179
|
-
const toolCounts = new Map<string, number>()
|
|
180
|
-
|
|
181
|
-
// Track which sessions we've already ensured exist in engram
|
|
182
|
-
const knownSessions = new Set<string>()
|
|
183
|
-
|
|
184
|
-
// Track sub-agent session IDs so we can suppress their tool-hook registrations.
|
|
185
|
-
// Sub-agents (Task() calls) have a parentID or a title ending in " subagent)".
|
|
186
|
-
// We must not register them as top-level Engram sessions — they cause session
|
|
187
|
-
// inflation (e.g. 170 sessions for 1 real conversation, issue #116).
|
|
188
|
-
const subAgentSessions = new Set<string>()
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Ensure a session exists in engram. Idempotent — calls POST /sessions
|
|
192
|
-
* which uses INSERT OR IGNORE. Safe to call multiple times.
|
|
193
|
-
*
|
|
194
|
-
* Silently skips sub-agent sessions (tracked in `subAgentSessions`).
|
|
195
|
-
*/
|
|
196
|
-
async function ensureSession(sessionId: string): Promise<boolean> {
|
|
197
|
-
if (!sessionId) return false
|
|
198
|
-
if (knownSessions.has(sessionId)) return true
|
|
199
|
-
// Do not register sub-agent sessions in Engram (issue #116).
|
|
200
|
-
if (subAgentSessions.has(sessionId)) return false
|
|
201
|
-
const session = await engramFetch("/sessions", {
|
|
202
|
-
method: "POST",
|
|
203
|
-
body: {
|
|
204
|
-
id: sessionId,
|
|
205
|
-
project,
|
|
206
|
-
directory: ctx.directory,
|
|
207
|
-
},
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
if (session === null) return false
|
|
211
|
-
|
|
212
|
-
knownSessions.add(sessionId)
|
|
213
|
-
return true
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Try to start engram server if not running
|
|
217
|
-
const running = await isEngramRunning()
|
|
218
|
-
if (!running) {
|
|
219
|
-
try {
|
|
220
|
-
Bun.spawn([engramBin, "serve"], {
|
|
221
|
-
stdout: "ignore",
|
|
222
|
-
stderr: "ignore",
|
|
223
|
-
stdin: "ignore",
|
|
224
|
-
})
|
|
225
|
-
await new Promise((r) => setTimeout(r, 500))
|
|
226
|
-
} catch {
|
|
227
|
-
// Binary not found or can't start — plugin will silently no-op
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Migrate project name if it changed (one-time, idempotent)
|
|
232
|
-
// Must run AFTER server startup to ensure the endpoint is available
|
|
233
|
-
if (oldProject !== project) {
|
|
234
|
-
await engramFetch("/projects/migrate", {
|
|
235
|
-
method: "POST",
|
|
236
|
-
body: { old_project: oldProject, new_project: project },
|
|
237
|
-
})
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Auto-import: if .engram/manifest.json exists in the project repo,
|
|
241
|
-
// run `engram sync --import` to load any new chunks into the local DB.
|
|
242
|
-
// This is how git-synced memories get loaded when cloning a repo or
|
|
243
|
-
// pulling changes. Each chunk is imported only once (tracked by ID).
|
|
244
|
-
try {
|
|
245
|
-
const manifestFile = `${ctx.directory}/.engram/manifest.json`
|
|
246
|
-
const file = Bun.file(manifestFile)
|
|
247
|
-
if (await file.exists()) {
|
|
248
|
-
Bun.spawn([engramBin, "sync", "--import"], {
|
|
249
|
-
cwd: ctx.directory,
|
|
250
|
-
stdout: "ignore",
|
|
251
|
-
stderr: "ignore",
|
|
252
|
-
stdin: "ignore",
|
|
253
|
-
})
|
|
254
|
-
}
|
|
255
|
-
} catch {
|
|
256
|
-
// Manifest doesn't exist or binary not found — silently skip
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ─── Safe Hook Wrapper ─────────────────────────────────────────
|
|
260
|
-
// Wraps every hook to catch errors and log them to OpenCode instead
|
|
261
|
-
// of letting them propagate. This prevents malformed payloads or
|
|
262
|
-
// missing fields (output.context, output.system, etc.) from crashing
|
|
263
|
-
// the TUI. Errors are logged and swallowed (promise resolves cleanly).
|
|
264
|
-
const safe = <A extends unknown[]>(name: string, fn: (...args: A) => Promise<void>) =>
|
|
265
|
-
async (...args: A): Promise<void> => {
|
|
266
|
-
try { await fn(...args) }
|
|
267
|
-
catch (error) { void logToOpenCode(client, "error", `Engram hook ${name} failed`, error) }
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return {
|
|
271
|
-
// ─── Event Listeners ───────────────────────────────────────────
|
|
272
|
-
|
|
273
|
-
event: safe("event", async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
274
|
-
// --- Session Created ---
|
|
275
|
-
if (event.type === "session.created") {
|
|
276
|
-
// Bug fix (#116): session data is nested under event.properties.info,
|
|
277
|
-
// not event.properties directly.
|
|
278
|
-
const info = (event.properties as any)?.info
|
|
279
|
-
const sessionId = info?.id
|
|
280
|
-
const parentID = info?.parentID
|
|
281
|
-
const title: string = info?.title ?? ""
|
|
282
|
-
|
|
283
|
-
// Sub-agent sessions (created via Task()) must NOT be registered as
|
|
284
|
-
// top-level Engram sessions. They cause massive session inflation
|
|
285
|
-
// (e.g. 170 sessions for 1 real conversation).
|
|
286
|
-
//
|
|
287
|
-
// Detection heuristics:
|
|
288
|
-
// - parentID is set on all Task() sub-agent sessions
|
|
289
|
-
// - title ends with " subagent)" as a secondary signal
|
|
290
|
-
const isSubAgent = !!parentID || title.endsWith(" subagent)")
|
|
291
|
-
|
|
292
|
-
if (sessionId && !isSubAgent) {
|
|
293
|
-
await ensureSession(sessionId)
|
|
294
|
-
} else if (sessionId && isSubAgent) {
|
|
295
|
-
// Remember this as a sub-agent session so tool-hook calls
|
|
296
|
-
// to ensureSession() are also suppressed for it.
|
|
297
|
-
subAgentSessions.add(sessionId)
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// --- Session Deleted ---
|
|
302
|
-
if (event.type === "session.deleted") {
|
|
303
|
-
// Same properties.info path as session.created.
|
|
304
|
-
const info = (event.properties as any)?.info
|
|
305
|
-
const sessionId = info?.id
|
|
306
|
-
if (sessionId) {
|
|
307
|
-
toolCounts.delete(sessionId)
|
|
308
|
-
knownSessions.delete(sessionId)
|
|
309
|
-
subAgentSessions.delete(sessionId)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
}),
|
|
314
|
-
|
|
315
|
-
// ─── User Prompt Capture ──────────────────────────────────────
|
|
316
|
-
// chat.message is called once per user message, before the LLM sees it.
|
|
317
|
-
// input.sessionID is always reliable here (no knownSessions workaround).
|
|
318
|
-
// output.message is typed as UserMessage (role:"user" already guaranteed).
|
|
319
|
-
// output.parts contains TextPart[] with the actual message text.
|
|
320
|
-
|
|
321
|
-
"chat.message": safe("chat.message", async (input: any, output: any) => {
|
|
322
|
-
// Skip sub-agent sessions — they inflate session counts (issue #116)
|
|
323
|
-
if (subAgentSessions.has(input.sessionID)) return
|
|
324
|
-
|
|
325
|
-
const sessionId = input.sessionID
|
|
326
|
-
|
|
327
|
-
// Extract text from parts (type:"text")
|
|
328
|
-
const content = output.parts
|
|
329
|
-
.filter((p: any) => p.type === "text")
|
|
330
|
-
.map((p: any) => p.text ?? "")
|
|
331
|
-
.join("\n")
|
|
332
|
-
.trim()
|
|
333
|
-
|
|
334
|
-
// Also fallback to summary if parts yield nothing
|
|
335
|
-
const fallback = !content && output.message.summary
|
|
336
|
-
? `${output.message.summary.title ?? ""}\n${output.message.summary.body ?? ""}`.trim()
|
|
337
|
-
: ""
|
|
338
|
-
|
|
339
|
-
const finalContent = content || fallback
|
|
340
|
-
|
|
341
|
-
// Only capture non-trivial prompts (>10 chars)
|
|
342
|
-
if (finalContent.length > 10) {
|
|
343
|
-
const sessionReady = await ensureSession(sessionId)
|
|
344
|
-
if (sessionReady) {
|
|
345
|
-
await engramFetch("/prompts", {
|
|
346
|
-
method: "POST",
|
|
347
|
-
body: {
|
|
348
|
-
session_id: sessionId,
|
|
349
|
-
content: stripPrivateTags(truncate(finalContent, 2000)),
|
|
350
|
-
project,
|
|
351
|
-
},
|
|
352
|
-
})
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
}),
|
|
356
|
-
|
|
357
|
-
// ─── Tool Execution Hook ─────────────────────────────────────
|
|
358
|
-
// Count tool calls per session (for session end stats).
|
|
359
|
-
// Also ensures the session exists — handles plugin reload / reconnect.
|
|
360
|
-
// Passive capture: when a Task tool completes, POST its output to
|
|
361
|
-
// the passive capture endpoint so the server extracts learnings.
|
|
362
|
-
|
|
363
|
-
"tool.execute.after": safe("tool.execute.after", async (input: any, output: any) => {
|
|
364
|
-
if (ENGRAM_TOOLS.has(input.tool.toLowerCase())) return
|
|
365
|
-
|
|
366
|
-
// input.sessionID comes from OpenCode — always available
|
|
367
|
-
const sessionId = input.sessionID
|
|
368
|
-
const sessionReady = sessionId ? await ensureSession(sessionId) : false
|
|
369
|
-
if (sessionReady && sessionId) {
|
|
370
|
-
toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1)
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Passive capture: extract learnings from Task tool output
|
|
374
|
-
// (OpenCode reports the tool name in lowercase: "task")
|
|
375
|
-
if (input.tool.toLowerCase() === "task" && output && sessionId) {
|
|
376
|
-
const text = typeof output === "string" ? output : JSON.stringify(output)
|
|
377
|
-
if (text.length > 50 && sessionReady) {
|
|
378
|
-
await engramFetch("/observations/passive", {
|
|
379
|
-
method: "POST",
|
|
380
|
-
body: {
|
|
381
|
-
session_id: sessionId,
|
|
382
|
-
content: stripPrivateTags(text),
|
|
383
|
-
project,
|
|
384
|
-
source: "task-complete",
|
|
385
|
-
},
|
|
386
|
-
})
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}),
|
|
390
|
-
|
|
391
|
-
// ─── System Prompt: Always-on memory instructions ──────────
|
|
392
|
-
// Injects MEMORY_INSTRUCTIONS into the system prompt of every message.
|
|
393
|
-
// This ensures the agent ALWAYS knows about Engram, even after compaction.
|
|
394
|
-
//
|
|
395
|
-
// We append to the last existing system entry instead of pushing a new one.
|
|
396
|
-
// Some models (Qwen3.5, Mistral/Ministral via llama.cpp) reject multiple
|
|
397
|
-
// system messages — their Jinja chat templates only allow a single system
|
|
398
|
-
// block at the beginning. By concatenating, we avoid adding extra system
|
|
399
|
-
// messages that would break these models. See: GitHub issue #23.
|
|
400
|
-
|
|
401
|
-
"experimental.chat.system.transform": safe("experimental.chat.system.transform", async (_input: any, output: any) => {
|
|
402
|
-
if (output.system.length > 0) {
|
|
403
|
-
output.system[output.system.length - 1] += "\n\n" + MEMORY_INSTRUCTIONS
|
|
404
|
-
} else {
|
|
405
|
-
output.system.push(MEMORY_INSTRUCTIONS)
|
|
406
|
-
}
|
|
407
|
-
}),
|
|
408
|
-
|
|
409
|
-
// ─── Compaction Hook: Persist memory + inject context ──────────
|
|
410
|
-
// Compaction is triggered by the system (not the agent) when context
|
|
411
|
-
// gets too long. The old agent "dies" and a new one starts with the
|
|
412
|
-
// compacted summary. This is our chance to:
|
|
413
|
-
// 1. Auto-save a session checkpoint (the agent can't do this itself)
|
|
414
|
-
// 2. Inject context from previous sessions into the compaction prompt
|
|
415
|
-
// 3. Tell the compressor to remind the new agent to save memories
|
|
416
|
-
|
|
417
|
-
"experimental.session.compacting": safe("experimental.session.compacting", async (input: any, output: any) => {
|
|
418
|
-
if (input.sessionID) {
|
|
419
|
-
await ensureSession(input.sessionID)
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Inject context from previous sessions
|
|
423
|
-
const data = await engramFetch(
|
|
424
|
-
`/context?project=${encodeURIComponent(project)}`
|
|
425
|
-
)
|
|
426
|
-
if (data?.context) {
|
|
427
|
-
output.context.push(data.context)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Tell the compressor to instruct the new agent to persist the
|
|
431
|
-
// compacted summary to Engram. The new agent reads the compacted
|
|
432
|
-
// summary and this instruction, then saves it as a session summary.
|
|
433
|
-
output.context.push(
|
|
434
|
-
`CRITICAL INSTRUCTION FOR COMPACTED SUMMARY:\n` +
|
|
435
|
-
`The agent has access to Engram persistent memory via MCP tools.\n` +
|
|
436
|
-
`You MUST include the following instruction at the TOP of the compacted summary:\n\n` +
|
|
437
|
-
`"FIRST ACTION REQUIRED: Call mem_session_summary with the content of this compacted summary. ` +
|
|
438
|
-
`Use project '${project}' if the tool accepts it. This preserves what was accomplished before compaction. ` +
|
|
439
|
-
`Do this BEFORE any other work. After that, delegate to the global engram subagent to recover only the relevant previous context."\n\n` +
|
|
440
|
-
`This is NOT optional. Without this, everything done before compaction is lost from memory.`
|
|
441
|
-
)
|
|
442
|
-
}),
|
|
443
|
-
}
|
|
444
|
-
}
|