clauderipple 0.8.10 → 0.9.1
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 +60 -0
- package/README.es.md +21 -15
- package/README.ja.md +13 -9
- package/README.ko.md +23 -9
- package/README.md +26 -11
- package/README.pt-BR.md +21 -15
- package/README.zh-CN.md +13 -9
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/settings.js +5 -2
- 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 +242 -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 +373 -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 +170 -52
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -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 +120 -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/chatgpt/index.js +23 -5
- package/dist/router/src/providers/chatgpt/translate.js +4 -1
- package/dist/router/src/providers/google/index.js +8 -3
- package/dist/router/src/providers/openai/index.js +6 -1
- package/dist/router/src/providers/openai/translate.js +6 -1
- package/dist/router/src/proxy.js +62 -24
- package/dist/router/src/requestlog.js +78 -20
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +908 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +919 -233
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -410
- package/docs/ARCHITECTURE.md +141 -0
- package/package.json +7 -3
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { Calibration, Domain, Effort, Level, Mode, Rule, Rung, Settings, SettingsChange, Step, TierTarget } from '../types/index.d.ts'
|
|
2
|
+
|
|
3
|
+
/** Bump whenever a writer's normalization can lose another format's fields. */
|
|
4
|
+
export const MOD_SETTINGS_SCHEMA_VERSION = 1
|
|
5
|
+
|
|
6
|
+
export const DOMAINS: readonly Domain[] = ['general', 'design', 'frontend', 'backend', 'infra', 'data', 'test', 'docs', 'research']
|
|
7
|
+
export const LEVELS: readonly Level[] = ['light', 'standard', 'deep']
|
|
8
|
+
export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
|
|
9
|
+
/** The worker ladder, bottom to top (docs/specs/2026-10-11-worker-ladder.md). */
|
|
10
|
+
export const RUNGS: readonly Rung[] = ['light', 'standard', 'hard', 'plan']
|
|
11
|
+
/** The rung each judged level lands on before kind and confidence move it. */
|
|
12
|
+
export const LEVEL_RUNG: Readonly<Record<Level, Rung>> = { light: 'light', standard: 'standard', deep: 'hard' }
|
|
13
|
+
/** A rung's effort when nothing else says one: a stored rung without one, a migrated pin. */
|
|
14
|
+
export const RUNG_EFFORT: Readonly<Record<Rung, Effort>> = { light: 'low', standard: 'medium', hard: 'high', plan: 'high' }
|
|
15
|
+
|
|
16
|
+
const claude = (model: string, effort: Effort): Step => ({ target: { agent: 'general-purpose', model }, effort })
|
|
17
|
+
|
|
18
|
+
export const DEFAULTS: Settings = {
|
|
19
|
+
effortAuto: true, judge: 'haiku', judgeModel: 'deepseek-v4.1-flash', jevSource: 'typesafe',
|
|
20
|
+
bias: 0, floor: 'low', ceiling: 'max', agentAuto: 'auto',
|
|
21
|
+
rules: {
|
|
22
|
+
// The spec's "Claude + GPT" example; a rung whose worker is missing moves up and says so.
|
|
23
|
+
// Only the general rule: the four workers replaced the per-field rules (2026-10-12).
|
|
24
|
+
general: { mode: 'ladder', rungs: {
|
|
25
|
+
light: [{ target: { agent: 'deepseek-v4-1-flash' }, effort: 'max' }], standard: [{ target: { agent: 'gpt-6-1-sol' }, effort: 'medium' }],
|
|
26
|
+
hard: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], plan: [claude('opus', 'high')],
|
|
27
|
+
} },
|
|
28
|
+
},
|
|
29
|
+
allow: [], modes: [],
|
|
30
|
+
providers: {},
|
|
31
|
+
fallback: { agent: 'general-purpose', model: 'sonnet' },
|
|
32
|
+
keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
|
|
33
|
+
compactWith: 'haiku', handoffSkill: '', handoffAfter: 'continue', handoffButton: 'advised',
|
|
34
|
+
swampAt: 50, swampAlert: false, layout: 'dashboard', appearance: 'auto', language: 'auto', hide: ['reason'], extras: [], setupDone: false,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Band chips that show only when switched on in Look. */
|
|
38
|
+
export const EXTRAS: readonly string[] = ['context', 'limits']
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Claude's own models as workers: the built-in general-purpose agent run on the model, `model` its
|
|
42
|
+
* current id. The key is what the allow-list stores.
|
|
43
|
+
*/
|
|
44
|
+
export const CLAUDE_WORKERS: readonly { name: string; model: string; provider: string; target: TierTarget }[] = [
|
|
45
|
+
{ name: 'general-purpose:haiku', model: 'claude-haiku-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'haiku' } },
|
|
46
|
+
{ name: 'general-purpose:sonnet', model: 'claude-sonnet-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'sonnet' } },
|
|
47
|
+
{ name: 'general-purpose:opus', model: 'claude-opus-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'opus' } },
|
|
48
|
+
{ name: 'general-purpose:fable', model: 'claude-fable-5-1', provider: 'anthropic', target: { agent: 'general-purpose', model: 'fable' } },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/** The allow-list key of a target: the agent's name, or `general-purpose:<model>` for Claude's own. */
|
|
52
|
+
export function candidateKey(target: TierTarget): string {
|
|
53
|
+
return target.model ? target.agent + ':' + target.model : target.agent
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function object(raw: unknown): Record<string, unknown> {
|
|
57
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}
|
|
58
|
+
}
|
|
59
|
+
function names(raw: unknown): string[] {
|
|
60
|
+
return Array.isArray(raw) ? [...new Set(raw.filter((v): v is string => typeof v === 'string' && !!v.trim()).map(v => v.trim()))] : []
|
|
61
|
+
}
|
|
62
|
+
function target(raw: unknown, fallback?: TierTarget): TierTarget | undefined {
|
|
63
|
+
const value = object(raw)
|
|
64
|
+
const agent = typeof value.agent === 'string' && value.agent.trim() ? value.agent.trim() : fallback?.agent
|
|
65
|
+
const model = typeof value.model === 'string' && value.model.trim() ? value.model.trim() : fallback?.model
|
|
66
|
+
return agent ? { agent, ...(model ? { model } : {}) } : undefined
|
|
67
|
+
}
|
|
68
|
+
function calibration(value: Record<string, unknown>): Calibration {
|
|
69
|
+
const out: Calibration = {}
|
|
70
|
+
if ([-2, -1, 0, 1, 2].includes(Number(value.effortOffset)) && typeof value.effortOffset === 'number') out.effortOffset = value.effortOffset as NonNullable<Calibration['effortOffset']>
|
|
71
|
+
for (const field of ['floor', 'ceiling'] as const) if (EFFORTS.includes(value[field] as Effort)) out[field] = value[field] as Effort
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
function copy<T>(value: T): T {
|
|
75
|
+
return JSON.parse(JSON.stringify(value)) as T
|
|
76
|
+
}
|
|
77
|
+
function sameTarget(a: TierTarget | undefined, b: TierTarget | undefined): boolean {
|
|
78
|
+
return !!a && !!b && a.agent === b.agent && a.model === b.model
|
|
79
|
+
}
|
|
80
|
+
/** An effort moved by calibration steps, then held between floor and ceiling (the floor wins). */
|
|
81
|
+
function calibrated(effort: Effort, cal: Calibration): Effort {
|
|
82
|
+
const min = EFFORTS.indexOf(cal.floor ?? 'low')
|
|
83
|
+
const max = Math.max(min, EFFORTS.indexOf(cal.ceiling ?? 'max'))
|
|
84
|
+
const shifted = Math.max(0, Math.min(4, EFFORTS.indexOf(effort) + (cal.effortOffset ?? 0)))
|
|
85
|
+
return EFFORTS[Math.max(min, Math.min(max, shifted))]!
|
|
86
|
+
}
|
|
87
|
+
/** One effort step up, max staying max. */
|
|
88
|
+
export function effortUp(effort: Effort): Effort {
|
|
89
|
+
return EFFORTS[Math.min(4, EFFORTS.indexOf(effort) + 1)]!
|
|
90
|
+
}
|
|
91
|
+
function step(raw: unknown, rung: Rung): Step | undefined {
|
|
92
|
+
const v = object(raw)
|
|
93
|
+
const chosen = target(v.target)
|
|
94
|
+
if (!chosen) return undefined
|
|
95
|
+
// A rung's backup model is dropped on load: the four workers have none (the next one up stands in).
|
|
96
|
+
return { target: chosen, effort: EFFORTS.includes(v.effort as Effort) ? v.effort as Effort : RUNG_EFFORT[rung] }
|
|
97
|
+
}
|
|
98
|
+
/** The effort a migrated rung starts at: the default ladder's when it runs the same model there. */
|
|
99
|
+
function migratedEffort(rung: Rung, chosen: TierTarget): Effort {
|
|
100
|
+
const own = DEFAULTS.rules.general.rungs[rung]?.[0]
|
|
101
|
+
return own && sameTarget(own.target, chosen) ? own.effort : RUNG_EFFORT[rung]
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A pin stored before 2026-10-11 as a ladder: light/standard/deep become Light/Standard/Hard, a
|
|
105
|
+
* missing level takes the nearest one above (as the pin climbed) or else below, each rung's effort
|
|
106
|
+
* is its default calibrated as the pin was, and Plan is Hard's model one effort higher.
|
|
107
|
+
*/
|
|
108
|
+
export function ladderFromPin(targets: Partial<Record<Level, TierTarget>>, cal: Calibration): Rule {
|
|
109
|
+
const known = LEVELS.filter(level => targets[level])
|
|
110
|
+
if (!known.length) return copy(DEFAULTS.rules.general)
|
|
111
|
+
const rungs: Partial<Record<Rung, Step>> = {}
|
|
112
|
+
LEVELS.forEach((level, at) => {
|
|
113
|
+
const above = LEVELS.slice(at).find(l => targets[l])
|
|
114
|
+
const chosen = targets[above ?? [...LEVELS.slice(0, at)].reverse().find(l => targets[l])!]!
|
|
115
|
+
const rung = LEVEL_RUNG[level]
|
|
116
|
+
rungs[rung] = { target: { ...chosen }, effort: calibrated(migratedEffort(rung, chosen), cal) }
|
|
117
|
+
})
|
|
118
|
+
rungs.plan = { target: { ...rungs.hard!.target }, effort: effortUp(rungs.hard!.effort) }
|
|
119
|
+
return { mode: 'ladder', rungs: ladderRungs(rungs) }
|
|
120
|
+
}
|
|
121
|
+
/** Single steps from older saves become one-item lists; malformed items are dropped in place. */
|
|
122
|
+
function ladderRungs(raw: unknown): Rule['rungs'] {
|
|
123
|
+
const source = object(raw)
|
|
124
|
+
const rungs: Rule['rungs'] = {}
|
|
125
|
+
for (const rung of RUNGS) {
|
|
126
|
+
const items = Array.isArray(source[rung]) ? source[rung] : [source[rung]]
|
|
127
|
+
const parsed = items.flatMap(item => { const value = step(item, rung); return value ? [value] : [] })
|
|
128
|
+
if (parsed.length) rungs[rung] = parsed
|
|
129
|
+
}
|
|
130
|
+
return rungs
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The one rule left, the general ladder (the four workers). A ladder keeps its rungs; a pin from
|
|
134
|
+
* before the ladder becomes one; Auto (experimental, removed 2026-10-12) becomes a ladder with no
|
|
135
|
+
* rungs set, which the router fills from the ticked models (`fillLadder`). Field rules are gone.
|
|
136
|
+
*/
|
|
137
|
+
function generalRule(raw: unknown): Rule {
|
|
138
|
+
const v = object(raw)
|
|
139
|
+
if (v.mode === 'ladder') return { mode: 'ladder', rungs: ladderRungs(v.rungs) }
|
|
140
|
+
if (v.mode === 'auto') return { mode: 'ladder', rungs: {} }
|
|
141
|
+
if (v.mode === 'pin') {
|
|
142
|
+
const source = object(v.targets)
|
|
143
|
+
const targets: Partial<Record<Level, TierTarget>> = {}
|
|
144
|
+
for (const level of LEVELS) { const parsed = target(source[level]); if (parsed) targets[level] = parsed }
|
|
145
|
+
return ladderFromPin(targets, calibration(v))
|
|
146
|
+
}
|
|
147
|
+
return copy(DEFAULTS.rules.general)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type Worker = { name: string; provider: string }
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Auto's allow-list names worker agents. Settings saved before 2026-10-10 named providers there;
|
|
154
|
+
* with the worker list known, a provider name becomes that provider's workers (names it does not
|
|
155
|
+
* know are kept: a worker may be missing only for now).
|
|
156
|
+
*/
|
|
157
|
+
export function migrateAllow(allow: readonly string[], workers: readonly Worker[]): string[] {
|
|
158
|
+
const agents = new Set(workers.map(w => w.name))
|
|
159
|
+
return [...new Set(allow.flatMap(entry => agents.has(entry) || !workers.some(w => w.provider === entry) ? [entry] : workers.filter(w => w.provider === entry).map(w => w.name)))]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The workers switched on by an allow-list; empty means every worker. */
|
|
163
|
+
export function allowedWorkers(allow: readonly string[], workers: readonly Worker[]): string[] {
|
|
164
|
+
if (!allow.length) return workers.map(w => w.name)
|
|
165
|
+
const on = new Set(migrateAllow(allow, workers))
|
|
166
|
+
return workers.filter(w => on.has(w.name)).map(w => w.name)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Switches one worker; every worker on again is stored as [], and the last one cannot go off. */
|
|
170
|
+
export function toggleAllow(allow: readonly string[], name: string, workers: readonly Worker[]): string[] {
|
|
171
|
+
const on = allowedWorkers(allow, workers)
|
|
172
|
+
const next = on.includes(name) ? on.filter(n => n !== name) : workers.filter(w => w.name === name || on.includes(w.name)).map(w => w.name)
|
|
173
|
+
if (!next.length) return [...allow]
|
|
174
|
+
return next.length === workers.length ? [] : next
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether a stored model is ticked: its key (an agent name, or `general-purpose:<model>`) or, for
|
|
179
|
+
* settings from before 2026-10-10, its provider is listed; an empty list ticks every model.
|
|
180
|
+
*/
|
|
181
|
+
export function isAllowed(key: string, provider: string, allow: readonly string[]): boolean {
|
|
182
|
+
return !allow.length || allow.includes(key) || allow.includes(provider)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The design ladder every save stored until 2026-10-12: Claude, Sonnet then Opus. */
|
|
186
|
+
const RETIRED_DESIGN = JSON.stringify([claude('sonnet', 'medium'), claude('sonnet', 'high'), claude('opus', 'high'), claude('opus', 'high')].map(s => [s.target.agent, s.target.model, s.effort]))
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether stored settings still carry what the four workers replaced: Auto (experimental), or a
|
|
190
|
+
* field rule that is not the design ladder every save used to store. The dashboard says so once.
|
|
191
|
+
*/
|
|
192
|
+
export function hadRetiredRouting(raw: unknown): boolean {
|
|
193
|
+
const rules = object(object(raw).rules)
|
|
194
|
+
if (object(rules.general).mode === 'auto') return true
|
|
195
|
+
return DOMAINS.some(domain => {
|
|
196
|
+
if (domain === 'general') return false
|
|
197
|
+
const stored = object(rules[domain])
|
|
198
|
+
if (stored.mode === undefined || stored.mode === 'inherit') return false
|
|
199
|
+
if (domain !== 'design' || stored.mode !== 'ladder') return true
|
|
200
|
+
const rungs = ladderRungs(stored.rungs)
|
|
201
|
+
return JSON.stringify(RUNGS.map(rung => { const s = rungs[rung]?.[0]; return s ? [s.target.agent, s.target.model, s.effort] : null })) !== RETIRED_DESIGN
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** How long a mode's name may be: it shows in the band. */
|
|
206
|
+
export const MODE_NAME_MAX = 40
|
|
207
|
+
/** How many modes are kept. */
|
|
208
|
+
export const MODES_MAX = 20
|
|
209
|
+
|
|
210
|
+
/** How long a mode's reset provider name may be. */
|
|
211
|
+
const PROVIDER_NAME_MAX = 64
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A mode's end: a time, one provider's next quota reset, or none. Until 2026-10-12 `'reset'` meant
|
|
215
|
+
* any used provider's; it becomes the first that could have offered it, ChatGPT then Claude. Settings
|
|
216
|
+
* do not carry a worker's provider, and ChatGPT is the only worker provider with a known reset, so
|
|
217
|
+
* any worker that is not Claude's own stands for ChatGPT.
|
|
218
|
+
*/
|
|
219
|
+
function modeUntil(raw: unknown, rungs: Rule['rungs']): Mode['until'] {
|
|
220
|
+
if (typeof raw === 'number') return Number.isFinite(raw) && raw > 0 ? raw : 'none'
|
|
221
|
+
if (raw === 'reset') return { reset: Object.values(rungs).some(steps => steps?.some(step => step.target.agent !== 'general-purpose')) ? 'chatgpt' : 'anthropic' }
|
|
222
|
+
const reset = object(raw).reset
|
|
223
|
+
return typeof reset === 'string' && reset.trim() ? { reset: reset.trim().slice(0, PROVIDER_NAME_MAX) } : 'none'
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Saved worker sets. Old prefer/avoid modes copy the base ladder on load, dropping their policy.
|
|
228
|
+
* Every entry owns its lists: later base edits cannot mutate a saved mode.
|
|
229
|
+
*/
|
|
230
|
+
export function normalizeModes(raw: unknown, base: Rule['rungs'] = DEFAULTS.rules.general.rungs): Mode[] {
|
|
231
|
+
if (!Array.isArray(raw)) return []
|
|
232
|
+
const out: Mode[] = []
|
|
233
|
+
for (const entry of raw) {
|
|
234
|
+
const v = object(entry)
|
|
235
|
+
const id = typeof v.id === 'string' ? v.id.trim().slice(0, 64) : ''
|
|
236
|
+
const name = typeof v.name === 'string' ? v.name.trim().slice(0, MODE_NAME_MAX) : ''
|
|
237
|
+
if (!id || !name || out.some(m => m.id === id)) continue
|
|
238
|
+
const rungs = ladderRungs(v.rungs === undefined ? base : v.rungs)
|
|
239
|
+
out.push({ id, name, rungs, delegation: v.delegation === 'eager' ? 'eager' : 'normal', until: modeUntil(v.until, rungs) })
|
|
240
|
+
if (out.length === MODES_MAX) break
|
|
241
|
+
}
|
|
242
|
+
return out
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The general pin every install had before the ladder: what stored `tiers` filled in. */
|
|
246
|
+
const LEGACY_GENERAL: Readonly<Record<Level, TierTarget>> = { light: { agent: 'deepseek-v4-1-flash' }, standard: { agent: 'gpt-6-1-sol' }, deep: { agent: 'gpt-6-astra' } }
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Persisted settings may be old; migrate tiers and pins once and never alias nested default data.
|
|
250
|
+
* Only the general ladder survives: Auto becomes a ladder with no rungs set (its candidates become
|
|
251
|
+
* the ticked models); field rules and Auto's own fields (preference, autoFloors, autoTieBand) are
|
|
252
|
+
* dropped. With `workers`, provider names in an allow-list become those providers' workers.
|
|
253
|
+
*/
|
|
254
|
+
export function normalizeSettings(raw: unknown, workers?: readonly Worker[]): Settings {
|
|
255
|
+
const value = object(raw)
|
|
256
|
+
const stored = object(value.rules)
|
|
257
|
+
const rules: Settings['rules'] = { general: generalRule(stored.general) }
|
|
258
|
+
const old = object(value.tiers)
|
|
259
|
+
if (!Object.hasOwn(stored, 'general') && Object.keys(old).length) {
|
|
260
|
+
const targets: Partial<Record<Level, TierTarget>> = copy(LEGACY_GENERAL)
|
|
261
|
+
for (const level of LEVELS) { const parsed = target(old[level]); if (parsed) targets[level] = parsed }
|
|
262
|
+
rules.general = ladderFromPin(targets, {})
|
|
263
|
+
}
|
|
264
|
+
const ticked = names(Array.isArray(value.allow) ? value.allow : object(stored.general).mode === 'auto' ? object(stored.general).allow : [])
|
|
265
|
+
const result: Settings = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } }
|
|
266
|
+
for (const [name, enabled] of Object.entries(object(value.providers))) if (typeof enabled === 'boolean') result.providers[name] = enabled
|
|
267
|
+
for (const key of ['effortAuto', 'keepWarm', 'setupDone', 'swampAlert'] as const) if (typeof value[key] === 'boolean') result[key] = value[key]
|
|
268
|
+
if (Array.isArray(value.extras)) result.extras = [...new Set(value.extras.filter((part): part is string => typeof part === 'string' && EXTRAS.includes(part)))]
|
|
269
|
+
const choices = {
|
|
270
|
+
judge: ['haiku', 'jev', 'model'], jevSource: ['typesafe', 'zen'], bias: [-2, -1, 0, 1, 2], floor: EFFORTS,
|
|
271
|
+
ceiling: EFFORTS, agentAuto: ['off', 'auto', 'all'], keepWarmHours: [1, 2, 4, 8], compactWith: ['haiku', 'session'],
|
|
272
|
+
handoffAfter: ['continue', 'confirm', 'copy'], handoffButton: ['advised', 'always'], layout: ['dashboard', 'minimal'],
|
|
273
|
+
appearance: ['auto', 'dark', 'light'], language: ['auto', 'ko', 'en', 'zh', 'ja', 'es', 'pt'],
|
|
274
|
+
} as const
|
|
275
|
+
for (const key of Object.keys(choices) as (keyof typeof choices)[]) if ((choices[key] as readonly unknown[]).includes(value[key])) Object.assign(result, { [key]: value[key] })
|
|
276
|
+
if (typeof value.judgeModel === 'string' && value.judgeModel.trim()) result.judgeModel = value.judgeModel.trim()
|
|
277
|
+
if (typeof value.handoffSkill === 'string') result.handoffSkill = value.handoffSkill.trim().replace(/^\//, '')
|
|
278
|
+
if (typeof value.keepWarmMinTokens === 'number' && Number.isFinite(value.keepWarmMinTokens) && value.keepWarmMinTokens >= 0) result.keepWarmMinTokens = value.keepWarmMinTokens
|
|
279
|
+
if (typeof value.swampAt === 'number' && Number.isFinite(value.swampAt) && value.swampAt >= 0 && value.swampAt <= 100) result.swampAt = value.swampAt
|
|
280
|
+
if (Array.isArray(value.hide)) result.hide = [...new Set(value.hide.filter((part): part is string => typeof part === 'string' && ['timer', 'reason', 'route', 'limits'].includes(part)))]
|
|
281
|
+
result.fallback = target(value.fallback, DEFAULTS.fallback)!
|
|
282
|
+
result.modes = normalizeModes(value.modes, rules.general.rungs)
|
|
283
|
+
// An active mode must be one of the modes; its start time stays only with it.
|
|
284
|
+
if (typeof value.activeMode === 'string' && result.modes.some(m => m.id === value.activeMode)) {
|
|
285
|
+
result.activeMode = value.activeMode
|
|
286
|
+
if (typeof value.activeSince === 'number' && Number.isFinite(value.activeSince) && value.activeSince > 0) result.activeSince = value.activeSince
|
|
287
|
+
}
|
|
288
|
+
return result
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Preserve unrelated remote edits, including sibling domains and provider switches. */
|
|
292
|
+
export function settingsChanges(before: Settings, after: Settings): SettingsChange[] {
|
|
293
|
+
function diff(a: unknown, b: unknown, path: string[]): SettingsChange[] {
|
|
294
|
+
if (JSON.stringify(a) === JSON.stringify(b)) return []
|
|
295
|
+
if (b === undefined) return [{ path, remove: true }]
|
|
296
|
+
if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
|
|
297
|
+
const left = object(a), right = object(b)
|
|
298
|
+
// Changing a discriminated rule replaces it, rather than retaining the old mode's fields.
|
|
299
|
+
if (left.mode === right.mode) return [...new Set([...Object.keys(left), ...Object.keys(right)])].flatMap(key => diff(left[key], right[key], [...path, key]))
|
|
300
|
+
}
|
|
301
|
+
return [{ path, value: b }]
|
|
302
|
+
}
|
|
303
|
+
return diff(before, after, [])
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function applySettingsChanges(base: Settings, changes: readonly SettingsChange[]): Settings {
|
|
307
|
+
const result = object(normalizeSettings(base))
|
|
308
|
+
for (const change of changes) {
|
|
309
|
+
let parent = result
|
|
310
|
+
for (const key of change.path.slice(0, -1)) {
|
|
311
|
+
const child = object(parent[key])
|
|
312
|
+
parent[key] = child
|
|
313
|
+
parent = child
|
|
314
|
+
}
|
|
315
|
+
const key = change.path.at(-1)
|
|
316
|
+
if (key === undefined) continue
|
|
317
|
+
if (change.remove) delete parent[key]
|
|
318
|
+
else parent[key] = change.value
|
|
319
|
+
}
|
|
320
|
+
return normalizeSettings(result)
|
|
321
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Effort, JudgeKind, Stats, Tier, Usage } from '../types'
|
|
2
|
+
import { t } from './i18n.ts'
|
|
3
|
+
import type { Lang } from './i18n.ts'
|
|
4
|
+
|
|
5
|
+
export function emptyStats(): Stats {
|
|
6
|
+
return { prompts: 0, requests: 0, input: 0, write: 0, read: 0, out: 0, byEffort: {},
|
|
7
|
+
judge: { haiku: 0, jev: 0, model: 0, ms: 0, tokens: 0 }, workers: {}, warm: { pings: 0, cost: 0 } }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Input-equivalent tokens compare real usage, not hypothetical dollars saved. */
|
|
11
|
+
export function weighted(usage: Usage): number {
|
|
12
|
+
return (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) * 1.25 +
|
|
13
|
+
(usage.cache_read_input_tokens ?? 0) * 0.1 + (usage.output_tokens ?? 0) * 5
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function addRequest(stats: Stats, usage: Usage, effort: Effort): Stats {
|
|
17
|
+
const bucket = stats.byEffort[effort] ?? { prompts: 0, cost: 0 }
|
|
18
|
+
return { ...stats, requests: stats.requests + 1, input: stats.input + (usage.input_tokens ?? 0),
|
|
19
|
+
write: stats.write + (usage.cache_creation_input_tokens ?? 0), read: stats.read + (usage.cache_read_input_tokens ?? 0),
|
|
20
|
+
out: stats.out + (usage.output_tokens ?? 0),
|
|
21
|
+
byEffort: { ...stats.byEffort, [effort]: { ...bucket, cost: bucket.cost + weighted(usage) } } }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function addPrompt(stats: Stats, effort: Effort): Stats {
|
|
25
|
+
const bucket = stats.byEffort[effort] ?? { prompts: 0, cost: 0 }
|
|
26
|
+
return { ...stats, prompts: stats.prompts + 1,
|
|
27
|
+
byEffort: { ...stats.byEffort, [effort]: { ...bucket, prompts: bucket.prompts + 1 } } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function addJudge(stats: Stats, judge: JudgeKind, ms: number, tokens: number): Stats {
|
|
31
|
+
return { ...stats, judge: { ...stats.judge, [judge]: stats.judge[judge] + 1,
|
|
32
|
+
ms: stats.judge.ms + ms, tokens: stats.judge.tokens + tokens } }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function addWorker(stats: Stats, tier: Tier): Stats {
|
|
36
|
+
return { ...stats, workers: { ...stats.workers, [tier]: (stats.workers[tier] ?? 0) + 1 } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function addWarm(stats: Stats, usage: Usage): Stats {
|
|
40
|
+
return { ...stats, warm: { pings: stats.warm.pings + 1, cost: stats.warm.cost + weighted(usage) } }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatStats(stats: Stats, lang: Lang): string {
|
|
44
|
+
const total = weighted({ input_tokens: stats.input, cache_creation_input_tokens: stats.write,
|
|
45
|
+
cache_read_input_tokens: stats.read, output_tokens: stats.out })
|
|
46
|
+
const judges = stats.judge.haiku + stats.judge.jev + stats.judge.model
|
|
47
|
+
const efforts = (['low', 'medium', 'high', 'xhigh', 'max'] as Effort[]).filter(level => stats.byEffort[level]).map(level => {
|
|
48
|
+
const bucket = stats.byEffort[level]!
|
|
49
|
+
return t(lang, 'commands.statsEffort', { effort: t(lang, 'band.' + level).toLowerCase(), prompts: bucket.prompts, cost: Math.round(bucket.cost) })
|
|
50
|
+
})
|
|
51
|
+
return [
|
|
52
|
+
t(lang, 'commands.statsAuto', { prompts: stats.prompts, requests: stats.requests, total: Math.round(total) }),
|
|
53
|
+
t(lang, 'commands.statsTokens', { input: stats.input, write: stats.write, read: stats.read, output: stats.out }),
|
|
54
|
+
...efforts,
|
|
55
|
+
t(lang, 'commands.statsJudges', { ...stats.judge, avg: judges ? Math.round(stats.judge.ms / judges) : 0 }),
|
|
56
|
+
t(lang, 'commands.statsWorkers', { workers: (['light', 'standard', 'deep', 'design'] as Tier[]).map(tier => `${t(lang, 'settings.' + tier).toLowerCase()} ${stats.workers[tier] ?? 0}`).join(', ') }),
|
|
57
|
+
t(lang, 'commands.statsWarm', { pings: stats.warm.pings, cost: Math.round(stats.warm.cost) }),
|
|
58
|
+
].join('\n')
|
|
59
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The Agents pane: what the host's own task cards do not say about each subagent of this session:
|
|
2
|
+
// the model it ran on, at what effort, who picked it and why. Live ones first, each a small card;
|
|
3
|
+
// finished ones fold into one line until opened.
|
|
4
|
+
//
|
|
5
|
+
// The desktop draws the host's task list right above this pane with the elapsed time, tokens,
|
|
6
|
+
// tool count and current activity of every agent. This pane leaves those to it there: its own
|
|
7
|
+
// count started at the spawn and summed every step, so the same agent showed two different times
|
|
8
|
+
// and totals a few lines apart. The terminal has no such list, so it keeps them.
|
|
9
|
+
//
|
|
10
|
+
// No pictures while agents run: every step of an agent redraws this pane, and each redraw starts
|
|
11
|
+
// 리플이's motion over, so among running agents it would never get past its first second. Only the
|
|
12
|
+
// empty pane, which nothing redraws, shows 리플이, waiting at its greeting size.
|
|
13
|
+
|
|
14
|
+
import { t } from '../i18n'
|
|
15
|
+
import type { RenderNode } from 'claude-code'
|
|
16
|
+
|
|
17
|
+
import type { AgentRec } from '../../types'
|
|
18
|
+
import { GREET_SIZE, speak, textButton } from './parts'
|
|
19
|
+
import type { Els } from './parts'
|
|
20
|
+
import { textTone } from './theme'
|
|
21
|
+
import type { AgentsActions, AgentsView, Frame } from './view'
|
|
22
|
+
import { compactNumber, effortLabel, elapsed, isRich, modelName, targetLabel } from './view'
|
|
23
|
+
|
|
24
|
+
const BY = { haiku: 'settings.haiku', jev: 'settings.jev', model: 'agents.routerModel', marker: 'agents.marker', default: 'agents.default', tier: 'toasts.yourPick' } as const
|
|
25
|
+
|
|
26
|
+
function who(agent: AgentRec, lang: Frame['lang']): string {
|
|
27
|
+
const pick = agent.pick
|
|
28
|
+
// The agent a person picked by name; Claude's own agents by their model.
|
|
29
|
+
return pick ? targetLabel(pick.target, lang) : agent.model ? modelName(agent.model.split('@')[0]!) : agent.type
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The model it ran on, unless that is what the description's own prefix ("Sol·high · …") already says. */
|
|
33
|
+
function modelLine(frame: Frame, agent: AgentRec): string[] {
|
|
34
|
+
const out = [who(agent, frame.lang)]
|
|
35
|
+
if (agent.pick) out.push(effortLabel(agent.pick.effort, frame.lang))
|
|
36
|
+
// The type only when it says something the model does not (Explore, Plan, a named agent).
|
|
37
|
+
if (agent.type && !out[0]!.toLowerCase().replace(/[^a-z0-9]/g, '').includes(agent.type.toLowerCase().replace(/[^a-z0-9]/g, ''))) out.push(agent.type)
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Why this model: who chose it, their reason, and the measured score behind an Auto pick. */
|
|
42
|
+
function whyLine(frame: Frame, agent: AgentRec): string {
|
|
43
|
+
const pick = agent.pick
|
|
44
|
+
if (!pick) return ''
|
|
45
|
+
const parts = [t(frame.lang, 'agents.by', { name: t(frame.lang, BY[pick.by]) }) + ' · ' + t(frame.lang, 'agents.domainLevel', { domain: t(frame.lang, 'workers.' + pick.domain), level: t(frame.lang, 'settings.' + pick.level) }) + (pick.rung ? ' · ' + t(frame.lang, 'workers.rung.' + pick.rung) : '')]
|
|
46
|
+
if (pick.why) parts.push('“' + pick.why + '”')
|
|
47
|
+
return parts.join(' · ')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function liveCard(el: Els, frame: Frame, agent: AgentRec, now: number): RenderNode {
|
|
51
|
+
const { Box, Text } = el
|
|
52
|
+
const tone = textTone(frame.appearance)
|
|
53
|
+
const rich = isRich(frame)
|
|
54
|
+
const pick = agent.pick
|
|
55
|
+
const waiting = agent.state === 'waiting'
|
|
56
|
+
const why = whyLine(frame, agent)
|
|
57
|
+
const doing = waiting ? t(frame.lang, 'agents.waiting') : agent.now ? agent.now + (agent.toolSince ? ' · ' + elapsed(now - agent.toolSince, frame.lang) : '') : t(frame.lang, 'agents.thinking')
|
|
58
|
+
const body: RenderNode[] = [
|
|
59
|
+
<Box key="title" flexDirection="row" alignItems="center" columnGap={1}>
|
|
60
|
+
<Text color={waiting ? undefined : tone.accent} dimColor={waiting}>{waiting ? '○' : '●'}</Text>
|
|
61
|
+
<Text bold wrap="wrap">{agent.description}</Text>
|
|
62
|
+
</Box>,
|
|
63
|
+
<Box key="model" flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={2}>
|
|
64
|
+
<Text>{modelLine(frame, agent).join(' · ')}</Text>
|
|
65
|
+
</Box>,
|
|
66
|
+
why ? (
|
|
67
|
+
<Box key="why" paddingLeft={2}>
|
|
68
|
+
<Text dimColor wrap="wrap">{why}</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
) : '',
|
|
71
|
+
pick?.fallbackReason ? (
|
|
72
|
+
<Box key="fallback" paddingLeft={2}>
|
|
73
|
+
<Text color={tone.warn} wrap="wrap">{t(frame.lang, 'agents.fallback', { reason: pick.fallbackReason })}</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
) : '',
|
|
76
|
+
rich ? '' : (
|
|
77
|
+
// The terminal has no task list of its own: what it is doing, for how long, at what cost.
|
|
78
|
+
<Box key="doing" flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={2}>
|
|
79
|
+
<Text color={waiting ? undefined : tone.accent} dimColor={waiting} wrap="wrap">{'▸ ' + doing}</Text>
|
|
80
|
+
<Text dimColor>{elapsed(now - agent.startedAt, frame.lang)}</Text>
|
|
81
|
+
{agent.cost > 0 ? <Text dimColor>{t(frame.lang, 'agents.tokens', { tokens: compactNumber(agent.cost) })}</Text> : null}
|
|
82
|
+
</Box>
|
|
83
|
+
),
|
|
84
|
+
]
|
|
85
|
+
// No box around it: the dot, the bold name and the rows under it are the card, a row of air
|
|
86
|
+
// the gap to the next.
|
|
87
|
+
return (
|
|
88
|
+
<Box key={'agent-' + agent.id} flexDirection="column">
|
|
89
|
+
{body}
|
|
90
|
+
</Box>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function doneLine(el: Els, frame: Frame, agent: AgentRec): RenderNode {
|
|
95
|
+
const { Box, Text } = el
|
|
96
|
+
const tone = textTone(frame.appearance)
|
|
97
|
+
const ok = agent.state === 'done'
|
|
98
|
+
return (
|
|
99
|
+
<Box key={'done-' + agent.id} flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={1}>
|
|
100
|
+
<Text color={ok ? tone.ok : tone.bad}>{ok ? '✓' : '✕'}</Text>
|
|
101
|
+
<Text wrap="wrap">{agent.description}</Text>
|
|
102
|
+
<Text dimColor>{modelLine(frame, agent).join(' · ')}</Text>
|
|
103
|
+
{isRich(frame) ? null : <Text dimColor>{elapsed((agent.endedAt ?? agent.startedAt) - agent.startedAt, frame.lang)}</Text>}
|
|
104
|
+
{!isRich(frame) && agent.cost > 0 ? <Text dimColor>{'≈' + compactNumber(agent.cost)}</Text> : null}
|
|
105
|
+
</Box>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The Agents pane body. */
|
|
110
|
+
export function renderAgents(el: Els, frame: Frame, view: AgentsView, actions: AgentsActions): RenderNode {
|
|
111
|
+
const { Box, Text } = el
|
|
112
|
+
const live = view.agents.filter(a => a.state === 'running' || a.state === 'waiting').sort((a, b) => a.startedAt - b.startedAt)
|
|
113
|
+
const done = view.agents.filter(a => a.state === 'done' || a.state === 'failed').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
|
|
114
|
+
// Empty, the pane has nothing to redraw it, so 리플이 can live out its idle loops here.
|
|
115
|
+
if (view.agents.length === 0) return speak(el, frame, { key: 'agents', face: 'calm', size: GREET_SIZE, title: t(frame.lang, 'agents.emptyTitle'), line: t(frame.lang, 'agents.emptyBody') })
|
|
116
|
+
const { Button } = el
|
|
117
|
+
return (
|
|
118
|
+
<Box flexDirection="column" rowGap={1} width="100%">
|
|
119
|
+
<Box flexDirection="row" flexWrap="wrap" columnGap={2}>
|
|
120
|
+
<Text bold>{live.length ? t(frame.lang, 'agents.liveCount', { count: live.length }) : t(frame.lang, 'agents.noneRunning')}</Text>
|
|
121
|
+
{done.length ? <Text dimColor>{t(frame.lang, 'agents.doneCount', { count: done.length })}</Text> : null}
|
|
122
|
+
</Box>
|
|
123
|
+
{live.map(agent => liveCard(el, frame, agent, view.now))}
|
|
124
|
+
{done.length ? (
|
|
125
|
+
<Box flexDirection="column">
|
|
126
|
+
<Box flexDirection="row">
|
|
127
|
+
{isRich(frame) ? (
|
|
128
|
+
<Button key="done" plain dimColor label={(view.showDone ? '▾ ' : '▸ ') + t(frame.lang, 'agents.doneCount', { count: done.length })} onPress={() => actions.toggleDone()} />
|
|
129
|
+
) : (
|
|
130
|
+
textButton(el, 'done', (view.showDone ? '▾ ' : '▸ ') + t(frame.lang, 'agents.doneCount', { count: done.length }), actions.toggleDone, 'f')
|
|
131
|
+
)}
|
|
132
|
+
</Box>
|
|
133
|
+
{view.showDone ? done.map(agent => doneLine(el, frame, agent)) : null}
|
|
134
|
+
</Box>
|
|
135
|
+
) : null}
|
|
136
|
+
</Box>
|
|
137
|
+
)
|
|
138
|
+
}
|