@oar-sh/oar 0.9.1 → 0.9.3

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 (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -0,0 +1,182 @@
1
+ // The canonical order of the Copilot model catalog.
2
+ //
3
+ // The runtime's `rpc.model.list()` comes back in no useful order (the live
4
+ // 2026-09-07 list interleaves gpt-6-astra after kimi-k2.7-code), and the
5
+ // relay's model_variants rows used to be numbered per ingest, restarting at 0
6
+ // for each batch of new ids — so pickers showed models in arrival order. This
7
+ // module is the single definition of "the order a Copilot picker shows":
8
+ //
9
+ // 1. `auto` first;
10
+ // 2. vendor rank: OpenAI, Anthropic, Google, xAI, Microsoft, Azure OpenAI,
11
+ // Moonshot AI, then any other vendor alphabetically;
12
+ // 3. within a vendor, newest version first — the first dotted number in the
13
+ // id (gpt-5.6-terra → 5.6, gpt-6-astra → 6, claude-opus-4.8-fast → 4.8,
14
+ // kimi-k3 → 3, mai-code-1.1-flash → 1.1), compared segment-wise;
15
+ // 4. ties: picker category powerful → versatile → lightweight → unknown;
16
+ // 5. preview after non-preview;
17
+ // 6. display name, natural-alpha.
18
+ //
19
+ // Pure: takes descriptors (any object with modelId|id and optional vendor,
20
+ // pickerCategory, preview, displayName|name), returns a new array. Browser
21
+ // and server neutral.
22
+
23
+ export const COPILOT_VENDOR_ORDER = Object.freeze([
24
+ 'OpenAI',
25
+ 'Anthropic',
26
+ 'Google',
27
+ 'xAI',
28
+ 'Microsoft',
29
+ 'Azure OpenAI',
30
+ 'Moonshot AI',
31
+ ]);
32
+
33
+ const PICKER_CATEGORY_ORDER = Object.freeze(['powerful', 'versatile', 'lightweight']);
34
+
35
+ export const AUTO_MODEL_ID = 'auto';
36
+
37
+ const VENDOR_BY_PREFIX = [
38
+ ['gpt-', 'OpenAI'],
39
+ ['chatgpt-', 'OpenAI'],
40
+ ['o1-', 'OpenAI'],
41
+ ['o3-', 'OpenAI'],
42
+ ['o4-', 'OpenAI'],
43
+ ['codex-', 'OpenAI'],
44
+ ['openai/', 'OpenAI'],
45
+ ['claude-', 'Anthropic'],
46
+ ['anthropic/', 'Anthropic'],
47
+ ['gemini-', 'Google'],
48
+ ['google/', 'Google'],
49
+ ['grok-', 'xAI'],
50
+ ['mai-', 'Microsoft'],
51
+ ['microsoft/', 'Microsoft'],
52
+ ['kimi-', 'Moonshot AI'],
53
+ ];
54
+
55
+ function modelIdOf(descriptor) {
56
+ if (typeof descriptor === 'string') return descriptor.trim();
57
+ return String(descriptor?.modelId ?? descriptor?.id ?? descriptor?.model ?? '').trim();
58
+ }
59
+
60
+ function displayNameOf(descriptor) {
61
+ if (typeof descriptor === 'string') return '';
62
+ return String(descriptor?.displayName ?? descriptor?.name ?? descriptor?.label ?? '').trim();
63
+ }
64
+
65
+ /**
66
+ * The vendor a bare model id implies, for rows that carry no runtime vendor
67
+ * (curated seeds, help-text ids, pre-metadata snapshots). A runtime-reported
68
+ * vendor always wins over this — `gpt-5-mini` is served by "Azure OpenAI".
69
+ */
70
+ export function inferCopilotVendor(modelId) {
71
+ const lower = String(modelId || '').trim().toLowerCase();
72
+ if (!lower) return null;
73
+ if (lower === 'o1' || lower === 'o3') return 'OpenAI';
74
+ for (const [prefix, vendor] of VENDOR_BY_PREFIX) {
75
+ if (lower.startsWith(prefix)) return vendor;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function vendorOf(descriptor) {
81
+ const explicit = typeof descriptor === 'string' ? '' : String(descriptor?.vendor ?? '').trim();
82
+ return explicit || inferCopilotVendor(modelIdOf(descriptor)) || '';
83
+ }
84
+
85
+ function vendorRank(vendor) {
86
+ if (!vendor) return COPILOT_VENDOR_ORDER.length + 1;
87
+ const index = COPILOT_VENDOR_ORDER.findIndex((name) => name.toLowerCase() === vendor.toLowerCase());
88
+ return index === -1 ? COPILOT_VENDOR_ORDER.length : index;
89
+ }
90
+
91
+ /** The first dotted number in an id, as numeric segments; null when none. */
92
+ export function parseModelVersion(modelId) {
93
+ const match = /(\d+(?:\.\d+)*)/.exec(String(modelId || ''));
94
+ if (!match) return null;
95
+ return match[1].split('.').map((segment) => Number(segment));
96
+ }
97
+
98
+ // Newest first: a missing segment is "older" than any present one (5 < 5.1),
99
+ // and an absent version sorts after every versioned id.
100
+ function compareVersionsDesc(a, b) {
101
+ if (!a && !b) return 0;
102
+ if (!a) return 1;
103
+ if (!b) return -1;
104
+ const length = Math.max(a.length, b.length);
105
+ for (let index = 0; index < length; index += 1) {
106
+ const left = a[index] ?? -1;
107
+ const right = b[index] ?? -1;
108
+ if (left !== right) return right - left;
109
+ }
110
+ return 0;
111
+ }
112
+
113
+ function categoryRank(descriptor) {
114
+ const category = typeof descriptor === 'string' ? '' : String(descriptor?.pickerCategory ?? '').trim().toLowerCase();
115
+ const index = PICKER_CATEGORY_ORDER.indexOf(category);
116
+ return index === -1 ? PICKER_CATEGORY_ORDER.length : index;
117
+ }
118
+
119
+ const naturalCollator = typeof Intl !== 'undefined' && typeof Intl.Collator === 'function'
120
+ ? new Intl.Collator('en', { numeric: true, sensitivity: 'base' })
121
+ : null;
122
+
123
+ function compareNatural(a, b) {
124
+ if (naturalCollator) return naturalCollator.compare(a, b);
125
+ return a.localeCompare(b);
126
+ }
127
+
128
+ /** The comparable key of one descriptor; exported for tests and debugging. */
129
+ export function copilotModelSortKey(descriptor) {
130
+ const modelId = modelIdOf(descriptor);
131
+ const vendor = vendorOf(descriptor);
132
+ return {
133
+ modelId,
134
+ auto: modelId.toLowerCase() === AUTO_MODEL_ID,
135
+ vendor,
136
+ vendorRank: vendorRank(vendor),
137
+ version: parseModelVersion(modelId),
138
+ categoryRank: categoryRank(descriptor),
139
+ preview: typeof descriptor !== 'string' && descriptor?.preview === true,
140
+ displayName: displayNameOf(descriptor) || modelId,
141
+ };
142
+ }
143
+
144
+ export function compareCopilotModels(a, b) {
145
+ const left = copilotModelSortKey(a);
146
+ const right = copilotModelSortKey(b);
147
+ if (left.auto !== right.auto) return left.auto ? -1 : 1;
148
+ if (left.vendorRank !== right.vendorRank) return left.vendorRank - right.vendorRank;
149
+ if (left.vendorRank >= COPILOT_VENDOR_ORDER.length) {
150
+ const byVendor = compareNatural(left.vendor, right.vendor);
151
+ if (byVendor !== 0) return byVendor;
152
+ }
153
+ const byVersion = compareVersionsDesc(left.version, right.version);
154
+ if (byVersion !== 0) return byVersion;
155
+ if (left.categoryRank !== right.categoryRank) return left.categoryRank - right.categoryRank;
156
+ if (left.preview !== right.preview) return left.preview ? 1 : -1;
157
+ const byName = compareNatural(left.displayName, right.displayName);
158
+ if (byName !== 0) return byName;
159
+ return compareNatural(left.modelId, right.modelId);
160
+ }
161
+
162
+ /** A new array of the descriptors in canonical order (stable). */
163
+ export function sortCopilotModels(descriptors = []) {
164
+ const list = Array.isArray(descriptors) ? descriptors.filter((entry) => modelIdOf(entry)) : [];
165
+ return list
166
+ .map((descriptor, index) => ({ descriptor, index }))
167
+ .sort((a, b) => compareCopilotModels(a.descriptor, b.descriptor) || a.index - b.index)
168
+ .map((entry) => entry.descriptor);
169
+ }
170
+
171
+ /**
172
+ * `modelId → catalogIndex` over the canonical order. Duplicate ids keep the
173
+ * first position; the map is what a relay writes into sort_order.
174
+ */
175
+ export function copilotCatalogIndexByModel(descriptors = []) {
176
+ const indexByModel = new Map();
177
+ for (const descriptor of sortCopilotModels(descriptors)) {
178
+ const modelId = modelIdOf(descriptor);
179
+ if (!indexByModel.has(modelId)) indexByModel.set(modelId, indexByModel.size);
180
+ }
181
+ return indexByModel;
182
+ }
@@ -0,0 +1,35 @@
1
+ // How agents embed media inline in their reply messages, taught once per
2
+ // provider. The rendering work is client-side (sanitizePreviewHtml resolves
3
+ // bare absolute paths to the drive/workspace file routes and upgrades
4
+ // media-extension images to players); this module only carries the words.
5
+ //
6
+ // One constant, four seams — Claude (system-prompt append), Copilot (relay
7
+ // tool guidance document), Cursor and Grok (once-per-worker prompt prefix) —
8
+ // following preview-tool-core.mjs: providers cannot drift apart on wording
9
+ // because there is exactly one wording.
10
+
11
+ export const MEDIA_EMBED_INSTRUCTION_HEADING = '## Embedding media in replies';
12
+
13
+ export const MEDIA_EMBED_INSTRUCTION_TEXT =
14
+ 'To show the user an image, video, or audio clip inline in a reply, write a '
15
+ + 'markdown image whose target is the file\'s absolute path on this machine, '
16
+ + 'e.g. ![what it shows](/home/user/screenshots/result.png) or '
17
+ + '![demo clip](C:\\Users\\user\\Videos\\demo.mp4). The web app renders it '
18
+ + 'inline for the signed-in user — video and audio files become players — '
19
+ + 'with no upload, URL-encoding, or extra tooling needed. Media does not '
20
+ + 'render in shared/public transcript views.';
21
+
22
+ export function renderMediaEmbedInstructionBlock() {
23
+ return `${MEDIA_EMBED_INSTRUCTION_HEADING}\n\n${MEDIA_EMBED_INSTRUCTION_TEXT}`;
24
+ }
25
+
26
+ /**
27
+ * Append the media-embed block to a guidance document unless it already
28
+ * carries one (idempotent, so a doc that gains the section statically or via a
29
+ * second apply pass is not taught twice).
30
+ */
31
+ export function applyMediaEmbedInstructions(baseInstructions = '') {
32
+ const base = String(baseInstructions || '').trimEnd();
33
+ if (base.includes(MEDIA_EMBED_INSTRUCTION_HEADING)) return base;
34
+ return [base, renderMediaEmbedInstructionBlock()].filter(Boolean).join('\n\n');
35
+ }
@@ -5,12 +5,42 @@ import { isValidModelId, normalizeModelIdCandidate } from './model-id.mjs';
5
5
  // of its own would inherit a sibling's.
6
6
  const MODEL_CONTAINER_KEYS = ['data', 'models', 'list', 'available', 'items', 'entries', 'result', 'response'];
7
7
 
8
+ /**
9
+ * Every reasoning-effort level any Copilot catalog entry has been observed to
10
+ * advertise, in ladder order. `none` is the GPT family's explicit "no
11
+ * reasoning" level (and the relay's "model default" convention for
12
+ * effort-less models); `minimal` is Gemini's floor. Consumers validate against
13
+ * THIS list, not a per-family guess.
14
+ */
15
+ export const KNOWN_REASONING_EFFORTS = Object.freeze(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
16
+
17
+ export const MODEL_PICKER_CATEGORIES = Object.freeze(['powerful', 'versatile', 'lightweight']);
18
+
8
19
  export function normalizeContextLimitTokens(value) {
9
20
  const numeric = Number(value);
10
21
  if (!Number.isFinite(numeric) || numeric <= 0) return null;
11
22
  return Math.round(numeric);
12
23
  }
13
24
 
25
+ /**
26
+ * The effort levels a catalog entry advertises, tolerant of both shapes the
27
+ * runtime speaks: the client-level `ModelInfo` (`supportedReasoningEfforts`)
28
+ * and the session-level `rpc.model.list()` entry, which is the RAW CAPI
29
+ * record carrying the list at `capabilities.supports.reasoning_effort`
30
+ * (live-verified on runtime 1.0.83 — the typed field never appears there;
31
+ * validating against it failed every effort-carrying turn, burn-in session
32
+ * ed5febdd). `null` = the entry carries no list: for an authoritative catalog
33
+ * entry that means the model has no effort control at all; callers that
34
+ * validate must stay PERMISSIVE on null, the runtime is the authority.
35
+ */
36
+ export function supportedEffortsOf(entry) {
37
+ const typed = entry?.supportedReasoningEfforts;
38
+ if (Array.isArray(typed)) return typed.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
39
+ const wire = entry?.capabilities?.supports?.reasoning_effort;
40
+ if (Array.isArray(wire)) return wire.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
41
+ return null;
42
+ }
43
+
14
44
  function findContextLimitTokens(value) {
15
45
  if (!value || typeof value !== 'object') return null;
16
46
  const candidates = [
@@ -52,36 +82,123 @@ function normalizePrice(value) {
52
82
  return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
53
83
  }
54
84
 
85
+ // Both spellings the runtime uses: the typed client `ModelInfo` is camelCase
86
+ // (`tokenPrices.longContext.inputPrice`), the raw CAPI record on the session
87
+ // list is snake_case (`token_prices.long_context.input_price`).
55
88
  function normalizePricing(value, fallbackBatchSize = null) {
56
89
  if (!value || typeof value !== 'object') return null;
57
- const batchSize = normalizeContextLimitTokens(value.batchSize) ?? normalizeContextLimitTokens(fallbackBatchSize);
90
+ const batchSize = normalizeContextLimitTokens(value.batchSize ?? value.batch_size)
91
+ ?? normalizeContextLimitTokens(fallbackBatchSize);
58
92
  const rates = {
59
- input: normalizePrice(value.inputPrice),
60
- output: normalizePrice(value.outputPrice),
61
- cacheRead: normalizePrice(value.cacheReadPrice ?? value.cachePrice),
62
- cacheWrite: normalizePrice(value.cacheWritePrice),
93
+ input: normalizePrice(value.inputPrice ?? value.input_price),
94
+ output: normalizePrice(value.outputPrice ?? value.output_price),
95
+ cacheRead: normalizePrice(value.cacheReadPrice ?? value.cache_read_price ?? value.cachePrice ?? value.cache_price),
96
+ cacheWrite: normalizePrice(value.cacheWritePrice ?? value.cache_write_price),
63
97
  };
64
98
  // batchSize alone is a denominator, not evidence of real pricing.
65
99
  if (!Object.values(rates).some((entry) => entry !== null)) return null;
66
100
  return { ...rates, batchSize };
67
101
  }
68
102
 
103
+ function tokenPricesOf(value) {
104
+ return value?.billing?.tokenPrices
105
+ || value?.billing?.token_prices
106
+ || value?.tokenPrices
107
+ || value?.token_prices
108
+ || null;
109
+ }
110
+
111
+ function longContextPricesOf(tokenPrices, value) {
112
+ return tokenPrices?.longContext || tokenPrices?.long_context || value?.longContext || value?.long_context || null;
113
+ }
114
+
115
+ function defaultPricesOf(tokenPrices) {
116
+ // The raw record nests the default tier under `default`; the typed shape
117
+ // puts the default rates directly on tokenPrices.
118
+ return tokenPrices?.default && typeof tokenPrices.default === 'object' ? tokenPrices.default : tokenPrices;
119
+ }
120
+
121
+ function limitsOf(value) {
122
+ return value?.capabilities?.limits || value?.limits || {};
123
+ }
124
+
125
+ /** `capabilities.limits.max_context_window_tokens` — the REAL window. */
126
+ function contextWindowTokensOf(value) {
127
+ const limits = limitsOf(value);
128
+ return normalizeContextLimitTokens(
129
+ limits.max_context_window_tokens ?? limits.maxContextWindowTokens ?? value?.maxContextWindowTokens,
130
+ );
131
+ }
132
+
133
+ function maxPromptTokensOf(value) {
134
+ const limits = limitsOf(value);
135
+ return normalizeContextLimitTokens(limits.max_prompt_tokens ?? limits.maxPromptTokens ?? value?.maxPromptTokens);
136
+ }
137
+
138
+ function maxOutputTokensOf(value) {
139
+ const limits = limitsOf(value);
140
+ return normalizeContextLimitTokens(limits.max_output_tokens ?? limits.maxOutputTokens ?? value?.maxOutputTokens) || 0;
141
+ }
142
+
143
+ /**
144
+ * The context budget of a pricing tier: prompt budget + output budget. The
145
+ * real window (`max_context_window_tokens`) caps it — a runtime's advertised
146
+ * prompt+output pair can exceed the window (haiku: 128000+32000 vs a 144000
147
+ * window) and the cap is what stops the auto-compact thresholds from being
148
+ * computed against tokens the model cannot actually hold. When no tier budget
149
+ * is advertised the window itself is the limit.
150
+ */
69
151
  function contextLimitForTier(value, tier = 'default') {
70
- const limits = value?.capabilities?.limits || value?.limits || {};
71
- const outputTokens = normalizeContextLimitTokens(
72
- limits.max_output_tokens ?? limits.maxOutputTokens ?? value?.maxOutputTokens,
73
- ) || 0;
74
- const tokenPrices = value?.billing?.tokenPrices || value?.tokenPrices || null;
152
+ const outputTokens = maxOutputTokensOf(value);
153
+ const window = contextWindowTokensOf(value);
154
+ const capToWindow = (tokens) => (tokens !== null && window !== null ? Math.min(tokens, window) : tokens);
155
+ const tokenPrices = tokenPricesOf(value);
75
156
  if (tier === 'long_context') {
76
- const longContext = tokenPrices?.longContext
77
- || value?.longContext;
78
- const promptTokens = findPromptBudgetTokens(longContext);
79
- return promptTokens === null ? null : promptTokens + outputTokens;
157
+ const promptTokens = findPromptBudgetTokens(longContextPricesOf(tokenPrices, value));
158
+ return promptTokens === null ? null : capToWindow(promptTokens + outputTokens);
80
159
  }
81
- const defaultPromptTokens = findPromptBudgetTokens(tokenPrices)
82
- ?? normalizeContextLimitTokens(limits.max_prompt_tokens ?? limits.maxPromptTokens);
83
- if (defaultPromptTokens !== null) return defaultPromptTokens + outputTokens;
84
- return findContextLimitTokens(value);
160
+ const defaultPromptTokens = findPromptBudgetTokens(defaultPricesOf(tokenPrices))
161
+ ?? maxPromptTokensOf(value);
162
+ if (defaultPromptTokens !== null) return capToWindow(defaultPromptTokens + outputTokens);
163
+ return window ?? findContextLimitTokens(value);
164
+ }
165
+
166
+ function normalizeText(value) {
167
+ const text = String(value ?? '').trim();
168
+ return text || null;
169
+ }
170
+
171
+ function normalizePickerCategory(value) {
172
+ const text = String(value || '').trim().toLowerCase();
173
+ return MODEL_PICKER_CATEGORIES.includes(text) ? text : null;
174
+ }
175
+
176
+ /**
177
+ * One descriptor per catalog entry, from EITHER wire shape (raw CAPI record
178
+ * or typed `ModelInfo`). Every field the API contract carries is decided
179
+ * here and nowhere else, so the worker's session list and the discovery
180
+ * service's client list yield identical metadata for the same model.
181
+ */
182
+ function describeEntry(modelId, value) {
183
+ const tokenPrices = tokenPricesOf(value);
184
+ const defaultPrices = defaultPricesOf(tokenPrices);
185
+ const batchSize = tokenPrices?.batchSize ?? tokenPrices?.batch_size ?? null;
186
+ return {
187
+ modelId,
188
+ contextLimitTokens: contextLimitForTier(value),
189
+ longContextLimitTokens: contextLimitForTier(value, 'long_context'),
190
+ pricing: {
191
+ default: normalizePricing(defaultPrices, batchSize),
192
+ longContext: normalizePricing(longContextPricesOf(tokenPrices, value), batchSize),
193
+ },
194
+ displayName: normalizeText(value.name ?? value.displayName),
195
+ vendor: normalizeText(value.vendor),
196
+ pickerCategory: normalizePickerCategory(value.model_picker_category ?? value.modelPickerCategory),
197
+ preview: value.preview === true,
198
+ contextWindowTokens: contextWindowTokensOf(value),
199
+ maxPromptTokens: maxPromptTokensOf(value),
200
+ supportedEfforts: supportedEffortsOf(value),
201
+ };
85
202
  }
86
203
 
87
204
  export function extractModelDescriptors(value, out = []) {
@@ -98,21 +215,55 @@ export function extractModelDescriptors(value, out = []) {
98
215
  if (typeof value !== 'object') return out;
99
216
 
100
217
  const modelId = normalizeModelIdCandidate(value.modelId || value.id || value.model || null);
101
- if (isValidModelId(modelId)) {
102
- const tokenPrices = value?.billing?.tokenPrices || value?.tokenPrices || null;
103
- out.push({
104
- modelId,
105
- contextLimitTokens: contextLimitForTier(value),
106
- longContextLimitTokens: contextLimitForTier(value, 'long_context'),
107
- pricing: {
108
- default: normalizePricing(tokenPrices),
109
- longContext: normalizePricing(tokenPrices?.longContext, tokenPrices?.batchSize),
110
- },
111
- });
112
- }
218
+ if (isValidModelId(modelId)) out.push(describeEntry(modelId, value));
113
219
  for (const key of MODEL_CONTAINER_KEYS) {
114
220
  const nested = value[key];
115
221
  if (nested !== undefined && nested !== null) extractModelDescriptors(nested, out);
116
222
  }
117
223
  return out;
118
224
  }
225
+
226
+ /**
227
+ * The per-model metadata object of the catalog API contract
228
+ * (`modelMetadataByModel[modelId]` on GET /api/models, GET /api/model-variants
229
+ * and the models_updated socket payload), built from one descriptor. All three
230
+ * snapshot producers publish exactly this; `catalogIndex` is assigned by the
231
+ * relay from the canonical order, never by a producer.
232
+ */
233
+ export function modelMetadataFromDescriptor(descriptor = {}) {
234
+ return {
235
+ defaultContextLimitTokens: normalizeContextLimitTokens(descriptor.contextLimitTokens),
236
+ longContextLimitTokens: normalizeContextLimitTokens(descriptor.longContextLimitTokens),
237
+ pricing: descriptor.pricing && typeof descriptor.pricing === 'object' ? descriptor.pricing : null,
238
+ displayName: normalizeText(descriptor.displayName),
239
+ vendor: normalizeText(descriptor.vendor),
240
+ pickerCategory: normalizePickerCategory(descriptor.pickerCategory),
241
+ preview: descriptor.preview === true,
242
+ contextWindowTokens: normalizeContextLimitTokens(descriptor.contextWindowTokens),
243
+ maxPromptTokens: normalizeContextLimitTokens(descriptor.maxPromptTokens),
244
+ supportedEfforts: Array.isArray(descriptor.supportedEfforts)
245
+ ? descriptor.supportedEfforts.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean)
246
+ : null,
247
+ catalogIndex: null,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * The `{ models, contextLimitsByModel, modelMetadataByModel }` triple every
253
+ * `/api/models/snapshot` publisher sends, so the worker, the standalone relay
254
+ * and the server-side discovery cannot drift in what they publish.
255
+ */
256
+ export function buildModelSnapshotFields(descriptors = []) {
257
+ const list = Array.isArray(descriptors) ? descriptors.filter((entry) => entry?.modelId) : [];
258
+ return {
259
+ models: list.map((entry) => entry.modelId),
260
+ contextLimitsByModel: Object.fromEntries(
261
+ list
262
+ .filter((entry) => normalizeContextLimitTokens(entry.contextLimitTokens) !== null)
263
+ .map((entry) => [entry.modelId, normalizeContextLimitTokens(entry.contextLimitTokens)]),
264
+ ),
265
+ modelMetadataByModel: Object.fromEntries(
266
+ list.map((entry) => [entry.modelId, modelMetadataFromDescriptor(entry)]),
267
+ ),
268
+ };
269
+ }
@@ -1,9 +1,15 @@
1
+ // The families the Copilot catalog has been observed to serve. This is the
2
+ // validator's baseline, not a whitelist of "models we like": an id the runtime
3
+ // itself lists must pass, or the model silently vanishes from every picker
4
+ // (grok-* and kimi-* did exactly that — 4 of 27 live catalog entries).
1
5
  const MODEL_ID_PREFIXES = [
2
6
  'gpt-',
3
7
  'chatgpt-',
4
8
  'claude-',
5
9
  'gemini-',
6
10
  'mai-',
11
+ 'grok-',
12
+ 'kimi-',
7
13
  'o1-',
8
14
  'o3-',
9
15
  'o4-',
@@ -0,0 +1,44 @@
1
+ // The installed-PWA app name, stored once per relay in app_settings and served
2
+ // to every browser through /manifest.webmanifest. It must live server-side:
3
+ // Android's WebAPK update check re-fetches the manifest outside any page
4
+ // session, so a name the server does not know reverts on the phone with an
5
+ // accept-or-uninstall prompt.
6
+
7
+ export const PWA_APP_NAME_DEFAULT = 'OAR';
8
+ export const PWA_APP_NAME_MAX_LENGTH = 60;
9
+
10
+ /** Empty is valid and means "use the default name". */
11
+ export function normalizePwaAppName(rawValue) {
12
+ const value = String(rawValue ?? '').replace(/\s+/g, ' ').trim();
13
+ if (value.length > PWA_APP_NAME_MAX_LENGTH) {
14
+ return { ok: false, error: `App name must be ${PWA_APP_NAME_MAX_LENGTH} characters or fewer` };
15
+ }
16
+ return { ok: true, value };
17
+ }
18
+
19
+ /** Stored DB value -> custom name, or '' when unset/junk (meaning default). */
20
+ export function readPwaAppNameSetting(storedValue) {
21
+ const normalized = normalizePwaAppName(storedValue);
22
+ return normalized.ok ? normalized.value : '';
23
+ }
24
+
25
+ /**
26
+ * Launcher labels get very little room: the first word when it fits, otherwise
27
+ * a hard slice of the full name.
28
+ */
29
+ export function derivePwaShortName(name) {
30
+ const text = String(name || '').trim();
31
+ if (!text) return PWA_APP_NAME_DEFAULT;
32
+ const firstWord = text.split(/\s+/)[0] || text;
33
+ if (firstWord.length <= 12) return firstWord;
34
+ return text.slice(0, 12).trim() || PWA_APP_NAME_DEFAULT;
35
+ }
36
+
37
+ export function resolvePwaManifestNames(customName, templateName) {
38
+ const custom = String(customName || '').trim();
39
+ if (custom) {
40
+ return { name: custom, short_name: derivePwaShortName(custom) };
41
+ }
42
+ const fallback = String(templateName || '').trim() || PWA_APP_NAME_DEFAULT;
43
+ return { name: fallback, short_name: derivePwaShortName(fallback) };
44
+ }
@@ -0,0 +1,16 @@
1
+ // Whether a choice question reads as "pick several" — for providers whose
2
+ // ask-user tool has no multi-select flag (Copilot's `ask_user`; Claude's
3
+ // AskUserQuestion carries `multiSelect: true` itself). The card then offers
4
+ // checkmarks instead of one-shot buttons and answers with the selected labels
5
+ // joined by ", ". Conservative on purpose: an ordinary "which one?" keeps its
6
+ // buttons, because a wrongly multi-select card costs the user a second click
7
+ // while a wrongly single-select one forces them into the free-text field.
8
+ export function looksLikeMultiSelectQuestion(questionText, choices = []) {
9
+ if (!Array.isArray(choices) || choices.length < 2) return false;
10
+ const text = String(questionText || '');
11
+ return /\ball that apply\b/i.test(text)
12
+ || /\bmulti-?select\b/i.test(text)
13
+ || /\b(select|choose|pick|check|tick|enable|turn on)\b[^.!?\n]{0,40}\b(all|multiple|several|any number|one or more|as many|more than one)\b/i.test(text)
14
+ || /\bmultiple (choices|options|selections|answers)\b/i.test(text)
15
+ || /\bone or more\b/i.test(text);
16
+ }
@@ -0,0 +1,15 @@
1
+ // The terminal outcome of a turn the user stopped — what the relay records
2
+ // when a worker acknowledges an abort control. Shared so a worker that dies
3
+ // before its ack lands fails the stopped row the same way instead of letting
4
+ // it be requeued (a requeue re-runs a prompt the user explicitly stopped).
5
+ export function buildRelayStopFailure() {
6
+ return {
7
+ kind: 'turn-aborted',
8
+ error: 'turn-aborted',
9
+ code: 'turn-aborted',
10
+ stableCode: 'relay.turn-aborted',
11
+ message: 'System note: This turn was stopped from the relay UI before completion.',
12
+ guidance: 'Send a new message to continue when you are ready.',
13
+ failedAt: new Date().toISOString(),
14
+ };
15
+ }
@@ -0,0 +1,76 @@
1
+ // Keeps a "/x" message the relay delivers to the Claude CLI plain text.
2
+ //
3
+ // The relay itself runs only /compact and /preview as commands, and neither
4
+ // reaches a worker as text: the composer (and the /api/message route, for
5
+ // /compact) intercept them. Any other "/x" the composer sends only after its
6
+ // warn-once guard ("press send again to send as text"), and the API accepts
7
+ // it as-is — so it must reach the model as the text the user typed. The Claude
8
+ // CLI, however, runs a prompt as a slash command when the string, or its LAST
9
+ // text block trimmed, starts with "/". Between turns that ran "/help what is
10
+ // 3 + 4?" as the CLI's /help (a `local_command` with "isn't available in this
11
+ // environment" as its output): the model never saw the question, and the
12
+ // relay attributed the next turn's reply to that row (live, 2026-09-26).
13
+ //
14
+ // The guard is a zero-width space in front of the "/". JavaScript's trim()
15
+ // does not strip U+200B, so neither the CLI's command check nor its queue's
16
+ // "is this a command" check fires, and the message is handled exactly like
17
+ // any other plain-text prompt (live probe, CLI 2.1.283: no local_command, the
18
+ // model answered the question, the transcript keeps the character). The
19
+ // relay stores and shows the user's own text; only the CLI sees the guard.
20
+ //
21
+ // A steered message needs none: the steer note (shared/steer-note.mjs) already
22
+ // leads its text, so the guard only applies where the text itself starts
23
+ // with "/".
24
+ //
25
+ // Not the SDK's per-message `client_composed: true`: that suppresses the
26
+ // dispatch too (same probe), but older CLIs ignore the field, it also turns
27
+ // off `@path` expansion and drops part of the turn-start context for that
28
+ // message (the probe lost the token-budget reminder), and per the CLI bundle
29
+ // its queue still classifies the text by the leading "/" (e.g. never merges
30
+ // it with other queued prompts) — the guard makes it plain text everywhere.
31
+
32
+ export const SLASH_COMMAND_GUARD = '\u200B';
33
+
34
+ function readsAsSlashCommand(text) {
35
+ return typeof text === 'string' && text.trim().startsWith('/');
36
+ }
37
+
38
+ /** Prefix the guard when the CLI would read the text as a slash command. */
39
+ export function withSlashCommandGuard(text) {
40
+ return readsAsSlashCommand(text) ? `${SLASH_COMMAND_GUARD}${text}` : text;
41
+ }
42
+
43
+ /**
44
+ * The guard applied to Claude user content: a string, or every text block of a
45
+ * content-block array (the CLI inspects the last block; guarding each keeps
46
+ * that true whatever the block order). Returns the input itself when nothing
47
+ * needed a guard; never mutates it.
48
+ */
49
+ export function withSlashCommandGuardContent(content) {
50
+ if (typeof content === 'string') return withSlashCommandGuard(content);
51
+ if (!Array.isArray(content)) return content;
52
+ if (!content.some((block) => block?.type === 'text' && readsAsSlashCommand(block.text))) return content;
53
+ return content.map((block) => (block?.type === 'text' && readsAsSlashCommand(block.text)
54
+ ? { ...block, text: withSlashCommandGuard(block.text) }
55
+ : block));
56
+ }
57
+
58
+ /**
59
+ * The user's own text from a guarded prompt: the leading guard is dropped only
60
+ * where it stands in front of a "/", so other text is returned unchanged.
61
+ */
62
+ export function stripSlashCommandGuard(text) {
63
+ const value = String(text ?? '');
64
+ return value.startsWith(SLASH_COMMAND_GUARD) && readsAsSlashCommand(value.slice(SLASH_COMMAND_GUARD.length))
65
+ ? value.slice(SLASH_COMMAND_GUARD.length)
66
+ : value;
67
+ }
68
+
69
+ /** stripSlashCommandGuard over a string or each text block of a content array. */
70
+ export function stripSlashCommandGuardContent(content) {
71
+ if (typeof content === 'string') return stripSlashCommandGuard(content);
72
+ if (!Array.isArray(content)) return content;
73
+ return content.map((block) => (block?.type === 'text' && typeof block.text === 'string'
74
+ ? { ...block, text: stripSlashCommandGuard(block.text) }
75
+ : block));
76
+ }