@rynfar/meridian 1.59.0 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +2 -1
  2. package/dist/{cli-wqvrd0yy.js → cli-tbhba0cv.js} +687 -231
  3. package/dist/cli.js +1 -1
  4. package/dist/{profilePage-naychnb8.js → profilePage-gtazq15d.js} +9 -0
  5. package/dist/proxy/adapters/detect.d.ts.map +1 -1
  6. package/dist/proxy/adapters/jcode.d.ts +13 -0
  7. package/dist/proxy/adapters/jcode.d.ts.map +1 -0
  8. package/dist/proxy/errors.d.ts.map +1 -1
  9. package/dist/proxy/messages.d.ts +29 -0
  10. package/dist/proxy/messages.d.ts.map +1 -1
  11. package/dist/proxy/oauthUsage.d.ts +34 -1
  12. package/dist/proxy/oauthUsage.d.ts.map +1 -1
  13. package/dist/proxy/openai.d.ts +5 -1
  14. package/dist/proxy/openai.d.ts.map +1 -1
  15. package/dist/proxy/passthroughEarlyStop.d.ts +48 -0
  16. package/dist/proxy/passthroughEarlyStop.d.ts.map +1 -1
  17. package/dist/proxy/query.d.ts +5 -2
  18. package/dist/proxy/query.d.ts.map +1 -1
  19. package/dist/proxy/sdkFeatures.d.ts +8 -0
  20. package/dist/proxy/sdkFeatures.d.ts.map +1 -1
  21. package/dist/proxy/server.d.ts.map +1 -1
  22. package/dist/proxy/session/cache.d.ts +1 -1
  23. package/dist/proxy/session/cache.d.ts.map +1 -1
  24. package/dist/proxy/session/lineage.d.ts +12 -0
  25. package/dist/proxy/session/lineage.d.ts.map +1 -1
  26. package/dist/proxy/sessionStore.d.ts +5 -1
  27. package/dist/proxy/sessionStore.d.ts.map +1 -1
  28. package/dist/proxy/transforms/registry.d.ts.map +1 -1
  29. package/dist/proxy/turnOutcome.d.ts +179 -0
  30. package/dist/proxy/turnOutcome.d.ts.map +1 -0
  31. package/dist/server.js +1 -1
  32. package/dist/telemetry/profilePage.d.ts.map +1 -1
  33. package/dist/telemetry/settingsPage.d.ts +1 -1
  34. package/dist/telemetry/settingsPage.d.ts.map +1 -1
  35. package/package.json +1 -1
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Was a turn productive, or did it come back silent?
3
+ *
4
+ * Three separate defects have now ended the same way: the proxy hands the
5
+ * client a well-formed terminal envelope — `stop_reason: "end_turn"`, HTTP 200,
6
+ * `error: null` — carrying nothing the client can act on. An interrupted tail
7
+ * (#768), an unsettled client abort (#768), a spent deny at the boundary seam
8
+ * (#768). Each was found by tracing its own cause; each was invisible until
9
+ * someone read a transcript.
10
+ *
11
+ * The causes differ, the shape does not. So this module states the shape as an
12
+ * invariant instead of chasing the next cause:
13
+ *
14
+ * A terminal envelope must carry text or a tool call.
15
+ *
16
+ * Anything else is a silent turn, and a silent turn is a defect — never a
17
+ * successful response. In an interactive session it costs a confused user and a
18
+ * retyped prompt. In an autonomous run there is nobody to retype: the loop sees
19
+ * a finished turn with no work in it and stops, or worse, reads its own
20
+ * unfulfilled promise on the next turn and reports the failure as its own.
21
+ *
22
+ * `thinking` deliberately does not count. The empty-turn signature IS thinking
23
+ * with an empty text block — counting it as content would classify the exact
24
+ * defect this module exists to catch as healthy.
25
+ *
26
+ * Text is treated as an answer, full stop. An earlier version also flagged a
27
+ * short text-only final on a deny-boundary continuation as an "announce" stall
28
+ * — "Let me read the config…" with no tool call — and spent a recovery turn on
29
+ * it. Measured against a live model that window turned out to be the shape of
30
+ * an ordinary concise answer: 12 recoveries across 12 healthy turns, zero
31
+ * genuine silences, and one case where the recovery rewrote a completed prose
32
+ * answer into a tool_use envelope the client would then execute. The cost of a
33
+ * false positive was not "one redundant closing turn"; it was acting on a
34
+ * question already answered. If that stall is real it needs a signal that
35
+ * distinguishes it from a short answer — length and conversation depth do not.
36
+ *
37
+ * Pure module — no I/O, no imports from server.ts or session/.
38
+ */
39
+ /** What a completed turn delivered to the client. */
40
+ export type TurnOutcome = {
41
+ kind: "productive";
42
+ } | {
43
+ kind: "silent";
44
+ reason: SilentReason;
45
+ };
46
+ /**
47
+ * Why a turn is silent. Both shapes reach the client identically; they are
48
+ * distinguished so telemetry can tell "the model said nothing" from "the model
49
+ * said nothing and we never even opened a message".
50
+ */
51
+ export type SilentReason =
52
+ /** Nothing at all — no blocks were forwarded. */
53
+ "no_blocks"
54
+ /** Blocks were forwarded, but none of them carried text or a tool call. */
55
+ | "no_actionable_content";
56
+ /**
57
+ * Classify a turn by what actually reached the client.
58
+ *
59
+ * `textEvents` counts forwarded text deltas, not text blocks: an EMPTY text
60
+ * block produces a block start and stop with no delta between them, which is
61
+ * precisely the shape a spent-deny turn takes. Counting blocks would miss it.
62
+ */
63
+ export declare function classifyTurnOutcome(input: {
64
+ /** Forwarded `text_delta` events (or, non-streaming, non-empty text blocks). */
65
+ textEvents: number;
66
+ /** Tool calls the client will execute — actionable even with no prose. */
67
+ toolUses: number;
68
+ /** Blocks forwarded in total, including thinking. */
69
+ blocksForwarded: number;
70
+ }): TurnOutcome;
71
+ /**
72
+ * The nudge sent to recover a silent turn, in the same SDK session.
73
+ *
74
+ * Deliberately not the CLI's own nudge ("your previous response had no visible
75
+ * output"). That one is issued INSIDE the session with the offending
76
+ * instruction still in context, so on the observed cases the model obeyed the
77
+ * instruction a second time and answered empty again. This one names the
78
+ * contradiction and discharges it, the same move that fixed the boundary seam.
79
+ *
80
+ * Says nothing about which defect caused the silence: the recovery is for the
81
+ * class, and the model does not need our diagnosis to answer.
82
+ */
83
+ export declare const SILENT_TURN_NUDGE: string;
84
+ /**
85
+ * Fault injection: swallow the upstream text of this request's turn.
86
+ *
87
+ * The defect's live rate is roughly three in five hundred requests, all on a
88
+ * session's second turn. A harness cannot wait for that — a ten-attempt run
89
+ * expects 0.06 occurrences — so without injection a green E2E is ambiguous:
90
+ * the guard works, or the defect simply did not happen. That ambiguity is
91
+ * exactly what let two earlier "verified" fixes ship broken.
92
+ *
93
+ * So the shape is reproduced on demand: drop the text the upstream turn
94
+ * produced, leaving its block start and stop in place — an EMPTY text block,
95
+ * which is the production signature exactly. Everything downstream (detection,
96
+ * recovery, envelope, telemetry) then runs for real against a real model; only
97
+ * the trigger is synthetic.
98
+ *
99
+ * EVERY text delta of the turn is dropped, not just the first: one surviving
100
+ * delta makes the turn productive and the guard never engages. The recovery
101
+ * turn is unaffected because it forwards through its own path, not the upstream
102
+ * forward site this gates.
103
+ *
104
+ * Off unless MERIDIAN_DEBUG_FORCE_SILENT_TURN names a session id (or `1` for
105
+ * every session). Debug-only — never a production path.
106
+ */
107
+ export declare function shouldInjectSilentTurn(input: {
108
+ raw: string | undefined;
109
+ sessionId: string | undefined;
110
+ }): boolean;
111
+ /**
112
+ * One frame of a recovery turn, re-indexed for the client's wire. `kind`
113
+ * doubles as the enqueue label suffix, so telemetry keeps the exact names the
114
+ * recovery has always logged (silent_recovery_block_start, …).
115
+ */
116
+ export type RecoveryLift = {
117
+ kind: "block_start";
118
+ frame: {
119
+ type: "content_block_start";
120
+ index: number;
121
+ content_block: {
122
+ type: "text";
123
+ text: string;
124
+ };
125
+ };
126
+ } | {
127
+ kind: "text_delta";
128
+ frame: {
129
+ type: "content_block_delta";
130
+ index: number;
131
+ delta: {
132
+ type: "text_delta";
133
+ text: unknown;
134
+ };
135
+ };
136
+ /** Characters delivered — 0 when the delta carried a non-string text. */
137
+ textChars: number;
138
+ } | {
139
+ kind: "block_stop";
140
+ frame: {
141
+ type: "content_block_stop";
142
+ index: number;
143
+ };
144
+ };
145
+ /**
146
+ * Lift a recovery turn's stream events into the message already open on the
147
+ * client's wire.
148
+ *
149
+ * Only text is lifted. A tool call arriving in the recovery turn would need
150
+ * its own block index space and the client's turn boundary to match — out of
151
+ * scope for a recovery; it still counts as one, through the shared PreToolUse
152
+ * capture at the recovery site in server.ts.
153
+ *
154
+ * The lifter owns the re-indexing handshake: a text block start allocates a
155
+ * client index from the caller's space — so the recovery block continues the
156
+ * open message's numbering instead of colliding with it — deltas are rewritten
157
+ * onto that index, and stop releases it. Deltas and stops outside an open
158
+ * block are dropped: emitting them would be malformed SSE.
159
+ */
160
+ export declare function createRecoveryLifter(allocateBlockIndex: () => number): {
161
+ lift(innerEvent: unknown): RecoveryLift | undefined;
162
+ };
163
+ /**
164
+ * Is one in-session recovery attempt worth making?
165
+ *
166
+ * Only for a stalled turn — silent or announce — at most once, and never when
167
+ * the client is already gone: a disconnected client cannot receive the
168
+ * recovered answer, and spending a model turn to write into a closed socket is
169
+ * pure waste.
170
+ */
171
+ export declare function shouldAttemptRecovery(input: {
172
+ outcome: TurnOutcome;
173
+ alreadyAttempted: boolean;
174
+ clientGone: boolean;
175
+ /** The SDK session to resume; without it there is nothing to continue from. */
176
+ sessionId?: string;
177
+ enabled: boolean;
178
+ }): boolean;
179
+ //# sourceMappingURL=turnOutcome.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turnOutcome.d.ts","sourceRoot":"","sources":["../../src/proxy/turnOutcome.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,qDAAqD;AACrD,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,GACtB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,CAAA;AAE5C;;;;GAIG;AACH,MAAM,MAAM,YAAY;AACtB,iDAAiD;AAC/C,WAAW;AACb,2EAA2E;GACzE,uBAAuB,CAAA;AAE3B;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE;IACzC,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAA;IAClB,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,CAAA;IAChB,qDAAqD;IACrD,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,WAAW,CAOd;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,QAIuC,CAAA;AAErE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;IACvB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAA;CAC9B,GAAG,OAAO,CAIV;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,GACpB;IACE,IAAI,EAAE,aAAa,CAAA;IACnB,KAAK,EAAE;QAAE,IAAI,EAAE,qBAAqB,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAA;CACrG,GACD;IACE,IAAI,EAAE,YAAY,CAAA;IAClB,KAAK,EAAE;QAAE,IAAI,EAAE,qBAAqB,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE;YAAE,IAAI,EAAE,YAAY,CAAC;YAAC,IAAI,EAAE,OAAO,CAAA;SAAE,CAAA;KAAE,CAAA;IACnG,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAA;CAClB,GACD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,oBAAoB,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAA;AAEhF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAAC,kBAAkB,EAAE,MAAM,MAAM,GAAG;IACtE,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,YAAY,GAAG,SAAS,CAAA;CACpD,CAgCA;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE;IAC3C,OAAO,EAAE,WAAW,CAAA;IACpB,gBAAgB,EAAE,OAAO,CAAA;IACzB,UAAU,EAAE,OAAO,CAAA;IACnB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,OAAO,CAAA;CACjB,GAAG,OAAO,CAMV"}
package/dist/server.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  runObserveHook,
12
12
  runTransformHook,
13
13
  startProxyServer
14
- } from "./cli-wqvrd0yy.js";
14
+ } from "./cli-tbhba0cv.js";
15
15
  import"./cli-h6hfkg3s.js";
16
16
  import"./cli-sry5aqdj.js";
17
17
  import"./cli-xmweegb1.js";
@@ -1 +1 @@
1
- {"version":3,"file":"profilePage.d.ts","sourceRoot":"","sources":["../../src/telemetry/profilePage.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,eAAO,MAAM,eAAe,QAqbpB,CAAA"}
1
+ {"version":3,"file":"profilePage.d.ts","sourceRoot":"","sources":["../../src/telemetry/profilePage.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,eAAO,MAAM,eAAe,QA8bpB,CAAA"}
@@ -2,5 +2,5 @@
2
2
  * SDK Features settings page — per-adapter toggle UI.
3
3
  * Same dark theme as the telemetry dashboard. No framework, no CDN.
4
4
  */
5
- export declare const settingsPageHtml = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Meridian \u2014 SDK Features</title>\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/telemetry/icon.svg\">\n<style>\n \n :root {\n /* Cool-gray neutral palette. High contrast, surface/border separation,\n no color cast muddying the text. Blue is the primary accent; violet\n is the fixed secondary \u2014 used together in the brand gradient and\n individually for hover states and a handful of telemetry badges. */\n --bg: #0d1117;\n --surface: #161b22;\n --surface2: #1c2128;\n --border: #30363d;\n /* Text */\n --text: #e6edf3;\n --muted: #8b949e;\n /* Brand \u2014 blue primary, violet secondary */\n --accent: #58a6ff;\n --accent2: #bc8cff;\n --violet: #bc8cff;\n --lavender: #d2a8ff;\n /* Semantic */\n --green: #3fb950;\n --yellow: #d29922;\n --red: #f85149;\n /* Telemetry-specific aliases (waterfall + lineage badges) */\n --blue: #58a6ff;\n --purple: #bc8cff;\n --queue: #d29922;\n --ttfb: #58a6ff;\n --upstream: #3fb950;\n }\n /* Banner backsplash \u2014 the brand look: a gentle diagonal wash with soft\n blue (top-left) and violet (bottom-right) glows. Pages must not set\n their own body background so this shows through everywhere. */\n body {\n background:\n radial-gradient(1200px 800px at 12% -8%, rgba(88,166,255,0.07), transparent 60%),\n radial-gradient(1100px 800px at 92% 108%, rgba(188,140,255,0.06), transparent 60%),\n linear-gradient(135deg, #0d1117 0%, #10151d 55%, #161b22 100%);\n background-attachment: fixed;\n background-color: var(--bg);\n }\n\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;\n color: var(--text); padding: 0; line-height: 1.5; }\n \n .meridian-header {\n position: sticky; top: 0; z-index: 100;\n display: flex; align-items: center; gap: 20px;\n padding: 10px 24px;\n background: rgba(13, 17, 23, 0.92);\n backdrop-filter: blur(12px);\n border-bottom: 1px solid var(--border, #30363d);\n }\n .meridian-header .mh-brand {\n display: flex; align-items: center; gap: 10px;\n text-decoration: none; color: var(--text, #e6edf3);\n }\n .meridian-header .mh-logo { display: block; }\n .meridian-header .mh-name {\n font-size: 15px; font-weight: 700; letter-spacing: 2px;\n text-transform: uppercase;\n }\n .meridian-header .mh-nav { display: flex; align-items: center; gap: 2px; }\n .meridian-header .mh-nav a {\n color: var(--muted, #8b949e); text-decoration: none; font-size: 12px;\n font-weight: 500; padding: 5px 10px; border-radius: 6px;\n transition: color 0.15s, background 0.15s;\n }\n .meridian-header .mh-nav a:hover { color: var(--text, #e6edf3); background: var(--surface, #161b22); }\n .meridian-header .mh-nav a.active { color: var(--accent, #58a6ff); background: var(--surface, #161b22); }\n .meridian-header .mh-right {\n margin-left: auto; display: flex; align-items: center; gap: 10px;\n }\n .meridian-header .mh-profile {\n display: none; align-items: center; gap: 6px;\n font-size: 11px; font-weight: 500; color: var(--text, #e6edf3);\n padding: 3px 10px; border-radius: 20px;\n background: var(--surface, #161b22); border: 1px solid var(--border, #30363d);\n text-decoration: none; transition: border-color 0.15s;\n }\n .meridian-header .mh-profile:hover { border-color: var(--accent, #58a6ff); }\n .meridian-header .mh-profile.visible { display: inline-flex; }\n .meridian-header .mh-profile .mh-profile-type {\n color: var(--muted, #8b949e); font-size: 10px;\n }\n .meridian-header .mh-status {\n display: inline-flex; align-items: center; gap: 6px;\n font-size: 11px; color: var(--muted, #8b949e); white-space: nowrap;\n }\n .meridian-header .mh-dot {\n width: 8px; height: 8px; border-radius: 50%;\n background: var(--muted, #8b949e); flex-shrink: 0;\n }\n .meridian-header .mh-dot.healthy { background: var(--green, #3fb950); box-shadow: 0 0 6px rgba(63,185,80,0.5); }\n .meridian-header .mh-dot.degraded { background: var(--yellow, #d29922); }\n .meridian-header .mh-dot.unhealthy { background: var(--red, #f85149); }\n @media (max-width: 720px) {\n .meridian-header { gap: 10px; padding: 10px 16px; flex-wrap: wrap; }\n .meridian-header .mh-name { display: none; }\n .meridian-header .mh-status .mh-status-text { display: none; }\n }\n\n .content { max-width: 900px; margin: 0 auto; padding: 24px; }\n h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }\n .subtitle { color: var(--muted); font-size: 13px; margin-bottom: 24px; }\n .nav { display: flex; gap: 16px; margin-bottom: 24px; font-size: 13px; }\n .nav a { color: var(--muted); text-decoration: none; }\n .nav a:hover { color: var(--accent); }\n .nav a.active { color: var(--accent); }\n\n .adapter-card {\n background: var(--surface); border: 1px solid var(--border); border-radius: 8px;\n padding: 20px; margin-bottom: 16px;\n }\n .adapter-header {\n display: flex; align-items: center; justify-content: space-between;\n margin-bottom: 16px;\n }\n .adapter-name { font-size: 16px; font-weight: 600; }\n .adapter-badge {\n font-size: 10px; padding: 2px 8px; border-radius: 10px;\n text-transform: uppercase; letter-spacing: 0.5px;\n }\n .badge-active { background: rgba(63, 185, 80, 0.15); color: var(--green); }\n .badge-inactive { background: rgba(139, 148, 158, 0.15); color: var(--muted); }\n\n .feature-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }\n @media (max-width: 600px) { .feature-grid { grid-template-columns: 1fr; } }\n\n .feature-row {\n display: flex; align-items: center; justify-content: space-between;\n padding: 10px 14px; border-radius: 6px;\n background: var(--bg); border: 1px solid var(--border);\n }\n .feature-info { display: flex; flex-direction: column; }\n .feature-label { font-size: 13px; font-weight: 500; }\n .feature-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }\n\n /* Toggle switch */\n .toggle { position: relative; width: 36px; height: 20px; flex-shrink: 0; }\n .toggle input { opacity: 0; width: 0; height: 0; }\n .toggle-track {\n position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;\n background: var(--border); border-radius: 10px; transition: background 0.2s;\n }\n .toggle-track::after {\n content: \"\"; position: absolute; height: 14px; width: 14px;\n left: 3px; bottom: 3px; background: var(--muted); border-radius: 50%;\n transition: transform 0.2s, background 0.2s;\n }\n .toggle input:checked + .toggle-track { background: var(--accent); }\n .toggle input:checked + .toggle-track::after {\n transform: translateX(16px); background: var(--text);\n }\n\n /* Select dropdown */\n .feature-select {\n background: var(--surface); color: var(--text); border: 1px solid var(--border);\n border-radius: 6px; padding: 4px 8px; font-size: 12px; cursor: pointer;\n }\n\n .save-indicator {\n position: fixed; bottom: 24px; right: 24px;\n background: var(--green); color: #000; padding: 8px 16px;\n border-radius: 6px; font-size: 13px; font-weight: 500;\n opacity: 0; transition: opacity 0.3s; pointer-events: none;\n }\n .save-indicator.visible { opacity: 1; }\n\n .reset-btn {\n background: none; border: 1px solid var(--border); color: var(--muted);\n border-radius: 6px; padding: 4px 12px; font-size: 11px; cursor: pointer;\n }\n .reset-btn:hover { border-color: var(--red); color: var(--red); }\n\n /* Model pricing */\n .pricing-table { width: 100%; border-collapse: collapse; font-size: 12px; }\n .pricing-table th { text-align: left; padding: 8px 10px; color: var(--muted); font-weight: 500;\n font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }\n .pricing-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); }\n .pricing-table tr:last-child td { border-bottom: none; }\n .pricing-model { font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; font-size: 12px; word-break: break-all; }\n .pricing-input { background: var(--bg); color: var(--text); border: 1px solid var(--border);\n border-radius: 6px; padding: 4px 8px; font-size: 12px; width: 84px; text-align: right;\n font-variant-numeric: tabular-nums; }\n .pricing-input:focus { border-color: var(--accent); outline: none; }\n .pricing-badge { font-size: 10px; padding: 2px 8px; border-radius: 10px;\n text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }\n .badge-override { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }\n .badge-builtin { background: rgba(139, 148, 158, 0.15); color: var(--muted); }\n .pricing-add { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 14px;\n padding-top: 14px; border-top: 1px solid var(--border); }\n .pricing-add input[type=\"text\"] { width: 240px; text-align: left; }\n .add-btn { background: var(--accent); border: none; color: #fff; border-radius: 6px;\n padding: 5px 14px; font-size: 12px; font-weight: 500; cursor: pointer; }\n .pricing-note { font-size: 11px; color: var(--muted); margin-top: 12px; line-height: 1.6; }\n</style>\n</head>\n<body>\n\n<header class=\"meridian-header\" id=\"meridianHeader\">\n <a class=\"mh-brand\" href=\"/\">\n <svg class=\"mh-logo\" width=\"24\" height=\"24\" viewBox=\"0 0 64 64\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\">\n <defs>\n <linearGradient id=\"mhGrad\" x1=\"32\" y1=\"4\" x2=\"32\" y2=\"60\" gradientUnits=\"userSpaceOnUse\">\n <stop stop-color=\"#58a6ff\"/>\n <stop offset=\"1\" stop-color=\"#bc8cff\"/>\n </linearGradient>\n </defs>\n <circle cx=\"32\" cy=\"32\" r=\"25\" stroke=\"url(#mhGrad)\" stroke-width=\"3.5\"/>\n <ellipse cx=\"32\" cy=\"32\" rx=\"10.5\" ry=\"25\" stroke=\"url(#mhGrad)\" stroke-width=\"2.5\" opacity=\"0.8\"/>\n <path d=\"M7 32h50\" stroke=\"url(#mhGrad)\" stroke-width=\"2\" opacity=\"0.4\"/>\n <circle cx=\"32\" cy=\"7\" r=\"4.5\" fill=\"#58a6ff\"/>\n <circle cx=\"32\" cy=\"57\" r=\"4.5\" fill=\"#bc8cff\"/>\n</svg>\n <span class=\"mh-name\">Meridian</span>\n </a>\n <nav class=\"mh-nav\">\n <a href=\"/\" id=\"nav-home\">Home</a>\n <a href=\"/telemetry\" id=\"nav-telemetry\">Telemetry</a>\n <a href=\"/profiles\" id=\"nav-profiles\">Profiles</a>\n <a href=\"/settings\" id=\"nav-settings\">Settings</a>\n <a href=\"/plugins\" id=\"nav-plugins\">Plugins</a>\n </nav>\n <div class=\"mh-right\">\n <a class=\"mh-profile\" id=\"mhProfile\" href=\"/\" title=\"Active profile \u2014 switch from the home page\"></a>\n <span class=\"mh-status\" id=\"mhStatus\"><span class=\"mh-dot\" id=\"mhDot\"></span><span class=\"mh-status-text\" id=\"mhStatusText\"></span></span>\n </div>\n</header>\n\n<div class=\"content\">\n <h1>SDK Features <span style=\"font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(210,153,34,0.15);color:var(--yellow);vertical-align:middle;margin-left:8px\">Experimental</span></h1>\n <p class=\"subtitle\" style=\"max-width:720px;line-height:1.6\">\n Unlock Claude Code features for any connected agent. Capabilities like auto-memory, dreaming, and CLAUDE.md \u2014 normally\n exclusive to Claude Code \u2014 become available to OpenCode, Crush, Droid, and any other harness routed through Meridian.\n Each agent keeps its own toolchain while gaining access to these additional features.<br><br>\n <strong style=\"color:var(--text)\">System prompts:</strong> For these features to work correctly, both the Claude Code prompt and your client prompt\n should be enabled. When both are active, they are appended together \u2014 Claude Code's base instructions come first,\n followed by your agent's specific instructions.\n </p>\n\n <div id=\"adapters\"></div>\n\n <h1 style=\"margin-top:40px\">Routing</h1>\n <p class=\"subtitle\" style=\"max-width:720px;line-height:1.6\">\n How unpinned requests choose an account. <strong style=\"color:var(--text)\">Active</strong> uses the manually\n selected profile. <strong style=\"color:var(--text)\">Sticky</strong> distributes sessions across profiles evenly\n (cache-affine). <strong style=\"color:var(--text)\">Priority</strong> drains the pool in order \u2014 highest first \u2014\n and fails over per request when an account runs out; conversations keep their account, and new sessions\n return to the preferred account after its window resets. An explicit <code>x-meridian-profile</code> header\n always overrides. Changes apply to the next request \u2014 no restart needed.\n </p>\n <div class=\"adapter-card\" id=\"routing-card\">\n <div id=\"routing-body\">Loading\u2026</div>\n </div>\n\n <h1 style=\"margin-top:40px\">Model Pricing</h1>\n <p class=\"subtitle\" style=\"max-width:720px;line-height:1.6\">\n Rates used by the telemetry cost estimate, in USD per million tokens. Edit a value to override the\n built-in rate, or add models the built-in table doesn't know about (they show as \"no pricing\" on the\n dashboard until defined here). Changes apply on the next dashboard refresh.\n </p>\n <div class=\"adapter-card\">\n <table class=\"pricing-table\">\n <thead><tr><th>Model</th><th>Input</th><th>Output</th><th>Cache Read</th><th>Cache Write</th><th>Source</th><th></th></tr></thead>\n <tbody id=\"pricingRows\"></tbody>\n </table>\n <div class=\"pricing-add\">\n <input type=\"text\" class=\"pricing-input\" id=\"newModelName\" placeholder=\"model id (e.g. claude-opus-9)\">\n <input type=\"number\" class=\"pricing-input\" id=\"newModelInput\" placeholder=\"input\" min=\"0\" step=\"0.01\">\n <input type=\"number\" class=\"pricing-input\" id=\"newModelOutput\" placeholder=\"output\" min=\"0\" step=\"0.01\">\n <input type=\"number\" class=\"pricing-input\" id=\"newModelCacheRead\" placeholder=\"cache read\" min=\"0\" step=\"0.01\">\n <input type=\"number\" class=\"pricing-input\" id=\"newModelCacheWrite\" placeholder=\"cache write\" min=\"0\" step=\"0.01\">\n <button class=\"add-btn\" onclick=\"addPricingModel()\">Add Model</button>\n </div>\n <div class=\"pricing-note\">\n Cache read and cache write are optional; when left blank they default to 0.1x and 1.25x of the\n input rate (the 5-minute cache TTL multipliers). Verify current list prices at\n <a href=\"https://claude.com/pricing\" target=\"_blank\" rel=\"noreferrer\" style=\"color:var(--accent)\">claude.com/pricing</a>.\n </div>\n </div>\n</div>\n\n<div class=\"save-indicator\" id=\"saveIndicator\">Saved</div>\n\n<script>\nconst FEATURES = [\n { key: 'codeSystemPrompt', label: 'Claude Code Prompt', desc: 'Include the built-in Claude Code system prompt (tool usage rules, safety guidelines, coding best practices)', type: 'toggle' },\n { key: 'clientSystemPrompt', label: 'Client Prompt', desc: 'Include the system prompt sent by the connecting agent (e.g. OpenCode or Crush instructions)', type: 'toggle' },\n { key: 'claudeMd', label: 'CLAUDE.md', desc: 'Load CLAUDE.md instruction files \u2014 Off: none, Project: ./CLAUDE.md only, Full: ~/.claude/CLAUDE.md + ./CLAUDE.md', type: 'select', options: ['off', 'project', 'full'] },\n { key: 'memory', label: 'Memory', desc: 'Read and write memories across sessions', type: 'toggle' },\n { key: 'dreaming', label: 'Auto-Dream', desc: 'Background memory consolidation', type: 'toggle' },\n { key: 'thinking', label: 'Thinking', desc: 'Extended thinking mode', type: 'select', options: ['disabled', 'adaptive', 'enabled'] },\n { key: 'thinkingPassthrough', label: 'Thinking Passthrough', desc: 'Forward thinking blocks to the client', type: 'toggle' },\n { key: 'sharedMemory', label: 'Shared Memory', desc: 'Share memory with Claude Code (~/.claude) instead of isolated storage', type: 'toggle' },\n { key: 'webFetchPreflight', label: 'WebFetch Preflight', desc: 'Check each WebFetch hostname against the Anthropic blocklist before fetching \u2014 off keeps hostnames local but fetches any URL unchecked. Only affects Cherry Studio; other adapters never run the SDK\\'s built-in WebFetch', type: 'toggle' },\n { key: 'maxBudgetUsd', label: 'Max Budget (USD)', desc: 'Per-request cost cap \u2014 query aborts if exceeded (0 = disabled)', type: 'number' },\n { key: 'fallbackModel', label: 'Fallback Model', desc: 'Auto-fallback model if primary fails', type: 'select', options: ['', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]'] },\n { key: 'sdkDebug', label: 'SDK Debug Logging', desc: 'Enable verbose SDK debug output to proxy stderr', type: 'toggle' },\n { key: 'additionalDirectories', label: 'Additional Directories', desc: 'Comma-separated extra paths Claude can access (monorepo libs, etc.)', type: 'text' },\n];\n\n// Display names only \u2014 NOT the list of adapters to render. The page renders\n// whatever /settings/api/features returns, so a new adapter shows up here the\n// moment it exists, using its raw name until someone gives it a pretty one.\n// A hardcoded list is what hid codex and cherry from this page entirely.\nconst ADAPTER_LABELS = {\n opencode: 'OpenCode',\n openai: 'OpenAI (/v1/chat/completions)',\n crush: 'Crush',\n forgecode: 'ForgeCode',\n pi: 'Pi',\n droid: 'Droid',\n passthrough: 'LiteLLM / Passthrough',\n codex: 'Codex CLI (/v1/responses)',\n cherry: 'Cherry Studio',\n 'claude-code': 'Claude Code',\n};\n\nlet currentConfig = {};\n\nasync function loadConfig() {\n const res = await fetch('/settings/api/features');\n currentConfig = await res.json();\n render();\n}\n\nasync function saveFeature(adapter, key, value) {\n const patch = {};\n patch[key] = value;\n await fetch('/settings/api/features/' + adapter, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(patch),\n });\n currentConfig[adapter][key] = value;\n showSaved();\n}\n\nasync function resetAdapter(adapter) {\n await fetch('/settings/api/features/' + adapter, { method: 'DELETE' });\n await loadConfig();\n showSaved();\n}\n\nfunction showSaved() {\n const el = document.getElementById('saveIndicator');\n el.classList.add('visible');\n setTimeout(() => el.classList.remove('visible'), 1500);\n}\n\nfunction hasAnyEnabled(features) {\n return features.codeSystemPrompt || !features.clientSystemPrompt || features.claudeMd !== 'off' || features.memory || features.dreaming ||\n features.thinking !== 'disabled' || features.thinkingPassthrough ||\n features.sharedMemory || features.webFetchPreflight === false || features.maxBudgetUsd > 0 ||\n features.fallbackModel || features.sdkDebug ||\n features.additionalDirectories;\n}\n\nfunction render() {\n const container = document.getElementById('adapters');\n container.innerHTML = '';\n\n for (const adapter of Object.keys(currentConfig)) {\n const label = ADAPTER_LABELS[adapter] || adapter;\n const features = currentConfig[adapter] || {};\n const active = hasAnyEnabled(features);\n\n const card = document.createElement('div');\n card.className = 'adapter-card';\n card.innerHTML = '<div class=\"adapter-header\">' +\n '<span class=\"adapter-name\">' + label + '</span>' +\n '<div style=\"display:flex;gap:8px;align-items:center\">' +\n '<span class=\"adapter-badge ' + (active ? 'badge-active' : 'badge-inactive') + '\">' +\n (active ? 'Active' : 'Default') +\n '</span>' +\n '<button class=\"reset-btn\" onclick=\"resetAdapter(\\''+adapter+'\\')\">Reset</button>' +\n '</div>' +\n '</div>';\n\n const grid = document.createElement('div');\n grid.className = 'feature-grid';\n\n for (const feat of FEATURES) {\n const row = document.createElement('div');\n row.className = 'feature-row';\n\n const info = '<div class=\"feature-info\"><span class=\"feature-label\">' +\n feat.label + '</span><span class=\"feature-desc\">' + feat.desc + '</span></div>';\n\n if (feat.type === 'toggle') {\n const checked = features[feat.key] ? 'checked' : '';\n row.innerHTML = info +\n '<label class=\"toggle\"><input type=\"checkbox\" ' + checked +\n ' onchange=\"saveFeature(\\''+adapter+'\\', \\''+feat.key+'\\', this.checked)\">' +\n '<span class=\"toggle-track\"></span></label>';\n } else if (feat.type === 'select') {\n const options = feat.options.map(o => {\n const label = o === '' ? '(None)' : o.charAt(0).toUpperCase()+o.slice(1);\n return '<option value=\"'+o+'\"'+(features[feat.key]===o?' selected':'')+'>'+label+'</option>';\n }).join('');\n row.innerHTML = info +\n '<select class=\"feature-select\" onchange=\"saveFeature(\\''+adapter+'\\', \\''+feat.key+'\\', this.value)\">' +\n options + '</select>';\n } else if (feat.type === 'number') {\n const value = features[feat.key] ?? 0;\n row.innerHTML = info +\n '<input type=\"number\" class=\"feature-select\" style=\"width:80px;text-align:right\" min=\"0\" step=\"0.01\" value=\"'+value+'\"' +\n ' onchange=\"saveFeature(\\''+adapter+'\\', \\''+feat.key+'\\', parseFloat(this.value)||0)\">';\n } else if (feat.type === 'text') {\n const value = (features[feat.key] ?? '').toString().replace(/\"/g, '&quot;');\n row.innerHTML = info +\n '<input type=\"text\" class=\"feature-select\" style=\"width:180px\" value=\"'+value+'\"' +\n ' onchange=\"saveFeature(\\''+adapter+'\\', \\''+feat.key+'\\', this.value)\">';\n }\n\n grid.appendChild(row);\n }\n\n card.appendChild(grid);\n container.appendChild(card);\n }\n}\n\n// ---- Model pricing (telemetry cost estimate) ----\nlet pricingData = { builtin: {}, overrides: {} };\n\nfunction fmtRate(v) { return String(Math.round(v * 10000) / 10000); }\n\nasync function loadPricing() {\n const res = await fetch('/settings/api/pricing');\n pricingData = await res.json();\n renderPricing();\n}\n\nasync function putPricing(model, rates) {\n const res = await fetch('/settings/api/pricing/' + encodeURIComponent(model), {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(rates),\n });\n if (!res.ok) {\n const err = await res.json().catch(function () { return {}; });\n alert('Could not save pricing: ' + (err.error || ('HTTP ' + res.status)));\n return false;\n }\n showSaved();\n return true;\n}\n\nasync function removePricing(model) {\n await fetch('/settings/api/pricing/' + encodeURIComponent(model), { method: 'DELETE' });\n showSaved();\n await loadPricing();\n}\n\nfunction rateCell(value, onCommit) {\n const td = document.createElement('td');\n const input = document.createElement('input');\n input.type = 'number';\n input.min = '0';\n input.step = '0.01';\n input.className = 'pricing-input';\n input.value = fmtRate(value);\n input.addEventListener('change', onCommit);\n td.appendChild(input);\n return { td: td, input: input };\n}\n\nfunction renderPricing() {\n const tbody = document.getElementById('pricingRows');\n tbody.innerHTML = '';\n const models = Array.from(new Set(\n Object.keys(pricingData.builtin).concat(Object.keys(pricingData.overrides))\n )).sort();\n\n for (const model of models) {\n const override = pricingData.overrides[model];\n const effective = override || pricingData.builtin[model];\n const tr = document.createElement('tr');\n\n const nameTd = document.createElement('td');\n nameTd.className = 'pricing-model';\n nameTd.textContent = model;\n tr.appendChild(nameTd);\n\n const cells = [];\n const commit = async function () {\n const rates = {\n inputPerMTok: parseFloat(cells[0].input.value),\n outputPerMTok: parseFloat(cells[1].input.value),\n cacheReadPerMTok: parseFloat(cells[2].input.value),\n cacheWritePerMTok: parseFloat(cells[3].input.value),\n };\n for (const k in rates) { if (!isFinite(rates[k]) || rates[k] < 0) return; }\n if (await putPricing(model, rates)) await loadPricing();\n };\n ['inputPerMTok', 'outputPerMTok', 'cacheReadPerMTok', 'cacheWritePerMTok'].forEach(function (key) {\n const cell = rateCell(effective[key], commit);\n cells.push(cell);\n tr.appendChild(cell.td);\n });\n\n const badgeTd = document.createElement('td');\n const badge = document.createElement('span');\n badge.className = 'pricing-badge ' + (override ? 'badge-override' : 'badge-builtin');\n badge.textContent = override ? 'Override' : 'Built-in';\n badgeTd.appendChild(badge);\n tr.appendChild(badgeTd);\n\n const actionTd = document.createElement('td');\n if (override) {\n const btn = document.createElement('button');\n btn.className = 'reset-btn';\n btn.textContent = pricingData.builtin[model] ? 'Reset' : 'Remove';\n btn.addEventListener('click', function () { removePricing(model); });\n actionTd.appendChild(btn);\n }\n tr.appendChild(actionTd);\n\n tbody.appendChild(tr);\n }\n}\n\nasync function addPricingModel() {\n const name = document.getElementById('newModelName').value.trim();\n const inputRate = parseFloat(document.getElementById('newModelInput').value);\n const outputRate = parseFloat(document.getElementById('newModelOutput').value);\n if (!name) { alert('Enter a model id'); return; }\n if (!isFinite(inputRate) || !isFinite(outputRate)) { alert('Enter input and output rates (USD per million tokens)'); return; }\n const rates = { inputPerMTok: inputRate, outputPerMTok: outputRate };\n const cacheRead = parseFloat(document.getElementById('newModelCacheRead').value);\n const cacheWrite = parseFloat(document.getElementById('newModelCacheWrite').value);\n if (isFinite(cacheRead)) rates.cacheReadPerMTok = cacheRead;\n if (isFinite(cacheWrite)) rates.cacheWritePerMTok = cacheWrite;\n if (await putPricing(name, rates)) {\n ['newModelName', 'newModelInput', 'newModelOutput', 'newModelCacheRead', 'newModelCacheWrite'].forEach(function (id) {\n document.getElementById(id).value = '';\n });\n await loadPricing();\n }\n}\n\nasync function loadRouting() {\n const res = await fetch('/settings/api/routing');\n const cfg = await res.json();\n const el = document.getElementById('routing-body');\n const envNote = (on) => on ? ' <span style=\"font-size:11px;color:var(--yellow)\">(env override active \u2014 setting saved but env wins)</span>' : '';\n let h = '<div style=\"display:flex;align-items:center;gap:12px;margin-bottom:14px\">'\n + '<label style=\"color:var(--muted);font-size:13px;width:90px\">Mode</label>'\n + '<select id=\"routing-mode\" style=\"background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px\">'\n + ['active','sticky','priority'].map(m => '<option value=\"'+m+'\"'+(cfg.routing===m?' selected':'')+'>'+m+'</option>').join('')\n + '</select>' + envNote(cfg.envOverride.routing) + '</div>';\n h += '<div id=\"routing-order-wrap\" style=\"'+(cfg.routing==='priority'?'':'display:none')+'\">'\n + '<div style=\"color:var(--muted);font-size:13px;margin-bottom:8px\">Pool order \u2014 highest priority first. Drained top to bottom.'\n + envNote(cfg.envOverride.profileOrder) + '</div>'\n + '<ol id=\"routing-order\" style=\"margin:0;padding-left:22px\">'\n + cfg.profileOrder.map((id, i) =>\n '<li style=\"padding:4px 0;color:var(--text)\">'\n + '<span style=\"font-family:var(--mono, monospace)\">'+id+'</span>'\n + ' <button data-move=\"up\" data-i=\"'+i+'\" style=\"margin-left:10px;background:var(--surface);color:var(--muted);border:1px solid var(--border);border-radius:4px;padding:1px 8px;cursor:pointer\"'+(i===0?' disabled':'')+'>&uarr;</button>'\n + ' <button data-move=\"down\" data-i=\"'+i+'\" style=\"background:var(--surface);color:var(--muted);border:1px solid var(--border);border-radius:4px;padding:1px 8px;cursor:pointer\"'+(i===cfg.profileOrder.length-1?' disabled':'')+'>&darr;</button>'\n + '</li>').join('')\n + '</ol></div>';\n el.innerHTML = h;\n document.getElementById('routing-mode').addEventListener('change', async (e) => {\n await fetch('/settings/api/routing', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routing: e.target.value }) });\n await loadRouting();\n });\n el.querySelectorAll('button[data-move]').forEach(btn => btn.addEventListener('click', async () => {\n const i = Number(btn.dataset.i);\n const j = btn.dataset.move === 'up' ? i - 1 : i + 1;\n const order = cfg.profileOrder.slice();\n const tmp = order[i]; order[i] = order[j]; order[j] = tmp;\n await fetch('/settings/api/routing', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileOrder: order }) });\n await loadRouting();\n }));\n}\n\nloadConfig();\nloadPricing();\nloadRouting();\n\n(function() {\n var profileChip = document.getElementById('mhProfile');\n var statusDot = document.getElementById('mhDot');\n var statusText = document.getElementById('mhStatusText');\n\n // Highlight active nav link\n var path = location.pathname;\n var navLinks = document.querySelectorAll('.mh-nav a');\n navLinks.forEach(function(a) {\n if (a.getAttribute('href') === path || (path === '/telemetry' && a.id === 'nav-telemetry') || (path === '/' && a.id === 'nav-home')) {\n a.classList.add('active');\n }\n });\n\n function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }\n\n function loadHeader() {\n fetch('/health').then(function(r) { return r.json(); }).then(function(h) {\n var st = h.status === 'healthy' ? 'healthy' : h.status === 'degraded' ? 'degraded' : 'unhealthy';\n statusDot.className = 'mh-dot ' + st;\n statusText.textContent = st === 'healthy' ? 'Operational' : st === 'degraded' ? 'Degraded' : 'Offline';\n }).catch(function() {\n statusDot.className = 'mh-dot unhealthy';\n statusText.textContent = 'Offline';\n });\n\n fetch('/profiles/list').then(function(r) { return r.json(); }).then(function(data) {\n var current = (data.profiles || []).find(function(p) { return p.isActive; });\n if (!current) { profileChip.classList.remove('visible'); return; }\n profileChip.innerHTML = esc(current.id) + ' <span class=\"mh-profile-type\">' + esc(current.type || '') + '</span>';\n profileChip.classList.add('visible');\n }).catch(function() {});\n }\n\n loadHeader();\n setInterval(loadHeader, 10000);\n // Pages call this after mutating state (e.g. switching the active profile)\n // so the header chip updates immediately instead of on the next poll.\n window.meridianHeaderRefresh = loadHeader;\n})();\n\n</script>\n</body>\n</html>";
5
+ export declare const settingsPageHtml = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Meridian \u2014 SDK Features</title>\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/telemetry/icon.svg\">\n<style>\n \n :root {\n /* Cool-gray neutral palette. High contrast, surface/border separation,\n no color cast muddying the text. Blue is the primary accent; violet\n is the fixed secondary \u2014 used together in the brand gradient and\n individually for hover states and a handful of telemetry badges. */\n --bg: #0d1117;\n --surface: #161b22;\n --surface2: #1c2128;\n --border: #30363d;\n /* Text */\n --text: #e6edf3;\n --muted: #8b949e;\n /* Brand \u2014 blue primary, violet secondary */\n --accent: #58a6ff;\n --accent2: #bc8cff;\n --violet: #bc8cff;\n --lavender: #d2a8ff;\n /* Semantic */\n --green: #3fb950;\n --yellow: #d29922;\n --red: #f85149;\n /* Telemetry-specific aliases (waterfall + lineage badges) */\n --blue: #58a6ff;\n --purple: #bc8cff;\n --queue: #d29922;\n --ttfb: #58a6ff;\n --upstream: #3fb950;\n }\n /* Banner backsplash \u2014 the brand look: a gentle diagonal wash with soft\n blue (top-left) and violet (bottom-right) glows. Pages must not set\n their own body background so this shows through everywhere. */\n body {\n background:\n radial-gradient(1200px 800px at 12% -8%, rgba(88,166,255,0.07), transparent 60%),\n radial-gradient(1100px 800px at 92% 108%, rgba(188,140,255,0.06), transparent 60%),\n linear-gradient(135deg, #0d1117 0%, #10151d 55%, #161b22 100%);\n background-attachment: fixed;\n background-color: var(--bg);\n }\n\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;\n color: var(--text); padding: 0; line-height: 1.5; }\n \n .meridian-header {\n position: sticky; top: 0; z-index: 100;\n display: flex; align-items: center; gap: 20px;\n padding: 10px 24px;\n background: rgba(13, 17, 23, 0.92);\n backdrop-filter: blur(12px);\n border-bottom: 1px solid var(--border, #30363d);\n }\n .meridian-header .mh-brand {\n display: flex; align-items: center; gap: 10px;\n text-decoration: none; color: var(--text, #e6edf3);\n }\n .meridian-header .mh-logo { display: block; }\n .meridian-header .mh-name {\n font-size: 15px; font-weight: 700; letter-spacing: 2px;\n text-transform: uppercase;\n }\n .meridian-header .mh-nav { display: flex; align-items: center; gap: 2px; }\n .meridian-header .mh-nav a {\n color: var(--muted, #8b949e); text-decoration: none; font-size: 12px;\n font-weight: 500; padding: 5px 10px; border-radius: 6px;\n transition: color 0.15s, background 0.15s;\n }\n .meridian-header .mh-nav a:hover { color: var(--text, #e6edf3); background: var(--surface, #161b22); }\n .meridian-header .mh-nav a.active { color: var(--accent, #58a6ff); background: var(--surface, #161b22); }\n .meridian-header .mh-right {\n margin-left: auto; display: flex; align-items: center; gap: 10px;\n }\n .meridian-header .mh-profile {\n display: none; align-items: center; gap: 6px;\n font-size: 11px; font-weight: 500; color: var(--text, #e6edf3);\n padding: 3px 10px; border-radius: 20px;\n background: var(--surface, #161b22); border: 1px solid var(--border, #30363d);\n text-decoration: none; transition: border-color 0.15s;\n }\n .meridian-header .mh-profile:hover { border-color: var(--accent, #58a6ff); }\n .meridian-header .mh-profile.visible { display: inline-flex; }\n .meridian-header .mh-profile .mh-profile-type {\n color: var(--muted, #8b949e); font-size: 10px;\n }\n .meridian-header .mh-status {\n display: inline-flex; align-items: center; gap: 6px;\n font-size: 11px; color: var(--muted, #8b949e); white-space: nowrap;\n }\n .meridian-header .mh-dot {\n width: 8px; height: 8px; border-radius: 50%;\n background: var(--muted, #8b949e); flex-shrink: 0;\n }\n .meridian-header .mh-dot.healthy { background: var(--green, #3fb950); box-shadow: 0 0 6px rgba(63,185,80,0.5); }\n .meridian-header .mh-dot.degraded { background: var(--yellow, #d29922); }\n .meridian-header .mh-dot.unhealthy { background: var(--red, #f85149); }\n @media (max-width: 720px) {\n .meridian-header { gap: 10px; padding: 10px 16px; flex-wrap: wrap; }\n .meridian-header .mh-name { display: none; }\n .meridian-header .mh-status .mh-status-text { display: none; }\n }\n\n .content { max-width: 900px; margin: 0 auto; padding: 24px; }\n h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }\n .subtitle { color: var(--muted); font-size: 13px; margin-bottom: 24px; }\n .nav { display: flex; gap: 16px; margin-bottom: 24px; font-size: 13px; }\n .nav a { color: var(--muted); text-decoration: none; }\n .nav a:hover { color: var(--accent); }\n .nav a.active { color: var(--accent); }\n\n .adapter-card {\n background: var(--surface); border: 1px solid var(--border); border-radius: 8px;\n padding: 20px; margin-bottom: 16px;\n }\n .adapter-header {\n display: flex; align-items: center; justify-content: space-between;\n margin-bottom: 16px;\n }\n .adapter-name { font-size: 16px; font-weight: 600; }\n .adapter-badge {\n font-size: 10px; padding: 2px 8px; border-radius: 10px;\n text-transform: uppercase; letter-spacing: 0.5px;\n }\n .badge-active { background: rgba(63, 185, 80, 0.15); color: var(--green); }\n .badge-inactive { background: rgba(139, 148, 158, 0.15); color: var(--muted); }\n\n .feature-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }\n @media (max-width: 600px) { .feature-grid { grid-template-columns: 1fr; } }\n\n .feature-row {\n display: flex; align-items: center; justify-content: space-between;\n padding: 10px 14px; border-radius: 6px;\n background: var(--bg); border: 1px solid var(--border);\n }\n .feature-info { display: flex; flex-direction: column; }\n .feature-label { font-size: 13px; font-weight: 500; }\n .feature-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }\n\n /* Toggle switch */\n .toggle { position: relative; width: 36px; height: 20px; flex-shrink: 0; }\n .toggle input { opacity: 0; width: 0; height: 0; }\n .toggle-track {\n position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;\n background: var(--border); border-radius: 10px; transition: background 0.2s;\n }\n .toggle-track::after {\n content: \"\"; position: absolute; height: 14px; width: 14px;\n left: 3px; bottom: 3px; background: var(--muted); border-radius: 50%;\n transition: transform 0.2s, background 0.2s;\n }\n .toggle input:checked + .toggle-track { background: var(--accent); }\n .toggle input:checked + .toggle-track::after {\n transform: translateX(16px); background: var(--text);\n }\n\n /* Select dropdown */\n .feature-select {\n background: var(--surface); color: var(--text); border: 1px solid var(--border);\n border-radius: 6px; padding: 4px 8px; font-size: 12px; cursor: pointer;\n }\n\n .save-indicator {\n position: fixed; bottom: 24px; right: 24px;\n background: var(--green); color: #000; padding: 8px 16px;\n border-radius: 6px; font-size: 13px; font-weight: 500;\n opacity: 0; transition: opacity 0.3s; pointer-events: none;\n }\n .save-indicator.visible { opacity: 1; }\n\n .reset-btn {\n background: none; border: 1px solid var(--border); color: var(--muted);\n border-radius: 6px; padding: 4px 12px; font-size: 11px; cursor: pointer;\n }\n .reset-btn:hover { border-color: var(--red); color: var(--red); }\n\n /* Model pricing */\n .pricing-table { width: 100%; border-collapse: collapse; font-size: 12px; }\n .pricing-table th { text-align: left; padding: 8px 10px; color: var(--muted); font-weight: 500;\n font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }\n .pricing-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); }\n .pricing-table tr:last-child td { border-bottom: none; }\n .pricing-model { font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; font-size: 12px; word-break: break-all; }\n .pricing-input { background: var(--bg); color: var(--text); border: 1px solid var(--border);\n border-radius: 6px; padding: 4px 8px; font-size: 12px; width: 84px; text-align: right;\n font-variant-numeric: tabular-nums; }\n .pricing-input:focus { border-color: var(--accent); outline: none; }\n .pricing-badge { font-size: 10px; padding: 2px 8px; border-radius: 10px;\n text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }\n .badge-override { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }\n .badge-builtin { background: rgba(139, 148, 158, 0.15); color: var(--muted); }\n .pricing-add { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 14px;\n padding-top: 14px; border-top: 1px solid var(--border); }\n .pricing-add input[type=\"text\"] { width: 240px; text-align: left; }\n .add-btn { background: var(--accent); border: none; color: #fff; border-radius: 6px;\n padding: 5px 14px; font-size: 12px; font-weight: 500; cursor: pointer; }\n .pricing-note { font-size: 11px; color: var(--muted); margin-top: 12px; line-height: 1.6; }\n</style>\n</head>\n<body>\n\n<header class=\"meridian-header\" id=\"meridianHeader\">\n <a class=\"mh-brand\" href=\"/\">\n <svg class=\"mh-logo\" width=\"24\" height=\"24\" viewBox=\"0 0 64 64\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\">\n <defs>\n <linearGradient id=\"mhGrad\" x1=\"32\" y1=\"4\" x2=\"32\" y2=\"60\" gradientUnits=\"userSpaceOnUse\">\n <stop stop-color=\"#58a6ff\"/>\n <stop offset=\"1\" stop-color=\"#bc8cff\"/>\n </linearGradient>\n </defs>\n <circle cx=\"32\" cy=\"32\" r=\"25\" stroke=\"url(#mhGrad)\" stroke-width=\"3.5\"/>\n <ellipse cx=\"32\" cy=\"32\" rx=\"10.5\" ry=\"25\" stroke=\"url(#mhGrad)\" stroke-width=\"2.5\" opacity=\"0.8\"/>\n <path d=\"M7 32h50\" stroke=\"url(#mhGrad)\" stroke-width=\"2\" opacity=\"0.4\"/>\n <circle cx=\"32\" cy=\"7\" r=\"4.5\" fill=\"#58a6ff\"/>\n <circle cx=\"32\" cy=\"57\" r=\"4.5\" fill=\"#bc8cff\"/>\n</svg>\n <span class=\"mh-name\">Meridian</span>\n </a>\n <nav class=\"mh-nav\">\n <a href=\"/\" id=\"nav-home\">Home</a>\n <a href=\"/telemetry\" id=\"nav-telemetry\">Telemetry</a>\n <a href=\"/profiles\" id=\"nav-profiles\">Profiles</a>\n <a href=\"/settings\" id=\"nav-settings\">Settings</a>\n <a href=\"/plugins\" id=\"nav-plugins\">Plugins</a>\n </nav>\n <div class=\"mh-right\">\n <a class=\"mh-profile\" id=\"mhProfile\" href=\"/\" title=\"Active profile \u2014 switch from the home page\"></a>\n <span class=\"mh-status\" id=\"mhStatus\"><span class=\"mh-dot\" id=\"mhDot\"></span><span class=\"mh-status-text\" id=\"mhStatusText\"></span></span>\n </div>\n</header>\n\n<div class=\"content\">\n <h1>SDK Features <span style=\"font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(210,153,34,0.15);color:var(--yellow);vertical-align:middle;margin-left:8px\">Experimental</span></h1>\n <p class=\"subtitle\" style=\"max-width:720px;line-height:1.6\">\n Unlock Claude Code features for any connected agent. Capabilities like auto-memory, dreaming, and CLAUDE.md \u2014 normally\n exclusive to Claude Code \u2014 become available to OpenCode, Crush, Droid, and any other harness routed through Meridian.\n Each agent keeps its own toolchain while gaining access to these additional features.<br><br>\n <strong style=\"color:var(--text)\">System prompts:</strong> For these features to work correctly, both the Claude Code prompt and your client prompt\n should be enabled. When both are active, they are appended together \u2014 Claude Code's base instructions come first,\n followed by your agent's specific instructions.\n </p>\n\n <div id=\"adapters\"></div>\n\n <h1 style=\"margin-top:40px\">Routing</h1>\n <p class=\"subtitle\" style=\"max-width:720px;line-height:1.6\">\n How unpinned requests choose an account. <strong style=\"color:var(--text)\">Active</strong> uses the manually\n selected profile. <strong style=\"color:var(--text)\">Sticky</strong> distributes sessions across profiles evenly\n (cache-affine). <strong style=\"color:var(--text)\">Priority</strong> drains the pool in order \u2014 highest first \u2014\n and fails over per request when an account runs out; conversations keep their account, and new sessions\n return to the preferred account after its window resets. An explicit <code>x-meridian-profile</code> header\n always overrides. Changes apply to the next request \u2014 no restart needed.\n </p>\n <div class=\"adapter-card\" id=\"routing-card\">\n <div id=\"routing-body\">Loading\u2026</div>\n </div>\n\n <h1 style=\"margin-top:40px\">Model Pricing</h1>\n <p class=\"subtitle\" style=\"max-width:720px;line-height:1.6\">\n Rates used by the telemetry cost estimate, in USD per million tokens. Edit a value to override the\n built-in rate, or add models the built-in table doesn't know about (they show as \"no pricing\" on the\n dashboard until defined here). Changes apply on the next dashboard refresh.\n </p>\n <div class=\"adapter-card\">\n <table class=\"pricing-table\">\n <thead><tr><th>Model</th><th>Input</th><th>Output</th><th>Cache Read</th><th>Cache Write</th><th>Source</th><th></th></tr></thead>\n <tbody id=\"pricingRows\"></tbody>\n </table>\n <div class=\"pricing-add\">\n <input type=\"text\" class=\"pricing-input\" id=\"newModelName\" placeholder=\"model id (e.g. claude-opus-9)\">\n <input type=\"number\" class=\"pricing-input\" id=\"newModelInput\" placeholder=\"input\" min=\"0\" step=\"0.01\">\n <input type=\"number\" class=\"pricing-input\" id=\"newModelOutput\" placeholder=\"output\" min=\"0\" step=\"0.01\">\n <input type=\"number\" class=\"pricing-input\" id=\"newModelCacheRead\" placeholder=\"cache read\" min=\"0\" step=\"0.01\">\n <input type=\"number\" class=\"pricing-input\" id=\"newModelCacheWrite\" placeholder=\"cache write\" min=\"0\" step=\"0.01\">\n <button class=\"add-btn\" onclick=\"addPricingModel()\">Add Model</button>\n </div>\n <div class=\"pricing-note\">\n Cache read and cache write are optional; when left blank they default to 0.1x and 1.25x of the\n input rate (the 5-minute cache TTL multipliers). Verify current list prices at\n <a href=\"https://claude.com/pricing\" target=\"_blank\" rel=\"noreferrer\" style=\"color:var(--accent)\">claude.com/pricing</a>.\n </div>\n </div>\n</div>\n\n<div class=\"save-indicator\" id=\"saveIndicator\">Saved</div>\n\n<script>\nconst FEATURES = [\n { key: 'codeSystemPrompt', label: 'Claude Code Prompt', desc: 'Include the built-in Claude Code system prompt (tool usage rules, safety guidelines, coding best practices)', type: 'toggle' },\n { key: 'clientSystemPrompt', label: 'Client Prompt', desc: 'Include the system prompt sent by the connecting agent (e.g. OpenCode or Crush instructions)', type: 'toggle' },\n { key: 'claudeMd', label: 'CLAUDE.md', desc: 'Load CLAUDE.md instruction files \u2014 Off: none, Project: ./CLAUDE.md only, Full: ~/.claude/CLAUDE.md + ./CLAUDE.md', type: 'select', options: ['off', 'project', 'full'] },\n { key: 'memory', label: 'Memory', desc: 'Read and write memories across sessions', type: 'toggle' },\n { key: 'dreaming', label: 'Auto-Dream', desc: 'Background memory consolidation', type: 'toggle' },\n { key: 'thinking', label: 'Thinking', desc: 'Extended thinking mode', type: 'select', options: ['disabled', 'adaptive', 'enabled'] },\n { key: 'thinkingPassthrough', label: 'Thinking Passthrough', desc: 'Forward thinking blocks to the client', type: 'toggle' },\n { key: 'sharedMemory', label: 'Shared Memory', desc: 'Share memory with Claude Code (~/.claude) instead of isolated storage', type: 'toggle' },\n { key: 'webFetchPreflight', label: 'WebFetch Preflight', desc: 'Check each WebFetch hostname against the Anthropic blocklist before fetching \u2014 off keeps hostnames local but fetches any URL unchecked. Only affects Cherry Studio; other adapters never run the SDK\\'s built-in WebFetch', type: 'toggle' },\n { key: 'claudeAiConnectors', label: 'claude.ai Connectors', desc: 'Load the MCP connectors attached to your claude.ai account (Drive, Gmail, Calendar). Off by default; always off in passthrough mode', type: 'toggle' },\n { key: 'maxBudgetUsd', label: 'Max Budget (USD)', desc: 'Per-request cost cap \u2014 query aborts if exceeded (0 = disabled)', type: 'number' },\n { key: 'fallbackModel', label: 'Fallback Model', desc: 'Auto-fallback model if primary fails', type: 'select', options: ['', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]'] },\n { key: 'sdkDebug', label: 'SDK Debug Logging', desc: 'Enable verbose SDK debug output to proxy stderr', type: 'toggle' },\n { key: 'additionalDirectories', label: 'Additional Directories', desc: 'Comma-separated extra paths Claude can access (monorepo libs, etc.)', type: 'text' },\n];\n\n// Display names only \u2014 NOT the list of adapters to render. The page renders\n// whatever /settings/api/features returns, so a new adapter shows up here the\n// moment it exists, using its raw name until someone gives it a pretty one.\n// A hardcoded list is what hid codex and cherry from this page entirely.\nconst ADAPTER_LABELS = {\n opencode: 'OpenCode',\n openai: 'OpenAI (/v1/chat/completions)',\n crush: 'Crush',\n forgecode: 'ForgeCode',\n pi: 'Pi',\n droid: 'Droid',\n passthrough: 'LiteLLM / Passthrough',\n codex: 'Codex CLI (/v1/responses)',\n cherry: 'Cherry Studio',\n 'claude-code': 'Claude Code',\n};\n\nlet currentConfig = {};\n\nasync function loadConfig() {\n const res = await fetch('/settings/api/features');\n currentConfig = await res.json();\n render();\n}\n\nasync function saveFeature(adapter, key, value) {\n const patch = {};\n patch[key] = value;\n await fetch('/settings/api/features/' + adapter, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(patch),\n });\n currentConfig[adapter][key] = value;\n showSaved();\n}\n\nasync function resetAdapter(adapter) {\n await fetch('/settings/api/features/' + adapter, { method: 'DELETE' });\n await loadConfig();\n showSaved();\n}\n\nfunction showSaved() {\n const el = document.getElementById('saveIndicator');\n el.classList.add('visible');\n setTimeout(() => el.classList.remove('visible'), 1500);\n}\n\nfunction hasAnyEnabled(features) {\n return features.codeSystemPrompt || !features.clientSystemPrompt || features.claudeMd !== 'off' || features.memory || features.dreaming ||\n features.thinking !== 'disabled' || features.thinkingPassthrough ||\n features.sharedMemory || features.webFetchPreflight === false ||\n features.claudeAiConnectors || features.maxBudgetUsd > 0 ||\n features.fallbackModel || features.sdkDebug ||\n features.additionalDirectories;\n}\n\nfunction render() {\n const container = document.getElementById('adapters');\n container.innerHTML = '';\n\n for (const adapter of Object.keys(currentConfig)) {\n const label = ADAPTER_LABELS[adapter] || adapter;\n const features = currentConfig[adapter] || {};\n const active = hasAnyEnabled(features);\n\n const card = document.createElement('div');\n card.className = 'adapter-card';\n card.innerHTML = '<div class=\"adapter-header\">' +\n '<span class=\"adapter-name\">' + label + '</span>' +\n '<div style=\"display:flex;gap:8px;align-items:center\">' +\n '<span class=\"adapter-badge ' + (active ? 'badge-active' : 'badge-inactive') + '\">' +\n (active ? 'Active' : 'Default') +\n '</span>' +\n '<button class=\"reset-btn\" onclick=\"resetAdapter(\\''+adapter+'\\')\">Reset</button>' +\n '</div>' +\n '</div>';\n\n const grid = document.createElement('div');\n grid.className = 'feature-grid';\n\n for (const feat of FEATURES) {\n const row = document.createElement('div');\n row.className = 'feature-row';\n\n const info = '<div class=\"feature-info\"><span class=\"feature-label\">' +\n feat.label + '</span><span class=\"feature-desc\">' + feat.desc + '</span></div>';\n\n if (feat.type === 'toggle') {\n const checked = features[feat.key] ? 'checked' : '';\n row.innerHTML = info +\n '<label class=\"toggle\"><input type=\"checkbox\" ' + checked +\n ' onchange=\"saveFeature(\\''+adapter+'\\', \\''+feat.key+'\\', this.checked)\">' +\n '<span class=\"toggle-track\"></span></label>';\n } else if (feat.type === 'select') {\n const options = feat.options.map(o => {\n const label = o === '' ? '(None)' : o.charAt(0).toUpperCase()+o.slice(1);\n return '<option value=\"'+o+'\"'+(features[feat.key]===o?' selected':'')+'>'+label+'</option>';\n }).join('');\n row.innerHTML = info +\n '<select class=\"feature-select\" onchange=\"saveFeature(\\''+adapter+'\\', \\''+feat.key+'\\', this.value)\">' +\n options + '</select>';\n } else if (feat.type === 'number') {\n const value = features[feat.key] ?? 0;\n row.innerHTML = info +\n '<input type=\"number\" class=\"feature-select\" style=\"width:80px;text-align:right\" min=\"0\" step=\"0.01\" value=\"'+value+'\"' +\n ' onchange=\"saveFeature(\\''+adapter+'\\', \\''+feat.key+'\\', parseFloat(this.value)||0)\">';\n } else if (feat.type === 'text') {\n const value = (features[feat.key] ?? '').toString().replace(/\"/g, '&quot;');\n row.innerHTML = info +\n '<input type=\"text\" class=\"feature-select\" style=\"width:180px\" value=\"'+value+'\"' +\n ' onchange=\"saveFeature(\\''+adapter+'\\', \\''+feat.key+'\\', this.value)\">';\n }\n\n grid.appendChild(row);\n }\n\n card.appendChild(grid);\n container.appendChild(card);\n }\n}\n\n// ---- Model pricing (telemetry cost estimate) ----\nlet pricingData = { builtin: {}, overrides: {} };\n\nfunction fmtRate(v) { return String(Math.round(v * 10000) / 10000); }\n\nasync function loadPricing() {\n const res = await fetch('/settings/api/pricing');\n pricingData = await res.json();\n renderPricing();\n}\n\nasync function putPricing(model, rates) {\n const res = await fetch('/settings/api/pricing/' + encodeURIComponent(model), {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(rates),\n });\n if (!res.ok) {\n const err = await res.json().catch(function () { return {}; });\n alert('Could not save pricing: ' + (err.error || ('HTTP ' + res.status)));\n return false;\n }\n showSaved();\n return true;\n}\n\nasync function removePricing(model) {\n await fetch('/settings/api/pricing/' + encodeURIComponent(model), { method: 'DELETE' });\n showSaved();\n await loadPricing();\n}\n\nfunction rateCell(value, onCommit) {\n const td = document.createElement('td');\n const input = document.createElement('input');\n input.type = 'number';\n input.min = '0';\n input.step = '0.01';\n input.className = 'pricing-input';\n input.value = fmtRate(value);\n input.addEventListener('change', onCommit);\n td.appendChild(input);\n return { td: td, input: input };\n}\n\nfunction renderPricing() {\n const tbody = document.getElementById('pricingRows');\n tbody.innerHTML = '';\n const models = Array.from(new Set(\n Object.keys(pricingData.builtin).concat(Object.keys(pricingData.overrides))\n )).sort();\n\n for (const model of models) {\n const override = pricingData.overrides[model];\n const effective = override || pricingData.builtin[model];\n const tr = document.createElement('tr');\n\n const nameTd = document.createElement('td');\n nameTd.className = 'pricing-model';\n nameTd.textContent = model;\n tr.appendChild(nameTd);\n\n const cells = [];\n const commit = async function () {\n const rates = {\n inputPerMTok: parseFloat(cells[0].input.value),\n outputPerMTok: parseFloat(cells[1].input.value),\n cacheReadPerMTok: parseFloat(cells[2].input.value),\n cacheWritePerMTok: parseFloat(cells[3].input.value),\n };\n for (const k in rates) { if (!isFinite(rates[k]) || rates[k] < 0) return; }\n if (await putPricing(model, rates)) await loadPricing();\n };\n ['inputPerMTok', 'outputPerMTok', 'cacheReadPerMTok', 'cacheWritePerMTok'].forEach(function (key) {\n const cell = rateCell(effective[key], commit);\n cells.push(cell);\n tr.appendChild(cell.td);\n });\n\n const badgeTd = document.createElement('td');\n const badge = document.createElement('span');\n badge.className = 'pricing-badge ' + (override ? 'badge-override' : 'badge-builtin');\n badge.textContent = override ? 'Override' : 'Built-in';\n badgeTd.appendChild(badge);\n tr.appendChild(badgeTd);\n\n const actionTd = document.createElement('td');\n if (override) {\n const btn = document.createElement('button');\n btn.className = 'reset-btn';\n btn.textContent = pricingData.builtin[model] ? 'Reset' : 'Remove';\n btn.addEventListener('click', function () { removePricing(model); });\n actionTd.appendChild(btn);\n }\n tr.appendChild(actionTd);\n\n tbody.appendChild(tr);\n }\n}\n\nasync function addPricingModel() {\n const name = document.getElementById('newModelName').value.trim();\n const inputRate = parseFloat(document.getElementById('newModelInput').value);\n const outputRate = parseFloat(document.getElementById('newModelOutput').value);\n if (!name) { alert('Enter a model id'); return; }\n if (!isFinite(inputRate) || !isFinite(outputRate)) { alert('Enter input and output rates (USD per million tokens)'); return; }\n const rates = { inputPerMTok: inputRate, outputPerMTok: outputRate };\n const cacheRead = parseFloat(document.getElementById('newModelCacheRead').value);\n const cacheWrite = parseFloat(document.getElementById('newModelCacheWrite').value);\n if (isFinite(cacheRead)) rates.cacheReadPerMTok = cacheRead;\n if (isFinite(cacheWrite)) rates.cacheWritePerMTok = cacheWrite;\n if (await putPricing(name, rates)) {\n ['newModelName', 'newModelInput', 'newModelOutput', 'newModelCacheRead', 'newModelCacheWrite'].forEach(function (id) {\n document.getElementById(id).value = '';\n });\n await loadPricing();\n }\n}\n\nasync function loadRouting() {\n const res = await fetch('/settings/api/routing');\n const cfg = await res.json();\n const el = document.getElementById('routing-body');\n const envNote = (on) => on ? ' <span style=\"font-size:11px;color:var(--yellow)\">(env override active \u2014 setting saved but env wins)</span>' : '';\n let h = '<div style=\"display:flex;align-items:center;gap:12px;margin-bottom:14px\">'\n + '<label style=\"color:var(--muted);font-size:13px;width:90px\">Mode</label>'\n + '<select id=\"routing-mode\" style=\"background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px\">'\n + ['active','sticky','priority'].map(m => '<option value=\"'+m+'\"'+(cfg.routing===m?' selected':'')+'>'+m+'</option>').join('')\n + '</select>' + envNote(cfg.envOverride.routing) + '</div>';\n h += '<div id=\"routing-order-wrap\" style=\"'+(cfg.routing==='priority'?'':'display:none')+'\">'\n + '<div style=\"color:var(--muted);font-size:13px;margin-bottom:8px\">Pool order \u2014 highest priority first. Drained top to bottom.'\n + envNote(cfg.envOverride.profileOrder) + '</div>'\n + '<ol id=\"routing-order\" style=\"margin:0;padding-left:22px\">'\n + cfg.profileOrder.map((id, i) =>\n '<li style=\"padding:4px 0;color:var(--text)\">'\n + '<span style=\"font-family:var(--mono, monospace)\">'+id+'</span>'\n + ' <button data-move=\"up\" data-i=\"'+i+'\" style=\"margin-left:10px;background:var(--surface);color:var(--muted);border:1px solid var(--border);border-radius:4px;padding:1px 8px;cursor:pointer\"'+(i===0?' disabled':'')+'>&uarr;</button>'\n + ' <button data-move=\"down\" data-i=\"'+i+'\" style=\"background:var(--surface);color:var(--muted);border:1px solid var(--border);border-radius:4px;padding:1px 8px;cursor:pointer\"'+(i===cfg.profileOrder.length-1?' disabled':'')+'>&darr;</button>'\n + '</li>').join('')\n + '</ol></div>';\n el.innerHTML = h;\n document.getElementById('routing-mode').addEventListener('change', async (e) => {\n await fetch('/settings/api/routing', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routing: e.target.value }) });\n await loadRouting();\n });\n el.querySelectorAll('button[data-move]').forEach(btn => btn.addEventListener('click', async () => {\n const i = Number(btn.dataset.i);\n const j = btn.dataset.move === 'up' ? i - 1 : i + 1;\n const order = cfg.profileOrder.slice();\n const tmp = order[i]; order[i] = order[j]; order[j] = tmp;\n await fetch('/settings/api/routing', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileOrder: order }) });\n await loadRouting();\n }));\n}\n\nloadConfig();\nloadPricing();\nloadRouting();\n\n(function() {\n var profileChip = document.getElementById('mhProfile');\n var statusDot = document.getElementById('mhDot');\n var statusText = document.getElementById('mhStatusText');\n\n // Highlight active nav link\n var path = location.pathname;\n var navLinks = document.querySelectorAll('.mh-nav a');\n navLinks.forEach(function(a) {\n if (a.getAttribute('href') === path || (path === '/telemetry' && a.id === 'nav-telemetry') || (path === '/' && a.id === 'nav-home')) {\n a.classList.add('active');\n }\n });\n\n function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }\n\n function loadHeader() {\n fetch('/health').then(function(r) { return r.json(); }).then(function(h) {\n var st = h.status === 'healthy' ? 'healthy' : h.status === 'degraded' ? 'degraded' : 'unhealthy';\n statusDot.className = 'mh-dot ' + st;\n statusText.textContent = st === 'healthy' ? 'Operational' : st === 'degraded' ? 'Degraded' : 'Offline';\n }).catch(function() {\n statusDot.className = 'mh-dot unhealthy';\n statusText.textContent = 'Offline';\n });\n\n fetch('/profiles/list').then(function(r) { return r.json(); }).then(function(data) {\n var current = (data.profiles || []).find(function(p) { return p.isActive; });\n if (!current) { profileChip.classList.remove('visible'); return; }\n profileChip.innerHTML = esc(current.id) + ' <span class=\"mh-profile-type\">' + esc(current.type || '') + '</span>';\n profileChip.classList.add('visible');\n }).catch(function() {});\n }\n\n loadHeader();\n setInterval(loadHeader, 10000);\n // Pages call this after mutating state (e.g. switching the active profile)\n // so the header chip updates immediately instead of on the next poll.\n window.meridianHeaderRefresh = loadHeader;\n})();\n\n</script>\n</body>\n</html>";
6
6
  //# sourceMappingURL=settingsPage.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"settingsPage.d.ts","sourceRoot":"","sources":["../../src/telemetry/settingsPage.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,eAAO,MAAM,gBAAgB,kj9BAkdrB,CAAA"}
1
+ {"version":3,"file":"settingsPage.d.ts","sourceRoot":"","sources":["../../src/telemetry/settingsPage.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,eAAO,MAAM,gBAAgB,yz9BAodrB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynfar/meridian",
3
- "version": "1.59.0",
3
+ "version": "1.61.0",
4
4
  "description": "Local Anthropic API powered by your Claude Max subscription. One subscription, every agent.",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",