@frontera-sdk/cli 1.53.23 → 1.53.24

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
@@ -20,6 +20,8 @@ import { projectModels } from './local-sync-models'
20
20
  import { deepEqual, sameContent } from './local-sync-merge'
21
21
  import { portableNotes, sameDocumentText, toLocal, toWorkspace } from './local-sync-portable'
22
22
  import { itemTitle } from './local-sync-names'
23
+ import { KNOWLEDGE_BASE_DIR, KNOWLEDGE_BASE_FILE, KNOWLEDGE_BASE_SETTINGS_FILE, KNOWLEDGE_BASE_SOURCES_DIR } from './local-knowledge-base-files'
24
+ import { KNOWLEDGE_BASES_FEATURE, KNOWLEDGE_SETTINGS_NAME, KNOWLEDGE_SETTINGS_REF, knowledgeBaseDocuments, platformRefName } from './local-knowledge-base-pull'
23
25
  import { isScaffoldSample } from './local-scaffold'
24
26
  import { hash, itemHash, readLocal, safePath, writeLock, type LockEntry, type PlanItem, type RemoteItem, type SyncKind, type SyncLock } from './local-sync-plan'
25
27
  import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync-snapshot'
@@ -44,7 +46,7 @@ import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync
44
46
  */
45
47
 
46
48
  /** The kinds a change request carries. */
47
- export const PUSH_KINDS: readonly SyncKind[] = ['blueprint', 'dataset', 'skill', 'knowledge', 'plugin', 'function', 'workflow', 'agent', 'evaluation', 'chat-app', 'lessons', 'app']
49
+ export const PUSH_KINDS: readonly SyncKind[] = ['blueprint', 'dataset', 'skill', 'knowledge', 'knowledge-base', 'plugin', 'function', 'workflow', 'agent', 'evaluation', 'chat-app', 'lessons', 'app']
48
50
 
49
51
  /** Why the other kinds are not proposed, in the reader's words. */
50
52
  export const NOT_PROPOSED: Partial<Record<SyncKind, string>> = {
@@ -65,7 +67,7 @@ const NOT_DELETED: Partial<Record<SyncKind, string>> = {
65
67
  * lacks and `sync.models` maps to none; `refused`: the workspace's checks
66
68
  * would refuse it (`POST /v1/change-requests/check`), said before proposing.
67
69
  */
68
- export type BlockCode = 'moved' | 'conflicts' | 'deleted' | 'workspace-deleted' | 'kind' | 'shape' | 'model' | 'refused'
70
+ export type BlockCode = 'moved' | 'conflicts' | 'deleted' | 'workspace-deleted' | 'kind' | 'shape' | 'model' | 'refused' | 'sign-in'
69
71
 
70
72
  /** A skill's file that is not text (an image, a font, a template): uploaded as a skill asset when proposed. */
71
73
  export interface PushAsset {
@@ -238,6 +240,20 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
238
240
  if (documents.length && !opts.documents) notes.push(`Its ${documents.length} document${documents.length === 1 ? '' : 's'} stay${documents.length === 1 ? 's' : ''} as the workspace has them unless you bring them; the request carries the knowledge base’s settings`)
239
241
  break
240
242
  }
243
+ case 'knowledge-base': {
244
+ if (name === KNOWLEDGE_SETTINGS_NAME) {
245
+ if (existsSync(join(root, KNOWLEDGE_BASE_DIR, name, KNOWLEDGE_BASE_FILE))) return { files: {}, notes, problem: `${KNOWLEDGE_BASE_DIR}/${name}/ is named like the organization’s Knowledge settings (${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE}): rename the folder to propose that knowledge base` }
246
+ paths = [`${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE}`]
247
+ break
248
+ }
249
+ // The base and every one of its sources: a source with no file here is one the request removes.
250
+ const dir = `${KNOWLEDGE_BASE_DIR}/${name}`
251
+ paths = [`${dir}/${KNOWLEDGE_BASE_FILE}`, ...filesUnder(root, `${dir}/${KNOWLEDGE_BASE_SOURCES_DIR}`).filter((p) => /^[^/]+\.json$/.test(p.slice(`${dir}/${KNOWLEDGE_BASE_SOURCES_DIR}/`.length)))]
252
+ const { documents, nested } = knowledgeBaseDocuments(root, name)
253
+ if (documents.length && !opts.documents) notes.push(`Its ${documents.length} document${documents.length === 1 ? '' : 's'} stay${documents.length === 1 ? 's' : ''} as the workspace has them unless you bring them: push with \`--documents ${dir}\``)
254
+ if (nested.length) notes.push(`${nested.map((n) => `${dir}/documents/${n}/`).join(', ')} stay${nested.length === 1 ? 's' : ''} here: a knowledge base has no folders, so put its files directly in ${dir}/documents/`)
255
+ break
256
+ }
241
257
  case 'blueprint':
242
258
  paths = [`blueprint/${name}.yaml`, `blueprint/${name}.yml`, `blueprint/${name}.json`]
243
259
  break
@@ -257,7 +273,7 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
257
273
  return { files: {}, notes }
258
274
  }
259
275
  // What the last pull wrote for it comes along when it is still here (an agent's files are fixed; the rest may not be).
260
- if (kind !== 'agent' && kind !== 'knowledge' && kind !== 'app') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
276
+ if (kind !== 'agent' && kind !== 'knowledge' && kind !== 'knowledge-base' && kind !== 'app') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
261
277
  const files: Record<string, string> = {}
262
278
  const here: Record<string, string> = {}
263
279
  const models = projectModels(root)
@@ -283,10 +299,11 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
283
299
  * The items the project holds, by ref, as a request names them: Agents,
284
300
  * workflows, Functions (a file or a folder of their own), skills, knowledge
285
301
  * bases, Blueprint files, and plugins (listed so a push can say why not).
302
+ * `file` is the file that makes it an item, and names it.
286
303
  */
287
- export function projectRefs(root: string): Map<string, { kind: SyncKind; name: string }> {
288
- const out = new Map<string, { kind: SyncKind; name: string }>()
289
- const add = (kind: SyncKind, name: string) => out.set(`${kind}/${name}`, { kind, name })
304
+ export function projectRefs(root: string): Map<string, { kind: SyncKind; name: string; file: string }> {
305
+ const out = new Map<string, { kind: SyncKind; name: string; file: string }>()
306
+ const add = (kind: SyncKind, name: string, file: string) => out.set(`${kind}/${name}`, { kind, name, file })
290
307
  const dirs = (rel: string) => {
291
308
  try {
292
309
  return readdirSync(join(root, rel)).filter((n) => !isIgnoredName(n) && !SKIP_DIRS.has(n)).sort()
@@ -294,30 +311,33 @@ export function projectRefs(root: string): Map<string, { kind: SyncKind; name: s
294
311
  return []
295
312
  }
296
313
  }
297
- for (const slug of dirs('agents')) if (existsSync(join(root, 'agents', slug, 'agent.json'))) add('agent', slug)
314
+ for (const slug of dirs('agents')) if (existsSync(join(root, 'agents', slug, 'agent.json'))) add('agent', slug, `agents/${slug}/agent.json`)
298
315
  for (const f of dirs('workflows')) {
299
316
  const m = /^(.+?)(?:\.workflow)?\.(ya?ml|json)$/.exec(f)
300
- if (m) add('workflow', m[1]!)
317
+ if (m) add('workflow', m[1]!, `workflows/${f}`)
301
318
  }
302
319
  for (const f of dirs('functions')) {
303
- if (/\.ts$/.test(f) && !/\.(test|d)\.ts$/.test(f)) add('function', f.replace(/\.ts$/, ''))
304
- else if (existsSync(join(root, 'functions', f, 'package.json'))) add('function', f)
320
+ if (/\.ts$/.test(f) && !/\.(test|d)\.ts$/.test(f)) add('function', f.replace(/\.ts$/, ''), `functions/${f}`)
321
+ else if (existsSync(join(root, 'functions', f, 'package.json'))) add('function', f, `functions/${f}/package.json`)
305
322
  }
306
- for (const n of dirs('skills')) if (existsSync(join(root, 'skills', n, 'SKILL.md'))) add('skill', n)
307
- for (const n of dirs('knowledge')) if (existsSync(join(root, 'knowledge', n, 'knowledge.json'))) add('knowledge', n)
308
- for (const dir of BLUEPRINT_DIRS) for (const f of dirs(`blueprint/${dir}`)) if (/\.(ya?ml|json)$/.test(f)) add('blueprint', `${dir}/${f.replace(/\.(ya?ml|json)$/, '')}`)
323
+ for (const n of dirs('skills')) if (existsSync(join(root, 'skills', n, 'SKILL.md'))) add('skill', n, `skills/${n}/SKILL.md`)
324
+ for (const n of dirs('knowledge')) if (existsSync(join(root, 'knowledge', n, 'knowledge.json'))) add('knowledge', n, `knowledge/${n}/knowledge.json`)
325
+ // The Knowledge app's bases, and the organization's Knowledge settings (`knowledge-base/settings`).
326
+ for (const n of dirs(KNOWLEDGE_BASE_DIR)) if (existsSync(join(root, KNOWLEDGE_BASE_DIR, n, KNOWLEDGE_BASE_FILE))) add('knowledge-base', n, `${KNOWLEDGE_BASE_DIR}/${n}/${KNOWLEDGE_BASE_FILE}`)
327
+ if (existsSync(join(root, KNOWLEDGE_BASE_DIR, KNOWLEDGE_BASE_SETTINGS_FILE))) add('knowledge-base', KNOWLEDGE_SETTINGS_NAME, `${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE}`)
328
+ for (const dir of BLUEPRINT_DIRS) for (const f of dirs(`blueprint/${dir}`)) if (/\.(ya?ml|json)$/.test(f)) add('blueprint', `${dir}/${f.replace(/\.(ya?ml|json)$/, '')}`, `blueprint/${dir}/${f}`)
309
329
  // The workspace's own grants beyond read: one Blueprint item, `blueprint/workspace-grants`.
310
- if (existsSync(join(root, WORKSPACE_GRANTS_FILE))) add('blueprint', WORKSPACE_GRANTS_NAME)
311
- for (const f of dirs('mcp')) if (f.endsWith('.plugin.json') && f !== '.plugin.json') add('plugin', f.slice(0, -'.plugin.json'.length))
312
- for (const f of dirs('datasets')) if (f.endsWith('.json') && f !== '.json') add('dataset', f.slice(0, -'.json'.length))
313
- for (const f of dirs('chat-apps')) if (f.endsWith('.json') && f !== '.json') add('chat-app', f.slice(0, -'.json'.length))
330
+ if (existsSync(join(root, WORKSPACE_GRANTS_FILE))) add('blueprint', WORKSPACE_GRANTS_NAME, WORKSPACE_GRANTS_FILE)
331
+ for (const f of dirs('mcp')) if (f.endsWith('.plugin.json') && f !== '.plugin.json') add('plugin', f.slice(0, -'.plugin.json'.length), `mcp/${f}`)
332
+ for (const f of dirs('datasets')) if (f.endsWith('.json') && f !== '.json') add('dataset', f.slice(0, -'.json'.length), `datasets/${f}`)
333
+ for (const f of dirs('chat-apps')) if (f.endsWith('.json') && f !== '.json') add('chat-app', f.slice(0, -'.json'.length), `chat-apps/${f}`)
314
334
  // A Custom App's settings file (X6); its code is the folder beside it, and its release a request's own file.
315
- for (const f of dirs('apps')) if (/^[a-z0-9][a-z0-9-]*\.json$/.test(f) && !f.endsWith('.release.json')) add('app', f.slice(0, -'.json'.length))
316
- for (const slug of dirs('agents')) if (existsSync(join(root, lessonsPath(slug)))) add('lessons', slug)
335
+ for (const f of dirs('apps')) if (/^[a-z0-9][a-z0-9-]*\.json$/.test(f) && !f.endsWith('.release.json')) add('app', f.slice(0, -'.json'.length), `apps/${f}`)
336
+ for (const slug of dirs('agents')) if (existsSync(join(root, lessonsPath(slug)))) add('lessons', slug, lessonsPath(slug))
317
337
  for (const slug of dirs('agents')) {
318
338
  for (const f of dirs(`agents/${slug}/evals`)) {
319
339
  const m = /^([^.]+)\.ya?ml$/.exec(f)
320
- if (m) add('evaluation', `${slug}.${m[1]}`)
340
+ if (m) add('evaluation', `${slug}.${m[1]}`, `agents/${slug}/evals/${f}`)
321
341
  }
322
342
  }
323
343
  return out
@@ -339,7 +359,9 @@ export interface CandidateInput {
339
359
  remote: RemoteItem[]
340
360
  /**
341
361
  * Knowledge bases whose documents are brought (opt-in, as for a pull):
342
- * what the pull wrote of each (`documents.json`) and what the workspace has now.
362
+ * what the pull wrote of each (`documents.json`) and what the workspace has
363
+ * now. Agent knowledge by its name; a Knowledge app base by its ref
364
+ * (`knowledge-base/<folder>`), as `documents.json` records it.
343
365
  */
344
366
  documents?: {
345
367
  names: Set<string>
@@ -348,7 +370,7 @@ export interface CandidateInput {
348
370
  }
349
371
  }
350
372
 
351
- const BLOCK_REASON: Record<Exclude<BlockCode, 'kind' | 'shape' | 'model' | 'refused'>, string> = {
373
+ const BLOCK_REASON: Record<Exclude<BlockCode, 'kind' | 'shape' | 'model' | 'refused' | 'sign-in'>, string> = {
352
374
  moved: 'Changed in the workspace too since your last pull: pull and merge it first',
353
375
  conflicts: 'Has conflicts from a pull: resolve them first',
354
376
  deleted: 'Deleted here',
@@ -400,8 +422,71 @@ export function skillAssets(root: string, name: string, paths: string[], theirs:
400
422
  return { assets, removed, changed: removed.length > 0 || assets.some((a) => a.change !== 'same') }
401
423
  }
402
424
 
425
+ /**
426
+ * Source files named otherwise than the workspace names their source. Apply
427
+ * matches a base's sources by file name, and the workspace names a source after
428
+ * its `name` (numbered `-2` and on when two share one), so a file named
429
+ * otherwise would be taken for a new source on the next push, and the one
430
+ * before removed with its documents.
431
+ */
432
+ export function misnamedSources(files: Record<string, string>): Array<{ path: string; want: string }> {
433
+ return Object.entries(files).flatMap(([path, text]) => {
434
+ const m = new RegExp(`/${KNOWLEDGE_BASE_SOURCES_DIR}/([^/]+)\\.json$`).exec(path)
435
+ if (!m) return []
436
+ let name: unknown
437
+ try {
438
+ name = (JSON.parse(text) as { name?: unknown }).name
439
+ } catch {
440
+ return []
441
+ }
442
+ if (typeof name !== 'string') return []
443
+ const want = platformRefName(name)
444
+ const key = m[1]!
445
+ const numbered = key.startsWith(`${want}-`) && /^\d+$/.test(key.slice(want.length + 1))
446
+ return key === want || numbered ? [] : [{ path, want: `${path.slice(0, -m[0].length)}/${KNOWLEDGE_BASE_SOURCES_DIR}/${want}.json` }]
447
+ })
448
+ }
449
+
403
450
  const kb = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`)
404
451
 
452
+ /**
453
+ * An Agent's files as a request sends them: its `knowledgeBases` name each
454
+ * base kept in a folder of its own by the name the workspace's index gives it
455
+ * (`platformName`), which is how Apply finds it.
456
+ */
457
+ export function withPlatformPicks(files: Record<string, string>, names: ReadonlyMap<string, string>): Record<string, string> {
458
+ if (!names.size) return files
459
+ return Object.fromEntries(Object.entries(files).map(([path, text]) => {
460
+ if (!path.endsWith('/agent.json')) return [path, text]
461
+ let doc: { knowledgeBases?: unknown }
462
+ try {
463
+ doc = JSON.parse(text) as { knowledgeBases?: unknown }
464
+ } catch {
465
+ return [path, text]
466
+ }
467
+ if (!Array.isArray(doc.knowledgeBases)) return [path, text]
468
+ const picks = doc.knowledgeBases.map((p) => {
469
+ const folder = typeof p === 'string' && p.startsWith(`${KNOWLEDGE_BASE_DIR}/`) ? p.slice(KNOWLEDGE_BASE_DIR.length + 1) : null
470
+ return folder && names.has(folder) ? `${KNOWLEDGE_BASE_DIR}/${names.get(folder)}` : p
471
+ })
472
+ return [path, `${JSON.stringify({ ...doc, knowledgeBases: picks }, null, 2)}\n`]
473
+ }))
474
+ }
475
+
476
+ const NOT_TAKEN = 'this workspace’s platform does not take knowledge bases from a project yet'
477
+ const BASES_NOT_TAKEN_FIX = `update the platform, or leave ${KNOWLEDGE_BASE_DIR}/ out of this push`
478
+
479
+ /** An agent.json without its `knowledgeBases`, for a platform that does not take them; the text as it is when it has none. */
480
+ function withoutPicks(text: string | null): string | null {
481
+ if (text === null) return null
482
+ try {
483
+ const { knowledgeBases, ...rest } = JSON.parse(text) as { knowledgeBases?: unknown }
484
+ return knowledgeBases === undefined ? text : `${JSON.stringify(rest, null, 2)}\n`
485
+ } catch {
486
+ return text
487
+ }
488
+ }
489
+
405
490
  /**
406
491
  * What a push would propose, item by item: every item changed here or only
407
492
  * here, with what it sends, or why it cannot be proposed now. Items that
@@ -414,6 +499,10 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
414
499
  const byPlan = new Map(plan.map((p) => [p.ref, p]))
415
500
  const here = projectRefs(root)
416
501
  const refs = new Set<string>([...plan.map((p) => p.ref), ...here.keys()])
502
+ // Knowledge bases filed in a folder other than the name the workspace gives them, by folder.
503
+ const platformNames = new Map(input.remote.flatMap((r) => (r.kind === 'knowledge-base' && r.platformName ? [[r.name, r.platformName] as const] : [])))
504
+ // An older platform runs the Knowledge app but refuses a request that carries a base, and drops an Agent's picks.
505
+ const takesBases = !!snapshot?.capabilities.includes(KNOWLEDGE_BASES_FEATURE)
417
506
  const out: PushItem[] = []
418
507
  for (const ref of [...refs].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
419
508
  const slash = ref.indexOf('/')
@@ -422,8 +511,6 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
422
511
  const p = byPlan.get(ref)
423
512
  const entry = lock?.items[ref]
424
513
  const theirs = remote.get(ref)
425
- // A plan's "only here" for a Function in its own folder is named after its entry file: the folder names it here.
426
- if (p?.status === 'local-only' && !entry && !here.has(ref)) continue
427
514
  // An Agent's lessons are never in the plan: they are proposed while the file differs from what the last pull wrote.
428
515
  const lessons = kind === 'lessons' ? lessonsProposal(root, input.lock?.workspace ?? snapshot?.workspace ?? '', name) : null
429
516
  if (kind === 'lessons' && !lessons) continue
@@ -431,12 +518,14 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
431
518
  const status = p?.status ?? 'local-only'
432
519
  if (status === 'new' || status === 'changed') continue
433
520
  const deleted = status === 'deleted-local'
434
- const bringDocuments = kind === 'knowledge' && !!input.documents?.names.has(name)
521
+ // Agent knowledge's are keyed by name, a Knowledge app base's by its ref.
522
+ const documentsKey = kind === 'knowledge' ? name : ref
523
+ const bringDocuments = (kind === 'knowledge' || (kind === 'knowledge-base' && ref !== KNOWLEDGE_SETTINGS_REF)) && !!input.documents?.names.has(documentsKey)
435
524
  const { files, notes, problem, assets: assetPaths } = deleted ? { files: {} as Record<string, string>, notes: [] as string[], problem: undefined, assets: undefined } : itemFiles(root, kind, name, entry, { documents: bringDocuments, ...(theirs ? { upstream: theirs.files } : {}) })
436
525
  // What a file hash does not cover: a skill's assets, and a knowledge base's documents when they are brought.
437
526
  const assetState = kind === 'skill' && !deleted && (assetPaths?.length || theirs?.assets?.length) ? skillAssets(root, name, assetPaths ?? [], theirs) : null
438
527
  const docs = bringDocuments && !deleted && input.documents
439
- ? documentChanges(localDocuments(root, name), input.documents.pulled[name] ?? {}, input.documents.platform.get(name) ?? [])
528
+ ? documentChanges(kind === 'knowledge' ? localDocuments(root, name) : knowledgeBaseDocuments(root, name).documents, input.documents.pulled[documentsKey] ?? {}, input.documents.platform.get(documentsKey) ?? [])
440
529
  : null
441
530
  const extra = Boolean(assetState?.changed) || Boolean(docs && (docs.upload.length || docs.remove.length))
442
531
  // `same` in the plan reads the base's paths; a file added to the item since is still a change here.
@@ -466,7 +555,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
466
555
  reason: null,
467
556
  id: entry?.id ?? theirs?.id ?? null,
468
557
  baseRevision: null,
469
- files,
558
+ files: kind === 'agent' ? withPlatformPicks(files, platformNames) : files,
470
559
  diff,
471
560
  base: baseFiles,
472
561
  notes,
@@ -492,9 +581,22 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
492
581
  if (lessons.removed.length) notes.push(`${lessons.removed.length} rule${lessons.removed.length === 1 ? '' : 's'} removed here ${lessons.removed.length === 1 ? 'isn’t' : 'aren’t'} proposed: an active rule is retired on the platform, where rules are reviewed`)
493
582
  if (!lessons.added.length && !lessons.removed.length) notes.push('Proposes the rules the workspace doesn’t have yet, for review')
494
583
  }
584
+ const agentPath = `agents/${name}/agent.json`
585
+ const sentAgent = kind === 'agent' && !takesBases && item.files[agentPath] !== undefined ? withoutPicks(item.files[agentPath]!)! : null
586
+ const picksLeftOut = sentAgent !== null && sentAgent !== item.files[agentPath]
587
+ if (picksLeftOut) {
588
+ item.files = { ...item.files, [agentPath]: sentAgent }
589
+ item.diff = item.diff.map((f) => (f.path === agentPath ? { ...f, base: withoutPicks(f.base), local: sentAgent } : f)).filter((f) => f.base !== f.local && !(f.base !== null && f.local !== null && sameContent(f.path, f.base, f.local)))
590
+ notes.push(`Its knowledgeBases are not sent: ${NOT_TAKEN}, so the Agent keeps the knowledge bases it searches there`)
591
+ }
495
592
  const block = (code: BlockCode, reason: string) => Object.assign(item, { proposable: false, blocked: code, reason })
593
+ const misnamed = kind === 'knowledge-base' ? misnamedSources(files) : []
496
594
  if (NOT_PROPOSED[kind]) block('kind', NOT_PROPOSED[kind]!)
497
595
  else if (!PUSH_KINDS.includes(kind)) block('kind', 'Not a kind a change request carries')
596
+ else if ((kind === 'knowledge-base' || (picksLeftOut && !item.diff.length)) && !takesBases) {
597
+ block('kind', kind === 'knowledge-base' ? 'This workspace’s platform does not take knowledge bases from a project yet' : `Its only change is knowledgeBases, and ${NOT_TAKEN}`)
598
+ item.fix = kind === 'knowledge-base' ? BASES_NOT_TAKEN_FIX : 'update the platform, then push it again'
599
+ }
498
600
  else if (status === 'conflict') block('moved', BLOCK_REASON.moved)
499
601
  else if (status === 'unresolved') block('conflicts', BLOCK_REASON.conflicts)
500
602
  else if (deleted && NOT_DELETED[kind]) block('shape', NOT_DELETED[kind]!)
@@ -502,6 +604,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
502
604
  // A saved object set is saved by Apply, never removed: what else names it would stop resolving (X6).
503
605
  else if (deleted && ref.startsWith('blueprint/object-sets/')) block('shape', 'A change request doesn’t remove a saved object set: remove it on the platform, or put its file back')
504
606
  else if (deleted && ref === WORKSPACE_GRANTS_REF) block('shape', `Deleting ${WORKSPACE_GRANTS_FILE} changes no grant: to take read_sensitive away, put the file back and list the type with \`permissions: [read]\``)
607
+ else if (deleted && ref === KNOWLEDGE_SETTINGS_REF) block('shape', `Deleting ${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE} changes no setting: put it back with the settings you want (a part it leaves out stays as the workspace has it)`)
505
608
  else if (status === 'deleted-remote') block('workspace-deleted', BLOCK_REASON['workspace-deleted'])
506
609
  else if (deleted) {
507
610
  // Proposable: Apply deletes it there, after everything that names it is updated.
@@ -509,6 +612,16 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
509
612
  else if (!Object.keys(files).length) block('shape', 'No files to propose')
510
613
  else if (kind === 'blueprint' && Object.keys(files).length > 1) block('shape', `${Object.keys(files).join(' and ')}: keep one file per Blueprint artifact`)
511
614
  else if (ref === WORKSPACE_GRANTS_REF && readWorkspaceGrants(root)?.problems.length) block('shape', readWorkspaceGrants(root)!.problems.join('; '))
615
+ else if (misnamed.length) {
616
+ block('shape', `${misnamed.map((w) => w.path).join(', ')}: the workspace names ${misnamed.length === 1 ? 'this source' : 'these sources'} by ${misnamed.length === 1 ? 'its' : 'their'} name, and a file named otherwise is taken for a new source, the one before removed with its documents`)
617
+ item.fix = `rename ${misnamed.map((w) => `${w.path} to ${w.want}`).join(', ')}`
618
+ }
619
+ // A request carries every source a base has: one the read could not file would be removed.
620
+ else if (theirs?.keptOnPlatform?.length) {
621
+ const kept = theirs.keptOnPlatform.map((n) => `“${n}”`).join(', ')
622
+ block('shape', `Its source${theirs.keptOnPlatform.length === 1 ? '' : 's'} ${kept} ${theirs.keptOnPlatform.length === 1 ? 'is' : 'are'} on the platform but not in ${KNOWLEDGE_BASE_SOURCES_DIR}/, and a change request would remove ${theirs.keptOnPlatform.length === 1 ? 'it' : 'them'}`)
623
+ item.fix = 'propose it signed in as someone who reads every source of the knowledge base (a Knowledge steward for an organization knowledge base), after a pull brings them'
624
+ }
512
625
  else if (assetState?.changed && entry?.revision && theirs?.revision && entry.revision !== theirs.revision && textSame) {
513
626
  // A pull never writes over an asset the folder has, so one the workspace changed since reads as a change here.
514
627
  block('moved', 'Its assets changed in the workspace since your last pull: delete the copies here to bring the workspace’s, then propose')
@@ -915,7 +1028,8 @@ export const KIND_WORD: Record<SyncKind, string> = {
915
1028
  workflow: 'Workflow',
916
1029
  function: 'Function',
917
1030
  skill: 'Skill',
918
- knowledge: 'Knowledge base',
1031
+ knowledge: 'Agent knowledge',
1032
+ 'knowledge-base': 'Knowledge base',
919
1033
  blueprint: 'Blueprint',
920
1034
  plugin: 'Plugin',
921
1035
  evaluation: 'Evaluation',
@@ -962,6 +1076,7 @@ export function changedParts(kind: SyncKind, name: string, paths: string[], file
962
1076
  } else if (kind === 'workflow') parts.add('definition')
963
1077
  else if (kind === 'function') parts.add('code')
964
1078
  else if (kind === 'knowledge') parts.add(path.endsWith(`/${QUALITY_CHECKS_FILE}`) ? 'quality checks' : 'settings')
1079
+ else if (kind === 'knowledge-base') parts.add(path.includes(`/${KNOWLEDGE_BASE_SOURCES_DIR}/`) ? 'sources' : 'settings')
965
1080
  else if (kind === 'plugin') parts.add('settings and policy')
966
1081
  else if (kind === 'evaluation') parts.add('cases')
967
1082
  else if (kind === 'chat-app') parts.add('experience')
@@ -975,12 +1090,17 @@ function joinWords(words: string[]): string {
975
1090
  return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`
976
1091
  }
977
1092
 
978
- /** One line per item, as the Overview says it: "Agent Support Concierge: behavior settings changed". */
979
- export function itemLine(item: { kind: SyncKind; name: string; title: string; change: 'changed' | 'new' | 'deleted'; changedPaths?: string[]; changedFiles?: PushFile[]; assetsChanged?: boolean; documentsChanged?: boolean }): string {
1093
+ /** An item as a line names it: "Agent Support Concierge"; a Blueprint file says what it is ("Object type Ticket"). */
1094
+ export function itemLabel(item: { kind: SyncKind; name: string; title: string }): string {
980
1095
  const [dir, apiName] = item.name.split('/')
981
1096
  const word = BLUEPRINT_WORD[dir ?? '']
982
- // A Blueprint file says what it is: "Object type Ticket".
983
- const label = item.kind === 'blueprint' ? (word && apiName ? `${word[0]!.toUpperCase()}${word.slice(1)} ${item.title}` : `Blueprint ${item.title}`) : `${KIND_WORD[item.kind]} ${item.title}`
1097
+ if (item.kind === 'blueprint') return word && apiName ? `${word[0]!.toUpperCase()}${word.slice(1)} ${item.title}` : `Blueprint ${item.title}`
1098
+ return item.kind === 'knowledge-base' && item.name === KNOWLEDGE_SETTINGS_NAME ? item.title : `${KIND_WORD[item.kind]} ${item.title}`
1099
+ }
1100
+
1101
+ /** One line per item, as the Overview says it: "Agent Support Concierge: behavior settings changed". */
1102
+ export function itemLine(item: { kind: SyncKind; name: string; title: string; change: 'changed' | 'new' | 'deleted'; changedPaths?: string[]; changedFiles?: PushFile[]; assetsChanged?: boolean; documentsChanged?: boolean }): string {
1103
+ const label = itemLabel(item)
984
1104
  if (item.change === 'deleted') return `${label}: delete`
985
1105
  // The platform keeps no lessons file, so "new" or "changed" depends only on
986
1106
  // whether this project pulled one; what the request does is the same either way.
@@ -1073,11 +1193,22 @@ export function failedName(outcome: ApplyOutcome, titles: Record<string, string>
1073
1193
  return titles[failed.ref] ?? outcome.steps.find((s) => s.key === failed.key)?.label ?? failed.ref
1074
1194
  }
1075
1195
 
1196
+ /**
1197
+ * A step's words with Agent knowledge and a knowledge base named as the lines
1198
+ * name them: the platform calls both "Knowledge base <folder>".
1199
+ */
1200
+ function stepWords(text: string, ref: string, titles: Record<string, string>): string {
1201
+ const [kind, ...rest] = ref.split('/')
1202
+ const name = rest.join('/')
1203
+ if ((kind !== 'knowledge' && kind !== 'knowledge-base') || !name) return text
1204
+ return text.split(`Knowledge base ${name}`).join(`${KIND_WORD[kind]} ${titles[ref] ?? name}`)
1205
+ }
1206
+
1076
1207
  /** Apply's steps, and after a failure what was put back, as `request apply` prints them. */
1077
1208
  export function describeOutcome(outcome: ApplyOutcome, titles: Record<string, string> = {}): string[] {
1078
1209
  const lines = outcome.steps.flatMap((s) => [
1079
- ` ${STEP_MARK[s.status]} ${s.label}${s.status === 'not-run' && !outcome.ok ? ' (not run)' : ''}`,
1080
- ...(s.notes ?? []).map((n) => ` · ${n}`),
1210
+ ` ${STEP_MARK[s.status]} ${stepWords(s.label, s.ref, titles)}${s.status === 'not-run' && !outcome.ok ? ' (not run)' : ''}`,
1211
+ ...(s.notes ?? []).map((n) => ` · ${stepWords(n, s.ref, titles)}`),
1081
1212
  ])
1082
1213
  if (outcome.ok) return [`Applied ${outcome.steps.length} step${outcome.steps.length === 1 ? '' : 's'}:`, ...lines]
1083
1214
  const failed = outcome.failure
@@ -20,8 +20,10 @@ import { slugify } from './local-project'
20
20
  /**
21
21
  * `object` is an object definition an Agent's object tools work on
22
22
  * (`object/claim_ticket`), by slug; its fields travel in the Agent's file.
23
+ * `knowledge-base` is a Knowledge app knowledge base an Agent searches
24
+ * (`knowledgeBases` in agent.json), by its folder under `knowledge-base/`.
23
25
  */
24
- export const REF_KINDS = ['function', 'workflow', 'agent', 'knowledge', 'plugin', 'objectType', 'action', 'model', 'skill', 'objectSet', 'object'] as const
26
+ export const REF_KINDS = ['function', 'workflow', 'agent', 'knowledge', 'knowledge-base', 'plugin', 'objectType', 'action', 'model', 'skill', 'objectSet', 'object'] as const
25
27
  export type RefKind = (typeof REF_KINDS)[number]
26
28
 
27
29
  /** Names as resources are named: slugs, API names, install names. */
@@ -28,6 +28,7 @@ import { missingImportNote, missingImportWarning, recoverMissingImports, type Mi
28
28
  import { attachDefinitions, type ObjectDefinitionRow } from './local-objects'
29
29
  import { checksFromFixtures, fixtureRows, qualityChecksPath, qualityChecksText, sourcePaths, sourceRows } from './local-quality-checks'
30
30
  import { readableName } from './local-sync-names'
31
+ import { agentPicks, knowledgeReach, readKnowledgeBases, type KnowledgeReach } from './local-knowledge-base-pull'
31
32
  import { WORKSPACE_GRANTS_FILE, WORKSPACE_GRANTS_NAME } from './local-workspace-grants'
32
33
  import { governedActionIds, invert, itemRef, referencesIn, refName as refNameOf, toNames, type Directory, type RefKind } from './local-sync-refs'
33
34
 
@@ -47,6 +48,14 @@ export interface SyncSource {
47
48
  workspaceId: string
48
49
  /** The person signed in, when they read nothing at organization grain (no Blueprint Steward) and no key reads for them. */
49
50
  notSteward?: string
51
+ /** The person signed in for the link (the device session): the Knowledge app's bases are read as them, never with a key. */
52
+ person?: PersonClients
53
+ }
54
+
55
+ /** One session, standing in the linked workspace or at its organization: what a person reads depends on where they stand. */
56
+ export interface PersonClients {
57
+ workspace: SyncClient
58
+ organization: SyncClient
50
59
  }
51
60
 
52
61
  /** Envelopes come single (`{data}`) or double (`{data:{data}}`); lists are what is inside. */
@@ -275,6 +284,12 @@ export interface ReadOptions {
275
284
  * sync index says changed. The Blueprint and evaluations are read whole.
276
285
  */
277
286
  only?: Set<string>
287
+ /** The folder each knowledge base was pulled into, by its id (`lockedFolders`), so a base keeps its folder. */
288
+ knowledgeBaseFolders?: ReadonlyMap<string, string>
289
+ /** The Knowledge app as a fetch already read it, so the bases and the Agents' picks agree on folders. */
290
+ knowledge?: KnowledgeReach
291
+ /** The platform's sync features, as a fetch read them: knowledge bases are read only where it takes them. */
292
+ capabilities?: readonly string[]
278
293
  }
279
294
 
280
295
  const wanted = (opts: ReadOptions, ref: string) => !opts.only || opts.only.has(ref)
@@ -581,25 +596,24 @@ export async function agentOperatingConfig(
581
596
  }
582
597
 
583
598
  /**
584
- * The Knowledge app's bases an Agent searches are platform settings, not part
585
- * of its files: a local runtime has no Knowledge app, and a base belongs to
586
- * one installation. Said, so what the Agent answers locally is not taken for
587
- * what it answers on the platform. Nothing to say where the platform has no
588
- * Knowledge app, the Agent searches none, or the route is not there.
599
+ * The Knowledge app's bases an Agent searches, said where the platform does
600
+ * not take knowledge bases from a project: agent.json cannot name them there,
601
+ * so what the Agent answers locally is not taken for what it answers on the
602
+ * platform. Nothing to say where it searches none, or the route is not there.
589
603
  */
590
- export async function knowledgeBasesNote(source: SyncSource, agentId: string): Promise<string | null> {
604
+ async function knowledgeBasesNote(source: SyncSource, agentId: string): Promise<string | null> {
591
605
  const answer = await source.workspace.get(`/v1/config/agents/${encodeURIComponent(agentId)}/knowledge-bases`).catch(() => null)
592
606
  const bases = answer ? one<{ bases?: Array<{ name?: unknown; searched?: unknown }> } | null>(answer)?.bases ?? [] : []
593
607
  const names = bases.filter((base) => base.searched === true && typeof base.name === 'string').map((base) => `“${base.name as string}”`)
594
608
  if (!names.length) return null
595
609
  const which = names.length === 1 ? `the knowledge base ${names[0]}` : `${names.length} knowledge bases (${names.join(', ')})`
596
- return `Searches ${which} from the Knowledge app on the platform. A local runtime has no Knowledge app, so here it answers without them: test this Agent’s Knowledge on the platform`
610
+ return `Searches ${which} from the Knowledge app on the platform. This workspace’s platform does not take knowledge bases from a project yet, so knowledgeBases is not pulled and the local runtime answers without them: update the platform, or test this Agent’s Knowledge on the platform`
597
611
  }
598
612
 
599
613
  /** An Agent's record (`/v1/config/agents/:id`): its published version, and its behavior settings, scrubbed. */
600
614
  interface AgentDetail { currentVersion?: { versionNumber?: number } | null; operatingConfig?: unknown }
601
615
 
602
- async function readAgents(source: SyncSource, lists: Lists, directory: Directory, opts: ReadOptions, warnings: string[], tools: () => Promise<PluginTools>): Promise<RemoteItem[]> {
616
+ async function readAgents(source: SyncSource, lists: Lists, directory: Directory, opts: ReadOptions, warnings: string[], tools: () => Promise<PluginTools>, knowledge: () => Promise<KnowledgeReach>): Promise<RemoteItem[]> {
603
617
  const out: RemoteItem[] = []
604
618
  const agents = lists.agents.filter((row) => wanted(opts, `agent/${row.agentId}`))
605
619
  if (!agents.length) return out
@@ -637,6 +651,13 @@ async function readAgents(source: SyncSource, lists: Lists, directory: Directory
637
651
  const agentDoc = projectAgent(snapshot, it.notes, toolSettings)
638
652
  // Its object tools carry the definitions they work on, named by slug below.
639
653
  it.notes.push(...attachDefinitions(agentDoc, lists.objectDefinitions ?? null))
654
+ // The knowledge bases it searches; left out (the picks left alone) when they could not be read, or the platform does not take them.
655
+ const picks = await knowledge().then(async (reach) => (reach.state === 'not-taken' ? { refs: [], notes: [await knowledgeBasesNote(source, row.id)].filter((n): n is string => !!n) } : agentPicks(reach, row.id))).catch((err: Error) => {
656
+ warnings.push(`Agent ${row.agentId} knowledge bases: ${err.message}`)
657
+ return null
658
+ })
659
+ if (picks?.refs.length) agentDoc.knowledgeBases = picks.refs
660
+ if (picks) it.notes.push(...picks.notes)
640
661
  const docs: Array<{ path: string; doc: unknown; format: 'json' }> = [{ path: `agents/${row.agentId}/agent.json`, doc: agentDoc, format: 'json' }]
641
662
  if (operating) docs.push({ path: `agents/${row.agentId}/operating.json`, doc: operating, format: 'json' })
642
663
  const bound: AgentBindings = { ...(bindings.get(row.agentId) ?? { functions: [], workflows: [] }) }
@@ -650,10 +671,11 @@ async function readAgents(source: SyncSource, lists: Lists, directory: Directory
650
671
  it.notes.push(`Its Context and data couldn’t be read on the platform: ${context.unread}. Until it can be read, local dev gives it read access to the object types the project holds, as an adaptation, so locally it may read more than on the platform`)
651
672
  }
652
673
  if (bound.functions.length || bound.workflows.length || bound.blueprint?.length || bound.blueprintUnread) docs.push({ path: `agents/${row.agentId}/bindings.json`, doc: bound, format: 'json' })
653
- const knowledgeBases = await knowledgeBasesNote(source, row.id)
654
- if (knowledgeBases) it.notes.push(knowledgeBases)
655
674
  await nameUnknownAgents(source, directory, docs)
656
- out.push(finish(it, directory, docs))
675
+ finish(it, directory, docs)
676
+ // Pulling the Agent brings the bases it searches.
677
+ if (picks?.refs.length) it.requires = [...new Set([...(it.requires ?? []), ...picks.refs])].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
678
+ out.push(it)
657
679
  }
658
680
  return out
659
681
  }
@@ -1670,6 +1692,9 @@ export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}):
1670
1692
  // Each Plugin's tools, read once and only when a Plugin file or an Agent's tool settings need them.
1671
1693
  let toolsRead: Promise<PluginTools> | null = null
1672
1694
  const tools = () => (toolsRead ??= readPluginTools(source, lists.apps, warnings))
1695
+ // The Knowledge app, read once and only when its bases or an Agent's picks are wanted.
1696
+ let knowledgeRead: Promise<KnowledgeReach> | null = opts.knowledge ? Promise.resolve(opts.knowledge) : null
1697
+ const knowledge = () => (knowledgeRead ??= knowledgeReach(source, opts.knowledgeBaseFolders, warnings, opts.capabilities))
1673
1698
  const pluginsWanted = want('plugin') && lists.apps.some((a) => a.install && (a.catalog?.kind ?? '').toLowerCase() === 'mcp' && wanted(opts, `plugin/${a.install.installName}`))
1674
1699
  const groups = await Promise.all([
1675
1700
  want('blueprint')
@@ -1679,8 +1704,15 @@ export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}):
1679
1704
  want('function') ? readFunctions(source, lists, opts, warnings) : [],
1680
1705
  pluginsWanted ? tools().then((t) => readPlugins(lists, opts, t)) : [],
1681
1706
  want('knowledge') ? readKnowledge(source, lists, opts, warnings) : [],
1707
+ // The Knowledge app's bases and settings: those `only` names, or every one.
1708
+ want('knowledge-base')
1709
+ ? knowledge().then((reach) => readKnowledgeBases(reach, warnings, opts.only)).then((read) => {
1710
+ notes.push(...read.notes)
1711
+ return read.items
1712
+ })
1713
+ : [],
1682
1714
  want('workflow') ? readWorkflows(source, lists, directory, opts, warnings) : [],
1683
- want('agent') ? readAgents(source, lists, directory, opts, warnings, tools) : [],
1715
+ want('agent') ? readAgents(source, lists, directory, opts, warnings, tools, knowledge) : [],
1684
1716
  want('evaluation') ? quietly(warnings, 'Evaluations', () => readEvaluations(source, lists, directory, warnings), []) : [],
1685
1717
  // Slide themes: the workspace's own; a platform without the route has none to bring.
1686
1718
  want('theme')