agentfootprint-lens 0.32.0 → 0.34.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.
package/README.md CHANGED
@@ -358,6 +358,84 @@ The headless half is on `agentfootprint-lens/core` — `defaultSelection`,
358
358
 
359
359
  ---
360
360
 
361
+ ## Render an artifact by its ref
362
+
363
+ agentfootprint 9.21–9.23 taught tools to check large results into an **artifact
364
+ store** and hand the model a ~30-token claim ticket (`art_…`) instead of the
365
+ bytes; the model finishes with `present({ ref, as, label })`. The lens is the
366
+ screen's half of that handshake: it **redeems the ref and renders a component
367
+ the app registered for the artifact's kind** — ids + props, never markup the
368
+ model wrote. The model can say *what* to show; only your registry says *how*.
369
+
370
+ ```tsx
371
+ import {
372
+ ArtifactPane,
373
+ httpArtifactResolver,
374
+ presentedFromEvents,
375
+ registerArtifactComponent,
376
+ } from 'agentfootprint-lens';
377
+
378
+ // 1. Teach Lens your kinds, once at startup. ('dataset/rows' already ships.)
379
+ registerArtifactComponent({
380
+ kind: 'chart/spec',
381
+ component: ({ meta, data }) => <MyBarChart title={meta.label} spec={data} />,
382
+ });
383
+
384
+ // 2. Point a resolver at the served agent — the SAME invoke path the
385
+ // conversation posts to. Session identity rides every redemption.
386
+ const resolver = httpArtifactResolver({ url: '/invoke', sessionId });
387
+
388
+ // 3. Render every artifact the run presented. On history reload, walk the
389
+ // stored transcript instead: readPresentedResult(toolMessage.content).
390
+ {presentedFromEvents(recording.events).map((call) => (
391
+ <ArtifactPane key={call.toolCallId ?? call.ref} presented={call} resolver={resolver} />
392
+ ))}
393
+ ```
394
+
395
+ `<ArtifactPane>` heads the ref first (the render decision), then gets the
396
+ payload and mounts the registered component. A kind nothing is registered for
397
+ falls back to an honest **metadata card** — ticket facts, a bounded payload
398
+ preview, copy/download — plus one line naming the `registerArtifactComponent`
399
+ call that closes the gap. Never a blank pane.
400
+
401
+ ### When the artifact is gone
402
+
403
+ Refs expire — TTL, retention budgets, a restarted store. The `present` result
404
+ carries a **description snapshot** at speak time, so a reloaded conversation
405
+ can still say what stood there, from history alone:
406
+
407
+ > Chart — "Q3 sales by region" (bar-chart, 41.0 KB) — expired; re-run to regenerate.
408
+
409
+ That placeholder needs **no store round-trip beyond the failed head** — this is
410
+ the spreadsheet's `#REF!`, taught manners. Missing, expired and
411
+ another-session's ref all answer one indistinguishable not-found by design (a
412
+ leaked ref probes nothing), and the pane renders the same stated absence for
413
+ all three. A resolution *door* failure (no store attached, wire down) is a
414
+ different sentence: the pane shows the server's own teaching refusal, verbatim.
415
+
416
+ ### The two resolvers
417
+
418
+ ```ts
419
+ import { httpArtifactResolver, storeArtifactResolver } from 'agentfootprint-lens/core';
420
+
421
+ // Over a served agent's wire ops (standingAgent / httpHost / nodeHost, or the
422
+ // managed-runtime dialect): POST { op: 'artifact-head' | 'artifact-get', ref,
423
+ // sessionId } to the invoke path. `userId`, extra `headers` and a custom
424
+ // `fetch` are options.
425
+ const overHttp = httpArtifactResolver({ url: 'https://host/invoke', sessionId: 's-1' });
426
+
427
+ // Over a directly passed store, for same-process demos and tests. The scope is
428
+ // REQUIRED — refs only resolve under the identity they were minted in.
429
+ const inProcess = storeArtifactResolver({ store, scope: { conversationId } });
430
+ ```
431
+
432
+ Both return `{ status: 'live' | 'absent' | 'failed' }` outcomes instead of
433
+ throwing, so a pane can branch. In the EventStream, the whole artifact
434
+ lifecycle — minted, resolved, expired, refused, presented — already reads as
435
+ prose via the default humanizer.
436
+
437
+ ---
438
+
361
439
  ## Theming
362
440
 
363
441
  **Lens inherits theme tokens from your app via CSS variables.** Set `--fp-*`
@@ -522,6 +600,19 @@ file through your own endpoint. Needs agentfootprint 9.9+; on anything older it
522
600
  renders a version hint instead of itself. See
523
601
  [Report a bug with this run](#report-a-bug-with-this-run).
524
602
 
603
+ ### `<ArtifactPane>` — render by ref, with `#REF!` taught manners
604
+
605
+ Takes one `present` call (`presented`) and an `ArtifactResolver` (`resolver`);
606
+ heads the ref, then renders the component registered for the artifact's kind
607
+ (`registerArtifactComponent({ kind, component })`) with `{ meta, data,
608
+ presented }` as props. `'dataset/rows'` ships as a built-in table; every
609
+ unregistered kind falls back to `<ArtifactMetaCard>` with the gap stated. An
610
+ expired/missing ref renders its placeholder from the speak-time snapshot alone.
611
+ The headless half — `httpArtifactResolver`, `storeArtifactResolver`,
612
+ `presentedFromEvents`, `readPresentedResult`, `artifactPlaceholder` — is on
613
+ `agentfootprint-lens/core`. See
614
+ [Render an artifact by its ref](#render-an-artifact-by-its-ref).
615
+
525
616
  ### Headless core
526
617
 
527
618
  `agentfootprint-lens/core` is React-free: `LensRecorder`, `ChangeNotifier`,
@@ -1552,8 +1552,254 @@ import {
1552
1552
  renderCommentary,
1553
1553
  selectCommentaryKey
1554
1554
  } from "agentfootprint";
1555
+
1556
+ // src/core/humanizeRouting.ts
1557
+ function fmtScore(n) {
1558
+ return String(Number(n.toFixed(2)));
1559
+ }
1560
+ function fmtShare(n) {
1561
+ return `${Math.round(n * 100)}%`;
1562
+ }
1563
+ function quoteList(ids) {
1564
+ return ids.join(", ");
1565
+ }
1566
+ function q(id) {
1567
+ return id === void 0 ? void 0 : `"${id}"`;
1568
+ }
1569
+ function movement(p) {
1570
+ const { from, to } = p;
1571
+ if (to !== void 0 && from !== void 0) {
1572
+ return from === to ? `Stayed in ${q(to)}` : `Moved from ${q(from)} to ${q(to)}`;
1573
+ }
1574
+ if (to !== void 0) return `Turn started in ${q(to)}`;
1575
+ if (from !== void 0) return `Stayed in ${q(from)}`;
1576
+ return "The turn was routed";
1577
+ }
1578
+ function droppedResumeNote(p) {
1579
+ const d = p.droppedResume;
1580
+ if (!d) return "";
1581
+ return ` The conversation's saved place (${q(d.id)}) no longer exists in this deployment.`;
1582
+ }
1583
+ function humanizeTurnRouted(p) {
1584
+ switch (p.by) {
1585
+ case "entry":
1586
+ case "rule":
1587
+ return `${movement(p)} \u2014 a rule matched (tier 1).${droppedResumeNote(p)}`;
1588
+ case "intent": {
1589
+ const winner = p.scores?.[0];
1590
+ const ruRow = p.runnerUp && p.scores ? p.scores.find((s) => s.id === p.runnerUp.id) : void 0;
1591
+ let clause = " \u2014 intent";
1592
+ if (winner) {
1593
+ clause += ` scored ${fmtScore(winner.score)}`;
1594
+ if (ruRow) clause += ` vs ${fmtScore(ruRow.score)} for runner-up ${q(ruRow.id)}`;
1595
+ else if (p.runnerUp)
1596
+ clause += `, ${fmtScore(p.runnerUp.gap)} ahead of ${q(p.runnerUp.id)}`;
1597
+ } else {
1598
+ clause += " matched";
1599
+ }
1600
+ const scorer = p.scorer ? ` (${p.scorer} scorer)` : "";
1601
+ return `${movement(p)}${clause}${scorer}.${droppedResumeNote(p)}`;
1602
+ }
1603
+ case "continuity": {
1604
+ const nearTie = p.decisive === false && p.scores && p.scores.length >= 2;
1605
+ if (nearTie) {
1606
+ const [a, b] = p.scores;
1607
+ const where2 = p.to ?? p.from;
1608
+ const tail = where2 !== void 0 ? `; continuing in ${q(where2)}` : "";
1609
+ return `Stayed \u2014 near-tie between ${q(a.id)} (${fmtShare(a.relevance)}) and ${q(b.id)} (${fmtShare(b.relevance)})${tail}.${droppedResumeNote(p)}`;
1610
+ }
1611
+ const where = p.to ?? p.from;
1612
+ const head = where !== void 0 ? `Stayed in ${q(where)}` : "Stayed";
1613
+ return `${head} \u2014 treated as a continuation of the conversation.${droppedResumeNote(p)}`;
1614
+ }
1615
+ case "menu":
1616
+ case "model-pick": {
1617
+ let line = "No single skill won \u2014 the model was offered a menu";
1618
+ if (p.offered && p.offered.length > 0)
1619
+ line += ` of ${p.offered.length}: ${quoteList(p.offered)}`;
1620
+ if (p.stayOffered) line += `; 'stay' was on the menu`;
1621
+ return `${line}.${droppedResumeNote(p)}`;
1622
+ }
1623
+ case "none": {
1624
+ if (p.droppedResume) {
1625
+ let line = `The conversation's saved place (${q(p.droppedResume.id)}) no longer exists in this deployment \u2014 the turn started fresh.`;
1626
+ if (p.offered && p.offered.length > 0)
1627
+ line += ` Options on the record: ${quoteList(p.offered)}.`;
1628
+ return line;
1629
+ }
1630
+ if (p.offered && p.offered.length > 0) {
1631
+ const top = p.scores?.[0]?.score;
1632
+ const floor = p.policy?.floor;
1633
+ const cleared = top !== void 0 && Number.isFinite(top) && (floor === void 0 || top > floor);
1634
+ if (p.decisive === false && (p.scores?.length ?? 0) >= 2 && cleared) {
1635
+ return `This message matched ${quoteList(p.offered)} too closely to call, and rails mode does not let the model break the tie \u2014 the turn ran on the base prompt.`;
1636
+ }
1637
+ if (top !== void 0 && !cleared) {
1638
+ return `No rule or intent matched this message strongly enough, and rails mode does not let the model route \u2014 the turn ran on the base prompt. The full menu stayed on the record: ${quoteList(p.offered)}.`;
1639
+ }
1640
+ return `Routing ended in a menu, but rails mode does not let the model route \u2014 the turn ran on the base prompt. On the record: ${quoteList(p.offered)}.`;
1641
+ }
1642
+ return "Routing decided nothing this turn \u2014 the turn ran on the base prompt.";
1643
+ }
1644
+ default:
1645
+ return `Turn routing: ${p.by}${p.to !== void 0 ? ` \u2192 ${q(p.to)}` : ""}.`;
1646
+ }
1647
+ }
1648
+ var COUNT_WORDS = ["Zero", "One", "Two", "Three", "Four", "Five"];
1649
+ function humanizeRouteConflict(p) {
1650
+ const winner = p.winner;
1651
+ const losers = p.losers ?? [];
1652
+ const winnerClause = winner ? `${winner.toolName ?? "a tool"} \u2192 ${q(winner.target) ?? "its target"} won (first in the batch)` : "the first match in the batch won";
1653
+ if (losers.length === 0) {
1654
+ return `Tool results conflicted on the next skill \u2014 ${winnerClause}.`;
1655
+ }
1656
+ const count = losers.length + 1;
1657
+ const countWord = count < COUNT_WORDS.length ? COUNT_WORDS[count] : String(count);
1658
+ const loserClause = losers.map((l) => `${l.toolName ?? "a tool"} \u2192 ${q(l.target) ?? "another skill"}`).join(", ");
1659
+ const verb = losers.length === 1 ? "was" : "were";
1660
+ return `${countWord} tool results wanted different next skills \u2014 ${winnerClause}; ${loserClause} ${verb} suppressed and ${losers.length === 1 ? "is" : "are"} on the record.`;
1661
+ }
1662
+ function humanizeSkillRejected(p) {
1663
+ const pick = `The model tried to switch to ${q(p.requestedId)}`;
1664
+ if (p.posture === "guard") {
1665
+ let line2 = `${pick}, but that pick was off the menu it was given \u2014 refused (guard).`;
1666
+ if (p.allowed && p.allowed.length > 0) line2 += ` Reachable: ${quoteList(p.allowed)}.`;
1667
+ return line2;
1668
+ }
1669
+ if (p.posture === "rails") {
1670
+ return `${pick}, but rails mode does not let the model route \u2014 refused.`;
1671
+ }
1672
+ const at = p.currentSkillId !== void 0 ? q(p.currentSkillId) : "the start";
1673
+ let line = `${pick}, but it is not reachable from ${at} \u2014 refused.`;
1674
+ if (p.allowed && p.allowed.length > 0) line += ` Reachable: ${quoteList(p.allowed)}.`;
1675
+ return line;
1676
+ }
1677
+ function humanizeCursorMovePick(move) {
1678
+ if (!move || move.by !== "model-pick") return null;
1679
+ if (move.offered === void 0 && move.declinedOffer === void 0) return null;
1680
+ let line = move.to !== void 0 ? `The model chose ${q(move.to)}` : "The model made a pick";
1681
+ if (move.offered !== void 0 && move.declinedOffer !== true) {
1682
+ line += ` from a ${move.offered.length}-item menu`;
1683
+ const rest = move.to !== void 0 ? move.offered.filter((id) => id !== move.to) : move.offered;
1684
+ if (rest.length > 0) line += ` (${quoteList(rest)} were offered too)`;
1685
+ }
1686
+ if (move.from !== void 0 && move.from !== move.to) {
1687
+ line += `, moving from ${q(move.from)}`;
1688
+ }
1689
+ line += ".";
1690
+ if (move.declinedOffer === true) {
1691
+ line += " Its pick was not on the offered menu \u2014 the divergence is on the record.";
1692
+ }
1693
+ return line;
1694
+ }
1695
+
1696
+ // src/core/bugReport/selection.ts
1697
+ var DEFAULT_MAX_BYTES = 24 * 1024 * 1024;
1698
+ function formatBytes(bytes) {
1699
+ if (!Number.isFinite(bytes) || bytes < 0) return "0 bytes";
1700
+ if (bytes < 1024) return `${Math.round(bytes)} bytes`;
1701
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1702
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1703
+ }
1704
+ function conversationUnits(manifest) {
1705
+ return manifest.units.filter((unit) => unit.kind === "conversation");
1706
+ }
1707
+ function fileUnits(manifest) {
1708
+ return manifest.units.filter((unit) => unit.kind !== "conversation");
1709
+ }
1710
+ function defaultSelection(manifest, recent = 3) {
1711
+ const conversations = conversationUnits(manifest);
1712
+ const keep = recent <= 0 ? [] : conversations.slice(Math.max(0, conversations.length - recent));
1713
+ return [...keep.map((unit) => unit.id), ...fileUnits(manifest).map((unit) => unit.id)];
1714
+ }
1715
+ function measureSelection(manifest, selected, limitBytes = DEFAULT_MAX_BYTES) {
1716
+ const ticked = new Set(selected);
1717
+ const units = manifest.units.filter((unit) => ticked.has(unit.id));
1718
+ const bytes = units.reduce((sum, unit) => sum + (unit.bytes || 0), 0);
1719
+ const over = bytes > limitBytes;
1720
+ const hint = over ? trimHintFor(manifest, ticked, limitBytes) : void 0;
1721
+ return {
1722
+ bytes,
1723
+ limitBytes,
1724
+ over,
1725
+ label: `${formatBytes(bytes)} of ${formatBytes(limitBytes)}`,
1726
+ conversations: units.filter((unit) => unit.kind === "conversation").length,
1727
+ ...hint !== void 0 && { hint }
1728
+ };
1729
+ }
1730
+ function trimHintFor(manifest, selected, limitBytes = DEFAULT_MAX_BYTES) {
1731
+ const ticked = new Set(selected);
1732
+ const droppable = manifest.units.filter((unit) => ticked.has(unit.id) && unit.kind === "conversation").sort((left, right) => right.bytes - left.bytes);
1733
+ let remaining = manifest.units.filter((unit) => ticked.has(unit.id)).reduce((sum, unit) => sum + (unit.bytes || 0), 0);
1734
+ if (remaining <= limitBytes) return void 0;
1735
+ const named = [];
1736
+ for (const unit of droppable) {
1737
+ if (named.length >= droppable.length - 1) break;
1738
+ remaining -= unit.bytes || 0;
1739
+ named.push(`${unit.id} (${formatBytes(unit.bytes || 0)})`);
1740
+ if (remaining <= limitBytes) break;
1741
+ }
1742
+ if (remaining > limitBytes) {
1743
+ return `Even with one conversation left this is over ${formatBytes(limitBytes)}. Untick a derived file, record a shorter reproduction, or file the issue and attach the zip by hand.`;
1744
+ }
1745
+ return `Untick ${joinWithAnd(named)} to fit.`;
1746
+ }
1747
+ function joinWithAnd(parts) {
1748
+ if (parts.length <= 1) return parts[0] ?? "";
1749
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
1750
+ }
1751
+
1752
+ // src/core/humanizeArtifacts.ts
1753
+ var ticket = (ref, kind, bytes) => `${ref ?? "art_?"} [${kind ?? "?"} \xB7 ${formatBytes(bytes ?? 0)}]`;
1754
+ function humanizeArtifactMinted(p) {
1755
+ const label = p.label !== void 0 ? ` "${p.label}"` : "";
1756
+ const by = p.tool !== void 0 ? ` by tool "${p.tool}"` : "";
1757
+ return `Artifact minted${by} \u2014${label} ${ticket(p.ref, p.kind, p.bytes)}. The model routes this ticket, not the bytes.`;
1758
+ }
1759
+ function humanizeArtifactResolved(p) {
1760
+ const actor = p.tool !== void 0 ? `Tool "${p.tool}"` : "The screen";
1761
+ const verb = p.via === "head" ? "described" : p.via === "get" ? "fetched" : `redeemed (${p.via ?? "?"})`;
1762
+ return `${actor} ${verb} artifact ${ticket(p.ref, p.kind, p.bytes)}.`;
1763
+ }
1764
+ function humanizeArtifactExpired(p) {
1765
+ return `Artifact ${ticket(p.ref, p.kind, p.bytes)} swept (${p.reason ?? "retention"}) \u2014 its ref no longer resolves, and says so.`;
1766
+ }
1767
+ function humanizeArtifactRefused(p) {
1768
+ const ref = p.ref !== void 0 ? ` for '${p.ref}'` : "";
1769
+ const by = p.tool !== void 0 ? ` (tool "${p.tool}")` : "";
1770
+ const detail = p.detail !== void 0 ? ` \u2014 ${p.detail}` : "";
1771
+ return `Artifact ${p.op ?? "?"}${ref} refused: ${p.reason ?? "unknown reason"}${by}${detail}`;
1772
+ }
1773
+ function humanizeArtifactPresented(p) {
1774
+ const s = p.snapshot;
1775
+ const label = s?.label !== void 0 ? ` "${s.label}"` : "";
1776
+ return `Presented${label} to the screen as '${p.as ?? "?"}' \u2014 ${ticket(p.ref, s?.kind, s?.bytes)}. The screen redeems the ref under its own session.`;
1777
+ }
1778
+
1779
+ // src/core/humanizer.ts
1555
1780
  var defaultHumanizer = (event) => {
1556
- if (event.type === "agentfootprint.context.evaluated") return null;
1781
+ if (event.type === "agentfootprint.context.evaluated") {
1782
+ const cursorMove = event.payload.cursorMove;
1783
+ return humanizeCursorMovePick(cursorMove);
1784
+ }
1785
+ if (event.type === "agentfootprint.skill.turn_routed") {
1786
+ return humanizeTurnRouted(event.payload);
1787
+ }
1788
+ if (event.type === "agentfootprint.skill.route_conflict") {
1789
+ return humanizeRouteConflict(event.payload);
1790
+ }
1791
+ switch (event.type) {
1792
+ case "agentfootprint.artifacts.minted":
1793
+ return humanizeArtifactMinted(event.payload);
1794
+ case "agentfootprint.artifacts.resolved":
1795
+ return humanizeArtifactResolved(event.payload);
1796
+ case "agentfootprint.artifacts.expired":
1797
+ return humanizeArtifactExpired(event.payload);
1798
+ case "agentfootprint.artifacts.refused":
1799
+ return humanizeArtifactRefused(event.payload);
1800
+ case "agentfootprint.artifacts.presented":
1801
+ return humanizeArtifactPresented(event.payload);
1802
+ }
1557
1803
  switch (event.type) {
1558
1804
  // Composition
1559
1805
  case "agentfootprint.composition.enter":
@@ -1637,6 +1883,8 @@ var defaultHumanizer = (event) => {
1637
1883
  return `Skill "${event.payload.skillId}" activated \u2014 ${event.payload.reason}.`;
1638
1884
  case "agentfootprint.skill.deactivated":
1639
1885
  return `Skill "${event.payload.skillId}" deactivated \u2014 ${event.payload.reason}.`;
1886
+ case "agentfootprint.skill.rejected":
1887
+ return humanizeSkillRejected(event.payload);
1640
1888
  // Error
1641
1889
  case "agentfootprint.error.fatal":
1642
1890
  return `\u26D4 The run failed \u2014 ${event.payload.error}`;
@@ -2525,20 +2773,20 @@ function expandSubflowInternals(subflows) {
2525
2773
  replayWalkInto(subRec, walkSubflowSpec(spec, path, { recurse: false }));
2526
2774
  const sub = subTrace.getGraph();
2527
2775
  const prefix = path.endsWith("/") ? path : `${path}/`;
2528
- const q = (id) => id.startsWith(prefix) ? id : `${prefix}${id}`;
2776
+ const q2 = (id) => id.startsWith(prefix) ? id : `${prefix}${id}`;
2529
2777
  for (const n of sub.nodes) {
2530
2778
  nodes.push({
2531
2779
  ...n,
2532
- id: q(n.id),
2780
+ id: q2(n.id),
2533
2781
  data: { ...n.data, subflowOf: subflowId }
2534
2782
  });
2535
2783
  }
2536
2784
  for (const e of sub.edges) {
2537
2785
  edges.push({
2538
2786
  ...e,
2539
- id: `${q(e.source)}->${q(e.target)}`,
2540
- source: q(e.source),
2541
- target: q(e.target)
2787
+ id: `${q2(e.source)}->${q2(e.target)}`,
2788
+ source: q2(e.source),
2789
+ target: q2(e.target)
2542
2790
  });
2543
2791
  }
2544
2792
  }
@@ -2821,62 +3069,6 @@ function layoutLensGraph(output, options = {}) {
2821
3069
  };
2822
3070
  }
2823
3071
 
2824
- // src/core/bugReport/selection.ts
2825
- var DEFAULT_MAX_BYTES = 24 * 1024 * 1024;
2826
- function formatBytes(bytes) {
2827
- if (!Number.isFinite(bytes) || bytes < 0) return "0 bytes";
2828
- if (bytes < 1024) return `${Math.round(bytes)} bytes`;
2829
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2830
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2831
- }
2832
- function conversationUnits(manifest) {
2833
- return manifest.units.filter((unit) => unit.kind === "conversation");
2834
- }
2835
- function fileUnits(manifest) {
2836
- return manifest.units.filter((unit) => unit.kind !== "conversation");
2837
- }
2838
- function defaultSelection(manifest, recent = 3) {
2839
- const conversations = conversationUnits(manifest);
2840
- const keep = recent <= 0 ? [] : conversations.slice(Math.max(0, conversations.length - recent));
2841
- return [...keep.map((unit) => unit.id), ...fileUnits(manifest).map((unit) => unit.id)];
2842
- }
2843
- function measureSelection(manifest, selected, limitBytes = DEFAULT_MAX_BYTES) {
2844
- const ticked = new Set(selected);
2845
- const units = manifest.units.filter((unit) => ticked.has(unit.id));
2846
- const bytes = units.reduce((sum, unit) => sum + (unit.bytes || 0), 0);
2847
- const over = bytes > limitBytes;
2848
- const hint = over ? trimHintFor(manifest, ticked, limitBytes) : void 0;
2849
- return {
2850
- bytes,
2851
- limitBytes,
2852
- over,
2853
- label: `${formatBytes(bytes)} of ${formatBytes(limitBytes)}`,
2854
- conversations: units.filter((unit) => unit.kind === "conversation").length,
2855
- ...hint !== void 0 && { hint }
2856
- };
2857
- }
2858
- function trimHintFor(manifest, selected, limitBytes = DEFAULT_MAX_BYTES) {
2859
- const ticked = new Set(selected);
2860
- const droppable = manifest.units.filter((unit) => ticked.has(unit.id) && unit.kind === "conversation").sort((left, right) => right.bytes - left.bytes);
2861
- let remaining = manifest.units.filter((unit) => ticked.has(unit.id)).reduce((sum, unit) => sum + (unit.bytes || 0), 0);
2862
- if (remaining <= limitBytes) return void 0;
2863
- const named = [];
2864
- for (const unit of droppable) {
2865
- if (named.length >= droppable.length - 1) break;
2866
- remaining -= unit.bytes || 0;
2867
- named.push(`${unit.id} (${formatBytes(unit.bytes || 0)})`);
2868
- if (remaining <= limitBytes) break;
2869
- }
2870
- if (remaining > limitBytes) {
2871
- return `Even with one conversation left this is over ${formatBytes(limitBytes)}. Untick a derived file, record a shorter reproduction, or file the issue and attach the zip by hand.`;
2872
- }
2873
- return `Untick ${joinWithAnd(named)} to fit.`;
2874
- }
2875
- function joinWithAnd(parts) {
2876
- if (parts.length <= 1) return parts[0] ?? "";
2877
- return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
2878
- }
2879
-
2880
3072
  // src/core/bugReport/issueBody.ts
2881
3073
  function buildIssueBody(args) {
2882
3074
  const { fields, manifest, evidence } = args;
@@ -2995,6 +3187,186 @@ function encodeBase64(bytes) {
2995
3187
  return btoa(binary);
2996
3188
  }
2997
3189
 
3190
+ // src/core/artifacts/resolvers.ts
3191
+ var ARTIFACT_HEAD_OP = "artifact-head";
3192
+ var ARTIFACT_GET_OP = "artifact-get";
3193
+ var NOT_FOUND_CODE = "ERR_ARTIFACT_NOT_FOUND";
3194
+ function httpArtifactResolver(options) {
3195
+ if (typeof options?.url !== "string" || options.url.trim().length === 0) {
3196
+ throw new Error(
3197
+ "httpArtifactResolver needs `url` \u2014 the served agent's invoke path, the same one conversation turns POST to (e.g. '/invoke' or 'https://host/invoke')."
3198
+ );
3199
+ }
3200
+ const doFetch = options.fetch ?? globalThis.fetch;
3201
+ if (typeof doFetch !== "function") {
3202
+ throw new Error(
3203
+ "httpArtifactResolver found no fetch: this runtime has no global fetch and none was passed. Pass one via { fetch } (Node < 18 needs a polyfill)."
3204
+ );
3205
+ }
3206
+ const redeem = async (op, ref) => {
3207
+ let response;
3208
+ try {
3209
+ response = await doFetch(options.url, {
3210
+ method: "POST",
3211
+ headers: { "content-type": "application/json", ...options.headers },
3212
+ body: JSON.stringify({
3213
+ op,
3214
+ ref,
3215
+ ...options.sessionId !== void 0 && { sessionId: options.sessionId },
3216
+ ...options.userId !== void 0 && { userId: options.userId }
3217
+ })
3218
+ });
3219
+ } catch (err) {
3220
+ return {
3221
+ status: "failed",
3222
+ message: `Could not reach ${options.url} to redeem '${ref}' (${err instanceof Error ? err.name || "network error" : "network error"}).`
3223
+ };
3224
+ }
3225
+ let body;
3226
+ try {
3227
+ body = await response.json();
3228
+ } catch {
3229
+ return {
3230
+ status: "failed",
3231
+ message: `${options.url} answered ${response.status} with a body that is not JSON \u2014 not an artifact wire reply.`
3232
+ };
3233
+ }
3234
+ const artifact = body?.artifact;
3235
+ if (artifact !== null && typeof artifact === "object") {
3236
+ const parcel = artifact;
3237
+ const meta = parcel.meta;
3238
+ if (meta === null || typeof meta !== "object" || typeof meta.kind !== "string") {
3239
+ return {
3240
+ status: "failed",
3241
+ message: `${options.url} answered with an artifact body carrying no readable meta \u2014 not the published wire shape.`
3242
+ };
3243
+ }
3244
+ return {
3245
+ status: "live",
3246
+ meta,
3247
+ ...op === ARTIFACT_GET_OP && "data" in parcel && { data: parcel.data }
3248
+ };
3249
+ }
3250
+ const code = typeof body?.code === "string" ? body.code : void 0;
3251
+ if (code === NOT_FOUND_CODE) return { status: "absent" };
3252
+ const message = typeof body?.error === "string" ? body.error : `${options.url} answered ${response.status} with no error message.`;
3253
+ return { status: "failed", message, ...code !== void 0 && { code } };
3254
+ };
3255
+ return {
3256
+ head: (ref) => redeem(ARTIFACT_HEAD_OP, ref),
3257
+ get: (ref) => redeem(ARTIFACT_GET_OP, ref)
3258
+ };
3259
+ }
3260
+ function storeArtifactResolver(options) {
3261
+ const store = options?.store;
3262
+ if (store === null || typeof store !== "object" || typeof store.head !== "function" || typeof store.get !== "function") {
3263
+ throw new Error(
3264
+ "storeArtifactResolver needs `store` \u2014 an object with the artifact read verbs `head(scope, ref)` and `get(scope, ref)` (any agentfootprint artifact store qualifies)."
3265
+ );
3266
+ }
3267
+ if (!("scope" in options)) {
3268
+ throw new Error(
3269
+ 'storeArtifactResolver needs `scope` \u2014 the isolation tuple the refs were minted under. A wrong scope resolves to "no data" by design, so it must be stated, never guessed.'
3270
+ );
3271
+ }
3272
+ const failed = (ref, err) => ({
3273
+ status: "failed",
3274
+ message: err instanceof Error && err.message ? err.message : `The store threw while redeeming '${ref}'.`
3275
+ });
3276
+ return {
3277
+ async head(ref) {
3278
+ try {
3279
+ const meta = await store.head(options.scope, ref);
3280
+ return meta === null ? { status: "absent" } : { status: "live", meta };
3281
+ } catch (err) {
3282
+ return failed(ref, err);
3283
+ }
3284
+ },
3285
+ async get(ref) {
3286
+ try {
3287
+ const record = await store.get(options.scope, ref);
3288
+ return record === null ? { status: "absent" } : { status: "live", meta: record.meta, data: record.data };
3289
+ } catch (err) {
3290
+ return failed(ref, err);
3291
+ }
3292
+ }
3293
+ };
3294
+ }
3295
+
3296
+ // src/core/artifacts/presented.ts
3297
+ var PRESENTED_EVENT_TYPE = "agentfootprint.artifacts.presented";
3298
+ function readSnapshot(value) {
3299
+ if (value === null || typeof value !== "object") return void 0;
3300
+ const s = value;
3301
+ if (typeof s.kind !== "string" || typeof s.mediaType !== "string" || typeof s.bytes !== "number")
3302
+ return void 0;
3303
+ return {
3304
+ kind: s.kind,
3305
+ mediaType: s.mediaType,
3306
+ bytes: s.bytes,
3307
+ ...typeof s.label === "string" && { label: s.label }
3308
+ };
3309
+ }
3310
+ function readPresentedShape(value) {
3311
+ if (typeof value.ref !== "string" || typeof value.as !== "string") return void 0;
3312
+ const snapshot = readSnapshot(value.snapshot);
3313
+ if (!snapshot) return void 0;
3314
+ return { ref: value.ref, as: value.as, snapshot };
3315
+ }
3316
+ function readPresentedResult(value) {
3317
+ let parsed = value;
3318
+ if (typeof value === "string") {
3319
+ try {
3320
+ parsed = JSON.parse(value);
3321
+ } catch {
3322
+ return void 0;
3323
+ }
3324
+ }
3325
+ if (parsed === null || typeof parsed !== "object") return void 0;
3326
+ const record = parsed;
3327
+ if (record.presented !== true) return void 0;
3328
+ const trunk = readPresentedShape(record);
3329
+ if (!trunk) return void 0;
3330
+ return {
3331
+ ...trunk,
3332
+ ...typeof record.toolCallId === "string" && { toolCallId: record.toolCallId }
3333
+ };
3334
+ }
3335
+ function presentedFromEvents(events) {
3336
+ if (!events) return [];
3337
+ const found = [];
3338
+ for (const item of events) {
3339
+ if (item === null || typeof item !== "object") continue;
3340
+ const wrapper = item;
3341
+ const event = typeof wrapper.type === "string" ? wrapper : wrapper.event !== null && typeof wrapper.event === "object" ? wrapper.event : void 0;
3342
+ if (!event || event.type !== PRESENTED_EVENT_TYPE) continue;
3343
+ const payload = event.payload;
3344
+ if (payload === null || typeof payload !== "object") continue;
3345
+ const record = payload;
3346
+ const trunk = readPresentedShape(record);
3347
+ if (!trunk) continue;
3348
+ found.push({
3349
+ ...trunk,
3350
+ ...typeof record.toolCallId === "string" && { toolCallId: record.toolCallId },
3351
+ ...typeof record.iteration === "number" && { iteration: record.iteration }
3352
+ });
3353
+ }
3354
+ return found;
3355
+ }
3356
+ function kindNoun(kind) {
3357
+ const head = kind.split("/")[0]?.trim() ?? "";
3358
+ if (head.length === 0) return "Artifact";
3359
+ return head[0].toUpperCase() + head.slice(1);
3360
+ }
3361
+ function describePresented(presented) {
3362
+ const { snapshot } = presented;
3363
+ const name = snapshot.label !== void 0 ? `"${snapshot.label}"` : presented.ref;
3364
+ return `${kindNoun(snapshot.kind)} \u2014 ${name} (${presented.as}, ${formatBytes(snapshot.bytes)})`;
3365
+ }
3366
+ function artifactPlaceholder(presented) {
3367
+ return `${describePresented(presented)} \u2014 expired; re-run to regenerate.`;
3368
+ }
3369
+
2998
3370
  export {
2999
3371
  ChangeNotifier,
3000
3372
  lensSnapshotRecorder,
@@ -3005,6 +3377,13 @@ export {
3005
3377
  observeRecording,
3006
3378
  buildStepGraphFromSnapshot,
3007
3379
  buildSpecTreeFromBoundary,
3380
+ DEFAULT_MAX_BYTES,
3381
+ formatBytes,
3382
+ conversationUnits,
3383
+ fileUnits,
3384
+ defaultSelection,
3385
+ measureSelection,
3386
+ trimHintFor,
3008
3387
  defaultHumanizer,
3009
3388
  humanizeWith,
3010
3389
  makeTeachingHumanizer,
@@ -3041,18 +3420,17 @@ export {
3041
3420
  toReactFlow,
3042
3421
  defaultSize,
3043
3422
  layoutLensGraph,
3044
- DEFAULT_MAX_BYTES,
3045
- formatBytes,
3046
- conversationUnits,
3047
- fileUnits,
3048
- defaultSelection,
3049
- measureSelection,
3050
- trimHintFor,
3051
3423
  buildIssueBody,
3052
3424
  parseGithubRepo,
3053
3425
  MAX_ISSUE_URL_BYTES,
3054
3426
  TRUNCATION_NOTICE,
3055
3427
  buildNewIssueUrl,
3056
- encodeBase64
3428
+ encodeBase64,
3429
+ httpArtifactResolver,
3430
+ storeArtifactResolver,
3431
+ readPresentedResult,
3432
+ presentedFromEvents,
3433
+ describePresented,
3434
+ artifactPlaceholder
3057
3435
  };
3058
- //# sourceMappingURL=chunk-QBSH233H.js.map
3436
+ //# sourceMappingURL=chunk-ZSOYNKUI.js.map