flowviant 0.97.0 → 0.100.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 (50) hide show
  1. package/README.md +15 -13
  2. package/bin/cli.mjs +108 -28
  3. package/bin/lib/agentPlan.mjs +159 -37
  4. package/bin/lib/artifacts.mjs +136 -26
  5. package/bin/lib/authReject.mjs +32 -0
  6. package/bin/lib/authproxy.mjs +52 -16
  7. package/bin/lib/childEnv.mjs +33 -1
  8. package/bin/lib/claude.mjs +166 -71
  9. package/bin/lib/config.mjs +36 -9
  10. package/bin/lib/credentials.mjs +29 -5
  11. package/bin/lib/deploy.mjs +364 -30
  12. package/bin/lib/desktopContract.mjs +227 -0
  13. package/bin/lib/env.mjs +130 -13
  14. package/bin/lib/fleet.mjs +121 -30
  15. package/bin/lib/git.mjs +131 -3
  16. package/bin/lib/hooks/readGuard.mjs +24 -0
  17. package/bin/lib/instance.mjs +129 -14
  18. package/bin/lib/knowledge.mjs +180 -60
  19. package/bin/lib/landed.mjs +38 -30
  20. package/bin/lib/launchCommand.mjs +23 -0
  21. package/bin/lib/listeners.mjs +211 -0
  22. package/bin/lib/localSessions.mjs +29 -4
  23. package/bin/lib/login.mjs +36 -12
  24. package/bin/lib/machines.mjs +24 -14
  25. package/bin/lib/mcp-cli.mjs +4 -3
  26. package/bin/lib/preflight.mjs +2 -1
  27. package/bin/lib/preview.mjs +95 -6
  28. package/bin/lib/printable.mjs +42 -0
  29. package/bin/lib/procRegistry.mjs +65 -0
  30. package/bin/lib/processes.mjs +20 -7
  31. package/bin/lib/projectTools.mjs +248 -0
  32. package/bin/lib/prompts.mjs +16 -11
  33. package/bin/lib/resources.mjs +46 -4
  34. package/bin/lib/runtimeOfferings.mjs +75 -0
  35. package/bin/lib/runtimes.mjs +71 -9
  36. package/bin/lib/shipMerge.mjs +8 -4
  37. package/bin/lib/trace.mjs +16 -6
  38. package/bin/lib/update.mjs +114 -25
  39. package/bin/lib/work.mjs +1192 -4584
  40. package/bin/lib/workAgentMerges.mjs +504 -0
  41. package/bin/lib/workAgentPlans.mjs +447 -0
  42. package/bin/lib/workAgentReview.mjs +702 -0
  43. package/bin/lib/workAgentTurns.mjs +1151 -0
  44. package/bin/lib/workDiffs.mjs +107 -0
  45. package/bin/lib/workPreviews.mjs +351 -0
  46. package/bin/lib/workProcesses.mjs +236 -0
  47. package/bin/lib/workPullRequests.mjs +313 -0
  48. package/bin/lib/workShip.mjs +385 -0
  49. package/bin/lib/worktreeDiff.mjs +99 -43
  50. package/package.json +2 -3
@@ -38,6 +38,12 @@
38
38
  * into a flag), a newline. A read-only turn runs ONE plain command; every
39
39
  * one of these is a second command or a redirect wearing the first one's
40
40
  * permission.
41
+ * · shell syntax that REWRITES A WORD after this guard read it — braces,
42
+ * parentheses and glob characters (`{ } ( ) * ? [`). `--outp{u,u}t=x` is
43
+ * `--output=x` by the time git sees it, and the substring tests below can
44
+ * only judge the word as written. (Quoted or not: the normalisation above
45
+ * has already dropped the quotes, so a quoted `*` is refused too — the
46
+ * over-refusal the paragraph above accepts.)
41
47
  * · the git arguments above, as substrings (so `--config` covers
42
48
  * `--config-env`, `--output` covers `--output-directory`), plus a bare `-c`
43
49
  * token and any token beginning `-O`.
@@ -75,6 +81,24 @@ const SHELL = [
75
81
  ['$', 'an expansion'],
76
82
  ['\n', 'a second line'],
77
83
  ['\r', 'a second line'],
84
+ // SHELL SYNTAX THE SUBSTRING TESTS BELOW DO NOT MODEL (2026-09-24, the
85
+ // audit). The shell rewrites a word AFTER this guard has read it, so a banned
86
+ // flag can be ASSEMBLED past a substring check: `--outp{u,u}t=x` and
87
+ // `-{c,c}` brace-expand into `--output=x` and `-c`, and `git log -1
88
+ // --outp{u,u}t=pwned.txt` wrote the file with this guard returning null
89
+ // (measured, git 2.55). Globs do the same with a planted file name —
90
+ // `-?` expands to a file called `-c` — and zsh adds glob QUALIFIERS
91
+ // (`*(e:…:)`, which evaluate code). None of them is something a single
92
+ // plain read needs, so the guard refuses the syntax rather than trying to
93
+ // expand it the way some shell would: over-refusal is the safe direction,
94
+ // and the Glob tool is the tool-shaped way to match names.
95
+ ['{', 'brace expansion'],
96
+ ['}', 'brace expansion'],
97
+ ['(', 'a subshell or glob qualifier'],
98
+ [')', 'a subshell or glob qualifier'],
99
+ ['*', 'a glob'],
100
+ ['?', 'a glob'],
101
+ ['[', 'a glob'],
78
102
  ];
79
103
 
80
104
  /**
@@ -97,7 +97,7 @@ import {
97
97
  import { homedir, platform } from 'node:os';
98
98
  import { join } from 'node:path';
99
99
  import { createHash } from 'node:crypto';
100
- import { VERSION } from './config.mjs';
100
+ import { VERSION, PROCESS_STARTED_AT } from './config.mjs';
101
101
 
102
102
  /** Deliberately a HASH: a credential must never become a filename. */
103
103
  export function instanceLockPath(fleetToken) {
@@ -139,11 +139,56 @@ function readHolder(path) {
139
139
  }
140
140
  }
141
141
 
142
+ /** Read-only desktop measurement. No lock is unknown because an unguarded or
143
+ * older daemon may still run; a stale lock with a dead pid measures empty. */
144
+ export function daemonRunningFor(fleetToken) {
145
+ const path = instanceLockPath(fleetToken);
146
+ let raw;
147
+ try { raw = readFileSync(path, 'utf8'); }
148
+ catch { return null; }
149
+ let holder;
150
+ try { holder = JSON.parse(raw); } catch { return null; }
151
+ if (!Number.isInteger(holder?.pid) || holder.pid <= 0) return null;
152
+ if (!alive(holder.pid)) return false;
153
+ return stillTheHolder(holder);
154
+ }
155
+
156
+ /**
157
+ * argv[1] AS IT WAS TYPED, when that differs from the resolved form.
158
+ *
159
+ * Node resolves `process.argv[1]` to an absolute path, while `/proc/<pid>/cmdline`
160
+ * and `ps` show the argv the process was actually given. A daemon started as
161
+ * `node bin/cli.mjs` (a dev checkout, `npm start`, a Docker CMD with a WORKDIR)
162
+ * therefore recorded `/abs/bin/cli.mjs` and was matched against `node
163
+ * bin/cli.mjs`, judged stale WHILE ALIVE — a second start ran beside it and
164
+ * `flowviant stop` signalled nothing. The raw token is recorded beside `entry`
165
+ * so both spellings identify the process. Linux only: `ps` joins argv with
166
+ * spaces, so no token can be recovered from it reliably, and there the absolute
167
+ * form plus the start-time check stand as before.
168
+ */
169
+ function rawEntry() {
170
+ if (platform() !== 'linux') return null;
171
+ try {
172
+ const tokens = readFileSync('/proc/self/cmdline', 'utf8').split('\0');
173
+ const raw = tokens[1 + process.execArgv.length];
174
+ return raw && raw !== process.argv[1] ? raw : null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
142
180
  const record = (repoRoot) =>
143
181
  JSON.stringify({
144
182
  pid: process.pid,
145
183
  repoRoot,
146
- startedAt: new Date().toISOString(),
184
+ // THE PROCESS'S START, not the moment the lock was written. The start path
185
+ // can wait on a person at the picker or the binding confirm for as long as
186
+ // they take (there is no answer time limit), and a lock line written three
187
+ // minutes after the process began put a LIVE daemon outside the 120s window
188
+ // startedAroundLockWrite brackets — so a second start cleared its "stale"
189
+ // lock and ran beside it, and stop and disconnect skipped it. The window now
190
+ // measures clock granularity only, whatever a prompt cost.
191
+ startedAt: PROCESS_STARTED_AT,
147
192
  // The script we were started from, and what we are. A takeover matches the
148
193
  // live command line against `entry` before signalling anything — a lock
149
194
  // records a PID, and a crashed daemon's PID can be reused by anything.
@@ -151,9 +196,68 @@ const record = (repoRoot) =>
151
196
  // matched on the holder's process START TIME instead; stillTheHolder says
152
197
  // why that is the weaker of the two claims and still strong enough.
153
198
  entry: process.argv[1] || '',
199
+ ...(rawEntry() ? { entryRaw: rawEntry() } : {}),
154
200
  version: VERSION,
155
201
  });
156
202
 
203
+ /** Up to this many parents are walked looking for the daemon we re-exec'd from.
204
+ * `npx -y flowviant@latest` puts `npm exec` (and a `sh -c`) between the old
205
+ * daemon and the new one; eight is several times that chain. */
206
+ const ANCESTOR_DEPTH = 8;
207
+
208
+ /** The parent pid of `pid`, or null where it cannot be read. */
209
+ function parentOf(pid) {
210
+ try {
211
+ if (platform() === 'linux') {
212
+ const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
213
+ const close = raw.lastIndexOf(')');
214
+ if (close < 0) return null;
215
+ const ppid = Number.parseInt(raw.slice(close + 1).trim().split(/\s+/)[1], 10);
216
+ return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
217
+ }
218
+ if (platform() === 'darwin') {
219
+ const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
220
+ encoding: 'utf8',
221
+ stdio: ['ignore', 'pipe', 'ignore'],
222
+ timeout: 3000,
223
+ });
224
+ const ppid = Number.parseInt(out.trim(), 10);
225
+ return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
226
+ }
227
+ } catch {
228
+ /* unreadable — ancestry unknown */
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * IS THIS HOLDER THE DAEMON THAT RE-EXEC'D US? — the self-update case.
235
+ *
236
+ * The direct parent is the global-install path: update.mjs spawns
237
+ * `process.execPath` itself, so the successor's ppid IS the holder. Under npx
238
+ * — the documented launch — the successor is spawned by `npm exec`, which the
239
+ * old daemon spawned, so the holder is a GRANDPARENT and a ppid check read it
240
+ * as a rival: the new daemon SIGTERMed its own proxy, the foreground process
241
+ * exited 143 while the successor ran on detached (or, with --no-takeover, both
242
+ * exited and the machine went dark after every update). So update.mjs names
243
+ * the pid it re-exec'd from, and that pid is adopted only when it really is an
244
+ * ANCESTOR of this process: the env var alone is inherited by anything the
245
+ * daemon spawns and proves nothing on its own. Ancestry that cannot be read
246
+ * adopts nothing — the pre-existing takeover rules then apply.
247
+ */
248
+ function isOurReexecParent(pid) {
249
+ if (!Number.isInteger(pid) || pid <= 0) return false;
250
+ if (pid === process.ppid) return true;
251
+ if (process.env.FLOWVIANT_REEXEC_FROM !== String(pid)) return false;
252
+ let p = process.ppid;
253
+ for (let i = 0; i < ANCESTOR_DEPTH && p && p > 1; i++) {
254
+ const up = parentOf(p);
255
+ if (up === pid) return true;
256
+ p = up;
257
+ }
258
+ return false;
259
+ }
260
+
157
261
  /** Same directory, whatever it is spelled as — symlinks and trailing slashes
158
262
  * included. A repo compared by string would let `/repo` and `/repo/` past. */
159
263
  function samePath(a, b) {
@@ -208,7 +312,7 @@ export function daemonInSameRepo(repoRoot, ownPath) {
208
312
  if (path === ownPath) continue; // our own credential — the lock above owns that question
209
313
  const holder = readHolder(path);
210
314
  if (!holder || !alive(holder.pid)) continue;
211
- if (holder.pid === process.ppid) continue; // ourselves mid self-update re-exec
315
+ if (isOurReexecParent(holder.pid)) continue; // ourselves mid self-update re-exec
212
316
  if (samePath(holder.repoRoot, repoRoot)) {
213
317
  NEIGHBOUR_PATHS.set(holder, path);
214
318
  return holder;
@@ -226,7 +330,10 @@ export function daemonInSameRepo(repoRoot, ownPath) {
226
330
  * around 41s. 120s is ~3x that worst path and ~4000x the typical one, and it is
227
331
  * still short enough that pid reuse cannot reach into it: reuse means cycling
228
332
  * the entire pid space (4194304 by default), which no machine does inside two
229
- * minutes. */
333
+ * minutes. (Since the lock records the PROCESS start rather than the moment it
334
+ * was written, a lock from this version sits a few milliseconds from its
335
+ * process whatever the start path cost; the window's width is for locks older
336
+ * daemons wrote, which still stamp the write.) */
230
337
  const TAKEOVER_START_WINDOW_MS = 120_000;
231
338
 
232
339
  /** ...and how far the OTHER way, which is a unit problem rather than a real
@@ -421,15 +528,20 @@ function stillTheHolder(holder) {
421
528
  // alive, so it takes the same road as a missing one.
422
529
  if (!want) return startedAroundLockWrite(holder);
423
530
  let cmdline;
531
+ let tokens;
424
532
  try {
425
- cmdline =
426
- platform() === 'linux'
427
- ? readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ')
428
- : execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
429
- encoding: 'utf8',
430
- stdio: ['ignore', 'pipe', 'ignore'],
431
- timeout: 3000,
432
- });
533
+ if (platform() === 'linux') {
534
+ const raw = readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8');
535
+ tokens = raw.split('\0');
536
+ cmdline = raw.replace(/\0/g, ' ');
537
+ } else {
538
+ cmdline = execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
539
+ encoding: 'utf8',
540
+ stdio: ['ignore', 'pipe', 'ignore'],
541
+ timeout: 3000,
542
+ });
543
+ tokens = cmdline.trim().split(/\s+/);
544
+ }
433
545
  } catch {
434
546
  // NOT `false`. takeOverFrom already returned early if the pid were gone, so
435
547
  // reaching here means the process is alive and we could not READ it —
@@ -437,7 +549,10 @@ function stillTheHolder(holder) {
437
549
  // here is what made the refusal claim the pid belonged to somebody else.
438
550
  return null;
439
551
  }
440
- if (!cmdline.includes(want)) return false;
552
+ // The typed spelling is matched as a whole TOKEN, never a substring: a
553
+ // relative `bin/cli.mjs` is a fragment of countless command lines.
554
+ const raw = typeof holder.entryRaw === 'string' && holder.entryRaw ? holder.entryRaw : null;
555
+ if (!cmdline.includes(want) && !(raw && tokens.includes(raw))) return false;
441
556
  // AN ENTRY MATCH ALONE IS NOT IDENTITY. Every daemon on the box shares one
442
557
  // entry path under a global install, so "cmdline contains this cli.mjs"
443
558
  // proves "is SOME flowviant daemon", not "is the daemon that wrote THIS
@@ -711,7 +826,7 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
711
826
  // ownership-checked, so it will not delete the lock it handed over.
712
827
  // (`flowviant login` also proxies a child, but that parent never reached
713
828
  // the daemon and holds nothing — the child simply acquires.)
714
- if (holder.pid === process.ppid) {
829
+ if (isOurReexecParent(holder.pid)) {
715
830
  try {
716
831
  writeFileSync(path, record(repoRoot));
717
832
  } catch {
@@ -63,7 +63,8 @@
63
63
  * `research/<slug>-v<N>.md`, with a generated `LIBRARY.md` at the root naming
64
64
  * every one (path, title, kind, the card that asked for it, the date, what it
65
65
  * supersedes). So "implement design A" is an agent opening LIBRARY.md, finding
66
- * design A, and reading the page.
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.
67
68
  *
68
69
  * SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
69
70
  * forbids every separator; a library item is the one thing allowed ONE, and
@@ -132,7 +133,7 @@ export const FLOWVIANT_OWN_PATHS = [
132
133
  export const LIBRARY_FILE = 'LIBRARY.md';
133
134
  /** The two subdirectories a kept item may live in, by kind. */
134
135
  export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
135
- const LIBRARY_EXT = { design: '.html', research: '.md' };
136
+ const LIBRARY_EXT = { design: ['.html', '.htm', '.gltf', '.obj', '.glb', '.bin'], research: ['.md'] };
136
137
  /** The server caps a library at 200 items; a longer manifest is cut. */
137
138
  const MAX_LIBRARY_ITEMS = 256;
138
139
 
@@ -148,23 +149,55 @@ const MARKER = '.flowviant/knowledge.rev';
148
149
  * this is somebody's disk, and one place checking is one deploy away from
149
150
  * zero places — the attachment fetch's own rule. */
150
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;
151
155
  /** The manifest is capped at the server (25 files); a manifest longer than
152
156
  * this is not one the product produced, and is cut rather than trusted. */
153
157
  const MAX_FILES = 64;
154
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
+
155
174
  /**
156
- * `safeUploadName`, verbatim in effect (work.mjs): keeps the extension, drops
157
- * every path separator, never starts with a dot or a dash. Re-applied here
158
- * 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.
159
186
  */
160
187
  export function safeKnowledgeName(raw) {
161
- const base = String(raw ?? '')
188
+ const clean = String(raw ?? '')
162
189
  .split(/[\\/]/)
163
190
  .pop()
164
191
  .replace(/[^A-Za-z0-9._-]/g, '_')
165
- .replace(/^[.-]+/, '')
166
- .slice(0, 80);
167
- 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}` : ''}`;
168
201
  }
169
202
 
170
203
  /**
@@ -326,12 +359,30 @@ function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
326
359
  export function safeLibraryPath(name, kind) {
327
360
  if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
328
361
  const parts = name.split('/');
329
- if (parts.length !== 2) return null;
330
- const [dir, file] = parts;
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);
331
365
  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}`;
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;
335
386
  }
336
387
 
337
388
  /** The manifest's library items that are safe to write, in manifest order, or
@@ -347,7 +398,14 @@ function libraryItemsOf(manifest) {
347
398
  const path = safeLibraryPath(it.name, it.kind);
348
399
  if (!path || seen.has(path.toLowerCase())) continue;
349
400
  seen.add(path.toLowerCase());
350
- out.push({ ...it, path });
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 });
351
409
  }
352
410
  return out;
353
411
  }
@@ -408,6 +466,32 @@ function ensureSubdir(dir, name) {
408
466
  return path;
409
467
  }
410
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
+
411
495
  /** Does this manifest look like one the server produced? Anything else is
412
496
  * ignored whole rather than half-applied. */
413
497
  export function isKnowledgeManifest(k) {
@@ -486,7 +570,8 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
486
570
 
487
571
  for (const f of planned) {
488
572
  const path = join(dir, f.local);
489
- if (Number(f.bytes) > maxBytes) {
573
+ const fileMax = knowledgeMaxFor(f.name, maxBytes);
574
+ if (Number(f.bytes) > fileMax) {
490
575
  // Permanent for this rev, and NOT a reason to delete the rest. Any stale
491
576
  // local copy under this name goes, because it is not the file the
492
577
  // manifest now names.
@@ -497,7 +582,7 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
497
582
  if (typeof f.sha256 === 'string' && localSha(path) === f.sha256.toLowerCase()) continue;
498
583
  try {
499
584
  const buf = await fetchFile(f.id);
500
- if (!Buffer.isBuffer(buf) || buf.byteLength > maxBytes) {
585
+ if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) {
501
586
  result.refused.push(f.local);
502
587
  keep.delete(f.local);
503
588
  continue;
@@ -574,58 +659,84 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
574
659
  }
575
660
  }
576
661
  for (const it of items) {
577
- const [sub, file] = it.path.split('/');
578
- const path = join(dir, sub, file);
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);
579
667
  wanted[sub].add(file);
580
- if (Number(it.bytes) > maxBytes) {
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) {
581
674
  result.refused.push(it.path);
582
675
  wanted[sub].delete(file);
583
676
  continue;
584
677
  }
585
678
  if (typeof it.sha256 === 'string' && localSha(path) === it.sha256.toLowerCase()) {
586
679
  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;
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);
595
699
  }
596
- if (typeof it.sha256 === 'string' && sha256Of(buf) !== it.sha256.toLowerCase()) {
597
- throw new Error('sha256 mismatch');
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
+ }
598
715
  }
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);
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); }
608
733
  }
609
734
  }
610
735
  // Each subdirectory holds exactly what the manifest names.
611
736
  for (const sub of Object.values(LIBRARY_DIRS)) {
612
737
  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
- }
738
+ try { if (!lstatSync(subPath).isDirectory()) continue; } catch { continue; }
739
+ sweepLibraryTree(dir, sub, wanted[sub], result);
629
740
  // An emptied subdirectory leaves with its last item.
630
741
  if (wanted[sub].size === 0) keep.delete(sub);
631
742
  }
@@ -697,18 +808,26 @@ function libraryStale(checkoutDir, manifest, { synced, refused }) {
697
808
  const items = libraryItemsOf(manifest);
698
809
  if (items === null) return false;
699
810
  if (!synced) return true;
700
- const expected = items.filter((it) => !(Number(it.bytes) > KNOWLEDGE_FILE_MAX_BYTES) && !refused.has(it.path));
811
+ const expected = items.filter((it) => !(Number(it.bytes) > knowledgeMaxFor(it.path, KNOWLEDGE_FILE_MAX_BYTES)) && !refused.has(it.path));
701
812
  if (expected.length === 0) return false;
702
813
  const dir = join(checkoutDir, KNOWLEDGE_DIR);
703
814
  const isFile = (p) => {
704
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
+ }
705
822
  return lstatSync(p).isFile();
706
823
  } catch {
707
824
  return false;
708
825
  }
709
826
  };
710
827
  if (!isFile(join(dir, LIBRARY_FILE))) return true;
711
- return expected.some((it) => !isFile(join(dir, ...it.path.split('/'))));
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('/')))));
712
831
  }
713
832
 
714
833
  /**
@@ -827,13 +946,14 @@ export function createKnowledgeSync({
827
946
  * URL is derived from the roster URL the way every `/fleet/*` path is, so a
828
947
  * self-hosted `FLOWVIANT_FLEET_URL` is honoured.
829
948
  */
830
- export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
949
+ export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_BINARY_MODEL_MAX_BYTES }) {
831
950
  const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
832
951
  // A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
833
952
  // the same credential, the same shape, the same caps.
834
953
  const libraryBase = String(fleetUrl).replace(/\/agents\/?$/, '/library');
835
- return async (id, { library = false } = {}) => {
836
- const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}`, {
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}`, {
837
957
  headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
838
958
  signal: AbortSignal.timeout(60_000),
839
959
  });