@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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 (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,305 @@
1
+ import type { TaskClass, TaskKind, TaskTier } from "../types.js"
2
+ import { isImageName, isImageUri, unique } from "../util.js"
3
+
4
+ export interface ClassifierFile {
5
+ readonly uri: string
6
+ readonly name?: string
7
+ readonly description?: string
8
+ }
9
+
10
+ export interface ClassifierInput {
11
+ readonly text: string
12
+ readonly files?: ReadonlyArray<ClassifierFile>
13
+ readonly agent?: string
14
+ readonly parentID?: string
15
+ readonly contextTokens?: number
16
+ readonly promptTokens?: number
17
+ readonly recentFailures?: number
18
+ readonly now?: number
19
+ }
20
+
21
+ const narrowCss =
22
+ /^\s*(?:please\s+)?(?:change|set|update|make)\b[^\n]{0,200}\b(?:color|colour|background|background-color|padding|margin|font-size|font-weight|border-radius|border-color|width|height|gap|display)\b[^\n]{0,120}\b(?:to|:)\s*(?:#[0-9a-f]{3,8}|\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|ch|fr)?|[a-z][a-z-]*)\b/i
23
+
24
+ const broadDesignTerms =
25
+ /\b(redesign|design system|visual design|design critique|design review|user experience|\bux\b|\bu[iI]\b audit|figma|mockup|wireframe|typography|color palette|colour palette|landing page design|component library|accessibility audit|a11y audit|design token|responsive layout)\b/i
26
+
27
+ const designTerms =
28
+ /\b(design|redesign|visual|layout|responsive|style|styling|css|tailwind|theme|animation|typography|colour|color|component|hover|focus|dark mode|light mode|accessibility|a11y|figma|mockup|screenshot|wireframe)\b/i
29
+
30
+ const architectureTerms =
31
+ /\b(architecture|architectural|multi-tenant|multi tenant|distributed system|microservice|event-driven|schema design|database design|data model|migration|monorepo|scalab|concurrency|race condition|deadlock|memory leak|performance profiling|throughput|latency budget|production incident|cross-service|cross service|refactor across)\b/i
32
+
33
+ const securityTerms =
34
+ /\b(security review|threat model|authorization model|authentication model|oauth|encryption|secrets management|vulnerability|exploit|injection|xss|csrf|privilege escalation)\b/i
35
+
36
+ const hardDebugTerms =
37
+ /\b(intermittent|flaky|only sometimes|production|regression|memory leak|deadlock|race condition|multiple services|cross-service)\b/i
38
+
39
+ const mechanicalPatterns: ReadonlyArray<{ readonly name: string; readonly pattern: RegExp }> = [
40
+ { name: "rename", pattern: /\brename\s+(?:the\s+)?[A-Za-z_$][\w$.-]*\s+to\s+[A-Za-z_$][\w$.-]*\b/i },
41
+ { name: "typo", pattern: /\b(fix|correct)\s+(?:a\s+)?(?:typo|spelling|misspell(?:ing)?)\b/i },
42
+ { name: "format", pattern: /\b(format|prettier|lint|eslint|clang-format|gofmt|rustfmt)\b/i },
43
+ { name: "imports", pattern: /\b(sort|organize|remove unused)\s+imports?\b/i },
44
+ { name: "replace", pattern: /\breplace\s+["'`].+["'`]\s+with\s+["'`].+["'`]/i },
45
+ { name: "version", pattern: /\b(bump|update|change)\s+(?:the\s+)?(?:package\s+)?version\b/i },
46
+ { name: "comment", pattern: /\b(add|update|fix)\s+(?:a\s+)?(?:code\s+)?(?:comment|docstring|jsdoc)\b/i },
47
+ { name: "readme", pattern: /\b(update|format|fix)\s+(?:the\s+)?(?:readme|documentation|docs)\b/i },
48
+ { name: "import-fix", pattern: /\b(fix|correct)\s+(?:an?\s+)?(?:import|indentation|whitespace)\b/i },
49
+ ]
50
+
51
+ const broadScopeTerms =
52
+ /\b(all|every|everywhere|across|multiple|components|codebase|project|architecture|refactor|feature|implement|migrate|migration|responsive|theme|states?|tests?|debug|investigate|why|security|performance)\b/i
53
+
54
+ const deterministicPattern =
55
+ /^\s*(?:please\s+)?(?:run|execute)\s+(?:the\s+)?(?:formatter|linter|lint|tests?|test suite|typecheck|type check|build|validation)\b/i
56
+
57
+ const premiumConstraint =
58
+ /\b(use|pick|choose)\s+(?:the\s+)?(?:best|strongest|most capable|premium)\b|\b(premium model|best model|strongest model|think hard|deep reasoning|use (?:claude )?opus|use (?:claude )?sonnet|use gpt-6|use glm-5\.3)\b/i
59
+
60
+ const economyConstraint =
61
+ /\b(use|pick|choose)\s+(?:the\s+)?(?:cheapest|cheap|economy|budget)\b|\b(?:don'?t|do not|avoid|no)\s+premium\b|\bminimum cost\b|\bsave (?:quota|credits)\b/i
62
+
63
+ const simpleWord = /\b(simple|small|quick|trivial|easy|just)\b/i
64
+
65
+ const countCodeBlocks = (text: string) => {
66
+ const blocks = text.match(/```[\s\S]*?```/g) ?? []
67
+ const longest = blocks.reduce((max, block) => Math.max(max, block.split(/\r?\n/).length), 0)
68
+ return { count: blocks.length, longest }
69
+ }
70
+
71
+ const countPaths = (text: string) => {
72
+ const matches = text.match(/(?:^|[\s("'`])((?:[\w.-]+\/)*[\w.-]+\.(?:tsx?|jsx?|mjs|cjs|css|scss|sass|html|json|jsonc|md|mdx|py|go|rs|swift|kt|java|rb|php|yaml|yml|toml|sh|sql|vue|svelte))\b/gi)
73
+ return unique((matches ?? []).map((value) => value.trim().replace(/^["'`(]/, ""))).length
74
+ }
75
+
76
+ const looksLikeImage = (file: ClassifierFile) =>
77
+ isImageUri(file.uri) || isImageName(file.name) || isImageName(file.description) || /image|screenshot|photo/i.test(file.description ?? "")
78
+
79
+ const classifyKind = (input: ClassifierInput, flags: {
80
+ design: boolean
81
+ architecture: boolean
82
+ security: boolean
83
+ debug: boolean
84
+ deterministic: boolean
85
+ mechanical: boolean
86
+ }): TaskKind => {
87
+ const text = input.text.toLowerCase()
88
+ if (flags.deterministic) return "deterministic"
89
+ if (flags.design) return "design"
90
+ if (flags.security) return "security"
91
+ if (flags.architecture) return "architecture"
92
+ if (flags.debug) return "debug"
93
+ if (/\brefactor\b/.test(text)) return "refactor"
94
+ if (/\b(test|spec|coverage|vitest|jest|playwright)\b/.test(text) && !/\blatest\b/.test(text)) return "test"
95
+ if (/\b(readme|documentation|docs|jsdoc|docstring|comment)\b/.test(text) && input.text.length < 1200) return "docs"
96
+ if (flags.mechanical) return "mechanical"
97
+ if (/\b(add|implement|build|create|support|introduce|feature)\b/.test(text)) return "feature"
98
+ return "unknown"
99
+ }
100
+
101
+ /**
102
+ * Deterministic, local task classification. It uses structural signals rather
103
+ * than trusting words such as "simple" or "quick". Ambiguity resolves upward to
104
+ * the standard tier.
105
+ */
106
+ export const classifyTask = (input: ClassifierInput): TaskClass => {
107
+ const text = input.text ?? ""
108
+ const files = input.files ?? []
109
+ const signals: string[] = []
110
+ const reasons: string[] = []
111
+ const code = countCodeBlocks(text)
112
+ const paths = countPaths(text)
113
+ // Paths such as src/components/Button.tsx must not masquerade as scope words
114
+ // like "components" or "tests". Keywords are scored on prose and code outside
115
+ // fenced blocks, with file paths removed.
116
+ const scoringText = text
117
+ .replace(/```[\s\S]*?```/g, " ")
118
+ .replace(/(?:[\w.-]+\/)+[\w.-]+\.(?:tsx?|jsx?|mjs|cjs|css|scss|sass|html|json|jsonc|md|mdx|py|go|rs|swift|kt|java|rb|php|yaml|yml|toml|sh|sql|vue|svelte)\b/gi, " ")
119
+ // Negative instructions ("do not run tests", "don't change anything else")
120
+ // are constraints, not evidence of broad scope.
121
+ const scopeText = scoringText.replace(/\b(?:do not|don't|never|avoid|without)\b[^.!?\n]*/gi, " ")
122
+ const lower = scoringText.toLowerCase()
123
+ const attachedImages = files.filter(looksLikeImage)
124
+ const hasImage = attachedImages.length > 0
125
+ const hasScreenshot =
126
+ hasImage &&
127
+ (/\b(screenshot|screen shot|figma|mockup|wireframe|visual|image|photo|design|ui)\b/i.test(text) ||
128
+ attachedImages.some((file) => /screenshot|screen|figma|mockup|wireframe|image|photo|design/i.test(`${file.name ?? ""} ${file.description ?? ""}`)))
129
+ const hasStackTrace =
130
+ /\n\s+at\s+\S+/.test(text) ||
131
+ /traceback \(most recent call last\)/i.test(text) ||
132
+ /\buncaught\b|\bpanic:|\bexception\b|\bstderr\b/i.test(text)
133
+ const promptChars = text.length
134
+ const promptTokens = input.promptTokens ?? Math.max(1, Math.ceil(promptChars / 4))
135
+ const contextTokens = input.contextTokens ?? 0
136
+ const recentFailures = input.recentFailures ?? 0
137
+
138
+ if (hasImage) signals.push("image_input")
139
+ if (hasScreenshot) signals.push("screenshot_input")
140
+ if (hasStackTrace) signals.push("stack_trace")
141
+ if (code.count > 0) signals.push("code_blocks")
142
+ if (paths > 1) signals.push("multiple_paths")
143
+ if (simpleWord.test(scoringText)) signals.push("self_described_simple_ignored")
144
+ if (recentFailures >= 2) signals.push("recent_failures")
145
+
146
+ const uiTermCount = (
147
+ scoringText.match(
148
+ /\b(responsive|theme|layout|typography|animation|keyboard navigation|loading state|error state|settings page|navbar|modal|form|button|component|accessibility|dark mode|light mode)\b/gi,
149
+ ) ?? []
150
+ ).length
151
+ const designFeature =
152
+ uiTermCount >= 2 &&
153
+ /\b(implement|build|create|add|design|redesign|page|component|layout|ui|screen|view)\b/i.test(scoringText)
154
+ const designIntensive =
155
+ broadDesignTerms.test(scoringText) ||
156
+ designFeature ||
157
+ (designTerms.test(scoringText) && (hasScreenshot || promptChars > 260)) ||
158
+ (hasScreenshot && /\b(improve|fix|review|match|implement|build|polish|critique)\b/i.test(text))
159
+ const sophisticatedDesign =
160
+ broadDesignTerms.test(scoringText) &&
161
+ !(narrowCss.test(text) && promptChars < 400) &&
162
+ /\b(redesign|design system|visual design|design critique|design review|ux audit|figma|mockup|wireframe|landing page|component library|accessibility audit|typography|design token)\b/i.test(scoringText)
163
+ if (designIntensive) signals.push("design_intensive")
164
+ if (sophisticatedDesign) signals.push("sophisticated_design")
165
+
166
+ const architecture = architectureTerms.test(scoringText)
167
+ const security = securityTerms.test(scoringText)
168
+ const hardDebug = hasStackTrace && hardDebugTerms.test(scoringText)
169
+ if (architecture) signals.push("architecture")
170
+ if (security) signals.push("security")
171
+ if (hardDebug) signals.push("hard_debug")
172
+
173
+ const deterministic = deterministicPattern.test(scopeText) && !/\b(add|change|implement|create|fix|refactor|update\s+the\s+code)\b/i.test(text)
174
+ if (deterministic) signals.push("deterministic_tooling")
175
+
176
+ const mechanicalMatches = mechanicalPatterns.filter((pattern) => pattern.pattern.test(scopeText))
177
+ const narrowScope =
178
+ promptChars < 550 &&
179
+ code.count <= 1 &&
180
+ code.longest <= 12 &&
181
+ paths <= 2 &&
182
+ !hasStackTrace &&
183
+ !broadScopeTerms.test(scopeText)
184
+ const onlyCss = narrowCss.test(text) && !/\b(theme|responsive|hover|focus|active|dark mode|light mode|all|every|components?|across|states?)\b/i.test(scopeText)
185
+ const mechanical =
186
+ (narrowScope && mechanicalMatches.length > 0) ||
187
+ (narrowScope && onlyCss) ||
188
+ (deterministic && promptChars < 300)
189
+ if (mechanicalMatches.length > 0) signals.push(`mechanical:${mechanicalMatches.map((match) => match.name).join(",")}`)
190
+ if (onlyCss) signals.push("narrow_css")
191
+ if (narrowScope) signals.push("narrow_scope")
192
+
193
+ const constraints = {
194
+ avoidPremium: economyConstraint.test(scoringText),
195
+ requestPremium: premiumConstraint.test(scoringText),
196
+ requestEconomy: /\b(cheapest|cheap|economy|budget)\b/i.test(text),
197
+ }
198
+ if (constraints.avoidPremium) signals.push("constraint:avoid_premium")
199
+ if (constraints.requestPremium) signals.push("constraint:request_premium")
200
+ if (constraints.requestEconomy) signals.push("constraint:request_economy")
201
+
202
+ let tier: TaskTier = 2
203
+ let confidence: TaskClass["confidence"] = "medium"
204
+
205
+ const strongPremiumSignals =
206
+ (architecture && /\b(architecture|multi-tenant|distributed|migration|concurrency|race condition|deadlock|memory leak|production incident)\b/i.test(text)) ||
207
+ security ||
208
+ hardDebug ||
209
+ sophisticatedDesign ||
210
+ (recentFailures >= 2 && (designIntensive || architecture || security || hardDebug || /\b(debug|fix|refactor|implement)\b/i.test(text)))
211
+
212
+ if (constraints.avoidPremium) {
213
+ tier = mechanical && narrowScope ? 1 : 2
214
+ reasons.push("User asked to avoid premium or expensive models; premium tiers are excluded.")
215
+ confidence = "high"
216
+ } else if (constraints.requestPremium) {
217
+ tier = 3
218
+ reasons.push("User explicitly asked for the strongest or a premium model; premium approval is still required by policy.")
219
+ confidence = "high"
220
+ } else if (strongPremiumSignals) {
221
+ tier = 3
222
+ reasons.push(
223
+ sophisticatedDesign
224
+ ? "Sophisticated design work requires a high design-capability score in addition to general intelligence."
225
+ : architecture
226
+ ? "Architecture or cross-system changes materially benefit from a premium model."
227
+ : security
228
+ ? "Security-sensitive work benefits from a premium model and stricter review."
229
+ : "Intermittent or multi-component debugging can exceed standard-model reliability.",
230
+ )
231
+ confidence = strongPremiumSignals && (designIntensive || architecture || security || hardDebug) ? "high" : "medium"
232
+ } else if (deterministic) {
233
+ tier = 0
234
+ reasons.push("This is deterministic tooling work; use existing formatters, linters, tests, or type checks.")
235
+ confidence = "high"
236
+ } else if (mechanical) {
237
+ tier = 1
238
+ reasons.push(
239
+ onlyCss
240
+ ? "A single, narrow styling change with unambiguous scope and deterministic validation."
241
+ : "A narrow mechanical edit with a small, explicit scope.",
242
+ )
243
+ confidence = narrowScope && (mechanicalMatches.length > 0 || onlyCss) ? "high" : "medium"
244
+ } else {
245
+ tier = 2
246
+ reasons.push("Substantive engineering work stays at or above the DeepSeek V4.1 Flash intelligence floor.")
247
+ confidence = "medium"
248
+ }
249
+
250
+ // A design request that is not sophisticated still needs a design-capable
251
+ // standard model, never a text-only model when a screenshot is attached.
252
+ if (designIntensive && tier < 2) {
253
+ tier = 2
254
+ reasons.push("Design work was raised to the standard tier because economy models are not qualified for design judgment.")
255
+ confidence = "high"
256
+ }
257
+
258
+ if (hasScreenshot && !designIntensive) {
259
+ reasons.push("Screenshot input requires native image understanding.")
260
+ }
261
+
262
+ const kind = classifyKind(input, {
263
+ design: designIntensive,
264
+ architecture,
265
+ security,
266
+ debug: hasStackTrace || /\b(bug|error|failing|fails|broken|doesn't work|does not work|debug)\b/i.test(text),
267
+ deterministic,
268
+ mechanical,
269
+ })
270
+
271
+ const needsLongContext = contextTokens > 250_000 || /\b(600k|large context|long conversation|whole repository|entire repo)\b/i.test(text)
272
+ if (needsLongContext) signals.push("long_context")
273
+
274
+ const scope = {
275
+ files: paths + files.length,
276
+ codeBlocks: code.count,
277
+ longestCodeBlockLines: code.longest,
278
+ hasStackTrace,
279
+ hasImage,
280
+ hasScreenshot,
281
+ promptChars,
282
+ promptTokens,
283
+ }
284
+
285
+ if (tier === 2 && confidence === "medium" && /\b(simple|small|quick|trivial)\b/i.test(text)) {
286
+ reasons.push("Self-described simplicity was ignored; the scope signals did not meet the economy-tier requirements.")
287
+ }
288
+
289
+ return {
290
+ tier,
291
+ kind,
292
+ requiresVision: hasImage,
293
+ designIntensive,
294
+ sophisticatedDesign,
295
+ needsLongContext,
296
+ confidence,
297
+ signals: unique(signals),
298
+ reasons: unique(reasons),
299
+ constraints,
300
+ scope,
301
+ }
302
+ }
303
+
304
+ /** Exposed for focused tests. */
305
+ export const __classifierInternals = { narrowCss, countCodeBlocks, countPaths, mechanicalPatterns }
@@ -0,0 +1,167 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { dirname, join } from "node:path"
4
+
5
+ /**
6
+ * The one place the router is allowed to write its own config.
7
+ *
8
+ * A settings panel that tells a user their number is wrong and then makes them
9
+ * hand-edit JSON is not a control panel, it is a bug report. But two writers on
10
+ * one file is how config systems rot, so every write goes through here and this
11
+ * file is deliberately small and paranoid:
12
+ *
13
+ * 1. Only the keys the caller names are changed. Everything else, including
14
+ * keys this version does not understand, is preserved byte-for-byte in
15
+ * structure.
16
+ * 2. The result is validated before it replaces anything, so the GUI cannot
17
+ * produce a file the loader would reject or silently repair.
18
+ * 3. It is written to a temp file and renamed, so a crash mid-write leaves the
19
+ * previous config intact.
20
+ * 4. One timestamped backup per write, keeping the last few.
21
+ *
22
+ * Known cost, stated plainly: JSON has no comments, so a hand-commented file
23
+ * loses them. Callers that care can set `ui.readOnlyConfig`.
24
+ */
25
+
26
+ export interface WriteResult {
27
+ readonly path: string
28
+ /** Keys the caller asked to change, flattened. */
29
+ readonly changed: ReadonlyArray<string>
30
+ readonly backup?: string
31
+ }
32
+
33
+ export const settingsPath = (home = homedir()): string => join(home, ".config", "opencode", "smart-router.json")
34
+
35
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
36
+ typeof value === "object" && value !== null && !Array.isArray(value)
37
+
38
+ /** Deep merge where `patch` wins. Arrays and scalars replace wholesale. */
39
+ export const mergePatch = (base: unknown, patch: unknown): unknown => {
40
+ if (!isRecord(patch)) return patch
41
+ const target = isRecord(base) ? { ...base } : {}
42
+ for (const [key, value] of Object.entries(patch)) {
43
+ if (value === undefined) continue
44
+ target[key] = isRecord(value) ? mergePatch(target[key], value) : value
45
+ }
46
+ return target
47
+ }
48
+
49
+ export const flattenKeys = (patch: unknown, prefix = ""): string[] => {
50
+ if (!isRecord(patch)) return prefix ? [prefix] : []
51
+ return Object.entries(patch).flatMap(([key, value]) =>
52
+ isRecord(value) ? flattenKeys(value, prefix ? `${prefix}.${key}` : key) : [prefix ? `${prefix}.${key}` : key],
53
+ )
54
+ }
55
+
56
+ const pruneBackups = (path: string, keep = 5): void => {
57
+ try {
58
+ const dir = dirname(path)
59
+ const name = path.slice(dir.length + 1)
60
+ const backups = readdirSync(dir)
61
+ .filter((entry) => entry.startsWith(`${name}.bak-`))
62
+ .sort()
63
+ for (const stale of backups.slice(0, Math.max(0, backups.length - keep))) {
64
+ unlinkSync(join(dir, stale))
65
+ }
66
+ } catch {
67
+ // Backup pruning is housekeeping; never fail a write over it.
68
+ }
69
+ }
70
+
71
+ export interface WriteSettingsOptions {
72
+ readonly patch: unknown
73
+ readonly home?: string
74
+ readonly path?: string
75
+ /** Reject the write instead of applying it. Return a message to show the user. */
76
+ readonly validate?: (merged: unknown) => string | undefined
77
+ readonly now?: () => number
78
+ }
79
+
80
+ export const writeSettingsPatch = (options: WriteSettingsOptions): WriteResult => {
81
+ const path = options.path ?? settingsPath(options.home)
82
+ const now = options.now ?? Date.now
83
+ let existing: unknown = {}
84
+ if (existsSync(path)) {
85
+ const text = readFileSync(path, "utf8")
86
+ try {
87
+ existing = JSON.parse(text)
88
+ } catch (error) {
89
+ // Refuse rather than overwrite: a broken file is the user's data, and
90
+ // replacing it would destroy whatever they were trying to hand-edit.
91
+ throw new Error(`Existing config is not valid JSON (${(error as Error).message}); refusing to overwrite it`)
92
+ }
93
+ }
94
+
95
+ const merged = mergePatch(existing, options.patch)
96
+ const problem = options.validate?.(merged)
97
+ if (problem) throw new Error(problem)
98
+
99
+ mkdirSync(dirname(path), { recursive: true })
100
+
101
+ let backup: string | undefined
102
+ if (existsSync(path)) {
103
+ backup = `${path}.bak-${new Date(now()).toISOString().replace(/[:.]/g, "-")}`
104
+ copyFileSync(path, backup)
105
+ }
106
+
107
+ // Same directory, so the rename is atomic on the same filesystem.
108
+ const temp = `${path}.tmp-${process.pid}`
109
+ writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 })
110
+ chmodSync(temp, 0o600)
111
+ renameSync(temp, path)
112
+
113
+ pruneBackups(path)
114
+ return { path, changed: flattenKeys(options.patch), ...(backup ? { backup } : {}) }
115
+ }
116
+
117
+ /**
118
+ * Parse what a person copies off an account page.
119
+ *
120
+ * The page shows a percentage; the router works in dollars; somebody has to
121
+ * divide. Accepting the text exactly as it appears removes the arithmetic and
122
+ * with it the mistake that arithmetic invites.
123
+ *
124
+ * "42%" -> percentage 42
125
+ * "$37.80" -> dollars 37.8
126
+ * "37.80" -> percentage 37.8, because that is the shape the page shows
127
+ */
128
+ export const parseAccountReading = (
129
+ raw: string,
130
+ ): { kind: "pct"; value: number } | { kind: "usd"; value: number } | undefined => {
131
+ const text = raw.trim()
132
+ if (!text) return undefined
133
+ if (text.includes("%")) {
134
+ const value = Number(text.replace("%", "").replace(/,/g, "").trim())
135
+ return Number.isFinite(value) && value >= 0 ? { kind: "pct", value: Math.min(100, value) } : undefined
136
+ }
137
+ if (text.includes("$")) {
138
+ const value = Number(text.replace("$", "").replace(/,/g, "").trim())
139
+ return Number.isFinite(value) && value >= 0 ? { kind: "usd", value } : undefined
140
+ }
141
+ const value = Number(text.replace(/,/g, ""))
142
+ return Number.isFinite(value) && value >= 0 ? { kind: "pct", value: Math.min(100, value) } : undefined
143
+ }
144
+
145
+ /**
146
+ * Parse a "resets in" phrase into an absolute time.
147
+ *
148
+ * Accepts what the page writes ("0d 23h", "23h") and what people type ("2 days",
149
+ * "in 90m"). Returns undefined rather than a guess when it cannot tell, so the
150
+ * caller can leave the field out instead of inventing a reset.
151
+ */
152
+ export const parseResetIn = (raw: string, now = Date.now()): number | undefined => {
153
+ const text = raw.trim().toLowerCase()
154
+ if (!text) return undefined
155
+ const days = /(\d+)\s*d/.exec(text)?.[1]
156
+ const hours = /(\d+)\s*h/.exec(text)?.[1]
157
+ const minutes = /(\d+)\s*m/.exec(text)?.[1]
158
+ if (days === undefined && hours === undefined && minutes === undefined) {
159
+ const absolute = Date.parse(raw)
160
+ return Number.isFinite(absolute) ? absolute : undefined
161
+ }
162
+ const d = Number(days ?? 0)
163
+ const h = Number(hours ?? 0)
164
+ const m = Number(minutes ?? 0)
165
+ if (d + h + m === 0) return undefined
166
+ return now + ((d * 24 + h) * 60 + m) * 60_000
167
+ }