@frontera-sdk/cli 1.52.39 → 1.52.41

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 (235) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +49 -5
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -8,24 +8,104 @@
8
8
  * extracted text or passages is still left off (`local-portability.ts`).
9
9
  */
10
10
 
11
+ import type { LocalCredentials } from './local-state'
12
+
11
13
  type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
12
14
 
15
+ /** One file into a local Media Set, as the runtime's owner (multipart, so not through a JSON call). */
16
+ export function mediaUpload(serviceUrl: string, creds: LocalCredentials, fetcher: typeof fetch = fetch) {
17
+ return async (mediaSetId: string, file: { bytes: Uint8Array; mime: string; filename: string; logicalPath: string }): Promise<{ status: number; body: unknown }> => {
18
+ const form = new FormData()
19
+ form.set('file', new File([new Blob([file.bytes.slice().buffer as ArrayBuffer])], file.filename, { type: file.mime }))
20
+ form.set('logicalPath', file.logicalPath)
21
+ const res = await fetcher(new URL(`/v1/media/sets/${encodeURIComponent(mediaSetId)}/items`, serviceUrl), {
22
+ method: 'POST',
23
+ headers: { authorization: `Bearer ${creds.session}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId },
24
+ body: form,
25
+ })
26
+ const text = await res.text()
27
+ let body: unknown = text
28
+ try {
29
+ body = text ? JSON.parse(text) : null
30
+ } catch {
31
+ // Keep the text.
32
+ }
33
+ return { status: res.status, body }
34
+ }
35
+ }
36
+
13
37
  const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as unknown
14
38
 
39
+ export interface MediaSetsReady {
40
+ /** Why one could not be made or read, or null. */
41
+ why: string | null
42
+ /**
43
+ * Each set's catalog dataset on the runtime, by the set's API name. It is
44
+ * `media_<set>` unless that name was taken when the set was made (a sample
45
+ * dataset of the same name): the runtime then names the catalog otherwise.
46
+ */
47
+ catalogs: Record<string, string>
48
+ /** What could not be done and is said, not stopped on: a set the workspace could not be granted. */
49
+ warnings: string[]
50
+ }
51
+
52
+ /**
53
+ * The workspace's `read` + `write` grant on a stand-in set, so `local dev`
54
+ * can browse it and put placeholder files in it. A runtime grants a set made
55
+ * from a workspace to it as it makes it; a set an earlier `local dev` made
56
+ * on an older runtime has none, and its `/items` answers 404. A refusal is a
57
+ * warning: the set's type is applied, without rows here.
58
+ */
59
+ async function grantWorkspace(call: Call, apiName: string, id: string, workspaceId: string): Promise<string | null> {
60
+ const granted = await call('POST', `/v1/media/sets/${encodeURIComponent(id)}/grants`, { workspaceId, permissions: ['read', 'write'] })
61
+ if (granted.status < 400) return null
62
+ const message = (granted.body as { message?: unknown } | null)?.message
63
+ return `the workspace couldn’t be granted the stand-in Media Set ${apiName} (${granted.status}${typeof message === 'string' ? ` ${message.slice(0, 160)}` : ''}), so its files can’t be listed or added here`
64
+ }
65
+
15
66
  /**
16
- * Make each Media Set the runtime lacks, by API name, as the runtime's owner.
17
- * Returns why one could not be made, or null.
67
+ * Make each Media Set the runtime lacks, by API name, as the runtime's owner,
68
+ * and read the dataset each one's catalog is. The project's own runtime only
69
+ * (`local dev`): each set it makes, and each it finds the workspace can't
70
+ * read, is granted to the workspace (`workspaceId`).
18
71
  */
19
- export async function ensureMediaSets(sets: Record<string, string>, call: Call): Promise<string | null> {
72
+ export async function ensureMediaSets(sets: Record<string, string>, call: Call, workspaceId?: string): Promise<MediaSetsReady> {
73
+ const catalogs: Record<string, string> = {}
74
+ const warnings: string[] = []
20
75
  const names = Object.keys(sets)
21
- if (!names.length) return null
76
+ if (!names.length) return { why: null, catalogs, warnings }
22
77
  const listed = await call('GET', '/v1/media/sets')
23
- if (listed.status >= 400) return `the runtime’s Media Sets couldn’t be read (${listed.status})`
24
- const here = new Set(((dataOf(listed.body) as { mediaSets?: Array<{ apiName?: string }> } | null)?.mediaSets ?? []).map((s) => s.apiName))
78
+ if (listed.status >= 400) return { why: `the runtime’s Media Sets couldn’t be read (${listed.status})`, catalogs, warnings }
79
+ const here = new Map(((dataOf(listed.body) as { mediaSets?: Array<{ id?: string; apiName?: string }> } | null)?.mediaSets ?? []).map((s) => [s.apiName, s.id] as const))
25
80
  for (const apiName of names) {
26
- if (here.has(apiName)) continue
27
- const made = await call('POST', '/v1/media/sets', { apiName, displayName: sets[apiName], description: 'An empty stand-in for the platform’s Media Set: its files stay on the platform.' })
28
- if (made.status >= 400) return `the Media Set ${apiName} couldn’t be made (${made.status} ${JSON.stringify(made.body).slice(0, 160)})`
81
+ const id = here.get(apiName)
82
+ if (!id) {
83
+ const made = await call('POST', '/v1/media/sets', { apiName, displayName: sets[apiName], description: 'A stand-in for the platform’s Media Set: its files stay on the platform. `frontera local dev` puts placeholder files in it while it is empty.' })
84
+ if (made.status >= 400) return { why: `the Media Set ${apiName} couldn’t be made (${made.status} ${JSON.stringify(made.body).slice(0, 160)})`, catalogs, warnings }
85
+ const created = dataOf(made.body) as { mediaSet?: { id?: unknown }; dataset?: { apiName?: unknown } } | null
86
+ const dataset = created?.dataset?.apiName
87
+ if (typeof dataset === 'string') catalogs[apiName] = dataset
88
+ // A runtime that grants a new set to its workspace already has; an older one has not, and this is the same grant.
89
+ const madeId = created?.mediaSet?.id
90
+ if (workspaceId && typeof madeId === 'string') {
91
+ const warning = await grantWorkspace(call, apiName, madeId, workspaceId)
92
+ if (warning) warnings.push(warning)
93
+ }
94
+ continue
95
+ }
96
+ // Made by an earlier run on an older runtime, without the workspace's grant: its items answer 404.
97
+ if (workspaceId) {
98
+ const items = await call('GET', `/v1/media/sets/${encodeURIComponent(id)}/items`)
99
+ if (items.status === 404) {
100
+ const warning = await grantWorkspace(call, apiName, id, workspaceId)
101
+ if (warning) warnings.push(warning)
102
+ }
103
+ }
104
+ // Made earlier: its catalog, made now if the set predates catalogs.
105
+ let read = await call('GET', `/v1/media/sets/${encodeURIComponent(id)}/dataset`)
106
+ if (read.status < 400 && !(dataOf(read.body) as { dataset?: unknown } | null)?.dataset) read = await call('POST', `/v1/media/sets/${encodeURIComponent(id)}/dataset`)
107
+ const dataset = read.status < 400 ? (dataOf(read.body) as { dataset?: { apiName?: unknown } } | null)?.dataset?.apiName : undefined
108
+ if (typeof dataset === 'string') catalogs[apiName] = dataset
29
109
  }
30
- return null
110
+ return { why: null, catalogs, warnings }
31
111
  }
@@ -0,0 +1,89 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { PROVIDER_MODELS } from './local-seed'
7
+
8
+ /**
9
+ * A workflow `llm` node's `temperature` that its model refuses (e2e G5).
10
+ *
11
+ * The runtime's model catalog does not record which sampling parameters a
12
+ * model takes, so such a node passes `validate` and `apply`, and its runs
13
+ * fail at the step with `model_parameter`. The local catalog's own models
14
+ * are known here (`PROVIDER_MODELS`): the ones served directly by Anthropic
15
+ * that refuse `temperature` are said at apply, as a validation warning of the
16
+ * node, with the fix.
17
+ *
18
+ * Only direct Anthropic models: through OpenRouter a parameter can be handled
19
+ * differently, and what is not known is not said.
20
+ */
21
+
22
+ /** How a model takes `temperature`: any value refused, or any but its default (1). */
23
+ type TemperatureRule = 'refused' | 'default-only'
24
+
25
+ /** Anthropic's models by id, newest first: those that refuse sampling parameters. */
26
+ const ANTHROPIC_TEMPERATURE: Array<{ match: RegExp; rule: TemperatureRule }> = [
27
+ { match: /^claude-(sonnet|haiku)-5-5\b/, rule: 'default-only' },
28
+ { match: /^claude-(fable-5|mythos-5|opus-5|opus-4-[78]|sonnet-5)(-|$)/, rule: 'refused' },
29
+ ]
30
+
31
+ /** The model a catalog slug names, when it is one the local catalog adds directly from Anthropic. */
32
+ function directAnthropic(slug: string): { name: string; rule: TemperatureRule } | null {
33
+ const model = PROVIDER_MODELS.anthropic?.find((m) => m.slug === slug)
34
+ if (!model) return null
35
+ const rule = ANTHROPIC_TEMPERATURE.find((r) => r.match.test(model.modelId))?.rule
36
+ return rule ? { name: model.name.replace(/\s*\([^)]*\)$/, ''), rule } : null
37
+ }
38
+
39
+ /** Whether `temperature` set to `value` is refused by the model `slug` names, with the model's name; null when it is taken or not known. */
40
+ export function temperatureRefusal(slug: string, value: unknown): { name: string; rule: TemperatureRule } | null {
41
+ const known = directAnthropic(slug)
42
+ if (!known) return null
43
+ if (known.rule === 'default-only' && value === 1) return null
44
+ return known
45
+ }
46
+
47
+ /** The `llm` nodes of a workflow at any depth (branches, loops, parallel arms): id, the model reference and the temperature set. */
48
+ export function llmNodes(doc: unknown): Array<{ id: string; model: string | null; temperature: unknown }> {
49
+ const out: Array<{ id: string; model: string | null; temperature: unknown }> = []
50
+ const visit = (v: unknown) => {
51
+ if (Array.isArray(v)) return v.forEach(visit)
52
+ if (!v || typeof v !== 'object') return
53
+ const o = v as Record<string, unknown>
54
+ if (o.kind === 'llm' && typeof o.id === 'string') {
55
+ const params = (o.params ?? {}) as Record<string, unknown>
56
+ out.push({ id: o.id, model: typeof o.resourceId === 'string' ? o.resourceId : null, temperature: params.temperature })
57
+ }
58
+ for (const [k, x] of Object.entries(o)) if (k !== 'trigger' && k !== 'layout') visit(x)
59
+ }
60
+ visit((doc as Record<string, unknown> | null)?.nodes ?? [])
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * The warnings for a workflow file's `llm` nodes whose model refuses their
66
+ * `temperature`, as `workflow push` prints a node's validation warning.
67
+ * `models` maps catalog slugs to ids, so a file whose references were
68
+ * resolved to this runtime's ids is read too.
69
+ */
70
+ export function llmParameterNotes(root: string, file: string, models?: ReadonlyMap<string, string>): string[] {
71
+ let doc: unknown
72
+ try {
73
+ const text = readFileSync(join(root, file), 'utf8')
74
+ doc = /\.json$/.test(file) ? JSON.parse(text) : parseYaml(text)
75
+ } catch {
76
+ return []
77
+ }
78
+ const slugOf = new Map([...(models ?? [])].map(([slug, id]) => [id, slug]))
79
+ const lines: string[] = []
80
+ for (const node of llmNodes(doc)) {
81
+ if (node.temperature === undefined || !node.model) continue
82
+ const ref = node.model.startsWith('model/') ? node.model.slice('model/'.length) : (slugOf.get(node.model) ?? node.model)
83
+ const refused = temperatureRefusal(ref, node.temperature)
84
+ if (!refused) continue
85
+ const what = refused.rule === 'refused' ? 'doesn’t take `temperature`' : 'takes no `temperature` but its default, 1'
86
+ lines.push(` warning: node "${node.id}": the model ${ref} (${refused.name}) ${what}, so every run fails at this step (\`model_parameter\`): remove \`temperature\` from its params`)
87
+ }
88
+ return lines
89
+ }
@@ -6,7 +6,7 @@ import { parse as parseYaml } from 'yaml'
6
6
  import { isIgnoredName } from './local-dev-sync'
7
7
  import type { LocalCredentials } from './local-state'
8
8
  import { objectSetNames, rows, type ObjectSetRow } from './local-sync-remote'
9
- import { parseRef, referencesIn, toIds } from './local-sync-refs'
9
+ import { lookupRef, parseRef, referencesIn, refName, toIds } from './local-sync-refs'
10
10
 
11
11
  /**
12
12
  * Saved object sets on the local runtime, from `blueprint/object-sets/`.
@@ -82,6 +82,8 @@ export interface ObjectSetsApplied {
82
82
  warnings: string[]
83
83
  /** Sets created again under a new id: what names them is applied again. */
84
84
  recreated: string[]
85
+ /** Each project set's id on the runtime, by its file's reference (`open-work-orders`): what `status` shows. */
86
+ ids: Record<string, string>
85
87
  }
86
88
 
87
89
  /**
@@ -90,7 +92,7 @@ export interface ObjectSetsApplied {
90
92
  * left off too, with why.
91
93
  */
92
94
  export async function applyObjectSets(root: string, call: Fetcher, leftOff: ReadonlySet<string> = new Set()): Promise<ObjectSetsApplied> {
93
- const out: ObjectSetsApplied = { lines: [], warnings: [], recreated: [] }
95
+ const out: ObjectSetsApplied = { lines: [], warnings: [], recreated: [], ids: {} }
94
96
  const files = readObjectSetFiles(root)
95
97
  if (!files.length) return out
96
98
  const listed = await call('GET', '/v1/sync/object-sets')
@@ -124,7 +126,7 @@ export async function applyObjectSets(root: string, call: Fetcher, leftOff: Read
124
126
  continue
125
127
  }
126
128
  // A set naming another waits for that one.
127
- const waits = referencesIn(doc.definition).filter((r) => r.kind === 'objectSet' && !ids.has(r.name))
129
+ const waits = referencesIn(doc.definition).filter((r) => r.kind === 'objectSet' && !lookupRef(ids, 'objectSet', r.name))
128
130
  if (waits.length) {
129
131
  next.push(f)
130
132
  continue
@@ -132,7 +134,9 @@ export async function applyObjectSets(root: string, call: Fetcher, leftOff: Read
132
134
  const { doc: definition } = toIds(doc.definition, { objectSet: ids })
133
135
  const description = typeof doc.description === 'string' ? doc.description : undefined
134
136
  const records = Array.isArray(doc.records) ? (doc.records as Array<string | number>) : []
135
- const have = byRef.get(f.ref)
137
+ // By its file's name, or by its own name as a reference holds it (a pull names the file so).
138
+ const have = byRef.get(f.ref) ?? byRef.get(refName(name))
139
+ if (have && !ids.has(f.ref)) ids.set(f.ref, have.id)
136
140
  let saved: { status: number; body: unknown }
137
141
  if (have && kind === 'dynamic' && have.kind === 'dynamic') {
138
142
  if (have.name === name && (have.description ?? undefined) === description && same(have.definition, definition)) continue
@@ -160,18 +164,26 @@ export async function applyObjectSets(root: string, call: Fetcher, leftOff: Read
160
164
  continue
161
165
  }
162
166
  const row = ((saved.body as { data?: unknown })?.data ?? saved.body) as { id?: string }
163
- if (row?.id) ids.set(f.ref, row.id)
167
+ if (row?.id) {
168
+ ids.set(f.ref, row.id)
169
+ // Named in words elsewhere (`objectSet/Open work orders`): found by its name too.
170
+ if (!ids.has(refName(name))) ids.set(refName(name), row.id)
171
+ }
164
172
  out.lines.push(` saved object set ${f.ref}`)
165
173
  }
166
174
  if (next.length === pending.length) {
167
175
  for (const f of next) {
168
- const missing = referencesIn(f.doc.definition).filter((r) => r.kind === 'objectSet' && !ids.has(r.name)).map((r) => `objectSet/${r.name}`)
176
+ const missing = referencesIn(f.doc.definition).filter((r) => r.kind === 'objectSet' && !lookupRef(ids, 'objectSet', r.name)).map((r) => `objectSet/${r.name}`)
169
177
  out.warnings.push(` warning: saved object set ${f.ref} was not saved here: it names ${missing.join(', ')}, which isn't in the project`)
170
178
  }
171
179
  break
172
180
  }
173
181
  pending = next
174
182
  }
183
+ for (const f of files) {
184
+ const id = ids.get(f.ref)
185
+ if (id) out.ids[f.ref] = id
186
+ }
175
187
  return out
176
188
  }
177
189
 
@@ -15,6 +15,39 @@ import type { LocalCredentials } from './local-state'
15
15
  interface Result {
16
16
  ok: boolean
17
17
  output: string
18
+ /**
19
+ * The `warning:` lines of `output` that are the platform's own findings on
20
+ * the settings (`handoff_needs_inbox`, a placeholder no variable fills):
21
+ * they hold wherever the Agent runs, so they are not adaptations to this
22
+ * runtime.
23
+ */
24
+ validationWarnings?: string[]
25
+ }
26
+
27
+ interface Issue {
28
+ severity?: string
29
+ code?: string
30
+ path?: string
31
+ message?: string
32
+ }
33
+
34
+ /**
35
+ * The issues a saved draft still has, one `warning:` line each, named by the
36
+ * project file and the setting's path
37
+ * (`agents/customer-care/operating.json procedures.3.actions.0`). Saving
38
+ * refuses only a malformed document; a cross-reference error is returned
39
+ * with the warnings and refuses publishing, so it is said too, as that.
40
+ */
41
+ export function operatingIssueLines(slug: string, body: unknown): string[] {
42
+ const data = ((body as { data?: unknown } | null)?.data ?? body) as { issues?: unknown } | null
43
+ const issues = Array.isArray(data?.issues) ? (data.issues as Issue[]) : []
44
+ return issues.map((issue) => {
45
+ const path = typeof issue.path === 'string' ? issue.path : ''
46
+ // The Agent's instructions are its agent.json prompts, not its behavior settings.
47
+ const file = path === 'instructions' || path.startsWith('instructions.') ? `agents/${slug}/agent.json` : `agents/${slug}/operating.json`
48
+ const blocks = issue.severity === 'error' ? ' (publishing is refused until this is fixed)' : ''
49
+ return ` warning: ${file}${path ? ` ${path}` : ''}: ${issue.message ?? issue.code ?? 'see the Agent’s settings'}${blocks}`
50
+ })
18
51
  }
19
52
 
20
53
  function describeError(status: number, body: unknown): string {
@@ -79,7 +112,8 @@ export async function applyOperatingConfig(args: {
79
112
  if (agent.status !== 200 || !id) return { ok: false, output: describeError(agent.status, agent.body) }
80
113
  const put = await call(`/v1/config/agents/${id}/operating`, { method: 'PUT', body: { operatingConfig } })
81
114
  if (put.status >= 400) return { ok: false, output: describeError(put.status, put.body) }
82
- return { ok: true, output: '' }
115
+ const warnings = operatingIssueLines(args.slug, put.body)
116
+ return { ok: true, output: warnings.join('\n'), ...(warnings.length ? { validationWarnings: warnings } : {}) }
83
117
  } catch (err) {
84
118
  return { ok: false, output: `error [UNAVAILABLE]: the local service did not answer: ${(err as Error).message}` }
85
119
  }
@@ -40,30 +40,181 @@ const pair = (p?: { provider: string | null; model: string | null }) => (p?.prov
40
40
 
41
41
  const onOff = (v?: boolean) => (v ? 'on' : 'off')
42
42
 
43
+ type Embedding = { provider: string | null; model: string | null }
44
+
45
+ /**
46
+ * One embedding model through each provider that serves it, in the order a
47
+ * runtime takes them: the vectors are the same, so knowledge search ranks the
48
+ * same whichever one embeds. Gemini's through Vertex, a Gemini key or
49
+ * OpenRouter; OpenAI's small model directly or through OpenRouter.
50
+ */
51
+ const EMBEDDING_FAMILIES: Array<Array<{ provider: string; model: string }>> = [
52
+ [
53
+ { provider: 'vertex', model: 'gemini-embedding-001' },
54
+ { provider: 'google', model: 'gemini-embedding-001' },
55
+ { provider: 'openrouter', model: 'google/gemini-embedding-001' },
56
+ ],
57
+ [
58
+ { provider: 'openai', model: 'text-embedding-3-small' },
59
+ { provider: 'openrouter', model: 'openai/text-embedding-3-small' },
60
+ ],
61
+ ]
62
+
63
+ /**
64
+ * The platform's default embedding model: the one an organization is seeded
65
+ * with, and the one a knowledge base's default threshold is tuned for. A copy
66
+ * of the service's `DEFAULT_EMBEDDING_PROVIDER` / `DEFAULT_EMBEDDING_MODEL`
67
+ * (packages/service/src/internal/embedding-defaults.ts), without the
68
+ * deployment's environment override, which the CLI cannot see.
69
+ */
70
+ export const PLATFORM_DEFAULT_EMBEDDING = { provider: 'vertex', model: 'gemini-embedding-001' } as const
71
+
72
+ /** The platform's default knowledge threshold (a maximum cosine distance: lower is stricter). */
73
+ export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.5
74
+
75
+ /** The threshold a local runtime searches a knowledge base with when it embeds with another model than the one the default is tuned for. */
76
+ export const WIDENED_KNOWLEDGE_THRESHOLD = 0.8
77
+
78
+ /**
79
+ * The threshold every knowledge base searches with on the keyless local
80
+ * embeddings (`local-embeddings.ts`), whatever its file sets: a threshold is
81
+ * tuned for a model's distances, and these are on another scale.
82
+ *
83
+ * Derived from how those vectors behave. Each word is a ±1 at a hashed
84
+ * position, so the cosine of a question of q words and a passage of c words
85
+ * is about (the words they share) / √(q·c), and a text that shares none
86
+ * sits at a distance of about 1 (orthogonal). One shared word in a 6-word
87
+ * question and a 2,048-character chunk (some 300 words) is a cosine of
88
+ * 1/√1800 ≈ 0.024, a distance of 0.976: the platform's 0.5, or the 0.8 a
89
+ * model's base is widened to, cuts it, and nearly every short question.
90
+ * 0.99 keeps a passage that shares a word with the question in chunks of up
91
+ * to some 1,600 words (q·c ≤ 10⁴, cosine ≥ 0.01) and leaves out the ones
92
+ * that share none; the base's top-K then keeps the passages that share the most.
93
+ * A word hashed onto another's position can let in a passage sharing none
94
+ * (the hashing's noise is about one such word), so this is approximate
95
+ * retrieval, said as that. Never used for a model's embeddings.
96
+ */
97
+ export const KEYLESS_KNOWLEDGE_THRESHOLD = 0.99
98
+
99
+ /**
100
+ * What a reader is told where knowledge search runs on the keyless local
101
+ * embeddings (`local status`, `knowledge get` and `search`, the apply's
102
+ * warning): the retrieval is approximate, and what gives the platform's.
103
+ */
104
+ export const KEYLESS_RETRIEVAL_NOTE = `retrieval here runs on approximate local embeddings (words hashed into vectors): a passage is found by the words it shares with the question, not by its meaning, and each knowledge base searches with threshold ${KEYLESS_KNOWLEDGE_THRESHOLD}, set for these vectors (its file keeps its own threshold for the workspace). A key the provider accepts for an embedding model gives the platform's retrieval: \`frontera local key-set <provider>\` (openai or openrouter; google when the workspace embeds with Gemini's model), then \`frontera local stop\` and \`frontera local start\`; the next \`frontera local dev\` sets each base's threshold for that model`
105
+
106
+ /** How a difference line says the runtime refused the workspace's model through this computer's key (`mirrorOrgSettings`). */
107
+ const KEY_REFUSED = /; the runtime refused it through this computer’s (\S+) key/
108
+
109
+ /** The provider's name as a key is named, for the line that says which key embeds. */
110
+ const KEY_NAME: Record<string, string> = { google: 'Gemini', openrouter: 'OpenRouter', openai: 'OpenAI', vertex: 'Vertex' }
111
+
112
+ const familyOf = (e?: Embedding | null) => (e?.provider && e.model ? EMBEDDING_FAMILIES.find((f) => f.some((m) => m.provider === e.provider && m.model === e.model)) : undefined)
113
+
114
+ /** The choices that embed as this one does: itself first, then the same model through other providers. */
115
+ export function embeddingEquivalents(e: Embedding): Array<{ provider: string; model: string }> {
116
+ if (!e.provider || !e.model) return []
117
+ const self = { provider: e.provider, model: e.model }
118
+ return [self, ...(familyOf(e) ?? []).filter((m) => m.provider !== self.provider || m.model !== self.model)]
119
+ }
120
+
121
+ /** Whether two choices embed alike: the same choice, or the same model through another provider. */
122
+ export function sameEmbedding(a?: Embedding | null, b?: Embedding | null): boolean {
123
+ if ((a?.provider ?? null) === (b?.provider ?? null) && (a?.model ?? null) === (b?.model ?? null)) return true
124
+ const family = familyOf(a)
125
+ return !!family && family === familyOf(b)
126
+ }
127
+
43
128
  /**
44
129
  * Where the runtime's settings differ from the workspace's, one line each,
45
130
  * the timezone left out (`local dev` sets it). A flag or setting only one
46
131
  * side reports (an older runtime) is left out too: nothing to compare.
47
132
  */
48
- export function settingsDifferences(workspace: OrgSettings, local: OrgSettings): string[] {
133
+ export function settingsDifferences(workspace: OrgSettings, local: OrgSettings, opts: { localEmbeddings?: boolean } = {}): string[] {
49
134
  const out: string[] = []
50
135
  const differs = (a: unknown, b: unknown) => JSON.stringify(a ?? null) !== JSON.stringify(b ?? null)
51
136
  const both = (k: keyof OrgSettings) => k in workspace && k in local
52
137
  if (both('computerMode') && differs(workspace.computerMode, local.computerMode)) out.push(`Computer mode is ${onOff(workspace.computerMode)} in the workspace and ${onOff(local.computerMode)} here`)
53
138
  if (both('memoryTree') && differs(workspace.memoryTree, local.memoryTree)) out.push(`The memory tree is ${onOff(workspace.memoryTree)} in the workspace and ${onOff(local.memoryTree)} here`)
54
- if (both('embedding') && differs(workspace.embedding, local.embedding)) out.push(`Embeddings use ${pair(workspace.embedding)} in the workspace and ${pair(local.embedding)} here, so knowledge search ranks differently`)
139
+ // The keyless local embeddings are set as OpenAI's model: said as what they are.
140
+ const embeddingHere = opts.localEmbeddings && local.embedding?.provider === 'openai' ? 'the keyless local embeddings' : pair(local.embedding)
141
+ if (both('embedding') && !sameEmbedding(workspace.embedding, local.embedding)) out.push(`Embeddings use ${pair(workspace.embedding)} in the workspace and ${embeddingHere} here, so knowledge search ranks differently`)
55
142
  if (both('memoryEmbedding') && differs(workspace.memoryEmbedding, local.memoryEmbedding)) out.push(`Memory embeddings use ${pair(workspace.memoryEmbedding)} in the workspace and ${pair(local.memoryEmbedding)} here`)
56
143
  if (both('webSearch') && differs(workspace.webSearch, local.webSearch)) out.push(`Web search is ${workspace.webSearch ?? DEFAULT} in the workspace and ${local.webSearch ?? DEFAULT} here`)
57
144
  if (both('imageModel') && differs(workspace.imageModel, local.imageModel)) out.push(`Images are made with ${pair(workspace.imageModel)} in the workspace and ${pair(local.imageModel)} here`)
58
145
  if (both('voiceSpeed') && differs(workspace.voiceSpeed, local.voiceSpeed)) out.push(`Voice speed is ${workspace.voiceSpeed ?? DEFAULT} in the workspace and ${local.voiceSpeed ?? DEFAULT} here`)
59
146
  const ws = workspace.featureFlags ?? {}
60
147
  const here = local.featureFlags ?? {}
61
- for (const key of Object.keys(ws).sort()) {
148
+ for (const key of Object.keys(ws).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
149
+ if (NOT_A_DIFFERENCE_HERE.has(key)) continue
62
150
  if (key in here && ws[key] !== here[key]) out.push(`Feature ${key} is ${onOff(ws[key])} in the workspace and ${onOff(here[key])} here`)
63
151
  }
64
152
  return out
65
153
  }
66
154
 
155
+ /**
156
+ * What one difference changes when the project is tested on this runtime,
157
+ * and whether it can be made to match: `effect` in one line, `align` the
158
+ * step that matches it, or why it stays the runtime's. Read from the line
159
+ * `settingsDifferences` wrote, so a build recorded by an earlier CLI is said
160
+ * the same way. Null for a line it does not know.
161
+ */
162
+ export interface SettingEffect {
163
+ effect: string
164
+ align: string
165
+ }
166
+
167
+ const KEEPS = '`local dev` keeps this runtime’s setting'
168
+
169
+ export function settingEffect(line: string): SettingEffect | null {
170
+ const both = (re: RegExp) => re.exec(line)
171
+ let m = both(/^Computer mode is (on|off) in the workspace and (on|off) here/)
172
+ if (m) {
173
+ return m[2] === 'off'
174
+ ? { effect: 'Agents with computer access run without it here: no browser, files or shell, and attachments go to the model', align: 'Can’t be aligned: the local runtime has no Agent computer; test computer work in the workspace' }
175
+ : { effect: 'Agents may use a computer here that they can’t in the workspace', align: KEEPS }
176
+ }
177
+ m = both(/^The memory tree is (on|off) in the workspace and (on|off) here/)
178
+ if (m) {
179
+ return m[2] === 'off'
180
+ ? { effect: 'Agents remember without the memory tree here, so what they recall across conversations differs from the workspace', align: KEEPS }
181
+ : { effect: 'Agents remember with the memory tree here, unlike the workspace', align: KEEPS }
182
+ }
183
+ if (/^Embeddings use /.test(line)) {
184
+ const effect = 'Knowledge search ranks passages differently here, so an Agent may cite other passages than in the workspace'
185
+ // The key is there, and the provider refused it: another key is the step, not "give it a key".
186
+ const refused = KEY_REFUSED.exec(line)
187
+ if (refused) {
188
+ const key = refused[1]!
189
+ const id = Object.entries(KEY_NAME).find(([, name]) => name === key)?.[0] ?? key.toLowerCase()
190
+ return { effect, align: `Can be aligned: the provider refused this computer’s ${key} key for it (\`frontera local logs service\` shows its answer after \`[embedding] preflight failed\`): store one it takes with \`frontera local key-set ${id}\`, then \`frontera local dev\` sets it` }
191
+ }
192
+ return { effect, align: 'Can be aligned: give this computer a key for the workspace’s model (`frontera local key-set google` or `openrouter`), then `frontera local dev` sets it' }
193
+ }
194
+ if (/^Memory embeddings use /.test(line)) {
195
+ return { effect: 'Agents recall their memories with another model here, so what they recall can differ', align: KEEPS }
196
+ }
197
+ m = both(/^Web search is (.+) in the workspace and (.+) here$/)
198
+ if (m) {
199
+ return { effect: 'Agents that search the web get results from another provider here, so their answers can cite other pages', align: `${KEEPS}: its provider is set up per deployment` }
200
+ }
201
+ if (/^Images are made with /.test(line)) return { effect: 'Images an Agent makes come from another model here', align: KEEPS }
202
+ if (/^Voice speed is /.test(line)) return { effect: 'Spoken replies play at another speed here', align: KEEPS }
203
+ m = both(/^Feature (\S+) is (on|off) in the workspace and (on|off) here/)
204
+ if (m) return { effect: `Whatever ${m[1]} switches is ${m[3]} here and ${m[2]} in the workspace`, align: KEEPS }
205
+ if (/^The timezone is /.test(line)) return { effect: 'Dates in Blueprint queries and usage windows resolve in another timezone here', align: 'Can be aligned: `frontera local dev` sets it; it failed last time, so run it again' }
206
+ return null
207
+ }
208
+
209
+ /**
210
+ * Feature flags that change nothing about how the project runs locally, so a
211
+ * different value is not a difference to report. `workflows.enabled` only
212
+ * shows or hides the Workflows entry in the platform's navigation; the local
213
+ * runtime applies and runs workflows whatever its value (always on locally
214
+ * for development).
215
+ */
216
+ export const NOT_A_DIFFERENCE_HERE: ReadonlySet<string> = new Set(['workflows.enabled'])
217
+
67
218
  type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
68
219
 
69
220
  export function settingsCalls(serviceUrl: string, creds: LocalCredentials, fetcher: typeof fetch = fetch): Call {
@@ -96,23 +247,70 @@ export interface SettingsMirror {
96
247
  differences: string[]
97
248
  }
98
249
 
250
+ /**
251
+ * The workspace's embedding model on the runtime, or the same model through
252
+ * a provider this computer has a key for (the runtime's credentialed
253
+ * providers, as `/v1/config/embedding` lists them): Gemini's through a Gemini
254
+ * key, then OpenRouter. Null when none is credentialed here or the runtime
255
+ * refused it (an older one, whose catalog lacks the model); the difference is
256
+ * said then.
257
+ */
258
+ async function mirrorEmbedding(workspace: Embedding, call: Call, opts: MirrorOptions): Promise<{ set: { provider: string; model: string } } | { refused: { provider: string; model: string } } | null> {
259
+ const read = await call('GET', '/v1/config/embedding')
260
+ if (read.status >= 400) return null
261
+ const body = read.body as { data?: { providers?: unknown } } & { providers?: unknown }
262
+ const providers = (body?.data?.providers ?? body?.providers) as Array<{ id?: unknown }> | undefined
263
+ const credentialed = new Set((Array.isArray(providers) ? providers : []).map((p) => p?.id).filter((id): id is string => typeof id === 'string'))
264
+ // The keyless local embeddings answer as OpenAI: their vectors are not OpenAI's model's.
265
+ if (opts.localEmbeddings) credentialed.delete('openai')
266
+ const pick = embeddingEquivalents(workspace).find((e) => credentialed.has(e.provider))
267
+ if (!pick) return null
268
+ const set = await call('PUT', '/v1/config/embedding', pick)
269
+ // A refusal here is the provider's answer to this computer's key (the service checks the model before it sets it).
270
+ return set.status < 400 ? { set: pick } : set.status === 400 ? { refused: pick } : null
271
+ }
272
+
273
+ export interface MirrorOptions {
274
+ /** The runtime embeds with the keyless local embeddings, which the service sees as an OpenAI credential. */
275
+ localEmbeddings?: boolean
276
+ }
277
+
99
278
  /**
100
279
  * Make the runtime's organization settings match the workspace's where that
101
- * is safe, and say where they still differ. Only the timezone is taken (the
280
+ * is safe, and say where they still differ. The timezone is taken (the
102
281
  * owner sets it, as Settings → Usage does): it decides how dates resolve in
103
- * Blueprint queries and usage windows. The rest stays the runtime's: a
282
+ * Blueprint queries and usage windows. So is the embedding model, when this
283
+ * computer has a key for it or for the same model through another provider,
284
+ * so knowledge search ranks as there. The rest stays the runtime's: a
104
285
  * provider or model there needs this computer's own credentials.
105
286
  */
106
- export async function mirrorOrgSettings(workspace: OrgSettings, call: Call): Promise<SettingsMirror> {
287
+ export async function mirrorOrgSettings(workspace: OrgSettings, call: Call, opts: MirrorOptions = {}): Promise<SettingsMirror> {
107
288
  const out: SettingsMirror = { lines: [], differences: [] }
108
289
  const read = await call('GET', '/v1/sync/settings')
109
290
  if (read.status >= 400) return out
110
- const local = ((read.body as { data?: unknown })?.data ?? read.body) as OrgSettings
291
+ let local = ((read.body as { data?: unknown })?.data ?? read.body) as OrgSettings
111
292
  if (workspace.timezone && local.timezone && workspace.timezone !== local.timezone) {
112
293
  const set = await call('PUT', '/v1/usage-limits/windows', { timezone: workspace.timezone })
113
294
  if (set.status < 400) out.lines.push(` timezone set to ${workspace.timezone}, as in the workspace`)
114
295
  else out.differences.push(`The timezone is ${workspace.timezone} in the workspace and ${local.timezone} here; it could not be set (${set.status})`)
115
296
  }
116
- out.differences.push(...settingsDifferences(workspace, local))
297
+ let refused: { provider: string; model: string } | null = null
298
+ if (workspace.embedding?.provider && workspace.embedding.model && 'embedding' in local && !sameEmbedding(workspace.embedding, local.embedding)) {
299
+ const mirrored = await mirrorEmbedding(workspace.embedding, call, opts).catch(() => null)
300
+ if (mirrored && 'set' in mirrored) {
301
+ const set = mirrored.set
302
+ const same = set.provider === workspace.embedding.provider && set.model === workspace.embedding.model
303
+ out.lines.push(` embeddings set to ${pair(set)}${same ? ', as in the workspace' : `, the workspace’s ${pair(workspace.embedding)} through this computer’s ${KEY_NAME[set.provider] ?? set.provider} key`}`)
304
+ local = { ...local, embedding: set }
305
+ } else if (mirrored) refused = mirrored.refused
306
+ }
307
+ // The key is stored and the provider refused it: said on the line, so its fix is another key, not "give it a key".
308
+ const keyRefused = refused ? `; the runtime refused it through this computer’s ${KEY_NAME[refused.provider] ?? refused.provider} key (${pair(refused)})` : ''
309
+ out.differences.push(...settingsDifferences(workspace, local, opts).map((d) => (keyRefused && /^Embeddings use /.test(d) ? `${d}${keyRefused}` : d)))
117
310
  return out
118
311
  }
312
+
313
+ /** What `local status --json` and the MCP `status` say of a runtime on the keyless local embeddings. */
314
+ export function keylessEmbeddingsStatus() {
315
+ return { kind: 'keyless-local', approximate: true, knowledgeThreshold: KEYLESS_KNOWLEDGE_THRESHOLD, note: KEYLESS_RETRIEVAL_NOTE } as const
316
+ }