@frontera-sdk/cli 1.53.23 → 1.53.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +4 -4
  2. package/src/api/knowledge-base-api.ts +230 -0
  3. package/src/commands/knowledge/index-commands.ts +6 -6
  4. package/src/commands/knowledge-base/index-commands.ts +328 -0
  5. package/src/commands/local/check.ts +12 -0
  6. package/src/commands/local/dev.ts +64 -2
  7. package/src/commands/local/index-commands.ts +34 -10
  8. package/src/commands/local/init.ts +11 -2
  9. package/src/commands/local/schema.ts +1 -1
  10. package/src/commands/local/sync-request.ts +135 -29
  11. package/src/commands/local/sync.ts +40 -25
  12. package/src/commands/registry.ts +4 -1
  13. package/src/commands/types.ts +6 -0
  14. package/src/flag-help.ts +10 -4
  15. package/src/forge/touches.ts +29 -0
  16. package/src/local/local-applied.ts +28 -0
  17. package/src/local/local-build-summary.ts +1 -0
  18. package/src/local/local-component-health.ts +7 -2
  19. package/src/local/local-dev-references.ts +6 -2
  20. package/src/local/local-dev-sync.ts +43 -4
  21. package/src/local/local-docker.ts +16 -1
  22. package/src/local/local-env.ts +19 -0
  23. package/src/local/local-file-checks.ts +56 -8
  24. package/src/local/local-knowledge-base-apply.ts +714 -0
  25. package/src/local/local-knowledge-base-files.ts +195 -0
  26. package/src/local/local-knowledge-base-pull.ts +458 -0
  27. package/src/local/local-knowledge-base-sources-off.ts +67 -0
  28. package/src/local/local-person-steps.ts +2 -1
  29. package/src/local/local-personas.ts +64 -7
  30. package/src/local/local-portability.ts +27 -0
  31. package/src/local/local-ports.ts +4 -2
  32. package/src/local/local-project-secrets.ts +30 -5
  33. package/src/local/local-scaffold.ts +2 -0
  34. package/src/local/local-schema-check.ts +3 -1
  35. package/src/local/local-schemas.ts +11 -1
  36. package/src/local/local-search-index.ts +92 -0
  37. package/src/local/local-secrets.ts +7 -2
  38. package/src/local/local-seed.ts +17 -0
  39. package/src/local/local-state.ts +7 -5
  40. package/src/local/local-studio-server.ts +3 -3
  41. package/src/local/local-supervisor.ts +45 -8
  42. package/src/local/local-sync-link.ts +16 -0
  43. package/src/local/local-sync-names.ts +10 -1
  44. package/src/local/local-sync-plan.ts +44 -5
  45. package/src/local/local-sync-push.ts +165 -34
  46. package/src/local/local-sync-refs.ts +3 -1
  47. package/src/local/local-sync-remote.ts +44 -12
  48. package/src/local/local-sync-snapshot.ts +156 -12
  49. package/src/main.ts +13 -0
  50. package/src/mcp/mcp-server.ts +3 -0
  51. package/src/mcp/tools/docs-tools.ts +1 -1
  52. package/src/mcp/tools/knowledge-base-tools.ts +85 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The files that describe a project's knowledge bases (the Knowledge app's
3
+ * bases, not Agent knowledge in `knowledge/<name>/`): their TypeScript shapes
4
+ * and the JSON Schemas `frontera local schema` prints for the two this CLI
5
+ * owns. A source file's format is the service's (`knowledge-base-source`,
6
+ * served from the Knowledge source spec), so only its shape is mirrored here.
7
+ *
8
+ * ```
9
+ * knowledge-base/
10
+ * settings.json KnowledgeBaseSettingsFile
11
+ * <name>/knowledge-base.json KnowledgeBaseFile
12
+ * <name>/sources/<source>.json KnowledgeBaseSourceFile
13
+ * <name>/documents/** uploaded files
14
+ * agents/<slug>/agent.json knowledgeBases: KnowledgeBaseRef[]
15
+ * ```
16
+ *
17
+ * People never appear in these files: stewards and document owners stay on
18
+ * the platform.
19
+ */
20
+
21
+ export const KNOWLEDGE_BASE_DIR = 'knowledge-base'
22
+ export const KNOWLEDGE_BASE_FILE = 'knowledge-base.json'
23
+ export const KNOWLEDGE_BASE_SOURCES_DIR = 'sources'
24
+ export const KNOWLEDGE_BASE_SETTINGS_FILE = 'settings.json'
25
+ /** `knowledge-base/<name>/documents/**`: the files uploaded into the base, when the project holds them. */
26
+ export const KNOWLEDGE_BASE_DOCUMENTS_DIR = 'documents'
27
+
28
+ /** A base's folder name: what `knowledge-base/<name>` names. */
29
+ export const KNOWLEDGE_BASE_NAME_PATTERN = '^[A-Za-z0-9][A-Za-z0-9._-]*$'
30
+ /** `knowledge-base/<name>`: how agent.json names a base, by its folder. */
31
+ export const KNOWLEDGE_BASE_REF_PATTERN = `^${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_NAME_PATTERN.slice(1)}`
32
+ export type KnowledgeBaseRef = `knowledge-base/${string}`
33
+
34
+ /** A workspace, by its slug. */
35
+ type WorkspaceSlug = string
36
+
37
+ export type KnowledgeBaseSharing = { allWorkspaces: true } | { workspaces: WorkspaceSlug[] }
38
+
39
+ /** `knowledge-base/<name>/knowledge-base.json`. A workspace base is never shared. */
40
+ export type KnowledgeBaseFile = {
41
+ /** 1 to 200 characters; the folder name when absent. */
42
+ name?: string
43
+ description?: string
44
+ } & ({ scope: 'workspace' } | { scope: 'organization'; sharing: KnowledgeBaseSharing })
45
+
46
+ export type KnowledgeSourceAuth =
47
+ | { kind: 'none' }
48
+ | { kind: 'bearer' }
49
+ | { kind: 'header'; name: string }
50
+ | { kind: 'basic'; username: string }
51
+ | { kind: 'oauth_client_credentials'; tokenUrl: string; clientId: string; scope?: string }
52
+
53
+ type KnowledgeSourceSpec = {
54
+ kind: 'api_pull'
55
+ name: string
56
+ schedule: 'manual' | 'hourly' | 'every_6_hours' | 'daily'
57
+ keepRemovedDays: 1 | 7 | 30
58
+ /** Where the list is, paging, changes and fields: checked by the service's schema, carried as it is. */
59
+ config: Record<string, unknown>
60
+ }
61
+
62
+ /**
63
+ * `knowledge-base/<name>/sources/<source>.json`: the service's source spec,
64
+ * plus the name of the secret (in secrets.json) that holds its credential,
65
+ * present exactly when the source signs in.
66
+ */
67
+ export type KnowledgeBaseSourceFile = KnowledgeSourceSpec &
68
+ ({ auth: { kind: 'none' }; credentialSecret?: never } | { auth: Exclude<KnowledgeSourceAuth, { kind: 'none' }>; credentialSecret: string })
69
+
70
+ export type KnowledgeSourcePolicy =
71
+ | { apiSources: 'off' | 'organization' | 'all_workspaces' }
72
+ | { apiSources: 'chosen_workspaces'; workspaces: WorkspaceSlug[] }
73
+
74
+ /** `knowledge-base/settings.json`: the organization's Knowledge settings. A key left out leaves that setting alone. */
75
+ export type KnowledgeBaseSettingsFile = {
76
+ assistant?: {
77
+ enabled: boolean
78
+ model: { provider: string; modelId: string } | null
79
+ name: string | null
80
+ instructions: string | null
81
+ }
82
+ searchModel?: { provider: string; model: string } | null
83
+ sources?: KnowledgeSourcePolicy
84
+ }
85
+
86
+ const DRAFT = 'https://json-schema.org/draft/2020-12/schema'
87
+ type JsonSchema = Record<string, unknown>
88
+
89
+ const WORKSPACES = {
90
+ type: 'array',
91
+ minItems: 1,
92
+ items: { type: 'string', minLength: 1, description: 'A workspace’s slug.' },
93
+ }
94
+
95
+ const BASE_FIELDS = {
96
+ name: { type: 'string', minLength: 1, maxLength: 200, description: 'The base’s name as people see it; the folder name when absent.' },
97
+ description: { type: 'string', description: 'What the base holds, in a sentence.' },
98
+ }
99
+
100
+ export const KNOWLEDGE_BASE_SCHEMA: JsonSchema = {
101
+ $schema: DRAFT,
102
+ title: 'Knowledge base (knowledge-base/<name>/knowledge-base.json)',
103
+ description:
104
+ 'A knowledge base in the Knowledge app: its name, what it holds, and who may search it. Its API sources are files in sources/ beside this one. An Agent searches it once its agent.json lists `knowledge-base/<name>` in knowledgeBases. A `workspace` base belongs to the project’s workspace and is never shared; an `organization` base belongs to the organization and is shared with every workspace or the ones listed.',
105
+ type: 'object',
106
+ oneOf: [
107
+ {
108
+ title: 'workspace',
109
+ type: 'object',
110
+ properties: { ...BASE_FIELDS, scope: { const: 'workspace', description: 'The project’s workspace owns the base. It cannot be shared.' } },
111
+ required: ['scope'],
112
+ additionalProperties: false,
113
+ },
114
+ {
115
+ title: 'organization',
116
+ type: 'object',
117
+ properties: {
118
+ ...BASE_FIELDS,
119
+ scope: { const: 'organization', description: 'The organization owns the base. Applying it needs a Knowledge steward.' },
120
+ sharing: {
121
+ description: 'The workspaces that may search it: `{ "allWorkspaces": true }`, or `{ "workspaces": ["<slug>", ...] }`.',
122
+ oneOf: [
123
+ { type: 'object', properties: { allWorkspaces: { const: true } }, required: ['allWorkspaces'], additionalProperties: false },
124
+ { type: 'object', properties: { workspaces: WORKSPACES }, required: ['workspaces'], additionalProperties: false },
125
+ ],
126
+ },
127
+ },
128
+ required: ['scope', 'sharing'],
129
+ additionalProperties: false,
130
+ },
131
+ ],
132
+ }
133
+
134
+ const NULLABLE_TEXT = (maxLength: number, description: string) => ({ anyOf: [{ type: 'string', maxLength }, { type: 'null' }], description })
135
+
136
+ export const KNOWLEDGE_BASE_SETTINGS_SCHEMA: JsonSchema = {
137
+ $schema: DRAFT,
138
+ title: 'Knowledge settings (knowledge-base/settings.json)',
139
+ description:
140
+ 'The organization’s Knowledge settings: the assistant that answers questions over the bases, the model that makes bases searchable by meaning, and which bases may have API sources. A key left out leaves that setting as the platform has it. Changing them needs permission to update the organization; the assistant also needs a Knowledge steward.',
141
+ type: 'object',
142
+ properties: {
143
+ assistant: {
144
+ type: 'object',
145
+ description: 'The Knowledge assistant. Give all four keys.',
146
+ properties: {
147
+ enabled: { type: 'boolean', description: 'Whether people can ask the assistant.' },
148
+ model: {
149
+ description: 'The model it answers with, or null for the platform’s choice. A model the local runtime lacks is swapped for its default there.',
150
+ anyOf: [
151
+ {
152
+ type: 'object',
153
+ properties: { provider: { type: 'string', minLength: 1, maxLength: 200 }, modelId: { type: 'string', minLength: 1, maxLength: 300 } },
154
+ required: ['provider', 'modelId'],
155
+ additionalProperties: false,
156
+ },
157
+ { type: 'null' },
158
+ ],
159
+ },
160
+ name: NULLABLE_TEXT(60, 'The name people see, or null for the default.'),
161
+ instructions: NULLABLE_TEXT(4000, 'Extra instructions for its answers, or null for none.'),
162
+ },
163
+ required: ['enabled', 'model', 'name', 'instructions'],
164
+ additionalProperties: false,
165
+ },
166
+ searchModel: {
167
+ description: 'The embedding model that makes bases searchable by meaning, or null for the platform’s default. Changing it rebuilds every base’s index.',
168
+ anyOf: [
169
+ {
170
+ type: 'object',
171
+ properties: { provider: { type: 'string', minLength: 1, maxLength: 100 }, model: { type: 'string', minLength: 1, maxLength: 300 } },
172
+ required: ['provider', 'model'],
173
+ additionalProperties: false,
174
+ },
175
+ { type: 'null' },
176
+ ],
177
+ },
178
+ sources: {
179
+ description: 'Which bases may have API sources: none (`off`), the organization’s (`organization`), those and every workspace’s (`all_workspaces`), or those and the listed workspaces’ (`chosen_workspaces` with `workspaces`).',
180
+ oneOf: [
181
+ { type: 'object', properties: { apiSources: { enum: ['off', 'organization', 'all_workspaces'] } }, required: ['apiSources'], additionalProperties: false },
182
+ { type: 'object', properties: { apiSources: { const: 'chosen_workspaces' }, workspaces: WORKSPACES }, required: ['apiSources', 'workspaces'], additionalProperties: false },
183
+ ],
184
+ },
185
+ },
186
+ additionalProperties: false,
187
+ }
188
+
189
+ /** agent.json `knowledgeBases`, added to the service's Agent schema by `localizeServiceSchema`. */
190
+ export const KNOWLEDGE_BASES_FIELD: JsonSchema = {
191
+ type: 'array',
192
+ description:
193
+ 'The knowledge bases the Agent searches, by folder: `["knowledge-base/<name>", ...]`, each a base in knowledge-base/<name>/knowledge-base.json. When the key is present it is the Agent’s whole list, and an empty list drops every base; leave the key out to keep the bases the Agent has. They are set when the project is applied, live at once, outside the Agent’s draft. Not Agent knowledge: that is `knowledgeAttachments`.',
194
+ items: { type: 'string', pattern: KNOWLEDGE_BASE_REF_PATTERN, 'x-frontera-reference': [KNOWLEDGE_BASE_DIR] },
195
+ }
@@ -0,0 +1,458 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+ import {
6
+ KNOWLEDGE_BASE_DIR,
7
+ KNOWLEDGE_BASE_FILE,
8
+ KNOWLEDGE_BASE_NAME_PATTERN,
9
+ KNOWLEDGE_BASE_SETTINGS_FILE,
10
+ KNOWLEDGE_BASE_SOURCES_DIR,
11
+ type KnowledgeBaseFile,
12
+ type KnowledgeBaseRef,
13
+ type KnowledgeBaseSettingsFile,
14
+ type KnowledgeBaseSharing,
15
+ type KnowledgeSourcePolicy,
16
+ } from './local-knowledge-base-files'
17
+ import { readPulledDocuments, recordPulledDocuments, sha256Of, type DocumentsPulled, type PulledDocument } from './local-knowledge-documents'
18
+ import type { SyncClient } from './local-sync-client'
19
+ import { readLink } from './local-sync-link'
20
+ import { readLock, type RemoteItem } from './local-sync-plan'
21
+ import type { PersonClients, SyncSource } from './local-sync-remote'
22
+
23
+ /**
24
+ * Reading the Knowledge app's knowledge bases as `knowledge-base/` holds them
25
+ * (not Agent knowledge, `knowledge/`). Knowledge reads and writes as a person,
26
+ * never with a key, so the read is the device session's: as the person stands
27
+ * in the linked workspace for what that workspace reads, and at the
28
+ * organization for what only a Knowledge steward or an administrator sees.
29
+ *
30
+ * knowledge-base/<folder>/knowledge-base.json a base: its name, scope and sharing
31
+ * knowledge-base/<folder>/sources/<name>.json an API source, its credential a secret's name
32
+ * knowledge-base/settings.json the organization's Knowledge settings
33
+ *
34
+ * Documents are not part of an item: they come only when asked for, into
35
+ * `knowledge-base/<folder>/documents/`, kept out of git.
36
+ */
37
+
38
+ const APP = '/v1/knowledge-app'
39
+
40
+ /** The sync capability of a platform that takes knowledge bases from a project; an older one runs the app but not this. */
41
+ export const KNOWLEDGE_BASES_FEATURE = 'knowledge-bases'
42
+
43
+ export const KNOWLEDGE_SIGN_IN = 'This workspace’s knowledge bases need a sign-in: run `frontera auth login --browser`, then fetch again'
44
+ const SETTINGS_UNREAD = `Only an organization owner or admin reads the organization’s Knowledge settings, so ${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE} is not pulled`
45
+
46
+ /** A note this read adds to the whole fetch: kept out when a later fetch reads nothing of Knowledge. */
47
+ export const isKnowledgeBaseNote = (note: string) => note === KNOWLEDGE_SIGN_IN || note === SETTINGS_UNREAD
48
+
49
+ /** The settings' item, as the sync index and a change request name it; its file is `knowledge-base/settings.json`. */
50
+ export const KNOWLEDGE_SETTINGS_NAME = 'settings'
51
+ export const KNOWLEDGE_SETTINGS_REF = `${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_SETTINGS_NAME}`
52
+
53
+ interface BaseRow {
54
+ id: string
55
+ ownerWorkspaceId: string | null
56
+ name: string
57
+ description?: string | null
58
+ createdAt?: string
59
+ sharing?: { allWorkspaces: boolean; workspaceIds: string[] } | null
60
+ }
61
+
62
+ /** A base and the folder it is filed under. */
63
+ interface FiledBase {
64
+ row: BaseRow
65
+ folder: string
66
+ /** Another base took the folder its name gives. */
67
+ renamed: boolean
68
+ }
69
+
70
+ /**
71
+ * What the Knowledge app gives one read, decided once: it does not run (404,
72
+ * nothing to say), the platform does not take knowledge bases from a project
73
+ * (nothing read, the project's left as they are), it runs and no one is
74
+ * signed in, a read failed (said as a warning, the last read kept), or the
75
+ * bases the linked workspace reads, each with its folder.
76
+ */
77
+ export type KnowledgeReach =
78
+ | { state: 'off' }
79
+ | { state: 'not-taken' }
80
+ | { state: 'signed-out' }
81
+ | { state: 'unread' }
82
+ | { state: 'read'; person: PersonClients; workspaceId: string; bases: FiledBase[] }
83
+
84
+ const errorCode = (err: unknown) => (err as { code?: string }).code
85
+
86
+ /** The folder each base the lock holds was pulled into, by the base's id. */
87
+ export function lockedFolders(root: string): Map<string, string> {
88
+ const out = new Map<string, string>()
89
+ for (const [ref, entry] of Object.entries(readLock(root)?.items ?? {})) {
90
+ if (ref.startsWith(`${KNOWLEDGE_BASE_DIR}/`) && ref !== KNOWLEDGE_SETTINGS_REF && entry.id) out.set(entry.id, ref.slice(KNOWLEDGE_BASE_DIR.length + 1))
91
+ }
92
+ return out
93
+ }
94
+
95
+ /** A name as the platform files it (`refName` in the service's sync index): as it is when already a reference name, else its words as a slug. */
96
+ export const platformRefName = (name: string) => (/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) ? name : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'unnamed')
97
+
98
+ /**
99
+ * The names the platform gives rows by id (`themeNames` in the service's sync
100
+ * index, which `knowledgeBaseNames` and `knowledgeSourceNames` use): by name,
101
+ * then id, each its reference name, numbered `-2` and on after one taken or reserved.
102
+ */
103
+ export function platformNames(rows: ReadonlyArray<{ id: string; name: string }>, reserved: readonly string[] = []): Map<string, string> {
104
+ const out = new Map<string, string>()
105
+ const taken = new Set(reserved)
106
+ for (const row of [...rows].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))) {
107
+ const base = platformRefName(row.name)
108
+ let name = base
109
+ for (let n = 2; taken.has(name); n++) name = `${base}-${n}`
110
+ taken.add(name)
111
+ out.set(row.id, name)
112
+ }
113
+ return out
114
+ }
115
+
116
+ /**
117
+ * The folder each knowledge base is filed under, by id. One the project holds
118
+ * already (`held`: the lock's, or one an Apply just created) keeps its folder,
119
+ * so a rename or a new base on the platform never moves a folder. Any other
120
+ * takes its name on the platform (the sync index's), numbered `-2` and on when
121
+ * a kept folder or `taken` has it. Folders are compared without case, as most
122
+ * computers compare them, and `settings` is the settings file's.
123
+ */
124
+ export function knowledgeBaseFolders(platform: ReadonlyMap<string, string>, held: ReadonlyMap<string, string>, taken: Iterable<string> = []): Map<string, string> {
125
+ const pattern = new RegExp(KNOWLEDGE_BASE_NAME_PATTERN)
126
+ const used = new Set([KNOWLEDGE_SETTINGS_NAME, ...taken].map((f) => f.toLowerCase()))
127
+ const out = new Map<string, string>()
128
+ const ordered = [...platform].sort(([ia, a], [ib, b]) => a.localeCompare(b) || (ia < ib ? -1 : ia > ib ? 1 : 0))
129
+ for (const [id] of ordered) {
130
+ const folder = held.get(id)
131
+ if (!folder || !pattern.test(folder) || used.has(folder.toLowerCase())) continue
132
+ used.add(folder.toLowerCase())
133
+ out.set(id, folder)
134
+ }
135
+ for (const [id, name] of ordered) {
136
+ if (out.has(id)) continue
137
+ let folder = name
138
+ for (let n = 2; used.has(folder.toLowerCase()); n++) folder = `${name}-${n}`
139
+ used.add(folder.toLowerCase())
140
+ out.set(id, folder)
141
+ }
142
+ return out
143
+ }
144
+
145
+ /** Folders by base, as `knowledgeBaseFolders` files them; `held` may name every one (a fetch that read the index). */
146
+ function fileBases(bases: BaseRow[], held: ReadonlyMap<string, string>): FiledBase[] {
147
+ const names = platformNames(bases, [KNOWLEDGE_SETTINGS_NAME])
148
+ const folders = knowledgeBaseFolders(names, held)
149
+ return bases.map((row) => {
150
+ const folder = folders.get(row.id)!
151
+ return { row, folder, renamed: !held.has(row.id) && folder.toLowerCase() !== platformRefName(row.name).toLowerCase() }
152
+ })
153
+ }
154
+
155
+ /** `capabilities` are the platform's sync features, read from it when not given. */
156
+ export async function knowledgeReach(source: SyncSource, held: ReadonlyMap<string, string> = new Map(), warnings: string[] = [], capabilities?: readonly string[]): Promise<KnowledgeReach> {
157
+ const features = capabilities ?? (await source.workspace.find<{ features?: string[] }>('/v1/sync/capabilities').catch(() => null))?.features ?? []
158
+ if (!features.includes(KNOWLEDGE_BASES_FEATURE)) return { state: 'not-taken' }
159
+ const person = source.person
160
+ // A key is refused by the app but still finds it running: only a 404 means there is none.
161
+ if (!person) return (await source.workspace.find(`${APP}/me`).then((found) => found !== null, (err) => errorCode(err) !== 'NOT_FOUND')) ? { state: 'signed-out' } : { state: 'off' }
162
+ try {
163
+ if ((await person.workspace.find(`${APP}/me`)) === null) return { state: 'off' }
164
+ const listed = await person.workspace.get<{ bases?: BaseRow[] } | null>(`${APP}/bases`)
165
+ // The workspace's own bases and the organization's shared with it; never another workspace's.
166
+ const bases = (listed?.bases ?? []).filter((b) => b.ownerWorkspaceId === null || b.ownerWorkspaceId === source.workspaceId)
167
+ return { state: 'read', person, workspaceId: source.workspaceId, bases: fileBases(bases, held) }
168
+ } catch (err) {
169
+ if (errorCode(err) === 'UNAUTHORIZED') return { state: 'signed-out' }
170
+ warnings.push(`Knowledge bases: ${(err as Error).message}`)
171
+ return { state: 'unread' }
172
+ }
173
+ }
174
+
175
+ interface WorkspaceRow { id: string; slug?: string | null }
176
+
177
+ /** Workspace slugs by id: every workspace for an administrator, else the person's own. */
178
+ async function workspaceSlugs(org: SyncClient): Promise<Map<string, string>> {
179
+ const list = await org.get<WorkspaceRow[]>('/v1/workspaces?scope=org').catch((err) => {
180
+ if (errorCode(err) !== 'FORBIDDEN') throw err
181
+ return org.get<WorkspaceRow[]>('/v1/workspaces')
182
+ })
183
+ return new Map((Array.isArray(list) ? list : []).flatMap((w) => (w.slug ? [[w.id, w.slug] as const] : [])))
184
+ }
185
+
186
+ /** Slugs for workspace ids, sorted; an id with no slug the person can read stays an id, said in `unnamed`. */
187
+ function slugsFor(ids: string[], slugs: ReadonlyMap<string, string>): { names: string[]; unnamed: string[] } {
188
+ const unnamed = ids.filter((id) => !slugs.has(id))
189
+ return { names: ids.map((id) => slugs.get(id) ?? id).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), unnamed }
190
+ }
191
+
192
+ const unnamedNote = (ids: string[]) =>
193
+ ids.length === 1
194
+ ? `Shared with a workspace you can’t see by its slug, so it is written by its id (${ids[0]})`
195
+ : `Shared with ${ids.length} workspaces you can’t see by their slugs, so they are written by id (${ids.join(', ')})`
196
+
197
+ interface SourceRow { id: string; kind: string; name: string; state?: string; schedule?: string; keepRemovedDays?: number; config?: Record<string, unknown>; auth?: { kind: string } & Record<string, unknown> }
198
+
199
+ /** A secret's name for a source's credential: `KNOWLEDGE_<FOLDER>_<SOURCE>`, as `frontera secret set` takes it. */
200
+ const credentialSecret = (folder: string, source: string) => `KNOWLEDGE_${folder}_${source}`.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/_+$/, '')
201
+
202
+ const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`
203
+
204
+ async function readBase(reach: Extract<KnowledgeReach, { state: 'read' }>, filed: FiledBase, slugs: () => Promise<Map<string, string>>): Promise<RemoteItem> {
205
+ const { row, folder } = filed
206
+ const dir = `${KNOWLEDGE_BASE_DIR}/${folder}`
207
+ const notes: string[] = []
208
+ if (filed.renamed) notes.push(`Another knowledge base has the same name, so this one is in ${dir}`)
209
+ let doc: KnowledgeBaseFile
210
+ const named = { name: row.name, ...(row.description ? { description: row.description } : {}) }
211
+ if (row.ownerWorkspaceId !== null) doc = { ...named, scope: 'workspace' }
212
+ else {
213
+ let sharing: KnowledgeBaseSharing = { allWorkspaces: true }
214
+ if (!row.sharing?.allWorkspaces) {
215
+ const { names, unnamed } = slugsFor(row.sharing?.workspaceIds ?? [], await slugs())
216
+ if (unnamed.length) notes.push(unnamedNote(unnamed))
217
+ sharing = { workspaces: names }
218
+ }
219
+ doc = { ...named, scope: 'organization', sharing }
220
+ }
221
+ const files: Record<string, string> = { [`${dir}/${KNOWLEDGE_BASE_FILE}`]: json(doc) }
222
+ // An organization base's sources are managed at the organization, by a Knowledge steward.
223
+ const reader = row.ownerWorkspaceId === null ? reach.person.organization : reach.person.workspace
224
+ const listed = await reader.get<{ sources?: SourceRow[] } | null>(`${APP}/bases/${encodeURIComponent(row.id)}/sources`)
225
+ // A source's file is named as the platform knows it (`knowledgeSourceNames`): a change request matches sources by it.
226
+ const sources = (listed?.sources ?? []).filter((source) => source.state !== 'removing')
227
+ const keys = platformNames(sources)
228
+ const taken = new Set<string>()
229
+ const kept: string[] = []
230
+ for (const source of [...sources].sort((a, b) => (keys.get(a.id)! < keys.get(b.id)! ? -1 : 1))) {
231
+ const name = keys.get(source.id)!
232
+ if (source.kind !== 'api_pull') {
233
+ notes.push(`Its source “${source.name}” is set on the platform, so it is not pulled`)
234
+ kept.push(source.name)
235
+ continue
236
+ }
237
+ if (!source.config || !source.auth) {
238
+ const who = row.ownerWorkspaceId === null ? 'a Knowledge steward' : 'someone who may change this knowledge base'
239
+ notes.push(`Only ${who} reads the settings of its source “${source.name}”, so it stays on the platform and is not in ${KNOWLEDGE_BASE_SOURCES_DIR}/`)
240
+ kept.push(source.name)
241
+ continue
242
+ }
243
+ if (taken.has(name.toLowerCase())) {
244
+ notes.push(`Its source “${source.name}” is named like another but for capital letters, which one folder can’t hold, so it stays on the platform and is not in ${KNOWLEDGE_BASE_SOURCES_DIR}/`)
245
+ kept.push(source.name)
246
+ continue
247
+ }
248
+ taken.add(name.toLowerCase())
249
+ const secret = source.auth.kind === 'none' ? null : credentialSecret(folder, name)
250
+ files[`${dir}/${KNOWLEDGE_BASE_SOURCES_DIR}/${name}.json`] = json({
251
+ kind: source.kind,
252
+ name: source.name,
253
+ schedule: source.schedule,
254
+ keepRemovedDays: source.keepRemovedDays,
255
+ config: source.config,
256
+ auth: source.auth,
257
+ ...(secret ? { credentialSecret: secret } : {}),
258
+ })
259
+ if (secret) notes.push(`Its source “${source.name}” signs in with a credential, which never leaves the platform: set it on this computer with \`frontera secret set ${secret}\``)
260
+ }
261
+ notes.push(`Its documents stay on the platform unless asked for: pull with \`--documents ${dir}\``)
262
+ return { ref: dir, kind: 'knowledge-base', name: folder, title: row.name, id: row.id, files, unresolved: [], notes, requires: [], ...(kept.length ? { keptOnPlatform: kept } : {}) }
263
+ }
264
+
265
+ /** One setting the person may not read (403), as absent. */
266
+ const unlessRefused = <T>(read: Promise<T>): Promise<T | null> => read.catch((err) => (errorCode(err) === 'FORBIDDEN' ? null : Promise.reject(err)))
267
+
268
+ async function readSettings(reach: Extract<KnowledgeReach, { state: 'read' }>, slugs: () => Promise<Map<string, string>>, notes: string[]): Promise<RemoteItem | null> {
269
+ const org = reach.person.organization
270
+ const [assistant, embedding, policy] = await Promise.all([
271
+ unlessRefused(org.get<{ setup?: { enabled: boolean; model: { provider: string; modelId: string } | null; name: string | null; instructions: string | null } | null } | null>(`${APP}/assistant`)),
272
+ unlessRefused(org.get<{ chosen?: { provider: string; model: string } | null } | null>(`${APP}/settings/embedding`)),
273
+ unlessRefused(org.get<{ policy?: { apiSources: string; workspaceIds?: string[] } } | null>(`${APP}/source-policy`)),
274
+ ])
275
+ const doc: KnowledgeBaseSettingsFile = {}
276
+ const itemNotes: string[] = []
277
+ const setup = assistant?.setup
278
+ if (setup) doc.assistant = { enabled: setup.enabled, model: setup.model ? { provider: setup.model.provider, modelId: setup.model.modelId } : null, name: setup.name, instructions: setup.instructions }
279
+ if (embedding) doc.searchModel = embedding.chosen ? { provider: embedding.chosen.provider, model: embedding.chosen.model } : null
280
+ if (policy?.policy) {
281
+ const mode = policy.policy.apiSources
282
+ if (mode === 'chosen_workspaces') {
283
+ const { names, unnamed } = slugsFor(policy.policy.workspaceIds ?? [], await slugs())
284
+ if (unnamed.length) itemNotes.push(`Its source policy names ${unnamedNote(unnamed).slice('Shared with '.length)}`)
285
+ doc.sources = { apiSources: 'chosen_workspaces', workspaces: names }
286
+ } else doc.sources = { apiSources: mode } as KnowledgeSourcePolicy
287
+ }
288
+ if (!Object.keys(doc).length) {
289
+ notes.push(SETTINGS_UNREAD)
290
+ return null
291
+ }
292
+ const left = [!setup && 'the assistant', !embedding && 'the search model', !policy?.policy && 'the source policy'].filter((w): w is string => !!w)
293
+ if (left.length) itemNotes.push(`Left out, as only an organization owner or admin reads ${left.length === 1 ? 'it' : 'them'}: ${left.join(', ')}; applying the file leaves ${left.length === 1 ? 'it' : 'them'} as on the platform`)
294
+ return { ref: KNOWLEDGE_SETTINGS_REF, kind: 'knowledge-base', name: KNOWLEDGE_SETTINGS_NAME, title: 'Knowledge settings', files: { [`${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE}`]: json(doc) }, unresolved: [], notes: itemNotes, requires: [] }
295
+ }
296
+
297
+ export interface KnowledgeBasesRead {
298
+ items: RemoteItem[]
299
+ /** Said once for the whole read: a sign-in to make, settings the person may not read. */
300
+ notes: string[]
301
+ /** The items are everything there is (the app is off, or every base was read): what is not among them is gone. */
302
+ listed: boolean
303
+ }
304
+
305
+ /**
306
+ * The bases and settings: every one, or those `only` names (a fetch reads
307
+ * what the sync index says moved). One that fails leaves the read unlisted, so
308
+ * nothing reads as deleted.
309
+ */
310
+ export async function readKnowledgeBases(reach: KnowledgeReach, warnings: string[], only?: ReadonlySet<string>): Promise<KnowledgeBasesRead> {
311
+ if (reach.state === 'off') return { items: [], notes: [], listed: true }
312
+ if (reach.state === 'signed-out') return { items: [], notes: [KNOWLEDGE_SIGN_IN], listed: false }
313
+ if (reach.state === 'unread' || reach.state === 'not-taken') return { items: [], notes: [], listed: false }
314
+ const org = reach.person.organization
315
+ let known: Promise<Map<string, string>> | null = null
316
+ const slugs = () => (known ??= workspaceSlugs(org))
317
+ const notes: string[] = []
318
+ try {
319
+ const wanted = (ref: string) => !only || only.has(ref)
320
+ const bases = await Promise.all(reach.bases.filter((b) => wanted(`${KNOWLEDGE_BASE_DIR}/${b.folder}`)).map((b) => readBase(reach, b, slugs)))
321
+ const settings = wanted(KNOWLEDGE_SETTINGS_REF) ? await readSettings(reach, slugs, notes) : null
322
+ return { items: [...bases, ...(settings ? [settings] : [])], notes, listed: !only }
323
+ } catch (err) {
324
+ warnings.push(`Knowledge bases: ${(err as Error).message}`)
325
+ return { items: [], notes: [], listed: false }
326
+ }
327
+ }
328
+
329
+ interface PickRow { id: string; name: string; picked: boolean; unavailable?: string | null }
330
+
331
+ /** An Agent's picks as agent.json names them (`knowledge-base/<folder>`), or null when the bases were not read. */
332
+ export async function agentPicks(reach: KnowledgeReach, agentId: string): Promise<{ refs: KnowledgeBaseRef[]; notes: string[] } | null> {
333
+ if (reach.state !== 'read') return null
334
+ const answer = await reach.person.workspace.get<{ bases?: PickRow[] } | null>(`${APP}/agents/${encodeURIComponent(agentId)}/bases`)
335
+ const folders = new Map(reach.bases.map((b) => [b.row.id, b.folder]))
336
+ const refs: KnowledgeBaseRef[] = []
337
+ const notes: string[] = []
338
+ for (const pick of (answer?.bases ?? []).filter((b) => b.picked)) {
339
+ const folder = pick.unavailable ? undefined : folders.get(pick.id)
340
+ if (folder) refs.push(`${KNOWLEDGE_BASE_DIR}/${folder}`)
341
+ else notes.push(`Also searches the knowledge base “${pick.name}”, which this workspace no longer reads: it is not in knowledgeBases, so applying agent.json drops it`)
342
+ }
343
+ return { refs: refs.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), notes }
344
+ }
345
+
346
+ // ── Documents, when asked for ────────────────────────────────────────────────
347
+
348
+ const DOCUMENTS_DIR = 'documents'
349
+
350
+ /**
351
+ * The documents in `knowledge-base/<folder>/documents/`, by file name: what a
352
+ * push may bring. A knowledge base has no folders, so a file in a subfolder is
353
+ * named in `nested` and stays here.
354
+ */
355
+ export function knowledgeBaseDocuments(root: string, folder: string): { documents: Array<{ fileName: string; bytes: Uint8Array }>; nested: string[] } {
356
+ const dir = join(root, KNOWLEDGE_BASE_DIR, folder, DOCUMENTS_DIR)
357
+ const out: { documents: Array<{ fileName: string; bytes: Uint8Array }>; nested: string[] } = { documents: [], nested: [] }
358
+ let names: string[]
359
+ try {
360
+ names = readdirSync(dir).sort()
361
+ } catch {
362
+ return out
363
+ }
364
+ for (const name of names) {
365
+ if (name.startsWith('.') || name.endsWith('.workspace')) continue
366
+ const st = statSync(join(dir, name), { throwIfNoEntry: false })
367
+ if (st?.isDirectory()) out.nested.push(name)
368
+ else if (st?.isFile()) out.documents.push({ fileName: name, bytes: new Uint8Array(readFileSync(join(dir, name))) })
369
+ }
370
+ return out
371
+ }
372
+ const IGNORED = `${KNOWLEDGE_BASE_DIR}/*/${DOCUMENTS_DIR}/`
373
+ const IGNORE_COMMENT = '# Knowledge base documents: brought on request, often the customer’s own content'
374
+
375
+ /** Add the documents line to the project's `.gitignore`, appended and only once. */
376
+ export function ignoreKnowledgeBaseDocuments(root: string): void {
377
+ const path = join(root, '.gitignore')
378
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : ''
379
+ if (current.split('\n').some((line) => [IGNORED, `/${IGNORED}`].includes(line.trim()))) return
380
+ const lead = current ? `${current}${current.endsWith('\n') ? '' : '\n'}\n` : ''
381
+ writeFileAtomic(path, `${lead}${IGNORE_COMMENT}\n${IGNORED}\n`, existsSync(path) ? statSync(path).mode & 0o777 : 0o644)
382
+ }
383
+
384
+ interface DocumentRow { id: string; title?: string | null; fileName?: string | null; createdAt?: string | null; source?: { id: string; name: string } | null }
385
+
386
+ /** A document's name as a file: no path, no leading dot. */
387
+ const fileNameOf = (doc: DocumentRow) => (doc.fileName ?? doc.title ?? '').replace(/\\/g, '/').split('/').pop()!.replace(/^\.+/, '').trim() || null
388
+
389
+ /** A base's documents as the workspace has them now, each by its file name: every one, synced ones included. */
390
+ export async function knowledgeBaseDocumentsOn(client: SyncClient, baseId: string): Promise<Array<{ id: string; fileName: string; createdAt: string | null; synced: boolean }>> {
391
+ const documents = (await client.get<{ documents?: DocumentRow[] } | null>(`${APP}/bases/${encodeURIComponent(baseId)}/documents`))?.documents ?? []
392
+ return documents.flatMap((d) => {
393
+ const fileName = fileNameOf(d)
394
+ return fileName ? [{ id: d.id, fileName, createdAt: d.createdAt ?? null, synced: !!d.source }] : []
395
+ })
396
+ }
397
+
398
+ /** `lembur.md` → `lembur-2.md`. */
399
+ function numbered(name: string, n: number): string {
400
+ const dot = name.lastIndexOf('.')
401
+ return dot > 0 ? `${name.slice(0, dot)}-${n}${name.slice(dot)}` : `${name}-${n}`
402
+ }
403
+
404
+ /**
405
+ * Bring a base's uploaded documents into `knowledge-base/<folder>/documents/`.
406
+ * A document a source synced stays with that source (`skipped`). A file already in the
407
+ * folder is never overwritten. Each written document's id and digest are
408
+ * recorded (`.frontera/sync/documents.json`, under `knowledge-base/<folder>`),
409
+ * so a push tells one changed here from one new here.
410
+ */
411
+ export async function pullKnowledgeBaseDocuments(root: string, client: SyncClient, base: { folder: string; id: string }): Promise<DocumentsPulled> {
412
+ const out: DocumentsPulled = { written: [], present: [], skipped: [], failed: [] }
413
+ const key = `${KNOWLEDGE_BASE_DIR}/${base.folder}`
414
+ const dir = `${key}/${DOCUMENTS_DIR}`
415
+ let documents: DocumentRow[]
416
+ try {
417
+ documents = (await client.get<{ documents?: DocumentRow[] } | null>(`${APP}/bases/${encodeURIComponent(base.id)}/documents`))?.documents ?? []
418
+ } catch (err) {
419
+ out.failed.push(`the list of documents: ${(err as Error).message}`)
420
+ return out
421
+ }
422
+ const workspace = readLink(root)?.workspace
423
+ // A document pulled before keeps its file name, whatever order the platform lists it in now.
424
+ const before = new Map(Object.entries(workspace ? (readPulledDocuments(root, workspace).bases[key] ?? {}) : {}).map(([path, d]) => [d.sourceId, path]))
425
+ const taken = new Set([...before.values()].map((p) => p.toLowerCase()))
426
+ const recorded: Record<string, PulledDocument> = {}
427
+ for (const doc of documents) {
428
+ const name = fileNameOf(doc)
429
+ if (!name) continue
430
+ if (doc.source) {
431
+ out.skipped.push(name)
432
+ continue
433
+ }
434
+ let rel = before.get(doc.id)
435
+ if (!rel) {
436
+ rel = name
437
+ for (let n = 2; taken.has(rel.toLowerCase()); n++) rel = numbered(name, n)
438
+ taken.add(rel.toLowerCase())
439
+ }
440
+ const path = `${dir}/${rel}`
441
+ if (existsSync(join(root, path))) {
442
+ out.present.push(path)
443
+ continue
444
+ }
445
+ try {
446
+ const bytes = await client.getBytes(`${APP}/documents/${encodeURIComponent(doc.id)}/file`)
447
+ mkdirSync(dirname(join(root, path)), { recursive: true })
448
+ writeFileSync(join(root, path), bytes)
449
+ out.written.push(path)
450
+ recorded[rel] = { sourceId: doc.id, sha256: sha256Of(bytes), bytes: bytes.byteLength, at: new Date().toISOString() }
451
+ } catch (err) {
452
+ out.failed.push(`${name}: ${(err as Error).message}`)
453
+ }
454
+ }
455
+ if (out.written.length) ignoreKnowledgeBaseDocuments(root)
456
+ if (workspace && Object.keys(recorded).length) recordPulledDocuments(root, workspace, key, recorded)
457
+ return out
458
+ }