@alfe.ai/integrations 0.2.1 → 0.2.3

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/dist/index.d.ts CHANGED
@@ -913,9 +913,11 @@ declare class HermesApplier implements RuntimeApplier {
913
913
  readonly runtime = "hermes";
914
914
  private home;
915
915
  private trackingPath;
916
+ /** `~/.hermes/config.yaml` — read-merge-written on removal (no `config unset`). */
917
+ private configYamlPath;
916
918
  private configSetRetries;
917
919
  private configSetRetryDelayMs;
918
- /** Serializes all `hermes config` writes so they never interleave. */
920
+ /** Serializes all config mutations (CLI `set` + config.yaml delete) so they never interleave. */
919
921
  private configSetQueue;
920
922
  constructor(options?: HermesApplierOptions);
921
923
  /**
@@ -929,8 +931,9 @@ declare class HermesApplier implements RuntimeApplier {
929
931
  /**
930
932
  * Remove config previously applied by an integration.
931
933
  *
932
- * Reads the tracking file to find which keys this integration set, then
933
- * removes each via `hermes config unset`. Clears the tracking entry.
934
+ * Reads the tracking file to find which dotted keys this integration set, then
935
+ * DELETES each from `~/.hermes/config.yaml` via a read-merge-write (there is no
936
+ * `hermes config unset` verb — see the file header). Clears the tracking entry.
934
937
  */
935
938
  removeConfig(integrationId: string): Promise<void>;
936
939
  /**
@@ -967,21 +970,25 @@ declare class HermesApplier implements RuntimeApplier {
967
970
  /** Convenience: `hermes config set <args>`, serialized + retried. */
968
971
  private runConfigSet;
969
972
  /**
970
- * Unset a single config key. The unset verb is isolated HERE so there is one
971
- * place to change if the spike proves `hermes config unset` is unavailable.
972
- *
973
- * FALLBACK (do NOT pre-build): if `hermes config unset` does not exist, the
974
- * substitute is `hermes config set <key> ""` (clear the value) or a
975
- * read-merge-write of config.yaml. Not implemented now — `unset` is the
976
- * documented verb; confirm in the Phase-0 spike before adding a fallback.
977
- * TODO(phase-0 spike): confirm `hermes config unset <key>` exists.
978
- */
979
- private unsetConfigKey;
980
- /**
981
- * Run `hermes config <args>` (set/unset), serialized against every other
982
- * config write and retried with backoff. Throws an Error whose (scrubbed)
983
- * message includes stderr after retries are exhausted, so the real cause
984
- * propagates to the integration errorMessage without leaking the value.
973
+ * Delete a set of dotted config keys from `~/.hermes/config.yaml`. There is no
974
+ * `hermes config unset` verb (see file header), so removal is a read-merge-write
975
+ * of the YAML via the Document API — preserving user-authored keys, comments,
976
+ * and formatting. Serialized against every other config mutation on the same
977
+ * queue so a delete can't interleave with an in-flight `hermes config set`.
978
+ */
979
+ private deleteConfigKeys;
980
+ /**
981
+ * Synchronous config.yaml read-merge-write: parse the doc, delete each dotted
982
+ * key (`model.base_url` → `deleteIn(['model','base_url'])`), and write back only
983
+ * if the text actually changed. No-op if config.yaml doesn't exist yet.
984
+ */
985
+ private deleteConfigKeysSync;
986
+ /**
987
+ * Run `hermes config <args>` (only `set` is used — removal goes via
988
+ * {@link deleteConfigKeys}), serialized against every other config mutation and
989
+ * retried with backoff. Throws an Error whose (scrubbed) message includes stderr
990
+ * after retries are exhausted, so the real cause propagates to the integration
991
+ * errorMessage without leaking the value.
985
992
  */
986
993
  private runConfigCommand;
987
994
  private readTracking;
package/dist/index.js CHANGED
@@ -1713,27 +1713,38 @@ const delay$1 = (ms) => new Promise((resolve) => {
1713
1713
  setTimeout(resolve, ms);
1714
1714
  });
1715
1715
  /**
1716
- * Structural equality for config read-back comparison. Primitives compare by
1717
- * `Object.is`; arrays are order-SENSITIVE (config arrays are positional);
1718
- * plain objects are order-INSENSITIVE (key order in JSON is not meaningful).
1719
- * Used only to confirm a value landed after a `config set` exited non-zero.
1716
+ * Asymmetric structural containment for verify-after-write: is every field we
1717
+ * INTENDED present in the ACTUAL read-back with our value? `actual` may carry
1718
+ * EXTRA keys that `intended` does not.
1719
+ *
1720
+ * Verify-after-write only needs to answer "did our write land?", and OpenClaw
1721
+ * NORMALIZES config on load — most notably it enriches every
1722
+ * `models.providers.*.models` entry with schema defaults (`reasoning`, `input`,
1723
+ * `cost`, `contextWindow`, `maxTokens`, …) that the applier never wrote. A
1724
+ * strict `deepEqual` against the enriched read-back is therefore GUARANTEED to
1725
+ * fail for model lists (extra keys → unequal key counts), which strands the
1726
+ * `alfe` activation in `error` forever even though the write succeeded. A subset
1727
+ * check tolerates enrichment while still catching a genuine failure (a value
1728
+ * OpenClaw never stored, or stored differently).
1729
+ *
1730
+ * Arrays stay positional and length-EXACT (enrichment adds object fields, never
1731
+ * elements, and never reorders); each element is compared by subset. Objects
1732
+ * require every `intended` key to be present-and-subset-matching in `actual`;
1733
+ * primitives compare by `Object.is`.
1720
1734
  */
1721
- function deepEqual(a, b) {
1722
- if (Object.is(a, b)) return true;
1723
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
1724
- const aIsArr = Array.isArray(a);
1725
- const bIsArr = Array.isArray(b);
1726
- if (aIsArr !== bIsArr) return false;
1727
- if (aIsArr && bIsArr) {
1728
- if (a.length !== b.length) return false;
1729
- return a.every((v, i) => deepEqual(v, b[i]));
1730
- }
1731
- const ao = a;
1732
- const bo = b;
1733
- const aKeys = Object.keys(ao);
1734
- const bKeys = Object.keys(bo);
1735
- if (aKeys.length !== bKeys.length) return false;
1736
- return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k]));
1735
+ function deepSubset(intended, actual) {
1736
+ if (Object.is(intended, actual)) return true;
1737
+ if (typeof intended !== "object" || typeof actual !== "object" || intended === null || actual === null) return false;
1738
+ const iIsArr = Array.isArray(intended);
1739
+ const aIsArr = Array.isArray(actual);
1740
+ if (iIsArr !== aIsArr) return false;
1741
+ if (iIsArr && aIsArr) {
1742
+ if (intended.length !== actual.length) return false;
1743
+ return intended.every((v, i) => deepSubset(v, actual[i]));
1744
+ }
1745
+ const io = intended;
1746
+ const ao = actual;
1747
+ return Object.keys(io).every((k) => Object.prototype.hasOwnProperty.call(ao, k) && deepSubset(io[k], ao[k]));
1737
1748
  }
1738
1749
  /**
1739
1750
  * Describe a `config set` target WITHOUT leaking values. The value argument is
@@ -1844,7 +1855,7 @@ var OpenClawApplier = class {
1844
1855
  ], { timeout: 1e4 });
1845
1856
  const actual = JSON.parse(stdout.trim());
1846
1857
  if (actual === OPENCLAW_REDACTED) return true;
1847
- return deepEqual(actual, expected);
1858
+ return deepSubset(expected, actual);
1848
1859
  } catch {
1849
1860
  return false;
1850
1861
  }
@@ -2094,7 +2105,7 @@ var OpenClawApplier = class {
2094
2105
  } catch (err) {
2095
2106
  if (await this.verifyApplied(async () => {
2096
2107
  const after = await readParentObject(parentPath);
2097
- return [...dottedKvs.entries()].every(([k, v]) => deepEqual(after[k], v));
2108
+ return [...dottedKvs.entries()].every(([k, v]) => deepSubset(v, after[k]));
2098
2109
  })) {
2099
2110
  log$3.warn({ parentPath }, "openclaw config set exited non-zero but config landed — continuing");
2100
2111
  continue;
@@ -2193,11 +2204,17 @@ var OpenClawApplier = class {
2193
2204
  * HermesApplier — applies integration config (and, later, native plugins) to
2194
2205
  * the Hermes runtime (Nous Research's Python agent).
2195
2206
  *
2196
- * Hermes config lives in `~/.hermes/config.yaml` (YAML) and is mutated via the
2197
- * `hermes config set/unset <dotted.key> <value>` CLI we never write the YAML
2198
- * file directly (mirrors the OpenClaw rule of letting the runtime own its own
2199
- * config format). Per-integration contributions are tracked in a separate file
2200
- * (`~/.hermes/.alfe-integrations.json`) so removal is precise.
2207
+ * Hermes config lives in `~/.hermes/config.yaml` (YAML). We APPLY config via the
2208
+ * `hermes config set <dotted.key> <value>` CLI (letting the runtime own its
2209
+ * write path, mirroring the OpenClaw rule). REMOVAL is different: `hermes config
2210
+ * unset` does NOT exist CONFIRMED against Hermes source, whose `config`
2211
+ * subcommands are only show/set/edit/path/env-path/check/migrate. So teardown is
2212
+ * a config.yaml read-merge-write that deletes the Alfe-applied dotted keys via
2213
+ * the `yaml` Document API (`parseDocument` + `deleteIn`), preserving
2214
+ * user-authored keys, comments, and formatting (the same approach
2215
+ * `hermes-mcp-sync.ts` uses for `mcp_servers`). Per-integration contributions are
2216
+ * tracked in a separate file (`~/.hermes/.alfe-integrations.json`) so we know
2217
+ * exactly which keys to delete.
2201
2218
  *
2202
2219
  * Scope (Phase 1, MCP-first hybrid):
2203
2220
  * - config: SUPPORTED — consumes `installs.runtimes.hermes.config` (the AI-proxy
@@ -2243,9 +2260,9 @@ function stringifyConfigValue(value) {
2243
2260
  return typeof value === "string" ? value : JSON.stringify(value);
2244
2261
  }
2245
2262
  /**
2246
- * Describe a `config set/unset` target WITHOUT leaking values. The value
2247
- * argument can be a secret (`model.api_key`), so it must never reach the
2248
- * integration's user-facing `errorMessage`. Keep only the program + verb + key.
2263
+ * Describe a `config set` target WITHOUT leaking values. The value argument can
2264
+ * be a secret (`model.api_key`), so it must never reach the integration's
2265
+ * user-facing `errorMessage`. Keep only the program + verb + key.
2249
2266
  */
2250
2267
  function redactConfigTarget(args) {
2251
2268
  return `hermes config ${args.slice(0, 2).join(" ")}`.trim();
@@ -2270,13 +2287,16 @@ var HermesApplier = class {
2270
2287
  runtime = "hermes";
2271
2288
  home;
2272
2289
  trackingPath;
2290
+ /** `~/.hermes/config.yaml` — read-merge-written on removal (no `config unset`). */
2291
+ configYamlPath;
2273
2292
  configSetRetries;
2274
2293
  configSetRetryDelayMs;
2275
- /** Serializes all `hermes config` writes so they never interleave. */
2294
+ /** Serializes all config mutations (CLI `set` + config.yaml delete) so they never interleave. */
2276
2295
  configSetQueue = Promise.resolve();
2277
2296
  constructor(options = {}) {
2278
2297
  this.home = options.home ?? options.workspace ?? DEFAULT_HERMES_HOME$1;
2279
2298
  this.trackingPath = options.configPath ?? join(this.home, ".alfe-integrations.json");
2299
+ this.configYamlPath = join(this.home, "config.yaml");
2280
2300
  this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES;
2281
2301
  this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS;
2282
2302
  }
@@ -2311,8 +2331,9 @@ var HermesApplier = class {
2311
2331
  /**
2312
2332
  * Remove config previously applied by an integration.
2313
2333
  *
2314
- * Reads the tracking file to find which keys this integration set, then
2315
- * removes each via `hermes config unset`. Clears the tracking entry.
2334
+ * Reads the tracking file to find which dotted keys this integration set, then
2335
+ * DELETES each from `~/.hermes/config.yaml` via a read-merge-write (there is no
2336
+ * `hermes config unset` verb — see the file header). Clears the tracking entry.
2316
2337
  */
2317
2338
  async removeConfig(integrationId) {
2318
2339
  const tracking = this.readTracking();
@@ -2320,13 +2341,13 @@ var HermesApplier = class {
2320
2341
  if (!(integrationId in integrations)) return;
2321
2342
  const integrationConfig = integrations[integrationId];
2322
2343
  const { leaves } = partitionEntries(flattenConfig(integrationConfig));
2323
- for (const { path } of leaves) try {
2324
- await this.unsetConfigKey(path);
2344
+ try {
2345
+ await this.deleteConfigKeys(leaves.map(({ path }) => path));
2325
2346
  } catch (err) {
2326
2347
  log$2.warn({
2327
2348
  err: err instanceof Error ? err.message : String(err),
2328
- key: path
2329
- }, "Failed to unset config via hermes config unset");
2349
+ integrationId
2350
+ }, "Failed to delete Alfe config keys from ~/.hermes/config.yaml");
2330
2351
  }
2331
2352
  tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
2332
2353
  this.writeTracking(tracking);
@@ -2410,23 +2431,40 @@ var HermesApplier = class {
2410
2431
  return this.runConfigCommand(["set", ...setArgs]);
2411
2432
  }
2412
2433
  /**
2413
- * Unset a single config key. The unset verb is isolated HERE so there is one
2414
- * place to change if the spike proves `hermes config unset` is unavailable.
2415
- *
2416
- * FALLBACK (do NOT pre-build): if `hermes config unset` does not exist, the
2417
- * substitute is `hermes config set <key> ""` (clear the value) or a
2418
- * read-merge-write of config.yaml. Not implemented now — `unset` is the
2419
- * documented verb; confirm in the Phase-0 spike before adding a fallback.
2420
- * TODO(phase-0 spike): confirm `hermes config unset <key>` exists.
2434
+ * Delete a set of dotted config keys from `~/.hermes/config.yaml`. There is no
2435
+ * `hermes config unset` verb (see file header), so removal is a read-merge-write
2436
+ * of the YAML via the Document API — preserving user-authored keys, comments,
2437
+ * and formatting. Serialized against every other config mutation on the same
2438
+ * queue so a delete can't interleave with an in-flight `hermes config set`.
2439
+ */
2440
+ deleteConfigKeys(paths) {
2441
+ const run = () => {
2442
+ this.deleteConfigKeysSync(paths);
2443
+ return Promise.resolve();
2444
+ };
2445
+ const result = this.configSetQueue.then(run, run);
2446
+ this.configSetQueue = result.catch(() => void 0);
2447
+ return result;
2448
+ }
2449
+ /**
2450
+ * Synchronous config.yaml read-merge-write: parse the doc, delete each dotted
2451
+ * key (`model.base_url` → `deleteIn(['model','base_url'])`), and write back only
2452
+ * if the text actually changed. No-op if config.yaml doesn't exist yet.
2421
2453
  */
2422
- unsetConfigKey(key) {
2423
- return this.runConfigCommand(["unset", key]);
2454
+ deleteConfigKeysSync(paths) {
2455
+ if (paths.length === 0 || !existsSync(this.configYamlPath)) return;
2456
+ const doc = parseDocument(readFileSync(this.configYamlPath, "utf-8"));
2457
+ const before = doc.toString();
2458
+ for (const path of paths) doc.deleteIn(path.split("."));
2459
+ const after = doc.toString();
2460
+ if (before !== after) writeFileSync(this.configYamlPath, after, "utf-8");
2424
2461
  }
2425
2462
  /**
2426
- * Run `hermes config <args>` (set/unset), serialized against every other
2427
- * config write and retried with backoff. Throws an Error whose (scrubbed)
2428
- * message includes stderr after retries are exhausted, so the real cause
2429
- * propagates to the integration errorMessage without leaking the value.
2463
+ * Run `hermes config <args>` (only `set` is used removal goes via
2464
+ * {@link deleteConfigKeys}), serialized against every other config mutation and
2465
+ * retried with backoff. Throws an Error whose (scrubbed) message includes stderr
2466
+ * after retries are exhausted, so the real cause propagates to the integration
2467
+ * errorMessage without leaking the value.
2430
2468
  */
2431
2469
  runConfigCommand(args) {
2432
2470
  const run = async () => {
@@ -2502,15 +2540,13 @@ var HermesApplier = class {
2502
2540
  const log$1 = createLogger("HermesMcpSync");
2503
2541
  const DEFAULT_HERMES_HOME = join(homedir(), ".hermes");
2504
2542
  /**
2505
- * SPIKE-PENDING SEAM #1 — `${VAR}` interpolation from `~/.hermes/.env`.
2506
- *
2507
- * We inject `ALFE_API_KEY=${ALFE_API_KEY}` into every Alfe-owned stdio server's
2508
- * env and put the real value in `~/.hermes/.env`. This assumes Hermes
2509
- * interpolates `${VAR}` references in `mcp_servers.<id>.env` from `.env` at spawn
2510
- * time. TODO(phase-0 spike): confirm. If Hermes does NOT interpolate, the
2511
- * fallback (NOT built here) is to write the literal `ALFE_API_KEY` VALUE inline
2512
- * into each `mcp_servers.<id>.env` and skip the `.env` file entirely — change
2513
- * `withAlfeApiKey()` + `ensureEnvApiKey()` together in that one case.
2543
+ * SEAM #1 — `${VAR}` interpolation from `~/.hermes/.env`. CONFIRMED (Hermes
2544
+ * source `config.py:5790` `_expand_env_vars`): Hermes loads `~/.hermes/.env` via
2545
+ * dotenv into `os.environ`, then recursively expands `${VAR}` refs across the
2546
+ * ENTIRE merged config including `mcp_servers.<id>.env.*` (and `model.api_key`
2547
+ * / `model.base_url`). So injecting `ALFE_API_KEY=${ALFE_API_KEY}` into every
2548
+ * Alfe-owned stdio server's env and writing the real value to `~/.hermes/.env`
2549
+ * resolves at spawn time. The inline-literal fallback is therefore not needed.
2514
2550
  */
2515
2551
  const ALFE_API_KEY_ENV_REF = "${ALFE_API_KEY}";
2516
2552
  const DEFAULT_DEBOUNCE_MS = 250;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",