nexus-prime 6.2.0 → 6.3.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.
@@ -64,13 +64,14 @@ export async function post(url, body, opts = {}) {
64
64
  return { ok: true, data, error: null, status: r.status };
65
65
  }
66
66
  let errText = `HTTP ${r.status}`;
67
+ let errData = null;
67
68
  try {
68
- const err = await r.json();
69
- errText = err?.error || err?.message || errText;
69
+ errData = await r.json();
70
+ errText = errData?.error || errData?.message || errText;
70
71
  } catch {
71
72
  try { errText = (await r.text()) || errText; } catch { /* ignore */ }
72
73
  }
73
- return { ok: false, data: null, error: errText, status: r.status };
74
+ return { ok: false, data: errData, error: errText, status: r.status };
74
75
  }).catch((e) => ({ ok: false, data: null, error: e?.message || String(e), status: 0 }));
75
76
 
76
77
  if (optimistic !== undefined) {
@@ -63,6 +63,16 @@ setOnEvent(evt => {
63
63
  if (tab === 'governance' && evt.category === 'byzantine') {
64
64
  Governance.handleByzantineEvent(evt);
65
65
  }
66
+ if (String(evt.type||'').startsWith('graph.cr.build')) {
67
+ bustCache('/api/knowledge-topology');
68
+ Repo.handleBuildEvent?.(evt);
69
+ if (tab === 'knowledge') {
70
+ Knowledge.load();
71
+ }
72
+ if (tab === 'memory' && evt.type === 'graph.cr.build.complete') {
73
+ Memory.load();
74
+ }
75
+ }
66
76
  // Route push-mode dispatch events regardless of active tab
67
77
  if (String(evt.type||'').startsWith('dispatch.')) {
68
78
  Workforce.handleDispatchEvent(evt);
@@ -87,6 +97,7 @@ async function loadWorkspace() {
87
97
  const repoEl = $('workspace-repo'), branchEl = $('workspace-branch');
88
98
  if (repoEl) repoEl.textContent = ws.repoName || '—';
89
99
  if (branchEl) branchEl.textContent = ws.branch || 'detached';
100
+ document.title = `${ws.repoName || 'Nexus Prime'} · Nexus Prime`;
90
101
  const badge = $('workspace-badge');
91
102
  if (badge) badge.title = `${ws.repoName||''} @ ${ws.repoRoot||''}`;
92
103
  }
@@ -181,6 +192,7 @@ function _attachHandlers() {
181
192
  document.addEventListener('keydown', e => {
182
193
  if ((e.ctrlKey||e.metaKey) && e.key === '`') { e.preventDefault(); _toggleChat(); }
183
194
  });
195
+ $('chat-toggle-btn')?.addEventListener('click', _toggleChat);
184
196
  $('chat-send-btn')?.addEventListener('click', _sendChat);
185
197
  $('chat-input')?.addEventListener('keydown', e => { if (e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); _sendChat(); } });
186
198
  $('chat-close-btn')?.addEventListener('click', _toggleChat);
@@ -226,7 +238,7 @@ async function bootstrap() {
226
238
  _renderHeader(false);
227
239
 
228
240
  // Fast first paint: board + workspace + projects + repo-tree + license
229
- const [,,, lic] = await Promise.all([
241
+ const [, , , , lic] = await Promise.all([
230
242
  Board.load(),
231
243
  loadWorkspace(),
232
244
  loadProjects(),
@@ -34,6 +34,7 @@ export const S = {
34
34
  tokensLifetime: null,
35
35
  operateSurface: null,
36
36
  synapseHealth: [],
37
+ synapseHealthRaw: null,
37
38
  events: [],
38
39
  ticker: [],
39
40
  spark: null,
@@ -3,8 +3,8 @@
3
3
  * PM tweak: pyramid is rendered ABOVE the kanban. The pyramid is the memory aha moment.
4
4
  */
5
5
 
6
- import { S } from '../state.js';
7
- import { api, post, bustCache } from '../api.js';
6
+ import { S, bus } from '../state.js';
7
+ import { api, post, bustCache, notifyNotReady } from '../api.js';
8
8
  import { openDrawer } from '../widgets/drawer.js';
9
9
  import { drawSparkline } from '../widgets/sparkline.js';
10
10
  import { renderPyramid } from '../widgets/pyramid.js';
@@ -66,18 +66,22 @@ function normalizeOperative(op) {
66
66
 
67
67
  /* ── Data loader ── */
68
68
  export async function load() {
69
- const [tok, life, op, sh] = await Promise.all([
69
+ const [tok, life, op, sh, health] = await Promise.all([
70
70
  api('/api/tokens/summary'),
71
71
  api('/api/tokens/lifetime', 15000),
72
72
  api('/api/dashboard/surface/operate', 5000),
73
73
  api('/api/synapse/health', 5000),
74
+ api('/api/health', 15000),
74
75
  ]);
75
76
  // Non-blocking: tool health data from ring buffer
76
77
  loadToolHealth();
77
78
  S.tokensSummary = tok;
78
79
  S.tokensLifetime = life;
79
80
  S.operateSurface = op;
81
+ S.synapseHealthRaw = sh;
80
82
  S.synapseHealth = (Array.isArray(sh) ? sh : (sh?.operatives||[])).map(normalizeOperative);
83
+ S.healthData = health;
84
+ notifyNotReady([sh]);
81
85
  // Prefetch curated specialists for first-run hero (non-blocking)
82
86
  if (!S.synapseHealth.length && !S.curatedSpecialists) {
83
87
  api('/api/specialists/curated', 60000).then(d => {
@@ -120,10 +124,31 @@ function renderPyramidWidget() {
120
124
  /* ── Hero KPI row ── */
121
125
  function renderHero() {
122
126
  const t=S.tokensSummary, lt=S.tokensLifetime, op=S.operateSurface;
123
- const saved = lt?.totalSaved ?? lt?.lifetime?.saved ?? t?.saved ?? 0;
127
+ const saved = Number(
128
+ lt?.savedTokens
129
+ ?? lt?.lifetime?.savedTokens
130
+ ?? lt?.totalSaved
131
+ ?? lt?.lifetime?.saved
132
+ ?? op?.tokenOptimization?.savedTokens
133
+ ?? t?.savedTokens
134
+ ?? t?.saved
135
+ ?? 0,
136
+ );
124
137
  animCounter('m-tokens', saved);
125
- const gross=t?.gross||0, net=t?.net||0;
126
- const pct = gross>0 ? Math.round((1-net/gross)*100) : 0;
138
+ const gross = Number(
139
+ lt?.grossInputTokens
140
+ ?? lt?.lifetime?.grossInputTokens
141
+ ?? op?.tokenOptimization?.grossInputTokens
142
+ ?? t?.gross
143
+ ?? 0,
144
+ );
145
+ const net = Number(t?.net ?? 0);
146
+ const pct = Number(
147
+ lt?.compressionPct
148
+ ?? lt?.lifetime?.compressionPct
149
+ ?? op?.tokenOptimization?.compressionPct
150
+ ?? (gross > 0 && net > 0 ? Math.round((1-net/gross)*100) : 0),
151
+ );
127
152
  const pEl = $('m-pct');
128
153
  if (pEl) { pEl.innerHTML=`${pct}<sup>%</sup>`; pEl.dataset.raw=pct; }
129
154
  const memCount = op?.memory?.total ?? S.memHealth?.total ?? S.memories.length;
@@ -143,6 +168,36 @@ function renderHero() {
143
168
  /* ── First-run hero (shown when no operatives are hired yet) ── */
144
169
  const FIRST_RUN_KEY = 'nexus_first_run_seen';
145
170
 
171
+ function getHireReadiness() {
172
+ const notes = [];
173
+ const synapseState = S.healthData?.runtimeEnvelope?.engines?.synapse ?? {};
174
+ const memoryStorage = S.healthData?.memory?.storage ?? {};
175
+ const rawReason = S.synapseHealthRaw?.reason;
176
+ const unavailable = Boolean(
177
+ S.synapseHealthRaw?.notReady
178
+ || synapseState.ready === false
179
+ || synapseState.available === false,
180
+ );
181
+ if (unavailable) {
182
+ notes.push({ tone: 'bad', text: rawReason || synapseState.reason || 'Synapse is not ready for hires in this dashboard session.' });
183
+ }
184
+ if (synapseState.fallbackApplied) {
185
+ notes.push({ tone: 'warn', text: synapseState.reason || 'Synapse is running in fallback storage mode.' });
186
+ }
187
+ if (memoryStorage.fallbackApplied) {
188
+ notes.push({ tone: 'warn', text: memoryStorage.fallbackReason || 'Memory storage fallback is active.' });
189
+ }
190
+ return { unavailable, notes };
191
+ }
192
+
193
+ function setFirstRunStatus(message, tone='info') {
194
+ const el = $('frh-status');
195
+ if (!el) return;
196
+ el.textContent = message || '';
197
+ el.style.display = message ? 'block' : 'none';
198
+ el.style.color = tone === 'bad' ? 'var(--bad)' : tone === 'warn' ? '#ffd14d' : 'var(--accent)';
199
+ }
200
+
146
201
  function renderFirstRunHero() {
147
202
  const el = $('first-run-hero');
148
203
  // No placeholder in current HTML — inject it before the live strip if needed
@@ -160,6 +215,12 @@ function renderFirstRunHero() {
160
215
 
161
216
  const specs = (S.curatedSpecialists || []).slice(0, 3);
162
217
  if (!specs.length) return; // Still loading — will re-render when prefetch resolves
218
+ const readiness = getHireReadiness();
219
+ const noticesHtml = readiness.notes.length
220
+ ? `<div class="frh-notices" style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">
221
+ ${readiness.notes.map(note => `<div class="chip ${note.tone === 'bad' ? 'bad' : ''}" style="${note.tone === 'bad' ? 'color:var(--bad);border-color:#ff5f5733;background:#ff5f5712' : 'color:#ffd14d;border-color:#ffd14d33;background:#ffd14d12'}">${esc(note.text)}</div>`).join('')}
222
+ </div>`
223
+ : '';
163
224
 
164
225
  const card = document.createElement('div');
165
226
  card.id = 'first-run-hero';
@@ -169,33 +230,48 @@ function renderFirstRunHero() {
169
230
  <div class="frh-title">Your next hire</div>
170
231
  <div class="frh-sub">Hire a specialist to start running tasks autonomously.</div>
171
232
  </div>
233
+ ${noticesHtml}
172
234
  <div class="frh-picks">
173
235
  ${specs.map(s => `
174
236
  <div class="frh-pick" data-specid="${esc(s.specialistId)}" data-specname="${esc(s.name)}">
175
237
  <div class="frh-pick-name">${esc(s.name)}</div>
176
238
  <div class="frh-pick-desc">${esc((s.description||'').slice(0, 72))}${(s.description||'').length > 72 ? '…' : ''}</div>
177
239
  <div class="frh-pick-cost">~$${esc(String(s.pricing?.typical ?? '?'))}/sortie</div>
178
- <button class="btn btn-primary btn-sm frh-hire-btn" data-specid="${esc(s.specialistId)}" data-specname="${esc(s.name)}">Hire</button>
240
+ <button class="btn btn-primary btn-sm frh-hire-btn" data-specid="${esc(s.specialistId)}" data-specname="${esc(s.name)}" ${readiness.unavailable ? 'disabled title="Synapse is not ready"' : ''}>Hire</button>
179
241
  </div>`).join('')}
180
242
  </div>
243
+ <div id="frh-status" style="display:none;margin-top:12px;font-size:var(--text-sm)"></div>
181
244
  <button class="btn btn-ghost btn-sm frh-dismiss" style="margin-top:var(--space-3)">Dismiss</button>`;
182
245
 
183
246
  // Wire buttons before inserting
184
247
  card.querySelectorAll('.frh-hire-btn').forEach(btn => {
185
248
  btn.addEventListener('click', async e => {
186
249
  e.stopPropagation();
250
+ if (readiness.unavailable) {
251
+ setFirstRunStatus(readiness.notes[0]?.text || 'Synapse is not ready for hires.', 'bad');
252
+ return;
253
+ }
187
254
  btn.disabled = true; btn.textContent = 'Hiring…';
255
+ setFirstRunStatus('Submitting hire request…');
188
256
  const result = await post('/api/synapse/hire', {
189
257
  specialistId: btn.dataset.specid,
190
258
  name: btn.dataset.specname,
191
259
  budgetCapUsd: 2,
192
260
  });
193
261
  if (result.ok) {
262
+ setFirstRunStatus(readiness.notes.some(note => note.tone === 'warn')
263
+ ? 'Hired successfully. Fallback storage warning is still active.'
264
+ : 'Hired successfully.');
194
265
  try { localStorage.setItem(FIRST_RUN_KEY, '1'); } catch { /* ignore */ }
195
266
  bustCache('/api/synapse/health');
196
267
  setTimeout(load, 800);
197
268
  } else {
198
269
  btn.disabled = false; btn.textContent = 'Hire';
270
+ const failure = result?.data?.hint
271
+ ? `${result.error}. ${result.data.hint}`
272
+ : (result.error || 'Hire failed.');
273
+ setFirstRunStatus(failure, 'bad');
274
+ bus.emit('toast', { msg: failure, kind: 'bad' });
199
275
  }
200
276
  });
201
277
  });
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { S } from '../state.js';
6
6
  import { api, post, bustCache } from '../api.js';
7
+ import { openDrawer } from '../widgets/drawer.js';
7
8
 
8
9
  const $ = id => document.getElementById(id);
9
10
  const esc = s => s==null?'':String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
@@ -179,17 +180,39 @@ async function _ingestCollection(id, name, btn) {
179
180
  }
180
181
 
181
182
  function _showCreateSheet() {
182
- const nameInput=$('rag-new-name'); if (!nameInput) return; // needs to be in HTML
183
- const name=(nameInput?.value||'').trim();
184
- if (!name) { _toast('Collection name is required', 'bad'); return; }
185
- post('/api/rag/collections', { name }).then(r => {
186
- if (r.ok) {
183
+ openDrawer({
184
+ title: 'Create collection',
185
+ body: `<div class="dsec">
186
+ <div class="dsec-title">New RAG collection</div>
187
+ <label class="form-label" for="rag-new-name">Collection name</label>
188
+ <input id="rag-new-name" class="form-input" type="text" placeholder="${esc(S.workspace?.repoName || 'repo-knowledge')}" autocomplete="off">
189
+ <button class="btn btn-primary" id="rag-create-confirm" style="margin-top:var(--space-4)">Create collection</button>
190
+ <div id="rag-create-status" style="margin-top:var(--space-3);font-size:var(--text-sm);color:var(--text-muted)"></div>
191
+ </div>`,
192
+ });
193
+ const submit = async () => {
194
+ const nameInput = $('rag-new-name');
195
+ const status = $('rag-create-status');
196
+ const name = (nameInput?.value || '').trim();
197
+ if (!name) {
198
+ if (status) { status.textContent = 'Collection name is required.'; status.style.color = 'var(--bad)'; }
199
+ return;
200
+ }
201
+ if (status) { status.textContent = 'Creating collection…'; status.style.color = 'var(--text-muted)'; }
202
+ const r = await post('/api/rag/collections', { name }).catch(() => null);
203
+ if (r?.ok) {
204
+ if (status) { status.textContent = `Collection "${name}" created.`; status.style.color = 'var(--ok)'; }
187
205
  _toast(`Collection "${name}" created.`, 'good');
188
206
  bustCache('/api/rag/collections');
189
207
  setTimeout(load, 800);
190
- } else {
191
- _toast(`Create failed: ${r.error}`, 'bad');
208
+ } else if (status) {
209
+ status.textContent = `Create failed: ${r?.error ?? 'unknown error'}`;
210
+ status.style.color = 'var(--bad)';
192
211
  }
212
+ };
213
+ $('rag-create-confirm')?.addEventListener('click', submit);
214
+ $('rag-new-name')?.addEventListener('keydown', e => {
215
+ if (e.key === 'Enter') submit();
193
216
  });
194
217
  }
195
218
 
@@ -303,18 +303,18 @@ async function _doSync() {
303
303
  if (msg) msg.textContent = 'Syncing…';
304
304
  const r = await post('/api/license/sync', {}).catch(() => null);
305
305
  if (btn) btn.disabled = false;
306
- if (r?.success) {
307
- if (msg) { msg.textContent = r.message ?? 'License synced.'; msg.style.color = 'var(--ok)'; }
306
+ if (r?.ok) {
307
+ if (msg) { msg.textContent = r.data?.message ?? 'License synced.'; msg.style.color = 'var(--ok)'; }
308
308
  setTimeout(load, 1000);
309
309
  } else {
310
- if (msg) { msg.textContent = r?.message ?? 'Sync failed.'; msg.style.color = 'var(--bad)'; }
310
+ if (msg) { msg.textContent = r?.error ?? r?.data?.message ?? 'Sync failed.'; msg.style.color = 'var(--bad)'; }
311
311
  }
312
312
  }
313
313
 
314
314
  async function _doDeactivate() {
315
315
  if (!confirm('Deactivate your license and return to the free tier?')) return;
316
316
  const r = await post('/api/license/deactivate', {}).catch(() => null);
317
- if (r?.success) setTimeout(load, 500);
317
+ if (r?.ok) setTimeout(load, 500);
318
318
  }
319
319
 
320
320
  async function _doActivate() {
@@ -327,10 +327,10 @@ async function _doActivate() {
327
327
  if (msg) msg.textContent = 'Activating…';
328
328
  const r = await post('/api/license/activate', { key }).catch(() => null);
329
329
  if (btn) btn.disabled = false;
330
- if (r?.success) {
331
- if (msg) { msg.textContent = r.message ?? 'Activated!'; msg.style.color = 'var(--ok)'; }
330
+ if (r?.ok) {
331
+ if (msg) { msg.textContent = r.data?.message ?? 'Activated!'; msg.style.color = 'var(--ok)'; }
332
332
  setTimeout(load, 1200);
333
333
  } else {
334
- if (msg) { msg.textContent = r?.message ?? 'Activation failed.'; msg.style.color = 'var(--bad)'; }
334
+ if (msg) { msg.textContent = r?.error ?? r?.data?.message ?? 'Activation failed.'; msg.style.color = 'var(--bad)'; }
335
335
  }
336
336
  }
@@ -11,6 +11,14 @@ import { renderPyramid } from '../widgets/pyramid.js';
11
11
  const $ = id => document.getElementById(id);
12
12
  const esc = s => s==null?'':String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
13
13
 
14
+ function fmtNum(n) {
15
+ if (n == null || isNaN(n)) return '—';
16
+ const v = Number(n);
17
+ if (v >= 1e6) return (v/1e6).toFixed(1)+'M';
18
+ if (v >= 1000) return (v/1000).toFixed(1)+'K';
19
+ return String(Math.round(v));
20
+ }
21
+
14
22
  function timeAgo(ts) {
15
23
  if (!ts) return '';
16
24
  const d=Date.now()-ts;
@@ -43,6 +51,7 @@ export function render() {
43
51
  renderPyramidWidget();
44
52
  renderFormationRate();
45
53
  renderPromotionTicker();
54
+ renderStats();
46
55
  renderGraph();
47
56
  renderMemList();
48
57
  initSlider();
@@ -87,6 +96,38 @@ function renderPromotionTicker() {
87
96
  </div>`).join('');
88
97
  }
89
98
 
99
+ function renderStats() {
100
+ const el = $('mem-stats'); if (!el) return;
101
+ const h = S.memHealth || {};
102
+ const meta = S.topology?.meta || {};
103
+ const prov = S.topology?.provenance || {};
104
+ const rows = [
105
+ ['Active memories', fmtNum(h.active ?? S.memories.length)],
106
+ ['Working / episodic / semantic', `${fmtNum(h.working ?? 0)} / ${fmtNum(h.episodic ?? 0)} / ${fmtNum(h.semantic ?? 0)}`],
107
+ ['Quarantined', fmtNum(h.quarantined ?? 0)],
108
+ ['Graph nodes / edges', `${fmtNum(S.gNodes.length)} / ${fmtNum(S.gLinks.length)}`],
109
+ ['Graph source', prov.graphSource || meta.graphSource || 'summary-only'],
110
+ ['Freshness', prov.freshness || meta.freshness || 'n/a'],
111
+ ];
112
+ const topTags = Array.isArray(h.topTags) ? h.topTags.slice(0, 6) : [];
113
+ el.innerHTML = `
114
+ <div class="shd" style="margin-bottom:8px">Memory signals</div>
115
+ ${rows.map(([k, v]) => `<div class="row"><span class="row-k">${esc(k)}</span><span class="row-v">${esc(String(v))}</span></div>`).join('')}
116
+ <div style="margin-top:12px;font-size:var(--text-sm);color:var(--text-dim)">Top tags</div>
117
+ <div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
118
+ ${topTags.length ? topTags.map(tag => `<span class="chip">${esc(tag)}</span>`).join('') : '<span style="color:var(--text-dim);font-size:var(--text-sm)">No tags yet</span>'}
119
+ </div>`;
120
+ }
121
+
122
+ function setGraphFallback(title, subtitle) {
123
+ const emptyCta = $('graph-empty-cta');
124
+ if (!emptyCta) return;
125
+ emptyCta.style.display = 'flex';
126
+ emptyCta.innerHTML = `
127
+ <div class="empty-title">${esc(title)}</div>
128
+ <div class="empty-sub">${esc(subtitle)}</div>`;
129
+ }
130
+
90
131
  /* ── D3 graph ── */
91
132
  function buildGraph() {
92
133
  S.gNodes=S.memories.map(m=>({
@@ -113,7 +154,7 @@ function buildGraph() {
113
154
  }
114
155
 
115
156
  function renderGraph() {
116
- const svg=$('graph-svg'); if (!svg||typeof d3==='undefined') return;
157
+ const svg=$('graph-svg'); if (!svg) return;
117
158
  const c=$('graph-container'), W=c?.clientWidth||640, H=c?.clientHeight||320;
118
159
  const cutoff=S.temporalCursor||Date.now();
119
160
  const nodes=S.gNodes.filter(n=>(n.createdAt||0)<=cutoff).map(n=>({...n}));
@@ -124,12 +165,21 @@ function renderGraph() {
124
165
  return nset.has(s)&&nset.has(t);
125
166
  }).map(l=>({...l}));
126
167
 
168
+ if (typeof d3 === 'undefined') {
169
+ svg.innerHTML = '';
170
+ setGraphFallback('Graph rendering unavailable', 'D3 did not load. Memory list and time controls still work.');
171
+ return;
172
+ }
173
+
127
174
  const d3s=d3.select(svg);
128
175
  d3s.selectAll('*').remove();
129
176
  d3s.attr('width',W).attr('height',H);
130
177
 
178
+ if (!nodes.length) {
179
+ setGraphFallback('No memory graph yet', 'Run a few tasks and memories will appear here.');
180
+ return;
181
+ }
131
182
  const emptyCta=$('graph-empty-cta');
132
- if (!nodes.length) { if (emptyCta) emptyCta.style.display='flex'; return; }
133
183
  if (emptyCta) emptyCta.style.display='none';
134
184
 
135
185
  if (S.graphSim) S.graphSim.stop();
@@ -203,8 +253,16 @@ function _memHoverOut(nodeEl, fileEl, linkEl) {
203
253
 
204
254
  /* ── Temporal slider ── */
205
255
  function initSlider() {
206
- const slider=$('temporal-slider'); if (!slider||!S.gNodes.length) return;
256
+ const slider=$('temporal-slider'); if (!slider) return;
257
+ if (!S.gNodes.length) {
258
+ slider.disabled = true;
259
+ const sL=$('slider-start'), nL=$('slider-now');
260
+ if (sL) sL.textContent = '—';
261
+ if (nL) nL.textContent = 'Now';
262
+ return;
263
+ }
207
264
  const minTs=Math.min(...S.gNodes.map(n=>n.createdAt||Date.now())), maxTs=Date.now();
265
+ slider.disabled = false;
208
266
  slider.min=String(minTs); slider.max=String(maxTs); slider.value=String(maxTs);
209
267
  S.temporalCursor=maxTs;
210
268
  const sL=$('slider-start'), nL=$('slider-now');