@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.
- 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
|
@@ -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
|
+
}
|