@link-assistant/hive-mind 2.18.0 → 2.19.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/CHANGELOG.md +32 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +3 -2
- package/src/agentic-cli-freshness.lib.mjs +118 -0
- package/src/agentic-cli-updater.lib.mjs +8 -4
- package/src/docker-sidecar.lib.mjs +17 -1
- package/src/formal-ai-image.lib.mjs +108 -10
- package/src/formal-ai-isolation.lib.mjs +15 -1
- package/src/formal-ai-runtime.lib.mjs +219 -6
- package/src/formal-ai-sidecar.lib.mjs +41 -9
- package/src/formal-ai.lib.mjs +5 -0
- package/src/hive-models.lib.mjs +181 -0
- package/src/hive-models.mjs +20 -0
- package/src/isolation-runner.lib.mjs +2 -2
- package/src/locales/en.lino +2 -1
- package/src/locales/hi.lino +2 -1
- package/src/locales/ru.lino +2 -1
- package/src/locales/zh.lino +2 -1
- package/src/model-catalogue-fetch.lib.mjs +333 -0
- package/src/model-catalogue-render.lib.mjs +191 -0
- package/src/model-catalogue-sources.lib.mjs +224 -0
- package/src/model-catalogue.lib.mjs +385 -0
- package/src/models/catalog.mjs +408 -0
- package/src/models/index.mjs +23 -362
- package/src/router-isolation.lib.mjs +103 -19
- package/src/router-routes.lib.mjs +250 -0
- package/src/router-sidecar.lib.mjs +33 -13
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.escalate.lib.mjs +3 -0
- package/src/solve.mjs +12 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +12 -0
- package/src/telegram-bot.mjs +4 -1
- package/src/telegram-models-command.lib.mjs +157 -0
- package/src/telegram-ui-messages.lib.mjs +1 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router route dialects (issue #2202).
|
|
3
|
+
*
|
|
4
|
+
* Router 1.0.0 replaced every public route with one classified namespace —
|
|
5
|
+
* `/api/health`, `/api/management/*`, `/api/services/*` — and removed all the
|
|
6
|
+
* legacy root, `/v1/*` and overlapping `/api/*` aliases (upstream router#391).
|
|
7
|
+
* The two surfaces are **disjoint**: probing `0.119.0` and `1.2.0` side by side
|
|
8
|
+
* with Hive Mind's own `serve` arguments, every path that answers on one is a
|
|
9
|
+
* 404 on the other (`docs/case-studies/issue-2202/data/measurements/
|
|
10
|
+
* router-route-comparison-2026-09-04.md`, reproducible with
|
|
11
|
+
* `experiments/issue-2202/compare-router-routes.sh`).
|
|
12
|
+
*
|
|
13
|
+
* So `--use-router` cannot hard-code either shape. It has to know which router
|
|
14
|
+
* it is talking to and spell the paths accordingly, which is what this module
|
|
15
|
+
* is: two frozen tables plus the resolution rule, and nothing else. Keeping it
|
|
16
|
+
* a leaf module means `router-isolation.lib.mjs`, `router-sidecar.lib.mjs` and
|
|
17
|
+
* the model catalogue can all derive their URLs from one place, and a future
|
|
18
|
+
* dialect is a table entry rather than a grep.
|
|
19
|
+
*
|
|
20
|
+
* Why both are supported rather than just the newest: on `1.x` the GitHub proxy
|
|
21
|
+
* moved under `/api/services/github/api/v3`, and `gh` builds a custom host's
|
|
22
|
+
* REST base as `https://<host>/api/v3/` with no path-prefix setting — a
|
|
23
|
+
* limitation the router's own release notes state twice. There is therefore no
|
|
24
|
+
* client-side configuration that lets an unmodified `gh` reach the new prefix,
|
|
25
|
+
* and moving the pin unconditionally would delete the `gh` mediation issue
|
|
26
|
+
* #2164 shipped. Until upstream lands a `gh`-reachable base, the default pin
|
|
27
|
+
* stays on the legacy dialect and `1.x` is opt-in via `HIVE_MIND_ROUTER_IMAGE`,
|
|
28
|
+
* with the trade-off reported rather than discovered.
|
|
29
|
+
*
|
|
30
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2202
|
|
31
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2164
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pre-1.0 routes. Everything hangs off the origin: Anthropic at `/v1/messages`,
|
|
36
|
+
* the OpenAI-compatible surface at `/v1`, GitHub at `/api/v3` and `/api/graphql`,
|
|
37
|
+
* git at `/git`. One `/v1/models` serves every provider the router has adopted,
|
|
38
|
+
* so there is a single catalogue endpoint rather than one per service.
|
|
39
|
+
*/
|
|
40
|
+
const LEGACY_DIALECT = Object.freeze({
|
|
41
|
+
id: 'legacy',
|
|
42
|
+
description: 'router < 1.0 (root, /v1/*, /api/v3)',
|
|
43
|
+
health: '/health',
|
|
44
|
+
// Pre-1.0 has no management namespace: token commands are CLI-only.
|
|
45
|
+
management: null,
|
|
46
|
+
services: Object.freeze({
|
|
47
|
+
anthropic: '',
|
|
48
|
+
openai: '/v1',
|
|
49
|
+
codex: '/v1',
|
|
50
|
+
qwen: '/v1',
|
|
51
|
+
// Never served on this dialect; callers must treat null as "not wired".
|
|
52
|
+
gemini: null,
|
|
53
|
+
}),
|
|
54
|
+
github: Object.freeze({
|
|
55
|
+
rest: '/api/v3',
|
|
56
|
+
graphql: '/api/graphql',
|
|
57
|
+
git: '/git',
|
|
58
|
+
}),
|
|
59
|
+
catalogues: Object.freeze([Object.freeze({ service: 'openai', path: '/v1/models', shape: 'openai' })]),
|
|
60
|
+
// An unmodified `gh` reaches the REST proxy, because its own base — /api/v3/ — is where the router serves it.
|
|
61
|
+
ghReachable: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Router >= 1.0. Three namespaces, one per route class, and one catalogue per
|
|
66
|
+
* adopted service. `/api/services/models` does not exist yet — the merged
|
|
67
|
+
* envelope is asked for upstream in link-assistant/router#417, but until it
|
|
68
|
+
* lands a client fans out.
|
|
69
|
+
*
|
|
70
|
+
* The catalogue routes answer a *superset* of the OpenAI envelope: `data` and
|
|
71
|
+
* `object` sit where OpenAI puts them, alongside `using_fallback`,
|
|
72
|
+
* `degraded_providers`, `degraded_reasons` and `catalog_conflicts`. The router
|
|
73
|
+
* keeps its own per-provider catalogue and retains the last known one when a
|
|
74
|
+
* refresh fails, so those fields are the difference between "this is live" and
|
|
75
|
+
* "this is what we last saw" — a reader that takes only `data[].id` discards
|
|
76
|
+
* the one signal that distinguishes them. Measured in
|
|
77
|
+
* `docs/case-studies/issue-2202/data/measurements/router-credentials-and-tokens-2026-09-04.md`.
|
|
78
|
+
*/
|
|
79
|
+
const CANONICAL_DIALECT = Object.freeze({
|
|
80
|
+
id: 'canonical',
|
|
81
|
+
description: 'router >= 1.0 (/api/health, /api/management/*, /api/services/*)',
|
|
82
|
+
health: '/api/health',
|
|
83
|
+
management: '/api/management',
|
|
84
|
+
services: Object.freeze({
|
|
85
|
+
anthropic: '/api/services/anthropic',
|
|
86
|
+
openai: '/api/services/openai/v1',
|
|
87
|
+
codex: '/api/services/codex/v1',
|
|
88
|
+
qwen: '/api/services/qwen/v1',
|
|
89
|
+
gemini: '/api/services/gemini',
|
|
90
|
+
}),
|
|
91
|
+
github: Object.freeze({
|
|
92
|
+
rest: '/api/services/github/api/v3',
|
|
93
|
+
graphql: '/api/services/github/api/graphql',
|
|
94
|
+
git: '/api/services/github/git',
|
|
95
|
+
}),
|
|
96
|
+
catalogues: Object.freeze([Object.freeze({ service: 'anthropic', path: '/api/services/anthropic/v1/models', shape: 'anthropic' }), Object.freeze({ service: 'openai', path: '/api/services/openai/v1/models', shape: 'openai' }), Object.freeze({ service: 'codex', path: '/api/services/codex/v1/models', shape: 'openai' }), Object.freeze({ service: 'qwen', path: '/api/services/qwen/v1/models', shape: 'openai' }), Object.freeze({ service: 'gemini', path: '/api/services/gemini/v1beta/models', shape: 'gemini' })]),
|
|
97
|
+
// `gh` has no path-prefix setting, so it cannot prepend /api/services/github.
|
|
98
|
+
ghReachable: false,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const ROUTER_ROUTE_DIALECTS = Object.freeze({
|
|
102
|
+
legacy: LEGACY_DIALECT,
|
|
103
|
+
canonical: CANONICAL_DIALECT,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** The dialect assumed when the router's version cannot be determined. */
|
|
107
|
+
export const ROUTER_DEFAULT_UNKNOWN_DIALECT = 'canonical';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Which router service each tool speaks to.
|
|
111
|
+
*
|
|
112
|
+
* `agent` is grouped with Claude because it is Anthropic-shaped, and `opencode`
|
|
113
|
+
* with plain OpenAI because it uses chat completions rather than the Codex
|
|
114
|
+
* `responses` wire API.
|
|
115
|
+
*/
|
|
116
|
+
export const ROUTER_TOOL_SERVICE = Object.freeze({
|
|
117
|
+
claude: 'anthropic',
|
|
118
|
+
agent: 'anthropic',
|
|
119
|
+
codex: 'codex',
|
|
120
|
+
opencode: 'openai',
|
|
121
|
+
qwen: 'qwen',
|
|
122
|
+
gemini: 'gemini',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Major version of a pinned router image reference.
|
|
127
|
+
*
|
|
128
|
+
* Handles `repo:1.2.0`, `repo:v1.2.0-rc.1` and a bare `1.2.0`. Returns null for
|
|
129
|
+
* a digest pin, a moving tag like `latest`, or anything else without a leading
|
|
130
|
+
* numeric component — the caller decides what an unknown version means.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} image e.g. `ghcr.io/link-assistant/router:1.2.0`
|
|
133
|
+
* @returns {number|null}
|
|
134
|
+
*/
|
|
135
|
+
export function parseRouterImageMajor(image) {
|
|
136
|
+
const raw = String(image || '').trim();
|
|
137
|
+
if (!raw) return null;
|
|
138
|
+
// Strip a digest pin first: it carries no version information at all.
|
|
139
|
+
const withoutDigest = raw.split('@')[0];
|
|
140
|
+
const lastColon = withoutDigest.lastIndexOf(':');
|
|
141
|
+
const lastSlash = withoutDigest.lastIndexOf('/');
|
|
142
|
+
// A colon before the last slash is a registry port, not a tag separator.
|
|
143
|
+
const tag = lastColon > lastSlash ? withoutDigest.slice(lastColon + 1) : withoutDigest;
|
|
144
|
+
const match = /^v?(\d+)\./.exec(tag.trim());
|
|
145
|
+
if (!match) return null;
|
|
146
|
+
return Number(match[1]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decide which route dialect a router speaks.
|
|
151
|
+
*
|
|
152
|
+
* Resolution order, most explicit first:
|
|
153
|
+
*
|
|
154
|
+
* 1. `HIVE_MIND_ROUTER_ROUTES` — an escape hatch for an operator running a
|
|
155
|
+
* build whose tag does not describe it (a fork, a digest pin, `latest`).
|
|
156
|
+
* 2. The pinned image tag, which is what Hive Mind itself starts.
|
|
157
|
+
* 3. {@link ROUTER_DEFAULT_UNKNOWN_DIALECT}, because an untagged or moving
|
|
158
|
+
* reference is far more likely to be a recent build than a pre-1.0 one.
|
|
159
|
+
*
|
|
160
|
+
* @returns {{dialect: object, source: 'env'|'image'|'default', error: string|null}}
|
|
161
|
+
*/
|
|
162
|
+
export function resolveRouterRouteDialect({ image = null, env = process.env } = {}) {
|
|
163
|
+
const explicit = String(env?.HIVE_MIND_ROUTER_ROUTES || '')
|
|
164
|
+
.trim()
|
|
165
|
+
.toLowerCase();
|
|
166
|
+
if (explicit) {
|
|
167
|
+
const chosen = ROUTER_ROUTE_DIALECTS[explicit];
|
|
168
|
+
if (chosen) return { dialect: chosen, source: 'env', error: null };
|
|
169
|
+
return {
|
|
170
|
+
dialect: ROUTER_ROUTE_DIALECTS[ROUTER_DEFAULT_UNKNOWN_DIALECT],
|
|
171
|
+
source: 'default',
|
|
172
|
+
error: `HIVE_MIND_ROUTER_ROUTES must be one of ${Object.keys(ROUTER_ROUTE_DIALECTS).join(', ')}: ${explicit}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const major = parseRouterImageMajor(image);
|
|
176
|
+
if (major === null) {
|
|
177
|
+
return { dialect: ROUTER_ROUTE_DIALECTS[ROUTER_DEFAULT_UNKNOWN_DIALECT], source: 'default', error: null };
|
|
178
|
+
}
|
|
179
|
+
return { dialect: major >= 1 ? CANONICAL_DIALECT : LEGACY_DIALECT, source: 'image', error: null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const trimOrigin = baseUrl => String(baseUrl || '').replace(/\/+$/, '');
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Join an origin and a dialect path into a base URL a client can be handed.
|
|
186
|
+
*
|
|
187
|
+
* @returns {string|null} null when either side is missing, so "not served on
|
|
188
|
+
* this dialect" stays distinguishable from "served at the origin" (the
|
|
189
|
+
* Anthropic case on the legacy dialect, where the path is an empty string).
|
|
190
|
+
*/
|
|
191
|
+
export function buildRouterRouteUrl(baseUrl, path) {
|
|
192
|
+
const origin = trimOrigin(baseUrl);
|
|
193
|
+
if (!origin || path === null || path === undefined) return null;
|
|
194
|
+
return `${origin}${path}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Base URL for one router service, e.g. `anthropic` or `codex`.
|
|
199
|
+
*
|
|
200
|
+
* @returns {string|null} null when the dialect does not serve it.
|
|
201
|
+
*/
|
|
202
|
+
export function buildRouterServiceUrl({ baseUrl, dialect, service } = {}) {
|
|
203
|
+
if (!dialect || !service) return null;
|
|
204
|
+
const path = dialect.services?.[service];
|
|
205
|
+
if (path === null || path === undefined) return null;
|
|
206
|
+
return buildRouterRouteUrl(baseUrl, path);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Base URL for the service a given tool talks to.
|
|
211
|
+
*
|
|
212
|
+
* @returns {string|null}
|
|
213
|
+
*/
|
|
214
|
+
export function buildRouterToolServiceUrl({ baseUrl, dialect, tool } = {}) {
|
|
215
|
+
const service = ROUTER_TOOL_SERVICE[String(tool || '').toLowerCase()];
|
|
216
|
+
if (!service) return null;
|
|
217
|
+
return buildRouterServiceUrl({ baseUrl, dialect, service });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Absolute URL of the router's unauthenticated health endpoint. */
|
|
221
|
+
export function buildRouterHealthUrl({ baseUrl, dialect } = {}) {
|
|
222
|
+
return buildRouterRouteUrl(baseUrl, dialect?.health);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Absolute model-catalogue endpoints for a dialect, with the response shape each
|
|
227
|
+
* one returns so a caller can normalise without sniffing.
|
|
228
|
+
*
|
|
229
|
+
* @returns {Array<{service: string, url: string, shape: 'anthropic'|'openai'|'gemini'}>}
|
|
230
|
+
*/
|
|
231
|
+
export function buildRouterCatalogueEndpoints({ baseUrl, dialect } = {}) {
|
|
232
|
+
if (!dialect) return [];
|
|
233
|
+
const endpoints = [];
|
|
234
|
+
for (const entry of dialect.catalogues || []) {
|
|
235
|
+
const url = buildRouterRouteUrl(baseUrl, entry.path);
|
|
236
|
+
if (url) endpoints.push({ service: entry.service, url, shape: entry.shape });
|
|
237
|
+
}
|
|
238
|
+
return endpoints;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Git URL prefix that replaces `https://github.com/`.
|
|
243
|
+
*
|
|
244
|
+
* Always trailing-slashed: git matches `url.<prefix>.insteadOf` textually, and a
|
|
245
|
+
* missing slash silently produces `…/gitowner/repo`.
|
|
246
|
+
*/
|
|
247
|
+
export function buildRouterGitUrlPrefix({ baseUrl, dialect } = {}) {
|
|
248
|
+
const url = buildRouterRouteUrl(baseUrl, dialect?.github?.git);
|
|
249
|
+
return url ? `${url}/` : null;
|
|
250
|
+
}
|
|
@@ -30,9 +30,9 @@ import os from 'node:os';
|
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import { promisify } from 'node:util';
|
|
32
32
|
|
|
33
|
-
import { attachDockerNetwork, DEFAULT_IMAGE_TIMEOUT_MS, dockerOk, dockerText, ensureDockerVolume, ensureInternalDockerNetwork, inspectDockerContainer, readDockerImageDigest, readSidecarState, reconcileSidecarLeases, resolveSidecarStatePath, sleep, writeSidecarState } from './docker-sidecar.lib.mjs';
|
|
33
|
+
import { attachDockerNetwork, DEFAULT_IMAGE_TIMEOUT_MS, dockerErrorMessage, dockerOk, dockerText, ensureDockerVolume, ensureInternalDockerNetwork, inspectDockerContainer, readDockerImageDigest, readSidecarState, reconcileSidecarLeases, resolveSidecarStatePath, sleep, writeSidecarState } from './docker-sidecar.lib.mjs';
|
|
34
34
|
import { drainTaskSessionData } from './router-session-drain.lib.mjs';
|
|
35
|
-
import { buildRouterTaskWiringScript, getInternalRouterBaseUrl, ROUTER_CREDENTIAL_MOUNTS, ROUTER_DATA_MOUNT, ROUTER_DATA_VOLUME_NAME, ROUTER_GH_CONFIG_MOUNT, ROUTER_SIDECAR_CONTAINER_NAME,
|
|
35
|
+
import { buildRouterTaskWiringScript, getInternalRouterBaseUrl, resolveRouterBaseUrl, resolveRouterDialect, resolveRouterSidecarImage, ROUTER_CREDENTIAL_MOUNTS, ROUTER_DATA_MOUNT, ROUTER_DATA_VOLUME_NAME, ROUTER_GH_CONFIG_MOUNT, ROUTER_SIDECAR_CONTAINER_NAME, ROUTER_SIDECAR_LABEL, ROUTER_SIDECAR_NETWORK_ALIAS, ROUTER_SIDECAR_NETWORK_NAME, ROUTER_SIDECAR_PORT, ROUTER_TLS_DNS_NAMES } from './router-isolation.lib.mjs';
|
|
36
36
|
import { withStateLock } from './state-lock.lib.mjs';
|
|
37
37
|
|
|
38
38
|
const execFileAsync = promisify(execFile);
|
|
@@ -65,8 +65,10 @@ export const isRouterSidecarEnabled = (env = process.env) => {
|
|
|
65
65
|
return raw !== '0' && raw !== 'false' && raw !== 'no';
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// Image resolution and the route dialect derived from it now live in
|
|
69
|
+
// router-isolation.lib.mjs, the leaf both this module and the task wiring
|
|
70
|
+
// import (issue #2202). Re-exported so callers keep their import site.
|
|
71
|
+
export { resolveRouterDialect, resolveRouterSidecarImage };
|
|
70
72
|
|
|
71
73
|
export const resolveRouterSidecarStatePath = (env = process.env) => resolveSidecarStatePath(STATE_FILE_NAME, env);
|
|
72
74
|
|
|
@@ -135,6 +137,17 @@ export const buildRouterSidecarRunArgs = ({ image, tokenSecret, credentialMounts
|
|
|
135
137
|
args.push('--env', `${mount.envVar}=${mount.target}`, '--volume', `${mount.source}:${mount.target}${mount.readOnly ? ':ro' : ''}`);
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
// The ChatGPT backend gates its newest models behind a recent client version,
|
|
141
|
+
// and answers `Model not found` for them when the header is absent — which is
|
|
142
|
+
// why the pin is at or above 0.120.0, where the router started sending one.
|
|
143
|
+
// Its bundled default tracks a recent Codex CLI, so it is deliberately left
|
|
144
|
+
// alone by default: forwarding whatever `codex` happens to be installed here
|
|
145
|
+
// could send an *older* version than the router already claims and re-gate the
|
|
146
|
+
// models this pin exists to reach. An operator who needs a specific one sets
|
|
147
|
+
// CODEX_CLIENT_VERSION and it is passed straight through.
|
|
148
|
+
const codexClientVersion = String(env?.CODEX_CLIENT_VERSION || '').trim();
|
|
149
|
+
if (codexClientVersion) args.push('--env', `CODEX_CLIENT_VERSION=${codexClientVersion}`);
|
|
150
|
+
|
|
138
151
|
const extraArgs = String(env?.HIVE_MIND_ROUTER_EXTRA_ARGS || '').trim();
|
|
139
152
|
if (extraArgs) args.push(...extraArgs.split(/\s+/));
|
|
140
153
|
|
|
@@ -151,8 +164,12 @@ export const buildRouterSidecarRunArgs = ({ image, tokenSecret, credentialMounts
|
|
|
151
164
|
* `bun`, which is used here as the HTTP client instead of adding a dependency to
|
|
152
165
|
* an image Hive Mind does not own.
|
|
153
166
|
*/
|
|
154
|
-
export const checkRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
|
|
155
|
-
|
|
167
|
+
export const checkRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs = 30_000, env = process.env, dialect = null } = {}) => {
|
|
168
|
+
// /health on router 0.x, /api/health on 1.x — and a 404 either way if the
|
|
169
|
+
// wrong one is asked for, which would read as "unhealthy" and stall the
|
|
170
|
+
// acquire loop until it gave up (issue #2202).
|
|
171
|
+
const healthPath = (dialect || resolveRouterDialect({ env }).dialect).health;
|
|
172
|
+
const probe = `fetch("https://127.0.0.1:${ROUTER_SIDECAR_PORT}${healthPath}").then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))`;
|
|
156
173
|
// The certificate names the alias, not 127.0.0.1, and this probe is a
|
|
157
174
|
// liveness check on a loopback socket inside the container — there is no
|
|
158
175
|
// network for anyone to sit in the middle of. Verification is disabled for
|
|
@@ -160,9 +177,12 @@ export const checkRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_
|
|
|
160
177
|
return dockerOk(run, ['exec', '--env', 'NODE_TLS_REJECT_UNAUTHORIZED=0', containerName, 'bun', '-e', probe], { timeoutMs });
|
|
161
178
|
};
|
|
162
179
|
|
|
163
|
-
export const waitForRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, attempts = DEFAULT_HEALTH_ATTEMPTS, delayMs = DEFAULT_HEALTH_DELAY_MS, sleepImpl = sleep, log = null, verbose = false } = {}) => {
|
|
180
|
+
export const waitForRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, attempts = DEFAULT_HEALTH_ATTEMPTS, delayMs = DEFAULT_HEALTH_DELAY_MS, sleepImpl = sleep, log = null, verbose = false, env = process.env, dialect = null } = {}) => {
|
|
181
|
+
// Resolved once rather than per attempt: the answer cannot change between
|
|
182
|
+
// them, and probing the wrong path would fail every attempt identically.
|
|
183
|
+
const routes = dialect || resolveRouterDialect({ env }).dialect;
|
|
164
184
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
165
|
-
if (await checkRouterSidecarHealth({ containerName, run })) {
|
|
185
|
+
if (await checkRouterSidecarHealth({ containerName, run, env, dialect: routes })) {
|
|
166
186
|
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: healthy after ${attempt} attempt(s)`);
|
|
167
187
|
return { healthy: true, attempts: attempt };
|
|
168
188
|
}
|
|
@@ -220,7 +240,7 @@ export const issueRouterTaskToken = async ({ sessionId, containerName = ROUTER_S
|
|
|
220
240
|
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: issued token ${tokenId || '(id unknown)'} for '${sessionId}'`);
|
|
221
241
|
return { token, tokenId, error: null };
|
|
222
242
|
} catch (error) {
|
|
223
|
-
return { token: null, tokenId: null, error:
|
|
243
|
+
return { token: null, tokenId: null, error: dockerErrorMessage(error) };
|
|
224
244
|
}
|
|
225
245
|
};
|
|
226
246
|
|
|
@@ -355,7 +375,7 @@ export const acquireRouterSidecar = async ({ sessionId, githubRepo = null, env =
|
|
|
355
375
|
try {
|
|
356
376
|
await dockerText(run, buildRouterSidecarRunArgs({ image, tokenSecret, credentialMounts, env }), { timeoutMs });
|
|
357
377
|
} catch (error) {
|
|
358
|
-
return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error:
|
|
378
|
+
return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: dockerErrorMessage(error) };
|
|
359
379
|
}
|
|
360
380
|
startedAt = now().toISOString();
|
|
361
381
|
if (log) await log(`🔀 Router sidecar started with ${credentialMounts.length} credential mount(s); tasks will not receive vendor credentials directly`);
|
|
@@ -365,7 +385,7 @@ export const acquireRouterSidecar = async ({ sessionId, githubRepo = null, env =
|
|
|
365
385
|
// acquire, including the ones that reuse a running container.
|
|
366
386
|
await attachDockerNetwork({ network: ROUTER_SIDECAR_NETWORK_NAME, container: ROUTER_SIDECAR_CONTAINER_NAME, alias: ROUTER_SIDECAR_NETWORK_ALIAS, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
367
387
|
|
|
368
|
-
const health = await waitForRouterSidecarHealth({ run, attempts: healthAttempts, delayMs: healthDelayMs, sleepImpl, log, verbose });
|
|
388
|
+
const health = await waitForRouterSidecarHealth({ run, attempts: healthAttempts, delayMs: healthDelayMs, sleepImpl, log, verbose, env });
|
|
369
389
|
if (!health.healthy) {
|
|
370
390
|
return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: 'router sidecar did not become healthy' };
|
|
371
391
|
}
|
|
@@ -432,7 +452,7 @@ export const registerRouterProvider = async ({ providerArgs, containerName = ROU
|
|
|
432
452
|
// `docker exec` bypasses the image entrypoint, so the binary is named again.
|
|
433
453
|
await dockerText(run, ['exec', containerName, 'router', ...providerArgs], { timeoutMs });
|
|
434
454
|
} catch (error) {
|
|
435
|
-
return { registered: false, error:
|
|
455
|
+
return { registered: false, error: dockerErrorMessage(error) };
|
|
436
456
|
}
|
|
437
457
|
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: registered provider '${providerArgs[providerArgs.indexOf('--name') + 1]}'`);
|
|
438
458
|
return { registered: true, error: null };
|
|
@@ -479,7 +499,7 @@ export const wireRouterTaskContainer = async ({ sessionId, tool = 'claude', base
|
|
|
479
499
|
try {
|
|
480
500
|
await dockerText(run, ['exec', '--user', '0', sessionId, 'sh', '-c', script], { timeoutMs });
|
|
481
501
|
} catch (error) {
|
|
482
|
-
return { wired: false, error:
|
|
502
|
+
return { wired: false, error: dockerErrorMessage(error) };
|
|
483
503
|
}
|
|
484
504
|
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: wired '${sessionId}' (CA installed, github=${githubMode}${routerIp ? ` via ${routerIp}` : ''})`);
|
|
485
505
|
return { wired: true, error: null };
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -65,6 +65,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
65
65
|
default: false,
|
|
66
66
|
hidden: true,
|
|
67
67
|
},
|
|
68
|
+
'tool-update': {
|
|
69
|
+
type: 'boolean',
|
|
70
|
+
description: 'Check for a newer version of the agentic CLI before starting the task (issue #2202). Use --no-tool-update to skip.',
|
|
71
|
+
default: true,
|
|
72
|
+
},
|
|
68
73
|
'tool-connection-check': {
|
|
69
74
|
type: 'boolean',
|
|
70
75
|
description: 'Perform tool connection check (enabled by default, use --no-tool-connection-check to skip). Does NOT affect model validation.',
|
package/src/solve.mjs
CHANGED
|
@@ -220,6 +220,18 @@ if (argv.subAgentModel) await validateAndExitOnInvalidClaudeSubAgentModel(argv.s
|
|
|
220
220
|
// Perform all system checks (skip tool connection check in dry-run or when --skip-tool-connection-check; model validation always runs)
|
|
221
221
|
const prepareOnly = argv.dryRun || argv.onlyPrepareCommand;
|
|
222
222
|
const skipToolConnectionCheck = prepareOnly || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
|
|
223
|
+
// Issue #2202 (R6): before we start driving the agentic CLI, make sure it is the
|
|
224
|
+
// version the task needs — a stale binary is the usual reason a brand-new model
|
|
225
|
+
// name is rejected. Best effort by design: `ensureAgenticCliFreshness` never
|
|
226
|
+
// throws, and the updater underneath refuses to swap a binary while other tasks
|
|
227
|
+
// are running. This run's own issue is excluded, because the idle gate detects
|
|
228
|
+
// tasks by scanning process command lines and would otherwise find us.
|
|
229
|
+
if (!prepareOnly && argv.toolUpdate !== false) {
|
|
230
|
+
const { describeFreshnessResult, ensureAgenticCliFreshness } = await import('./agentic-cli-freshness.lib.mjs');
|
|
231
|
+
const freshness = await ensureAgenticCliFreshness({ tools: [tool], verbose: argv.verbose, log: message => log(message, { verbose: true }), ignoreTasks: [issueUrl] });
|
|
232
|
+
const freshnessLine = describeFreshnessResult(freshness);
|
|
233
|
+
if (freshnessLine) await log(`🔄 ${freshnessLine}`);
|
|
234
|
+
}
|
|
223
235
|
const { cascadePlaywrightMcpDisable, ensureSolvePlaywrightMcpReady } = await import('./playwright-mcp.lib.mjs');
|
|
224
236
|
await cascadePlaywrightMcpDisable(argv, log);
|
|
225
237
|
if (!(await performSystemChecks(argv.minDiskSpace || 10240, skipToolConnectionCheck, argv.model, argv))) {
|
package/src/task.config.lib.mjs
CHANGED
|
@@ -68,6 +68,11 @@ export const createYargsConfig = yargsInstance =>
|
|
|
68
68
|
description: '[EXPERIMENTAL] Route model traffic through the hive-mind-router sidecar instead of mounting AI credentials into the task container (issue #2164)',
|
|
69
69
|
default: false,
|
|
70
70
|
})
|
|
71
|
+
.option('tool-update', {
|
|
72
|
+
type: 'boolean',
|
|
73
|
+
description: 'Check for a newer version of the agentic CLI before starting the task (issue #2202). Use --no-tool-update to skip.',
|
|
74
|
+
default: true,
|
|
75
|
+
})
|
|
71
76
|
.option('screen-name', {
|
|
72
77
|
type: 'string',
|
|
73
78
|
description: 'Screen session name when --isolation screen is used',
|
package/src/task.mjs
CHANGED
|
@@ -43,6 +43,7 @@ if (earlyArgs.length === 0 || earlyArgs.includes('--help') || earlyArgs.includes
|
|
|
43
43
|
console.log(' --model, -m Model to use');
|
|
44
44
|
console.log(' --isolation agent-commander isolation mode [default: docker]');
|
|
45
45
|
console.log(' --use-router [EXPERIMENTAL] Route model traffic through the hive-mind-router sidecar (issue #2164)');
|
|
46
|
+
console.log(' --no-tool-update Skip the agentic CLI version check before starting');
|
|
46
47
|
console.log(' --dry-run Print split output without creating GitHub issues');
|
|
47
48
|
console.log(' --verbose, -v Enable verbose logging');
|
|
48
49
|
console.log(' --output-format Output format (text or json) [default: text]');
|
|
@@ -293,6 +294,17 @@ try {
|
|
|
293
294
|
await log(formatAligned('🔒', 'Isolation:', argv.isolation));
|
|
294
295
|
await log(formatAligned('✂️', 'Split mode:', argv.split ? `enabled (count: ${argv.splitCount})` : 'disabled'));
|
|
295
296
|
|
|
297
|
+
// Issue #2202 (R6): refresh the agentic CLI before the run drives it, for the
|
|
298
|
+
// same reason /solve does — and, for the same reason, not on a dry run, which
|
|
299
|
+
// drives no CLI at all. Never fatal: a registry outage must not cost the
|
|
300
|
+
// operator their task.
|
|
301
|
+
if (!argv.dryRun && argv.toolUpdate !== false) {
|
|
302
|
+
const { describeFreshnessResult, ensureAgenticCliFreshness } = await import('./agentic-cli-freshness.lib.mjs');
|
|
303
|
+
const freshness = await ensureAgenticCliFreshness({ tools: [argv.tool], verbose: argv.verbose, log: message => log(message, { verbose: true }), ignoreTasks: argv.split ? [taskInput] : [] });
|
|
304
|
+
const freshnessLine = describeFreshnessResult(freshness);
|
|
305
|
+
if (freshnessLine) await log(`🔄 ${freshnessLine}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
296
308
|
const result = argv.split ? await runSplitMode() : await runClarifyOrDecomposeMode();
|
|
297
309
|
|
|
298
310
|
if (argv.outputFormat === 'json') {
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -471,6 +471,9 @@ const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs'
|
|
|
471
471
|
registerMergeCommand(bot, sharedCommandOpts);
|
|
472
472
|
const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
|
|
473
473
|
const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, { ...sharedCommandOpts, getSolveQueue, safeReply, resolveLocale: resolveLocaleFromTelegramCtx });
|
|
474
|
+
// Issue #2202 (R5): /models lists the merged model catalogue per tool.
|
|
475
|
+
const { registerModelsCommand } = await import('./telegram-models-command.lib.mjs');
|
|
476
|
+
const { handleModelsCommand } = registerModelsCommand(bot, { ...sharedCommandOpts, safeReply });
|
|
474
477
|
const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.mjs'); // #1688
|
|
475
478
|
registerSubscribeCommands(bot, sharedCommandOpts);
|
|
476
479
|
const { registerTaskCommands } = await import('./telegram-task-command.lib.mjs');
|
|
@@ -1027,7 +1030,7 @@ bot.on('message', async (ctx, next) => {
|
|
|
1027
1030
|
const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
|
|
1028
1031
|
const taskHandlers = Object.fromEntries(TASK_COMMAND_NAMES.map(command => [command, handleTaskCommand]));
|
|
1029
1032
|
const fixHandlers = Object.fromEntries(FIX_COMMAND_NAMES.map(command => [command, handleFixCommand]));
|
|
1030
|
-
const handlers = { ...solveHandlers, ...taskHandlers, ...fixHandlers, auth: handleAuthCommand, hive: handleHiveCommand, queue: handleSolveQueueCommand };
|
|
1033
|
+
const handlers = { ...solveHandlers, ...taskHandlers, ...fixHandlers, auth: handleAuthCommand, hive: handleHiveCommand, queue: handleSolveQueueCommand, models: handleModelsCommand };
|
|
1031
1034
|
|
|
1032
1035
|
const handler = handlers[extracted.command];
|
|
1033
1036
|
if (!handler) return next();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram /models command implementation (issue #2202, R5).
|
|
3
|
+
*
|
|
4
|
+
* Shows the merged model catalogue for a tool: what this installation ships,
|
|
5
|
+
* what the live sources are serving right now, and which of the two a given
|
|
6
|
+
* model is in. Every source it reads is a listing endpoint that cannot bill a
|
|
7
|
+
* token (R7), and the answer is cached for an hour (R9), so asking often is
|
|
8
|
+
* free.
|
|
9
|
+
*
|
|
10
|
+
* Usage in chat:
|
|
11
|
+
* /models -> the default tool (claude)
|
|
12
|
+
* /models --tool codex -> one specific tool
|
|
13
|
+
* /models --all -> every tool, one message each
|
|
14
|
+
* /models --details -> add context window and pricing (R8)
|
|
15
|
+
* /models --refresh -> ignore the cache and re-read the sources
|
|
16
|
+
* /models --no-update -> skip the CLI version check (R6)
|
|
17
|
+
*
|
|
18
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2202
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { ensureAgenticCliFreshness, describeFreshnessResult } from './agentic-cli-freshness.lib.mjs';
|
|
22
|
+
import { MODEL_CATALOGUE_TOOLS, getMergedModelCatalogue } from './model-catalogue.lib.mjs';
|
|
23
|
+
import { formatModelCatalogueTelegram } from './model-catalogue-render.lib.mjs';
|
|
24
|
+
import { safeReply as defaultSafeReply } from './telegram-safe-reply.lib.mjs';
|
|
25
|
+
|
|
26
|
+
const GROUP_ONLY_MESSAGE = '❌ The /models command only works in group chats. Please add this bot to a group and make it an admin.';
|
|
27
|
+
|
|
28
|
+
/** The tool answered when the operator names none — the one Hive Mind drives by default. */
|
|
29
|
+
export const DEFAULT_MODELS_COMMAND_TOOL = 'claude';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse the argument tail of a `/models` message.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately forgiving: a chat is not a shell, so `--tool codex`,
|
|
35
|
+
* `--tool=codex`, and a bare `codex` all mean the same thing. Anything it
|
|
36
|
+
* cannot make sense of comes back as `error` so the handler can say so instead
|
|
37
|
+
* of silently answering a different question.
|
|
38
|
+
*/
|
|
39
|
+
export const parseModelsCommandArgs = (text = '') => {
|
|
40
|
+
const result = { tools: [], all: false, refresh: false, details: false, update: true, error: null };
|
|
41
|
+
const tokens = String(text).trim().split(/\s+/).slice(1).filter(Boolean);
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
44
|
+
const token = tokens[index];
|
|
45
|
+
const lower = token.toLowerCase();
|
|
46
|
+
if (lower === '--all' || lower === 'all') {
|
|
47
|
+
result.all = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (lower === '--refresh' || lower === 'refresh') {
|
|
51
|
+
result.refresh = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (lower === '--details' || lower === '--detail' || lower === 'details') {
|
|
55
|
+
result.details = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (lower === '--no-update' || lower === '--no-tool-update') {
|
|
59
|
+
result.update = false;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let value;
|
|
63
|
+
if (lower.startsWith('--tool=')) value = lower.slice('--tool='.length);
|
|
64
|
+
else if (lower === '--tool' || lower === '-t') value = (tokens[++index] ?? '').toLowerCase();
|
|
65
|
+
else if (!lower.startsWith('-')) value = lower;
|
|
66
|
+
else {
|
|
67
|
+
result.error = `Unknown option: ${token}`;
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const entry of value.split(',').filter(Boolean)) {
|
|
72
|
+
if (!MODEL_CATALOGUE_TOOLS.includes(entry)) {
|
|
73
|
+
result.error = `Unknown tool: ${entry}. Known tools: ${MODEL_CATALOGUE_TOOLS.join(', ')}`;
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
if (!result.tools.includes(entry)) result.tools.push(entry);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (result.all) result.tools = [...MODEL_CATALOGUE_TOOLS];
|
|
81
|
+
if (result.tools.length === 0) result.tools = [DEFAULT_MODELS_COMMAND_TOOL];
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Registers the /models command handler with the bot.
|
|
87
|
+
*
|
|
88
|
+
* @param {Object} bot Telegraf bot instance
|
|
89
|
+
* @param {Object} options the shared command options every telegram command takes
|
|
90
|
+
* @returns {{ handleModelsCommand: Function }} the handler, for the text fallback
|
|
91
|
+
*/
|
|
92
|
+
export function registerModelsCommand(bot, options = {}) {
|
|
93
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, safeReply, loadCatalogue = getMergedModelCatalogue, freshness = ensureAgenticCliFreshness, env = process.env } = options;
|
|
94
|
+
|
|
95
|
+
async function handleModelsCommand(ctx) {
|
|
96
|
+
VERBOSE && console.log('[VERBOSE] /models command received');
|
|
97
|
+
|
|
98
|
+
if (addBreadcrumb) {
|
|
99
|
+
await addBreadcrumb({
|
|
100
|
+
category: 'telegram.command',
|
|
101
|
+
message: '/models command received',
|
|
102
|
+
level: 'info',
|
|
103
|
+
data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const reply = (text, replyOptions = {}) => (safeReply || defaultSafeReply)(ctx, text, { reply_to_message_id: ctx.message?.message_id, ...replyOptions });
|
|
108
|
+
|
|
109
|
+
if (isOldMessage?.(ctx)) {
|
|
110
|
+
VERBOSE && console.log('[VERBOSE] /models ignored: old message');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (isForwardedOrReply?.(ctx)) {
|
|
114
|
+
VERBOSE && console.log('[VERBOSE] /models ignored: forwarded or reply');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (isGroupChat && !isGroupChat(ctx)) {
|
|
118
|
+
VERBOSE && console.log('[VERBOSE] /models ignored: not a group chat');
|
|
119
|
+
await reply(GROUP_ONLY_MESSAGE);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const authorize = isTopicAuthorized || (isChatAuthorized ? context => isChatAuthorized(context.chat.id) : () => true);
|
|
123
|
+
if (!authorize(ctx)) {
|
|
124
|
+
VERBOSE && console.log('[VERBOSE] /models ignored: not authorized');
|
|
125
|
+
await reply(buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const args = parseModelsCommandArgs(ctx.message?.text ?? '');
|
|
130
|
+
if (args.error) {
|
|
131
|
+
await reply(`❌ ${args.error}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// R6: give the agentic CLIs a chance to update before we describe what they
|
|
136
|
+
// can run. Best-effort — a failed refresh must not cost the operator their
|
|
137
|
+
// answer, so the outcome is reported and then ignored.
|
|
138
|
+
const refreshed = await freshness({ tools: args.tools, env, enabled: args.update, verbose: VERBOSE, log: async message => VERBOSE && console.log(`[VERBOSE] /models ${message}`) });
|
|
139
|
+
const freshnessLine = describeFreshnessResult(refreshed);
|
|
140
|
+
if (freshnessLine) await reply(freshnessLine);
|
|
141
|
+
|
|
142
|
+
for (const tool of args.tools) {
|
|
143
|
+
try {
|
|
144
|
+
const merged = await loadCatalogue({ tool, env, refresh: args.refresh });
|
|
145
|
+
await reply(formatModelCatalogueTelegram(merged, { details: args.details }));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
await reply(`⚠️ Could not build the ${tool} catalogue: ${error?.message ?? error}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
bot.command(/^models$/i, handleModelsCommand);
|
|
153
|
+
|
|
154
|
+
return { handleModelsCommand };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default { DEFAULT_MODELS_COMMAND_TOOL, parseModelsCommandArgs, registerModelsCommand };
|
|
@@ -92,7 +92,7 @@ export function buildTelegramHelpMessage({ locale = null, chatId, chatType = '',
|
|
|
92
92
|
message.push('');
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
const simpleCommandKeys = ['telegram.help_solve_queue', 'telegram.help_limits', 'telegram.help_version', 'telegram.help_language', 'telegram.help_accept_invites', 'telegram.help_merge', 'telegram.help_merge_usage', 'telegram.help_merge_description', 'telegram.help_subscribe', 'telegram.help_help', 'telegram.help_stop_start', 'telegram.help_stop_uuid', 'telegram.help_log', 'telegram.help_terminal_watch'];
|
|
95
|
+
const simpleCommandKeys = ['telegram.help_solve_queue', 'telegram.help_models', 'telegram.help_limits', 'telegram.help_version', 'telegram.help_language', 'telegram.help_accept_invites', 'telegram.help_merge', 'telegram.help_merge_usage', 'telegram.help_merge_description', 'telegram.help_subscribe', 'telegram.help_help', 'telegram.help_stop_start', 'telegram.help_stop_uuid', 'telegram.help_log', 'telegram.help_terminal_watch'];
|
|
96
96
|
for (const key of simpleCommandKeys) addLine(message, key, {}, locale);
|
|
97
97
|
message.push('');
|
|
98
98
|
addLine(message, 'telegram.help_notifications', {}, locale);
|