@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.
- package/package.json +4 -4
- package/src/api/knowledge-base-api.ts +230 -0
- package/src/commands/knowledge/index-commands.ts +6 -6
- package/src/commands/knowledge-base/index-commands.ts +328 -0
- package/src/commands/local/check.ts +12 -0
- package/src/commands/local/dev.ts +64 -2
- package/src/commands/local/index-commands.ts +34 -10
- package/src/commands/local/init.ts +11 -2
- package/src/commands/local/schema.ts +1 -1
- package/src/commands/local/sync-request.ts +135 -29
- package/src/commands/local/sync.ts +40 -25
- package/src/commands/registry.ts +4 -1
- package/src/commands/types.ts +6 -0
- package/src/flag-help.ts +10 -4
- package/src/forge/touches.ts +29 -0
- package/src/local/local-applied.ts +28 -0
- package/src/local/local-build-summary.ts +1 -0
- package/src/local/local-component-health.ts +7 -2
- package/src/local/local-dev-references.ts +6 -2
- package/src/local/local-dev-sync.ts +43 -4
- package/src/local/local-docker.ts +16 -1
- package/src/local/local-env.ts +19 -0
- package/src/local/local-file-checks.ts +56 -8
- package/src/local/local-knowledge-base-apply.ts +714 -0
- package/src/local/local-knowledge-base-files.ts +195 -0
- package/src/local/local-knowledge-base-pull.ts +458 -0
- package/src/local/local-knowledge-base-sources-off.ts +67 -0
- package/src/local/local-person-steps.ts +2 -1
- package/src/local/local-personas.ts +64 -7
- package/src/local/local-portability.ts +27 -0
- package/src/local/local-ports.ts +4 -2
- package/src/local/local-project-secrets.ts +30 -5
- package/src/local/local-scaffold.ts +2 -0
- package/src/local/local-schema-check.ts +3 -1
- package/src/local/local-schemas.ts +11 -1
- package/src/local/local-search-index.ts +92 -0
- package/src/local/local-secrets.ts +7 -2
- package/src/local/local-seed.ts +17 -0
- package/src/local/local-state.ts +7 -5
- package/src/local/local-studio-server.ts +3 -3
- package/src/local/local-supervisor.ts +45 -8
- package/src/local/local-sync-link.ts +16 -0
- package/src/local/local-sync-names.ts +10 -1
- package/src/local/local-sync-plan.ts +44 -5
- package/src/local/local-sync-push.ts +165 -34
- package/src/local/local-sync-refs.ts +3 -1
- package/src/local/local-sync-remote.ts +44 -12
- package/src/local/local-sync-snapshot.ts +156 -12
- package/src/main.ts +13 -0
- package/src/mcp/mcp-server.ts +3 -0
- package/src/mcp/tools/docs-tools.ts +1 -1
- 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
|
|
445
|
-
const kinds =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
package/src/mcp/mcp-server.ts
CHANGED
|
@@ -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
|
+
}
|