loopengine 0.1.50 → 0.1.52

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.
@@ -200,7 +200,14 @@ export const playgroundHtml = `<!doctype html>
200
200
  image's real visible edge instead of floating in empty space beside
201
201
  a narrower image inside a full-width box. */
202
202
  .msg-preview-wrap { position: relative; display: inline-block; max-width: 100%; margin: 6px 0; }
203
- .msg-preview-img { display: block; max-width: 100%; max-height: 260px; width: auto; height: auto; border-radius: 8px; cursor: zoom-in; }
203
+ /* .msg-plain-img (no download link — see renderMessageMarkdown's own
204
+ doc comment) gets the same capped size and click-to-zoom as the
205
+ merged .msg-preview-img, just without the corner download icon and
206
+ without a wrap element of its own (nothing needs to be positioned
207
+ relative to it) — see the chatPane click handler below and
208
+ openImageLightbox's own handling of a missing downloadHref. */
209
+ .msg-preview-img, .msg-plain-img { display: block; max-width: 100%; max-height: 260px; width: auto; height: auto; border-radius: 8px; cursor: zoom-in; }
210
+ .msg-plain-img { margin: 6px 0; }
204
211
  .msg-download-icon {
205
212
  position: absolute;
206
213
  top: 8px;
@@ -405,6 +412,42 @@ export const playgroundHtml = `<!doctype html>
405
412
  // target for one that happens to reuse an id namespace.
406
413
  var toolCallCardsById = {};
407
414
 
415
+ // Keyed by a signed URL's own base (everything before its "?") —
416
+ // populated from every 'toollane:result' event's own 'summary' (see
417
+ // core/loop-events.ts's own ToolLaneResultEvent doc comment: the raw,
418
+ // unparsed tool return value, straight from execute() with nothing
419
+ // reshaped in between). A long signed-URL query string (a GCS
420
+ // Signature param can run ~300 opaque base64 characters) is exactly
421
+ // the kind of thing an LLM can flip one character of while generating
422
+ // its own reply text from the same value earlier in its context — see
423
+ // correctKnownUrls below, which snaps a written URL back to this
424
+ // known-good one whenever the base (the object path — descriptive,
425
+ // not random, so far less likely to get mistyped) matches but the
426
+ // full string doesn't. Cleared on every conversation switch, same as
427
+ // toolCallCardsById above — a base from an unrelated earlier session
428
+ // is never a valid correction target (and its signed URL has likely
429
+ // expired anyway).
430
+ var knownUrlsByBase = {};
431
+
432
+ // Recursively walks an arbitrary JSON value (a tool's own summary,
433
+ // parsed) collecting every string that looks like a signed URL — has
434
+ // a "?" (nothing to correct in a bare URL with no query to corrupt).
435
+ // Last one seen for a given base wins, which is fine: a re-signed URL
436
+ // for the same object is just as valid a correction target as the
437
+ // first one seen.
438
+ function recordKnownUrls(value) {
439
+ if (typeof value === 'string') {
440
+ if (value.indexOf('https://') === 0 || value.indexOf('http://') === 0) {
441
+ var qIndex = value.indexOf('?');
442
+ if (qIndex !== -1) knownUrlsByBase[value.slice(0, qIndex)] = value;
443
+ }
444
+ } else if (Array.isArray(value)) {
445
+ for (var i = 0; i < value.length; i++) recordKnownUrls(value[i]);
446
+ } else if (value && typeof value === 'object') {
447
+ for (var key in value) recordKnownUrls(value[key]);
448
+ }
449
+ }
450
+
408
451
  // A page refresh only ever loses *this tab's* JS state — the actual
409
452
  // conversation is durably stored server-side (see session-store.ts).
410
453
  // What's missing after a refresh is just "which session ids did I
@@ -456,8 +499,30 @@ export const playgroundHtml = `<!doctype html>
456
499
  // attachment — a real HTTP response header, so a plain click already
457
500
  // downloads correctly regardless of origin, no JS fetch/blob trick or
458
501
  // same-origin requirement needed.
502
+ //
503
+ // Before any of that: correctKnownUrls (see knownUrlsByBase's own doc
504
+ // comment) snaps any signed URL the model wrote back to the exact
505
+ // known-good one whenever they share a base but differ — recovers a
506
+ // broken image/download link from a transcription slip without
507
+ // needing the model to get a ~300-character signature exactly right.
508
+ //
509
+ // Built from a runtime backslash character, never a literal
510
+ // backslash-letter sequence in this file's own source — same gotcha
511
+ // renderMessageMarkdown's own patterns below document in full (this
512
+ // page is one big outer template literal whose own output is itself
513
+ // parsed as JS a second time).
514
+ var urlBs = String.fromCharCode(92);
515
+ var urlPattern = new RegExp('https?:' + urlBs + '/' + urlBs + '/[^' + urlBs + 's)' + urlBs + ']]+', 'g');
516
+ function correctKnownUrls(md) {
517
+ return md.replace(urlPattern, function (url) {
518
+ var qIndex = url.indexOf('?');
519
+ if (qIndex === -1) return url;
520
+ var known = knownUrlsByBase[url.slice(0, qIndex)];
521
+ return known || url;
522
+ });
523
+ }
459
524
  function renderMessageMarkdown(md) {
460
- var lines = escapeHtml(md).split('\\n');
525
+ var lines = escapeHtml(correctKnownUrls(md)).split('\\n');
461
526
  var html = '';
462
527
  var inCode = false;
463
528
  var codeBuffer = '';
@@ -488,7 +553,7 @@ export const playgroundHtml = `<!doctype html>
488
553
  .replace(inlineCodePattern, '<code>$1</code>')
489
554
  .replace(boldPattern, '<strong>$1</strong>')
490
555
  .replace(italicPattern, '<em>$1</em>')
491
- .replace(imagePattern, '<img src="$2" alt="$1" class="msg-plain-img">')
556
+ .replace(imagePattern, '<img src="$2" alt="$1" class="msg-plain-img" data-full-src="$2">')
492
557
  .replace(linkPattern, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
493
558
  }
494
559
  function flushParagraph() {
@@ -571,7 +636,7 @@ export const playgroundHtml = `<!doctype html>
571
636
  // isn't a recognized string escape character either. A plain string
572
637
  // needs no such escaping for "/" at all.
573
638
  var previewMergePattern = new RegExp(
574
- '<img src="([^"]*)" alt="([^"]*)" class="msg-plain-img">' + bs + 's*<a href="([^"]*)"[^>]*>[^<]*</a>',
639
+ '<img src="([^"]*)" alt="([^"]*)" class="msg-plain-img" data-full-src="[^"]*">' + bs + 's*<a href="([^"]*)"[^>]*>[^<]*</a>',
575
640
  'g',
576
641
  );
577
642
  html = html.replace(previewMergePattern, function (match, src, alt, href) {
@@ -591,7 +656,16 @@ export const playgroundHtml = `<!doctype html>
591
656
 
592
657
  function openImageLightbox(src, downloadHref) {
593
658
  lightboxImage.src = src;
594
- lightboxDownload.href = downloadHref;
659
+ // A plain image (no download link — see renderMessageMarkdown's own
660
+ // doc comment on .msg-plain-img) has no downloadHref at all; hiding
661
+ // the button beats pointing it at '' (a dead link that would just
662
+ // reload the page) or at src (opens inline, not an actual download).
663
+ if (downloadHref) {
664
+ lightboxDownload.href = downloadHref;
665
+ lightboxDownload.style.display = '';
666
+ } else {
667
+ lightboxDownload.style.display = 'none';
668
+ }
595
669
  lightboxOverlay.classList.add('open');
596
670
  }
597
671
  function closeImageLightbox() {
@@ -616,7 +690,7 @@ export const playgroundHtml = `<!doctype html>
616
690
  // rewired after each new message.
617
691
  chatPane.addEventListener('click', function (ev) {
618
692
  var target = ev.target;
619
- if (target && target.classList && target.classList.contains('msg-preview-img')) {
693
+ if (target && target.classList && (target.classList.contains('msg-preview-img') || target.classList.contains('msg-plain-img'))) {
620
694
  openImageLightbox(target.getAttribute('data-full-src'), target.getAttribute('data-download-href'));
621
695
  }
622
696
  });
@@ -1168,6 +1242,7 @@ export const playgroundHtml = `<!doctype html>
1168
1242
  sessionLabel.classList.add('copyable');
1169
1243
  chatPane.textContent = '';
1170
1244
  toolCallCardsById = {};
1245
+ knownUrlsByBase = {};
1171
1246
  setEmptyHint(timelinePane, 'Loop events (tool calls, permission checks, budget checks) will appear here as the agent runs.');
1172
1247
  renderSessionsPane();
1173
1248
 
@@ -1318,6 +1393,7 @@ export const playgroundHtml = `<!doctype html>
1318
1393
  function resetConversation() {
1319
1394
  sessionId = null;
1320
1395
  toolCallCardsById = {};
1396
+ knownUrlsByBase = {};
1321
1397
  removeThinking();
1322
1398
  sessionLabel.textContent = 'session: (new)';
1323
1399
  sessionLabel.classList.remove('copyable');
@@ -1451,6 +1527,19 @@ export const playgroundHtml = `<!doctype html>
1451
1527
  removeThinking();
1452
1528
  appendQuestionCard(data);
1453
1529
  appendTimelineEntry(eventName, data);
1530
+ } else if (eventName === 'toollane:result') {
1531
+ // data.summary is a tool's raw, unreshaped return value (see
1532
+ // knownUrlsByBase's own doc comment above) — a JSON string for
1533
+ // this ability, but tools generally can return plain text too, so
1534
+ // a parse failure here just means nothing to record, not an error.
1535
+ if (typeof data.summary === 'string') {
1536
+ try {
1537
+ recordKnownUrls(JSON.parse(data.summary));
1538
+ } catch (err) {
1539
+ // Not JSON — nothing to correct against.
1540
+ }
1541
+ }
1542
+ appendTimelineEntry(eventName, data);
1454
1543
  } else {
1455
1544
  appendTimelineEntry(eventName, data);
1456
1545
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",