armin-opencode 0.1.1

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 ADDED
@@ -0,0 +1,18 @@
1
+ # armin-opencode
2
+
3
+ [ARMIN](https://github.com/bastian-seifert/armin) — queryable decision memory
4
+ for AI coding agents — packaged for [opencode](https://opencode.ai).
5
+
6
+ ```bash
7
+ npx armin-opencode install # or: npm i -g armin-opencode && armin install
8
+ export ARMIN_ENABLED=1
9
+ ```
10
+
11
+ `armin install` downloads the `armin-engine` sidecar binary for your platform
12
+ from the [GitHub releases](https://github.com/bastian-seifert/armin/releases)
13
+ and registers the plugin in your global opencode config. Enable it per
14
+ environment with `ARMIN_ENABLED=1`.
15
+
16
+ See the [main repo](https://github.com/bastian-seifert/armin) for what the
17
+ agent gets (durable decisions/rules/open items, scoped reasoning-state brief,
18
+ compaction survival, live status page).
package/bin/armin.js ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * armin — installer for the ARMIN opencode middleware.
4
+ *
5
+ * armin install download the armin-engine binary for this platform,
6
+ * register the opencode plugin, print next steps
7
+ * armin --version print the package version
8
+ *
9
+ * The engine binary is fetched from the matching GitHub release
10
+ * (bastian-seifert/armin). If the download fails (offline, unsupported
11
+ * platform), fall back to building from source with cargo.
12
+ */
13
+ "use strict";
14
+
15
+ const { execFileSync } = require("child_process");
16
+ const fs = require("fs");
17
+ const https = require("https");
18
+ const os = require("os");
19
+ const path = require("path");
20
+ const zlib = require("zlib");
21
+
22
+ const PKG_ROOT = path.join(__dirname, "..");
23
+ const PKG = require(path.join(PKG_ROOT, "package.json"));
24
+ const REPO = "bastian-seifert/armin";
25
+ const BIN_DIR = process.env.ARMIN_BIN_DIR || path.join(os.homedir(), ".local", "bin");
26
+ const ENGINE = path.join(BIN_DIR, "armin-engine");
27
+
28
+ function platformAsset() {
29
+ const a = process.arch, p = process.platform;
30
+ if (p === "linux" && a === "x64") return "armin-engine-linux-x64.tar.gz";
31
+ if (p === "darwin" && a === "x64") return "armin-engine-macos-x64.tar.gz";
32
+ if (p === "darwin" && a === "arm64") return "armin-engine-macos-arm64.tar.gz";
33
+ return null;
34
+ }
35
+
36
+ function download(url, redirects = 0) {
37
+ return new Promise((resolve, reject) => {
38
+ if (redirects > 5) return reject(new Error("too many redirects"));
39
+ https.get(url, { headers: { "User-Agent": "armin-installer" } }, (res) => {
40
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
41
+ res.resume();
42
+ return resolve(download(res.headers.location, redirects + 1));
43
+ }
44
+ if (res.statusCode !== 200) {
45
+ res.resume();
46
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
47
+ }
48
+ resolve(res);
49
+ }).on("error", reject);
50
+ });
51
+ }
52
+
53
+ async function fetchEngine(version) {
54
+ const asset = platformAsset();
55
+ if (!asset) throw new Error(`no prebuilt engine for ${process.platform}-${process.arch}`);
56
+ const url = `https://github.com/${REPO}/releases/download/v${version}/${asset}`;
57
+ process.stdout.write(`downloading ${asset} (v${version}) ...\n`);
58
+ const res = await download(url);
59
+ const chunks = [];
60
+ for await (const chunk of res) chunks.push(chunk);
61
+ return Buffer.concat(chunks);
62
+ }
63
+
64
+ function extractTarGz(buf, dest) {
65
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "armin-pkg-"));
66
+ const tgz = path.join(tmp, "pkg.tar.gz");
67
+ fs.writeFileSync(tgz, buf);
68
+ execFileSync("tar", ["-xzf", tgz, "-C", tmp]);
69
+ // archive layout: <archive-name>/armin-engine
70
+ const inner = fs.readdirSync(tmp).find((f) => f !== "pkg.tar.gz");
71
+ const binary = path.join(tmp, inner || ".", "armin-engine");
72
+ fs.mkdirSync(BIN_DIR, { recursive: true });
73
+ fs.copyFileSync(binary, ENGINE);
74
+ fs.chmodSync(ENGINE, 0o755);
75
+ fs.rmSync(tmp, { recursive: true, force: true });
76
+ }
77
+
78
+ function buildFromSource() {
79
+ const repoRoot = process.env.ARMIN_SOURCE_DIR;
80
+ if (!repoRoot || !fs.existsSync(path.join(repoRoot, "armin-core"))) {
81
+ return false;
82
+ }
83
+ process.stdout.write("building armin-engine from source ...\n");
84
+ execFileSync("cargo", ["build", "--release", "-p", "armin-engine"], {
85
+ cwd: path.join(repoRoot, "armin-core"),
86
+ stdio: "inherit",
87
+ });
88
+ fs.mkdirSync(BIN_DIR, { recursive: true });
89
+ fs.copyFileSync(
90
+ path.join(repoRoot, "armin-core", "target", "release", "armin-engine"),
91
+ ENGINE,
92
+ );
93
+ fs.chmodSync(ENGINE, 0o755);
94
+ return true;
95
+ }
96
+
97
+ function registerPlugin() {
98
+ const plugin = path.join(PKG_ROOT, "plugins", "armin.ts");
99
+ if (!fs.existsSync(plugin)) throw new Error(`plugin not found at ${plugin}`);
100
+ const cfgDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config", "opencode");
101
+ const cfgPath = fs.existsSync(path.join(cfgDir, "opencode.jsonc"))
102
+ ? path.join(cfgDir, "opencode.jsonc")
103
+ : path.join(cfgDir, "opencode.json");
104
+ let data = {};
105
+ if (fs.existsSync(cfgPath)) {
106
+ const text = fs.readFileSync(cfgPath, "utf-8");
107
+ try {
108
+ data = JSON.parse(text.replace(/^\s*\/\/.*$/gm, ""));
109
+ } catch {
110
+ console.log(`\nCould not parse ${cfgPath} — register the plugin manually:\n add "plugin": ["file://${plugin}"] to your opencode config\n`);
111
+ return;
112
+ }
113
+ }
114
+ const entry = "file://" + plugin;
115
+ const plugins = Array.isArray(data.plugin) ? data.plugin : [];
116
+ if (!plugins.includes(entry)) {
117
+ plugins.push(entry);
118
+ data.plugin = plugins;
119
+ fs.mkdirSync(cfgDir, { recursive: true });
120
+ fs.writeFileSync(cfgPath, JSON.stringify(data, null, 2));
121
+ }
122
+ console.log(`plugin registered in ${cfgPath}`);
123
+ }
124
+
125
+ async function install() {
126
+ if (fs.existsSync(ENGINE)) {
127
+ console.log(`engine already present: ${ENGINE} (delete it to re-install)`);
128
+ } else {
129
+ try {
130
+ const buf = await fetchEngine(PKG.version);
131
+ extractTarGz(buf, BIN_DIR);
132
+ console.log(`installed: ${ENGINE}`);
133
+ } catch (e) {
134
+ console.log(`download failed: ${e.message}`);
135
+ if (!buildFromSource()) {
136
+ console.log(
137
+ `\nFallback: build from source —\n git clone https://github.com/${REPO}.git\n cd armin && scripts/install.sh\n`,
138
+ );
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ }
143
+ }
144
+ registerPlugin();
145
+ console.log(`
146
+ ── done ────────────────────────────────────────────────────────
147
+ Enable ARMIN per environment:
148
+
149
+ export ARMIN_ENABLED=1
150
+
151
+ Optional:
152
+ ARMIN_EXTRACTION_MODE=jev TypeSafe System One extraction
153
+ ARMIN_MODEL=<model> extraction model override
154
+ ARMIN_DEBUG=1 verbose logging + /ui URL
155
+ `);
156
+ }
157
+
158
+ const arg = process.argv[2];
159
+ if (arg === "--version" || arg === "-v") {
160
+ console.log(`armin-installer ${PKG.version}`);
161
+ } else if (arg && arg !== "install") {
162
+ console.log("usage: armin [install|--version]");
163
+ process.exitCode = arg === "help" ? 0 : 1;
164
+ } else {
165
+ install().catch((e) => {
166
+ console.error(e);
167
+ process.exitCode = 1;
168
+ });
169
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "armin-opencode",
3
+ "version": "0.1.1",
4
+ "description": "ARMIN — queryable decision memory for AI coding agents. Installs the armin-engine sidecar and registers the opencode plugin.",
5
+ "license": "Apache-2.0",
6
+ "author": "Bastian Seifert",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/bastian-seifert/armin.git"
10
+ },
11
+ "bin": {
12
+ "armin": "bin/armin.js"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "plugins/",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "opencode",
24
+ "ai-agents",
25
+ "agent-memory",
26
+ "llm",
27
+ "cli"
28
+ ]
29
+ }
@@ -0,0 +1,829 @@
1
+ /**
2
+ * ARMIN — reasoning-graph middleware for opencode.
3
+ *
4
+ * Spawns the `armin-engine` Rust sidecar, passively captures the session's
5
+ * tool calls and assistant/user prose into an argument graph, and pushes a
6
+ * compact reasoning-state brief into the system prompt so decisions, open
7
+ * questions, and contradictions survive context compaction.
8
+ *
9
+ * Opt-in: either set `ARMIN_ENABLED=1` (or `ARMIN_ENGINE_BIN`), or add an
10
+ * `armin` section to any opencode config file (opencode.json / jsonc,
11
+ * global or project):
12
+ *
13
+ * {
14
+ * "$schema": "https://opencode.ai/config.json",
15
+ * "armin": {
16
+ * "enabled": true,
17
+ * "provider": "anthropic", // anthropic | openai (default: infer from keys)
18
+ * "apiKey": "sk-...", // sent to the sidecar only; env key wins if both set
19
+ * "model": "session", // "session" = follow the live session model
20
+ * // "small" = reuse opencode's small_model setting
21
+ * // any string = fixed extraction model
22
+ * "batchMs": 15000, // LLM extraction debounce window
23
+ * "batchEvents": 10, // events per LLM extraction call
24
+ * "dbDir": "~/.opencode/armin" // per-project graph databases
25
+ * // (keyed by git origin remote; path
26
+ * // fallback for non-git dirs)
27
+ * }
28
+ * }
29
+ *
30
+ * Precedence: engine defaults < config files (global < project) < env vars
31
+ * (ARMIN_MODEL, ARMIN_BATCH_MS, ARMIN_BATCH_EVENTS, ARMIN_DB_DIR,
32
+ * ARMIN_ENGINE_BIN, ARMIN_DEBUG) — env is the escape hatch.
33
+ *
34
+ * Note: opencode's auth store (OAuth logins) is not exposed to plugins, so
35
+ * extraction uses `apiKey`/environment API keys; the session *model* can
36
+ * still be followed with "model": "session".
37
+ */
38
+ import type { Plugin } from "@opencode-ai/plugin"
39
+ import { tool } from "@opencode-ai/plugin"
40
+
41
+ // ── Configuration ─────────────────────────────────────────────────────────────
42
+
43
+ const DEBUG = process.env.ARMIN_DEBUG === "1"
44
+ const MAX_RESTARTS = 3
45
+ const MAX_TEXT_CHARS = 2000
46
+
47
+ function log(...args: unknown[]) {
48
+ if (DEBUG) console.log("[armin]", ...args)
49
+ }
50
+
51
+ function sleep(ms: number): Promise<void> {
52
+ return new Promise((r) => setTimeout(r, ms))
53
+ }
54
+
55
+ function truncate(s: string, max: number): string {
56
+ return s.length <= max ? s : `${s.slice(0, max)}…`
57
+ }
58
+
59
+ // ── Config files (JSON + JSONC, no deps) ──────────────────────────────────────
60
+
61
+ /** Parse JSON, falling back to a string-aware JSONC cleanup (comments,
62
+ * trailing commas). Returns null when the file is unparseable. */
63
+ function parseConfigFile(text: string): Record<string, unknown> | null {
64
+ try {
65
+ return JSON.parse(text)
66
+ } catch {
67
+ // fall through to JSONC handling
68
+ }
69
+ let out = ""
70
+ let inString = false
71
+ let escape = false
72
+ for (let i = 0; i < text.length; i++) {
73
+ const c = text[i]
74
+ if (inString) {
75
+ out += c
76
+ if (escape) escape = false
77
+ else if (c === "\\") escape = true
78
+ else if (c === '"') inString = false
79
+ continue
80
+ }
81
+ if (c === '"') {
82
+ inString = true
83
+ out += c
84
+ continue
85
+ }
86
+ if (c === "/" && text[i + 1] === "/") {
87
+ while (i < text.length && text[i] !== "\n") i++
88
+ out += "\n"
89
+ continue
90
+ }
91
+ if (c === "/" && text[i + 1] === "*") {
92
+ i += 2
93
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
94
+ i++
95
+ continue
96
+ }
97
+ out += c
98
+ }
99
+ const noTrailing = out.replace(/,(\s*[}\]])/g, "$1")
100
+ try {
101
+ return JSON.parse(noTrailing)
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ /** Read the merged `armin` section (plus the top-level `small_model` key)
108
+ * from opencode config files. Later files win, mirroring opencode's own
109
+ * global→project merge order. */
110
+ async function loadArminConfig(worktree: string): Promise<{
111
+ armin: Record<string, unknown>
112
+ smallModel?: string
113
+ }> {
114
+ const home = process.env.HOME ?? "~"
115
+ const candidates = [
116
+ // global (same order as opencode itself: later files win)
117
+ `${home}/.config/opencode/opencode.jsonc`,
118
+ `${home}/.config/opencode/opencode.json`,
119
+ `${home}/.config/opencode/config.json`,
120
+ // project
121
+ `${worktree}/opencode.jsonc`,
122
+ `${worktree}/opencode.json`,
123
+ `${worktree}/.opencode/opencode.jsonc`,
124
+ `${worktree}/.opencode/opencode.json`,
125
+ ]
126
+
127
+ let armin: Record<string, unknown> = {}
128
+ let smallModel: string | undefined
129
+ for (const file of candidates) {
130
+ try {
131
+ const f = Bun.file(file)
132
+ if (!(await f.exists())) continue
133
+ const parsed = parseConfigFile(await f.text())
134
+ if (!parsed) continue
135
+ if (parsed.armin && typeof parsed.armin === "object") {
136
+ armin = { ...armin, ...(parsed.armin as Record<string, unknown>) }
137
+ }
138
+ if (typeof parsed.small_model === "string") smallModel = parsed.small_model
139
+ } catch (e) {
140
+ log(`skipping unreadable config ${file}:`, String(e))
141
+ }
142
+ }
143
+ return { armin, smallModel }
144
+ }
145
+
146
+ /** Slug for database file names. */
147
+ function slugify(s: string): string {
148
+ return s.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "")
149
+ }
150
+
151
+ /**
152
+ * Project identity key: the git origin remote when the worktree is a clone
153
+ * of a remote, else the worktree path itself. Sessions in different
154
+ * checkouts/worktrees of the same project then share one graph — memory
155
+ * follows the project, not the checkout location.
156
+ */
157
+ function projectKeyFor(worktree: string): string {
158
+ try {
159
+ const proc = Bun.spawnSync(
160
+ ["git", "-C", worktree, "config", "--get", "remote.origin.url"],
161
+ { stdout: "pipe", stderr: "pipe" },
162
+ )
163
+ const url = proc.stdout.toString().trim()
164
+ if (url) return url.replace(/\.git\/?$/i, "")
165
+ } catch {
166
+ // not a git repo or no origin — fall through to the worktree path
167
+ }
168
+ return worktree
169
+ }
170
+
171
+ /** One database per project: sessions in any checkout of the same remote
172
+ * share the graph; non-git directories are keyed by their path. */
173
+ function dbPathFor(dbDir: string, worktree: string): string {
174
+ const slug = slugify(projectKeyFor(worktree))
175
+ return `${dbDir}/${slug || "default"}.db`
176
+ }
177
+
178
+ /** Pick the first engine binary that exists: env override → repo build → install path. */
179
+ async function resolveEngineBin(worktree: string): Promise<string> {
180
+ const candidates = [
181
+ process.env.ARMIN_ENGINE_BIN,
182
+ `${worktree}/armin-core/target/release/armin-engine`,
183
+ `${worktree}/../armin-core/target/release/armin-engine`,
184
+ `${process.env.HOME}/.local/bin/armin-engine`,
185
+ ].filter((c): c is string => !!c)
186
+ for (const candidate of candidates) {
187
+ try {
188
+ if (await Bun.file(candidate).exists()) return candidate
189
+ } catch {
190
+ // unreadable path — try the next candidate
191
+ }
192
+ }
193
+ return candidates[candidates.length - 1]
194
+ }
195
+
196
+ // ── Sidecar lifecycle ─────────────────────────────────────────────────────────
197
+
198
+ class Sidecar {
199
+ private proc?: Bun.Subprocess<"ignore", "pipe", "pipe">
200
+ private readonly _token = crypto.randomUUID().replace(/-/g, "")
201
+ private starts = 0
202
+ private disposed = false
203
+ port: number | null = null
204
+
205
+ get token(): string {
206
+ return this._token
207
+ }
208
+
209
+ constructor(
210
+ private engineBin: string,
211
+ private dbFile: string,
212
+ private spawnEnv: Record<string, string> = {},
213
+ ) {}
214
+
215
+ /** Start the engine and wait for the port handshake + health check. */
216
+ async start(): Promise<boolean> {
217
+ if (this.disposed) return false
218
+ if (await this.healthy()) return true
219
+ if (this.starts >= MAX_RESTARTS) {
220
+ log("sidecar restart limit reached; ARMIN inert")
221
+ return false
222
+ }
223
+ this.starts++
224
+
225
+ const args = [
226
+ this.engineBin,
227
+ "--port", "0",
228
+ "--db-path", this.dbFile,
229
+ "--auth-token", this.token,
230
+ ]
231
+
232
+ log("spawning sidecar:", args.join(" "))
233
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">
234
+ try {
235
+ proc = Bun.spawn(args, {
236
+ stdout: "pipe",
237
+ stderr: "pipe",
238
+ env: { ...process.env, ...this.spawnEnv },
239
+ })
240
+ } catch (e) {
241
+ log("failed to spawn sidecar:", String(e))
242
+ return false
243
+ }
244
+ this.proc = proc
245
+
246
+ // Drain stderr so a chatty engine can't block on a full pipe.
247
+ ;(async () => {
248
+ try {
249
+ for await (const line of proc.stderr) {
250
+ if (DEBUG) log("engine:", String(line).trim())
251
+ }
252
+ } catch {
253
+ // process exited
254
+ }
255
+ })()
256
+
257
+ const port = await this.readPort(proc, 5_000)
258
+ if (!port) {
259
+ log("sidecar did not report ARMIN_PORT in time")
260
+ proc.kill()
261
+ return false
262
+ }
263
+ this.port = port
264
+
265
+ for (let i = 0; i < 50; i++) {
266
+ if (await this.healthy()) {
267
+ log(`sidecar ready on 127.0.0.1:${port}`)
268
+ return true
269
+ }
270
+ if (this.disposed) return false
271
+ await sleep(200)
272
+ }
273
+ log("sidecar failed health check")
274
+ return false
275
+ }
276
+
277
+ /** Ensure the sidecar is up, restarting if it crashed or hung. */
278
+ async ensure(): Promise<boolean> {
279
+ if (await this.healthy()) return true
280
+ // Process still alive? It may just be busy — retry once before restarting.
281
+ if (this.proc && this.proc.exitCode === null) {
282
+ await sleep(300)
283
+ if (await this.healthy()) return true
284
+ this.proc.kill()
285
+ }
286
+ this.port = null
287
+ return this.start()
288
+ }
289
+
290
+ async dispose(): Promise<void> {
291
+ this.disposed = true
292
+ if (this.proc && this.proc.exitCode === null) {
293
+ this.proc.kill() // SIGTERM → graceful flush
294
+ await sleep(500)
295
+ if (this.proc.exitCode === null) this.proc.kill(9)
296
+ }
297
+ }
298
+
299
+ private async readPort(
300
+ proc: Bun.Subprocess<"ignore", "pipe", "pipe">,
301
+ timeoutMs: number,
302
+ ): Promise<number | null> {
303
+ const reader = proc.stdout.getReader()
304
+ const deadline = Date.now() + timeoutMs
305
+ let buf = ""
306
+ while (Date.now() < deadline) {
307
+ let chunk: Awaited<ReturnType<typeof reader.read>> | null
308
+ try {
309
+ chunk = await Promise.race([
310
+ reader.read(),
311
+ sleep(timeoutMs).then(() => null),
312
+ ])
313
+ } catch {
314
+ break
315
+ }
316
+ if (!chunk) break
317
+ buf += new TextDecoder().decode(chunk.value ?? new Uint8Array())
318
+ const m = buf.match(/ARMIN_PORT=(\d+)/)
319
+ if (m) {
320
+ reader.cancel().catch(() => {})
321
+ return Number(m[1])
322
+ }
323
+ }
324
+ reader.cancel().catch(() => {})
325
+ return null
326
+ }
327
+
328
+ private async healthy(): Promise<boolean> {
329
+ if (!this.port) return false
330
+ try {
331
+ const res = await fetch(`http://127.0.0.1:${this.port}/api/v1/health`, {
332
+ headers: authHeaders(this.token),
333
+ signal: AbortSignal.timeout(500),
334
+ })
335
+ return res.ok
336
+ } catch {
337
+ return false
338
+ }
339
+ }
340
+ }
341
+
342
+ // ── HTTP client ───────────────────────────────────────────────────────────────
343
+
344
+ function authHeaders(token: string): Record<string, string> {
345
+ return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
346
+ }
347
+
348
+ class EngineClient {
349
+ constructor(private sidecar: Sidecar) {}
350
+
351
+ get token(): string {
352
+ return this.sidecar.token
353
+ }
354
+
355
+ async get<T = unknown>(path: string): Promise<T | null> {
356
+ return this.request<T>("GET", path)
357
+ }
358
+
359
+ async post<T = unknown>(path: string, body: unknown): Promise<T | null> {
360
+ return this.request<T>("POST", path, body)
361
+ }
362
+
363
+ private async request<T>(
364
+ method: string,
365
+ path: string,
366
+ body?: unknown,
367
+ ): Promise<T | null> {
368
+ if (!(await this.sidecar.ensure())) return null
369
+ try {
370
+ const res = await fetch(
371
+ `http://127.0.0.1:${this.sidecar.port}/api/v1${path}`,
372
+ {
373
+ method,
374
+ headers: authHeaders(this.token),
375
+ body: body === undefined ? undefined : JSON.stringify(body),
376
+ signal: AbortSignal.timeout(10_000),
377
+ },
378
+ )
379
+ if (!res.ok) {
380
+ log(`${path} -> ${res.status}`)
381
+ return null
382
+ }
383
+ return (await res.json()) as T
384
+ } catch (e) {
385
+ log(`${path} failed:`, String(e))
386
+ return null
387
+ }
388
+ }
389
+ }
390
+
391
+ // ── Event capture ─────────────────────────────────────────────────────────────
392
+
393
+ class Capture {
394
+ private seq = 0
395
+
396
+ constructor(private api: EngineClient) {}
397
+
398
+ /** Fire-and-forget: capture must never block the agent's turn. */
399
+ ingest(
400
+ sessionID: string,
401
+ kind: "tool_call" | "utterance" | "user_prompt",
402
+ role: string,
403
+ text: string,
404
+ opts?: { toolName?: string; files?: string[] },
405
+ ): void {
406
+ if (!text.trim()) return
407
+ const now = Date.now() / 1000
408
+ const event = {
409
+ id: `${sessionID.slice(-8)}-${Date.now().toString(36)}-${this.seq++}`,
410
+ session_id: sessionID,
411
+ agent_role: role,
412
+ start_time: now,
413
+ end_time: now,
414
+ text: truncate(text, MAX_TEXT_CHARS),
415
+ event_kind: kind,
416
+ tool_name: opts?.toolName ?? null,
417
+ files: opts?.files ?? [],
418
+ commit: null,
419
+ }
420
+ void this.api.post("/ingest", [event])
421
+ }
422
+ }
423
+
424
+ // ── Plugin ────────────────────────────────────────────────────────────────────
425
+
426
+ /** Pull file paths out of common tool-arg shapes (read/edit/write...). */
427
+ function extractFiles(args: unknown): string[] {
428
+ const out: string[] = []
429
+ if (args && typeof args === "object") {
430
+ const obj = args as Record<string, unknown>
431
+ for (const key of ["filePath", "path", "file"]) {
432
+ const v = obj[key]
433
+ if (typeof v === "string" && v.includes("/")) out.push(v)
434
+ }
435
+ if (Array.isArray(obj.paths)) {
436
+ for (const v of obj.paths) {
437
+ if (typeof v === "string" && v.includes("/")) out.push(v)
438
+ }
439
+ }
440
+ }
441
+ return [...new Set(out)].slice(0, 8)
442
+ }
443
+
444
+ const ArminPlugin: Plugin = async (ctx) => {
445
+ // Config precedence: engine defaults < config files < env vars.
446
+ const fileConfig = await loadArminConfig(ctx.worktree)
447
+ const armin = fileConfig.armin
448
+
449
+ const enabledViaConfig = armin.enabled === true
450
+ const enabledViaEnv = process.env.ARMIN_ENABLED === "1" || !!process.env.ARMIN_ENGINE_BIN
451
+ if (!enabledViaConfig && !enabledViaEnv) {
452
+ log("disabled (set armin.enabled=true in config or ARMIN_ENABLED=1)")
453
+ return {}
454
+ }
455
+
456
+ const cfgStr = (key: string, envKey: string): string | undefined =>
457
+ (process.env[envKey] as string | undefined) ??
458
+ (typeof armin[key] === "string" ? (armin[key] as string) : undefined)
459
+
460
+ const dbDir = cfgStr("dbDir", "ARMIN_DB_DIR") ?? `${process.env.HOME}/.opencode/armin`
461
+ const engineBin = await resolveEngineBin(ctx.worktree)
462
+ const dbFile = dbPathFor(dbDir, ctx.worktree)
463
+ try {
464
+ await import("node:fs").then((fs) => fs.mkdirSync(dbDir, { recursive: true }))
465
+ } catch {
466
+ // Engine also creates parent dirs; this is just a head start.
467
+ }
468
+
469
+ // ── Model & provider resolution ─────────────────────────────────────
470
+ // - "session" → follow the live session model (pushed at runtime via
471
+ // /config once the first chat message arrives)
472
+ // - "small" → reuse opencode's top-level small_model setting
473
+ // - other → fixed model string
474
+ // - unset → LLM_MODEL env or the provider's default
475
+ const rawModel = cfgStr("model", "ARMIN_MODEL")
476
+ let model: string | undefined
477
+ let followSessionModel = false
478
+ if (rawModel === "session") {
479
+ followSessionModel = true
480
+ } else if (rawModel === "small") {
481
+ const small = fileConfig.smallModel
482
+ if (small) model = small.includes("/") ? small.split("/").slice(1).join("/") : small
483
+ } else if (rawModel) {
484
+ model = rawModel
485
+ }
486
+
487
+ const spawnEnv: Record<string, string> = {}
488
+ if (model) spawnEnv.LLM_MODEL = model
489
+ const provider = armin.provider
490
+ if (provider === "anthropic" || provider === "openai") {
491
+ spawnEnv.LLM_PROVIDER = provider
492
+ }
493
+ // API key: config value is passed to the sidecar only; a key already in
494
+ // the environment wins (no override).
495
+ if (typeof armin.apiKey === "string" && armin.apiKey) {
496
+ const keyProvider = provider === "openai" ? "openai" : "anthropic"
497
+ const keyVar = keyProvider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY"
498
+ if (!process.env[keyVar]) spawnEnv[keyVar] = armin.apiKey as string
499
+ }
500
+ // Batch knobs: env wins, config fills in.
501
+ const batchMs =
502
+ process.env.ARMIN_BATCH_MS ?? (typeof armin.batchMs === "number" ? String(armin.batchMs) : undefined)
503
+ if (batchMs) spawnEnv.ARMIN_BATCH_MS = batchMs
504
+ const batchEvents =
505
+ process.env.ARMIN_BATCH_EVENTS ??
506
+ (typeof armin.batchEvents === "number" ? String(armin.batchEvents) : undefined)
507
+ if (batchEvents) spawnEnv.ARMIN_BATCH_EVENTS = batchEvents
508
+
509
+ const sidecar = new Sidecar(engineBin, dbFile, spawnEnv)
510
+ const started = await sidecar.start()
511
+ if (!started) {
512
+ log("sidecar unavailable — plugin inert")
513
+ return { dispose: () => sidecar.dispose() }
514
+ }
515
+
516
+ const api = new EngineClient(sidecar)
517
+ const capture = new Capture(api)
518
+
519
+ if (sidecar.port) {
520
+ log(`reasoning-state UI: http://127.0.0.1:${sidecar.port}/ui`)
521
+ }
522
+
523
+ // ── Cold start: import AGENTS.md / CLAUDE.md into an empty graph ──────
524
+ // Deterministic parse on the engine side (content-hash IDs, idempotent).
525
+ // No LLM, no network beyond the local sidecar.
526
+ void (async () => {
527
+ try {
528
+ const snap = await api.get<{ nodes: unknown[] }>("/snapshot")
529
+ if (!snap || (snap.nodes?.length ?? 0) > 0) return
530
+ const fs = await import("node:fs")
531
+ const doc = ["AGENTS.md", "CLAUDE.md"]
532
+ .map((f) => `${ctx.worktree}/${f}`)
533
+ .find((p) => fs.existsSync(p))
534
+ if (!doc) return
535
+ const content = fs.readFileSync(doc, "utf-8")
536
+ const res = await api.post<{ found: number; imported: number; rules: number }>(
537
+ "/import",
538
+ { content, session_id: `import-${Date.now()}` },
539
+ )
540
+ if (res && res.imported > 0) {
541
+ log(
542
+ `imported ${doc}: ${res.imported} node(s) ` +
543
+ `(${res.rules} rule(s), ${res.found - res.rules} decision/open item(s))`,
544
+ )
545
+ }
546
+ } catch {
547
+ // Import is best-effort — never block the session.
548
+ }
549
+ })()
550
+
551
+ // Last seen text per streaming part ID — capture each text part once.
552
+ const lastPartText = new Map<string, string>()
553
+ // Recently edited files (ring buffer) — scopes the brief's "Binding here"
554
+ // section to what the agent is actually working on.
555
+ const recentFiles: string[] = []
556
+ const rememberFiles = (files: string[]) => {
557
+ for (const f of files) {
558
+ const i = recentFiles.indexOf(f)
559
+ if (i >= 0) recentFiles.splice(i, 1)
560
+ recentFiles.unshift(f)
561
+ }
562
+ if (recentFiles.length > 20) recentFiles.length = 20
563
+ }
564
+ const briefFooter = (process.env.ARMIN_BRIEF_FOOTER ?? cfgStr("briefFooter", "ARMIN_BRIEF_FOOTER")) === "1"
565
+ // Last model pushed to the engine (for "session" model following).
566
+ let lastPushedModel: string | undefined
567
+ const ingestAssistantText = (sessionID: string, partID: string, text: string) => {
568
+ if (lastPartText.get(partID) === text) return
569
+ lastPartText.set(partID, text)
570
+ if (lastPartText.size > 500) {
571
+ const oldest = lastPartText.keys().next().value
572
+ if (oldest) lastPartText.delete(oldest)
573
+ }
574
+ capture.ingest(sessionID, "utterance", "assistant", text)
575
+ }
576
+
577
+ return {
578
+ dispose: () => sidecar.dispose(),
579
+
580
+ // ── Passive capture (no LLM, no added latency) ─────────────────────
581
+ "tool.execute.after": async (input, output) => {
582
+ const files = extractFiles(input.args)
583
+ rememberFiles(files)
584
+ const text = `${input.tool} ${output.title ?? ""}: ${truncate(output.output ?? "", 400)}`
585
+ capture.ingest(input.sessionID, "tool_call", "agent", text, {
586
+ toolName: input.tool,
587
+ files,
588
+ })
589
+ // Optional (off by default): append the scoped memory as a footer to
590
+ // mutating tool outputs — the tool result is model context at exactly
591
+ // the moment a remembered constraint matters.
592
+ if (briefFooter && files.length > 0) {
593
+ const filesParam = encodeURIComponent(recentFiles.slice(0, 10).join(","))
594
+ const res = await api.get<{ brief: string; empty: boolean }>(
595
+ `/state/brief?files=${filesParam}`,
596
+ )
597
+ if (res && !res.empty && res.brief.includes("Binding here")) {
598
+ const binding = res.brief
599
+ .split("Binding here")[1]
600
+ ?.split("\n")
601
+ .filter((l) => l.startsWith("- "))
602
+ .slice(0, 3)
603
+ .join("\n")
604
+ if (binding) {
605
+ output.output = `${output.output ?? ""}\n\n[ARMIN — settled decisions/rules for these files]\n${binding}`
606
+ }
607
+ }
608
+ }
609
+ },
610
+
611
+ event: async ({ event }) => {
612
+ if (event.type === "message.part.updated") {
613
+ const props = event.properties as Record<string, any>
614
+ const part = props?.part
615
+ if (part?.type === "text" && part?.time?.end && props?.sessionID) {
616
+ ingestAssistantText(props.sessionID, part.id, part.text ?? "")
617
+ }
618
+ }
619
+ },
620
+
621
+ "chat.message": async (input, output) => {
622
+ // Follow the live session model if configured: push it to the engine
623
+ // whenever it changes (cheap localhost POST, fire-and-forget).
624
+ if (followSessionModel && input.model?.modelID) {
625
+ const modelID = input.model.modelID
626
+ if (modelID && modelID !== lastPushedModel) {
627
+ lastPushedModel = modelID
628
+ void api.post("/config", { model: modelID })
629
+ }
630
+ }
631
+ const texts = (output.parts ?? [])
632
+ .filter((p: any) => p.type === "text")
633
+ .map((p: any) => p.text ?? "")
634
+ .join("\n")
635
+ if (texts.trim()) {
636
+ capture.ingest(input.sessionID, "user_prompt", "user", texts)
637
+ }
638
+ },
639
+
640
+ // ── Push: zero-cost reasoning-state reminder on every turn ────────
641
+ "experimental.chat.system.transform": async (_input, output) => {
642
+ try {
643
+ const filesParam =
644
+ recentFiles.length > 0
645
+ ? `?files=${encodeURIComponent(recentFiles.slice(0, 10).join(","))}`
646
+ : ""
647
+ const res = await api.get<{ brief: string; empty: boolean }>(
648
+ `/state/brief${filesParam}`,
649
+ )
650
+ if (res && !res.empty && res.brief) {
651
+ output.system.push(
652
+ "Reasoning state (ARMIN): tracked decisions, rules, and open items. " +
653
+ "Use query_graph for detail. If a new request conflicts with a decision or rule " +
654
+ "below, say so explicitly before deviating.\n" + res.brief,
655
+ )
656
+ }
657
+ } catch {
658
+ // Engine down — skip injection silently.
659
+ }
660
+ },
661
+
662
+ // ── Reasoning survives compaction ─────────────────────────────────
663
+ "experimental.session.compacting": async (_input, output) => {
664
+ const [decisions, debt] = await Promise.all([
665
+ api.get<any[]>("/decisions"),
666
+ api.get<{ items: { debt_type: string; description: string }[] }>("/debt"),
667
+ ])
668
+ const lines: string[] = []
669
+ if (Array.isArray(decisions) && decisions.length) {
670
+ lines.push("Decisions made in this session (do not re-litigate):")
671
+ for (const d of decisions.slice(0, 8)) {
672
+ lines.push(`- [${d.status}] ${d.label}`)
673
+ }
674
+ }
675
+ const questions = (debt?.items ?? []).filter((i) =>
676
+ String(i.debt_type).includes("Question"),
677
+ )
678
+ if (questions.length) {
679
+ lines.push("Open questions that remain unresolved:")
680
+ for (const q of questions.slice(0, 8)) {
681
+ lines.push(`- ${q.description}`)
682
+ }
683
+ }
684
+ if (lines.length) output.context.push(lines.join("\n"))
685
+ },
686
+
687
+ // ── Tools ──────────────────────────────────────────────────────────
688
+ tool: {
689
+ query_graph: tool({
690
+ description:
691
+ "Ask a question about this session's reasoning graph (decisions, evidence, assumptions, contradictions). Returns an answer with a trace of node IDs usable in resolve_question.",
692
+ args: {
693
+ question: tool.schema.string().describe("The question to answer from the reasoning graph"),
694
+ },
695
+ async execute(args) {
696
+ const res = await api.post<{ answer: string; trace: string[]; cited_events: string[] }>(
697
+ "/query",
698
+ { question: args.question },
699
+ )
700
+ if (!res) return "Reasoning graph is unavailable."
701
+ if (!res.answer && !res.trace?.length)
702
+ return "The graph has no relevant reasoning recorded yet."
703
+ return [
704
+ res.answer,
705
+ "",
706
+ `Trace (node IDs): ${JSON.stringify(res.trace)}`,
707
+ `Cited events: ${JSON.stringify(res.cited_events)}`,
708
+ ].join("\n")
709
+ },
710
+ }),
711
+
712
+ record_decision: tool({
713
+ description:
714
+ "Record a decision you made, with its rationale. Optionally pass question node IDs (from query_graph trace) that this decision resolves.",
715
+ args: {
716
+ label: tool.schema.string().describe("Short summary, max ~15 words"),
717
+ description: tool.schema.string().describe("Detailed rationale"),
718
+ resolves: tool.schema.array(tool.schema.string()).optional().describe("Question node IDs this decision resolves"),
719
+ resolutions: tool.schema.array(tool.schema.string()).optional().describe("Reasoning per resolution"),
720
+ files: tool.schema.array(tool.schema.string()).optional().describe("Related file paths"),
721
+ },
722
+ async execute(args, context) {
723
+ const res = await api.post<{ node_id: string; edge_ids: string[] }>("/agent/decision", {
724
+ label: args.label,
725
+ description: args.description,
726
+ session_id: context.sessionID,
727
+ resolves: args.resolves ?? [],
728
+ resolutions: args.resolutions ?? [],
729
+ files: args.files ?? [],
730
+ })
731
+ return res
732
+ ? `Recorded decision ${res.node_id}${res.edge_ids.length ? ` (resolved ${res.edge_ids.length} question(s))` : ""}`
733
+ : "Reasoning graph is unavailable; decision not recorded."
734
+ },
735
+ }),
736
+
737
+ raise_question: tool({
738
+ description:
739
+ "Record an unresolved question so it is tracked as reasoning debt and resurfaces in later turns and after compaction.",
740
+ args: {
741
+ label: tool.schema.string().describe("Short summary of the question, max ~15 words"),
742
+ description: tool.schema.string().describe("Full question and context"),
743
+ files: tool.schema.array(tool.schema.string()).optional().describe("Related file paths"),
744
+ },
745
+ async execute(args, context) {
746
+ const res = await api.post<{ node_id: string }>("/agent/question", {
747
+ label: args.label,
748
+ description: args.description,
749
+ session_id: context.sessionID,
750
+ files: args.files ?? [],
751
+ })
752
+ return res
753
+ ? `Recorded question ${res.node_id}`
754
+ : "Reasoning graph is unavailable; question not recorded."
755
+ },
756
+ }),
757
+
758
+ resolve_question: tool({
759
+ description:
760
+ "Mark a tracked question as resolved by linking it to a node (use node IDs from query_graph's trace).",
761
+ args: {
762
+ question_id: tool.schema.string(),
763
+ resolver_node_id: tool.schema.string(),
764
+ reasoning: tool.schema.string().describe("How the resolver answers the question"),
765
+ },
766
+ async execute(args) {
767
+ const res = await api.post("/resolve", args)
768
+ return res ? `Resolved ${args.question_id}` : "Reasoning graph is unavailable."
769
+ },
770
+ }),
771
+
772
+ invalidate_assumption: tool({
773
+ description:
774
+ "Mark a previously recorded assumption as no longer valid (e.g. a constraint turned out to be false).",
775
+ args: {
776
+ node_id: tool.schema.string(),
777
+ rationale: tool.schema.string().describe("Why the assumption no longer holds"),
778
+ },
779
+ async execute(args) {
780
+ const res = await api.post("/invalidate", args)
781
+ return res ? `Invalidated ${args.node_id}` : "Reasoning graph is unavailable."
782
+ },
783
+ }),
784
+
785
+ get_status: tool({
786
+ description:
787
+ "Get the session's reasoning status: decisions with validation state, reasoning debt (unresolved questions, contradictions, unsupported claims), and top risks.",
788
+ args: {},
789
+ async execute() {
790
+ const [decisions, debt, risks] = await Promise.all([
791
+ api.get<{ label: string; status: string }[]>("/decisions"),
792
+ api.get<{ items: { debt_type: string; description: string }[]; total_score: number }>("/debt"),
793
+ api.get<{ label: string; impact_score: number }[]>("/risks"),
794
+ ])
795
+ if (!decisions && !debt) return "Reasoning graph is unavailable."
796
+ const parts: string[] = []
797
+ if (Array.isArray(decisions) && decisions.length) {
798
+ parts.push(
799
+ "## Decisions\n" +
800
+ decisions.slice(0, 10).map((d) => `- [${d.status}] ${d.label}`).join("\n"),
801
+ )
802
+ }
803
+ if (debt?.items?.length) {
804
+ parts.push(
805
+ `## Reasoning debt (score ${debt.total_score})\n` +
806
+ debt.items
807
+ .slice(0, 10)
808
+ .map((i) => `- ${i.debt_type}: ${i.description}`)
809
+ .join("\n"),
810
+ )
811
+ }
812
+ if (Array.isArray(risks) && risks.length) {
813
+ parts.push(
814
+ "## Risks\n" +
815
+ risks
816
+ .slice(0, 5)
817
+ .map((r) => `- (${r.impact_score?.toFixed?.(2) ?? "?"}) ${r.label}`)
818
+ .join("\n"),
819
+ )
820
+ }
821
+ return parts.join("\n\n") || "No reasoning recorded yet."
822
+ },
823
+ }),
824
+ },
825
+ }
826
+ }
827
+
828
+ export const ArminPluginExport = ArminPlugin
829
+ export default ArminPlugin