flowviant 0.96.0 → 0.97.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.
@@ -54,6 +54,30 @@
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.
67
+ *
68
+ * SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
69
+ * forbids every separator; a library item is the one thing allowed ONE, and
70
+ * only after one of the two literal prefixes, with a stem that is already
71
+ * safe and the extension its kind hands back. `..`, a third segment, another
72
+ * prefix, a leading dot — refused, never rewritten, because a rewritten path
73
+ * could collide with another item's real one. The two directories are OURS the
74
+ * way the library directory is: a symlink or a file planted there is removed
75
+ * (the link, never its target) and a real directory made.
76
+ *
77
+ * ABSENT MEANS "LEAVE IT ALONE", the same rule the whole key keeps: a manifest
78
+ * with no `library` key is an older server, and `designs/`, `research/` and
79
+ * `LIBRARY.md` are neither synced nor swept. `library: { items: [] }` is the
80
+ * emptied catalog, SAID — both directories and the catalog go.
57
81
  */
58
82
 
59
83
  import {
@@ -102,6 +126,16 @@ export const FLOWVIANT_OWN_PATHS = [
102
126
  '.flowviant/artifacts/',
103
127
  '.flowviant/uploads/',
104
128
  ];
129
+ /** The generated catalog of the kept library, at the knowledge root. Reserved
130
+ * like INSTRUCTIONS.md: a person's file of that name is suffixed, because the
131
+ * prompt tells the CLI this one is the catalog. */
132
+ export const LIBRARY_FILE = 'LIBRARY.md';
133
+ /** The two subdirectories a kept item may live in, by kind. */
134
+ export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
135
+ const LIBRARY_EXT = { design: '.html', research: '.md' };
136
+ /** The server caps a library at 200 items; a longer manifest is cut. */
137
+ const MAX_LIBRARY_ITEMS = 256;
138
+
105
139
  /** The person's standing brief, written from the manifest's `instructions`.
106
140
  * A RESERVED name: a library FILE called this gets a suffix, because the
107
141
  * prompt tells the CLI to read this one first and a stranger's file must
@@ -143,7 +177,14 @@ export function safeKnowledgeName(raw) {
143
177
  * Windows disks are).
144
178
  */
145
179
  export function planKnowledgeNames(files) {
146
- const taken = new Set([INSTRUCTIONS_FILE.toLowerCase()]);
180
+ // The catalog's name and the two library directories are reserved beside
181
+ // the brief (0.97.0): a person's `LIBRARY.md` or a file called `designs`
182
+ // must never stand where the catalog or a directory has to.
183
+ const taken = new Set([
184
+ INSTRUCTIONS_FILE.toLowerCase(),
185
+ LIBRARY_FILE.toLowerCase(),
186
+ ...Object.values(LIBRARY_DIRS),
187
+ ]);
147
188
  const out = [];
148
189
  for (const f of files) {
149
190
  const name = safeKnowledgeName(f.name);
@@ -232,25 +273,141 @@ function ensureDir(checkoutDir) {
232
273
  return dir;
233
274
  }
234
275
 
235
- export function readKnowledgeMarker(checkoutDir) {
276
+ /**
277
+ * THE MARKER SAYS WHICH REV WAS WRITTEN, AND WHETHER THE LIBRARY WAS PART OF
278
+ * IT (2026-09-23): `<rev>` or `<rev>:lib`.
279
+ *
280
+ * The rev alone could not tell an upgrade from a sync. A 0.96.0 box wrote the
281
+ * marker for rev N while ignoring the manifest's `library` key (it did not
282
+ * know one); updated to 0.97.0 with `knowledge_rev` still N, it read "N",
283
+ * matched the roster, and never synced the library — then the server handed
284
+ * `task.references` naming `designs/x-v1.html` and the agent was told to read
285
+ * a file that was not there. `:lib` records that THIS rev's sync carried a
286
+ * library key, so the upgrade case is one string compare and re-syncs at
287
+ * once. An older daemon reading `N:lib` sees NaN and re-syncs once, which is
288
+ * the harmless direction.
289
+ */
290
+ function parseMarker(checkoutDir) {
236
291
  try {
237
- const n = Number(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
238
- return Number.isInteger(n) && n >= 0 ? n : null;
292
+ const m = /^(\d+)(:lib)?$/.exec(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
293
+ if (!m) return null;
294
+ const n = Number(m[1]);
295
+ return Number.isSafeInteger(n) ? { rev: n, lib: Boolean(m[2]) } : null;
239
296
  } catch {
240
297
  return null;
241
298
  }
242
299
  }
243
300
 
244
- function writeKnowledgeMarker(checkoutDir, rev) {
301
+ export function readKnowledgeMarker(checkoutDir) {
302
+ return parseMarker(checkoutDir)?.rev ?? null;
303
+ }
304
+
305
+ /** Did the sync that wrote the marker carry a `library` key? */
306
+ export function knowledgeMarkerHasLibrary(checkoutDir) {
307
+ return parseMarker(checkoutDir)?.lib ?? false;
308
+ }
309
+
310
+ function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
245
311
  try {
246
312
  mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
247
- writeAtomic(join(checkoutDir, MARKER), `${rev}\n`);
313
+ writeAtomic(join(checkoutDir, MARKER), `${rev}${lib ? ':lib' : ''}\n`);
248
314
  } catch {
249
315
  /* a lost marker costs one re-sync after a restart — every file already on
250
316
  disk matches by sha and is not downloaded again */
251
317
  }
252
318
  }
253
319
 
320
+ /**
321
+ * A kept item's relative path, or null when it is not EXACTLY
322
+ * `<designs|research>/<safe stem>.<its kind's extension>` — see the header.
323
+ * Refused rather than rewritten: a rewritten path is one the server never
324
+ * named, and it could land on another item's real one.
325
+ */
326
+ export function safeLibraryPath(name, kind) {
327
+ if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
328
+ const parts = name.split('/');
329
+ if (parts.length !== 2) return null;
330
+ const [dir, file] = parts;
331
+ if (dir !== LIBRARY_DIRS[kind]) return null;
332
+ if (!file || file !== safeKnowledgeName(file)) return null;
333
+ if (!file.toLowerCase().endsWith(LIBRARY_EXT[kind]) || file.length <= LIBRARY_EXT[kind].length) return null;
334
+ return `${dir}/${file}`;
335
+ }
336
+
337
+ /** The manifest's library items that are safe to write, in manifest order, or
338
+ * null when the key is ABSENT (an older server — leave everything alone). */
339
+ function libraryItemsOf(manifest) {
340
+ const lib = manifest?.library;
341
+ if (lib === undefined || lib === null) return null;
342
+ if (typeof lib !== 'object' || !Array.isArray(lib.items)) return null;
343
+ const out = [];
344
+ const seen = new Set();
345
+ for (const it of lib.items.slice(0, MAX_LIBRARY_ITEMS)) {
346
+ if (!it || typeof it.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(it.id)) continue;
347
+ const path = safeLibraryPath(it.name, it.kind);
348
+ if (!path || seen.has(path.toLowerCase())) continue;
349
+ seen.add(path.toLowerCase());
350
+ out.push({ ...it, path });
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /** One line of the catalog: server strings are made single-line and capped —
356
+ * they are a person's card title and an artifact's name, headed into a file
357
+ * an agent reads, so they may not carry a line break that forges a second
358
+ * entry. */
359
+ const catalogText = (v, max) =>
360
+ String(v ?? '')
361
+ .replace(/[\r\n\t]+/g, ' ')
362
+ .replace(/\s+/g, ' ')
363
+ .trim()
364
+ .slice(0, max);
365
+
366
+ /**
367
+ * THE CATALOG, generated whole from the manifest on every sync:
368
+ *
369
+ * - designs/landing-v2.html — Landing mockup (design, from card "Redesign
370
+ * landing page", 2026-09-23, supersedes designs/landing-v1.html)
371
+ *
372
+ * (one line per item, oldest first — the server's order — so a sync that adds
373
+ * one item adds one line and moves nothing). Null when there are no items, in
374
+ * which case the file is removed rather than written empty.
375
+ */
376
+ export function renderLibraryCatalog(items) {
377
+ if (!items || items.length === 0) return null;
378
+ const lines = items.map((it) => {
379
+ const facts = [it.kind === 'research' ? 'research' : 'design'];
380
+ const card = catalogText(it.taskTitle, 200);
381
+ if (card) facts.push(`from card "${card.replace(/"/g, "'")}"`);
382
+ const day = typeof it.createdAt === 'string' ? it.createdAt.slice(0, 10) : '';
383
+ if (/^\d{4}-\d{2}-\d{2}$/.test(day)) facts.push(day);
384
+ const sup = typeof it.supersedes === 'string' ? catalogText(it.supersedes, 200) : '';
385
+ if (sup) facts.push(`supersedes ${sup}`);
386
+ const title = catalogText(it.title, 200) || it.path;
387
+ return `- ${it.path} — ${title} (${facts.join(', ')})`;
388
+ });
389
+ return (
390
+ '# Library\n\n' +
391
+ 'The designs and research this project kept. Paths are relative to this directory;\n' +
392
+ 'a newer version supersedes an older one of the same name.\n\n' +
393
+ `${lines.join('\n')}\n`
394
+ );
395
+ }
396
+
397
+ /** A library subdirectory as a real directory — ours, like the library's own
398
+ * path: a symlink or a file planted there is removed, never followed. */
399
+ function ensureSubdir(dir, name) {
400
+ const path = join(dir, name);
401
+ try {
402
+ const st = lstatSync(path);
403
+ if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(path);
404
+ } catch {
405
+ /* absent */
406
+ }
407
+ mkdirSync(path, { recursive: true });
408
+ return path;
409
+ }
410
+
254
411
  /** Does this manifest look like one the server produced? Anything else is
255
412
  * ignored whole rather than half-applied. */
256
413
  export function isKnowledgeManifest(k) {
@@ -285,6 +442,8 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
285
442
  typeof manifest.instructions === 'string' && manifest.instructions.trim()
286
443
  ? manifest.instructions
287
444
  : null;
445
+ // null = the key is ABSENT (leave the library's paths alone); [] = emptied.
446
+ const library = libraryItemsOf(manifest);
288
447
 
289
448
  if (!flowviantDirOk(checkoutDir)) {
290
449
  // Not a failure to retry every poll — nothing changes until a person
@@ -295,8 +454,13 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
295
454
  return result;
296
455
  }
297
456
 
298
- // EMPTIED: the directory goes, and the prompt paragraph with it.
299
- if (files.length === 0 && !instructions) {
457
+ // EMPTIED: the directory goes, and the prompt paragraph with it — unless the
458
+ // library key is ABSENT and a synced library is on disk, which an older
459
+ // server's emptied shelf has no say over.
460
+ const libraryOnDisk =
461
+ library === null &&
462
+ [LIBRARY_FILE, ...Object.values(LIBRARY_DIRS)].some((n) => existsSync(join(dirPath, n)));
463
+ if (files.length === 0 && !instructions && (library === null ? !libraryOnDisk : library.length === 0)) {
300
464
  if (existsSync(dirPath)) {
301
465
  try {
302
466
  const st = lstatSync(dirPath);
@@ -314,6 +478,11 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
314
478
  const planned = planKnowledgeNames(files);
315
479
  const keep = new Set(planned.map((p) => p.local));
316
480
  if (instructions) keep.add(INSTRUCTIONS_FILE);
481
+ // ABSENT library key: its paths are neither synced nor swept.
482
+ if (library === null) {
483
+ keep.add(LIBRARY_FILE);
484
+ for (const d of Object.values(LIBRARY_DIRS)) keep.add(d);
485
+ }
317
486
 
318
487
  for (const f of planned) {
319
488
  const path = join(dir, f.local);
@@ -363,6 +532,10 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
363
532
  }
364
533
  }
365
534
 
535
+ if (library !== null) {
536
+ await syncLibrary({ dir, items: library, fetchFile, maxBytes, keep, result });
537
+ }
538
+
366
539
  // Everything the manifest no longer names — files, stray temp names, and
367
540
  // anything that is not a regular file — leaves.
368
541
  let entries = [];
@@ -383,6 +556,96 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
383
556
  return result;
384
557
  }
385
558
 
559
+ /**
560
+ * THE KEPT LIBRARY'S HALF OF A SYNC: each item into its subdirectory (fetched
561
+ * only when its sha differs, written atomically, the bytes checked against the
562
+ * manifest), every other entry in the two subdirectories removed, and
563
+ * `LIBRARY.md` regenerated from the items that are on disk. A download that
564
+ * fails keeps whatever copy was there and clears `ok`, the file rule; an item
565
+ * over the cap is refused for this rev and left out of the catalog.
566
+ */
567
+ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
568
+ const wanted = { designs: new Set(), research: new Set() };
569
+ const onDisk = [];
570
+ for (const kind of Object.keys(LIBRARY_DIRS)) {
571
+ if (items.some((it) => it.kind === kind)) {
572
+ ensureSubdir(dir, LIBRARY_DIRS[kind]);
573
+ keep.add(LIBRARY_DIRS[kind]);
574
+ }
575
+ }
576
+ for (const it of items) {
577
+ const [sub, file] = it.path.split('/');
578
+ const path = join(dir, sub, file);
579
+ wanted[sub].add(file);
580
+ if (Number(it.bytes) > maxBytes) {
581
+ result.refused.push(it.path);
582
+ wanted[sub].delete(file);
583
+ continue;
584
+ }
585
+ if (typeof it.sha256 === 'string' && localSha(path) === it.sha256.toLowerCase()) {
586
+ onDisk.push(it);
587
+ continue;
588
+ }
589
+ try {
590
+ const buf = await fetchFile(it.id, { library: true });
591
+ if (!Buffer.isBuffer(buf) || buf.byteLength > maxBytes) {
592
+ result.refused.push(it.path);
593
+ wanted[sub].delete(file);
594
+ continue;
595
+ }
596
+ if (typeof it.sha256 === 'string' && sha256Of(buf) !== it.sha256.toLowerCase()) {
597
+ throw new Error('sha256 mismatch');
598
+ }
599
+ writeAtomic(path, buf);
600
+ result.wrote.push(it.path);
601
+ onDisk.push(it);
602
+ } catch {
603
+ result.ok = false;
604
+ result.failed.push(it.path);
605
+ // A stale copy is better than none until the retry lands — and it stays
606
+ // in the catalog only if it is actually there.
607
+ if (localSha(path) !== null) onDisk.push(it);
608
+ }
609
+ }
610
+ // Each subdirectory holds exactly what the manifest names.
611
+ for (const sub of Object.values(LIBRARY_DIRS)) {
612
+ const subPath = join(dir, sub);
613
+ let entries = [];
614
+ try {
615
+ if (!lstatSync(subPath).isDirectory()) continue;
616
+ entries = readdirSync(subPath);
617
+ } catch {
618
+ continue;
619
+ }
620
+ for (const name of entries) {
621
+ if (wanted[sub].has(name)) continue;
622
+ try {
623
+ rmSync(join(subPath, name), { recursive: true, force: true });
624
+ result.removed.push(`${sub}/${name}`);
625
+ } catch {
626
+ /* best-effort */
627
+ }
628
+ }
629
+ // An emptied subdirectory leaves with its last item.
630
+ if (wanted[sub].size === 0) keep.delete(sub);
631
+ }
632
+ const catalog = renderLibraryCatalog(onDisk);
633
+ if (catalog) {
634
+ keep.add(LIBRARY_FILE);
635
+ const path = join(dir, LIBRARY_FILE);
636
+ let same = false;
637
+ try {
638
+ same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === catalog;
639
+ } catch {
640
+ /* absent */
641
+ }
642
+ if (!same) {
643
+ writeAtomic(path, catalog);
644
+ result.wrote.push(LIBRARY_FILE);
645
+ }
646
+ }
647
+ }
648
+
386
649
  /**
387
650
  * The absolute library path, or null when there is nothing there for a turn
388
651
  * to read. The prompt paragraph is rendered only when this is non-null, so a
@@ -404,7 +667,8 @@ export function knowledgeDirFor(checkoutDir) {
404
667
  function libraryMissing(checkoutDir, manifest) {
405
668
  const named =
406
669
  (Array.isArray(manifest.files) && manifest.files.length > 0) ||
407
- (typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '');
670
+ (typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '') ||
671
+ (Array.isArray(manifest.library?.items) && manifest.library.items.length > 0);
408
672
  if (!named) return false;
409
673
  try {
410
674
  lstatSync(join(checkoutDir, KNOWLEDGE_DIR));
@@ -414,6 +678,39 @@ function libraryMissing(checkoutDir, manifest) {
414
678
  }
415
679
  }
416
680
 
681
+ /**
682
+ * Does the manifest name LIBRARY items the disk no longer holds — or a library
683
+ * this box never synced at all? The same-rev skip's second question, beside
684
+ * `libraryMissing`'s "is the directory there".
685
+ *
686
+ * `synced` is the marker's `:lib` (or the driver's memory of it): false means
687
+ * the rev on the marker was written by a sync that ignored the library, the
688
+ * 0.96.0-to-0.97.0 upgrade. Otherwise every named item's path AND `LIBRARY.md`
689
+ * must be regular files — a hand-deleted `designs/`, or one item removed, is a
690
+ * re-sync, and the files still on disk match by hash and are not fetched
691
+ * again. Items the manifest itself puts over the cap, and items this driver
692
+ * already saw refused for this rev, are not expected: a refusal is permanent
693
+ * for the rev, and expecting it would re-fetch ten megabytes every poll.
694
+ * An ABSENT `library` key expects nothing (an older server has no say).
695
+ */
696
+ function libraryStale(checkoutDir, manifest, { synced, refused }) {
697
+ const items = libraryItemsOf(manifest);
698
+ if (items === null) return false;
699
+ if (!synced) return true;
700
+ const expected = items.filter((it) => !(Number(it.bytes) > KNOWLEDGE_FILE_MAX_BYTES) && !refused.has(it.path));
701
+ if (expected.length === 0) return false;
702
+ const dir = join(checkoutDir, KNOWLEDGE_DIR);
703
+ const isFile = (p) => {
704
+ try {
705
+ return lstatSync(p).isFile();
706
+ } catch {
707
+ return false;
708
+ }
709
+ };
710
+ if (!isFile(join(dir, LIBRARY_FILE))) return true;
711
+ return expected.some((it) => !isFile(join(dir, ...it.path.split('/'))));
712
+ }
713
+
417
714
  /**
418
715
  * The per-process driver: called with every roster's `knowledge` key.
419
716
  *
@@ -430,6 +727,11 @@ export function createKnowledgeSync({
430
727
  now = () => Date.now(),
431
728
  }) {
432
729
  let rev = readKnowledgeMarker(checkoutDir);
730
+ /** Whether the sync that wrote `rev` carried a library key — the marker's
731
+ * `:lib`, then this process's own memory. */
732
+ let libSynced = knowledgeMarkerHasLibrary(checkoutDir);
733
+ /** Library paths refused for size at `rev` — not expected on disk. */
734
+ let libRefused = new Set();
433
735
  let busy = false;
434
736
  let failures = 0;
435
737
  let retryAt = 0;
@@ -459,7 +761,17 @@ export function createKnowledgeSync({
459
761
  * something over a directory that does not exist is re-synced; files
460
762
  * already on disk match by hash and are not fetched again.
461
763
  */
462
- if (knowledge.rev === rev && !libraryMissing(checkoutDir, knowledge)) return null;
764
+ //
765
+ // …AND THE SAME REV IS NOT ALWAYS THE SAME LIBRARY: an upgrade from a
766
+ // daemon that ignored the `library` key, or a hand-deleted `designs/`,
767
+ // leaves the marker current over files a card's references name. See
768
+ // `libraryStale`.
769
+ if (
770
+ knowledge.rev === rev &&
771
+ !libraryMissing(checkoutDir, knowledge) &&
772
+ !libraryStale(checkoutDir, knowledge, { synced: libSynced, refused: libRefused })
773
+ )
774
+ return null;
463
775
  if (busy) return null;
464
776
  if (knowledge.rev === failedRev && now() < retryAt) return null;
465
777
  busy = true;
@@ -472,10 +784,12 @@ export function createKnowledgeSync({
472
784
  const r = await syncKnowledge({ checkoutDir, manifest: knowledge, fetchFile });
473
785
  if (r.ok) {
474
786
  rev = knowledge.rev;
787
+ libSynced = libraryItemsOf(knowledge) !== null;
788
+ libRefused = new Set(r.refused);
475
789
  failures = 0;
476
790
  retryAt = 0;
477
791
  failedRev = null;
478
- writeKnowledgeMarker(checkoutDir, rev);
792
+ writeKnowledgeMarker(checkoutDir, rev, libSynced);
479
793
  if (r.wrote.length || r.removed.length) {
480
794
  log(
481
795
  `knowledge · synced rev ${rev}` +
@@ -515,8 +829,11 @@ export function createKnowledgeSync({
515
829
  */
516
830
  export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
517
831
  const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
518
- return async (id) => {
519
- const res = await fetch(`${base}/${encodeURIComponent(id)}`, {
832
+ // A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
833
+ // the same credential, the same shape, the same caps.
834
+ const libraryBase = String(fleetUrl).replace(/\/agents\/?$/, '/library');
835
+ return async (id, { library = false } = {}) => {
836
+ const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}`, {
520
837
  headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
521
838
  signal: AbortSignal.timeout(60_000),
522
839
  });