@cccarv82/freya 2.3.7 → 2.3.9

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/README.md CHANGED
@@ -2,23 +2,34 @@
2
2
 
3
3
  > **Sua Assistente de Produtividade Local-First no navegador.**
4
4
 
5
- F.R.E.Y.A. é um sistema de agentes de IA projetado para organizar seu trabalho, gerenciar status de projetos, rastrear tarefas e registrar sua evolução de carreira, tudo através de uma interface de chat simples e direta no navegador.
5
+ F.R.E.Y.A. é um sistema de agentes de IA projetado para organizar seu trabalho, gerenciar status de projetos, rastrear tarefas e registrar sua evolução de carreira, tudo através de uma interface web local no navegador.
6
6
 
7
7
  ## 🌟 Principais Recursos
8
8
 
9
+ * **Web local (apenas navegador):** Acesso 100% via UI web local, sem app desktop e sem cloud.
9
10
  * **Ingestão Universal:** Registre updates, blockers e notas mentais em linguagem natural.
10
11
  * **Gestão de Tarefas:** Crie, liste e conclua tarefas ("Lembre-me de fazer X", "Minhas tarefas", "Terminei X").
12
+ * **Timeline + Projetos:** Linha do tempo com filtros por tag, projeto e tipo, além de listagem de projetos.
13
+ * **Companion Panels:** Painel rápido com qualidade de log, resumo executivo, anomalias e resumo de risco.
14
+ * **Incident Radar:** Card de incidentes com status e ação de “marcar resolvido”.
15
+ * **Task Heatmap:** Visão por projeto com prioridade, contadores e link direto para status.
16
+ * **Relatórios Automatizados:** Gere resumos semanais, dailies, relatório de Scrum Master e relatórios executivos.
11
17
  * **Oráculo:** Pergunte sobre o status de qualquer projeto ("Como está o projeto X?") e recupere logs diários ("O que anotei ontem?").
12
18
  * **Career Coach:** Gere "Brag Sheets" automáticas para suas avaliações de desempenho.
13
- * **Relatórios Automatizados:** Gere resumos semanais, dailies, relatório de Scrum Master e relatórios executivos.
14
- * **Blockers & Riscos:** Gere um relatório rápido de blockers priorizados por severidade.
19
+ * **Links Bidirecionais:** Auto-link entre notas, tarefas e projetos (compatível com Obsidian).
15
20
  * **Saúde do Sistema:** Valide a integridade dos seus dados locais com um comando.
16
21
  * **Git Automation:** Gere commits inteligentes automaticamente. A Freya analisa suas mudanças e escreve a mensagem para você.
17
22
  * **Privacidade Total:** Seus dados (JSON e Markdown) ficam 100% locais na sua máquina.
18
23
 
19
24
  ## 📦 Instalação (Web UI)
20
25
 
21
- A FREYA agora roda como um app web local. Basta iniciar o servidor e abrir o navegador.
26
+ FREYA web
27
+
28
+ A FREYA roda como um app web local. Basta iniciar o servidor e abrir o navegador.
29
+
30
+ ```bash
31
+ npx @cccarv82/freya@latest --no-open --port 3872
32
+ ```
22
33
 
23
34
  ## 🚢 Publicação no npm (maintainers)
24
35
 
package/cli/web-ui.css CHANGED
@@ -374,7 +374,7 @@ h1 { margin: 0; font-size: 22px; letter-spacing: -.02em; }
374
374
  .midSpan { grid-column: auto; }
375
375
  }
376
376
 
377
- .panel { border: 1px solid var(--line); background: var(--paper); border-radius: var(--radius); overflow: hidden; box-shadow: 0 1px 0 rgba(16,24,40,.04); }
377
+ .panel { border: 1px solid var(--line); background: var(--paper); border-radius: var(--radius); box-shadow: 0 1px 0 rgba(16,24,40,.04); }
378
378
  .panelHead { display: flex; align-items: center; justify-content: space-between; padding: 12px 12px; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, var(--paper2), var(--paper)); }
379
379
  .panelHead b { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
380
380
  .panelBody { padding: 12px; }
package/cli/web-ui.js CHANGED
@@ -665,7 +665,6 @@
665
665
  const items = Array.isArray(state.projects) ? state.projects : [];
666
666
  const filtered = items.filter((p) => {
667
667
  const hay = [p.client, p.program, p.stream, p.project, p.slug, (p.tags||[]).join(' ')].join(' ').toLowerCase();
668
- if (kind !== 'all' && String(i.kind||'') !== kind) return false;
669
668
  return !filter || hay.includes(filter);
670
669
  });
671
670
  el.innerHTML = '';
@@ -1268,6 +1267,74 @@
1268
1267
  }
1269
1268
  }
1270
1269
 
1270
+ async function refreshQualityScore() {
1271
+ const el = $('qualityScoreCard');
1272
+ if (el) el.innerHTML = '<div class="help">Carregando score...</div>';
1273
+ try {
1274
+ const r = await api('/api/quality/score', { dir: dirOrDefault() });
1275
+ if (!el) return;
1276
+ if (r && r.needsInit) {
1277
+ el.innerHTML = '<div class="help">Workspace não inicializado.</div>';
1278
+ return;
1279
+ }
1280
+ const score = (r && typeof r.score === 'number') ? r.score : null;
1281
+ const breakdown = (r && r.breakdown) ? r.breakdown : {};
1282
+ const threshold = 90;
1283
+ const status = (score !== null && score >= threshold) ? 'ok' : 'warn';
1284
+
1285
+ const line = (label, data, keyLabel) => {
1286
+ if (!data) return '';
1287
+ const pct = (typeof data.pct === 'number') ? `${data.pct}%` : 'n/a';
1288
+ const detail = keyLabel ? `${data[keyLabel] || 0}/${data.total || 0}` : `${data.total || 0}`;
1289
+ return `<div class=\"help\" style=\"margin-top:4px\"><b>${escapeHtml(label)}:</b> ${escapeHtml(pct)} (${escapeHtml(detail)})</div>`;
1290
+ };
1291
+
1292
+ const html = `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
1293
+ + `<div style=\"min-width:0\"><div style=\"font-weight:800\">${score === null ? 'Sem score' : `Score: ${score}%`}</div>`
1294
+ + `${line('Tasks com projectSlug', breakdown.tasks, 'withProjectSlug')}`
1295
+ + `${line('Status com history', breakdown.status, 'withHistory')}`
1296
+ + `${line('Blockers com projectSlug', breakdown.blockers, 'withProjectSlug')}`
1297
+ + `</div>`
1298
+ + `<div class=\"pill ${status}\">${status}</div>`
1299
+ + `</div>`;
1300
+ el.innerHTML = html;
1301
+ } catch {
1302
+ if (el) el.innerHTML = '<div class="help">Falha ao carregar score.</div>';
1303
+ }
1304
+ }
1305
+
1306
+ async function refreshRiskSummary() {
1307
+ const el = $('riskSummary');
1308
+ if (el) el.innerHTML = '<div class="help">Carregando riscos...</div>';
1309
+ try {
1310
+ const r = await api('/api/risk/summary', { dir: dirOrDefault() });
1311
+ if (!el) return;
1312
+ if (r && r.needsInit) {
1313
+ el.innerHTML = '<div class="help">Workspace não inicializado.</div>';
1314
+ return;
1315
+ }
1316
+ const items = Array.isArray(r.items) ? r.items : [];
1317
+ if (!items.length) {
1318
+ el.innerHTML = '<div class="help">Sem riscos relevantes.</div>';
1319
+ return;
1320
+ }
1321
+ const rows = items.map((it) => {
1322
+ const age = (it.oldestBlockerDays != null) ? `${it.oldestBlockerDays}d` : 'n/a';
1323
+ return `<div class=\"rep\">`
1324
+ + `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
1325
+ + `<div style=\"min-width:0\"><div style=\"font-weight:800\">${escapeHtml(it.slug || '')}</div>`
1326
+ + `<div class=\"help\" style=\"margin-top:4px\">Pendentes: ${escapeHtml(String(it.pendingTasks || 0))} · Blockers 7d+: ${escapeHtml(String(it.oldBlockers || 0))} · Mais antigo: ${escapeHtml(age)}</div>`
1327
+ + `</div>`
1328
+ + `<div class=\"pill warn\">risco</div>`
1329
+ + `</div>`
1330
+ + `</div>`;
1331
+ }).join('');
1332
+ el.innerHTML = rows;
1333
+ } catch {
1334
+ if (el) el.innerHTML = '<div class="help">Falha ao carregar riscos.</div>';
1335
+ }
1336
+ }
1337
+
1271
1338
  async function refreshExecutiveSummary() {
1272
1339
  const el = $('executiveSummary');
1273
1340
  if (el) el.textContent = 'Carregando resumo...';
@@ -1706,8 +1773,10 @@
1706
1773
 
1707
1774
  if (isCompanionPage) {
1708
1775
  await refreshHealthChecklist();
1776
+ await refreshQualityScore();
1709
1777
  await refreshExecutiveSummary();
1710
1778
  await refreshAnomalies();
1779
+ await refreshRiskSummary();
1711
1780
  await refreshIncidents();
1712
1781
  await refreshHeatmap();
1713
1782
  return;
@@ -1759,8 +1828,10 @@
1759
1828
  window.setTimelineKind = setTimelineKind;
1760
1829
  window.refreshBlockersInsights = refreshBlockersInsights;
1761
1830
  window.refreshHealthChecklist = refreshHealthChecklist;
1831
+ window.refreshQualityScore = refreshQualityScore;
1762
1832
  window.refreshExecutiveSummary = refreshExecutiveSummary;
1763
1833
  window.refreshAnomalies = refreshAnomalies;
1834
+ window.refreshRiskSummary = refreshRiskSummary;
1764
1835
  window.copyOut = copyOut;
1765
1836
  window.copyPath = copyPath;
1766
1837
  window.openSelected = openSelected;
package/cli/web.js CHANGED
@@ -1499,6 +1499,16 @@ function buildCompanionHtml(safeDefault, appVersion) {
1499
1499
 
1500
1500
  <section class="reportsGrid" id="healthChecklist"></section>
1501
1501
 
1502
+ <section class="panel" style="margin-top:16px">
1503
+ <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1504
+ <b>Qualidade de Log</b>
1505
+ <button class="btn small" type="button" onclick="refreshQualityScore()">Atualizar</button>
1506
+ </div>
1507
+ <div class="panelBody">
1508
+ <div id="qualityScoreCard"></div>
1509
+ </div>
1510
+ </section>
1511
+
1502
1512
  <section class="panel" style="margin-top:16px">
1503
1513
  <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1504
1514
  <b>Resumo Executivo</b>
@@ -1519,6 +1529,16 @@ function buildCompanionHtml(safeDefault, appVersion) {
1519
1529
  </div>
1520
1530
  </section>
1521
1531
 
1532
+ <section class="panel" style="margin-top:16px">
1533
+ <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1534
+ <b>Resumo de Risco</b>
1535
+ <button class="btn small" type="button" onclick="refreshRiskSummary()">Atualizar</button>
1536
+ </div>
1537
+ <div class="panelBody">
1538
+ <div id="riskSummary"></div>
1539
+ </div>
1540
+ </section>
1541
+
1522
1542
  <section class="panel" style="margin-top:16px">
1523
1543
  <div class="panelHead"><b>Incident Radar</b></div>
1524
1544
  <div class="panelBody">
@@ -1941,7 +1961,7 @@ async function cmdWeb({ port, dir, open, dev }) {
1941
1961
 
1942
1962
  if (req.method === 'GET' && req.url === '/timeline') {
1943
1963
  try { res.__freyaDebug.workspaceDir = normalizeWorkspaceDir(dir || './freya'); } catch {}
1944
- const body = timelineHtml(dir || './freya');
1964
+ const body = buildTimelineHtml(dir || './freya', version);
1945
1965
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
1946
1966
  res.end(body);
1947
1967
  return;
@@ -2112,6 +2132,136 @@ if (req.url === '/api/timeline') {
2112
2132
  return safeJson(res, 200, { ok: true, summary, stats: { recent: recent.length, openBlockers: openBlockers.length, ...counts } });
2113
2133
  }
2114
2134
 
2135
+ if (req.url === '/api/quality/score') {
2136
+ if (!looksLikeFreyaWorkspace(workspaceDir)) {
2137
+ return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
2138
+ }
2139
+
2140
+ const pct = (count, total) => (total > 0 ? Math.round((count / total) * 1000) / 10 : null);
2141
+
2142
+ // Tasks with projectSlug
2143
+ let tasksTotal = 0;
2144
+ let tasksWithProject = 0;
2145
+ const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2146
+ if (exists(taskFile)) {
2147
+ const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2148
+ const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2149
+ tasksTotal = tasks.length;
2150
+ for (const t of tasks) {
2151
+ const slug = String(t && t.projectSlug ? t.projectSlug : '').trim();
2152
+ if (slug) tasksWithProject++;
2153
+ }
2154
+ }
2155
+
2156
+ // Status files with history array
2157
+ let statusTotal = 0;
2158
+ let statusWithHistory = 0;
2159
+ const base = path.join(workspaceDir, 'data', 'Clients');
2160
+ if (exists(base)) {
2161
+ const stack = [base];
2162
+ while (stack.length) {
2163
+ const dirp = stack.pop();
2164
+ const entries = fs.readdirSync(dirp, { withFileTypes: true });
2165
+ for (const ent of entries) {
2166
+ const full = path.join(dirp, ent.name);
2167
+ if (ent.isDirectory()) stack.push(full);
2168
+ else if (ent.isFile() && ent.name === 'status.json') {
2169
+ statusTotal++;
2170
+ const doc = readJsonOrNull(full) || {};
2171
+ if (Array.isArray(doc.history)) statusWithHistory++;
2172
+ }
2173
+ }
2174
+ }
2175
+ }
2176
+
2177
+ // Blockers with projectSlug
2178
+ let blockersTotal = 0;
2179
+ let blockersWithProject = 0;
2180
+ const blockersFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2181
+ if (exists(blockersFile)) {
2182
+ const blockersDoc = readJsonOrNull(blockersFile) || { blockers: [] };
2183
+ const blockers = Array.isArray(blockersDoc.blockers) ? blockersDoc.blockers : [];
2184
+ blockersTotal = blockers.length;
2185
+ for (const b of blockers) {
2186
+ const slug = String(b && b.projectSlug ? b.projectSlug : '').trim();
2187
+ if (slug) blockersWithProject++;
2188
+ }
2189
+ }
2190
+
2191
+ const breakdown = {
2192
+ tasks: { total: tasksTotal, withProjectSlug: tasksWithProject, pct: pct(tasksWithProject, tasksTotal) },
2193
+ status: { total: statusTotal, withHistory: statusWithHistory, pct: pct(statusWithHistory, statusTotal) },
2194
+ blockers: { total: blockersTotal, withProjectSlug: blockersWithProject, pct: pct(blockersWithProject, blockersTotal) }
2195
+ };
2196
+
2197
+ const scoreParts = [breakdown.tasks.pct, breakdown.status.pct, breakdown.blockers.pct].filter((v) => typeof v === 'number');
2198
+ const score = scoreParts.length ? Math.round((scoreParts.reduce((a, b) => a + b, 0) / scoreParts.length) * 10) / 10 : null;
2199
+
2200
+ return safeJson(res, 200, { ok: true, score, breakdown });
2201
+ }
2202
+
2203
+ if (req.url === '/api/risk/summary') {
2204
+ if (!looksLikeFreyaWorkspace(workspaceDir)) {
2205
+ return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
2206
+ }
2207
+
2208
+ const pendingThreshold = 5;
2209
+ const daysThreshold = 7;
2210
+ const now = Date.now();
2211
+
2212
+ const pendingByProject = {};
2213
+ const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2214
+ if (exists(taskFile)) {
2215
+ const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2216
+ const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2217
+ for (const t of tasks) {
2218
+ if (!t || t.status === 'COMPLETED') continue;
2219
+ const slug = String(t.projectSlug || '').trim();
2220
+ if (!slug) continue;
2221
+ pendingByProject[slug] = (pendingByProject[slug] || 0) + 1;
2222
+ }
2223
+ }
2224
+
2225
+ const blockersByProject = {};
2226
+ const oldestByProject = {};
2227
+ const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2228
+ if (exists(blockerFile)) {
2229
+ const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
2230
+ const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
2231
+ for (const b of blockers) {
2232
+ if (!b) continue;
2233
+ const status = String(b.status || '').toUpperCase();
2234
+ if (status !== 'OPEN' && status !== 'MITIGATING') continue;
2235
+ const slug = String(b.projectSlug || '').trim();
2236
+ if (!slug) continue;
2237
+ const createdAt = b.createdAt ? Date.parse(b.createdAt) : null;
2238
+ if (!createdAt) continue;
2239
+ const ageDays = Math.floor((now - createdAt) / (24 * 60 * 60 * 1000));
2240
+ if (ageDays < daysThreshold) continue;
2241
+ blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
2242
+ if (oldestByProject[slug] == null || ageDays > oldestByProject[slug]) oldestByProject[slug] = ageDays;
2243
+ }
2244
+ }
2245
+
2246
+ const projects = new Set([...Object.keys(pendingByProject), ...Object.keys(blockersByProject)]);
2247
+ const items = [];
2248
+ for (const slug of projects) {
2249
+ const pending = pendingByProject[slug] || 0;
2250
+ const oldBlockers = blockersByProject[slug] || 0;
2251
+ const oldestDays = oldestByProject[slug] || null;
2252
+ if (pending <= pendingThreshold && oldBlockers === 0) continue;
2253
+ items.push({ slug, pendingTasks: pending, oldBlockers, oldestBlockerDays: oldestDays });
2254
+ }
2255
+
2256
+ items.sort((a, b) => {
2257
+ if (b.oldBlockers !== a.oldBlockers) return b.oldBlockers - a.oldBlockers;
2258
+ if (b.pendingTasks !== a.pendingTasks) return b.pendingTasks - a.pendingTasks;
2259
+ return (b.oldestBlockerDays || 0) - (a.oldestBlockerDays || 0);
2260
+ });
2261
+
2262
+ return safeJson(res, 200, { ok: true, items: items.slice(0, 5), threshold: { pending: pendingThreshold, days: daysThreshold } });
2263
+ }
2264
+
2115
2265
  if (req.url === '/api/anomalies') {
2116
2266
  const anomalies = {
2117
2267
  tasksMissingProject: { count: 0, samples: [] },
@@ -2269,6 +2419,7 @@ if (req.url === '/api/reports/list') {
2269
2419
  const lc = textInput.toLowerCase();
2270
2420
  const projectsDir = path.join(workspaceDir, 'docs', 'projects');
2271
2421
  const links = [];
2422
+ const slugs = [];
2272
2423
 
2273
2424
  if (exists(projectsDir)) {
2274
2425
  const files = fs.readdirSync(projectsDir).filter((f) => f.endsWith('.md'));
@@ -2278,7 +2429,10 @@ if (req.url === '/api/reports/list') {
2278
2429
  const txt = fs.readFileSync(full, 'utf8');
2279
2430
  const m = txt.match(/DataPath:\s*data\/Clients\/(.+?)\//i);
2280
2431
  const slug = m ? m[1].toLowerCase() : name.toLowerCase();
2281
- if (lc.includes(slug)) links.push('[[' + name + ']]');
2432
+ if (lc.includes(slug)) {
2433
+ links.push('[[' + name + ']]');
2434
+ slugs.push(slug);
2435
+ }
2282
2436
  }
2283
2437
  }
2284
2438
 
@@ -2293,17 +2447,21 @@ if (req.url === '/api/reports/list') {
2293
2447
  if (ent.isDirectory()) stack.push(full);
2294
2448
  else if (ent.isFile() && ent.name === 'status.json') {
2295
2449
  const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/').toLowerCase();
2296
- if (lc.includes(slug)) links.push('[[' + slug + ']]');
2450
+ if (lc.includes(slug)) {
2451
+ links.push('[[' + slug + ']]');
2452
+ slugs.push(slug);
2453
+ }
2297
2454
  }
2298
2455
  }
2299
2456
  }
2300
2457
  }
2301
2458
 
2302
- const uniq = Array.from(new Set(links));
2303
- if (!uniq.length) return '';
2304
- return '\n\nLinks: ' + uniq.join(' ');
2459
+ const uniqLinks = Array.from(new Set(links));
2460
+ const uniqSlugs = Array.from(new Set(slugs));
2461
+ const linksText = uniqLinks.length ? ('\n\nLinks: ' + uniqLinks.join(' ')) : '';
2462
+ return { linksText, slugs: uniqSlugs };
2305
2463
  } catch {
2306
- return '';
2464
+ return { linksText: '', slugs: [] };
2307
2465
  }
2308
2466
  }
2309
2467
 
@@ -2319,9 +2477,33 @@ if (req.url === '/api/reports/list') {
2319
2477
  const hh = String(stamp.getHours()).padStart(2, '0');
2320
2478
  const mm = String(stamp.getMinutes()).padStart(2, '0');
2321
2479
 
2322
- const block = `\n\n## [${hh}:${mm}] Raw Input\n${text}\n`;
2480
+ const linkInfo = autoLinkNotes(text);
2481
+ const linksText = linkInfo && linkInfo.linksText ? linkInfo.linksText : '';
2482
+ const slugs = linkInfo && Array.isArray(linkInfo.slugs) ? linkInfo.slugs : [];
2483
+
2484
+ const block = `\n\n## [${hh}:${mm}] Raw Input\n${text}${linksText}\n`;
2323
2485
  fs.appendFileSync(file, block, 'utf8');
2324
2486
 
2487
+ if (slugs.length) {
2488
+ const logRel = path.relative(workspaceDir, file).replace(/\\/g, '/');
2489
+ const stampText = `${d} ${hh}:${mm}`;
2490
+ for (const slug of slugs) {
2491
+ const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
2492
+ if (!exists(statusPath)) continue;
2493
+ const doc = readJsonOrNull(statusPath) || { history: [] };
2494
+ if (!Array.isArray(doc.history)) doc.history = [];
2495
+ const already = doc.history.some((h) => h && (String(h.source || '').includes(logRel) || String(h.content || '').includes(logRel)));
2496
+ if (already) continue;
2497
+ doc.history.push({
2498
+ date: isoNow(),
2499
+ type: 'Log',
2500
+ content: `Log entry ${stampText} (${logRel})`,
2501
+ source: logRel
2502
+ });
2503
+ writeJson(statusPath, doc);
2504
+ }
2505
+ }
2506
+
2325
2507
  return safeJson(res, 200, { ok: true, file: path.relative(workspaceDir, file).replace(/\\/g, '/'), appended: true });
2326
2508
  }
2327
2509
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "2.3.7",
3
+ "version": "2.3.9",
4
4
  "description": "Personal AI Assistant with local-first persistence",
5
5
  "scripts": {
6
6
  "health": "node scripts/validate-data.js && node scripts/validate-structure.js",