switchroom 0.15.32 → 0.15.34

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.
@@ -28994,6 +28994,26 @@ async function getJson(url, opts) {
28994
28994
  return { ok: false, reason: String(err.message ?? err) };
28995
28995
  }
28996
28996
  }
28997
+ function summarizeBasedOn(reflect) {
28998
+ const basedOn = reflect?.based_on ?? {};
28999
+ const basedOnCounts = {};
29000
+ let totalSourceFacts = 0;
29001
+ const derivedFromModelIds = [];
29002
+ for (const [type, facts] of Object.entries(basedOn ?? {})) {
29003
+ if (!Array.isArray(facts))
29004
+ continue;
29005
+ basedOnCounts[type] = facts.length;
29006
+ totalSourceFacts += facts.length;
29007
+ if (type === "mental-models") {
29008
+ for (const f of facts) {
29009
+ const id = f?.id;
29010
+ if (typeof id === "string" && id)
29011
+ derivedFromModelIds.push(id);
29012
+ }
29013
+ }
29014
+ }
29015
+ return { basedOnCounts, totalSourceFacts, derivedFromModelIds };
29016
+ }
28997
29017
  async function inspectBankHealth(mcpUrl, bankId, opts) {
28998
29018
  const base = hindsightRestBase(mcpUrl);
28999
29019
  const bank = encodeURIComponent(bankId);
@@ -29042,16 +29062,22 @@ async function inspectBankHealth(mcpUrl, bankId, opts) {
29042
29062
  pendingOperations: stats.data.pending_operations ?? 0,
29043
29063
  newestDocumentAt,
29044
29064
  unextractedDocuments: unextracted,
29045
- mentalModels: (models.data.items ?? []).filter((m) => typeof m?.id === "string" && typeof m?.name === "string").map((m) => ({
29046
- id: m.id,
29047
- name: m.name,
29048
- lastRefreshedAt: m.last_refreshed_at ?? null,
29049
- createdAt: m.created_at ?? null,
29050
- contentLength: (m.content ?? "").length,
29051
- contentHead: (m.content ?? "").slice(0, 200),
29052
- sourceQuery: m.source_query ?? "",
29053
- refreshMode: m.trigger?.mode ?? null
29054
- }))
29065
+ mentalModels: (models.data.items ?? []).filter((m) => typeof m?.id === "string" && typeof m?.name === "string").map((m) => {
29066
+ const { basedOnCounts, totalSourceFacts, derivedFromModelIds } = summarizeBasedOn(m.reflect_response);
29067
+ return {
29068
+ id: m.id,
29069
+ name: m.name,
29070
+ lastRefreshedAt: m.last_refreshed_at ?? null,
29071
+ createdAt: m.created_at ?? null,
29072
+ contentLength: (m.content ?? "").length,
29073
+ contentHead: (m.content ?? "").slice(0, 200),
29074
+ sourceQuery: m.source_query ?? "",
29075
+ refreshMode: m.trigger?.mode ?? null,
29076
+ basedOnCounts,
29077
+ totalSourceFacts,
29078
+ derivedFromModelIds
29079
+ };
29080
+ })
29055
29081
  };
29056
29082
  }
29057
29083
  async function getMentalModelDetail(mcpUrl, bankId, modelId, opts) {
@@ -29062,14 +29088,7 @@ async function getMentalModelDetail(mcpUrl, bankId, modelId, opts) {
29062
29088
  if (!res.ok)
29063
29089
  return res;
29064
29090
  const m = res.data;
29065
- const basedOn = m.reflect_response?.based_on ?? {};
29066
- const basedOnCounts = {};
29067
- let totalSourceFacts = 0;
29068
- for (const [type, facts] of Object.entries(basedOn)) {
29069
- const n = Array.isArray(facts) ? facts.length : 0;
29070
- basedOnCounts[type] = n;
29071
- totalSourceFacts += n;
29072
- }
29091
+ const { basedOnCounts, totalSourceFacts, derivedFromModelIds } = summarizeBasedOn(m.reflect_response);
29073
29092
  return {
29074
29093
  ok: true,
29075
29094
  model: {
@@ -29081,7 +29100,8 @@ async function getMentalModelDetail(mcpUrl, bankId, modelId, opts) {
29081
29100
  createdAt: m.created_at ?? null,
29082
29101
  refreshMode: m.trigger?.mode ?? null,
29083
29102
  basedOnCounts,
29084
- totalSourceFacts
29103
+ totalSourceFacts,
29104
+ derivedFromModelIds
29085
29105
  }
29086
29106
  };
29087
29107
  }
@@ -50554,8 +50574,8 @@ var {
50554
50574
  } = import__.default;
50555
50575
 
50556
50576
  // src/build-info.ts
50557
- var VERSION = "0.15.32";
50558
- var COMMIT_SHA = "61984cef";
50577
+ var VERSION = "0.15.34";
50578
+ var COMMIT_SHA = "508eb512";
50559
50579
 
50560
50580
  // src/cli/agent.ts
50561
50581
  init_source();
@@ -485,6 +485,59 @@
485
485
  .card-meta { gap: 0.3rem 1rem; }
486
486
  nav.tabs { padding: 0 1rem; overflow-x: auto; }
487
487
  }
488
+
489
+ /* --- Memory tab: banded cards, provenance bars, model relationships --- */
490
+ .mm-band { padding: .55rem 0; border-top: 1px solid var(--border); }
491
+ .mm-band-h { font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-dim); margin-bottom: .4rem; display: flex; align-items: center; gap: .4rem; }
492
+ .mm-band-h .n { color: var(--text); opacity: .6; }
493
+ .mm-stat-line { color: var(--text); font-size: .9em; line-height: 1.5; }
494
+ .mm-stat-line .dim { color: var(--text-dim); }
495
+ /* stacked provenance bar (segments sized by source-fact composition) */
496
+ .prov-bar { display: flex; height: 7px; border-radius: 4px; overflow: hidden; background: var(--border); margin: .35rem 0 .15rem; }
497
+ .prov-bar > span { display: block; min-width: 2px; }
498
+ .prov-obs { background: var(--blue); }
499
+ .prov-dir { background: var(--green); }
500
+ .prov-der { background: var(--yellow); }
501
+ .prov-legend { font-size: .72rem; color: var(--text-dim); display: flex; flex-wrap: wrap; gap: .15rem .8rem; margin-top: .3rem; }
502
+ .prov-legend i { width: .6rem; height: .6rem; border-radius: 2px; display: inline-block; vertical-align: middle; margin-right: .25rem; }
503
+ /* freshness heat-strip: one cell per model */
504
+ .heat { display: flex; gap: 2px; flex-wrap: wrap; margin-top: .4rem; }
505
+ .heat span { width: 16px; height: 8px; border-radius: 2px; }
506
+ .heat .fresh { background: var(--green); }
507
+ .heat .stale { background: var(--yellow); }
508
+ .heat .cold { background: var(--text-dim); opacity: .45; }
509
+ .heat .corrupt { background: var(--red); }
510
+ /* model rows + cluster (hub→leaf) tree connectors */
511
+ .mm-row { padding: .4rem 0; }
512
+ .mm-row + .mm-row { border-top: 1px solid var(--border); }
513
+ .mm-cluster { border-top: 1px solid var(--border); }
514
+ .mm-cluster .mm-row + .mm-row { border-top: none; }
515
+ .mm-top { display: flex; align-items: baseline; gap: .45rem; flex-wrap: wrap; }
516
+ .mm-name { font-weight: 600; }
517
+ .mm-meta { font-size: .8em; color: var(--text-dim); margin-left: auto; white-space: nowrap; }
518
+ .mm-why { color: var(--text-dim); font-size: .84em; margin-top: .12rem; }
519
+ .mm-leaf { padding-left: 1.15rem; position: relative; }
520
+ .mm-leaf::before { content: ""; position: absolute; left: .35rem; top: 0; bottom: 0; border-left: 2px solid var(--border); }
521
+ .mm-leaf:last-child::before { bottom: calc(100% - 1.05rem); }
522
+ .mm-leaf::after { content: ""; position: absolute; left: .35rem; top: 1.05rem; width: .5rem; border-top: 2px solid var(--border); }
523
+ /* relationship chips */
524
+ .mm-chips { margin-top: .25rem; font-size: .8em; color: var(--text-dim); display: flex; flex-wrap: wrap; gap: .25rem; align-items: center; }
525
+ .mm-chip { background: var(--surface-hover); border: 1px solid var(--border); border-radius: 5px; padding: 0 .35rem; cursor: pointer; color: var(--text); }
526
+ .mm-chip:hover { border-color: var(--blue); }
527
+ .mm-chip.raw { cursor: default; color: var(--text-dim); }
528
+ .mm-chip.muted { background: transparent; border: 1px dashed var(--border); cursor: default; color: var(--text-dim); }
529
+ /* model badges */
530
+ .mm-badge { font-size: .7rem; border: 1px solid var(--border); border-radius: 5px; padding: 0 .3rem; color: var(--text-dim); }
531
+ .mm-badge.stale { color: var(--yellow); border-color: var(--yellow); }
532
+ .mm-badge.corrupt { color: var(--red); border-color: var(--red); }
533
+ .mm-badge.hub { color: var(--blue); border-color: var(--blue); }
534
+ /* flash when a chip scrolls you to its target row */
535
+ @keyframes mmflash { 0% { background: rgba(96,165,250,.28); } 100% { background: transparent; } }
536
+ .mm-flash { animation: mmflash 1.3s ease-out; border-radius: 6px; }
537
+ /* attention band (problems promoted to top of card) */
538
+ .mm-attn { border-left: 3px solid var(--red); padding: .4rem .6rem; margin: .15rem 0 .35rem; background: rgba(248,113,113,.07); border-radius: 0 6px 6px 0; font-size: .86em; }
539
+ .mm-attn.warn { border-left-color: var(--yellow); background: rgba(251,191,36,.06); }
540
+ .mm-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
488
541
  </style>
489
542
  </head>
490
543
  <body>
@@ -620,6 +673,7 @@
620
673
  container.innerHTML = renderProblem(problemFor('hindsight-down', { url: m.url }));
621
674
  return;
622
675
  }
676
+ const banks = m.banks || [];
623
677
  const statusDot = (s) => `<span class="status-dot ${s === 'ok' ? 'active' : s === 'warn' ? 'auth-warning' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span>`;
624
678
  const fmtDay = (iso) => iso ? iso.slice(0, 10) : '—';
625
679
  const fmtAge = (iso) => {
@@ -628,32 +682,50 @@
628
682
  if (isNaN(d)) return '';
629
683
  return d < 1 ? 'today' : `${Math.round(d)}d ago`;
630
684
  };
631
- // A bank "has a user-profile model" if any mental model is named
632
- // like user-profile (mirrors hindsight's exact-name check, but
633
- // tolerant of the "User Profile" display variant for the UI hint).
685
+ const fmtNum = (n) => (n || 0).toLocaleString();
686
+ const fmtK = (n) => (n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k' : String(n || 0));
687
+ const isStaleTs = (iso) => iso && (Date.now() - Date.parse(iso)) > 7 * 86400000;
634
688
  const hasUserProfile = (b) => (b.mentalModels || []).some(mm =>
635
689
  /^user[- ]?profile$/i.test(String(mm.name || '')));
636
- // JSON for a single-quoted onclick attribute: escape the quote
637
- // chars that could break out of the attribute (', &, <, >).
690
+ // JSON for a single-quoted onclick attribute (escapeHtml covers ' & < > ").
638
691
  const attrJson = (v) => escapeHtml(JSON.stringify(v));
692
+ // Fold hindsight's fact types into 3 provenance buckets: observations,
693
+ // directives/other facts, and derived-from-other-models.
694
+ const provSegments = (counts) => {
695
+ const c = counts || {};
696
+ const obs = c.observation || 0;
697
+ const models = c['mental-models'] || 0;
698
+ let other = 0;
699
+ for (const [k, v] of Object.entries(c)) {
700
+ if (k !== 'observation' && k !== 'mental-models') other += v || 0;
701
+ }
702
+ return { obs, other, models, total: obs + other + models };
703
+ };
704
+ const provBar = (counts, h) => {
705
+ const s = provSegments(counts);
706
+ if (s.total === 0) return '';
707
+ const seg = (n, cls) => n > 0 ? `<span class="${cls}" style="flex:${n}" title="${n}"></span>` : '';
708
+ return `<div class="prov-bar"${h ? ` style="height:${h}px"` : ''}>${seg(s.obs, 'prov-obs')}${seg(s.other, 'prov-dir')}${seg(s.models, 'prov-der')}</div>`;
709
+ };
639
710
 
640
- // --- "How memory works" explainer (live fleet totals) ---
641
- // The Memory tab's job: make the invisible pipeline legible. The
642
- // operator should understand WHAT the agents remember, the WHY behind
643
- // each model, and HOW the pipeline turns conversations into recall.
644
- const banks = m.banks || [];
711
+ // --- "How memory works" explainer + shared provenance legend ---
645
712
  const totals = banks.reduce((a, b) => {
646
713
  a.docs += b.totalDocuments || 0;
647
714
  a.facts += b.totalFacts || 0;
648
715
  a.models += (b.mentalModels || []).length;
649
716
  return a;
650
717
  }, { docs: 0, facts: 0, models: 0 });
651
- const fmtNum = (n) => (n || 0).toLocaleString();
652
718
  const stage = (label, count, desc) => `<div style="flex:1 1 140px;min-width:130px;background:var(--surface-hover);border-radius:8px;padding:.6rem .7rem">
653
719
  <div style="font-weight:600">${escapeHtml(label)}${count !== '' ? ` <span style="color:var(--blue)">${count}</span>` : ''}</div>
654
720
  <div style="color:var(--text-dim);font-size:.78em;margin-top:.25rem;line-height:1.35">${escapeHtml(desc)}</div>
655
721
  </div>`;
656
722
  const arrow = `<div style="display:flex;align-items:center;color:var(--text-dim);font-size:1.1em">→</div>`;
723
+ const legend = `<div class="prov-legend">
724
+ <span style="color:var(--text)">Source mix:</span>
725
+ <span><i class="prov-obs"></i>observations</span>
726
+ <span><i class="prov-dir"></i>directives &amp; facts</span>
727
+ <span><i class="prov-der"></i>from other models</span>
728
+ </div>`;
657
729
  const explainer = `<div class="agent-card" style="margin-bottom:1rem">
658
730
  <div class="card-header" style="cursor:default">
659
731
  <span class="agent-name">How memory works</span>
@@ -665,100 +737,186 @@
665
737
  ${arrow}
666
738
  ${stage('Facts', fmtNum(totals.facts), "Hindsight extracts durable facts from each conversation — on its OWN model, never the agent's quota.")}
667
739
  ${arrow}
668
- ${stage('Mental models', fmtNum(totals.models), 'Facts are synthesized into named models, each answering one recall question.')}
740
+ ${stage('Mental models', fmtNum(totals.models), 'Facts (and other models) are synthesized into named models, each answering one recall question.')}
669
741
  ${arrow}
670
742
  ${stage('Recall', '', 'On each turn the agent pulls the relevant models back into context — it never re-reads raw history.')}
671
743
  </div>
672
- <div style="color:var(--text-dim);font-size:.85em">Expand any model below to read what it knows and where it came from. A <span style="color:var(--yellow)">stale</span> or empty model means the agent is reasoning from an out-of-date picture.</div>
744
+ <div style="color:var(--text-dim);font-size:.85em">A <span style="color:var(--blue)">hub</span> model synthesizes other models (the <span style="color:var(--text)">draws on</span> links); a <span style="color:var(--yellow)">stale</span> or empty model means the agent is reasoning from an out-of-date picture. The bars below show each model's source mix.</div>
745
+ ${legend}
673
746
  </div>
674
747
  </div>`;
675
748
 
676
749
  const cards = banks.map((b, bi) => {
677
750
  const bankJs = attrJson(b.bank);
678
- const models = (b.mentalModels || []).map((mm, mi) => {
751
+ const models = b.mentalModels || [];
752
+ const corrupt = new Set(b.corruptedMentalModelNames || []);
753
+ const byId = new Map(models.map(mm => [mm.id, mm]));
754
+ // model→model edges, self-references and dangling ids filtered for hub-ness
755
+ const edgesOf = (mm) => (mm.derivedFromModelIds || []).filter(id => id && id !== mm.id);
756
+ const anyEdges = models.some(mm => edgesOf(mm).length > 0);
757
+ const hubs = models.filter(mm => edgesOf(mm).length > 0);
758
+
759
+ const freshRank = (mm) => corrupt.has(mm.name) ? 2 : isStaleTs(mm.lastRefreshedAt || mm.createdAt) ? 1 : 0;
760
+ const tsMs = (mm) => { const ts = mm.lastRefreshedAt || mm.createdAt; return ts ? Date.parse(ts) : 0; };
761
+ const freshCmp = (a, c) => freshRank(a) - freshRank(c) || tsMs(c) - tsMs(a);
762
+
763
+ // One model row. isLeaf indents it under a hub with a tree connector.
764
+ const modelRow = (mm, opts) => {
765
+ opts = opts || {};
679
766
  const ts = mm.lastRefreshedAt || mm.createdAt;
680
- const stale = ts && (Date.now() - Date.parse(ts)) > 7 * 86400000;
681
- const detailId = `memdetail-${bi}-${mi}`;
767
+ const stale = isStaleTs(ts);
768
+ const corr = corrupt.has(mm.name);
769
+ const isHub = edgesOf(mm).length > 0;
770
+ const safe = (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, '_');
771
+ const detailId = `memdetail-${bi}-${safe(mm.id)}`;
772
+ const rowId = `mmrow-${bi}-${safe(mm.id)}`;
682
773
  const idJs = attrJson(mm.id);
683
- const ageLabel = fmtAge(ts) || 'never refreshed';
774
+ const meta = [fmtK(mm.contentLength) + 'c', mm.refreshMode, fmtAge(ts) || 'never'].filter(Boolean).join(' · ');
775
+ const badges = [
776
+ isHub ? `<span class="mm-badge hub" title="synthesizes other models">hub</span>` : '',
777
+ corr ? `<span class="mm-badge corrupt">corrupted</span>` : (stale ? `<span class="mm-badge stale">stale</span>` : ''),
778
+ ].join('');
779
+ let chips = '';
780
+ if (isHub) {
781
+ const items = edgesOf(mm).map(id => {
782
+ const t = byId.get(id);
783
+ return t
784
+ ? `<span class="mm-chip" onclick='focusModel(${bi}, ${attrJson(t.id)})'>${escapeHtml(t.name)}</span>`
785
+ : `<span class="mm-chip raw" title="not a model in this bank">${escapeHtml(id)}</span>`;
786
+ });
787
+ const s = provSegments(mm.basedOnCounts);
788
+ if (s.obs + s.other > 0) items.push(`<span class="mm-chip muted" title="also synthesized from raw facts">+ raw facts</span>`);
789
+ chips = `<div class="mm-chips"><span>draws on:</span>${items.join('')}</div>`;
790
+ }
684
791
  const why = mm.sourceQuery
685
- ? `<div style="color:var(--text-dim);font-size:.84em;margin-top:.15rem">answers: “${escapeHtml(mm.sourceQuery)}”</div>`
792
+ ? `<div class="mm-why">answers: “${escapeHtml(mm.sourceQuery)}”</div>`
686
793
  : '';
687
- const modeBadge = mm.refreshMode
688
- ? `<span style="color:var(--text-dim);font-size:.78em;border:1px solid var(--border);border-radius:5px;padding:0 .35rem">${escapeHtml(mm.refreshMode)}</span>`
689
- : '';
690
- return `<div style="border-top:1px solid var(--border);padding:.5rem 0">
691
- <div style="display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap">
692
- <strong>${escapeHtml(mm.name)}</strong>
693
- <span style="font-size:.82em;${stale ? 'color:var(--yellow)' : 'color:var(--text-dim)'}">${ageLabel}${stale ? ' · stale' : ''}</span>
694
- ${modeBadge}
695
- <button class="btn" type="button" style="margin-left:auto;padding:.12rem .55rem;font-size:.8em" onclick='memViewModel(${bankJs}, ${idJs}, "${detailId}", this)'>view</button>
696
- </div>
794
+ return `<div class="mm-row${opts.leaf ? ' mm-leaf' : ''}" id="${rowId}">
795
+ <div class="mm-top"><span class="mm-name">${escapeHtml(mm.name)}</span>${badges}<span class="mm-meta">${escapeHtml(meta)}</span></div>
697
796
  ${why}
698
- <div id="${detailId}" style="display:none;margin-top:.5rem"></div>
797
+ ${provBar(mm.basedOnCounts)}
798
+ ${chips}
799
+ <div style="margin-top:.3rem"><button class="btn" type="button" style="padding:.1rem .5rem;font-size:.78em" onclick='memViewModel(${bankJs}, ${idJs}, "${detailId}", this)'>view content</button></div>
800
+ <div id="${detailId}" style="display:none;margin-top:.4rem"></div>
699
801
  </div>`;
700
- }).join('');
701
- const gapLine = b.recentUnextractedCount > 0
702
- ? `<div style="color:var(--red);margin-top:.4rem">⚠ ${b.recentUnextractedCount} recent conversation(s) stored but NOT extracted (oldest ${fmtDay(b.oldestUnextractedAt)}) invisible to recall until reprocessed</div>`
703
- : '';
704
- const corruptLine = (b.corruptedMentalModelNames || []).length > 0
705
- ? `<div style="color:var(--red);margin-top:.4rem">⚠ corrupted mental model(s): ${escapeHtml(b.corruptedMentalModelNames.join(', '))} — content is an LLM failure message; refresh once quota recovers</div>`
802
+ };
803
+
804
+ // Order: each hub followed by its leaves (a cluster), then orphans
805
+ // freshest-first. No edges anywhere → a flat freshness-ordered list
806
+ // (no spine, no chips) the common, calm case.
807
+ const rendered = new Set();
808
+ const blocks = [];
809
+ if (anyEdges) {
810
+ for (const hub of hubs) {
811
+ if (rendered.has(hub.id)) continue;
812
+ rendered.add(hub.id);
813
+ let cluster = modelRow(hub, {});
814
+ for (const id of edgesOf(hub)) {
815
+ const leaf = byId.get(id);
816
+ if (!leaf || rendered.has(leaf.id)) continue;
817
+ rendered.add(leaf.id);
818
+ cluster += modelRow(leaf, { leaf: true });
819
+ }
820
+ blocks.push(`<div class="mm-cluster">${cluster}</div>`);
821
+ }
822
+ }
823
+ const orphans = models.filter(mm => !rendered.has(mm.id)).sort(freshCmp);
824
+ for (const o of orphans) blocks.push(modelRow(o, {}));
825
+ const modelsHtml = blocks.join('');
826
+
827
+ // Attention band — problems promoted to the top of the card.
828
+ const warnLines = [];
829
+ if ((b.corruptedMentalModelNames || []).length > 0) {
830
+ warnLines.push(`corrupted: ${escapeHtml(b.corruptedMentalModelNames.join(', '))} — content is an LLM failure message; refresh once quota recovers`);
831
+ }
832
+ if (b.recentUnextractedCount > 0) {
833
+ warnLines.push(`${b.recentUnextractedCount} recent conversation(s) stored but NOT extracted (oldest ${fmtDay(b.oldestUnextractedAt)}) — invisible to recall until reprocessed`);
834
+ }
835
+ const attn = warnLines.length
836
+ ? `<div class="mm-attn ${b.status === 'fail' ? '' : 'warn'}">${warnLines.map(w => `<div>⚠ ${w}</div>`).join('')}</div>`
706
837
  : '';
707
- // --- Remediation buttons (audit ranks 7 + 22) ---
708
- // Each triggers an HTTP poke at hindsight; hindsight does the LLM
709
- // work on its own provider — the dashboard makes NO model call.
838
+
839
+ // Bank-summary band: dense stat line + aggregate source-mix bar + freshness heat-strip.
840
+ const agg = {};
841
+ for (const mm of models) for (const [k, v] of Object.entries(mm.basedOnCounts || {})) agg[k] = (agg[k] || 0) + (v || 0);
842
+ const statLine = `${fmtNum(b.totalDocuments)} <span class="dim">conversations</span> · ${fmtNum(b.totalFacts)} <span class="dim">facts</span> · <span class="dim">latest</span> ${fmtDay(b.newestDocumentAt)}${fmtAge(b.newestDocumentAt) ? ` <span class="dim">(${fmtAge(b.newestDocumentAt)})</span>` : ''} · ${models.length} <span class="dim">model${models.length === 1 ? '' : 's'}</span>${b.staleMentalModelCount ? ` <span style="color:var(--yellow)">(${b.staleMentalModelCount} stale)</span>` : ''}`;
843
+ const heatCell = (mm) => {
844
+ const ts = mm.lastRefreshedAt || mm.createdAt;
845
+ const age = ts ? (Date.now() - Date.parse(ts)) / 86400000 : null;
846
+ const cls = corrupt.has(mm.name) ? 'corrupt' : age === null ? 'cold' : age > 7 ? 'stale' : 'fresh';
847
+ return `<span class="${cls}" title="${escapeHtml(mm.name)} — ${fmtAge(ts) || 'never refreshed'}"></span>`;
848
+ };
849
+ const heat = models.length ? `<div class="heat" title="freshness, one cell per model">${models.map(heatCell).join('')}</div>` : '';
850
+ const aggBar = provBar(agg, 9);
851
+
852
+ // Mental-models band header — count + a hub-summary when relationships exist.
853
+ const mmHeader = `Mental models <span class="n">${models.length}</span>` +
854
+ (anyEdges ? `<span class="mm-badge hub" style="margin-left:auto">${hubs.length} synthesize${hubs.length === 1 ? 's' : ''} others</span>` : '');
855
+
856
+ // --- Remediation buttons (unchanged behaviour) ---
710
857
  const buttons = [];
711
- // Reprocess the extraction-gap docs.
712
858
  if (b.recentUnextractedCount > 0) {
713
859
  const n = Math.min(b.recentUnextractedCount, (b.unextractedDocIds || []).length) || b.recentUnextractedCount;
714
860
  buttons.push(`<button class="btn" type="button" onclick='memReprocess(${bankJs}, this)'>Reprocess ${n} doc${n === 1 ? '' : 's'}</button>`);
715
861
  }
716
- // Refresh each corrupted/stale model (map corrupted NAME → id).
717
862
  const affectedIds = new Set();
718
863
  for (const name of (b.corruptedMentalModelNames || [])) {
719
- const mm = (b.mentalModels || []).find(x => x.name === name);
864
+ const mm = models.find(x => x.name === name);
720
865
  if (mm && mm.id) affectedIds.add(mm.id);
721
866
  }
722
- for (const mm of (b.mentalModels || [])) {
723
- const ts = mm.lastRefreshedAt || mm.createdAt;
724
- const stale = ts && (Date.now() - Date.parse(ts)) > 7 * 86400000;
725
- if (stale && mm.id) affectedIds.add(mm.id);
867
+ for (const mm of models) {
868
+ if (isStaleTs(mm.lastRefreshedAt || mm.createdAt) && mm.id) affectedIds.add(mm.id);
726
869
  }
727
- for (const mm of (b.mentalModels || [])) {
870
+ for (const mm of models) {
728
871
  if (!affectedIds.has(mm.id)) continue;
729
872
  const idJs = attrJson(mm.id), nameJs = attrJson(mm.name);
730
873
  buttons.push(`<button class="btn" type="button" onclick='memRefreshModel(${bankJs}, ${idJs}, ${nameJs}, this)'>Refresh ${escapeHtml(mm.name)}</button>`);
731
874
  }
732
- // Build the user-profile model when the bank has data but no profile.
733
875
  if (b.totalDocuments > 0 && !hasUserProfile(b)) {
734
876
  buttons.push(`<button class="btn" type="button" onclick='memBuildProfile(${bankJs}, this)'>Build profile</button>`);
735
877
  }
736
- const buttonRow = buttons.length
737
- ? `<div class="rem-actions" style="margin-top:.6rem;display:flex;flex-wrap:wrap;gap:.4rem">${buttons.join('')}</div>`
878
+ const actionsBand = buttons.length
879
+ ? `<div class="mm-band"><div class="mm-band-h">Actions</div><div class="mm-actions">${buttons.join('')}</div></div>`
738
880
  : '';
881
+
739
882
  return `<div class="agent-card">
740
883
  <div class="card-header" style="cursor:default">
741
884
  ${statusDot(b.status)}<span class="agent-name">${escapeHtml(b.bank)}</span>
742
885
  <span style="color:var(--text-dim);font-size:.85em;margin-left:.5rem">${escapeHtml((b.agents || []).join(', '))}</span>
743
886
  </div>
744
887
  <div style="padding:0 1.25rem 1rem">
745
- <div style="color:var(--text-dim);margin-bottom:.4rem">${escapeHtml(b.statusDetail || '')}</div>
746
- <div class="card-meta" style="padding:0">
747
- <div class="meta-item"><label>Conversations </label><span>${b.totalDocuments}</span></div>
748
- <div class="meta-item"><label>Facts </label><span>${b.totalFacts}</span></div>
749
- <div class="meta-item"><label>Latest activity </label><span>${fmtDay(b.newestDocumentAt)} ${fmtAge(b.newestDocumentAt) ? '(' + fmtAge(b.newestDocumentAt) + ')' : ''}</span></div>
750
- <div class="meta-item"><label>Mental models </label><span>${(b.mentalModels || []).length}${b.staleMentalModelCount ? ` <span style="color:var(--yellow)">(${b.staleMentalModelCount} stale)</span>` : ''}</span></div>
888
+ <div style="color:var(--text-dim);margin:.1rem 0 .3rem">${escapeHtml(b.statusDetail || '')}</div>
889
+ ${attn}
890
+ <div class="mm-band" style="border-top:none">
891
+ <div class="mm-band-h">Bank summary</div>
892
+ <div class="mm-stat-line">${statLine}</div>
893
+ ${aggBar}
894
+ ${heat}
751
895
  </div>
752
- ${models ? `<div style="margin-top:.5rem"><div style="font-size:.74em;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.1rem">Mental models — what this fleet remembers</div>${models}</div>` : ''}
753
- ${corruptLine}
754
- ${gapLine}
755
- ${buttonRow}
896
+ <div class="mm-band">
897
+ <div class="mm-band-h">${mmHeader}</div>
898
+ ${modelsHtml || '<div style="color:var(--text-dim);font-size:.85em">No mental models yet — facts are still accumulating.</div>'}
899
+ </div>
900
+ ${actionsBand}
756
901
  </div>
757
902
  </div>`;
758
903
  }).join('');
759
904
  container.innerHTML = explainer + `<div class="agents-grid">${cards || '<div style="color:var(--text-dim)">No agent banks configured.</div>'}</div>`;
760
905
  }
761
906
 
907
+ // Chip-as-cross-link: scroll to the named model's row in the same card
908
+ // and flash it. Pure DOM — no graph canvas. `bi` is the bank index, `id`
909
+ // the target model id (must match modelRow's id sanitization exactly).
910
+ function focusModel(bi, id) {
911
+ const rowId = `mmrow-${bi}-${String(id).replace(/[^a-zA-Z0-9_-]/g, '_')}`;
912
+ const el = document.getElementById(rowId);
913
+ if (!el) return;
914
+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
915
+ el.classList.remove('mm-flash');
916
+ void el.offsetWidth; // restart the animation
917
+ el.classList.add('mm-flash');
918
+ }
919
+
762
920
  // --- Memory remediation actions ---
763
921
  // Each pokes a hindsight REST/MCP endpoint via the web's POST routes.
764
922
  // Hindsight does the LLM extraction on its OWN claude-code provider;
@@ -857,23 +1015,41 @@
857
1015
  return provLine + body;
858
1016
  }
859
1017
 
860
- async function fetchConnections() {
861
- // Each fetch falls back independently (.catch → default). A single
862
- // network blip — e.g. one endpoint momentarily unreachable — must NOT
863
- // reject the whole batch and blank the connected accounts; the others
864
- // still render. (Previously a bare Promise.all meant any one failure
865
- // wiped the tab, so a connected Google/Microsoft account "vanished".)
866
- const safe = (p, fallback) => p.then(r => r.ok ? r.json() : fallback).catch(() => fallback);
1018
+ async function fetchConnections(opts) {
1019
+ opts = opts || {};
1020
+ const attempt = opts.attempt || 0;
1021
+ // Distinguish a FAILED fetch from a genuinely-empty result. The old
1022
+ // `safe` collapsed both to []/{}, so a transient broker/web blip
1023
+ // rendered identically to "nothing configured" and because this tab
1024
+ // fetches only on open (NOT the 10s fleet poll), a one-time failure
1025
+ // stuck until a manual re-click. Track ok per fetch so renderConnections
1026
+ // can show an honest "couldn't load" state, and self-heal with bounded
1027
+ // retries. (A single endpoint failing still renders the others.)
1028
+ const safe = (p, fallback) => p
1029
+ .then(r => r.ok ? r.json().then(d => ({ ok: true, data: d })) : { ok: false, data: fallback })
1030
+ .catch(() => ({ ok: false, data: fallback }));
867
1031
  try {
868
- const [google, microsoft, notion, agents] = await Promise.all([
1032
+ const [g, ms, n, ag] = await Promise.all([
869
1033
  safe(fetch(`${API}/api/google-accounts`, { headers: authHeaders() }), []),
870
1034
  safe(fetch(`${API}/api/microsoft-accounts`, { headers: authHeaders() }), []),
871
1035
  safe(fetch(`${API}/api/notion-workspace`, { headers: authHeaders() }), { configured: false, databases: [] }),
872
1036
  safe(fetch(`${API}/api/agents`, { headers: authHeaders() }), []),
873
1037
  ]);
874
- const agentNames = (agents || []).map(a => a.name).sort();
875
- renderConnections({ google, microsoft, notion, agentNames });
1038
+ // Notion legitimately reports unconfigured not a failure. The OAuth
1039
+ // providers + the agent list are the ones whose failure must not read
1040
+ // as "empty".
1041
+ const fetchFailed = !g.ok || !ms.ok || !ag.ok;
1042
+ renderConnections({
1043
+ google: g.data, microsoft: ms.data, notion: n.data,
1044
+ agentNames: (ag.data || []).map(a => a.name).sort(),
1045
+ googleFailed: !g.ok, microsoftFailed: !ms.ok, fetchFailed,
1046
+ });
876
1047
  clearError();
1048
+ // Self-heal a transient blip without a manual re-click. Bounded
1049
+ // backoff (3s, 6s, 9s); stops the instant a fetch succeeds.
1050
+ if (fetchFailed && attempt < 3) {
1051
+ setTimeout(() => fetchConnections({ attempt: attempt + 1 }), 3000 * (attempt + 1));
1052
+ }
877
1053
  } catch (err) {
878
1054
  showError(`Failed to fetch connections: ${err.message}`);
879
1055
  }
@@ -1408,6 +1584,14 @@
1408
1584
  detail: 'These accounts are from the config; the broker did not return live slot state, so connection status may be stale.',
1409
1585
  remediation: { kind: 'none', label: 'Refresh the tab once the broker is reachable to see live status.' },
1410
1586
  };
1587
+ case 'connections-unreachable':
1588
+ return {
1589
+ title: "Couldn't load live connection data — retrying",
1590
+ detail: 'The dashboard could not reach the account data source (the broker or web container may be restarting). ' +
1591
+ 'This is NOT "no accounts configured" — your connected accounts are unchanged. ' +
1592
+ 'Auto-retrying; the tab fills in once it recovers.',
1593
+ remediation: { kind: 'none', label: 'Or refresh the page if it persists.' },
1594
+ };
1411
1595
  default:
1412
1596
  return { title: String(kind || 'Problem'), remediation: { kind: 'none' } };
1413
1597
  }
@@ -1935,13 +2119,21 @@
1935
2119
  const notion = data.notion || { configured: false, databases: [] };
1936
2120
  const agentNames = data.agentNames || [];
1937
2121
 
2122
+ // When a provider's fetch FAILED (not genuinely empty), its empty-state
2123
+ // must say "couldn't load", never "none configured" — otherwise a
2124
+ // transient blip reads as "you have no accounts".
1938
2125
  const googleSection = _connectionSection(
1939
2126
  'Google',
1940
- 'No Google accounts. Add one under <code>google_accounts:</code> and run <code>switchroom auth google account add</code>.',
2127
+ data.googleFailed
2128
+ ? "Couldn't load Google accounts — the data source was unreachable (retrying)."
2129
+ : 'No Google accounts. Add one under <code>google_accounts:</code> and run <code>switchroom auth google account add</code>.',
1941
2130
  google.map(a => renderOAuthAccountCard(a, { showType: false, provider: 'google', agentNames })).join(''),
1942
2131
  );
1943
2132
 
1944
2133
  const msCards = microsoft.map(a => renderOAuthAccountCard(a, { showType: true, provider: 'microsoft', agentNames })).join('');
2134
+ const msEmpty = data.microsoftFailed
2135
+ ? "Couldn't load Microsoft accounts — the data source was unreachable (retrying)."
2136
+ : 'No Microsoft accounts yet — click <b>Connect a Microsoft account</b> above (or <code>/connect microsoft</code> from Telegram).';
1945
2137
  const microsoftSection = `
1946
2138
  <div style="margin-bottom:1.5rem">
1947
2139
  <h3 style="margin:0 0 .6rem;font-size:.95rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em">
@@ -1951,7 +2143,7 @@
1951
2143
  <div id="ms-connect-card"></div>
1952
2144
  ${msCards
1953
2145
  ? `<div class="accounts-grid">${msCards}</div>`
1954
- : `<div class="loading" style="padding:.8rem">No Microsoft accounts yet — click <b>Connect a Microsoft account</b> above (or <code>/connect microsoft</code> from Telegram).</div>`}
2146
+ : `<div class="loading" style="padding:.8rem">${msEmpty}</div>`}
1955
2147
  </div>`;
1956
2148
 
1957
2149
  let notionCards = '';
@@ -1998,7 +2190,14 @@
1998
2190
  ? renderProblem(problemFor('connections-degraded', {}))
1999
2191
  : '';
2000
2192
 
2001
- container.innerHTML = degradedBanner + googleSection + microsoftSection + notionSection;
2193
+ // A failed fetch must NOT masquerade as "nothing configured" — lead
2194
+ // with an honest banner so the operator knows the empty look is a
2195
+ // load failure (auto-retrying), not an absence of accounts.
2196
+ const unreachableBanner = data.fetchFailed
2197
+ ? renderProblem(problemFor('connections-unreachable', {}))
2198
+ : '';
2199
+
2200
+ container.innerHTML = unreachableBanner + degradedBanner + googleSection + microsoftSection + notionSection;
2002
2201
  }
2003
2202
 
2004
2203
  function renderSchedule(data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.32",
3
+ "version": "0.15.34",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54460,10 +54460,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54460
54460
  }
54461
54461
 
54462
54462
  // ../src/build-info.ts
54463
- var VERSION = "0.15.32";
54464
- var COMMIT_SHA = "61984cef";
54465
- var COMMIT_DATE = "2026-06-15T12:32:06Z";
54466
- var LATEST_PR = 2384;
54463
+ var VERSION = "0.15.34";
54464
+ var COMMIT_SHA = "508eb512";
54465
+ var COMMIT_DATE = "2026-06-16T02:47:01Z";
54466
+ var LATEST_PR = 2389;
54467
54467
  var COMMITS_AHEAD_OF_TAG = 0;
54468
54468
 
54469
54469
  // gateway/boot-version.ts