@frontera-sdk/cli 1.53.23 → 1.53.25

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 (52) hide show
  1. package/package.json +4 -4
  2. package/src/api/knowledge-base-api.ts +230 -0
  3. package/src/commands/knowledge/index-commands.ts +6 -6
  4. package/src/commands/knowledge-base/index-commands.ts +328 -0
  5. package/src/commands/local/check.ts +12 -0
  6. package/src/commands/local/dev.ts +64 -2
  7. package/src/commands/local/index-commands.ts +34 -10
  8. package/src/commands/local/init.ts +11 -2
  9. package/src/commands/local/schema.ts +1 -1
  10. package/src/commands/local/sync-request.ts +135 -29
  11. package/src/commands/local/sync.ts +40 -25
  12. package/src/commands/registry.ts +4 -1
  13. package/src/commands/types.ts +6 -0
  14. package/src/flag-help.ts +10 -4
  15. package/src/forge/touches.ts +29 -0
  16. package/src/local/local-applied.ts +28 -0
  17. package/src/local/local-build-summary.ts +1 -0
  18. package/src/local/local-component-health.ts +7 -2
  19. package/src/local/local-dev-references.ts +6 -2
  20. package/src/local/local-dev-sync.ts +43 -4
  21. package/src/local/local-docker.ts +16 -1
  22. package/src/local/local-env.ts +19 -0
  23. package/src/local/local-file-checks.ts +56 -8
  24. package/src/local/local-knowledge-base-apply.ts +714 -0
  25. package/src/local/local-knowledge-base-files.ts +195 -0
  26. package/src/local/local-knowledge-base-pull.ts +458 -0
  27. package/src/local/local-knowledge-base-sources-off.ts +67 -0
  28. package/src/local/local-person-steps.ts +2 -1
  29. package/src/local/local-personas.ts +64 -7
  30. package/src/local/local-portability.ts +27 -0
  31. package/src/local/local-ports.ts +4 -2
  32. package/src/local/local-project-secrets.ts +30 -5
  33. package/src/local/local-scaffold.ts +2 -0
  34. package/src/local/local-schema-check.ts +3 -1
  35. package/src/local/local-schemas.ts +11 -1
  36. package/src/local/local-search-index.ts +92 -0
  37. package/src/local/local-secrets.ts +7 -2
  38. package/src/local/local-seed.ts +17 -0
  39. package/src/local/local-state.ts +7 -5
  40. package/src/local/local-studio-server.ts +3 -3
  41. package/src/local/local-supervisor.ts +45 -8
  42. package/src/local/local-sync-link.ts +16 -0
  43. package/src/local/local-sync-names.ts +10 -1
  44. package/src/local/local-sync-plan.ts +44 -5
  45. package/src/local/local-sync-push.ts +165 -34
  46. package/src/local/local-sync-refs.ts +3 -1
  47. package/src/local/local-sync-remote.ts +44 -12
  48. package/src/local/local-sync-snapshot.ts +156 -12
  49. package/src/main.ts +13 -0
  50. package/src/mcp/mcp-server.ts +3 -0
  51. package/src/mcp/tools/docs-tools.ts +1 -1
  52. package/src/mcp/tools/knowledge-base-tools.ts +85 -0
@@ -3,8 +3,8 @@ import { basename, join } from 'node:path'
3
3
 
4
4
  import { CliError, UsageError } from '../../errors'
5
5
  import { canPrompt, promptSecret } from '../../prompt'
6
- import { allTargets } from '../../local/local-dev-sync'
7
6
  import { pullDocuments, type DocumentsPulled } from '../../local/local-knowledge-documents'
7
+ import { KNOWLEDGE_SIGN_IN, lockedFolders, pullKnowledgeBaseDocuments } from '../../local/local-knowledge-base-pull'
8
8
  import { appCodeLine, appCodeVersion, earlierPullShadows, pullAppCode, type AppCodePulled } from '../../local/local-app-code'
9
9
  import { remakeLines, remakeReason, remakeSampleData, type SampleRemake } from '../../local/local-sample-remake'
10
10
  import { lessonsToPropose, pullLessons, type LessonsPulled } from '../../local/local-lessons'
@@ -18,6 +18,7 @@ import {
18
18
  linkTarget,
19
19
  namedTarget,
20
20
  organizationReader,
21
+ personReader,
21
22
  projectFile,
22
23
  projectKeyFingerprints,
23
24
  projectTargets,
@@ -93,6 +94,7 @@ import {
93
94
  usesGovernedActions,
94
95
  writeBase,
95
96
  type ClearedConflicts,
97
+ type FetchOptions,
96
98
  type FetchResult,
97
99
  type RemoteSnapshot,
98
100
  } from '../../local/local-sync-snapshot'
@@ -185,31 +187,29 @@ export async function sourceFor(owner: KeyOwner, link: SyncLink, name: string |
185
187
  const key = getSyncKey(owner, 'workspace')
186
188
  if (!key) throw new CliError(`no workspace key for ${name ? `target ${name}` : 'this project'} on this machine`, { code: 'SYNC_NO_KEY', hint: `run \`${targetWords({ name }, 'frontera local sync connect')}\` to store one (the link is in frontera.yaml; keys are per machine)` })
187
189
  const { client, notSteward } = await organizationReader(owner, link)
190
+ // After it: a profile that did not know its organization yet is recorded there once.
191
+ const person = await personReader(owner, link)
188
192
  return {
189
193
  workspace: syncClient(link.platform, key),
190
194
  ...(client ? { organization: client } : {}),
191
195
  workspaceId: link.workspace,
192
196
  ...(notSteward ? { notSteward } : {}),
197
+ ...(person ? { person } : {}),
193
198
  }
194
199
  }
195
200
 
196
- /** Items the project holds that the workspace may not: for the plan's "only here". */
201
+ /**
202
+ * Items the project holds that the workspace may not: for the plan's "only
203
+ * here", as a push lists them. An Agent's lessons are counted apart.
204
+ */
197
205
  function localItems(project: LocalProject): Array<{ ref: string; kind: SyncKind; name: string; files: string[] }> {
198
- return allTargets(project.root).flatMap((t): Array<{ ref: string; kind: SyncKind; name: string; files: string[] }> => {
199
- if (t.kind === 'agent') return [{ ref: `agent/${t.slug}`, kind: 'agent', name: t.slug, files: [t.file] }]
200
- if (t.kind === 'workflow') return [{ ref: `workflow/${t.slug}`, kind: 'workflow', name: t.slug, files: [t.file] }]
201
- if (t.kind === 'function') {
202
- const name = basename(t.file).replace(/\.ts$/, '')
203
- return [{ ref: `function/${name}`, kind: 'function', name, files: [t.file] }]
204
- }
205
- return []
206
- })
206
+ return [...projectRefs(project.root)].flatMap(([ref, { kind, name, file }]) => (kind === 'lessons' ? [] : [{ ref, kind, name, files: [file] }]))
207
207
  }
208
208
 
209
209
  function kindsFlag(ctx: CommandContext): Set<SyncKind> | undefined {
210
210
  const raw = flagString(ctx, 'kinds')
211
211
  if (!raw) return undefined
212
- const aliases: Record<string, SyncKind> = { agents: 'agent', workflows: 'workflow', functions: 'function', skills: 'skill', plugins: 'plugin', evaluations: 'evaluation', evals: 'evaluation', datasets: 'dataset', themes: 'theme', 'chat-apps': 'chat-app', chatapps: 'chat-app', apps: 'app' }
212
+ const aliases: Record<string, SyncKind> = { 'knowledge-bases': 'knowledge-base', agents: 'agent', workflows: 'workflow', functions: 'function', skills: 'skill', plugins: 'plugin', evaluations: 'evaluation', evals: 'evaluation', datasets: 'dataset', themes: 'theme', 'chat-apps': 'chat-app', chatapps: 'chat-app', apps: 'app' }
213
213
  const out = new Set<SyncKind>()
214
214
  for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
215
215
  const kind = (aliases[part] ?? part) as SyncKind
@@ -261,9 +261,9 @@ export async function settleConflicts(project: LocalProject, link: SyncLink, sna
261
261
  }
262
262
 
263
263
  /** Fetch into the snapshot with the project's keys, then bring conflicted items up to the newer version. */
264
- export async function fetchFor(project: LocalProject, link: SyncLink, opts: { full?: boolean } = {}): Promise<FetchResult & { conflicts: ConflictsSettled }> {
264
+ export async function fetchFor(project: LocalProject, link: SyncLink, opts: Pick<FetchOptions, 'full' | 'knowledgeBaseFolders'> = {}): Promise<FetchResult & { conflicts: ConflictsSettled }> {
265
265
  const source = await sourceFor(project, link)
266
- const result = await fetchWorkspace(project.root, source, { blueprint: link.blueprint, full: opts.full })
266
+ const result = await fetchWorkspace(project.root, source, { blueprint: link.blueprint, full: opts.full, knowledgeBaseFolders: opts.knowledgeBaseFolders })
267
267
  await nameLink(project, link, source)
268
268
  return { ...result, conflicts: await settleConflicts(project, link, result.snapshot, source) }
269
269
  }
@@ -273,10 +273,10 @@ export async function fetchFor(project: LocalProject, link: SyncLink, opts: { fu
273
273
  * target into its own snapshot (`target.state`) with its own keys. A named
274
274
  * target is never pulled from, so it has no conflicts to settle.
275
275
  */
276
- export async function fetchTarget(project: LocalProject, target: SyncTarget, opts: { full?: boolean } = {}): Promise<FetchResult> {
276
+ export async function fetchTarget(project: LocalProject, target: SyncTarget, opts: Pick<FetchOptions, 'full' | 'knowledgeBaseFolders'> = {}): Promise<FetchResult> {
277
277
  if (!target.name) return fetchFor(project, target.link, opts)
278
278
  const source = await sourceFor(target.owner, target.link, target.name)
279
- const result = await fetchWorkspace(target.state, source, { blueprint: target.link.blueprint, full: opts.full })
279
+ const result = await fetchWorkspace(target.state, source, { blueprint: target.link.blueprint, full: opts.full, knowledgeBaseFolders: opts.knowledgeBaseFolders })
280
280
  await nameLink(project, target.link, source, (named) => writeTarget(project.root, target.name!, { ...named, ...(target.production ? { production: true } : {}) }))
281
281
  return result
282
282
  }
@@ -370,7 +370,7 @@ async function refreshLocalCatalog(project: LocalProject): Promise<void> {
370
370
  if (models?.length) rememberLocalCatalog(project.root, models)
371
371
  }
372
372
 
373
- export async function planFor(project: LocalProject, link: SyncLink, opts: { kinds?: Set<SyncKind>; drafts?: Set<string>; fetch?: 'auto' | 'always' | 'never'; repair?: 'offline' | 'online' } = {}) {
373
+ export async function planFor(project: LocalProject, link: SyncLink, opts: { kinds?: Set<SyncKind>; drafts?: Set<string>; fetch?: 'auto' | 'always' | 'never'; repair?: 'offline' | 'online'; full?: boolean } = {}) {
374
374
  await refreshLocalCatalog(project)
375
375
  const local = localItems(project).filter((l) => !opts.kinds || opts.kinds.has(l.kind))
376
376
  const readFromBase = (path: string) => readBase(project.root, path)
@@ -378,14 +378,14 @@ export async function planFor(project: LocalProject, link: SyncLink, opts: { kin
378
378
  if (opts.drafts?.size) {
379
379
  const lock = readLock(project.root)
380
380
  const base = lock?.workspace === link.workspace ? lock : null
381
- const remote = await readWorkspace(await sourceFor(project, link), { kinds: opts.kinds, drafts: opts.drafts, blueprint: link.blueprint })
381
+ const remote = await readWorkspace(await sourceFor(project, link), { kinds: opts.kinds, drafts: opts.drafts, blueprint: link.blueprint, knowledgeBaseFolders: lockedFolders(project.root) })
382
382
  return { remote, plan: computePlan(project.root, remote.items, base, local, { base: readFromBase, directory: remote.directory }), lock, snapshot: null, fetched: null, bases: noBases }
383
383
  }
384
384
  let snapshot = snapshotFor(project, link)
385
385
  let fetched: Awaited<ReturnType<typeof fetchFor>> | null = null
386
386
  const stale = !snapshot || Date.now() - Date.parse(snapshot.fetchedAt) > FRESH_MS
387
387
  if (opts.fetch === 'always' || (opts.fetch !== 'never' && stale)) {
388
- fetched = await fetchFor(project, link)
388
+ fetched = await fetchFor(project, link, { full: opts.full })
389
389
  snapshot = fetched.snapshot
390
390
  }
391
391
  // Read after the fetch: settling conflicts may have moved it.
@@ -788,11 +788,14 @@ const pull = async (ctx: CommandContext) => {
788
788
  const byRef = new Map(remote.items.map((i) => [i.ref, i]))
789
789
  // Knowledge bases whose documents come too: every base this pull writes (a pulled Agent answers from them),
790
790
  // unless --no-documents; --documents names more, already in the project, to bring theirs again.
791
+ // A Knowledge app base (`knowledge-base/<folder>`) brings its documents only when named.
791
792
  const noDocuments = flagBool(ctx, 'no-documents')
792
- const documents = refsFlag(ctx, 'documents').map((r) => r.replace(/^knowledge\//, ''))
793
- if (noDocuments && documents.length) throw new UsageError('--documents and --no-documents ask opposite things', 'leave one out: --no-documents pulls no documents; --documents <name> pulls those bases\' documents')
794
- const noBase = documents.filter((n) => !byRef.has(`knowledge/${n}`))
795
- if (noBase.length) throw new UsageError(`no knowledge base ${noBase.join(', ')} in the workspace`, '--documents takes knowledge base names, as `--preview` lists them (knowledge/<name>)')
793
+ const asked = refsFlag(ctx, 'documents')
794
+ const baseDocuments = asked.filter((r) => r.startsWith('knowledge-base/'))
795
+ const documents = asked.filter((r) => !r.startsWith('knowledge-base/')).map((r) => r.replace(/^knowledge\//, ''))
796
+ if (noDocuments && asked.length) throw new UsageError('--documents and --no-documents ask opposite things', 'leave one out: --no-documents pulls no documents; --documents <name> pulls those bases\' documents')
797
+ const noBase = [...documents.filter((n) => !byRef.has(`knowledge/${n}`)), ...baseDocuments.filter((r) => !byRef.has(r) || r === 'knowledge-base/settings')]
798
+ if (noBase.length) throw new UsageError(`no knowledge base ${noBase.join(', ')} in the workspace`, '--documents takes knowledge base names, as `--preview` lists them (knowledge/<name>, or knowledge-base/<name> for the Knowledge app)')
796
799
  const byPlan = new Map(plan.map((p) => [p.ref, p]))
797
800
  const status = new Map(plan.map((p) => [p.ref, p.status]))
798
801
  const clean = (p: PlanItem) => p.status === 'changed' || (p.status === 'conflict' && p.merge?.conflicts === 0)
@@ -813,13 +816,14 @@ const pull = async (ctx: CommandContext) => {
813
816
  } else if (kinds) {
814
817
  // A kind pulls what is new, changed or merges cleanly; it never replaces local edits unasked.
815
818
  chosen = plan.filter((p) => p.status === 'new' || clean(p)).map((p) => p.ref)
816
- } else if (deleteHere.length || keepLocal.length || documents.length || flagBool(ctx, 'lessons')) {
819
+ } else if (deleteHere.length || keepLocal.length || documents.length || baseDocuments.length || flagBool(ctx, 'lessons')) {
817
820
  chosen = []
818
821
  } else {
819
822
  throw new UsageError('say what to pull', 'name items (`agent/support-concierge`), kinds (`--kinds workflows`), or `--all-changed`; `--preview` lists them')
820
823
  }
821
824
  // A knowledge base not in the project yet comes with its documents.
822
825
  for (const n of documents) if (status.get(`knowledge/${n}`) === 'new' && !chosen.includes(`knowledge/${n}`)) chosen.push(`knowledge/${n}`)
826
+ for (const r of baseDocuments) if (status.get(r) === 'new' && !chosen.includes(r)) chosen.push(r)
823
827
  // An item pulled with conflicts waits for `resolve`: a pull writes nothing else for it.
824
828
  const waiting = chosen.filter((r) => status.get(r) === 'unresolved')
825
829
  chosen = chosen.filter((r) => status.get(r) !== 'unresolved')
@@ -869,6 +873,15 @@ const pull = async (ctx: CommandContext) => {
869
873
  docs[n] = id ? await pullDocuments(project.root, client, { name: n, id }) : { written: [], present: [], skipped: [], failed: ['its id is not in the fetched snapshot: fetch again'] }
870
874
  }
871
875
  }
876
+ if (baseDocuments.length) {
877
+ // Read as the person signed in: a key never reads the Knowledge app.
878
+ const person = await personReader(project, link)
879
+ for (const r of baseDocuments) {
880
+ const id = byRef.get(r)?.id
881
+ const folder = r.slice('knowledge-base/'.length)
882
+ docs[r] = !person ? { written: [], present: [], skipped: [], failed: [KNOWLEDGE_SIGN_IN] } : id ? await pullKnowledgeBaseDocuments(project.root, person.workspace, { folder, id }) : { written: [], present: [], skipped: [], failed: ['its id is not in the fetched snapshot: fetch again'] }
883
+ }
884
+ }
872
885
  // Each pulled Custom App's code comes too, into apps/<slug>/, unless --no-app-code; a folder already there is kept,
873
886
  // except one an earlier pull left unchanged whose package.json and bun.lock disagree: the deployed source replaces it.
874
887
  const appCode: AppCodePulled[] = []
@@ -930,7 +943,7 @@ const pull = async (ctx: CommandContext) => {
930
943
  ...lessons.removed.map((a) => ` ${a}: no reviewed rules in the workspace any more; agents/${a}/lessons.yaml removed`),
931
944
  ]
932
945
  const docLines = Object.entries(docs).flatMap(([n, d]) => [
933
- ` ${n}: ${plural(d.written.length, 'document')} written${d.present.length ? `, ${d.present.length} already here` : ''}${d.skipped.length ? `, ${d.skipped.length} not a kind local dev uploads (${d.skipped.join(', ')})` : ''}`,
946
+ ` ${n}: ${plural(d.written.length, 'document')} written${d.present.length ? `, ${d.present.length} already here` : ''}${d.skipped.length ? `, ${d.skipped.length} ${n.startsWith('knowledge-base/') ? 'kept on the platform by their source' : 'not a kind local dev uploads'} (${d.skipped.join(', ')})` : ''}`,
934
947
  ...d.failed.map((f) => ` could not read ${f}`),
935
948
  ])
936
949
  // What won't run locally as pulled, with the reason and whose fix it is: the checks `local dev` applies.
@@ -1310,6 +1323,8 @@ export const localSync: Command = {
1310
1323
  'frontera local sync pull knowledge/faq',
1311
1324
  'frontera local sync pull --kinds knowledge --no-documents',
1312
1325
  'frontera local sync pull --documents faq',
1326
+ 'frontera local sync pull --kinds knowledge-bases',
1327
+ 'frontera local sync pull --documents knowledge-base/hr-policies',
1313
1328
  'frontera local sync pull app/ops-console --no-app-code',
1314
1329
  'frontera local sync pull agent/support-concierge --lessons',
1315
1330
  'frontera local sync diff agent/support-concierge',
@@ -37,6 +37,7 @@ import { capabilityCommands } from './capability/index-commands'
37
37
  import { datasetCommands } from './dataset/index-commands'
38
38
  import { sourceCommands } from './source/index-commands'
39
39
  import { knowledgeCommands } from './knowledge/index-commands'
40
+ import { knowledgeBaseCommands } from './knowledge-base/index-commands'
40
41
  import { mediaCommands } from './media/index-commands'
41
42
  import { packCommands } from './pack/index-commands'
42
43
  import { secretCommands } from './secret/index-commands'
@@ -133,6 +134,7 @@ export const COMMANDS: readonly Command[] = [
133
134
  ...datasetCommands,
134
135
  ...sourceCommands,
135
136
  ...knowledgeCommands,
137
+ ...knowledgeBaseCommands,
136
138
  ...mediaCommands,
137
139
  ...packCommands,
138
140
  ...secretCommands,
@@ -212,7 +214,8 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
212
214
  plugin: 'Integrations and MCP servers connected to this workspace',
213
215
  dataset: 'Source datasets a Blueprint object type can bind to',
214
216
  source: 'Connected Sources — where a dataset’s rows come from',
215
- knowledge: 'Knowledge bases and the sources inside them',
217
+ knowledge: 'Agent knowledge — the bases Agents attach, and their files',
218
+ 'knowledge-base': 'Knowledge app bases — search, documents, stewards',
216
219
  pack: 'Reusable skill bundles — author once, install per workspace',
217
220
  secret: 'Workspace secrets — named here, never printed back',
218
221
  voice: 'Voice — Agent phone numbers and outbound calls',
@@ -86,6 +86,12 @@ export interface CommandMeta {
86
86
  * is refused, so a noun names its way out without rewording the refusal.
87
87
  */
88
88
  sessionAlternative?: string
89
+ /**
90
+ * Session-lane only: on the project's own local runtime, with no `--as`,
91
+ * the command acts as the runtime's owner, whose session the runtime keeps
92
+ * (Studio acts as the same person). Elsewhere a person signs in as usual.
93
+ */
94
+ localOwner?: boolean
89
95
  /**
90
96
  * Present → the command is planned but unbuilt, and fails with this message.
91
97
  * Reserved in the table rather than omitted: a caller reading "unknown
package/src/flag-help.ts CHANGED
@@ -101,7 +101,8 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
101
101
  // Local runtime (internal FDE tooling)
102
102
  source: 'platform checkout to run the local runtime from (default: the one this CLI runs from)',
103
103
  image: 'run the local runtime from release images instead of a platform checkout',
104
- exclude: 'optional components to leave out of the local runtime: jobs, runner, storage',
104
+ exclude: 'optional components to leave out of the local runtime: jobs, runner, storage, search-index',
105
+ with: 'components the local runtime runs only when asked: search-index, which a project with a knowledge-base/ folder runs anyway',
105
106
  'no-backup': 'also delete the local database and secrets',
106
107
  follow: 'keep printing new log lines as they are written',
107
108
  lines: 'how many of the latest log lines to print (default 100)',
@@ -199,8 +200,11 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
199
200
  limit: 'maximum rows to return in one page; each surface has its own ceiling',
200
201
  'page-token': 'cursor from the previous response’s nextPageToken; omit for the first page',
201
202
  as:
202
- 'on the local runtime only: act as a local persona (`submitter`, or an approval step such as '
203
- + '`dispatch_lead`), as `frontera local status` lists them; refused anywhere else',
203
+ 'on the local runtime only: act as a local persona (`submitter`, an approval step such as '
204
+ + '`dispatch_lead`, or `knowledge-steward`, `workspace-member`, `outsider` for knowledge bases), '
205
+ + 'as `frontera local status` lists them; refused anywhere else',
206
+ base: 'a knowledge base by name, id or project folder',
207
+ 'credential-from': 'where to read a credential from: `-` for stdin, or a file path. Never an inline value',
204
208
  // A metric's query (`blueprint metric query`).
205
209
  measure: 'comma-separated measures of the metric to return; all of them when absent',
206
210
  'group-by': 'comma-separated dimensions of the metric to break the numbers down by',
@@ -293,7 +297,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
293
297
  title: 'the change request’s title; a push that opens a new one needs it',
294
298
  why: 'a person-step: why it is needed now, in a sentence',
295
299
  changes: 'a person-step: what it changes, in the workspace’s words',
296
- credential: 'a person-step: who or what it needs (workspace-key, organization-key, steward, admin, second-person, person, registry-owner)',
300
+ credential: 'a person-step: who or what it needs (workspace-key, organization-key, steward, knowledge-steward, admin, second-person, person, registry-owner)',
297
301
  delivery: 'a person-step: the delivery it belongs to; steps with the same one show as one card',
298
302
  all: 'list the settled ones too',
299
303
  browser: 'sign in by approving a code in the browser, as yourself',
@@ -385,6 +389,8 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
385
389
  end: 'iso',
386
390
  only: 'live|test',
387
391
  as: 'persona',
392
+ base: 'name',
393
+ 'credential-from': '-|path',
388
394
  cursor: 'cursor',
389
395
  lifecycle: 'state',
390
396
  actions: 'allow_all|read_only|custom',
@@ -35,6 +35,7 @@ export const FORGE_ARTIFACT_KINDS = [
35
35
  'source',
36
36
  'dataset',
37
37
  'knowledge',
38
+ 'knowledge-base',
38
39
  'workflow',
39
40
  ] as const satisfies readonly ForgeArtifactKind[]
40
41
 
@@ -55,6 +56,8 @@ export const TOUCHING_VERBS: Record<ForgeArtifactKind, readonly string[]> = {
55
56
  // `revise` cuts the dataset's next revision from its CSV.
56
57
  dataset: ['create', 'upload', 'revise', 'test-source'],
57
58
  knowledge: ['create', 'attach', 'detach', 'upload', 'retry', 'delete'],
59
+ // The Knowledge app's bases, recorded through `ACTION_VERBS`: `source sync` and `steward add|remove`.
60
+ 'knowledge-base': ['sync', 'steward'],
58
61
  // `webhook-test` starts a run on the draft through its webhook, as `run` does.
59
62
  // `get`, `lineage`, `list`, `runs` and `validate` are reads — `validate` is
60
63
  // `push --dry-run` and writes nothing. `cancel` is deliberately absent: it
@@ -93,6 +96,20 @@ export const AGENT_PROXY_NOUNS: Record<string, { verbs: readonly string[]; agent
93
96
  voice: { verbs: ['number-add', 'number-apply', 'number-enable', 'number-disable', 'number-hours', 'number-remove'], agentAt: 0 },
94
97
  }
95
98
 
99
+ /**
100
+ * Verbs whose first positional is an action, only some of which change the
101
+ * artifact: `knowledge-base source test` stores nothing, `source sync` makes
102
+ * the source due now. The action names the verb the touch records, and
103
+ * `refAt` the positional after the action that names the artifact (null: a
104
+ * steward is a person, never a ref).
105
+ */
106
+ export const ACTION_VERBS: Partial<Record<ForgeArtifactKind, Record<string, { actions: Record<string, string>; refAt: number | null }>>> = {
107
+ 'knowledge-base': {
108
+ source: { actions: { sync: 'sync' }, refAt: 0 },
109
+ steward: { actions: { add: 'steward', remove: 'steward' }, refAt: null },
110
+ },
111
+ }
112
+
96
113
  /**
97
114
  * Verbs whose positional is a FILE, not the artifact's name. `frontera
98
115
  * function deploy src/nightly.ts` names a source file; the slug comes from
@@ -180,6 +197,9 @@ export const MCP_READ_TOOLS = new Set([
180
197
  'workflow_test_step',
181
198
  ])
182
199
 
200
+ /** `frontera mcp`'s knowledge base tools that change nothing; they are not in the service's catalogue. */
201
+ export const KNOWLEDGE_BASE_READ_TOOLS = new Set(['knowledge_base_list', 'knowledge_base_search', 'knowledge_base_status'])
202
+
183
203
  /** Tools whose success makes the artifact LIVE. */
184
204
  export const MCP_LIVE_TOOLS = new Set([
185
205
  'publish_agent', 'promote_platform_app_version', 'workflow_promote_version', 'promote_function_version',
@@ -219,6 +239,8 @@ function mcpArtifact(tool: string, input: Record<string, unknown>): { kind: Forg
219
239
  if (tool === 'data_refresh_dataset') return { kind: 'dataset', ref: str('apiName') }
220
240
  if (tool === 'promote_platform_app_version') return { kind: 'app', ref: str('app') ?? str('slug') }
221
241
  if (/_skill$/.test(tool)) return { kind: 'skill', ref: str('slug') ?? str('skillId') }
242
+ // The Knowledge app's bases, from `frontera mcp`, ahead of Agent knowledge, whose tools also say "knowledge".
243
+ if (tool.startsWith('knowledge_base_')) return KNOWLEDGE_BASE_READ_TOOLS.has(tool) ? null : { kind: 'knowledge-base', ref: str('base') }
222
244
  if (/knowledge/.test(tool)) return { kind: 'knowledge', ref: str('knowledgeBaseId') ?? str('name') }
223
245
  if (/^create_agent$|^duplicate_agent$/.test(tool)) return { kind: 'agent', ref: null }
224
246
  return null
@@ -398,6 +420,13 @@ export function parseFronteraCommands(line: string): ParsedFronteraCommand[] {
398
420
 
399
421
  const kind = NOUN_ALIASES[noun] ?? (isKind(noun) ? noun : null)
400
422
  if (!kind) continue
423
+ const acting = ACTION_VERBS[kind]?.[verb]
424
+ if (acting) {
425
+ const [action, ...after] = rest
426
+ const touch = action ? acting.actions[action] : undefined
427
+ if (touch) out.push({ kind, verb: touch, ref: acting.refAt === null ? null : (after[acting.refAt] ?? null), live: false })
428
+ continue
429
+ }
401
430
  if (!TOUCHING_VERBS[kind].includes(verb)) continue
402
431
  const ref = (FILE_POSITIONAL_VERBS[kind] ?? []).includes(verb)
403
432
  ? null
@@ -8,6 +8,8 @@ import type { LocalProject } from './local-project'
8
8
  import { standInTools } from './local-stand-ins'
9
9
  import { importedFiles } from './local-function-shared'
10
10
  import { NOT_A_DIFFERENCE_HERE } from './local-org-settings'
11
+ import { KNOWLEDGE_BASE_DIR, KNOWLEDGE_BASE_SETTINGS_FILE } from './local-knowledge-base-files'
12
+ import { agentPicks, appliedBaseIds, pickedFolder } from './local-knowledge-base-apply'
11
13
 
12
14
  /**
13
15
  * What `frontera local dev` has applied to this runtime, by content hash. It
@@ -71,11 +73,25 @@ function treeHash(dir: string): string | null {
71
73
  return any ? hash.digest('hex') : null
72
74
  }
73
75
 
76
+ /** An agent.json's `knowledgeBases`, or null when it has none or cannot be read. */
77
+ function agentPicksIn(path: string): string[] | null {
78
+ try {
79
+ return agentPicks(JSON.parse(readFileSync(path, 'utf8')))
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
74
85
  export function contentHash(project: LocalProject, target: SyncTarget): string | null {
75
86
  // The Blueprint is applied as one tree, so it changes when any file in it does.
76
87
  if (target.kind === 'blueprint') return treeHash(join(project.root, 'blueprint'))
77
88
  // A knowledge base, a skill and a slide theme are folders: a new document, reference or image changes them.
78
89
  if (target.kind === 'knowledge' || target.kind === 'skill' || target.kind === 'theme' || target.kind === 'brand') return treeHash(join(project.root, dirname(target.file)))
90
+ // A Knowledge app base, and the settings that allow its sources: allowing them applies it again.
91
+ if (target.kind === 'knowledge-base') {
92
+ const tree = treeHash(join(project.root, dirname(target.file)))
93
+ return tree && createHash('sha256').update(tree).update(fileDigest(join(project.root, KNOWLEDGE_BASE_DIR, KNOWLEDGE_BASE_SETTINGS_FILE)) ?? '').digest('hex')
94
+ }
79
95
  // A stand-in lists the connection's tools and those the project's files name on it: either changing re-applies it.
80
96
  if (target.kind === 'plugin' && target.file.endsWith('.stand-in.json')) return createHash('sha256').update(JSON.stringify(standInTools(project.root, target.name))).update(fileDigest(join(project.root, target.file)) ?? '').digest('hex')
81
97
  const own = fileDigest(join(project.root, target.file))
@@ -93,6 +109,14 @@ export function contentHash(project: LocalProject, target: SyncTarget): string |
93
109
  hash.update(treeHash(join(project.root, 'functions')) ?? '').update(treeHash(join(project.root, 'vendor')) ?? '')
94
110
  for (const path of importedFiles(project.root, target.file).filter((p) => !p.startsWith('functions/')).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) hash.update(path).update('\0').update(fileDigest(join(project.root, path)) ?? '')
95
111
  }
112
+ // The bases it picks, by their ids here: a base made again (after a reset) sets its picks again.
113
+ if (target.kind === 'agent') {
114
+ const picks = agentPicksIn(join(project.root, target.file))
115
+ if (picks) {
116
+ const ids = appliedBaseIds(project.stateDir)
117
+ hash.update(JSON.stringify(picks.map((p) => ids[pickedFolder(p) ?? ''] ?? null)))
118
+ }
119
+ }
96
120
  // Context and data the pull could not read is assumed on the project's object types: a new one changes it.
97
121
  if (target.kind === 'bindings' && /"blueprintUnread"\s*:\s*true/.test(readFileSync(join(project.root, target.file), 'utf8'))) {
98
122
  hash.update(treeHash(join(project.root, 'blueprint', 'object-types')) ?? '')
@@ -389,6 +413,10 @@ export function refOfTarget(target: SyncTarget): string {
389
413
  return `dataset/${target.apiName}`
390
414
  case 'knowledge':
391
415
  return `knowledge/${target.name}`
416
+ case 'knowledge-base':
417
+ return `${KNOWLEDGE_BASE_DIR}/${target.name}`
418
+ case 'knowledge-base-settings':
419
+ return 'knowledge-base-settings'
392
420
  case 'skill':
393
421
  return `skill/${target.name}`
394
422
  case 'plugin':
@@ -474,6 +474,7 @@ export function itemKindsText(rows: BuildRow[]): string {
474
474
  sharedField: ['shared field', 'shared fields'], objectSet: ['saved object set', 'saved object sets'], action: ['governed Action', 'governed Actions'],
475
475
  knowledge: ['knowledge base', 'knowledge bases'], skill: ['skill', 'skills'], plugin: ['Plugin', 'Plugins'], theme: ['slide theme', 'slide themes'],
476
476
  'chat-app': ['Chat App', 'Chat Apps'], app: ['Custom App', 'Custom Apps'], 'mcp-server': ['MCP server', 'MCP servers'],
477
+ 'knowledge-base': ['knowledge base', 'knowledge bases'], 'knowledge-base-settings': ['Knowledge settings', 'Knowledge settings'],
477
478
  }
478
479
  const counts = new Map<string, number>()
479
480
  for (const r of rows) {
@@ -1,7 +1,8 @@
1
1
  import { connect } from 'node:net'
2
2
 
3
3
  import { containerState } from './local-docker'
4
- import { isAlive, type ComponentName, type ComponentState, type LocalState } from './local-state'
4
+ import { searchIndexHealth } from './local-search-index'
5
+ import { COMPONENTS, isAlive, type ComponentName, type ComponentState, type LocalState } from './local-state'
5
6
 
6
7
  /**
7
8
  * Whether each part of a running local runtime is up now, not what the state
@@ -15,6 +16,8 @@ export interface ComponentProbe {
15
16
  container: (name: string) => ReturnType<typeof containerState>
16
17
  port: (port: number) => Promise<boolean>
17
18
  alive: (pid: number) => boolean
19
+ /** The search index's cluster health: null when usable, else why not. Without it, its port decides. */
20
+ searchIndex?: (port: number) => Promise<string | null>
18
21
  }
19
22
 
20
23
  /** A part of the runtime that is down, and why, in words. `cache` is the service's Redis, not a listed component. */
@@ -49,6 +52,7 @@ const defaultProbe: ComponentProbe = {
49
52
  },
50
53
  port: (port) => portAnswers(port),
51
54
  alive: isAlive,
55
+ searchIndex: (port) => searchIndexHealth(port),
52
56
  }
53
57
 
54
58
  const containerOf = (state: LocalState, c: ComponentState) => c.container ?? `frontera-${state.projectId}-${c.name}`
@@ -63,6 +67,7 @@ async function whyDown(state: LocalState, c: ComponentState, probe: ComponentPro
63
67
  } else if (!state.supervisorPid || !probe.alive(state.supervisorPid)) {
64
68
  return 'the supervisor exited'
65
69
  }
70
+ if (c.name === 'search-index' && c.port && probe.searchIndex) return probe.searchIndex(c.port)
66
71
  if (c.port && !(await probe.port(c.port))) return `nothing answers on its port ${c.port}`
67
72
  return null
68
73
  }
@@ -108,7 +113,7 @@ export async function downToRestart(state: LocalState, probe: ComponentProbe = d
108
113
  return down.length ? down : null
109
114
  }
110
115
 
111
- const ORDER: Array<DownComponent['name']> = ['postgres', 'cache', 'storage', 'service', 'jobs', 'runner', 'studio']
116
+ const ORDER = COMPONENTS.flatMap((name): Array<DownComponent['name']> => (name === 'postgres' ? [name, 'cache'] : [name]))
112
117
 
113
118
  /** What brings a down component back, said on its line. */
114
119
  export const BRING_BACK = '`frontera local start` brings it back (the data is kept)'
@@ -344,10 +344,14 @@ export async function resolveTarget(
344
344
  } catch (err) {
345
345
  return { ok: false, output: `error [USAGE]: ${target.file} could not be read: ${(err as Error).message}` }
346
346
  }
347
+ // The Agent's Knowledge app bases are set after it applies (`applyAgentKnowledgeBases`), outside its draft,
348
+ // whose format has no such key: the copy `agent apply` sends goes without it.
349
+ const picks = target.kind === 'agent' && !!doc && typeof doc === 'object' && 'knowledgeBases' in doc
350
+ if (picks) delete (doc as Record<string, unknown>).knowledgeBases
347
351
  const modelSlugs = target.kind === 'agent' && (Array.isArray((doc as { modelConfigs?: unknown[] })?.modelConfigs) || typeof (doc as { config?: { voice?: { voiceSlug?: unknown } } })?.config?.voice?.voiceSlug === 'string')
348
352
  const mcpServers = target.kind === 'agent' && Array.isArray((doc as { mcpServers?: unknown[] })?.mcpServers) && (doc as { mcpServers: unknown[] }).mcpServers.length > 0
349
353
  const objectTools = target.kind === 'agent' && Array.isArray((doc as { objectBindings?: unknown[] })?.objectBindings) && (doc as { objectBindings: unknown[] }).objectBindings.length > 0
350
- if (!kindsIn(doc).size && !modelSlugs && !mcpServers && !objectTools && !governedActionIds(doc).length && !(target.kind === 'workflow' && workflowNeedsLocalizing(doc))) return { ok: true, output: '', doc }
354
+ if (!picks && !kindsIn(doc).size && !modelSlugs && !mcpServers && !objectTools && !governedActionIds(doc).length && !(target.kind === 'workflow' && workflowNeedsLocalizing(doc))) return { ok: true, output: '', doc }
351
355
  const dir = await directory()
352
356
  const warnings: string[] = []
353
357
  // Built-in connections the project has stand-ins for (`local-stand-ins.ts`).
@@ -432,7 +436,7 @@ export async function resolveTarget(
432
436
  }
433
437
  }
434
438
  if (target.kind === 'operating') return { ok: true, doc: resolved, output: warnings.join('\n') }
435
- if (!kindsIn(doc).size && !warnings.length && JSON.stringify(resolved) === JSON.stringify(readDoc(path))) return { ok: true, output: '', doc }
439
+ if (!picks && !kindsIn(doc).size && !warnings.length && JSON.stringify(resolved) === JSON.stringify(readDoc(path))) return { ok: true, output: '', doc }
436
440
  const copy = join('.frontera', 'local', 'resolved', target.file)
437
441
  mkdirSync(dirname(join(project.root, copy)), { recursive: true })
438
442
  writeFileSync(join(project.root, copy), /\.json$/.test(target.file) ? `${JSON.stringify(resolved, null, 2)}\n` : stringifyYaml(resolved, { lineWidth: 0 }))
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
3
3
 
4
4
  import { agentIdentity, createFlags } from './local-agent-name'
5
+ import { KNOWLEDGE_BASE_DIR, KNOWLEDGE_BASE_DOCUMENTS_DIR, KNOWLEDGE_BASE_FILE, KNOWLEDGE_BASE_SETTINGS_FILE, KNOWLEDGE_BASE_SOURCES_DIR } from './local-knowledge-base-files'
5
6
  import { widerGrants, type WorkspaceGrant } from './local-workspace-grants'
6
7
 
7
8
  /**
@@ -20,6 +21,8 @@ import { widerGrants, type WorkspaceGrant } from './local-workspace-grants'
20
21
  * functions/<name>/ (with package.json) → function deploy <its entry> (a Function deployed from its own folder)
21
22
  * blueprint/** → blueprint apply --yes, then publish and grant (organization profile)
22
23
  * knowledge/<name>/knowledge.json → knowledge create, then upload the folder's new documents
24
+ * knowledge-base/<name>/ → the Knowledge app's base, its sources and documents (local-knowledge-base-apply.ts)
25
+ * knowledge-base/settings.json → the organization's Knowledge settings
23
26
  * skills/<name>/SKILL.md → skill push skills/<name>
24
27
  * mcp/<name>.plugin.json → plugin add mcp (or configure), tools enabled
25
28
  * mcp/<name>.stand-in.json → a stand-in Plugin for a built-in connection: its tools answer “not connected”
@@ -49,6 +52,9 @@ export type SyncTarget =
49
52
  | { kind: 'function'; key: string; file: string }
50
53
  | { kind: 'blueprint'; key: string }
51
54
  | { kind: 'knowledge'; key: string; name: string; file: string }
55
+ /** A base in the Knowledge app: `file` is its knowledge-base.json; its sources and documents are in its folder. */
56
+ | { kind: 'knowledge-base'; key: string; name: string; file: string }
57
+ | { kind: 'knowledge-base-settings'; key: string; file: string }
52
58
  | { kind: 'skill'; key: string; name: string; file: string }
53
59
  | { kind: 'plugin'; key: string; name: string; file: string }
54
60
  | { kind: 'theme'; key: string; name: string; file: string }
@@ -112,6 +118,13 @@ export function targetFor(root: string, absolute: string): SyncTarget | null {
112
118
  if (top === 'knowledge' && parts.length >= 3 && second && existsSync(join(root, 'knowledge', second, 'knowledge.json'))) {
113
119
  return { kind: 'knowledge', key: `knowledge:${second}`, name: second, file: join('knowledge', second, 'knowledge.json') }
114
120
  }
121
+ if (top === KNOWLEDGE_BASE_DIR && parts.length === 2 && second === KNOWLEDGE_BASE_SETTINGS_FILE) {
122
+ return { kind: 'knowledge-base-settings', key: 'knowledge-base-settings', file: rel }
123
+ }
124
+ // A Knowledge app base is its file, its sources and its documents.
125
+ if (top === KNOWLEDGE_BASE_DIR && second && existsSync(join(root, KNOWLEDGE_BASE_DIR, second, KNOWLEDGE_BASE_FILE)) && isKnowledgeBaseFile(parts.slice(2))) {
126
+ return { kind: 'knowledge-base', key: `knowledge-base:${second}`, name: second, file: join(KNOWLEDGE_BASE_DIR, second, KNOWLEDGE_BASE_FILE) }
127
+ }
115
128
  if (top === 'skills' && parts.length >= 3 && second && existsSync(join(root, 'skills', second, 'SKILL.md'))) {
116
129
  return { kind: 'skill', key: `skill:${second}`, name: second, file: join('skills', second, 'SKILL.md') }
117
130
  }
@@ -154,6 +167,14 @@ export function targetFor(root: string, absolute: string): SyncTarget | null {
154
167
  return null
155
168
  }
156
169
 
170
+ /** The files of a base's folder `local dev` applies: knowledge-base.json, `sources/<source>.json` and anything under `documents/`. */
171
+ function isKnowledgeBaseFile(inFolder: string[]): boolean {
172
+ const [first, second] = inFolder
173
+ if (inFolder.length === 1) return first === KNOWLEDGE_BASE_FILE
174
+ if (first === KNOWLEDGE_BASE_SOURCES_DIR) return inFolder.length === 2 && !!second?.endsWith('.json')
175
+ return first === KNOWLEDGE_BASE_DOCUMENTS_DIR
176
+ }
177
+
157
178
  /**
158
179
  * The entry of a Function kept in its own folder: the TypeScript file whose
159
180
  * `automation({ name: … })` names the folder, else index.ts or src/index.ts.
@@ -229,11 +250,12 @@ export function allTargets(root: string): SyncTarget[] {
229
250
  }
230
251
  }
231
252
  }
232
- for (const top of ['datasets', 'blueprint', 'knowledge', 'skills', 'mcp', 'themes', 'functions', 'workflows', 'agents', 'chat-apps', 'apps', 'mcp-servers', 'brand']) walk(join(root, top), 1)
253
+ for (const top of ['datasets', 'blueprint', 'knowledge', KNOWLEDGE_BASE_DIR, 'skills', 'mcp', 'themes', 'functions', 'workflows', 'agents', 'chat-apps', 'apps', 'mcp-servers', 'brand']) walk(join(root, top), 1)
233
254
  if (existsSync(join(root, 'library.json'))) out.set('library', { kind: 'library', key: 'library', file: 'library.json' })
234
255
  // Datasets first (object types read them), then the Blueprint (Agents and
235
256
  // workflows read its objects), then what Agents use by name (knowledge
236
- // bases and slide themes, skills, plugins), then Functions (workflows and
257
+ // bases, the Knowledge settings before the Knowledge app's bases whose
258
+ // sources they allow, slide themes, skills, plugins), then Functions (workflows and
237
259
  // Agents call them), then Agents (a workflow's agent step names one), then workflows, then what
238
260
  // each Agent may start, then the Agents' behavior settings (a procedure runs
239
261
  // only what its Agent may start), then the Chat Apps their Agents are used in and the Apps that
@@ -242,7 +264,7 @@ export function allTargets(root: string): SyncTarget[] {
242
264
  return [...out.values()].sort(byApplyOrder)
243
265
  }
244
266
 
245
- const ORDER = { brand: -1, dataset: 0, blueprint: 1, knowledge: 2, theme: 2, skill: 3, plugin: 4, function: 5, agent: 6, workflow: 7, bindings: 8, operating: 9, lessons: 9, 'chat-app': 10, app: 10, 'mcp-server': 10, library: 11 }
267
+ const ORDER = { brand: -1, dataset: 0, blueprint: 1, knowledge: 2, 'knowledge-base-settings': 2, theme: 2, 'knowledge-base': 3, skill: 3, plugin: 4, function: 5, agent: 6, workflow: 7, bindings: 8, operating: 9, lessons: 9, 'chat-app': 10, app: 10, 'mcp-server': 10, library: 11 }
246
268
 
247
269
  /** Dependencies first: what a later kind reads or calls is applied before it. */
248
270
  export function byApplyOrder(a: SyncTarget, b: SyncTarget): number {
@@ -297,6 +319,8 @@ export function commandFor(target: SyncTarget, profiles: SyncProfiles): string[]
297
319
  // Session-only route; `local dev` calls it itself (see local-operating.ts).
298
320
  throw new Error('behavior settings are applied over the API, not with a CLI command')
299
321
  case 'knowledge':
322
+ case 'knowledge-base':
323
+ case 'knowledge-base-settings':
300
324
  case 'plugin':
301
325
  case 'bindings':
302
326
  case 'theme':
@@ -437,6 +461,10 @@ export function describeTarget(target: SyncTarget): string {
437
461
  return 'Blueprint'
438
462
  case 'knowledge':
439
463
  return `knowledge base ${target.name}`
464
+ case 'knowledge-base':
465
+ return `${KNOWLEDGE_BASE_DIR}/${target.name}`
466
+ case 'knowledge-base-settings':
467
+ return 'the Knowledge settings'
440
468
  case 'skill':
441
469
  return `skill ${target.name}`
442
470
  case 'plugin':
@@ -505,6 +533,11 @@ export function unappliedReason(root: string, rel: string): string | null {
505
533
  case 'knowledge':
506
534
  if (parts.length === 2) return 'a knowledge base is a folder: knowledge/<name>/knowledge.json ({ "name": "…", "description": "…" }) with its documents beside it'
507
535
  return `knowledge/${second}/ has no knowledge.json ({ "name": "…", "description": "…" }), so its documents are not uploaded`
536
+ case KNOWLEDGE_BASE_DIR:
537
+ if (parts.length === 2) return `a knowledge base is a folder: ${KNOWLEDGE_BASE_DIR}/<name>/${KNOWLEDGE_BASE_FILE}, with its API sources in sources/ and its files in documents/; the one file beside the folders is ${KNOWLEDGE_BASE_SETTINGS_FILE} (\`frontera local schema knowledge-base\`)`
538
+ if (!existsSync(join(root, KNOWLEDGE_BASE_DIR, second!, KNOWLEDGE_BASE_FILE))) return `${KNOWLEDGE_BASE_DIR}/${second}/ has no ${KNOWLEDGE_BASE_FILE}, so nothing in it is applied (\`frontera local schema knowledge-base\`)`
539
+ if (parts[2] === KNOWLEDGE_BASE_SOURCES_DIR) return `a source is ${KNOWLEDGE_BASE_DIR}/${second}/${KNOWLEDGE_BASE_SOURCES_DIR}/<source>.json, directly in ${KNOWLEDGE_BASE_SOURCES_DIR}/ (\`frontera local schema knowledge-base-source\`)`
540
+ return `a knowledge base folder holds ${KNOWLEDGE_BASE_FILE}, ${KNOWLEDGE_BASE_SOURCES_DIR}/ and ${KNOWLEDGE_BASE_DOCUMENTS_DIR}/: put the files to upload in ${KNOWLEDGE_BASE_DIR}/${second}/${KNOWLEDGE_BASE_DOCUMENTS_DIR}/`
508
541
  case 'skills':
509
542
  if (parts.length === 2) return 'a skill is a folder: skills/<name>/SKILL.md'
510
543
  return `skills/${second}/ has no SKILL.md, so it is not pushed`
@@ -546,6 +579,12 @@ export function unappliedFiles(root: string): string[] {
546
579
  const [top, second] = rel.split(sep)
547
580
  // Folders whose files are not each applied: a Function's helpers, a knowledge base's documents, an App's code.
548
581
  if (top === 'functions' || top === 'apps' || (top === 'knowledge' && second && existsSync(join(root, 'knowledge', second, 'knowledge.json')))) continue
582
+ // A base's documents are each uploaded; a folder without knowledge-base.json is one line.
583
+ if (top === KNOWLEDGE_BASE_DIR && rel.split(sep)[2] === KNOWLEDGE_BASE_DOCUMENTS_DIR) continue
584
+ if (top === KNOWLEDGE_BASE_DIR && second && rel.split(sep).length === 2 && !existsSync(join(root, KNOWLEDGE_BASE_DIR, second, KNOWLEDGE_BASE_FILE))) {
585
+ out.push(`${rel}/: ${unappliedReason(root, join(rel, '_'))}`)
586
+ continue
587
+ }
549
588
  if (top === 'skills' && second && existsSync(join(root, 'skills', second, 'SKILL.md'))) continue
550
589
  if (top === 'themes' && second && existsSync(join(root, 'themes', second, 'theme.json'))) continue
551
590
  if (top === 'knowledge' || top === 'skills' || top === 'themes') {
@@ -562,6 +601,6 @@ export function unappliedFiles(root: string): string[] {
562
601
  }
563
602
  }
564
603
  }
565
- for (const top of ['agents', 'blueprint', 'chat-apps', 'datasets', 'knowledge', 'mcp', 'mcp-servers', 'skills', 'themes', 'workflows']) walk(join(root, top), 1)
604
+ for (const top of ['agents', 'blueprint', 'chat-apps', 'datasets', 'knowledge', KNOWLEDGE_BASE_DIR, 'mcp', 'mcp-servers', 'skills', 'themes', 'workflows']) walk(join(root, top), 1)
566
605
  return out
567
606
  }