@jetrabbits/agentic 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,11 +104,11 @@ Every copied or generated file carries an internal marker. Markdown uses YAML fr
104
104
 
105
105
  ## MemPalace install and validation logs
106
106
 
107
- When MemPalace MCP is enabled during interactive install, `agentic` now reports setup progress in explicit steps so users can see what succeeded or failed:
107
+ When MemPalace MCP is enabled during interactive install, TUI install, or through `AGENTIC_ENABLE_MEMPALACE=y`, `agentic` now reports setup progress in explicit steps so users can see what succeeded or failed:
108
108
 
109
109
  1. Python availability check
110
110
  2. pip availability check
111
111
  3. `pip install mempalace`
112
- 4. Project initialization (`mempalace init` and `mempalace mine`)
112
+ 4. Explicit skip log for automatic project initialization, plus optional manual `mempalace init`/`mempalace mine` instructions
113
113
 
114
- After setup, install checks that `mempalace-mcp` is present and leaves runtime startup/tool validation to the post-install doctor smoke check. Generated MCP configs invoke `mempalace-mcp` without arguments for all supported agent targets.
114
+ If auto-install or runtime checks fail, `agentic` prints manual setup instructions and continues. After setup, install checks that `mempalace-mcp` is present and leaves runtime startup/tool validation to the post-install doctor smoke check. Generated MCP configs invoke `mempalace-mcp` without arguments for all supported agent targets.
@@ -0,0 +1,33 @@
1
+ # Agentic Stabilization v0.3.0
2
+
3
+ ## User-Facing Behavior
4
+
5
+ - Post-install doctor checks run independently for `codex`, `opencode`, `claude`, and `gemini`.
6
+ - `AGENTIC_DOCTOR_TIMEOUT_SECONDS` defaults to `10`; a timeout is reported as a doctor failure and install continues.
7
+ - Codex doctor runs non-interactively with `--ephemeral` and `--sandbox workspace-write`.
8
+ - OpenCode uses `agent-model-mapper` instead of the removed `model-checker` artifacts.
9
+ - `agent-model-mapper` writes `.opencode/opencode.json` during interactive install only after confirmation.
10
+ - `agent-model-mapper` uses `fzf` for install-time model dropdowns when available and OpenCode startup skips once all roles are mapped.
11
+ - The runtime OpenCode plugin never opens `fzf`, asks questions, or writes project files.
12
+ - Context7 no longer asks for an API key; it uses `CONTEXT7_API_KEY` when set and otherwise prints config-path guidance for adding one later.
13
+ - OpenCode MemPalace setup writes `mempalace-mcp` config without running `mempalace init` automatically.
14
+ - Telegram notification credentials are read only from `OPENCODE_TELEGRAM_BOT_TOKEN` and `OPENCODE_TELEGRAM_CHAT_ID`.
15
+ - MemPalace-enabled installs create a managed `.mempalaceignore` unless the target project already has one.
16
+ - Real Codex, OpenCode, and Telegram blackbox scenarios are part of `make test`.
17
+ - `make test-coverage` traces `agentic` through e2e runs and fails below 90% line coverage.
18
+
19
+ ## Acceptance Criteria
20
+
21
+ - Hung agent doctor commands time out and do not stop remaining selected agents from running.
22
+ - Doctor output includes timeout duration, exit status, and per-agent elapsed time.
23
+ - `extensions/opencode/plugins/model-checker.ts` and `model-checker.json` are absent.
24
+ - `extensions/opencode/opencode.json` lists `agent-model-mapper`.
25
+ - Runtime model mapper execution does not prompt or modify project files.
26
+ - Telegram plugin tests prove environment-only credentials and no secret output.
27
+ - Real blackbox tests print created files, instruction evidence, MCP usage prompts, and MemPalace fact prompts without printing Telegram secrets.
28
+
29
+ ## Operational Constraints
30
+
31
+ - `make test` now requires real `codex` and `opencode` binaries, working model auth, network access, Context7/MemPalace access, and Telegram credentials.
32
+ - Telegram credentials must never be committed or written to Agentic config.
33
+ - Coverage is line-based Bash trace coverage for the `agentic` script, not branch coverage.
@@ -54,7 +54,7 @@ OPENCODE_TELEGRAM_CHAT_ID
54
54
  - `.kilocode/mcp.json` for `kilocode`
55
55
  - `~/.gemini/antigravity/mcp_config.json` for `antigravity` (global user config)
56
56
 
57
- Interactive installs ask whether to enable Context7. If enabled, the Context7 API key is optional. Empty keys keep the install usable with default Context7 limits or rule-only fallback behavior. Non-interactive installs enable Context7 only when `CONTEXT7_API_KEY` is already set. Generated guidance requires agents to use Context7 for framework, SDK, library, and API documentation before relying on model memory when the project config is present.
57
+ Interactive installs ask whether to enable Context7. If enabled, Context7 is configured without a key unless `CONTEXT7_API_KEY` is already set; the install output prints the config path(s) and an example key placement. Non-interactive installs enable Context7 when either `AGENTIC_ENABLE_CONTEXT7=y` or `CONTEXT7_API_KEY` is set. Generated guidance requires agents to use Context7 for framework, SDK, library, and API documentation before relying on model memory when the project config is present.
58
58
 
59
59
  Directory copies are processed in batches so large specialization installs avoid spawning a separate marker/manifest process for every copied file. Manifest protection still applies: existing unmanaged files are skipped on rerun, user-modified managed files are skipped, and new generated files can be added by newer `agentic` versions.
60
60
 
@@ -104,7 +104,7 @@ The final install line prints the exact path:
104
104
  Agentic log file: /tmp/agentic-20260512-114203.ABC123
105
105
  ```
106
106
 
107
- `agentic` also runs a final doctor smoke check for selected real agent targets (`codex`, `opencode`, `claude`, `gemini`). The doctor runs `/develop-feature напиши hello world python` in a temporary copy of the project and prints one status row per selected agent. Doctor failures are reported but do not roll back or fail the install. Disable doctor for CI or cheap checks with:
107
+ `agentic` also runs a final doctor smoke check for selected real agent targets (`codex`, `opencode`, `claude`, `gemini`). The doctor runs in a temporary copy of the project and prints one status row per selected agent. OpenCode uses a lightweight pure smoke prompt instead of the full `develop-feature` command, so install-time doctor checks do not start a long SDLC workflow. Each agent has an independent timeout controlled by `AGENTIC_DOCTOR_TIMEOUT_SECONDS` and defaults to `10` seconds. Doctor failures and timeouts are reported but do not roll back or fail the install. Disable doctor for cheap checks with:
108
108
 
109
109
  ```bash
110
110
  AGENTIC_DOCTOR=0 agentic install ...
@@ -163,38 +163,34 @@ scoop install fzf
163
163
 
164
164
  ## OpenCode optional plugins
165
165
 
166
- When `opencode` is selected, interactive installs ask whether to enable Telegram notifications and the model checker. The answer is stored globally in:
166
+ When `opencode` is selected, interactive installs ask whether to enable Telegram notifications and `agent-model-mapper`. The answer is stored globally in:
167
167
 
168
168
  ```text
169
169
  ~/.config/agentic/opencode-plugins.json
170
170
  ```
171
171
 
172
- Non-interactive installs create a disabled config when no config exists. Telegram can also read `OPENCODE_TELEGRAM_BOT_TOKEN` and `OPENCODE_TELEGRAM_CHAT_ID`.
172
+ Non-interactive installs create a disabled config when no config exists. Telegram reads `OPENCODE_TELEGRAM_BOT_TOKEN` and `OPENCODE_TELEGRAM_CHAT_ID` from the environment only; tokens are not written to `~/.config/agentic/opencode-plugins.json`. When enabled, `agent-model-mapper` runs during interactive `agentic install`/`agentic tui`, uses `fzf` as a dropdown picker when available, and writes `.opencode/opencode.json` only after confirmation. OpenCode startup never prompts for model mapping; the runtime plugin only reports whether install-time mapping is already complete.
173
173
 
174
174
  ## Context7
175
175
 
176
- For `opencode`, `codex`, `claude`, `cursor`, `gemini`, `kilocode`, and `antigravity`, interactive installs ask whether to add Context7 MCP configuration. If enabled, the Context7 API key prompt is optional; leave it empty to configure Context7 without a key. Most targets use project-level files, while `antigravity` is written to the global user path `~/.gemini/antigravity/mcp_config.json`.
176
+ For `opencode`, `codex`, `claude`, `cursor`, `gemini`, `kilocode`, and `antigravity`, interactive installs ask whether to add Context7 MCP configuration. If enabled, Context7 is configured without a key unless `CONTEXT7_API_KEY` is already set in the environment. The install output prints the generated config path(s) and an example showing where to add the key later. Most targets use project-level files, while `antigravity` is written to the global user path `~/.gemini/antigravity/mcp_config.json`.
177
177
 
178
- Non-interactive installs skip Context7 unless `CONTEXT7_API_KEY` is set in the environment. Agents are instructed to use Context7 for framework, library, SDK, API, and setup documentation when the project config is present.
178
+ Non-interactive installs enable Context7 when either `AGENTIC_ENABLE_CONTEXT7=y` or `CONTEXT7_API_KEY` is set. Agents are instructed to use Context7 for framework, library, SDK, API, and setup documentation when the project config is present.
179
179
 
180
180
  ## MemPalace
181
181
 
182
- For `opencode`, `codex`, `claude`, `cursor`, `gemini`, and `antigravity`, MemPalace MCP is configured as a local Python module instead of a hosted MCP URL. Install it first:
183
-
184
- ```bash
185
- pip install mempalace
186
- ```
182
+ For `opencode`, `codex`, `claude`, `cursor`, `gemini`, `kilocode`, and `antigravity`, MemPalace MCP is configured as a local Python module instead of a hosted MCP URL. When MemPalace is enabled, `agentic` attempts to install it automatically with `pip install mempalace`.
187
183
 
188
184
  Generated configs run `mempalace-mcp` without arguments for all supported agent targets. Runtime startup and MCP tool errors are checked by the post-install doctor stage.
189
185
 
190
- During install, if MemPalace is enabled, `agentic` checks whether `mempalace-mcp` is available. For OpenCode installs, `agentic` creates `<project>` and runs `mempalace init "<project>" --yes --auto-mine`. If checks fail (for example, package not installed yet), install continues and agents fall back to standard context discovery.
186
+ During install, if MemPalace is enabled, `agentic` writes a managed `.mempalaceignore` when the target project does not already have one. OpenCode installs do not run `mempalace init` automatically; project indexing is optional and printed as a manual follow-up. If auto-install or runtime checks fail, install continues, manual setup instructions are printed, and agents fall back to standard context discovery.
191
187
 
192
- ## Real agent doctor E2E
188
+ ## Real agent blackbox E2E
193
189
 
194
- The deterministic e2e suite uses fake agent binaries and does not call models. Real agent doctor checks are opt-in because they may use network access, credentials, and model credits:
190
+ `make test` includes real Codex, OpenCode, and Telegram blackbox scenarios. The local environment must provide working `codex`, `opencode`, Context7/MemPalace access, network access, valid auth, and Telegram credentials in `OPENCODE_TELEGRAM_BOT_TOKEN` and `OPENCODE_TELEGRAM_CHAT_ID`. Secrets are redacted from test output.
195
191
 
196
192
  ```bash
197
- AGENTIC_RUN_REAL_AGENT_E2E=1 make test-real-agent-doctor
193
+ make test-real-blackbox
198
194
  ```
199
195
 
200
196
  ## Deprecated wrapper
@@ -33,9 +33,9 @@ When `agentic` installs the OpenCode extension, it configures optional plugins i
33
33
  ~/.config/agentic/opencode-plugins.json
34
34
  ```
35
35
 
36
- Telegram notifications and model checking are opt-in. If the config is absent or a plugin is disabled, the plugin returns no hooks and OpenCode continues without that behavior.
36
+ Telegram notifications and agent model mapping are opt-in. If the config is absent or a plugin is disabled, the plugin returns no hooks and OpenCode continues without that behavior.
37
37
 
38
- Telegram notifications use either the stored config values or these environment variables:
38
+ Telegram notifications read credentials from environment variables only:
39
39
 
40
40
  ```text
41
41
  OPENCODE_TELEGRAM_BOT_TOKEN
@@ -44,5 +44,7 @@ OPENCODE_TELEGRAM_CHAT_ID
44
44
 
45
45
  Non-interactive `agentic install` defaults optional plugins to disabled when no config exists.
46
46
 
47
+ `agent-model-mapper` reads roles from target `.opencode/agents/*.md` and discovers model names from `~/.config/opencode/opencode.json`, falling back to a built-in list only when that file has no model names. When enabled, interactive `agentic install`/`agentic tui` prompts for a main and fallback model per role, using `fzf` as a dropdown picker when available, and writes `.opencode/opencode.json` only after confirmation. OpenCode startup never opens `fzf` or waits for model input; the runtime plugin only reports whether install-time mapping is complete.
48
+
47
49
  For OpenCode targets, `agentic` writes generated operating guidance to `.opencode/AGENTS.md`. If OpenCode is installed
48
50
  alongside another agent target, root `AGENTS.md` is generated as well for the non-OpenCode target.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
  "plugin": [
4
- "model-checker",
4
+ "agent-model-mapper",
5
5
  "sound-notification",
6
6
  "telegram-notification"
7
7
  ],
@@ -0,0 +1,106 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import { readdir, readFile } from "node:fs/promises"
4
+ import { basename, join } from "node:path"
5
+
6
+ type AgenticPluginConfig = {
7
+ agentModelMapper?: {
8
+ enabled?: boolean
9
+ }
10
+ }
11
+
12
+ type Role = {
13
+ name: string
14
+ description: string
15
+ mode: string
16
+ }
17
+
18
+ function readAgenticConfig(): AgenticPluginConfig {
19
+ const configHome = process.env.XDG_CONFIG_HOME || join(process.env.HOME || "", ".config")
20
+ const configPath = join(configHome, "agentic", "opencode-plugins.json")
21
+
22
+ try {
23
+ return JSON.parse(readFileSync(configPath, "utf-8")) as AgenticPluginConfig
24
+ } catch {
25
+ return {}
26
+ }
27
+ }
28
+
29
+ function parseFrontmatter(text: string): Record<string, string> {
30
+ if (!text.startsWith("---\n")) return {}
31
+ const end = text.indexOf("\n---", 4)
32
+ if (end === -1) return {}
33
+
34
+ const result: Record<string, string> = {}
35
+ for (const line of text.slice(4, end).split("\n")) {
36
+ const index = line.indexOf(":")
37
+ if (index === -1) continue
38
+ result[line.slice(0, index).trim()] = line.slice(index + 1).trim().replace(/^['"]|['"]$/g, "")
39
+ }
40
+ return result
41
+ }
42
+
43
+ async function readRoles(directory: string): Promise<Role[]> {
44
+ const agentsDir = join(directory, ".opencode", "agents")
45
+ let entries: string[]
46
+ try {
47
+ entries = await readdir(agentsDir)
48
+ } catch {
49
+ return []
50
+ }
51
+
52
+ const roles: Role[] = []
53
+ for (const entry of entries.sort()) {
54
+ if (!entry.endsWith(".md")) continue
55
+ const path = join(agentsDir, entry)
56
+ const text = await readFile(path, "utf-8")
57
+ const frontmatter = parseFrontmatter(text)
58
+ roles.push({
59
+ name: basename(entry, ".md"),
60
+ description: frontmatter.description || "OpenCode agent",
61
+ mode: frontmatter.mode || "subagent",
62
+ })
63
+ }
64
+ return roles
65
+ }
66
+
67
+ function readJsonIfExists(path: string): unknown {
68
+ if (!existsSync(path)) return undefined
69
+ try {
70
+ return JSON.parse(readFileSync(path, "utf-8"))
71
+ } catch {
72
+ return undefined
73
+ }
74
+ }
75
+
76
+ function hasCompleteAgentModelMapping(directory: string, roles: Role[]): boolean {
77
+ const state = readJsonIfExists(join(directory, ".opencode", "agent-model-mapper.state.json")) as Record<string, any> | undefined
78
+ if (!state?.configured) return false
79
+
80
+ const config = readJsonIfExists(join(directory, ".opencode", "opencode.json")) as Record<string, any> | undefined
81
+ const agents = config?.agent
82
+ if (!agents || typeof agents !== "object") return false
83
+ return roles.every((role) => {
84
+ const agent = agents[role.name]
85
+ return agent && typeof agent === "object" && typeof agent.model === "string" && agent.model.trim().length > 0
86
+ })
87
+ }
88
+
89
+ export const AgentModelMapperPlugin: Plugin = async ({ directory }) => {
90
+ const config = readAgenticConfig()
91
+ if (!config.agentModelMapper?.enabled) return {}
92
+
93
+ const roles = await readRoles(directory)
94
+ if (!roles.length) {
95
+ console.log("agent-model-mapper: skipped because .opencode/agents/*.md was not found")
96
+ return {}
97
+ }
98
+
99
+ if (hasCompleteAgentModelMapping(directory, roles)) {
100
+ console.log("agent-model-mapper: skipped because all Agentic roles already have model mappings")
101
+ return {}
102
+ }
103
+
104
+ console.log("agent-model-mapper: install-time model mapping is required; run agentic install or agentic tui")
105
+ return {}
106
+ }
@@ -5,11 +5,24 @@ import { join } from "node:path"
5
5
  type AgenticPluginConfig = {
6
6
  telegram?: {
7
7
  enabled?: boolean
8
- botToken?: string
9
- chatId?: string
10
8
  }
11
9
  }
12
10
 
11
+ function telegramApiBaseUrl(): string {
12
+ return process.env.OPENCODE_TELEGRAM_API_BASE_URL || "https://api.telegram.org"
13
+ }
14
+
15
+ function telegramUrl(botToken: string, method: string): string {
16
+ return `${telegramApiBaseUrl()}/bot${botToken}/${method}`
17
+ }
18
+
19
+ function redactSecret(value: unknown): string {
20
+ const text = String(value)
21
+ const token = process.env.OPENCODE_TELEGRAM_BOT_TOKEN
22
+ const chatId = process.env.OPENCODE_TELEGRAM_CHAT_ID
23
+ return [token, chatId].filter(Boolean).reduce((output, secret) => output.split(secret as string).join("[redacted]"), text)
24
+ }
25
+
13
26
  function readAgenticConfig(): AgenticPluginConfig {
14
27
  const configHome = process.env.XDG_CONFIG_HOME || join(process.env.HOME || "", ".config")
15
28
  const configPath = join(configHome, "agentic", "opencode-plugins.json")
@@ -24,16 +37,13 @@ function readAgenticConfig(): AgenticPluginConfig {
24
37
  export const TelegramNotificationPlugin: Plugin = async ({ $, client, directory }) => {
25
38
  const config = readAgenticConfig()
26
39
  const telegram = config.telegram
27
- const botToken = process.env.OPENCODE_TELEGRAM_BOT_TOKEN || telegram?.botToken
28
- const chatId = process.env.OPENCODE_TELEGRAM_CHAT_ID || telegram?.chatId
40
+ const botToken = process.env.OPENCODE_TELEGRAM_BOT_TOKEN
41
+ const chatId = process.env.OPENCODE_TELEGRAM_CHAT_ID
29
42
 
30
43
  if (!telegram?.enabled || !botToken || !chatId) {
31
44
  return {}
32
45
  }
33
46
 
34
- process.env.OPENCODE_TELEGRAM_BOT_TOKEN = botToken
35
- process.env.OPENCODE_TELEGRAM_CHAT_ID = chatId
36
-
37
47
  return {
38
48
  event: async ({ event }) => {
39
49
  if (event.type === "session.idle") {
@@ -60,7 +70,7 @@ export const TelegramNotificationPlugin: Plugin = async ({ $, client, directory
60
70
  }
61
71
  }
62
72
  } catch (e) {
63
- await $`echo "Error: ${e}" >> ${directory}/.opencode/telegram-debug.log`
73
+ await $`echo "Error: ${redactSecret(e)}" >> ${directory}/.opencode/telegram-debug.log`
64
74
  }
65
75
 
66
76
  try {
@@ -74,13 +84,13 @@ export const TelegramNotificationPlugin: Plugin = async ({ $, client, directory
74
84
  )
75
85
 
76
86
  await fetch(
77
- `https://api.telegram.org/bot${botToken}/sendDocument`,
87
+ telegramUrl(botToken, "sendDocument"),
78
88
  { method: "POST", body: formData }
79
89
  )
80
90
 
81
91
  const shortText = fullText.slice(0, 3000)
82
92
  await fetch(
83
- `https://api.telegram.org/bot${botToken}/sendMessage`,
93
+ telegramUrl(botToken, "sendMessage"),
84
94
  {
85
95
  method: "POST",
86
96
  headers: { "Content-Type": "application/json" },
@@ -96,7 +106,7 @@ export const TelegramNotificationPlugin: Plugin = async ({ $, client, directory
96
106
  : messageText
97
107
 
98
108
  await fetch(
99
- `https://api.telegram.org/bot${botToken}/sendMessage`,
109
+ telegramUrl(botToken, "sendMessage"),
100
110
  {
101
111
  method: "POST",
102
112
  headers: { "Content-Type": "application/json" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetrabbits/agentic",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Agent Intelligence Configuration CLI",
5
5
  "bin": {
6
6
  "agentic": "bin/agentic.js"
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "AGENTS.md",
14
+ "MEMORY.md",
14
15
  "CHANGELOG.md",
15
16
  "UPGRADE.md",
16
17
  "LICENSE",
@@ -1,13 +0,0 @@
1
- {
2
- "models": [
3
- "openai/gpt-5.5",
4
- "opencode/big-pickle",
5
- "opencode/minimax-m2.5-free",
6
- "google/antigravity-claude-opus-4-6-thinking",
7
- "google/antigravity-claude-sonnet-4-6",
8
- "google/antigravity-gemini-3-flash",
9
- "google/antigravity-gemini-3.1-pro"
10
- ],
11
- "timeoutMs": 2000,
12
- "concurrency": 10
13
- }
@@ -1,302 +0,0 @@
1
- import type { Plugin } from "@opencode-ai/plugin"
2
- import { mkdir, readFile, writeFile } from "node:fs/promises"
3
- import { readFileSync } from "node:fs"
4
- import { join } from "node:path"
5
- import { tmpdir } from "node:os"
6
- import { spawn } from "node:child_process"
7
-
8
- interface ModelResult {
9
- model: string
10
- status: "passed" | "failed"
11
- error?: string
12
- }
13
-
14
- interface ModelCheckerConfig {
15
- models: string[]
16
- timeoutMs: number
17
- concurrency: number
18
- prompt: string
19
- probeDir: string
20
- }
21
-
22
- type CommandResult = {
23
- code: number | null
24
- output: string
25
- timedOut: boolean
26
- durationMs: number
27
- }
28
-
29
- type AgenticPluginConfig = {
30
- modelChecker?: {
31
- enabled?: boolean
32
- }
33
- }
34
-
35
- function readAgenticConfig(): AgenticPluginConfig {
36
- const configHome = process.env.XDG_CONFIG_HOME || join(process.env.HOME || "", ".config")
37
- const configPath = join(configHome, "agentic", "opencode-plugins.json")
38
-
39
- try {
40
- return JSON.parse(readFileSync(configPath, "utf-8")) as AgenticPluginConfig
41
- } catch {
42
- return {}
43
- }
44
- }
45
-
46
- async function readModelsJson(projectDir: string): Promise<ModelCheckerConfig> {
47
- const defaults: ModelCheckerConfig = {
48
- models: [],
49
- timeoutMs: 10000,
50
- concurrency: 3,
51
- prompt: "Reply with exactly: OK",
52
- probeDir: tmpdir(),
53
- }
54
-
55
- try {
56
- const filePath = join(projectDir, ".opencode", "plugins", "model-checker.json")
57
- const content = await readFile(filePath, "utf-8")
58
- const data = JSON.parse(content)
59
- if (!Array.isArray(data.models)) return defaults
60
-
61
- const models = [...new Set(data.models.map(String).filter(Boolean))]
62
- const timeoutMs = Number.isFinite(data.timeoutMs)
63
- ? Math.min(120000, Math.max(1000, Number(data.timeoutMs)))
64
- : defaults.timeoutMs
65
- const concurrency = Number.isFinite(data.concurrency)
66
- ? Math.min(10, Math.max(1, Number(data.concurrency)))
67
- : defaults.concurrency
68
- const prompt = typeof data.prompt === "string" && data.prompt.trim() ? data.prompt.trim() : defaults.prompt
69
- const probeDir = typeof data.probeDir === "string" && data.probeDir.trim() ? data.probeDir.trim() : defaults.probeDir
70
-
71
- return {
72
- models,
73
- timeoutMs,
74
- concurrency,
75
- prompt,
76
- probeDir,
77
- }
78
- } catch {
79
- return defaults
80
- }
81
- }
82
-
83
- async function runModelProbe(model: string, prompt: string, timeoutMs: number, probeDir: string): Promise<CommandResult> {
84
- return await new Promise((resolve) => {
85
- const startedAt = Date.now()
86
- const child = spawn("opencode", ["run", prompt, "-m", model, "--dir", probeDir, "--log-level", "ERROR"], {
87
- cwd: probeDir,
88
- stdio: ["ignore", "pipe", "pipe"],
89
- env: {
90
- ...process.env,
91
- OPENCODE_MODEL_CHECKER_ACTIVE: "1",
92
- },
93
- })
94
-
95
- let stdout = ""
96
- let stderr = ""
97
- let timedOut = false
98
-
99
- const timer = setTimeout(() => {
100
- timedOut = true
101
- child.kill("SIGKILL")
102
- }, timeoutMs)
103
-
104
- child.stdout.on("data", (chunk: Buffer | string) => {
105
- stdout += chunk.toString()
106
- })
107
-
108
- child.stderr.on("data", (chunk: Buffer | string) => {
109
- stderr += chunk.toString()
110
- })
111
-
112
- child.on("close", (code) => {
113
- clearTimeout(timer)
114
- resolve({
115
- code,
116
- timedOut,
117
- output: `${stdout}\n${stderr}`.toLowerCase(),
118
- durationMs: Date.now() - startedAt,
119
- })
120
- })
121
-
122
- child.on("error", (error) => {
123
- clearTimeout(timer)
124
- resolve({
125
- code: 1,
126
- timedOut,
127
- output: String(error).toLowerCase(),
128
- durationMs: Date.now() - startedAt,
129
- })
130
- })
131
- })
132
- }
133
-
134
- async function runModelChecks(
135
- models: string[],
136
- prompt: string,
137
- timeoutMs: number,
138
- concurrency: number,
139
- probeDir: string,
140
- ): Promise<CommandResult[]> {
141
- const workers = Math.min(concurrency, models.length)
142
- const results: CommandResult[] = new Array(models.length)
143
- let cursor = 0
144
-
145
- await Promise.all(
146
- Array.from({ length: workers }, async () => {
147
- while (true) {
148
- const index = cursor++
149
- if (index >= models.length) return
150
- results[index] = await runModelProbe(models[index], prompt, timeoutMs, probeDir)
151
- }
152
- }),
153
- )
154
-
155
- return results
156
- }
157
-
158
- function classifyResult(model: string, probe: CommandResult): ModelResult {
159
- if (probe.timedOut) return { model, status: "failed", error: "timeout" }
160
-
161
- if (probe.output.includes("quota") || probe.output.includes("insufficient") || probe.output.includes("429")) {
162
- return { model, status: "failed", error: "quota" }
163
- }
164
-
165
- if (probe.code !== 0) {
166
- if (/(\b4\d\d\b|\b5\d\d\b)/.test(probe.output)) {
167
- return { model, status: "failed", error: "provider error" }
168
- }
169
- return { model, status: "failed", error: `exit code ${probe.code ?? "null"}` }
170
- }
171
-
172
- if (!probe.output.includes("ok")) {
173
- return { model, status: "failed", error: "unexpected response" }
174
- }
175
-
176
- return { model, status: "passed" }
177
- }
178
-
179
- function printLists(passed: ModelResult[], failed: ModelResult[]) {
180
- console.log("\npassed_models:")
181
- for (const model of passed) {
182
- console.log(`- ${model.model}`)
183
- }
184
-
185
- console.log("\nfailed_models:")
186
- for (const model of failed) {
187
- console.log(`- ${model.model}${model.error ? ` (${model.error})` : ""}`)
188
- }
189
- }
190
-
191
- function formatTelegramMessage(passed: ModelResult[], failed: ModelResult[], selected: string): string {
192
- const passedText = passed.length ? passed.map((x) => `• ${x.model}`).join("\n") : "(none)"
193
- const failedText = failed.length
194
- ? failed.map((x) => `• ${x.model}${x.error ? ` (${x.error})` : ""}`).join("\n")
195
- : "(none)"
196
-
197
- return [
198
- "<b>LLM availability check</b>",
199
- "",
200
- `<b>passed_models (${passed.length})</b>`,
201
- passedText,
202
- "",
203
- `<b>failed_models (${failed.length})</b>`,
204
- failedText,
205
- "",
206
- `<b>selected model:</b> ${selected}`,
207
- ].join("\n")
208
- }
209
-
210
- async function sendTelegram(message: string) {
211
- const token = process.env.OPENCODE_TELEGRAM_BOT_TOKEN
212
- const chatID = process.env.OPENCODE_TELEGRAM_CHAT_ID
213
- if (!token || !chatID) return
214
-
215
- try {
216
- await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
217
- method: "POST",
218
- headers: { "Content-Type": "application/json" },
219
- body: JSON.stringify({
220
- chat_id: chatID,
221
- text: message,
222
- parse_mode: "HTML",
223
- }),
224
- })
225
- } catch {
226
- // no-op
227
- }
228
- }
229
-
230
- async function regenerateOpencodeJson(projectDir: string, selected: string, passed: string[]) {
231
- const filePath = join(projectDir, ".opencode", "opencode.json")
232
- try {
233
- const content = await readFile(filePath, "utf-8")
234
- const config = JSON.parse(content)
235
-
236
- if (!config.agent || typeof config.agent !== "object") return
237
-
238
- for (const [agentName, agentConfig] of Object.entries<any>(config.agent)) {
239
- if (!agentConfig || typeof agentConfig !== "object") continue
240
- if (agentConfig.mode !== "subagent") continue
241
-
242
- config.agent[agentName].model = selected
243
- }
244
-
245
- await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf-8")
246
- } catch {
247
- // no-op
248
- }
249
- }
250
-
251
- export const ModelCheckerPlugin: Plugin = async ({ directory }) => {
252
- const config = readAgenticConfig()
253
- if (!config.modelChecker?.enabled) {
254
- return {}
255
- }
256
-
257
- if (process.env.OPENCODE_MODEL_CHECKER_ACTIVE === "1") {
258
- return {}
259
- }
260
-
261
- if (process.argv[2] === "run") {
262
- return {}
263
- }
264
-
265
- async function runChecks() {
266
- const config = await readModelsJson(directory)
267
- const { models, prompt, timeoutMs, concurrency, probeDir } = config
268
- if (!models.length) return
269
-
270
- await mkdir(probeDir, { recursive: true })
271
-
272
- console.log("\nStarting LLM model availability check...")
273
- console.log(`- models: ${models.length}, concurrency: ${Math.min(concurrency, models.length)}, timeout: ${timeoutMs}ms`)
274
- const results: ModelResult[] = []
275
- const probes = await runModelChecks(models, prompt, timeoutMs, concurrency, probeDir)
276
- for (const [index, model] of models.entries()) {
277
- const probe = probes[index]
278
- const result = classifyResult(model, probe)
279
- results.push(result)
280
- console.log(
281
- `- checking ${model}... ${result.status === "passed" ? "OK" : `FAIL (${result.error})`} (${probe.durationMs}ms)`,
282
- )
283
- }
284
-
285
- const passedModels = results.filter((x) => x.status === "passed")
286
- const failedModels = results.filter((x) => x.status === "failed")
287
- const selected = passedModels[0]?.model ?? "none"
288
-
289
- printLists(passedModels, failedModels)
290
- console.log(`\nselected_model: ${selected}`)
291
-
292
- if (selected !== "none") {
293
- await regenerateOpencodeJson(directory, selected, passedModels.map((x) => x.model))
294
- }
295
-
296
- await sendTelegram(formatTelegramMessage(passedModels, failedModels, selected))
297
- }
298
-
299
- await runChecks()
300
-
301
- return {}
302
- }