@cccarv82/freya 1.0.57 → 1.0.59

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/cli/web-ui.css CHANGED
@@ -155,19 +155,6 @@ body {
155
155
  color: var(--muted);
156
156
  }
157
157
 
158
- .statusPill {
159
- display: flex;
160
- align-items: center;
161
- gap: 8px;
162
- padding: 6px 10px;
163
- border: 1px solid var(--line);
164
- border-radius: 999px;
165
- background: var(--paper2);
166
- font-family: var(--mono);
167
- font-size: 12px;
168
- color: var(--muted);
169
- }
170
-
171
158
  .dot { width: 10px; height: 10px; border-radius: 6px; background: rgba(148,163,184,.6); }
172
159
  .dot.ok { background: rgba(16,185,129,.85); }
173
160
  .dot.err { background: rgba(239,68,68,.85); }
@@ -300,6 +287,74 @@ body {
300
287
  color: var(--muted);
301
288
  margin-bottom: 10px;
302
289
  }
290
+
291
+ .reportsHeader {
292
+ display: flex;
293
+ align-items: flex-end;
294
+ justify-content: space-between;
295
+ gap: 16px;
296
+ margin-bottom: 12px;
297
+ }
298
+
299
+ .reportsTitle { font-size: 18px; font-weight: 700; }
300
+ .reportsSubtitle { font-size: 12px; color: var(--faint); margin-top: 4px; }
301
+ .reportsActions { display: flex; gap: 10px; }
302
+
303
+ .reportsTools { margin-bottom: 12px; }
304
+ .reportsTools input { width: 100%; }
305
+
306
+ .reportsGrid {
307
+ display: grid;
308
+ gap: 14px;
309
+ }
310
+
311
+ .reportCard {
312
+ border: 1px solid var(--line);
313
+ border-radius: 16px;
314
+ padding: 14px;
315
+ background: var(--paper);
316
+ box-shadow: var(--shadow2);
317
+ display: grid;
318
+ gap: 12px;
319
+ }
320
+
321
+ .reportHead {
322
+ display: flex;
323
+ justify-content: space-between;
324
+ gap: 14px;
325
+ cursor: pointer;
326
+ }
327
+
328
+ .reportName { font-weight: 700; }
329
+ .reportMeta { font-size: 12px; color: var(--faint); margin-top: 4px; display: flex; align-items: center; gap: 10px; }
330
+ .reportMetaText { display: inline-block; }
331
+ .reportHeadActions { display: flex; gap: 8px; align-items: center; }
332
+
333
+ .iconBtn {
334
+ border: 1px solid var(--line);
335
+ background: rgba(0,0,0,.25);
336
+ color: var(--muted);
337
+ border-radius: 10px;
338
+ padding: 2px 8px;
339
+ cursor: pointer;
340
+ font-size: 12px;
341
+ }
342
+
343
+ .reportBody { display: none; gap: 10px; }
344
+ .reportCard.expanded .reportBody { display: grid; }
345
+
346
+ .reportPreview {
347
+ border: 1px solid var(--line);
348
+ border-radius: 12px;
349
+ padding: 12px;
350
+ background: rgba(0,0,0,.18);
351
+ min-height: 120px;
352
+ outline: none;
353
+ }
354
+
355
+ .reportRaw { display: none; width: 100%; overflow: hidden; resize: none; }
356
+ .reportCard.raw .reportPreview { display: none; }
357
+ .reportCard.raw .reportRaw { display: block; }
303
358
  .centerHead { display: flex; justify-content: space-between; align-items: flex-end; gap: 18px; margin-bottom: 14px; }
304
359
  .statusLine { display:flex; align-items:center; justify-content:flex-end; gap: 12px; }
305
360
 
package/cli/web-ui.js CHANGED
@@ -8,6 +8,9 @@
8
8
  lastReportPath: null,
9
9
  lastText: '',
10
10
  reports: [],
11
+ reportTexts: {},
12
+ reportModes: {},
13
+ reportExpanded: {},
11
14
  selectedReport: null,
12
15
  lastPlan: '',
13
16
  lastApplied: null,
@@ -18,16 +21,8 @@
18
21
  chatLoaded: false
19
22
  };
20
23
 
21
- function applyTheme(theme) {
22
- document.documentElement.setAttribute('data-theme', theme);
23
- localStorage.setItem('freya.theme', theme);
24
- const t = $('themeToggle');
25
- if (t) t.textContent = theme === 'dark' ? 'Claro' : 'Escuro';
26
- }
27
-
28
- function toggleTheme() {
29
- const t = localStorage.getItem('freya.theme') || 'dark';
30
- applyTheme(t === 'dark' ? 'light' : 'dark');
24
+ function applyDarkTheme() {
25
+ document.documentElement.setAttribute('data-theme', 'dark');
31
26
  }
32
27
 
33
28
  function setPill(kind, text) {
@@ -68,6 +63,20 @@
68
63
  if (inList) { html += '</ul>'; inList = false; }
69
64
  };
70
65
 
66
+ const inlineFormat = (text) => {
67
+ const esc = escapeHtml(text || '');
68
+ const codes = [];
69
+ let out = esc.replace(inlineCodeRe, (_, c) => {
70
+ const idx = codes.length;
71
+ codes.push(c);
72
+ return `@@CODE${idx}@@`;
73
+ });
74
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
75
+ out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
76
+ out = out.replace(/@@CODE(\d+)@@/g, (_, i) => `<code class="md-inline">${codes[Number(i)]}</code>`);
77
+ return out;
78
+ };
79
+
71
80
  for (const line of lines) {
72
81
  if (line.trim().startsWith(FENCE)) {
73
82
  if (!inCode) {
@@ -90,14 +99,14 @@
90
99
  if (h) {
91
100
  closeList();
92
101
  const lvl = h[1].length;
93
- html += '<h' + lvl + ' class="md-h' + lvl + '">' + escapeHtml(h[2]) + '</h' + lvl + '>';
102
+ html += '<h' + lvl + ' class="md-h' + lvl + '">' + inlineFormat(h[2]) + '</h' + lvl + '>';
94
103
  continue;
95
104
  }
96
105
 
97
106
  const li = line.match(/^[ \t]*[-*][ \t]+(.*)$/);
98
107
  if (li) {
99
108
  if (!inList) { html += '<ul class="md-ul">'; inList = true; }
100
- const content = escapeHtml(li[1]).replace(inlineCodeRe, '<code class="md-inline">$1</code>');
109
+ const content = inlineFormat(li[1]);
101
110
  html += '<li>' + content + '</li>';
102
111
  continue;
103
112
  }
@@ -109,7 +118,7 @@
109
118
  }
110
119
 
111
120
  closeList();
112
- const p = escapeHtml(line).replace(inlineCodeRe, '<code class="md-inline">$1</code>');
121
+ const p = inlineFormat(line);
113
122
  html += '<p class="md-p">' + p + '</p>';
114
123
  }
115
124
 
@@ -380,9 +389,13 @@
380
389
  const def = (window.__FREYA_DEFAULT_DIR && window.__FREYA_DEFAULT_DIR !== '__FREYA_DEFAULT_DIR__')
381
390
  ? window.__FREYA_DEFAULT_DIR
382
391
  : (localStorage.getItem('freya.dir') || './freya');
383
- $('dir').value = def;
384
- $('sidePath').textContent = def || './freya';
385
- localStorage.setItem('freya.dir', $('dir').value || './freya');
392
+ const dirEl = $('dir');
393
+ if (dirEl) {
394
+ dirEl.value = def;
395
+ localStorage.setItem('freya.dir', dirEl.value || './freya');
396
+ }
397
+ const side = $('sidePath');
398
+ if (side) side.textContent = def || './freya';
386
399
  }
387
400
 
388
401
  async function api(p, body) {
@@ -411,8 +424,9 @@
411
424
  }
412
425
 
413
426
  function dirOrDefault() {
414
- const d = $('dir').value.trim();
415
- return d || './freya';
427
+ const dEl = $('dir');
428
+ const d = dEl ? dEl.value.trim() : '';
429
+ return d || (localStorage.getItem('freya.dir') || './freya');
416
430
  }
417
431
 
418
432
  function fmtWhen(ms) {
@@ -494,6 +508,172 @@
494
508
  }
495
509
  }
496
510
 
511
+ function autoGrowTextarea(el) {
512
+ if (!el) return;
513
+ el.style.height = 'auto';
514
+ el.style.height = el.scrollHeight + 'px';
515
+ }
516
+
517
+ function downloadReportPdf(item) {
518
+ const text = state.reportTexts[item.relPath] || '';
519
+ const html = `<!doctype html><html><head><meta charset="utf-8" /><title>${escapeHtml(item.name)}</title><style>body{font-family:Arial, sans-serif; padding:32px; color:#111;} h1,h2,h3{margin:16px 0 8px;} pre{background:#f5f5f5; padding:12px; border-radius:8px;}</style></head><body>${renderMarkdown(text)}</body></html>`;
520
+ const win = window.open('', '_blank');
521
+ if (!win) return;
522
+ win.document.write(html);
523
+ win.document.close();
524
+ win.focus();
525
+ win.print();
526
+ }
527
+
528
+ function renderReportsPage() {
529
+ const grid = $('reportsGrid');
530
+ if (!grid) return;
531
+ const q = ($('reportsFilter') ? $('reportsFilter').value : '').trim().toLowerCase();
532
+ const list = (state.reports || []).filter((it) => {
533
+ if (!q) return true;
534
+ return (it.name + ' ' + it.kind).toLowerCase().includes(q);
535
+ });
536
+
537
+ grid.innerHTML = '';
538
+ for (const item of list) {
539
+ const card = document.createElement('div');
540
+ const mode = state.reportModes[item.relPath] || 'preview';
541
+ const expanded = state.reportExpanded && state.reportExpanded[item.relPath];
542
+ card.className = 'reportCard' + (mode === 'raw' ? ' raw' : '') + (expanded ? ' expanded' : '');
543
+
544
+ const meta = fmtWhen(item.mtimeMs);
545
+ card.innerHTML =
546
+ '<div class="reportHead" data-action="expand">'
547
+ + '<div>'
548
+ + '<div class="reportName">' + escapeHtml(item.name) + '</div>'
549
+ + '<div class="reportMeta">'
550
+ + '<span class="reportMetaText">' + escapeHtml(item.relPath) + ' • ' + escapeHtml(meta) + '</span>'
551
+ + '<button class="iconBtn" data-action="pdf" title="Baixar PDF">⬇</button>'
552
+ + '</div>'
553
+ + '</div>'
554
+ + '<div class="reportHeadActions">'
555
+ + '<button class="btn small" data-action="toggle">' + (mode === 'raw' ? 'Preview' : 'Markdown') + '</button>'
556
+ + '<button class="btn small primary" data-action="save">Salvar</button>'
557
+ + '</div>'
558
+ + '</div>'
559
+ + '<div class="reportBody">'
560
+ + '<div class="reportPreview" contenteditable="true"></div>'
561
+ + '<textarea class="reportRaw" rows="6"></textarea>'
562
+ + '</div>';
563
+
564
+ const text = state.reportTexts[item.relPath] || '';
565
+ const preview = card.querySelector('.reportPreview');
566
+ if (preview) preview.innerHTML = renderMarkdown(text || '');
567
+ const raw = card.querySelector('.reportRaw');
568
+ if (raw) {
569
+ raw.value = text;
570
+ autoGrowTextarea(raw);
571
+ raw.addEventListener('input', () => {
572
+ state.reportTexts[item.relPath] = raw.value;
573
+ autoGrowTextarea(raw);
574
+ });
575
+ }
576
+
577
+ if (preview) {
578
+ preview.addEventListener('input', () => {
579
+ const val = preview.innerText || '';
580
+ state.reportTexts[item.relPath] = val;
581
+ if (raw) {
582
+ raw.value = val;
583
+ autoGrowTextarea(raw);
584
+ }
585
+ });
586
+ }
587
+
588
+ const toggleBtn = card.querySelector('[data-action="toggle"]');
589
+ if (toggleBtn) {
590
+ toggleBtn.onclick = () => {
591
+ state.reportModes[item.relPath] = (state.reportModes[item.relPath] === 'raw') ? 'preview' : 'raw';
592
+ renderReportsPage();
593
+ };
594
+ }
595
+
596
+ const saveBtn = card.querySelector('[data-action="save"]');
597
+ if (saveBtn) {
598
+ saveBtn.onclick = async () => {
599
+ try {
600
+ const content = (raw && typeof raw.value === 'string') ? raw.value : (state.reportTexts[item.relPath] || '');
601
+ setPill('run', 'salvando…');
602
+ await api('/api/reports/write', { dir: dirOrDefault(), relPath: item.relPath, text: content });
603
+ state.reportTexts[item.relPath] = content;
604
+ setPill('ok', 'salvo');
605
+ setTimeout(() => setPill('ok', 'pronto'), 800);
606
+ renderReportsPage();
607
+ } catch (e) {
608
+ setPill('err', 'falhou');
609
+ }
610
+ };
611
+ }
612
+
613
+ const pdfBtn = card.querySelector('[data-action="pdf"]');
614
+ if (pdfBtn) {
615
+ pdfBtn.onclick = (ev) => {
616
+ ev.stopPropagation();
617
+ downloadReportPdf(item);
618
+ };
619
+ }
620
+
621
+ const head = card.querySelector('[data-action="expand"]');
622
+ if (head) {
623
+ head.onclick = () => {
624
+ state.reportExpanded = state.reportExpanded || {};
625
+ state.reportExpanded[item.relPath] = !state.reportExpanded[item.relPath];
626
+ renderReportsPage();
627
+ };
628
+ }
629
+
630
+ grid.appendChild(card);
631
+ }
632
+ }
633
+
634
+ async function refreshReportsPage() {
635
+ try {
636
+ setPill('run', 'carregando…');
637
+ const r = await api('/api/reports/list', { dir: dirOrDefault() });
638
+ state.reports = (r.reports || []);
639
+ state.reportTexts = {};
640
+ await Promise.all(state.reports.map(async (item) => {
641
+ try {
642
+ const rr = await api('/api/reports/read', { dir: dirOrDefault(), relPath: item.relPath });
643
+ state.reportTexts[item.relPath] = rr.text || '';
644
+ } catch {
645
+ state.reportTexts[item.relPath] = '';
646
+ }
647
+ }));
648
+ renderReportsPage();
649
+ setPill('ok', 'pronto');
650
+ } catch (e) {
651
+ setPill('err', 'falhou');
652
+ }
653
+ }
654
+
655
+ function wireRailNav() {
656
+ const dash = $('railDashboard');
657
+ const rep = $('railReports');
658
+ if (dash) {
659
+ dash.onclick = () => {
660
+ const isReports = document.body && document.body.dataset && document.body.dataset.page === 'reports';
661
+ if (isReports) {
662
+ window.location.href = '/';
663
+ return;
664
+ }
665
+ const c = document.querySelector('.centerBody');
666
+ if (c) c.scrollTo({ top: 0, behavior: 'smooth' });
667
+ };
668
+ }
669
+ if (rep) {
670
+ rep.onclick = () => {
671
+ const isReports = document.body && document.body.dataset && document.body.dataset.page === 'reports';
672
+ if (!isReports) window.location.href = '/reports';
673
+ };
674
+ }
675
+ }
676
+
497
677
  async function editTask(t) {
498
678
  try {
499
679
  const currentSlug = t.projectSlug ? String(t.projectSlug) : '';
@@ -990,10 +1170,11 @@
990
1170
  }
991
1171
 
992
1172
  // init
993
- applyTheme('dark');
994
- try { localStorage.setItem('freya.theme', 'dark'); } catch (err) {}
995
- $('chipPort').textContent = location.host;
1173
+ applyDarkTheme();
1174
+ const chipPort = $('chipPort');
1175
+ if (chipPort) chipPort.textContent = location.host;
996
1176
  loadLocal();
1177
+ wireRailNav();
997
1178
 
998
1179
  // Developer drawer (persist open/close)
999
1180
  try {
@@ -1007,23 +1188,34 @@
1007
1188
  }
1008
1189
  } catch {}
1009
1190
 
1191
+ const isReportsPage = document.body && document.body.dataset && document.body.dataset.page === 'reports';
1192
+
1010
1193
  // Load persisted settings from the workspace + bootstrap (auto-init + auto-health)
1011
1194
  (async () => {
1012
1195
  let defaults = null;
1013
1196
  try {
1014
1197
  defaults = await api('/api/defaults', { dir: dirOrDefault() });
1015
1198
  if (defaults && defaults.workspaceDir) {
1016
- $('dir').value = defaults.workspaceDir;
1017
- $('sidePath').textContent = defaults.workspaceDir;
1199
+ const dirEl = $('dir');
1200
+ if (dirEl) dirEl.value = defaults.workspaceDir;
1201
+ const side = $('sidePath');
1202
+ if (side) side.textContent = defaults.workspaceDir;
1018
1203
  }
1019
1204
  if (defaults && defaults.settings) {
1020
- $('discord').value = defaults.settings.discordWebhookUrl || '';
1021
- $('teams').value = defaults.settings.teamsWebhookUrl || '';
1205
+ const discord = $('discord');
1206
+ const teams = $('teams');
1207
+ if (discord) discord.value = defaults.settings.discordWebhookUrl || '';
1208
+ if (teams) teams.value = defaults.settings.teamsWebhookUrl || '';
1022
1209
  }
1023
1210
  } catch (e) {
1024
1211
  // ignore
1025
1212
  }
1026
1213
 
1214
+ if (isReportsPage) {
1215
+ await refreshReportsPage();
1216
+ return;
1217
+ }
1218
+
1027
1219
  // If workspace isn't initialized yet, auto-init (reduces clicks)
1028
1220
  try {
1029
1221
  if (defaults && defaults.workspaceOk === false) {
@@ -1060,12 +1252,13 @@
1060
1252
  window.exportObsidian = exportObsidian;
1061
1253
  window.rebuildIndex = rebuildIndex;
1062
1254
  window.renderReportsList = renderReportsList;
1255
+ window.renderReportsPage = renderReportsPage;
1256
+ window.refreshReportsPage = refreshReportsPage;
1063
1257
  window.copyOut = copyOut;
1064
1258
  window.copyPath = copyPath;
1065
1259
  window.openSelected = openSelected;
1066
1260
  window.downloadSelected = downloadSelected;
1067
1261
  window.clearOut = clearOut;
1068
- window.toggleTheme = toggleTheme;
1069
1262
  window.saveInbox = saveInbox;
1070
1263
  window.saveAndPlan = saveAndPlan;
1071
1264
  window.toggleAutoApply = toggleAutoApply;
package/cli/web.js CHANGED
@@ -881,6 +881,11 @@ function html(defaultDir) {
881
881
  return buildHtml(safeDefault, APP_VERSION);
882
882
  }
883
883
 
884
+ function reportsHtml(defaultDir) {
885
+ const safeDefault = String(defaultDir || './freya').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
886
+ return buildReportsHtml(safeDefault, APP_VERSION);
887
+ }
888
+
884
889
  function buildHtml(safeDefault, appVersion) {
885
890
  const safeVersion = escapeHtml(appVersion || 'unknown');
886
891
  return `<!doctype html>
@@ -901,11 +906,8 @@ function buildHtml(safeDefault, appVersion) {
901
906
  <div class="railLogo">F</div>
902
907
  </div>
903
908
  <div class="railNav">
904
- <button class="railBtn active" type="button" title="Dashboard" onclick="window.scrollTo({ top: 0, behavior: 'smooth' })">D</button>
905
- <button class="railBtn" type="button" title="Relatórios" onclick="document.getElementById('reportsSection')?.scrollIntoView({ behavior: 'smooth' })">R</button>
906
- <button class="railBtn" type="button" title="Relatórios (atalho 2)">R</button>
907
- <button class="railBtn" type="button" title="Preview">P</button>
908
- <button class="railBtn" type="button" title="Conversa">C</button>
909
+ <button class="railBtn active" id="railDashboard" type="button" title="Dashboard">D</button>
910
+ <button class="railBtn" id="railReports" type="button" title="Relatórios">R</button>
909
911
  </div>
910
912
  <div class="railBottom">
911
913
  <div class="railStatus" id="railStatus" title="status"></div>
@@ -920,7 +922,6 @@ function buildHtml(safeDefault, appVersion) {
920
922
  <div class="brand">FREYA</div>
921
923
  <div class="brandSub">Assistente de status local-first</div>
922
924
  </div>
923
- <div class="statusPill"><span class="dot" id="dot"></span><span id="pill">pronto</span></div>
924
925
  </div>
925
926
  <div class="topActions">
926
927
  <span class="chip" id="chipVersion">v${safeVersion}</span>
@@ -1130,6 +1131,82 @@ function buildHtml(safeDefault, appVersion) {
1130
1131
  </html>`;
1131
1132
  }
1132
1133
 
1134
+ function buildReportsHtml(safeDefault, appVersion) {
1135
+ const safeVersion = escapeHtml(appVersion || 'unknown');
1136
+ return `<!doctype html>
1137
+ <html>
1138
+ <head>
1139
+ <meta charset="utf-8" />
1140
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1141
+ <title>FREYA Reports</title>
1142
+ <link rel="stylesheet" href="/app.css" />
1143
+ </head>
1144
+ <body data-page="reports">
1145
+ <div class="app">
1146
+ <div class="frame">
1147
+ <div class="shell">
1148
+
1149
+ <aside class="rail">
1150
+ <div class="railTop">
1151
+ <div class="railLogo">F</div>
1152
+ </div>
1153
+ <div class="railNav">
1154
+ <button class="railBtn" id="railDashboard" type="button" title="Dashboard">D</button>
1155
+ <button class="railBtn active" id="railReports" type="button" title="Relatórios">R</button>
1156
+ </div>
1157
+ <div class="railBottom">
1158
+ <div class="railStatus" id="railStatus" title="status"></div>
1159
+ </div>
1160
+ </aside>
1161
+
1162
+ <main class="center reportsPage" id="reportsPage">
1163
+ <div class="topbar">
1164
+ <div class="brandLine">
1165
+ <span class="spark"></span>
1166
+ <div class="brandStack">
1167
+ <div class="brand">FREYA</div>
1168
+ <div class="brandSub">Relatórios</div>
1169
+ </div>
1170
+ </div>
1171
+ <div class="topActions">
1172
+ <span class="chip" id="chipVersion">v${safeVersion}</span>
1173
+ <span class="chip" id="chipPort">127.0.0.1:3872</span>
1174
+ </div>
1175
+ </div>
1176
+
1177
+ <div class="centerBody">
1178
+ <input id="dir" type="hidden" />
1179
+
1180
+ <section class="reportsHeader">
1181
+ <div>
1182
+ <div class="reportsTitle">Relatórios</div>
1183
+ <div class="reportsSubtitle">Edite e refine seus relatórios com preview em Markdown.</div>
1184
+ </div>
1185
+ <div class="reportsActions">
1186
+ <button class="btn small" type="button" onclick="refreshReportsPage()">Atualizar</button>
1187
+ </div>
1188
+ </section>
1189
+
1190
+ <section class="reportsTools">
1191
+ <input id="reportsFilter" placeholder="filtrar (ex: daily, executive, 2026-01-29)" oninput="renderReportsPage()" />
1192
+ </section>
1193
+
1194
+ <section class="reportsGrid" id="reportsGrid"></section>
1195
+ </div>
1196
+ </main>
1197
+
1198
+ </div>
1199
+ </div>
1200
+ </div>
1201
+
1202
+ <script>
1203
+ window.__FREYA_DEFAULT_DIR = "${safeDefault}";
1204
+ </script>
1205
+ <script src="/app.js"></script>
1206
+ </body>
1207
+ </html>`;
1208
+ }
1209
+
1133
1210
  function ensureDir(p) {
1134
1211
  fs.mkdirSync(p, { recursive: true });
1135
1212
  }
@@ -1316,6 +1393,14 @@ async function cmdWeb({ port, dir, open, dev }) {
1316
1393
  return;
1317
1394
  }
1318
1395
 
1396
+ if (req.method === 'GET' && req.url === '/reports') {
1397
+ try { res.__freyaDebug.workspaceDir = normalizeWorkspaceDir(dir || './freya'); } catch {}
1398
+ const body = reportsHtml(dir || './freya');
1399
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
1400
+ res.end(body);
1401
+ return;
1402
+ }
1403
+
1319
1404
  if (req.method === 'GET' && req.url === '/app.css') {
1320
1405
  const css = fs.readFileSync(path.join(__dirname, 'web-ui.css'), 'utf8');
1321
1406
  res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-store' });
@@ -1412,6 +1497,23 @@ async function cmdWeb({ port, dir, open, dev }) {
1412
1497
  return safeJson(res, 200, { relPath: rel, fullPath: full });
1413
1498
  }
1414
1499
 
1500
+ if (req.url === '/api/reports/write') {
1501
+ const rel = payload.relPath;
1502
+ const text = payload.text;
1503
+ if (!rel) return safeJson(res, 400, { error: 'Missing relPath' });
1504
+ if (typeof text !== 'string') return safeJson(res, 400, { error: 'Missing text' });
1505
+ const reportsDir = path.join(workspaceDir, 'docs', 'reports');
1506
+ const full = path.join(workspaceDir, rel);
1507
+ const safeReportsDir = path.resolve(reportsDir);
1508
+ const safeFull = path.resolve(full);
1509
+ if (!safeFull.startsWith(safeReportsDir + path.sep)) {
1510
+ return safeJson(res, 400, { error: 'Invalid report path' });
1511
+ }
1512
+ if (!exists(safeFull)) return safeJson(res, 404, { error: 'Report not found' });
1513
+ fs.writeFileSync(safeFull, text, 'utf8');
1514
+ return safeJson(res, 200, { ok: true, relPath: rel });
1515
+ }
1516
+
1415
1517
  if (req.url === '/api/inbox/add') {
1416
1518
  const text = String(payload.text || '').trim();
1417
1519
  if (!text) return safeJson(res, 400, { error: 'Missing text' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "1.0.57",
3
+ "version": "1.0.59",
4
4
  "description": "Personal AI Assistant with local-first persistence",
5
5
  "scripts": {
6
6
  "health": "node scripts/validate-data.js",