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.
Files changed (69) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +21 -15
  3. package/README.ja.md +13 -9
  4. package/README.ko.md +23 -9
  5. package/README.md +26 -11
  6. package/README.pt-BR.md +21 -15
  7. package/README.zh-CN.md +13 -9
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/codex.js +3 -1
  10. package/dist/cli/src/settings.js +5 -2
  11. package/dist/mod/.claude-plugin/plugin.json +9 -1
  12. package/dist/mod/hooks/cache.ts +60 -0
  13. package/dist/mod/hooks/compact.ts +40 -0
  14. package/dist/mod/hooks/handoff.ts +43 -0
  15. package/dist/mod/hooks/i18n.ts +2716 -0
  16. package/dist/mod/hooks/judge.ts +242 -0
  17. package/dist/mod/hooks/ladder.js +179 -0
  18. package/dist/mod/hooks/ladder.ts +174 -0
  19. package/dist/mod/hooks/modes.ts +93 -0
  20. package/dist/mod/hooks/register.tsx +1458 -256
  21. package/dist/mod/hooks/session.ts +62 -0
  22. package/dist/mod/hooks/settings.js +342 -0
  23. package/dist/mod/hooks/settings.ts +321 -0
  24. package/dist/mod/hooks/stats.ts +59 -0
  25. package/dist/mod/hooks/ui/agents.tsx +138 -0
  26. package/dist/mod/hooks/ui/alerts.tsx +373 -0
  27. package/dist/mod/hooks/ui/art.ts +628 -0
  28. package/dist/mod/hooks/ui/band.tsx +836 -0
  29. package/dist/mod/hooks/ui/kit.tsx +258 -0
  30. package/dist/mod/hooks/ui/log.tsx +109 -0
  31. package/dist/mod/hooks/ui/parts.tsx +217 -0
  32. package/dist/mod/hooks/ui/ripple.ts +614 -0
  33. package/dist/mod/hooks/ui/settings.tsx +376 -0
  34. package/dist/mod/hooks/ui/setup.tsx +148 -0
  35. package/dist/mod/hooks/ui/theme.ts +217 -0
  36. package/dist/mod/hooks/ui/view.ts +371 -0
  37. package/dist/mod/hooks/worker.ts +1 -1
  38. package/dist/mod/hooks/workers.ts +217 -0
  39. package/dist/mod/tests/register.test.tsx +1730 -0
  40. package/dist/mod/tests/ui.test.tsx +853 -0
  41. package/dist/mod/tsconfig.json +2 -1
  42. package/dist/mod/types/index.d.ts +320 -1
  43. package/dist/router/src/admin.js +170 -52
  44. package/dist/router/src/agents.js +24 -5
  45. package/dist/router/src/bootstrap.js +4 -2
  46. package/dist/router/src/config.js +3 -0
  47. package/dist/router/src/health.js +33 -0
  48. package/dist/router/src/index.js +1 -0
  49. package/dist/router/src/ingress/models.js +4 -1
  50. package/dist/router/src/mod-settings.js +120 -0
  51. package/dist/router/src/model-policy.js +6 -0
  52. package/dist/router/src/model-scores.js +159 -0
  53. package/dist/router/src/picker.js +7 -0
  54. package/dist/router/src/providers/chatgpt/index.js +23 -5
  55. package/dist/router/src/providers/chatgpt/translate.js +4 -1
  56. package/dist/router/src/providers/google/index.js +8 -3
  57. package/dist/router/src/providers/openai/index.js +6 -1
  58. package/dist/router/src/providers/openai/translate.js +6 -1
  59. package/dist/router/src/proxy.js +62 -24
  60. package/dist/router/src/requestlog.js +78 -20
  61. package/dist/router/src/version.js +1 -1
  62. package/dist/ui/app.js +908 -209
  63. package/dist/ui/favicon-32.png +0 -0
  64. package/dist/ui/favicon.svg +12 -0
  65. package/dist/ui/i18n.js +919 -233
  66. package/dist/ui/index.html +159 -87
  67. package/dist/ui/style.css +789 -410
  68. package/docs/ARCHITECTURE.md +141 -0
  69. package/package.json +7 -3
@@ -0,0 +1,62 @@
1
+ import type { SessionContextUsage, SessionRateLimit } from 'claude-code'
2
+ import type { CacheMemo, Hot } from '../types'
3
+
4
+ export function hottest(rateLimits: readonly SessionRateLimit[]): Hot | null {
5
+ const eligible = rateLimits.filter(limit => ['five_hour', 'seven_day'].includes(limit.kind) && limit.percentUsed >= 80)
6
+ const limit = eligible.reduce<SessionRateLimit | undefined>((hot, next) => !hot || next.percentUsed > hot.percentUsed ? next : hot, undefined)
7
+ return limit ? { kind: limit.kind, percent: limit.percentUsed, ...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {}) } : null
8
+ }
9
+
10
+ /**
11
+ * The reset is read in the person's own clock: `offsetMinutes` is `Date#getTimezoneOffset` (minutes
12
+ * behind UTC), passed in so tests do not depend on the machine's zone.
13
+ */
14
+ export function limitLabel(hot: Hot, now: number, offsetMinutes = new Date(now).getTimezoneOffset()): string {
15
+ const label = `${hot.kind === 'five_hour' ? '5h' : 'weekly'} ${Math.round(hot.percent)}%`
16
+ const reset = hot.resetsAt ? Date.parse(hot.resetsAt) : NaN
17
+ if (!Number.isFinite(reset)) return label
18
+ const date = new Date(reset - offsetMinutes * 60000)
19
+ const time = `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
20
+ const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()]
21
+ return `${label} · resets ${reset - now < 20 * 3600000 ? time : `${day} ${time}`}`
22
+ }
23
+
24
+ export function saveUntil(hot: Hot | null, now: number): number {
25
+ const reset = hot?.resetsAt ? Date.parse(hot.resetsAt) : NaN
26
+ return Number.isFinite(reset) && reset > now ? reset : now + 5 * 3600000
27
+ }
28
+
29
+ /** The context percent when it is at or past `swampAt`, else null. */
30
+ export function swamped(context: SessionContextUsage | null, swampAt: number): number | null {
31
+ if (!context || context.tokens === undefined) return null
32
+ const percent = context.percent ?? (context.window > 0 ? context.tokens / context.window * 100 : undefined)
33
+ return percent !== undefined && percent >= swampAt ? percent : null
34
+ }
35
+
36
+ export function coldAlert(memo: CacheMemo | null, contextTokens: number, now: number, hidden: boolean): boolean {
37
+ return !hidden && memo !== null && memo.expiresAt <= now && contextTokens >= 150000
38
+ }
39
+
40
+ export function hotAlert(hot: Hot | null, hidden: number | null): boolean {
41
+ return hot !== null && hot.percent >= 80 && (hidden === null || hot.percent >= hidden + 10)
42
+ }
43
+
44
+ /**
45
+ * The heavy-context band shows once per crossing: seen (or put off) at `hidden` percent, it waits
46
+ * until the context grows SWAMP_STEP points more. A hidden value over 100 is a token count kept by
47
+ * an older mod and is ignored.
48
+ */
49
+ export const SWAMP_STEP = 15
50
+ export function swampAlert(percent: number | null, hidden: number | null): boolean {
51
+ return percent !== null && (hidden === null || hidden > 100 || percent >= hidden + SWAMP_STEP)
52
+ }
53
+
54
+ /** Rendering consumes eligibility, keeping dismissal and idle decisions outside drawing code. */
55
+ export function bandPriority(state: {
56
+ panel?: 'settings' | 'compact' | 'handoff' | 'setup' | null
57
+ judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean
58
+ }): 'settings' | 'compact' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'dashboard' {
59
+ if (state.panel) return state.panel
60
+ for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp'] as const) if (state[band]) return band
61
+ return 'dashboard'
62
+ }
@@ -0,0 +1,342 @@
1
+ /** Bump whenever a writer's normalization can lose another format's fields. */
2
+ export const MOD_SETTINGS_SCHEMA_VERSION = 1;
3
+ export const DOMAINS = ['general', 'design', 'frontend', 'backend', 'infra', 'data', 'test', 'docs', 'research'];
4
+ export const LEVELS = ['light', 'standard', 'deep'];
5
+ export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
6
+ /** The worker ladder, bottom to top (docs/specs/2026-10-11-worker-ladder.md). */
7
+ export const RUNGS = ['light', 'standard', 'hard', 'plan'];
8
+ /** The rung each judged level lands on before kind and confidence move it. */
9
+ export const LEVEL_RUNG = { light: 'light', standard: 'standard', deep: 'hard' };
10
+ /** A rung's effort when nothing else says one: a stored rung without one, a migrated pin. */
11
+ export const RUNG_EFFORT = { light: 'low', standard: 'medium', hard: 'high', plan: 'high' };
12
+ const claude = (model, effort) => ({ target: { agent: 'general-purpose', model }, effort });
13
+ export const DEFAULTS = {
14
+ effortAuto: true, judge: 'haiku', judgeModel: 'deepseek-v4.1-flash', jevSource: 'typesafe',
15
+ bias: 0, floor: 'low', ceiling: 'max', agentAuto: 'auto',
16
+ rules: {
17
+ // The spec's "Claude + GPT" example; a rung whose worker is missing moves up and says so.
18
+ // Only the general rule: the four workers replaced the per-field rules (2026-10-12).
19
+ general: { mode: 'ladder', rungs: {
20
+ light: [{ target: { agent: 'deepseek-v4-1-flash' }, effort: 'max' }], standard: [{ target: { agent: 'gpt-6-1-sol' }, effort: 'medium' }],
21
+ hard: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], plan: [claude('opus', 'high')],
22
+ } },
23
+ },
24
+ allow: [], modes: [],
25
+ providers: {},
26
+ fallback: { agent: 'general-purpose', model: 'sonnet' },
27
+ keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
28
+ compactWith: 'haiku', handoffSkill: '', handoffAfter: 'continue', handoffButton: 'advised',
29
+ swampAt: 50, swampAlert: false, layout: 'dashboard', appearance: 'auto', language: 'auto', hide: ['reason'], extras: [], setupDone: false,
30
+ };
31
+ /** Band chips that show only when switched on in Look. */
32
+ export const EXTRAS = ['context', 'limits'];
33
+ /**
34
+ * Claude's own models as workers: the built-in general-purpose agent run on the model, `model` its
35
+ * current id. The key is what the allow-list stores.
36
+ */
37
+ export const CLAUDE_WORKERS = [
38
+ { name: 'general-purpose:haiku', model: 'claude-haiku-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'haiku' } },
39
+ { name: 'general-purpose:sonnet', model: 'claude-sonnet-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'sonnet' } },
40
+ { name: 'general-purpose:opus', model: 'claude-opus-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'opus' } },
41
+ { name: 'general-purpose:fable', model: 'claude-fable-5-1', provider: 'anthropic', target: { agent: 'general-purpose', model: 'fable' } },
42
+ ];
43
+ /** The allow-list key of a target: the agent's name, or `general-purpose:<model>` for Claude's own. */
44
+ export function candidateKey(target) {
45
+ return target.model ? target.agent + ':' + target.model : target.agent;
46
+ }
47
+ function object(raw) {
48
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
49
+ }
50
+ function names(raw) {
51
+ return Array.isArray(raw) ? [...new Set(raw.filter((v) => typeof v === 'string' && !!v.trim()).map(v => v.trim()))] : [];
52
+ }
53
+ function target(raw, fallback) {
54
+ const value = object(raw);
55
+ const agent = typeof value.agent === 'string' && value.agent.trim() ? value.agent.trim() : fallback?.agent;
56
+ const model = typeof value.model === 'string' && value.model.trim() ? value.model.trim() : fallback?.model;
57
+ return agent ? { agent, ...(model ? { model } : {}) } : undefined;
58
+ }
59
+ function calibration(value) {
60
+ const out = {};
61
+ if ([-2, -1, 0, 1, 2].includes(Number(value.effortOffset)) && typeof value.effortOffset === 'number')
62
+ out.effortOffset = value.effortOffset;
63
+ for (const field of ['floor', 'ceiling'])
64
+ if (EFFORTS.includes(value[field]))
65
+ out[field] = value[field];
66
+ return out;
67
+ }
68
+ function copy(value) {
69
+ return JSON.parse(JSON.stringify(value));
70
+ }
71
+ function sameTarget(a, b) {
72
+ return !!a && !!b && a.agent === b.agent && a.model === b.model;
73
+ }
74
+ /** An effort moved by calibration steps, then held between floor and ceiling (the floor wins). */
75
+ function calibrated(effort, cal) {
76
+ const min = EFFORTS.indexOf(cal.floor ?? 'low');
77
+ const max = Math.max(min, EFFORTS.indexOf(cal.ceiling ?? 'max'));
78
+ const shifted = Math.max(0, Math.min(4, EFFORTS.indexOf(effort) + (cal.effortOffset ?? 0)));
79
+ return EFFORTS[Math.max(min, Math.min(max, shifted))];
80
+ }
81
+ /** One effort step up, max staying max. */
82
+ export function effortUp(effort) {
83
+ return EFFORTS[Math.min(4, EFFORTS.indexOf(effort) + 1)];
84
+ }
85
+ function step(raw, rung) {
86
+ const v = object(raw);
87
+ const chosen = target(v.target);
88
+ if (!chosen)
89
+ return undefined;
90
+ // A rung's backup model is dropped on load: the four workers have none (the next one up stands in).
91
+ return { target: chosen, effort: EFFORTS.includes(v.effort) ? v.effort : RUNG_EFFORT[rung] };
92
+ }
93
+ /** The effort a migrated rung starts at: the default ladder's when it runs the same model there. */
94
+ function migratedEffort(rung, chosen) {
95
+ const own = DEFAULTS.rules.general.rungs[rung]?.[0];
96
+ return own && sameTarget(own.target, chosen) ? own.effort : RUNG_EFFORT[rung];
97
+ }
98
+ /**
99
+ * A pin stored before 2026-10-11 as a ladder: light/standard/deep become Light/Standard/Hard, a
100
+ * missing level takes the nearest one above (as the pin climbed) or else below, each rung's effort
101
+ * is its default calibrated as the pin was, and Plan is Hard's model one effort higher.
102
+ */
103
+ export function ladderFromPin(targets, cal) {
104
+ const known = LEVELS.filter(level => targets[level]);
105
+ if (!known.length)
106
+ return copy(DEFAULTS.rules.general);
107
+ const rungs = {};
108
+ LEVELS.forEach((level, at) => {
109
+ const above = LEVELS.slice(at).find(l => targets[l]);
110
+ const chosen = targets[above ?? [...LEVELS.slice(0, at)].reverse().find(l => targets[l])];
111
+ const rung = LEVEL_RUNG[level];
112
+ rungs[rung] = { target: { ...chosen }, effort: calibrated(migratedEffort(rung, chosen), cal) };
113
+ });
114
+ rungs.plan = { target: { ...rungs.hard.target }, effort: effortUp(rungs.hard.effort) };
115
+ return { mode: 'ladder', rungs: ladderRungs(rungs) };
116
+ }
117
+ /** Single steps from older saves become one-item lists; malformed items are dropped in place. */
118
+ function ladderRungs(raw) {
119
+ const source = object(raw);
120
+ const rungs = {};
121
+ for (const rung of RUNGS) {
122
+ const items = Array.isArray(source[rung]) ? source[rung] : [source[rung]];
123
+ const parsed = items.flatMap(item => { const value = step(item, rung); return value ? [value] : []; });
124
+ if (parsed.length)
125
+ rungs[rung] = parsed;
126
+ }
127
+ return rungs;
128
+ }
129
+ /**
130
+ * The one rule left, the general ladder (the four workers). A ladder keeps its rungs; a pin from
131
+ * before the ladder becomes one; Auto (experimental, removed 2026-10-12) becomes a ladder with no
132
+ * rungs set, which the router fills from the ticked models (`fillLadder`). Field rules are gone.
133
+ */
134
+ function generalRule(raw) {
135
+ const v = object(raw);
136
+ if (v.mode === 'ladder')
137
+ return { mode: 'ladder', rungs: ladderRungs(v.rungs) };
138
+ if (v.mode === 'auto')
139
+ return { mode: 'ladder', rungs: {} };
140
+ if (v.mode === 'pin') {
141
+ const source = object(v.targets);
142
+ const targets = {};
143
+ for (const level of LEVELS) {
144
+ const parsed = target(source[level]);
145
+ if (parsed)
146
+ targets[level] = parsed;
147
+ }
148
+ return ladderFromPin(targets, calibration(v));
149
+ }
150
+ return copy(DEFAULTS.rules.general);
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, workers) {
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
+ /** The workers switched on by an allow-list; empty means every worker. */
162
+ export function allowedWorkers(allow, workers) {
163
+ if (!allow.length)
164
+ 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
+ /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. */
169
+ export function toggleAllow(allow, name, workers) {
170
+ const on = allowedWorkers(allow, workers);
171
+ const next = on.includes(name) ? on.filter(n => n !== name) : workers.filter(w => w.name === name || on.includes(w.name)).map(w => w.name);
172
+ if (!next.length)
173
+ return [...allow];
174
+ return next.length === workers.length ? [] : next;
175
+ }
176
+ /**
177
+ * Whether a stored model is ticked: its key (an agent name, or `general-purpose:<model>`) or, for
178
+ * settings from before 2026-10-10, its provider is listed; an empty list ticks every model.
179
+ */
180
+ export function isAllowed(key, provider, allow) {
181
+ return !allow.length || allow.includes(key) || allow.includes(provider);
182
+ }
183
+ /** The design ladder every save stored until 2026-10-12: Claude, Sonnet then Opus. */
184
+ 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]));
185
+ /**
186
+ * Whether stored settings still carry what the four workers replaced: Auto (experimental), or a
187
+ * field rule that is not the design ladder every save used to store. The dashboard says so once.
188
+ */
189
+ export function hadRetiredRouting(raw) {
190
+ const rules = object(object(raw).rules);
191
+ if (object(rules.general).mode === 'auto')
192
+ return true;
193
+ return DOMAINS.some(domain => {
194
+ if (domain === 'general')
195
+ return false;
196
+ const stored = object(rules[domain]);
197
+ if (stored.mode === undefined || stored.mode === 'inherit')
198
+ return false;
199
+ if (domain !== 'design' || stored.mode !== 'ladder')
200
+ return true;
201
+ const rungs = ladderRungs(stored.rungs);
202
+ return JSON.stringify(RUNGS.map(rung => { const s = rungs[rung]?.[0]; return s ? [s.target.agent, s.target.model, s.effort] : null; })) !== RETIRED_DESIGN;
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
+ /** How long a mode's reset provider name may be. */
210
+ const PROVIDER_NAME_MAX = 64;
211
+ /**
212
+ * A mode's end: a time, one provider's next quota reset, or none. Until 2026-10-12 `'reset'` meant
213
+ * any used provider's; it becomes the first that could have offered it, ChatGPT then Claude. Settings
214
+ * do not carry a worker's provider, and ChatGPT is the only worker provider with a known reset, so
215
+ * any worker that is not Claude's own stands for ChatGPT.
216
+ */
217
+ function modeUntil(raw, rungs) {
218
+ if (typeof raw === 'number')
219
+ return Number.isFinite(raw) && raw > 0 ? raw : 'none';
220
+ if (raw === 'reset')
221
+ 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
+ * Saved worker sets. Old prefer/avoid modes copy the base ladder on load, dropping their policy.
227
+ * Every entry owns its lists: later base edits cannot mutate a saved mode.
228
+ */
229
+ export function normalizeModes(raw, base = DEFAULTS.rules.general.rungs) {
230
+ if (!Array.isArray(raw))
231
+ return [];
232
+ const out = [];
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))
238
+ continue;
239
+ const rungs = ladderRungs(v.rungs === undefined ? base : v.rungs);
240
+ out.push({ id, name, rungs, delegation: v.delegation === 'eager' ? 'eager' : 'normal', until: modeUntil(v.until, rungs) });
241
+ if (out.length === MODES_MAX)
242
+ break;
243
+ }
244
+ return out;
245
+ }
246
+ /** The general pin every install had before the ladder: what stored `tiers` filled in. */
247
+ const LEGACY_GENERAL = { light: { agent: 'deepseek-v4-1-flash' }, standard: { agent: 'gpt-6-1-sol' }, deep: { agent: 'gpt-6-astra' } };
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, workers) {
255
+ const value = object(raw);
256
+ const stored = object(value.rules);
257
+ const rules = { general: generalRule(stored.general) };
258
+ const old = object(value.tiers);
259
+ if (!Object.hasOwn(stored, 'general') && Object.keys(old).length) {
260
+ const targets = copy(LEGACY_GENERAL);
261
+ for (const level of LEVELS) {
262
+ const parsed = target(old[level]);
263
+ if (parsed)
264
+ targets[level] = parsed;
265
+ }
266
+ rules.general = ladderFromPin(targets, {});
267
+ }
268
+ const ticked = names(Array.isArray(value.allow) ? value.allow : object(stored.general).mode === 'auto' ? object(stored.general).allow : []);
269
+ const result = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } };
270
+ for (const [name, enabled] of Object.entries(object(value.providers)))
271
+ if (typeof enabled === 'boolean')
272
+ result.providers[name] = enabled;
273
+ for (const key of ['effortAuto', 'keepWarm', 'setupDone', 'swampAlert'])
274
+ if (typeof value[key] === 'boolean')
275
+ result[key] = value[key];
276
+ if (Array.isArray(value.extras))
277
+ result.extras = [...new Set(value.extras.filter((part) => typeof part === 'string' && EXTRAS.includes(part)))];
278
+ const choices = {
279
+ judge: ['haiku', 'jev', 'model'], jevSource: ['typesafe', 'zen'], bias: [-2, -1, 0, 1, 2], floor: EFFORTS,
280
+ ceiling: EFFORTS, agentAuto: ['off', 'auto', 'all'], keepWarmHours: [1, 2, 4, 8], compactWith: ['haiku', 'session'],
281
+ handoffAfter: ['continue', 'confirm', 'copy'], handoffButton: ['advised', 'always'], layout: ['dashboard', 'minimal'],
282
+ appearance: ['auto', 'dark', 'light'], language: ['auto', 'ko', 'en', 'zh', 'ja', 'es', 'pt'],
283
+ };
284
+ for (const key of Object.keys(choices))
285
+ if (choices[key].includes(value[key]))
286
+ Object.assign(result, { [key]: value[key] });
287
+ if (typeof value.judgeModel === 'string' && value.judgeModel.trim())
288
+ result.judgeModel = value.judgeModel.trim();
289
+ if (typeof value.handoffSkill === 'string')
290
+ result.handoffSkill = value.handoffSkill.trim().replace(/^\//, '');
291
+ if (typeof value.keepWarmMinTokens === 'number' && Number.isFinite(value.keepWarmMinTokens) && value.keepWarmMinTokens >= 0)
292
+ result.keepWarmMinTokens = value.keepWarmMinTokens;
293
+ if (typeof value.swampAt === 'number' && Number.isFinite(value.swampAt) && value.swampAt >= 0 && value.swampAt <= 100)
294
+ result.swampAt = value.swampAt;
295
+ if (Array.isArray(value.hide))
296
+ result.hide = [...new Set(value.hide.filter((part) => typeof part === 'string' && ['timer', 'reason', 'route', 'limits'].includes(part)))];
297
+ result.fallback = target(value.fallback, DEFAULTS.fallback);
298
+ result.modes = normalizeModes(value.modes, rules.general.rungs);
299
+ // An active mode must be one of the modes; its start time stays only with it.
300
+ if (typeof value.activeMode === 'string' && result.modes.some(m => m.id === value.activeMode)) {
301
+ result.activeMode = value.activeMode;
302
+ if (typeof value.activeSince === 'number' && Number.isFinite(value.activeSince) && value.activeSince > 0)
303
+ result.activeSince = value.activeSince;
304
+ }
305
+ return result;
306
+ }
307
+ /** Preserve unrelated remote edits, including sibling domains and provider switches. */
308
+ export function settingsChanges(before, after) {
309
+ function diff(a, b, path) {
310
+ if (JSON.stringify(a) === JSON.stringify(b))
311
+ return [];
312
+ if (b === undefined)
313
+ return [{ path, remove: true }];
314
+ if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
315
+ const left = object(a), right = object(b);
316
+ // Changing a discriminated rule replaces it, rather than retaining the old mode's fields.
317
+ if (left.mode === right.mode)
318
+ return [...new Set([...Object.keys(left), ...Object.keys(right)])].flatMap(key => diff(left[key], right[key], [...path, key]));
319
+ }
320
+ return [{ path, value: b }];
321
+ }
322
+ return diff(before, after, []);
323
+ }
324
+ export function applySettingsChanges(base, changes) {
325
+ const result = object(normalizeSettings(base));
326
+ for (const change of changes) {
327
+ let parent = result;
328
+ for (const key of change.path.slice(0, -1)) {
329
+ const child = object(parent[key]);
330
+ parent[key] = child;
331
+ parent = child;
332
+ }
333
+ const key = change.path.at(-1);
334
+ if (key === undefined)
335
+ continue;
336
+ if (change.remove)
337
+ delete parent[key];
338
+ else
339
+ parent[key] = change.value;
340
+ }
341
+ return normalizeSettings(result);
342
+ }