@shield-agent/kya 0.19.0 → 0.21.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 (55) hide show
  1. package/.mcp.json +1 -1
  2. package/README.md +21 -5
  3. package/amp/settings.example.json +1 -1
  4. package/claude/claude_desktop_config.example.json +1 -1
  5. package/cline/cline_mcp_settings.example.json +1 -1
  6. package/copilot/mcp-config.example.json +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +23 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands/receipt.d.ts +13 -0
  11. package/dist/commands/receipt.d.ts.map +1 -1
  12. package/dist/commands/receipt.js +20 -0
  13. package/dist/commands/receipt.js.map +1 -1
  14. package/dist/receipt/embeddings.d.ts +34 -0
  15. package/dist/receipt/embeddings.d.ts.map +1 -0
  16. package/dist/receipt/embeddings.js +140 -0
  17. package/dist/receipt/embeddings.js.map +1 -0
  18. package/dist/receipt/live-server.d.ts +3 -0
  19. package/dist/receipt/live-server.d.ts.map +1 -1
  20. package/dist/receipt/live-server.js +24 -3
  21. package/dist/receipt/live-server.js.map +1 -1
  22. package/dist/receipt/receipt-css.d.ts.map +1 -1
  23. package/dist/receipt/receipt-css.js +29 -0
  24. package/dist/receipt/receipt-css.js.map +1 -1
  25. package/dist/receipt/render-receipt.d.ts +4 -1
  26. package/dist/receipt/render-receipt.d.ts.map +1 -1
  27. package/dist/receipt/render-receipt.js +199 -67
  28. package/dist/receipt/render-receipt.js.map +1 -1
  29. package/dist/receipt/search.d.ts +79 -0
  30. package/dist/receipt/search.d.ts.map +1 -0
  31. package/dist/receipt/search.js +475 -0
  32. package/dist/receipt/search.js.map +1 -0
  33. package/dist/receipt/share.d.ts +65 -0
  34. package/dist/receipt/share.d.ts.map +1 -0
  35. package/dist/receipt/share.js +109 -0
  36. package/dist/receipt/share.js.map +1 -0
  37. package/dist/star-cta.d.ts +14 -0
  38. package/dist/star-cta.d.ts.map +1 -0
  39. package/dist/star-cta.js +51 -0
  40. package/dist/star-cta.js.map +1 -0
  41. package/droid/README.md +1 -1
  42. package/droid/mcp.example.json +1 -1
  43. package/gemini/settings.example.json +1 -1
  44. package/grok/README.md +1 -1
  45. package/kilo/kilo.example.json +1 -1
  46. package/kimi/mcp.example.json +1 -1
  47. package/kiro/mcp.example.json +1 -1
  48. package/manifest.json +1 -1
  49. package/mastracode/mcp.example.json +1 -1
  50. package/mcp.json +1 -1
  51. package/openai/codex.config.example.toml +1 -1
  52. package/opencode/opencode.example.json +1 -1
  53. package/package.json +14 -3
  54. package/qwen/settings.example.json +1 -1
  55. package/server.json +2 -2
@@ -12,6 +12,7 @@ import { computeDashboard } from "./dashboard.js";
12
12
  import { buildChangesModel } from "./changes.js";
13
13
  import { GATE_NOT_SETUP_QUICKSTART, loadGatePage, toolNameFromToolId, toolServerPrefix, } from "./gate-page.js";
14
14
  import { receiptCss } from "./receipt-css.js";
15
+ import { applyChipFilters, rankEvents } from "./search.js";
15
16
  export const PAGE_SIZE = 50;
16
17
  export function clampPage(page, total) {
17
18
  if (total === 0)
@@ -215,6 +216,7 @@ export function buildReceiptModel(sessionId, events, extras) {
215
216
  mcpSeen: extras?.mcpSeen,
216
217
  live: extras?.live,
217
218
  liveToken: extras?.liveToken,
219
+ searchQuery: extras?.searchQuery,
218
220
  identity: extras?.identity,
219
221
  sandboxes: extras?.sandboxes,
220
222
  wiredHosts: extras?.wiredHosts,
@@ -236,6 +238,7 @@ export function buildWindowReceiptModel(events, days, extras) {
236
238
  mcpSeen: extras?.mcpSeen,
237
239
  live: extras?.live,
238
240
  liveToken: extras?.liveToken,
241
+ searchQuery: extras?.searchQuery,
239
242
  identity: extras?.identity,
240
243
  sandboxes: extras?.sandboxes,
241
244
  wiredHosts: extras?.wiredHosts,
@@ -256,6 +259,7 @@ export function loadReceiptModel(input) {
256
259
  page: input.page ?? 1,
257
260
  live: input.live,
258
261
  liveToken: input.liveToken,
262
+ searchQuery: input.searchQuery,
259
263
  identity: loadIdentity(input.cwd),
260
264
  sandboxes: loadSandboxes(input.cwd),
261
265
  wiredHosts: loadWiredHosts(input.cwd),
@@ -309,17 +313,17 @@ function projectMeta(project) {
309
313
  <span class="dot">-</span>
310
314
  <span class="project">${esc(clip(p, 40))}</span>`;
311
315
  }
312
- function renderFeed(events, nowMs, live) {
316
+ function renderFeed(events, nowMs, live, preserveOrder) {
313
317
  if (events.length === 0) {
314
318
  const hint = live
315
319
  ? "No events yet — evaluate a tool call with <code>kya wrap</code> and this page will refresh."
316
320
  : "No events yet — evaluate a tool call with <code>kya wrap</code>, then regenerate this receipt.";
317
321
  return `<p class="empty">${hint}</p>`;
318
322
  }
319
- const sorted = [...events].sort((a, b) => b.ts.localeCompare(a.ts));
323
+ const ordered = preserveOrder ? [...events] : [...events].sort((a, b) => b.ts.localeCompare(a.ts));
320
324
  const parts = [];
321
325
  let lastDay = "";
322
- for (const e of sorted) {
326
+ for (const e of ordered) {
323
327
  const key = localDayKey(e.ts);
324
328
  if (key !== lastDay) {
325
329
  lastDay = key;
@@ -1273,52 +1277,32 @@ function normalizeTrailEvents(events) {
1273
1277
  targetPath: e.targetPath ? clip(e.targetPath, 80) : undefined,
1274
1278
  }));
1275
1279
  }
1276
- function eventMatchesFilter(e, group, value) {
1277
- switch (group) {
1278
- case "verdict":
1279
- return e.verdict.toUpperCase() === value;
1280
- case "never":
1281
- return (e.neverEvent || e.reasonCode === "NEVER_EVENT") && value === "1";
1282
- case "mode":
1283
- return e.mode === value;
1284
- case "plane":
1285
- return (e.host?.trim() || "unknown") === value;
1286
- case "product":
1287
- return (e.product ?? "other") === value;
1288
- case "project":
1289
- if (value === "")
1290
- return false;
1291
- return (e.project?.trim() ?? "") === value;
1292
- case "tool":
1293
- return clip(e.toolId, 60) === value;
1294
- case "session":
1295
- return (e.sessionId || "unknown") === value;
1296
- case "server": {
1297
- const parsed = parseMcpToolId(e.toolId);
1298
- return parsed != null && mcpServerLabel(parsed.server) === value;
1299
- }
1300
- default:
1301
- return false;
1280
+ export function renderFeedPage(events, filters, page, nowMs, live, query, embeddings, queryEmbedding) {
1281
+ const q = query?.trim();
1282
+ let ordered;
1283
+ let total;
1284
+ if (q) {
1285
+ const ranked = rankEvents(events, {
1286
+ query: q,
1287
+ filters,
1288
+ embeddings,
1289
+ queryEmbedding,
1290
+ facetBoost: 0.05,
1291
+ });
1292
+ ordered = ranked.map((r) => r.event);
1293
+ total = ranked.length;
1294
+ }
1295
+ else {
1296
+ const sorted = [...events].sort((a, b) => b.ts.localeCompare(a.ts));
1297
+ ordered = applyChipFilters(sorted, filters);
1298
+ total = ordered.length;
1302
1299
  }
1303
- }
1304
- function applyFeedFilters(events, filters) {
1305
- const groups = Object.entries(filters)
1306
- .map(([group, vals]) => [group, vals.filter((v) => v !== "")])
1307
- .filter(([, vals]) => vals.length > 0);
1308
- if (groups.length === 0)
1309
- return [...events];
1310
- return events.filter((e) => groups.every(([group, vals]) => vals.some((v) => eventMatchesFilter(e, group, v))));
1311
- }
1312
- export function renderFeedPage(events, filters, page, nowMs, live) {
1313
- const sorted = [...events].sort((a, b) => b.ts.localeCompare(a.ts));
1314
- const filtered = applyFeedFilters(sorted, filters);
1315
- const total = filtered.length;
1316
1300
  const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
1317
1301
  const p = clampPage(page, pages);
1318
1302
  const start = (p - 1) * PAGE_SIZE;
1319
- const pageEvents = filtered.slice(start, start + PAGE_SIZE);
1303
+ const pageEvents = ordered.slice(start, start + PAGE_SIZE);
1320
1304
  return {
1321
- feedHtml: renderFeed(pageEvents, nowMs, live),
1305
+ feedHtml: renderFeed(pageEvents, nowMs, live, !!q),
1322
1306
  paginationHtml: paginationBar(total, p, pages),
1323
1307
  total,
1324
1308
  page: p,
@@ -1358,7 +1342,7 @@ export function renderReceiptHtml(model) {
1358
1342
  ? clampPage(model.page ?? 1, Math.max(1, Math.ceil(events.length / PAGE_SIZE)))
1359
1343
  : 1;
1360
1344
  const initialFeed = livePaged
1361
- ? renderFeedPage(events, {}, currentPage, nowMs, model.live ?? false)
1345
+ ? renderFeedPage(events, {}, currentPage, nowMs, model.live ?? false, model.searchQuery)
1362
1346
  : {
1363
1347
  feedHtml: renderFeed(events, nowMs, model.live ?? false),
1364
1348
  paginationHtml: `<div class="pagination-bar"><span class="mute">${events.length} event${events.length === 1 ? "" : "s"}</span></div>`,
@@ -1402,18 +1386,17 @@ export function renderReceiptHtml(model) {
1402
1386
  })();
1403
1387
  </script>`
1404
1388
  : "";
1405
- // Client-side chip filters and pagination: only emitted for live reports
1406
- // (liveToken present). Static receipts render page 1 without interactivity.
1407
- // State maps are null-prototype: attacker-controlled values (project names)
1408
- // must never resolve via Object.prototype. The id lets tests extract just
1409
- // this script. KNOWN is space-delimited so `indexOf(' '+g+' ')` doubles as a
1410
- // prototype-safe whitelist ('constructor' etc. never match).
1411
- // Filter state persists in the ?f= QUERY param, not the hash: the hash is
1412
- // owned by the pure-CSS :target tabs (#overview/#changes/#certify/#activity/
1413
- // #system), so a hash-based filter would hide the Activity zone on every
1414
- // chip toggle and every tab click would wipe the filter. Legacy #f= hashes
1415
- // are still parsed on load (read-only); the next save writes the ?f= form
1416
- // and drops the legacy fragment.
1389
+ // Client-side chip filters, pagination, and search: only emitted for live
1390
+ // reports (liveToken present). Static receipts render a separate BM25-only
1391
+ // search script below. State maps are null-prototype: attacker-controlled
1392
+ // values (project names) must never resolve via Object.prototype. The id lets
1393
+ // tests extract just this script. KNOWN is space-delimited so
1394
+ // `indexOf(' '+g+' ')` doubles as a prototype-safe whitelist ('constructor'
1395
+ // etc. never match). Filter state persists in the ?f= QUERY param, not the
1396
+ // hash: the hash is owned by the pure-CSS :target tabs, so a hash-based
1397
+ // filter would hide the Activity zone on every chip toggle. Legacy #f= hashes
1398
+ // are still parsed on load (read-only); the next save writes the ?f= form and
1399
+ // drops the legacy fragment.
1417
1400
  const filterScript = model.liveToken
1418
1401
  ? `<script id="kya-filters">
1419
1402
  (function(){
@@ -1421,9 +1404,12 @@ export function renderReceiptHtml(model) {
1421
1404
  if (!feed) return;
1422
1405
  var chg = document.getElementById('changes-list');
1423
1406
  var bar = document.getElementById('pagination-bar');
1407
+ var searchInput = document.getElementById('feed-search');
1408
+ var searchMeta = document.querySelector('.feed-search-meta');
1424
1409
  var KNOWN = ' verdict never mode plane product project tool session server ';
1425
1410
  var state = Object.create(null);
1426
1411
  var page = 1;
1412
+ var q = '';
1427
1413
  var LIVE = !!(document.querySelectorAll && document.querySelectorAll('main[data-live-token]').length);
1428
1414
  var clearBtn = document.getElementById('clear-filters');
1429
1415
  var chips = document.querySelectorAll('[data-fgroup]');
@@ -1441,12 +1427,16 @@ export function renderReceiptHtml(model) {
1441
1427
  function parse(){
1442
1428
  state = Object.create(null);
1443
1429
  page = 1;
1430
+ q = '';
1444
1431
  var raw = null;
1445
1432
  var s = location.search;
1446
1433
  if (s.indexOf('?') === 0) {
1447
1434
  var params = s.slice(1).split('&');
1448
1435
  for (var i = 0; i < params.length; i++) {
1449
1436
  if (raw === null && params[i].indexOf('f=') === 0) { raw = params[i].slice(2); }
1437
+ else if (params[i].indexOf('q=') === 0) {
1438
+ try { q = decodeURIComponent(params[i].slice(2).replace(/\\+/g, ' ')); } catch(e){ q = params[i].slice(2); }
1439
+ }
1450
1440
  else if (params[i].indexOf('page=') === 0) {
1451
1441
  var p = parseInt(params[i].slice(5), 10);
1452
1442
  if (p > 0) page = p;
@@ -1481,19 +1471,18 @@ export function renderReceiptHtml(model) {
1481
1471
  if (s.indexOf('?') === 0) {
1482
1472
  var params = s.slice(1).split('&');
1483
1473
  for (var i = 0; i < params.length; i++) {
1484
- if (params[i] && params[i].indexOf('f=') !== 0 && params[i].indexOf('page=') !== 0) kept.push(params[i]);
1474
+ if (params[i] && params[i].indexOf('f=') !== 0 && params[i].indexOf('q=') !== 0 && params[i].indexOf('page=') !== 0) kept.push(params[i]);
1485
1475
  }
1486
1476
  }
1487
1477
  if (parts.length) kept.push('f=' + parts.join(','));
1478
+ if (q) kept.push('q=' + encodeURIComponent(q));
1488
1479
  if (page > 1) kept.push('page=' + page);
1489
- var q = kept.length ? '?' + kept.join('&') : '';
1490
- // The tab hash rides along untouched; a legacy #f= fragment is not a tab
1491
- // hash and is dropped now that the state lives in the query.
1480
+ var qq = kept.length ? '?' + kept.join('&') : '';
1492
1481
  var h = location.hash.indexOf('#f=') === 0 ? '' : location.hash;
1493
- history.replaceState(null, '', location.pathname + q + h);
1482
+ history.replaceState(null, '', location.pathname + qq + h);
1494
1483
  }
1495
1484
  function active(){
1496
- return Object.keys(state).some(function(g){ return Object.keys(state[g]).length > 0; });
1485
+ return Object.keys(state).some(function(g){ return Object.keys(state[g]).length > 0; }) || !!q;
1497
1486
  }
1498
1487
  // AND across groups, OR within a group; an unstamped facet never matches.
1499
1488
  function rowVisible(row, on){
@@ -1505,7 +1494,7 @@ export function renderReceiptHtml(model) {
1505
1494
  return true;
1506
1495
  }
1507
1496
  function apply(){
1508
- var on = active();
1497
+ var on = Object.keys(state).some(function(g){ return Object.keys(state[g]).length > 0; });
1509
1498
  // Guard: the script is only emitted for live reports, so the feed is
1510
1499
  // always server-rendered; changes entries still filter client-side.
1511
1500
  if (!LIVE) {
@@ -1536,7 +1525,13 @@ export function renderReceiptHtml(model) {
1536
1525
  var pressed = !!(state[chip.dataset.fgroup] && state[chip.dataset.fgroup][chip.dataset.fvalue]);
1537
1526
  chip.setAttribute('aria-pressed', pressed ? 'true' : 'false');
1538
1527
  });
1539
- if (clearBtn) clearBtn.hidden = !on;
1528
+ if (clearBtn) clearBtn.hidden = !active();
1529
+ }
1530
+ function updateMeta(total){
1531
+ if (!searchMeta) return;
1532
+ if (!q) { searchMeta.style.display = 'none'; return; }
1533
+ searchMeta.textContent = (total || 0) + ' matches · ranked by relevance';
1534
+ searchMeta.style.display = '';
1540
1535
  }
1541
1536
  function feedUpdate(){
1542
1537
  var T = readToken();
@@ -1544,12 +1539,13 @@ export function renderReceiptHtml(model) {
1544
1539
  fetch('/feed?t=' + encodeURIComponent(T), {
1545
1540
  method: 'POST',
1546
1541
  headers: { 'Content-Type': 'application/json' },
1547
- body: JSON.stringify({ filters: state, page: page })
1542
+ body: JSON.stringify({ filters: state, page: page, q: q })
1548
1543
  })
1549
1544
  .then(function(r){ return r.json(); })
1550
1545
  .then(function(data){
1551
1546
  if (feed && data.feedHtml) feed.innerHTML = data.feedHtml;
1552
1547
  if (bar && data.paginationHtml) bar.innerHTML = data.paginationHtml;
1548
+ updateMeta(data.total);
1553
1549
  })
1554
1550
  .catch(function(err){ console.error('KYA feed update failed', err); });
1555
1551
  }
@@ -1582,10 +1578,139 @@ export function renderReceiptHtml(model) {
1582
1578
  feedUpdate();
1583
1579
  });
1584
1580
  }
1585
- if (clearBtn) clearBtn.addEventListener('click', function(){ state = Object.create(null); page = 1; save(); apply(); if (LIVE) feedUpdate(); });
1581
+ if (searchInput) {
1582
+ var debounce = 0;
1583
+ searchInput.addEventListener('input', function(){
1584
+ q = (searchInput.value || '').trim();
1585
+ page = 1;
1586
+ save(); apply();
1587
+ clearTimeout(debounce);
1588
+ if (LIVE) debounce = setTimeout(feedUpdate, 120);
1589
+ });
1590
+ }
1591
+ if (clearBtn) clearBtn.addEventListener('click', function(){ state = Object.create(null); page = 1; q = ''; if (searchInput) searchInput.value = ''; save(); apply(); if (LIVE) feedUpdate(); });
1586
1592
  parse(); apply();
1593
+ if (searchInput) searchInput.value = q;
1587
1594
  if (LIVE && (active() || page !== 1)) feedUpdate();
1588
1595
  })();
1596
+ </script>`
1597
+ : "";
1598
+ // Static receipts: a small client-side BM25-only scorer that filters the
1599
+ // rendered feed rows. No network round-trips and no semantic embeddings.
1600
+ const staticSearchScript = !model.liveToken
1601
+ ? `<script id="kya-static-search">
1602
+ (function(){
1603
+ var feed = document.getElementById('feed');
1604
+ var input = document.getElementById('feed-search');
1605
+ var meta = document.querySelector('.feed-search-meta');
1606
+ var clearBtn = document.getElementById('clear-filters');
1607
+ if (!feed || !input) return;
1608
+ var STOP = Object.create(null);
1609
+ "a an the and or but is are was were be been being to of in on at by for with as this that it its from up about into through during before after above below between among within without against over under again further then once here there when where why how all any both each few more most other some such only own same so than too very can will just should now did does do has have had having get got gets make made makes use used uses using".split(" ").forEach(function(w){ STOP[w] = 1; });
1610
+ function stem(t){
1611
+ if (t.length <= 3) return t;
1612
+ if (t.slice(-3) === 'ing') return t.slice(0,-3) || t;
1613
+ if (t.slice(-2) === 'ed') return t.slice(0,-2) || t;
1614
+ if (t.slice(-1) === 's' && t.slice(-2) !== 'ss') return t.slice(0,-1) || t;
1615
+ return t;
1616
+ }
1617
+ function tokenize(text){
1618
+ var out = [];
1619
+ var parts = String(text).toLowerCase().split(/[^a-z0-9]+/);
1620
+ for (var i = 0; i < parts.length; i++) {
1621
+ var p = parts[i];
1622
+ if (!p) continue;
1623
+ var split = p.replace(/([A-Z])/g, ' $1').replace(/[_\\-.]/g, ' ').toLowerCase().split(/[^a-z0-9]+/);
1624
+ for (var j = 0; j < split.length; j++) {
1625
+ var s = stem(split[j]);
1626
+ if (s && !STOP[s]) out.push(s);
1627
+ }
1628
+ }
1629
+ return out;
1630
+ }
1631
+ var rows = Array.prototype.slice.call(feed.querySelectorAll('.ev'));
1632
+ var docs = rows.map(function(row){
1633
+ var text = row.textContent || '';
1634
+ var tokens = tokenize(text);
1635
+ var freq = Object.create(null);
1636
+ for (var i = 0; i < tokens.length; i++) freq[tokens[i]] = (freq[tokens[i]] || 0) + 1;
1637
+ return { row: row, tokens: tokens, freq: freq, text: text };
1638
+ });
1639
+ var N = docs.length;
1640
+ var avgLen = N ? docs.reduce(function(a,d){ return a + d.tokens.length; }, 0) / N : 0;
1641
+ var q = '';
1642
+ function scoreDoc(doc, qTokens){
1643
+ if (!qTokens.length) return 1;
1644
+ var score = 0, k1 = 1.5, b = 0.75;
1645
+ for (var i = 0; i < qTokens.length; i++) {
1646
+ var t = qTokens[i];
1647
+ var df = 0;
1648
+ for (var j = 0; j < docs.length; j++) if (docs[j].freq[t]) df++;
1649
+ if (!df) continue;
1650
+ var tf = doc.freq[t] || 0;
1651
+ if (!tf) continue;
1652
+ var idf = Math.log(1 + (N - df + 0.5) / (df + 0.5));
1653
+ var denom = tf + k1 * (1 - b + b * doc.tokens.length / Math.max(avgLen, 1));
1654
+ score += idf * (tf * (k1 + 1) / denom);
1655
+ }
1656
+ return score;
1657
+ }
1658
+ function apply(){
1659
+ var qTokens = tokenize(q);
1660
+ var visible = 0;
1661
+ for (var i = 0; i < docs.length; i++) {
1662
+ var s = scoreDoc(docs[i], qTokens);
1663
+ var hide = q && s <= 0;
1664
+ docs[i].row.classList.toggle('filtered-out', hide);
1665
+ if (!hide) visible++;
1666
+ }
1667
+ var day = null, dayHas = false;
1668
+ function flush(){ if (day) day.classList.toggle('filtered-out', q && !dayHas); }
1669
+ for (var k = 0; k < feed.children.length; k++) {
1670
+ var el = feed.children[k];
1671
+ if (el.classList.contains('day')) { flush(); day = el; dayHas = false; }
1672
+ else if (el.classList.contains('ev') && !el.classList.contains('filtered-out')) dayHas = true;
1673
+ }
1674
+ flush();
1675
+ if (meta) {
1676
+ if (q) { meta.textContent = visible + ' matches · filtered by relevance'; meta.style.display = ''; }
1677
+ else meta.style.display = 'none';
1678
+ }
1679
+ if (clearBtn) clearBtn.hidden = !q;
1680
+ }
1681
+ function save(){
1682
+ var kept = [];
1683
+ var s = location.search;
1684
+ if (s.indexOf('?') === 0) {
1685
+ var params = s.slice(1).split('&');
1686
+ for (var i = 0; i < params.length; i++) {
1687
+ if (params[i] && params[i].indexOf('q=') !== 0) kept.push(params[i]);
1688
+ }
1689
+ }
1690
+ if (q) kept.push('q=' + encodeURIComponent(q));
1691
+ var qq = kept.length ? '?' + kept.join('&') : '';
1692
+ var h = location.hash;
1693
+ history.replaceState(null, '', location.pathname + qq + h);
1694
+ }
1695
+ input.addEventListener('input', function(){ q = (input.value || '').trim(); apply(); save(); });
1696
+ if (clearBtn) clearBtn.addEventListener('click', function(){ q = ''; input.value = ''; apply(); save(); });
1697
+ var s = location.search;
1698
+ var found = false;
1699
+ if (s.indexOf('?') === 0) {
1700
+ var params = s.slice(1).split('&');
1701
+ for (var i = 0; i < params.length; i++) {
1702
+ if (params[i].indexOf('q=') === 0) {
1703
+ try { q = decodeURIComponent(params[i].slice(2).replace(/\\+/g, ' ')); } catch(e){ q = params[i].slice(2); }
1704
+ found = true;
1705
+ }
1706
+ }
1707
+ }
1708
+ // No ?q= in the URL: honor the server-prefilled input (static artifacts
1709
+ // generated with kya receipt --q) so they open pre-filtered.
1710
+ if (!found) q = (input.value || '').trim();
1711
+ input.value = q;
1712
+ apply();
1713
+ })();
1589
1714
  </script>`
1590
1715
  : "";
1591
1716
  const livePill = model.live ? `<span class="live" title="Watching trail.jsonl">Live</span>` : "";
@@ -1775,6 +1900,7 @@ export function renderReceiptHtml(model) {
1775
1900
  <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">Theme</button>
1776
1901
  <!-- Stop-report action is wired by the live-server POST handler. -->
1777
1902
  <a href="#" id="stop-report">Stop report</a>
1903
+ ${model.liveToken ? '<a href="https://github.com/The-Pixel-Boys/shield-agent" class="star-link" target="_blank" rel="noopener noreferrer">Star on GitHub ↗</a>' : ""}
1778
1904
  </footer>
1779
1905
  </aside>
1780
1906
  ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
@@ -1799,6 +1925,11 @@ ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
1799
1925
  ${overviewBody}
1800
1926
  </section>
1801
1927
  <section class="zone" id="activity" aria-labelledby="tab-activity">
1928
+ <div class="feed-search">
1929
+ <input id="feed-search" class="feed-search-input" type="search" placeholder="Search activity in plain language…" value="${esc(model.searchQuery ?? "")}" autocomplete="off" />
1930
+ ${model.searchQuery ? `<div class="feed-search-meta">${initialFeed.total} matches · ${livePaged ? "ranked by relevance" : "filtered by relevance"}</div>` : ""}
1931
+ </div>
1932
+ ${!model.liveToken && model.searchQuery ? '<p class="feed-search-note">Semantic ranking requires the live report server (<code>kya start</code>).</p>' : ""}
1802
1933
  <div class="feed-head">
1803
1934
  <div class="stats" aria-label="Counts">
1804
1935
  <button type="button" class="stat ok" data-fgroup="verdict" data-fvalue="ALLOW" aria-pressed="false" title="Filter: Allow">Allow<b>${c.allow}</b></button>
@@ -1833,6 +1964,7 @@ ${changesPanel(changes, nowMs)}
1833
1964
  </section>
1834
1965
  </main>
1835
1966
  ${filterScript}
1967
+ ${staticSearchScript}
1836
1968
  ${themeScript}
1837
1969
  ${gateScript}
1838
1970
  ${liveScript}