visualproof 0.2.0 → 0.4.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 (96) hide show
  1. package/README.md +88 -33
  2. package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
  3. package/dist/assets/tips/phone-fingers.light.webp +0 -0
  4. package/dist/assets/tips/tour-agent.webp +0 -0
  5. package/dist/assets/tips/tour-arrange.webp +0 -0
  6. package/dist/assets/tips/tour-artwork.webp +0 -0
  7. package/dist/assets/tips/tour-checks.webp +0 -0
  8. package/dist/assets/tips/tour-export.webp +0 -0
  9. package/dist/assets/tips/tour-keys.webp +0 -0
  10. package/dist/assets/tips/tour-library.webp +0 -0
  11. package/dist/assets/tips/tour-project.webp +0 -0
  12. package/dist/assets/tips/tour-review.webp +0 -0
  13. package/dist/assets/tips/tour-share.webp +0 -0
  14. package/dist/assets/tips/tour-source.webp +0 -0
  15. package/dist/assets/tips/tour-target.webp +0 -0
  16. package/dist/assets/tips/tour-view.webp +0 -0
  17. package/dist/assets/tips/tour.json +289 -0
  18. package/dist/assets/tips/tour.webp +0 -0
  19. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  20. package/dist/assets/webfonts/metrics.json +1 -0
  21. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  22. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  23. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  24. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  25. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  26. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  27. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  28. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  29. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  30. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  31. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  32. package/dist/mcp/visualproof-mcp.mjs +3 -1
  33. package/dist/server/agent.js +94 -91
  34. package/dist/server/agentdocs.js +1 -1
  35. package/dist/server/artcmd.js +335 -0
  36. package/dist/server/children.js +85 -0
  37. package/dist/server/doctor.js +105 -42
  38. package/dist/server/events.js +10 -0
  39. package/dist/server/export.js +11 -4
  40. package/dist/server/extract.js +3 -2
  41. package/dist/server/figures.js +3 -2
  42. package/dist/server/film.js +380 -0
  43. package/dist/server/history.js +11 -1
  44. package/dist/server/hover.js +8 -5
  45. package/dist/server/live.js +72 -9
  46. package/dist/server/main.js +352 -23
  47. package/dist/server/mcptools.js +4 -1
  48. package/dist/server/media.js +114 -0
  49. package/dist/server/pipeline.js +21 -10
  50. package/dist/server/poppler.js +3 -2
  51. package/dist/server/readonlygate.js +4 -4
  52. package/dist/server/review.js +4 -1
  53. package/dist/server/reviewapi.js +35 -1
  54. package/dist/server/snapshot.js +10 -1
  55. package/dist/server/sourcestore.js +3 -2
  56. package/dist/server/status.js +3 -2
  57. package/dist/server/supervise.js +219 -0
  58. package/dist/server/updates.js +951 -0
  59. package/dist/server/usage.js +3 -1
  60. package/dist/server/version.js +183 -0
  61. package/dist/server/view.js +0 -7
  62. package/dist/server/views.js +24 -0
  63. package/dist/shared/artfonts.js +81 -0
  64. package/dist/shared/artrecipe.js +316 -0
  65. package/dist/shared/collab.js +7 -2
  66. package/dist/shared/declfind.js +95 -0
  67. package/dist/shared/lostfound.js +1 -0
  68. package/dist/shared/pastgraph.js +6 -6
  69. package/dist/shared/review.js +62 -5
  70. package/dist/shared/timeline.js +5 -0
  71. package/dist/web/bundle/app.js +3006 -413
  72. package/dist/web/bundle/edges.js +1 -1
  73. package/dist/web/index.html +13 -1
  74. package/dist/web/src/agentmark.js +14 -0
  75. package/dist/web/src/artwork.js +1000 -0
  76. package/dist/web/src/bundles.js +151 -0
  77. package/dist/web/src/canvas.js +3716 -0
  78. package/dist/web/src/drag.js +61 -0
  79. package/dist/web/src/edgequeue.js +204 -0
  80. package/dist/web/src/gl/gl.js +123 -0
  81. package/dist/web/src/gl/renderer.js +451 -0
  82. package/dist/web/src/halo.js +95 -0
  83. package/dist/web/src/highlight.js +108 -0
  84. package/dist/web/src/overview.js +393 -0
  85. package/dist/web/src/reviewqueue.js +250 -0
  86. package/dist/web/src/selection.js +272 -0
  87. package/dist/web/src/status.js +80 -0
  88. package/dist/web/src/visitors.js +318 -0
  89. package/dist/web/src/visits.js +174 -0
  90. package/dist/web/style.css +196 -1
  91. package/docs/agent-api/README.md +1 -1
  92. package/docs/agent-api/export.md +41 -2
  93. package/docs/agent-api/history.md +5 -3
  94. package/docs/agent-api/mcp.md +1 -1
  95. package/docs/agent-api/reading.md +2 -1
  96. package/package.json +5 -1
@@ -29,11 +29,15 @@
29
29
  import { historyEndpoints, historyWorld } from "./historyapi.js";
30
30
  import { PastBuilds } from "./pastdoc.js";
31
31
  import { spawnSync } from "node:child_process";
32
+ import { packageVersion } from "./version.js";
33
+ import { withoutReasons } from "../shared/timeline.js";
34
+ import { childEnv } from "./children.js";
32
35
  import { readFileSync } from "node:fs";
33
36
  import { join } from "node:path";
34
37
  import { gunzipSync } from "node:zlib";
35
38
  import { ACTIVITY_KINDS, ACTIVITY_WHO, cleanView, MAX_PINNED, SCHEMES, validName } from "../shared/collab.js";
36
39
  import { addressOf, readRegion, showing, writeSnapshot } from "../shared/sharesnap.js";
40
+ import { encodeRecipe, readRecipe, RECIPE_PARAM, resolveRecipe, SAVE_PARAM } from "../shared/artrecipe.js";
37
41
  import { contextText, exactContext, readContext } from "../shared/context.js";
38
42
  import { compiledExcerpt, excerptOf } from "../shared/excerpt.js";
39
43
  import { tokenizeLean } from "../shared/lexer.js";
@@ -56,6 +60,7 @@ import { inTargets, nonStandardAxiom, offBoard, reviewQueue, TCB_SITES, whyChain
56
60
  import { reviewThen } from "../shared/pastgraph.js";
57
61
  import { contextAt, namespacesOf } from "../shared/scope.js";
58
62
  import { PLACES, search, SearchIndex, snippet } from "../shared/search.js";
63
+ import { foldedAt, guessNames } from "../shared/declfind.js";
59
64
  import { fullWhen } from "../shared/titles.js";
60
65
  import { ALL_SITES, Graph } from "../shared/viewgraph.js";
61
66
  import { layoutKey, wholeLayout } from "../shared/board.js";
@@ -172,9 +177,6 @@ const PARTS = ["source", "context", "uses", "users", "describe", "print"];
172
177
  const WHERE = { statement: SITE_STMT, body: SITE_BODY, proof: SITE_PROOF, grammar: SITE_GRAMMAR };
173
178
  /** what a trusted standard axiom is trusted by */
174
179
  const STANDARD = "standard axiom";
175
- /** the last components of names Lean generates beside a declaration (extractor/Extract.lean,
176
- * `generatedSuffixes`, and the like) */
177
- const GENERATED_LAST = /^(?:rec|recOn|casesOn|noConfusion|noConfusionType|ctorElim|ctorElimType|below|brecOn|binductionOn|ibelow|ctorIdx|toCtorIdx|ofNat|ofNat_ctorIdx|sizeOf_spec|injEq|inj|eq_def|induct|mutual_induct|fun_cases|induct_unfolding|ext_iff|congr_simp|mk|elim|(?:eq|match|proof)_\d+|_.*)$/;
178
180
  /** The graph on show, as the API reads it: built from the server's prepared view once per
179
181
  * version. The page's review route reads it too (server/reviewapi.ts). */
180
182
  export class View {
@@ -372,41 +374,9 @@ export class AgentApi {
372
374
  this.view = new View(g.version, g.prep.metaGz, g.prep.edgesGz);
373
375
  return this.view;
374
376
  }
375
- /** Up to five names like `name`: the same name in another case; then the same last component,
376
- * or one a typo or two away, in the same namespace first; then what the search finds. */
377
+ /** Up to five names like `name` (shared/declfind.ts, `guessNames`). */
377
378
  guess(v, name) {
378
- const g = v.g;
379
- const dot = name.lastIndexOf(".");
380
- const last = name.slice(dot + 1).toLowerCase();
381
- const ns = dot < 0 ? "" : name.slice(0, dot);
382
- const scored = new Map();
383
- const offer = (n, score) => {
384
- if ((scored.get(n) ?? Infinity) > score)
385
- scored.set(n, score);
386
- };
387
- const lower = name.toLowerCase();
388
- const most = last.length < 5 ? 1 : 2;
389
- // the graph's declarations, and the names Lean generated from a command that they fold
390
- for (const name of [...g.nodes.map((n) => n.name), ...g.foldedInto.keys()]) {
391
- if (name.toLowerCase() === lower)
392
- offer(name, -1);
393
- const k = name.lastIndexOf(".");
394
- const d = editDistance(name.slice(k + 1).toLowerCase(), last, most);
395
- if (d <= most)
396
- offer(name, 2 * d + (name.slice(0, Math.max(0, k)) === ns ? 0 : 1));
397
- }
398
- let rank = 0;
399
- for (const q of [name.slice(dot + 1), name.replace(/\./g, " ")]) {
400
- if (q.trim().length < 2)
401
- continue;
402
- const f = search(v.index, q);
403
- for (const h of [...f.hits.name, ...f.hits.statement].slice(0, 10))
404
- offer(g.nodes[h.i].name, 10 + rank++);
405
- }
406
- return [...scored]
407
- .sort((a, b) => a[1] - b[1] || a[0].length - b[0].length || (a[0] < b[0] ? -1 : 1))
408
- .slice(0, 5)
409
- .map(([n]) => n);
379
+ return guessNames(v.g, v.index, name);
410
380
  }
411
381
  /** A declaration by its full name, or the answer that there is none (with what was meant). A
412
382
  * name the graph folds into a declaration is that declaration (`folded` says so). */
@@ -419,35 +389,10 @@ export class AgentApi {
419
389
  const guesses = this.guess(v, name.trim());
420
390
  return fail(404, `There is no declaration ${name} in the graph.`, guesses.length ? `Did you mean ${guesses[0]}? Names are full, as the search gives them.` : "Search for it: GET /api/agent/search?q=<words>.", { didYouMean: guesses });
421
391
  }
422
- /** The declaration a name the graph has no node for is folded into: a declaration Lean generated
423
- * from a command (ViewNode.also), or a constructor, recursor, field, equation lemma… of a
424
- * declaration or of one so generated, which is a name one component below it. A name one
425
- * component below a declaration that is a typo or two away from a declaration beside it is
426
- * taken for that typo, not for something folded, unless its last component is one Lean
427
- * generates. */
392
+ /** The declaration a name the graph has no node for is folded into (shared/declfind.ts,
393
+ * `foldedAt`). */
428
394
  foldedAt(v, name) {
429
- const g = v.g;
430
- const direct = g.foldedInto.get(name);
431
- if (direct !== undefined)
432
- return direct;
433
- const dot = name.lastIndexOf(".");
434
- if (dot <= 0)
435
- return undefined;
436
- const head = name.slice(0, dot), last = name.slice(dot + 1);
437
- const at = g.byName.get(head) ?? g.foldedInto.get(head);
438
- if (at === undefined || !last)
439
- return undefined;
440
- if (!GENERATED_LAST.test(last)) {
441
- const most = last.length < 5 ? 1 : 2;
442
- const near = (n) => n.length > head.length + 1 && n.startsWith(head + ".") && !n.includes(".", head.length + 1) && editDistance(n.slice(head.length + 1).toLowerCase(), last.toLowerCase(), most) <= most;
443
- for (const n of g.nodes)
444
- if (near(n.name))
445
- return undefined;
446
- for (const n of g.foldedInto.keys())
447
- if (near(n))
448
- return undefined;
449
- }
450
- return at;
395
+ return foldedAt(v.g, name);
451
396
  }
452
397
  /** What an answer about `asked` says when that name is folded into declaration `i`. */
453
398
  folded(v, asked, i) {
@@ -514,8 +459,10 @@ export class AgentApi {
514
459
  return {
515
460
  by: o.by,
516
461
  agent: !!o.agent,
517
- // someone invited by a personal link: the name is theirs (CollabSpec decision 9)
518
- ...(o.person ? { verified: true } : {}),
462
+ // someone invited by a personal link: the name is theirs (CollabSpec decision 9), unless
463
+ // someone else recorded it for them at the same screen, who is named (ReviewSpec decision 8)
464
+ ...(o.person && !o.recordedBy ? { verified: true } : {}),
465
+ ...(o.recordedBy ? { recordedBy: o.recordedBy.by } : {}),
519
466
  verdict: o.verdict,
520
467
  ...(o.comment ? { comment: o.comment } : {}),
521
468
  at: o.at,
@@ -873,7 +820,7 @@ export class AgentApi {
873
820
  const advise = !this.refuse(me, "advise");
874
821
  const byKey = new Map(this.endpoints.map((e) => [`${e.method} ${e.path}`, e]));
875
822
  return ok({
876
- about: "VisualProof shows a Lean project's declarations, the TCB of its theorems (what a theorem's statement rests on) and the review people make of it. This is its API for AI agents.",
823
+ about: `VisualProof ${packageVersion()} shows a Lean project's declarations, the TCB of its theorems (what a theorem's statement rests on) and the review people make of it. This is its API for AI agents.`,
877
824
  you: {
878
825
  name: me.name,
879
826
  kind: me.kind,
@@ -1216,7 +1163,9 @@ export class AgentApi {
1216
1163
  if (typeof t !== "string")
1217
1164
  return t;
1218
1165
  try {
1219
- return { status: 200, body: null, text: { type: "text/javascript; charset=utf-8", body: readFileSync(MCP_FILE, "utf8") } };
1166
+ // the file carries the version of the server it came from (UpdateSpec decision 11)
1167
+ const body = readFileSync(MCP_FILE, "utf8").replace(/^const VERSION = "[^"\n]*";$/m, `const VERSION = ${JSON.stringify(packageVersion())};`);
1168
+ return { status: 200, body: null, text: { type: "text/javascript; charset=utf-8", body } };
1220
1169
  }
1221
1170
  catch {
1222
1171
  return fail(404, "The MCP server is not beside this server.", "Use the HTTP API (GET /api/agent).");
@@ -2046,7 +1995,7 @@ export class AgentApi {
2046
1995
  // what the review's logs record, as the page reads it
2047
1996
  for (const [name, ops] of Object.entries(d.opinions))
2048
1997
  for (const o of ops)
2049
- out.push({ at: o.at, kind: "opinion", by: o.by, agent: !!o.agent, name, verdict: o.verdict, ...(o.comment ? { comment: o.comment } : {}) });
1998
+ out.push({ at: o.at, kind: "opinion", by: o.by, ...(o.recordedBy ? { recordedBy: o.recordedBy.by } : {}), agent: !!o.agent, name, verdict: o.verdict, ...(o.comment ? { comment: o.comment } : {}) });
2050
1999
  // what an agent did at level full is marked as an agent's, as its advice is
2051
2000
  for (const [name, t] of Object.entries(d.targets))
2052
2001
  out.push({ at: t.at, kind: "target", by: t.by, agent: !!t.agent, name });
@@ -2077,7 +2026,7 @@ export class AgentApi {
2077
2026
  out.push({ ...rest, ...who, moved: ops.flatMap((o) => (o.op === "clear" ? [] : [o.key])).slice(0, 100), undoneBy: undone.get(e.id) ?? null });
2078
2027
  }
2079
2028
  else
2080
- out.push({ ...e, ...who });
2029
+ out.push({ ...withoutReasons(e), ...who });
2081
2030
  }
2082
2031
  const about = (a.query.get("name") ?? "").trim();
2083
2032
  // a note is about the declarations it names; a link, about the declaration it is on, or was on
@@ -2228,6 +2177,10 @@ export class AgentApi {
2228
2177
  unit = v.g.nodes[i].name;
2229
2178
  }
2230
2179
  const verdict = b.verdict;
2180
+ // an agent's advice is its own: people record for one another at one screen, agents never
2181
+ // (ReviewSpec decision 8)
2182
+ if (b.for !== undefined)
2183
+ return fail(403, "An agent records no opinion for anyone else.", "Leave out for: your advice is your own.");
2231
2184
  // "dispute" was the old name of a comment (renamed 2026-10-09): refused, with the new name
2232
2185
  if (verdict === "dispute")
2233
2186
  return fail(400, "verdict is approve or comment: \"dispute\" is now \"comment\".", "Use comment: send {name, verdict: \"comment\", comment}.");
@@ -2528,6 +2481,66 @@ export class AgentApi {
2528
2481
  }
2529
2482
  return { place: cleanView(next), temporary };
2530
2483
  }
2484
+ /** A link to a picture of the board (ExportSpec decision 20, drafted 2026-10-15): a recipe
2485
+ * (shared/artrecipe.ts) checked, and resolved against the board of the view it names — the
2486
+ * Project's layout in the view's clustering — so that a name, a target or a cluster the board has
2487
+ * not is refused here rather than drawn as a picture unlike the one meant; the selection it gives
2488
+ * goes in the address, the recipe after it. The page that opens the link resolves it again
2489
+ * against the board it opens on, opens the artwork mode, and saves the files asked for. Nothing
2490
+ * changes here, and the link holds no key. */
2491
+ async artwork(a) {
2492
+ const no = needAgent(a.caller);
2493
+ if (no)
2494
+ return no;
2495
+ const b = body(a);
2496
+ const read = readRecipe(b.recipe);
2497
+ if ("error" in read)
2498
+ return fail(400, read.error, read.hint);
2499
+ const r = read.recipe;
2500
+ const save = b.save === undefined || b.save === null ? [] : typeof b.save === "string" ? b.save.split(",").map((k) => k.trim()) : null;
2501
+ if (!save || save.some((k) => k !== "png" && k !== "svg"))
2502
+ return fail(400, "save is png, svg or png,svg.", "The files the page saves once the board is ready; left out, none.");
2503
+ const v = this.graph();
2504
+ if (!(v instanceof View))
2505
+ return v;
2506
+ let sel = null;
2507
+ if (r.selection) {
2508
+ const i = this.find(v, r.selection.name, "selection.name");
2509
+ if (typeof i !== "number")
2510
+ return i;
2511
+ sel = v.g.nodes[i].name;
2512
+ }
2513
+ const rec = r.view && r.view !== PROJECT ? this.world.views?.book.get(r.view) : undefined;
2514
+ if (r.view && r.view !== PROJECT && !rec)
2515
+ return fail(404, `There is no view ${r.view}.`, "GET /api/agent/views lists them; left out, the Project.");
2516
+ const L = v.meta.layouts[rec?.clustering ?? "namespace"];
2517
+ const res = resolveRecipe(r, {
2518
+ clusters: L.clusters,
2519
+ clusterOf: (name) => {
2520
+ const i = v.g.byName.get(name);
2521
+ return i === undefined ? undefined : L.cluster[i];
2522
+ },
2523
+ targets: Object.keys(this.world.review.data.targets),
2524
+ projectCount: v.meta.projectCount,
2525
+ });
2526
+ if (res.problems.length)
2527
+ return fail(404, `The recipe names what the board has not: ${res.problems.join("; ")}.`, "Clusters by key (GET /api/agent/views/view lists a view's), declarations and targets by full name (GET /api/agent/search; the targets: GET /api/agent/targets). A target of the review the graph no longer has is left out unsaid where the recipe does not name it.", { problems: res.problems });
2528
+ const address = addressOf({ view: rec ? rec.id : null, scope: null, mode: r.selection?.mode ?? "tcb", selection: null, sel });
2529
+ const link = `${a.base ?? ""}/#${address}&${RECIPE_PARAM}=${encodeRecipe(r)}${save.length ? `&${SAVE_PARAM}=${[...new Set(save)].join(",")}` : ""}`;
2530
+ const look = res.look;
2531
+ return ok({
2532
+ ok: true,
2533
+ link,
2534
+ length: link.length,
2535
+ size: [look.w, look.h],
2536
+ names: look.labels === "chosen" ? res.named : look.labels,
2537
+ targets: look.targets === "chosen" ? look.shownTargets : look.targets,
2538
+ selection: sel ? { name: sel, mode: r.selection.mode ?? "tcb" } : null,
2539
+ // names and targets beyond what the artwork carries, left out: said, not refused
2540
+ left: res.left,
2541
+ note: "It holds no key: it opens for whoever is let in already, or for a script that enters with the key (scripts/artwork-batch.mjs). The page that opens it resolves the recipe again against its board, opens the artwork mode fitted to the whole board, and saves the files asked for on that machine; nothing comes back here.",
2542
+ });
2543
+ }
2531
2544
  /** A link to what the agent looks at, or to a place it gives over its own, as a person's Share makes
2532
2545
  * one with Exactly what I see (CollabSpec decision 7, the owner's decision of 2026-10-12): the
2533
2546
  * address, and after it a snapshot of everything followed, which the page that opens it applies
@@ -2887,7 +2900,7 @@ export class AgentApi {
2887
2900
  {
2888
2901
  method: "GET", path: "/api/agent/activity", brief: "the timeline", about: "The timeline, oldest first: opinions and advice, targets and key marks, files of the library added, retitled, archived or restored and links made, edited or removed (all from the review's logs), builds, TCB changes, targets taken off, prefixes trusted or no longer, views saved, renamed, rescoped, deleted or laid out afresh, notes added to a view or taken out of it, notes, layouts and their undoing.",
2889
2902
  params: { since: "an ISO time (e.g. the `now` of your last answer) or an event id: what came after", name: "optional: only what is about this declaration (opinions, advice, notes that name it, key marks, links on it; a target's own events)", limit: "default 100, at most 1000 (the newest)" },
2890
- returns: "{now, events: [{at, kind, by?, agent?, …}] oldest first, more}; kinds: opinion, target, key (a key declaration as marked now: target, name), build, tcb, untarget, trust, untrust, view (a view saved, renamed, rescoped, deleted or laid out afresh: what, view, name, was?, scope; or a note of the project added to it or taken out of it: what items, note, text, shown), note (a note on a view written, edited or deleted: what, view, title, note, text), file (a file of the library: what is added, edited — its title, wasTitle the one before —, archived or restored; sha, title), link (what is made, edited or removed; id, subject, label, file, and an edit's changed (subject, label, place), wasSubject?, wasLabel?, auto? where VisualProof carried it with a renamed cluster), layout (with moved and undoneBy), undo; relayout only in a timeline kept from before views",
2903
+ returns: "{now, events: [{at, kind, by?, agent?, …}] oldest first, more}; kinds: opinion, target, key (a key declaration as marked now: target, name), build, tcb, untarget, trust, untrust, view (a view saved, renamed, rescoped, deleted or laid out afresh: what, view, name, was?, scope; or a note of the project added to it or taken out of it: what items, note, text, shown), note (a note on a view written, edited or deleted: what, view, title, note, text), file (a file of the library: what is added, edited — its title, wasTitle the one before —, archived or restored; sha, title), link (what is made, edited or removed; id, subject, label, file, and an edit's changed (subject, label, place), wasSubject?, wasLabel?, auto? where VisualProof carried it with a renamed cluster), layout (with moved and undoneBy), undo, tool (the VisualProof version serving the site changed: from, to, setAside?); relayout only in a timeline kept from before views",
2891
2904
  run: (a) => this.activity(a),
2892
2905
  },
2893
2906
  {
@@ -2920,6 +2933,12 @@ export class AgentApi {
2920
2933
  returns: "{ok, link, exact, carries: what the link opens on, length, note, temporary?}",
2921
2934
  run: (a) => this.viewLink(a),
2922
2935
  },
2936
+ {
2937
+ method: "POST", path: "/api/agent/artwork", about: "A link that opens the board as a picture, in the artwork mode, as the recipe says (ExportSpec decision 20): its size, its look, names by a rule, the targets marked, the selection — in words that hold on any project, so that one recipe makes the same kind of picture of every codebase; the page saves the PNG or SVG where asked, once the board is ready. It changes nothing, and holds no key. A name, a target or a cluster the board has not is refused (404, problems).",
2938
+ params: { recipe: "the picture: {size, orientation, style, theme, transparent, colouring, border, title, subtitle, titleSize, footSize, source, names, targets, keys, notes, edges, selection, lit, view}, each optional (GET /api/agent/docs/export, \"Artwork\")", save: "png, svg or png,svg: the files the page saves once the board is ready; left out, none" },
2939
+ returns: "{ok, link, length, size: [w, h], names: {clusters, decls: written wherever they fall; ruled: by the rule, each only where it covers no other name} | how the board writes them, targets, selection, left: names and targets left out for room, note}",
2940
+ run: (a) => this.artwork(a),
2941
+ },
2923
2942
  {
2924
2943
  method: "POST", path: "/api/agent/pins", needs: "full", about: "Drop or let go of pins on the Project view, as people drag (for another view: POST /api/agent/views/arrange): one batch, recorded in the timeline with what it replaced, undoable at once.",
2925
2944
  params: { mode: "namespace (default) or module", pins: `[{name or cluster, at: [x, y] in the parent's coordinates (GET /api/agent/place), or null to let go}], at most ${MAX_PINS}` },
@@ -3174,6 +3193,8 @@ export function doingOf(a) {
3174
3193
  return of("going to", clip(b.sel)) ?? of("going to the view of", clip(b.review)) ?? "moving its view";
3175
3194
  case "/api/agent/view/link":
3176
3195
  return "making a link to what it looks at";
3196
+ case "/api/agent/artwork":
3197
+ return "making a link to a picture of the board";
3177
3198
  case "/api/agent/ask-follow":
3178
3199
  return clip(b.who) ? `asking ${clip(b.who)} to follow it` : "asking everyone to follow it";
3179
3200
  case "/api/agent/opinion":
@@ -3226,24 +3247,6 @@ const str = (x) => (typeof x === "string" ? x : null);
3226
3247
  export function whoOf(c) {
3227
3248
  return { name: c.name ?? "", ...(c.token ? { token: c.token.id } : {}) };
3228
3249
  }
3229
- /** The edit distance between two short strings, or `most + 1` once it is more than `most`. */
3230
- function editDistance(a, b, most) {
3231
- if (Math.abs(a.length - b.length) > most)
3232
- return most + 1;
3233
- let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
3234
- for (let i = 1; i <= a.length; i++) {
3235
- const row = [i];
3236
- let best = i;
3237
- for (let j = 1; j <= b.length; j++) {
3238
- row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
3239
- best = Math.min(best, row[j]);
3240
- }
3241
- if (best > most)
3242
- return most + 1;
3243
- prev = row;
3244
- }
3245
- return Math.min(prev[b.length], most + 1);
3246
- }
3247
3250
  /** An integer parameter within bounds, `dflt` when absent or not a number. */
3248
3251
  function clampInt(s, dflt, lo, hi) {
3249
3252
  const n = s === null || s.trim() === "" ? NaN : Number(s);
@@ -3258,7 +3261,7 @@ export function commitOf(root) {
3258
3261
  if (was && now - was.at < 10_000)
3259
3262
  return was.commit;
3260
3263
  const git = (...args) => {
3261
- const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 5000, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "ignore"] });
3264
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 5000, env: childEnv({ GIT_OPTIONAL_LOCKS: "0" }), stdio: ["ignore", "pipe", "ignore"] });
3262
3265
  return r.status === 0 ? r.stdout.trim() : null;
3263
3266
  };
3264
3267
  const hash = git("rev-parse", "HEAD");
@@ -16,7 +16,7 @@ export const DOC_TOPICS = [
16
16
  { topic: "following", title: "Following", about: "where people are, your view, links to it, what people see" },
17
17
  { topic: "views", title: "Views and the layout", about: "queries, views, places, pins" },
18
18
  { topic: "library", title: "The library and links", about: "papers and images, their pages, links to them" },
19
- { topic: "export", title: "Exported documents", about: "a view's document, typeset" },
19
+ { topic: "export", title: "Exported documents", about: "a view's document, typeset; a picture" },
20
20
  { topic: "history", title: "History and activity", about: "the timeline, builds, renames, the lost-and-found" },
21
21
  { topic: "talking", title: "Talking with people", about: "your inbox, replies, your state" },
22
22
  { topic: "mcp", title: "VisualProof's MCP server", about: "installing it, its tools and their actions" },