@frontera-sdk/cli 1.52.39 → 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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { basename, dirname, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { DatasetApi, type DatasetColumn } from '../../api/dataset-api'
|
|
4
|
+
import { DatasetApi, type DatasetColumn, type DatasetSummary } from '../../api/dataset-api'
|
|
5
5
|
import { CliError } from '../../errors'
|
|
6
6
|
import { table } from '../../table'
|
|
7
7
|
import { flagString, type Command } from '../types'
|
|
@@ -103,6 +103,76 @@ function readColumns(raw: unknown, path: string): DatasetColumn[] {
|
|
|
103
103
|
})
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The column contract of a file dataset that declares `columns`: each included
|
|
108
|
+
* column with the type and nullability the file says, the CSV's own where it
|
|
109
|
+
* says nothing (`upload-preview`). The CSV must hold exactly the included
|
|
110
|
+
* columns, since a contract built this way records none as left out.
|
|
111
|
+
*/
|
|
112
|
+
async function fileContract(api: DatasetApi, csvPath: string, included: string[], declared: Map<string, { nullable?: boolean; databaseType?: string }>, filePath: string): Promise<DatasetColumn[]> {
|
|
113
|
+
const preview = await api.uploadPreview(csvPath)
|
|
114
|
+
const inferred = new Map((preview.columns ?? []).flatMap((c) => (c.name ? [[c.name, c] as const] : [])))
|
|
115
|
+
const extra = [...inferred.keys()].filter((name) => !included.includes(name))
|
|
116
|
+
if (extra.length) {
|
|
117
|
+
throw new CliError(`${filePath} declares \`columns\`, and its CSV carries ${extra.join(', ')}, which \`includedColumns\` leaves out.`, {
|
|
118
|
+
code: 'USAGE',
|
|
119
|
+
hint: 'With `columns`, the CSV holds exactly the included columns: add them to includedColumns, or drop them from the CSV.',
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return included.map((name): DatasetColumn => {
|
|
123
|
+
const said = declared.get(name)
|
|
124
|
+
const seen = inferred.get(name)
|
|
125
|
+
const databaseType = (said?.databaseType ?? seen?.databaseType) as DatasetColumn['databaseType'] | undefined
|
|
126
|
+
if (!databaseType || !COLUMN_TYPES.includes(databaseType as never)) {
|
|
127
|
+
throw new CliError(`${filePath}: the CSV has no column "${name}" to read its type from.`, {
|
|
128
|
+
code: 'USAGE',
|
|
129
|
+
hint: `Add it to the CSV, or declare it: columns: [{ name: ${name}, databaseType: text, nullable: true }]`,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
return { name, databaseType, nullable: said?.nullable ?? seen?.nullable ?? true }
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A file dataset's `columns`: what it declares of each column instead of
|
|
138
|
+
* leaving it to the first CSV (`[{ name, nullable, databaseType? }]`). Each
|
|
139
|
+
* names an included column and says its nullability, its type, or both;
|
|
140
|
+
* the rest come from the CSV.
|
|
141
|
+
*/
|
|
142
|
+
function readFileColumns(raw: unknown, included: string[], path: string): Map<string, { nullable?: boolean; databaseType?: string }> {
|
|
143
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
144
|
+
throw new CliError(`${path} has \`columns\` that is not a list.`, {
|
|
145
|
+
code: 'USAGE',
|
|
146
|
+
hint: 'columns: [{ name: closed_at, nullable: true }] — the columns whose nullability (or type) the file declares.',
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
const out = new Map<string, { nullable?: boolean; databaseType?: string }>()
|
|
150
|
+
raw.forEach((entry, position) => {
|
|
151
|
+
const where = `${path}: columns[${position}]`
|
|
152
|
+
const column = (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>
|
|
153
|
+
const name = column.name
|
|
154
|
+
if (typeof name !== 'string' || !included.includes(name)) {
|
|
155
|
+
throw new CliError(`${where} names ${typeof name === 'string' ? `"${name}", which is not in includedColumns` : 'no column'}.`, {
|
|
156
|
+
code: 'USAGE',
|
|
157
|
+
hint: `Included: ${included.join(', ')}`,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
const strays = Object.keys(column).filter((k) => !['name', 'nullable', 'databaseType'].includes(k))
|
|
161
|
+
if (strays.length) throw new CliError(`${where} carries ${strays.join(', ')}.`, { code: 'USAGE', hint: 'A file dataset’s column is { name, nullable, databaseType }; databaseType is optional.' })
|
|
162
|
+
if (column.nullable !== undefined && typeof column.nullable !== 'boolean') {
|
|
163
|
+
throw new CliError(`${where}: \`nullable\` is true or false.`, { code: 'USAGE', hint: 'nullable: true lets the column hold empty values in any later CSV.' })
|
|
164
|
+
}
|
|
165
|
+
if (column.databaseType !== undefined && (typeof column.databaseType !== 'string' || !COLUMN_TYPES.includes(column.databaseType as never))) {
|
|
166
|
+
throw new CliError(`${where} has an unknown \`databaseType\`.`, { code: 'USAGE', hint: `One of: ${COLUMN_TYPES.join(', ')}` })
|
|
167
|
+
}
|
|
168
|
+
if (column.nullable === undefined && column.databaseType === undefined) {
|
|
169
|
+
throw new CliError(`${where} declares nothing about "${name}".`, { code: 'USAGE', hint: 'Say `nullable`, `databaseType`, or both; leave a column out to take both from the CSV.' })
|
|
170
|
+
}
|
|
171
|
+
out.set(name, { ...(column.nullable !== undefined ? { nullable: column.nullable as boolean } : {}), ...(column.databaseType !== undefined ? { databaseType: column.databaseType as string } : {}) })
|
|
172
|
+
})
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
106
176
|
const list: Command = {
|
|
107
177
|
meta: {
|
|
108
178
|
noun: 'dataset',
|
|
@@ -476,7 +546,9 @@ const create: Command = {
|
|
|
476
546
|
const KIND_KEYS: Record<string, Set<string>> = {
|
|
477
547
|
blank: new Set(['columns']),
|
|
478
548
|
source: new Set(['source', 'schema', 'relation', 'includedColumns']),
|
|
479
|
-
file
|
|
549
|
+
// `columns`: what the file declares of each column (its nullability, its type) instead of
|
|
550
|
+
// leaving both to the first CSV, whose contract every later upload must then match.
|
|
551
|
+
file: new Set(['path', 'includedColumns', 'columns']),
|
|
480
552
|
}
|
|
481
553
|
const COMMON = new Set([
|
|
482
554
|
'apiName', 'displayName', 'description', 'kind', 'keyColumns', 'deterministicKeyConfirmed',
|
|
@@ -569,10 +641,21 @@ const create: Command = {
|
|
|
569
641
|
}
|
|
570
642
|
// Relative to the DEFINITION, not the shell's cwd: the two travel together in a
|
|
571
643
|
// repo, and resolving against cwd broke the moment anyone ran it from elsewhere.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
)
|
|
644
|
+
const csvPath = resolve(dirname(path), csv)
|
|
645
|
+
const included = names(document.includedColumns, 'includedColumns', filePath)
|
|
646
|
+
const declared = document.columns === undefined ? null : readFileColumns(document.columns, included, filePath)
|
|
647
|
+
if (!declared) {
|
|
648
|
+
created = await api.createFromFile(csvPath, { ...common, includedColumns: included })
|
|
649
|
+
} else {
|
|
650
|
+
// The contract as the file declares it, then the CSV loaded into it: a column declared
|
|
651
|
+
// nullable stays so, whatever this CSV holds.
|
|
652
|
+
const columns = await fileContract(api, csvPath, included, declared, filePath)
|
|
653
|
+
const blank = await api.createBlank({ ...common, columns })
|
|
654
|
+
if (!blank.id) {
|
|
655
|
+
throw new CliError(`"${apiName}" was created without an id, so its rows were not loaded.`, { code: 'FAILURE', hint: `frontera dataset upload ${apiName} ${csv}` })
|
|
656
|
+
}
|
|
657
|
+
created = await api.uploadSnapshot(blank.id, csvPath)
|
|
658
|
+
}
|
|
576
659
|
} else {
|
|
577
660
|
throw new CliError(`${filePath} has an unknown \`kind\`: ${String(kind)}.`, {
|
|
578
661
|
code: 'USAGE',
|
|
@@ -664,4 +747,70 @@ const upload: Command = {
|
|
|
664
747
|
},
|
|
665
748
|
}
|
|
666
749
|
|
|
667
|
-
|
|
750
|
+
const revise: Command = {
|
|
751
|
+
meta: {
|
|
752
|
+
noun: 'dataset',
|
|
753
|
+
verb: 'revise',
|
|
754
|
+
args: [],
|
|
755
|
+
flags: { file: 'string' },
|
|
756
|
+
aliases: { f: 'file' },
|
|
757
|
+
summary: 'Cut a new revision of a file dataset from its CSV, when the CSV holds empty values the current revision refuses',
|
|
758
|
+
examples: ['frontera dataset revise -f datasets/work_orders.json'],
|
|
759
|
+
},
|
|
760
|
+
/**
|
|
761
|
+
* A file dataset's first load fixes which columns may be empty. A later CSV
|
|
762
|
+
* with empty values in a column that had none is refused by `upload`; this
|
|
763
|
+
* makes that CSV the dataset's next revision instead, which only widens what
|
|
764
|
+
* may be empty. A column added, dropped or retyped, or a key column that
|
|
765
|
+
* became nullable, is a different dataset: the service refuses it, naming
|
|
766
|
+
* the column. Object types still read the old revision until they are bound
|
|
767
|
+
* to the new one (`blueprint bind`, which `local dev` does).
|
|
768
|
+
*/
|
|
769
|
+
async run(ctx) {
|
|
770
|
+
const filePath = flagString(ctx, 'file')
|
|
771
|
+
if (!filePath) throw new CliError('A definition file is required.', { code: 'USAGE', hint: 'frontera dataset revise -f datasets/<apiName>.json' })
|
|
772
|
+
const path = resolve(ctx.cwd, filePath)
|
|
773
|
+
let document: Record<string, unknown>
|
|
774
|
+
try {
|
|
775
|
+
document = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
|
|
776
|
+
} catch (err) {
|
|
777
|
+
throw new CliError(`${filePath} could not be read as JSON: ${(err as Error).message}`, { code: 'USAGE', hint: 'A dataset definition is JSON: `frontera local schema dataset`.' })
|
|
778
|
+
}
|
|
779
|
+
if (document.kind !== 'file' || typeof document.path !== 'string' || typeof document.apiName !== 'string') {
|
|
780
|
+
throw new CliError(`${filePath} is not a file dataset.`, { code: 'USAGE', hint: 'revise reads kind: "file" with `apiName` and `path`; a Source dataset is revised from its Source.' })
|
|
781
|
+
}
|
|
782
|
+
const apiName = document.apiName
|
|
783
|
+
const csvPath = resolve(dirname(path), document.path)
|
|
784
|
+
const api = new DatasetApi(ctx.apiUrl, ctx.token)
|
|
785
|
+
const dataset = await resolveDataset(api, apiName)
|
|
786
|
+
const revisions = await api.revisions(dataset.id!)
|
|
787
|
+
const expectedRevision = (revisions.find((r) => r.id === dataset.currentRevisionId) ?? revisions.at(-1))?.revision
|
|
788
|
+
if (typeof expectedRevision !== 'number') {
|
|
789
|
+
throw new CliError(`"${apiName}" has no current revision to follow.`, { code: 'CONFLICT', hint: `frontera dataset get ${apiName}` })
|
|
790
|
+
}
|
|
791
|
+
let result: DatasetSummary
|
|
792
|
+
try {
|
|
793
|
+
result = await api.reviseFromFile(dataset.id!, csvPath, expectedRevision)
|
|
794
|
+
} catch (err) {
|
|
795
|
+
if (err instanceof CliError && (err.code === 'DATASET_REVISION_UNCHANGED' || /DATASET_REVISION_UNCHANGED/.test(err.message))) {
|
|
796
|
+
return { data: { unchanged: true, revision: expectedRevision }, text: `"${apiName}" already fits ${basename(csvPath)}: revision ${expectedRevision} stays.` }
|
|
797
|
+
}
|
|
798
|
+
if (err instanceof CliError && err.code === 'CONFLICT') {
|
|
799
|
+
throw new CliError(err.message, {
|
|
800
|
+
code: 'CONFLICT',
|
|
801
|
+
hint: 'A new revision may only let more columns be empty. A column added, dropped or retyped (or a key column that became nullable) is a new dataset: give it another apiName, or on a local runtime run `frontera local reset`, which creates every dataset again from its definition.',
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
throw err
|
|
805
|
+
}
|
|
806
|
+
const revision = (result as { currentRevision?: { revision?: number } }).currentRevision?.revision
|
|
807
|
+
return {
|
|
808
|
+
data: result,
|
|
809
|
+
text: `Cut revision ${revision ?? expectedRevision + 1} of "${apiName}" from ${basename(csvPath)}.\n`
|
|
810
|
+
+ ' Object types read the old revision until bound to this one: `frontera blueprint bind <Type> --dataset '
|
|
811
|
+
+ `${apiName} --file <mapping> --accept-contract-change\`.`,
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export const datasetCommands: Command[] = [list, get, rows, sources, preview, testSource, create, upload, revise]
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
2
|
|
|
3
|
-
import { PlatformApi } from '../../api/platform-api'
|
|
3
|
+
import { PlatformApi, type KnowledgeRetrieval } from '../../api/platform-api'
|
|
4
4
|
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { DEFAULT_KNOWLEDGE_THRESHOLD, KEYLESS_KNOWLEDGE_THRESHOLD, KEYLESS_RETRIEVAL_NOTE, PLATFORM_DEFAULT_EMBEDDING, sameEmbedding, WIDENED_KNOWLEDGE_THRESHOLD } from '../../local/local-org-settings'
|
|
6
|
+
import { localRuntimeAt } from '../../local/local-runtime-target'
|
|
7
|
+
import { isLocalEmbeddings } from '../../local/local-seed'
|
|
5
8
|
import { table } from '../../table'
|
|
6
9
|
import { resolveAgentRef } from '../agent/resolve'
|
|
7
10
|
import { flagString, type Command } from '../types'
|
|
@@ -124,9 +127,11 @@ const get: Command = {
|
|
|
124
127
|
|
|
125
128
|
const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
126
129
|
const base = await client.knowledgeBase(await resolveKnowledgeRef(client, ref))
|
|
130
|
+
// On the keyless local embeddings the threshold shown is the one set for them, and retrieval is approximate: said.
|
|
131
|
+
const local = (await keylessRetrieval(client, ctx.cwd, ctx.apiUrl)) ? { localRetrieval: keylessRetrievalInfo() } : {}
|
|
127
132
|
|
|
128
133
|
return {
|
|
129
|
-
data: base,
|
|
134
|
+
data: { ...base, ...local },
|
|
130
135
|
// Whatever the route returns, flattened. Naming the fields here would
|
|
131
136
|
// print "?" for every one the service renames, and the embedding model
|
|
132
137
|
// and chunker are exactly the fields a caller comes to this verb to read.
|
|
@@ -137,7 +142,7 @@ const get: Command = {
|
|
|
137
142
|
v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v),
|
|
138
143
|
]),
|
|
139
144
|
[undefined, 70],
|
|
140
|
-
),
|
|
145
|
+
) + ('localRetrieval' in local ? `\n\nNote: ${KEYLESS_RETRIEVAL_NOTE}.` : ''),
|
|
141
146
|
}
|
|
142
147
|
},
|
|
143
148
|
}
|
|
@@ -160,9 +165,13 @@ const search: Command = {
|
|
|
160
165
|
],
|
|
161
166
|
flags: { 'top-k': 'string', threshold: 'string' },
|
|
162
167
|
summary: 'Search a knowledge base and show the chunks that come back',
|
|
168
|
+
details: [
|
|
169
|
+
'Without --threshold the base\'s own match threshold cuts the results, as an Agent\'s retrieval does. The cutoff is a maximum cosine distance from 0 to 2 (lower is stricter), while a score is higher-is-better: with vector scoring a score is 1 − distance / 2, so a 0.5 cutoff keeps scores above 0.75. The output says which cutoff applied.',
|
|
170
|
+
],
|
|
163
171
|
examples: [
|
|
164
172
|
'frontera knowledge search product-docs "how do refunds work"',
|
|
165
173
|
'frontera knowledge search product-docs "refund policy" --top-k 3 --json',
|
|
174
|
+
'frontera knowledge search product-docs "refund policy" --threshold 0.8',
|
|
166
175
|
],
|
|
167
176
|
},
|
|
168
177
|
async run(ctx) {
|
|
@@ -182,8 +191,9 @@ const search: Command = {
|
|
|
182
191
|
}
|
|
183
192
|
const thresholdRaw = flagString(ctx, 'threshold')
|
|
184
193
|
const threshold = thresholdRaw === undefined ? undefined : Number(thresholdRaw)
|
|
185
|
-
|
|
186
|
-
|
|
194
|
+
// A maximum cosine distance: 0 matches only identical text, 2 matches everything.
|
|
195
|
+
if (threshold !== undefined && (Number.isNaN(threshold) || threshold < 0 || threshold > 2)) {
|
|
196
|
+
throw new UsageError(`--threshold must be a cosine distance from 0 to 2, not "${thresholdRaw}"`, 'lower is stricter: try --threshold 0.8 to widen a search the base\'s own cutoff empties')
|
|
187
197
|
}
|
|
188
198
|
|
|
189
199
|
const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
@@ -195,42 +205,104 @@ const search: Command = {
|
|
|
195
205
|
})
|
|
196
206
|
|
|
197
207
|
const chunks = result.results
|
|
208
|
+
const cutoff = cutoffLine(result.retrieval, threshold)
|
|
209
|
+
// On the keyless local embeddings: retrieval is approximate, said with what gives the platform's.
|
|
210
|
+
const keyless = await keylessRetrieval(client, ctx.cwd, ctx.apiUrl)
|
|
211
|
+
const keylessLine = keyless ? `\n Note: ${KEYLESS_RETRIEVAL_NOTE}.` : ''
|
|
212
|
+
if (keyless) Object.assign(result, { localRetrieval: keylessRetrievalInfo() })
|
|
198
213
|
if (chunks.length === 0) {
|
|
199
214
|
// The overwhelmingly common cause, and the one a caller cannot see from
|
|
200
215
|
// here: ingestion is asynchronous, so a base uploaded a minute ago is
|
|
201
216
|
// empty rather than broken.
|
|
217
|
+
// How near the search came, when the service says: the threshold that would show it.
|
|
218
|
+
const nearest = result.retrieval?.nearestDistance
|
|
219
|
+
const max = typeof result.retrieval?.maxDistance === 'number' ? result.retrieval.maxDistance : threshold
|
|
220
|
+
if (typeof nearest === 'number') {
|
|
221
|
+
// Only asked when it could matter: a near miss the widened local threshold would keep.
|
|
222
|
+
const embedding = !keyless && nearMissOfWidening(nearest) ? await client.embeddingSetting().catch(() => null) : null
|
|
223
|
+
const widening = embeddingNote(nearest, embedding, isLoopbackApi(ctx.apiUrl))
|
|
224
|
+
return {
|
|
225
|
+
data: result,
|
|
226
|
+
text: `No chunks matched: the nearest chunk is at distance ${nearest}, past the cutoff${max === undefined ? '' : ` of ${max}`}. \`--threshold\` above ${nearest} (up to 2) shows it; setting the base's \`similarityThreshold\` there changes what an Agent's retrieval keeps.\n ${cutoff}${widening ? `\n ${widening}` : ''}${keylessLine}`,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (nearest === null) {
|
|
230
|
+
return {
|
|
231
|
+
data: result,
|
|
232
|
+
text: `No chunks matched: ${ref} has nothing searchable yet. \`frontera knowledge sources ${ref}\` lists its documents: none, or still \`processing\`.`,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
202
235
|
return {
|
|
203
236
|
data: result,
|
|
204
237
|
text:
|
|
205
238
|
'No chunks matched.\n'
|
|
206
239
|
+ ` If the upload was recent, check \`frontera knowledge sources ${ref}\` — a source\n`
|
|
207
240
|
+ ' still `processing` is not searchable yet.\n'
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
+
|
|
211
|
-
? ` --threshold ${threshold} is a maximum cosine distance — raise it to widen the search.\n`
|
|
212
|
-
: ' The default cutoff is 0.9; pass a higher --threshold to widen the search.\n')
|
|
213
|
-
+ ' Otherwise the corpus may simply not cover this.',
|
|
241
|
+
+ ` ${cutoff} Raise --threshold (up to 2) to widen the search.\n`
|
|
242
|
+
+ ' Otherwise the corpus may simply not cover this.'
|
|
243
|
+
+ keylessLine,
|
|
214
244
|
}
|
|
215
245
|
}
|
|
216
246
|
|
|
247
|
+
// A distance beside each score when the service gives one: it is what the cutoff compares.
|
|
248
|
+
const distances = chunks.some((c) => typeof c.distance === 'number')
|
|
217
249
|
return {
|
|
218
250
|
data: result,
|
|
219
251
|
text: table(
|
|
220
|
-
['score', 'source', 'chunk'],
|
|
252
|
+
['score', ...(distances ? ['distance'] : []), 'source', 'chunk'],
|
|
221
253
|
chunks.map((c) => [
|
|
222
254
|
typeof c.score === 'number' ? c.score.toFixed(3) : String(c.score ?? ''),
|
|
255
|
+
...(distances ? [typeof c.distance === 'number' ? c.distance.toFixed(3) : ''] : []),
|
|
223
256
|
String(c.fileName ?? c.sourceId ?? ''),
|
|
224
257
|
String(c.content ?? c.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
225
258
|
]),
|
|
226
|
-
[8, 30, 90],
|
|
227
|
-
)
|
|
259
|
+
distances ? [8, 9, 30, 90] : [8, 30, 90],
|
|
260
|
+
) + `\n\n${cutoff}${keylessLine}`,
|
|
228
261
|
}
|
|
229
262
|
},
|
|
230
263
|
}
|
|
231
264
|
|
|
265
|
+
/** Whether the nearest distance sits between the default threshold and the one a local runtime widens to. */
|
|
266
|
+
function nearMissOfWidening(nearest: number): boolean {
|
|
267
|
+
return nearest > DEFAULT_KNOWLEDGE_THRESHOLD && nearest <= WIDENED_KNOWLEDGE_THRESHOLD
|
|
268
|
+
}
|
|
269
|
+
|
|
232
270
|
/**
|
|
233
|
-
*
|
|
271
|
+
* Why a near miss may be the embedding model rather than the corpus: the
|
|
272
|
+
* default threshold is tuned for the platform's default embedding model, and
|
|
273
|
+
* another model ranks on-topic passages a little further away. A local
|
|
274
|
+
* runtime widens such a base's threshold when `frontera local dev` applies
|
|
275
|
+
* it, and this says so. Null when the miss is not in that band, or the model
|
|
276
|
+
* is the default or not known.
|
|
277
|
+
*/
|
|
278
|
+
export function embeddingNote(nearest: number, embedding: { provider: string | null; model: string | null } | null, local = true): string | null {
|
|
279
|
+
if (!nearMissOfWidening(nearest)) return null
|
|
280
|
+
if (!embedding?.provider || !embedding.model || sameEmbedding(embedding, PLATFORM_DEFAULT_EMBEDDING)) return null
|
|
281
|
+
// A hosted workspace hears only what it can act on: its own model, and the base's Match threshold.
|
|
282
|
+
if (!local) return `This organization embeds with ${embedding.provider} / ${embedding.model}, not the model the default threshold of ${DEFAULT_KNOWLEDGE_THRESHOLD} is tuned for, so on-topic passages sit further away: if relevant passages are cut off, raise the base's Match threshold.`
|
|
283
|
+
return `This organization embeds with ${embedding.provider} / ${embedding.model}, not the platform's default (${PLATFORM_DEFAULT_EMBEDDING.provider} / ${PLATFORM_DEFAULT_EMBEDDING.model}) the default threshold of ${DEFAULT_KNOWLEDGE_THRESHOLD} is tuned for, so on-topic passages sit further away. A local runtime widens a base's threshold to ${WIDENED_KNOWLEDGE_THRESHOLD} when \`frontera local dev\` applies it, if its file sets none or ${DEFAULT_KNOWLEDGE_THRESHOLD}.`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Which cutoff the search applied, on the scale `score` is read on: the
|
|
288
|
+
* base's own match threshold unless `--threshold` named one (that is what an
|
|
289
|
+
* Agent's retrieval uses), a maximum cosine distance, so a 0.5 cutoff keeps
|
|
290
|
+
* similarity scores above 0.75 and a 0.70 score is outside it.
|
|
291
|
+
*/
|
|
292
|
+
export function cutoffLine(retrieval: KnowledgeRetrieval | undefined, threshold: number | undefined): string {
|
|
293
|
+
const max = typeof retrieval?.maxDistance === 'number' ? retrieval.maxDistance : threshold
|
|
294
|
+
const from = retrieval?.thresholdFrom === 'request' || (!retrieval && threshold !== undefined) ? '--threshold' : 'the base\'s own match threshold, as an Agent\'s retrieval uses it'
|
|
295
|
+
if (max === undefined) return 'Cutoff: the base\'s own match threshold, as an Agent\'s retrieval uses it (a maximum cosine distance from 0 to 2; lower is stricter).'
|
|
296
|
+
if (retrieval?.scoring === 'fusion' || retrieval?.scoring === 'rerank') {
|
|
297
|
+
return `Cutoff: a maximum cosine distance of ${max}, from ${from}. Scores here are the ${retrieval.scoring === 'rerank' ? 'reranker\'s' : 'keyword and vector fusion\'s'}, so a score isn't comparable to the cutoff.`
|
|
298
|
+
}
|
|
299
|
+
const minScore = typeof retrieval?.minScore === 'number' ? retrieval.minScore : Math.max(0, 1 - max / 2)
|
|
300
|
+
return `Cutoff: a maximum cosine distance of ${max}, from ${from}: scores ≥ ${Number(minScore.toFixed(3))} (score = 1 − distance / 2).`
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Re-run ingestion for a source that failed, or that is stuck pending or
|
|
305
|
+
* processing with no ingestion behind it (`local status` names one).
|
|
234
306
|
*
|
|
235
307
|
* `upload` exits 0 on a partial batch by design — one unreadable file does not
|
|
236
308
|
* end the run — so a failed source is normal and recovery has to be normal
|
|
@@ -246,7 +318,7 @@ const retry: Command = {
|
|
|
246
318
|
{ name: 'source', required: true, description: 'source id, from `frontera knowledge sources`' },
|
|
247
319
|
],
|
|
248
320
|
flags: {},
|
|
249
|
-
summary: 'Re-run ingestion for one source that failed',
|
|
321
|
+
summary: 'Re-run ingestion for one source that failed or is stuck processing',
|
|
250
322
|
examples: ['frontera knowledge retry product-docs <sourceId>'],
|
|
251
323
|
},
|
|
252
324
|
async run(ctx) {
|
|
@@ -511,12 +583,15 @@ const agents: Command = {
|
|
|
511
583
|
rows.length === 0
|
|
512
584
|
? `No agent can read ${ref} — attach one with \`frontera knowledge attach ${ref} <agent>\`.`
|
|
513
585
|
: table(
|
|
514
|
-
['agent', 'id'],
|
|
586
|
+
['agent', 'id', 'attached in'],
|
|
515
587
|
rows.map((r) => {
|
|
516
588
|
const id = String(r.agentId ?? '')
|
|
517
|
-
//
|
|
518
|
-
// so print the id rather than a "?"
|
|
519
|
-
|
|
589
|
+
// The row names the Agent's slug; an older platform's does not, and an agent still in
|
|
590
|
+
// `configuring` is absent from `agent list`, so print the id rather than a "?".
|
|
591
|
+
const slug = typeof r.agentSlug === 'string' && r.agentSlug ? r.agentSlug : slugs.get(id) ?? id
|
|
592
|
+
// Published, staged in its draft, or both; an older platform lists only published ones.
|
|
593
|
+
const where = r.published === false ? 'draft' : r.staged === false ? 'published (its draft drops it)' : r.staged === true ? 'published and draft' : 'published'
|
|
594
|
+
return [slug, id, where]
|
|
520
595
|
}),
|
|
521
596
|
),
|
|
522
597
|
}
|
|
@@ -563,3 +638,30 @@ export const knowledgeCommands: Command[] = [
|
|
|
563
638
|
agents,
|
|
564
639
|
...reserved,
|
|
565
640
|
]
|
|
641
|
+
|
|
642
|
+
/** Whether the command talks to a runtime on this machine (a local runtime is always on a loopback address). */
|
|
643
|
+
/**
|
|
644
|
+
* Whether this runs on the keyless local embeddings: only against the
|
|
645
|
+
* project's own local runtime (`isLocalEmbeddings` reads this computer's
|
|
646
|
+
* keys), read from the organization's embedding setting. False when it
|
|
647
|
+
* can't say, and anywhere else.
|
|
648
|
+
*/
|
|
649
|
+
async function keylessRetrieval(client: PlatformApi, cwd: string, apiUrl: string): Promise<boolean> {
|
|
650
|
+
if (!localRuntimeAt(cwd, apiUrl)) return false
|
|
651
|
+
const embedding = await client.embeddingSetting().catch(() => null)
|
|
652
|
+
return !!embedding && isLocalEmbeddings(embedding)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** What `--json` carries beside a base or a search on the keyless local embeddings. */
|
|
656
|
+
export function keylessRetrievalInfo() {
|
|
657
|
+
return { embeddings: 'keyless-local', approximate: true, threshold: KEYLESS_KNOWLEDGE_THRESHOLD, note: KEYLESS_RETRIEVAL_NOTE } as const
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function isLoopbackApi(apiUrl: string): boolean {
|
|
661
|
+
try {
|
|
662
|
+
const host = new URL(apiUrl).hostname
|
|
663
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1' || host.endsWith('.localhost')
|
|
664
|
+
} catch {
|
|
665
|
+
return false
|
|
666
|
+
}
|
|
667
|
+
}
|