monomind 2.1.0 → 2.1.3

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 (49) hide show
  1. package/README.md +74 -92
  2. package/package.json +3 -3
  3. package/packages/@monomind/cli/.claude/helpers/handlers/capture-handler.cjs +24 -10
  4. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +2 -2
  5. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +29 -0
  6. package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +35 -23
  7. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +21 -0
  8. package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +73 -2
  9. package/packages/@monomind/cli/README.md +74 -92
  10. package/packages/@monomind/cli/dist/src/browser/workflow/store.d.ts +2 -2
  11. package/packages/@monomind/cli/dist/src/commands/browse.d.ts +1 -1
  12. package/packages/@monomind/cli/dist/src/commands/doctor-env-checks.js +5 -0
  13. package/packages/@monomind/cli/dist/src/commands/org.js +46 -29
  14. package/packages/@monomind/cli/dist/src/commands/platforms.d.ts +1 -1
  15. package/packages/@monomind/cli/dist/src/init/executor.js +30 -6
  16. package/packages/@monomind/cli/dist/src/mcp-tools/auto-install.d.ts +8 -8
  17. package/packages/@monomind/cli/dist/src/mcp-tools/coherence/types.d.ts +31 -31
  18. package/packages/@monomind/cli/dist/src/mcp-tools/hive-mind-tools.js +5 -1
  19. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-tools.js +28 -3
  20. package/packages/@monomind/cli/dist/src/mcp-tools/quality/coverage-analysis/prioritize-gaps.d.ts +36 -36
  21. package/packages/@monomind/cli/dist/src/mcp-tools/quality/security-compliance/detect-secrets.d.ts +4 -4
  22. package/packages/@monomind/cli/dist/src/memory/ewc-consolidation.d.ts +12 -0
  23. package/packages/@monomind/cli/dist/src/memory/sona-optimizer.d.ts +12 -0
  24. package/packages/@monomind/cli/dist/src/orgrt/broker.d.ts +3 -1
  25. package/packages/@monomind/cli/dist/src/orgrt/broker.js +8 -3
  26. package/packages/@monomind/cli/dist/src/orgrt/bus.d.ts +5 -1
  27. package/packages/@monomind/cli/dist/src/orgrt/bus.js +5 -1
  28. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +17 -1
  29. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +115 -21
  30. package/packages/@monomind/cli/dist/src/orgrt/forwarder.js +41 -8
  31. package/packages/@monomind/cli/dist/src/orgrt/inbox.d.ts +11 -0
  32. package/packages/@monomind/cli/dist/src/orgrt/inbox.js +56 -0
  33. package/packages/@monomind/cli/dist/src/orgrt/policy.d.ts +5 -1
  34. package/packages/@monomind/cli/dist/src/orgrt/policy.js +58 -5
  35. package/packages/@monomind/cli/dist/src/orgrt/server.d.ts +3 -2
  36. package/packages/@monomind/cli/dist/src/orgrt/server.js +7 -40
  37. package/packages/@monomind/cli/dist/src/orgrt/session.d.ts +10 -1
  38. package/packages/@monomind/cli/dist/src/orgrt/session.js +24 -1
  39. package/packages/@monomind/cli/dist/src/orgrt/test-loop.js +8 -8
  40. package/packages/@monomind/cli/dist/src/orgrt/types.d.ts +52 -52
  41. package/packages/@monomind/cli/dist/src/output.d.ts +29 -29
  42. package/packages/@monomind/cli/dist/src/output.js +10 -2
  43. package/packages/@monomind/cli/dist/src/types.d.ts +2 -2
  44. package/packages/@monomind/cli/dist/src/ui/dashboard.html +203 -9
  45. package/packages/@monomind/cli/dist/src/ui/orgs-files.js +192 -0
  46. package/packages/@monomind/cli/dist/src/ui/orgs.html +21 -52
  47. package/packages/@monomind/cli/dist/src/ui/server.mjs +42 -6
  48. package/packages/@monomind/cli/package.json +12 -16
  49. package/packages/@monomind/cli/dist/src/orgrt/live.html +0 -56
@@ -428,6 +428,18 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
428
428
  /* SVG org chart */
429
429
  .org-chart-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; margin-bottom: 12px; }
430
430
  .org-chart-svg { width: 100%; display: block; }
431
+ .v2-bubble-inner {
432
+ background: oklch(18% 0.01 55 / 0.95);
433
+ border: 1px solid oklch(72% 0.18 75 / 0.4);
434
+ border-radius: 8px;
435
+ padding: 4px 8px;
436
+ font: 500 9.5px 'Inter', system-ui, sans-serif;
437
+ color: oklch(88% 0.01 75);
438
+ text-align: center;
439
+ white-space: nowrap;
440
+ overflow: hidden;
441
+ text-overflow: ellipsis;
442
+ }
431
443
  .org-chart-legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 0 2px; }
432
444
  .ocl-item { display: flex; align-items: center; gap: 5px; font-size: 10px; color: var(--text-lo); }
433
445
 
@@ -2468,7 +2480,7 @@ function switchView(v, { updateHash = true } = {}) {
2468
2480
  const titles = { now:'Now', projects:'Projects', sessions:'Sessions', loops:'Loops', tokens:'Tokens', memory:'Memory', orgs:'Orgs', monograph:'Monograph', monoagent:'MonoAgent', global:'Global Feed', 'global-loops':'Global Loops', 'global-tokens':'Global Tokens', chat:'Global Agent Chat' };
2469
2481
  document.getElementById('view-title').textContent = titles[v] || v;
2470
2482
  const PROJECT = DIR ? shortPath(DIR) : 'monomind';
2471
- const VIEW_LABELS = { now: 'Now', sessions: 'Sessions', projects: 'Projects', loops: 'Loops', tokens: 'Tokens', memory: 'Memory', orgs: 'Orgs', monograph: 'Monograph', global: 'Global Feed', 'global-loops': 'Global Loops', 'global-tokens': 'Global Tokens' };
2483
+ const VIEW_LABELS = { now: 'Now', sessions: 'Sessions', projects: 'Projects', loops: 'Loops', tokens: 'Tokens', memory: 'Memory', orgs: 'Orgs', monograph: 'Monograph', monoagent: 'MonoAgent', global: 'Global Feed', 'global-loops': 'Global Loops', 'global-tokens': 'Global Tokens', chat: 'Global Agent Chat' };
2472
2484
  document.title = `monomind · ${PROJECT} · ${VIEW_LABELS[v] || v}`;
2473
2485
  if (updateHash) _pushHash();
2474
2486
  // Projects always re-fetches so onclick paths in cards stay current
@@ -5786,6 +5798,7 @@ let _v2SelOrg = null;
5786
5798
  let _v2OrgData = null;
5787
5799
  let _v2OrgTab = 'chart';
5788
5800
  const _v2OrgEventLog = {};
5801
+ let _v2ChartLayout = { pos: {}, edgeIdByPair: new Map() };
5789
5802
 
5790
5803
  async function renderOrgs() {
5791
5804
  try {
@@ -6204,6 +6217,9 @@ function v2RenderOrgChart() {
6204
6217
  <image href="${avatarSrc}" x="${-avR}" y="${-R + 2}" width="${avR * 2}" height="${avR * 2}" clip-path="url(#${nodeClipIds[i]})" preserveAspectRatio="xMidYMid meet" onerror="this.setAttribute('href','data/avatars/coder.svg')"/>
6205
6218
  <text text-anchor="middle" y="${nameY}" font-size="9" font-weight="${leader?'600':'500'}" font-family="'Inter',system-ui,sans-serif" fill="${color}">${esc(nameText)}</text>
6206
6219
  ${subTypeText ? `<text text-anchor="middle" y="${+nameY + 11}" font-size="7.5" font-family="'Inter',system-ui,sans-serif" fill="oklch(48% 0.005 75)">${esc(subTypeText)}</text>` : ''}
6220
+ <foreignObject class="v2-activity-bubble" data-bubble-for="${esc(role.id)}" x="-70" y="${-R - 46}" width="140" height="34" style="overflow:visible;pointer-events:none;visibility:hidden;opacity:0">
6221
+ <div class="v2-bubble-inner" xmlns="http://www.w3.org/1999/xhtml"></div>
6222
+ </foreignObject>
6207
6223
  </g>`;
6208
6224
  });
6209
6225
 
@@ -6230,6 +6246,7 @@ function v2RenderOrgChart() {
6230
6246
  <g id="v2oc-edges">${edgesHTML}</g>
6231
6247
  <g id="v2oc-nodes">${nodesHTML}</g>
6232
6248
  <g id="v2oc-particles" opacity="${isRunning ? 1 : 0}">${particlesHTML}</g>
6249
+ <g id="v2oc-letters"></g>
6233
6250
  </svg>
6234
6251
  </div>
6235
6252
  <div class="org-chart-legend" style="margin-top:10px">
@@ -6262,8 +6279,158 @@ function v2RenderOrgChart() {
6262
6279
  el.style.opacity = '0';
6263
6280
  });
6264
6281
  }
6282
+
6283
+ const edgeIdByPair = new Map();
6284
+ comms.forEach((edge, ei) => {
6285
+ if (edge.from === edge.to) return;
6286
+ edgeIdByPair.set(edge.from + '->' + edge.to, 'v2ep' + ei);
6287
+ });
6288
+ _v2ChartLayout = { pos, edgeIdByPair };
6289
+ v2OrgActivityReset();
6290
+ }
6291
+
6292
+ function _v2RoleName(id, roles) {
6293
+ const r = (roles || []).find(x => x.id === id);
6294
+ return r ? (r.name || r.title || r.id) : id;
6295
+ }
6296
+
6297
+ function v2DeriveActivityText(ev, roles) {
6298
+ if (!ev || !ev.from) return null;
6299
+ const type = ev.type || '';
6300
+ if (type === 'org:tool') return ev.tool ? ('running ' + ev.tool) : 'active';
6301
+ if (type === 'org:checkpoint') {
6302
+ const p = ev.progress || '';
6303
+ return p ? (p.length > 40 ? p.slice(0, 39) + '...' : p) : 'active';
6304
+ }
6305
+ if (type === 'org:comms') {
6306
+ if (!ev.to || ev.to === 'all') return 'broadcasting';
6307
+ return 'messaging ' + _v2RoleName(ev.to, roles);
6308
+ }
6309
+ if (type === 'org:artifact') {
6310
+ const label = ev.artifact && ev.artifact.label;
6311
+ return label ? ('writing ' + label) : 'active';
6312
+ }
6313
+ return 'active';
6314
+ }
6315
+
6316
+ function v2DeriveLetterTarget(ev, roles) {
6317
+ if (!ev || !ev.from) return null;
6318
+ const type = ev.type || '';
6319
+ if (type === 'org:comms') {
6320
+ if (!ev.to || ev.to === 'all') return null;
6321
+ return { from: ev.from, to: ev.to };
6322
+ }
6323
+ if (type === 'org:checkpoint' || type === 'org:artifact') {
6324
+ const leader = (roles || []).find(_v2OrgIsLeader);
6325
+ if (!leader || leader.id === ev.from) return null;
6326
+ return { from: ev.from, to: leader.id };
6327
+ }
6328
+ return null;
6329
+ }
6330
+
6331
+ let _v2LetterBurst = { lastTs: 0, count: 0 };
6332
+
6333
+ function v2SpawnLetter(fromId, toId) {
6334
+ const layout = _v2ChartLayout;
6335
+ const fp = layout.pos[fromId], tp = layout.pos[toId];
6336
+ if (!fp || !tp) return;
6337
+ const group = document.getElementById('v2oc-letters');
6338
+ if (!group) return;
6339
+
6340
+ const now = Date.now();
6341
+ if (now - _v2LetterBurst.lastTs < 150) _v2LetterBurst.count++;
6342
+ else _v2LetterBurst.count = 0;
6343
+ _v2LetterBurst.lastTs = now;
6344
+ const delay = (_v2LetterBurst.count * 0.08).toFixed(2);
6345
+
6346
+ const svgNS = 'http://www.w3.org/2000/svg';
6347
+ let pathId = layout.edgeIdByPair.get(fromId + '->' + toId);
6348
+ let synthesizedPath = null;
6349
+ if (!pathId) {
6350
+ pathId = 'v2lp' + Math.random().toString(36).slice(2, 9);
6351
+ synthesizedPath = document.createElementNS(svgNS, 'path');
6352
+ synthesizedPath.setAttribute('id', pathId);
6353
+ synthesizedPath.setAttribute('d', 'M' + fp.x + ',' + fp.y + ' L' + tp.x + ',' + tp.y);
6354
+ synthesizedPath.setAttribute('fill', 'none');
6355
+ synthesizedPath.setAttribute('stroke', 'none');
6356
+ document.getElementById('v2oc-edges').appendChild(synthesizedPath);
6357
+ }
6358
+
6359
+ const letter = document.createElementNS(svgNS, 'g');
6360
+ const circ = document.createElementNS(svgNS, 'circle');
6361
+ circ.setAttribute('r', '6');
6362
+ circ.setAttribute('fill', 'oklch(85% 0.16 85)');
6363
+ circ.setAttribute('stroke', 'oklch(30% 0.02 55)');
6364
+ circ.setAttribute('stroke-width', '1');
6365
+ letter.appendChild(circ);
6366
+ const flap = document.createElementNS(svgNS, 'path');
6367
+ flap.setAttribute('d', 'M-3.5,-2 L0,1 L3.5,-2');
6368
+ flap.setAttribute('stroke', 'oklch(30% 0.02 55)');
6369
+ flap.setAttribute('stroke-width', '1');
6370
+ flap.setAttribute('fill', 'none');
6371
+ letter.appendChild(flap);
6372
+
6373
+ const anim = document.createElementNS(svgNS, 'animateMotion');
6374
+ anim.setAttribute('dur', '0.8s');
6375
+ anim.setAttribute('begin', delay + 's');
6376
+ anim.setAttribute('fill', 'freeze');
6377
+ const mpath = document.createElementNS(svgNS, 'mpath');
6378
+ mpath.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + pathId);
6379
+ anim.appendChild(mpath);
6380
+ letter.appendChild(anim);
6381
+ group.appendChild(letter);
6382
+
6383
+ if (typeof gsap !== 'undefined') {
6384
+ gsap.fromTo(letter, { scale: 0, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, duration: 0.15, delay: Number(delay), ease: 'back.out(1.7)' });
6385
+ }
6386
+
6387
+ const totalMs = (Number(delay) + 0.8) * 1000;
6388
+ setTimeout(function() {
6389
+ function cleanup() {
6390
+ letter.remove();
6391
+ if (synthesizedPath) synthesizedPath.remove();
6392
+ }
6393
+ if (typeof gsap !== 'undefined') {
6394
+ gsap.to(letter, { autoAlpha: 0, duration: 0.15, onComplete: cleanup });
6395
+ } else cleanup();
6396
+ }, totalMs);
6397
+ }
6398
+
6399
+ let _v2BubbleTimers = new Map();
6400
+
6401
+ function v2OrgActivityReset() {
6402
+ _v2BubbleTimers.forEach(t => clearTimeout(t));
6403
+ _v2BubbleTimers = new Map();
6265
6404
  }
6266
6405
 
6406
+ function v2ShowActivityBubble(roleId, text) {
6407
+ const fo = document.querySelector('.v2-activity-bubble[data-bubble-for="' + CSS.escape(roleId) + '"]');
6408
+ if (!fo) return;
6409
+ const inner = fo.querySelector('.v2-bubble-inner');
6410
+ inner.textContent = text;
6411
+ if (typeof gsap !== 'undefined') {
6412
+ gsap.killTweensOf(fo);
6413
+ fo.style.visibility = 'visible';
6414
+ gsap.fromTo(fo, { scale: 0.8, autoAlpha: 0 }, {
6415
+ scale: 1, autoAlpha: 1, duration: 0.3, ease: 'back.out(1.7)',
6416
+ transformOrigin: '50% 100%',
6417
+ });
6418
+ } else {
6419
+ fo.style.visibility = 'visible';
6420
+ fo.style.opacity = '1';
6421
+ }
6422
+ const existing = _v2BubbleTimers.get(roleId);
6423
+ if (existing) clearTimeout(existing);
6424
+ _v2BubbleTimers.set(roleId, setTimeout(function() {
6425
+ if (typeof gsap !== 'undefined') {
6426
+ gsap.to(fo, { autoAlpha: 0, duration: 0.4, ease: 'power2.in', onComplete: function() { fo.style.visibility = 'hidden'; } });
6427
+ } else {
6428
+ fo.style.opacity = '0';
6429
+ fo.style.visibility = 'hidden';
6430
+ }
6431
+ _v2BubbleTimers.delete(roleId);
6432
+ }, 5000));
6433
+ }
6267
6434
 
6268
6435
  // ── in-place running dot updater (avoids full re-render + GSAP flicker on SSE events) ──
6269
6436
  function v2UpdateChartRunningDots() {
@@ -7940,17 +8107,28 @@ window.odtChatSelectAgent = function(name) {
7940
8107
  });
7941
8108
  const feed = document.getElementById('odt-chat-feed');
7942
8109
  const emptyEl = document.getElementById('odt-chat-empty');
7943
- const sess = _odtChatSessions.find(s => s.id === _odtChatCurrentId);
7944
8110
  feed.querySelectorAll('.cv-msg').forEach(e => e.remove());
7945
- if (!sess) { if (emptyEl) emptyEl.style.display = 'block'; return; }
7946
- const visible = (sess.events || []).filter(ev => _odtChatAgentMatches(ev));
8111
+ const id = _odtChatCurrentId;
8112
+ let events = null;
8113
+ let sess = null;
8114
+ if (id && id.startsWith('rgrp:') && _odtRunGroups[id]) {
8115
+ events = _odtGetMergedRunGroupEvents(_odtRunGroups[id]);
8116
+ } else if (id && id.startsWith('grp:') && _odtSessionGroups[id]) {
8117
+ events = _odtGetMergedGroupEvents(_odtSessionGroups[id]);
8118
+ } else {
8119
+ sess = _odtChatSessions.find(s => s.id === id);
8120
+ events = sess ? (sess.events || []) : null;
8121
+ }
8122
+ if (events === null) { if (emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = 'Select a session to see agent communications.'; } return; }
8123
+ const visible = events.filter(ev => _odtChatAgentMatches(ev));
7947
8124
  if (!visible.length) {
7948
8125
  if (emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = name === 'all' ? 'No events.' : `${name} was online in this run but sent no messages.`; }
7949
8126
  } else {
7950
8127
  if (emptyEl) emptyEl.style.display = 'none';
7951
8128
  visible.forEach(ev => _odtAppendEvent(ev, false));
8129
+ if (!feed.querySelector('.cv-msg') && emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = 'No displayable events.'; }
7952
8130
  }
7953
- if (!sess._isOrgRun) {
8131
+ if (sess && !sess._isOrgRun) {
7954
8132
  const _linkedRunId = 'run-' + (sess.id || '').replace(/^mm-/, '');
7955
8133
  const _linkedRun = _odtChatSessions.find(s => s._isOrgRun && s.id === _linkedRunId);
7956
8134
  if (_linkedRun) {
@@ -7976,17 +8154,25 @@ window.odtChatSelectAgent = function(name) {
7976
8154
  // Chat tab render mode (Summary/Detailed/Raw) — persists to localStorage
7977
8155
  let _odtChatMode = localStorage.getItem('odt-chat-mode') || 'detailed';
7978
8156
 
7979
- function setOdtChatMode(mode) {
7980
- _odtChatMode = mode;
7981
- localStorage.setItem('odt-chat-mode', mode);
8157
+ function _odtApplyChatModeUI(mode) {
7982
8158
  document.querySelectorAll('.odt-mode-btn').forEach(b => b.classList.remove('active'));
7983
8159
  const btn = document.getElementById('odt-mode-' + mode);
7984
8160
  if (btn) btn.classList.add('active');
7985
8161
  }
7986
8162
 
8163
+ function setOdtChatMode(mode) {
8164
+ _odtChatMode = mode;
8165
+ localStorage.setItem('odt-chat-mode', mode);
8166
+ _odtApplyChatModeUI(mode);
8167
+ // Re-render the current selection so the mode change is reflected immediately —
8168
+ // previously this only affected events appended after the switch.
8169
+ if (_odtChatCurrentId) window.odtChatSelectAgent(_odtChatCurrentAgent);
8170
+ }
8171
+
7987
8172
  function initOdtChatMode() {
7988
8173
  const saved = localStorage.getItem('odt-chat-mode') || 'detailed';
7989
- setOdtChatMode(saved);
8174
+ _odtChatMode = saved;
8175
+ _odtApplyChatModeUI(saved);
7990
8176
  }
7991
8177
 
7992
8178
  function _odtAppendEvent(ev, animate) {
@@ -8146,6 +8332,14 @@ function _odtHandleLiveEvent(ev) {
8146
8332
  const _arr = [..._odtChatSeenKeys];
8147
8333
  _odtChatSeenKeys = new Set(_arr.slice(1000));
8148
8334
  }
8335
+ // Wire activity bubble for org chart visualization
8336
+ if (ev && ev.org === _v2SelOrg) {
8337
+ const roles = Array.isArray(_v2OrgData && _v2OrgData.roles) ? _v2OrgData.roles : [];
8338
+ const text = v2DeriveActivityText(ev, roles);
8339
+ if (text && ev.from) v2ShowActivityBubble(ev.from, text);
8340
+ const letterTarget = v2DeriveLetterTarget(ev, roles);
8341
+ if (letterTarget) v2SpawnLetter(letterTarget.from, letterTarget.to);
8342
+ }
8149
8343
  // Route org run events (have runId + org) to the active run session
8150
8344
  if (ev?.org && ev?.runId && ev.org === _v2SelOrg) {
8151
8345
  let runSess = _odtChatSessions.find(s => s.id === ev.runId);
@@ -0,0 +1,192 @@
1
+ // packages/@monomind/cli/dist/src/ui/orgs-files.js
2
+ // Files tab + diff-view feature, split out of orgs.html to keep that file under
3
+ // the CLAUDE.md 500-line guideline. Loaded as a classic <script> right where this
4
+ // code used to live inline — shares the same global scope as orgs.html's main
5
+ // script (chatSessions, orgSessionMatch, esc, currentTab, loadChatSessions,
6
+ // viewArtifact are all defined there and referenced here by closure).
7
+
8
+ // ── Files tab: every asset produced by this org, grouped by path with full
9
+ // version history (not deduped) so successive writes to the same file can be
10
+ // diffed against each other, latest-touched file first ──
11
+ let filesGroups = [];
12
+
13
+ function collectOrgArtifacts() {
14
+ const bySessions = chatSessions.filter(orgSessionMatch);
15
+ const byPath = new Map(); // path -> [{ art, ts, from }]
16
+ bySessions.forEach(s => (s.events || []).forEach(ev => {
17
+ if (ev.type !== 'org:artifact') return;
18
+ const art = ev.artifact || (ev.path ? { path: ev.path, label: ev.label, mimeType: ev.mimeType } : null);
19
+ if (!art || !art.path) return;
20
+ if (!byPath.has(art.path)) byPath.set(art.path, []);
21
+ byPath.get(art.path).push({ art, ts: ev.ts || 0, from: ev.from || '' });
22
+ }));
23
+ const groups = [];
24
+ byPath.forEach((versions, path) => {
25
+ versions.sort((a, b) => a.ts - b.ts);
26
+ groups.push({ path, versions, latest: versions[versions.length - 1] });
27
+ });
28
+ groups.sort((a, b) => b.latest.ts - a.latest.ts);
29
+ return groups;
30
+ }
31
+
32
+ function mkFileCard(group) {
33
+ const { art, ts, from } = group.latest;
34
+ const el = document.createElement('div');
35
+ el.className = 'file-card';
36
+ const isText = (art.mimeType || '').startsWith('text/') || (art.mimeType || '') === 'application/json';
37
+ const labelRaw = art.label || (art.path || 'file').split('/').pop();
38
+ const timeStr = ts ? new Date(ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
39
+ const meta = [from, art.path ? art.path.split('/').slice(-3).join('/') : null, timeStr].filter(Boolean).join(' · ');
40
+ const vBadge = group.versions.length > 1 ? `<span class="fc-vbadge">${group.versions.length}v</span>` : '';
41
+ const diffable = group.versions.filter(v => typeof v.art.content === 'string').length >= 2;
42
+ el.innerHTML = `
43
+ <div class="fc-icon">📄</div>
44
+ <div class="fc-body">
45
+ <div class="fc-name">${esc(labelRaw)} ${vBadge}</div>
46
+ <div class="fc-meta">${esc(meta)}</div>
47
+ </div>
48
+ ${diffable ? `<button class="fc-diff" onclick="openDiffPanel(${JSON.stringify(art.path)})">Diff</button>` : ''}
49
+ ${isText && art.path
50
+ ? `<button class="fc-view" onclick="viewArtifact(${JSON.stringify(art.path)},${JSON.stringify(labelRaw)})">View</button>`
51
+ : `<span class="fc-binary">Binary</span>`}
52
+ `;
53
+ return el;
54
+ }
55
+
56
+ function paintFilesGrid() {
57
+ const grid = document.getElementById('files-grid');
58
+ const empty = document.getElementById('files-empty');
59
+ if (!grid) return;
60
+ filesGroups = collectOrgArtifacts();
61
+ renderFilesGridFromGroups();
62
+ }
63
+
64
+ /** Re-renders the grid from the current in-memory `filesGroups` — no rescan.
65
+ * Split out of paintFilesGrid() so applyArtifactEvent() can repaint after an
66
+ * O(groups) incremental update instead of forcing a full O(all-events) rescan. */
67
+ function renderFilesGridFromGroups() {
68
+ const grid = document.getElementById('files-grid');
69
+ const empty = document.getElementById('files-empty');
70
+ if (!grid) return;
71
+ grid.innerHTML = '';
72
+ if (!filesGroups.length) { if (empty) empty.style.display = 'block'; return; }
73
+ if (empty) empty.style.display = 'none';
74
+ filesGroups.forEach(group => grid.appendChild(mkFileCard(group)));
75
+ }
76
+
77
+ /** Incrementally folds a single new org:artifact event into `filesGroups` —
78
+ * O(groups) to re-sort by latest-touched, not O(all sessions' all events)
79
+ * like collectOrgArtifacts(). Mirrors collectOrgArtifacts()'s grouping rules. */
80
+ function applyArtifactEvent(ev) {
81
+ const art = ev.artifact || (ev.path ? { path: ev.path, label: ev.label, mimeType: ev.mimeType } : null);
82
+ if (!art || !art.path) return;
83
+ const entry = { art, ts: ev.ts || 0, from: ev.from || '' };
84
+ let group = filesGroups.find(g => g.path === art.path);
85
+ if (!group) {
86
+ group = { path: art.path, versions: [], latest: entry };
87
+ filesGroups.push(group);
88
+ }
89
+ group.versions.push(entry);
90
+ group.versions.sort((a, b) => a.ts - b.ts);
91
+ group.latest = group.versions[group.versions.length - 1];
92
+ filesGroups.sort((a, b) => b.latest.ts - a.latest.ts);
93
+ renderFilesGridFromGroups();
94
+ }
95
+
96
+ function renderFilesTab() {
97
+ paintFilesGrid(); // paint immediately with whatever's cached
98
+ loadChatSessions().then(() => { if (currentTab === 'files') paintFilesGrid(); });
99
+ }
100
+
101
+ // ── Diff view: compare two captured versions of the same file ──
102
+ window.openDiffPanel = function(path) {
103
+ const group = filesGroups.find(g => g.path === path);
104
+ if (!group) return;
105
+ const versions = group.versions.filter(v => typeof v.art.content === 'string');
106
+ if (versions.length < 2) return;
107
+ const label = group.latest.art.label || path.split('/').pop();
108
+ renderDiffPanel(label, versions);
109
+ };
110
+
111
+ function renderDiffPanel(label, versions) {
112
+ let panel = document.getElementById('artifact-panel');
113
+ if (!panel) {
114
+ panel = document.createElement('div');
115
+ panel.id = 'artifact-panel';
116
+ panel.style.cssText = 'position:fixed;top:0;right:0;width:640px;height:100vh;background:#0a0a14;border-left:1px solid #333;z-index:1000;display:flex;flex-direction:column;overflow:hidden';
117
+ document.body.appendChild(panel);
118
+ }
119
+ const optLabel = v => `${new Date(v.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} · ${esc(v.from || '?')}`;
120
+ const opts = versions.map((v, i) => `<option value="${i}">${optLabel(v)}</option>`).join('');
121
+ panel.innerHTML = `
122
+ <div style="padding:12px 14px;border-bottom:1px solid #1a1a2a;display:flex;align-items:center;gap:10px">
123
+ <span style="font-size:13px;font-weight:700;color:#ccc;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(label)} — diff</span>
124
+ <button onclick="document.getElementById('artifact-panel').remove()" style="background:none;border:none;color:#888;font-size:16px;cursor:pointer">✕</button>
125
+ </div>
126
+ <div style="padding:8px 14px;border-bottom:1px solid #1a1a2a;display:flex;gap:8px;align-items:center;font-size:10px;color:#888">
127
+ <select id="diff-from" style="background:#151520;color:#ccc;border:1px solid #333;border-radius:3px;padding:2px 6px;font-size:10px">${opts}</select>
128
+ <span>→</span>
129
+ <select id="diff-to" style="background:#151520;color:#ccc;border:1px solid #333;border-radius:3px;padding:2px 6px;font-size:10px">${opts}</select>
130
+ </div>
131
+ <div id="diff-body" style="flex:1;overflow:auto;font-size:11px;line-height:1.6;font-family:var(--mono,ui-monospace,monospace)"></div>
132
+ `;
133
+ panel.style.display = 'flex';
134
+ const fromSel = document.getElementById('diff-from');
135
+ const toSel = document.getElementById('diff-to');
136
+ fromSel.value = String(versions.length - 2);
137
+ toSel.value = String(versions.length - 1);
138
+ const recompute = () => renderDiffBody(versions[Number(fromSel.value)].art.content, versions[Number(toSel.value)].art.content);
139
+ fromSel.onchange = recompute;
140
+ toSel.onchange = recompute;
141
+ recompute();
142
+ }
143
+
144
+ function renderDiffBody(oldText, newText) {
145
+ const body = document.getElementById('diff-body');
146
+ if (!body) return;
147
+ if (oldText === newText) {
148
+ body.innerHTML = '<div style="padding:14px;color:#666">No differences between these two versions.</div>';
149
+ return;
150
+ }
151
+ const rows = diffLines(oldText, newText);
152
+ if (!rows) {
153
+ body.innerHTML = '<div style="padding:14px;color:#666">File too large to diff line-by-line.</div>';
154
+ return;
155
+ }
156
+ body.innerHTML = rows.map(r => {
157
+ const style = r.type === 'add' ? 'background:#0d2818;color:#7ee787'
158
+ : r.type === 'del' ? 'background:#2d0d10;color:#ffa198' : 'color:#8b949e';
159
+ const prefix = r.type === 'add' ? '+' : r.type === 'del' ? '-' : ' ';
160
+ return `<div style="${style};padding:1px 12px;white-space:pre-wrap;word-break:break-word">${esc(prefix + ' ' + r.text)}</div>`;
161
+ }).join('');
162
+ }
163
+
164
+ /** Line-level LCS diff. Returns null (caller shows a fallback message) rather
165
+ * than hanging the tab if both files are unexpectedly huge — the O(n*m) DP
166
+ * table would otherwise blow past what's reasonable to allocate client-side.
167
+ * Bounds n and m individually, not just their product: a lopsided pair (e.g.
168
+ * n=1, m=4000000 — one huge file diffed against a near-empty one) passes the
169
+ * product check but still allocates a 4M-row DP table, one row per line of A. */
170
+ function diffLines(a, b) {
171
+ const A = a.split('\n'), B = b.split('\n');
172
+ const n = A.length, m = B.length;
173
+ const DIFF_MAX_LINES = 20000;
174
+ if (n > DIFF_MAX_LINES || m > DIFF_MAX_LINES || n * m > 4000000) return null;
175
+ const dp = new Array(n + 1);
176
+ for (let i = 0; i <= n; i++) dp[i] = new Int32Array(m + 1);
177
+ for (let i = n - 1; i >= 0; i--) {
178
+ for (let j = m - 1; j >= 0; j--) {
179
+ dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
180
+ }
181
+ }
182
+ const out = [];
183
+ let i = 0, j = 0;
184
+ while (i < n && j < m) {
185
+ if (A[i] === B[j]) { out.push({ type: 'ctx', text: A[i] }); i++; j++; }
186
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ type: 'del', text: A[i] }); i++; }
187
+ else { out.push({ type: 'add', text: B[j] }); j++; }
188
+ }
189
+ while (i < n) { out.push({ type: 'del', text: A[i] }); i++; }
190
+ while (j < m) { out.push({ type: 'add', text: B[j] }); j++; }
191
+ return out;
192
+ }
@@ -412,6 +412,15 @@ html, body {
412
412
  border-radius: 4px; padding: 4px 10px; font-size: 10px; font-weight: 700; cursor: pointer;
413
413
  }
414
414
  .fc-view:hover { background: #204a70; }
415
+ .fc-diff {
416
+ flex-shrink: 0; background: #2a1a3a; color: #b98dd9; border: 1px solid #8e44ad44;
417
+ border-radius: 4px; padding: 4px 10px; font-size: 10px; font-weight: 700; cursor: pointer;
418
+ }
419
+ .fc-diff:hover { background: #3a2050; }
420
+ .fc-vbadge {
421
+ font-size: 8px; font-weight: 700; color: var(--dim); border: 1px solid var(--border);
422
+ border-radius: 2px; padding: 1px 5px; margin-left: 4px; letter-spacing: 0.5px;
423
+ }
415
424
  .fc-binary { flex-shrink: 0; font-size: 9px; color: var(--dim); }
416
425
 
417
426
  /* ── Activity / event log ───────────────────────────────────────── */
@@ -1125,56 +1134,14 @@ window.switchTab = function(tab) {
1125
1134
  else if (orgDetailData) renderTab(tab);
1126
1135
  };
1127
1136
 
1128
- // ── Files tab: every asset produced by this org, deduped by path, latest first ──
1129
- function collectOrgArtifacts() {
1130
- const bySessions = chatSessions.filter(orgSessionMatch);
1131
- const seen = new Map(); // path -> { art, ts, from }
1132
- bySessions.forEach(s => (s.events || []).forEach(ev => {
1133
- if (ev.type !== 'org:artifact') return;
1134
- const art = ev.artifact || (ev.path ? { path: ev.path, label: ev.label, mimeType: ev.mimeType } : null);
1135
- if (!art || !art.path) return;
1136
- const prev = seen.get(art.path);
1137
- if (!prev || (ev.ts || 0) >= prev.ts) seen.set(art.path, { art, ts: ev.ts || 0, from: ev.from || '' });
1138
- }));
1139
- return Array.from(seen.values()).sort((a, b) => b.ts - a.ts);
1140
- }
1141
-
1142
- function mkFileCard(entry) {
1143
- const { art, ts, from } = entry;
1144
- const el = document.createElement('div');
1145
- el.className = 'file-card';
1146
- const isText = (art.mimeType || '').startsWith('text/') || (art.mimeType || '') === 'application/json';
1147
- const labelRaw = art.label || (art.path || 'file').split('/').pop();
1148
- const timeStr = ts ? new Date(ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
1149
- const meta = [from, art.path ? art.path.split('/').slice(-3).join('/') : null, timeStr].filter(Boolean).join(' · ');
1150
- el.innerHTML = `
1151
- <div class="fc-icon">📄</div>
1152
- <div class="fc-body">
1153
- <div class="fc-name">${esc(labelRaw)}</div>
1154
- <div class="fc-meta">${esc(meta)}</div>
1155
- </div>
1156
- ${isText && art.path
1157
- ? `<button class="fc-view" onclick="viewArtifact(${JSON.stringify(art.path)},${JSON.stringify(labelRaw)})">View</button>`
1158
- : `<span class="fc-binary">Binary</span>`}
1159
- `;
1160
- return el;
1161
- }
1162
-
1163
- function paintFilesGrid() {
1164
- const grid = document.getElementById('files-grid');
1165
- const empty = document.getElementById('files-empty');
1166
- if (!grid) return;
1167
- const files = collectOrgArtifacts();
1168
- grid.innerHTML = '';
1169
- if (!files.length) { if (empty) empty.style.display = 'block'; return; }
1170
- if (empty) empty.style.display = 'none';
1171
- files.forEach(entry => grid.appendChild(mkFileCard(entry)));
1172
- }
1173
-
1174
- function renderFilesTab() {
1175
- paintFilesGrid(); // paint immediately with whatever's cached
1176
- loadChatSessions().then(() => { if (currentTab === 'files') paintFilesGrid(); });
1177
- }
1137
+ // Files tab + diff view extracted to orgs-files.js to keep this file under
1138
+ // the CLAUDE.md 500-line-per-file guideline. Loaded as a classic script below,
1139
+ // sharing this script's global scope (function declarations here call into it,
1140
+ // e.g. renderFilesTab() from renderTab() above and applyArtifactEvent() from
1141
+ // handleMmEvent() below).
1142
+ </script>
1143
+ <script src="orgs-files.js"></script>
1144
+ <script>
1178
1145
 
1179
1146
  // ── Render all orgs in sidebar ────────────────────────────────────
1180
1147
  function renderSidebar() {
@@ -2139,8 +2106,10 @@ function handleMmEvent(ev) {
2139
2106
  appendChatEvent(ev, true);
2140
2107
  chatScrollFeed();
2141
2108
  }
2142
- // Live-update the Files tab when a new asset lands while it's open
2143
- if (ev.type === 'org:artifact' && currentTab === 'files') paintFilesGrid();
2109
+ // Live-update the Files tab when a new asset lands while it's open — incremental,
2110
+ // not a rescan of every session's every event (that's collectOrgArtifacts()'s job,
2111
+ // reserved for tab-open / initial load in renderFilesTab()).
2112
+ if (ev.type === 'org:artifact' && currentTab === 'files') applyArtifactEvent(ev);
2144
2113
  }
2145
2114
  }
2146
2115