dsh-web-search-pro 0.2.0 → 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.
Files changed (266) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +197 -32
  3. package/assets/skills/dsh-web-search-pro/SKILL.md +14 -13
  4. package/assets/skills/dsh-web-search-pro/references/config.md +36 -0
  5. package/assets/skills/dsh-web-search-pro/references/sources.md +19 -4
  6. package/assets/skills/dsh-web-search-pro/references/troubleshooting.md +3 -1
  7. package/catalog/sources.v1.json +22 -12
  8. package/lib/actions/approval.js +50 -1
  9. package/lib/actions/approval.js.map +1 -1
  10. package/lib/actions/config.js +274 -0
  11. package/lib/actions/config.js.map +1 -0
  12. package/lib/actions/errors.js +8 -1
  13. package/lib/actions/errors.js.map +1 -1
  14. package/lib/actions/format.js +10 -0
  15. package/lib/actions/format.js.map +1 -1
  16. package/lib/actions/index-view.js +2 -2
  17. package/lib/actions/index-view.js.map +1 -1
  18. package/lib/actions/read.js +7 -3
  19. package/lib/actions/read.js.map +1 -1
  20. package/lib/actions/registry.js +3 -1
  21. package/lib/actions/registry.js.map +1 -1
  22. package/lib/actions/run.js +2 -2
  23. package/lib/actions/run.js.map +1 -1
  24. package/lib/actions/schema.js +18 -0
  25. package/lib/actions/schema.js.map +1 -1
  26. package/lib/actions/search.js +12 -6
  27. package/lib/actions/search.js.map +1 -1
  28. package/lib/actions/sources.js +116 -41
  29. package/lib/actions/sources.js.map +1 -1
  30. package/lib/actions/types.js +19 -1
  31. package/lib/actions/types.js.map +1 -1
  32. package/lib/browser-access.js +25 -0
  33. package/lib/browser-access.js.map +1 -1
  34. package/lib/catalog/recommend.js +11 -3
  35. package/lib/catalog/recommend.js.map +1 -1
  36. package/lib/catalog/schema.js +3 -1
  37. package/lib/catalog/schema.js.map +1 -1
  38. package/lib/cli/builtin-specs.js +341 -0
  39. package/lib/cli/builtin-specs.js.map +1 -0
  40. package/lib/cli/chain.js +193 -0
  41. package/lib/cli/chain.js.map +1 -0
  42. package/lib/cli/chains-spec.js +118 -0
  43. package/lib/cli/chains-spec.js.map +1 -0
  44. package/lib/cli/engine.js +99 -0
  45. package/lib/cli/engine.js.map +1 -0
  46. package/lib/cli/opencli-sites.js +83 -0
  47. package/lib/cli/opencli-sites.js.map +1 -0
  48. package/lib/cli/opencli.js +153 -0
  49. package/lib/cli/opencli.js.map +1 -0
  50. package/lib/cli/probe.js +128 -0
  51. package/lib/cli/probe.js.map +1 -0
  52. package/lib/cli/runner.js +564 -0
  53. package/lib/cli/runner.js.map +1 -0
  54. package/lib/cli/spec.js +496 -0
  55. package/lib/cli/spec.js.map +1 -0
  56. package/lib/cli/time-text.js +79 -0
  57. package/lib/cli/time-text.js.map +1 -0
  58. package/lib/cli/trial.js +57 -0
  59. package/lib/cli/trial.js.map +1 -0
  60. package/lib/client/SettingsCard.js +14 -32
  61. package/lib/client/SettingsCard.js.map +1 -1
  62. package/lib/client/agent-mark.js +27 -0
  63. package/lib/client/agent-mark.js.map +1 -0
  64. package/lib/client/card-frame.js +118 -0
  65. package/lib/client/card-frame.js.map +1 -0
  66. package/lib/client/cli-form.js +257 -0
  67. package/lib/client/cli-form.js.map +1 -0
  68. package/lib/client/field-docs.js +130 -0
  69. package/lib/client/field-docs.js.map +1 -0
  70. package/lib/client/field-keys.js +81 -0
  71. package/lib/client/field-keys.js.map +1 -0
  72. package/lib/client/field-rows.js +71 -0
  73. package/lib/client/field-rows.js.map +1 -0
  74. package/lib/client/fill.js +3 -0
  75. package/lib/client/fill.js.map +1 -0
  76. package/lib/client/form-specs.js +15 -1
  77. package/lib/client/form-specs.js.map +1 -1
  78. package/lib/client/form.js +296 -27
  79. package/lib/client/form.js.map +1 -1
  80. package/lib/client/group-advanced.js +23 -0
  81. package/lib/client/group-advanced.js.map +1 -0
  82. package/lib/client/group-agent.js +28 -0
  83. package/lib/client/group-agent.js.map +1 -0
  84. package/lib/client/group-cli.js +136 -0
  85. package/lib/client/group-cli.js.map +1 -0
  86. package/lib/client/group-common.js +29 -0
  87. package/lib/client/group-common.js.map +1 -0
  88. package/lib/client/group-judge.js +67 -0
  89. package/lib/client/group-judge.js.map +1 -0
  90. package/lib/client/group-order.js +201 -0
  91. package/lib/client/group-order.js.map +1 -0
  92. package/lib/client/group-runtime.js +33 -0
  93. package/lib/client/group-runtime.js.map +1 -0
  94. package/lib/client/group-sources.js +83 -0
  95. package/lib/client/group-sources.js.map +1 -0
  96. package/lib/client/groups-basic.js +36 -0
  97. package/lib/client/groups-basic.js.map +1 -0
  98. package/lib/client/index.js.map +1 -1
  99. package/lib/client/json-list.js +50 -0
  100. package/lib/client/json-list.js.map +1 -0
  101. package/lib/client/locales.js +666 -215
  102. package/lib/client/locales.js.map +1 -1
  103. package/lib/client/order.js +192 -0
  104. package/lib/client/order.js.map +1 -0
  105. package/lib/client/overlay-ops.js +46 -0
  106. package/lib/client/overlay-ops.js.map +1 -0
  107. package/lib/client/sources-table.js +1 -0
  108. package/lib/client/sources-table.js.map +1 -1
  109. package/lib/client/styles.js +74 -47
  110. package/lib/client/styles.js.map +1 -1
  111. package/lib/client/text-view.js +98 -0
  112. package/lib/client/text-view.js.map +1 -0
  113. package/lib/client/ui-kit.js +120 -0
  114. package/lib/client/ui-kit.js.map +1 -0
  115. package/lib/client/validators.js +22 -0
  116. package/lib/client/validators.js.map +1 -1
  117. package/lib/client.js +9151 -1987
  118. package/lib/client.js.map +1 -1
  119. package/lib/config-fillers.js +53 -0
  120. package/lib/config-fillers.js.map +1 -0
  121. package/lib/config-groups.js +49 -0
  122. package/lib/config-groups.js.map +1 -0
  123. package/lib/config.js +87 -107
  124. package/lib/config.js.map +1 -1
  125. package/lib/default-chain.js +66 -0
  126. package/lib/default-chain.js.map +1 -0
  127. package/lib/deps.js +109 -102
  128. package/lib/deps.js.map +1 -1
  129. package/lib/engine-error.js +19 -0
  130. package/lib/engine-error.js.map +1 -0
  131. package/lib/engines.js +32 -115
  132. package/lib/engines.js.map +1 -1
  133. package/lib/fetch.js +23 -4
  134. package/lib/fetch.js.map +1 -1
  135. package/lib/index.js +30 -5
  136. package/lib/index.js.map +1 -1
  137. package/lib/overlay-types.js +88 -0
  138. package/lib/overlay-types.js.map +1 -0
  139. package/lib/overlay.js +683 -0
  140. package/lib/overlay.js.map +1 -0
  141. package/lib/pipeline/candidates.js +27 -1
  142. package/lib/pipeline/candidates.js.map +1 -1
  143. package/lib/pipeline/compile.js +63 -0
  144. package/lib/pipeline/compile.js.map +1 -1
  145. package/lib/pipeline/confidence.js +64 -0
  146. package/lib/pipeline/confidence.js.map +1 -0
  147. package/lib/pipeline/fusion.js +50 -3
  148. package/lib/pipeline/fusion.js.map +1 -1
  149. package/lib/pipeline/plan.js +2 -2
  150. package/lib/pipeline/plan.js.map +1 -1
  151. package/lib/pipeline/render.js +20 -2
  152. package/lib/pipeline/render.js.map +1 -1
  153. package/lib/pipeline/run.js +21 -7
  154. package/lib/pipeline/run.js.map +1 -1
  155. package/lib/pipeline/sources-spec.js +19 -3
  156. package/lib/pipeline/sources-spec.js.map +1 -1
  157. package/lib/providers/builtin.js +13 -14
  158. package/lib/providers/builtin.js.map +1 -1
  159. package/lib/providers/deepseek.js +348 -0
  160. package/lib/providers/deepseek.js.map +1 -0
  161. package/lib/providers/http.js.map +1 -1
  162. package/lib/providers/keyed-meta.js +7 -0
  163. package/lib/providers/keyed-meta.js.map +1 -1
  164. package/lib/providers/platforms.js +118 -58
  165. package/lib/providers/platforms.js.map +1 -1
  166. package/lib/providers/registry.js.map +1 -1
  167. package/lib/restart.js +47 -0
  168. package/lib/restart.js.map +1 -0
  169. package/lib/router.js +203 -27
  170. package/lib/router.js.map +1 -1
  171. package/lib/rpc-types.js +15 -0
  172. package/lib/rpc-types.js.map +1 -0
  173. package/lib/rpc.js +85 -0
  174. package/lib/rpc.js.map +1 -0
  175. package/lib/tools.js +10 -4
  176. package/lib/tools.js.map +1 -1
  177. package/lib/types/actions/approval.d.ts +11 -0
  178. package/lib/types/actions/config.d.ts +54 -0
  179. package/lib/types/actions/format.d.ts +9 -2
  180. package/lib/types/actions/run.d.ts +1 -0
  181. package/lib/types/actions/schema.d.ts +7 -1
  182. package/lib/types/actions/sources.d.ts +29 -3
  183. package/lib/types/actions/types.d.ts +28 -2
  184. package/lib/types/browser-access.d.ts +17 -0
  185. package/lib/types/catalog/recommend.d.ts +4 -0
  186. package/lib/types/catalog/schema.d.ts +2 -0
  187. package/lib/types/cli/builtin-specs.d.ts +38 -0
  188. package/lib/types/cli/chain.d.ts +83 -0
  189. package/lib/types/cli/chains-spec.d.ts +48 -0
  190. package/lib/types/cli/engine.d.ts +50 -0
  191. package/lib/types/cli/opencli-sites.d.ts +24 -0
  192. package/lib/types/cli/opencli.d.ts +77 -0
  193. package/lib/types/cli/probe.d.ts +53 -0
  194. package/lib/types/cli/runner.d.ts +126 -0
  195. package/lib/types/cli/spec.d.ts +269 -0
  196. package/lib/types/cli/time-text.d.ts +12 -0
  197. package/lib/types/cli/trial.d.ts +19 -0
  198. package/lib/types/client/SettingsCard.d.ts +17 -1
  199. package/lib/types/client/agent-mark.d.ts +22 -0
  200. package/lib/types/client/card-frame.d.ts +35 -0
  201. package/lib/types/client/cli-form.d.ts +97 -0
  202. package/lib/types/client/field-docs.d.ts +15 -0
  203. package/lib/types/client/field-keys.d.ts +41 -0
  204. package/lib/types/client/field-rows.d.ts +70 -0
  205. package/lib/types/client/fill.d.ts +2 -0
  206. package/lib/types/client/form-specs.d.ts +12 -3
  207. package/lib/types/client/form.d.ts +79 -1
  208. package/lib/types/client/group-advanced.d.ts +9 -0
  209. package/lib/types/client/group-agent.d.ts +20 -0
  210. package/lib/types/client/group-cli.d.ts +52 -0
  211. package/lib/types/client/group-common.d.ts +12 -0
  212. package/lib/types/client/group-judge.d.ts +17 -0
  213. package/lib/types/client/group-order.d.ts +26 -0
  214. package/lib/types/client/group-runtime.d.ts +15 -0
  215. package/lib/types/client/group-sources.d.ts +46 -0
  216. package/lib/types/client/groups-basic.d.ts +18 -0
  217. package/lib/types/client/index.d.ts +8 -0
  218. package/lib/types/client/json-list.d.ts +21 -0
  219. package/lib/types/client/locales.d.ts +1079 -196
  220. package/lib/types/client/order.d.ts +102 -0
  221. package/lib/types/client/overlay-ops.d.ts +37 -0
  222. package/lib/types/client/styles.d.ts +56 -41
  223. package/lib/types/client/text-view.d.ts +22 -0
  224. package/lib/types/client/ui-kit.d.ts +200 -0
  225. package/lib/types/client/validators.d.ts +6 -0
  226. package/lib/types/config-fillers.d.ts +9 -0
  227. package/lib/types/config-groups.d.ts +23 -0
  228. package/lib/types/config.d.ts +265 -216
  229. package/lib/types/default-chain.d.ts +63 -0
  230. package/lib/types/deps.d.ts +25 -23
  231. package/lib/types/engine-error.d.ts +12 -0
  232. package/lib/types/engines.d.ts +45 -10
  233. package/lib/types/fetch.d.ts +12 -3
  234. package/lib/types/index.d.ts +164 -146
  235. package/lib/types/overlay-types.d.ts +98 -0
  236. package/lib/types/overlay.d.ts +141 -0
  237. package/lib/types/pipeline/candidates.d.ts +19 -0
  238. package/lib/types/pipeline/compile.d.ts +11 -0
  239. package/lib/types/pipeline/confidence.d.ts +35 -0
  240. package/lib/types/pipeline/fusion.d.ts +23 -1
  241. package/lib/types/pipeline/render.d.ts +16 -0
  242. package/lib/types/pipeline/run.d.ts +3 -1
  243. package/lib/types/pipeline/sources-spec.d.ts +5 -2
  244. package/lib/types/pipeline/types.d.ts +37 -0
  245. package/lib/types/providers/deepseek.d.ts +130 -0
  246. package/lib/types/providers/http.d.ts +2 -1
  247. package/lib/types/providers/keyed-meta.d.ts +5 -0
  248. package/lib/types/providers/platforms.d.ts +15 -3
  249. package/lib/types/providers/registry.d.ts +10 -0
  250. package/lib/types/restart.d.ts +52 -0
  251. package/lib/types/router.d.ts +69 -9
  252. package/lib/types/rpc-types.d.ts +79 -0
  253. package/lib/types/rpc.d.ts +41 -0
  254. package/lib/types/tools.d.ts +6 -0
  255. package/lib/types/util.d.ts +16 -1
  256. package/lib/util.js +17 -4
  257. package/lib/util.js.map +1 -1
  258. package/package.json +51 -43
  259. package/scripts/check-client-bundle.mjs +1 -1
  260. package/scripts/cli-login-check.mjs +214 -0
  261. package/lib/client/RubricEditor.js +0 -47
  262. package/lib/client/RubricEditor.js.map +0 -1
  263. package/lib/client/fields.js +0 -31
  264. package/lib/client/fields.js.map +0 -1
  265. package/lib/types/client/RubricEditor.d.ts +0 -6
  266. package/lib/types/client/fields.d.ts +0 -65
package/lib/overlay.js ADDED
@@ -0,0 +1,683 @@
1
+ /**
2
+ * The agent overlay: the settings an agent may change at run time without hand-editing the configuration.
3
+ *
4
+ * The profile configuration stays the user's baseline. The overlay is a small managed file next to the plugin's database
5
+ * (`$DSH_HOME/data/web-search-pro/overlay.json`, or beside `dbPath` when the user moved it) that `dynamic()` merges on top of the
6
+ * baseline on every read, so every consumer sees the change at once, with no watcher and no restart. It holds only what
7
+ * overlay-types.ts lists (engine order, source priority / disabled, confidence, platform backend chains, custom CLI specs),
8
+ * carries a revision for optimistic locking, a checksum, and an audit trail cut to {@link MAX_AUDIT_ENTRIES} entries.
9
+ *
10
+ * The same functions validate a change before it is written and the whole file when it is loaded: a file that does not parse,
11
+ * fails its checksum, holds a value the validators refuse, or no longer agrees with the baseline (a chain naming a CLI the user
12
+ * removed) is ignored as a whole and reported, never half applied. Validation reuses the functions the settings card and
13
+ * `sources.status` use (`cliIssues`, `sourcesIssues`, the CLI spec validator, the source id rules).
14
+ *
15
+ * Adding or replacing only: the overlay never deletes a baseline entry. Taking a baseline CLI out of service is
16
+ * `sources.disabled`; removing a CLI the overlay added is `config.remove_cli`.
17
+ * @module web-search-pro/overlay
18
+ */
19
+ import crypto from 'node:crypto';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { ActionArgError, ActionConflictError, ActionNotFoundError, ActionUnavailableError } from "./actions/types.js";
23
+ import { isBackendId } from "./cli/chains-spec.js";
24
+ import { resolveCliAdapters, validateCliAdapterSpec } from "./cli/spec.js";
25
+ import { cliIssues, sourcesIssues } from "./client/validators.js";
26
+ import { describeKey, MAX_AUDIT_ENTRIES, MAX_REASON, MIN_REASON, OVERLAY_FILE, OVERLAY_VERSION, overlayKeys, summarizeValue, } from "./overlay-types.js";
27
+ import { resolveSources } from "./pipeline/sources-spec.js";
28
+ export { MAX_AUDIT_ENTRIES, OVERLAY_FILE, OVERLAY_VERSION };
29
+ // ── limits ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────
30
+ const MAX_ENGINES = 40;
31
+ const MAX_LIST = 40;
32
+ const MAX_CONFIDENCE = 60;
33
+ const MAX_PLATFORMS = 30;
34
+ const MAX_BACKENDS = 12;
35
+ const MAX_CLI = 10;
36
+ const MAX_SPEC_BYTES = 16_384;
37
+ const MAX_FILE_BYTES = 1_048_576;
38
+ const FORBIDDEN_IDS = new Set(['__proto__', 'constructor', 'prototype']);
39
+ /** A source, backend or platform id as the configuration writes it (`github-issues`, `custom-cli:mine`). */
40
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,59}$/;
41
+ /** An adapter id (the key of `cliAdapters`): the rule of the spec validator. */
42
+ const CLI_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,39}$/;
43
+ const CUSTOM_PREFIX = 'custom-cli:';
44
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
45
+ const nullRecord = () => Object.create(null);
46
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
47
+ export function emptyOverlay() {
48
+ return { version: OVERLAY_VERSION, revision: 0, updatedAt: new Date(0).toISOString(), values: {}, audit: [] };
49
+ }
50
+ export function checksumOf(overlay) {
51
+ const canonical = JSON.stringify({ version: overlay.version, revision: overlay.revision, updatedAt: overlay.updatedAt, values: overlay.values, audit: overlay.audit });
52
+ return crypto.createHash('sha256').update(canonical).digest('hex');
53
+ }
54
+ /** `<dir of dbPath>/overlay.json`: the overlay sits beside the database, wherever the user put it. */
55
+ export function overlayFileFor(dbPath) {
56
+ return path.join(path.dirname(dbPath), OVERLAY_FILE);
57
+ }
58
+ function partsOf(base, values) {
59
+ return {
60
+ engines: [...values.engines ?? base.engines],
61
+ priority: [...values.sources?.priority ?? base.sources.priority],
62
+ disabled: [...values.sources?.disabled ?? base.sources.disabled],
63
+ confidence: { ...base.sources.confidence, ...values.sources?.confidence },
64
+ platformBackends: { ...base.platformBackends, ...values.platformBackends },
65
+ cliAdapters: { ...base.cliAdapters, ...values.cliAdapters },
66
+ };
67
+ }
68
+ /** The baseline with the overlay merged on top. Never mutates its inputs. */
69
+ export function mergeOverlay(base, values) {
70
+ if (overlayKeys(values).length === 0)
71
+ return base;
72
+ const parts = partsOf(base, values);
73
+ return {
74
+ ...base,
75
+ engines: parts.engines,
76
+ sources: { ...base.sources, priority: parts.priority, disabled: parts.disabled, confidence: parts.confidence },
77
+ ...Object.keys(parts.platformBackends).length ? { platformBackends: parts.platformBackends } : {},
78
+ ...Object.keys(parts.cliAdapters).length ? { cliAdapters: parts.cliAdapters } : {},
79
+ };
80
+ }
81
+ /** Where the effective value of an overlay key comes from, and what it is. */
82
+ export function effectiveValueOf(config, key) {
83
+ const info = describeKey(key);
84
+ if (!info)
85
+ return undefined;
86
+ switch (info.kind) {
87
+ case 'engines': return config.engines;
88
+ case 'priority': return config.sources.priority;
89
+ case 'disabled': return config.sources.disabled;
90
+ case 'confidence': return config.sources.confidence[info.target];
91
+ case 'platform': return config.platformBackends?.[info.target];
92
+ case 'cli': return config.cliAdapters?.[info.target];
93
+ }
94
+ }
95
+ function errorsOf(parts, known) {
96
+ const errors = new Set();
97
+ const specs = resolveCliAdapters(parts.cliAdapters).specs;
98
+ const customOk = (id) => id.startsWith(CUSTOM_PREFIX) && specs.has(id.slice(CUSTOM_PREFIX.length));
99
+ if (parts.engines.length === 0)
100
+ errors.add('engines must list at least one source id');
101
+ for (const id of parts.engines) {
102
+ if (!ID_PATTERN.test(id))
103
+ errors.add('engines: "' + id + '" is not a source id');
104
+ else if (id.startsWith(CUSTOM_PREFIX)) {
105
+ const spec = specs.get(id.slice(CUSTOM_PREFIX.length));
106
+ if (!spec)
107
+ errors.add('engines: "' + id + '" names a custom CLI that is not defined (cliAdapters.' + id.slice(CUSTOM_PREFIX.length) + ')');
108
+ else if (spec.engine !== true)
109
+ errors.add('engines: "' + id + '" is a custom CLI without "engine": true, so it cannot be a general engine');
110
+ }
111
+ else if (!known(id))
112
+ errors.add('engines: "' + id + '" is not a registered source');
113
+ }
114
+ const sources = { priority: parts.priority, disabled: parts.disabled, confidence: parts.confidence };
115
+ const sourceKnown = (id) => known(id) || customOk(id);
116
+ const backendKnown = (id) => isBackendId(id) && (!id.startsWith(CUSTOM_PREFIX) || customOk(id));
117
+ for (const message of resolveSources(sources, sourceKnown, backendKnown).diagnostics)
118
+ if (!message.includes('both prioritised and disabled'))
119
+ errors.add(message);
120
+ for (const issue of sourcesIssues(sources))
121
+ if (issue.level === 'error')
122
+ errors.add(issue.message);
123
+ for (const issue of cliIssues(parts.platformBackends, parts.cliAdapters))
124
+ if (issue.level === 'error')
125
+ errors.add(issue.message);
126
+ return [...errors];
127
+ }
128
+ /** Problems the overlay values add to what the baseline already has (the user's own mistakes are not the overlay's to answer for). */
129
+ export function overlayErrors(base, values, known) {
130
+ const own = new Set(errorsOf(partsOf(base, {}), known));
131
+ return errorsOf(partsOf(base, values), known).filter(message => !own.has(message));
132
+ }
133
+ function checkId(id, what, pattern = ID_PATTERN) {
134
+ if (typeof id !== 'string' || !pattern.test(id) || FORBIDDEN_IDS.has(id))
135
+ throw new ActionArgError(what + ' must be an id such as ' + (pattern === CLI_ID_PATTERN ? '"my-tool" (lower case letters, digits, . _ -, at most 40)' : '"ddg" or "custom-cli:my-tool"') + ', got ' + JSON.stringify(id));
136
+ return id;
137
+ }
138
+ function checkList(value, what, max, options = {}) {
139
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string'))
140
+ throw new ActionArgError(what + ' must be a list of source ids');
141
+ if (value.length > max)
142
+ throw new ActionArgError(what + ' may hold at most ' + max + ' ids');
143
+ if ((options.min ?? 0) > value.length)
144
+ throw new ActionArgError(what + ' must list at least ' + options.min + ' id');
145
+ const ids = value.map(item => item.trim());
146
+ ids.forEach(id => checkId(id, what + ' entry'));
147
+ const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
148
+ if (duplicate !== undefined)
149
+ throw new ActionArgError(what + ' lists "' + duplicate + '" twice');
150
+ return ids;
151
+ }
152
+ /**
153
+ * Every problem with the `id` / `spec` of a `config.upsert_cli` call, one message each ([] when the call is valid). The single
154
+ * source of these rules: {@link OverlayStore.apply} refuses a change that has any, and the action's `precheck` asks it before the
155
+ * user is, so an unusable spec never reaches an approval prompt. It reads no state, so it never depends on the overlay file.
156
+ */
157
+ export function cliSpecProblems(id, spec) {
158
+ const problems = [];
159
+ const idOk = typeof id === 'string' && CLI_ID_PATTERN.test(id) && !FORBIDDEN_IDS.has(id);
160
+ if (!idOk)
161
+ problems.push('id must be an id such as "my-tool" (lower case letters, digits, . _ -, at most 40), got ' + JSON.stringify(id));
162
+ if (!isRecord(spec)) {
163
+ problems.push('spec must be an adapter spec object (see the cliAdapters setting)');
164
+ return problems;
165
+ }
166
+ if (idOk && spec.id !== undefined && spec.id !== id)
167
+ problems.push('spec.id must equal id ("' + id + '") or be left out');
168
+ let size = 0;
169
+ try {
170
+ size = JSON.stringify(spec).length;
171
+ }
172
+ catch {
173
+ problems.push('spec must be plain JSON');
174
+ return problems;
175
+ }
176
+ if (size > MAX_SPEC_BYTES) {
177
+ problems.push('spec is larger than ' + MAX_SPEC_BYTES + ' bytes');
178
+ return problems;
179
+ }
180
+ if (idOk) {
181
+ const { id: _id, ...rest } = spec;
182
+ const checked = validateCliAdapterSpec({ ...rest, id }, 'user');
183
+ if (!checked.ok)
184
+ problems.push(...checked.errors.slice(0, 8).map(error => 'spec: ' + error));
185
+ }
186
+ return problems;
187
+ }
188
+ /** Reason rule shared by every write: a real sentence, not a placeholder. */
189
+ export function checkReason(reason) {
190
+ const text = typeof reason === 'string' ? reason.trim() : '';
191
+ if (text.length < MIN_REASON || text.length > MAX_REASON)
192
+ throw new ActionArgError('reason must be ' + MIN_REASON + '-' + MAX_REASON + ' characters: say why the setting changes (it is recorded in the audit trail)');
193
+ return text;
194
+ }
195
+ /** Shape of the stored values: keys, types, sizes. Throws a plain Error naming the problem (used for the whole file). */
196
+ function checkShape(values) {
197
+ if (!isRecord(values))
198
+ throw new Error('values is not an object');
199
+ for (const key of Object.keys(values))
200
+ if (!['engines', 'sources', 'platformBackends', 'cliAdapters'].includes(key))
201
+ throw new Error('unknown key "' + key + '" in values');
202
+ const out = {};
203
+ const wrap = (fn) => {
204
+ try {
205
+ return fn();
206
+ }
207
+ catch (error) {
208
+ throw new Error(error instanceof Error ? error.message : String(error));
209
+ }
210
+ };
211
+ if (values.engines !== undefined)
212
+ out.engines = wrap(() => checkList(values.engines, 'engines', MAX_ENGINES, { min: 1 }));
213
+ if (values.sources !== undefined) {
214
+ const sources = values.sources;
215
+ if (!isRecord(sources))
216
+ throw new Error('sources is not an object');
217
+ for (const key of Object.keys(sources))
218
+ if (!['priority', 'disabled', 'confidence'].includes(key))
219
+ throw new Error('unknown key "' + key + '" in sources');
220
+ out.sources = {};
221
+ if (sources.priority !== undefined)
222
+ out.sources.priority = wrap(() => checkList(sources.priority, 'sources.priority', MAX_LIST));
223
+ if (sources.disabled !== undefined)
224
+ out.sources.disabled = wrap(() => checkList(sources.disabled, 'sources.disabled', MAX_LIST));
225
+ if (sources.confidence !== undefined) {
226
+ if (!isRecord(sources.confidence))
227
+ throw new Error('sources.confidence is not an object');
228
+ const entries = Object.entries(sources.confidence);
229
+ if (entries.length > MAX_CONFIDENCE)
230
+ throw new Error('too many confidence entries');
231
+ const confidence = nullRecord();
232
+ for (const [id, value] of entries) {
233
+ wrap(() => checkId(id, 'sources.confidence key'));
234
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1)
235
+ throw new Error('sources.confidence.' + id + ' is not a number 0..1');
236
+ confidence[id] = value;
237
+ }
238
+ out.sources.confidence = { ...confidence };
239
+ }
240
+ if (Object.keys(out.sources).length === 0)
241
+ delete out.sources;
242
+ }
243
+ if (values.platformBackends !== undefined) {
244
+ if (!isRecord(values.platformBackends))
245
+ throw new Error('platformBackends is not an object');
246
+ const entries = Object.entries(values.platformBackends);
247
+ if (entries.length > MAX_PLATFORMS)
248
+ throw new Error('too many platforms');
249
+ const chains = nullRecord();
250
+ for (const [platform, ids] of entries) {
251
+ wrap(() => checkId(platform, 'platformBackends key'));
252
+ chains[platform] = wrap(() => checkList(ids, 'platformBackends.' + platform, MAX_BACKENDS));
253
+ }
254
+ out.platformBackends = { ...chains };
255
+ }
256
+ if (values.cliAdapters !== undefined) {
257
+ if (!isRecord(values.cliAdapters))
258
+ throw new Error('cliAdapters is not an object');
259
+ const entries = Object.entries(values.cliAdapters);
260
+ if (entries.length > MAX_CLI)
261
+ throw new Error('too many custom CLIs (at most ' + MAX_CLI + ')');
262
+ const specs = nullRecord();
263
+ for (const [id, spec] of entries) {
264
+ wrap(() => checkId(id, 'cliAdapters key', CLI_ID_PATTERN));
265
+ if (!isRecord(spec) || 'id' in spec)
266
+ throw new Error('cliAdapters.' + id + ' is not a spec object without an id');
267
+ if (JSON.stringify(spec).length > MAX_SPEC_BYTES)
268
+ throw new Error('cliAdapters.' + id + ' is too large');
269
+ const checked = validateCliAdapterSpec({ ...spec, id }, 'user');
270
+ if (!checked.ok)
271
+ throw new Error('cliAdapters.' + id + ' is not a valid spec: ' + checked.errors.slice(0, 3).join('; '));
272
+ specs[id] = spec;
273
+ }
274
+ out.cliAdapters = { ...specs };
275
+ }
276
+ return out;
277
+ }
278
+ function checkAudit(audit) {
279
+ if (!Array.isArray(audit) || audit.length > MAX_AUDIT_ENTRIES)
280
+ throw new Error('bad audit');
281
+ return audit.map((entry) => {
282
+ if (!isRecord(entry) || typeof entry.action !== 'string' || typeof entry.reason !== 'string' || typeof entry.at !== 'string' || !Number.isInteger(entry.revision)
283
+ || (entry.actor !== 'agent' && entry.actor !== 'user') || !Array.isArray(entry.changes))
284
+ throw new Error('bad audit entry');
285
+ const changes = entry.changes.map((change) => {
286
+ if (!isRecord(change) || typeof change.key !== 'string' || typeof change.before !== 'string' || typeof change.after !== 'string')
287
+ throw new Error('bad audit change');
288
+ return { key: change.key, before: change.before, after: change.after };
289
+ });
290
+ return { revision: entry.revision, at: entry.at, actor: entry.actor, action: entry.action, ...typeof entry.sessionId === 'string' ? { sessionId: entry.sessionId } : {}, reason: entry.reason, changes };
291
+ });
292
+ }
293
+ export class OverlayStore {
294
+ options;
295
+ overlay = emptyOverlay();
296
+ signature = '';
297
+ loadProblem;
298
+ compat;
299
+ constructor(options) {
300
+ this.options = options;
301
+ this.reload();
302
+ }
303
+ get file() { return this.options.file; }
304
+ // -- reading ------------------------------------------------------------------------------------------------------------
305
+ stat() {
306
+ try {
307
+ const stat = fs.statSync(this.options.file);
308
+ return stat.mtimeMs + ':' + stat.size;
309
+ }
310
+ catch {
311
+ return 'missing';
312
+ }
313
+ }
314
+ /** Re-read the file when its mtime or size changed. No watcher: every read of the effective config asks. */
315
+ refresh() {
316
+ if (this.stat() !== this.signature)
317
+ this.reload();
318
+ }
319
+ reload() {
320
+ this.signature = this.stat();
321
+ this.loadProblem = undefined;
322
+ this.compat = undefined;
323
+ if (this.signature === 'missing') {
324
+ this.overlay = emptyOverlay();
325
+ return;
326
+ }
327
+ try {
328
+ this.overlay = this.parse(this.readFile());
329
+ }
330
+ catch (error) {
331
+ // Not fail-open: the baseline applies and the problem is visible. The file stays in place for inspection.
332
+ this.overlay = emptyOverlay();
333
+ this.loadProblem = 'the overlay file ' + this.options.file + ' was ignored and the configuration applies as written: ' + String(error instanceof Error ? error.message : error).slice(0, 300);
334
+ }
335
+ }
336
+ readFile() {
337
+ const size = fs.statSync(this.options.file).size;
338
+ if (size > MAX_FILE_BYTES)
339
+ throw new Error('the file is larger than ' + MAX_FILE_BYTES + ' bytes');
340
+ return fs.readFileSync(this.options.file, 'utf8');
341
+ }
342
+ /** Strict parse: unknown keys, wrong types, a failed checksum or any value the validators refuse reject the whole file. */
343
+ parse(text) {
344
+ let raw;
345
+ try {
346
+ raw = JSON.parse(text);
347
+ }
348
+ catch {
349
+ throw new Error('the file is not valid JSON');
350
+ }
351
+ if (!isRecord(raw))
352
+ throw new Error('the file is not an object');
353
+ for (const key of Object.keys(raw))
354
+ if (!['version', 'revision', 'updatedAt', 'values', 'audit', 'checksum'].includes(key))
355
+ throw new Error('unknown key "' + key + '"');
356
+ if (raw.version !== OVERLAY_VERSION)
357
+ throw new Error('unsupported version');
358
+ if (!Number.isInteger(raw.revision) || raw.revision < 0)
359
+ throw new Error('bad revision');
360
+ if (typeof raw.updatedAt !== 'string')
361
+ throw new Error('bad updatedAt');
362
+ const audit = checkAudit(raw.audit);
363
+ const body = { version: OVERLAY_VERSION, revision: raw.revision, updatedAt: raw.updatedAt, values: raw.values, audit };
364
+ if (typeof raw.checksum !== 'string' || raw.checksum !== checksumOf(body))
365
+ throw new Error('the checksum does not match: the file was edited outside the plugin');
366
+ return { ...body, values: checkShape(raw.values) };
367
+ }
368
+ /** Why the overlay is being ignored, if it is: a broken file, or values that no longer agree with the baseline. */
369
+ get problem() {
370
+ this.refresh();
371
+ return this.loadProblem ?? this.incompatibility();
372
+ }
373
+ incompatibility() {
374
+ if (this.loadProblem || overlayKeys(this.overlay.values).length === 0)
375
+ return undefined;
376
+ const base = this.options.baseline();
377
+ const signature = this.overlay.revision + '|' + (this.options.knownSignature?.() ?? '') + '|' + JSON.stringify(partsOf(base, {}));
378
+ if (this.compat?.signature !== signature)
379
+ this.compat = { signature, errors: overlayErrors(base, this.overlay.values, this.options.known) };
380
+ return this.compat.errors.length
381
+ ? 'the overlay (revision ' + this.overlay.revision + ') no longer agrees with the configuration and is ignored as a whole: ' + this.compat.errors.slice(0, 3).join('; ') + (this.compat.errors.length > 3 ? ' (+' + (this.compat.errors.length - 3) + ' more)' : '')
382
+ : undefined;
383
+ }
384
+ /** The loaded overlay (an empty one while the file is missing or ignored). */
385
+ current() {
386
+ this.refresh();
387
+ return this.overlay;
388
+ }
389
+ /** The configuration as the plugin runs it: the baseline with the overlay on top, or the baseline alone while the overlay is ignored. */
390
+ effective() {
391
+ const base = this.options.baseline();
392
+ this.refresh();
393
+ if (this.loadProblem || overlayKeys(this.overlay.values).length === 0 || this.incompatibility())
394
+ return base;
395
+ return mergeOverlay(base, this.overlay.values);
396
+ }
397
+ baselineConfig() { return this.options.baseline(); }
398
+ /** The overlaid keys with the newest audit entry that touched each, for the card and `config.inspect`. */
399
+ items() {
400
+ const overlay = this.current();
401
+ const base = this.options.baseline();
402
+ return overlayKeys(overlay.values).map((key) => {
403
+ const info = describeKey(key);
404
+ const value = effectiveValueOf(mergeOverlay(base, overlay.values), key);
405
+ const latest = [...overlay.audit].reverse().find(entry => entry.changes.some(change => change.key === key));
406
+ return {
407
+ key, kind: info.kind, ...info.target ? { target: info.target } : {}, value,
408
+ summary: summarizeValue(key, value), baseline: summarizeValue(key, effectiveValueOf(base, key)),
409
+ ...latest ? { at: latest.at, reason: latest.reason, actor: latest.actor, ...latest.sessionId ? { sessionId: latest.sessionId } : {} } : {},
410
+ };
411
+ });
412
+ }
413
+ view(limit = 10) {
414
+ const problem = this.problem;
415
+ const overlay = this.current();
416
+ return {
417
+ file: this.options.file, revision: overlay.revision, updatedAt: overlay.updatedAt, valid: problem === undefined, ...problem ? { problem } : {},
418
+ items: this.items(), audit: overlay.audit.slice(-Math.max(1, Math.min(MAX_AUDIT_ENTRIES, limit))).reverse(),
419
+ };
420
+ }
421
+ // -- writing ------------------------------------------------------------------------------------------------------------
422
+ /** Compute the values a change leads to without writing anything. Throws {@link ActionArgError} for an invalid change. */
423
+ plan(change, base) {
424
+ const values = structuredClone(this.overlay.values);
425
+ const sources = () => (values.sources ??= {});
426
+ const here = overlayKeys(values);
427
+ switch (change.kind) {
428
+ case 'order': {
429
+ const given = [change.engines, change.priority, change.disabled, change.platform, change.backends].some(value => value !== undefined);
430
+ if (!given)
431
+ throw new ActionArgError('give at least one of engines, priority, disabled, or platform with backends');
432
+ if ((change.platform === undefined) !== (change.backends === undefined))
433
+ throw new ActionArgError('platform and backends go together: name the platform and give its whole backend chain');
434
+ if (change.engines !== undefined)
435
+ values.engines = checkList(change.engines, 'engines', MAX_ENGINES, { min: 1 });
436
+ if (change.priority !== undefined)
437
+ sources().priority = checkList(change.priority, 'priority', MAX_LIST);
438
+ if (change.disabled !== undefined)
439
+ sources().disabled = checkList(change.disabled, 'disabled', MAX_LIST);
440
+ if (change.platform !== undefined) {
441
+ const platform = checkId(change.platform, 'platform');
442
+ (values.platformBackends ??= {})[platform] = checkList(change.backends, 'backends', MAX_BACKENDS);
443
+ }
444
+ break;
445
+ }
446
+ case 'confidence': {
447
+ const id = checkId(change.id, 'id');
448
+ if (change.value === null) {
449
+ if (values.sources?.confidence && id in values.sources.confidence)
450
+ delete values.sources.confidence[id];
451
+ break;
452
+ }
453
+ if (typeof change.value !== 'number' || !Number.isFinite(change.value) || change.value < 0 || change.value > 1)
454
+ throw new ActionArgError('value must be a number 0..1, or null to drop the overlay entry of ' + id);
455
+ (sources().confidence ??= {})[id] = change.value;
456
+ break;
457
+ }
458
+ case 'upsertCli': {
459
+ const problems = cliSpecProblems(change.id, change.spec);
460
+ if (problems.length)
461
+ throw new ActionArgError(problems.join('; '), 'Fix the id / spec and call again; the same rules apply as in the settings card (read-only commands only, no login or write words, no cookie flags, no secrets).');
462
+ const id = change.id;
463
+ const { id: _id, ...spec } = change.spec;
464
+ if (!(id in (values.cliAdapters ?? {})) && Object.keys(values.cliAdapters ?? {}).length >= MAX_CLI)
465
+ throw new ActionArgError('the overlay holds at most ' + MAX_CLI + ' custom CLIs');
466
+ (values.cliAdapters ??= {})[id] = spec;
467
+ break;
468
+ }
469
+ case 'removeCli': {
470
+ const id = checkId(change.id, 'id', CLI_ID_PATTERN);
471
+ if (!values.cliAdapters || !(id in values.cliAdapters)) {
472
+ throw new ActionNotFoundError('the overlay holds no custom CLI "' + id + '"', base.cliAdapters && id in base.cliAdapters
473
+ ? 'It comes from the configuration, which an agent cannot remove. To stop using it, add custom-cli:' + id + ' to sources.disabled with config.set_order.'
474
+ : 'config.inspect lists the CLIs the overlay added.');
475
+ }
476
+ delete values.cliAdapters[id];
477
+ break;
478
+ }
479
+ case 'reset': {
480
+ const drop = (what, present) => { if (change.id !== undefined && !present)
481
+ throw new ActionNotFoundError('the overlay has no ' + what + ' "' + String(change.id) + '"', 'config.inspect lists what the overlay holds.'); };
482
+ if (change.scope === 'all') {
483
+ delete values.engines;
484
+ delete values.sources;
485
+ delete values.platformBackends;
486
+ delete values.cliAdapters;
487
+ break;
488
+ }
489
+ if (change.scope === 'order') {
490
+ delete values.engines;
491
+ if (values.sources) {
492
+ delete values.sources.priority;
493
+ delete values.sources.disabled;
494
+ }
495
+ break;
496
+ }
497
+ if (change.scope === 'confidence') {
498
+ const map = values.sources?.confidence;
499
+ if (change.id !== undefined) {
500
+ const id = checkId(change.id, 'id');
501
+ drop('confidence entry', !!map && id in map);
502
+ if (map)
503
+ delete map[id];
504
+ }
505
+ else if (values.sources)
506
+ delete values.sources.confidence;
507
+ break;
508
+ }
509
+ if (change.scope === 'cli') {
510
+ const map = values.cliAdapters;
511
+ if (change.id !== undefined) {
512
+ const id = checkId(change.id, 'id', CLI_ID_PATTERN);
513
+ drop('custom CLI', !!map && id in map);
514
+ if (map)
515
+ delete map[id];
516
+ }
517
+ else
518
+ delete values.cliAdapters;
519
+ break;
520
+ }
521
+ const map = values.platformBackends;
522
+ if (change.id !== undefined) {
523
+ const id = checkId(change.id, 'id');
524
+ drop('platform chain', !!map && id in map);
525
+ if (map)
526
+ delete map[id];
527
+ }
528
+ else
529
+ delete values.platformBackends;
530
+ break;
531
+ }
532
+ case 'revoke': {
533
+ if (change.keys === 'all') {
534
+ delete values.engines;
535
+ delete values.sources;
536
+ delete values.platformBackends;
537
+ delete values.cliAdapters;
538
+ break;
539
+ }
540
+ for (const key of change.keys) {
541
+ if (!here.includes(key))
542
+ throw new ActionNotFoundError('the overlay holds no "' + key + '"');
543
+ const info = describeKey(key);
544
+ if (info.kind === 'engines')
545
+ delete values.engines;
546
+ else if (info.kind === 'priority')
547
+ delete values.sources?.priority;
548
+ else if (info.kind === 'disabled')
549
+ delete values.sources?.disabled;
550
+ else if (info.kind === 'confidence')
551
+ delete values.sources?.confidence?.[info.target];
552
+ else if (info.kind === 'platform')
553
+ delete values.platformBackends?.[info.target];
554
+ else
555
+ delete values.cliAdapters?.[info.target];
556
+ }
557
+ break;
558
+ }
559
+ }
560
+ return this.normalize(values, base);
561
+ }
562
+ /** Drop empty containers, and entries equal to the baseline (an override that changes nothing is not one). */
563
+ normalize(values, base) {
564
+ if (values.engines && same(values.engines, base.engines))
565
+ delete values.engines;
566
+ if (values.sources) {
567
+ if (values.sources.priority && same(values.sources.priority, base.sources.priority))
568
+ delete values.sources.priority;
569
+ if (values.sources.disabled && same(values.sources.disabled, base.sources.disabled))
570
+ delete values.sources.disabled;
571
+ for (const [id, value] of Object.entries(values.sources.confidence ?? {}))
572
+ if (base.sources.confidence[id] === value)
573
+ delete values.sources.confidence[id];
574
+ if (values.sources.confidence && Object.keys(values.sources.confidence).length === 0)
575
+ delete values.sources.confidence;
576
+ if (Object.keys(values.sources).length === 0)
577
+ delete values.sources;
578
+ }
579
+ for (const [platform, ids] of Object.entries(values.platformBackends ?? {}))
580
+ if (base.platformBackends?.[platform] && same(base.platformBackends[platform], ids))
581
+ delete values.platformBackends[platform];
582
+ if (values.platformBackends && Object.keys(values.platformBackends).length === 0)
583
+ delete values.platformBackends;
584
+ for (const [id, spec] of Object.entries(values.cliAdapters ?? {}))
585
+ if (base.cliAdapters?.[id] && same({ ...base.cliAdapters[id], id: undefined }, { ...spec, id: undefined }))
586
+ delete values.cliAdapters[id];
587
+ if (values.cliAdapters && Object.keys(values.cliAdapters).length === 0)
588
+ delete values.cliAdapters;
589
+ return values;
590
+ }
591
+ /**
592
+ * Plan, validate and write a change: re-reads the file first, checks the revision, validates the merged result with the
593
+ * server's own functions, writes atomically (0600) and appends the audit entry. A failed change leaves the file as it was.
594
+ */
595
+ apply(change, meta) {
596
+ this.refresh();
597
+ const wholeReset = (change.kind === 'reset' && change.scope === 'all') || (change.kind === 'revoke' && change.keys === 'all');
598
+ let quarantined = false;
599
+ if (this.loadProblem) {
600
+ if (!wholeReset)
601
+ throw new ActionUnavailableError('the overlay file is unreadable and is being ignored (the configuration applies as written), so it cannot be changed: ' + this.loadProblem, 'Nothing was changed. Call config.reset {scope:"all", reason:...} (or ask the user to use "Revoke all" in the settings card) to move the bad file aside, then repeat this change.');
602
+ this.quarantine();
603
+ quarantined = true;
604
+ }
605
+ if (meta.expectedRevision !== undefined && meta.expectedRevision !== this.overlay.revision) {
606
+ throw new ActionConflictError('the overlay is at revision ' + this.overlay.revision + ', not ' + meta.expectedRevision + ': it changed since it was read', 'Call config.inspect, look at what changed, and decide again.');
607
+ }
608
+ const reason = checkReason(meta.reason);
609
+ const base = this.options.baseline();
610
+ const stale = this.incompatibility();
611
+ // A stale overlay can only be taken back (reset / revoke), not added to.
612
+ if (stale && change.kind !== 'reset' && change.kind !== 'revoke') {
613
+ throw new ActionUnavailableError('the overlay no longer agrees with the configuration, so it cannot be changed: ' + stale, 'Nothing was changed. Call config.reset (scope all, or the scope that holds the stale entry) first.');
614
+ }
615
+ const before = this.overlay.values;
616
+ const next = this.plan(change, base);
617
+ if (same(before, next) && !quarantined)
618
+ return { changed: false, revision: this.overlay.revision, keys: [], changes: [] };
619
+ const errors = overlayErrors(base, next, this.options.known);
620
+ if (errors.length) {
621
+ throw new ActionArgError('the change is refused, the overlay stays as it was: ' + errors.slice(0, 4).join('; ') + (errors.length > 4 ? ' (+' + (errors.length - 4) + ' more)' : ''), 'Fix the values (config.inspect shows what is in force, the settings card the same rules) and call again.');
622
+ }
623
+ const keys = [...new Set([...overlayKeys(before), ...overlayKeys(next)])].filter(key => !same(presenceOf(before, key), presenceOf(next, key)));
624
+ const changes = keys.map((key) => ({
625
+ key, before: summarizeValue(key, effectiveValueOf(mergeOverlay(base, before), key)), after: summarizeValue(key, effectiveValueOf(mergeOverlay(base, next), key)),
626
+ }));
627
+ if (quarantined)
628
+ changes.push({ key: '(overlay file)', before: 'unreadable', after: 'moved aside, a fresh overlay started' });
629
+ const at = (this.options.now?.() ?? new Date()).toISOString();
630
+ const audit = {
631
+ revision: this.overlay.revision + 1, at, actor: meta.actor, action: meta.action, ...meta.sessionId ? { sessionId: meta.sessionId } : {}, reason, changes: changes.slice(0, 20),
632
+ };
633
+ const written = { version: OVERLAY_VERSION, revision: audit.revision, updatedAt: at, values: next, audit: [...this.overlay.audit, audit].slice(-MAX_AUDIT_ENTRIES) };
634
+ this.write(written);
635
+ return { changed: true, revision: written.revision, keys, changes, audit };
636
+ }
637
+ write(overlay) {
638
+ const dir = path.dirname(this.options.file);
639
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
640
+ const temporary = this.options.file + '.tmp-' + crypto.randomUUID().slice(0, 8);
641
+ try {
642
+ fs.writeFileSync(temporary, JSON.stringify({ ...overlay, checksum: checksumOf(overlay) }, null, 2), { encoding: 'utf8', mode: 0o600 });
643
+ fs.renameSync(temporary, this.options.file);
644
+ }
645
+ catch (error) {
646
+ try {
647
+ fs.rmSync(temporary, { force: true });
648
+ }
649
+ catch { /* nothing to clean */ }
650
+ throw error;
651
+ }
652
+ this.overlay = overlay;
653
+ this.signature = this.stat();
654
+ this.loadProblem = undefined;
655
+ this.compat = undefined;
656
+ }
657
+ /** Move an unreadable file aside (kept for inspection) so a fresh overlay can start. */
658
+ quarantine() {
659
+ try {
660
+ fs.renameSync(this.options.file, this.options.file + '.corrupt-' + Date.now());
661
+ }
662
+ catch { /* nothing to move */ }
663
+ this.overlay = emptyOverlay();
664
+ this.loadProblem = undefined;
665
+ this.compat = undefined;
666
+ this.signature = this.stat();
667
+ }
668
+ }
669
+ /** Whether the overlay holds a key (and its value), so a key that the overlay just stopped holding counts as changed even when it falls back to an equal value. */
670
+ function presenceOf(values, key) {
671
+ const info = describeKey(key);
672
+ if (!info)
673
+ return undefined;
674
+ switch (info.kind) {
675
+ case 'engines': return values.engines;
676
+ case 'priority': return values.sources?.priority;
677
+ case 'disabled': return values.sources?.disabled;
678
+ case 'confidence': return values.sources?.confidence?.[info.target];
679
+ case 'platform': return values.platformBackends?.[info.target];
680
+ case 'cli': return values.cliAdapters?.[info.target];
681
+ }
682
+ }
683
+ //# sourceMappingURL=overlay.js.map