@opennodes/registry 0.1.5 → 0.1.7

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/web/index.html +107 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "OpenNodes (ONP) reference registry: node registration with proof-of-resource qualification (Stage A/B/C), transparent ranking, enforceable estimates, catalog importers (OpenRouter, Hugging Face, models.dev, Ollama), MCP surface, and the web app (catalog, chat, advisor, Node Builder)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -396,8 +396,13 @@
396
396
  <button class="btn sec" id="sc-load" style="padding:0.35rem 0.7rem; font-size:0.74rem">Load</button>
397
397
  <button class="btn sec" id="sc-del" style="padding:0.35rem 0.7rem; font-size:0.74rem" title="delete">✕</button>
398
398
  </div>
399
- <button class="btn sec" id="sc-export" style="margin-top:0.4rem; padding:0.35rem 0.7rem; font-size:0.74rem">Export as ONP-6 Scenario JSON</button>
400
- <p class="prov" id="sc-note" style="margin:0.35rem 0 0">saved in your browser; export produces the portable ONP-6 §5 format</p>
399
+ <div style="display:flex; gap:0.4rem; margin-top:0.4rem; flex-wrap:wrap">
400
+ <button class="btn sec" id="sc-export" style="padding:0.35rem 0.7rem; font-size:0.74rem">Export JSON</button>
401
+ <button class="btn sec" id="sc-import" style="padding:0.35rem 0.7rem; font-size:0.74rem">Import JSON</button>
402
+ <button class="btn sec" id="sc-share" style="padding:0.35rem 0.7rem; font-size:0.74rem">Copy share link</button>
403
+ <input type="file" id="sc-file" accept=".json,application/json" hidden>
404
+ </div>
405
+ <p class="prov" id="sc-note" style="margin:0.35rem 0 0">Saved in your browser — no account. Export/import use the portable ONP-6 §5 format; a share link carries the whole scenario in the URL (never your API keys).</p>
401
406
  </details>
402
407
  <label class="f">API key <span style="font-weight:400">(per host, kept in your browser)</span></label>
403
408
  <input class="wide mono" id="ch-key" type="password" placeholder="optional" style="font-size:0.76rem">
@@ -409,7 +414,7 @@
409
414
  </div>
410
415
  <div class="panel pad">
411
416
  <span class="lab" style="font-family:'IBM Plex Mono',monospace; font-size:0.62rem; font-weight:600; letter-spacing:0.12em; color:var(--accent)">DESKTOP &amp; APPS</span>
412
- <p style="font-size:0.8rem; color:var(--muted); margin:0.5rem 0 0.4rem"><b>Surge</b>, a desktop client with native OpenNodes support (models + MCP tools in one app), is in development — its repository opens with the first public build; follow <a href="https://opennodes.io">opennodes.io</a> for the release.</p>
417
+ <p style="font-size:0.8rem; color:var(--muted); margin:0.5rem 0 0.4rem"><b>Surge</b>, a desktop client with native OpenNodes support (models + MCP tools in one app), is in pre-release: the <a href="https://github.com/opennodes-io/surge">source is on GitHub</a>, with no installers yet.</p>
413
418
  <p style="font-size:0.8rem; color:var(--muted); margin:0">Available today: bring every model here into <b>Cursor, VS Code, or any OpenAI-compatible app</b> via the <a href="https://github.com/opennodes-io/opennodes/tree/main/impl">onp gateway and MCP server</a>.</p>
414
419
  </div>
415
420
  </div>
@@ -1002,7 +1007,7 @@ async function loadChatTargets() {
1002
1007
  + (local.length ? `<optgroup label="On your machine — needs a local Ollama with this model pulled">${local.map((o, i) => opt(o, ready.length + i)).join('')}</optgroup>` : '')
1003
1008
  + (imports.length ? `<optgroup label="Imported catalogs — your own API key required">${imports.map((o, i) => opt(o, ready.length + local.length + i)).join('')}</optgroup>` : '')
1004
1009
  + '<option value="custom">custom endpoint (local Ollama / any OpenAI server)</option>';
1005
- if (!chat.roster.length && ready.length && !sessionStorage.getItem('onp-advisor-handoff')) {
1010
+ if (!chat.roster.length && ready.length && !sessionStorage.getItem('onp-advisor-handoff') && !/[?&]s=/.test(location.hash)) {
1006
1011
  chat.roster.push({ offering: ready[0] }); // a newcomer can just type
1007
1012
  renderRoster();
1008
1013
  const log = $('#ch-log');
@@ -1011,6 +1016,7 @@ async function loadChatTargets() {
1011
1016
  } catch { $('#ch-add').innerHTML = '<option value="custom">custom endpoint</option>'; }
1012
1017
  }
1013
1018
  await applyAdvisorHandoff();
1019
+ await applySharedScenario();
1014
1020
  }
1015
1021
 
1016
1022
  /** The catalog advisor hands its shortlist to the chat roster via sessionStorage. */
@@ -1404,38 +1410,47 @@ $('#sc-save').addEventListener('click', () => {
1404
1410
  scRefreshList(); $('#sc-list').value = name;
1405
1411
  $('#sc-note').textContent = `saved "${name}"`;
1406
1412
  });
1407
- $('#sc-load').addEventListener('click', async () => {
1408
- const sc = scAll()[$('#sc-list').value];
1409
- if (!sc) return;
1410
- await loadChatTargets();
1413
+ /** Apply a scenario snapshot to the chat (roster, mode, instructions). Used by Load, Import, and share links. */
1414
+ async function scApply(sc, verb = 'loaded') {
1411
1415
  chat.roster = [];
1412
1416
  const missing = [];
1413
- for (const entry of sc.roster) {
1414
- if (entry.custom) chat.roster.push({ custom: entry.custom });
1415
- else {
1416
- const [nodeId, offId] = entry.pin.split('/');
1417
- const o = chat.offerings.find((x) => x.node_id === nodeId && x.offering_id === offId);
1418
- if (o) chat.roster.push({ offering: o }); else missing.push(entry.pin);
1417
+ for (const entry of sc.roster ?? []) {
1418
+ if (entry.custom) { chat.roster.push({ custom: entry.custom }); continue; }
1419
+ let o = null;
1420
+ if (entry.pin) {
1421
+ const [nodeId, ...rest] = entry.pin.split('/'); const offId = rest.join('/');
1422
+ o = chat.offerings.find((x) => x.node_id === nodeId && x.offering_id === offId);
1423
+ if (!o) { try { o = await api(`/v0/offerings/${encodeURIComponent(nodeId)}/${encodeURIComponent(offId)}`); } catch { o = null; } }
1424
+ } else if (entry.select) { // ONP-6 late binding: resolve the requirement against this registry now
1425
+ try { o = (await api('/v0/offerings?limit=1&' + new URLSearchParams(entry.select))).offerings[0] ?? null; } catch { o = null; }
1419
1426
  }
1427
+ if (o) chat.roster.push({ offering: o }); else missing.push(entry.pin ?? JSON.stringify(entry.select));
1420
1428
  }
1421
- $('#ch-mode').value = sc.mode; $('#ch-mode').dispatchEvent(new Event('change'));
1429
+ const MODES = ['single', 'compare', 'jury', 'pipeline', 'iterative'];
1430
+ $('#ch-mode').value = MODES.includes(sc.mode) ? sc.mode : 'single'; $('#ch-mode').dispatchEvent(new Event('change'));
1422
1431
  $('#ch-rounds').value = String(sc.rounds || 2);
1423
1432
  $('#ch-seqprompt').value = sc.instructions?.pipeline ?? $('#ch-seqprompt').value;
1424
1433
  $('#ch-juryprompt').value = sc.instructions?.jury ?? $('#ch-juryprompt').value;
1425
1434
  $('#ch-revprompt').value = sc.instructions?.revise ?? $('#ch-revprompt').value;
1426
1435
  renderRoster();
1427
1436
  if (sc.judge < chat.roster.length) $('#ch-judge').value = String(sc.judge);
1437
+ if (sc.name) $('#sc-name').value = sc.name;
1428
1438
  $('#sc-note').textContent = missing.length
1429
- ? `loaded; ${missing.length} pinned offering(s) no longer listed: ${missing.join(', ')}`
1430
- : `loaded "${sc.name}"`;
1439
+ ? `${verb}; ${missing.length} model(s) could not be resolved here: ${missing.join(', ')}`
1440
+ : `${verb} "${sc.name ?? 'scenario'}" — ${chat.roster.length} model(s), mode ${$('#ch-mode').value}`;
1441
+ }
1442
+ $('#sc-load').addEventListener('click', async () => {
1443
+ const sc = scAll()[$('#sc-list').value];
1444
+ if (!sc) return;
1445
+ await loadChatTargets();
1446
+ await scApply(sc);
1431
1447
  });
1432
1448
  $('#sc-del').addEventListener('click', () => {
1433
1449
  const all = scAll(); delete all[$('#sc-list').value];
1434
1450
  localStorage.setItem(SC_STORE, JSON.stringify(all)); scRefreshList();
1435
1451
  });
1436
- $('#sc-export').addEventListener('click', () => {
1437
- // Bridge to the portable ONP-6 §5 Scenario document
1438
- const s = scSnapshot($('#sc-name').value.trim() || $('#sc-list').value || 'chat-scenario');
1452
+ /** Snapshot -> portable ONP-6 §5 Scenario document. */
1453
+ function scToDoc(s) {
1439
1454
  const roles = {};
1440
1455
  s.roster.forEach((r, i) => { roles['m' + (i + 1)] = r.pin ? { pin: r.pin } : { custom: r.custom }; });
1441
1456
  let steps = [];
@@ -1450,7 +1465,78 @@ $('#sc-export').addEventListener('click', () => {
1450
1465
  steps.push({ id: 'verdict', role: roleIds[Math.min(s.judge, roleIds.length - 1)], merge: 'judge', instruction: s.instructions.jury });
1451
1466
  }
1452
1467
  }
1453
- const doc = { onp_scenario: '0.1', name: s.name, roles, steps, policy: {} };
1468
+ return { onp_scenario: '0.1', name: s.name, roles, steps, policy: {} };
1469
+ }
1470
+
1471
+ /** ONP-6 Scenario document (or a native snapshot) -> snapshot the chat can apply. */
1472
+ function scFromDoc(doc) {
1473
+ if (!doc || typeof doc !== 'object') throw new Error('not a JSON object');
1474
+ if (Array.isArray(doc.roster) && doc.mode) return doc; // already a native snapshot
1475
+ if (!doc.onp_scenario || !doc.roles || !Array.isArray(doc.steps)) throw new Error('not an ONP-6 scenario (needs onp_scenario, roles, steps)');
1476
+ const roleIds = Object.keys(doc.roles);
1477
+ const roster = roleIds.map((id) => {
1478
+ const r = doc.roles[id] ?? {};
1479
+ if (r.custom) return { custom: r.custom };
1480
+ if (r.pin) return { pin: r.pin };
1481
+ if (r.node_id && r.offering_id) return { pin: `${r.node_id}/${r.offering_id}` };
1482
+ if (r.select) return { select: r.select };
1483
+ return { select: {} };
1484
+ });
1485
+ const fan = doc.steps.find((st) => st.mode === 'parallel');
1486
+ const verdict = doc.steps.find((st) => st.merge === 'judge' || st.merge?.role);
1487
+ let mode = 'single';
1488
+ if (fan) mode = fan.rounds ? 'iterative' : (verdict ? 'jury' : 'compare');
1489
+ else if (doc.steps.length > 1) mode = 'pipeline';
1490
+ const judgeRole = verdict?.role ?? verdict?.merge?.role;
1491
+ return {
1492
+ name: doc.name ?? 'imported-scenario', mode,
1493
+ rounds: fan?.rounds ?? 2,
1494
+ judge: Math.max(0, roleIds.indexOf(judgeRole)),
1495
+ roster,
1496
+ instructions: {
1497
+ pipeline: doc.steps.find((st) => st.instruction && !st.merge)?.instruction ?? doc.steps.find((st) => st.prompt && st.input && st.input !== '$user')?.prompt,
1498
+ jury: verdict?.instruction,
1499
+ revise: fan?.revise_instruction,
1500
+ },
1501
+ };
1502
+ }
1503
+ const b64u = {
1504
+ enc: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
1505
+ dec: (b) => new TextDecoder().decode(Uint8Array.from(atob(b.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))),
1506
+ };
1507
+ const scCurrentDoc = () => scToDoc(scSnapshot($('#sc-name').value.trim() || $('#sc-list').value || 'chat-scenario'));
1508
+
1509
+ $('#sc-import').addEventListener('click', () => $('#sc-file').click());
1510
+ $('#sc-file').addEventListener('change', async (e) => {
1511
+ const file = e.target.files[0]; e.target.value = '';
1512
+ if (!file) return;
1513
+ try {
1514
+ const sc = scFromDoc(JSON.parse(await file.text()));
1515
+ await loadChatTargets();
1516
+ await scApply(sc, 'imported');
1517
+ } catch (err) { $('#sc-note').textContent = 'import failed: ' + err.message; }
1518
+ });
1519
+ $('#sc-share').addEventListener('click', async () => {
1520
+ if (!chat.roster.length) { $('#sc-note').textContent = 'add at least one model before sharing'; return; }
1521
+ const url = `${location.origin}${location.pathname}#/chat?s=${b64u.enc(JSON.stringify(scCurrentDoc()))}`;
1522
+ try { await navigator.clipboard.writeText(url); $('#sc-note').textContent = `share link copied (${url.length} chars) — it contains the roster, mode and instructions, never API keys`; }
1523
+ catch { prompt('Copy this share link:', url); }
1524
+ });
1525
+ /** A #/chat?s=… link carries a whole scenario; apply it once, then clean the address bar. */
1526
+ async function applySharedScenario() {
1527
+ const m = location.hash.match(/[?&]s=([A-Za-z0-9_-]+)/);
1528
+ if (!m) return;
1529
+ history.replaceState(null, '', location.pathname + '#/chat');
1530
+ try {
1531
+ await scApply(scFromDoc(JSON.parse(b64u.dec(m[1]))), 'opened shared scenario');
1532
+ const log = $('#ch-log');
1533
+ if (log && !chat.history.length) log.innerHTML = `<p class="sub" style="margin:0">Shared scenario <b>${esc($('#sc-name').value || 'scenario')}</b> loaded: ${chat.roster.length} model(s), mode <b>${esc($('#ch-mode').value)}</b>. Review the roster on the right, then say something.</p>`;
1534
+ } catch (err) { $('#sc-note').textContent = 'could not open the shared scenario: ' + err.message; }
1535
+ }
1536
+
1537
+ $('#sc-export').addEventListener('click', () => {
1538
+ const doc = scCurrentDoc();
1539
+ const s = { name: doc.name };
1454
1540
  const a = document.createElement('a');
1455
1541
  a.href = URL.createObjectURL(new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' }));
1456
1542
  a.download = s.name.replace(/[^a-z0-9._-]+/gi, '-') + '.onp-scenario.json';