@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.
- package/CHANGELOG.md +118 -0
- package/CONTRIBUTING.md +96 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/SECURITY.md +174 -0
- package/config.example.json +108 -0
- package/dist/allowance.js +487 -0
- package/dist/gui-experimental.cjs +1 -0
- package/dist/index.js +11111 -0
- package/dist/npm/index.js +40 -0
- package/dist/tui.js +587 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +120 -0
- package/docs/LIMITATIONS.md +213 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +72 -0
- package/install +75 -0
- package/package.json +92 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1030 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +203 -0
- package/src/core/models/seed.ts +771 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +481 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +578 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +628 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1273 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +919 -0
- package/src/core/types.ts +825 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1670 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +48 -0
- package/src/rpc.ts +204 -0
- package/src/server/controller.ts +3281 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +671 -0
- 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
|
+
}
|