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 +18 -0
- package/bin/armin.js +169 -0
- package/package.json +29 -0
- package/plugins/armin.ts +829 -0
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
|
+
}
|
package/plugins/armin.ts
ADDED
|
@@ -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
|