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.
- package/dist/dashboard/app/api.js +4 -3
- package/dist/dashboard/app/main.js +13 -1
- package/dist/dashboard/app/state.js +1 -0
- package/dist/dashboard/app/views/board.js +83 -7
- package/dist/dashboard/app/views/knowledge.js +30 -7
- package/dist/dashboard/app/views/license.js +7 -7
- package/dist/dashboard/app/views/memory.js +61 -3
- package/dist/dashboard/app/views/repo.js +205 -67
- package/dist/dashboard/app/views/workforce.js +65 -5
- package/dist/dashboard/routes/graph.d.ts +0 -11
- package/dist/dashboard/routes/graph.js +232 -54
- package/dist/dashboard/routes/runtime.js +52 -10
- package/dist/dashboard/selectors/summary-selector.js +22 -2
- package/dist/dashboard/server.d.ts +1 -0
- package/dist/dashboard/server.js +49 -7
- package/dist/engines/workspace-resolver.js +18 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
69
|
-
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:
|
|
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 [
|
|
241
|
+
const [, , , , lic] = await Promise.all([
|
|
230
242
|
Board.load(),
|
|
231
243
|
loadWorkspace(),
|
|
232
244
|
loadProjects(),
|
|
@@ -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 }
|
|
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 =
|
|
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=
|
|
126
|
-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
@@ -179,17 +180,39 @@ async function _ingestCollection(id, name, btn) {
|
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
function _showCreateSheet() {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
|
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
|
|
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');
|