@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
@@ -76,6 +76,9 @@ import { applyApp } from '../../local/local-apps'
76
76
  import { applyBrand, brandUpload } from '../../local/local-brand'
77
77
  import { applyLibrary } from '../../local/local-library'
78
78
  import { projectFileChecks } from '../../local/local-file-checks'
79
+ import { applyAgentKnowledgeBases, applyKnowledgeBase, applyKnowledgeSettings, knowledgeBaseCalls, knowledgeBasesOff, knowledgeOffOutput, movedCredentials, removeDeletedBases } from '../../local/local-knowledge-base-apply'
80
+ import { readKeptSchema } from '../../local/local-schemas'
81
+ import { readProviderSetup } from '../../local/local-providers'
79
82
  import { actionFailureLines, dedupeLines, hasIndexedPaths, nameIndexedPaths, refusedArtifact, refusedByRules, sourceSystemHint } from '../../local/local-blueprint-errors'
80
83
 
81
84
  /**
@@ -192,7 +195,7 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
192
195
  // What the Agent's files say that would apply in silence and fail in a chat (`local-agent-checks.ts`).
193
196
  const agent = agentChecks(project, target)
194
197
  // A Chat App, knowledge base or skill against its format: a key the platform drops is said, a wrong shape refused.
195
- const formats = projectFileChecks(project.root, target as { kind: string; file?: string })
198
+ const formats = projectFileChecks(project.root, target as { kind: string; file?: string }, (kind) => readKeptSchema(project, kind))
196
199
  const checked = { errors: [...agent.errors, ...formats.errors], warnings: [...agent.warnings, ...formats.warnings] }
197
200
  if (checked.errors.length) return { ok: false, output: [`error [USAGE]: ${describeTarget(target)} can't be applied as written`, ...checked.errors.map((e) => ` ${e}`), ...checked.warnings].join('\n') }
198
201
  const resolved = await resolveTarget(project, target, lookup, creds?.workspaceId, creds?.organizationId)
@@ -217,6 +220,15 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
217
220
  if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
218
221
  return withNotes(await applyBindings(project, target, opts.serviceUrl, creds))
219
222
  }
223
+ if (target.kind === 'knowledge-base' || target.kind === 'knowledge-base-settings') {
224
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
225
+ // As the runtime's owner, a Knowledge steward: an organization base is made at the organization level.
226
+ const call = knowledgeBaseCalls(opts.serviceUrl, creds)
227
+ const off = await knowledgeBasesOff(readState(project)?.components, call)
228
+ if (off) return { ok: false, output: knowledgeOffOutput(off) }
229
+ if (target.kind === 'knowledge-base-settings') return withNotes(await applyKnowledgeSettings(project.root, { call, workspaceId: creds.workspaceId, preferredModel: readProviderSetup().defaults.agentModel ?? null }))
230
+ return withNotes(await applyKnowledgeBase(project, target.name, { call, organizationId: creds.organizationId, workspaceId: creds.workspaceId, secrets: await localSecretPresence(opts.serviceUrl, creds) }))
231
+ }
220
232
  if (target.kind === 'knowledge') return withNotes(await applyKnowledge(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined))
221
233
  if (target.kind === 'theme') {
222
234
  if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
@@ -431,6 +443,14 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
431
443
  // Agents stay drafts here, so the row keeps the name it was created with unless set from the file.
432
444
  const named = await syncAgentName({ slug: target.slug, identity: agentIdentity(project.root, target.file), serviceUrl: opts.serviceUrl, creds })
433
445
  if (named) result = { ...result, output: `${result.output}\n${named}`.trim() }
446
+ // Its Knowledge app bases (`knowledgeBases`): set live, outside the draft, once the Agent is here.
447
+ if (creds.session) {
448
+ const call = knowledgeBaseCalls(opts.serviceUrl, creds)
449
+ const found = await call('workspace', 'GET', `/v1/config/agents/${encodeURIComponent(target.slug)}`).catch(() => ({ status: 0, body: null }))
450
+ const agentId = found.status === 200 ? ((found.body as { data?: { id?: string } } | null)?.data?.id ?? null) : null
451
+ const picks = await applyAgentKnowledgeBases(project, original.file, agentId, call)
452
+ if (picks.length) result = { ...result, output: [result.output, ...picks].join('\n').trim() }
453
+ }
434
454
  }
435
455
  let authoredAgain: string[] = []
436
456
  if (actionCalls && blueprint) {
@@ -928,7 +948,14 @@ async function dev(ctx: CommandContext) {
928
948
  const embeddings = target.kind === 'knowledge' ? ((await embeddingsDiffer(project.root, { serviceUrl, creds }).catch((): false => false)) || ('same' as const)) : undefined
929
949
  const embeddingsMoved = !!embeddings && !!hash && applied.get(target.key) === hash && (recorded?.embeddings ?? 'same') !== embeddings
930
950
  if (embeddingsMoved) ctx.output.note(` ${describeTarget(target)}: the runtime’s embeddings changed since it was applied (${embeddingWords(recorded?.embeddings ?? 'same')} then, ${embeddingWords(embeddings!)} now): applying it again so its threshold follows`)
931
- if (hash && applied.get(target.key) === hash && !draftGone && !actionsDown.length && !versionUnknown && !embeddingsMoved) {
951
+ // A Knowledge app base, or the Knowledge settings, while the runtime's search index is off: unchanged files,
952
+ // but nothing here can search the base, so it is applied, and fails saying why and what to run.
953
+ const knowledgeOff = (target.kind === 'knowledge-base' || target.kind === 'knowledge-base-settings') && !!hash && applied.get(target.key) === hash && !!creds.session && !!(await knowledgeBasesOff(readState(project)?.components, knowledgeBaseCalls(serviceUrl, creds)))
954
+ // A base whose source signs in with a secret set, or taken away, on the runtime since it was applied (`frontera secret set`):
955
+ // unchanged files, but it is applied again so the source signs in with the value there now.
956
+ const secretsMoved = target.kind === 'knowledge-base' && !!hash && applied.get(target.key) === hash && !knowledgeOff ? await movedCredentials(project, target.name, organizationId, () => localSecretPresence(serviceUrl, creds)) : []
957
+ if (secretsMoved.length) ctx.output.note(` ${describeTarget(target)}: ${secretsMoved.join(', ')} changed on the runtime since it was applied: applying it again so its sources sign in with the value there now`)
958
+ if (hash && applied.get(target.key) === hash && !draftGone && !actionsDown.length && !versionUnknown && !embeddingsMoved && !knowledgeOff && !secretsMoved.length) {
932
959
  unchanged += 1
933
960
  // An Agent applied before names were set from its file still reads its slug: set it once.
934
961
  if (target.kind === 'agent' && !named.has(target.slug)) {
@@ -1069,6 +1096,8 @@ async function dev(ctx: CommandContext) {
1069
1096
  let secretsDue = false
1070
1097
  /** Whether to deploy again the Custom Apps a database restore took away (`local-app-restore.ts`): at the start, and after a restore. */
1071
1098
  let appsDue = true
1099
+ /** Whether to look for knowledge-base/ folders deleted since their base was made (`removeDeletedBases`): at the start, and after one goes. */
1100
+ let basesDue = true
1072
1101
 
1073
1102
  /**
1074
1103
  * A Custom App deployed after the baseline a restore went back to is not on
@@ -1183,6 +1212,13 @@ async function dev(ctx: CommandContext) {
1183
1212
  appsDue = false
1184
1213
  await redeployRestoredApps()
1185
1214
  }
1215
+ // After this round's applies, so a renamed folder has found its base by its name first. A base folder
1216
+ // here that fails to apply may be one renamed: the pass waits for it, still due.
1217
+ const baseFailing = [...failing.keys()].some((key) => key.startsWith('knowledge-base:') && existsSync(join(project.root, 'knowledge-base', key.slice('knowledge-base:'.length))))
1218
+ if (basesDue && !stopping && !runtimeDown && !baseFailing) {
1219
+ basesDue = false
1220
+ await removeDeletedKnowledgeBases()
1221
+ }
1186
1222
  } finally {
1187
1223
  busy = false
1188
1224
  if (round.since) {
@@ -1363,6 +1399,27 @@ async function dev(ctx: CommandContext) {
1363
1399
  }
1364
1400
  }
1365
1401
 
1402
+ /** The knowledge bases this project made here whose folder was deleted: removed, said, and forgotten as applied, so a folder put back is applied again. */
1403
+ const removeDeletedKnowledgeBases = async (): Promise<void> => {
1404
+ if (!creds?.session) return
1405
+ try {
1406
+ const { lines, removed, kept } = await removeDeletedBases(project, organizationId, knowledgeBaseCalls(serviceUrl, creds), readState(project)?.components)
1407
+ for (const line of lines) ctx.output.note(line)
1408
+ // A base kept for a folder that has not applied yet: looked at again after the next round.
1409
+ if (kept.length) basesDue = true
1410
+ if (!removed.length) return
1411
+ const known = loadHashes(project, organizationId)
1412
+ for (const key of removed.map((folder) => `knowledge-base:${folder}`)) {
1413
+ applied.delete(key)
1414
+ known.delete(key)
1415
+ }
1416
+ saveHashes(project, organizationId, known)
1417
+ recordApplied(project, { at: new Date().toISOString(), what: removed.map((folder) => `knowledge-base/${folder}`).join(', '), ok: true })
1418
+ } catch (err) {
1419
+ ctx.output.note(` warning: the knowledge bases of deleted knowledge-base/ folders couldn’t be checked: ${(err as Error).message}`)
1420
+ }
1421
+ }
1422
+
1366
1423
  // Watch from the start, so a save during the first sync is not missed.
1367
1424
  const once = flagBool(ctx, 'once')
1368
1425
  // One watcher per project (Forge keeps one running; a second would apply every change twice). `--once` is always allowed.
@@ -1390,6 +1447,11 @@ async function dev(ctx: CommandContext) {
1390
1447
  // An Agent's folder, a workflow's or a Function's file deleted: what the runtime holds follows.
1391
1448
  if (/^(agents|workflows|functions)([\\/]|$)/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) void sweepOrphans()
1392
1449
  if (/^agents[\\/][^\\/]+[\\/]operating\.json$/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) void clearDeletedBehavior()
1450
+ // Something under knowledge-base/ deleted: a base folder gone goes from the runtime after the next round.
1451
+ if (/^knowledge-base[\\/]/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) {
1452
+ basesDue = true
1453
+ if (!busy) schedule()
1454
+ }
1393
1455
  // Only a change that queued an item starts a round: the watcher's own writes under `.frontera/`,
1394
1456
  // a screenshot, a coding agent's settings or a note in docs/ never do.
1395
1457
  if (queuePath(filename.toString()) && !busy) schedule()
@@ -15,7 +15,7 @@ import { findObjectSet, normalizeObjectSets, projectObjectSetNames } from '../..
15
15
  import { actionsStatusText, appsStatusText, buildStatusText, emptyKnowledgeLines, itemKindsText, notPulledLines, objectSetsStatusText, projectBuildRows, projectBuildSummary, projectFilesLines } from '../../local/local-build-summary'
16
16
  import { inheritedActionWarnings } from '../../local/local-check-inherited'
17
17
  import { checkoutVersion, docsStatus, versionAtStart } from '../../local/local-docs'
18
- import { dockerAvailable } from '../../local/local-docker'
18
+ import { dockerAvailable, dockerMemoryBytes } from '../../local/local-docker'
19
19
  import { downPhase, downToRestart, liveComponents, type DownComponent } from '../../local/local-component-health'
20
20
  import { readConfig, writeConfig, type LocalConfig } from '../../local/local-env'
21
21
  import { configuredProviders } from '../../local/local-keys'
@@ -31,7 +31,8 @@ import {
31
31
  stopRuntime,
32
32
  waitForPhase,
33
33
  } from '../../local/local-control'
34
- import { clearState, ensureStateDir, isAlive, logPath, readState, type ComponentName, type LocalState } from '../../local/local-state'
34
+ import { clearState, COMPONENTS, ensureStateDir, isAlive, logPath, readState, type ComponentName, type LocalState } from '../../local/local-state'
35
+ import { searchIndexChoice, searchIndexNeeds } from '../../local/local-search-index'
35
36
  import { newSessionToken } from '../../local/local-studio-server'
36
37
  import { migrateMcpConfigs } from '../../local/local-scaffold'
37
38
  import { DEFAULT_REGISTRY, runSupervisor } from '../../local/local-supervisor'
@@ -79,8 +80,9 @@ import { readLock, recentWriters, writerWords } from '../../local/local-writers'
79
80
  * Frontera Studio on top of it. Nothing here talks to a Frontera deployment.
80
81
  */
81
82
 
82
- const COMPONENTS: ComponentName[] = ['postgres', 'storage', 'service', 'jobs', 'runner', 'studio']
83
- const OPTIONAL: ComponentName[] = ['jobs', 'runner', 'storage']
83
+ const OPTIONAL: ComponentName[] = ['jobs', 'runner', 'storage', 'search-index']
84
+ /** Off unless the project needs it or `--with` names it. */
85
+ const ON_REQUEST: ComponentName[] = ['search-index']
84
86
 
85
87
  const sessionFile = (project: LocalProject) => join(project.stateDir, 'studio-session')
86
88
 
@@ -126,10 +128,10 @@ function renderStatus(state: LocalState, project: LocalProject, profile?: { prof
126
128
  for (const c of state.components) {
127
129
  const where = c.kind === 'container' ? 'container' : c.kind === 'process' ? 'process' : 'supervisor'
128
130
  const status = down.some((d) => d.name === c.name) ? 'down' : c.status
129
- lines.push(` ${DOT[status] ?? '·'} ${c.name.padEnd(9)} ${status.padEnd(9)} ${c.status === 'off' ? (c.detail ?? '') : where}${c.status === 'failed' && c.detail ? ` — ${c.detail}` : ''}`)
131
+ lines.push(` ${DOT[status] ?? '·'} ${c.name.padEnd(12)} ${status.padEnd(9)} ${c.status === 'off' ? (c.detail ?? '') : where}${c.status === 'failed' && c.detail ? ` — ${c.detail}` : ''}`)
130
132
  }
131
133
  const cache = down.find((d) => d.name === 'cache')
132
- if (cache) lines.push(` ✕ ${'cache'.padEnd(9)} ${'down'.padEnd(9)} container — ${cache.why}; \`frontera local start\` brings it back`)
134
+ if (cache) lines.push(` ✕ ${'cache'.padEnd(12)} ${'down'.padEnd(9)} container — ${cache.why}; \`frontera local start\` brings it back`)
133
135
  if (state.credentials) {
134
136
  lines.push('')
135
137
  lines.push(` Signed in as ${state.credentials.email}`)
@@ -222,6 +224,17 @@ function parseList(raw: string | undefined): ComponentName[] {
222
224
  return names
223
225
  }
224
226
 
227
+ function parseWith(raw: string | undefined): ComponentName[] {
228
+ if (!raw) return []
229
+ const names = raw.split(',').map((s) => s.trim()).filter(Boolean) as ComponentName[]
230
+ for (const n of names) {
231
+ if (!ON_REQUEST.includes(n)) {
232
+ throw new UsageError(`\`${n}\` cannot be added with --with`, `only ${ON_REQUEST.join(', ')} starts on request; the other components run unless excluded`)
233
+ }
234
+ }
235
+ return names
236
+ }
237
+
225
238
  async function start(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
226
239
  const project = localProject(ctx.cwd)
227
240
  // Coding-agent configs from before `frontera mcp` launched the documentation server alone.
@@ -262,13 +275,21 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
262
275
  if (sourceFlag && !isPlatformSource(source!)) {
263
276
  throw new UsageError(`${source} is not a Frontera platform checkout`, 'point --source at the repository root that holds packages/service')
264
277
  }
265
- const exclude = keep && !flagString(ctx, 'exclude') ? previous!.exclude : parseList(flagString(ctx, 'exclude'))
278
+ const exclude: string[] = keep && !flagString(ctx, 'exclude') ? previous!.exclude : parseList(flagString(ctx, 'exclude'))
279
+ const added: string[] = keep && !flagString(ctx, 'with') ? (previous!.with ?? []) : parseWith(flagString(ctx, 'with'))
280
+ const both = added.find((n) => exclude.includes(n))
281
+ if (both) throw new UsageError(`\`${both}\` is both added with --with and excluded`, `pass one of \`--with ${both}\` and \`--exclude ${both}\``)
282
+ // The supervisor decides the same way when it starts it.
283
+ const searchIndex = searchIndexChoice({ root: project.root, with: added, exclude, dockerMemoryBytes: dockerMemoryBytes() })
284
+ const needs = searchIndex.on ? searchIndexNeeds(exclude) : null
285
+ if (needs) throw new UsageError(needs, 'the search index runs for a project with a knowledge-base/ folder, or with `--with search-index`')
266
286
  const config: LocalConfig = {
267
287
  mode: source ? 'source' : 'image',
268
288
  platformSource: source ?? undefined,
269
289
  platformVersion: versionAtStart({ checkout: source ? checkoutVersion(source) : null, flag: flagString(ctx, 'version'), previous, cli: cliVersion() }),
270
290
  registry: normalizeRegistry(flagString(ctx, 'registry') ?? process.env.FRONTERA_LOCAL_REGISTRY ?? previous?.registry),
271
291
  exclude,
292
+ ...(added.length ? { with: added } : {}),
272
293
  }
273
294
  ensureStateDir(project)
274
295
  writeConfig(project, config)
@@ -277,7 +298,7 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
277
298
  const freed = reclaimedNote(reclaimed)
278
299
  if (freed) ctx.output.note(freed)
279
300
  for (const name of [...COMPONENTS, 'cache', 'embeddings', 'traces'] as const) {
280
- if (exclude.includes(name as ComponentName)) continue
301
+ if (exclude.includes(name) || (name === 'search-index' && !searchIndex.on)) continue
281
302
  if (!(await isPortFree(ports[name]))) {
282
303
  const holder = portHolder(ports[name])
283
304
  throw new CliError(`port ${ports[name]} (${name}) is already in use${holder ? ` by ${holder}` : ''}`, {
@@ -322,9 +343,12 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
322
343
 
323
344
  const startMeta = {
324
345
  args: [],
325
- flags: { source: 'string', image: 'boolean', version: 'string', registry: 'string', exclude: 'string' },
346
+ flags: { source: 'string', image: 'boolean', version: 'string', registry: 'string', exclude: 'string', with: 'string' },
326
347
  summary: 'Start Frontera\'s runtimes on this machine and seed an owner, workspace and keys',
327
- examples: ['frontera local start', 'frontera local start --exclude runner', 'frontera local start --image --version 1.52.19', 'frontera local start --image --registry registry.example.com/frontera'],
348
+ details: [
349
+ 'The search index (for knowledge bases) runs when the project has a knowledge-base/ folder, or with `--with search-index`; `--exclude search-index` keeps it off. It needs storage and jobs, and Docker with about 6 GB of memory (Docker Desktop: Settings > Resources > Memory limit): with less it stays off and `frontera local status` says so.',
350
+ ],
351
+ examples: ['frontera local start', 'frontera local start --exclude runner', 'frontera local start --with search-index', 'frontera local start --image --version 1.52.19', 'frontera local start --image --registry registry.example.com/frontera'],
328
352
  offline: true,
329
353
  } as const
330
354
 
@@ -27,6 +27,13 @@ import { localSync } from './sync'
27
27
  * kinds, and sample data for the pulled Blueprint. `frontera local start`
28
28
  * then runs it, and `local dev` applies what was pulled.
29
29
  */
30
+ /**
31
+ * Everything a solution is made of: Apps, Chat Apps and slide themes too, so a
32
+ * pulled project has what the workspace lists. Knowledge bases come without
33
+ * their documents, and a platform that does not take them pulls none.
34
+ */
35
+ const FROM_WORKSPACE_KINDS = 'blueprint,skills,functions,plugins,knowledge,knowledge-bases,workflows,agents,evaluations,themes,chat-apps,apps'
36
+
30
37
  export const localInit: Command = {
31
38
  meta: {
32
39
  noun: 'local',
@@ -46,6 +53,9 @@ export const localInit: Command = {
46
53
  rows: 'string',
47
54
  seed: 'string',
48
55
  },
56
+ flagHelp: {
57
+ kinds: `with --from-workspace, the kinds to pull, separated by commas (default: ${FROM_WORKSPACE_KINDS.split(',').join(', ')}); knowledge bases come without their documents (\`frontera local sync pull --documents knowledge-base/<name>\` brings them); \`none\` links the project and pulls nothing`,
58
+ },
49
59
  summary: 'Create a project: an Agent with a test case, a workflow, a Function and a Blueprint object type with its data',
50
60
  examples: [
51
61
  'frontera local init claims-intake',
@@ -158,8 +168,7 @@ async function startFromWorkspace(ctx: CommandContext, target: string): Promise<
158
168
  } else if (!readLink(target)) {
159
169
  throw new UsageError('no workspace to start from', 'pass --platform <url> --workspace-key - with the key on stdin')
160
170
  }
161
- // Everything a solution is made of: Apps, Chat Apps and slide themes too, so a pulled project has what the workspace lists.
162
- const kinds = flagString(ctx, 'kinds') ?? 'blueprint,skills,functions,plugins,knowledge,workflows,agents,evaluations,themes,chat-apps,apps'
171
+ const kinds = flagString(ctx, 'kinds') ?? FROM_WORKSPACE_KINDS
163
172
  // `none` links the project and pulls nothing, for someone who picks items next (`local sync pull <kind/name>…`).
164
173
  if (kinds === 'none') {
165
174
  const link = readLink(target)
@@ -16,7 +16,7 @@ export const localSchema: Command = {
16
16
  verb: 'schema',
17
17
  args: [{ name: 'kind', required: false, description: SCHEMA_KINDS.join(' | ') }],
18
18
  flags: {},
19
- summary: 'Print the JSON Schema of a project file (Agent, its bindings, workflow, tests, dataset, slide theme, Blueprint) for the runtime\'s release',
19
+ summary: 'Print the JSON Schema of a project file (Agent, its bindings, workflow, tests, dataset, slide theme, Blueprint, knowledge base) for the runtime\'s release',
20
20
  examples: ['frontera local schema', 'frontera local schema operating', 'frontera local schema workflow-tests', 'frontera local schema blueprint-action', 'frontera local schema bindings', "frontera local schema workflow | jq '.\"$defs\" | keys'"],
21
21
  offline: true,
22
22
  },
@@ -13,7 +13,11 @@ import { mcpServersNotCarried } from '../../local/local-mcp-servers'
13
13
  import { localProject, type LocalProject } from '../../local/local-project'
14
14
  import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
15
15
  import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
16
- import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetLabel, targetOf, targetProfileName, targetSession, targetWords, type SyncLink, type SyncTarget } from '../../local/local-sync-link'
16
+ import { getSyncKey, personReader, readLink, registerTargetProfile, setModelMapping, targetLabel, targetOf, targetProfileName, targetSession, targetWords, type SyncLink, type SyncTarget } from '../../local/local-sync-link'
17
+ import { KNOWLEDGE_BASE_DIR } from '../../local/local-knowledge-base-files'
18
+ import { KNOWLEDGE_SETTINGS_REF, knowledgeBaseDocumentsOn } from '../../local/local-knowledge-base-pull'
19
+ import { sourcesOffNeeds } from '../../local/local-knowledge-base-sources-off'
20
+ import type { PersonClients } from '../../local/local-sync-remote'
17
21
  import { itemTitle } from '../../local/local-sync-names'
18
22
  import { checkBody, markMissingModels, markRefused, movedOnTarget, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
19
23
  import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
@@ -86,6 +90,9 @@ function needLink(project: LocalProject): SyncLink {
86
90
  return link
87
91
  }
88
92
 
93
+ /** An item as a line names it: its kind and title; the Knowledge settings by their title alone. */
94
+ const labelOf = (i: { ref: string; kind: SyncKind; title: string }) => (i.ref === KNOWLEDGE_SETTINGS_REF ? i.title : `${KIND_WORD[i.kind]} ${i.title}`)
95
+
89
96
  const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
90
97
 
91
98
  // ── The platform ────────────────────────────────────────────────────────────
@@ -331,6 +338,8 @@ interface ViewItem {
331
338
  id: string
332
339
  ref: string
333
340
  kind: SyncKind
341
+ /** The platform's id of what the item became, once applied (a knowledge base Apply created). */
342
+ resourceId?: string | null
334
343
  /** What the item's candidate is: evidence taken for it is bound to it. */
335
344
  candidateDigest?: string
336
345
  name: string
@@ -547,12 +556,13 @@ function assetUploader(project: Pick<LocalProject, 'root'>, api: Platform) {
547
556
  async function stageDocuments(project: Pick<LocalProject, 'root'>, api: Platform, view: View): Promise<number> {
548
557
  let sent = 0
549
558
  for (const item of view.items) {
550
- if (item.kind !== 'knowledge' || !item.documents) continue
559
+ if ((item.kind !== 'knowledge' && item.kind !== 'knowledge-base') || !item.documents) continue
551
560
  for (const doc of item.documents.upload) {
552
561
  if (doc.staged) continue
553
- const path = documentPath(doc.folder, doc.fileName)
554
- const bytes = new Uint8Array(readFileSync(join(project.root, 'knowledge', item.name, path)))
555
- if (sha256Of(bytes) !== doc.sha256) throw new CliError(`knowledge/${item.name}/${path} changed while it was proposed`, { code: 'CONFLICT', hint: 'push again' })
562
+ // A Knowledge app base keeps its documents in `documents/`, with no folders.
563
+ const path = item.kind === 'knowledge' ? `knowledge/${item.name}/${documentPath(doc.folder, doc.fileName)}` : `${KNOWLEDGE_BASE_DIR}/${item.name}/documents/${doc.fileName}`
564
+ const bytes = new Uint8Array(readFileSync(join(project.root, path)))
565
+ if (sha256Of(bytes) !== doc.sha256) throw new CliError(`${path} changed while it was proposed`, { code: 'CONFLICT', hint: 'push again' })
556
566
  await api.send('PUT', `/v1/change-requests/${view.request.id}/documents/${doc.sha256}`, bytes, 'application/octet-stream', { timeoutMs: 300_000 })
557
567
  sent++
558
568
  }
@@ -578,6 +588,29 @@ async function platformDocuments(api: Platform, names: string[], remote: Array<{
578
588
  return out
579
589
  }
580
590
 
591
+ /** What the workspace holds of each Knowledge app base whose documents are brought, read as the person signed in: its documents by name. */
592
+ async function baseDocumentsOn(person: PersonClients | null, refs: string[], remote: Array<{ ref: string; id?: string }>): Promise<Map<string, Array<{ id: string; fileName: string }>>> {
593
+ const out = new Map<string, Array<{ id: string; fileName: string }>>()
594
+ for (const ref of refs) {
595
+ const id = remote.find((r) => r.ref === ref)?.id
596
+ out.set(ref, person && id ? await knowledgeBaseDocumentsOn(person.workspace, id) : [])
597
+ }
598
+ return out
599
+ }
600
+
601
+ /** The words for what a knowledge base step needs of the person applying (the platform's `knowledge_*` needs), as this CLI's next step. */
602
+ function knowledgeNextStep(code: string, acting: Acting): string | null {
603
+ if (code === 'knowledge_needs_person') return `sign in with \`${signInCommand(acting.profile)}\` and push again, so the change request is yours to apply as yourself`
604
+ if (code === 'knowledge_needs_steward') return 'apply it signed in as a Knowledge steward, or ask one to apply it; an organization owner or admin names stewards (`frontera knowledge-base steward add`)'
605
+ if (code === 'knowledge_settings_need_admin') return 'ask an organization owner or admin to apply it, or take the parts only they set (searchModel, sources) out of knowledge-base/settings.json'
606
+ return null
607
+ }
608
+
609
+ /** What Apply needs of a person, each with this CLI's next step where it has its own. */
610
+ function personNeeds(plan: ApplyStep[], acting: Acting): ReturnType<typeof needsAPerson> {
611
+ return needsAPerson(plan).map((n) => ({ ...n, fix: knowledgeNextStep(n.code, acting) ?? n.fix }))
612
+ }
613
+
581
614
  /**
582
615
  * What still uses each item this push would delete, read with the platform's
583
616
  * own in-use check before anything is proposed, so a deletion names its
@@ -637,7 +670,7 @@ function changedThereLines(target: SyncTarget, changed: Array<{ ref: string; kin
637
670
  return [
638
671
  '',
639
672
  `Changed in ${targetLabel(target)} since this project last delivered it, so not proposed:`,
640
- ...changed.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: to put the project’s version back over it, \`${targetWords(target, `frontera local sync resolve ${i.ref} --keep mine`)}\``),
673
+ ...changed.map((i) => ` ${labelOf(i)}: to put the project’s version back over it, \`${targetWords(target, `frontera local sync resolve ${i.ref} --keep mine`)}\``),
641
674
  ]
642
675
  }
643
676
 
@@ -653,13 +686,10 @@ function previewText(target: SyncTarget, items: PushItem[], kept: { title: strin
653
686
  // Each item's notes under it.
654
687
  ...ok.flatMap((i) => [` [x] ${pushItemLine(i)}`, ...(usedBy.has(i.ref) ? [` ! Still used by ${usedBy.get(i.ref)!.join(', ')}: applying would be refused`] : []), ...i.notes.map((n) => ` · ${n}`)]),
655
688
  ]
656
- if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}`))
657
- if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
689
+ if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${labelOf(i)}`))
690
+ if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${labelOf(i)}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
658
691
  lines.push(...changedThereLines(target, changedThere))
659
- const before = person.filter((p) => !p.after)
660
- const after = person.filter((p) => p.after)
661
- if (before.length) lines.push('', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`]))
662
- if (after.length) lines.push('', 'Left for a person after Apply:', ...after.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Then: ${p.fix}`]))
692
+ lines.push(...needLines(person))
663
693
  if (orgNote) lines.push('', orgNote)
664
694
  if (notCarried) lines.push('', notCarried)
665
695
  if (!available) lines.push('', 'This platform does not take change requests yet.')
@@ -667,6 +697,16 @@ function previewText(target: SyncTarget, items: PushItem[], kept: { title: strin
667
697
  return lines.join('\n')
668
698
  }
669
699
 
700
+ /** What Apply needs of a person, before it and after it, as the preview and the push say it. */
701
+ function needLines(person: ReturnType<typeof needsAPerson>): string[] {
702
+ const before = person.filter((p) => !p.after)
703
+ const after = person.filter((p) => p.after)
704
+ return [
705
+ ...(before.length ? ['', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`])] : []),
706
+ ...(after.length ? ['', 'Left for a person after Apply:', ...after.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Then: ${p.fix}`])] : []),
707
+ ]
708
+ }
709
+
670
710
  /**
671
711
  * The release items asked for: each App resolved in the workspace, and the
672
712
  * deployment named, or its newest one (what was deployed last, previews
@@ -729,20 +769,39 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
729
769
  const command = (words: string) => targetWords(target, `frontera local sync ${words}`)
730
770
  // Fetch first, as git does before a push: an item the workspace moved past is pulled and merged first.
731
771
  // A named target is never pulled from: it is compared with what this project last delivered to it (`targetPlan`).
732
- const { plan, lock, snapshot, remote, delivered } = target.name ? await targetPlan(project, target) : { ...(await planFor(project, link, { fetch: 'always', repair: 'online' })), delivered: null }
772
+ // A project with knowledge bases reads the whole index: the names Apply finds a base by can move without its revision.
773
+ const { plan, lock, snapshot, remote, delivered } = target.name ? await targetPlan(project, target) : { ...(await planFor(project, link, { fetch: 'always', repair: 'online', full: existsSync(join(project.root, KNOWLEDGE_BASE_DIR)) })), delivered: null }
733
774
  if (!plan || !snapshot) throw new CliError('could not fetch the workspace', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch` and look at what it says' })
734
775
  const available = snapshot.capabilities.includes('change-requests')
735
776
  // Knowledge bases whose documents come too: opt-in, as for a pull (`*` for every one the project has).
736
- const asked = (flagString(ctx, 'documents') ?? '').split(',').map((s) => s.trim().replace(/^knowledge\//, '')).filter(Boolean)
737
- const knowledgeHere = [...projectRefs(project.root).values()].filter((r) => r.kind === 'knowledge').map((r) => r.name)
738
- const documentNames = asked.includes('*') ? knowledgeHere : asked
739
- const notHere = documentNames.filter((n) => !knowledgeHere.includes(n))
740
- if (notHere.length) throw new UsageError(`no knowledge base ${notHere.join(', ')} in this project`, '--documents takes knowledge base names (knowledge/<name>), or * for every one')
777
+ // Agent knowledge by name (`knowledge/<name>`), a Knowledge app base by its folder (`knowledge-base/<folder>`).
778
+ const asked = (flagString(ctx, 'documents') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
779
+ const here = [...projectRefs(project.root).values()]
780
+ const knowledgeHere = here.filter((r) => r.kind === 'knowledge').map((r) => r.name)
781
+ const basesHere = here.filter((r) => r.kind === 'knowledge-base' && `${KNOWLEDGE_BASE_DIR}/${r.name}` !== KNOWLEDGE_SETTINGS_REF).map((r) => `${KNOWLEDGE_BASE_DIR}/${r.name}`)
782
+ const isBase = (a: string) => a.startsWith(`${KNOWLEDGE_BASE_DIR}/`)
783
+ const documentNames = asked.includes('*') ? knowledgeHere : asked.filter((a) => !isBase(a)).map((a) => a.replace(/^knowledge\//, ''))
784
+ const documentBases = asked.includes('*') ? basesHere : asked.filter(isBase)
785
+ const notHere = [...documentNames.filter((n) => !knowledgeHere.includes(n)), ...documentBases.filter((r) => !basesHere.includes(r))]
786
+ if (notHere.length) throw new UsageError(`no knowledge base ${notHere.join(', ')} in this project`, '--documents takes knowledge base names (knowledge/<name>, or knowledge-base/<folder> for the Knowledge app), or * for every one')
741
787
  const api = await platformFor(target)
742
- const documents = documentNames.length
743
- ? { names: new Set(documentNames), pulled: readPulledDocuments(target.state, link.workspace).bases, platform: await platformDocuments(api, documentNames, remote.items) }
788
+ // The Knowledge app's bases are read, proposed and applied as the person signed in, never with the project's keys.
789
+ const signedIn = here.some((r) => r.kind === 'knowledge-base') || plan.some((p) => p.kind === 'knowledge-base') ? await personReader(target.owner, link) : null
790
+ const documents = documentNames.length || documentBases.length
791
+ ? {
792
+ names: new Set([...documentNames, ...documentBases]),
793
+ pulled: readPulledDocuments(target.state, link.workspace).bases,
794
+ platform: new Map([...(await platformDocuments(api, documentNames, remote.items)), ...(await baseDocumentsOn(signedIn, documentBases, remote.items))]),
795
+ }
744
796
  : undefined
745
797
  const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, named, ...(documents ? { documents } : {}) })
798
+ if (!signedIn) {
799
+ const signIn = signInCommand(api.acting.profile)
800
+ for (const i of all) {
801
+ if (i.kind !== 'knowledge-base' || !i.proposable) continue
802
+ Object.assign(i, { proposable: false, blocked: 'sign-in', reason: `This workspace’s knowledge bases need a sign-in: run \`${signIn}\`, then push again`, fix: `sign in with \`${signIn}\`` })
803
+ }
804
+ }
746
805
  // A named target is never pulled from: what it changed since this project delivered it has its own words.
747
806
  const changedThere = target.name ? plan.filter((p) => p.status === 'changed').map((p) => ({ ref: p.ref, kind: p.kind, title: p.title })) : []
748
807
  if (target.name) {
@@ -764,7 +823,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
764
823
  const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
765
824
  const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
766
825
  const checked = available ? await precheck(api, all, organizationKeyOf(api.acting), target) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
767
- const person = needsAPerson(checked.plan)
826
+ // Sources the project's Knowledge settings keep off: the platform's checks pass them, and Apply refuses them.
827
+ const person = [...personNeeds(checked.plan, api.acting), ...sourcesOffNeeds(project.root).filter((n) => all.some((i) => i.ref === n.ref && i.proposable))]
768
828
  const kept = currentRequest(readRequests(target.state, link.workspace))
769
829
  const updating = kept && isOpen(kept.state) ? kept : null
770
830
  const orgStored = !!getSyncKey(target.owner, 'organization')
@@ -835,7 +895,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
835
895
  // Named items must each be proposable; with none named, an item the workspace moved past stops the push.
836
896
  const stopping = chosen.filter((i) => !i.proposable && (named.length > 0 || i.blocked === 'moved' || i.blocked === 'conflicts'))
837
897
  if (stopping.length) {
838
- const lines = stopping.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)
898
+ const lines = stopping.map((i) => ` ${labelOf(i)}: ${i.reason}`)
839
899
  throw new CliError(`not proposed: ${plural(stopping.length, 'item')} cannot go yet\n${lines.join('\n')}`, {
840
900
  code: 'SYNC_PUSH_BLOCKED',
841
901
  hint: target.name && stopping.some((i) => i.blocked === 'moved')
@@ -852,7 +912,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
852
912
  // Changes the checks refused are changes still: say why each can't go, not that nothing changed.
853
913
  const held = chosen.filter((i) => !i.proposable && !i.sample)
854
914
  if (held.length) {
855
- throw new CliError(`nothing to propose: ${plural(held.length, 'change')} here cannot go yet\n${held.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]).join('\n')}`, {
915
+ throw new CliError(`nothing to propose: ${plural(held.length, 'change')} here cannot go yet\n${held.flatMap((i) => [` ${labelOf(i)}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]).join('\n')}`, {
856
916
  code: 'SYNC_PUSH_BLOCKED',
857
917
  hint: `fix ${held.length === 1 ? 'it' : 'them'} as said, then check with \`${command('push --preview')}\``,
858
918
  details: { blocked: held.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })) },
@@ -916,16 +976,18 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
916
976
  notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
917
977
  samples: leftSamples.map((i) => i.ref),
918
978
  ...stewardData(api.acting, needsOrganizationKey(view.items), orgStored),
979
+ needsPerson: person.filter((p) => going.some((i) => i.ref === p.ref)),
919
980
  notCarried,
920
981
  },
921
982
  text: [
922
983
  `${open ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${targetLabel(target)}: ${plural(view.items.length, 'item')}.`,
923
984
  ...going.map((i) => ` ${pushItemLine(i)}`),
924
985
  ...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
925
- ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
986
+ ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${labelOf(i)}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
926
987
  ...changedThereLines(target, changedThere),
927
988
  ...(leftSamples.length ? ['', `Left out: ${plural(leftSamples.length, 'sample')} from setting up the project (${leftSamples.map((i) => i.title).join(', ')}); name ${leftSamples.length === 1 ? 'it' : 'them'} to propose`] : []),
928
989
  ...(needsOrganizationKey(view.items) ? ['', stewardNote(api.acting, true, pushAgain(api.acting, view))] : []),
990
+ ...needLines(person.filter((p) => going.some((i) => i.ref === p.ref))),
929
991
  ...(mcp ? ['', mcp.line] : []),
930
992
  '',
931
993
  `Apply it with \`${command('request apply')}\`; see it with \`${command('request')}\`.`,
@@ -1072,6 +1134,18 @@ async function currentView(target: SyncTarget): Promise<{ view: View; titles: Re
1072
1134
  * request proposed before items were named by display name kept "brand"),
1073
1135
  * then what its proposed files declare. The kept titles are not rewritten.
1074
1136
  */
1137
+ /** Whether an Agent's file sets its knowledge bases, which Apply sets as the person applying. */
1138
+ function picksIn(text: string | undefined): boolean {
1139
+ try {
1140
+ return Array.isArray((JSON.parse(text ?? '') as { knowledgeBases?: unknown }).knowledgeBases)
1141
+ } catch {
1142
+ return false
1143
+ }
1144
+ }
1145
+
1146
+ /** Each item's title by ref, as the request's lines name them. */
1147
+ const allTitlesOf = (view: View, titles: Record<string, string>) => Object.fromEntries(view.items.map((i) => [i.ref, titleOf(i, titles)]))
1148
+
1075
1149
  function titleOf(item: ViewItem, titles: Record<string, string>): string {
1076
1150
  if (item.kind === 'app') return `${item.name} ${releasedDeployment(item) ?? ''}`.trim()
1077
1151
  return itemTitle(item.kind, item.name, item.files, titles[item.ref])
@@ -1178,17 +1252,28 @@ async function apply(ctx: CommandContext) {
1178
1252
  if (view.request.state === 'merged') throw new CliError(`“${view.request.title}” is already applied`, { code: 'CONFLICT', hint: `propose new changes with \`${command('push -t "<title>"')}\`` })
1179
1253
  if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: `propose the changes again with \`${command('push -t "<title>"')}\`` })
1180
1254
  const proposed: ProposedBy = proposedByOther(acting, view) === 'key' ? 'key' : 'you'
1181
- if (acting.as === 'person' && acting.organizationKeyStored && view.items.some((i) => i.kind === 'blueprint' || i.kind === 'agent')) {
1255
+ const stewardMatters = acting.as === 'person' && acting.organizationKeyStored && view.items.some((i) => i.kind === 'blueprint' || i.kind === 'agent')
1256
+ // A knowledge base, the Knowledge settings and an Agent's picks are applied by a person who may change them.
1257
+ const knowledgeMatters = view.items.some((i) => i.kind === 'knowledge-base' || (i.kind === 'agent' && picksIn(i.files[`agents/${i.name}/agent.json`])))
1258
+ if (stewardMatters || knowledgeMatters) {
1182
1259
  // The platform refuses a person who is no Steward only once Apply runs, so ask its checks, with every item so references resolve.
1183
- const items = view.items.map((i) => ({ ref: i.ref, files: i.files, ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}), ...(i.deleted ? { deleted: true } : {}) }))
1260
+ const items = view.items.map((i) => ({ ref: i.ref, ...(i.resourceId && i.kind === 'knowledge-base' ? { id: i.resourceId } : {}), files: i.files, ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}), ...(i.deleted ? { deleted: true } : {}) }))
1184
1261
  const plan = await api.call<unknown>('POST', '/v1/change-requests/check', { items }, { timeoutMs: 120_000 }).then(planOf, () => [])
1185
- const stop = notSteward(acting, stewardStepUnmet(needsAPerson(plan), view.items), pushAgain(acting, view), proposed)
1262
+ const stop = stewardMatters ? notSteward(acting, stewardStepUnmet(needsAPerson(plan), view.items), pushAgain(acting, view), proposed) : null
1186
1263
  if (stop) throw stop
1264
+ const unmet = personNeeds(plan, acting).filter((n) => n.code.startsWith('knowledge_') && !n.after)
1265
+ if (unmet.length) {
1266
+ throw new CliError(`not applied: ${unmet.map((n) => `${allTitlesOf(view, titles)[n.ref] ?? n.ref}: ${n.message}`).join('; ')}`, {
1267
+ code: unmet.some((n) => n.code === 'knowledge_needs_person') ? 'SYNC_NOT_SIGNED_IN' : 'FORBIDDEN',
1268
+ hint: unmet[0]!.fix,
1269
+ details: { needsPerson: unmet },
1270
+ })
1271
+ }
1187
1272
  }
1188
1273
  const refused = notProposer(acting, view, 'apply')
1189
1274
  // A workspace admin signed in may apply another's request once every item is approved: the platform decides.
1190
1275
  if (refused && !(acting.as === 'person' && view.request.state === 'approved')) throw refused
1191
- const allTitles = Object.fromEntries(view.items.map((i) => [i.ref, titleOf(i, titles)]))
1276
+ const allTitles = allTitlesOf(view, titles)
1192
1277
  const blueprint = view.items.some((i) => i.kind === 'blueprint')
1193
1278
  // With the keys, sent whenever one is stored: an Agent's Blueprint grants are a Steward's too, in a request without Blueprint files (P5 A5).
1194
1279
  const organizationKey = organizationKeyOf(acting)
@@ -1243,7 +1328,9 @@ async function apply(ctx: CommandContext) {
1243
1328
  try {
1244
1329
  // A deletion shows only in a full read of the workspace's index (an incremental one lists no deletions).
1245
1330
  const deleted = answer.items.filter((i) => i.deleted).map((i) => i.ref)
1246
- const result = await fetchTarget(project, target, { full: deleted.length > 0 })
1331
+ // A knowledge base keeps the folder it was proposed from, whatever name the workspace files it under (one just created too).
1332
+ const folders = new Map(answer.items.flatMap((i) => (i.kind === 'knowledge-base' && i.resourceId && !i.deleted && i.ref !== KNOWLEDGE_SETTINGS_REF ? [[i.resourceId, i.name] as const] : [])))
1333
+ const result = await fetchTarget(project, target, { full: deleted.length > 0, knowledgeBaseFolders: folders })
1247
1334
  // A Custom App release moved a pointer; the App's own sync item (its description) is not the project's to adopt.
1248
1335
  const adoptable = answer.items.filter((i) => i.kind !== 'app').map((i) => i.ref)
1249
1336
  // A named target's base is its own (`target.state`), so the next push to it reads what changed there since.
@@ -1295,6 +1382,7 @@ async function apply(ctx: CommandContext) {
1295
1382
  */
1296
1383
  async function recordAppliedDocuments(target: SyncTarget, api: Platform, view: View): Promise<void> {
1297
1384
  const { link } = target
1385
+ await recordAppliedBaseDocuments(target, view)
1298
1386
  const items = view.items.filter((i) => i.kind === 'knowledge' && i.documents)
1299
1387
  if (!items.length) return
1300
1388
  const snapshot = readSnapshot(target.state)
@@ -1322,6 +1410,24 @@ async function recordAppliedDocuments(target: SyncTarget, api: Platform, view: V
1322
1410
  }
1323
1411
  }
1324
1412
 
1413
+ /** The same for the Knowledge app's bases, read as the person signed in, recorded under `knowledge-base/<folder>`. */
1414
+ async function recordAppliedBaseDocuments(target: SyncTarget, view: View): Promise<void> {
1415
+ const items = view.items.filter((i) => i.kind === 'knowledge-base' && i.documents && i.resourceId)
1416
+ if (!items.length) return
1417
+ const person = await personReader(target.owner, target.link)
1418
+ if (!person) return
1419
+ for (const item of items) {
1420
+ const rows = await knowledgeBaseDocumentsOn(person.workspace, item.resourceId!)
1421
+ const entries: Record<string, PulledDocument | null> = {}
1422
+ for (const d of item.documents!.upload) {
1423
+ const newest = rows.filter((r) => r.fileName === d.fileName && !r.synced).sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')))[0]
1424
+ if (newest) entries[d.fileName] = { sourceId: newest.id, sha256: d.sha256, bytes: d.bytes, at: new Date().toISOString() }
1425
+ }
1426
+ for (const d of item.documents!.remove) entries[d.fileName] = null
1427
+ recordPulledDocuments(target.state, target.link.workspace, item.ref, entries)
1428
+ }
1429
+ }
1430
+
1325
1431
  async function close(ctx: CommandContext) {
1326
1432
  const project = localProject(ctx.cwd)
1327
1433
  const target = chooseTarget(project, ctx)