@north-light/crouter 0.3.262 → 0.3.263

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 (48) hide show
  1. package/dist/api/dto/health.d.ts +2 -0
  2. package/dist/builtin-memory/internal/memory-loading.md +3 -3
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +5 -3
  5. package/dist/clients/attach/input/controller.d.ts +4 -0
  6. package/dist/clients/attach/input/controller.js +8 -0
  7. package/dist/clients/attach/session/bindings.d.ts +3 -0
  8. package/dist/clients/attach/session/bindings.js +7 -0
  9. package/dist/clients/attach/session/keys.d.ts +2 -0
  10. package/dist/clients/attach/session/keys.js +3 -3
  11. package/dist/clients/attach/viewer.js +616 -616
  12. package/dist/commands/memory/read.js +15 -38
  13. package/dist/commands/memory/shared.d.ts +1 -1
  14. package/dist/commands/memory/shared.js +1 -1
  15. package/dist/commands/sys/context/admin/docs-panel.d.ts +3 -3
  16. package/dist/commands/sys/context/admin/model.d.ts +7 -2
  17. package/dist/commands/sys/context/admin/model.js +7 -1
  18. package/dist/commands/sys/context/admin/read-view.d.ts +5 -5
  19. package/dist/commands/sys/context/admin/read-view.js +9 -4
  20. package/dist/commands/sys/context/resolve.d.ts +32 -3
  21. package/dist/commands/sys/context/resolve.js +74 -25
  22. package/dist/commands/sys/panels/models-panel.d.ts +28 -5
  23. package/dist/commands/sys/panels/models-panel.js +558 -75
  24. package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +1 -1
  25. package/dist/core/__tests__/model-routes-config.test.d.ts +1 -0
  26. package/dist/core/__tests__/model-routes-config.test.js +142 -0
  27. package/dist/core/__tests__/on-read-nested-store.test.js +8 -0
  28. package/dist/core/__tests__/profile-project-memory-delivery.test.js +264 -1
  29. package/dist/core/__tests__/seam/broker-provider-retry.test.js +2 -2
  30. package/dist/core/canvas/render-source.d.ts +1 -1
  31. package/dist/core/canvas/render-source.js +3 -2
  32. package/dist/core/config.d.ts +19 -2
  33. package/dist/core/config.js +162 -47
  34. package/dist/core/keybindings/catalog.js +9 -7
  35. package/dist/core/model-routes.js +10 -3
  36. package/dist/core/substrate/listings.d.ts +1 -1
  37. package/dist/core/substrate/listings.js +6 -2
  38. package/dist/core/substrate/on-read.d.ts +61 -5
  39. package/dist/core/substrate/on-read.js +129 -22
  40. package/dist/core/substrate/render.d.ts +3 -2
  41. package/dist/core/substrate/render.js +133 -38
  42. package/dist/daemon/api/__tests__/seam/api-server.test.js +34 -0
  43. package/dist/daemon/api/handlers/canvas.js +18 -2
  44. package/dist/daemon/api/handlers/health.js +17 -12
  45. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +2 -2
  46. package/dist/types.d.ts +4 -2
  47. package/package.json +1 -1
  48. package/runtime.lock.json +2 -2
@@ -167,66 +167,140 @@ function mergeModelLadders(raw, base = defaultModelLaddersConfig()) {
167
167
  function validStrength(value) {
168
168
  return value === 'ultra' || value === 'strong' || value === 'medium' || value === 'light';
169
169
  }
170
- /** Validate and overlay route entries. Invalid declarations are loud but local:
171
- * a typo must not prevent unrelated launch configuration from resolving. */
172
- function mergeModelRoutes(raw, base = {}) {
173
- const out = { ...base };
170
+ const MODEL_STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
171
+ function routeRecord() {
172
+ return Object.create(null);
173
+ }
174
+ function setRouteRecord(record, routeId, value) {
175
+ Object.defineProperty(record, routeId, { value, writable: true, enumerable: true, configurable: true });
176
+ }
177
+ function copyRouteRecord(record) {
178
+ const copy = routeRecord();
179
+ for (const [routeId, value] of Object.entries(record ?? {}))
180
+ setRouteRecord(copy, routeId, value);
181
+ return copy;
182
+ }
183
+ function routeInvalid(routeId, rule) {
184
+ emitEvent({ level: 'warn', event: 'config.model_routes.invalid', fields: { route_id: routeId, rule } });
185
+ }
186
+ /** Overlay raw route patches first so a higher scope may replace one cell of a
187
+ * complete lower route. Completeness belongs to the resulting effective route,
188
+ * never an individual raw scope patch. */
189
+ function mergeModelRoutePatches(raw, base = {}) {
190
+ const out = copyRouteRecord(base);
174
191
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
175
192
  return out;
176
193
  for (const [routeId, value] of Object.entries(raw)) {
177
- const patch = value;
178
- const invalid = (rule) => { emitEvent({ level: 'warn', event: 'config.model_routes.invalid', fields: { route_id: routeId, rule } }); };
179
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
180
- invalid('route must be an object');
194
+ if (routeId.trim() === '') {
195
+ routeInvalid(routeId, 'route id must be a non-empty string');
181
196
  continue;
182
197
  }
183
- const route = { ...out[routeId], ...patch, models: { ...out[routeId]?.models, ...patch.models } };
184
- if (typeof route.family !== 'string' || route.family.trim() === '') {
185
- invalid('family must be a non-empty string');
186
- continue;
187
- }
188
- if (typeof route.credentialSource !== 'string' || route.credentialSource.trim() === '') {
189
- invalid('credentialSource must be a non-empty string');
190
- continue;
191
- }
192
- if (typeof route.providerId !== 'string' || route.providerId.trim() === '') {
193
- invalid('providerId must be a non-empty string');
194
- continue;
195
- }
196
- if (!route.models || typeof route.models !== 'object' || Array.isArray(route.models)) {
197
- invalid('models must be an object');
198
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
199
+ routeInvalid(routeId, 'route must be an object');
198
200
  continue;
199
201
  }
200
- const models = {};
202
+ const patch = value;
203
+ const current = out[routeId];
204
+ const route = { ...current };
201
205
  let rule;
202
- for (const [strength, spec] of Object.entries(route.models)) {
203
- if (!validStrength(strength)) {
204
- rule = `models.${strength} is not a model strength`;
205
- break;
206
- }
207
- if (typeof spec !== 'string' || spec.trim() === '') {
208
- rule = `models.${strength} must be a non-empty provider/id ref`;
206
+ for (const field of ['family', 'credentialSource', 'providerId']) {
207
+ const candidate = patch[field];
208
+ if (candidate === undefined)
209
+ continue;
210
+ if (typeof candidate !== 'string' || candidate.trim() === '') {
211
+ rule = `${field} must be a non-empty string`;
209
212
  break;
210
213
  }
211
- const slash = spec.indexOf('/');
212
- if (slash <= 0 || slash === spec.length - 1) {
213
- rule = `models.${strength} must be provider/id`;
214
- break;
214
+ route[field] = candidate;
215
+ }
216
+ if (rule === undefined && patch.models !== undefined) {
217
+ if (patch.models === null || typeof patch.models !== 'object' || Array.isArray(patch.models))
218
+ rule = 'models must be an object';
219
+ else {
220
+ const models = { ...current?.models };
221
+ for (const [strength, spec] of Object.entries(patch.models)) {
222
+ if (!validStrength(strength)) {
223
+ rule = `models.${strength} is not a model strength`;
224
+ break;
225
+ }
226
+ if (typeof spec !== 'string' || spec.trim() === '') {
227
+ rule = `models.${strength} must be a non-empty provider/id ref`;
228
+ break;
229
+ }
230
+ const slash = spec.indexOf('/');
231
+ if (slash <= 0 || slash === spec.length - 1) {
232
+ rule = `models.${strength} must be provider/id`;
233
+ break;
234
+ }
235
+ models[strength] = spec;
236
+ }
237
+ route.models = models;
215
238
  }
216
- if (spec.slice(0, slash) !== route.providerId) {
217
- rule = `models.${strength} provider prefix must equal providerId`;
218
- break;
239
+ }
240
+ if (rule === undefined && route.models !== undefined && route.providerId !== undefined) {
241
+ for (const [strength, spec] of Object.entries(route.models)) {
242
+ if (spec.slice(0, spec.indexOf('/')) !== route.providerId) {
243
+ rule = `models.${strength} provider prefix must equal providerId`;
244
+ break;
245
+ }
219
246
  }
220
- models[strength] = spec;
221
247
  }
222
248
  if (rule !== undefined) {
223
- invalid(rule);
249
+ routeInvalid(routeId, rule);
224
250
  continue;
225
251
  }
226
- out[routeId] = { family: route.family, credentialSource: route.credentialSource, providerId: route.providerId, models };
252
+ setRouteRecord(out, routeId, route);
227
253
  }
228
254
  return out;
229
255
  }
256
+ /** Resolve complete routes after every scope has overlaid its sparse raw patch.
257
+ * Sparse standalone routes are read for one compatibility release only: they
258
+ * retain their historical candidate behavior, emit a repair warning, and never
259
+ * gain invented cells. New writes cannot create them. */
260
+ function resolveModelRoutes(routes) {
261
+ const complete = routeRecord();
262
+ const legacy = routeRecord();
263
+ for (const [routeId, route] of Object.entries(routes)) {
264
+ if (route.family === undefined || route.credentialSource === undefined || route.providerId === undefined || route.models === undefined) {
265
+ routeInvalid(routeId, 'route must include family, credentialSource, providerId, and models');
266
+ continue;
267
+ }
268
+ const missing = MODEL_STRENGTHS.filter((strength) => route.models[strength] === undefined);
269
+ if (missing.length > 0) {
270
+ routeInvalid(routeId, `incomplete route; missing strengths: ${missing.join(', ')}`);
271
+ setRouteRecord(legacy, routeId, { family: route.family, credentialSource: route.credentialSource, providerId: route.providerId, models: route.models });
272
+ continue;
273
+ }
274
+ setRouteRecord(complete, routeId, {
275
+ family: route.family,
276
+ credentialSource: route.credentialSource,
277
+ providerId: route.providerId,
278
+ models: route.models,
279
+ });
280
+ }
281
+ return { complete, legacy };
282
+ }
283
+ function assertCompleteModelRoute(routeId, route) {
284
+ if (routeId.trim() === '')
285
+ throw new Error('model route id must be a non-empty string');
286
+ for (const field of ['family', 'credentialSource', 'providerId']) {
287
+ if (typeof route[field] !== 'string' || route[field].trim() === '')
288
+ throw new Error(`model route "${routeId}" ${field} must be a non-empty string`);
289
+ }
290
+ const missing = MODEL_STRENGTHS.filter((strength) => route.models?.[strength] === undefined);
291
+ if (missing.length > 0)
292
+ throw new Error(`model route "${routeId}" is incomplete; missing strengths: ${missing.join(', ')}`);
293
+ for (const strength of MODEL_STRENGTHS) {
294
+ const spec = route.models[strength];
295
+ if (typeof spec !== 'string' || spec.trim() === '')
296
+ throw new Error(`model route "${routeId}" models.${strength} must be a non-empty provider/id ref`);
297
+ const slash = spec.indexOf('/');
298
+ if (slash <= 0 || slash === spec.length - 1)
299
+ throw new Error(`model route "${routeId}" models.${strength} must be provider/id`);
300
+ if (spec.slice(0, slash) !== route.providerId)
301
+ throw new Error(`model route "${routeId}" models.${strength} provider prefix must equal providerId`);
302
+ }
303
+ }
230
304
  function mergeModelRouting(raw, base) {
231
305
  if (raw === undefined)
232
306
  return base;
@@ -679,14 +753,15 @@ function mergeConfig(partial) {
679
753
  const keybindings = normalizeKeybindings(partial.keybindings);
680
754
  const tmux_passthrough = normalizeTmuxPassthrough(partial.tmux_passthrough);
681
755
  const modelLadders = mergeModelLadders(partial.modelLadders);
682
- const modelRoutes = mergeModelRoutes(partial.modelRoutes);
756
+ const routes = resolveModelRoutes(mergeModelRoutePatches(partial.modelRoutes));
757
+ const modelRoutes = routes.complete;
683
758
  const modelRouting = mergeModelRouting(partial.modelRouting, undefined);
684
759
  const kinds = mergeKinds(partial.kinds);
685
760
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
686
761
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
687
762
  const bin = normalizeBin(partial.bin);
688
763
  const humanActions = normalizeHumanActions(partial.humanActions);
689
- return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
764
+ return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
690
765
  }
691
766
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
692
767
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -775,13 +850,13 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
775
850
  // (ladders/routes/spawnEnv) have no plugin channel.
776
851
  let kinds = mergeKinds(userRaw?.kinds, layerPluginKinds('user', scopeRoot('user'), defaults.kinds));
777
852
  let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
778
- let modelRoutes = mergeModelRoutes(userRaw?.modelRoutes);
853
+ let modelRoutePatches = mergeModelRoutePatches(userRaw?.modelRoutes);
779
854
  let modelRouting = mergeModelRouting(userRaw?.modelRouting, undefined);
780
855
  let spawnEnv = mergeSpawnEnv(userRaw?.spawnEnv, defaults.spawnEnv?.allow);
781
856
  if (profileRaw !== null) {
782
857
  kinds = mergeKinds(profileRaw.kinds, kinds);
783
858
  modelLadders = mergeModelLadders(profileRaw.modelLadders, modelLadders);
784
- modelRoutes = mergeModelRoutes(profileRaw.modelRoutes, modelRoutes);
859
+ modelRoutePatches = mergeModelRoutePatches(profileRaw.modelRoutes, modelRoutePatches);
785
860
  modelRouting = mergeModelRouting(profileRaw.modelRouting, modelRouting);
786
861
  spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
787
862
  }
@@ -790,11 +865,19 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
790
865
  if (raw === null)
791
866
  continue;
792
867
  modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
793
- modelRoutes = mergeModelRoutes(raw.modelRoutes, modelRoutes);
868
+ modelRoutePatches = mergeModelRoutePatches(raw.modelRoutes, modelRoutePatches);
794
869
  modelRouting = mergeModelRouting(raw.modelRouting, modelRouting);
795
870
  spawnEnv = mergeSpawnEnv(raw.spawnEnv, spawnEnv.allow);
796
871
  }
797
- return { kinds, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), spawnEnv };
872
+ const routes = resolveModelRoutes(modelRoutePatches);
873
+ return {
874
+ kinds,
875
+ modelLadders,
876
+ ...(Object.keys(routes.complete).length > 0 ? { modelRoutes: routes.complete } : {}),
877
+ ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}),
878
+ ...(modelRouting !== undefined ? { modelRouting } : {}),
879
+ spawnEnv,
880
+ };
798
881
  }
799
882
  /** The effective `KindConfig` for one full kind string (top-level, e.g.
800
883
  * `developer`, or sub-kind, e.g. `audit/security`), across
@@ -930,6 +1013,38 @@ export function persistModelLadders(root, patch) {
930
1013
  atomicWriteJson(lockedPath, { ...raw, modelLadders: next });
931
1014
  });
932
1015
  }
1016
+ /** Persist one complete route at one exact config root. The route is the only
1017
+ * raw route entry touched; unrelated routes and local ladder defaults remain
1018
+ * absent. Pass `undefined` to remove this scope's entry, which resumes lower
1019
+ * scope inheritance rather than introducing a tombstone. */
1020
+ export function persistModelRoute(root, routeId, route) {
1021
+ if (route !== undefined)
1022
+ assertCompleteModelRoute(routeId, route);
1023
+ else if (routeId.trim() === '')
1024
+ throw new Error('model route id must be a non-empty string');
1025
+ const path = configPathFor(root);
1026
+ ensureDir(root);
1027
+ withConfigPathLock(path, (lockedPath) => {
1028
+ const raw = readJsonIfExists(lockedPath) ?? {};
1029
+ const routes = copyRouteRecord((raw.modelRoutes ?? {}));
1030
+ if (route === undefined)
1031
+ delete routes[routeId];
1032
+ else {
1033
+ const current = routes[routeId];
1034
+ setRouteRecord(routes, routeId, {
1035
+ ...(current !== null && typeof current === 'object' && !Array.isArray(current) ? current : {}),
1036
+ ...route,
1037
+ models: { ...route.models },
1038
+ });
1039
+ }
1040
+ const next = { ...raw };
1041
+ if (Object.keys(routes).length === 0)
1042
+ delete next.modelRoutes;
1043
+ else
1044
+ next.modelRoutes = routes;
1045
+ atomicWriteJson(lockedPath, next);
1046
+ });
1047
+ }
933
1048
  /** Mutate one exact config root. Setup uses this for profile and explicit
934
1049
  * project targets that cannot be represented by the user/project Scope enum. */
935
1050
  export function updateConfigAtRoot(root, mutate) {
@@ -72,7 +72,7 @@ export const BINDING_CATALOG = Object.freeze([
72
72
  b('crtr.tmux.node.previous', 'Global tmux', 'Previous node', 'Focus the previous canvas node.', ['alt+['], 'host-global', ['tmux.root']),
73
73
  b('crtr.tmux.inbox.toggle', 'Global tmux', 'Toggle inbox', 'Open or close the human inbox in the focused viewer.', ['alt+i'], 'host-global', ['tmux.root']),
74
74
  ...attachEntry('crtr.tmux.menu.attach.detach', 'crtr.attach.detach', 'Detach', 'Detach from the node.', ['d'], ['ctrl+d']),
75
- ...attachEntry('crtr.tmux.menu.attach.clear-or-detach', 'crtr.attach.clear-or-detach', 'Clear input (detach if empty)', 'Clear input or detach when input is empty.', ['c'], ['ctrl+c']),
75
+ ...attachEntry('crtr.tmux.menu.attach.clear-or-detach', 'crtr.attach.clear-or-detach', 'Clear input (cancel agent on repeat)', 'Clear the input; press again within a second to cancel the running agent turn. Detach lives on its own binding.', ['c'], ['ctrl+c']),
76
76
  ...attachEntry('crtr.tmux.menu.attach.graph.toggle', 'crtr.attach.graph.toggle', 'Toggle graph', 'Open or close the graph overlay.', ['g'], ['alt+g']),
77
77
  ...attachEntry('crtr.tmux.menu.attach.help.toggle', 'crtr.attach.help.toggle', 'Help', 'Open the attach help overlay.', ['?'], ['alt+shift+k']),
78
78
  ...attachEntry('crtr.tmux.menu.attach.inbox.toggle', 'crtr.attach.inbox.toggle', 'Toggle inbox', 'Open or close the human inbox surface in this viewer.', ['i'], []),
@@ -190,13 +190,13 @@ function setupBindings() {
190
190
  b('crtr.setup.tab-next', 'Setup', 'Next tab', 'Select the next setup tab.', ['tab', 'right'], 'terminal', SETUP_TAB_CONTEXTS),
191
191
  b('crtr.setup.tab-previous', 'Setup', 'Previous tab', 'Select the previous setup tab.', ['shift+tab', 'left'], 'terminal', SETUP_TAB_CONTEXTS),
192
192
  b('crtr.setup.open', 'Setup', 'Open', 'Open the selected setup item.', ['enter'], 'terminal', ['setup.login']),
193
- b('crtr.setup.list-down', 'Setup', 'Move down', 'Move to the next setup item.', ['j', 'down'], 'terminal', ['setup.list', 'setup.models', 'setup.models.dimension-picker']),
194
- b('crtr.setup.list-up', 'Setup', 'Move up', 'Move to the previous setup item.', ['k', 'up'], 'terminal', ['setup.list', 'setup.models', 'setup.models.dimension-picker']),
193
+ b('crtr.setup.list-down', 'Setup', 'Move down', 'Move to the next setup item.', ['j', 'down'], 'terminal', ['setup.list', 'setup.models', 'setup.models.dimension-picker', 'setup.models.route-form']),
194
+ b('crtr.setup.list-up', 'Setup', 'Move up', 'Move to the previous setup item.', ['k', 'up'], 'terminal', ['setup.list', 'setup.models', 'setup.models.dimension-picker', 'setup.models.route-form']),
195
195
  b('crtr.setup.toggle', 'Setup', 'Toggle', 'Toggle the selected setup item.', ['space'], 'terminal', ['setup.list', 'setup.keybindings']),
196
196
  b('crtr.setup.details', 'Setup', 'Show details', 'Show details for the selected item.', ['?'], 'terminal', ['setup.list']),
197
197
  b('crtr.setup.advance', 'Setup', 'Advance', 'Advance the setup flow.', ['enter'], 'terminal', ['setup.list']),
198
- b('crtr.setup.confirm.accept', 'Setup', 'Confirm', 'Accept the setup confirmation.', ['y', 'enter'], 'terminal', ['setup.confirm']),
199
- b('crtr.setup.confirm.back', 'Setup', 'Go back', 'Cancel the setup confirmation.', ['n', 'escape', 'q'], 'terminal', ['setup.confirm'], 'dismiss-modal'),
198
+ b('crtr.setup.confirm.accept', 'Setup', 'Confirm', 'Accept the setup confirmation.', ['y', 'enter'], 'terminal', ['setup.confirm', 'setup.models.confirm']),
199
+ b('crtr.setup.confirm.back', 'Setup', 'Go back', 'Cancel the setup confirmation.', ['n', 'escape', 'q'], 'terminal', ['setup.confirm', 'setup.models.confirm'], 'dismiss-modal'),
200
200
  b('crtr.setup.keybindings.edit', 'Setup', 'Edit binding', 'Edit the selected binding.', ['enter'], 'terminal', ['setup.keybindings']),
201
201
  b('crtr.setup.keybindings.commit-edit', 'Setup', 'Commit edit', 'Commit the active binding edit.', ['enter'], 'terminal', ['setup.keybindings.edit']),
202
202
  b('crtr.setup.keybindings.cancel-edit', 'Setup', 'Cancel edit', 'Cancel the active binding edit.', ['escape'], 'terminal', ['setup.keybindings.edit'], 'dismiss-modal'),
@@ -212,10 +212,10 @@ function setupBindings() {
212
212
  b('crtr.setup.keybindings.filter-clear', 'Setup', 'Clear filter', 'Clear the keybinding filter.', ['escape'], 'terminal', ['setup.keybindings.filter'], 'dismiss-modal'),
213
213
  b('crtr.setup.cell-previous', 'Setup', 'Previous field', 'Select the previous field or setup table cell.', ['h'], 'terminal', ['setup.models', 'setup.keybindings']),
214
214
  b('crtr.setup.cell-next', 'Setup', 'Next field', 'Select the next field or setup table cell.', ['l'], 'terminal', ['setup.models', 'setup.keybindings']),
215
- b('crtr.setup.models.open', 'Setup', 'Edit model', 'Edit the selected model cell.', ['enter'], 'terminal', ['setup.models']),
215
+ b('crtr.setup.models.open', 'Setup', 'Edit model', 'Edit the selected model cell or route field.', ['enter'], 'terminal', ['setup.models', 'setup.models.route-form']),
216
216
  b('crtr.setup.models.scope-next', 'Setup', 'Next model config scope', 'Edit the next applicable user, profile, or project config.', ['s'], 'terminal', ['setup.models']),
217
217
  b('crtr.setup.models.commit', 'Setup', 'Commit pick', 'Commit the provider, strength, model, or thinking-level pick.', ['enter'], 'terminal', ['setup.models.picker', 'setup.models.dimension-picker']),
218
- b('crtr.setup.models.cancel', 'Setup', 'Cancel pick', 'Cancel the provider, strength, model, or thinking-level picker.', ['escape'], 'terminal', ['setup.models.picker', 'setup.models.dimension-picker'], 'dismiss-modal'),
218
+ b('crtr.setup.models.cancel', 'Setup', 'Cancel pick', 'Cancel the model picker or route form.', ['escape'], 'terminal', ['setup.models.picker', 'setup.models.dimension-picker', 'setup.models.route-form'], 'dismiss-modal'),
219
219
  ];
220
220
  }
221
221
  export const BINDING_IDS = new Set(BINDING_CATALOG.map((definition) => definition.id));
@@ -246,4 +246,6 @@ export const SAFETY_REQUIREMENTS = Object.freeze([
246
246
  { context: 'setup.models', capability: 'leave-screen' },
247
247
  { context: 'setup.models.picker', capability: 'dismiss-modal' },
248
248
  { context: 'setup.models.dimension-picker', capability: 'dismiss-modal' },
249
+ { context: 'setup.models.route-form', capability: 'dismiss-modal' },
250
+ { context: 'setup.models.confirm', capability: 'dismiss-modal' },
249
251
  ]);
@@ -19,6 +19,8 @@ function parseSpec(spec) {
19
19
  function synthesizeRoutes(ladders) {
20
20
  const route = (family) => {
21
21
  const providerId = ladders[family].strong.slice(0, ladders[family].strong.indexOf('/'));
22
+ // `credentialSource` orders routes. Auth and subscription-pool lookup are
23
+ // provider-wide, keyed by providerId, so this does not select credentials.
22
24
  return { family, credentialSource: 'default', providerId, models: ladders[family] };
23
25
  };
24
26
  return { anthropic: route('anthropic'), openai: route('openai') };
@@ -32,7 +34,10 @@ function strengths(requested, configured) {
32
34
  /** Expand config-derived route order only. Availability never affects rank. */
33
35
  export function expandModelCandidates(request, cwd, profileId) {
34
36
  const cfg = readMergedLaunchConfig(cwd, profileId ?? null);
35
- const routes = cfg.modelRoutes && Object.keys(cfg.modelRoutes).length > 0 ? cfg.modelRoutes : synthesizeRoutes(cfg.modelLadders);
37
+ // Complete routes are the effective contract. Sparse entries written by an
38
+ // older crouter release remain readable for one cutover release only.
39
+ const declaredRoutes = { ...(cfg.modelRoutes ?? {}), ...(cfg.legacyModelRoutes ?? {}) };
40
+ const routes = Object.keys(declaredRoutes).length > 0 ? declaredRoutes : synthesizeRoutes(cfg.modelLadders);
36
41
  const routeEntries = Object.entries(routes);
37
42
  const fallback = cfg.modelRouting?.strengthFallback ?? [readRotationConfig().defaultFallbackStrength ?? 'strong'];
38
43
  const declarationFamilies = routeEntries.map(([, route]) => route.family).filter((value, index, all) => all.indexOf(value) === index);
@@ -79,7 +84,9 @@ export function expandModelCandidates(request, cwd, profileId) {
79
84
  return;
80
85
  if (concrete && parsed.modelId !== concrete.modelId)
81
86
  return;
82
- const key = `${parsed.providerId}/${parsed.modelId}:${parsed.thinkingLevel ?? ''}`;
87
+ // Route identity is meaningful: two panel-managed routes can intentionally
88
+ // share a provider/model while carrying distinct credential-source order.
89
+ const key = JSON.stringify([routeId, parsed.providerId, parsed.modelId, parsed.thinkingLevel]);
83
90
  if (seen.has(key))
84
91
  return;
85
92
  seen.add(key);
@@ -91,7 +98,7 @@ export function expandModelCandidates(request, cwd, profileId) {
91
98
  if (concrete) {
92
99
  const [routeId, route] = concreteRoute ?? [
93
100
  'concrete',
94
- { family: requestedFamily, credentialSource: 'default', providerId: concrete.providerId, models: { [requestedStrength]: request.spec } },
101
+ { family: requestedFamily, credentialSource: 'default' },
95
102
  ];
96
103
  append(routeId, route, requestedStrength, request.spec);
97
104
  }
@@ -25,4 +25,4 @@ export declare function docListingDirs(byName: ReadonlyMap<string, MemoryDoc>, n
25
25
  * `filterDelivered` additionally drops members already exposed at preview rank
26
26
  * or higher. A subdirectory always keeps its row — it advertises a subtree, so
27
27
  * an omitted preview leaves the bare name. */
28
- export declare function renderDirListing(byName: ReadonlyMap<string, MemoryDoc>, dir: string, target: ExposureTarget | null, filterDelivered: boolean): string[];
28
+ export declare function renderDirListing(byName: ReadonlyMap<string, MemoryDoc>, dir: string, target: ExposureTarget | null, filterDelivered: boolean, onPreview?: (doc: MemoryDoc) => void): string[];
@@ -93,7 +93,7 @@ function line(name, suffix) {
93
93
  * `filterDelivered` additionally drops members already exposed at preview rank
94
94
  * or higher. A subdirectory always keeps its row — it advertises a subtree, so
95
95
  * an omitted preview leaves the bare name. */
96
- export function renderDirListing(byName, dir, target, filterDelivered) {
96
+ export function renderDirListing(byName, dir, target, filterDelivered, onPreview) {
97
97
  const node = treeFor(byName).node(dir);
98
98
  if (node === null)
99
99
  return [];
@@ -102,10 +102,14 @@ export function renderDirListing(byName, dir, target, filterDelivered) {
102
102
  const suffix = previewSuffix(member.document, target, filterDelivered);
103
103
  if (suffix === null)
104
104
  continue;
105
+ onPreview?.(member.document);
105
106
  lines.push(line(member.name, suffix));
106
107
  }
107
108
  for (const sub of node.childDirectories) {
108
- lines.push(line(sub.name, sub.document === null ? null : previewSuffix(sub.document, target, filterDelivered)));
109
+ const suffix = sub.document === null ? null : previewSuffix(sub.document, target, filterDelivered);
110
+ if (sub.document !== null && suffix !== null)
111
+ onPreview?.(sub.document);
112
+ lines.push(line(sub.name, suffix));
109
113
  }
110
114
  return lines;
111
115
  }
@@ -1,8 +1,62 @@
1
+ import { type MemoryDoc, type MemoryTarget } from '../memory-resolver.js';
1
2
  import { type ContextExposureState, type ExposureTarget } from './injected-store.js';
3
+ import { type Rung, type SubstrateDoc } from './schema.js';
2
4
  import type { NodeConfigSubject } from './subject-fields.js';
5
+ export type DeliveryCause = 'directory listing attached to content delivery' | 'memory-read companion of content delivery';
6
+ export interface AccountedDelivery {
7
+ rung: Rung;
8
+ cause?: DeliveryCause;
9
+ /** Resolver descriptor for accounting-only attachment rows. */
10
+ source?: MemoryDoc;
11
+ }
12
+ /** The renderer's structured delivery ledger. It records precisely the document
13
+ * envelopes and listing lines that rendered, so diagnostics can report the
14
+ * same attachment fixpoint without parsing its text or recreating it. */
15
+ export interface DeliveryAccounting {
16
+ deliveries: Map<string, AccountedDelivery>;
17
+ }
18
+ export declare function deliveryAccounting(): DeliveryAccounting;
19
+ export declare function accountDelivery(accounting: DeliveryAccounting | undefined, doc: Pick<SubstrateDoc, 'path'>, rung: Rung, cause?: DeliveryCause, source?: MemoryDoc): void;
20
+ /** How one content delivery's attachments render, and the ledger + corpus the
21
+ * whole fixpoint runs against. ONE options object flows through every nested
22
+ * companion delivery, so `dirs` accumulates across the fixpoint and a
23
+ * companion inherits its parent's listing mode and memory target. */
24
+ export interface ContentAttachmentOptions {
25
+ /** Subject for companion `memory-read` gate evaluation. */
26
+ subject: NodeConfigSubject | null;
27
+ /** The one exposure ledger this fixpoint reads and registers against. */
28
+ target: ExposureTarget;
29
+ /** The corpus every directory question answers from — the caller's own
30
+ * listing corpus, never an ambient re-resolution. */
31
+ byName: ReadonlyMap<string, MemoryDoc>;
32
+ /** What companion `memory-read` routes plan against. Absent = the ambient
33
+ * cwd/profile target and this session's cached event corpus. */
34
+ memoryTarget?: MemoryTarget;
35
+ /** `blocks` attaches each disclosed listing as its own `<memory-listing>`.
36
+ * `tree` attaches none and accumulates the disclosed directories into
37
+ * `dirs` instead, for a caller that renders those members itself (the boot
38
+ * catalog tree) and registers them as it renders. */
39
+ listings: 'blocks' | 'tree';
40
+ /** Accumulator for `tree` mode: every directory this fixpoint disclosed. */
41
+ dirs?: string[];
42
+ /** An explicit `crtr memory read`: the read doc's OWN directory renders
43
+ * unfiltered and returns as `listing` rather than attaching as a block. */
44
+ explicit?: boolean;
45
+ /** Optional structured mirror of the actual emitted document deliveries. */
46
+ accounting?: DeliveryAccounting;
47
+ /** Why this winner was reached through the current content attachment walk. */
48
+ deliveryCause?: DeliveryCause;
49
+ }
50
+ /** The attachment semantics of a content delivery. Content is read-equivalent:
51
+ * it exposes its own/enclosing directory listings and fires memory-read routes.
52
+ * The exposure ledger makes this finite even when companion routes cycle. */
53
+ export declare function contentReadAttachments(doc: Pick<SubstrateDoc, 'name' | 'path' | 'body'>, opts: ContentAttachmentOptions): {
54
+ blocks: string[];
55
+ listing?: string[];
56
+ };
3
57
  /** Surface docs whose `read` entries match a successfully read file, using
4
58
  * the caller-supplied daemon snapshot subject. */
5
- export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, target?: ExposureTarget): string;
59
+ export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, target?: ExposureTarget, accounting?: DeliveryAccounting): string;
6
60
  /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
7
61
  * lookup. The shared exposure target prevents a later read from repeating a
8
62
  * document already loaded here.
@@ -11,24 +65,26 @@ export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, r
11
65
  * relationship cap BEFORE filtering, ordering, rendering, and exposure
12
66
  * registration. A later explicit `crtr memory read` can still raise a document
13
67
  * that workspace-open disclosed at a lower rung. */
14
- export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null, target?: ExposureTarget): string;
68
+ export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null, target?: ExposureTarget, accounting?: DeliveryAccounting): string;
15
69
  /** Inner `<memory>` blocks for the memory-read event: docs whose
16
70
  * `memory-read` entries match the doc a `crtr memory read` just resolved
17
71
  * (known by its exact canonical `name` — no mount-stripped or other alias;
18
72
  * `excludeRealpath` keeps the resolved doc from delivering itself). Returns
19
73
  * unwrapped blocks — the read leaf composes one `<auto-loaded-context>`
20
74
  * envelope from these plus the directory listings. */
21
- export declare function memoryReadDocBlocks(subject: NodeConfigSubject | null, excludeRealpath: string, name: string, target: ExposureTarget): string[];
75
+ export declare function memoryReadDocBlocks(excludeRealpath: string, name: string, opts: Omit<ContentAttachmentOptions, 'byName'> & {
76
+ byName?: ReadonlyMap<string, MemoryDoc>;
77
+ }): string[];
22
78
  /** Surface docs whose `command` entries match an executed bash command.
23
79
  * The corpus is the resolved cwd/profile set; a command has no file from which
24
80
  * to discover enclosing project stores. */
25
- export declare function renderOnCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget): string;
81
+ export declare function renderOnCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget, accounting?: DeliveryAccounting): string;
26
82
  /** Surface docs whose `pre-command` entries match a bash command that has NOT
27
83
  * run yet. The exact twin of the post-execution renderer above, and its empty
28
84
  * string is load-bearing: `''` means every matching doc is already exposed at
29
85
  * its matching rung or above, which is the release — the caller lets the
30
86
  * command through. */
31
- export declare function renderPreCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget): string;
87
+ export declare function renderPreCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget, accounting?: DeliveryAccounting): string;
32
88
  /** Does ANY doc in this session's corpus carry a `pre-command` entry? The
33
89
  * short-circuit a pre-execution caller checks first: with no such doc — the
34
90
  * common case — no command need ever pay for a subject lookup. */