hermes-connect-services 1.0.1 → 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 +29 -4
- package/bin/sync-hermes-config.mjs +33 -14
- package/package.json +1 -1
- package/src/hermes-home.mjs +122 -0
- package/src/roster.mjs +23 -14
- package/src/yaml-patch.mjs +22 -1
- package/tests/test-yaml-patch.mjs +26 -0
package/README.md
CHANGED
|
@@ -21,6 +21,15 @@ node hermes-connect-services/bin/sync-hermes-config.mjs --install-hooks
|
|
|
21
21
|
|
|
22
22
|
两步可以合成一次带 `--install-hooks` 的运行。
|
|
23
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
|
+
|
|
24
33
|
也可以走 npm(四个端点包 + 本包装在同一项目里,脚本会自动在 `node_modules/` 找兄弟包):
|
|
25
34
|
|
|
26
35
|
```sh
|
|
@@ -114,18 +123,34 @@ model:
|
|
|
114
123
|
|
|
115
124
|
**401 / 模型不在了** — 端点 token 轮换或上游名册变了,重跑 `sync-hermes-config.mjs`。
|
|
116
125
|
|
|
117
|
-
**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
|
+
手工兜底(可选,范围更大):
|
|
118
136
|
|
|
119
137
|
```yaml
|
|
120
138
|
hooks_auto_accept: true
|
|
121
139
|
```
|
|
122
140
|
|
|
123
|
-
|
|
141
|
+
**改完必须重启 hermes** — hook 在网关启动时注册,已运行的会话不会补挂。
|
|
124
142
|
|
|
125
|
-
|
|
126
|
-
|
|
143
|
+
**连不上回环地址(`404 no such route` 带绝对 URL,或 connection refused)** — hermes 是 Python + httpx,会读 `HTTP_PROXY` / `HTTPS_PROXY`。历史上这条会把手打回环的请求送去代理,`--install-hooks` 因此会在 hermes home 的 `.env` 里托管一段:
|
|
144
|
+
|
|
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 <<<
|
|
127
150
|
```
|
|
128
151
|
|
|
152
|
+
实测 httpx + openai SDK 通常已自行放行 `127.0.0.1`,这段属于兜底:机器换代理软件、或代理换成强制拦截型时能少一次排查。(`.env` 是 `override=True` 载入的,所以它会顶掉系统里同名的 `NO_PROXY`;要在代理侧仍走别的例外host,把它们的域名补进这四行。)
|
|
153
|
+
|
|
129
154
|
**Thoughts 太长被压缩** — `model_overrides` 里的 `context_window` 来自端点实时 roster;换模型名后重跑同步脚本同步上下文窗口。
|
|
130
155
|
|
|
131
156
|
**思考档位** — hermes 全局 `agent.reasoning_effort` 默认 `ultra`,这些端点会原样转发 OpenAI 的 `reasoning_effort`。上游不认 `ultra` 时把它降到 `high`。不想让任何模型思考就加 `--no-reasoning`。
|
|
@@ -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.0
|
|
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",
|
|
@@ -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/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
|
}
|
|
@@ -106,4 +106,30 @@ check('survives an unterminated marker (hand-edit gone wrong)', () => {
|
|
|
106
106
|
assert.match(repaired.text, /# <<< zcode-ai-connect <<</);
|
|
107
107
|
});
|
|
108
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
|
+
|
|
109
135
|
console.log(`\n${passed} passed`);
|