@kontextmind/kxm 0.6.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.
Files changed (227) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.kxm/README.md +14 -0
  3. package/.kxm/assets/README.md +5 -0
  4. package/.kxm/assets/retrospectives/README.md +5 -0
  5. package/.kxm/config/README.md +5 -0
  6. package/.kxm/config/agents.json +43 -0
  7. package/.kxm/config/env.example +56 -0
  8. package/.kxm/config/update.example.yaml +9 -0
  9. package/.kxm/config/workflows/fix.json +160 -0
  10. package/.kxm/config/workflows/jira-development.json +116 -0
  11. package/.kxm/config/workflows/provenance-quorum.json +150 -0
  12. package/.kxm/config/workflows/v04-dogfood.json +72 -0
  13. package/CHANGELOG.md +465 -0
  14. package/LICENSE +21 -0
  15. package/README.md +306 -0
  16. package/SECURITY.md +72 -0
  17. package/docs/README.md +48 -0
  18. package/docs/agent-communication-envelopes-and-gates.md +553 -0
  19. package/docs/architecture.md +242 -0
  20. package/docs/assignment-runner.md +241 -0
  21. package/docs/configuration.md +361 -0
  22. package/docs/continuous-improvement.md +114 -0
  23. package/docs/getting-started.md +253 -0
  24. package/docs/kxm-handbook.md +1090 -0
  25. package/docs/operations.md +205 -0
  26. package/docs/provenance-gates.md +291 -0
  27. package/docs/skills.md +45 -0
  28. package/docs/templates/README.md +95 -0
  29. package/docs/templates/adr.md +88 -0
  30. package/docs/templates/architecture.md +120 -0
  31. package/docs/templates/bug-fix.md +109 -0
  32. package/docs/templates/feature.md +108 -0
  33. package/docs/templates/handoff.md +72 -0
  34. package/docs/templates/postmortem.md +77 -0
  35. package/docs/templates/research.md +100 -0
  36. package/docs/templates/review.md +85 -0
  37. package/docs/templates/runbook.md +73 -0
  38. package/docs/templates/test-plan.md +87 -0
  39. package/docs/templates/test-report.md +72 -0
  40. package/docs/test-matrix.md +121 -0
  41. package/docs/troubleshooting.md +249 -0
  42. package/docs/vnext/README.md +62 -0
  43. package/docs/vnext/architecture.md +185 -0
  44. package/docs/vnext/effects-and-recovery.md +172 -0
  45. package/docs/vnext/lifecycles.md +235 -0
  46. package/docs/vnext/migration.md +220 -0
  47. package/docs/vnext/routing.md +184 -0
  48. package/docs/vnext/synchronization.md +172 -0
  49. package/docs/vnext/terminology.md +240 -0
  50. package/docs/vnext/validation.md +335 -0
  51. package/docs/webhook-workflows.md +240 -0
  52. package/docs/workflow-guide.md +1150 -0
  53. package/examples/README.md +102 -0
  54. package/examples/provenance-workflow.json +40 -0
  55. package/examples/requester.ts +30 -0
  56. package/examples/reviewer-agent.ts +29 -0
  57. package/examples/roundtrip.ts +46 -0
  58. package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
  59. package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
  60. package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
  61. package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
  62. package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
  63. package/examples/vnext/.kxm/agents/planner.yaml +13 -0
  64. package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
  65. package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
  66. package/examples/vnext/.kxm/gates.yaml +8 -0
  67. package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
  68. package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
  69. package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
  70. package/examples/vnext/.kxm/models/implementation.yaml +14 -0
  71. package/examples/vnext/.kxm/models/primary.yaml +17 -0
  72. package/examples/vnext/.kxm/prices.yaml +111 -0
  73. package/examples/vnext/.kxm/project/env.yaml +7 -0
  74. package/examples/vnext/.kxm/project.yaml +32 -0
  75. package/examples/vnext/.kxm/repo/repo.yaml +8 -0
  76. package/examples/vnext/.kxm/workflows/default.yaml +92 -0
  77. package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
  78. package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
  79. package/examples/vnext/README.md +53 -0
  80. package/examples/vnext/records/assignment-result-recorded.json +63 -0
  81. package/examples/vnext/records/assignment-result.json +46 -0
  82. package/examples/vnext/records/context-candidate.json +42 -0
  83. package/examples/vnext/records/delivery-manifest.json +66 -0
  84. package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
  85. package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
  86. package/examples/vnext/records/run-created.json +54 -0
  87. package/examples/vnext/records/sync-event.json +65 -0
  88. package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
  89. package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
  90. package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
  91. package/examples/workflow-signal.ts +63 -0
  92. package/package.json +129 -0
  93. package/plugins/kxm/.claude-plugin/plugin.json +73 -0
  94. package/plugins/kxm/.mcp.json +19 -0
  95. package/plugins/kxm/README.md +93 -0
  96. package/plugins/kxm/dist/cli.js +42853 -0
  97. package/plugins/kxm/dist/client.js +416 -0
  98. package/plugins/kxm/dist/core.js +1823 -0
  99. package/plugins/kxm/dist/extension.js +3797 -0
  100. package/plugins/kxm/dist/mcp-server.js +17104 -0
  101. package/plugins/kxm/dist/runtime.js +23361 -0
  102. package/plugins/kxm/dist/server.js +13640 -0
  103. package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
  104. package/plugins/kxm/package.json +12 -0
  105. package/plugins/kxm/skills/kxm/SKILL.md +97 -0
  106. package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
  107. package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
  108. package/plugins/kxm/src/arbiter.ts +355 -0
  109. package/plugins/kxm/src/artifacts-exist.ts +62 -0
  110. package/plugins/kxm/src/autocomplete.ts +236 -0
  111. package/plugins/kxm/src/cli.ts +3707 -0
  112. package/plugins/kxm/src/client.ts +614 -0
  113. package/plugins/kxm/src/commands.ts +1063 -0
  114. package/plugins/kxm/src/config.ts +290 -0
  115. package/plugins/kxm/src/context/providers.ts +101 -0
  116. package/plugins/kxm/src/context-packet.ts +332 -0
  117. package/plugins/kxm/src/context.ts +499 -0
  118. package/plugins/kxm/src/core.ts +6 -0
  119. package/plugins/kxm/src/database.ts +563 -0
  120. package/plugins/kxm/src/diagnostics.ts +184 -0
  121. package/plugins/kxm/src/envelope.ts +118 -0
  122. package/plugins/kxm/src/extension.ts +895 -0
  123. package/plugins/kxm/src/external-effects.ts +299 -0
  124. package/plugins/kxm/src/github-watch.ts +255 -0
  125. package/plugins/kxm/src/hub-binding.ts +160 -0
  126. package/plugins/kxm/src/hub.ts +2502 -0
  127. package/plugins/kxm/src/improve.ts +383 -0
  128. package/plugins/kxm/src/inbox.ts +10 -0
  129. package/plugins/kxm/src/kxm-install-kind.ts +113 -0
  130. package/plugins/kxm/src/kxm-update-config.ts +39 -0
  131. package/plugins/kxm/src/kxm-update.ts +238 -0
  132. package/plugins/kxm/src/local-snapshot.ts +406 -0
  133. package/plugins/kxm/src/logger.ts +198 -0
  134. package/plugins/kxm/src/mcp-server.ts +143 -0
  135. package/plugins/kxm/src/memory.ts +385 -0
  136. package/plugins/kxm/src/nous-pi.ts +287 -0
  137. package/plugins/kxm/src/nous-provider.ts +729 -0
  138. package/plugins/kxm/src/price-calc.ts +87 -0
  139. package/plugins/kxm/src/prices.ts +121 -0
  140. package/plugins/kxm/src/protocol.ts +172 -0
  141. package/plugins/kxm/src/recovery.ts +211 -0
  142. package/plugins/kxm/src/redact.ts +26 -0
  143. package/plugins/kxm/src/retrospective.ts +400 -0
  144. package/plugins/kxm/src/routing.ts +830 -0
  145. package/plugins/kxm/src/runtime.ts +9 -0
  146. package/plugins/kxm/src/server.ts +117 -0
  147. package/plugins/kxm/src/session-work.ts +571 -0
  148. package/plugins/kxm/src/session.ts +184 -0
  149. package/plugins/kxm/src/skills.ts +535 -0
  150. package/plugins/kxm/src/state.ts +326 -0
  151. package/plugins/kxm/src/store.ts +637 -0
  152. package/plugins/kxm/src/studio-layout.ts +268 -0
  153. package/plugins/kxm/src/suggest.ts +162 -0
  154. package/plugins/kxm/src/task-manager.ts +244 -0
  155. package/plugins/kxm/src/telemetry.ts +116 -0
  156. package/plugins/kxm/src/tui.ts +1046 -0
  157. package/plugins/kxm/src/vnext-bindings.ts +403 -0
  158. package/plugins/kxm/src/vnext-config.ts +1646 -0
  159. package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
  160. package/plugins/kxm/src/vnext-engine-command.ts +533 -0
  161. package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
  162. package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
  163. package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
  164. package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
  165. package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
  166. package/plugins/kxm/src/vnext-engine.ts +2458 -0
  167. package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
  168. package/plugins/kxm/src/vnext-harness.ts +1142 -0
  169. package/plugins/kxm/src/vnext-init.ts +430 -0
  170. package/plugins/kxm/src/vnext-migrate.ts +1848 -0
  171. package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
  172. package/plugins/kxm/src/vnext-permission.ts +936 -0
  173. package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
  174. package/plugins/kxm/src/vnext-repair.ts +1094 -0
  175. package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
  176. package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
  177. package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
  178. package/plugins/kxm/src/vnext-runtime.ts +663 -0
  179. package/plugins/kxm/src/vnext-template.ts +247 -0
  180. package/plugins/kxm/src/wiki.ts +313 -0
  181. package/plugins/kxm/src/workflow.ts +1548 -0
  182. package/schemas/vnext/README.md +46 -0
  183. package/schemas/vnext/agent.schema.json +40 -0
  184. package/schemas/vnext/assignment-result.schema.json +66 -0
  185. package/schemas/vnext/backup-manifest.schema.json +89 -0
  186. package/schemas/vnext/candidate.schema.json +109 -0
  187. package/schemas/vnext/common.schema.json +422 -0
  188. package/schemas/vnext/context-candidate.schema.json +76 -0
  189. package/schemas/vnext/context-packet.schema.json +192 -0
  190. package/schemas/vnext/delivery-manifest.schema.json +159 -0
  191. package/schemas/vnext/environment.schema.json +66 -0
  192. package/schemas/vnext/gate-registry.schema.json +109 -0
  193. package/schemas/vnext/handoff-manifest.schema.json +146 -0
  194. package/schemas/vnext/init-operation.schema.json +61 -0
  195. package/schemas/vnext/local-repository-bindings.schema.json +30 -0
  196. package/schemas/vnext/memory-record.schema.json +45 -0
  197. package/schemas/vnext/migration-decision.schema.json +26 -0
  198. package/schemas/vnext/migration-plan.schema.json +123 -0
  199. package/schemas/vnext/migration-receipt.schema.json +52 -0
  200. package/schemas/vnext/model.schema.json +42 -0
  201. package/schemas/vnext/permission-diff.schema.json +57 -0
  202. package/schemas/vnext/prices.schema.json +115 -0
  203. package/schemas/vnext/project.schema.json +85 -0
  204. package/schemas/vnext/repository.schema.json +24 -0
  205. package/schemas/vnext/run-event.schema.json +460 -0
  206. package/schemas/vnext/session-brief.schema.json +153 -0
  207. package/schemas/vnext/sync-event.schema.json +234 -0
  208. package/schemas/vnext/template-provenance.schema.json +38 -0
  209. package/schemas/vnext/workflow.schema.json +248 -0
  210. package/scripts/assignment-run.d.mts +354 -0
  211. package/scripts/assignment-run.mjs +4451 -0
  212. package/scripts/build-runtime.mjs +56 -0
  213. package/scripts/check-generated.mjs +77 -0
  214. package/scripts/check-versions.mjs +34 -0
  215. package/scripts/emit-codex-artifacts.d.mts +9 -0
  216. package/scripts/emit-codex-artifacts.mjs +91 -0
  217. package/scripts/harness-run.d.mts +83 -0
  218. package/scripts/harness-run.mjs +2095 -0
  219. package/scripts/kxm-hub.mjs +105 -0
  220. package/scripts/kxm-publish-npm.mjs +327 -0
  221. package/scripts/kxm-release-github.mjs +472 -0
  222. package/scripts/kxm-runtime-supervisor.mjs +7 -0
  223. package/scripts/kxm-worker.mjs +1127 -0
  224. package/scripts/kxm.mjs +27 -0
  225. package/scripts/roster-policy.d.mts +20 -0
  226. package/scripts/roster-policy.mjs +161 -0
  227. package/scripts/smoke-multi-pi.mjs +479 -0
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Opt-in Nous provider helpers. Pure: env parse, loopback checks, catalog
3
+ * validation, model-config building, and guidance. No Pi / pi-ai imports.
4
+ *
5
+ * Public GET /v1/models catalog fields are observed: context_length,
6
+ * top_provider.max_completion_tokens, architecture.input_modalities,
7
+ * supported_parameters, and pricing.prompt/completion/input_cache_* as
8
+ * per-token decimal strings plus pricing.overrides[]. Convert once to
9
+ * usd_per_million_tokens. Never apply pricing.original or a blanket discount.
10
+ * Assumed OpenAI-style fixtures remain valid. Streaming flags are unverified.
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { redactSecrets } from "./redact.ts";
15
+
16
+ export const NOUS_DIRECT_ID = "nous";
17
+ export const NOUS_PROXY_ID = "nous-proxy";
18
+ export const NOUS_DIRECT_BASE_URL = "https://inference-api.nousresearch.com/v1";
19
+ export const NOUS_PROXY_DEFAULT_BASE_URL = "http://127.0.0.1:8645/v1";
20
+ export const NOUS_PROXY_PLACEHOLDER_KEY = "kxm-nous-proxy";
21
+ export const NOUS_CATALOG_SCHEMA = "kxm.nous-catalog.v1";
22
+ export const NOUS_PRICE_UNITS = "usd_per_million_tokens";
23
+ export const DEFAULT_DISCOVERY_TIMEOUT_MS = 5_000;
24
+ export const NOUS_CATALOG_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
25
+
26
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
27
+ const PROVIDER_TOKENS = new Set(["direct", "proxy"]);
28
+
29
+ export type NousProviderKind = "direct" | "proxy";
30
+ export type NousPriceBasis = "list" | "upper-bound";
31
+ export type NousBilling = "metered" | "subscription" | "subscription_plus_usage";
32
+ export type NousInputModality = "text" | "image";
33
+
34
+ const PER_TOKEN_TO_USD_PER_M = 1_000_000;
35
+ const PI_INPUT_ORDER: NousInputModality[] = ["text", "image"];
36
+
37
+ export interface NousCostRates {
38
+ input: number;
39
+ output: number;
40
+ cacheRead: number;
41
+ cacheWrite: number;
42
+ }
43
+
44
+ export interface NousCostTier extends NousCostRates {
45
+ inputTokensAbove: number;
46
+ }
47
+
48
+ export interface NousModelRecord {
49
+ id: string;
50
+ contextWindow: number;
51
+ maxTokens: number;
52
+ cost: NousCostRates;
53
+ priceBasis: NousPriceBasis;
54
+ billing: NousBilling;
55
+ verified: boolean;
56
+ name?: string;
57
+ }
58
+
59
+ export interface NousCatalogPin {
60
+ schema: typeof NOUS_CATALOG_SCHEMA;
61
+ recordedAt: string;
62
+ source: string;
63
+ units: typeof NOUS_PRICE_UNITS;
64
+ hash: string;
65
+ models: Record<string, Omit<NousModelRecord, "id">>;
66
+ }
67
+
68
+ export interface DiscoveredModel {
69
+ id: string;
70
+ name?: string;
71
+ contextWindow?: number;
72
+ maxTokens?: number;
73
+ cost?: NousCostRates;
74
+ units?: string;
75
+ verified?: boolean;
76
+ tiers?: NousCostTier[];
77
+ input?: NousInputModality[];
78
+ reasoning?: boolean;
79
+ tools?: boolean;
80
+ pricingIssue?: string;
81
+ capabilityIssue?: string;
82
+ }
83
+
84
+ export interface DiscoveryProvenance {
85
+ source: string;
86
+ fetchedAt: string;
87
+ rawSha256: string;
88
+ }
89
+
90
+ export type DiscoveryOutcome =
91
+ | { ok: true; models: DiscoveredModel[]; provenance?: DiscoveryProvenance }
92
+ | { ok: false; error: "timeout" | "connection-refused" | "auth" | "http" | "invalid"; status?: number; reason: string };
93
+
94
+ export type CatalogLoad =
95
+ | { ok: true; pin: NousCatalogPin }
96
+ | { ok: false; reason: string };
97
+
98
+ export interface BuiltModel {
99
+ id: string;
100
+ name: string;
101
+ contextWindow: number;
102
+ maxTokens: number;
103
+ cost: NousCostRates;
104
+ priceBasis: NousPriceBasis;
105
+ billing: NousBilling;
106
+ verified: boolean;
107
+ input?: NousInputModality[];
108
+ reasoning?: boolean;
109
+ tools?: boolean;
110
+ tiers?: NousCostTier[];
111
+ }
112
+
113
+ export interface BuildModelsResult {
114
+ registered: BuiltModel[];
115
+ skipped: Array<{ id: string; reason: string }>;
116
+ }
117
+
118
+ export type GuidanceLevel = "info" | "error";
119
+ export interface GuidanceMessage {
120
+ message: string;
121
+ level: GuidanceLevel;
122
+ }
123
+
124
+ export type NousEnvParse =
125
+ | { status: "unset"; providers: []; timeoutMs: number }
126
+ | { status: "invalid"; providers: []; unknownTokens: string[]; timeoutMs: number; error: string }
127
+ | {
128
+ status: "ready";
129
+ providers: NousProviderKind[];
130
+ timeoutMs: number;
131
+ proxyUrl: string;
132
+ catalogFile?: string;
133
+ };
134
+
135
+ export function parseNousEnv(env: NodeJS.Dict<string> = process.env): NousEnvParse {
136
+ const raw = env.KXM_NOUS_PROVIDERS?.trim() ?? "";
137
+ const timeoutMs = parseTimeout(env.KXM_NOUS_DISCOVERY_TIMEOUT_MS);
138
+ if (!raw) {
139
+ return { status: "unset", providers: [], timeoutMs };
140
+ }
141
+ const tokens = raw.split(",").map((part) => part.trim()).filter(Boolean);
142
+ const unknownTokens = [...new Set(tokens.filter((token) => !PROVIDER_TOKENS.has(token)))];
143
+ if (unknownTokens.length > 0) {
144
+ return {
145
+ status: "invalid",
146
+ providers: [],
147
+ unknownTokens,
148
+ timeoutMs,
149
+ error: `unknown KXM_NOUS_PROVIDERS token(s): ${unknownTokens.join(", ")}; expected direct and/or proxy`,
150
+ };
151
+ }
152
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
153
+ return {
154
+ status: "invalid",
155
+ providers: [],
156
+ unknownTokens: [],
157
+ timeoutMs: DEFAULT_DISCOVERY_TIMEOUT_MS,
158
+ error: "KXM_NOUS_DISCOVERY_TIMEOUT_MS must be a positive finite number of milliseconds",
159
+ };
160
+ }
161
+ const providers = [...new Set(tokens)] as NousProviderKind[];
162
+ const catalogFile = env.KXM_NOUS_CATALOG_FILE?.trim();
163
+ const proxyOverride = env.KXM_NOUS_PROXY_URL?.trim();
164
+ return {
165
+ status: "ready",
166
+ providers,
167
+ timeoutMs,
168
+ proxyUrl: proxyOverride && proxyOverride.length > 0 ? proxyOverride : NOUS_PROXY_DEFAULT_BASE_URL,
169
+ ...(catalogFile ? { catalogFile } : {}),
170
+ };
171
+ }
172
+
173
+ function parseTimeout(raw: string | undefined): number {
174
+ if (raw === undefined || raw.trim() === "") return DEFAULT_DISCOVERY_TIMEOUT_MS;
175
+ const value = Number(raw);
176
+ return value;
177
+ }
178
+
179
+ export function isLoopbackUrl(value: string): boolean {
180
+ let parsed: URL;
181
+ try {
182
+ parsed = new URL(value);
183
+ } catch {
184
+ return false;
185
+ }
186
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
187
+ if (parsed.username || parsed.password) return false;
188
+ const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
189
+ return LOOPBACK_HOSTS.has(host);
190
+ }
191
+
192
+ export function modelsUrl(baseUrl: string): string {
193
+ return `${baseUrl.replace(/\/+$/, "")}/models`;
194
+ }
195
+
196
+ export function sanitizeNousText(value: string): string {
197
+ const stripped = value
198
+ .replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
199
+ .replace(/\b(NOUS_API_KEY|OPENROUTER_API_KEY|ANTHROPIC_API_KEY|api[_-]?key)\s*[:=]\s*\S+/gi, "$1=[redacted]");
200
+ return redactSecrets(stripped).slice(0, 500);
201
+ }
202
+
203
+ export function catalogCanonicalPayload(pin: { recordedAt: string; source: string; units: string; models: unknown }): string {
204
+ return canonicalJson({
205
+ recordedAt: pin.recordedAt,
206
+ source: pin.source,
207
+ units: pin.units,
208
+ models: pin.models,
209
+ });
210
+ }
211
+
212
+ export function catalogHash(pin: { recordedAt: string; source: string; units: string; models: unknown }): string {
213
+ return `sha256:${createHash("sha256").update(catalogCanonicalPayload(pin)).digest("hex")}`;
214
+ }
215
+
216
+ export function parseCatalogPin(raw: unknown, nowMs = Date.now()): CatalogLoad {
217
+ if (!isRecord(raw)) return { ok: false, reason: "catalog is not an object" };
218
+ if (raw.schema !== NOUS_CATALOG_SCHEMA) {
219
+ return { ok: false, reason: `catalog schema must be ${NOUS_CATALOG_SCHEMA}` };
220
+ }
221
+ if (typeof raw.recordedAt !== "string" || Number.isNaN(Date.parse(raw.recordedAt))) {
222
+ return { ok: false, reason: "catalog recordedAt must be an ISO-8601 timestamp" };
223
+ }
224
+ if (typeof raw.source !== "string" || raw.source.trim().length === 0) {
225
+ return { ok: false, reason: "catalog source is required" };
226
+ }
227
+ if (raw.units !== NOUS_PRICE_UNITS) {
228
+ return { ok: false, reason: `catalog units must be ${NOUS_PRICE_UNITS}` };
229
+ }
230
+ if (typeof raw.hash !== "string" || !/^sha256:[a-f0-9]{64}$/.test(raw.hash)) {
231
+ return { ok: false, reason: "catalog hash must be sha256:<64 hex>" };
232
+ }
233
+ if (!isRecord(raw.models)) return { ok: false, reason: "catalog models must be an object" };
234
+
235
+ const expected = catalogHash({
236
+ recordedAt: raw.recordedAt,
237
+ source: raw.source.trim(),
238
+ units: NOUS_PRICE_UNITS,
239
+ models: raw.models,
240
+ });
241
+ if (raw.hash !== expected) {
242
+ return { ok: false, reason: "catalog hash does not match recordedAt/source/units/models" };
243
+ }
244
+
245
+ const models: NousCatalogPin["models"] = {};
246
+ for (const [id, entry] of Object.entries(raw.models)) {
247
+ const parsed = parsePinModel(id, entry);
248
+ if (!parsed.ok) return parsed;
249
+ models[id] = parsed.model;
250
+ }
251
+
252
+ const pin: NousCatalogPin = {
253
+ schema: NOUS_CATALOG_SCHEMA,
254
+ recordedAt: raw.recordedAt,
255
+ source: raw.source.trim(),
256
+ units: NOUS_PRICE_UNITS,
257
+ hash: raw.hash,
258
+ models,
259
+ };
260
+ const recordedAtMs = Date.parse(pin.recordedAt);
261
+ if (recordedAtMs > nowMs + 60_000) {
262
+ return { ok: false, reason: "catalog recordedAt is in the future" };
263
+ }
264
+ if (nowMs - recordedAtMs > NOUS_CATALOG_MAX_AGE_MS) {
265
+ return { ok: false, reason: "catalog is stale (recordedAt older than 30 days)" };
266
+ }
267
+ return { ok: true, pin };
268
+ }
269
+
270
+ function parsePinModel(id: string, entry: unknown): { ok: true; model: Omit<NousModelRecord, "id"> } | { ok: false; reason: string } {
271
+ if (!id.trim()) return { ok: false, reason: "catalog model id is empty" };
272
+ if (!isRecord(entry)) return { ok: false, reason: `catalog model ${id} is not an object` };
273
+ const contextWindow = asPositiveInt(entry.contextWindow);
274
+ const maxTokens = asPositiveInt(entry.maxTokens);
275
+ if (contextWindow === undefined || maxTokens === undefined) {
276
+ return { ok: false, reason: `catalog model ${id} is missing positive contextWindow/maxTokens` };
277
+ }
278
+ if (entry.priceBasis !== "list" && entry.priceBasis !== "upper-bound") {
279
+ return { ok: false, reason: `catalog model ${id} priceBasis must be list or upper-bound` };
280
+ }
281
+ if (entry.billing !== "metered" && entry.billing !== "subscription" && entry.billing !== "subscription_plus_usage") {
282
+ return { ok: false, reason: `catalog model ${id} billing must be metered, subscription, or subscription_plus_usage` };
283
+ }
284
+ if (typeof entry.verified !== "boolean") {
285
+ return { ok: false, reason: `catalog model ${id} verified must be a boolean` };
286
+ }
287
+ const rates = parseRates(entry.cost);
288
+ if (!rates) return { ok: false, reason: `catalog model ${id} cost rates must be finite nonnegative numbers` };
289
+ let cost = rates;
290
+ let priceBasis: NousPriceBasis = entry.priceBasis;
291
+ if (entry.tiers !== undefined) {
292
+ if (!Array.isArray(entry.tiers) || entry.tiers.length === 0) {
293
+ return { ok: false, reason: `catalog model ${id} tiers must be a nonempty array when present` };
294
+ }
295
+ const tierRates: NousCostRates[] = [rates];
296
+ for (const tier of entry.tiers) {
297
+ if (!isRecord(tier)) return { ok: false, reason: `catalog model ${id} has a malformed tier` };
298
+ const parsedTier = parseRates(tier);
299
+ if (!parsedTier) return { ok: false, reason: `catalog model ${id} tier rates must be finite nonnegative numbers` };
300
+ if (asNonnegInt(tier.inputTokensAbove) === undefined) {
301
+ return { ok: false, reason: `catalog model ${id} tier is missing inputTokensAbove` };
302
+ }
303
+ tierRates.push(parsedTier);
304
+ }
305
+ cost = upperBoundRates(tierRates);
306
+ priceBasis = "upper-bound";
307
+ }
308
+ if (!entry.verified && hasZeroRate(cost)) {
309
+ return { ok: false, reason: `catalog model ${id} has unverified zero rates` };
310
+ }
311
+ return {
312
+ ok: true,
313
+ model: {
314
+ contextWindow,
315
+ maxTokens,
316
+ cost,
317
+ priceBasis,
318
+ billing: entry.billing,
319
+ verified: entry.verified,
320
+ ...(typeof entry.name === "string" && entry.name.trim() ? { name: entry.name.trim() } : {}),
321
+ },
322
+ };
323
+ }
324
+
325
+ export function parseModelsResponse(raw: unknown): DiscoveryOutcome {
326
+ if (!isRecord(raw) || !Array.isArray(raw.data)) {
327
+ return { ok: false, error: "invalid", reason: "models response is not an object with a data array" };
328
+ }
329
+ const models: DiscoveredModel[] = [];
330
+ for (const item of raw.data) {
331
+ if (!isRecord(item) || typeof item.id !== "string" || !item.id.trim()) {
332
+ return { ok: false, error: "invalid", reason: "models response contains an entry without an id" };
333
+ }
334
+ const model: DiscoveredModel = { id: item.id.trim() };
335
+ if (typeof item.name === "string" && item.name.trim()) model.name = item.name.trim();
336
+ const topProvider = isRecord(item.top_provider) ? item.top_provider : undefined;
337
+ const contextWindow = asPositiveInt(item.contextWindow ?? item.context_window ?? item.context_length);
338
+ const maxTokens = asPositiveInt(
339
+ item.maxTokens ?? item.max_tokens ?? item.max_output_tokens ?? topProvider?.max_completion_tokens,
340
+ );
341
+ if (contextWindow !== undefined) model.contextWindow = contextWindow;
342
+ if (maxTokens !== undefined) model.maxTokens = maxTokens;
343
+ if (typeof item.units === "string") model.units = item.units;
344
+ if (typeof item.verified === "boolean") model.verified = item.verified;
345
+
346
+ const architecture = isRecord(item.architecture) ? item.architecture : undefined;
347
+ if (architecture && "input_modalities" in architecture) {
348
+ const input = parseInputModalities(architecture.input_modalities);
349
+ if (!input) model.capabilityIssue = "unknown input modalities";
350
+ else model.input = input;
351
+ }
352
+
353
+ const supported = parseSupportedParameters(item.supported_parameters);
354
+ if (supported) {
355
+ model.reasoning = supported.reasoning;
356
+ model.tools = supported.tools;
357
+ }
358
+
359
+ if (isRecord(item.pricing)) {
360
+ const live = parseLivePricing(item.pricing);
361
+ if (!live) {
362
+ model.pricingIssue = "malformed or incomplete live pricing; costly tiers are not dropped and zeros are not guessed";
363
+ } else {
364
+ model.cost = live.cost;
365
+ model.units = NOUS_PRICE_UNITS;
366
+ if (live.tiers.length > 0) model.tiers = live.tiers;
367
+ }
368
+ } else {
369
+ const cost = isRecord(item.cost) ? parseRates(item.cost) : undefined;
370
+ if (cost) model.cost = cost;
371
+ if (Array.isArray(item.tiers)) {
372
+ const tiers: NousCostTier[] = [];
373
+ for (const tier of item.tiers) {
374
+ if (!isRecord(tier)) {
375
+ model.pricingIssue = "malformed assumed-shape tier";
376
+ break;
377
+ }
378
+ const rates = parseRates(tier);
379
+ const above = asNonnegInt(tier.inputTokensAbove ?? tier.input_tokens_above);
380
+ if (!rates || above === undefined) {
381
+ model.pricingIssue = "incomplete assumed-shape tier";
382
+ break;
383
+ }
384
+ tiers.push({ ...rates, inputTokensAbove: above });
385
+ }
386
+ if (!model.pricingIssue && tiers.length > 0) model.tiers = tiers;
387
+ if (model.pricingIssue) {
388
+ delete model.cost;
389
+ delete model.tiers;
390
+ }
391
+ }
392
+ }
393
+ models.push(model);
394
+ }
395
+ return { ok: true, models };
396
+ }
397
+
398
+ export async function fetchNousModels(options: {
399
+ url: string;
400
+ timeoutMs: number;
401
+ authorization?: string;
402
+ fetchImpl?: typeof fetch;
403
+ nowMs?: number;
404
+ }): Promise<DiscoveryOutcome> {
405
+ const fetchImpl = options.fetchImpl ?? fetch;
406
+ const controller = new AbortController();
407
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
408
+ try {
409
+ const headers: Record<string, string> = { Accept: "application/json" };
410
+ if (options.authorization) headers.Authorization = `Bearer ${options.authorization}`;
411
+ const response = await fetchImpl(options.url, { method: "GET", headers, signal: controller.signal });
412
+ if (response.status === 401 || response.status === 403) {
413
+ return { ok: false, error: "auth", status: response.status, reason: `HTTP ${response.status}` };
414
+ }
415
+ if (!response.ok) {
416
+ return { ok: false, error: "http", status: response.status, reason: `HTTP ${response.status}` };
417
+ }
418
+ let text: string;
419
+ try {
420
+ text = await response.text();
421
+ } catch {
422
+ return { ok: false, error: "invalid", reason: "models response is not JSON" };
423
+ }
424
+ let payload: unknown;
425
+ try {
426
+ payload = JSON.parse(text) as unknown;
427
+ } catch {
428
+ return { ok: false, error: "invalid", reason: "models response is not JSON" };
429
+ }
430
+ const parsed = parseModelsResponse(payload);
431
+ if (!parsed.ok) return parsed;
432
+ return {
433
+ ...parsed,
434
+ provenance: {
435
+ source: options.url,
436
+ fetchedAt: new Date(options.nowMs ?? Date.now()).toISOString(),
437
+ rawSha256: `sha256:${createHash("sha256").update(text).digest("hex")}`,
438
+ },
439
+ };
440
+ } catch (error) {
441
+ if (isAbortError(error)) {
442
+ return { ok: false, error: "timeout", reason: `discovery timed out after ${options.timeoutMs}ms` };
443
+ }
444
+ if (isConnectionRefused(error)) {
445
+ return { ok: false, error: "connection-refused", reason: "connection refused" };
446
+ }
447
+ return { ok: false, error: "http", reason: sanitizeNousText(error instanceof Error ? error.message : "discovery failed") };
448
+ } finally {
449
+ clearTimeout(timer);
450
+ }
451
+ }
452
+
453
+ export function buildModelConfigs(
454
+ discovered: DiscoveredModel[],
455
+ pin: NousCatalogPin | undefined,
456
+ route: NousProviderKind,
457
+ ): BuildModelsResult {
458
+ const registered: BuiltModel[] = [];
459
+ const skipped: Array<{ id: string; reason: string }> = [];
460
+ const requiredBilling: NousBilling[] = route === "direct"
461
+ ? ["metered"]
462
+ : ["subscription", "subscription_plus_usage"];
463
+
464
+ for (const item of discovered) {
465
+ if (item.capabilityIssue) {
466
+ skipped.push({ id: item.id, reason: `excluded: ${item.capabilityIssue}` });
467
+ continue;
468
+ }
469
+ const fromPin = pin?.models[item.id];
470
+ if (item.pricingIssue && !fromPin) {
471
+ skipped.push({ id: item.id, reason: `unpriced, not registered: ${item.pricingIssue}` });
472
+ continue;
473
+ }
474
+ const capacity = {
475
+ contextWindow: fromPin?.contextWindow ?? item.contextWindow,
476
+ maxTokens: fromPin?.maxTokens ?? item.maxTokens,
477
+ };
478
+ if (capacity.contextWindow === undefined || capacity.maxTokens === undefined) {
479
+ skipped.push({ id: item.id, reason: "unpriced, not registered: missing contextWindow or maxTokens" });
480
+ continue;
481
+ }
482
+ let cost: NousCostRates | undefined;
483
+ let priceBasis: NousPriceBasis = "list";
484
+ let verified = false;
485
+ let tiers: NousCostTier[] | undefined;
486
+ if (fromPin) {
487
+ cost = fromPin.cost;
488
+ priceBasis = fromPin.priceBasis;
489
+ verified = fromPin.verified;
490
+ } else {
491
+ const rateSources: NousCostRates[] = [];
492
+ if (item.cost) rateSources.push(item.cost);
493
+ if (item.tiers && item.tiers.length > 0) rateSources.push(...item.tiers);
494
+ if (rateSources.length === 0) {
495
+ skipped.push({ id: item.id, reason: "unpriced, not registered: no numeric cost rates and no catalog pin" });
496
+ continue;
497
+ }
498
+ if (item.units !== NOUS_PRICE_UNITS) {
499
+ skipped.push({ id: item.id, reason: `unpriced, not registered: units must be ${NOUS_PRICE_UNITS}` });
500
+ continue;
501
+ }
502
+ if (item.tiers && item.tiers.length > 0) {
503
+ priceBasis = "upper-bound";
504
+ cost = upperBoundRates(rateSources);
505
+ tiers = item.tiers;
506
+ } else {
507
+ cost = item.cost;
508
+ }
509
+ verified = item.verified === true;
510
+ }
511
+ if (!cost) {
512
+ skipped.push({ id: item.id, reason: "unpriced, not registered" });
513
+ continue;
514
+ }
515
+ if (hasZeroRate(cost) && !verified) {
516
+ skipped.push({ id: item.id, reason: "unpriced, not registered: unverified zero rates" });
517
+ continue;
518
+ }
519
+ const billing: NousBilling = fromPin?.billing ?? (route === "direct" ? "metered" : "subscription");
520
+ if (!requiredBilling.includes(billing)) {
521
+ skipped.push({ id: item.id, reason: `excluded: billing ${billing} is not valid for ${route}` });
522
+ continue;
523
+ }
524
+ const display = labeledModelName(fromPin?.name ?? item.name ?? item.id, route, priceBasis);
525
+ registered.push({
526
+ id: item.id,
527
+ name: display,
528
+ contextWindow: capacity.contextWindow,
529
+ maxTokens: capacity.maxTokens,
530
+ cost,
531
+ priceBasis,
532
+ billing,
533
+ verified,
534
+ ...(item.input ? { input: item.input } : {}),
535
+ ...(item.reasoning !== undefined ? { reasoning: item.reasoning } : {}),
536
+ ...(item.tools !== undefined ? { tools: item.tools } : {}),
537
+ ...(tiers ? { tiers } : {}),
538
+ });
539
+ }
540
+ return { registered, skipped };
541
+ }
542
+
543
+ export function guidanceFor(input: {
544
+ parsed: NousEnvParse;
545
+ directHasKey?: boolean;
546
+ directDiscovery?: DiscoveryOutcome;
547
+ proxyDiscovery?: DiscoveryOutcome;
548
+ proxyUrlInvalid?: boolean;
549
+ catalogError?: string;
550
+ skipped?: Array<{ id: string; reason: string }>;
551
+ }): GuidanceMessage[] {
552
+ const messages: GuidanceMessage[] = [];
553
+ if (input.parsed.status === "invalid") {
554
+ messages.push({ message: input.parsed.error, level: "error" });
555
+ return messages;
556
+ }
557
+ if (input.parsed.status !== "ready") return messages;
558
+ if (input.parsed.providers.includes("direct") && !input.directHasKey) {
559
+ messages.push({
560
+ message: "Nous direct API is opted in but NOUS_API_KEY is unset. Set NOUS_API_KEY. This slice does not use stored Pi /login credentials.",
561
+ level: "info",
562
+ });
563
+ }
564
+ if (input.proxyUrlInvalid) {
565
+ messages.push({
566
+ message: "KXM_NOUS_PROXY_URL must be a loopback http(s) URL (127.0.0.1, localhost, or ::1). Failing closed.",
567
+ level: "error",
568
+ });
569
+ }
570
+ if (input.proxyDiscovery?.ok === false && input.proxyDiscovery.error === "connection-refused") {
571
+ messages.push({
572
+ message: "Nous proxy is not reachable. Start it with `hermes proxy start` and check `hermes proxy status`. KXM does not install, spawn, or log in for you.",
573
+ level: "info",
574
+ });
575
+ }
576
+ if (input.proxyDiscovery?.ok === false && input.proxyDiscovery.error === "auth") {
577
+ messages.push({
578
+ message: "Nous proxy returned unauthorized. Log in with `hermes login --provider nous`. Newer docs also mention `hermes setup --portal` (not verified on this CLI).",
579
+ level: "info",
580
+ });
581
+ }
582
+ if (input.directDiscovery?.ok === false && input.directDiscovery.error === "timeout") {
583
+ messages.push({ message: `Nous direct discovery ${input.directDiscovery.reason}.`, level: "info" });
584
+ }
585
+ if (input.proxyDiscovery?.ok === false && input.proxyDiscovery.error === "timeout") {
586
+ messages.push({ message: `Nous proxy discovery ${input.proxyDiscovery.reason}.`, level: "info" });
587
+ }
588
+ if (input.catalogError) {
589
+ messages.push({ message: `Nous catalog pin not used: ${input.catalogError}.`, level: "error" });
590
+ }
591
+ if (input.skipped && input.skipped.length > 0) {
592
+ const preview = input.skipped.slice(0, 8).map((item) => `${item.id} (${item.reason})`).join("; ");
593
+ messages.push({
594
+ message: `Skipped ${input.skipped.length} Nous model(s) as unpriced or invalid: ${preview}.`,
595
+ level: "info",
596
+ });
597
+ }
598
+ return messages.map((item) => ({ ...item, message: sanitizeNousText(item.message) }));
599
+ }
600
+
601
+ function labeledModelName(base: string, route: NousProviderKind, priceBasis: NousPriceBasis): string {
602
+ if (route === "proxy") {
603
+ return priceBasis === "upper-bound"
604
+ ? `${base} (subscription proxy, market ref; upper-bound)`
605
+ : `${base} (subscription proxy, market ref)`;
606
+ }
607
+ return priceBasis === "upper-bound" ? `${base} (upper-bound market ref)` : base;
608
+ }
609
+
610
+ function parseLivePricing(pricing: Record<string, unknown>): { cost: NousCostRates; tiers: NousCostTier[] } | undefined {
611
+ const cost = parseLiveRates(pricing);
612
+ if (!cost) return undefined;
613
+ if (!("overrides" in pricing) || pricing.overrides === undefined) {
614
+ return { cost, tiers: [] };
615
+ }
616
+ if (!Array.isArray(pricing.overrides)) return undefined;
617
+ const tiers: NousCostTier[] = [];
618
+ for (const override of pricing.overrides) {
619
+ if (!isRecord(override)) return undefined;
620
+ const rates = parseLiveRates(override);
621
+ const above = asNonnegInt(override.min_prompt_tokens ?? override.minPromptTokens);
622
+ if (!rates || above === undefined) return undefined;
623
+ tiers.push({ ...rates, inputTokensAbove: above });
624
+ }
625
+ return { cost, tiers };
626
+ }
627
+
628
+ function parseLiveRates(value: Record<string, unknown>): NousCostRates | undefined {
629
+ const input = perTokenToUsdPerM(value.prompt);
630
+ const output = perTokenToUsdPerM(value.completion);
631
+ const cacheRead = perTokenToUsdPerM(value.input_cache_read ?? value.inputCacheRead);
632
+ const cacheWrite = perTokenToUsdPerM(value.input_cache_write ?? value.inputCacheWrite);
633
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined) return undefined;
634
+ return { input, output, cacheRead, cacheWrite };
635
+ }
636
+
637
+ function perTokenToUsdPerM(value: unknown): number | undefined {
638
+ let perToken: number | undefined;
639
+ if (typeof value === "number") perToken = value;
640
+ else if (typeof value === "string" && value.trim() !== "") perToken = Number(value);
641
+ if (perToken === undefined || !Number.isFinite(perToken) || perToken < 0) return undefined;
642
+ const usdPerM = Number((perToken * PER_TOKEN_TO_USD_PER_M).toFixed(8));
643
+ if (!Number.isFinite(usdPerM) || usdPerM < 0) return undefined;
644
+ return usdPerM;
645
+ }
646
+
647
+ function parseInputModalities(value: unknown): NousInputModality[] | undefined {
648
+ if (!Array.isArray(value)) return undefined;
649
+ const present = new Set(value.filter((item): item is NousInputModality => item === "text" || item === "image"));
650
+ const mapped = PI_INPUT_ORDER.filter((item) => present.has(item));
651
+ return mapped.length > 0 ? mapped : undefined;
652
+ }
653
+
654
+ function parseSupportedParameters(value: unknown): { reasoning: boolean; tools: boolean } | undefined {
655
+ if (!Array.isArray(value)) return undefined;
656
+ const params = new Set(value.filter((item): item is string => typeof item === "string"));
657
+ return {
658
+ reasoning: params.has("reasoning") || params.has("include_reasoning"),
659
+ tools: params.has("tools") || params.has("tool_choice"),
660
+ };
661
+ }
662
+
663
+ function parseRates(value: unknown): NousCostRates | undefined {
664
+ if (!isRecord(value)) return undefined;
665
+ const input = asFiniteNonneg(value.input);
666
+ const output = asFiniteNonneg(value.output);
667
+ const cacheRead = asFiniteNonneg(value.cacheRead ?? value.cache_read ?? value.cache_input);
668
+ const cacheWrite = asFiniteNonneg(value.cacheWrite ?? value.cache_write);
669
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined) return undefined;
670
+ return { input, output, cacheRead, cacheWrite };
671
+ }
672
+
673
+ function upperBoundRates(list: NousCostRates[]): NousCostRates {
674
+ return {
675
+ input: Math.max(...list.map((item) => item.input)),
676
+ output: Math.max(...list.map((item) => item.output)),
677
+ cacheRead: Math.max(...list.map((item) => item.cacheRead)),
678
+ cacheWrite: Math.max(...list.map((item) => item.cacheWrite)),
679
+ };
680
+ }
681
+
682
+ function hasZeroRate(cost: NousCostRates): boolean {
683
+ return cost.input === 0 || cost.output === 0 || cost.cacheRead === 0 || cost.cacheWrite === 0;
684
+ }
685
+
686
+ function asFiniteNonneg(value: unknown): number | undefined {
687
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
688
+ return value;
689
+ }
690
+
691
+ function asPositiveInt(value: unknown): number | undefined {
692
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) return undefined;
693
+ return value;
694
+ }
695
+
696
+ function asNonnegInt(value: unknown): number | undefined {
697
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return undefined;
698
+ return value;
699
+ }
700
+
701
+ function isRecord(value: unknown): value is Record<string, unknown> {
702
+ return typeof value === "object" && value !== null && !Array.isArray(value);
703
+ }
704
+
705
+ function canonicalJson(value: unknown): string {
706
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
707
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
708
+ const keys = Object.keys(value).sort();
709
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
710
+ }
711
+
712
+ function isAbortError(error: unknown): boolean {
713
+ if (!error || typeof error !== "object") return false;
714
+ const name = (error as { name?: string }).name;
715
+ return name === "AbortError" || name === "TimeoutError";
716
+ }
717
+
718
+ function isConnectionRefused(error: unknown): boolean {
719
+ const codes: string[] = [];
720
+ let current: unknown = error;
721
+ for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
722
+ const code = (current as { code?: unknown }).code;
723
+ if (typeof code === "string") codes.push(code);
724
+ const message = (current as { message?: unknown }).message;
725
+ if (typeof message === "string" && /ECONNREFUSED/i.test(message)) return true;
726
+ current = (current as { cause?: unknown }).cause;
727
+ }
728
+ return codes.includes("ECONNREFUSED");
729
+ }