flowviant 0.96.0 → 0.99.0

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 (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +174 -33
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +377 -45
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +146 -12
  12. package/bin/lib/fleet.mjs +125 -30
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +162 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +460 -23
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +354 -32
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +152 -11
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1261 -4476
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -54,6 +54,31 @@
54
54
  * ten seconds. A file refused on SIZE is not a failure to retry: it is
55
55
  * permanent for that rev, skipped with a warning, and the rest of the library
56
56
  * is written.
57
+ *
58
+ * ── THE KEPT LIBRARY (2026-09-23, 0.97.0) ──
59
+ *
60
+ * The manifest may carry `library: { items: [...] }` — the designs and
61
+ * write-ups the project KEPT when a person accepted them — and each item lands
62
+ * at its own relative path, `designs/<slug>-v<N>.html` or
63
+ * `research/<slug>-v<N>.md`, with a generated `LIBRARY.md` at the root naming
64
+ * every one (path, title, kind, the card that asked for it, the date, what it
65
+ * supersedes). So "implement design A" is an agent opening LIBRARY.md, finding
66
+ * design A, and reading the page. At 0.99.0 a kept design is one folder,
67
+ * including its original relative sidecar paths and measured preview.
68
+ *
69
+ * SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
70
+ * forbids every separator; a library item is the one thing allowed ONE, and
71
+ * only after one of the two literal prefixes, with a stem that is already
72
+ * safe and the extension its kind hands back. `..`, a third segment, another
73
+ * prefix, a leading dot — refused, never rewritten, because a rewritten path
74
+ * could collide with another item's real one. The two directories are OURS the
75
+ * way the library directory is: a symlink or a file planted there is removed
76
+ * (the link, never its target) and a real directory made.
77
+ *
78
+ * ABSENT MEANS "LEAVE IT ALONE", the same rule the whole key keeps: a manifest
79
+ * with no `library` key is an older server, and `designs/`, `research/` and
80
+ * `LIBRARY.md` are neither synced nor swept. `library: { items: [] }` is the
81
+ * emptied catalog, SAID — both directories and the catalog go.
57
82
  */
58
83
 
59
84
  import {
@@ -102,6 +127,16 @@ export const FLOWVIANT_OWN_PATHS = [
102
127
  '.flowviant/artifacts/',
103
128
  '.flowviant/uploads/',
104
129
  ];
130
+ /** The generated catalog of the kept library, at the knowledge root. Reserved
131
+ * like INSTRUCTIONS.md: a person's file of that name is suffixed, because the
132
+ * prompt tells the CLI this one is the catalog. */
133
+ export const LIBRARY_FILE = 'LIBRARY.md';
134
+ /** The two subdirectories a kept item may live in, by kind. */
135
+ export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
136
+ const LIBRARY_EXT = { design: ['.html', '.htm', '.gltf', '.obj', '.glb', '.bin'], research: ['.md'] };
137
+ /** The server caps a library at 200 items; a longer manifest is cut. */
138
+ const MAX_LIBRARY_ITEMS = 256;
139
+
105
140
  /** The person's standing brief, written from the manifest's `instructions`.
106
141
  * A RESERVED name: a library FILE called this gets a suffix, because the
107
142
  * prompt tells the CLI to read this one first and a stranger's file must
@@ -114,23 +149,55 @@ const MARKER = '.flowviant/knowledge.rev';
114
149
  * this is somebody's disk, and one place checking is one deploy away from
115
150
  * zero places — the attachment fetch's own rule. */
116
151
  export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
152
+ export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
153
+ const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
154
+ ? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
117
155
  /** The manifest is capped at the server (25 files); a manifest longer than
118
156
  * this is not one the product produced, and is cut rather than trusted. */
119
157
  const MAX_FILES = 64;
120
158
 
159
+ /** FNV-1a, 32-bit, as 8 hex — deterministic, so the same long name always maps
160
+ * to the same stored name. Verbatim against the server's `fnv1a8`
161
+ * (apps/api/src/routes/sessionsAttachments.routes.ts). */
162
+ function fnv1a8(s) {
163
+ let h = 0x811c9dc5;
164
+ for (let i = 0; i < s.length; i++) {
165
+ h ^= s.charCodeAt(i);
166
+ h = Math.imul(h, 0x01000193);
167
+ }
168
+ return (h >>> 0).toString(16).padStart(8, '0');
169
+ }
170
+ /** What counts as an extension worth keeping through a cut. */
171
+ const SAFE_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
172
+ const SAFE_NAME_MAX = 80;
173
+
121
174
  /**
122
- * `safeUploadName`, verbatim in effect (work.mjs): keeps the extension, drops
123
- * every path separator, never starts with a dot or a dash. Re-applied here
124
- * although the server did it, because this string becomes a path on this box.
175
+ * `safeFileName`, verbatim (apps/api/src/routes/sessionsAttachments.routes.ts):
176
+ * drops every path separator, never starts with a dot or a dash, and — since
177
+ * 2026-09-24 — THE EXTENSION SURVIVES A CUT. A bare `slice(0, 80)` truncated
178
+ * an over-long name mid-extension (an 84-character `….html` mockup stored as
179
+ * `….` with an unrecognised type and its bytes discarded), so a long name's
180
+ * stem is now cut and an 8-hex FNV-1a hash of the WHOLE sanitised name is
181
+ * appended before the extension: stable across calls, at most 80 characters,
182
+ * idempotent over its own output, and — the reason it is re-applied here
183
+ * rather than trusted to the server's own pass — byte-identical to what
184
+ * `safeFileName` computes there, so a knowledge file synced under this name
185
+ * and one requested by name later never disagree over the cut.
125
186
  */
126
187
  export function safeKnowledgeName(raw) {
127
- const base = String(raw ?? '')
188
+ const clean = String(raw ?? '')
128
189
  .split(/[\\/]/)
129
190
  .pop()
130
191
  .replace(/[^A-Za-z0-9._-]/g, '_')
131
- .replace(/^[.-]+/, '')
132
- .slice(0, 80);
133
- return base || 'file';
192
+ .replace(/^[.-]+/, '');
193
+ if (!clean) return 'file';
194
+ if (clean.length <= SAFE_NAME_MAX) return clean;
195
+ const dot = clean.lastIndexOf('.');
196
+ const ext = dot > 0 && SAFE_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
197
+ const stem = ext ? clean.slice(0, dot) : clean;
198
+ const tag = `-${fnv1a8(clean)}`;
199
+ const room = SAFE_NAME_MAX - tag.length - (ext ? ext.length + 1 : 0);
200
+ return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
134
201
  }
135
202
 
136
203
  /**
@@ -143,7 +210,14 @@ export function safeKnowledgeName(raw) {
143
210
  * Windows disks are).
144
211
  */
145
212
  export function planKnowledgeNames(files) {
146
- const taken = new Set([INSTRUCTIONS_FILE.toLowerCase()]);
213
+ // The catalog's name and the two library directories are reserved beside
214
+ // the brief (0.97.0): a person's `LIBRARY.md` or a file called `designs`
215
+ // must never stand where the catalog or a directory has to.
216
+ const taken = new Set([
217
+ INSTRUCTIONS_FILE.toLowerCase(),
218
+ LIBRARY_FILE.toLowerCase(),
219
+ ...Object.values(LIBRARY_DIRS),
220
+ ]);
147
221
  const out = [];
148
222
  for (const f of files) {
149
223
  const name = safeKnowledgeName(f.name);
@@ -232,25 +306,192 @@ function ensureDir(checkoutDir) {
232
306
  return dir;
233
307
  }
234
308
 
235
- export function readKnowledgeMarker(checkoutDir) {
309
+ /**
310
+ * THE MARKER SAYS WHICH REV WAS WRITTEN, AND WHETHER THE LIBRARY WAS PART OF
311
+ * IT (2026-09-23): `<rev>` or `<rev>:lib`.
312
+ *
313
+ * The rev alone could not tell an upgrade from a sync. A 0.96.0 box wrote the
314
+ * marker for rev N while ignoring the manifest's `library` key (it did not
315
+ * know one); updated to 0.97.0 with `knowledge_rev` still N, it read "N",
316
+ * matched the roster, and never synced the library — then the server handed
317
+ * `task.references` naming `designs/x-v1.html` and the agent was told to read
318
+ * a file that was not there. `:lib` records that THIS rev's sync carried a
319
+ * library key, so the upgrade case is one string compare and re-syncs at
320
+ * once. An older daemon reading `N:lib` sees NaN and re-syncs once, which is
321
+ * the harmless direction.
322
+ */
323
+ function parseMarker(checkoutDir) {
236
324
  try {
237
- const n = Number(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
238
- return Number.isInteger(n) && n >= 0 ? n : null;
325
+ const m = /^(\d+)(:lib)?$/.exec(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
326
+ if (!m) return null;
327
+ const n = Number(m[1]);
328
+ return Number.isSafeInteger(n) ? { rev: n, lib: Boolean(m[2]) } : null;
239
329
  } catch {
240
330
  return null;
241
331
  }
242
332
  }
243
333
 
244
- function writeKnowledgeMarker(checkoutDir, rev) {
334
+ export function readKnowledgeMarker(checkoutDir) {
335
+ return parseMarker(checkoutDir)?.rev ?? null;
336
+ }
337
+
338
+ /** Did the sync that wrote the marker carry a `library` key? */
339
+ export function knowledgeMarkerHasLibrary(checkoutDir) {
340
+ return parseMarker(checkoutDir)?.lib ?? false;
341
+ }
342
+
343
+ function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
245
344
  try {
246
345
  mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
247
- writeAtomic(join(checkoutDir, MARKER), `${rev}\n`);
346
+ writeAtomic(join(checkoutDir, MARKER), `${rev}${lib ? ':lib' : ''}\n`);
248
347
  } catch {
249
348
  /* a lost marker costs one re-sync after a restart — every file already on
250
349
  disk matches by sha and is not downloaded again */
251
350
  }
252
351
  }
253
352
 
353
+ /**
354
+ * A kept item's relative path, or null when it is not EXACTLY
355
+ * `<designs|research>/<safe stem>.<its kind's extension>` — see the header.
356
+ * Refused rather than rewritten: a rewritten path is one the server never
357
+ * named, and it could land on another item's real one.
358
+ */
359
+ export function safeLibraryPath(name, kind) {
360
+ if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
361
+ const parts = name.split('/');
362
+ if (parts.length !== 2 && !(kind === 'design' && parts.length >= 3 && parts.length <= 7)) return null;
363
+ const [dir, ...rest] = parts;
364
+ const file = rest.at(-1);
365
+ if (dir !== LIBRARY_DIRS[kind]) return null;
366
+ if (rest.length >= 2 && !/^[a-z0-9-]+-v[1-9][0-9]*$/.test(rest[0])) return null;
367
+ if (rest.slice(1, -1).some((p) => !safeBundleSegment(p))) return null;
368
+ if (!file || (rest.length === 1 ? file !== safeKnowledgeName(file) : !safeBundleSegment(file))) return null;
369
+ if (!LIBRARY_EXT[kind].some((ext) => file.toLowerCase().endsWith(ext) && file.length > ext.length)) return null;
370
+ return name;
371
+ }
372
+
373
+ function safeBundleSegment(part) {
374
+ return typeof part === 'string' && part.length > 0 && part.length <= 80 &&
375
+ /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(part) && !part.endsWith('.');
376
+ }
377
+
378
+ function safeBundleFile(path, rootDir) {
379
+ if (typeof path !== 'string' || !path.startsWith(`${rootDir}/`)) return null;
380
+ const relative = path.slice(rootDir.length + 1);
381
+ if (relative === 'preview.png') return null;
382
+ const parts = relative.split('/');
383
+ if (!parts.length || parts.some((p) => !safeBundleSegment(p))) return null;
384
+ if (!/\.(gltf|obj|glb|bin|png|jpg|jpeg|gif|webp|svg|json)$/i.test(relative)) return null;
385
+ return path;
386
+ }
387
+
388
+ /** The manifest's library items that are safe to write, in manifest order, or
389
+ * null when the key is ABSENT (an older server — leave everything alone). */
390
+ function libraryItemsOf(manifest) {
391
+ const lib = manifest?.library;
392
+ if (lib === undefined || lib === null) return null;
393
+ if (typeof lib !== 'object' || !Array.isArray(lib.items)) return null;
394
+ const out = [];
395
+ const seen = new Set();
396
+ for (const it of lib.items.slice(0, MAX_LIBRARY_ITEMS)) {
397
+ if (!it || typeof it.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(it.id)) continue;
398
+ const path = safeLibraryPath(it.name, it.kind);
399
+ if (!path || seen.has(path.toLowerCase())) continue;
400
+ seen.add(path.toLowerCase());
401
+ const bundleDir = path.split('/').length >= 3 ? path.split('/').slice(0, 2).join('/') : null;
402
+ const expectedPreview = bundleDir ? `${bundleDir}/preview.png` : it.name.replace(/\.html$/, '.png');
403
+ const previewPath = it.kind === 'design' && it.preview && it.preview.name === expectedPreview ? expectedPreview : null;
404
+ const files = bundleDir && Array.isArray(it.files) ? it.files.map((f, index) => ({ ...f,
405
+ index, path: safeBundleFile(f.name, bundleDir),
406
+ })).filter((f) => f.path && !seen.has(f.path.toLowerCase())) : [];
407
+ for (const f of files) seen.add(f.path.toLowerCase());
408
+ out.push({ ...it, path, files, previewPath, previewUnknown: it.kind === 'design' && path.endsWith('.html') && it.preview === undefined });
409
+ }
410
+ return out;
411
+ }
412
+
413
+ /** One line of the catalog: server strings are made single-line and capped —
414
+ * they are a person's card title and an artifact's name, headed into a file
415
+ * an agent reads, so they may not carry a line break that forges a second
416
+ * entry. */
417
+ const catalogText = (v, max) =>
418
+ String(v ?? '')
419
+ .replace(/[\r\n\t]+/g, ' ')
420
+ .replace(/\s+/g, ' ')
421
+ .trim()
422
+ .slice(0, max);
423
+
424
+ /**
425
+ * THE CATALOG, generated whole from the manifest on every sync:
426
+ *
427
+ * - designs/landing-v2.html — Landing mockup (design, from card "Redesign
428
+ * landing page", 2026-09-23, supersedes designs/landing-v1.html)
429
+ *
430
+ * (one line per item, oldest first — the server's order — so a sync that adds
431
+ * one item adds one line and moves nothing). Null when there are no items, in
432
+ * which case the file is removed rather than written empty.
433
+ */
434
+ export function renderLibraryCatalog(items) {
435
+ if (!items || items.length === 0) return null;
436
+ const lines = items.map((it) => {
437
+ const facts = [it.kind === 'research' ? 'research' : 'design'];
438
+ const card = catalogText(it.taskTitle, 200);
439
+ if (card) facts.push(`from card "${card.replace(/"/g, "'")}"`);
440
+ const day = typeof it.createdAt === 'string' ? it.createdAt.slice(0, 10) : '';
441
+ if (/^\d{4}-\d{2}-\d{2}$/.test(day)) facts.push(day);
442
+ const sup = typeof it.supersedes === 'string' ? catalogText(it.supersedes, 200) : '';
443
+ if (sup) facts.push(`supersedes ${sup}`);
444
+ const title = catalogText(it.title, 200) || it.path;
445
+ return `- ${it.path} — ${title} (${facts.join(', ')})`;
446
+ });
447
+ return (
448
+ '# Library\n\n' +
449
+ 'The designs and research this project kept. Paths are relative to this directory;\n' +
450
+ 'a newer version supersedes an older one of the same name.\n\n' +
451
+ `${lines.join('\n')}\n`
452
+ );
453
+ }
454
+
455
+ /** A library subdirectory as a real directory — ours, like the library's own
456
+ * path: a symlink or a file planted there is removed, never followed. */
457
+ function ensureSubdir(dir, name) {
458
+ const path = join(dir, name);
459
+ try {
460
+ const st = lstatSync(path);
461
+ if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(path);
462
+ } catch {
463
+ /* absent */
464
+ }
465
+ mkdirSync(path, { recursive: true });
466
+ return path;
467
+ }
468
+
469
+ function ensureLibraryParents(dir, relativePath) {
470
+ const parts = relativePath.split('/');
471
+ let at = dir;
472
+ for (const part of parts.slice(0, -1)) at = ensureSubdir(at, part);
473
+ }
474
+
475
+ function sweepLibraryTree(dir, sub, wanted, result) {
476
+ const walk = (at, prefix = '') => {
477
+ let entries;
478
+ try { entries = readdirSync(at); } catch { return; }
479
+ for (const name of entries) {
480
+ const rel = prefix ? `${prefix}/${name}` : name;
481
+ const path = join(at, name);
482
+ let st;
483
+ try { st = lstatSync(path); } catch { continue; }
484
+ if (st.isDirectory() && !st.isSymbolicLink()) {
485
+ walk(path, rel);
486
+ try { if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); } catch { /* next sync */ }
487
+ } else if (!wanted.has(rel)) {
488
+ try { rmSync(path, { recursive: true, force: true }); result.removed.push(`${sub}/${rel}`); } catch { /* next sync */ }
489
+ }
490
+ }
491
+ };
492
+ walk(join(dir, sub));
493
+ };
494
+
254
495
  /** Does this manifest look like one the server produced? Anything else is
255
496
  * ignored whole rather than half-applied. */
256
497
  export function isKnowledgeManifest(k) {
@@ -285,6 +526,8 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
285
526
  typeof manifest.instructions === 'string' && manifest.instructions.trim()
286
527
  ? manifest.instructions
287
528
  : null;
529
+ // null = the key is ABSENT (leave the library's paths alone); [] = emptied.
530
+ const library = libraryItemsOf(manifest);
288
531
 
289
532
  if (!flowviantDirOk(checkoutDir)) {
290
533
  // Not a failure to retry every poll — nothing changes until a person
@@ -295,8 +538,13 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
295
538
  return result;
296
539
  }
297
540
 
298
- // EMPTIED: the directory goes, and the prompt paragraph with it.
299
- if (files.length === 0 && !instructions) {
541
+ // EMPTIED: the directory goes, and the prompt paragraph with it — unless the
542
+ // library key is ABSENT and a synced library is on disk, which an older
543
+ // server's emptied shelf has no say over.
544
+ const libraryOnDisk =
545
+ library === null &&
546
+ [LIBRARY_FILE, ...Object.values(LIBRARY_DIRS)].some((n) => existsSync(join(dirPath, n)));
547
+ if (files.length === 0 && !instructions && (library === null ? !libraryOnDisk : library.length === 0)) {
300
548
  if (existsSync(dirPath)) {
301
549
  try {
302
550
  const st = lstatSync(dirPath);
@@ -314,10 +562,16 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
314
562
  const planned = planKnowledgeNames(files);
315
563
  const keep = new Set(planned.map((p) => p.local));
316
564
  if (instructions) keep.add(INSTRUCTIONS_FILE);
565
+ // ABSENT library key: its paths are neither synced nor swept.
566
+ if (library === null) {
567
+ keep.add(LIBRARY_FILE);
568
+ for (const d of Object.values(LIBRARY_DIRS)) keep.add(d);
569
+ }
317
570
 
318
571
  for (const f of planned) {
319
572
  const path = join(dir, f.local);
320
- if (Number(f.bytes) > maxBytes) {
573
+ const fileMax = knowledgeMaxFor(f.name, maxBytes);
574
+ if (Number(f.bytes) > fileMax) {
321
575
  // Permanent for this rev, and NOT a reason to delete the rest. Any stale
322
576
  // local copy under this name goes, because it is not the file the
323
577
  // manifest now names.
@@ -328,7 +582,7 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
328
582
  if (typeof f.sha256 === 'string' && localSha(path) === f.sha256.toLowerCase()) continue;
329
583
  try {
330
584
  const buf = await fetchFile(f.id);
331
- if (!Buffer.isBuffer(buf) || buf.byteLength > maxBytes) {
585
+ if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) {
332
586
  result.refused.push(f.local);
333
587
  keep.delete(f.local);
334
588
  continue;
@@ -363,6 +617,10 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
363
617
  }
364
618
  }
365
619
 
620
+ if (library !== null) {
621
+ await syncLibrary({ dir, items: library, fetchFile, maxBytes, keep, result });
622
+ }
623
+
366
624
  // Everything the manifest no longer names — files, stray temp names, and
367
625
  // anything that is not a regular file — leaves.
368
626
  let entries = [];
@@ -383,6 +641,122 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
383
641
  return result;
384
642
  }
385
643
 
644
+ /**
645
+ * THE KEPT LIBRARY'S HALF OF A SYNC: each item into its subdirectory (fetched
646
+ * only when its sha differs, written atomically, the bytes checked against the
647
+ * manifest), every other entry in the two subdirectories removed, and
648
+ * `LIBRARY.md` regenerated from the items that are on disk. A download that
649
+ * fails keeps whatever copy was there and clears `ok`, the file rule; an item
650
+ * over the cap is refused for this rev and left out of the catalog.
651
+ */
652
+ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
653
+ const wanted = { designs: new Set(), research: new Set() };
654
+ const onDisk = [];
655
+ for (const kind of Object.keys(LIBRARY_DIRS)) {
656
+ if (items.some((it) => it.kind === kind)) {
657
+ ensureSubdir(dir, LIBRARY_DIRS[kind]);
658
+ keep.add(LIBRARY_DIRS[kind]);
659
+ }
660
+ }
661
+ for (const it of items) {
662
+ const [sub, ...tail] = it.path.split('/');
663
+ const file = tail.join('/');
664
+ const path = join(dir, ...it.path.split('/'));
665
+ ensureLibraryParents(dir, it.path);
666
+ const itemMax = knowledgeMaxFor(it.path, maxBytes);
667
+ wanted[sub].add(file);
668
+ if (it.previewPath) wanted[sub].add(it.previewPath.split('/').slice(1).join('/'));
669
+ if (it.previewUnknown) {
670
+ const oldPng = file.includes('/') ? `${file.slice(0, file.lastIndexOf('/'))}/preview.png` : file.replace(/\.html$/, '.png');
671
+ if (localSha(join(dir, sub, ...oldPng.split('/'))) !== null) wanted[sub].add(oldPng);
672
+ }
673
+ if (Number(it.bytes) > itemMax) {
674
+ result.refused.push(it.path);
675
+ wanted[sub].delete(file);
676
+ continue;
677
+ }
678
+ if (typeof it.sha256 === 'string' && localSha(path) === it.sha256.toLowerCase()) {
679
+ onDisk.push(it);
680
+ } else {
681
+ try {
682
+ const buf = await fetchFile(it.id, { library: true });
683
+ if (!Buffer.isBuffer(buf) || buf.byteLength > itemMax) {
684
+ result.refused.push(it.path);
685
+ wanted[sub].delete(file);
686
+ continue;
687
+ }
688
+ if (typeof it.sha256 === 'string' && sha256Of(buf) !== it.sha256.toLowerCase()) {
689
+ throw new Error('sha256 mismatch');
690
+ }
691
+ ensureLibraryParents(dir, it.path);
692
+ writeAtomic(path, buf);
693
+ result.wrote.push(it.path);
694
+ onDisk.push(it);
695
+ } catch {
696
+ result.ok = false;
697
+ result.failed.push(it.path);
698
+ if (localSha(path) !== null) onDisk.push(it);
699
+ }
700
+ }
701
+ if (it.previewPath && it.preview?.sha256) {
702
+ ensureLibraryParents(dir, it.previewPath);
703
+ const previewFile = join(dir, ...it.previewPath.split('/'));
704
+ if (localSha(previewFile) !== it.preview.sha256.toLowerCase()) {
705
+ try {
706
+ const buf = await fetchFile(it.id, { library: true, preview: true });
707
+ if (!Buffer.isBuffer(buf) || buf.byteLength > 2 * 1024 * 1024 || sha256Of(buf) !== it.preview.sha256.toLowerCase()) throw new Error('preview mismatch');
708
+ ensureLibraryParents(dir, it.previewPath);
709
+ writeAtomic(previewFile, buf);
710
+ result.wrote.push(it.previewPath);
711
+ } catch {
712
+ result.ok = false;
713
+ result.failed.push(it.previewPath);
714
+ }
715
+ }
716
+ }
717
+ for (const f of it.files ?? []) {
718
+ ensureLibraryParents(dir, f.path);
719
+ const rel = f.path.split('/').slice(1).join('/');
720
+ wanted[sub].add(rel);
721
+ const filePath = join(dir, ...f.path.split('/'));
722
+ const fileMax = knowledgeMaxFor(f.path, maxBytes);
723
+ if (Number(f.bytes) > fileMax) { result.refused.push(f.path); wanted[sub].delete(rel); continue; }
724
+ if (localSha(filePath) === String(f.sha256).toLowerCase()) continue;
725
+ try {
726
+ const buf = await fetchFile(it.id, { library: true, fileIndex: f.index });
727
+ if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) { result.refused.push(f.path); wanted[sub].delete(rel); continue; }
728
+ if (sha256Of(buf) !== String(f.sha256).toLowerCase()) throw new Error('sha256 mismatch');
729
+ ensureLibraryParents(dir, f.path);
730
+ writeAtomic(filePath, buf);
731
+ result.wrote.push(f.path);
732
+ } catch { result.ok = false; result.failed.push(f.path); }
733
+ }
734
+ }
735
+ // Each subdirectory holds exactly what the manifest names.
736
+ for (const sub of Object.values(LIBRARY_DIRS)) {
737
+ const subPath = join(dir, sub);
738
+ try { if (!lstatSync(subPath).isDirectory()) continue; } catch { continue; }
739
+ sweepLibraryTree(dir, sub, wanted[sub], result);
740
+ // An emptied subdirectory leaves with its last item.
741
+ if (wanted[sub].size === 0) keep.delete(sub);
742
+ }
743
+ const catalog = renderLibraryCatalog(onDisk);
744
+ if (catalog) {
745
+ keep.add(LIBRARY_FILE);
746
+ const path = join(dir, LIBRARY_FILE);
747
+ let same = false;
748
+ try {
749
+ same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === catalog;
750
+ } catch {
751
+ /* absent */
752
+ }
753
+ if (!same) {
754
+ writeAtomic(path, catalog);
755
+ result.wrote.push(LIBRARY_FILE);
756
+ }
757
+ }
758
+ }
759
+
386
760
  /**
387
761
  * The absolute library path, or null when there is nothing there for a turn
388
762
  * to read. The prompt paragraph is rendered only when this is non-null, so a
@@ -404,7 +778,8 @@ export function knowledgeDirFor(checkoutDir) {
404
778
  function libraryMissing(checkoutDir, manifest) {
405
779
  const named =
406
780
  (Array.isArray(manifest.files) && manifest.files.length > 0) ||
407
- (typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '');
781
+ (typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '') ||
782
+ (Array.isArray(manifest.library?.items) && manifest.library.items.length > 0);
408
783
  if (!named) return false;
409
784
  try {
410
785
  lstatSync(join(checkoutDir, KNOWLEDGE_DIR));
@@ -414,6 +789,47 @@ function libraryMissing(checkoutDir, manifest) {
414
789
  }
415
790
  }
416
791
 
792
+ /**
793
+ * Does the manifest name LIBRARY items the disk no longer holds — or a library
794
+ * this box never synced at all? The same-rev skip's second question, beside
795
+ * `libraryMissing`'s "is the directory there".
796
+ *
797
+ * `synced` is the marker's `:lib` (or the driver's memory of it): false means
798
+ * the rev on the marker was written by a sync that ignored the library, the
799
+ * 0.96.0-to-0.97.0 upgrade. Otherwise every named item's path AND `LIBRARY.md`
800
+ * must be regular files — a hand-deleted `designs/`, or one item removed, is a
801
+ * re-sync, and the files still on disk match by hash and are not fetched
802
+ * again. Items the manifest itself puts over the cap, and items this driver
803
+ * already saw refused for this rev, are not expected: a refusal is permanent
804
+ * for the rev, and expecting it would re-fetch ten megabytes every poll.
805
+ * An ABSENT `library` key expects nothing (an older server has no say).
806
+ */
807
+ function libraryStale(checkoutDir, manifest, { synced, refused }) {
808
+ const items = libraryItemsOf(manifest);
809
+ if (items === null) return false;
810
+ if (!synced) return true;
811
+ const expected = items.filter((it) => !(Number(it.bytes) > knowledgeMaxFor(it.path, KNOWLEDGE_FILE_MAX_BYTES)) && !refused.has(it.path));
812
+ if (expected.length === 0) return false;
813
+ const dir = join(checkoutDir, KNOWLEDGE_DIR);
814
+ const isFile = (p) => {
815
+ try {
816
+ let parent = dir;
817
+ for (const part of p.slice(dir.length + 1).split('/').slice(0, -1)) {
818
+ parent = join(parent, part);
819
+ const st = lstatSync(parent);
820
+ if (st.isSymbolicLink() || !st.isDirectory()) return false;
821
+ }
822
+ return lstatSync(p).isFile();
823
+ } catch {
824
+ return false;
825
+ }
826
+ };
827
+ if (!isFile(join(dir, LIBRARY_FILE))) return true;
828
+ return expected.some((it) => !isFile(join(dir, ...it.path.split('/'))) ||
829
+ (it.previewPath && !refused.has(it.previewPath) && !isFile(join(dir, ...it.previewPath.split('/')))) ||
830
+ (it.files ?? []).some((f) => !refused.has(f.path) && !isFile(join(dir, ...f.path.split('/')))));
831
+ }
832
+
417
833
  /**
418
834
  * The per-process driver: called with every roster's `knowledge` key.
419
835
  *
@@ -430,6 +846,11 @@ export function createKnowledgeSync({
430
846
  now = () => Date.now(),
431
847
  }) {
432
848
  let rev = readKnowledgeMarker(checkoutDir);
849
+ /** Whether the sync that wrote `rev` carried a library key — the marker's
850
+ * `:lib`, then this process's own memory. */
851
+ let libSynced = knowledgeMarkerHasLibrary(checkoutDir);
852
+ /** Library paths refused for size at `rev` — not expected on disk. */
853
+ let libRefused = new Set();
433
854
  let busy = false;
434
855
  let failures = 0;
435
856
  let retryAt = 0;
@@ -459,7 +880,17 @@ export function createKnowledgeSync({
459
880
  * something over a directory that does not exist is re-synced; files
460
881
  * already on disk match by hash and are not fetched again.
461
882
  */
462
- if (knowledge.rev === rev && !libraryMissing(checkoutDir, knowledge)) return null;
883
+ //
884
+ // …AND THE SAME REV IS NOT ALWAYS THE SAME LIBRARY: an upgrade from a
885
+ // daemon that ignored the `library` key, or a hand-deleted `designs/`,
886
+ // leaves the marker current over files a card's references name. See
887
+ // `libraryStale`.
888
+ if (
889
+ knowledge.rev === rev &&
890
+ !libraryMissing(checkoutDir, knowledge) &&
891
+ !libraryStale(checkoutDir, knowledge, { synced: libSynced, refused: libRefused })
892
+ )
893
+ return null;
463
894
  if (busy) return null;
464
895
  if (knowledge.rev === failedRev && now() < retryAt) return null;
465
896
  busy = true;
@@ -472,10 +903,12 @@ export function createKnowledgeSync({
472
903
  const r = await syncKnowledge({ checkoutDir, manifest: knowledge, fetchFile });
473
904
  if (r.ok) {
474
905
  rev = knowledge.rev;
906
+ libSynced = libraryItemsOf(knowledge) !== null;
907
+ libRefused = new Set(r.refused);
475
908
  failures = 0;
476
909
  retryAt = 0;
477
910
  failedRev = null;
478
- writeKnowledgeMarker(checkoutDir, rev);
911
+ writeKnowledgeMarker(checkoutDir, rev, libSynced);
479
912
  if (r.wrote.length || r.removed.length) {
480
913
  log(
481
914
  `knowledge · synced rev ${rev}` +
@@ -513,10 +946,14 @@ export function createKnowledgeSync({
513
946
  * URL is derived from the roster URL the way every `/fleet/*` path is, so a
514
947
  * self-hosted `FLOWVIANT_FLEET_URL` is honoured.
515
948
  */
516
- export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
949
+ export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_BINARY_MODEL_MAX_BYTES }) {
517
950
  const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
518
- return async (id) => {
519
- const res = await fetch(`${base}/${encodeURIComponent(id)}`, {
951
+ // A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
952
+ // the same credential, the same shape, the same caps.
953
+ const libraryBase = String(fleetUrl).replace(/\/agents\/?$/, '/library');
954
+ return async (id, { library = false, preview = false, fileIndex = null } = {}) => {
955
+ const suffix = preview ? '/preview' : Number.isSafeInteger(fileIndex) && fileIndex >= 0 ? `/file/${fileIndex}` : '';
956
+ const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}${suffix}`, {
520
957
  headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
521
958
  signal: AbortSignal.timeout(60_000),
522
959
  });