wendkeep 0.69.0 → 0.71.1

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,611 @@
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 async function loadMemoryDocument(fetchImpl = globalThis.fetch, projectId = '', logicalPath = '') {
100
+ const query = new URLSearchParams({ path: logicalPath });
101
+ return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/document?' + query.toString());
102
+ }
103
+
104
+ export async function searchProjectMemory(fetchImpl = globalThis.fetch, projectId = '', query = '') {
105
+ const params = new URLSearchParams({ q: query });
106
+ return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/search?' + params.toString());
107
+ }
108
+
109
+ export async function loadDashboardData(fetchImpl = globalThis.fetch) {
110
+ const index = await requestJson(fetchImpl, '/v1/projects');
111
+ const projects = Array.isArray(index?.projects) ? index.projects : [];
112
+ return Promise.all(projects.map(async (summary) => {
113
+ const detail = await requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(summary.projectId)}`);
114
+ return buildProjectViewModel(summary, detail, new Date());
115
+ }));
116
+ }
117
+
118
+ export function buildProjectViewModel(summary = {}, detail = {}, now = new Date()) {
119
+ const snapshot = detail.snapshot || {};
120
+ const session = snapshot.session || {};
121
+ const health = snapshot.health || {};
122
+ const changes = Array.isArray(snapshot.changes) ? snapshot.changes : [];
123
+ return {
124
+ projectId: String(summary.projectId || detail.projectId || snapshot.project_id || ''),
125
+ projectName: String(summary.projectName || detail.projectName || snapshot.project_name || 'Projeto sem nome'),
126
+ version: String(snapshot.wendkeep_version || summary.wendkeepVersion || '—'),
127
+ eventCount: Number(detail.eventCount || summary.eventCount || 0),
128
+ capturedAt: String(snapshot.captured_at || detail.capturedAt || ''),
129
+ stale: isSnapshotStale(snapshot.captured_at || detail.capturedAt, now),
130
+ session: {
131
+ status: String(session.status || 'inactive'),
132
+ provider: String(session.provider || '—'),
133
+ changeSlug: String(session.change_slug || '—'),
134
+ lastSeen: String(session.last_seen || '—'),
135
+ },
136
+ health: {
137
+ ok: health.ok === true,
138
+ status: String(health.status || 'unavailable'),
139
+ failureCount: Number(health.failure_count || 0),
140
+ warningCount: Number(health.warning_count || 0),
141
+ registrySessions: Number(health.registry_sessions || 0),
142
+ derivedNotes: Number(health.derived_notes || 0),
143
+ },
144
+ changes: changes.map((change) => ({
145
+ slug: String(change.slug || '—'),
146
+ current: change.current === true,
147
+ openTasks: Number(change.openTasks || 0),
148
+ doneTasks: Number(change.doneTasks || 0),
149
+ warning: String(change.warning || ''),
150
+ })),
151
+ };
152
+ }
153
+
154
+ export function filterProjects(models = [], filter = '') {
155
+ const query = String(filter || '').trim().toLowerCase();
156
+ if (!query) return [...models];
157
+ return models.filter((model) => [
158
+ model.projectId, model.projectName, model.version, model.session?.provider,
159
+ model.session?.changeSlug, model.health?.status, statusLabel(model),
160
+ ].join(' ').toLowerCase().includes(query));
161
+ }
162
+
163
+ function byId(id) { return document.getElementById(id); }
164
+ function setHidden(element, hidden) { if (element) element.hidden = hidden; }
165
+ function text(element, value) { if (element) element.textContent = String(value ?? ''); }
166
+ function node(tag, className, content = '') {
167
+ const element = document.createElement(tag);
168
+ if (className) element.className = className;
169
+ if (content !== '') element.textContent = String(content);
170
+ return element;
171
+ }
172
+ function formatDate(value) {
173
+ const date = new Date(value);
174
+ if (Number.isNaN(date.getTime())) return 'sem captura registrada';
175
+ return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
176
+ }
177
+ function statusClass(model) {
178
+ if (!model.health.ok || model.health.failureCount > 0) return 'is-danger';
179
+ if (model.stale || model.health.warningCount > 0) return 'is-warning';
180
+ return 'is-healthy';
181
+ }
182
+ function statusLabel(model) {
183
+ if (!model.health.ok || model.health.failureCount > 0) return 'degradado';
184
+ if (model.stale) return 'stale';
185
+ if (model.health.warningCount > 0) return 'atenção';
186
+ return 'saudável';
187
+ }
188
+
189
+ function renderMetrics(models) {
190
+ const target = byId('metrics-grid');
191
+ if (!target) return;
192
+ const healthy = models.filter((model) => model.health.ok && !model.stale).length;
193
+ const active = models.filter((model) => model.session.status === 'active').length;
194
+ const openChanges = models.reduce((sum, model) => sum + model.changes.filter((change) => change.openTasks > 0).length, 0);
195
+ const metrics = [
196
+ ['Projetos', models.length, 'snapshots registrados'],
197
+ ['Saudáveis', healthy, healthy === models.length ? 'todos os sinais verdes' : 'requer atenção'],
198
+ ['Sessões ativas', active, active === 1 ? 'uma sessão em andamento' : 'sessões em andamento'],
199
+ ['Changes abertas', openChanges, 'com tarefas pendentes'],
200
+ ];
201
+ target.replaceChildren(...metrics.map(([label, value, caption]) => {
202
+ const card = node('article', 'metric');
203
+ card.append(node('span', 'metric-label', label), node('strong', 'metric-value', value), node('span', 'metric-caption', caption));
204
+ return card;
205
+ }));
206
+ }
207
+
208
+ function renderProjectList(models, selectedId, filter) {
209
+ const list = byId('project-list');
210
+ const empty = byId('empty-state');
211
+ if (!list || !empty) return;
212
+ const visible = filterProjects(models, filter);
213
+ list.replaceChildren(...visible.map((model) => {
214
+ const item = node('button', `project-item${model.projectId === selectedId ? ' is-selected' : ''}`);
215
+ item.type = 'button';
216
+ item.dataset.projectId = model.projectId;
217
+ item.setAttribute('aria-label', `${model.projectName}, ${statusLabel(model)}`);
218
+ const dot = node('span', `project-status ${statusClass(model)}`);
219
+ dot.setAttribute('aria-hidden', 'true');
220
+ const copy = node('span');
221
+ copy.append(node('span', 'project-title', model.projectName), node('span', 'project-meta', `${model.version} · ${model.session.provider}`));
222
+ item.append(dot, copy, node('span', 'project-count', `${model.changes.length} changes`));
223
+ item.addEventListener('click', () => {
224
+ window.dispatchEvent(new CustomEvent('observer:select-project', { detail: model.projectId }));
225
+ });
226
+ return item;
227
+ }));
228
+ setHidden(empty, visible.length > 0);
229
+ if (visible.length === 0) setHidden(empty, false);
230
+ }
231
+
232
+ function renderDetail(model) {
233
+ const panel = byId('project-detail');
234
+ if (!panel) return;
235
+ if (!model) {
236
+ panel.replaceChildren(node('div', 'detail-placeholder'));
237
+ const placeholder = panel.firstElementChild;
238
+ 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.'));
239
+ return;
240
+ }
241
+ const header = node('div', 'detail-header');
242
+ const title = node('div');
243
+ title.append(node('p', 'eyebrow', 'PROJECT DETAIL'), node('h2', '', model.projectName));
244
+ const badge = node('span', `health-badge ${statusClass(model)}`, statusLabel(model));
245
+ header.append(title, badge);
246
+ const facts = node('div', 'detail-facts');
247
+ facts.append(
248
+ fact('Versão', model.version),
249
+ fact('Sessão', `${model.session.status} · ${model.session.provider}`),
250
+ fact('Último snapshot', formatDate(model.capturedAt)),
251
+ );
252
+ const changeHeading = node('div', 'change-heading');
253
+ changeHeading.append(node('h3', '', 'Changes e tarefas'), node('span', '', `${model.changes.length} registradas`));
254
+ const changeList = node('div', 'change-list');
255
+ if (model.changes.length === 0) {
256
+ changeList.append(node('p', 'muted', 'Nenhuma change publicada neste snapshot.'));
257
+ } else {
258
+ for (const change of model.changes) {
259
+ const item = node('div', 'change-item');
260
+ const indicator = node('span', `change-indicator${change.current ? ' is-current' : ''}`);
261
+ indicator.setAttribute('aria-hidden', 'true');
262
+ const copy = node('span');
263
+ copy.append(node('span', 'change-name', change.slug));
264
+ if (change.warning) copy.append(node('span', 'change-warning', change.warning));
265
+ item.append(indicator, copy, node('span', 'change-tasks', `${change.openTasks} abertas · ${change.doneTasks} feitas`));
266
+ changeList.append(item);
267
+ }
268
+ }
269
+ const openWorkspace = node('a', 'workspace-open', 'Abrir workspace da memória →');
270
+ openWorkspace.href = '#project/' + encodeURIComponent(model.projectId) + '/overview';
271
+ panel.replaceChildren(header, facts, openWorkspace, changeHeading, changeList);
272
+ if (model.stale) panel.append(node('p', 'stale-note', '○ Snapshot desatualizado — aguardando novo evento do hook.'));
273
+ }
274
+
275
+ function fact(label, value) {
276
+ const item = node('div', 'fact');
277
+ item.append(node('span', 'fact-label', label), node('span', 'fact-value', value));
278
+ return item;
279
+ }
280
+
281
+ function escapeHtml(value) {
282
+ return String(value ?? '')
283
+ .replaceAll('&', '&')
284
+ .replaceAll('<', '&lt;')
285
+ .replaceAll('>', '&gt;')
286
+ .replaceAll('"', '&quot;')
287
+ .replaceAll("'", '&#039;');
288
+ }
289
+
290
+ function markdownHtml(content) {
291
+ return String(content || '').split(/\r?\n/).map((line) => {
292
+ const escaped = escapeHtml(line);
293
+ if (line.startsWith('### ')) return '<h4>' + escaped.slice(4) + '</h4>';
294
+ if (line.startsWith('## ')) return '<h3>' + escaped.slice(3) + '</h3>';
295
+ if (line.startsWith('# ')) return '<h2>' + escaped.slice(2) + '</h2>';
296
+ if (line.startsWith('- ')) return '<li>' + escaped.slice(2) + '</li>';
297
+ if (!line.trim()) return '<div class="markdown-gap" aria-hidden="true"></div>';
298
+ return '<p>' + escaped + '</p>';
299
+ }).join('');
300
+ }
301
+
302
+ function documentHref(projectId, logicalPath) {
303
+ return '#document/' + encodeURIComponent(projectId) + '/' + encodeURIComponent(logicalPath);
304
+ }
305
+
306
+ function documentRow(projectId, document) {
307
+ const link = node('a', 'memory-document-row');
308
+ link.href = documentHref(projectId, document.logical_path);
309
+ const title = String(document.logical_path || '').split('/').at(-1) || 'Documento';
310
+ link.append(
311
+ node('span', 'memory-document-glyph', memoryCategory(document.logical_path).slice(0, 1)),
312
+ node('span', 'memory-document-copy'),
313
+ node('span', 'memory-document-size', String(Number(document.bytes || 0)) + ' B'),
314
+ );
315
+ link.querySelector('.memory-document-copy').append(
316
+ node('strong', '', title.replace(/\.[^.]+$/, '')),
317
+ node('span', 'memory-document-meta', memoryCategory(document.logical_path) + ' · revisão ' + (document.revision || 0)),
318
+ );
319
+ return link;
320
+ }
321
+
322
+ function renderDocumentRows(container, projectId, documents, emptyMessage = 'Nenhum documento encontrado.') {
323
+ if (!documents.length) {
324
+ container.replaceChildren(node('p', 'muted', emptyMessage));
325
+ return;
326
+ }
327
+ container.replaceChildren(...documents.map((document) => documentRow(projectId, document)));
328
+ }
329
+
330
+ function renderWorkspaceOverview(container, model, memory) {
331
+ const title = node('div', 'workspace-section-heading');
332
+ title.append(node('p', 'eyebrow', 'PROJECT OVERVIEW'), node('h2', '', 'Memória em um só lugar'));
333
+ const facts = node('div', 'detail-facts');
334
+ facts.append(
335
+ fact('Documentos', memory?.tree?.document_count || 0),
336
+ fact('Eventos', memory?.sync?.event_count || 0),
337
+ fact('Última sessão', model?.session?.provider || '—'),
338
+ );
339
+ const intro = node('div', 'workspace-intro');
340
+ intro.append(
341
+ node('p', '', 'O container mantém a cópia completa deste projeto. Navegue pelas sessões, notas e mudanças sem sair do Observer.'),
342
+ node('p', 'muted', 'Último evento: ' + formatDate(memory?.sync?.last_event_at)),
343
+ );
344
+ container.replaceChildren(title, facts, intro);
345
+ }
346
+
347
+ function renderSessions(container, projectId, documents) {
348
+ const heading = node('div', 'workspace-section-heading');
349
+ heading.append(node('p', 'eyebrow', 'SESSION ARCHIVE'), node('h2', '', 'Sessões'));
350
+ const filters = node('div', 'memory-filter-strip');
351
+ filters.append(node('span', 'filter-chip is-active', 'Todas'), node('span', 'filter-chip', 'Ativas'), node('span', 'filter-chip', 'Codex'), node('span', 'filter-chip', 'Claude'));
352
+ const list = node('div', 'memory-document-list');
353
+ renderDocumentRows(list, projectId, documents.filter((item) => item.logical_path.startsWith('02-Sessões/')), 'Nenhuma sessão foi sincronizada.');
354
+ container.replaceChildren(heading, filters, list);
355
+ }
356
+
357
+ function renderMemory(container, projectId, documents) {
358
+ const heading = node('div', 'workspace-section-heading');
359
+ heading.append(node('p', 'eyebrow', 'CANONICAL MEMORY'), node('h2', '', 'Memória'));
360
+ const categories = node('div', 'category-strip');
361
+ const counts = new Map();
362
+ for (const document of documents) {
363
+ const label = memoryCategory(document.logical_path);
364
+ counts.set(label, (counts.get(label) || 0) + 1);
365
+ }
366
+ for (const [label, count] of counts) categories.append(node('span', 'category-chip', label + ' ' + count));
367
+ const search = node('label', 'memory-inline-search');
368
+ search.append(node('span', 'sr-only', 'Filtrar documentos'));
369
+ const input = node('input');
370
+ input.type = 'search';
371
+ input.placeholder = 'Filtrar documentos';
372
+ search.append(input);
373
+ const list = node('div', 'memory-document-list');
374
+ renderDocumentRows(list, projectId, documents);
375
+ input.addEventListener('input', () => renderDocumentRows(list, projectId, filterMemoryDocuments(documents, input.value)));
376
+ container.replaceChildren(heading, categories, search, list);
377
+ }
378
+
379
+ function renderChanges(container, projectId, documents) {
380
+ const heading = node('div', 'workspace-section-heading');
381
+ heading.append(node('p', 'eyebrow', 'CHANGE LEDGER'), node('h2', '', 'Changes'));
382
+ const list = node('div', 'memory-document-list');
383
+ renderDocumentRows(list, projectId, documents.filter((item) => item.logical_path.startsWith('08-Mudanças/')), 'Nenhuma change foi sincronizada.');
384
+ container.replaceChildren(heading, list);
385
+ }
386
+
387
+ function renderSync(container, sync, projectId = '') {
388
+ const heading = node('div', 'workspace-section-heading');
389
+ heading.append(node('p', 'eyebrow', 'SYNC CONTROL'), node('h2', '', 'Sincronização'));
390
+ const facts = node('div', 'detail-facts');
391
+ facts.append(
392
+ fact('Modo', sync?.mode || 'indisponível'),
393
+ fact('Pendentes', sync?.pending_count || 0),
394
+ fact('Conflitos', sync?.conflict_count || 0),
395
+ );
396
+ const note = node('div', 'sync-callout', sync?.conflict_count ? 'Existem conflitos que exigem revisão.' : 'A memória local está acompanhando o container.');
397
+ const exportLink = node('a', 'workspace-open', 'Exportar cópia read-only →');
398
+ exportLink.href = '/v1/projects/' + encodeURIComponent(projectId) + '/memory/export';
399
+ exportLink.target = '_blank';
400
+ exportLink.rel = 'noopener';
401
+ container.replaceChildren(heading, facts, note, exportLink);
402
+ }
403
+
404
+ function renderReader(container, document) {
405
+ const heading = node('div', 'reader-heading');
406
+ heading.append(node('p', 'eyebrow', document.category), node('h2', '', document.title));
407
+ const meta = node('div', 'reader-meta');
408
+ meta.append(
409
+ node('span', '', document.logicalPath),
410
+ node('span', '', 'revisão ' + document.revision),
411
+ node('span', '', document.hash.slice(0, 12)),
412
+ );
413
+ const toggle = node('button', 'reader-toggle', 'Ver fonte');
414
+ const body = node('article', 'markdown-reader');
415
+ body.innerHTML = markdownHtml(document.content);
416
+ let source = false;
417
+ toggle.addEventListener('click', () => {
418
+ source = !source;
419
+ toggle.textContent = source ? 'Ver renderizado' : 'Ver fonte';
420
+ body.innerHTML = source ? '<pre>' + escapeHtml(document.content) + '</pre>' : markdownHtml(document.content);
421
+ });
422
+ const copy = node('button', 'reader-copy', 'Copiar conteúdo');
423
+ copy.addEventListener('click', async () => {
424
+ try { await navigator.clipboard.writeText(document.content); copy.textContent = 'Copiado'; } catch { copy.textContent = 'Copie manualmente'; }
425
+ });
426
+ const actions = node('div', 'reader-actions');
427
+ actions.append(toggle, copy);
428
+ container.replaceChildren(heading, meta, actions, body);
429
+ }
430
+
431
+ function startDashboardV2() {
432
+ const dashboardPanel = byId('dashboard-panel');
433
+ if (!dashboardPanel) return;
434
+ const workspacePanel = byId('workspace-panel');
435
+ const workspaceContent = byId('workspace-content');
436
+ const state = {
437
+ models: [],
438
+ selectedId: '',
439
+ filter: '',
440
+ memory: new Map(),
441
+ route: parseObserverRoute(globalThis.location?.hash || ''),
442
+ };
443
+ const dashboardError = byId('dashboard-error');
444
+ const connectionDot = byId('connection-dot');
445
+ const connectionLabel = byId('connection-label');
446
+ const fetchJson = (...args) => globalThis.fetch(...args);
447
+ const setConnection = (kind, label) => {
448
+ connectionDot?.classList.remove('is-online', 'is-warning', 'is-offline');
449
+ connectionDot?.classList.add(kind);
450
+ text(connectionLabel, label);
451
+ };
452
+ const render = () => {
453
+ setHidden(dashboardPanel, false);
454
+ renderMetrics(state.models);
455
+ renderProjectList(state.models, state.selectedId, state.filter);
456
+ renderDetail(state.models.find((model) => model.projectId === state.selectedId));
457
+ text(byId('last-sync'), 'Última leitura local · ' + new Intl.DateTimeFormat('pt-BR', { timeStyle: 'short' }).format(new Date()));
458
+ };
459
+ const setWorkspaceHeader = (model, memory, route) => {
460
+ text(byId('workspace-title'), route.kind === 'search' ? 'Busca na memória' : model?.projectName || 'Projeto');
461
+ text(
462
+ byId('workspace-subtitle'),
463
+ route.kind === 'search'
464
+ ? 'Resultados completos no container local.'
465
+ : (model?.projectId || '') + ' · memória canônica do container',
466
+ );
467
+ const sync = memory?.sync || {};
468
+ const badge = byId('workspace-sync-badge');
469
+ if (badge) {
470
+ badge.className = 'sync-badge';
471
+ if (Number(sync.conflict_count || 0) > 0) badge.classList.add('is-warning');
472
+ else if (sync.mode === 'container-authority') badge.classList.add('is-online');
473
+ text(badge, sync.mode || 'sem sincronização');
474
+ }
475
+ const back = byId('workspace-back');
476
+ if (back) back.href = '#overview';
477
+ const meta = byId('workspace-meta');
478
+ if (meta) {
479
+ const tree = memory?.tree || {};
480
+ meta.replaceChildren(
481
+ fact('Documentos', tree.document_count || sync.document_count || 0),
482
+ fact('Eventos', sync.event_count || 0),
483
+ fact('Conflitos', sync.conflict_count || 0),
484
+ );
485
+ }
486
+ document.querySelectorAll('[data-workspace-section]').forEach((link) => {
487
+ const section = link.dataset.workspaceSection;
488
+ link.classList.toggle('is-active', route.kind === 'project' && route.section === section);
489
+ if (model) link.href = '#project/' + encodeURIComponent(model.projectId) + '/' + section;
490
+ });
491
+ };
492
+ const showWorkspaceError = (message) => {
493
+ if (!workspaceContent) return;
494
+ workspaceContent.replaceChildren(node('div', 'workspace-error', message || 'Não foi possível carregar a memória.'));
495
+ };
496
+ const ensureProjectMemory = async (projectId) => {
497
+ if (!state.memory.has(projectId)) state.memory.set(projectId, await loadProjectMemory(fetchJson, projectId));
498
+ return state.memory.get(projectId);
499
+ };
500
+ const renderSearchRoute = async (query) => {
501
+ const model = state.models.find((item) => item.projectId === state.selectedId) || state.models[0];
502
+ setHidden(dashboardPanel, true);
503
+ setHidden(workspacePanel, false);
504
+ if (!model) {
505
+ showWorkspaceError('Nenhum projeto registrado para pesquisar.');
506
+ return;
507
+ }
508
+ state.selectedId = model.projectId;
509
+ try {
510
+ const memory = await ensureProjectMemory(model.projectId);
511
+ setWorkspaceHeader(model, memory, { kind: 'search' });
512
+ const heading = node('div', 'workspace-section-heading');
513
+ heading.append(
514
+ node('p', 'eyebrow', 'MEMORY SEARCH'),
515
+ node('h2', '', query ? 'Resultados para “' + query + '”' : 'Buscar na memória'),
516
+ );
517
+ const results = query ? (await searchProjectMemory(fetchJson, model.projectId, query)).results || [] : [];
518
+ const list = node('div', 'memory-document-list');
519
+ renderDocumentRows(list, model.projectId, results, query ? 'Nenhum documento contém esse termo.' : 'Digite um termo para pesquisar.');
520
+ workspaceContent?.replaceChildren(heading, list);
521
+ } catch (error) {
522
+ showWorkspaceError(error.message);
523
+ }
524
+ };
525
+ const renderWorkspaceRoute = async (route) => {
526
+ const model = state.models.find((item) => item.projectId === route.projectId);
527
+ if (!model) {
528
+ setHidden(dashboardPanel, false);
529
+ setHidden(workspacePanel, true);
530
+ return;
531
+ }
532
+ state.selectedId = model.projectId;
533
+ setHidden(dashboardPanel, true);
534
+ setHidden(workspacePanel, false);
535
+ if (workspaceContent) workspaceContent.replaceChildren(node('p', 'muted', 'Carregando memória do container…'));
536
+ try {
537
+ const memory = await ensureProjectMemory(model.projectId);
538
+ setWorkspaceHeader(model, memory, route);
539
+ const documents = memory.tree?.documents || [];
540
+ if (route.kind === 'document') {
541
+ const payload = await loadMemoryDocument(fetchJson, model.projectId, route.logicalPath);
542
+ renderReader(workspaceContent, buildMemoryDocumentViewModel(payload, payload.content));
543
+ return;
544
+ }
545
+ if (route.section === 'sessions') renderSessions(workspaceContent, model.projectId, documents);
546
+ else if (route.section === 'memory') renderMemory(workspaceContent, model.projectId, documents);
547
+ else if (route.section === 'changes') renderChanges(workspaceContent, model.projectId, documents);
548
+ else if (route.section === 'sync') renderSync(workspaceContent, memory.sync, model.projectId);
549
+ else renderWorkspaceOverview(workspaceContent, model, memory);
550
+ } catch (error) {
551
+ showWorkspaceError(error.message);
552
+ }
553
+ };
554
+ const renderRoute = async () => {
555
+ state.route = parseObserverRoute(globalThis.location?.hash || '');
556
+ if (state.route.kind === 'overview') {
557
+ setHidden(dashboardPanel, false);
558
+ setHidden(workspacePanel, true);
559
+ render();
560
+ return;
561
+ }
562
+ if (state.route.kind === 'search') {
563
+ await renderSearchRoute(state.route.query);
564
+ return;
565
+ }
566
+ await renderWorkspaceRoute(state.route);
567
+ };
568
+ const refresh = async () => {
569
+ setConnection('is-warning', 'Sincronizando');
570
+ try {
571
+ const models = await loadDashboardData(fetchJson);
572
+ state.models = models;
573
+ if (!state.selectedId || !models.some((model) => model.projectId === state.selectedId)) state.selectedId = models[0]?.projectId || '';
574
+ setHidden(dashboardError, true);
575
+ render();
576
+ setConnection('is-online', 'Observer online');
577
+ await renderRoute();
578
+ } catch (error) {
579
+ const failure = classifyRefreshError(error, state.models.length > 0);
580
+ setHidden(dashboardError, false);
581
+ text(dashboardError, failure.message);
582
+ setConnection('is-offline', 'Conexão degradada');
583
+ render();
584
+ }
585
+ };
586
+ byId('refresh-button')?.addEventListener('click', refresh);
587
+ byId('project-filter')?.addEventListener('input', (event) => {
588
+ state.filter = event.target.value;
589
+ renderProjectList(state.models, state.selectedId, state.filter);
590
+ });
591
+ byId('global-search-form')?.addEventListener('submit', (event) => {
592
+ event.preventDefault();
593
+ const query = byId('global-search')?.value?.trim() || '';
594
+ globalThis.location.hash = '#search?q=' + encodeURIComponent(query);
595
+ });
596
+ byId('global-search')?.addEventListener('keydown', (event) => {
597
+ if (event.key !== 'Enter') return;
598
+ event.preventDefault();
599
+ const query = event.currentTarget.value.trim();
600
+ globalThis.location.hash = '#search?q=' + encodeURIComponent(query);
601
+ });
602
+ window.addEventListener('observer:select-project', (event) => {
603
+ state.selectedId = event.detail;
604
+ globalThis.location.hash = '#project/' + encodeURIComponent(state.selectedId) + '/overview';
605
+ });
606
+ window.addEventListener('hashchange', () => { renderRoute(); });
607
+ globalThis.setInterval(refresh, REFRESH_INTERVAL_MS);
608
+ refresh();
609
+ }
610
+
611
+ if (typeof document !== 'undefined') startDashboardV2();
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="WendKeep Observer">
2
+ <rect width="64" height="64" rx="16" fill="#10252a"/>
3
+ <path d="M16 17h8l8 23 8-23h8L36 49h-8z" fill="#a7f3d0"/>
4
+ <circle cx="49" cy="17" r="5" fill="#f5b85b"/>
5
+ </svg>