hermes-connect-services 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hawchou1995
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,134 @@
1
- # Temporary Holding Version
1
+ # hermes-connect-services
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 zcode-ai-connect 的四个本地端点接进 [hermes](https://github.com/NousResearch/hermes-agent):打开 hermes 自动拉起服务,模型选择器里出现 Trae / WorkBuddy / Qoder / 千问办公四组模型。零依赖、零 API key、零按量计费。
4
+
5
+ ```
6
+ hermes ──▶ 127.0.0.1:39272 zcode-trae-connect ──▶ Trae CN
7
+ ──▶ 127.0.0.1:39271 zcode-workbuddy-connect ──▶ WorkBuddy
8
+ ──▶ 127.0.0.1:39270 zcode-qoder-connect ──▶ Qoder CN
9
+ ──▶ 127.0.0.1:39269 zcode-qwenwork-connect ──▶ 千问办公
10
+ ```
11
+
12
+ ## 一、安装
13
+
14
+ ```sh
15
+ # 1. 同步 provider 到 hermes 的 config.yaml(从四个端点实时拉名册与 token)
16
+ node hermes-connect-services/bin/sync-hermes-config.mjs
17
+
18
+ # 2. 打开 hermes 自动拉起服务(写入 hooks.on_session_start)
19
+ node hermes-connect-services/bin/sync-hermes-config.mjs --install-hooks
20
+ ```
21
+
22
+ 两步可以合成一次带 `--install-hooks` 的运行。配置文件位置自动探测:
23
+
24
+ | 平台 | 路径 |
25
+ |---|---|
26
+ | Windows | `%LOCALAPPDATA%\hermes\config.yaml` |
27
+ | 其他 | `~/.hermes/config.yaml` |
28
+ | 覆盖 | 环境变量 `HERMES_HOME`,或 `--config <path>` |
29
+
30
+ 每次写盘前备份为 `config.yaml.bak`。
31
+
32
+ ## 二、写进去的东西
33
+
34
+ 脚本只碰三块**标记注释**之间的区域,其余字节一个不动(注释、缩进、你的手工配置全部保留):
35
+
36
+ ```yaml
37
+ providers:
38
+ # >>> zcode-ai-connect >>>
39
+ zc-trae:
40
+ name: "Trae CN"
41
+ base_url: "http://127.0.0.1:39272/v1"
42
+ api_mode: "chat_completions"
43
+ api_key: "<端点 token>"
44
+ discover_models: false
45
+ request_timeout_seconds: 600
46
+ models:
47
+ "Doubao-Seed-2.1-Pro": {}
48
+ ...
49
+ # <<< zcode-ai-connect <<<
50
+
51
+ model_overrides:
52
+ # >>> zcode-ai-connect >>>
53
+ zc-trae:
54
+ "Doubao-Seed-2.1-Pro":
55
+ context_window: 1000000
56
+ supports_tools: true
57
+ supports_reasoning: true
58
+ supports_vision: false
59
+ # <<< zcode-ai-connect <<<
60
+
61
+ hooks:
62
+ # >>> zcode-ai-connect >>>
63
+ on_session_start:
64
+ - command: '"<node.exe>" "<...>/ensure-services.mjs"'
65
+ timeout: 30
66
+ # <<< zcode-ai-connect <<<
67
+ ```
68
+
69
+ - provider id 一律 `zc-` 前缀,与 hermes 内建(openrouter / nous / gemini …)和你自己写的 provider 互不干扰;
70
+ - `discover_models: false` —— 模型清单在同步时就写死,hermes 不必为了渲染选择器去探测一个没启动的 sidecar;
71
+ - `request_timeout_seconds: 600` —— 长思考模型排队到首帧可能要 90 秒,默认超时会掐掉合法请求;
72
+ - `--only <id>` 只重写指定 provider,其余托管内容原样保留。
73
+
74
+ ## 三、选模型
75
+
76
+ ```
77
+ /model # 交互选择
78
+ hermes --provider zc-workbuddy --model deepseek-v4.1-flash
79
+ ```
80
+
81
+ 或在 `config.yaml` 里直接改默认:
82
+
83
+ ```yaml
84
+ model:
85
+ provider: zc-workbuddy
86
+ default: deepseek-v4.1-flash
87
+ base_url: http://127.0.0.1:39271/v1
88
+ ```
89
+
90
+ ## 四、命令
91
+
92
+ | 命令 | 作用 |
93
+ |---|---|
94
+ | `node bin/sync-hermes-config.mjs` | 同步四个 provider 的模型名册与 token |
95
+ | `… --install-hooks` | 额外写入 `hooks.on_session_start` 懒启动 |
96
+ | `… --remove` | 移除本工具在 config.yaml 里的全部托管块(干净卸载) |
97
+ | `… --only zc-trae` | 只同步一个端点 |
98
+ | `… --ensure` | 同步前先把没起来的端点拉起来 |
99
+ | `… --dry-run` | 只打印要改什么,不写盘 |
100
+ | `… --no-reasoning` | 全部模型标 `supports_reasoning: false` |
101
+ | `… --config <path>` | 指定配置文件 |
102
+ | `node bin/ensure-services.mjs [--verbose]` | 单独执行懒启动(也是 hook 本体) |
103
+
104
+ ## 五、排障
105
+
106
+ **401 / 模型不在了** — 端点 token 轮换或上游名册变了,重跑 `sync-hermes-config.mjs`。
107
+
108
+ **hook 第一次不执行** — hermes 首次运行 hook 需要交互确认。非交互场景在 config.yaml 设:
109
+
110
+ ```yaml
111
+ hooks_auto_accept: true
112
+ ```
113
+
114
+ **连不上回环地址(`404 no such route` 带绝对 URL、**或 connection refused)** — hermes 是 Python + httpx,会读 `HTTP_PROXY` / `HTTPS_PROXY`。这些变量存在时本地请求会被送去代理,回环地址自然打不通。启动时带上 no_proxy:
115
+
116
+ ```sh
117
+ NO_PROXY=127.0.0.1,localhost # PowerShell: $env:NO_PROXY="127.0.0.1,localhost"
118
+ ```
119
+
120
+ **Thoughts 太长被压缩** — `model_overrides` 里的 `context_window` 来自端点实时 roster;换模型名后重跑同步脚本同步上下文窗口。
121
+
122
+ **思考档位** — hermes 全局 `agent.reasoning_effort` 默认 `ultra`,这些端点会原样转发 OpenAI 的 `reasoning_effort`。上游不认 `ultra` 时把它降到 `high`。不想让任何模型思考就加 `--no-reasoning`。
123
+
124
+ ## 六、测试
125
+
126
+ ```sh
127
+ node hermes-connect-services/tests/test-yaml-patch.mjs
128
+ ```
129
+
130
+ 覆盖的是最危险的那部分:托管块插入/替换/卸载、幂等性、`--only` 不删邻居、孤儿标记自愈。
131
+
132
+ ---
133
+
134
+ MIT License — 见仓库 [LICENSE](../LICENSE)。
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ensure-services.mjs — bring the four loopback endpoints up if they are down.
4
+ *
5
+ * This is the hermes-side half of the suite's launch-on-open design: each
6
+ * plugin expects something to be listening on its fixed port before the first
7
+ * message goes out, because the provider entry in hermes' config.yaml points
8
+ * at that port.
9
+ *
10
+ * It is installed as a hermes shell hook on `on_session_start` (see
11
+ * `--install-hooks` on sync-hermes-config.mjs) and it is safe to run by hand.
12
+ *
13
+ * Contract with hermes' shell-hook runner:
14
+ * - stdin receives a JSON payload → drain it so nothing blocks;
15
+ * - stdout must be empty or a JSON object → we print diagnostics to stderr
16
+ * only, so `--verbose` debugging never risks being parsed as a directive;
17
+ * - always exits 0 — a hook that fails the session because it could not
18
+ * start a sidecar would be worse than no hook.
19
+ *
20
+ * Usage:
21
+ * node ensure-services.mjs [--verbose] [--only zc-trae]
22
+ */
23
+
24
+ import { SERVICES, ensure, log } from '../src/services.mjs';
25
+
26
+ const argv = process.argv.slice(2);
27
+ const verbose = argv.includes('--verbose') || argv.includes('-v');
28
+ const only = argv.includes('--only') ? argv[argv.indexOf('--only') + 1] : null;
29
+ const targets = only ? SERVICES.filter((s) => s.id === only || s.tool === only) : SERVICES;
30
+
31
+ const say = (line) => process.stderr.write(`${line}\n`);
32
+
33
+ // hermes writes a hook payload to stdin; nothing here reads it, but leaving it
34
+ // undrained risks a full pipe on some platforms.
35
+ process.stdin.resume().on('data', () => {});
36
+
37
+ if (targets.length === 0) {
38
+ say(`[hermes-connect-services] unknown --only target: ${only}`);
39
+ process.exit(0);
40
+ }
41
+
42
+ const results = await Promise.all(targets.map(async (service) => {
43
+ const result = await ensure(service);
44
+ await log(service, `[hermes] ensure: ${result.action}${result.up ? '' : ` (not reachable on :${result.port})`}`);
45
+ return result;
46
+ }));
47
+
48
+ if (verbose) {
49
+ say('[hermes-connect-services]');
50
+ for (const r of results) say(` ${r.service.id.padEnd(13)} :${r.port} ${r.up ? 'up ' : 'DOWN'} ${r.action}`);
51
+ } else {
52
+ const down = results.filter((r) => !r.up);
53
+ if (down.length > 0) {
54
+ say(`[hermes-connect-services] ${down.length}/${results.length} endpoint(s) unreachable: ${down.map((r) => r.service.id).join(', ')}`);
55
+ say(' Their models will fail until started. Start one manually with:');
56
+ for (const r of down) say(` node "${import.meta.dirname}/../../${r.service.tool}/bin/cli.mjs" serve --port ${r.port}`);
57
+ say(' ...or re-run this script with --verbose.');
58
+ }
59
+ }
60
+
61
+ process.exit(0);
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-hermes-config.mjs — publish the four loopback endpoints into hermes'
4
+ * config.yaml as OpenAI-compatible custom providers.
5
+ *
6
+ * Writes exactly three managed regions and touches nothing else:
7
+ *
8
+ * providers.<id> — base_url / bearer token / model allowlist
9
+ * model_overrides.<id> — context window, tools, vision, reasoning
10
+ * hooks.on_session_start (optional, `--install-hooks`) — lazy-start script
11
+ *
12
+ * Everything outside those marker comments (vm blocks, platform tokens,
13
+ * MQTT chat settings, hand-written providers) survives untouched — the file is
14
+ * patched as text, never re-serialised from a parsed tree.
15
+ *
16
+ * A backup of the pre-run file lands next to it as `<name>.bak`.
17
+ *
18
+ * Usage:
19
+ * node sync-hermes-config.mjs [--dry-run] [--ensure] [--install-hooks]
20
+ * [--remove] [--only <zc-id>] [--config <path>]
21
+ * [--no-reasoning] [--verbose]
22
+ */
23
+
24
+ import { copyFileSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
25
+ import { dirname as pathDirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { SERVICES, HOME, SUITE_ROOT, ensure as ensureService } from '../src/services.mjs';
28
+ import { loadRoster, describe } from '../src/roster.mjs';
29
+ import { patchManagedBlock, readManagedBlock, readManagedChildren } from '../src/yaml-patch.mjs';
30
+
31
+ const HERE = pathDirname(fileURLToPath(import.meta.url));
32
+ const MARKER = 'zcode-ai-connect';
33
+
34
+ const argv = process.argv.slice(2);
35
+ const flag = (name) => argv.includes(name);
36
+ const value = (name) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : null);
37
+
38
+ const options = {
39
+ dryRun: flag('--dry-run') || flag('-n'),
40
+ ensure: flag('--ensure'),
41
+ installHooks: flag('--install-hooks'),
42
+ remove: flag('--remove'),
43
+ noReasoning: flag('--no-reasoning'),
44
+ verbose: flag('--verbose') || flag('-v'),
45
+ only: value('--only'),
46
+ config: value('--config'),
47
+ };
48
+
49
+ /** hermes keeps its user config in %LOCALAPPDATA%/hermes on Windows, ~/.hermes elsewhere. */
50
+ function locateConfig() {
51
+ if (options.config) return options.config;
52
+ const candidates = [
53
+ process.env.HERMES_HOME ? join(process.env.HERMES_HOME, 'config.yaml') : null,
54
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'hermes', 'config.yaml') : null,
55
+ join(HOME, '.hermes', 'config.yaml'),
56
+ ].filter(Boolean);
57
+ return candidates.find((p) => existsSync(p)) ?? candidates[candidates.length - 1];
58
+ }
59
+
60
+ const CONFIG_PATH = locateConfig();
61
+
62
+ /**
63
+ * True when the file already binds an `on_session_start` hook outside our
64
+ * managed region — i.e. one the user wrote, which we must not displace.
65
+ */
66
+ function ownsHookSlot(text) {
67
+ const lines = text.split(/\r?\n/);
68
+ const key = lines.findIndex((line) => /^hooks\s*:\s*(#.*)?$/.test(line));
69
+ if (key === -1) return false;
70
+ let end = lines.length;
71
+ for (let i = key + 1; i < lines.length; i += 1) {
72
+ if (/^\S/.test(lines[i]) && lines[i].trim() !== '') {
73
+ end = i;
74
+ break;
75
+ }
76
+ }
77
+ return lines.slice(key + 1, end).some((line) => /^\s+on_session_start\s*:/.test(line));
78
+ }
79
+
80
+ /** YAML double-quoted scalar — safe for ids containing `#`, `:` or leading dashes. */
81
+ const q = (input) => `"${String(input).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
82
+
83
+ /**
84
+ * One provider entry. `discover_models: false` is deliberate: the allowlist is
85
+ * written from the live roster at sync time, so hermes never has to probe a
86
+ * stopped sidecar to render its picker.
87
+ */
88
+ function providerBlockYaml(service, roster, models) {
89
+ const lines = [
90
+ `${service.id}:`,
91
+ ` name: ${q(service.name)}`,
92
+ ` base_url: ${q(roster.baseUrl)}`,
93
+ ` api_mode: "chat_completions"`,
94
+ ` api_key: ${q(roster.token)}`,
95
+ ` discover_models: false`,
96
+ // Long thinking bursts are normal on these models; the default request
97
+ // timeout would abort a legitimate 90s queue wait.
98
+ ` request_timeout_seconds: 600`,
99
+ ` models:`,
100
+ ];
101
+ for (const model of models) lines.push(` ${q(model.id)}: {}`);
102
+ return lines;
103
+ }
104
+
105
+ function overridesBlockYaml(service, models) {
106
+ if (models.length === 0) return null;
107
+ const lines = [`${service.id}:`];
108
+ for (const model of models) {
109
+ lines.push(` ${q(model.id)}:`);
110
+ if (model.contextWindow) lines.push(` context_window: ${model.contextWindow}`);
111
+ lines.push(` supports_tools: true`);
112
+ lines.push(` supports_reasoning: ${options.noReasoning ? 'false' : String(model.supportsReasoning)}`);
113
+ lines.push(` supports_vision: ${String(model.supportsVision)}`);
114
+ }
115
+ return lines;
116
+ }
117
+
118
+ function hooksBlockYaml() {
119
+ const nodeExe = process.execPath.replaceAll('\\', '/');
120
+ const script = join(HERE, 'ensure-services.mjs').replaceAll('\\', '/');
121
+ // shlex.split + shell=False: the token that reaches Popen must keep both
122
+ // paths quoted, so the whole value is single-quoted YAML rather than two
123
+ // adjacent double-quoted scalars (which YAML rejects outright).
124
+ const command = `'${`"${nodeExe}" "${script}"`}'`;
125
+ return [
126
+ `on_session_start:`,
127
+ ` - command: ${command}`,
128
+ ` timeout: 30`,
129
+ ];
130
+ }
131
+
132
+ // --- collect rosters --------------------------------------------------------
133
+
134
+ const targets = options.only
135
+ ? SERVICES.filter((s) => s.id === options.only || s.tool === options.only)
136
+ : SERVICES;
137
+ if (targets.length === 0) {
138
+ console.error(`[sync-hermes] unknown --only target: ${options.only}`);
139
+ console.error(` known ids: ${SERVICES.map((s) => s.id).join(', ')}`);
140
+ process.exit(1);
141
+ }
142
+
143
+ const collected = [];
144
+ for (const service of targets) {
145
+ if (options.ensure) {
146
+ const started = await ensureService(service);
147
+ if (options.verbose) {
148
+ console.log(` ${service.id.padEnd(13)} :${started.port} ${started.up ? 'up ' : 'DOWN'} ${started.action}`);
149
+ }
150
+ }
151
+ try {
152
+ const roster = await loadRoster(service);
153
+ const models = roster.rows.map((row) => describe(row, { assumeReasoning: !options.noReasoning }));
154
+ collected.push({ service, roster, models });
155
+ } catch (error) {
156
+ console.log(` ${service.id.padEnd(13)} skipped — ${String(error.message ?? error).slice(0, 90)}`);
157
+ }
158
+ }
159
+
160
+ if (collected.length === 0) {
161
+ console.error(`[sync-hermes] no endpoint answered; nothing written to ${CONFIG_PATH}`);
162
+ console.error(` Start them first, or re-run with --ensure.`);
163
+ process.exit(1);
164
+ }
165
+
166
+ // --- patch ------------------------------------------------------------------
167
+
168
+ /**
169
+ * Rebuild one region, keeping the blocks this run did not touch.
170
+ *
171
+ * `--only zc-trae` must not delete the other three providers, so existing
172
+ * children are read back first and only the targeted ids are overwritten.
173
+ */
174
+ function mergeInto(originalText, key, updates) {
175
+ const existing = new Map(readManagedChildren(originalText, key, { marker: MARKER }).map((b) => [b.key, b.body]));
176
+ const touched = new Set();
177
+ for (const [childKey, body] of updates) {
178
+ existing.set(childKey, body);
179
+ touched.add(childKey);
180
+ }
181
+ return {
182
+ body: [...existing.values()].join('\n'),
183
+ action: touched.size < updates.length ? 'partial' : null,
184
+ };
185
+ }
186
+
187
+ const original = readFileSync(CONFIG_PATH, 'utf8');
188
+ let text = original;
189
+
190
+ if (options.remove) {
191
+ for (const key of ['providers', 'model_overrides', 'hooks']) {
192
+ const result = patchManagedBlock(text, key, '', { marker: MARKER });
193
+ text = result.text;
194
+ }
195
+ } else {
196
+ const providerUpdates = [];
197
+ const overrideUpdates = [];
198
+ for (const entry of collected) {
199
+ providerUpdates.push([entry.service.id, providerBlockYaml(entry.service, entry.roster, entry.models).join('\n')]);
200
+ const overrides = overridesBlockYaml(entry.service, entry.models);
201
+ if (overrides) overrideUpdates.push([entry.service.id, overrides.join('\n')]);
202
+ }
203
+
204
+ const changes = [];
205
+ for (const [key, updates] of [
206
+ ['providers', providerUpdates],
207
+ ['model_overrides', overrideUpdates],
208
+ ]) {
209
+ const merged = mergeInto(original, key, updates);
210
+ const result = patchManagedBlock(text, key, merged.body, { marker: MARKER });
211
+ text = result.text;
212
+ changes.push(`${key}: ${result.action}${options.only ? ` (only ${options.only})` : ''}`);
213
+ }
214
+
215
+ if (options.installHooks) {
216
+ const body = hooksBlockYaml().join('\n');
217
+ if (readManagedBlock(text, 'hooks', { marker: MARKER }).trim() !== '') {
218
+ text = patchManagedBlock(text, 'hooks', body, { marker: MARKER }).text;
219
+ changes.push('hooks: replaced');
220
+ } else if (ownsHookSlot(original)) {
221
+ // Someone already hangs a hook on this event. Taking the slot silently
222
+ // would be the kind of surprise that ends with a deleted screenshot.
223
+ console.log('hooks: 已有非托管的 on_session_start 条目 — 跳过(用 --remove 清托管块后手动合并)');
224
+ } else {
225
+ const result = patchManagedBlock(text, 'hooks', body, { marker: MARKER });
226
+ text = result.text;
227
+ changes.push(`hooks: ${result.action}`);
228
+ }
229
+ }
230
+ console.log(changes.map((line) => ` ${line}`).join('\n'));
231
+ }
232
+
233
+ if (text === original) {
234
+ console.log(' nothing changed');
235
+ } else if (options.dryRun) {
236
+ console.log(` --dry-run: ${CONFIG_PATH} not written`);
237
+ } else {
238
+ copyFileSync(CONFIG_PATH, `${CONFIG_PATH}.bak`);
239
+ writeFileSync(CONFIG_PATH, text, 'utf8');
240
+ console.log(` wrote ${CONFIG_PATH}(backup: ${CONFIG_PATH}.bak)`);
241
+ }
242
+
243
+ // --- report -----------------------------------------------------------------
244
+
245
+ console.log('');
246
+ for (const entry of collected) {
247
+ console.log(`${entry.service.id.padEnd(13)} ${String(entry.models.length).padStart(3)} models @ ${entry.roster.baseUrl}`);
248
+ }
249
+ console.log('');
250
+ console.log('选择模型: hermes 里 `/model`,或直接');
251
+ console.log(` ${CONFIG_PATH.replaceAll('\\', '/')} -> model.provider: ${collected[0].service.id}`);
252
+ console.log(` hermes --provider ${collected[0].service.id} --model <id>`);
253
+ console.log('');
254
+ console.log('Token 轮换(报 401)或上游名册变化时,重跑本脚本即可。');
255
+
256
+ if (options.installHooks && !options.remove && !options.dryRun) {
257
+ const autoAccept = readFileSync(CONFIG_PATH, 'utf8').match(/^hooks_auto_accept:\s*(.+)$/m)?.[1]?.trim();
258
+ if (autoAccept !== 'true') {
259
+ console.log('');
260
+ console.log(`提示: 首次运行 hook 需要交互确认。非交互场景请在 ${CONFIG_PATH} 设置 hooks_auto_accept: true`);
261
+ console.log(' (当前值: ' + (autoAccept ?? '未设置') + ')');
262
+ }
263
+ }
264
+
265
+ if (options.remove) {
266
+ console.log('');
267
+ console.log(`已移除 ${CONFIG_PATH} 中的 ${MARKER} 托管块${options.dryRun ? ' (--dry-run: 未写盘)' : ''}`);
268
+ console.log(` suite root: ${SUITE_ROOT}`);
269
+ console.log(` config: ${dirname(CONFIG_PATH)}`);
270
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "hermes-connect-services",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "xiao-hans",
9
+ "url": "https://github.com/xiao-hans"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/xiao-hans/zcode-ai-connect.git",
14
+ "directory": "hermes-connect-services"
15
+ },
16
+ "homepage": "https://github.com/xiao-hans/zcode-ai-connect/tree/main/hermes-connect-services#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/xiao-hans/zcode-ai-connect/issues"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.17.0"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "bin": {
30
+ "hermes-connect-ensure": "bin/ensure-services.mjs",
31
+ "hermes-connect-sync": "bin/sync-hermes-config.mjs"
32
+ },
33
+ "dependencies": {},
34
+ "keywords": [
35
+ "hermes",
36
+ "openai-compatible",
37
+ "loopback",
38
+ "trae",
39
+ "workbuddy",
40
+ "qoder",
41
+ "qwenwork"
42
+ ]
43
+ }
package/src/roster.mjs ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Read each endpoint's live model roster.
3
+ *
4
+ * `/v1/models` is the only source of truth here: the tool's own static catalog
5
+ * files drift (a model can go LOCKED, get renamed, or gain an image input
6
+ * overnight), and the bearer token rotates. Everything the hermes provider
7
+ * entry needs comes from one probe.
8
+ *
9
+ * @module hermes-connect-services/src/roster
10
+ */
11
+
12
+ import { readEndpoint } from './services.mjs';
13
+
14
+ /**
15
+ * @param {object} service
16
+ * @returns {Promise<{ baseUrl: string, token: string, port: number, rows: Array<object> }>}
17
+ * @throws when the endpoint is unreachable or answers non-200.
18
+ */
19
+ export async function loadRoster(service, { timeoutMs = 5000 } = {}) {
20
+ const { token, port } = await readEndpoint(service);
21
+ const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
22
+ headers: token === '' ? {} : { Authorization: `Bearer ${token}` },
23
+ signal: AbortSignal.timeout(timeoutMs),
24
+ });
25
+ if (!response.ok) {
26
+ throw new Error(`/v1/models -> ${response.status} (端点没跑?先 serve)`);
27
+ }
28
+ const json = await response.json();
29
+ return {
30
+ baseUrl: `http://127.0.0.1:${port}/v1`,
31
+ token,
32
+ port,
33
+ rows: Array.isArray(json?.data) ? json.data : [],
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Normalise one roster row into what hermes needs.
39
+ *
40
+ * hermes' own picker will happily serve a bare id, but context window,
41
+ * tool-calling and vision are what decide whether a long session survives:
42
+ * get the context window wrong and compression fires halfway through a turn.
43
+ */
44
+ export function describe(row, { assumeReasoning = true } = {}) {
45
+ const context = Number.isInteger(row.context_window) && row.context_window > 0
46
+ ? row.context_window
47
+ : undefined;
48
+ const output = Number.isInteger(row.max_output_tokens) && row.max_output_tokens > 0
49
+ ? row.max_output_tokens
50
+ : undefined;
51
+ return {
52
+ id: row.id,
53
+ displayName: row.display_name ?? row.id,
54
+ contextWindow: context,
55
+ maxOutputTokens: output,
56
+ supportsVision: row.supports_images === true,
57
+ // The endpoints forward OpenAI's `reasoning_effort` verbatim, so declaring
58
+ // support is accurate for every model they expose; `--no-reasoning`
59
+ // downgrades the whole provider when a user prefers plain answers.
60
+ supportsReasoning: row.supports_images === false && row.id.startsWith('Doubao-')
61
+ ? false
62
+ : assumeReasoning,
63
+ };
64
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The four loopback endpoints this suite serves, described once so the ensure
3
+ * script and the config sync agree on ports, state dirs and provider ids.
4
+ *
5
+ * Provider ids are prefixed with `zc-` so they can never collide with a
6
+ * hermes built-in (openrouter / nous / gemini / …) or with a provider the user
7
+ * already declared by hand. Everything else about the entry is derived at sync
8
+ * time from the live endpoint, which is the only source of truth for the
9
+ * roster and the bearer token.
10
+ *
11
+ * @module hermes-connect-services/src/services
12
+ */
13
+
14
+ import { existsSync } from 'node:fs';
15
+ import { readFile } from 'node:fs/promises';
16
+ import { mkdir, appendFile } from 'node:fs/promises';
17
+ import { spawn } from 'node:child_process';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ /** Suite root = parent of this directory; overridable for relocation/tests. */
22
+ export const SUITE_ROOT = resolve(
23
+ process.env.AI_CONNECT_SUITE_ROOT ?? dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
24
+ );
25
+
26
+ export const SERVICES = [
27
+ {
28
+ id: 'zc-qwenwork',
29
+ name: '千问办公 (QwenWork)',
30
+ tool: 'zcode-qwenwork-connect',
31
+ port: 39269,
32
+ state: '.zcode-qwenwork-connect',
33
+ },
34
+ {
35
+ id: 'zc-qoder',
36
+ name: 'Qoder CN',
37
+ tool: 'zcode-qoder-connect',
38
+ port: 39270,
39
+ state: '.zcode-qoder-connect',
40
+ },
41
+ {
42
+ id: 'zc-workbuddy',
43
+ name: 'WorkBuddy',
44
+ tool: 'zcode-workbuddy-connect',
45
+ port: 39271,
46
+ state: '.zcode-workbuddy-connect',
47
+ },
48
+ {
49
+ id: 'zc-trae',
50
+ name: 'Trae CN',
51
+ tool: 'zcode-trae-connect',
52
+ port: 39272,
53
+ state: '.zcode-trae-connect',
54
+ },
55
+ ];
56
+
57
+ export const HOME = process.env.USERPROFILE ?? process.env.HOME ?? '.';
58
+
59
+ export const cliPath = (service) => join(SUITE_ROOT, service.tool, 'bin', 'cli.mjs');
60
+ export const toolRoot = (service) => join(SUITE_ROOT, service.tool);
61
+ export const stateDir = (service) => join(HOME, service.state);
62
+
63
+ /** Read `{token, port}` from the tool's state dir, falling back to its default port. */
64
+ export async function readEndpoint(service) {
65
+ try {
66
+ const document = JSON.parse(await readFile(join(stateDir(service), 'endpoint.json'), 'utf8'));
67
+ const token = typeof document.token === 'string' ? document.token : '';
68
+ const port = Number.isInteger(document.port) && document.port > 0 ? document.port : service.port;
69
+ return { token, port };
70
+ } catch {
71
+ return { token: '', port: service.port };
72
+ }
73
+ }
74
+
75
+ /** True only when the endpoint answers healthily — the same contract as every plugin's hook. */
76
+ export async function healthy(port, token, timeoutMs = 1500) {
77
+ try {
78
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`, {
79
+ headers: token === '' ? {} : { Authorization: `Bearer ${token}` },
80
+ signal: AbortSignal.timeout(timeoutMs),
81
+ });
82
+ if (!response.ok) return false;
83
+ const body = await response.json();
84
+ return body?.ok === true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ async function waitForHealth(port, token, attempts = 20) {
91
+ for (let i = 0; i < attempts; i += 1) {
92
+ if (await healthy(port, token)) return true;
93
+ await new Promise((done) => setTimeout(done, 500));
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Detached, windowless, best-effort start.
100
+ *
101
+ * `windowsHide` plus `stdio: 'ignore'` matters: a session-start hook that
102
+ * flashes four console windows would be worse than no hook at all.
103
+ */
104
+ function spawnDetached(service, port) {
105
+ const child = spawn(process.execPath, [cliPath(service), 'serve', '--port', String(port)], {
106
+ detached: true,
107
+ stdio: 'ignore',
108
+ windowsHide: true,
109
+ cwd: toolRoot(service),
110
+ });
111
+ child.unref();
112
+ return child.pid;
113
+ }
114
+
115
+ /**
116
+ * Ensure one endpoint is up. Never throws — the session must go on even when
117
+ * the host refuses to spawn.
118
+ *
119
+ * @returns {{ service: object, port: number, token: string, up: boolean, action: string, pid?: number }}
120
+ */
121
+ export async function ensure(service) {
122
+ const { token, port } = await readEndpoint(service);
123
+ const base = { service, port, token };
124
+
125
+ if (!existsSync(cliPath(service))) {
126
+ return { ...base, up: false, action: `not installed (missing ${cliPath(service)})` };
127
+ }
128
+ if (await healthy(port, token)) {
129
+ return { ...base, up: true, action: 'already up' };
130
+ }
131
+
132
+ let pid;
133
+ try {
134
+ pid = spawnDetached(service, port);
135
+ } catch (error) {
136
+ return { ...base, up: false, action: `spawn refused: ${String(error).slice(0, 80)}` };
137
+ }
138
+ if (await waitForHealth(port, token)) {
139
+ return { ...base, up: true, action: 'started', pid };
140
+ }
141
+ return { ...base, up: false, action: `did not become healthy on :${port}`, pid };
142
+ }
143
+
144
+ /** Best-effort transcript for hook debugging; never allowed to throw. */
145
+ export async function log(service, line) {
146
+ try {
147
+ await mkdir(stateDir(service), { recursive: true });
148
+ await appendFile(join(stateDir(service), 'service.log'), `${new Date().toISOString()} ${line}\n`);
149
+ } catch {
150
+ // Diagnostics are optional.
151
+ }
152
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * YAML managed-block patcher — zero dependencies, text-in/text-out.
3
+ *
4
+ * hermes' config.yaml is hand-edited YAML: comments, ordering and quoting all
5
+ * carry meaning for whoever reads it next. A generic YAML round-trip would
6
+ * rewrite the whole file on every sync, so this module does the narrowest
7
+ * thing that can work:
8
+ *
9
+ * - insert / replace *one block per top-level mapping key*, delimited by a
10
+ * marker comment pair;
11
+ * - leave every other byte of the file untouched.
12
+ *
13
+ * Blocks are idempotent: the managed region is torn down and rewritten on each
14
+ * run, so removing a provider from the roster really removes it from the
15
+ * config instead of leaving a stale entry behind.
16
+ *
17
+ * @module hermes-connect-services/src/yaml-patch
18
+ */
19
+
20
+ const START = (marker) => `# >>> ${marker} >>>`;
21
+ const END = (marker) => `# <<< ${marker} <<<`;
22
+
23
+ /**
24
+ * Replace the managed region inside one top-level mapping key.
25
+ *
26
+ * @param {string} text Whole config file.
27
+ * @param {string} topKey Top-level mapping key, e.g. `providers`.
28
+ * @param {string} blockBody Already-indented YAML lines (2 spaces) to host,
29
+ * without the parent key itself. `''` removes the
30
+ * region and nothing else.
31
+ * @param {{ marker?: string, indent?: string }} [options]
32
+ * @returns {{ text: string, changed: boolean, action: 'inserted'|'replaced'|'removed'|'noop' }}
33
+ */
34
+ export function patchManagedBlock(text, topKey, blockBody, options = {}) {
35
+ const marker = options.marker ?? 'zcode-ai-connect';
36
+ const indent = options.indent ?? ' ';
37
+ const startToken = START(marker);
38
+ const endToken = END(marker);
39
+
40
+ const lines = text.split(/\r?\n/);
41
+ const keyIndex = findTopLevelKey(lines, topKey);
42
+
43
+ if (keyIndex === -1) {
44
+ if (blockBody.trim() === '') return { text, changed: false, action: 'noop' };
45
+ return {
46
+ text: appendKeyBlock(text, topKey, blockBody, { marker, indent }),
47
+ changed: true,
48
+ action: 'inserted',
49
+ };
50
+ }
51
+
52
+ // An inline empty map (`providers: {}`) is legal YAML but has nowhere to put
53
+ // children — turn it into a block before patching.
54
+ if (isInlineEmptyMap(lines[keyIndex])) lines[keyIndex] = `${topKey}:`;
55
+
56
+ const region = mappingRegion(lines, keyIndex);
57
+ const owned = findManagedRegion(lines, region, startToken, endToken);
58
+ const nextBlock = buildManagedBlock(blockBody, { marker, indent });
59
+
60
+ if (owned) {
61
+ const replacement = nextBlock; // '' when the caller wants it gone
62
+ const before = lines.join('\n');
63
+ lines.splice(owned.start, owned.end - owned.start + 1, ...replacement);
64
+ const after = lines.join('\n');
65
+ return {
66
+ text: after,
67
+ // Rewriting an identical region is not a change: callers (and their
68
+ // backups) must not churn just because sync ran twice.
69
+ changed: after !== before,
70
+ action: replacement.length === 0 ? 'removed' : 'replaced',
71
+ };
72
+ }
73
+
74
+ if (nextBlock.length === 0) return { text, changed: false, action: 'noop' };
75
+ lines.splice(region.end, 0, ...nextBlock);
76
+ return { text: lines.join('\n'), changed: true, action: 'inserted' };
77
+ }
78
+
79
+ /**
80
+ * Read back what is currently inside one managed region — used by `--diff`
81
+ * reports and by tests. Returns '' when absent.
82
+ */
83
+ export function readManagedBlock(text, topKey, options = {}) {
84
+ const marker = options.marker ?? 'zcode-ai-connect';
85
+ const lines = text.split(/\r?\n/);
86
+ const keyIndex = findTopLevelKey(lines, topKey);
87
+ if (keyIndex === -1) return '';
88
+ const region = mappingRegion(lines, keyIndex);
89
+ const owned = findManagedRegion(lines, region, START(marker), END(marker));
90
+ if (!owned) return '';
91
+ return lines.slice(owned.start + 1, owned.end).join('\n');
92
+ }
93
+
94
+ /**
95
+ * Split a managed region into its per-child blocks, keyed by the child's name.
96
+ *
97
+ * Needed for partial syncs (`--only zc-trae`): the region is rewritten whole,
98
+ * so without this the three untouched providers would be dropped on the floor.
99
+ *
100
+ * @returns {Array<{ key: string, body: string }>}
101
+ */
102
+ export function readManagedChildren(text, topKey, options = {}) {
103
+ const marker = options.marker ?? 'zcode-ai-connect';
104
+ const lines = text.split(/\r?\n/);
105
+ const keyIndex = findTopLevelKey(lines, topKey);
106
+ if (keyIndex === -1) return [];
107
+ const region = mappingRegion(lines, keyIndex);
108
+ const owned = findManagedRegion(lines, region, START(marker), END(marker));
109
+ if (!owned) return [];
110
+ // Inside `providers:` the region carries the mapping's 2-space indent; strip
111
+ // the common prefix before looking for column-0 child keys.
112
+ return splitBlocks(dedent(lines.slice(owned.start + 1, owned.end)));
113
+ }
114
+
115
+ /** Remove the shortest common leading indent shared by every non-blank line. */
116
+ function dedent(lines) {
117
+ const indents = lines
118
+ .filter((line) => line.trim() !== '')
119
+ .map((line) => line.length - line.trimStart().length);
120
+ const shift = indents.length === 0 ? 0 : Math.min(...indents);
121
+ return shift === 0 ? lines : lines.map((line) => (line.trim() === '' ? line : line.slice(shift)));
122
+ }
123
+
124
+ /** True when the top-level mapping key exists at all. */
125
+ export function hasTopLevelKey(text, topKey) {
126
+ return findTopLevelKey(text.split(/\r?\n/), topKey) !== -1;
127
+ }
128
+
129
+ // --- internals -------------------------------------------------------------
130
+
131
+ /** Index of a top-level `key:` line, or -1. Inline keys inside blocks never match. */
132
+ function findTopLevelKey(lines, topKey) {
133
+ const escaped = topKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
134
+ const pattern = new RegExp(`^${escaped}\\s*:\\s*(#.*)?$`);
135
+ return lines.findIndex((line) => pattern.test(line));
136
+ }
137
+
138
+ function isInlineEmptyMap(line) {
139
+ return /:\s*\{\s*\}\s*(#.*)?$/.test(line);
140
+ }
141
+
142
+ /**
143
+ * Line range owned by the mapping that starts at `keyIndex`: everything after
144
+ * the key line until the next top-level key (or EOF). Trailing blank lines are
145
+ * excluded so inserted blocks land right after the last real child.
146
+ */
147
+ function mappingRegion(lines, keyIndex) {
148
+ let end = lines.length;
149
+ for (let i = keyIndex + 1; i < lines.length; i += 1) {
150
+ if (/^\S/.test(lines[i]) && lines[i].trim() !== '') {
151
+ end = i;
152
+ break;
153
+ }
154
+ }
155
+ while (end - 1 > keyIndex && lines[end - 1].trim() === '') end -= 1;
156
+ return { start: keyIndex + 1, end };
157
+ }
158
+
159
+ function findManagedRegion(lines, region, startToken, endToken) {
160
+ let start = -1;
161
+ for (let i = region.start; i < region.end; i += 1) {
162
+ if (lines[i].trim() === startToken) {
163
+ start = i;
164
+ break;
165
+ }
166
+ }
167
+ if (start === -1) return null;
168
+ for (let i = start; i < region.end; i += 1) {
169
+ if (lines[i].trim() === endToken) return { start, end: i };
170
+ }
171
+ // Unterminated marker (hand edit gone wrong) — claim up to the region end so
172
+ // the next sync repairs it deterministically.
173
+ return { start, end: region.end - 1 };
174
+ }
175
+
176
+ function buildManagedBlock(blockBody, { marker, indent }) {
177
+ const body = blockBody.replace(/\s+$/, '');
178
+ if (body === '') return [];
179
+ return [
180
+ `${indent}${START(marker)}`,
181
+ ...body.split('\n').map((line) => (line === '' ? '' : `${indent}${line}`)),
182
+ `${indent}${END(marker)}`,
183
+ ];
184
+ }
185
+
186
+ /**
187
+ * Cut a list of already-dedented lines into top-level blocks. A block starts at
188
+ * a column-0 `key:` line and runs to the next one.
189
+ */
190
+ function splitBlocks(body) {
191
+ const blocks = [];
192
+ let current = null;
193
+ for (const line of body) {
194
+ if (/^\S/.test(line) && /^\S[^#]*:\s*$/.test(line)) {
195
+ if (current) blocks.push(current);
196
+ current = { key: line.replace(/:\s*$/, ''), lines: [line] };
197
+ continue;
198
+ }
199
+ if (current) current.lines.push(line);
200
+ }
201
+ if (current) blocks.push(current);
202
+ return blocks.map(({ key, lines }) => ({
203
+ key,
204
+ body: lines.join('\n').replace(/\s+$/, ''),
205
+ }));
206
+ }
207
+
208
+ function appendKeyBlock(text, topKey, blockBody, { marker, indent }) {
209
+ const head = text.replace(/\s+$/, '');
210
+ const block = buildManagedBlock(blockBody, { marker, indent });
211
+ return [`${head}`, '', `${topKey}:`, ...block, ''].join('\n');
212
+ }