@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
@@ -8,6 +8,7 @@ import { hash, itemHash, safePath, SYNC_KINDS, type RemoteItem, type SyncKind, t
8
8
  import { readOrgSettings, type OrgSettings } from './local-org-settings'
9
9
  import type { Portability } from './local-portability'
10
10
  import { readWorkspace, type SyncSource, type WorkspaceCatalogModel } from './local-sync-remote'
11
+ import { agentPicks, isKnowledgeBaseNote, KNOWLEDGE_BASES_FEATURE, KNOWLEDGE_SETTINGS_REF, KNOWLEDGE_SIGN_IN, knowledgeBaseFolders, knowledgeReach, lockedFolders, readKnowledgeBases, type KnowledgeReach } from './local-knowledge-base-pull'
11
12
  import { REF_KINDS, type Directory, type RefKind, type Unresolved } from './local-sync-refs'
12
13
 
13
14
  /**
@@ -56,6 +57,10 @@ export interface SnapshotItem {
56
57
  assets?: string[]
57
58
  /** Its files recovered from the deployed bundle, not its source (`RemoteItem.recovered`). */
58
59
  recovered?: string[]
60
+ /** `RemoteItem.keptOnPlatform`. */
61
+ keptOnPlatform?: string[]
62
+ /** `RemoteItem.platformName`. */
63
+ platformName?: string
59
64
  }
60
65
 
61
66
  export interface RemoteSnapshot {
@@ -106,6 +111,12 @@ export interface RemoteSnapshot {
106
111
  * where the runtime differs. Absent on a platform without the route.
107
112
  */
108
113
  settings?: OrgSettings
114
+ /**
115
+ * The index listed knowledge bases that moved, and no one signed in could
116
+ * read them: the next fetch reads the whole index, so a sign-in brings them
117
+ * even though the cursor has moved past.
118
+ */
119
+ knowledgeUnread?: true
109
120
  }
110
121
 
111
122
  /**
@@ -196,7 +207,7 @@ export function snapshotItems(root: string, snap: RemoteSnapshot): RemoteItem[]
196
207
  files[path] = content
197
208
  }
198
209
  const assets = (s.assets ?? []).map((path) => ({ path, from: underDir(root, ASSETS_DIR, path) }))
199
- return [{ ref, kind: s.kind, name: s.name, title: s.title, files, version: s.version, id: s.id, revision: s.revision, unresolved: s.unresolved, notes: s.notes, requires: s.requires, ...(s.portability ? { portability: s.portability } : {}), ...(s.state ? { state: s.state } : {}), ...(assets.length ? { assets } : {}), ...(s.recovered?.length ? { recovered: s.recovered } : {}) }]
210
+ return [{ ref, kind: s.kind, name: s.name, title: s.title, files, version: s.version, id: s.id, revision: s.revision, unresolved: s.unresolved, notes: s.notes, requires: s.requires, ...(s.portability ? { portability: s.portability } : {}), ...(s.state ? { state: s.state } : {}), ...(assets.length ? { assets } : {}), ...(s.recovered?.length ? { recovered: s.recovered } : {}), ...(s.keptOnPlatform?.length ? { keptOnPlatform: s.keptOnPlatform } : {}), ...(s.platformName ? { platformName: s.platformName } : {}) }]
200
211
  })
201
212
  }
202
213
 
@@ -243,6 +254,8 @@ function toSnapshotItem(item: RemoteItem, revision?: string): SnapshotItem {
243
254
  ...(item.state ? { state: item.state } : {}),
244
255
  ...(item.assets?.length ? { assets: item.assets.map((a) => a.path).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)) } : {}),
245
256
  ...(item.recovered?.length ? { recovered: [...item.recovered].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)) } : {}),
257
+ ...(item.keptOnPlatform?.length ? { keptOnPlatform: item.keptOnPlatform } : {}),
258
+ ...(item.platformName ? { platformName: item.platformName } : {}),
246
259
  }
247
260
  }
248
261
 
@@ -346,16 +359,46 @@ export interface FetchOptions {
346
359
  /** Read the whole index, and with it every deletion, even when the cursor is recent. */
347
360
  full?: boolean
348
361
  now?: Date
362
+ /** Knowledge bases' folders by id beyond the lock's: those an Apply just created, filed where the project has them. */
363
+ knowledgeBaseFolders?: ReadonlyMap<string, string>
349
364
  }
350
365
 
351
366
  const isKind = (k: string): k is SyncKind => (SYNC_KINDS as readonly string[]).includes(k)
352
367
 
368
+ const KNOWLEDGE_BASE_PREFIX = 'knowledge-base/'
369
+ const isBaseRef = (ref: string) => ref.startsWith(KNOWLEDGE_BASE_PREFIX) && ref !== KNOWLEDGE_SETTINGS_REF
370
+
371
+ /**
372
+ * The index's knowledge bases filed by folder (`knowledgeBaseFolders`): the
373
+ * index names each as the platform does, and a project keeps the folder it
374
+ * holds. Returns the entries with their refs moved, every base's folder by id
375
+ * (those the snapshot holds unlisted too), and the platform's name of each ref
376
+ * filed under another.
377
+ */
378
+ function fileIndexedBases(entries: IndexItem[], items: Record<string, SnapshotItem>, held: ReadonlyMap<string, string>, full: boolean) {
379
+ const listed = entries.filter((e) => e.kind === 'knowledge-base' && isBaseRef(e.ref))
380
+ const ids = new Set(listed.map((e) => e.id))
381
+ // An unlisted base keeps its folder; after a whole index it is gone, and its folder is free.
382
+ const kept = full ? [] : Object.values(items).filter((it) => it.kind === 'knowledge-base' && it.id && !ids.has(it.id) && it.name !== 'settings')
383
+ const folders = knowledgeBaseFolders(new Map(listed.map((e) => [e.id, e.ref.slice(KNOWLEDGE_BASE_PREFIX.length)])), held, kept.map((it) => it.name))
384
+ const platformNames = new Map<string, string>()
385
+ const filed = entries.map((e) => {
386
+ if (!ids.has(e.id) || !isBaseRef(e.ref)) return e
387
+ const ref = `${KNOWLEDGE_BASE_PREFIX}${folders.get(e.id)!}`
388
+ if (ref !== e.ref) platformNames.set(ref, e.ref.slice(KNOWLEDGE_BASE_PREFIX.length))
389
+ return { ...e, ref }
390
+ })
391
+ for (const it of kept) folders.set(it.id!, it.name)
392
+ return { entries: filed, folders, platformNames }
393
+ }
394
+
353
395
  const WARNING_LABEL: Partial<Record<SyncKind, [string, string]>> = {
354
396
  agent: ['Agent', 'Agents'],
355
397
  workflow: ['Workflow', 'Workflows'],
356
398
  function: ['Function', 'Functions'],
357
399
  skill: ['Skill', 'Skills'],
358
400
  knowledge: ['Knowledge', 'Knowledge'],
401
+ 'knowledge-base': ['Knowledge base', 'Knowledge bases'],
359
402
  plugin: ['Plugin', 'Plugins'],
360
403
  }
361
404
 
@@ -411,7 +454,8 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
411
454
  // Read on every fetch: small, and it has no revision of its own.
412
455
  const settings = capabilities.includes('settings') ? await readOrgSettings(source.workspace) : null
413
456
  if (!capabilities.includes('index')) {
414
- const read = stopIfUnanswered(await readWorkspace(source, { blueprint: opts.blueprint }))
457
+ const read = stopIfUnanswered(await readWorkspace(source, { blueprint: opts.blueprint, knowledgeBaseFolders: lockedFolders(root), capabilities }))
458
+ if (!capabilities.includes(KNOWLEDGE_BASES_FEATURE)) for (const it of read.items) if (it.kind === 'agent') keepPicks(root, it)
415
459
  const items = Object.fromEntries(read.items.map((i) => [i.ref, toSnapshotItem(i)]))
416
460
  const next: RemoteSnapshot = {
417
461
  version: 1,
@@ -440,10 +484,15 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
440
484
  }
441
485
 
442
486
  const recentFull = previous?.fullIndexAt && now.getTime() - Date.parse(previous.fullIndexAt) < FULL_INDEX_EVERY_MS
443
- const since = !opts.full && !keysChanged && previous && !previous.full && previous.cursor && recentFull ? previous.cursor : null
444
- const index = await source.workspace.get<IndexAnswer>(`/v1/sync/index${since ? `?since=${encodeURIComponent(since)}` : ''}`)
445
- const kinds = index.kinds.filter(isKind)
487
+ const since = !opts.full && !keysChanged && previous && !previous.full && previous.cursor && recentFull && !previous.knowledgeUnread ? previous.cursor : null
488
+ const answered = await source.workspace.get<IndexAnswer>(`/v1/sync/index${since ? `?since=${encodeURIComponent(since)}` : ''}`)
489
+ const kinds = answered.kinds.filter(isKind)
446
490
  const items: Record<string, SnapshotItem> = previous && !previous.full && !keysChanged ? { ...previous.items } : {}
491
+ // Knowledge bases the index lists are filed by the project's folders before anything reads the entries.
492
+ const indexedBases = kinds.includes('knowledge-base')
493
+ const held = new Map([...lockedFolders(root), ...(opts.knowledgeBaseFolders ?? [])])
494
+ const filedBases = indexedBases ? fileIndexedBases(answered.items, items, held, answered.full) : null
495
+ const index: IndexAnswer = filedBases ? { ...answered, items: filedBases.entries } : answered
447
496
  const groups: Partial<Record<SyncKind, string>> = previous && !previous.full && !keysChanged ? { ...previous.groups } : {}
448
497
  const skipped: Record<string, string> = previous && !previous.full && !keysChanged ? { ...previous.skipped } : {}
449
498
  const removed = new Set<string>()
@@ -485,10 +534,52 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
485
534
  for (const ref of Object.keys(skipped)) if (!listed.has(ref)) delete skipped[ref]
486
535
  }
487
536
 
537
+ // An Agent's picks are set outside its draft, so its revision does not move with them: an Agent whose picks moved
538
+ // is read again. Where the index lists knowledge bases, a pick moves its base's revision, so only then are they compared.
539
+ const knowledgeWarnings: string[] = []
540
+ const knowledgeNotes: string[] = []
541
+ let knowledge: KnowledgeReach | null = null
542
+ let bases: (Awaited<ReturnType<typeof readKnowledgeBases>> & { warnings: string[] }) | null = null
543
+ let picksMayMove = false
544
+ let knowledgeUnread = false
545
+ if (filedBases) {
546
+ const movedBases = [...only].filter((r) => r.startsWith(KNOWLEDGE_BASE_PREFIX))
547
+ picksMayMove = movedBases.some(isBaseRef) || [...removed].some(isBaseRef)
548
+ // An Agent read again needs the reach too: its picks are read with the bases' folders.
549
+ if (movedBases.length || picksMayMove || readKinds.has('agent')) {
550
+ knowledge = await knowledgeReach(source, filedBases.folders, knowledgeWarnings, capabilities)
551
+ stopIfUnanswered({ warnings: knowledgeWarnings })
552
+ }
553
+ if (knowledge && knowledge.state !== 'read') {
554
+ // Read when someone signed in can: kept as last read until then, and never taken as having nothing to file.
555
+ for (const ref of movedBases) only.delete(ref)
556
+ readKinds.delete('knowledge-base')
557
+ for (const [ref, it] of Object.entries(previous?.items ?? {})) {
558
+ if (it.kind === 'knowledge-base' && !(ref in items) && (!index.full || listed.has(ref))) items[ref] = it
559
+ }
560
+ knowledgeUnread = movedBases.length > 0
561
+ if (knowledgeUnread && knowledge.state === 'signed-out') knowledgeNotes.push(KNOWLEDGE_SIGN_IN)
562
+ picksMayMove = false
563
+ }
564
+ } else {
565
+ // The index has no entry for knowledge bases: read whole on every fetch, as the settings are.
566
+ knowledge = await knowledgeReach(source, held, knowledgeWarnings, capabilities)
567
+ bases = stopIfUnanswered({ ...(await readKnowledgeBases(knowledge, knowledgeWarnings)), warnings: knowledgeWarnings })
568
+ picksMayMove = true
569
+ }
570
+ if (knowledge?.state === 'read' && picksMayMove) {
571
+ for (const ref of await picksMoved(root, items, only, knowledge)) {
572
+ only.add(ref)
573
+ readKinds.add('agent')
574
+ }
575
+ }
576
+
488
577
  let read: Awaited<ReturnType<typeof readWorkspace>> | null = null
489
578
  if (readKinds.size) {
490
579
  // The Blueprint and evaluations are read whole whatever `only` says.
491
- read = stopIfUnanswered(await readWorkspace(source, { kinds: readKinds, only, blueprint: opts.blueprint }))
580
+ read = stopIfUnanswered(await readWorkspace(source, { kinds: readKinds, only, blueprint: opts.blueprint, knowledgeBaseFolders: filedBases?.folders ?? held, capabilities, ...(knowledge ? { knowledge } : {}) }))
581
+ // Picks no one could read, or a platform does not take, are not "none": an Agent read again keeps those it had.
582
+ if (knowledge?.state === 'signed-out' || knowledge?.state === 'unread' || knowledge?.state === 'not-taken') for (const it of read.items) if (it.kind === 'agent') keepPicks(root, it)
492
583
  }
493
584
  const revisionOf = new Map(index.items.map((e) => [e.ref, e.revision]))
494
585
  const got = read?.items ?? []
@@ -502,10 +593,20 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
502
593
  if (!regroup.has(it.kind) && !only.has(it.ref)) continue
503
594
  // A kind read whole carries its group's revision (the Blueprint's release), so a pull records it
504
595
  // in the lock and a lost base can be read again at it.
505
- items[it.ref] = toSnapshotItem(it, regroup.has(it.kind) ? groups[it.kind] : revisionOf.get(it.ref))
596
+ const platformName = filedBases?.platformNames.get(it.ref)
597
+ items[it.ref] = { ...toSnapshotItem(it, regroup.has(it.kind) ? groups[it.kind] : revisionOf.get(it.ref)), ...(platformName ? { platformName } : {}) }
506
598
  removed.delete(it.ref)
507
599
  delete skipped[it.ref]
508
600
  }
601
+ // The bases read whole replace the kind's items; a read that was not whole (signed out, failed) keeps the last.
602
+ const basesChanged = bases?.listed ? bases.items.filter((it) => previous?.items[it.ref]?.hash !== itemHash(it.files)) : []
603
+ if (bases?.listed) {
604
+ for (const [ref, it] of Object.entries(items)) if (it.kind === 'knowledge-base' && !bases.items.some((b) => b.ref === ref)) drop(ref)
605
+ for (const it of bases.items) {
606
+ items[it.ref] = toSnapshotItem(it)
607
+ removed.delete(it.ref)
608
+ }
609
+ }
509
610
  // Listed but nothing came back and nothing failed: a thing with no files (a workflow without a
510
611
  // definition). Remembered by revision so it is not read again until it changes; a failed read is retried.
511
612
  for (const ref of only) {
@@ -522,18 +623,61 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
522
623
  full: false,
523
624
  platformVersion,
524
625
  capabilities,
525
- kinds,
626
+ // Every base the workspace reads was listed: one missing from the snapshot is deleted.
627
+ // Bases no one could read are not known to be gone: the kind is not covered until they are read.
628
+ kinds: knowledgeUnread ? kinds.filter((k) => k !== 'knowledge-base') : bases?.listed && knowledge?.state === 'read' ? [...kinds, 'knowledge-base'] : kinds,
526
629
  groups,
527
630
  skipped,
528
631
  items,
529
- warnings: read?.warnings ?? [],
530
- notes: read ? read.notes : previous?.notes ?? [],
632
+ warnings: [...(read?.warnings ?? []), ...knowledgeWarnings],
633
+ // Knowledge's own notes (a sign-in, settings left out) stay until the bases are read again.
634
+ notes: [
635
+ ...(read ? read.notes : (previous?.notes ?? []).filter((n) => !isKnowledgeBaseNote(n))),
636
+ ...(bases?.notes ?? []),
637
+ ...knowledgeNotes,
638
+ ...(filedBases && !readKinds.has('knowledge-base') && !knowledgeNotes.length ? (previous?.notes ?? []).filter((n) => isKnowledgeBaseNote(n) && !(read?.notes ?? []).includes(n)) : []),
639
+ ],
531
640
  ...(read ? { names: namesOf(read.directory) } : previous?.names ? { names: previous.names } : {}),
532
641
  ...(read?.models.length ? { models: read.models } : previous?.models ? { models: previous.models } : {}),
533
642
  organizationKey: !!source.organization,
534
643
  reader: SNAPSHOT_READER,
535
644
  ...(settings ? { settings } : previous?.settings ? { settings: previous.settings } : {}),
645
+ ...(knowledgeUnread ? { knowledgeUnread: true as const } : {}),
646
+ }
647
+ writeSnapshot(root, previous, next, [...got.filter((g) => g.ref in items), ...(bases?.listed ? bases.items : [])])
648
+ return { snapshot: next, changed: [...got.filter((g) => g.ref in items), ...basesChanged].map((g) => g.ref), removed: [...removed], fullIndex: index.full }
649
+ }
650
+
651
+ /** An Agent's `knowledgeBases` as the last fetch had them, else as the last pull wrote them, when this read could not see any. */
652
+ function keepPicks(root: string, it: RemoteItem): void {
653
+ const path = `agents/${it.name}/agent.json`
654
+ const picksOf = (text: string | null | undefined) => {
655
+ try {
656
+ return (JSON.parse(text ?? '') as { knowledgeBases?: unknown }).knowledgeBases
657
+ } catch {
658
+ return undefined
659
+ }
536
660
  }
537
- writeSnapshot(root, previous, next, got.filter((g) => g.ref in items))
538
- return { snapshot: next, changed: got.filter((g) => g.ref in items).map((g) => g.ref), removed: [...removed], fullIndex: index.full }
661
+ const text = it.files[path]
662
+ if (text === undefined || picksOf(text) !== undefined) return
663
+ const had = picksOf(readUnder(root, REMOTE_DIR, path)) ?? picksOf(readBase(root, path))
664
+ if (!Array.isArray(had)) return
665
+ it.files[path] = `${JSON.stringify({ ...(JSON.parse(text) as Record<string, unknown>), knowledgeBases: had }, null, 2)}\n`
666
+ }
667
+
668
+ /** The Agents the snapshot holds whose picks on the platform differ from their agent.json's `knowledgeBases`. */
669
+ async function picksMoved(root: string, items: Record<string, SnapshotItem>, only: ReadonlySet<string>, knowledge: KnowledgeReach): Promise<string[]> {
670
+ const agents = Object.entries(items).filter(([ref, it]) => it.kind === 'agent' && it.id && !only.has(ref))
671
+ const moved = await Promise.all(agents.map(async ([ref, it]) => {
672
+ const now = await agentPicks(knowledge, it.id!).catch(() => null)
673
+ if (!now) return null
674
+ let held: unknown = []
675
+ try {
676
+ held = (JSON.parse(readUnder(root, REMOTE_DIR, `agents/${it.name}/agent.json`) ?? '{}') as { knowledgeBases?: unknown }).knowledgeBases ?? []
677
+ } catch {
678
+ return ref
679
+ }
680
+ return JSON.stringify(held) === JSON.stringify(now.refs) ? null : ref
681
+ }))
682
+ return moved.filter((r): r is string => r !== null)
539
683
  }
package/src/main.ts CHANGED
@@ -370,6 +370,19 @@ async function main(): Promise<number> {
370
370
  output.note(`acting as ${acting.persona.displayName} (${acting.persona.key}), a local persona`)
371
371
  }
372
372
 
373
+ // On the project's own local runtime, a command that acts as a person acts as its owner when it says so.
374
+ if (command.meta.localOwner && !personaToken && credential.token.startsWith('sk-') && !process.env.FRONTERA_TOKEN?.trim()) {
375
+ const local = localRuntimeAt(cwd, credential.apiUrl)
376
+ if (local?.creds.session) {
377
+ personaToken = local.creds.session
378
+ credential = { apiUrl: credential.apiUrl, token: local.creds.session }
379
+ token = local.creds.session
380
+ if (!workspaceFlag && commandScope(command.meta) === 'workspace') workspaceFlag = local.creds.workspaceId
381
+ orgFlag ??= local.creds.organizationId
382
+ output.note(`acting as ${local.creds.email}, the local runtime's owner`)
383
+ }
384
+ }
385
+
373
386
  /**
374
387
  * `--workspace` is global — same reasoning as `--profile`: an escape hatch
375
388
  * that exists on six commands and not the seventh is useless when the
@@ -8,6 +8,7 @@ import { agentTools, createMemory } from './tools/agent-tools'
8
8
  import { appTools } from './tools/app-tools'
9
9
  import { browserTools } from './tools/browser-tools'
10
10
  import { docsTools } from './tools/docs-tools'
11
+ import { knowledgeBaseTools } from './tools/knowledge-base-tools'
11
12
  import { projectTools } from './tools/project-tools'
12
13
  import { testTools } from './tools/test-tools'
13
14
  import { workflowTools } from './tools/workflow-tools'
@@ -29,6 +30,7 @@ See what you built, scoped to what you changed:
29
30
  - After changing an Agent: apply, then agent_chat with it and trace_get the trace; test_run its cases. newlyFailing are what your change broke; couldNotRun are cases the model provider or the runtime kept from running, not a regression: give the person their fix. Save a case with test_save_case and say what a good reply does (expectedContains, checks), not what this one said.
30
31
  - When agent_chat answers with pendingDecision, the turn waits on the person's card (a tool approval or a Function's confirmation): decide it with agent_decide, each decision deliberately, as the person would. Typed words never answer a card.
31
32
  - After changing a workflow: apply, then workflow_run it with version "draft". A webhook trigger: workflow_webhook_test sends a request to the draft's test door and reads the run it started.
33
+ - After changing a knowledge base (knowledge-base/): apply, then knowledge_base_status until reading is 0, then knowledge_base_search; search with as: "outsider" to check who may not read it.
32
34
  - After changing an App embedded in Studio: browser_open its preview; the snapshot reads the App's frame, and its refs click and type inside it.
33
35
  - After changing an App: app_render the pages you touched and read the console and failed requests; browser_open to click through a flow. app_deploy builds it and says in evidence whether the render it carries was clean.
34
36
  - When you report back, include the screenshot path or the trace link.
@@ -53,6 +55,7 @@ function projectToolset(project: LocalProject): McpTool[] {
53
55
  ...agentTools(project, memory),
54
56
  ...testTools(project, memory),
55
57
  ...workflowTools(project),
58
+ ...knowledgeBaseTools(project),
56
59
  ...appTools(project),
57
60
  ...browserTools(project),
58
61
  ...docsTools(project, (kind) => cliSchema(kind) ?? readKeptSchema(project, kind)),
@@ -12,7 +12,7 @@ const DOCS_TOOLS = [
12
12
  { name: 'search_documentation', description: "Search Frontera's documentation for the release the local runtime runs. Returns guides with the best matching heading and a snippet; read one with read_documentation." },
13
13
  { name: 'read_documentation', description: 'Read a guide by its slug from a search result, whole or one heading of it.' },
14
14
  { name: 'list_documentation', description: 'List every guide: slug, title and description. Use it to browse an area, e.g. everything under "workflows/".' },
15
- { name: 'read_file_schema', description: 'The exact format of a kind of project file (agent.json, operating.json, a workflow, an MCP server, the Blueprint), as JSON Schema, for the runtime\'s release. Read it before writing such a file.' },
15
+ { name: 'read_file_schema', description: 'The exact format of a kind of project file (agent.json, operating.json, a workflow, an MCP server, the Blueprint, a knowledge base in knowledge-base/<name>/, its sources and knowledge-base/settings.json), as JSON Schema, for the runtime\'s release. Read it before writing such a file.' },
16
16
  ] as const
17
17
 
18
18
  export function docsTools(project: LocalProject, schemaFor: SchemaLookup): McpTool[] {
@@ -0,0 +1,85 @@
1
+ import { citation, KnowledgeBaseApi, plainSnippet, scopeOf } from '../../api/knowledge-base-api'
2
+ import { resolveBase } from '../../commands/knowledge-base/index-commands'
3
+ import { CliError } from '../../errors'
4
+ import { actAsLocalPersona, KNOWLEDGE_PERSONAS } from '../../local/local-personas'
5
+ import type { LocalProject } from '../../local/local-project'
6
+ import { errorResult, intArg, jsonResult, stringArg, type McpTool, type ToolResult } from '../mcp-protocol'
7
+ import { requireRuntime } from '../mcp-runtime'
8
+
9
+ /**
10
+ * The Knowledge app's knowledge bases (`knowledge-base/<name>/`) on the local
11
+ * runtime, read as its owner, or as a local persona with `as`. Reads only:
12
+ * the files are applied by `apply`, and sources and stewards change through
13
+ * `frontera knowledge-base`.
14
+ */
15
+
16
+ const PERSONAS = KNOWLEDGE_PERSONAS.map((p) => p.key).join(', ')
17
+
18
+ async function client(project: LocalProject, as?: string): Promise<KnowledgeBaseApi> {
19
+ const link = requireRuntime(project)
20
+ if (!as) return new KnowledgeBaseApi(link.serviceUrl, link.creds.session!, link.creds.workspaceId, true, link.creds.organizationId)
21
+ const acting = await actAsLocalPersona({ meta: { noun: 'knowledge-base', verb: 'search' }, cwd: project.root, apiUrl: link.serviceUrl, wanted: as })
22
+ return new KnowledgeBaseApi(link.serviceUrl, acting.token, acting.workspaceId, true, acting.organizationId)
23
+ }
24
+
25
+ /** A CLI refusal as the agent reads it: what happened, then what to do. */
26
+ async function answer(run: () => Promise<unknown>): Promise<ToolResult> {
27
+ try {
28
+ return jsonResult(await run())
29
+ } catch (err) {
30
+ if (err instanceof CliError) return errorResult(err.hint ? `${err.message}. Next: ${err.hint}` : err.message)
31
+ throw err
32
+ }
33
+ }
34
+
35
+ export function knowledgeBaseTools(project: LocalProject): McpTool[] {
36
+ return [
37
+ {
38
+ name: 'knowledge_base_list',
39
+ description: 'The knowledge bases of the Knowledge app on the local runtime (the project\'s knowledge-base/ folders once applied), with their scope and sharing. Not Agent knowledge (knowledge/).',
40
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
41
+ annotations: { title: 'Knowledge bases', readOnlyHint: true, openWorldHint: false },
42
+ run: () => answer(async () => (await (await client(project)).bases()).map((b) => ({ id: b.id, name: b.name, scope: scopeOf(b), sharing: b.sharing }))),
43
+ },
44
+ {
45
+ name: 'knowledge_base_status',
46
+ description: 'Each knowledge base\'s documents on the local runtime: ready (searchable), reading (still being checked or read) and failed. Call it after apply until reading is 0, before searching.',
47
+ inputSchema: { type: 'object', properties: { base: { type: 'string', description: 'One knowledge base, by name, id or project folder; all when absent.' } }, additionalProperties: false },
48
+ annotations: { title: 'Knowledge base status', readOnlyHint: true, openWorldHint: false },
49
+ run: (input) => answer(async () => {
50
+ const api = await client(project)
51
+ const ref = stringArg(input, 'base')
52
+ const rows = await api.statuses(ref ? [await resolveBase(api, project.root, ref)] : await api.bases())
53
+ return rows.map(({ base, ...counts }) => ({ id: base.id, name: base.name, ...counts }))
54
+ }),
55
+ },
56
+ {
57
+ name: 'knowledge_base_search',
58
+ description: `Search the knowledge bases on the local runtime as a person would, with a citation for each result (title, base, page). \`as\` searches as a local persona (${PERSONAS}) to check who reads what: outsider reads nothing in the workspace's bases.`,
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ query: { type: 'string', description: 'What to search for.' },
63
+ base: { type: 'string', description: 'Search only this knowledge base, by name, id or project folder.' },
64
+ limit: { type: 'number', description: 'How many results (1 to 50, default 10).' },
65
+ as: { type: 'string', description: `A local persona to search as: ${PERSONAS}.` },
66
+ },
67
+ required: ['query'],
68
+ additionalProperties: false,
69
+ },
70
+ annotations: { title: 'Search knowledge bases', readOnlyHint: true, openWorldHint: false },
71
+ run: (input) => answer(async () => {
72
+ const query = stringArg(input, 'query', { required: true })
73
+ const api = await client(project, stringArg(input, 'as'))
74
+ const ref = stringArg(input, 'base')
75
+ const only = ref ? [(await resolveBase(api, project.root, ref)).id] : undefined
76
+ const found = await api.search({ query, limit: intArg(input, 'limit', 10, 1, 50), ...(only ? { knowledgeBaseIds: only } : {}) })
77
+ return {
78
+ query,
79
+ ...(found.correctedQuery ? { correctedQuery: found.correctedQuery } : {}),
80
+ results: found.results.map((r) => ({ citation: citation(r), title: r.title, knowledgeBase: r.knowledgeBase?.name ?? null, page: r.page, snippet: plainSnippet(r.snippet), documentId: r.documentId })),
81
+ }
82
+ }),
83
+ },
84
+ ]
85
+ }