@cccarv82/freya 1.0.11 → 1.0.12

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.
Files changed (2) hide show
  1. package/cli/web.js +329 -15
  2. package/package.json +1 -1
package/cli/web.js CHANGED
@@ -59,6 +59,67 @@ function newestFile(dir, prefix) {
59
59
  return files[0]?.p || null;
60
60
  }
61
61
 
62
+ function settingsPath(workspaceDir) {
63
+ return path.join(workspaceDir, 'data', 'settings', 'settings.json');
64
+ }
65
+
66
+ function readSettings(workspaceDir) {
67
+ const p = settingsPath(workspaceDir);
68
+ try {
69
+ if (!exists(p)) return { discordWebhookUrl: '', teamsWebhookUrl: '' };
70
+ const json = JSON.parse(fs.readFileSync(p, 'utf8'));
71
+ return {
72
+ discordWebhookUrl: json.discordWebhookUrl || '',
73
+ teamsWebhookUrl: json.teamsWebhookUrl || ''
74
+ };
75
+ } catch {
76
+ return { discordWebhookUrl: '', teamsWebhookUrl: '' };
77
+ }
78
+ }
79
+
80
+ function writeSettings(workspaceDir, settings) {
81
+ const p = settingsPath(workspaceDir);
82
+ ensureDir(path.dirname(p));
83
+ const out = {
84
+ schemaVersion: 1,
85
+ updatedAt: new Date().toISOString(),
86
+ discordWebhookUrl: settings.discordWebhookUrl || '',
87
+ teamsWebhookUrl: settings.teamsWebhookUrl || ''
88
+ };
89
+ fs.writeFileSync(p, JSON.stringify(out, null, 2) + '\n', 'utf8');
90
+ return out;
91
+ }
92
+
93
+ function listReports(workspaceDir) {
94
+ const dir = path.join(workspaceDir, 'docs', 'reports');
95
+ if (!exists(dir)) return [];
96
+
97
+ const files = fs.readdirSync(dir)
98
+ .filter((f) => f.endsWith('.md'))
99
+ .map((name) => {
100
+ const full = path.join(dir, name);
101
+ const st = fs.statSync(full);
102
+ return { name, full, mtimeMs: st.mtimeMs, size: st.size };
103
+ })
104
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
105
+
106
+ function kind(name) {
107
+ if (name.startsWith('executive-')) return 'executive';
108
+ if (name.startsWith('sm-weekly-')) return 'sm-weekly';
109
+ if (name.startsWith('blockers-')) return 'blockers';
110
+ if (name.startsWith('daily-')) return 'daily';
111
+ return 'other';
112
+ }
113
+
114
+ return files.map((f) => ({
115
+ kind: kind(f.name),
116
+ name: f.name,
117
+ relPath: path.relative(workspaceDir, f.full).replace(/\\/g, '/'),
118
+ mtimeMs: f.mtimeMs,
119
+ size: f.size
120
+ }));
121
+ }
122
+
62
123
  function safeJson(res, code, obj) {
63
124
  const body = JSON.stringify(obj);
64
125
  res.writeHead(code, {
@@ -607,6 +668,32 @@ function html(defaultDir) {
607
668
 
608
669
  .sideBtn { width: 100%; margin-top: 8px; }
609
670
 
671
+ .rep {
672
+ width: 100%;
673
+ text-align: left;
674
+ border: 1px solid var(--line);
675
+ border-radius: 12px;
676
+ background: var(--paper2);
677
+ padding: 10px 12px;
678
+ cursor: pointer;
679
+ font-family: var(--mono);
680
+ font-size: 12px;
681
+ color: var(--muted);
682
+ }
683
+ .rep:hover { border-color: var(--line2); box-shadow: 0 10px 22px rgba(16,24,40,.10); }
684
+ .repActive { border-color: rgba(59,130,246,.55); box-shadow: 0 0 0 4px rgba(59,130,246,.12); }
685
+
686
+ .md-h1{ font-size: 20px; margin: 10px 0 6px; }
687
+ .md-h2{ font-size: 16px; margin: 10px 0 6px; }
688
+ .md-h3{ font-size: 14px; margin: 10px 0 6px; }
689
+ .md-p{ margin: 6px 0; color: var(--muted); line-height: 1.5; }
690
+ .md-ul{ margin: 6px 0 6px 18px; color: var(--muted); }
691
+ .md-inline{ font-family: var(--mono); font-size: 12px; padding: 2px 6px; border: 1px solid var(--line); border-radius: 8px; background: rgba(255,255,255,.55); }
692
+ [data-theme="dark"] .md-inline{ background: rgba(0,0,0,.18); }
693
+ .md-code{ background: rgba(0,0,0,.05); border: 1px solid var(--line); border-radius: 14px; padding: 12px; overflow:auto; }
694
+ [data-theme="dark"] .md-code{ background: rgba(0,0,0,.22); }
695
+ .md-sp{ height: 10px; }
696
+
610
697
  .k { display: inline-block; padding: 2px 6px; border: 1px solid var(--line); border-radius: 8px; background: rgba(255,255,255,.65); font-family: var(--mono); font-size: 12px; color: var(--muted); }
611
698
  [data-theme="dark"] .k { background: rgba(0,0,0,.18); }
612
699
 
@@ -689,35 +776,52 @@ function html(defaultDir) {
689
776
 
690
777
  <div style="height:12px"></div>
691
778
 
692
- <div style="height:16px"></div>
693
-
694
779
  <label>Discord webhook URL</label>
695
780
  <input id="discord" placeholder="https://discord.com/api/webhooks/..." />
696
781
  <div style="height:10px"></div>
697
782
 
698
783
  <label>Teams webhook URL</label>
699
784
  <input id="teams" placeholder="https://..." />
700
- <div class="help">O publish usa incoming webhooks. (Depois a gente evolui para anexos/chunks.)</div>
785
+ <div class="help">Os webhooks ficam salvos na workspace em <code>data/settings/settings.json</code>.</div>
701
786
 
702
787
  <div style="height:10px"></div>
703
788
  <div class="stack">
704
- <button class="btn" onclick="publish('discord')">Publish last → Discord</button>
705
- <button class="btn" onclick="publish('teams')">Publish lastTeams</button>
789
+ <button class="btn" onclick="saveSettings()">Save settings</button>
790
+ <button class="btn" onclick="publish('discord')">Publish selectedDiscord</button>
791
+ <button class="btn" onclick="publish('teams')">Publish selected → Teams</button>
706
792
  </div>
793
+
794
+ <div style="height:14px"></div>
795
+
796
+ <div class="help"><b>Dica:</b> clique em um relatório em <i>Reports</i> para ver o preview e habilitar publish/copy.</div>
707
797
  </div>
708
798
  </div>
709
799
 
710
800
  <div class="panel">
711
801
  <div class="panelHead">
712
- <b>Output</b>
802
+ <b>Reports</b>
803
+ <div class="stack">
804
+ <button class="btn small" onclick="refreshReports()">Refresh</button>
805
+ </div>
806
+ </div>
807
+ <div class="panelBody">
808
+ <input id="reportsFilter" placeholder="filter (ex: daily, executive, 2026-01-29)" style="width:100%; margin-bottom:10px" oninput="renderReportsList()" />
809
+ <div id="reportsList" style="display:grid; gap:8px"></div>
810
+ <div class="help">Últimos relatórios em <code>docs/reports</code>. Clique para abrir preview.</div>
811
+ </div>
812
+ </div>
813
+
814
+ <div class="panel">
815
+ <div class="panelHead">
816
+ <b>Preview</b>
713
817
  <div class="stack">
714
818
  <button class="btn small" onclick="copyOut()">Copy</button>
715
819
  <button class="btn small" onclick="clearOut()">Clear</button>
716
820
  </div>
717
821
  </div>
718
822
  <div class="panelBody">
719
- <div class="log" id="out"></div>
720
- <div class="help">Dica: quando um report gera arquivo, mostramos o conteúdo real do report aqui (melhor que stdout).</div>
823
+ <div id="reportPreview" class="log md" style="font-family: var(--sans);"></div>
824
+ <div class="help">O preview renderiza Markdown básico (headers, listas, code). O botão Copy copia o conteúdo completo.</div>
721
825
  </div>
722
826
  </div>
723
827
 
@@ -732,7 +836,7 @@ function html(defaultDir) {
732
836
  <script>
733
837
  window.__FREYA_DEFAULT_DIR = "${safeDefault}";
734
838
  const $ = (id) => document.getElementById(id);
735
- const state = { lastReportPath: null, lastText: '' };
839
+ const state = { lastReportPath: null, lastText: '', reports: [], selectedReport: null };
736
840
 
737
841
  function applyTheme(theme) {
738
842
  document.documentElement.setAttribute('data-theme', theme);
@@ -754,13 +858,89 @@ function html(defaultDir) {
754
858
  $('status') && ($('status').textContent = text);
755
859
  }
756
860
 
861
+ function escapeHtml(str) {
862
+ return String(str)
863
+ .replace(/&/g, '&amp;')
864
+ .replace(/</g, '&lt;')
865
+ .replace(/>/g, '&gt;')
866
+ .replace(/\"/g, '&quot;')
867
+ .replace(/'/g, '&#39;');
868
+ }
869
+
870
+ function renderMarkdown(md) {
871
+ const lines = String(md || '').split(/\r?\n/);
872
+ let html = '';
873
+ let inCode = false;
874
+ let inList = false;
875
+
876
+ const BT = String.fromCharCode(96); // backtick
877
+ const FENCE = BT + BT + BT;
878
+ const inlineCodeRe = /\x60([^\x60]+)\x60/g;
879
+
880
+ const closeList = () => {
881
+ if (inList) { html += '</ul>'; inList = false; }
882
+ };
883
+
884
+ for (const line of lines) {
885
+ if (line.trim().startsWith(FENCE)) {
886
+ if (!inCode) {
887
+ closeList();
888
+ inCode = true;
889
+ html += '<pre class="md-code"><code>';
890
+ } else {
891
+ inCode = false;
892
+ html += '</code></pre>';
893
+ }
894
+ continue;
895
+ }
896
+
897
+ if (inCode) {
898
+ html += escapeHtml(line) + '\n';
899
+ continue;
900
+ }
901
+
902
+ const h = line.match(/^(#{1,3})\s+(.*)$/);
903
+ if (h) {
904
+ closeList();
905
+ const lvl = h[1].length;
906
+ html += '<h' + lvl + ' class="md-h' + lvl + '">' + escapeHtml(h[2]) + '</h' + lvl + '>';
907
+ continue;
908
+ }
909
+
910
+ const li = line.match(/^\s*[-*]\s+(.*)$/);
911
+ if (li) {
912
+ if (!inList) { html += '<ul class="md-ul">'; inList = true; }
913
+ const content = escapeHtml(li[1]).replace(inlineCodeRe, '<code class="md-inline">$1</code>');
914
+ html += '<li>' + content + '</li>';
915
+ continue;
916
+ }
917
+
918
+ if (line.trim() === '') {
919
+ closeList();
920
+ html += '<div class="md-sp"></div>';
921
+ continue;
922
+ }
923
+
924
+ closeList();
925
+ const p = escapeHtml(line).replace(inlineCodeRe, '<code class="md-inline">$1</code>');
926
+ html += '<p class="md-p">' + p + '</p>';
927
+ }
928
+
929
+ closeList();
930
+ if (inCode) html += '</code></pre>';
931
+ return html;
932
+ }
933
+
757
934
  function setOut(text) {
758
935
  state.lastText = text || '';
759
- $('out').textContent = text || '';
936
+ const el = $('reportPreview');
937
+ if (el) el.innerHTML = renderMarkdown(state.lastText);
760
938
  }
761
939
 
762
940
  function clearOut() {
763
- setOut('');
941
+ state.lastText = '';
942
+ const el = $('reportPreview');
943
+ if (el) el.innerHTML = '';
764
944
  setPill('ok', 'idle');
765
945
  }
766
946
 
@@ -781,14 +961,10 @@ function html(defaultDir) {
781
961
 
782
962
  function saveLocal() {
783
963
  localStorage.setItem('freya.dir', $('dir').value);
784
- localStorage.setItem('freya.discord', $('discord').value);
785
- localStorage.setItem('freya.teams', $('teams').value);
786
964
  }
787
965
 
788
966
  function loadLocal() {
789
967
  $('dir').value = (window.__FREYA_DEFAULT_DIR && window.__FREYA_DEFAULT_DIR !== '__FREYA_DEFAULT_DIR__') ? window.__FREYA_DEFAULT_DIR : (localStorage.getItem('freya.dir') || './freya');
790
- $('discord').value = localStorage.getItem('freya.discord') || '';
791
- $('teams').value = localStorage.getItem('freya.teams') || '';
792
968
  $('sidePath').textContent = $('dir').value || './freya';
793
969
  // Always persist the current run's default dir to avoid stale values
794
970
  localStorage.setItem('freya.dir', $('dir').value || './freya');
@@ -810,6 +986,78 @@ function html(defaultDir) {
810
986
  return d || './freya';
811
987
  }
812
988
 
989
+ function fmtWhen(ms) {
990
+ try {
991
+ const d = new Date(ms);
992
+ const yy = String(d.getFullYear());
993
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
994
+ const dd = String(d.getDate()).padStart(2, '0');
995
+ const hh = String(d.getHours()).padStart(2, '0');
996
+ const mi = String(d.getMinutes()).padStart(2, '0');
997
+ return yy + '-' + mm + '-' + dd + ' ' + hh + ':' + mi;
998
+ } catch {
999
+ return '';
1000
+ }
1001
+ }
1002
+
1003
+ async function selectReport(item) {
1004
+ const rr = await api('/api/reports/read', { dir: dirOrDefault(), relPath: item.relPath });
1005
+ state.selectedReport = item;
1006
+ setLast(item.name);
1007
+ setOut(rr.text || '');
1008
+ renderReportsList();
1009
+ }
1010
+
1011
+ function renderReportsList() {
1012
+ const list = $('reportsList');
1013
+ if (!list) return;
1014
+ const q = ($('reportsFilter') ? $('reportsFilter').value : '').trim().toLowerCase();
1015
+ const filtered = (state.reports || []).filter((it) => {
1016
+ if (!q) return true;
1017
+ return (it.name + ' ' + it.kind).toLowerCase().includes(q);
1018
+ });
1019
+
1020
+ list.innerHTML = '';
1021
+ for (const item of filtered) {
1022
+ const btn = document.createElement('button');
1023
+ btn.className = 'rep' + (state.selectedReport && state.selectedReport.relPath === item.relPath ? ' repActive' : '');
1024
+ btn.type = 'button';
1025
+ const meta = fmtWhen(item.mtimeMs);
1026
+ btn.innerHTML =
1027
+ '<div style="display:flex; gap:10px; align-items:center; justify-content:space-between">'
1028
+ + '<div style="min-width:0">'
1029
+ + '<div><span style="font-weight:800">' + escapeHtml(item.kind) + '</span> <span style="opacity:.7">—</span> ' + escapeHtml(item.name) + '</div>'
1030
+ + '<div style="opacity:.65; font-size:11px; margin-top:4px">' + escapeHtml(item.relPath) + '</div>'
1031
+ + '</div>'
1032
+ + '<div style="opacity:.7; font-size:11px; white-space:nowrap">' + escapeHtml(meta) + '</div>'
1033
+ + '</div>';
1034
+
1035
+ btn.onclick = async () => {
1036
+ try {
1037
+ await selectReport(item);
1038
+ } catch (e) {
1039
+ setPill('err', 'open failed');
1040
+ }
1041
+ };
1042
+ list.appendChild(btn);
1043
+ }
1044
+ }
1045
+
1046
+ async function refreshReports() {
1047
+ try {
1048
+ const r = await api('/api/reports/list', { dir: dirOrDefault() });
1049
+ state.reports = (r.reports || []).slice(0, 50);
1050
+ renderReportsList();
1051
+
1052
+ // Auto-select latest if nothing selected yet
1053
+ if (!state.selectedReport && state.reports && state.reports[0]) {
1054
+ await selectReport(state.reports[0]);
1055
+ }
1056
+ } catch (e) {
1057
+ // ignore
1058
+ }
1059
+ }
1060
+
813
1061
  async function pickDir() {
814
1062
  try {
815
1063
  setPill('run','picker…');
@@ -835,6 +1083,7 @@ function html(defaultDir) {
835
1083
  const r = await api('/api/init', { dir: dirOrDefault() });
836
1084
  setOut(r.output);
837
1085
  setLast(null);
1086
+ await refreshReports();
838
1087
  setPill('ok','init ok');
839
1088
  } catch (e) {
840
1089
  setPill('err','init failed');
@@ -851,6 +1100,7 @@ function html(defaultDir) {
851
1100
  const r = await api('/api/update', { dir: dirOrDefault() });
852
1101
  setOut(r.output);
853
1102
  setLast(null);
1103
+ await refreshReports();
854
1104
  setPill('ok','update ok');
855
1105
  } catch (e) {
856
1106
  setPill('err','update failed');
@@ -900,6 +1150,7 @@ function html(defaultDir) {
900
1150
  setOut(r.output);
901
1151
  setLast(r.reportPath || null);
902
1152
  if (r.reportText) state.lastText = r.reportText;
1153
+ await refreshReports();
903
1154
  setPill('ok', name + ' ok');
904
1155
  } catch (e) {
905
1156
  setPill('err', name + ' failed');
@@ -907,6 +1158,24 @@ function html(defaultDir) {
907
1158
  }
908
1159
  }
909
1160
 
1161
+ async function saveSettings() {
1162
+ try {
1163
+ saveLocal();
1164
+ setPill('run','saving…');
1165
+ await api('/api/settings/save', {
1166
+ dir: dirOrDefault(),
1167
+ settings: {
1168
+ discordWebhookUrl: $('discord').value.trim(),
1169
+ teamsWebhookUrl: $('teams').value.trim()
1170
+ }
1171
+ });
1172
+ setPill('ok','saved');
1173
+ setTimeout(() => setPill('ok','idle'), 800);
1174
+ } catch (e) {
1175
+ setPill('err','save failed');
1176
+ }
1177
+ }
1178
+
910
1179
  async function publish(target) {
911
1180
  try {
912
1181
  saveLocal();
@@ -926,6 +1195,25 @@ function html(defaultDir) {
926
1195
  applyTheme(localStorage.getItem('freya.theme') || 'light');
927
1196
  $('chipPort').textContent = location.host;
928
1197
  loadLocal();
1198
+
1199
+ // Load persisted settings from the workspace
1200
+ (async () => {
1201
+ try {
1202
+ const r = await api('/api/defaults', { dir: dirOrDefault() });
1203
+ if (r && r.workspaceDir) {
1204
+ $('dir').value = r.workspaceDir;
1205
+ $('sidePath').textContent = r.workspaceDir;
1206
+ }
1207
+ if (r && r.settings) {
1208
+ $('discord').value = r.settings.discordWebhookUrl || '';
1209
+ $('teams').value = r.settings.teamsWebhookUrl || '';
1210
+ }
1211
+ } catch (e) {
1212
+ // ignore
1213
+ }
1214
+ refreshReports();
1215
+ })();
1216
+
929
1217
  setPill('ok','idle');
930
1218
  </script>
931
1219
  </body>
@@ -1127,6 +1415,32 @@ async function cmdWeb({ port, dir, open, dev }) {
1127
1415
  return safeJson(res, 200, { dir: picked });
1128
1416
  }
1129
1417
 
1418
+ if (req.url === '/api/defaults') {
1419
+ const settings = readSettings(workspaceDir);
1420
+ const reports = listReports(workspaceDir).slice(0, 20);
1421
+ return safeJson(res, 200, { workspaceDir, settings, reports });
1422
+ }
1423
+
1424
+ if (req.url === '/api/settings/save') {
1425
+ const incoming = payload.settings || {};
1426
+ const saved = writeSettings(workspaceDir, incoming);
1427
+ return safeJson(res, 200, { ok: true, settings: { discordWebhookUrl: saved.discordWebhookUrl, teamsWebhookUrl: saved.teamsWebhookUrl } });
1428
+ }
1429
+
1430
+ if (req.url === '/api/reports/list') {
1431
+ const reports = listReports(workspaceDir);
1432
+ return safeJson(res, 200, { reports });
1433
+ }
1434
+
1435
+ if (req.url === '/api/reports/read') {
1436
+ const rel = payload.relPath;
1437
+ if (!rel) return safeJson(res, 400, { error: 'Missing relPath' });
1438
+ const full = path.join(workspaceDir, rel);
1439
+ if (!exists(full)) return safeJson(res, 404, { error: 'Report not found' });
1440
+ const text = fs.readFileSync(full, 'utf8');
1441
+ return safeJson(res, 200, { relPath: rel, text });
1442
+ }
1443
+
1130
1444
  if (req.url === '/api/init') {
1131
1445
  const pkg = '@cccarv82/freya';
1132
1446
  const r = await run(guessNpxCmd(), [guessNpxYesFlag(), pkg, 'init', workspaceDir], process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Personal AI Assistant with local-first persistence",
5
5
  "scripts": {
6
6
  "health": "node scripts/validate-data.js",