claude-memory-layer 1.0.49 → 1.0.50
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/cli/index.js +442 -8
- package/dist/cli/index.js.map +4 -4
- package/dist/server/api/index.js +439 -5
- package/dist/server/api/index.js.map +4 -4
- package/dist/server/index.js +441 -7
- package/dist/server/index.js.map +4 -4
- package/dist/ui/assets/js/bootstrap.js +63 -1
- package/dist/ui/assets/js/disclosure.js +158 -10
- package/dist/ui/assets/js/modals.js +39 -18
- package/dist/ui/assets/js/overview.js +99 -9
- package/dist/ui/assets/js/state.js +28 -1
- package/dist/ui/assets/js/views.js +626 -15
- package/dist/ui/index.html +130 -0
- package/dist/ui/style.css +460 -0
- package/package.json +3 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
function switchView(viewName) {
|
|
2
|
-
|
|
1
|
+
function switchView(viewName, options = {}) {
|
|
2
|
+
const forceReload = Boolean(options.forceReload);
|
|
3
|
+
if (state.currentView === viewName && !forceReload) return Promise.resolve();
|
|
3
4
|
state.currentView = viewName;
|
|
4
5
|
|
|
5
6
|
// Update nav active state
|
|
@@ -18,10 +19,13 @@ function switchView(viewName) {
|
|
|
18
19
|
|
|
19
20
|
// Load view content
|
|
20
21
|
switch (viewName) {
|
|
21
|
-
case 'knowledge-graph': loadKnowledgeGraphView();
|
|
22
|
-
case 'memory-banks': loadMemoryBanksView();
|
|
23
|
-
case '
|
|
24
|
-
case '
|
|
22
|
+
case 'knowledge-graph': return loadKnowledgeGraphView();
|
|
23
|
+
case 'memory-banks': return loadMemoryBanksView();
|
|
24
|
+
case 'sessions': return loadSessionInspectorView();
|
|
25
|
+
case 'user-prompts': return loadUserPromptsView();
|
|
26
|
+
case 'playground': return loadPlaygroundView();
|
|
27
|
+
case 'configuration': return loadConfigurationView();
|
|
28
|
+
default: return Promise.resolve();
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -232,13 +236,14 @@ async function loadMemoryBankLevel(level) {
|
|
|
232
236
|
container.innerHTML = `
|
|
233
237
|
<div class="mb-event-list">
|
|
234
238
|
${events.map(e => {
|
|
235
|
-
const typeClass =
|
|
239
|
+
const typeClass = eventTypeBadgeClass(e.eventType);
|
|
240
|
+
const eventArg = jsAttrArg(e.id || '');
|
|
236
241
|
return `
|
|
237
|
-
<div class="mb-event-card" onclick="openDetailModal(
|
|
242
|
+
<div class="mb-event-card" ${e.id ? `onclick="openDetailModal(${eventArg})"` : ''}>
|
|
238
243
|
<div class="mb-event-header">
|
|
239
|
-
<span class="event-type-badge ${typeClass}">${e.eventType}</span>
|
|
244
|
+
<span class="event-type-badge ${typeClass}">${escapeHtml(e.eventType || 'event')}</span>
|
|
240
245
|
<div style="display:flex; gap:8px; align-items:center;">
|
|
241
|
-
${e.accessCount > 0 ? `<span class="access-badge"><i class="ri-eye-line"></i> ${e.accessCount}</span>` : ''}
|
|
246
|
+
${e.accessCount > 0 ? `<span class="access-badge"><i class="ri-eye-line"></i> ${formatNumber(e.accessCount)}</span>` : ''}
|
|
242
247
|
<span class="event-time">${new Date(e.timestamp).toLocaleString()}</span>
|
|
243
248
|
</div>
|
|
244
249
|
</div>
|
|
@@ -254,6 +259,421 @@ async function loadMemoryBankLevel(level) {
|
|
|
254
259
|
}
|
|
255
260
|
}
|
|
256
261
|
|
|
262
|
+
// --- Session Inspector View ---
|
|
263
|
+
|
|
264
|
+
function formatSessionTime(value) {
|
|
265
|
+
if (!value) return '-';
|
|
266
|
+
const date = new Date(value);
|
|
267
|
+
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function formatSessionDateGroup(value) {
|
|
271
|
+
if (!value) return 'Undated sessions';
|
|
272
|
+
const date = new Date(value);
|
|
273
|
+
if (Number.isNaN(date.getTime())) return 'Undated sessions';
|
|
274
|
+
const today = new Date();
|
|
275
|
+
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
|
276
|
+
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
277
|
+
const deltaDays = Math.round((startOfToday - startOfDate) / 86400000);
|
|
278
|
+
if (deltaDays === 0) return 'Today';
|
|
279
|
+
if (deltaDays === 1) return 'Yesterday';
|
|
280
|
+
if (deltaDays > 1 && deltaDays < 7) return `${deltaDays} days ago`;
|
|
281
|
+
return date.toLocaleDateString();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sessionShortId(id) {
|
|
285
|
+
const text = String(id || 'unknown');
|
|
286
|
+
return text.length > 18 ? `${text.slice(0, 18)}...` : text;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function sessionEventTypeClass(type) {
|
|
290
|
+
return eventTypeBadgeClass(type);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getSessionEventLevel(event) {
|
|
294
|
+
const metadata = event?.metadata || event?.meta || {};
|
|
295
|
+
return metadata.level || metadata.memoryLevel || metadata.stage || 'L0';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function sessionPreviewText(event, max = 280) {
|
|
299
|
+
const preview = event?.preview || '';
|
|
300
|
+
return escapeHtml(String(preview).slice(0, max));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sessionEvidencePreviewText(event, max = 120) {
|
|
304
|
+
if (event?.eventType === 'tool_observation') {
|
|
305
|
+
return '<em>Tool observation preview hidden</em>';
|
|
306
|
+
}
|
|
307
|
+
return sessionPreviewText(event, max);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function jumpToSession(sessionId, eventId) {
|
|
311
|
+
if (!sessionId) return;
|
|
312
|
+
state.pendingSessionJump = { sessionId, eventId: eventId || null, project: state.currentProject || '' };
|
|
313
|
+
state.sessionJumpEventId = eventId || null;
|
|
314
|
+
state.sessionDetailRequestId = (state.sessionDetailRequestId || 0) + 1;
|
|
315
|
+
if (typeof closeAllModals === 'function') closeAllModals();
|
|
316
|
+
await switchView('sessions', { forceReload: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function loadSessionInspectorView() {
|
|
320
|
+
const listEl = document.getElementById('session-list');
|
|
321
|
+
if (!listEl) return;
|
|
322
|
+
|
|
323
|
+
const requestId = (state.sessionInspectorRequestId || 0) + 1;
|
|
324
|
+
state.sessionInspectorRequestId = requestId;
|
|
325
|
+
const projectAtStart = state.currentProject;
|
|
326
|
+
const jumpAtStart = state.pendingSessionJump;
|
|
327
|
+
state.isSessionInspectorLoading = true;
|
|
328
|
+
renderSessionList();
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const res = await fetch(apiUrl(`${API_BASE}/sessions`, {
|
|
332
|
+
page: state.sessionInspectorPage,
|
|
333
|
+
pageSize: state.sessionInspectorPageSize
|
|
334
|
+
}));
|
|
335
|
+
const data = res.ok ? await res.json() : { sessions: [] };
|
|
336
|
+
if (requestId !== state.sessionInspectorRequestId || projectAtStart !== state.currentProject) {
|
|
337
|
+
if (projectAtStart !== state.currentProject && jumpAtStart && state.pendingSessionJump === jumpAtStart) {
|
|
338
|
+
state.pendingSessionJump = null;
|
|
339
|
+
state.sessionJumpEventId = null;
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const pendingJump = state.pendingSessionJump;
|
|
344
|
+
const jump = pendingJump?.project === projectAtStart ? pendingJump : null;
|
|
345
|
+
if (pendingJump && !jump) {
|
|
346
|
+
state.pendingSessionJump = null;
|
|
347
|
+
state.sessionJumpEventId = null;
|
|
348
|
+
}
|
|
349
|
+
let sessions = data.sessions || [];
|
|
350
|
+
if (jump?.sessionId && !sessions.some(s => s.id === jump.sessionId)) {
|
|
351
|
+
sessions = [{ id: jump.sessionId, eventCount: 0 }, ...sessions];
|
|
352
|
+
}
|
|
353
|
+
state.sessionInspectorSessions = sessions;
|
|
354
|
+
state.isSessionInspectorLoading = false;
|
|
355
|
+
renderSessionList();
|
|
356
|
+
|
|
357
|
+
const selectedStillExists = state.selectedSession && state.sessionInspectorSessions.some(s => s.id === state.selectedSession.id);
|
|
358
|
+
const nextSessionId = jump?.sessionId || (selectedStillExists ? state.selectedSession.id : state.sessionInspectorSessions[0]?.id);
|
|
359
|
+
if (nextSessionId) {
|
|
360
|
+
await selectSession(nextSessionId);
|
|
361
|
+
if (requestId !== state.sessionInspectorRequestId || projectAtStart !== state.currentProject) return;
|
|
362
|
+
if (jump && state.pendingSessionJump === jump && jump.sessionId === nextSessionId) state.pendingSessionJump = null;
|
|
363
|
+
} else {
|
|
364
|
+
state.selectedSession = null;
|
|
365
|
+
state.selectedSessionTurns = [];
|
|
366
|
+
state.selectedSessionEvents = [];
|
|
367
|
+
renderSessionConversation();
|
|
368
|
+
renderSessionSnapshot();
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (requestId !== state.sessionInspectorRequestId || projectAtStart !== state.currentProject) {
|
|
372
|
+
if (projectAtStart !== state.currentProject && jumpAtStart && state.pendingSessionJump === jumpAtStart) {
|
|
373
|
+
state.pendingSessionJump = null;
|
|
374
|
+
state.sessionJumpEventId = null;
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (jumpAtStart && state.pendingSessionJump === jumpAtStart) {
|
|
379
|
+
state.pendingSessionJump = null;
|
|
380
|
+
state.sessionJumpEventId = null;
|
|
381
|
+
}
|
|
382
|
+
state.isSessionInspectorLoading = false;
|
|
383
|
+
listEl.innerHTML = `<div class="session-empty" style="color:var(--error);">Failed to load sessions: ${escapeHtml(error.message)}</div>`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function renderSessionList() {
|
|
388
|
+
const listEl = document.getElementById('session-list');
|
|
389
|
+
const metaEl = document.getElementById('session-inspector-meta');
|
|
390
|
+
if (!listEl) return;
|
|
391
|
+
|
|
392
|
+
if (state.isSessionInspectorLoading) {
|
|
393
|
+
listEl.innerHTML = '<div class="session-empty">Loading sessions...</div>';
|
|
394
|
+
if (metaEl) metaEl.textContent = 'Loading recent sessions...';
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const sessions = state.sessionInspectorSessions || [];
|
|
399
|
+
if (metaEl) {
|
|
400
|
+
metaEl.textContent = `${sessions.length} sessions · page ${state.sessionInspectorPage}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (sessions.length === 0) {
|
|
404
|
+
listEl.innerHTML = '<div class="session-empty">No sessions found for the selected project.</div>';
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let previousGroup = '';
|
|
409
|
+
listEl.innerHTML = sessions.map((session) => {
|
|
410
|
+
const selected = state.selectedSession?.id === session.id;
|
|
411
|
+
const durationMs = session.startedAt && session.lastEventAt
|
|
412
|
+
? Math.max(0, new Date(session.lastEventAt).getTime() - new Date(session.startedAt).getTime())
|
|
413
|
+
: 0;
|
|
414
|
+
const durationMin = durationMs > 0 ? `${Math.max(1, Math.round(durationMs / 60000))}m` : '-';
|
|
415
|
+
const timeValue = session.lastEventAt || session.endedAt || session.startedAt || session.firstUserPromptAt;
|
|
416
|
+
const group = formatSessionDateGroup(timeValue);
|
|
417
|
+
const promptPreview = session.promptPreview || 'No user prompt preview yet';
|
|
418
|
+
const source = session.source || 'unknown';
|
|
419
|
+
const toolCount = Number(session.toolCount || session.eventTypeCounts?.tool_observation || 0);
|
|
420
|
+
const responseCount = Number(session.responseCount || session.eventTypeCounts?.agent_response || 0);
|
|
421
|
+
const groupHtml = group !== previousGroup ? `<div class="session-date-group">${escapeHtml(group)}</div>` : '';
|
|
422
|
+
previousGroup = group;
|
|
423
|
+
return `
|
|
424
|
+
${groupHtml}
|
|
425
|
+
<button class="session-list-item ${selected ? 'active' : ''}" onclick="selectSession(${jsAttrArg(session.id)})">
|
|
426
|
+
<span class="session-list-title">${escapeHtml(promptPreview)}</span>
|
|
427
|
+
<span class="session-list-id">${escapeHtml(sessionShortId(session.id))}</span>
|
|
428
|
+
<span class="session-list-meta">${formatNumber(session.eventCount || 0)} events · ${durationMin} · ${formatNumber(toolCount)} tools · ${formatNumber(responseCount)} responses</span>
|
|
429
|
+
<span class="session-list-time">${formatSessionTime(timeValue)}</span>
|
|
430
|
+
<span class="session-list-source">source: ${escapeHtml(source)}</span>
|
|
431
|
+
</button>
|
|
432
|
+
`;
|
|
433
|
+
}).join('');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function selectSession(sessionId) {
|
|
437
|
+
if (!sessionId) return;
|
|
438
|
+
|
|
439
|
+
const detailRequestId = (state.sessionDetailRequestId || 0) + 1;
|
|
440
|
+
state.sessionDetailRequestId = detailRequestId;
|
|
441
|
+
const activeJump = state.pendingSessionJump;
|
|
442
|
+
if (!activeJump || activeJump.sessionId !== sessionId || activeJump.project !== (state.currentProject || '')) {
|
|
443
|
+
state.sessionJumpEventId = null;
|
|
444
|
+
}
|
|
445
|
+
const projectAtStart = state.currentProject;
|
|
446
|
+
const sessions = state.sessionInspectorSessions || [];
|
|
447
|
+
state.selectedSession = sessions.find(s => s.id === sessionId) || { id: sessionId, eventCount: 0 };
|
|
448
|
+
state.selectedSessionTurns = [];
|
|
449
|
+
state.selectedSessionEvents = [];
|
|
450
|
+
state.isSessionDetailLoading = true;
|
|
451
|
+
renderSessionList();
|
|
452
|
+
renderSessionConversation();
|
|
453
|
+
renderSessionSnapshot();
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const [turnsRes, eventsRes] = await Promise.all([
|
|
457
|
+
fetch(apiUrl(`${API_BASE}/turns`, { sessionId, limit: 50, offset: 0 })),
|
|
458
|
+
fetch(apiUrl(`${API_BASE}/events`, { sessionId, limit: 300, sort: 'oldest' }))
|
|
459
|
+
]);
|
|
460
|
+
const turnsData = turnsRes.ok ? await turnsRes.json() : { turns: [] };
|
|
461
|
+
const eventsData = eventsRes.ok ? await eventsRes.json() : { events: [] };
|
|
462
|
+
|
|
463
|
+
if (
|
|
464
|
+
detailRequestId !== state.sessionDetailRequestId ||
|
|
465
|
+
projectAtStart !== state.currentProject ||
|
|
466
|
+
state.selectedSession?.id !== sessionId
|
|
467
|
+
) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
state.selectedSessionTurns = turnsData.turns || [];
|
|
472
|
+
state.selectedSessionEvents = eventsData.events || [];
|
|
473
|
+
state.isSessionDetailLoading = false;
|
|
474
|
+
renderSessionConversation();
|
|
475
|
+
renderSessionSnapshot();
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (
|
|
478
|
+
detailRequestId !== state.sessionDetailRequestId ||
|
|
479
|
+
projectAtStart !== state.currentProject ||
|
|
480
|
+
state.selectedSession?.id !== sessionId
|
|
481
|
+
) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
state.isSessionDetailLoading = false;
|
|
485
|
+
const conversationEl = document.getElementById('session-conversation');
|
|
486
|
+
if (conversationEl) {
|
|
487
|
+
conversationEl.innerHTML = `<div class="session-empty" style="color:var(--error);">Failed to load session detail: ${escapeHtml(error.message)}</div>`;
|
|
488
|
+
}
|
|
489
|
+
renderSessionSnapshot();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function renderSessionConversation() {
|
|
494
|
+
const container = document.getElementById('session-conversation');
|
|
495
|
+
const metaEl = document.getElementById('session-conversation-meta');
|
|
496
|
+
if (!container) return;
|
|
497
|
+
|
|
498
|
+
if (state.isSessionDetailLoading) {
|
|
499
|
+
container.innerHTML = '<div class="session-empty">Loading conversation turns...</div>';
|
|
500
|
+
if (metaEl) metaEl.textContent = 'Loading...';
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!state.selectedSession) {
|
|
505
|
+
container.innerHTML = '<div class="session-empty">Choose a session on the left to see user → tools → assistant flow.</div>';
|
|
506
|
+
if (metaEl) metaEl.textContent = 'No session selected';
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const turns = state.selectedSessionTurns || [];
|
|
511
|
+
if (metaEl) {
|
|
512
|
+
metaEl.textContent = `${sessionShortId(state.selectedSession.id)} · ${turns.length} turns`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (turns.length === 0) {
|
|
516
|
+
container.innerHTML = '<div class="session-empty">No turn grouping found. Try running turn backfill if this is legacy data.</div>';
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const jumpNotice = state.sessionJumpEventId ? `
|
|
521
|
+
<div class="session-jump-notice">
|
|
522
|
+
<i class="ri-corner-down-right-line"></i>
|
|
523
|
+
<span>Opened from User Prompts · Jump target is highlighted below.</span>
|
|
524
|
+
</div>
|
|
525
|
+
` : '';
|
|
526
|
+
|
|
527
|
+
container.innerHTML = jumpNotice + turns.map((turn, index) => {
|
|
528
|
+
const events = turn.events || [];
|
|
529
|
+
const userEvents = events.filter(e => e.eventType === 'user_prompt');
|
|
530
|
+
const agentEvents = events.filter(e => e.eventType === 'agent_response');
|
|
531
|
+
const toolEvents = events.filter(e => e.eventType === 'tool_observation');
|
|
532
|
+
const otherEvents = events.filter(e => !['user_prompt', 'agent_response', 'tool_observation'].includes(e.eventType));
|
|
533
|
+
const jumpedToolEvent = toolEvents.find(e => e.id && state.sessionJumpEventId === e.id);
|
|
534
|
+
const renderMessage = (event, roleLabel, icon) => {
|
|
535
|
+
const jumpClass = event.id && state.sessionJumpEventId === event.id ? 'session-message-jump' : '';
|
|
536
|
+
return `
|
|
537
|
+
<div class="session-message ${sessionEventTypeClass(event.eventType)} ${jumpClass}" ${event.id ? `onclick="openDetailModal(${jsAttrArg(event.id)})"` : ''}>
|
|
538
|
+
<div class="session-message-role">${icon} ${roleLabel}${jumpClass ? ' · <span class="session-jump-badge">Jump target</span>' : ''}</div>
|
|
539
|
+
<div class="session-message-preview">${sessionPreviewText(event)}</div>
|
|
540
|
+
<div class="session-message-meta">${escapeHtml(event.eventType || 'event')} · ${formatSessionTime(event.timestamp)} · ${escapeHtml(event.id || '')}</div>
|
|
541
|
+
</div>
|
|
542
|
+
`;
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
return `
|
|
546
|
+
<article class="session-turn">
|
|
547
|
+
<div class="session-turn-header">
|
|
548
|
+
<span>Turn ${index + 1}</span>
|
|
549
|
+
<span>${formatNumber(turn.eventCount || events.length || 0)} events · ${formatNumber(turn.toolCount ?? toolEvents.length)} tools · ${turn.hasResponse ? 'answered' : 'open'}</span>
|
|
550
|
+
</div>
|
|
551
|
+
${userEvents.map(e => renderMessage(e, 'User', '👤')).join('')}
|
|
552
|
+
${toolEvents.length > 0 ? `
|
|
553
|
+
<div class="session-tool-summary ${jumpedToolEvent ? 'session-message-jump' : ''}" ${jumpedToolEvent?.id ? `onclick="openDetailModal(${jsAttrArg(jumpedToolEvent.id)})"` : ''}>
|
|
554
|
+
<i class="ri-tools-line"></i>
|
|
555
|
+
<span>${formatNumber(toolEvents.length)} tool observations captured · raw tool output hidden in timeline${jumpedToolEvent ? ' · target tool highlighted' : ''}</span>
|
|
556
|
+
</div>
|
|
557
|
+
` : ''}
|
|
558
|
+
${agentEvents.map(e => renderMessage(e, 'Assistant', '🤖')).join('')}
|
|
559
|
+
${otherEvents.map(e => renderMessage(e, 'Event', '•')).join('')}
|
|
560
|
+
</article>
|
|
561
|
+
`;
|
|
562
|
+
}).join('');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function setSessionSnapshotTab(tab) {
|
|
566
|
+
const allowed = ['overview', 'evidence', 'quality'];
|
|
567
|
+
state.sessionSnapshotTab = allowed.includes(tab) ? tab : 'overview';
|
|
568
|
+
document.querySelectorAll('#session-snapshot-tabs .sort-btn').forEach(btn => {
|
|
569
|
+
btn.classList.toggle('active', btn.dataset.sessionSnapshotTab === state.sessionSnapshotTab);
|
|
570
|
+
});
|
|
571
|
+
renderSessionSnapshot();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function renderSessionSnapshot() {
|
|
575
|
+
const container = document.getElementById('session-snapshot-content');
|
|
576
|
+
if (!container) return;
|
|
577
|
+
|
|
578
|
+
if (state.isSessionDetailLoading) {
|
|
579
|
+
container.innerHTML = '<div class="session-empty">Loading memory snapshot...</div>';
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!state.selectedSession) {
|
|
584
|
+
container.innerHTML = '<div class="session-empty">Select a session to see memory levels, evidence links, and quality signals.</div>';
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const tab = state.sessionSnapshotTab || 'overview';
|
|
589
|
+
if (tab === 'evidence') {
|
|
590
|
+
renderSessionEvidenceSnapshot(container);
|
|
591
|
+
} else if (tab === 'quality') {
|
|
592
|
+
renderSessionQualitySnapshot(container);
|
|
593
|
+
} else {
|
|
594
|
+
renderSessionOverviewSnapshot(container);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function renderSessionOverviewSnapshot(container) {
|
|
599
|
+
const session = state.selectedSession || {};
|
|
600
|
+
const turns = state.selectedSessionTurns || [];
|
|
601
|
+
const events = state.selectedSessionEvents || [];
|
|
602
|
+
const eventCount = session.eventCount || events.length || 0;
|
|
603
|
+
const levelCounts = {};
|
|
604
|
+
const typeCounts = {};
|
|
605
|
+
for (const event of events) {
|
|
606
|
+
const level = getSessionEventLevel(event);
|
|
607
|
+
levelCounts[level] = (levelCounts[level] || 0) + 1;
|
|
608
|
+
const type = event.eventType || 'unknown';
|
|
609
|
+
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
container.innerHTML = `
|
|
613
|
+
<div class="snapshot-kpi-grid">
|
|
614
|
+
<div class="snapshot-kpi"><strong>${formatNumber(eventCount)} events</strong><span>captured</span></div>
|
|
615
|
+
<div class="snapshot-kpi"><strong>${formatNumber(turns.length)} turns</strong><span>conversation groups</span></div>
|
|
616
|
+
<div class="snapshot-kpi"><strong>${formatNumber(events.filter(e => (e.accessCount || 0) > 0).length)} used</strong><span>retrieved later</span></div>
|
|
617
|
+
</div>
|
|
618
|
+
<div class="snapshot-section-title">Memory Levels</div>
|
|
619
|
+
<div class="snapshot-chip-list">
|
|
620
|
+
${Object.entries(levelCounts).length > 0
|
|
621
|
+
? Object.entries(levelCounts).map(([level, count]) => `<span class="snapshot-chip">${escapeHtml(level)} · ${formatNumber(count)}</span>`).join('')
|
|
622
|
+
: '<span class="snapshot-chip muted">No level metadata</span>'}
|
|
623
|
+
</div>
|
|
624
|
+
<div class="snapshot-section-title">Event Mix</div>
|
|
625
|
+
<div class="snapshot-chip-list">
|
|
626
|
+
${Object.entries(typeCounts).map(([type, count]) => `<span class="snapshot-chip">${escapeHtml(type)} · ${formatNumber(count)}</span>`).join('') || '<span class="snapshot-chip muted">No events loaded</span>'}
|
|
627
|
+
</div>
|
|
628
|
+
<div class="snapshot-note">Raw metadata is intentionally hidden here; use Event Detail for explicit drill-down.</div>
|
|
629
|
+
`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function renderSessionEvidenceSnapshot(container) {
|
|
633
|
+
const events = state.selectedSessionEvents || [];
|
|
634
|
+
const visible = events.slice(0, 30);
|
|
635
|
+
container.innerHTML = `
|
|
636
|
+
<div class="snapshot-section-title">Evidence Coverage</div>
|
|
637
|
+
<div class="snapshot-note">Showing sanitized event IDs, types, levels, and short previews. Raw metadata and tool output are not rendered in this panel.</div>
|
|
638
|
+
${visible.length === 0 ? '<div class="session-empty">No evidence events loaded.</div>' : ''}
|
|
639
|
+
<div class="snapshot-evidence-list">
|
|
640
|
+
${visible.map(event => `
|
|
641
|
+
<button class="snapshot-evidence-item" ${event.id ? `onclick="openDetailModal(${jsAttrArg(event.id)})"` : ''}>
|
|
642
|
+
<span class="event-type-badge ${sessionEventTypeClass(event.eventType)}">${escapeHtml(event.eventType || 'event')}</span>
|
|
643
|
+
<span class="snapshot-evidence-id">${escapeHtml(event.id || '')}</span>
|
|
644
|
+
<span class="snapshot-evidence-meta">${escapeHtml(getSessionEventLevel(event))} · ${formatSessionTime(event.timestamp)} · ${formatNumber(event.accessCount || 0)} hits</span>
|
|
645
|
+
<span class="snapshot-evidence-preview">${sessionEvidencePreviewText(event, 120)}</span>
|
|
646
|
+
</button>
|
|
647
|
+
`).join('')}
|
|
648
|
+
</div>
|
|
649
|
+
`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function renderSessionQualitySnapshot(container) {
|
|
653
|
+
const turns = state.selectedSessionTurns || [];
|
|
654
|
+
const events = state.selectedSessionEvents || [];
|
|
655
|
+
const answeredTurns = turns.filter(t => t.hasResponse).length;
|
|
656
|
+
const toolCount = turns.reduce((sum, turn) => sum + Number(turn.toolCount || 0), 0);
|
|
657
|
+
const userPromptCount = events.filter(e => e.eventType === 'user_prompt').length;
|
|
658
|
+
const responseCount = events.filter(e => e.eventType === 'agent_response').length;
|
|
659
|
+
const responseCoverage = turns.length > 0 ? Math.round((answeredTurns / turns.length) * 100) : 0;
|
|
660
|
+
|
|
661
|
+
container.innerHTML = `
|
|
662
|
+
<div class="snapshot-kpi-grid">
|
|
663
|
+
<div class="snapshot-kpi"><strong>${responseCoverage}%</strong><span>response coverage</span></div>
|
|
664
|
+
<div class="snapshot-kpi"><strong>${formatNumber(toolCount)}</strong><span>tool observations</span></div>
|
|
665
|
+
<div class="snapshot-kpi"><strong>${formatNumber(userPromptCount)}:${formatNumber(responseCount)}</strong><span>prompt/response</span></div>
|
|
666
|
+
</div>
|
|
667
|
+
<div class="snapshot-section-title">Turn Quality Signals</div>
|
|
668
|
+
<div class="snapshot-chip-list">
|
|
669
|
+
<span class="snapshot-chip">${formatNumber(answeredTurns)} answered turns</span>
|
|
670
|
+
<span class="snapshot-chip">${formatNumber(Math.max(0, turns.length - answeredTurns))} open turns</span>
|
|
671
|
+
<span class="snapshot-chip">${formatNumber(events.filter(e => (e.accessCount || 0) > 0).length)} reused memories</span>
|
|
672
|
+
</div>
|
|
673
|
+
<div class="snapshot-note">This is a lightweight dashboard heuristic, not a model judgment. It helps spot sessions with missing responses, heavy tool usage, or later memory reuse.</div>
|
|
674
|
+
`;
|
|
675
|
+
}
|
|
676
|
+
|
|
257
677
|
// --- User Prompts View ---
|
|
258
678
|
|
|
259
679
|
async function renderUserPromptList() {
|
|
@@ -302,15 +722,26 @@ async function renderUserPromptList() {
|
|
|
302
722
|
</div>
|
|
303
723
|
`;
|
|
304
724
|
|
|
305
|
-
const cards = sessionItems.map((e) =>
|
|
306
|
-
|
|
725
|
+
const cards = sessionItems.map((e) => {
|
|
726
|
+
const eventArg = jsAttrArg(e.id || '');
|
|
727
|
+
const sessionArg = jsAttrArg(e.sessionId || '');
|
|
728
|
+
return `
|
|
729
|
+
<div class="event-item" style="cursor:pointer;" ${e.id ? `onclick="openDetailModal(${eventArg})"` : ''}>
|
|
307
730
|
<div class="event-header">
|
|
308
731
|
<span class="event-type-badge type-user-prompt">user_prompt</span>
|
|
309
732
|
<span class="event-time">${new Date(e.timestamp).toLocaleString()}</span>
|
|
310
733
|
</div>
|
|
311
734
|
<div class="event-content" style="-webkit-line-clamp:4;">${escapeHtml(e.preview || '')}</div>
|
|
735
|
+
${e.sessionId ? `
|
|
736
|
+
<div class="event-actions">
|
|
737
|
+
<button type="button" class="inline-action-btn" onclick="event.stopPropagation(); jumpToSession(${sessionArg}, ${eventArg})">
|
|
738
|
+
<i class="ri-corner-right-up-line"></i> Open in Sessions
|
|
739
|
+
</button>
|
|
740
|
+
</div>
|
|
741
|
+
` : ''}
|
|
312
742
|
</div>
|
|
313
|
-
|
|
743
|
+
`;
|
|
744
|
+
}).join('');
|
|
314
745
|
|
|
315
746
|
return heading + cards;
|
|
316
747
|
}).join('');
|
|
@@ -342,17 +773,191 @@ async function loadUserPromptsView() {
|
|
|
342
773
|
}
|
|
343
774
|
}
|
|
344
775
|
|
|
776
|
+
// --- Playground View ---
|
|
777
|
+
|
|
778
|
+
async function loadPlaygroundView() {
|
|
779
|
+
renderPlaygroundDryRun(state.playgroundLastRun);
|
|
780
|
+
const status = document.getElementById('playground-status');
|
|
781
|
+
if (status && !state.playgroundLastRun) {
|
|
782
|
+
status.textContent = state.currentProject
|
|
783
|
+
? 'Ready. Dry-run uses the selected project scope.'
|
|
784
|
+
: 'Ready. Global dry-run; select a project for project-local replay.';
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function runPlaygroundDryRun() {
|
|
789
|
+
if (state.isPlaygroundLoading) return;
|
|
790
|
+
const queryInput = document.getElementById('playground-query');
|
|
791
|
+
const query = (queryInput?.value || '').trim();
|
|
792
|
+
const status = document.getElementById('playground-status');
|
|
793
|
+
const button = document.getElementById('playground-run-btn');
|
|
794
|
+
if (!query) {
|
|
795
|
+
if (status) status.textContent = 'Enter a query before running a dry-run replay.';
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const strategy = document.getElementById('playground-strategy')?.value || 'fast';
|
|
800
|
+
const topK = Math.max(1, Math.min(20, parseInt(document.getElementById('playground-topk')?.value || '5', 10) || 5));
|
|
801
|
+
const windowSize = Math.max(1, Math.min(10, parseInt(document.getElementById('playground-window')?.value || '3', 10) || 3));
|
|
802
|
+
const includeShared = Boolean(document.getElementById('playground-include-shared')?.checked);
|
|
803
|
+
|
|
804
|
+
state.isPlaygroundLoading = true;
|
|
805
|
+
if (button) button.disabled = true;
|
|
806
|
+
if (status) status.textContent = 'Running dry-run replay...';
|
|
807
|
+
renderPlaygroundDryRun(null, true);
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
const res = await fetch(apiUrl('/api/playground/dry-run'), {
|
|
811
|
+
method: 'POST',
|
|
812
|
+
headers: { 'Content-Type': 'application/json' },
|
|
813
|
+
body: JSON.stringify({
|
|
814
|
+
query,
|
|
815
|
+
options: { strategy, topK, includeShared, windowSize }
|
|
816
|
+
})
|
|
817
|
+
});
|
|
818
|
+
const data = await res.json();
|
|
819
|
+
if (!res.ok) throw new Error(data.error || 'Playground dry-run failed');
|
|
820
|
+
state.playgroundLastRun = data;
|
|
821
|
+
if (status) status.textContent = `Dry-run complete · ${data.search?.results?.length || 0} result(s) · ${data.replayTrace?.join(' → ') || 'no trace'}`;
|
|
822
|
+
renderPlaygroundDryRun(data);
|
|
823
|
+
} catch (error) {
|
|
824
|
+
if (status) status.textContent = `Dry-run failed: ${error.message}`;
|
|
825
|
+
renderPlaygroundDryRun({ error: error.message, replayTrace: [] });
|
|
826
|
+
} finally {
|
|
827
|
+
state.isPlaygroundLoading = false;
|
|
828
|
+
if (button) button.disabled = false;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function renderPlaygroundDryRun(result, isLoading = false) {
|
|
833
|
+
const container = document.getElementById('playground-output');
|
|
834
|
+
if (!container) return;
|
|
835
|
+
if (isLoading) {
|
|
836
|
+
container.innerHTML = `
|
|
837
|
+
<div class="disclosure-results"><div class="disclosure-empty">Searching ranked candidates...</div></div>
|
|
838
|
+
<div class="disclosure-drilldown"><div class="disclosure-empty">Replay trace will appear after search.</div></div>
|
|
839
|
+
`;
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (!result) {
|
|
843
|
+
container.innerHTML = `
|
|
844
|
+
<div class="disclosure-results"><div class="disclosure-empty">Run a query to see ranked results and replay trace.</div></div>
|
|
845
|
+
<div class="disclosure-drilldown"><div class="disclosure-empty">Expansion/source details will appear here.</div></div>
|
|
846
|
+
`;
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (result.error) {
|
|
850
|
+
container.innerHTML = `
|
|
851
|
+
<div class="disclosure-results"><div class="disclosure-empty" style="color:var(--error);">${escapeHtml(result.error)}</div></div>
|
|
852
|
+
<div class="disclosure-drilldown"><div class="disclosure-empty">No replay output.</div></div>
|
|
853
|
+
`;
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const results = result.search?.results || [];
|
|
858
|
+
const resultCards = results.length ? results.map((r, idx) => `
|
|
859
|
+
<div class="disclosure-result${r.id === result.selectedResultId ? ' active' : ''}">
|
|
860
|
+
<div class="disclosure-result-head">
|
|
861
|
+
<span class="event-type-badge">#${idx + 1} ${escapeHtml(r.resultType || 'result')}</span>
|
|
862
|
+
<span class="disclosure-scope-pill">score ${Number(r.score || 0).toFixed(2)}</span>
|
|
863
|
+
</div>
|
|
864
|
+
<div class="disclosure-snippet">${highlightDisclosureText(r.snippet || r.preview || '', result.query || '')}</div>
|
|
865
|
+
<div class="disclosure-rank-explain"><strong>Why this ranked</strong>${(r.reasons || []).map(reason => `<span class="disclosure-chip">${escapeHtml(reason)}</span>`).join('') || '<span class="disclosure-chip">no_reason</span>'}</div>
|
|
866
|
+
</div>
|
|
867
|
+
`).join('') : '<div class="disclosure-empty">No results. Try a broader query or another project scope.</div>';
|
|
868
|
+
|
|
869
|
+
const trace = (result.replayTrace || []).map(step => `<span class="disclosure-chip">${escapeHtml(step)}</span>`).join('');
|
|
870
|
+
const expansionFacts = (result.expansion?.surroundingFacts || []).slice(0, 6).map(f => `
|
|
871
|
+
<div class="shared-item"><div class="shared-info"><span>${escapeHtml(f.snippet || f.summary || f.id || 'fact')}</span></div></div>
|
|
872
|
+
`).join('') || '<div class="disclosure-empty">No expansion facts returned.</div>';
|
|
873
|
+
const rawEvents = (result.source?.rawEvents || []).slice(0, 3).map(e => {
|
|
874
|
+
const preview = e?.preview || e?.id || e?.eventId || 'source preview unavailable';
|
|
875
|
+
return `<pre class="disclosure-source-pre">${escapeHtml(buildSafeDisclosurePreview(preview))}</pre>`;
|
|
876
|
+
}).join('') || '<div class="disclosure-empty">No safe source preview returned.</div>';
|
|
877
|
+
|
|
878
|
+
container.innerHTML = `
|
|
879
|
+
<div class="disclosure-results">
|
|
880
|
+
<div class="snapshot-note">Dry-run: ${result.mutated === false ? 'no memory writes' : 'mutation state unknown'} · selected ${escapeHtml(result.selectedResultId || 'none')}</div>
|
|
881
|
+
${resultCards}
|
|
882
|
+
</div>
|
|
883
|
+
<div class="disclosure-drilldown">
|
|
884
|
+
<div class="disclosure-section-title">Replay Trace</div>
|
|
885
|
+
<div class="disclosure-rank-explain">${trace || '<span class="disclosure-chip">empty</span>'}</div>
|
|
886
|
+
<div class="disclosure-section-title" style="margin-top:14px;">Expand layer</div>
|
|
887
|
+
${expansionFacts}
|
|
888
|
+
<div class="disclosure-section-title" style="margin-top:14px;">Source layer</div>
|
|
889
|
+
${rawEvents}
|
|
890
|
+
</div>
|
|
891
|
+
`;
|
|
892
|
+
}
|
|
893
|
+
|
|
345
894
|
// --- Configuration View ---
|
|
346
895
|
|
|
896
|
+
function renderSetupProviderHealthCard(setupHealth) {
|
|
897
|
+
if (!setupHealth) {
|
|
898
|
+
return `
|
|
899
|
+
<div class="card setup-provider-health-card" style="margin-bottom:24px;">
|
|
900
|
+
<div class="card-header"><div class="card-title"><i class="ri-stethoscope-line"></i><span>Setup & Provider Health</span></div></div>
|
|
901
|
+
<div class="disclosure-empty">Setup health is unavailable.</div>
|
|
902
|
+
</div>
|
|
903
|
+
`;
|
|
904
|
+
}
|
|
905
|
+
const setup = setupHealth.setup || {};
|
|
906
|
+
const storage = setup.storage || {};
|
|
907
|
+
const outbox = setup.outbox || {};
|
|
908
|
+
const providers = setupHealth.providers || {};
|
|
909
|
+
const claude = providers.claudeCli || {};
|
|
910
|
+
const embeddings = providers.embeddings || {};
|
|
911
|
+
const recommendations = Array.isArray(setupHealth.recommendations) ? setupHealth.recommendations : [];
|
|
912
|
+
const status = setupHealth.status || 'unknown';
|
|
913
|
+
const scope = setup.scope || 'global';
|
|
914
|
+
const recommendationHtml = recommendations.length
|
|
915
|
+
? recommendations.map(item => `<div class="snapshot-note">${escapeHtml(item)}</div>`).join('')
|
|
916
|
+
: '<div class="snapshot-note">No setup action required.</div>';
|
|
917
|
+
|
|
918
|
+
return `
|
|
919
|
+
<div class="card setup-provider-health-card" style="margin-bottom:24px;">
|
|
920
|
+
<div class="card-header" style="align-items:flex-start;">
|
|
921
|
+
<div>
|
|
922
|
+
<div class="card-title"><i class="ri-stethoscope-line"></i><span>Setup & Provider Health</span></div>
|
|
923
|
+
<div class="session-muted">Provider readiness, storage visibility, and setup guidance for the current ${escapeHtml(scope)} scope.</div>
|
|
924
|
+
</div>
|
|
925
|
+
<span class="disclosure-scope-pill">${escapeHtml(status)}</span>
|
|
926
|
+
</div>
|
|
927
|
+
<div class="cfg-grid">
|
|
928
|
+
<div class="cfg-section">
|
|
929
|
+
<div class="cfg-section-title"><i class="ri-hard-drive-2-line"></i>Storage Readiness</div>
|
|
930
|
+
<div class="cfg-row"><span class="cfg-row-label">Status</span><span class="cfg-row-value">${escapeHtml(storage.status || 'unknown')}</span></div>
|
|
931
|
+
<div class="cfg-row"><span class="cfg-row-label">Events</span><span class="cfg-row-value">${formatNumber(storage.totalEvents || 0)} events</span></div>
|
|
932
|
+
<div class="cfg-row"><span class="cfg-row-label">Vectors</span><span class="cfg-row-value">${formatNumber(storage.vectorCount || 0)} vectors</span></div>
|
|
933
|
+
<div class="cfg-row"><span class="cfg-row-label">Outbox</span><span class="cfg-row-value">${formatNumber(outbox.pending || 0)} pending · ${formatNumber(outbox.failed || 0)} failed · ${formatNumber(outbox.stuckProcessing || 0)} stuck</span></div>
|
|
934
|
+
</div>
|
|
935
|
+
<div class="cfg-section">
|
|
936
|
+
<div class="cfg-section-title"><i class="ri-terminal-box-line"></i>Provider Readiness</div>
|
|
937
|
+
<div class="cfg-row"><span class="cfg-row-label">Claude CLI</span><span class="cfg-row-value">${escapeHtml(claude.status || 'unknown')}</span></div>
|
|
938
|
+
<div class="cfg-row"><span class="cfg-row-label">Claude Auth Signal</span><span class="cfg-row-value">${escapeHtml(claude.authSignal || 'not-detected')}</span></div>
|
|
939
|
+
<div class="cfg-row"><span class="cfg-row-label">Embedding Backend</span><span class="cfg-row-value">${escapeHtml(embeddings.status || 'unknown')}</span></div>
|
|
940
|
+
<div class="cfg-row"><span class="cfg-row-label">Backend</span><span class="cfg-row-value">${escapeHtml(embeddings.backend || '@huggingface/transformers')}</span></div>
|
|
941
|
+
</div>
|
|
942
|
+
</div>
|
|
943
|
+
<div style="margin-top:14px;">
|
|
944
|
+
<div class="section-label">Setup recommendations</div>
|
|
945
|
+
${recommendationHtml}
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
`;
|
|
949
|
+
}
|
|
950
|
+
|
|
347
951
|
async function loadConfigurationView() {
|
|
348
952
|
const container = document.getElementById('cfg-content');
|
|
349
953
|
container.innerHTML = '<div style="text-align:center; padding:60px; color:var(--text-muted);">Loading configuration...</div>';
|
|
350
954
|
|
|
351
955
|
try {
|
|
352
|
-
const [statsRes, graduationRes, endlessRes] = await Promise.all([
|
|
956
|
+
const [statsRes, graduationRes, endlessRes, setupHealthRes] = await Promise.all([
|
|
353
957
|
fetch(apiUrl(`${API_BASE}/stats`)).then(r => r.json()).catch(() => null),
|
|
354
958
|
fetch(apiUrl(`${API_BASE}/stats/graduation`)).then(r => r.json()).catch(() => null),
|
|
355
|
-
fetch(apiUrl(`${API_BASE}/stats/endless`)).then(r => r.json()).catch(() => null)
|
|
959
|
+
fetch(apiUrl(`${API_BASE}/stats/endless`)).then(r => r.json()).catch(() => null),
|
|
960
|
+
fetch(apiUrl(`${API_BASE}/health/setup`)).then(r => r.json()).catch(() => null)
|
|
356
961
|
]);
|
|
357
962
|
|
|
358
963
|
const memory = statsRes?.memory || {};
|
|
@@ -360,8 +965,10 @@ async function loadConfigurationView() {
|
|
|
360
965
|
const criteria = graduationRes?.criteria || {};
|
|
361
966
|
const descriptions = graduationRes?.description || {};
|
|
362
967
|
const endless = endlessRes || {};
|
|
968
|
+
const setupHealth = setupHealthRes || null;
|
|
363
969
|
|
|
364
970
|
container.innerHTML = `
|
|
971
|
+
${renderSetupProviderHealthCard(setupHealth)}
|
|
365
972
|
<div class="cfg-grid">
|
|
366
973
|
<div class="cfg-section">
|
|
367
974
|
<div class="cfg-section-title"><i class="ri-database-2-line"></i>Storage</div>
|
|
@@ -462,6 +1069,10 @@ function escapeHtml(unsafe) {
|
|
|
462
1069
|
.replace(/'/g, "'");
|
|
463
1070
|
}
|
|
464
1071
|
|
|
1072
|
+
function jsAttrArg(value) {
|
|
1073
|
+
return escapeHtml(JSON.stringify(String(value ?? '')));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
465
1076
|
// --- Chat Panel ---
|
|
466
1077
|
|
|
467
1078
|
const CHAT_STORAGE_KEY = 'code-memory-chat-history';
|