@frontera-sdk/cli 1.52.38 → 1.52.40
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/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +48 -4
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +331 -31
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +875 -88
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +211 -21
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +180 -7
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +24 -21
|
@@ -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
|
-
*
|
|
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<
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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,18 +40,105 @@ 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
|
-
|
|
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`)
|
|
@@ -59,11 +146,75 @@ export function settingsDifferences(workspace: OrgSettings, local: OrgSettings):
|
|
|
59
146
|
const ws = workspace.featureFlags ?? {}
|
|
60
147
|
const here = local.featureFlags ?? {}
|
|
61
148
|
for (const key of Object.keys(ws).sort()) {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|