@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.
- package/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- 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.  or '
|
|
17
|
+
+ '. 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
|
|
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
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
??
|
|
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
|
+
}
|
package/shared/model-id.mjs
CHANGED
|
@@ -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
|
+
}
|