wendkeep 0.71.1 → 0.72.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.
package/src/observer.mjs CHANGED
@@ -2,18 +2,22 @@ import { homedir } from 'node:os';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
  import { appendObserverEvent, readObserverIndex, registerObserverProject } from './observer-store.mjs';
4
4
  import { buildProjectSnapshot } from './observer-snapshot.mjs';
5
- import { compareMemoryParity, publishObserverMemory } from './observer-memory-publish.mjs';
5
+ import { compareMemoryParity } from './observer-memory-publish.mjs';
6
+ import { publishObserverSql } from './observer-sql-publish.mjs';
6
7
  import { startObserverServer } from './observer-server.mjs';
8
+ import { ensureObserverDatabase, migrateObserverDatabase, listSqlProjects, OBSERVER_SQL_FILE, OBSERVER_SQL_SCHEMA_VERSION } from './observer-sql-store.mjs';
7
9
  import { resolveProjectVault } from '../packages/vault/src/project-vault.mjs';
10
+ import { observerAuthHeaders, resolveObserverToken } from './observer-auth.mjs';
8
11
 
9
12
  export const OBSERVER_HELP = `wendkeep observer — Observer local multi-projeto
10
13
 
11
14
  Uso:
12
15
  wendkeep observer serve [--data-dir P] [--host 127.0.0.1] [--port 8787]
13
- [--allow-non-loopback]
16
+ [--allow-non-loopback] [--token TOKEN]
14
17
  wendkeep observer register --project P [--vault V] [--data-dir D] [--json]
15
18
  wendkeep observer publish --project P [--vault V] [--data-dir D] [--json]
16
- wendkeep observer memory import --project P [--vault V] [--url U] [--json]
19
+ wendkeep observer memory import --project P [--vault V] [--url U] [--token TOKEN]
20
+ [--capture-level metadata|messages|full-transcript] [--json]
17
21
  wendkeep observer status [--data-dir D] [--json]
18
22
 
19
23
  O Observer local pode manter snapshots operacionais e uma cópia completa da memória em volume
@@ -54,6 +58,21 @@ function summary(index) {
54
58
  };
55
59
  }
56
60
 
61
+ function databaseSummary(dir) {
62
+ const db = ensureObserverDatabase(dir);
63
+ try {
64
+ const migrations = migrateObserverDatabase(db);
65
+ return {
66
+ engine: 'sqlite',
67
+ file: OBSERVER_SQL_FILE,
68
+ schema_version: OBSERVER_SQL_SCHEMA_VERSION,
69
+ migrations: migrations.applied.length,
70
+ projects: listSqlProjects(db).length,
71
+ ready: true,
72
+ };
73
+ } finally { db.close(); }
74
+ }
75
+
57
76
  export async function runObserver(argv = [], { write = (chunk) => process.stdout.write(chunk) } = {}) {
58
77
  const [sub] = argv;
59
78
  const asJson = argv.includes('--json');
@@ -62,9 +81,10 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
62
81
  return 0;
63
82
  }
64
83
  const dir = dataDir(argv);
84
+ const token = resolveObserverToken(optionValue(argv, '--token'));
65
85
 
66
86
  if (sub === 'status') {
67
- print(summary(readObserverIndex(dir)), asJson, write);
87
+ print({ ...summary(readObserverIndex(dir)), database: databaseSummary(dir) }, asJson, write);
68
88
  return 0;
69
89
  }
70
90
 
@@ -75,6 +95,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
75
95
  host,
76
96
  port: Number(optionValue(argv, '--port') || 8787),
77
97
  allowNonLoopback: argv.includes('--allow-non-loopback'),
98
+ token,
78
99
  });
79
100
  const address = server.address();
80
101
  process.stdout.write(`wendkeep observer listening: http://${address.address}:${address.port}\n`);
@@ -89,7 +110,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
89
110
  const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
90
111
  const url = optionValue(argv, '--url') || process.env.WENDKEEP_OBSERVER_URL || '';
91
112
  if (!url) throw new Error('observer memory import: --url ou WENDKEEP_OBSERVER_URL é obrigatório.');
92
- const headers = { 'content-type': 'application/json', accept: 'application/json' };
113
+ const headers = observerAuthHeaders(token, { 'content-type': 'application/json', accept: 'application/json' });
93
114
  const registration = await fetch(
94
115
  String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(snapshot.project_id),
95
116
  {
@@ -103,20 +124,24 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
103
124
  },
104
125
  );
105
126
  if (!registration.ok) throw new Error('Observer não registrou o projeto: HTTP ' + registration.status + '.');
106
- const memory = await publishObserverMemory({
127
+ const sql = await publishObserverSql({
107
128
  vaultBase: vault,
108
129
  projectId: snapshot.project_id,
109
130
  url,
131
+ token,
132
+ captureLevel: optionValue(argv, '--capture-level') || process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
110
133
  });
111
134
  const parity = await compareMemoryParity({
112
135
  vaultBase: vault,
113
136
  projectId: snapshot.project_id,
114
137
  url,
138
+ token,
115
139
  });
116
140
  const result = {
117
- ok: memory.ok && parity.missing === 0 && parity.mismatched === 0,
141
+ ok: sql.ok && parity.missing === 0 && parity.mismatched === 0,
118
142
  project_id: snapshot.project_id,
119
- memory,
143
+ sql,
144
+ memory: { ...sql, authority: 'sqlite' },
120
145
  parity,
121
146
  };
122
147
  print(result, asJson, write);
@@ -1,5 +1,5 @@
1
1
  // Pure helper: extract a single version's release notes from a Keep-a-Changelog
2
- // file. Reused by scripts/release.mjs and .github/workflows/release.yml so the
2
+ // file. Reused by scripts/release.mjs and .github/workflows/auto-tag.yml so the
3
3
  // GitHub Release body always matches the committed CHANGELOG.
4
4
 
5
5
  const HEADER_RE = /^##\s*\[([^\]]+)\]\s*[—–-]\s*(.+?)\s*$/;
@@ -0,0 +1,68 @@
1
+ const DEPENDENCY_FIELDS = Object.freeze([
2
+ 'dependencies',
3
+ 'devDependencies',
4
+ 'optionalDependencies',
5
+ 'peerDependencies',
6
+ ]);
7
+
8
+ export function packageHasSelfDependency(pkg = {}) {
9
+ const name = String(pkg.name || '');
10
+ if (!name) return false;
11
+ return DEPENDENCY_FIELDS.some((field) => Object.hasOwn(pkg[field] || {}, name));
12
+ }
13
+
14
+ export function evaluateReleaseProvenance({
15
+ name,
16
+ version,
17
+ headCommit,
18
+ tagCommit = '',
19
+ publishedIntegrity = '',
20
+ localIntegrity = '',
21
+ requirePublished = false,
22
+ } = {}) {
23
+ const tag = `v${version}`;
24
+ if (tagCommit && tagCommit !== headCommit) {
25
+ return {
26
+ ok: false,
27
+ code: 'tag_commit_mismatch',
28
+ message: `${tag} aponta para ${tagCommit.slice(0, 7)}, não para ${headCommit.slice(0, 7)}. Bump a versão antes de alterar a árvore publicada.`,
29
+ };
30
+ }
31
+ if (publishedIntegrity && !tagCommit) {
32
+ return {
33
+ ok: false,
34
+ code: 'published_tag_missing',
35
+ message: `${name}@${version} está publicado, mas ${tag} não comprova o commit correspondente.`,
36
+ };
37
+ }
38
+ if (requirePublished && !publishedIntegrity) {
39
+ return {
40
+ ok: false,
41
+ code: 'published_artifact_missing',
42
+ message: `${name}@${version} ainda não possui integridade consultável no npm.`,
43
+ };
44
+ }
45
+ if (publishedIntegrity && localIntegrity && publishedIntegrity !== localIntegrity) {
46
+ return {
47
+ ok: false,
48
+ code: 'tarball_integrity_mismatch',
49
+ message: `o tarball de ${tag} diverge do artefato publicado no npm.`,
50
+ };
51
+ }
52
+ if (publishedIntegrity && !localIntegrity) {
53
+ return {
54
+ ok: false,
55
+ code: 'local_integrity_missing',
56
+ message: `não foi possível calcular a integridade do tarball de ${tag}.`,
57
+ };
58
+ }
59
+ return {
60
+ ok: true,
61
+ code: publishedIntegrity ? 'verified' : 'release_candidate',
62
+ name,
63
+ version,
64
+ tag,
65
+ commit: headCommit,
66
+ integrity: publishedIntegrity || localIntegrity || '',
67
+ };
68
+ }
package/src/taxonomy.mjs CHANGED
@@ -131,7 +131,7 @@ export const RUNNABLE_HOOKS = [
131
131
  // the MOST agent-agnostic mechanism it supports; the Claude Code plugin entry
132
132
  // (extraKnownMarketplaces + enabledPlugins) is an additive bonus, never the base.
133
133
  // - mcp: .mcp.json server entry (works on any MCP-capable agent)
134
- // - wendkeepHook: a wendkeep-authored hook (runs via `npx wendkeep hook`, any agent)
134
+ // - wendkeepHook: a wendkeep-authored hook (runs via `npx --no-install wendkeep hook`, any agent)
135
135
  // - installer: cross-agent install script (used on non-Claude agents)
136
136
  // marketplace/plugin shapes are verified against a real ~/.claude/settings.json.
137
137
  export const COMPANIONS = [
@@ -52,7 +52,7 @@ export function renderVaultReadme({ projectName, vaultPath, withMcp = true, loca
52
52
  const mcpIntro = withMcp ? ', and read/written by the **MCPVault** MCP server' : '';
53
53
  const access = [`- **Obsidian:** open this folder with "Open folder as vault" → \`${vaultPath}\``];
54
54
  if (withMcp) access.push('- **Agent (MCP):** the `wendkeep-vault` server (MCPVault) points at this vault (set in `.mcp.json`), giving the agent read/write on the notes.');
55
- access.push('- **Hooks:** Codex and Claude Code call `npx wendkeep hook <name>`; the vault is discovered from the project-local `.wendkeep.json` binding and checked against `.brain/PROJECT.json`.');
55
+ access.push('- **Hooks:** Codex and Claude Code call `npx --no-install wendkeep hook <name>`; the vault is discovered from the project-local `.wendkeep.json` binding and checked against `.brain/PROJECT.json`.');
56
56
  return `# Obsidian vault — ${name}
57
57
 
58
58
  > Knowledge base of **${name}**, captured automatically by wendkeep from AI coding-agent
@@ -84,7 +84,7 @@ ${access.join('\n')}
84
84
  const mcpIntro = withMcp ? ', e lida/escrita pelo MCP server **MCPVault**' : '';
85
85
  const access = [`- **Obsidian:** abra esta pasta com "Open folder as vault" → \`${vaultPath}\``];
86
86
  if (withMcp) access.push('- **Agente (MCP):** o servidor `wendkeep-vault` (MCPVault) é apontado para este vault pelo `wendkeep init` (em `.mcp.json`), dando ao agente leitura/escrita das notas.');
87
- access.push('- **Hooks:** Codex e Claude Code chamam `npx wendkeep hook <name>`; o vault é descoberto pelo vínculo local `.wendkeep.json` e validado contra `.brain/PROJECT.json`.');
87
+ access.push('- **Hooks:** Codex e Claude Code chamam `npx --no-install wendkeep hook <name>`; o vault é descoberto pelo vínculo local `.wendkeep.json` e validado contra `.brain/PROJECT.json`.');
88
88
  return `# Vault Obsidian — ${name}
89
89
 
90
90
  > Base de conhecimento de **${name}**, capturada automaticamente pelo wendkeep a
@@ -96,11 +96,82 @@ export async function loadProjectMemory(fetchImpl = globalThis.fetch, projectId
96
96
  return { tree, sync };
97
97
  }
98
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
+
99
166
  export async function loadMemoryDocument(fetchImpl = globalThis.fetch, projectId = '', logicalPath = '') {
100
167
  const query = new URLSearchParams({ path: logicalPath });
101
168
  return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/document?' + query.toString());
102
169
  }
103
170
 
171
+ export async function loadProjectTranscript(fetchImpl = globalThis.fetch, projectId = '', transcriptId = '') {
172
+ return requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(projectId)}/transcripts/${encodeURIComponent(transcriptId)}`);
173
+ }
174
+
104
175
  export async function searchProjectMemory(fetchImpl = globalThis.fetch, projectId = '', query = '') {
105
176
  const params = new URLSearchParams({ q: query });
106
177
  return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/search?' + params.toString());
@@ -401,6 +472,165 @@ function renderSync(container, sync, projectId = '') {
401
472
  container.replaceChildren(heading, facts, note, exportLink);
402
473
  }
403
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
+
404
634
  function renderReader(container, document) {
405
635
  const heading = node('div', 'reader-heading');
406
636
  heading.append(node('p', 'eyebrow', document.category), node('h2', '', document.title));
@@ -438,6 +668,8 @@ function startDashboardV2() {
438
668
  selectedId: '',
439
669
  filter: '',
440
670
  memory: new Map(),
671
+ usage: new Map(),
672
+ usageFilters: new Map(),
441
673
  route: parseObserverRoute(globalThis.location?.hash || ''),
442
674
  };
443
675
  const dashboardError = byId('dashboard-error');
@@ -497,6 +729,12 @@ function startDashboardV2() {
497
729
  if (!state.memory.has(projectId)) state.memory.set(projectId, await loadProjectMemory(fetchJson, projectId));
498
730
  return state.memory.get(projectId);
499
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
+ };
500
738
  const renderSearchRoute = async (query) => {
501
739
  const model = state.models.find((item) => item.projectId === state.selectedId) || state.models[0];
502
740
  setHidden(dashboardPanel, true);
@@ -542,7 +780,16 @@ function startDashboardV2() {
542
780
  renderReader(workspaceContent, buildMemoryDocumentViewModel(payload, payload.content));
543
781
  return;
544
782
  }
545
- if (route.section === 'sessions') renderSessions(workspaceContent, model.projectId, documents);
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);
546
793
  else if (route.section === 'memory') renderMemory(workspaceContent, model.projectId, documents);
547
794
  else if (route.section === 'changes') renderChanges(workspaceContent, model.projectId, documents);
548
795
  else if (route.section === 'sync') renderSync(workspaceContent, memory.sync, model.projectId);
@@ -96,6 +96,7 @@
96
96
  <aside class="workspace-rail panel" aria-label="Navegação do projeto">
97
97
  <nav id="workspace-nav" class="workspace-nav">
98
98
  <a data-workspace-section="overview" href="#project/project-a/overview">Overview</a>
99
+ <a data-workspace-section="usage" href="#project/project-a/usage">Consumo</a>
99
100
  <a data-workspace-section="sessions" href="#project/project-a/sessions">Sessões</a>
100
101
  <a data-workspace-section="memory" href="#project/project-a/memory">Memória</a>
101
102
  <a data-workspace-section="changes" href="#project/project-a/changes">Changes</a>
@@ -183,6 +183,33 @@ input::placeholder { color: var(--faint); }
183
183
  .markdown-gap { height: .35em; }
184
184
  .sync-callout, .workspace-error { margin-top: 24px; padding: 17px; border: 1px solid rgba(156, 224, 198, .2); border-radius: var(--radius-sm); color: var(--muted); background: rgba(156, 224, 198, .05); font-size: .8rem; line-height: 1.5; }
185
185
  .workspace-error { border-color: rgba(245, 139, 131, .28); color: var(--red); background: rgba(245, 139, 131, .07); }
186
+ .usage-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 9px; margin: 20px 0; padding: 13px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: rgba(7, 16, 21, .28); }
187
+ .usage-filter { display: grid; gap: 5px; min-width: 112px; color: var(--faint); font-size: .61rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
188
+ .usage-filter input, .usage-filter select { min-width: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 6px; color: var(--text); background: rgba(7, 16, 21, .65); font-size: .73rem; text-transform: none; }
189
+ .button-primary { padding: 9px 12px; border: 1px solid rgba(156, 224, 198, .3); border-radius: 7px; color: var(--ink-950); background: var(--mint); font-size: .7rem; font-weight: 700; }
190
+ .button-primary:hover { background: var(--mint-bright); }
191
+ .usage-summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px; margin-bottom: 14px; }
192
+ .usage-summary-card { min-width: 0; padding: 15px; border: 1px solid var(--line); border-radius: var(--radius-md); background: rgba(20, 43, 50, .45); }
193
+ .usage-summary-value { display: block; overflow: hidden; margin: 10px 0 5px; color: var(--mint-bright); font-family: Georgia, serif; font-size: clamp(1.15rem, 2vw, 1.75rem); font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
194
+ .usage-warning { margin-bottom: 12px; padding: 13px 15px; border: 1px solid rgba(246, 189, 114, .3); border-radius: var(--radius-sm); color: var(--amber); background: rgba(246, 189, 114, .07); font-size: .76rem; line-height: 1.45; }
195
+ .usage-panel { margin-top: 14px; padding: 17px; border: 1px solid var(--line); border-radius: var(--radius-md); background: rgba(7, 16, 21, .25); }
196
+ .usage-panel h3 { margin: 0 0 13px; color: var(--mint-bright); font-size: .82rem; letter-spacing: .06em; text-transform: uppercase; }
197
+ .usage-token-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 7px; }
198
+ .usage-token-grid .fact { min-width: 0; padding: 10px; }
199
+ .usage-trend { display: grid; gap: 6px; }
200
+ .usage-trend-row, .usage-model-row { display: grid; grid-template-columns: minmax(100px, 1fr) auto auto; gap: 12px; align-items: center; padding: 9px 11px; border-radius: 7px; color: var(--muted); background: rgba(20, 43, 50, .38); font-size: .72rem; }
201
+ .usage-trend-row strong, .usage-model-row strong { color: var(--mint); font-weight: 600; }
202
+ .usage-agent { border-top: 1px solid var(--line); }
203
+ .usage-agent:first-of-type { border-top: 0; }
204
+ .usage-agent summary { display: flex; justify-content: space-between; gap: 12px; padding: 12px 2px; cursor: pointer; color: var(--text); font-size: .78rem; }
205
+ .usage-agent-meta { color: var(--muted); font-size: .68rem; font-weight: 400; }
206
+ .usage-model-list { display: grid; gap: 6px; padding: 0 0 12px 17px; }
207
+ .usage-call { border-top: 1px solid var(--line); }
208
+ .usage-call:first-of-type { border-top: 0; }
209
+ .usage-call summary { display: flex; justify-content: space-between; gap: 12px; padding: 11px 2px; cursor: pointer; font-size: .76rem; }
210
+ .usage-call-copy { padding: 0 0 15px 17px; color: var(--muted); font-size: .72rem; }
211
+ .usage-call-copy h4 { margin: 13px 0 6px; color: var(--faint); font-size: .63rem; letter-spacing: .1em; text-transform: uppercase; }
212
+ .usage-call-copy pre, .usage-transcript { max-height: 260px; overflow: auto; margin: 6px 0; padding: 11px; border: 1px solid var(--line); border-radius: 6px; color: var(--mint-bright); background: rgba(7, 16, 21, .62); font-family: "Cascadia Code", "Consolas", monospace; font-size: .7rem; line-height: 1.5; white-space: pre-wrap; }
186
213
  .footer-note { display: flex; justify-content: center; gap: 8px; padding: 18px 0 28px; color: var(--faint); font-size: .7rem; }
187
214
  .footer-separator { color: var(--line-strong); }
188
215
  .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@@ -196,7 +223,7 @@ input::placeholder { color: var(--faint); }
196
223
  .detail-panel { min-height: 420px; }
197
224
  .workspace-layout { grid-template-columns: 1fr; }
198
225
  .workspace-rail { order: 0; }
199
- .workspace-nav { grid-template-columns: repeat(5, 1fr); }
226
+ .workspace-nav { grid-template-columns: repeat(6, 1fr); }
200
227
  .workspace-nav a { border-left: 0; border-bottom: 2px solid transparent; padding: 9px 6px; text-align: center; }
201
228
  .workspace-nav a:hover, .workspace-nav a.is-active { border-bottom-color: var(--amber); border-left-color: transparent; }
202
229
  .workspace-meta { display: none; }
@@ -212,12 +239,18 @@ input::placeholder { color: var(--faint); }
212
239
  .panel-heading { flex-direction: column; }
213
240
  .search-field, .search-field input { width: 100%; }
214
241
  .detail-facts { grid-template-columns: 1fr; }
242
+ .usage-summary-grid { grid-template-columns: repeat(2, 1fr); }
243
+ .usage-token-grid { grid-template-columns: repeat(3, 1fr); }
215
244
  .detail-header { flex-direction: column; }
216
245
  .workspace-header { grid-template-columns: 1fr auto; align-items: start; gap: 13px; }
217
246
  .workspace-header .back-link { grid-column: 1 / -1; }
218
247
  .workspace-header h1 { font-size: 2.8rem; }
219
248
  .workspace-content { padding: 20px 15px; }
220
249
  .workspace-nav { grid-template-columns: repeat(3, 1fr); }
250
+ .usage-filters { align-items: stretch; flex-direction: column; }
251
+ .usage-filter, .usage-filter input, .usage-filter select, .button-primary { width: 100%; }
252
+ .usage-summary-grid { grid-template-columns: 1fr; }
253
+ .usage-token-grid { grid-template-columns: repeat(2, 1fr); }
221
254
  .memory-document-row { grid-template-columns: 28px minmax(0, 1fr); }
222
255
  .memory-document-size { display: none; }
223
256
  .footer-note { flex-direction: column; align-items: center; gap: 4px; }