@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.
Files changed (225) 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 +48 -4
  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/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. 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: new Set(['path', 'includedColumns']),
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
- created = await api.createFromFile(
573
- resolve(dirname(path), csv),
574
- { ...common, includedColumns: names(document.includedColumns, 'includedColumns', filePath) },
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
- export const datasetCommands: Command[] = [list, get, rows, sources, preview, testSource, create, upload]
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
- if (threshold !== undefined && Number.isNaN(threshold)) {
186
- throw new UsageError(`--threshold must be a number, not "${thresholdRaw}"`, 'try --threshold 0.7')
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
- // Stated the right way round: --threshold is a distance cutoff, so a
209
- // LOW value is the restrictive one and 0 can never match.
210
- + (threshold !== undefined
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
- * Re-run ingestion for a source that failed.
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
- // An agent still in `configuring` is absent from `agent list`,
518
- // so print the id rather than a "?" that reads as corruption.
519
- return [slugs.get(id) ?? id, id]
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
+ }