@frontera-sdk/cli 1.52.27 → 1.52.29
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 +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- package/src/local/local-workflow-state.ts +131 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { KNOWLEDGE_DOCUMENT } from './local-dev-references'
|
|
6
|
+
import type { SyncClient } from './local-sync-client'
|
|
7
|
+
import { readLink } from './local-sync-link'
|
|
8
|
+
import { rows } from './local-sync-remote'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A knowledge base's documents, brought from the workspace when asked
|
|
12
|
+
* (`frontera local sync pull --documents faq`, or "Bring documents" in
|
|
13
|
+
* Forge's Pull dialog). Off by default: documents can be large and are often
|
|
14
|
+
* the customer's own content.
|
|
15
|
+
*
|
|
16
|
+
* They are not merged or tracked as the other pulled files are. Each lands
|
|
17
|
+
* in `knowledge/<name>/` under its own file name, as a document added by hand
|
|
18
|
+
* would, and `local dev` uploads it the same way. A file already in the
|
|
19
|
+
* folder is never overwritten, so a second pull brings only what is new.
|
|
20
|
+
* Only the kinds `local dev` uploads are brought; the rest are listed.
|
|
21
|
+
*
|
|
22
|
+
* The knowledge base's folders are subfolders, as the platform's uploader
|
|
23
|
+
* turns a dropped directory into folders: a document filed in Policies ›
|
|
24
|
+
* Returns lands in `knowledge/<name>/Policies/Returns/`, and `local dev`
|
|
25
|
+
* and a change request file it there again. A document is known by its path
|
|
26
|
+
* under the knowledge base's folder (`Policies/Returns/terms.pdf`).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
interface SourceRow {
|
|
30
|
+
id?: string
|
|
31
|
+
fileName?: string | null
|
|
32
|
+
mimeType?: string | null
|
|
33
|
+
storageKey?: string | null
|
|
34
|
+
folderId?: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A knowledge base folder as `GET …/folders` lists it. */
|
|
38
|
+
export interface FolderRow {
|
|
39
|
+
id: string
|
|
40
|
+
parentId?: string | null
|
|
41
|
+
name: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A folder's name as a project folder: no path separator, no leading dot. */
|
|
45
|
+
export const folderSegment = (name: string) => name.replace(/[\\/]/g, '-').replace(/^\.+/, '').trim() || 'folder'
|
|
46
|
+
|
|
47
|
+
/** Each folder's path from the top (`Policies/Returns`), by id; a folder whose parents do not lead to the top has none. */
|
|
48
|
+
export function folderPaths(folders: FolderRow[]): Map<string, string> {
|
|
49
|
+
const byId = new Map(folders.map((f) => [f.id, f]))
|
|
50
|
+
const out = new Map<string, string>()
|
|
51
|
+
const pathOf = (id: string, seen: Set<string>): string | null => {
|
|
52
|
+
const known = out.get(id)
|
|
53
|
+
if (known !== undefined) return known
|
|
54
|
+
const f = byId.get(id)
|
|
55
|
+
if (!f || seen.has(id)) return null
|
|
56
|
+
seen.add(id)
|
|
57
|
+
const parent = f.parentId ? pathOf(f.parentId, seen) : ''
|
|
58
|
+
if (parent === null) return null
|
|
59
|
+
const path = parent ? `${parent}/${folderSegment(f.name)}` : folderSegment(f.name)
|
|
60
|
+
out.set(id, path)
|
|
61
|
+
return path
|
|
62
|
+
}
|
|
63
|
+
for (const f of folders) pathOf(f.id, new Set())
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A knowledge base's folders by id, or none (an older platform, or no folders). */
|
|
68
|
+
export async function readFolders(client: { get: (path: string) => Promise<unknown> }, kbId: string): Promise<Map<string, string>> {
|
|
69
|
+
try {
|
|
70
|
+
const listed = (await client.get(`/v1/workspace-knowledge/${encodeURIComponent(kbId)}/folders`)) as { folders?: unknown; data?: { folders?: unknown } } | null
|
|
71
|
+
const rows = (listed?.folders ?? listed?.data?.folders ?? []) as FolderRow[]
|
|
72
|
+
return folderPaths(Array.isArray(rows) ? rows : [])
|
|
73
|
+
} catch {
|
|
74
|
+
return new Map()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A document's path under its knowledge base's folder. */
|
|
79
|
+
export const documentPath = (folder: string | null | undefined, fileName: string) => (folder ? `${folder}/${fileName}` : fileName)
|
|
80
|
+
|
|
81
|
+
/** A document path's folder (null at the top) and file name. */
|
|
82
|
+
export function splitDocumentPath(path: string): { folder: string | null; fileName: string } {
|
|
83
|
+
const at = path.lastIndexOf('/')
|
|
84
|
+
return at < 0 ? { folder: null, fileName: path } : { folder: path.slice(0, at), fileName: path.slice(at + 1) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DocumentsPulled {
|
|
88
|
+
/** Project-relative paths written. */
|
|
89
|
+
written: string[]
|
|
90
|
+
/** Already in the folder: left as they are. */
|
|
91
|
+
present: string[]
|
|
92
|
+
/** Not a kind `local dev` uploads (a spreadsheet, an image), by file name. */
|
|
93
|
+
skipped: string[]
|
|
94
|
+
/** Could not be read, with why. */
|
|
95
|
+
failed: string[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A document's name as a file in the folder: no path, no leading dot. */
|
|
99
|
+
function fileNameOf(source: SourceRow): string | null {
|
|
100
|
+
const name = basename(String(source.fileName ?? '').replace(/\\/g, '/')).replace(/^\.+/, '').trim()
|
|
101
|
+
return name && name !== 'knowledge.json' ? name : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── What the project brought, for proposing back ─────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** A document a pull wrote: the platform's copy it came from, and its content's digest then. */
|
|
107
|
+
export interface PulledDocument {
|
|
108
|
+
sourceId: string
|
|
109
|
+
sha256: string
|
|
110
|
+
bytes: number
|
|
111
|
+
at: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `.frontera/sync/documents.json` (not committed, with the rest of `sync/`):
|
|
116
|
+
* per knowledge base, the documents a pull wrote, by their path under the
|
|
117
|
+
* knowledge base's folder (a file name at the top). A push reads
|
|
118
|
+
* it to tell a document changed here from one new here, and to take off the
|
|
119
|
+
* platform only a document the project pulled and then removed.
|
|
120
|
+
*/
|
|
121
|
+
export interface DocumentsFile {
|
|
122
|
+
version: 1
|
|
123
|
+
workspace: string
|
|
124
|
+
bases: Record<string, Record<string, PulledDocument>>
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const DOCUMENTS_JSON = join('.frontera', 'sync', 'documents.json')
|
|
128
|
+
|
|
129
|
+
export function readPulledDocuments(root: string, workspace: string): DocumentsFile {
|
|
130
|
+
const empty: DocumentsFile = { version: 1, workspace, bases: {} }
|
|
131
|
+
try {
|
|
132
|
+
const file = join(root, DOCUMENTS_JSON)
|
|
133
|
+
if (!existsSync(file)) return empty
|
|
134
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as DocumentsFile
|
|
135
|
+
return parsed?.version === 1 && parsed.workspace === workspace && parsed.bases && typeof parsed.bases === 'object' ? parsed : empty
|
|
136
|
+
} catch {
|
|
137
|
+
return empty
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Record documents for one knowledge base: an entry sets it, null forgets it. */
|
|
142
|
+
export function recordPulledDocuments(root: string, workspace: string, kb: string, entries: Record<string, PulledDocument | null>): void {
|
|
143
|
+
const file = readPulledDocuments(root, workspace)
|
|
144
|
+
const base = { ...(file.bases[kb] ?? {}) }
|
|
145
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
146
|
+
if (entry) base[name] = entry
|
|
147
|
+
else delete base[name]
|
|
148
|
+
}
|
|
149
|
+
file.bases[kb] = base
|
|
150
|
+
mkdirSync(join(root, '.frontera', 'sync'), { recursive: true })
|
|
151
|
+
writeFileSync(join(root, DOCUMENTS_JSON), `${JSON.stringify(file, null, 2)}\n`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const sha256Of = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
|
155
|
+
|
|
156
|
+
/** The documents' paths under a knowledge base's folder, its subfolders included: the files `local dev` uploads. */
|
|
157
|
+
export function documentPaths(root: string, kb: string): string[] {
|
|
158
|
+
const top = join(root, 'knowledge', kb)
|
|
159
|
+
const out: string[] = []
|
|
160
|
+
const walk = (rel: string, depth: number) => {
|
|
161
|
+
if (depth > 16) return
|
|
162
|
+
let names: string[]
|
|
163
|
+
try {
|
|
164
|
+
names = readdirSync(join(top, rel))
|
|
165
|
+
} catch {
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
if (name.startsWith('.') || name === 'node_modules') continue
|
|
170
|
+
const path = rel ? `${rel}/${name}` : name
|
|
171
|
+
let st
|
|
172
|
+
try {
|
|
173
|
+
st = statSync(join(top, path))
|
|
174
|
+
} catch {
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (st.isDirectory()) walk(path, depth + 1)
|
|
178
|
+
else if (st.isFile() && KNOWLEDGE_DOCUMENT.test(name) && path !== 'knowledge.json') out.push(path)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (existsSync(top)) walk('', 0)
|
|
182
|
+
return out.sort()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The documents in a knowledge base's folder and its subfolders, by path. */
|
|
186
|
+
export function localDocuments(root: string, kb: string): Array<{ fileName: string; bytes: Uint8Array }> {
|
|
187
|
+
const dir = join(root, 'knowledge', kb)
|
|
188
|
+
return documentPaths(root, kb).flatMap((f) => {
|
|
189
|
+
try {
|
|
190
|
+
return [{ fileName: f, bytes: new Uint8Array(readFileSync(join(dir, f))) }]
|
|
191
|
+
} catch {
|
|
192
|
+
return []
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `fileName` is the document's path under the knowledge base's folder (`Policies/terms.pdf`); a push sends its folder apart. */
|
|
198
|
+
export interface DocumentChange {
|
|
199
|
+
upload: Array<{ fileName: string; sha256: string; bytes: number; mime: string; replaces: string | null; change: 'new' | 'changed'; content: Uint8Array }>
|
|
200
|
+
remove: Array<{ fileName: string; sourceId: string }>
|
|
201
|
+
/** What is left as it is, in words: a document named like one the workspace has that this project did not pull. */
|
|
202
|
+
notes: string[]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const DOCUMENT_MIME: Record<string, string> = {
|
|
206
|
+
md: 'text/markdown',
|
|
207
|
+
markdown: 'text/markdown',
|
|
208
|
+
txt: 'text/plain',
|
|
209
|
+
pdf: 'application/pdf',
|
|
210
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
211
|
+
html: 'text/html',
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The largest document a change request carries (the platform's limit). */
|
|
215
|
+
export const MAX_DOCUMENT_BYTES = 25 * 1024 * 1024
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What proposing a knowledge base's documents sends, against the documents
|
|
219
|
+
* the pull wrote (`documents.json`) and those the workspace has now:
|
|
220
|
+
*
|
|
221
|
+
* new here uploaded
|
|
222
|
+
* pulled, changed uploaded, and the pulled copy taken off once Apply's other steps are done
|
|
223
|
+
* pulled, removed taken off the platform
|
|
224
|
+
* unchanged nothing
|
|
225
|
+
* named like one the workspace has that the project did not pull: left alone, said
|
|
226
|
+
*/
|
|
227
|
+
export function documentChanges(local: Array<{ fileName: string; bytes: Uint8Array }>, pulled: Record<string, PulledDocument>, platform: Array<{ id: string; fileName: string }>): DocumentChange {
|
|
228
|
+
const out: DocumentChange = { upload: [], remove: [], notes: [] }
|
|
229
|
+
const onPlatform = new Map(platform.map((p) => [p.id, p]))
|
|
230
|
+
const names = new Set(platform.map((p) => p.fileName))
|
|
231
|
+
for (const doc of local) {
|
|
232
|
+
const sha256 = sha256Of(doc.bytes)
|
|
233
|
+
const mime = DOCUMENT_MIME[doc.fileName.split('.').pop()?.toLowerCase() ?? ''] ?? 'application/octet-stream'
|
|
234
|
+
const was = pulled[doc.fileName]
|
|
235
|
+
if (doc.bytes.byteLength > MAX_DOCUMENT_BYTES) {
|
|
236
|
+
out.notes.push(`${doc.fileName} stays here: larger than ${MAX_DOCUMENT_BYTES / 1024 / 1024} MB`)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
if (was) {
|
|
240
|
+
if (was.sha256 === sha256) continue
|
|
241
|
+
out.upload.push({ fileName: doc.fileName, sha256, bytes: doc.bytes.byteLength, mime, replaces: onPlatform.has(was.sourceId) ? was.sourceId : null, change: 'changed', content: doc.bytes })
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (names.has(doc.fileName)) {
|
|
245
|
+
out.notes.push(`${doc.fileName} is named like a document the workspace has that this project didn't bring; it stays as it is (bring the documents to compare)`)
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
out.upload.push({ fileName: doc.fileName, sha256, bytes: doc.bytes.byteLength, mime, replaces: null, change: 'new', content: doc.bytes })
|
|
249
|
+
}
|
|
250
|
+
const here = new Set(local.map((d) => d.fileName))
|
|
251
|
+
for (const [fileName, was] of Object.entries(pulled)) {
|
|
252
|
+
if (here.has(fileName) || !onPlatform.has(was.sourceId)) continue
|
|
253
|
+
out.remove.push({ fileName, sourceId: was.sourceId })
|
|
254
|
+
}
|
|
255
|
+
return out
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function pullDocuments(root: string, client: SyncClient, kb: { name: string; id: string }): Promise<DocumentsPulled> {
|
|
259
|
+
const out: DocumentsPulled = { written: [], present: [], skipped: [], failed: [] }
|
|
260
|
+
const folder = join('knowledge', kb.name)
|
|
261
|
+
let sources: SourceRow[]
|
|
262
|
+
try {
|
|
263
|
+
sources = rows<SourceRow>(await client.get(`/v1/workspace-knowledge/${encodeURIComponent(kb.id)}/sources`))
|
|
264
|
+
} catch (err) {
|
|
265
|
+
out.failed.push(`the list of documents: ${(err as Error).message}`)
|
|
266
|
+
return out
|
|
267
|
+
}
|
|
268
|
+
// The knowledge base's folders become subfolders; a document in none sits at the top.
|
|
269
|
+
const folders = sources.some((s) => s.folderId) ? await readFolders(client, kb.id) : new Map<string, string>()
|
|
270
|
+
mkdirSync(join(root, folder), { recursive: true })
|
|
271
|
+
const recorded: Record<string, PulledDocument> = {}
|
|
272
|
+
for (const source of sources) {
|
|
273
|
+
let name = fileNameOf(source)
|
|
274
|
+
if (!source.id || !name) continue
|
|
275
|
+
// Kept only as its indexed text (added inline, not uploaded as a file): it comes as that text.
|
|
276
|
+
const textOnly = 'storageKey' in source && (!source.storageKey || source.storageKey.includes('://'))
|
|
277
|
+
if (textOnly && !/\.(md|markdown|txt)$/i.test(name)) name = `${name}.txt`
|
|
278
|
+
if (!KNOWLEDGE_DOCUMENT.test(name)) {
|
|
279
|
+
out.skipped.push(name)
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
const rel = documentPath(source.folderId ? (folders.get(source.folderId) ?? null) : null, name)
|
|
283
|
+
const path = join(folder, rel)
|
|
284
|
+
if (existsSync(join(root, path))) {
|
|
285
|
+
out.present.push(path)
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const bytes = await client.getBytes(`/v1/workspace-knowledge/${encodeURIComponent(kb.id)}/sources/${encodeURIComponent(source.id)}/preview`)
|
|
290
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
291
|
+
writeFileSync(join(root, path), bytes)
|
|
292
|
+
out.written.push(path)
|
|
293
|
+
recorded[rel] = { sourceId: source.id, sha256: sha256Of(bytes), bytes: bytes.byteLength, at: new Date().toISOString() }
|
|
294
|
+
} catch (err) {
|
|
295
|
+
out.failed.push(`${name}: ${(err as Error).message}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// What the pull wrote, so proposing back tells a changed document from a new one.
|
|
299
|
+
const workspace = readLink(root)?.workspace
|
|
300
|
+
if (workspace && Object.keys(recorded).length) recordPulledDocuments(root, workspace, kb.name, recorded)
|
|
301
|
+
return out
|
|
302
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
6
|
+
|
|
7
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
8
|
+
import type { SyncClient } from './local-sync-client'
|
|
9
|
+
import { rows } from './local-sync-remote'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* An Agent's lessons: the reviewed rules it learned from customer feedback
|
|
13
|
+
* on the platform (feedback rules someone approved), which its prompt
|
|
14
|
+
* carries there. Brought only when asked (`frontera local sync pull
|
|
15
|
+
* --lessons`, or "Bring lessons" in Forge's Pull dialog), as they come from
|
|
16
|
+
* customers' feedback; the feedback itself is never brought.
|
|
17
|
+
*
|
|
18
|
+
* They are kept in `agents/<slug>/lessons.yaml`, and `local dev` gives them
|
|
19
|
+
* to the local Agent so its prompt reads as the platform's does. A pull
|
|
20
|
+
* rewrites the file only while it is as the last pull wrote it: one edited
|
|
21
|
+
* here is kept, and said.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const LESSONS_JSON = join('.frontera', 'sync', 'lessons.json')
|
|
25
|
+
|
|
26
|
+
export const lessonsPath = (slug: string) => `agents/${slug}/lessons.yaml`
|
|
27
|
+
|
|
28
|
+
const HEADER = '# Reviewed rules this Agent learned from customer feedback on the platform (frontera local sync pull --lessons).\n# frontera local dev gives them to the local Agent, as the platform’s prompt carries them.\n'
|
|
29
|
+
|
|
30
|
+
export interface Lesson {
|
|
31
|
+
rule: string
|
|
32
|
+
approvedAt?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One Agent's lessons as `/v1/sync/lessons` lists them. */
|
|
36
|
+
export interface LessonsRow {
|
|
37
|
+
agent: string
|
|
38
|
+
rules: Array<{ text: string; approvedAt?: string | null }>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function lessonsText(rules: LessonsRow['rules']): string {
|
|
42
|
+
const lessons: Lesson[] = rules.map((r) => ({ rule: r.text, ...(r.approvedAt ? { approvedAt: r.approvedAt } : {}) }))
|
|
43
|
+
return HEADER + stringifyYaml({ lessons }, { lineWidth: 0 })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The rules a lessons file holds, in order, once each. */
|
|
47
|
+
export function readLessons(text: string): string[] {
|
|
48
|
+
const doc = parseYaml(text) as { lessons?: Array<{ rule?: unknown } | string> } | null
|
|
49
|
+
const out: string[] = []
|
|
50
|
+
for (const l of Array.isArray(doc?.lessons) ? doc!.lessons : []) {
|
|
51
|
+
const rule = typeof l === 'string' ? l : typeof l?.rule === 'string' ? l.rule : ''
|
|
52
|
+
if (rule.trim() && !out.includes(rule.trim())) out.push(rule.trim())
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sha = (text: string) => createHash('sha256').update(text).digest('hex')
|
|
58
|
+
|
|
59
|
+
interface LessonsFile {
|
|
60
|
+
version: 1
|
|
61
|
+
workspace: string
|
|
62
|
+
/** What the last pull wrote, by Agent slug: the file's digest. */
|
|
63
|
+
files: Record<string, string>
|
|
64
|
+
/** The rules the last pull wrote, by Agent slug: what a change request compares the file with. */
|
|
65
|
+
rules?: Record<string, string[]>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readRecord(root: string, workspace: string): LessonsFile {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(readFileSync(join(root, LESSONS_JSON), 'utf8')) as LessonsFile
|
|
71
|
+
if (parsed?.version === 1 && parsed.workspace === workspace && parsed.files) return parsed
|
|
72
|
+
} catch {
|
|
73
|
+
// None yet.
|
|
74
|
+
}
|
|
75
|
+
return { version: 1, workspace, files: {} }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LessonsPulled {
|
|
79
|
+
/** Agents whose lessons file was written. */
|
|
80
|
+
written: string[]
|
|
81
|
+
/** Agents whose file was removed: the workspace has no reviewed rules for them any more. */
|
|
82
|
+
removed: string[]
|
|
83
|
+
/** Agents whose file was edited here since the last pull: kept as it is. */
|
|
84
|
+
kept: string[]
|
|
85
|
+
/** How many rules came, in all. */
|
|
86
|
+
rules: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bring the reviewed rules of these Agents (the project's own, by slug).
|
|
91
|
+
* Returns null when the platform has no lessons route (an older one).
|
|
92
|
+
*/
|
|
93
|
+
export async function pullLessons(root: string, workspace: string, ws: SyncClient, agents: string[]): Promise<LessonsPulled | null> {
|
|
94
|
+
const found = await ws.find<unknown>('/v1/sync/lessons')
|
|
95
|
+
if (found === null) return null
|
|
96
|
+
const byAgent = new Map(rows<LessonsRow>(found).map((r) => [r.agent, r.rules]))
|
|
97
|
+
const record = readRecord(root, workspace)
|
|
98
|
+
const out: LessonsPulled = { written: [], removed: [], kept: [], rules: 0 }
|
|
99
|
+
for (const slug of [...new Set(agents)].sort()) {
|
|
100
|
+
const path = join(root, lessonsPath(slug))
|
|
101
|
+
const here = existsSync(path) ? readFileSync(path, 'utf8') : null
|
|
102
|
+
const editedHere = here !== null && record.files[slug] !== sha(here)
|
|
103
|
+
const rules = byAgent.get(slug) ?? []
|
|
104
|
+
if (!rules.length) {
|
|
105
|
+
if (here !== null && !editedHere) {
|
|
106
|
+
rmSync(path, { force: true })
|
|
107
|
+
delete record.files[slug]
|
|
108
|
+
out.removed.push(slug)
|
|
109
|
+
}
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const text = lessonsText(rules)
|
|
113
|
+
out.rules += rules.length
|
|
114
|
+
if (here === text) {
|
|
115
|
+
record.files[slug] = sha(text)
|
|
116
|
+
;(record.rules ??= {})[slug] = rules.map((r) => r.text)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (editedHere) {
|
|
120
|
+
out.kept.push(slug)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
mkdirSync(join(root, 'agents', slug), { recursive: true })
|
|
124
|
+
writeFileSync(path, text)
|
|
125
|
+
record.files[slug] = sha(text)
|
|
126
|
+
;(record.rules ??= {})[slug] = rules.map((r) => r.text)
|
|
127
|
+
out.written.push(slug)
|
|
128
|
+
}
|
|
129
|
+
mkdirSync(join(root, '.frontera', 'sync'), { recursive: true })
|
|
130
|
+
writeFileAtomic(join(root, LESSONS_JSON), `${JSON.stringify(record, null, 2)}\n`, 0o644)
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Proposing them back ──────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** What a change request would carry of an Agent's lessons file, or null when it is as the last pull (or Apply) left it. */
|
|
137
|
+
export function lessonsProposal(root: string, workspace: string, slug: string): { rules: string[]; added: string[]; removed: string[]; pulled: boolean } | null {
|
|
138
|
+
const path = join(root, lessonsPath(slug))
|
|
139
|
+
if (!existsSync(path)) return null
|
|
140
|
+
const text = readFileSync(path, 'utf8')
|
|
141
|
+
const record = readRecord(root, workspace)
|
|
142
|
+
if (record.files[slug] === sha(text)) return null
|
|
143
|
+
let rules: string[]
|
|
144
|
+
try {
|
|
145
|
+
rules = readLessons(text)
|
|
146
|
+
} catch {
|
|
147
|
+
rules = []
|
|
148
|
+
}
|
|
149
|
+
const before = record.rules?.[slug] ?? []
|
|
150
|
+
return { rules, added: rules.filter((r) => !before.includes(r)), removed: before.filter((r) => !rules.includes(r)), pulled: slug in record.files }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The Agents whose lessons a change request would carry now, as `push` finds
|
|
155
|
+
* them. A fetch never lists lessons, so `status` (and Forge's card) count
|
|
156
|
+
* these beside the plan's changes.
|
|
157
|
+
*/
|
|
158
|
+
export function lessonsToPropose(root: string, workspace: string): string[] {
|
|
159
|
+
const agents = join(root, 'agents')
|
|
160
|
+
if (!existsSync(agents)) return []
|
|
161
|
+
return readdirSync(agents)
|
|
162
|
+
.filter((slug) => lessonsProposal(root, workspace, slug) !== null)
|
|
163
|
+
.sort()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* After an Apply proposed an Agent's rules: the file is taken as the last pull
|
|
168
|
+
* left it, so it is not proposed again, and the next pull brings the rules the
|
|
169
|
+
* platform's reviewers approved (a rule still waiting for review leaves it
|
|
170
|
+
* until then).
|
|
171
|
+
*/
|
|
172
|
+
export function adoptProposedLessons(root: string, workspace: string, slugs: string[]): void {
|
|
173
|
+
if (!slugs.length) return
|
|
174
|
+
const record = readRecord(root, workspace)
|
|
175
|
+
for (const slug of slugs) {
|
|
176
|
+
const path = join(root, lessonsPath(slug))
|
|
177
|
+
if (!existsSync(path)) continue
|
|
178
|
+
const text = readFileSync(path, 'utf8')
|
|
179
|
+
record.files[slug] = sha(text)
|
|
180
|
+
try {
|
|
181
|
+
;(record.rules ??= {})[slug] = readLessons(text)
|
|
182
|
+
} catch {
|
|
183
|
+
// A file that doesn't parse proposed nothing.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
mkdirSync(join(root, '.frontera', 'sync'), { recursive: true })
|
|
187
|
+
writeFileAtomic(join(root, LESSONS_JSON), `${JSON.stringify(record, null, 2)}\n`, 0o644)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
191
|
+
|
|
192
|
+
/** Why an efficient-prompts Agent's lessons don't reach its prompt here, as the build says it. */
|
|
193
|
+
export const LESSONS_NOT_EMBEDDED = 'Its lessons don’t reach its prompt here: with efficient prompts they are retrieved by similarity, and this runtime couldn’t embed them (it has no embedding model set)'
|
|
194
|
+
|
|
195
|
+
const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as unknown
|
|
196
|
+
|
|
197
|
+
const errorOf = (a: { status: number; body: unknown }) => {
|
|
198
|
+
const b = a.body as { message?: unknown } | null
|
|
199
|
+
return `${a.status} ${String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 200)}`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The names the runtime's prompt reads an Agent's rules under: its name (the chat's) and its slug (memory retrieval's). */
|
|
203
|
+
function agentKeys(root: string, slug: string): { keys: string[]; efficient: boolean } {
|
|
204
|
+
try {
|
|
205
|
+
const doc = JSON.parse(readFileSync(join(root, 'agents', slug, 'agent.json'), 'utf8')) as { config?: { name?: unknown; features?: { efficientPrompts?: unknown } } }
|
|
206
|
+
const name = typeof doc.config?.name === 'string' && doc.config.name.trim() ? doc.config.name.trim() : null
|
|
207
|
+
return { keys: [...new Set([slug, ...(name ? [name] : [])])], efficient: doc.config?.features?.efficientPrompts === true }
|
|
208
|
+
} catch {
|
|
209
|
+
return { keys: [slug], efficient: false }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Give an Agent its lessons on the runtime: each rule in the file is an
|
|
215
|
+
* active feedback rule there, and an active rule the file no longer has is
|
|
216
|
+
* expired. The runtime's chat reads rules by the Agent's name and its
|
|
217
|
+
* memory retrieval by its slug, so each is saved under both.
|
|
218
|
+
*
|
|
219
|
+
* Each rule is saved embedded (`embed`), with the runtime's embedding model,
|
|
220
|
+
* as efficient prompts retrieve rules by similarity. For an Agent with
|
|
221
|
+
* efficient prompts, a rule saved earlier without an embedding is saved
|
|
222
|
+
* again with one. When the runtime cannot embed, the build says the lessons
|
|
223
|
+
* don't reach that prompt.
|
|
224
|
+
*/
|
|
225
|
+
export async function applyLessons(root: string, slug: string, call: Call): Promise<{ ok: boolean; output: string }> {
|
|
226
|
+
const path = join(root, lessonsPath(slug))
|
|
227
|
+
if (!existsSync(path)) return { ok: false, output: `error [LESSONS]: ${lessonsPath(slug)} is missing` }
|
|
228
|
+
let wanted: string[]
|
|
229
|
+
try {
|
|
230
|
+
wanted = readLessons(readFileSync(path, 'utf8'))
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return { ok: false, output: `error [USAGE]: ${lessonsPath(slug)} does not parse: ${(err as Error).message.split('\n')[0]}` }
|
|
233
|
+
}
|
|
234
|
+
const { keys, efficient } = agentKeys(root, slug)
|
|
235
|
+
let added = 0
|
|
236
|
+
let expired = 0
|
|
237
|
+
let notEmbedded = 0
|
|
238
|
+
for (const agentName of keys) {
|
|
239
|
+
const listed = await call('GET', `/v1/feedback/rules?agentName=${encodeURIComponent(agentName)}&status=active`)
|
|
240
|
+
if (listed.status >= 400) return { ok: false, output: `error [LESSONS]: ${errorOf(listed)}` }
|
|
241
|
+
const active = (dataOf(listed.body) as Array<{ id: string; ruleText: string; workspaceId?: string | null; embedding?: unknown }> | null) ?? []
|
|
242
|
+
// The runtime's own rules for this Agent; one every workspace shares is not the project's to expire.
|
|
243
|
+
const own = active.filter((r) => r.workspaceId)
|
|
244
|
+
// Efficient prompts retrieve rules by similarity: a rule of the project's saved here without
|
|
245
|
+
// an embedding (before the runtime embedded them) is saved again, with one.
|
|
246
|
+
const unembedded = efficient ? own.filter((r) => wanted.includes(r.ruleText) && (r.embedding === null || r.embedding === undefined)) : []
|
|
247
|
+
for (const rule of wanted) {
|
|
248
|
+
const again = unembedded.find((r) => r.ruleText === rule)
|
|
249
|
+
if (active.some((r) => r.ruleText === rule) && !again) continue
|
|
250
|
+
const made = await call('POST', '/v1/feedback/rules', { agentName, ruleText: rule, confidence: '1', sourceFeedbackIds: [], status: 'active', embed: true })
|
|
251
|
+
if (made.status >= 400) return { ok: false, output: `error [LESSONS]: ${errorOf(made)}` }
|
|
252
|
+
const embedded = (dataOf(made.body) as { embedded?: unknown } | null)?.embedded === true
|
|
253
|
+
if (!embedded) notEmbedded += 1
|
|
254
|
+
if (again) {
|
|
255
|
+
// The new copy is there; the old one goes, unless the new one has no embedding either.
|
|
256
|
+
if (!embedded) {
|
|
257
|
+
const dropped = await call('PATCH', `/v1/feedback/rules/${encodeURIComponent(String(((dataOf(made.body) as { id?: unknown } | null)?.id) ?? ''))}`, { status: 'expired' })
|
|
258
|
+
if (dropped.status >= 400) return { ok: false, output: `error [LESSONS]: ${errorOf(dropped)}` }
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
const gone = await call('PATCH', `/v1/feedback/rules/${encodeURIComponent(again.id)}`, { status: 'expired' })
|
|
262
|
+
if (gone.status >= 400) return { ok: false, output: `error [LESSONS]: ${errorOf(gone)}` }
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
added += 1
|
|
266
|
+
}
|
|
267
|
+
for (const r of own.filter((x) => !wanted.includes(x.ruleText))) {
|
|
268
|
+
const gone = await call('PATCH', `/v1/feedback/rules/${encodeURIComponent(r.id)}`, { status: 'expired' })
|
|
269
|
+
if (gone.status >= 400) return { ok: false, output: `error [LESSONS]: ${errorOf(gone)}` }
|
|
270
|
+
expired += 1
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const lines = [` ${wanted.length} lesson${wanted.length === 1 ? '' : 's'} for Agent ${slug}${added || expired ? ` (${[added ? `${added} saved` : '', expired ? `${expired} expired` : ''].filter(Boolean).join(', ')})` : ''}`]
|
|
274
|
+
// With efficient prompts the platform retrieves the rules closest to each message by their
|
|
275
|
+
// embeddings; a rule the runtime could not embed doesn't reach that prompt.
|
|
276
|
+
if (efficient && notEmbedded) lines.push(` warning: ${LESSONS_NOT_EMBEDDED}`)
|
|
277
|
+
return { ok: true, output: lines.join('\n') }
|
|
278
|
+
}
|