@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
@@ -0,0 +1,714 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { basename, join, relative, sep } from 'node:path'
4
+
5
+ import { isIgnoredName } from './local-dev-sync'
6
+ import {
7
+ KNOWLEDGE_BASE_DIR,
8
+ KNOWLEDGE_BASE_DOCUMENTS_DIR,
9
+ KNOWLEDGE_BASE_FILE,
10
+ KNOWLEDGE_BASE_SETTINGS_FILE,
11
+ KNOWLEDGE_BASE_SOURCES_DIR,
12
+ type KnowledgeBaseFile,
13
+ type KnowledgeBaseSettingsFile,
14
+ type KnowledgeBaseSourceFile,
15
+ } from './local-knowledge-base-files'
16
+ import { classify, knowledgeBaseNeeds, type Need } from './local-portability'
17
+ import { hasSecretValue, needsValueLine, type SecretPresence } from './local-secrets'
18
+ import type { ComponentState, LocalCredentials } from './local-state'
19
+
20
+ /**
21
+ * `frontera local dev` for `knowledge-base/`: each base folder and the
22
+ * organization's Knowledge settings made on the local runtime as the files
23
+ * say, through the Knowledge app's routes, as the runtime's owner.
24
+ *
25
+ * knowledge-base/<name>/knowledge-base.json the base: created, or renamed, by the id this runtime gave it
26
+ * knowledge-base/<name>/sources/<source>.json each API source: created, changed or removed
27
+ * knowledge-base/<name>/documents/** each file uploaded once, by its sha256
28
+ * knowledge-base/settings.json the assistant, the search model and the source policy
29
+ * agents/<slug>/agent.json knowledgeBases the Agent's picks, after the Agent is applied
30
+ *
31
+ * Each step reads what the runtime holds first and writes only what differs,
32
+ * so applying unchanged files again makes no write. The ids this runtime gave
33
+ * each base, source and uploaded document are kept in
34
+ * `.frontera/local/knowledge-bases.json`; a base whose id is gone (a reset) is
35
+ * found again by its name, or made again. A base this runtime made is
36
+ * removed once its folder is deleted (`removeDeletedBases`).
37
+ */
38
+
39
+ const API = '/v1/knowledge-app'
40
+
41
+ /** Where the owner stands for a call: in the project's workspace, or at the organization level (no workspace). */
42
+ export type Place = 'workspace' | 'organization'
43
+
44
+ export type KnowledgeCall = (place: Place, method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
45
+
46
+ /** Calls as the runtime's owner (the session the runtime keeps); a `FormData` body is sent as it is. */
47
+ export function knowledgeBaseCalls(serviceUrl: string, creds: Pick<LocalCredentials, 'session' | 'organizationId' | 'workspaceId'>, fetcher: typeof fetch = fetch): KnowledgeCall {
48
+ return async (place, method, path, body) => {
49
+ const form = body instanceof FormData
50
+ const res = await fetcher(new URL(path, serviceUrl), {
51
+ method,
52
+ headers: {
53
+ authorization: `Bearer ${creds.session}`,
54
+ 'x-org-id': creds.organizationId,
55
+ ...(place === 'workspace' ? { 'x-workspace-id': creds.workspaceId } : {}),
56
+ ...(body === undefined || form ? {} : { 'content-type': 'application/json' }),
57
+ },
58
+ body: body === undefined ? undefined : form ? body : JSON.stringify(body),
59
+ })
60
+ const text = await res.text()
61
+ let parsed: unknown = text
62
+ try {
63
+ parsed = text ? JSON.parse(text) : null
64
+ } catch {
65
+ // Kept as text for the message.
66
+ }
67
+ return { status: res.status, body: parsed }
68
+ }
69
+ }
70
+
71
+ const dataOf = <T>(body: unknown): T => ((body as { data?: unknown } | null)?.data ?? body) as T
72
+ const messageOf = (r: { status: number; body: unknown }) => {
73
+ const b = r.body as { message?: unknown } | null
74
+ return `${r.status} ${String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 300)}`
75
+ }
76
+ const isOk = (r: { status: number }) => r.status >= 200 && r.status < 300
77
+
78
+ export interface RunResult {
79
+ ok: boolean
80
+ output: string
81
+ }
82
+
83
+ // ── What this runtime gave each base ────────────────────────────────────────
84
+
85
+ export interface AppliedBase {
86
+ id: string
87
+ /** Where this runtime made the base; absent for one it found by its name, which a deleted folder never removes. */
88
+ made?: Place
89
+ /** Source file stem (`sources/<stem>.json`) to the source's id here. */
90
+ sources: Record<string, string>
91
+ /** A document file's sha256 to the id of the document uploaded from it. */
92
+ documents: Record<string, string>
93
+ /** Source file stem to the secret it was last sent and when that secret was set (`credentialStamp`). */
94
+ credentials?: Record<string, string>
95
+ }
96
+
97
+ interface AppliedRecord {
98
+ organizationId: string
99
+ bases: Record<string, AppliedBase>
100
+ }
101
+
102
+ const RECORD_FILE = 'knowledge-bases.json'
103
+
104
+ function readRecord(stateDir: string): AppliedRecord | null {
105
+ try {
106
+ const doc = JSON.parse(readFileSync(join(stateDir, RECORD_FILE), 'utf8')) as AppliedRecord
107
+ return doc && typeof doc.organizationId === 'string' && doc.bases && typeof doc.bases === 'object' ? doc : null
108
+ } catch {
109
+ return null
110
+ }
111
+ }
112
+
113
+ /** The bases applied to this runtime's organization, by folder; none for another organization (the runtime was reset). */
114
+ export function appliedBases(stateDir: string, organizationId: string): Record<string, AppliedBase> {
115
+ const record = readRecord(stateDir)
116
+ return record?.organizationId === organizationId ? record.bases : {}
117
+ }
118
+
119
+ /** Each base's id here, by folder, whatever organization it was applied to: what an Agent's picks are set from. */
120
+ export function appliedBaseIds(stateDir: string): Record<string, string> {
121
+ return Object.fromEntries(Object.entries(readRecord(stateDir)?.bases ?? {}).map(([folder, b]) => [folder, b.id]))
122
+ }
123
+
124
+ function saveBase(stateDir: string, organizationId: string, folder: string, base: AppliedBase | null): void {
125
+ const bases = { ...appliedBases(stateDir, organizationId) }
126
+ if (base) bases[folder] = base
127
+ else delete bases[folder]
128
+ mkdirSync(stateDir, { recursive: true })
129
+ writeFileSync(join(stateDir, RECORD_FILE), `${JSON.stringify({ organizationId, bases }, null, 2)}\n`)
130
+ }
131
+
132
+ // ── The search index ────────────────────────────────────────────────────────
133
+
134
+ const RESTART = 'run `frontera local stop`, then `frontera local start`'
135
+
136
+ /** Why the runtime cannot build knowledge bases, and what to run. */
137
+ export interface KnowledgeOff {
138
+ why: string
139
+ next: string
140
+ }
141
+
142
+ /**
143
+ * Why the runtime cannot build knowledge bases, with the next step; null when
144
+ * it can. Read from how the runtime started its search index, then from the
145
+ * Knowledge app itself, which answers 404 when it does not run.
146
+ */
147
+ export async function knowledgeBasesOff(components: readonly ComponentState[] | undefined, call: KnowledgeCall): Promise<KnowledgeOff | null> {
148
+ const index = components?.find((c) => c.name === 'search-index')
149
+ if (!index) return { why: 'the runtime started without its search index', next: RESTART }
150
+ if (index.status === 'off') {
151
+ if (index.detail === 'excluded') return { why: 'the runtime started with `--exclude search-index`', next: `${RESTART} without it` }
152
+ if (index.detail?.startsWith('no knowledge-base/ folder')) return { why: 'knowledge-base/ was added after the runtime started', next: RESTART }
153
+ return { why: index.detail ?? 'it is off', next: RESTART }
154
+ }
155
+ if (index.status !== 'running') return { why: `it is ${index.status === 'failed' ? 'down' : index.status}${index.detail ? ` (${index.detail})` : ''}`, next: `${RESTART}; \`frontera local logs search-index\` says why it stopped` }
156
+ // Not answering at all is the apply's to say, as for any item.
157
+ const me = await call('workspace', 'GET', `${API}/me`).catch(() => null)
158
+ if (me?.status === 404) return { why: "the runtime's service does not run the Knowledge app", next: RESTART }
159
+ return null
160
+ }
161
+
162
+ /** What `local dev` fails a knowledge base or the Knowledge settings with while the search index is off. */
163
+ export function knowledgeOffOutput(off: KnowledgeOff): string {
164
+ return `error [LOCAL_KNOWLEDGE_OFF]: knowledge bases need the local runtime's search index, which is off: ${off.why}\n hint: ${off.next}`
165
+ }
166
+
167
+ // ── Reading a base's folder ─────────────────────────────────────────────────
168
+
169
+ interface SourceEntry {
170
+ stem: string
171
+ file: string
172
+ spec: KnowledgeBaseSourceFile
173
+ }
174
+
175
+ interface DocumentEntry {
176
+ file: string
177
+ sha256: string
178
+ }
179
+
180
+ interface BaseFiles {
181
+ base: KnowledgeBaseFile
182
+ sources: SourceEntry[]
183
+ documents: DocumentEntry[]
184
+ }
185
+
186
+ function readJson<T>(root: string, rel: string): T {
187
+ return JSON.parse(readFileSync(join(root, rel), 'utf8')) as T
188
+ }
189
+
190
+ function filesUnder(root: string, dir: string): string[] {
191
+ const out: string[] = []
192
+ const walk = (at: string) => {
193
+ let names: string[]
194
+ try {
195
+ names = readdirSync(at).sort()
196
+ } catch {
197
+ return
198
+ }
199
+ for (const name of names) {
200
+ if (isIgnoredName(name)) continue
201
+ const path = join(at, name)
202
+ const stat = lstatSync(path)
203
+ if (stat.isDirectory()) walk(path)
204
+ else if (stat.isFile()) out.push(relative(root, path).split(sep).join('/'))
205
+ }
206
+ }
207
+ walk(join(root, dir))
208
+ return out
209
+ }
210
+
211
+ function readBaseFiles(root: string, folder: string): BaseFiles {
212
+ const dir = `${KNOWLEDGE_BASE_DIR}/${folder}`
213
+ const base = readJson<KnowledgeBaseFile>(root, `${dir}/${KNOWLEDGE_BASE_FILE}`)
214
+ const sources = filesUnder(root, `${dir}/${KNOWLEDGE_BASE_SOURCES_DIR}`)
215
+ .filter((f) => f.endsWith('.json') && f.split('/').length === 4)
216
+ .map((file) => ({ stem: basename(file, '.json'), file, spec: readJson<KnowledgeBaseSourceFile>(root, file) }))
217
+ const documents = filesUnder(root, `${dir}/${KNOWLEDGE_BASE_DOCUMENTS_DIR}`).map((file) => ({
218
+ file,
219
+ sha256: createHash('sha256').update(readFileSync(join(root, file))).digest('hex'),
220
+ }))
221
+ return { base, sources, documents }
222
+ }
223
+
224
+ const adaptationLines = (needs: Need[]) => classify(needs).adaptations.map((a) => ` warning: ${a}`)
225
+
226
+ // ── A source's credential ───────────────────────────────────────────────────
227
+
228
+ /**
229
+ * The credential step for a source that signs in; `applySources` is its one
230
+ * caller. The runtime never gives a secret's value back, so the source is sent
231
+ * the secret's name (`credentialSecret`), which the local runtime's service
232
+ * reads and seals. While the runtime has no value for it, the source is left
233
+ * out with the command that sets one. A runtime that cannot say what it holds
234
+ * is sent the name, and refuses an unknown one with that command.
235
+ */
236
+ export function sourceCredential(file: string, secret: string, secrets: SecretPresence | null): { send: { credentialSecret: string }; stamp: string } | { leftOut: string[] } {
237
+ if (secrets && !hasSecretValue(secrets, secret)) return { leftOut: [needsValueLine(secret, file)] }
238
+ return { send: { credentialSecret: secret }, stamp: credentialStamp(secret, secrets) }
239
+ }
240
+
241
+ /** The secret a source signs in with and when it was set: a source is sent the name again when this moves. */
242
+ const credentialStamp = (secret: string, secrets: SecretPresence | null) => `${secret} ${secrets?.updatedAt?.get(secret) ?? ''}`
243
+
244
+ /**
245
+ * The secrets a base's sources sign in with that were set, or lost their
246
+ * value, on the runtime since the base was applied: its files are unchanged,
247
+ * but `local dev` applies it again so each source signs in with the value now.
248
+ * `secrets` is asked only for a base with such a source.
249
+ */
250
+ export async function movedCredentials(project: { root: string; stateDir: string }, folder: string, organizationId: string, secrets: () => Promise<SecretPresence | null>): Promise<string[]> {
251
+ let sources: SourceEntry[]
252
+ try {
253
+ sources = readBaseFiles(project.root, folder).sources.filter((s) => s.spec.credentialSecret)
254
+ } catch {
255
+ return []
256
+ }
257
+ if (!sources.length) return []
258
+ const presence = await secrets()
259
+ if (!presence) return []
260
+ const sent = appliedBases(project.stateDir, organizationId)[folder]?.credentials ?? {}
261
+ const now = (secret: string) => (hasSecretValue(presence, secret) ? credentialStamp(secret, presence) : undefined)
262
+ return [...new Set(sources.filter((s) => now(s.spec.credentialSecret!) !== sent[s.stem]).map((s) => s.spec.credentialSecret!))]
263
+ }
264
+
265
+ // ── Applying a base ─────────────────────────────────────────────────────────
266
+
267
+ interface BaseRow {
268
+ id: string
269
+ name: string
270
+ description: string | null
271
+ ownerWorkspaceId: string | null
272
+ canManage?: boolean
273
+ sharing: { allWorkspaces: boolean; workspaceIds: string[] } | null
274
+ }
275
+
276
+ interface SourceRow {
277
+ id: string
278
+ name: string
279
+ state: string
280
+ schedule?: string
281
+ keepRemovedDays?: number
282
+ config?: unknown
283
+ auth?: unknown
284
+ /** When its credential was set; null for one that has none. */
285
+ credential?: { setAt: string } | null
286
+ }
287
+
288
+ export interface ApplyDeps {
289
+ call: KnowledgeCall
290
+ organizationId: string
291
+ /** The runtime's workspace: the project's. */
292
+ workspaceId: string
293
+ /** The secrets the runtime holds, by name; null when it could not say. */
294
+ secrets: SecretPresence | null
295
+ }
296
+
297
+ async function must(call: Promise<{ status: number; body: unknown }>, what: string): Promise<unknown> {
298
+ const r = await call
299
+ if (!isOk(r)) throw new Error(`${what}: ${messageOf(r)}`)
300
+ return r.body
301
+ }
302
+
303
+ const ownedHere = (row: BaseRow, place: Place, workspaceId: string) => (place === 'workspace' ? row.ownerWorkspaceId === workspaceId : row.ownerWorkspaceId === null)
304
+
305
+ /** The base the folder is here: by the id this runtime gave it, else by its name; null when there is none. A recorded base in the other scope is refused, as the platform refuses it. */
306
+ async function findBase(call: KnowledgeCall, place: Place, name: string, workspaceId: string, recordedId: string | undefined, folder: string): Promise<BaseRow | null> {
307
+ if (recordedId) {
308
+ for (const at of [place, place === 'workspace' ? 'organization' : 'workspace'] as const) {
309
+ const got = await call(at, 'GET', `${API}/bases/${recordedId}`)
310
+ if (!isOk(got)) continue
311
+ const row = dataOf<{ base: BaseRow }>(got.body).base
312
+ if (ownedHere(row, place, workspaceId)) return row
313
+ const its: Place = row.ownerWorkspaceId === null ? 'organization' : 'workspace'
314
+ throw new Error(
315
+ `${KNOWLEDGE_BASE_DIR}/${folder}/${KNOWLEDGE_BASE_FILE}: scope is ${place}, but its knowledge base ${row.name} is ${its === 'workspace' ? 'a workspace' : 'an organization'} base here; a knowledge base keeps its layer, so make a new one for the other layer` +
316
+ `\n hint: put back \`"scope": "${its}"\`, or make a new folder under ${KNOWLEDGE_BASE_DIR}/ for the ${place} base`,
317
+ )
318
+ }
319
+ }
320
+ const listed = dataOf<{ bases: BaseRow[] }>(await must(call(place, 'GET', `${API}/bases`), 'the knowledge bases could not be listed')).bases
321
+ return listed.find((b) => b.name === name && ownedHere(b, place, workspaceId) && b.canManage !== false) ?? null
322
+ }
323
+
324
+ /** The whole folder as the runtime should hold it; each step writes only what differs. */
325
+ export async function applyKnowledgeBase(project: { root: string; stateDir: string }, folder: string, deps: ApplyDeps): Promise<RunResult> {
326
+ const { call, workspaceId } = deps
327
+ const lines: string[] = []
328
+ let files: BaseFiles
329
+ try {
330
+ files = readBaseFiles(project.root, folder)
331
+ } catch (err) {
332
+ return { ok: false, output: `error [USAGE]: ${KNOWLEDGE_BASE_DIR}/${folder} could not be read: ${(err as Error).message}` }
333
+ }
334
+ const { base: file } = files
335
+ const place: Place = file.scope === 'workspace' ? 'workspace' : 'organization'
336
+ const name = file.name ?? folder
337
+ const prior = appliedBases(project.stateDir, deps.organizationId)[folder]
338
+ const applied: AppliedBase = { id: '', made: prior?.made, sources: { ...prior?.sources }, documents: { ...prior?.documents }, credentials: { ...prior?.credentials } }
339
+ try {
340
+ let row = await findBase(call, place, name, workspaceId, prior?.id, folder)
341
+ if (!row) {
342
+ const made = await must(call(place, 'POST', `${API}/bases`, { name, ...(file.description ? { description: file.description } : {}) }), `knowledge base ${name} could not be created`)
343
+ row = dataOf<{ base: BaseRow }>(made).base
344
+ Object.assign(applied, { made: place, sources: {}, documents: {}, credentials: {} })
345
+ lines.push(` created ${place === 'workspace' ? 'workspace' : 'organization'} knowledge base ${name}`)
346
+ } else {
347
+ if (row.id !== prior?.id) Object.assign(applied, { made: undefined, sources: {}, documents: {}, credentials: {} })
348
+ const change: Record<string, string> = {}
349
+ if (row.name !== name) change.name = name
350
+ if (file.description !== undefined && (row.description ?? '') !== file.description) change.description = file.description
351
+ if (Object.keys(change).length) {
352
+ const was = row.name
353
+ await must(call(place, 'PATCH', `${API}/bases/${row.id}`, change), `knowledge base ${name} could not be changed`)
354
+ lines.push(change.name ? ` renamed knowledge base ${was} to ${name}` : ` updated knowledge base ${name}’s description`)
355
+ }
356
+ }
357
+ applied.id = row.id
358
+ // Saved now: a step below that fails leaves the base found by its id next time.
359
+ saveBase(project.stateDir, deps.organizationId, folder, applied)
360
+ if (file.scope === 'organization') lines.push(...(await applySharing(call, row, file.sharing, workspaceId)))
361
+ lines.push(...adaptationLines(knowledgeBaseNeeds(file, files.sources)))
362
+ lines.push(...(await applySources(call, place, row.id, files.sources, applied, deps.secrets, folder)))
363
+ saveBase(project.stateDir, deps.organizationId, folder, applied)
364
+ lines.push(...(await applyDocuments(project.root, call, place, row.id, files.documents, applied)))
365
+ saveBase(project.stateDir, deps.organizationId, folder, applied)
366
+ } catch (err) {
367
+ if (applied.id) saveBase(project.stateDir, deps.organizationId, folder, applied)
368
+ return { ok: false, output: [...lines, `error [KNOWLEDGE_BASE_APPLY]: ${(err as Error).message}`].join('\n') }
369
+ }
370
+ return { ok: true, output: lines.join('\n') }
371
+ }
372
+
373
+ /**
374
+ * The bases whose `knowledge-base/<name>/` folder is gone, after `local dev`
375
+ * applied the folders there are: one this runtime made is removed here, with
376
+ * a line, and forgotten; one it found by its name (made in Studio, say) is
377
+ * only forgotten. A folder renamed keeps its base when its file carries
378
+ * `name` (a pull always writes it): the new folder found the base by that
379
+ * name, so it is handed the old folder's record instead. A file without `name`
380
+ * takes its folder's, so the renamed folder made a new base, and the old one
381
+ * is removed. A base that a folder still here names (its scope and name), or
382
+ * any base while a folder's file cannot be read, is never removed: that folder
383
+ * may be the same base renamed, not applied yet. It is listed in `kept`, to
384
+ * look again once that folder applies. Nothing is removed while the search
385
+ * index is off, and a removal the runtime refuses keeps the record, to try
386
+ * again. `removed` lists the folders forgotten.
387
+ */
388
+ export async function removeDeletedBases(
389
+ project: { root: string; stateDir: string },
390
+ organizationId: string,
391
+ call: KnowledgeCall,
392
+ components: readonly ComponentState[] | undefined,
393
+ ): Promise<{ lines: string[]; removed: string[]; kept: string[] }> {
394
+ const record = appliedBases(project.stateDir, organizationId)
395
+ const gone = (folder: string) => !existsSync(join(project.root, KNOWLEDGE_BASE_DIR, folder))
396
+ const claimedBy = new Map(Object.entries(record).filter(([folder]) => !gone(folder)).map(([folder, base]) => [base.id, folder]))
397
+ const lines: string[] = []
398
+ const removed: string[] = []
399
+ const kept: string[] = []
400
+ let off: boolean | undefined
401
+ let here: ReturnType<typeof namedHere> | undefined
402
+ for (const [folder, base] of Object.entries(record)) {
403
+ if (!gone(folder)) continue
404
+ const claimant = claimedBy.get(base.id)
405
+ if (base.made && claimant) saveBase(project.stateDir, organizationId, claimant, { ...appliedBases(project.stateDir, organizationId)[claimant]!, made: base.made })
406
+ else if (base.made) {
407
+ off ??= (await knowledgeBasesOff(components, call)) !== null
408
+ if (off) continue
409
+ here ??= namedHere(project.root)
410
+ if (here.unread) {
411
+ lines.push(` ${KNOWLEDGE_BASE_DIR}/${folder} was deleted, and its knowledge base stays while ${KNOWLEDGE_BASE_DIR}/${here.unread}/${KNOWLEDGE_BASE_FILE} cannot be read: that folder may be the same base renamed`)
412
+ kept.push(folder)
413
+ continue
414
+ }
415
+ if (here.names.size) {
416
+ const got = await call(base.made, 'GET', `${API}/bases/${base.id}`)
417
+ if (!isOk(got) && got.status !== 404) {
418
+ lines.push(` warning: ${KNOWLEDGE_BASE_DIR}/${folder} was deleted, and its knowledge base could not be read here: ${messageOf(got)}`)
419
+ continue
420
+ }
421
+ const name = isOk(got) ? dataOf<{ base: BaseRow }>(got.body).base.name : null
422
+ const holder = name === null ? undefined : here.names.get(`${base.made} ${name}`)
423
+ if (holder) {
424
+ lines.push(` ${KNOWLEDGE_BASE_DIR}/${folder} was deleted, and ${KNOWLEDGE_BASE_DIR}/${holder} names its knowledge base ${name}, so it stays for that folder`)
425
+ kept.push(folder)
426
+ continue
427
+ }
428
+ }
429
+ const r = await call(base.made, 'DELETE', `${API}/bases/${base.id}`)
430
+ if (!isOk(r) && r.status !== 404) {
431
+ lines.push(` warning: ${KNOWLEDGE_BASE_DIR}/${folder} was deleted, and its knowledge base could not be removed here: ${messageOf(r)}`)
432
+ continue
433
+ }
434
+ lines.push(` ✓ ${KNOWLEDGE_BASE_DIR}/${folder} was deleted: removed the knowledge base it made here`)
435
+ } else if (!claimant) lines.push(` ${KNOWLEDGE_BASE_DIR}/${folder} was deleted: its knowledge base was not made by this project here, so it stays`)
436
+ saveBase(project.stateDir, organizationId, folder, null)
437
+ removed.push(folder)
438
+ }
439
+ return { lines, removed, kept }
440
+ }
441
+
442
+ /** The bases the folders here name, by scope and name, to their folder; `unread` is a folder whose file cannot be read, so it may name any. */
443
+ function namedHere(root: string): { names: Map<string, string>; unread?: string } {
444
+ const names = new Map<string, string>()
445
+ let folders: string[]
446
+ try {
447
+ folders = readdirSync(join(root, KNOWLEDGE_BASE_DIR)).sort()
448
+ } catch {
449
+ return { names }
450
+ }
451
+ for (const folder of folders) {
452
+ const rel = `${KNOWLEDGE_BASE_DIR}/${folder}/${KNOWLEDGE_BASE_FILE}`
453
+ if (!existsSync(join(root, rel))) continue
454
+ try {
455
+ const file = readJson<KnowledgeBaseFile>(root, rel)
456
+ names.set(`${file.scope === 'workspace' ? 'workspace' : 'organization'} ${file.name ?? folder}`, folder)
457
+ } catch {
458
+ return { names, unread: folder }
459
+ }
460
+ }
461
+ return { names }
462
+ }
463
+
464
+ /** An organization base is shared with every workspace here, or with this runtime's one workspace, the project's. */
465
+ async function applySharing(call: KnowledgeCall, row: BaseRow, sharing: Extract<KnowledgeBaseFile, { scope: 'organization' }>['sharing'], workspaceId: string): Promise<string[]> {
466
+ const want = 'allWorkspaces' in sharing ? { allWorkspaces: true, workspaceIds: [] as string[] } : { allWorkspaces: false, workspaceIds: [workspaceId] }
467
+ const have = row.sharing ?? { allWorkspaces: false, workspaceIds: [] }
468
+ if (have.allWorkspaces === want.allWorkspaces && (want.allWorkspaces || [...have.workspaceIds].sort().join() === want.workspaceIds.join())) return []
469
+ await must(call('organization', 'PUT', `${API}/bases/${row.id}/shares`, want.allWorkspaces ? { allWorkspaces: true } : want), `knowledge base ${row.name} could not be shared`)
470
+ return [` shared knowledge base ${row.name} with ${want.allWorkspaces ? 'every workspace' : 'the project’s workspace'}`]
471
+ }
472
+
473
+ const SPEC_KEYS = ['name', 'schedule', 'keepRemovedDays', 'config', 'auth'] as const
474
+
475
+ /** JSON with each object's keys in order: two specs the same up to key order read the same. */
476
+ function canonical(value: unknown): string {
477
+ const sort = (v: unknown): unknown =>
478
+ Array.isArray(v) ? v.map(sort) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, x]) => [k, sort(x)])) : v
479
+ return JSON.stringify(sort(value))
480
+ }
481
+
482
+ async function applySources(call: KnowledgeCall, place: Place, baseId: string, sources: SourceEntry[], applied: AppliedBase, secrets: SecretPresence | null, folder: string): Promise<string[]> {
483
+ if (!sources.length && !Object.keys(applied.sources).length) return []
484
+ const lines: string[] = []
485
+ const listed = dataOf<{ sources: SourceRow[]; sourcesAllowed: boolean }>(await must(call(place, 'GET', `${API}/bases/${baseId}/sources`), 'the base’s sources could not be read'))
486
+ const live = listed.sources.filter((s) => s.state !== 'removing')
487
+ const byId = new Map(live.map((s) => [s.id, s]))
488
+ const wanted = new Set<string>()
489
+ const sentCredentials = (applied.credentials ??= {})
490
+ for (const { stem, file, spec } of sources) {
491
+ const credential = spec.credentialSecret ? sourceCredential(file, spec.credentialSecret, secrets) : null
492
+ if (credential && 'leftOut' in credential) {
493
+ lines.push(...credential.leftOut)
494
+ delete sentCredentials[stem]
495
+ continue
496
+ }
497
+ const recorded = applied.sources[stem]
498
+ const claimed = new Set(Object.values(applied.sources))
499
+ const existing = (recorded ? byId.get(recorded) : undefined) ?? live.find((s) => s.name === spec.name && !claimed.has(s.id))
500
+ if (!listed.sourcesAllowed) {
501
+ if (existing) wanted.add(stem)
502
+ // Decided for this value of the secret: not applied again for it until it is set again.
503
+ if (credential) sentCredentials[stem] = credential.stamp
504
+ lines.push(` warning: goes without its source ${stem} here: API sources are off for this base on the runtime; allow them with \`"sources": {"apiSources": "${place === 'organization' ? 'organization' : 'all_workspaces'}"}\` in ${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE}`)
505
+ continue
506
+ }
507
+ wanted.add(stem)
508
+ const want: Record<string, unknown> = { name: spec.name, schedule: spec.schedule, keepRemovedDays: spec.keepRemovedDays, config: spec.config, auth: spec.auth }
509
+ if (!existing) {
510
+ const made = await must(call(place, 'POST', `${API}/bases/${baseId}/sources`, { kind: spec.kind, ...want, ...credential?.send }), `source ${stem} could not be added`)
511
+ applied.sources[stem] = dataOf<{ source: SourceRow }>(made).source.id
512
+ if (credential) sentCredentials[stem] = credential.stamp
513
+ else delete sentCredentials[stem]
514
+ lines.push(` added source ${stem} to ${KNOWLEDGE_BASE_DIR}/${folder}`)
515
+ continue
516
+ }
517
+ applied.sources[stem] = existing.id
518
+ const patch: Record<string, unknown> = Object.fromEntries(SPEC_KEYS.filter((k) => canonical(existing[k]) !== canonical(want[k])).map((k) => [k, want[k]]))
519
+ // The name again when the secret was set since, the source has no credential, or where it is sent moves (which the service refuses without one).
520
+ if (credential && (sentCredentials[stem] !== credential.stamp || existing.credential === null || 'config' in patch || 'auth' in patch)) Object.assign(patch, credential.send)
521
+ if (Object.keys(patch).length) {
522
+ await must(call(place, 'PATCH', `${API}/sources/${existing.id}`, patch), `source ${stem} could not be changed`)
523
+ lines.push(` changed source ${stem}: ${Object.keys(patch).map((k) => (k === 'credentialSecret' ? 'credential' : k)).join(', ')}`)
524
+ }
525
+ if (credential) sentCredentials[stem] = credential.stamp
526
+ else delete sentCredentials[stem]
527
+ }
528
+ for (const [stem, id] of Object.entries(applied.sources)) {
529
+ if (wanted.has(stem)) continue
530
+ delete applied.sources[stem]
531
+ delete sentCredentials[stem]
532
+ if (!byId.has(id)) continue
533
+ await must(call(place, 'DELETE', `${API}/sources/${id}`), `source ${stem} could not be removed`)
534
+ lines.push(` removed source ${stem}: its file is gone, or it is left out here`)
535
+ }
536
+ return lines
537
+ }
538
+
539
+ /** A refusal of the file itself (its kind or size): said, and the file left out; any other refusal fails the base. */
540
+ const FILE_REFUSALS = new Set(['file_type', 'file_too_large'])
541
+
542
+ async function applyDocuments(root: string, call: KnowledgeCall, place: Place, baseId: string, documents: DocumentEntry[], applied: AppliedBase): Promise<string[]> {
543
+ if (!documents.length && !Object.keys(applied.documents).length) return []
544
+ const lines: string[] = []
545
+ const listed = dataOf<{ documents: Array<{ id: string }> }>(await must(call(place, 'GET', `${API}/bases/${baseId}/documents`), 'the base’s documents could not be read'))
546
+ const here = new Set(listed.documents.map((d) => d.id))
547
+ const wanted = new Set(documents.map((d) => d.sha256))
548
+ let uploaded = 0
549
+ for (const doc of documents) {
550
+ const id = applied.documents[doc.sha256]
551
+ if (id && here.has(id)) continue
552
+ const form = new FormData()
553
+ form.append('file', new File([readFileSync(join(root, doc.file))], basename(doc.file)))
554
+ const r = await call(place, 'POST', `${API}/bases/${baseId}/documents`, form)
555
+ if (!isOk(r)) {
556
+ if (FILE_REFUSALS.has(String((r.body as { reason?: unknown } | null)?.reason))) {
557
+ lines.push(` warning: ${doc.file} is not uploaded: ${messageOf(r).replace(/^\d+ /, '')}`)
558
+ continue
559
+ }
560
+ throw new Error(`${doc.file} could not be uploaded: ${messageOf(r)}`)
561
+ }
562
+ applied.documents[doc.sha256] = dataOf<{ document: { id: string } }>(r.body).document.id
563
+ here.add(applied.documents[doc.sha256]!)
564
+ uploaded += 1
565
+ }
566
+ for (const [sha, id] of Object.entries(applied.documents)) {
567
+ if (wanted.has(sha)) continue
568
+ delete applied.documents[sha]
569
+ if (!here.has(id)) continue
570
+ const r = await call(place, 'DELETE', `${API}/documents/${id}`)
571
+ if (!isOk(r) && r.status !== 404) throw new Error(`a document whose file is gone could not be removed: ${messageOf(r)}`)
572
+ lines.push(' removed a document whose file is gone or changed')
573
+ }
574
+ if (uploaded) lines.push(` uploaded ${uploaded} document${uploaded === 1 ? '' : 's'}: each is read in the background, then searchable`)
575
+ return lines
576
+ }
577
+
578
+ // ── Knowledge settings ──────────────────────────────────────────────────────
579
+
580
+ interface AssistantModel {
581
+ provider: string
582
+ modelId: string
583
+ }
584
+
585
+ interface AssistantSetup {
586
+ enabled: boolean
587
+ model: AssistantModel | null
588
+ name: string | null
589
+ instructions: string | null
590
+ options: AssistantModel[]
591
+ }
592
+
593
+ const blankToNull = (v: string | null) => (v?.trim() ? v.trim() : null)
594
+ const sameModel = (a: AssistantModel | null, b: AssistantModel | null) => (a === null || b === null ? a === b : a.provider === b.provider && a.modelId === b.modelId)
595
+
596
+ /** `knowledge-base/settings.json` on the runtime: each key the file has, as it says, with what the runtime lacks swapped. */
597
+ export async function applyKnowledgeSettings(root: string, deps: { call: KnowledgeCall; workspaceId: string; preferredModel?: string | null }): Promise<RunResult> {
598
+ const { call } = deps
599
+ const rel = `${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE}`
600
+ let file: KnowledgeBaseSettingsFile
601
+ try {
602
+ file = readJson<KnowledgeBaseSettingsFile>(root, rel)
603
+ } catch (err) {
604
+ return { ok: false, output: `error [USAGE]: ${rel} could not be read: ${(err as Error).message}` }
605
+ }
606
+ const lines: string[] = []
607
+ try {
608
+ if (file.assistant) lines.push(...(await applyAssistant(call, file.assistant, deps.preferredModel ?? null)))
609
+ if (file.searchModel !== undefined) lines.push(...(await applySearchModel(call, file.searchModel)))
610
+ if (file.sources) lines.push(...(await applySourcePolicy(call, file.sources, deps.workspaceId)))
611
+ } catch (err) {
612
+ return { ok: false, output: [...lines, `error [KNOWLEDGE_SETTINGS_APPLY]: ${(err as Error).message}`].join('\n') }
613
+ }
614
+ return { ok: true, output: lines.join('\n') }
615
+ }
616
+
617
+ async function applyAssistant(call: KnowledgeCall, assistant: NonNullable<KnowledgeBaseSettingsFile['assistant']>, preferred: string | null): Promise<string[]> {
618
+ const setup = dataOf<{ setup: AssistantSetup | null }>(await must(call('organization', 'GET', `${API}/assistant`), 'the Knowledge assistant could not be read')).setup
619
+ if (!setup) throw new Error('the runtime’s owner may not set up the Knowledge assistant')
620
+ const lines: string[] = []
621
+ let { model, enabled } = assistant
622
+ if (model && !setup.options.some((o) => sameModel(o, model))) {
623
+ const swap = setup.options.find((o) => preferred === `${o.provider}/${o.modelId}` || preferred === o.modelId) ?? setup.options[0] ?? null
624
+ lines.push(swap
625
+ ? ` warning: the Knowledge assistant names the model ${model.provider}/${model.modelId}, which this runtime does not have; it uses ${swap.provider}/${swap.modelId} here`
626
+ : ` warning: the Knowledge assistant names the model ${model.provider}/${model.modelId}, and this runtime has no model it can answer with: its answers stay off here`)
627
+ model = swap
628
+ if (!swap) enabled = false
629
+ }
630
+ const want = { enabled, model, name: blankToNull(assistant.name), instructions: blankToNull(assistant.instructions) }
631
+ if (want.enabled === setup.enabled && sameModel(want.model, setup.model) && want.name === setup.name && want.instructions === setup.instructions) return lines
632
+ await must(call('organization', 'PUT', `${API}/assistant`, want), 'the Knowledge assistant could not be set up')
633
+ return [...lines, ` set up the Knowledge assistant (answers ${want.enabled ? 'on' : 'off'})`]
634
+ }
635
+
636
+ async function applySearchModel(call: KnowledgeCall, searchModel: KnowledgeBaseSettingsFile['searchModel']): Promise<string[]> {
637
+ const setting = dataOf<{ chosen: { provider: string; model: string } | null; models: Array<{ provider: string; model: string }> }>(
638
+ await must(call('organization', 'GET', `${API}/settings/embedding`), 'the Knowledge search model could not be read'),
639
+ )
640
+ const lines: string[] = []
641
+ let want = searchModel ?? null
642
+ if (want && !setting.models.some((m) => m.provider === want!.provider && m.model === want!.model)) {
643
+ lines.push(` warning: the knowledge bases search by meaning with ${want.provider}/${want.model}, which this runtime does not have; they use the organization’s embedding model here`)
644
+ want = null
645
+ }
646
+ const have = setting.chosen
647
+ if (want === null ? have === null : have?.provider === want.provider && have.model === want.model) return lines
648
+ await must(call('organization', 'PUT', `${API}/settings/embedding`, { choice: want }), 'the Knowledge search model could not be set')
649
+ return [...lines, want ? ` set the Knowledge search model to ${want.provider}/${want.model}: every base’s index is built again` : ' set the Knowledge search model to follow the organization’s embedding model']
650
+ }
651
+
652
+ async function applySourcePolicy(call: KnowledgeCall, sources: NonNullable<KnowledgeBaseSettingsFile['sources']>, workspaceId: string): Promise<string[]> {
653
+ const policy = dataOf<{ policy: { apiSources: string; workspaceIds: string[] } }>(await must(call('organization', 'GET', `${API}/source-policy`), 'the source policy could not be read')).policy
654
+ const lines: string[] = []
655
+ const want = { apiSources: sources.apiSources, workspaceIds: sources.apiSources === 'chosen_workspaces' ? [workspaceId] : [] }
656
+ if (sources.apiSources === 'chosen_workspaces') lines.push(` warning: API sources are allowed for ${sources.workspaces.join(', ')} on the platform; here, for this runtime’s one workspace, the project’s`)
657
+ if (policy.apiSources === want.apiSources && [...policy.workspaceIds].sort().join() === want.workspaceIds.join()) return lines
658
+ await must(call('organization', 'PUT', `${API}/source-policy`, want.apiSources === 'chosen_workspaces' ? want : { apiSources: want.apiSources }), 'the source policy could not be set')
659
+ return [...lines, ` set which knowledge bases may have API sources: ${want.apiSources}`]
660
+ }
661
+
662
+ // ── An Agent's picks ────────────────────────────────────────────────────────
663
+
664
+ /** The base folder a pick names (`knowledge-base/<name>`), or null. */
665
+ export const pickedFolder = (ref: unknown): string | null => (typeof ref === 'string' && ref.startsWith(`${KNOWLEDGE_BASE_DIR}/`) ? ref.slice(KNOWLEDGE_BASE_DIR.length + 1) || null : null)
666
+
667
+ /** agent.json's `knowledgeBases`, or null when the key is left out (the picks stay as they are). */
668
+ export function agentPicks(doc: unknown): string[] | null {
669
+ const picks = (doc as { knowledgeBases?: unknown } | null)?.knowledgeBases
670
+ return Array.isArray(picks) ? picks.filter((p): p is string => typeof p === 'string') : null
671
+ }
672
+
673
+ /**
674
+ * Set the Agent's picks from its file, after the Agent is applied: the whole
675
+ * list when `knowledgeBases` is there (an empty one drops them all), nothing
676
+ * when it is left out. A pick whose base is not on the runtime is left out
677
+ * and said. Returns the lines to say.
678
+ */
679
+ export async function applyAgentKnowledgeBases(project: { root: string; stateDir: string }, agentFile: string, agentId: string | null, call: KnowledgeCall): Promise<string[]> {
680
+ let picks: string[] | null
681
+ try {
682
+ picks = agentPicks(readJson<unknown>(project.root, agentFile))
683
+ } catch {
684
+ return []
685
+ }
686
+ if (picks === null) return []
687
+ if (!agentId) return [` warning: ${agentFile} knowledgeBases: the Agent is not on the runtime, so its knowledge bases are not set`]
688
+ const ids = appliedBaseIds(project.stateDir)
689
+ const lines: string[] = []
690
+ const want: string[] = []
691
+ for (const ref of picks) {
692
+ const id = ids[pickedFolder(ref) ?? '']
693
+ if (id) want.push(id)
694
+ else lines.push(` warning: goes without ${ref} here: that knowledge base is not on this runtime`)
695
+ }
696
+ const path = `${API}/agents/${agentId}/bases`
697
+ const got = await call('workspace', 'GET', path).catch((err: Error) => ({ status: 0, body: { message: err.message } }))
698
+ if (got.status === 404 && (got.body as { reason?: unknown } | null)?.reason === 'knowledge_off') {
699
+ return [` warning: goes without its knowledge bases here: the runtime’s search index is off`]
700
+ }
701
+ if (!isOk(got)) return [...lines, ` warning: ${agentFile} knowledgeBases could not be read on the runtime: ${messageOf(got)}`]
702
+ const have = dataOf<{ bases: Array<{ id: string; picked: boolean }> }>(got.body).bases.filter((b) => b.picked).map((b) => b.id).sort()
703
+ const wanted = [...new Set(want)].sort()
704
+ if (have.join() === wanted.join()) return lines
705
+ const put = await call('workspace', 'PUT', path, { knowledgeBaseIds: wanted }).catch((err: Error) => ({ status: 0, body: { message: err.message } }))
706
+ if (!isOk(put)) return [...lines, ` warning: ${agentFile} knowledgeBases could not be set on the runtime: ${messageOf(put)}`]
707
+ return [...lines, wanted.length ? ` set the Agent’s knowledge bases: ${picks.filter((p) => ids[pickedFolder(p) ?? '']).join(', ')}` : ' dropped the Agent’s knowledge bases']
708
+ }
709
+
710
+ /** Whether a project has the folder a pick names. */
711
+ export function hasKnowledgeBaseFolder(root: string, ref: string): boolean {
712
+ const folder = pickedFolder(ref)
713
+ return !!folder && existsSync(join(root, KNOWLEDGE_BASE_DIR, folder, KNOWLEDGE_BASE_FILE))
714
+ }