clauderipple 0.8.9 → 0.9.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 +62 -0
- package/README.es.md +0 -4
- package/README.ja.md +0 -4
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +0 -4
- package/README.zh-CN.md +0 -4
- package/dist/app/dist/main.js +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/index.js +1 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/cli/src/tray.js +41 -5
- package/dist/mod/.claude-plugin/plugin.json +9 -1
- package/dist/mod/hooks/cache.ts +60 -0
- package/dist/mod/hooks/compact.ts +40 -0
- package/dist/mod/hooks/handoff.ts +43 -0
- package/dist/mod/hooks/i18n.ts +2716 -0
- package/dist/mod/hooks/judge.ts +226 -0
- package/dist/mod/hooks/ladder.js +179 -0
- package/dist/mod/hooks/ladder.ts +174 -0
- package/dist/mod/hooks/modes.ts +93 -0
- package/dist/mod/hooks/register.tsx +1458 -256
- package/dist/mod/hooks/session.ts +62 -0
- package/dist/mod/hooks/settings.js +342 -0
- package/dist/mod/hooks/settings.ts +321 -0
- package/dist/mod/hooks/stats.ts +59 -0
- package/dist/mod/hooks/ui/agents.tsx +138 -0
- package/dist/mod/hooks/ui/alerts.tsx +370 -0
- package/dist/mod/hooks/ui/art.ts +628 -0
- package/dist/mod/hooks/ui/band.tsx +836 -0
- package/dist/mod/hooks/ui/kit.tsx +258 -0
- package/dist/mod/hooks/ui/log.tsx +109 -0
- package/dist/mod/hooks/ui/parts.tsx +217 -0
- package/dist/mod/hooks/ui/ripple.ts +614 -0
- package/dist/mod/hooks/ui/settings.tsx +376 -0
- package/dist/mod/hooks/ui/setup.tsx +148 -0
- package/dist/mod/hooks/ui/theme.ts +217 -0
- package/dist/mod/hooks/ui/view.ts +371 -0
- package/dist/mod/hooks/worker.ts +1 -1
- package/dist/mod/hooks/workers.ts +217 -0
- package/dist/mod/tests/register.test.tsx +1730 -0
- package/dist/mod/tests/ui.test.tsx +853 -0
- package/dist/mod/tsconfig.json +2 -1
- package/dist/mod/types/index.d.ts +320 -1
- package/dist/router/src/admin.js +195 -53
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/compat.js +10 -2
- package/dist/router/src/config.js +3 -0
- package/dist/router/src/health.js +33 -0
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/ingress/models.js +4 -1
- package/dist/router/src/mod-settings.js +119 -0
- package/dist/router/src/model-policy.js +6 -0
- package/dist/router/src/model-scores.js +159 -0
- package/dist/router/src/picker.js +7 -0
- package/dist/router/src/providers/anthropic-account-pool.js +10 -4
- package/dist/router/src/providers/anthropic-accounts.js +26 -1
- package/dist/router/src/providers/chatgpt/catalog.js +45 -14
- package/dist/router/src/providers/chatgpt/index.js +9 -3
- package/dist/router/src/providers/chatgpt/translate.js +9 -5
- package/dist/router/src/providers/google/index.js +6 -2
- package/dist/router/src/providers/openai/index.js +4 -1
- package/dist/router/src/providers/openai/translate.js +1 -1
- package/dist/router/src/proxy.js +14 -3
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +932 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +923 -237
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -408
- package/docs/ARCHITECTURE.md +134 -2
- package/package.json +7 -3
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { Domain, Effort, JudgeInput, Kind, Level, Pick, Settings, Verdict, WorkerVerdict } from '../types'
|
|
2
|
+
import { DOMAINS, LEVELS } from './settings.ts'
|
|
3
|
+
import { t } from './i18n.ts'
|
|
4
|
+
import type { Lang } from './i18n.ts'
|
|
5
|
+
|
|
6
|
+
const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
|
|
7
|
+
|
|
8
|
+
export const EFFORT_JUDGE_SYSTEM = `Choose the cheapest reasoning effort that can do this work well. This is a coding agent able to inspect files, run commands, and edit code, not merely answer a chat question.
|
|
9
|
+
Assess work volume, uncertainty, and the cost of mistakes, not message length. A short choice approving a build inherits the size of that build. Corrections and steering retain at least the running effort; closing remarks need little effort. "Think hard" or thorough reasoning requires at least high; an explicit quick answer calls for low.
|
|
10
|
+
Low and medium are only for turns that need no judgment: low for chit-chat, thanks, or a direct answer needing no inspection; medium for one fully specified, low-risk step the user spelled out (run this command, rename this). Anything that asks the agent to decide, explain why, review, verify, debug, plan, delegate, or change more than one thing needs at least high; most coding-agent turns are high. xhigh is for unusually difficult investigation, and max only for the hardest open-ended problems. Consider the current model's strength.
|
|
11
|
+
Return only JSON {"effort":"low|medium|high|xhigh|max","sure":0.0,"why":"short reason"}. Confidence is between zero and one. The reason uses the user's language and at most six words.`
|
|
12
|
+
|
|
13
|
+
/** A terse continuation usually carries no new scope; answers to questions are handled separately. */
|
|
14
|
+
export function isShortFollowUp(text: string): boolean {
|
|
15
|
+
const trimmed = text.trim()
|
|
16
|
+
return trimmed.length > 0 && trimmed.length <= 12 && trimmed.split(/\s+/).length <= 2
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function looksLikeQuestion(assistantTail: string): boolean {
|
|
20
|
+
return assistantTail.slice(-400).includes('?')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The caller resolves slash commands so skills can be judged while host commands stay untouched. */
|
|
24
|
+
export function shouldJudge(input: {
|
|
25
|
+
text: string; origin: string; auto: boolean; hostCommand?: boolean; modelId?: string
|
|
26
|
+
attachments?: readonly { type: string }[]; current?: Pick | null; assistantTail?: string
|
|
27
|
+
}): boolean {
|
|
28
|
+
if (!input.auto || !['composer', 'bridge', 'sdk'].includes(input.origin) || (!input.text.trim() && !input.attachments?.length) || input.hostCommand) return false
|
|
29
|
+
if (input.modelId !== undefined && !isCacheSafeModel(input.modelId)) return false
|
|
30
|
+
const followup = input.current && input.current.by !== 'manual' && !input.text.trim().startsWith('/') &&
|
|
31
|
+
!input.attachments?.length && isShortFollowUp(input.text) && !looksLikeQuestion(input.assistantTail ?? '')
|
|
32
|
+
return !followup
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function recent(input: JudgeInput): string {
|
|
36
|
+
const user = input.messages.findLast(message => message.role === 'user' && message.text.trim())
|
|
37
|
+
const assistant = input.messages.findLast(message => message.role === 'assistant' && message.text.trim())
|
|
38
|
+
return [user && `user: ${user.text.slice(0, 400)}`, assistant && `assistant: ${assistant.text.slice(-2000)}`].filter(Boolean).join('\n')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function nextText(input: JudgeInput, limit: number): string {
|
|
42
|
+
const counts: Record<string, number> = {}
|
|
43
|
+
for (const attachment of input.attachments ?? []) counts[attachment.type] = (counts[attachment.type] ?? 0) + 1
|
|
44
|
+
const attachments = Object.entries(counts).map(([type, count]) => `${type}: ${count}`).join(', ')
|
|
45
|
+
return [input.next.slice(0, limit), attachments && `Attachments (${attachments}).`,
|
|
46
|
+
input.midTurn && 'Steering a running turn: keep its effort for corrections and additions.'].filter(Boolean).join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Keep options in the assistant tail, since a one-letter answer may approve substantial work. */
|
|
50
|
+
export function judgeContext(input: JudgeInput): string {
|
|
51
|
+
return [
|
|
52
|
+
`Current: ${input.current?.model ?? 'unknown'} / ${input.current?.effort ?? 'medium'}`,
|
|
53
|
+
recent(input) && `Recent conversation:\n${recent(input)}`,
|
|
54
|
+
input.contextPercent !== undefined && Number.isFinite(input.contextPercent) && `Context: ${Math.round(input.contextPercent)}%`,
|
|
55
|
+
`Next message:\n${nextText(input, 2000)}`,
|
|
56
|
+
].filter(Boolean).join('\n\n')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
60
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function confidence(value: unknown): number | undefined {
|
|
64
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? value : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function effort(value: unknown): Effort | undefined {
|
|
68
|
+
return EFFORTS.includes(value as Effort) ? value as Effort : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** JSON is a boundary: prose and fences are harmless, malformed objects are not verdicts. */
|
|
72
|
+
export function parseEffortVerdict(text: string): Verdict | undefined {
|
|
73
|
+
const value = verdictObject(text)
|
|
74
|
+
const level = effort(value?.effort)
|
|
75
|
+
if (!level) return undefined
|
|
76
|
+
const sure = confidence(value?.sure)
|
|
77
|
+
return { effort: level, ...(sure !== undefined ? { sure } : {}), why: typeof value?.why === 'string' ? value.why.slice(0, 60) : '' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Shared boundary decoder keeps effort and worker fields on the same object. */
|
|
81
|
+
export function verdictObject(text: string): Record<string, unknown> | undefined {
|
|
82
|
+
for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
|
|
83
|
+
for (let end = text.lastIndexOf('}'); end > start; end = text.lastIndexOf('}', end - 1)) {
|
|
84
|
+
try {
|
|
85
|
+
const value: unknown = JSON.parse(text.slice(start, end + 1))
|
|
86
|
+
const parsed = record(value)
|
|
87
|
+
if (parsed) return parsed
|
|
88
|
+
} catch {
|
|
89
|
+
// Try a smaller span: a judge can put braces in its surrounding prose.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const effortCriteria = {
|
|
97
|
+
low: 'Small direct work with little uncertainty or planning.',
|
|
98
|
+
medium: 'Ordinary bounded coding with a few inspections or edits.',
|
|
99
|
+
high: 'Substantial multi-step coding or debugging where mistakes matter.',
|
|
100
|
+
xhigh: 'Difficult extended investigation with interacting constraints.',
|
|
101
|
+
max: 'Exceptional open-ended research or the hardest reasoning problems.',
|
|
102
|
+
}
|
|
103
|
+
const levelCriteria = {
|
|
104
|
+
light: 'Mechanical chores, repeated changes, or simple lookups.',
|
|
105
|
+
standard: 'Implement a designed change, research, summarize documentation, or review.',
|
|
106
|
+
deep: 'Careful high-stakes reasoning, subtle debugging, architecture, or security.',
|
|
107
|
+
}
|
|
108
|
+
const domainCriteria: Record<Domain, string> = {
|
|
109
|
+
design: 'Visual composition, branding, taste, look and voice of copy.',
|
|
110
|
+
frontend: 'Frontend UI implementation, components, styles and interactions.',
|
|
111
|
+
backend: 'Server application logic, APIs and authentication.', infra: 'Infrastructure, deployment, networks and operations.',
|
|
112
|
+
data: 'Data processing, schemas, queries and analysis.', test: 'Tests, quality assurance and verification.',
|
|
113
|
+
docs: 'Documentation, technical writing and summaries.', research: 'Investigation and research.', general: 'Other or cross-domain work.',
|
|
114
|
+
}
|
|
115
|
+
/** Plan decides what to build; do carries it out. The ladder sends plans at standard or deep to its Plan rung. */
|
|
116
|
+
export const kindCriteria: Record<Kind, string> = {
|
|
117
|
+
plan: 'Decide what to build: architecture, design decisions, specifications, or a review whose verdict decides.',
|
|
118
|
+
do: 'Carry out what is decided: implement, fix, run, look up, rename, write, or check.',
|
|
119
|
+
}
|
|
120
|
+
export function kindOf(value: unknown): Kind | undefined {
|
|
121
|
+
return value === 'plan' || value === 'do' ? value : undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function jevBody(input: JudgeInput, task: string) {
|
|
125
|
+
return {
|
|
126
|
+
model: 'jev-latest' as const,
|
|
127
|
+
state: { task, current_model: input.current?.model ?? null, current_effort: input.current?.effort ?? null,
|
|
128
|
+
recent_conversation: recent(input), next_message: nextText(input, 4000) },
|
|
129
|
+
questions: { effort: { type: 'choice' as const, instructions: 'Select the effort needed for this work.', criteria: { ...effortCriteria } } },
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function jevEffortRequest(input: JudgeInput) {
|
|
134
|
+
return jevBody(input, EFFORT_JUDGE_SYSTEM)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Capability and kind of work are independent; the judge never chooses a provider. */
|
|
138
|
+
export function jevWorkerRequest(input: { description: string; prompt: string }) {
|
|
139
|
+
const body = jevBody({ messages: [], next: `${input.description}\n${input.prompt.slice(0, 3000)}` }, 'Choose the domain, capability level, kind of work and reasoning effort for a delegated coding task. Judge scope and risk independently of the domain.')
|
|
140
|
+
return { ...body, questions: { ...body.questions,
|
|
141
|
+
domain: { type: 'choice' as const, instructions: 'Select the domain of this task.', criteria: { ...domainCriteria } },
|
|
142
|
+
level: { type: 'choice' as const, instructions: 'Select the capability level needed.', criteria: { ...levelCriteria } },
|
|
143
|
+
kind: { type: 'choice' as const, instructions: 'Select whether this task decides what to build or carries it out.', criteria: { ...kindCriteria } },
|
|
144
|
+
} }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function answers(json: unknown): Record<string, unknown> | undefined {
|
|
148
|
+
const root = record(json)
|
|
149
|
+
return record(root?.answers) ?? record(record(root?.result)?.answers)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function answerSure(answer: Record<string, unknown>): number | undefined {
|
|
153
|
+
return confidence(answer.confidence ?? record(answer.probabilities)?.[String(answer.choice)])
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Torn between levels, Jev leaves a session at the effort it runs when that effort is still a plausible answer. */
|
|
157
|
+
export function parseJevEffort(json: unknown, current?: { effort: Effort } | null, lang: Lang = 'en'): Verdict | undefined {
|
|
158
|
+
const answer = record(answers(json)?.effort)
|
|
159
|
+
const level = effort(answer?.choice)
|
|
160
|
+
if (!answer || !level) return undefined
|
|
161
|
+
const sure = answerSure(answer)
|
|
162
|
+
const odds = record(answer.probabilities)
|
|
163
|
+
const plausible = current && (!odds || (confidence(odds[current.effort]) ?? 0) >= 0.25)
|
|
164
|
+
if (sure !== undefined && sure < 0.5 && current && current.effort !== level && plausible) return { effort: current.effort, sure, why: t(lang, 'judge.keptUnsure') }
|
|
165
|
+
return { effort: level, ...(sure !== undefined ? { sure } : {}), why: '' }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function parseJevWorker(json: unknown): WorkerVerdict | undefined {
|
|
169
|
+
const reply = answers(json)
|
|
170
|
+
const domainAnswer = record(reply?.domain)
|
|
171
|
+
const levelAnswer = record(reply?.level)
|
|
172
|
+
const effortAnswer = record(reply?.effort)
|
|
173
|
+
const selectedEffort = effort(effortAnswer?.choice)
|
|
174
|
+
if (!domainAnswer || !DOMAINS.includes(domainAnswer.choice as Domain) || !levelAnswer || !LEVELS.includes(levelAnswer.choice as Level) || !effortAnswer || !selectedEffort) return undefined
|
|
175
|
+
// Confidence moves a task up the ladder, so it is the level's question that counts, not the kind's.
|
|
176
|
+
const scores = [answerSure(domainAnswer), answerSure(levelAnswer), answerSure(effortAnswer)].filter((score): score is number => score !== undefined)
|
|
177
|
+
const kind = kindOf(record(reply?.kind)?.choice) ?? 'do'
|
|
178
|
+
return { domain: domainAnswer.choice as Domain, level: levelAnswer.choice as Level, kind, effort: selectedEffort, ...(scores.length ? { sure: Math.min(...scores) } : {}), why: '' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function jevUsage(json: unknown): number {
|
|
182
|
+
const usage = record(record(json)?.usage)
|
|
183
|
+
return [usage?.input_tokens, usage?.output_tokens].reduce<number>((sum, count) => sum + (typeof count === 'number' && Number.isFinite(count) && count >= 0 ? count : 0), 0)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function jevFailure(status: number | 'timeout' | 'network', lang: Lang = 'en'): string {
|
|
187
|
+
if (status === 401 || status === 403) return t(lang, 'toasts.jevRejected')
|
|
188
|
+
if (status === 402) return t(lang, 'toasts.jevCredits')
|
|
189
|
+
if (status === 429) return t(lang, 'toasts.jevRateLimited')
|
|
190
|
+
if (status === 'timeout') return t(lang, 'toasts.jevTimeout')
|
|
191
|
+
if (status === 'network') return t(lang, 'toasts.jevNetwork')
|
|
192
|
+
return t(lang, 'toasts.jevHttp', { status })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function parseJevKey(fileText: string): string | undefined {
|
|
196
|
+
const value = /^[ \t]*TYPESAFE_API_KEY[ \t]*=[ \t]*([^\r\n]*)/m.exec(fileText)?.[1]?.trim()
|
|
197
|
+
if (!value) return undefined
|
|
198
|
+
const unquoted = ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1).trim() : value
|
|
199
|
+
return unquoted || undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function tipped(level: Effort, sure: number | undefined, bias: Settings['bias']): Effort {
|
|
203
|
+
if (!bias || sure === undefined || !Number.isFinite(sure) || sure < 0 || sure >= (Math.abs(bias) === 2 ? 0.85 : 0.65)) return level
|
|
204
|
+
return EFFORTS[Math.max(0, Math.min(4, EFFORTS.indexOf(level) + Math.sign(bias)))]!
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function bounded(level: Effort, floor: Effort, ceiling: Effort): Effort {
|
|
208
|
+
const min = EFFORTS.indexOf(floor)
|
|
209
|
+
const max = Math.max(min, EFFORTS.indexOf(ceiling))
|
|
210
|
+
return EFFORTS[Math.max(min, Math.min(max, EFFORTS.indexOf(level)))]!
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function capped(level: Effort, saving: boolean): Effort {
|
|
214
|
+
return saving && EFFORTS.indexOf(level) > 1 ? 'medium' : level
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Save is last, so even a high floor cannot defeat the subscription-saving cap. */
|
|
218
|
+
export function finalEffort(verdict: Verdict, settings: Settings, saving: boolean, lang: Lang = 'en'): { effort: Effort; why: string } {
|
|
219
|
+
const adjusted = bounded(tipped(verdict.effort, verdict.sure, settings.bias), settings.floor, settings.ceiling)
|
|
220
|
+
const result = capped(adjusted, saving)
|
|
221
|
+
return { effort: result, why: result !== adjusted ? t(lang, 'toasts.saveMode') : adjusted !== verdict.effort ? t(lang, 'toasts.yourSettings') : verdict.why }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function isCacheSafeModel(modelId: string): boolean {
|
|
225
|
+
return /(?:opus|sonnet|haiku)-5-5(?:$|[^\d])/.test(modelId)
|
|
226
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The four workers (docs/specs/2026-10-12-worker-tiers.md): which rung a verdict lands on, and the
|
|
2
|
+
// fill that gives every rung a model from the ticked, connected ones. Pure, with type-only imports,
|
|
3
|
+
// so the router fills settings with the same rule the mod uses.
|
|
4
|
+
import { CLAUDE_WORKERS, EFFORTS, LEVEL_RUNG, RUNGS, candidateKey, isAllowed } from "./settings.js";
|
|
5
|
+
/** Below this the judge is unsure enough that a task takes the next rung. */
|
|
6
|
+
export const SURE_ENOUGH = 0.5;
|
|
7
|
+
/**
|
|
8
|
+
* Doing work lands on Low, Medium and High by level. Planning — a deliverable that is a decision,
|
|
9
|
+
* design, strategy or verdict — is the Max worker's job from standard up (a business strategy
|
|
10
|
+
* judged "standard plan" went to the Medium worker before, 2026-10-12 pick log); a light plan
|
|
11
|
+
* (rename a section of a spec) is still light work.
|
|
12
|
+
* An unsure verdict moves up one rung (Low → Medium → High); High and Plan stay, so only a
|
|
13
|
+
* planning verdict ever reaches Plan.
|
|
14
|
+
*/
|
|
15
|
+
export function rungOf(verdict) {
|
|
16
|
+
const rung = verdict.kind === 'plan' && verdict.level !== 'light' ? 'plan' : LEVEL_RUNG[verdict.level];
|
|
17
|
+
if (verdict.sure === undefined || !(verdict.sure < SURE_ENOUGH))
|
|
18
|
+
return rung;
|
|
19
|
+
return rung === 'light' ? 'standard' : rung === 'standard' ? 'hard' : rung;
|
|
20
|
+
}
|
|
21
|
+
/** The rung above, none above Plan. */
|
|
22
|
+
export function rungAbove(rung) {
|
|
23
|
+
return RUNGS[RUNGS.indexOf(rung) + 1];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The nearest effort a model takes, as the router clamps one (compat.ts clampEffort): ties go up,
|
|
27
|
+
* an unknown ladder keeps the effort.
|
|
28
|
+
*/
|
|
29
|
+
export function nearestEffort(effort, ladder) {
|
|
30
|
+
const known = ladder.filter((value) => EFFORTS.includes(value));
|
|
31
|
+
if (!known.length || known.includes(effort))
|
|
32
|
+
return effort;
|
|
33
|
+
const at = EFFORTS.indexOf(effort);
|
|
34
|
+
return known.reduce((best, next) => {
|
|
35
|
+
const d = Math.abs(EFFORTS.indexOf(next) - at);
|
|
36
|
+
const bd = Math.abs(EFFORTS.indexOf(best) - at);
|
|
37
|
+
return d < bd || (d === bd && EFFORTS.indexOf(next) > EFFORTS.indexOf(best)) ? next : best;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** How a provider is paid for, from its configuration (type, auth, preset, address). */
|
|
41
|
+
export function billingOf(provider, model) {
|
|
42
|
+
if (model && /(?::|-)free$/i.test(model))
|
|
43
|
+
return 'free';
|
|
44
|
+
if (provider.type === 'chatgpt')
|
|
45
|
+
return 'subscription';
|
|
46
|
+
if (provider.type === 'anthropic')
|
|
47
|
+
return provider.auth === 'api-key' ? 'payg' : 'subscription';
|
|
48
|
+
if (provider.type === 'google')
|
|
49
|
+
return provider.auth === 'antigravity' ? 'subscription' : 'payg';
|
|
50
|
+
if (provider.preset === 'opencode-go')
|
|
51
|
+
return 'subscription';
|
|
52
|
+
if (provider.preset === 'ollama' || provider.preset === 'lmstudio')
|
|
53
|
+
return 'free';
|
|
54
|
+
// A named hosted service is paid for wherever it is reached through; only a hand-entered local server is free.
|
|
55
|
+
if (provider.preset)
|
|
56
|
+
return 'payg';
|
|
57
|
+
if (provider.url) {
|
|
58
|
+
try {
|
|
59
|
+
const host = new URL(provider.url).hostname;
|
|
60
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host.endsWith('.localhost'))
|
|
61
|
+
return 'free';
|
|
62
|
+
}
|
|
63
|
+
catch { /* not an address: paid like any other API */ }
|
|
64
|
+
}
|
|
65
|
+
return 'payg';
|
|
66
|
+
}
|
|
67
|
+
/** A family's newest model: gpt-6.1-sol over gpt-6-sol. */
|
|
68
|
+
function newest(choices, family) {
|
|
69
|
+
const version = (model) => (/(\d+(?:[.-]\d+)*)/.exec(model)?.[1] ?? '0').split(/[.-]/).map(Number);
|
|
70
|
+
const later = (a, b) => { for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
71
|
+
const d = (b[i] ?? 0) - (a[i] ?? 0);
|
|
72
|
+
if (d)
|
|
73
|
+
return d;
|
|
74
|
+
} return 0; };
|
|
75
|
+
return choices.filter(c => family.test(c.model) && !c.target.model).sort((a, b) => later(version(a.model), version(b.model)) || a.model.localeCompare(b.model))[0];
|
|
76
|
+
}
|
|
77
|
+
/** The cheapest model paid by the token or free: free first, then by measured cost; unmeasured last. */
|
|
78
|
+
export function cheapest(choices) {
|
|
79
|
+
const cost = (c) => c.billing === 'free' ? 0 : c.cost ?? Number.POSITIVE_INFINITY;
|
|
80
|
+
return choices.filter(c => (c.billing === 'payg' || c.billing === 'free') && !c.target.model)
|
|
81
|
+
.sort((a, b) => cost(a) - cost(b) || a.model.localeCompare(b.model))[0];
|
|
82
|
+
}
|
|
83
|
+
const claudeModel = (alias) => (choices) => choices.find(c => c.target.agent === 'general-purpose' && c.target.model === alias);
|
|
84
|
+
const family = (pattern) => (choices) => newest(choices, pattern);
|
|
85
|
+
const LUNA = family(/\bluna\b/i);
|
|
86
|
+
const SOL = family(/\bsol\b/i);
|
|
87
|
+
const ASTRA = family(/\bastra\b/i);
|
|
88
|
+
const HAIKU = claudeModel('haiku');
|
|
89
|
+
const SONNET = claudeModel('sonnet');
|
|
90
|
+
const OPUS = claudeModel('opus');
|
|
91
|
+
/**
|
|
92
|
+
* The fill rule's wishes (a preset's, by name; only `claudeGpt` is used): each rung's in order: the first is what the preset names, the rest what stands in
|
|
93
|
+
* when it is not connected (the nearest choice, a GPT model's Claude counterpart).
|
|
94
|
+
*/
|
|
95
|
+
const PRESETS = {
|
|
96
|
+
claude: {
|
|
97
|
+
light: [{ pick: HAIKU, effort: 'low' }], standard: [{ pick: SONNET, effort: 'medium' }],
|
|
98
|
+
hard: [{ pick: OPUS, effort: 'high' }], plan: [{ pick: OPUS, effort: 'high' }],
|
|
99
|
+
},
|
|
100
|
+
claudeGpt: {
|
|
101
|
+
light: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
|
|
102
|
+
standard: [{ pick: SOL, effort: 'medium' }, { pick: SONNET, effort: 'medium' }],
|
|
103
|
+
hard: [{ pick: ASTRA, effort: 'high' }, { pick: OPUS, effort: 'high' }],
|
|
104
|
+
plan: [{ pick: OPUS, effort: 'high' }],
|
|
105
|
+
},
|
|
106
|
+
gpt: {
|
|
107
|
+
light: [{ pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
|
|
108
|
+
standard: [{ pick: SOL, effort: 'medium' }, { pick: SONNET, effort: 'medium' }],
|
|
109
|
+
hard: [{ pick: ASTRA, effort: 'high' }, { pick: OPUS, effort: 'high' }],
|
|
110
|
+
plan: [{ pick: ASTRA, effort: 'xhigh' }, { pick: OPUS, effort: 'high' }],
|
|
111
|
+
},
|
|
112
|
+
cheap: {
|
|
113
|
+
light: [{ pick: cheapest, effort: 'high' }, { pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
|
|
114
|
+
standard: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'medium' }, { pick: HAIKU, effort: 'medium' }],
|
|
115
|
+
hard: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'high' }, { pick: SONNET, effort: 'medium' }],
|
|
116
|
+
plan: [{ pick: SONNET, effort: 'high' }],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
/** Claude's own models as rung choices: always there, since the session itself runs on Claude. */
|
|
120
|
+
export function claudeChoices(efforts) {
|
|
121
|
+
const ladder = efforts.filter((e) => EFFORTS.includes(e));
|
|
122
|
+
return CLAUDE_WORKERS.map(w => ({ target: { ...w.target }, model: w.model, provider: w.provider, billing: 'subscription', efforts: ladder }));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A preset's rule applied to the connected, ticked models: each rung takes its first wish that is
|
|
126
|
+
* there, at that wish's effort moved to the nearest the model takes. A rung that had to take a
|
|
127
|
+
* later wish is listed in `substituted`.
|
|
128
|
+
*/
|
|
129
|
+
export function preset(id, choices) {
|
|
130
|
+
const rungs = {};
|
|
131
|
+
const substituted = [];
|
|
132
|
+
for (const rung of RUNGS) {
|
|
133
|
+
const wants = PRESETS[id][rung];
|
|
134
|
+
let filled = false;
|
|
135
|
+
for (const [at, want] of wants.entries()) {
|
|
136
|
+
const choice = want.pick(choices);
|
|
137
|
+
if (!choice)
|
|
138
|
+
continue;
|
|
139
|
+
rungs[rung] = { target: { ...choice.target }, effort: nearestEffort(want.effort, choice.efforts) };
|
|
140
|
+
if (at > 0)
|
|
141
|
+
substituted.push(rung);
|
|
142
|
+
filled = true;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
// Claude is always there, so only a choice list without it lands here: the session model.
|
|
146
|
+
if (!filled) {
|
|
147
|
+
rungs[rung] = { target: { agent: 'general-purpose' }, effort: wants[0].effort };
|
|
148
|
+
substituted.push(rung);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { rungs, substituted };
|
|
152
|
+
}
|
|
153
|
+
/** The choices a person ticked; an empty list ticks every one. */
|
|
154
|
+
export function tickedChoices(choices, allow) {
|
|
155
|
+
return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow));
|
|
156
|
+
}
|
|
157
|
+
/** Only empty lists need filling; unticked entries stay in order and are skipped at dispatch. */
|
|
158
|
+
export function ladderNeedsFill(rungs, _allow = []) {
|
|
159
|
+
return RUNGS.some(rung => !rungs[rung]?.length);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Fill empty lists from the ticked, connected choices. Existing ordered lists are never rewritten
|
|
163
|
+
* for temporary availability or shared allow-list changes; dispatch explains each skipped entry.
|
|
164
|
+
*/
|
|
165
|
+
export function fillLadder(rungs, allow, choices) {
|
|
166
|
+
const pick = preset('claudeGpt', tickedChoices(choices, allow));
|
|
167
|
+
const out = {};
|
|
168
|
+
const filled = [];
|
|
169
|
+
for (const rung of RUNGS) {
|
|
170
|
+
const own = rungs[rung];
|
|
171
|
+
if (own?.length) {
|
|
172
|
+
out[rung] = own;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
out[rung] = [pick.rungs[rung]];
|
|
176
|
+
filled.push(rung);
|
|
177
|
+
}
|
|
178
|
+
return { rungs: out, filled };
|
|
179
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The four workers (docs/specs/2026-10-12-worker-tiers.md): which rung a verdict lands on, and the
|
|
2
|
+
// fill that gives every rung a model from the ticked, connected ones. Pure, with type-only imports,
|
|
3
|
+
// so the router fills settings with the same rule the mod uses.
|
|
4
|
+
|
|
5
|
+
import type { Billing, Effort, LadderChoice, Preset, PresetId, Rung, Rungs, Step, WorkerVerdict } from '../types/index.d.ts'
|
|
6
|
+
import { CLAUDE_WORKERS, EFFORTS, LEVEL_RUNG, RUNGS, candidateKey, isAllowed } from './settings.ts'
|
|
7
|
+
|
|
8
|
+
/** Below this the judge is unsure enough that a task takes the next rung. */
|
|
9
|
+
export const SURE_ENOUGH = 0.5
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Doing work lands on Low, Medium and High by level. Planning — a deliverable that is a decision,
|
|
13
|
+
* design, strategy or verdict — is the Max worker's job from standard up (a business strategy
|
|
14
|
+
* judged "standard plan" went to the Medium worker before, 2026-10-12 pick log); a light plan
|
|
15
|
+
* (rename a section of a spec) is still light work.
|
|
16
|
+
* An unsure verdict moves up one rung (Low → Medium → High); High and Plan stay, so only a
|
|
17
|
+
* planning verdict ever reaches Plan.
|
|
18
|
+
*/
|
|
19
|
+
export function rungOf(verdict: Pick<WorkerVerdict, 'level' | 'kind' | 'sure'>): Rung {
|
|
20
|
+
const rung = verdict.kind === 'plan' && verdict.level !== 'light' ? 'plan' : LEVEL_RUNG[verdict.level]
|
|
21
|
+
if (verdict.sure === undefined || !(verdict.sure < SURE_ENOUGH)) return rung
|
|
22
|
+
return rung === 'light' ? 'standard' : rung === 'standard' ? 'hard' : rung
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The rung above, none above Plan. */
|
|
26
|
+
export function rungAbove(rung: Rung): Rung | undefined {
|
|
27
|
+
return RUNGS[RUNGS.indexOf(rung) + 1]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The nearest effort a model takes, as the router clamps one (compat.ts clampEffort): ties go up,
|
|
32
|
+
* an unknown ladder keeps the effort.
|
|
33
|
+
*/
|
|
34
|
+
export function nearestEffort(effort: Effort, ladder: readonly string[]): Effort {
|
|
35
|
+
const known = ladder.filter((value): value is Effort => EFFORTS.includes(value as Effort))
|
|
36
|
+
if (!known.length || known.includes(effort)) return effort
|
|
37
|
+
const at = EFFORTS.indexOf(effort)
|
|
38
|
+
return known.reduce((best, next) => {
|
|
39
|
+
const d = Math.abs(EFFORTS.indexOf(next) - at)
|
|
40
|
+
const bd = Math.abs(EFFORTS.indexOf(best) - at)
|
|
41
|
+
return d < bd || (d === bd && EFFORTS.indexOf(next) > EFFORTS.indexOf(best)) ? next : best
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How a provider is paid for, from its configuration (type, auth, preset, address). */
|
|
46
|
+
export function billingOf(provider: { type?: string; auth?: string; preset?: string; url?: string }, model?: string): Billing {
|
|
47
|
+
if (model && /(?::|-)free$/i.test(model)) return 'free'
|
|
48
|
+
if (provider.type === 'chatgpt') return 'subscription'
|
|
49
|
+
if (provider.type === 'anthropic') return provider.auth === 'api-key' ? 'payg' : 'subscription'
|
|
50
|
+
if (provider.type === 'google') return provider.auth === 'antigravity' ? 'subscription' : 'payg'
|
|
51
|
+
if (provider.preset === 'opencode-go') return 'subscription'
|
|
52
|
+
if (provider.preset === 'ollama' || provider.preset === 'lmstudio') return 'free'
|
|
53
|
+
// A named hosted service is paid for wherever it is reached through; only a hand-entered local server is free.
|
|
54
|
+
if (provider.preset) return 'payg'
|
|
55
|
+
if (provider.url) {
|
|
56
|
+
try {
|
|
57
|
+
const host = new URL(provider.url).hostname
|
|
58
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host.endsWith('.localhost')) return 'free'
|
|
59
|
+
} catch { /* not an address: paid like any other API */ }
|
|
60
|
+
}
|
|
61
|
+
return 'payg'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A family's newest model: gpt-6.1-sol over gpt-6-sol. */
|
|
65
|
+
function newest(choices: readonly LadderChoice[], family: RegExp): LadderChoice | undefined {
|
|
66
|
+
const version = (model: string): number[] => (/(\d+(?:[.-]\d+)*)/.exec(model)?.[1] ?? '0').split(/[.-]/).map(Number)
|
|
67
|
+
const later = (a: number[], b: number[]): number => { for (let i = 0; i < Math.max(a.length, b.length); i++) { const d = (b[i] ?? 0) - (a[i] ?? 0); if (d) return d } return 0 }
|
|
68
|
+
return choices.filter(c => family.test(c.model) && !c.target.model).sort((a, b) => later(version(a.model), version(b.model)) || a.model.localeCompare(b.model))[0]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The cheapest model paid by the token or free: free first, then by measured cost; unmeasured last. */
|
|
72
|
+
export function cheapest(choices: readonly LadderChoice[]): LadderChoice | undefined {
|
|
73
|
+
const cost = (c: LadderChoice): number => c.billing === 'free' ? 0 : c.cost ?? Number.POSITIVE_INFINITY
|
|
74
|
+
return choices.filter(c => (c.billing === 'payg' || c.billing === 'free') && !c.target.model)
|
|
75
|
+
.sort((a, b) => cost(a) - cost(b) || a.model.localeCompare(b.model))[0]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Want = { pick: (choices: readonly LadderChoice[]) => LadderChoice | undefined; effort: Effort }
|
|
79
|
+
|
|
80
|
+
const claudeModel = (alias: string) => (choices: readonly LadderChoice[]) => choices.find(c => c.target.agent === 'general-purpose' && c.target.model === alias)
|
|
81
|
+
const family = (pattern: RegExp) => (choices: readonly LadderChoice[]) => newest(choices, pattern)
|
|
82
|
+
const LUNA = family(/\bluna\b/i)
|
|
83
|
+
const SOL = family(/\bsol\b/i)
|
|
84
|
+
const ASTRA = family(/\bastra\b/i)
|
|
85
|
+
const HAIKU = claudeModel('haiku')
|
|
86
|
+
const SONNET = claudeModel('sonnet')
|
|
87
|
+
const OPUS = claudeModel('opus')
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The fill rule's wishes (a preset's, by name; only `claudeGpt` is used): each rung's in order: the first is what the preset names, the rest what stands in
|
|
91
|
+
* when it is not connected (the nearest choice, a GPT model's Claude counterpart).
|
|
92
|
+
*/
|
|
93
|
+
const PRESETS: Readonly<Record<PresetId, Readonly<Record<Rung, readonly Want[]>>>> = {
|
|
94
|
+
claude: {
|
|
95
|
+
light: [{ pick: HAIKU, effort: 'low' }], standard: [{ pick: SONNET, effort: 'medium' }],
|
|
96
|
+
hard: [{ pick: OPUS, effort: 'high' }], plan: [{ pick: OPUS, effort: 'high' }],
|
|
97
|
+
},
|
|
98
|
+
claudeGpt: {
|
|
99
|
+
light: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
|
|
100
|
+
standard: [{ pick: SOL, effort: 'medium' }, { pick: SONNET, effort: 'medium' }],
|
|
101
|
+
hard: [{ pick: ASTRA, effort: 'high' }, { pick: OPUS, effort: 'high' }],
|
|
102
|
+
plan: [{ pick: OPUS, effort: 'high' }],
|
|
103
|
+
},
|
|
104
|
+
gpt: {
|
|
105
|
+
light: [{ pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
|
|
106
|
+
standard: [{ pick: SOL, effort: 'medium' }, { pick: SONNET, effort: 'medium' }],
|
|
107
|
+
hard: [{ pick: ASTRA, effort: 'high' }, { pick: OPUS, effort: 'high' }],
|
|
108
|
+
plan: [{ pick: ASTRA, effort: 'xhigh' }, { pick: OPUS, effort: 'high' }],
|
|
109
|
+
},
|
|
110
|
+
cheap: {
|
|
111
|
+
light: [{ pick: cheapest, effort: 'high' }, { pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
|
|
112
|
+
standard: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'medium' }, { pick: HAIKU, effort: 'medium' }],
|
|
113
|
+
hard: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'high' }, { pick: SONNET, effort: 'medium' }],
|
|
114
|
+
plan: [{ pick: SONNET, effort: 'high' }],
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Claude's own models as rung choices: always there, since the session itself runs on Claude. */
|
|
119
|
+
export function claudeChoices(efforts: readonly string[]): LadderChoice[] {
|
|
120
|
+
const ladder = efforts.filter((e): e is Effort => EFFORTS.includes(e as Effort))
|
|
121
|
+
return CLAUDE_WORKERS.map(w => ({ target: { ...w.target }, model: w.model, provider: w.provider, billing: 'subscription' as const, efforts: ladder }))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A preset's rule applied to the connected, ticked models: each rung takes its first wish that is
|
|
126
|
+
* there, at that wish's effort moved to the nearest the model takes. A rung that had to take a
|
|
127
|
+
* later wish is listed in `substituted`.
|
|
128
|
+
*/
|
|
129
|
+
export function preset(id: PresetId, choices: readonly LadderChoice[]): Preset {
|
|
130
|
+
const rungs = {} as Record<Rung, Step>
|
|
131
|
+
const substituted: Rung[] = []
|
|
132
|
+
for (const rung of RUNGS) {
|
|
133
|
+
const wants = PRESETS[id][rung]
|
|
134
|
+
let filled = false
|
|
135
|
+
for (const [at, want] of wants.entries()) {
|
|
136
|
+
const choice = want.pick(choices)
|
|
137
|
+
if (!choice) continue
|
|
138
|
+
rungs[rung] = { target: { ...choice.target }, effort: nearestEffort(want.effort, choice.efforts) }
|
|
139
|
+
if (at > 0) substituted.push(rung)
|
|
140
|
+
filled = true
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
// Claude is always there, so only a choice list without it lands here: the session model.
|
|
144
|
+
if (!filled) { rungs[rung] = { target: { agent: 'general-purpose' }, effort: wants[0]!.effort }; substituted.push(rung) }
|
|
145
|
+
}
|
|
146
|
+
return { rungs, substituted }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The choices a person ticked; an empty list ticks every one. */
|
|
150
|
+
export function tickedChoices(choices: readonly LadderChoice[], allow: readonly string[]): LadderChoice[] {
|
|
151
|
+
return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Only empty lists need filling; unticked entries stay in order and are skipped at dispatch. */
|
|
155
|
+
export function ladderNeedsFill(rungs: Rungs, _allow: readonly string[] = []): boolean {
|
|
156
|
+
return RUNGS.some(rung => !rungs[rung]?.length)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Fill empty lists from the ticked, connected choices. Existing ordered lists are never rewritten
|
|
161
|
+
* for temporary availability or shared allow-list changes; dispatch explains each skipped entry.
|
|
162
|
+
*/
|
|
163
|
+
export function fillLadder(rungs: Rungs, allow: readonly string[], choices: readonly LadderChoice[]): { rungs: Record<Rung, Step[]>; filled: Rung[] } {
|
|
164
|
+
const pick = preset('claudeGpt', tickedChoices(choices, allow))
|
|
165
|
+
const out = {} as Record<Rung, Step[]>
|
|
166
|
+
const filled: Rung[] = []
|
|
167
|
+
for (const rung of RUNGS) {
|
|
168
|
+
const own = rungs[rung]
|
|
169
|
+
if (own?.length) { out[rung] = own; continue }
|
|
170
|
+
out[rung] = [pick.rungs[rung]]
|
|
171
|
+
filled.push(rung)
|
|
172
|
+
}
|
|
173
|
+
return { rungs: out, filled }
|
|
174
|
+
}
|