hermes-connect-services 1.0.0 → 1.1.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.
- package/README.md +39 -5
- package/bin/sync-hermes-config.mjs +33 -14
- package/package.json +2 -1
- package/src/hermes-home.mjs +122 -0
- package/src/roster.mjs +23 -14
- package/src/services.mjs +45 -5
- package/src/yaml-patch.mjs +22 -1
- package/tests/test-yaml-patch.mjs +135 -0
package/README.md
CHANGED
|
@@ -19,7 +19,25 @@ node hermes-connect-services/bin/sync-hermes-config.mjs
|
|
|
19
19
|
node hermes-connect-services/bin/sync-hermes-config.mjs --install-hooks
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
两步可以合成一次带 `--install-hooks`
|
|
22
|
+
两步可以合成一次带 `--install-hooks` 的运行。
|
|
23
|
+
|
|
24
|
+
`--install-hooks` 顺手把 hook 真正跑起来所需的两件东西一起办了(只写 config.yaml 是不够的):
|
|
25
|
+
|
|
26
|
+
| 目标文件 | 写入内容 | 少了它会怎样 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `shell-hooks-allowlist.json` | `on_session_start` 的审批条目 | hook 每次会话都被跳过,端点从不启动 |
|
|
29
|
+
| `.env` | 托管块 `NO_PROXY=127.0.0.1,…` | 代理软件会把回环调用吞掉 |
|
|
30
|
+
|
|
31
|
+
写完**重启 hermes**:hook 只在网关启动时注册。
|
|
32
|
+
|
|
33
|
+
也可以走 npm(四个端点包 + 本包装在同一项目里,脚本会自动在 `node_modules/` 找兄弟包):
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm i zcode-trae-connect zcode-workbuddy-connect zcode-qoder-connect zcode-qwenwork-connect hermes-connect-services
|
|
37
|
+
npx hermes-connect-sync --install-hooks
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
配置文件位置自动探测:
|
|
23
41
|
|
|
24
42
|
| 平台 | 路径 |
|
|
25
43
|
|---|---|
|
|
@@ -105,17 +123,33 @@ model:
|
|
|
105
123
|
|
|
106
124
|
**401 / 模型不在了** — 端点 token 轮换或上游名册变了,重跑 `sync-hermes-config.mjs`。
|
|
107
125
|
|
|
108
|
-
**hook
|
|
126
|
+
**hook 被静默跳过 —— 这是最常见的"provider temporarily unavailable"真因。** hermes 只运行 `(event, command)` 出现在 `shell-hooks-allowlist.json` 里的 hook;没列入的会被日志记一句 `not allowlisted … — skipped` 然后**永不执行**,于是四个端点从未启动,hermes 侧只看到 connection refused,一路重试到 5 个 cycle。
|
|
127
|
+
|
|
128
|
+
`--install-hooks` 现在会自己把这一对写进 allowlist(位于 hermes home,与 config.yaml 同级),并保留你其他的 hook 审批。检查:
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
node bin/sync-hermes-config.mjs --install-hooks --verbose # 应输出 hook allowlist: added|present
|
|
132
|
+
grep -c allowlisted ~/AppData/Local/hermes/logs/agent.log # 不该再增长
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
手工兜底(可选,范围更大):
|
|
109
136
|
|
|
110
137
|
```yaml
|
|
111
138
|
hooks_auto_accept: true
|
|
112
139
|
```
|
|
113
140
|
|
|
114
|
-
|
|
141
|
+
**改完必须重启 hermes** — hook 在网关启动时注册,已运行的会话不会补挂。
|
|
142
|
+
|
|
143
|
+
**连不上回环地址(`404 no such route` 带绝对 URL,或 connection refused)** — hermes 是 Python + httpx,会读 `HTTP_PROXY` / `HTTPS_PROXY`。历史上这条会把手打回环的请求送去代理,`--install-hooks` 因此会在 hermes home 的 `.env` 里托管一段:
|
|
115
144
|
|
|
116
|
-
```sh
|
|
117
|
-
NO_PROXY=127.0.0.1,localhost # PowerShell: $env:NO_PROXY="127.0.0.1,localhost"
|
|
118
145
|
```
|
|
146
|
+
# >>> zcode-ai-connect >>>
|
|
147
|
+
NO_PROXY=127.0.0.1,localhost,::1,0.0.0.0
|
|
148
|
+
no_proxy=127.0.0.1,localhost,::1,0.0.0.0
|
|
149
|
+
# <<< zcode-ai-connect <<<
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
实测 httpx + openai SDK 通常已自行放行 `127.0.0.1`,这段属于兜底:机器换代理软件、或代理换成强制拦截型时能少一次排查。(`.env` 是 `override=True` 载入的,所以它会顶掉系统里同名的 `NO_PROXY`;要在代理侧仍走别的例外host,把它们的域名补进这四行。)
|
|
119
153
|
|
|
120
154
|
**Thoughts 太长被压缩** — `model_overrides` 里的 `context_window` 来自端点实时 roster;换模型名后重跑同步脚本同步上下文窗口。
|
|
121
155
|
|
|
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
27
27
|
import { SERVICES, HOME, SUITE_ROOT, ensure as ensureService } from '../src/services.mjs';
|
|
28
28
|
import { loadRoster, describe } from '../src/roster.mjs';
|
|
29
29
|
import { patchManagedBlock, readManagedBlock, readManagedChildren } from '../src/yaml-patch.mjs';
|
|
30
|
+
import { allowHook, revokeHooks, ensureNoProxy, removeNoProxy } from '../src/hermes-home.mjs';
|
|
30
31
|
|
|
31
32
|
const HERE = pathDirname(fileURLToPath(import.meta.url));
|
|
32
33
|
const MARKER = 'zcode-ai-connect';
|
|
@@ -118,15 +119,20 @@ function overridesBlockYaml(service, models) {
|
|
|
118
119
|
function hooksBlockYaml() {
|
|
119
120
|
const nodeExe = process.execPath.replaceAll('\\', '/');
|
|
120
121
|
const script = join(HERE, 'ensure-services.mjs').replaceAll('\\', '/');
|
|
122
|
+
// The raw string hermes shlex-splits into argv; also the key it matches
|
|
123
|
+
// against shell-hooks-allowlist.json, so the two must stay byte-identical.
|
|
124
|
+
const command = `"${nodeExe}" "${script}"`;
|
|
121
125
|
// shlex.split + shell=False: the token that reaches Popen must keep both
|
|
122
126
|
// paths quoted, so the whole value is single-quoted YAML rather than two
|
|
123
127
|
// adjacent double-quoted scalars (which YAML rejects outright).
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
return {
|
|
129
|
+
command,
|
|
130
|
+
lines: [
|
|
131
|
+
`on_session_start:`,
|
|
132
|
+
` - command: '${command}'`,
|
|
133
|
+
` timeout: 30`,
|
|
134
|
+
],
|
|
135
|
+
};
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
// --- collect rosters --------------------------------------------------------
|
|
@@ -192,6 +198,10 @@ if (options.remove) {
|
|
|
192
198
|
const result = patchManagedBlock(text, key, '', { marker: MARKER });
|
|
193
199
|
text = result.text;
|
|
194
200
|
}
|
|
201
|
+
const HOME_DIR = pathDirname(CONFIG_PATH);
|
|
202
|
+
const revoked = revokeHooks(HOME_DIR, [hooksBlockYaml().command], { dryRun: options.dryRun });
|
|
203
|
+
if (revoked) console.log(` hook allowlist: removed ${revoked}`);
|
|
204
|
+
if (removeNoProxy(HOME_DIR, { dryRun: options.dryRun })) console.log(' no_proxy: removed');
|
|
195
205
|
} else {
|
|
196
206
|
const providerUpdates = [];
|
|
197
207
|
const overrideUpdates = [];
|
|
@@ -213,7 +223,8 @@ if (options.remove) {
|
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
if (options.installHooks) {
|
|
216
|
-
const
|
|
226
|
+
const hook = hooksBlockYaml();
|
|
227
|
+
const body = hook.lines.join('\n');
|
|
217
228
|
if (readManagedBlock(text, 'hooks', { marker: MARKER }).trim() !== '') {
|
|
218
229
|
text = patchManagedBlock(text, 'hooks', body, { marker: MARKER }).text;
|
|
219
230
|
changes.push('hooks: replaced');
|
|
@@ -226,6 +237,16 @@ if (options.remove) {
|
|
|
226
237
|
text = result.text;
|
|
227
238
|
changes.push(`hooks: ${result.action}`);
|
|
228
239
|
}
|
|
240
|
+
|
|
241
|
+
// Writing the hook is only half of it: hermes refuses to run any
|
|
242
|
+
// (event, command) it has not approved, and silently skips it each session.
|
|
243
|
+
const HOME_DIR = pathDirname(CONFIG_PATH);
|
|
244
|
+
const allowed = allowHook(HOME_DIR, 'on_session_start', hook.command, { dryRun: options.dryRun });
|
|
245
|
+
changes.push(`hook allowlist: ${allowed}`);
|
|
246
|
+
// httpx honours HTTP_PROXY; without this the loopback calls die as
|
|
247
|
+
// "Provider temporarily unavailable".
|
|
248
|
+
const proxied = ensureNoProxy(HOME_DIR, { dryRun: options.dryRun });
|
|
249
|
+
changes.push(`no_proxy: ${proxied}`);
|
|
229
250
|
}
|
|
230
251
|
console.log(changes.map((line) => ` ${line}`).join('\n'));
|
|
231
252
|
}
|
|
@@ -253,13 +274,11 @@ console.log(` hermes --provider ${collected[0].service.id} --model <id>`);
|
|
|
253
274
|
console.log('');
|
|
254
275
|
console.log('Token 轮换(报 401)或上游名册变化时,重跑本脚本即可。');
|
|
255
276
|
|
|
256
|
-
if (options.installHooks && !options.remove
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
console.log(' (当前值: ' + (autoAccept ?? '未设置') + ')');
|
|
262
|
-
}
|
|
277
|
+
if (options.installHooks && !options.remove) {
|
|
278
|
+
console.log('');
|
|
279
|
+
console.log('⚠ 重启 hermes 让两件事生效:');
|
|
280
|
+
console.log(' 1. on_session_start hook 重新注册(已写入 allowlist,不会再被跳过)');
|
|
281
|
+
console.log(' 2. .env 里的 NO_PROXY(否则 127.0.0.1 的请求会被 HTTP_PROXY 吞掉)');
|
|
263
282
|
}
|
|
264
283
|
|
|
265
284
|
if (options.remove) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermes-connect-services",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Bring zcode-ai-connect's four loopback endpoints to hermes: lazy-start them on session start and sync them into hermes' config.yaml as OpenAI-compatible providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"bin",
|
|
25
25
|
"src",
|
|
26
|
+
"tests",
|
|
26
27
|
"README.md",
|
|
27
28
|
"LICENSE"
|
|
28
29
|
],
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hermes-home.mjs — the two side files a shell hook needs before it will run.
|
|
3
|
+
*
|
|
4
|
+
* `config.yaml` alone is not enough to make `hooks.on_session_start` fire:
|
|
5
|
+
*
|
|
6
|
+
* 1. shell-hooks-allowlist.json — hermes only runs a hook whose exact
|
|
7
|
+
* (event, command) pair is approved. Without it the gateway logs
|
|
8
|
+
* "not allowlisted ... — skipped" on every session and the endpoints are
|
|
9
|
+
* never started. We append the pair, keeping other hooks' approvals.
|
|
10
|
+
*
|
|
11
|
+
* 2. .env NO_PROXY — the OpenAI SDK underneath hermes is httpx, which honours
|
|
12
|
+
* HTTP_PROXY. A machine-wide proxy therefore swallows every 127.0.0.1 call
|
|
13
|
+
* and the provider dies with ECONNREFUSED. The loopback endpoints must be
|
|
14
|
+
* excluded from proxying.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const ALLOWLIST_FILENAME = 'shell-hooks-allowlist.json';
|
|
21
|
+
const ENV_MARKER_START = '# >>> zcode-ai-connect >>>';
|
|
22
|
+
const ENV_MARKER_END = '# <<< zcode-ai-connect <<<';
|
|
23
|
+
const LOOPBACK = '127.0.0.1,localhost,::1,0.0.0.0';
|
|
24
|
+
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
return parsed && typeof parsed === 'object' ? parsed : { approvals: [] };
|
|
29
|
+
} catch {
|
|
30
|
+
return { approvals: [] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Approve one (event, command) pair. Idempotent: an identical pair is left
|
|
36
|
+
* alone so the approved_at timestamp does not churn on every sync.
|
|
37
|
+
*
|
|
38
|
+
* @returns {'added' | 'present' | 'skipped-dry-run'}
|
|
39
|
+
*/
|
|
40
|
+
export function allowHook(homeDir, event, command, { dryRun = false } = {}) {
|
|
41
|
+
const path = join(homeDir, ALLOWLIST_FILENAME);
|
|
42
|
+
const data = readJson(path);
|
|
43
|
+
const approvals = Array.isArray(data.approvals) ? data.approvals : [];
|
|
44
|
+
if (approvals.some((e) => e && e.event === event && e.command === command)) return 'present';
|
|
45
|
+
if (dryRun) return 'skipped-dry-run';
|
|
46
|
+
|
|
47
|
+
if (existsSync(path)) copyFileSync(path, `${path}.bak`);
|
|
48
|
+
data.approvals = [...approvals, {
|
|
49
|
+
event,
|
|
50
|
+
command,
|
|
51
|
+
approved_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
52
|
+
script_mtime_at_approval: null,
|
|
53
|
+
}];
|
|
54
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
55
|
+
return 'added';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop our own approvals (used by --remove). Other hooks stay approved. */
|
|
59
|
+
export function revokeHooks(homeDir, commands, { dryRun = false } = {}) {
|
|
60
|
+
const path = join(homeDir, ALLOWLIST_FILENAME);
|
|
61
|
+
if (!existsSync(path)) return 0;
|
|
62
|
+
const data = readJson(path);
|
|
63
|
+
const approvals = Array.isArray(data.approvals) ? data.approvals : [];
|
|
64
|
+
const kept = approvals.filter((e) => !(e && commands.includes(e.command)));
|
|
65
|
+
const removed = approvals.length - kept.length;
|
|
66
|
+
if (removed === 0 || dryRun) return removed;
|
|
67
|
+
copyFileSync(path, `${path}.bak`);
|
|
68
|
+
data.approvals = kept;
|
|
69
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
70
|
+
return removed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Make sure the loopback hosts bypass the machine proxy. The managed block is
|
|
75
|
+
* rewritten in place so repeated runs stay byte-stable.
|
|
76
|
+
*
|
|
77
|
+
* @returns {'wrote' | 'updated' | 'present' | 'skipped-dry-run'}
|
|
78
|
+
*/
|
|
79
|
+
export function ensureNoProxy(homeDir, { dryRun = false } = {}) {
|
|
80
|
+
const path = join(homeDir, '.env');
|
|
81
|
+
const body = [
|
|
82
|
+
ENV_MARKER_START,
|
|
83
|
+
`NO_PROXY=${LOOPBACK}`,
|
|
84
|
+
`no_proxy=${LOOPBACK}`,
|
|
85
|
+
ENV_MARKER_END,
|
|
86
|
+
].join('\n');
|
|
87
|
+
|
|
88
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
89
|
+
const start = text.indexOf(ENV_MARKER_START);
|
|
90
|
+
const end = text.indexOf(ENV_MARKER_END);
|
|
91
|
+
|
|
92
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
93
|
+
const current = text.slice(start, end + ENV_MARKER_END.length);
|
|
94
|
+
if (current === body) return 'present';
|
|
95
|
+
if (dryRun) return 'skipped-dry-run';
|
|
96
|
+
copyFileSync(path, `${path}.bak`);
|
|
97
|
+
writeFileSync(path, text.slice(0, start) + body + text.slice(end + ENV_MARKER_END.length), 'utf8');
|
|
98
|
+
return 'updated';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (dryRun) return 'skipped-dry-run';
|
|
102
|
+
if (existsSync(path)) copyFileSync(path, `${path}.bak`);
|
|
103
|
+
const prefix = text === '' ? '' : (text.endsWith('\n') ? '\n' : '\n\n');
|
|
104
|
+
writeFileSync(path, `${text}${prefix}${body}\n`, 'utf8');
|
|
105
|
+
return 'wrote';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove the managed NO_PROXY block (used by --remove). */
|
|
109
|
+
export function removeNoProxy(homeDir, { dryRun = false } = {}) {
|
|
110
|
+
const path = join(homeDir, '.env');
|
|
111
|
+
if (!existsSync(path)) return false;
|
|
112
|
+
const text = readFileSync(path, 'utf8');
|
|
113
|
+
const start = text.indexOf(ENV_MARKER_START);
|
|
114
|
+
const end = text.indexOf(ENV_MARKER_END);
|
|
115
|
+
if (start === -1 || end === -1) return false;
|
|
116
|
+
if (dryRun) return true;
|
|
117
|
+
copyFileSync(path, `${path}.bak`);
|
|
118
|
+
const before = text.slice(0, start).replace(/\n+$/, '\n');
|
|
119
|
+
const after = text.slice(end + ENV_MARKER_END.length).replace(/^\n+/, '');
|
|
120
|
+
writeFileSync(path, `${before}${after}`, 'utf8');
|
|
121
|
+
return true;
|
|
122
|
+
}
|
package/src/roster.mjs
CHANGED
|
@@ -16,22 +16,31 @@ import { readEndpoint } from './services.mjs';
|
|
|
16
16
|
* @returns {Promise<{ baseUrl: string, token: string, port: number, rows: Array<object> }>}
|
|
17
17
|
* @throws when the endpoint is unreachable or answers non-200.
|
|
18
18
|
*/
|
|
19
|
-
export async function loadRoster(service, { timeoutMs = 5000 } = {}) {
|
|
19
|
+
export async function loadRoster(service, { timeoutMs = 5000, attempts = 3, delayMs = 1500 } = {}) {
|
|
20
20
|
const { token, port } = await readEndpoint(service);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
// A sidecar that `ensure` just spawned answers /healthz before its roster is
|
|
22
|
+
// populated; reading once here writes a provider with zero models and hermes
|
|
23
|
+
// then shows nothing under it. Retry the empty answer, not the error.
|
|
24
|
+
let last = { baseUrl: `http://127.0.0.1:${port}/v1`, token, port, rows: [] };
|
|
25
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
26
|
+
const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
|
27
|
+
headers: token === '' ? {} : { Authorization: `Bearer ${token}` },
|
|
28
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
29
|
+
});
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(`/v1/models -> ${response.status} (端点没跑?先 serve)`);
|
|
32
|
+
}
|
|
33
|
+
const json = await response.json();
|
|
34
|
+
last = {
|
|
35
|
+
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
36
|
+
token,
|
|
37
|
+
port,
|
|
38
|
+
rows: Array.isArray(json?.data) ? json.data : [],
|
|
39
|
+
};
|
|
40
|
+
if (last.rows.length > 0 || attempt === attempts) break;
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
27
42
|
}
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
31
|
-
token,
|
|
32
|
-
port,
|
|
33
|
-
rows: Array.isArray(json?.data) ? json.data : [],
|
|
34
|
-
};
|
|
43
|
+
return last;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
/**
|
package/src/services.mjs
CHANGED
|
@@ -15,12 +15,21 @@ import { existsSync } from 'node:fs';
|
|
|
15
15
|
import { readFile } from 'node:fs/promises';
|
|
16
16
|
import { mkdir, appendFile } from 'node:fs/promises';
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
|
-
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { dirname, join, resolve, basename } from 'node:path';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Suite root = the directory the sibling tools live in.
|
|
23
|
+
*
|
|
24
|
+
* In a git checkout that is the repo root; in an npm install the module sits
|
|
25
|
+
* at `<proj>/node_modules/hermes-connect-services/src/`, so one level up from
|
|
26
|
+
* `node_modules` is what we want — otherwise every path would gain a second
|
|
27
|
+
* `node_modules`.
|
|
28
|
+
*/
|
|
29
|
+
const PACKAGE_PARENT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
22
30
|
export const SUITE_ROOT = resolve(
|
|
23
|
-
process.env.AI_CONNECT_SUITE_ROOT
|
|
31
|
+
process.env.AI_CONNECT_SUITE_ROOT
|
|
32
|
+
?? (basename(PACKAGE_PARENT) === 'node_modules' ? dirname(PACKAGE_PARENT) : PACKAGE_PARENT),
|
|
24
33
|
);
|
|
25
34
|
|
|
26
35
|
export const SERVICES = [
|
|
@@ -56,8 +65,39 @@ export const SERVICES = [
|
|
|
56
65
|
|
|
57
66
|
export const HOME = process.env.USERPROFILE ?? process.env.HOME ?? '.';
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
const toolDirCache = new Map();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Where a sibling tool actually lives.
|
|
72
|
+
*
|
|
73
|
+
* Three layouts have to work, because this module ships both as a git checkout
|
|
74
|
+
* and as an npm package:
|
|
75
|
+
*
|
|
76
|
+
* <repo>/hermes-connect-services/ + <repo>/zcode-trae-connect/ (checkout)
|
|
77
|
+
* <proj>/node_modules/zcode-trae-connect/ (npm install)
|
|
78
|
+
* <prefix>/node_modules/zcode-trae-connect/ (npm -g)
|
|
79
|
+
*
|
|
80
|
+
* `AI_CONNECT_SUITE_ROOT` wins when set, which is the escape hatch for any
|
|
81
|
+
* layout not covered above.
|
|
82
|
+
*/
|
|
83
|
+
export function toolDir(service) {
|
|
84
|
+
const cached = toolDirCache.get(service.id);
|
|
85
|
+
if (cached) return cached;
|
|
86
|
+
|
|
87
|
+
const candidates = [
|
|
88
|
+
process.env.AI_CONNECT_SUITE_ROOT ? join(process.env.AI_CONNECT_SUITE_ROOT, service.tool) : null,
|
|
89
|
+
join(SUITE_ROOT, service.tool),
|
|
90
|
+
join(SUITE_ROOT, 'node_modules', service.tool),
|
|
91
|
+
join(dirname(SUITE_ROOT), service.tool),
|
|
92
|
+
].filter(Boolean);
|
|
93
|
+
|
|
94
|
+
const resolved = candidates.find((dir) => existsSync(join(dir, 'bin', 'cli.mjs'))) ?? candidates[1];
|
|
95
|
+
toolDirCache.set(service.id, resolved);
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const cliPath = (service) => join(toolDir(service), 'bin', 'cli.mjs');
|
|
100
|
+
export const toolRoot = (service) => toolDir(service);
|
|
61
101
|
export const stateDir = (service) => join(HOME, service.state);
|
|
62
102
|
|
|
63
103
|
/** Read `{token, port}` from the tool's state dir, falling back to its default port. */
|
package/src/yaml-patch.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
const START = (marker) => `# >>> ${marker} >>>`;
|
|
21
21
|
const END = (marker) => `# <<< ${marker} <<<`;
|
|
22
|
+
/** A child key this suite owns — used to find an orphaned region's real start. */
|
|
23
|
+
const OWNS_CHILD = /^\s{0,4}zc-[A-Za-z0-9._-]+\s*:\s*$/;
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Replace the managed region inside one top-level mapping key.
|
|
@@ -156,6 +158,18 @@ function mappingRegion(lines, keyIndex) {
|
|
|
156
158
|
return { start: keyIndex + 1, end };
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Locate the managed region inside one mapping.
|
|
163
|
+
*
|
|
164
|
+
* `orphan` covers the case that actually bites in the field: hermes rewrites
|
|
165
|
+
* config.yaml from its own model (GUI model switch, `hermes config set`, …) and
|
|
166
|
+
* drops the opening marker comment while keeping the body and the closing one.
|
|
167
|
+
* Treating that as "no region" makes the next sync insert a second copy of every
|
|
168
|
+
* provider — duplicate top-level keys, and PyYAML silently keeps the last. So an
|
|
169
|
+
* orphan is claimed from its first owned child (`zc-*`) to the stray end marker
|
|
170
|
+
* and rewritten whole. Starting at the child—not at the mapping—is deliberate:
|
|
171
|
+
* a hand-written provider above ours must never be swallowed.
|
|
172
|
+
*/
|
|
159
173
|
function findManagedRegion(lines, region, startToken, endToken) {
|
|
160
174
|
let start = -1;
|
|
161
175
|
for (let i = region.start; i < region.end; i += 1) {
|
|
@@ -164,7 +178,14 @@ function findManagedRegion(lines, region, startToken, endToken) {
|
|
|
164
178
|
break;
|
|
165
179
|
}
|
|
166
180
|
}
|
|
167
|
-
if (start === -1)
|
|
181
|
+
if (start === -1) {
|
|
182
|
+
const endIndex = lines.findIndex((line, i) => i >= region.start && i < region.end && line.trim() === endToken);
|
|
183
|
+
if (endIndex === -1) return null;
|
|
184
|
+
for (let i = region.start; i < endIndex; i += 1) {
|
|
185
|
+
if (OWNS_CHILD.test(lines[i])) return { start: i, end: endIndex, orphan: true };
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
168
189
|
for (let i = start; i < region.end; i += 1) {
|
|
169
190
|
if (lines[i].trim() === endToken) return { start, end: i };
|
|
170
191
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Regressions for the YAML patcher — the one piece of this module that can
|
|
4
|
+
* corrupt a user's config, so it gets tests instead of good intentions.
|
|
5
|
+
*
|
|
6
|
+
* Run: node hermes-connect-services/tests/test-yaml-patch.mjs
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { patchManagedBlock, readManagedBlock, readManagedChildren } from '../src/yaml-patch.mjs';
|
|
10
|
+
|
|
11
|
+
const MARKER = 'zcode-ai-connect';
|
|
12
|
+
const sample = [
|
|
13
|
+
'model:',
|
|
14
|
+
' provider: nous',
|
|
15
|
+
'providers:',
|
|
16
|
+
' xiaoxin:',
|
|
17
|
+
' name: xiaoxin',
|
|
18
|
+
' base_url: https://example.test',
|
|
19
|
+
'fallback_providers: []',
|
|
20
|
+
'',
|
|
21
|
+
'# trailing comment that must survive',
|
|
22
|
+
'display:',
|
|
23
|
+
' language: zh',
|
|
24
|
+
'',
|
|
25
|
+
].join('\n');
|
|
26
|
+
|
|
27
|
+
let passed = 0;
|
|
28
|
+
const check = (name, fn) => {
|
|
29
|
+
fn();
|
|
30
|
+
passed += 1;
|
|
31
|
+
console.log(` ok ${name}`);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
console.log('test-yaml-patch');
|
|
35
|
+
|
|
36
|
+
check('inserts a managed region inside an existing mapping', () => {
|
|
37
|
+
const { text, action } = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "http://127.0.0.1:1/v1"', { marker: MARKER });
|
|
38
|
+
assert.equal(action, 'inserted');
|
|
39
|
+
assert.match(text, /providers:\n xiaoxin:/);
|
|
40
|
+
assert.match(text, /# >>> zcode-ai-connect >>>\n {2}zc-trae:/);
|
|
41
|
+
assert.match(text, /fallback_providers: \[\]/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
check('replaces its own region and leaves foreign keys alone', () => {
|
|
45
|
+
const first = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
|
|
46
|
+
const second = patchManagedBlock(first, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER }).text;
|
|
47
|
+
assert.match(second, /base_url: "b"/);
|
|
48
|
+
assert.match(second, / xiaoxin:/);
|
|
49
|
+
assert.equal(readManagedChildren(second, 'providers', { marker: MARKER }).length, 1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
check('is idempotent: re-patching identical content writes no change', () => {
|
|
53
|
+
const once = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
|
|
54
|
+
const twice = patchManagedBlock(once, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
|
|
55
|
+
assert.equal(twice.changed, false);
|
|
56
|
+
assert.equal(twice.text, once);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
check('removal restores the original bytes exactly', () => {
|
|
60
|
+
const once = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
|
|
61
|
+
const gone = patchManagedBlock(once, 'providers', '', { marker: MARKER }).text;
|
|
62
|
+
assert.equal(gone, sample);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
check('creates the top-level key when it is missing', () => {
|
|
66
|
+
const { text, action } = patchManagedBlock(sample, 'model_overrides', 'zc-trae:\n "id":\n supports_tools: true', { marker: MARKER });
|
|
67
|
+
assert.equal(action, 'inserted');
|
|
68
|
+
assert.match(text, /\nmodel_overrides:\n {2}# >>> zcode-ai-connect >>>/);
|
|
69
|
+
assert.match(text, /display:\n {2}language: zh/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
check('never touches content outside its own mapping', () => {
|
|
73
|
+
const { text } = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
|
|
74
|
+
assert.equal(readManagedBlock(text, 'model', { marker: MARKER }), '');
|
|
75
|
+
assert.match(text, /model:\n {2}provider: nous/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
check('readManagedChildren round-trips every provider id', () => {
|
|
79
|
+
const body = [
|
|
80
|
+
'zc-trae:',
|
|
81
|
+
' base_url: "http://127.0.0.1:39272/v1"',
|
|
82
|
+
' models:',
|
|
83
|
+
' "a": {}',
|
|
84
|
+
'zc-workbuddy:',
|
|
85
|
+
' base_url: "http://127.0.0.1:39271/v1"',
|
|
86
|
+
].join('\n');
|
|
87
|
+
const once = patchManagedBlock(sample, 'providers', body, { marker: MARKER }).text;
|
|
88
|
+
const children = readManagedChildren(once, 'providers', { marker: MARKER });
|
|
89
|
+
assert.deepEqual(children.map((c) => c.key), ['zc-trae', 'zc-workbuddy']);
|
|
90
|
+
assert.match(children[0].body, /models:\n {4}"a": \{\}/);
|
|
91
|
+
// Feeding them back unchanged must be a no-op — this is what keeps `--only`
|
|
92
|
+
// from silently deleting the providers it was not asked about.
|
|
93
|
+
const twice = patchManagedBlock(once, 'providers', children.map((c) => c.body).join('\n'), { marker: MARKER });
|
|
94
|
+
assert.equal(twice.text, once);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
check('survives an unterminated marker (hand-edit gone wrong)', () => {
|
|
98
|
+
// A hand-edit that dropped the closing marker leaves an orphan inside the
|
|
99
|
+
// providers mapping — the next sync must repair it, not stack a second one.
|
|
100
|
+
const broken = sample.replace(
|
|
101
|
+
'fallback_providers: []',
|
|
102
|
+
` # >>> ${MARKER} >>>\n zc-trae:\n base_url: "a"\nfallback_providers: []`,
|
|
103
|
+
);
|
|
104
|
+
const repaired = patchManagedBlock(broken, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
|
|
105
|
+
assert.equal(repaired.text.match(new RegExp(`# >>> ${MARKER} >>>`, 'g')).length, 1);
|
|
106
|
+
assert.match(repaired.text, /# <<< zcode-ai-connect <<</);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
check('reclaims an orphaned region whose opening marker hermes ate', () => {
|
|
110
|
+
// Real-world shape: hermes rewrote config.yaml from its own model and kept the
|
|
111
|
+
// body plus the closing marker, losing only the opening one. A naive patcher
|
|
112
|
+
// reads "no region" and inserts a second copy of every provider — duplicate
|
|
113
|
+
// top-level keys that PyYAML silently collapses.
|
|
114
|
+
const orphan = [
|
|
115
|
+
'providers:',
|
|
116
|
+
' xiaoxin:',
|
|
117
|
+
' name: xiaoxin',
|
|
118
|
+
' zc-trae:',
|
|
119
|
+
' base_url: "a"',
|
|
120
|
+
` # <<< ${MARKER} <<<`,
|
|
121
|
+
'fallback_providers: []',
|
|
122
|
+
'',
|
|
123
|
+
].join('\n');
|
|
124
|
+
const fixed = patchManagedBlock(orphan, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER });
|
|
125
|
+
assert.equal(fixed.action, 'replaced');
|
|
126
|
+
assert.equal(fixed.text.match(new RegExp(`# >>> ${MARKER} >>>`, 'g')).length, 1);
|
|
127
|
+
assert.equal(fixed.text.match(new RegExp(`# <<< ${MARKER} <<<`, 'g')).length, 1);
|
|
128
|
+
// The hand-written provider above ours survives; ours is rewritten.
|
|
129
|
+
assert.match(fixed.text, / {2}xiaoxin:\n {4}name: xiaoxin/);
|
|
130
|
+
assert.match(fixed.text, /base_url: "b"/);
|
|
131
|
+
// And it is stable from now on.
|
|
132
|
+
assert.equal(patchManagedBlock(fixed.text, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER }).changed, false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
console.log(`\n${passed} passed`);
|