wendkeep 0.70.0 → 0.72.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.
@@ -0,0 +1,858 @@
1
+ export const STALE_AFTER_MS = 60_000;
2
+ export const REFRESH_INTERVAL_MS = 15_000;
3
+ const MEMORY_CATEGORY_LABELS = {
4
+ '02-Sessões': 'Sessões',
5
+ '04-Decisões': 'Decisões',
6
+ '05-Bugs': 'Bugs',
7
+ '06-Aprendizados': 'Aprendizados',
8
+ '07-Specs': 'Specs',
9
+ '08-Mudanças': 'Changes',
10
+ '.brain': 'Core',
11
+ };
12
+
13
+ export function parseObserverRoute(hash = '') {
14
+ const raw = String(hash || '').replace(/^#/, '');
15
+ if (!raw) return { kind: 'overview' };
16
+ if (raw.startsWith('search')) {
17
+ const query = new URLSearchParams(raw.includes('?') ? raw.slice(raw.indexOf('?') + 1) : '');
18
+ return { kind: 'search', query: query.get('q') || '' };
19
+ }
20
+ const parts = raw.split('/');
21
+ if (parts[0] === 'project' && parts[1]) {
22
+ return { kind: 'project', projectId: decodeURIComponent(parts[1]), section: parts[2] || 'overview' };
23
+ }
24
+ if (parts[0] === 'document' && parts[1] && parts[2]) {
25
+ return {
26
+ kind: 'document',
27
+ projectId: decodeURIComponent(parts[1]),
28
+ logicalPath: decodeURIComponent(parts.slice(2).join('/')),
29
+ };
30
+ }
31
+ return { kind: 'overview' };
32
+ }
33
+
34
+ export function memoryCategory(logicalPath = '') {
35
+ return MEMORY_CATEGORY_LABELS[String(logicalPath).split('/')[0]] || 'Memória';
36
+ }
37
+
38
+ export function filterMemoryDocuments(documents = [], filter = '') {
39
+ const query = String(filter || '').trim().toLowerCase();
40
+ if (!query) return [...documents];
41
+ return documents.filter((item) => [
42
+ item.logical_path, item.entity_type, memoryCategory(item.logical_path),
43
+ ].join(' ').toLowerCase().includes(query));
44
+ }
45
+
46
+ export function buildMemoryDocumentViewModel(metadata = {}, content = '') {
47
+ const logicalPath = String(metadata.logical_path || '');
48
+ const parts = logicalPath.split('/');
49
+ return {
50
+ title: (parts.at(-1) || 'Documento').replace(/\.[^.]+$/, ''),
51
+ category: memoryCategory(logicalPath),
52
+ logicalPath,
53
+ content: String(content || ''),
54
+ hash: String(metadata.content_hash || ''),
55
+ entityType: String(metadata.entity_type || 'memory'),
56
+ revision: Number(metadata.revision || 0),
57
+ sourceSessionId: String(metadata.source_session_id || ''),
58
+ capturedAt: String(metadata.captured_at || ''),
59
+ };
60
+ }
61
+
62
+ export function isSnapshotStale(capturedAt, now = new Date()) {
63
+ const captured = Date.parse(String(capturedAt || ''));
64
+ const current = now instanceof Date ? now.getTime() : Date.parse(String(now));
65
+ return !Number.isFinite(captured) || !Number.isFinite(current) || current - captured > STALE_AFTER_MS;
66
+ }
67
+
68
+ export function classifyRefreshError(error = {}, hasModels = false) {
69
+ return {
70
+ kind: hasModels ? 'degraded' : 'unavailable',
71
+ message: hasModels
72
+ ? `Não foi possível atualizar. Última leitura preservada. ${error.message || 'erro desconhecido.'}`
73
+ : `Observer indisponível. ${error.message || 'erro desconhecido.'}`,
74
+ preserve: hasModels,
75
+ };
76
+ }
77
+
78
+ async function requestJson(fetchImpl, url) {
79
+ const response = await fetchImpl(url, {
80
+ headers: { Accept: 'application/json' },
81
+ });
82
+ if (!response.ok) {
83
+ const error = new Error(`Observer respondeu HTTP ${response.status}.`);
84
+ error.status = response.status;
85
+ throw error;
86
+ }
87
+ return response.json();
88
+ }
89
+
90
+ export async function loadProjectMemory(fetchImpl = globalThis.fetch, projectId = '') {
91
+ const id = encodeURIComponent(projectId);
92
+ const [tree, sync] = await Promise.all([
93
+ requestJson(fetchImpl, '/v1/projects/' + id + '/memory/tree'),
94
+ requestJson(fetchImpl, '/v1/projects/' + id + '/sync'),
95
+ ]);
96
+ return { tree, sync };
97
+ }
98
+
99
+ export function usageQuery(filters = {}) {
100
+ const params = new URLSearchParams();
101
+ const keys = ['from', 'to', 'agent_id', 'subagent_id', 'provider', 'model_provider', 'model', 'change', 'session_id', 'role'];
102
+ for (const key of keys) {
103
+ const value = filters[key] ?? filters[key.replace('_id', 'Id')] ?? '';
104
+ if (String(value).trim()) params.set(key, String(value));
105
+ }
106
+ const query = params.toString();
107
+ return query ? `?${query}` : '';
108
+ }
109
+
110
+ export async function loadProjectUsage(fetchImpl = globalThis.fetch, projectId = '', filters = {}) {
111
+ const id = encodeURIComponent(projectId);
112
+ const query = usageQuery(filters);
113
+ const [summary, breakdown, calls] = await Promise.all([
114
+ requestJson(fetchImpl, `/v1/projects/${id}/usage/summary${query}`),
115
+ requestJson(fetchImpl, `/v1/projects/${id}/usage/breakdown${query}`),
116
+ requestJson(fetchImpl, `/v1/projects/${id}/usage/calls${query}`),
117
+ ]);
118
+ return { summary, breakdown, calls, filters: { ...filters } };
119
+ }
120
+
121
+ export function buildUsageViewModel(usage = {}) {
122
+ const summary = usage.summary || {};
123
+ const agents = (Array.isArray(usage.breakdown?.agents) ? usage.breakdown.agents : []).map((agent) => ({ ...agent, children: [] }));
124
+ const byId = new Map(agents.map((agent) => [agent.agent_id, agent]));
125
+ for (const agent of agents) {
126
+ const parent = agent.parent_agent_id ? byId.get(agent.parent_agent_id) : null;
127
+ if (parent && parent !== agent) parent.children.push(agent);
128
+ }
129
+ const roots = agents.filter((agent) => !agent.parent_agent_id || !byId.has(agent.parent_agent_id));
130
+ const tokens = {
131
+ input: Number(summary.tokens?.input || 0),
132
+ cacheWrite: Number(summary.tokens?.cache_write || 0),
133
+ cacheRead: Number(summary.tokens?.cache_read || 0),
134
+ output: Number(summary.tokens?.output || 0),
135
+ reasoning: Number(summary.tokens?.reasoning || 0),
136
+ total: Number(summary.tokens?.total || 0),
137
+ };
138
+ const coverage = summary.coverage || {};
139
+ const complete = Number(coverage.complete || 0);
140
+ const summaryOnly = Number(coverage.summary_only || 0);
141
+ return {
142
+ totalCost: Number(summary.cost_usd || 0),
143
+ mainCost: Number(summary.main_cost_usd || 0),
144
+ subagentCost: Number(summary.subagent_cost_usd || 0),
145
+ wastedCost: Number(summary.wasted_usd || 0),
146
+ calls: Array.isArray(usage.calls?.calls) ? usage.calls.calls : [],
147
+ callCount: Number(summary.calls || usage.calls?.total || 0),
148
+ sessions: Number(summary.sessions || 0),
149
+ agentsCount: Number(summary.agents || 0),
150
+ subagentsCount: Number(summary.subagents || 0),
151
+ modelsCount: Number(summary.models || 0),
152
+ tokens,
153
+ trend: Array.isArray(summary.by_day) ? summary.by_day : [],
154
+ agents: roots,
155
+ allAgents: agents,
156
+ coverage: {
157
+ complete,
158
+ summaryOnly,
159
+ total: Number(coverage.transcripts || complete + summaryOnly),
160
+ label: `${complete} completo${complete === 1 ? '' : 's'} · ${summaryOnly} agregado${summaryOnly === 1 ? '' : 's'}`,
161
+ },
162
+ hasUnknownPricing: Number(summary.unknown_priced_rollups || 0) > 0,
163
+ };
164
+ }
165
+
166
+ export async function loadMemoryDocument(fetchImpl = globalThis.fetch, projectId = '', logicalPath = '') {
167
+ const query = new URLSearchParams({ path: logicalPath });
168
+ return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/document?' + query.toString());
169
+ }
170
+
171
+ export async function loadProjectTranscript(fetchImpl = globalThis.fetch, projectId = '', transcriptId = '') {
172
+ return requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(projectId)}/transcripts/${encodeURIComponent(transcriptId)}`);
173
+ }
174
+
175
+ export async function searchProjectMemory(fetchImpl = globalThis.fetch, projectId = '', query = '') {
176
+ const params = new URLSearchParams({ q: query });
177
+ return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/search?' + params.toString());
178
+ }
179
+
180
+ export async function loadDashboardData(fetchImpl = globalThis.fetch) {
181
+ const index = await requestJson(fetchImpl, '/v1/projects');
182
+ const projects = Array.isArray(index?.projects) ? index.projects : [];
183
+ return Promise.all(projects.map(async (summary) => {
184
+ const detail = await requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(summary.projectId)}`);
185
+ return buildProjectViewModel(summary, detail, new Date());
186
+ }));
187
+ }
188
+
189
+ export function buildProjectViewModel(summary = {}, detail = {}, now = new Date()) {
190
+ const snapshot = detail.snapshot || {};
191
+ const session = snapshot.session || {};
192
+ const health = snapshot.health || {};
193
+ const changes = Array.isArray(snapshot.changes) ? snapshot.changes : [];
194
+ return {
195
+ projectId: String(summary.projectId || detail.projectId || snapshot.project_id || ''),
196
+ projectName: String(summary.projectName || detail.projectName || snapshot.project_name || 'Projeto sem nome'),
197
+ version: String(snapshot.wendkeep_version || summary.wendkeepVersion || '—'),
198
+ eventCount: Number(detail.eventCount || summary.eventCount || 0),
199
+ capturedAt: String(snapshot.captured_at || detail.capturedAt || ''),
200
+ stale: isSnapshotStale(snapshot.captured_at || detail.capturedAt, now),
201
+ session: {
202
+ status: String(session.status || 'inactive'),
203
+ provider: String(session.provider || '—'),
204
+ changeSlug: String(session.change_slug || '—'),
205
+ lastSeen: String(session.last_seen || '—'),
206
+ },
207
+ health: {
208
+ ok: health.ok === true,
209
+ status: String(health.status || 'unavailable'),
210
+ failureCount: Number(health.failure_count || 0),
211
+ warningCount: Number(health.warning_count || 0),
212
+ registrySessions: Number(health.registry_sessions || 0),
213
+ derivedNotes: Number(health.derived_notes || 0),
214
+ },
215
+ changes: changes.map((change) => ({
216
+ slug: String(change.slug || '—'),
217
+ current: change.current === true,
218
+ openTasks: Number(change.openTasks || 0),
219
+ doneTasks: Number(change.doneTasks || 0),
220
+ warning: String(change.warning || ''),
221
+ })),
222
+ };
223
+ }
224
+
225
+ export function filterProjects(models = [], filter = '') {
226
+ const query = String(filter || '').trim().toLowerCase();
227
+ if (!query) return [...models];
228
+ return models.filter((model) => [
229
+ model.projectId, model.projectName, model.version, model.session?.provider,
230
+ model.session?.changeSlug, model.health?.status, statusLabel(model),
231
+ ].join(' ').toLowerCase().includes(query));
232
+ }
233
+
234
+ function byId(id) { return document.getElementById(id); }
235
+ function setHidden(element, hidden) { if (element) element.hidden = hidden; }
236
+ function text(element, value) { if (element) element.textContent = String(value ?? ''); }
237
+ function node(tag, className, content = '') {
238
+ const element = document.createElement(tag);
239
+ if (className) element.className = className;
240
+ if (content !== '') element.textContent = String(content);
241
+ return element;
242
+ }
243
+ function formatDate(value) {
244
+ const date = new Date(value);
245
+ if (Number.isNaN(date.getTime())) return 'sem captura registrada';
246
+ return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
247
+ }
248
+ function statusClass(model) {
249
+ if (!model.health.ok || model.health.failureCount > 0) return 'is-danger';
250
+ if (model.stale || model.health.warningCount > 0) return 'is-warning';
251
+ return 'is-healthy';
252
+ }
253
+ function statusLabel(model) {
254
+ if (!model.health.ok || model.health.failureCount > 0) return 'degradado';
255
+ if (model.stale) return 'stale';
256
+ if (model.health.warningCount > 0) return 'atenção';
257
+ return 'saudável';
258
+ }
259
+
260
+ function renderMetrics(models) {
261
+ const target = byId('metrics-grid');
262
+ if (!target) return;
263
+ const healthy = models.filter((model) => model.health.ok && !model.stale).length;
264
+ const active = models.filter((model) => model.session.status === 'active').length;
265
+ const openChanges = models.reduce((sum, model) => sum + model.changes.filter((change) => change.openTasks > 0).length, 0);
266
+ const metrics = [
267
+ ['Projetos', models.length, 'snapshots registrados'],
268
+ ['Saudáveis', healthy, healthy === models.length ? 'todos os sinais verdes' : 'requer atenção'],
269
+ ['Sessões ativas', active, active === 1 ? 'uma sessão em andamento' : 'sessões em andamento'],
270
+ ['Changes abertas', openChanges, 'com tarefas pendentes'],
271
+ ];
272
+ target.replaceChildren(...metrics.map(([label, value, caption]) => {
273
+ const card = node('article', 'metric');
274
+ card.append(node('span', 'metric-label', label), node('strong', 'metric-value', value), node('span', 'metric-caption', caption));
275
+ return card;
276
+ }));
277
+ }
278
+
279
+ function renderProjectList(models, selectedId, filter) {
280
+ const list = byId('project-list');
281
+ const empty = byId('empty-state');
282
+ if (!list || !empty) return;
283
+ const visible = filterProjects(models, filter);
284
+ list.replaceChildren(...visible.map((model) => {
285
+ const item = node('button', `project-item${model.projectId === selectedId ? ' is-selected' : ''}`);
286
+ item.type = 'button';
287
+ item.dataset.projectId = model.projectId;
288
+ item.setAttribute('aria-label', `${model.projectName}, ${statusLabel(model)}`);
289
+ const dot = node('span', `project-status ${statusClass(model)}`);
290
+ dot.setAttribute('aria-hidden', 'true');
291
+ const copy = node('span');
292
+ copy.append(node('span', 'project-title', model.projectName), node('span', 'project-meta', `${model.version} · ${model.session.provider}`));
293
+ item.append(dot, copy, node('span', 'project-count', `${model.changes.length} changes`));
294
+ item.addEventListener('click', () => {
295
+ window.dispatchEvent(new CustomEvent('observer:select-project', { detail: model.projectId }));
296
+ });
297
+ return item;
298
+ }));
299
+ setHidden(empty, visible.length > 0);
300
+ if (visible.length === 0) setHidden(empty, false);
301
+ }
302
+
303
+ function renderDetail(model) {
304
+ const panel = byId('project-detail');
305
+ if (!panel) return;
306
+ if (!model) {
307
+ panel.replaceChildren(node('div', 'detail-placeholder'));
308
+ const placeholder = panel.firstElementChild;
309
+ placeholder.append(node('span', 'detail-glyph', '✦'), node('p', 'eyebrow', 'PROJECT DETAIL'), node('h2', '', 'Selecione um projeto'), node('p', '', 'Saúde, sessão e changes aparecem aqui quando você escolher um ponto da constelação.'));
310
+ return;
311
+ }
312
+ const header = node('div', 'detail-header');
313
+ const title = node('div');
314
+ title.append(node('p', 'eyebrow', 'PROJECT DETAIL'), node('h2', '', model.projectName));
315
+ const badge = node('span', `health-badge ${statusClass(model)}`, statusLabel(model));
316
+ header.append(title, badge);
317
+ const facts = node('div', 'detail-facts');
318
+ facts.append(
319
+ fact('Versão', model.version),
320
+ fact('Sessão', `${model.session.status} · ${model.session.provider}`),
321
+ fact('Último snapshot', formatDate(model.capturedAt)),
322
+ );
323
+ const changeHeading = node('div', 'change-heading');
324
+ changeHeading.append(node('h3', '', 'Changes e tarefas'), node('span', '', `${model.changes.length} registradas`));
325
+ const changeList = node('div', 'change-list');
326
+ if (model.changes.length === 0) {
327
+ changeList.append(node('p', 'muted', 'Nenhuma change publicada neste snapshot.'));
328
+ } else {
329
+ for (const change of model.changes) {
330
+ const item = node('div', 'change-item');
331
+ const indicator = node('span', `change-indicator${change.current ? ' is-current' : ''}`);
332
+ indicator.setAttribute('aria-hidden', 'true');
333
+ const copy = node('span');
334
+ copy.append(node('span', 'change-name', change.slug));
335
+ if (change.warning) copy.append(node('span', 'change-warning', change.warning));
336
+ item.append(indicator, copy, node('span', 'change-tasks', `${change.openTasks} abertas · ${change.doneTasks} feitas`));
337
+ changeList.append(item);
338
+ }
339
+ }
340
+ const openWorkspace = node('a', 'workspace-open', 'Abrir workspace da memória →');
341
+ openWorkspace.href = '#project/' + encodeURIComponent(model.projectId) + '/overview';
342
+ panel.replaceChildren(header, facts, openWorkspace, changeHeading, changeList);
343
+ if (model.stale) panel.append(node('p', 'stale-note', '○ Snapshot desatualizado — aguardando novo evento do hook.'));
344
+ }
345
+
346
+ function fact(label, value) {
347
+ const item = node('div', 'fact');
348
+ item.append(node('span', 'fact-label', label), node('span', 'fact-value', value));
349
+ return item;
350
+ }
351
+
352
+ function escapeHtml(value) {
353
+ return String(value ?? '')
354
+ .replaceAll('&', '&')
355
+ .replaceAll('<', '&lt;')
356
+ .replaceAll('>', '&gt;')
357
+ .replaceAll('"', '&quot;')
358
+ .replaceAll("'", '&#039;');
359
+ }
360
+
361
+ function markdownHtml(content) {
362
+ return String(content || '').split(/\r?\n/).map((line) => {
363
+ const escaped = escapeHtml(line);
364
+ if (line.startsWith('### ')) return '<h4>' + escaped.slice(4) + '</h4>';
365
+ if (line.startsWith('## ')) return '<h3>' + escaped.slice(3) + '</h3>';
366
+ if (line.startsWith('# ')) return '<h2>' + escaped.slice(2) + '</h2>';
367
+ if (line.startsWith('- ')) return '<li>' + escaped.slice(2) + '</li>';
368
+ if (!line.trim()) return '<div class="markdown-gap" aria-hidden="true"></div>';
369
+ return '<p>' + escaped + '</p>';
370
+ }).join('');
371
+ }
372
+
373
+ function documentHref(projectId, logicalPath) {
374
+ return '#document/' + encodeURIComponent(projectId) + '/' + encodeURIComponent(logicalPath);
375
+ }
376
+
377
+ function documentRow(projectId, document) {
378
+ const link = node('a', 'memory-document-row');
379
+ link.href = documentHref(projectId, document.logical_path);
380
+ const title = String(document.logical_path || '').split('/').at(-1) || 'Documento';
381
+ link.append(
382
+ node('span', 'memory-document-glyph', memoryCategory(document.logical_path).slice(0, 1)),
383
+ node('span', 'memory-document-copy'),
384
+ node('span', 'memory-document-size', String(Number(document.bytes || 0)) + ' B'),
385
+ );
386
+ link.querySelector('.memory-document-copy').append(
387
+ node('strong', '', title.replace(/\.[^.]+$/, '')),
388
+ node('span', 'memory-document-meta', memoryCategory(document.logical_path) + ' · revisão ' + (document.revision || 0)),
389
+ );
390
+ return link;
391
+ }
392
+
393
+ function renderDocumentRows(container, projectId, documents, emptyMessage = 'Nenhum documento encontrado.') {
394
+ if (!documents.length) {
395
+ container.replaceChildren(node('p', 'muted', emptyMessage));
396
+ return;
397
+ }
398
+ container.replaceChildren(...documents.map((document) => documentRow(projectId, document)));
399
+ }
400
+
401
+ function renderWorkspaceOverview(container, model, memory) {
402
+ const title = node('div', 'workspace-section-heading');
403
+ title.append(node('p', 'eyebrow', 'PROJECT OVERVIEW'), node('h2', '', 'Memória em um só lugar'));
404
+ const facts = node('div', 'detail-facts');
405
+ facts.append(
406
+ fact('Documentos', memory?.tree?.document_count || 0),
407
+ fact('Eventos', memory?.sync?.event_count || 0),
408
+ fact('Última sessão', model?.session?.provider || '—'),
409
+ );
410
+ const intro = node('div', 'workspace-intro');
411
+ intro.append(
412
+ node('p', '', 'O container mantém a cópia completa deste projeto. Navegue pelas sessões, notas e mudanças sem sair do Observer.'),
413
+ node('p', 'muted', 'Último evento: ' + formatDate(memory?.sync?.last_event_at)),
414
+ );
415
+ container.replaceChildren(title, facts, intro);
416
+ }
417
+
418
+ function renderSessions(container, projectId, documents) {
419
+ const heading = node('div', 'workspace-section-heading');
420
+ heading.append(node('p', 'eyebrow', 'SESSION ARCHIVE'), node('h2', '', 'Sessões'));
421
+ const filters = node('div', 'memory-filter-strip');
422
+ filters.append(node('span', 'filter-chip is-active', 'Todas'), node('span', 'filter-chip', 'Ativas'), node('span', 'filter-chip', 'Codex'), node('span', 'filter-chip', 'Claude'));
423
+ const list = node('div', 'memory-document-list');
424
+ renderDocumentRows(list, projectId, documents.filter((item) => item.logical_path.startsWith('02-Sessões/')), 'Nenhuma sessão foi sincronizada.');
425
+ container.replaceChildren(heading, filters, list);
426
+ }
427
+
428
+ function renderMemory(container, projectId, documents) {
429
+ const heading = node('div', 'workspace-section-heading');
430
+ heading.append(node('p', 'eyebrow', 'CANONICAL MEMORY'), node('h2', '', 'Memória'));
431
+ const categories = node('div', 'category-strip');
432
+ const counts = new Map();
433
+ for (const document of documents) {
434
+ const label = memoryCategory(document.logical_path);
435
+ counts.set(label, (counts.get(label) || 0) + 1);
436
+ }
437
+ for (const [label, count] of counts) categories.append(node('span', 'category-chip', label + ' ' + count));
438
+ const search = node('label', 'memory-inline-search');
439
+ search.append(node('span', 'sr-only', 'Filtrar documentos'));
440
+ const input = node('input');
441
+ input.type = 'search';
442
+ input.placeholder = 'Filtrar documentos';
443
+ search.append(input);
444
+ const list = node('div', 'memory-document-list');
445
+ renderDocumentRows(list, projectId, documents);
446
+ input.addEventListener('input', () => renderDocumentRows(list, projectId, filterMemoryDocuments(documents, input.value)));
447
+ container.replaceChildren(heading, categories, search, list);
448
+ }
449
+
450
+ function renderChanges(container, projectId, documents) {
451
+ const heading = node('div', 'workspace-section-heading');
452
+ heading.append(node('p', 'eyebrow', 'CHANGE LEDGER'), node('h2', '', 'Changes'));
453
+ const list = node('div', 'memory-document-list');
454
+ renderDocumentRows(list, projectId, documents.filter((item) => item.logical_path.startsWith('08-Mudanças/')), 'Nenhuma change foi sincronizada.');
455
+ container.replaceChildren(heading, list);
456
+ }
457
+
458
+ function renderSync(container, sync, projectId = '') {
459
+ const heading = node('div', 'workspace-section-heading');
460
+ heading.append(node('p', 'eyebrow', 'SYNC CONTROL'), node('h2', '', 'Sincronização'));
461
+ const facts = node('div', 'detail-facts');
462
+ facts.append(
463
+ fact('Modo', sync?.mode || 'indisponível'),
464
+ fact('Pendentes', sync?.pending_count || 0),
465
+ fact('Conflitos', sync?.conflict_count || 0),
466
+ );
467
+ const note = node('div', 'sync-callout', sync?.conflict_count ? 'Existem conflitos que exigem revisão.' : 'A memória local está acompanhando o container.');
468
+ const exportLink = node('a', 'workspace-open', 'Exportar cópia read-only →');
469
+ exportLink.href = '/v1/projects/' + encodeURIComponent(projectId) + '/memory/export';
470
+ exportLink.target = '_blank';
471
+ exportLink.rel = 'noopener';
472
+ container.replaceChildren(heading, facts, note, exportLink);
473
+ }
474
+
475
+ function formatNumber(value) {
476
+ return new Intl.NumberFormat('pt-BR').format(Number(value) || 0);
477
+ }
478
+
479
+ function formatUsd(value) {
480
+ return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'USD', minimumFractionDigits: 4 }).format(Number(value) || 0);
481
+ }
482
+
483
+ function usageFilter(label, name, value, type = 'text') {
484
+ const wrapper = node('label', 'usage-filter');
485
+ wrapper.append(node('span', '', label));
486
+ const input = node('input');
487
+ input.name = name;
488
+ input.type = type;
489
+ input.value = String(value || '');
490
+ wrapper.append(input);
491
+ return wrapper;
492
+ }
493
+
494
+ function usageSelect(label, name, value, options) {
495
+ const wrapper = node('label', 'usage-filter');
496
+ wrapper.append(node('span', '', label));
497
+ const select = node('select');
498
+ select.name = name;
499
+ for (const [optionValue, optionLabel] of options) {
500
+ const option = node('option', '', optionLabel);
501
+ option.value = optionValue;
502
+ option.selected = optionValue === String(value || '');
503
+ select.append(option);
504
+ }
505
+ wrapper.append(select);
506
+ return wrapper;
507
+ }
508
+
509
+ function renderUsage(container, projectId, usage, { onFiltersChanged, onTranscript } = {}) {
510
+ const view = buildUsageViewModel(usage);
511
+ const filters = usage?.filters || {};
512
+ const heading = node('div', 'workspace-section-heading');
513
+ heading.append(node('p', 'eyebrow', 'PROJECT CONSUMPTION'), node('h2', '', 'Consumo'));
514
+
515
+ const filterForm = node('form', 'usage-filters');
516
+ filterForm.append(
517
+ usageFilter('De', 'from', filters.from, 'date'),
518
+ usageFilter('Até', 'to', filters.to, 'date'),
519
+ usageSelect('Escopo', 'role', filters.role, [['', 'Principal + subagentes'], ['main', 'Principal'], ['subagent', 'Subagentes']]),
520
+ usageFilter('Agente', 'agent_id', filters.agent_id || filters.agentId),
521
+ usageFilter('Subagente', 'subagent_id', filters.subagent_id || filters.subagentId),
522
+ usageFilter('Provedor', 'provider', filters.provider),
523
+ usageFilter('Provedor do modelo', 'model_provider', filters.model_provider || filters.modelProvider),
524
+ usageFilter('Modelo', 'model', filters.model),
525
+ usageFilter('Change', 'change', filters.change || filters.changeSlug),
526
+ usageFilter('Sessão', 'session_id', filters.session_id || filters.sessionId),
527
+ node('button', 'button-primary', 'Aplicar filtros'),
528
+ );
529
+ filterForm.addEventListener('submit', (event) => {
530
+ event.preventDefault();
531
+ const form = new FormData(filterForm);
532
+ onFiltersChanged?.({
533
+ from: form.get('from') || '', to: form.get('to') || '', role: form.get('role') || '',
534
+ agent_id: form.get('agent_id') || '', subagent_id: form.get('subagent_id') || '',
535
+ provider: form.get('provider') || '', model_provider: form.get('model_provider') || '', model: form.get('model') || '',
536
+ change: form.get('change') || '', session_id: form.get('session_id') || '',
537
+ });
538
+ });
539
+
540
+ const cards = node('div', 'usage-summary-grid');
541
+ const summaryCards = [
542
+ ['Custo total', formatUsd(view.totalCost), `${formatUsd(view.mainCost)} principal · ${formatUsd(view.subagentCost)} subagentes`],
543
+ ['Tokens', formatNumber(view.tokens.total), `entrada ${formatNumber(view.tokens.input)} · saída ${formatNumber(view.tokens.output)}`],
544
+ ['Chamadas', formatNumber(view.callCount), `${formatNumber(view.sessions)} sessões · ${formatNumber(view.modelsCount)} modelos`],
545
+ ['Cobertura', view.coverage.label, `${formatNumber(view.coverage.total)} transcript(s)`],
546
+ ];
547
+ for (const [label, value, caption] of summaryCards) {
548
+ const card = node('article', 'usage-summary-card');
549
+ card.append(node('span', 'metric-label', label), node('strong', 'usage-summary-value', value), node('span', 'metric-caption', caption));
550
+ cards.append(card);
551
+ }
552
+
553
+ const content = [];
554
+ if (view.hasUnknownPricing) content.push(node('div', 'usage-warning', 'Há modelos sem tarifa conhecida. Os custos históricos registrados não foram recalculados.'));
555
+ if (view.wastedCost > 0) content.push(node('div', 'usage-callout', `Desperdício registrado em workflows interrompidos: ${formatUsd(view.wastedCost)}.`));
556
+
557
+ const tokenPanel = node('section', 'usage-panel');
558
+ tokenPanel.append(node('h3', '', 'Tokens por categoria'));
559
+ const tokenGrid = node('div', 'usage-token-grid');
560
+ for (const [label, value] of [['Entrada', view.tokens.input], ['Cache write', view.tokens.cacheWrite], ['Cache read', view.tokens.cacheRead], ['Saída', view.tokens.output], ['Reasoning', view.tokens.reasoning], ['Total', view.tokens.total]]) {
561
+ tokenGrid.append(fact(label, formatNumber(value)));
562
+ }
563
+ tokenPanel.append(tokenGrid);
564
+ content.push(tokenPanel);
565
+
566
+ const trendPanel = node('section', 'usage-panel');
567
+ trendPanel.append(node('h3', '', 'Tendência diária'));
568
+ const trend = node('div', 'usage-trend');
569
+ if (!view.trend.length) trend.append(node('p', 'muted', 'Sem atividade no período selecionado.'));
570
+ else for (const day of view.trend) {
571
+ const row = node('div', 'usage-trend-row');
572
+ row.append(node('span', '', day.date), node('span', '', `${formatNumber(day.tokens_total)} tokens`), node('strong', '', formatUsd(day.cost_usd)));
573
+ trend.append(row);
574
+ }
575
+ trendPanel.append(trend);
576
+ content.push(trendPanel);
577
+
578
+ const hierarchy = node('section', 'usage-panel usage-hierarchy');
579
+ hierarchy.append(node('h3', '', 'Agentes, modelos e subagentes'));
580
+ if (!view.agents.length) hierarchy.append(node('p', 'muted', 'Nenhum agente com consumo no período.'));
581
+ const renderAgent = (agent) => {
582
+ const details = node('details', 'usage-agent');
583
+ const summary = node('summary');
584
+ summary.append(node('strong', '', agent.agent_name || agent.agent_id), node('span', 'usage-agent-meta', `${agent.role} · ${formatNumber(agent.tokens_total)} tokens · ${formatUsd(agent.cost_usd)}`));
585
+ details.append(summary);
586
+ const models = node('div', 'usage-model-list');
587
+ for (const model of agent.models || []) {
588
+ const row = node('div', 'usage-model-row');
589
+ row.append(node('span', '', `${model.model_provider || '—'} · ${model.model || 'modelo sem nome'}`), node('span', '', `${formatNumber(model.tokens_total)} tokens`), node('strong', '', formatUsd(model.cost_usd)));
590
+ models.append(row);
591
+ }
592
+ details.append(models);
593
+ for (const child of agent.children || []) details.append(renderAgent(child));
594
+ return details;
595
+ };
596
+ for (const agent of view.agents) hierarchy.append(renderAgent(agent));
597
+ content.push(hierarchy);
598
+
599
+ const callsPanel = node('section', 'usage-panel usage-calls');
600
+ callsPanel.append(node('h3', '', 'Chamadas e transcripts'));
601
+ if (!view.calls.length) callsPanel.append(node('p', 'muted', 'Nenhuma chamada individual disponível; históricos agregados aparecem acima.'));
602
+ for (const call of view.calls) {
603
+ const details = node('details', 'usage-call');
604
+ const summary = node('summary');
605
+ summary.append(node('strong', '', call.model || 'modelo sem nome'), node('span', 'usage-agent-meta', `${call.role || 'principal'} · ${formatNumber(call.tokens?.total)} tokens · ${formatUsd(call.cost_usd)}`));
606
+ details.append(summary);
607
+ const copy = node('div', 'usage-call-copy');
608
+ copy.append(node('p', '', `Sessão: ${call.session_id || '—'} · Agente: ${call.agent_id || '—'}`));
609
+ copy.append(node('h4', '', 'Prompt'), node('pre', '', call.prompt || '—'), node('h4', '', 'Resposta'), node('pre', '', call.response || '—'));
610
+ if (call.transcript_id) {
611
+ const transcriptButton = node('button', 'reader-toggle', 'Abrir transcript completo');
612
+ const transcriptTarget = node('pre', 'usage-transcript', 'Carregando transcript…');
613
+ transcriptTarget.hidden = true;
614
+ transcriptButton.addEventListener('click', async () => {
615
+ transcriptTarget.hidden = false;
616
+ if (transcriptTarget.dataset.loaded) return;
617
+ try {
618
+ const transcript = await onTranscript?.(call.transcript_id);
619
+ transcriptTarget.textContent = transcript?.content || 'Transcript vazio.';
620
+ transcriptTarget.dataset.loaded = 'true';
621
+ } catch (error) {
622
+ transcriptTarget.textContent = error.message || 'Transcript indisponível.';
623
+ }
624
+ });
625
+ copy.append(transcriptButton, transcriptTarget);
626
+ }
627
+ details.append(copy);
628
+ callsPanel.append(details);
629
+ }
630
+ content.push(callsPanel);
631
+ container.replaceChildren(heading, filterForm, cards, ...content);
632
+ }
633
+
634
+ function renderReader(container, document) {
635
+ const heading = node('div', 'reader-heading');
636
+ heading.append(node('p', 'eyebrow', document.category), node('h2', '', document.title));
637
+ const meta = node('div', 'reader-meta');
638
+ meta.append(
639
+ node('span', '', document.logicalPath),
640
+ node('span', '', 'revisão ' + document.revision),
641
+ node('span', '', document.hash.slice(0, 12)),
642
+ );
643
+ const toggle = node('button', 'reader-toggle', 'Ver fonte');
644
+ const body = node('article', 'markdown-reader');
645
+ body.innerHTML = markdownHtml(document.content);
646
+ let source = false;
647
+ toggle.addEventListener('click', () => {
648
+ source = !source;
649
+ toggle.textContent = source ? 'Ver renderizado' : 'Ver fonte';
650
+ body.innerHTML = source ? '<pre>' + escapeHtml(document.content) + '</pre>' : markdownHtml(document.content);
651
+ });
652
+ const copy = node('button', 'reader-copy', 'Copiar conteúdo');
653
+ copy.addEventListener('click', async () => {
654
+ try { await navigator.clipboard.writeText(document.content); copy.textContent = 'Copiado'; } catch { copy.textContent = 'Copie manualmente'; }
655
+ });
656
+ const actions = node('div', 'reader-actions');
657
+ actions.append(toggle, copy);
658
+ container.replaceChildren(heading, meta, actions, body);
659
+ }
660
+
661
+ function startDashboardV2() {
662
+ const dashboardPanel = byId('dashboard-panel');
663
+ if (!dashboardPanel) return;
664
+ const workspacePanel = byId('workspace-panel');
665
+ const workspaceContent = byId('workspace-content');
666
+ const state = {
667
+ models: [],
668
+ selectedId: '',
669
+ filter: '',
670
+ memory: new Map(),
671
+ usage: new Map(),
672
+ usageFilters: new Map(),
673
+ route: parseObserverRoute(globalThis.location?.hash || ''),
674
+ };
675
+ const dashboardError = byId('dashboard-error');
676
+ const connectionDot = byId('connection-dot');
677
+ const connectionLabel = byId('connection-label');
678
+ const fetchJson = (...args) => globalThis.fetch(...args);
679
+ const setConnection = (kind, label) => {
680
+ connectionDot?.classList.remove('is-online', 'is-warning', 'is-offline');
681
+ connectionDot?.classList.add(kind);
682
+ text(connectionLabel, label);
683
+ };
684
+ const render = () => {
685
+ setHidden(dashboardPanel, false);
686
+ renderMetrics(state.models);
687
+ renderProjectList(state.models, state.selectedId, state.filter);
688
+ renderDetail(state.models.find((model) => model.projectId === state.selectedId));
689
+ text(byId('last-sync'), 'Última leitura local · ' + new Intl.DateTimeFormat('pt-BR', { timeStyle: 'short' }).format(new Date()));
690
+ };
691
+ const setWorkspaceHeader = (model, memory, route) => {
692
+ text(byId('workspace-title'), route.kind === 'search' ? 'Busca na memória' : model?.projectName || 'Projeto');
693
+ text(
694
+ byId('workspace-subtitle'),
695
+ route.kind === 'search'
696
+ ? 'Resultados completos no container local.'
697
+ : (model?.projectId || '') + ' · memória canônica do container',
698
+ );
699
+ const sync = memory?.sync || {};
700
+ const badge = byId('workspace-sync-badge');
701
+ if (badge) {
702
+ badge.className = 'sync-badge';
703
+ if (Number(sync.conflict_count || 0) > 0) badge.classList.add('is-warning');
704
+ else if (sync.mode === 'container-authority') badge.classList.add('is-online');
705
+ text(badge, sync.mode || 'sem sincronização');
706
+ }
707
+ const back = byId('workspace-back');
708
+ if (back) back.href = '#overview';
709
+ const meta = byId('workspace-meta');
710
+ if (meta) {
711
+ const tree = memory?.tree || {};
712
+ meta.replaceChildren(
713
+ fact('Documentos', tree.document_count || sync.document_count || 0),
714
+ fact('Eventos', sync.event_count || 0),
715
+ fact('Conflitos', sync.conflict_count || 0),
716
+ );
717
+ }
718
+ document.querySelectorAll('[data-workspace-section]').forEach((link) => {
719
+ const section = link.dataset.workspaceSection;
720
+ link.classList.toggle('is-active', route.kind === 'project' && route.section === section);
721
+ if (model) link.href = '#project/' + encodeURIComponent(model.projectId) + '/' + section;
722
+ });
723
+ };
724
+ const showWorkspaceError = (message) => {
725
+ if (!workspaceContent) return;
726
+ workspaceContent.replaceChildren(node('div', 'workspace-error', message || 'Não foi possível carregar a memória.'));
727
+ };
728
+ const ensureProjectMemory = async (projectId) => {
729
+ if (!state.memory.has(projectId)) state.memory.set(projectId, await loadProjectMemory(fetchJson, projectId));
730
+ return state.memory.get(projectId);
731
+ };
732
+ const ensureProjectUsage = async (projectId) => {
733
+ const filters = state.usageFilters.get(projectId) || {};
734
+ const usage = await loadProjectUsage(fetchJson, projectId, filters);
735
+ state.usage.set(projectId, usage);
736
+ return usage;
737
+ };
738
+ const renderSearchRoute = async (query) => {
739
+ const model = state.models.find((item) => item.projectId === state.selectedId) || state.models[0];
740
+ setHidden(dashboardPanel, true);
741
+ setHidden(workspacePanel, false);
742
+ if (!model) {
743
+ showWorkspaceError('Nenhum projeto registrado para pesquisar.');
744
+ return;
745
+ }
746
+ state.selectedId = model.projectId;
747
+ try {
748
+ const memory = await ensureProjectMemory(model.projectId);
749
+ setWorkspaceHeader(model, memory, { kind: 'search' });
750
+ const heading = node('div', 'workspace-section-heading');
751
+ heading.append(
752
+ node('p', 'eyebrow', 'MEMORY SEARCH'),
753
+ node('h2', '', query ? 'Resultados para “' + query + '”' : 'Buscar na memória'),
754
+ );
755
+ const results = query ? (await searchProjectMemory(fetchJson, model.projectId, query)).results || [] : [];
756
+ const list = node('div', 'memory-document-list');
757
+ renderDocumentRows(list, model.projectId, results, query ? 'Nenhum documento contém esse termo.' : 'Digite um termo para pesquisar.');
758
+ workspaceContent?.replaceChildren(heading, list);
759
+ } catch (error) {
760
+ showWorkspaceError(error.message);
761
+ }
762
+ };
763
+ const renderWorkspaceRoute = async (route) => {
764
+ const model = state.models.find((item) => item.projectId === route.projectId);
765
+ if (!model) {
766
+ setHidden(dashboardPanel, false);
767
+ setHidden(workspacePanel, true);
768
+ return;
769
+ }
770
+ state.selectedId = model.projectId;
771
+ setHidden(dashboardPanel, true);
772
+ setHidden(workspacePanel, false);
773
+ if (workspaceContent) workspaceContent.replaceChildren(node('p', 'muted', 'Carregando memória do container…'));
774
+ try {
775
+ const memory = await ensureProjectMemory(model.projectId);
776
+ setWorkspaceHeader(model, memory, route);
777
+ const documents = memory.tree?.documents || [];
778
+ if (route.kind === 'document') {
779
+ const payload = await loadMemoryDocument(fetchJson, model.projectId, route.logicalPath);
780
+ renderReader(workspaceContent, buildMemoryDocumentViewModel(payload, payload.content));
781
+ return;
782
+ }
783
+ if (route.section === 'usage') {
784
+ const usage = await ensureProjectUsage(model.projectId);
785
+ renderUsage(workspaceContent, model.projectId, usage, {
786
+ onFiltersChanged: (filters) => {
787
+ state.usageFilters.set(model.projectId, filters);
788
+ renderWorkspaceRoute({ ...route });
789
+ },
790
+ onTranscript: (transcriptId) => loadProjectTranscript(fetchJson, model.projectId, transcriptId),
791
+ });
792
+ } else if (route.section === 'sessions') renderSessions(workspaceContent, model.projectId, documents);
793
+ else if (route.section === 'memory') renderMemory(workspaceContent, model.projectId, documents);
794
+ else if (route.section === 'changes') renderChanges(workspaceContent, model.projectId, documents);
795
+ else if (route.section === 'sync') renderSync(workspaceContent, memory.sync, model.projectId);
796
+ else renderWorkspaceOverview(workspaceContent, model, memory);
797
+ } catch (error) {
798
+ showWorkspaceError(error.message);
799
+ }
800
+ };
801
+ const renderRoute = async () => {
802
+ state.route = parseObserverRoute(globalThis.location?.hash || '');
803
+ if (state.route.kind === 'overview') {
804
+ setHidden(dashboardPanel, false);
805
+ setHidden(workspacePanel, true);
806
+ render();
807
+ return;
808
+ }
809
+ if (state.route.kind === 'search') {
810
+ await renderSearchRoute(state.route.query);
811
+ return;
812
+ }
813
+ await renderWorkspaceRoute(state.route);
814
+ };
815
+ const refresh = async () => {
816
+ setConnection('is-warning', 'Sincronizando');
817
+ try {
818
+ const models = await loadDashboardData(fetchJson);
819
+ state.models = models;
820
+ if (!state.selectedId || !models.some((model) => model.projectId === state.selectedId)) state.selectedId = models[0]?.projectId || '';
821
+ setHidden(dashboardError, true);
822
+ render();
823
+ setConnection('is-online', 'Observer online');
824
+ await renderRoute();
825
+ } catch (error) {
826
+ const failure = classifyRefreshError(error, state.models.length > 0);
827
+ setHidden(dashboardError, false);
828
+ text(dashboardError, failure.message);
829
+ setConnection('is-offline', 'Conexão degradada');
830
+ render();
831
+ }
832
+ };
833
+ byId('refresh-button')?.addEventListener('click', refresh);
834
+ byId('project-filter')?.addEventListener('input', (event) => {
835
+ state.filter = event.target.value;
836
+ renderProjectList(state.models, state.selectedId, state.filter);
837
+ });
838
+ byId('global-search-form')?.addEventListener('submit', (event) => {
839
+ event.preventDefault();
840
+ const query = byId('global-search')?.value?.trim() || '';
841
+ globalThis.location.hash = '#search?q=' + encodeURIComponent(query);
842
+ });
843
+ byId('global-search')?.addEventListener('keydown', (event) => {
844
+ if (event.key !== 'Enter') return;
845
+ event.preventDefault();
846
+ const query = event.currentTarget.value.trim();
847
+ globalThis.location.hash = '#search?q=' + encodeURIComponent(query);
848
+ });
849
+ window.addEventListener('observer:select-project', (event) => {
850
+ state.selectedId = event.detail;
851
+ globalThis.location.hash = '#project/' + encodeURIComponent(state.selectedId) + '/overview';
852
+ });
853
+ window.addEventListener('hashchange', () => { renderRoute(); });
854
+ globalThis.setInterval(refresh, REFRESH_INTERVAL_MS);
855
+ refresh();
856
+ }
857
+
858
+ if (typeof document !== 'undefined') startDashboardV2();