@cccarv82/freya 1.0.29 → 1.0.31

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 (3) hide show
  1. package/cli/web-ui.js +58 -4
  2. package/cli/web.js +173 -4
  3. package/package.json +1 -1
package/cli/web-ui.js CHANGED
@@ -12,7 +12,8 @@
12
12
  lastPlan: '',
13
13
  lastApplied: null,
14
14
  autoApply: true,
15
- autoRunReports: false
15
+ autoRunReports: false,
16
+ prettyPublish: true
16
17
  };
17
18
 
18
19
  function applyTheme(theme) {
@@ -205,6 +206,7 @@
205
206
  localStorage.setItem('freya.dir', $('dir').value);
206
207
  try { localStorage.setItem('freya.autoApply', state.autoApply ? '1' : '0'); } catch {}
207
208
  try { localStorage.setItem('freya.autoRunReports', state.autoRunReports ? '1' : '0'); } catch {}
209
+ try { localStorage.setItem('freya.prettyPublish', state.prettyPublish ? '1' : '0'); } catch {}
208
210
  }
209
211
 
210
212
  function loadLocal() {
@@ -218,6 +220,11 @@
218
220
  if (v2 !== null) state.autoRunReports = v2 === '1';
219
221
  const cb2 = $('autoRunReports');
220
222
  if (cb2) cb2.checked = !!state.autoRunReports;
223
+
224
+ const v3 = localStorage.getItem('freya.prettyPublish');
225
+ if (v3 !== null) state.prettyPublish = v3 === '1';
226
+ const cb3 = $('prettyPublish');
227
+ if (cb3) cb3.checked = !!state.prettyPublish;
221
228
  } catch {}
222
229
 
223
230
  const def = (window.__FREYA_DEFAULT_DIR && window.__FREYA_DEFAULT_DIR !== '__FREYA_DEFAULT_DIR__')
@@ -347,7 +354,9 @@
347
354
  const pri = (t.priority || '').toUpperCase();
348
355
  row.innerHTML = '<div style="display:flex; justify-content:space-between; gap:10px; align-items:center">'
349
356
  + '<div style="min-width:0"><div style="font-weight:700">' + escapeHtml(t.description || '') + '</div>'
350
- + '<div style="opacity:.7; font-size:11px; margin-top:4px">' + escapeHtml(String(t.category || '')) + (pri ? (' · ' + escapeHtml(pri)) : '') + '</div></div>'
357
+ + '<div style="opacity:.7; font-size:11px; margin-top:4px">' + escapeHtml(String(t.category || ''))
358
+ + (t.projectSlug ? (' · <span style="font-family:var(--mono); opacity:.9">[' + escapeHtml(String(t.projectSlug)) + ']</span>') : '')
359
+ + (pri ? (' · ' + escapeHtml(pri)) : '') + '</div></div>'
351
360
  + '<button class="btn small" type="button">Complete</button>'
352
361
  + '</div>';
353
362
  const btn = row.querySelector('button');
@@ -383,7 +392,9 @@
383
392
  const sev = String(b.severity || '').toUpperCase();
384
393
  row.innerHTML = '<div style="display:flex; justify-content:space-between; gap:10px; align-items:center">'
385
394
  + '<div style="min-width:0"><div style="font-weight:800">' + escapeHtml(sev) + '</div>'
386
- + '<div style="margin-top:4px">' + escapeHtml(b.title || '') + '</div>'
395
+ + '<div style="margin-top:4px">' + escapeHtml(b.title || '')
396
+ + (b.projectSlug ? (' <span style="font-family:var(--mono); opacity:.8">[' + escapeHtml(String(b.projectSlug)) + ']</span>') : '')
397
+ + '</div>'
387
398
  + '</div>'
388
399
  + '<div style="opacity:.7; font-size:11px; white-space:nowrap">' + escapeHtml(fmtWhen(new Date(b.createdAt || Date.now()).getTime())) + '</div>'
389
400
  + '</div>';
@@ -515,6 +526,39 @@
515
526
  }
516
527
  }
517
528
 
529
+ async function reloadSlugRules() {
530
+ try {
531
+ const r = await api('/api/project-slug-map/get', { dir: dirOrDefault() });
532
+ const el = $('slugRules');
533
+ if (el) el.value = JSON.stringify(r.map || { rules: [] }, null, 2);
534
+ setPill('ok', 'rules loaded');
535
+ setTimeout(() => setPill('ok', 'idle'), 800);
536
+ } catch (e) {
537
+ setPill('err', 'rules load failed');
538
+ setOut(String(e && e.message ? e.message : e));
539
+ }
540
+ }
541
+
542
+ async function saveSlugRules() {
543
+ try {
544
+ const el = $('slugRules');
545
+ if (!el) return;
546
+ const raw = String(el.value || '').trim();
547
+ if (!raw) throw new Error('Rules JSON is empty');
548
+ let map;
549
+ try { map = JSON.parse(raw); } catch (e) { throw new Error('Invalid JSON: ' + (e.message || e)); }
550
+
551
+ setPill('run', 'saving rules…');
552
+ const r = await api('/api/project-slug-map/save', { dir: dirOrDefault(), map });
553
+ if (el) el.value = JSON.stringify(r.map || map, null, 2);
554
+ setPill('ok', 'rules saved');
555
+ setTimeout(() => setPill('ok', 'idle'), 800);
556
+ } catch (e) {
557
+ setPill('err', 'rules save failed');
558
+ setOut(String(e && e.message ? e.message : e));
559
+ }
560
+ }
561
+
518
562
  async function saveSettings() {
519
563
  try {
520
564
  saveLocal();
@@ -556,7 +600,8 @@
556
600
  const webhookUrl = target === 'discord' ? $('discord').value.trim() : $('teams').value.trim();
557
601
  if (!webhookUrl) throw new Error('Configure o webhook antes.');
558
602
  setPill('run', 'publish…');
559
- await api('/api/publish', { webhookUrl, text: state.lastText, mode: 'chunks', allowSecrets: true });
603
+ const mode = state.prettyPublish ? 'pretty' : 'chunks';
604
+ await api('/api/publish', { webhookUrl, text: state.lastText, mode, allowSecrets: true });
560
605
  setPill('ok', 'published');
561
606
  } catch (e) {
562
607
  setPill('err', 'publish failed');
@@ -583,6 +628,12 @@
583
628
  }
584
629
  }
585
630
 
631
+ function togglePrettyPublish() {
632
+ const cb = $('prettyPublish');
633
+ state.prettyPublish = cb ? !!cb.checked : true;
634
+ try { localStorage.setItem('freya.prettyPublish', state.prettyPublish ? '1' : '0'); } catch {}
635
+ }
636
+
586
637
  function toggleAutoRunReports() {
587
638
  const cb = $('autoRunReports');
588
639
  state.autoRunReports = cb ? !!cb.checked : false;
@@ -754,6 +805,8 @@
754
805
  window.saveSettings = saveSettings;
755
806
  window.refreshReports = refreshReports;
756
807
  window.refreshToday = refreshToday;
808
+ window.reloadSlugRules = reloadSlugRules;
809
+ window.saveSlugRules = saveSlugRules;
757
810
  window.renderReportsList = renderReportsList;
758
811
  window.copyOut = copyOut;
759
812
  window.copyPath = copyPath;
@@ -765,6 +818,7 @@
765
818
  window.saveAndPlan = saveAndPlan;
766
819
  window.toggleAutoApply = toggleAutoApply;
767
820
  window.toggleAutoRunReports = toggleAutoRunReports;
821
+ window.togglePrettyPublish = togglePrettyPublish;
768
822
  window.applyPlan = applyPlan;
769
823
  window.runSuggestedReports = runSuggestedReports;
770
824
  })();
package/cli/web.js CHANGED
@@ -91,6 +91,66 @@ function writeSettings(workspaceDir, settings) {
91
91
  return out;
92
92
  }
93
93
 
94
+ function projectSlugMapPath(workspaceDir) {
95
+ return path.join(workspaceDir, 'data', 'settings', 'project-slug-map.json');
96
+ }
97
+
98
+ function readProjectSlugMap(workspaceDir) {
99
+ const p = projectSlugMapPath(workspaceDir);
100
+ try {
101
+ if (!exists(p)) {
102
+ ensureDir(path.dirname(p));
103
+ const defaults = {
104
+ schemaVersion: 1,
105
+ updatedAt: new Date().toISOString(),
106
+ rules: [
107
+ { contains: 'fideliza', slug: 'vivo/fidelizacao' },
108
+ { contains: 'bnpl', slug: 'vivo/bnpl' },
109
+ { contains: 'dpgc', slug: 'vivo/bnpl/dpgc' },
110
+ { contains: 'vivo+', slug: 'vivo/vivoplus' }
111
+ ]
112
+ };
113
+ fs.writeFileSync(p, JSON.stringify(defaults, null, 2) + '\n', 'utf8');
114
+ return defaults;
115
+ }
116
+ const json = JSON.parse(fs.readFileSync(p, 'utf8'));
117
+ if (!json || !Array.isArray(json.rules)) return { schemaVersion: 1, rules: [] };
118
+ return json;
119
+ } catch {
120
+ return { schemaVersion: 1, rules: [] };
121
+ }
122
+ }
123
+
124
+ function inferProjectSlug(text, map) {
125
+ const t = String(text || '').toLowerCase();
126
+ if (!t.trim()) return '';
127
+
128
+ let base = '';
129
+ const rules = (map && Array.isArray(map.rules)) ? map.rules : [];
130
+ for (const r of rules) {
131
+ if (!r) continue;
132
+ const needle = String(r.contains || '').toLowerCase().trim();
133
+ const slug = String(r.slug || '').trim();
134
+ if (!needle || !slug) continue;
135
+ if (t.includes(needle)) { base = slug; break; }
136
+ }
137
+
138
+ // CHG tags
139
+ const chg = (t.match(/\bchg\s*0*\d{4,}\b/i) || [])[0];
140
+ const chgNorm = chg ? chg.toLowerCase().replace(/\s+/g, '') : '';
141
+
142
+ // If no base but looks like Vivo context, at least prefix vivo
143
+ if (!base && (t.includes('vivo') || t.includes('vivo+'))) base = 'vivo';
144
+
145
+ if (base && chgNorm) {
146
+ // keep numeric id
147
+ const id = chgNorm.replace(/[^0-9]/g, '');
148
+ if (id) base = base.replace(/\/+$/g, '') + '/chg' + id;
149
+ }
150
+
151
+ return base;
152
+ }
153
+
94
154
  function listReports(workspaceDir) {
95
155
  const dir = path.join(workspaceDir, 'docs', 'reports');
96
156
  if (!exists(dir)) return [];
@@ -121,6 +181,36 @@ function listReports(workspaceDir) {
121
181
  }));
122
182
  }
123
183
 
184
+ function extractTitleFromMarkdown(md) {
185
+ const t = String(md || '');
186
+ const m = /^#\s+(.+)$/m.exec(t);
187
+ if (m && m[1]) return m[1].trim();
188
+ // fallback: first non-empty line
189
+ const line = t.split(/\r?\n/).map((l) => l.trim()).find((l) => l);
190
+ return line ? line.slice(0, 80) : 'Freya report';
191
+ }
192
+
193
+ function stripFirstH1(md) {
194
+ const t = String(md || '');
195
+ return t.replace(/^#\s+.+\r?\n/, '').trim();
196
+ }
197
+
198
+ function splitForEmbed(text, limit = 3900) {
199
+ const t = String(text || '');
200
+ if (t.length <= limit) return [t];
201
+ const chunks = [];
202
+ let i = 0;
203
+ while (i < t.length) {
204
+ let end = Math.min(t.length, i + limit);
205
+ // prefer splitting at newline
206
+ const nl = t.lastIndexOf('\n', end);
207
+ if (nl > i + 200) end = nl;
208
+ chunks.push(t.slice(i, end));
209
+ i = end;
210
+ }
211
+ return chunks;
212
+ }
213
+
124
214
  function splitForDiscord(text, limit = 1900) {
125
215
  const t = String(text || '');
126
216
  if (t.length <= limit) return [t];
@@ -174,14 +264,19 @@ function postJson(url, bodyObj) {
174
264
  });
175
265
  }
176
266
 
177
- function postDiscordWebhook(url, content) {
178
- return postJson(url, { content });
267
+ function postDiscordWebhook(url, payload) {
268
+ if (typeof payload === 'string') return postJson(url, { content: payload });
269
+ return postJson(url, payload);
179
270
  }
180
271
 
181
272
  function postTeamsWebhook(url, text) {
182
273
  return postJson(url, { text });
183
274
  }
184
275
 
276
+ function postTeamsCard(url, card) {
277
+ return postJson(url, card);
278
+ }
279
+
185
280
  function escapeJsonControlChars(jsonText) {
186
281
  // Replace unescaped control chars inside JSON string literals with safe escapes.
187
282
  // Handles Copilot outputs where newlines/tabs leak into string values.
@@ -329,6 +424,42 @@ async function publishRobust(webhookUrl, text, opts = {}) {
329
424
  const u = new URL(webhookUrl);
330
425
  const isDiscord = u.hostname.includes('discord.com') || u.hostname.includes('discordapp.com');
331
426
 
427
+ const mode = String(opts.mode || 'chunks');
428
+
429
+ if (mode === 'pretty') {
430
+ const title = extractTitleFromMarkdown(text);
431
+ const body = stripFirstH1(text);
432
+
433
+ if (isDiscord) {
434
+ const parts = splitForEmbed(body, 3900);
435
+ for (let i = 0; i < parts.length; i++) {
436
+ const payload = {
437
+ embeds: [
438
+ {
439
+ title: i === 0 ? title : undefined,
440
+ description: parts[i],
441
+ color: 0x5865F2
442
+ }
443
+ ]
444
+ };
445
+ await postDiscordWebhook(webhookUrl, payload);
446
+ }
447
+ return { ok: true, chunks: parts.length, mode: 'pretty' };
448
+ }
449
+
450
+ // Teams (MessageCard)
451
+ const card = {
452
+ '@type': 'MessageCard',
453
+ '@context': 'http://schema.org/extensions',
454
+ summary: title,
455
+ themeColor: '0078D7',
456
+ title,
457
+ text: body
458
+ };
459
+ await postTeamsCard(webhookUrl, card);
460
+ return { ok: true, chunks: 1, mode: 'pretty' };
461
+ }
462
+
332
463
  const chunks = isDiscord ? splitForDiscord(text, 1900) : splitForDiscord(text, 1800);
333
464
 
334
465
  for (const chunk of chunks) {
@@ -622,6 +753,11 @@ function buildHtml(safeDefault) {
622
753
  <div class="help">Os webhooks ficam salvos na workspace em <code>data/settings/settings.json</code>.</div>
623
754
 
624
755
  <div style="height:10px"></div>
756
+ <label style="display:flex; align-items:center; gap:10px; user-select:none; margin: 6px 0 12px 0">
757
+ <input id="prettyPublish" type="checkbox" checked style="width:auto" onchange="togglePrettyPublish()" />
758
+ Pretty publish (cards/embeds)
759
+ </label>
760
+
625
761
  <div class="stack">
626
762
  <button class="btn" onclick="saveSettings()">Save settings</button>
627
763
  <button class="btn" onclick="publish('discord')">Publish selected → Discord</button>
@@ -631,6 +767,15 @@ function buildHtml(safeDefault) {
631
767
  <div style="height:14px"></div>
632
768
 
633
769
  <div class="help"><b>Dica:</b> clique em um relatório em <i>Reports</i> para ver o preview e habilitar publish/copy.</div>
770
+
771
+ <div style="height:14px"></div>
772
+ <label>Project slug rules</label>
773
+ <textarea id="slugRules" rows="8" placeholder="{ \"rules\": [ { \"contains\": \"fideliza\", \"slug\": \"vivo/fidelizacao\" } ] }" style="width:100%; padding:10px 12px; border-radius:12px; border:1px solid var(--line); background: rgba(255,255,255,.72); color: var(--text); outline:none; resize: vertical; font-family: var(--mono);"></textarea>
774
+ <div class="help">Regras usadas pra inferir <code>projectSlug</code>. Formato JSON (objeto com <code>rules</code>). Editável no estilo Obsidian-friendly.</div>
775
+ <div class="stack" style="margin-top:10px">
776
+ <button class="btn" onclick="reloadSlugRules()">Reload rules</button>
777
+ <button class="btn" onclick="saveSlugRules()">Save rules</button>
778
+ </div>
634
779
  </div>
635
780
  </div>
636
781
 
@@ -934,6 +1079,29 @@ async function cmdWeb({ port, dir, open, dev }) {
934
1079
  return safeJson(res, 200, { ok: true, settings: { discordWebhookUrl: saved.discordWebhookUrl, teamsWebhookUrl: saved.teamsWebhookUrl } });
935
1080
  }
936
1081
 
1082
+
1083
+ if (req.url === '/api/project-slug-map/get') {
1084
+ const map = readProjectSlugMap(workspaceDir);
1085
+ return safeJson(res, 200, { ok: true, map });
1086
+ }
1087
+
1088
+ if (req.url === '/api/project-slug-map/save') {
1089
+ const map = payload.map;
1090
+ if (!map || typeof map !== 'object') return safeJson(res, 400, { error: 'Missing map' });
1091
+ if (!Array.isArray(map.rules)) return safeJson(res, 400, { error: 'map.rules must be an array' });
1092
+
1093
+ // normalize + basic validation
1094
+ const rules = map.rules
1095
+ .map((r) => ({ contains: String(r.contains || '').trim(), slug: String(r.slug || '').trim() }))
1096
+ .filter((r) => r.contains && r.slug);
1097
+
1098
+ const out = { schemaVersion: 1, updatedAt: new Date().toISOString(), rules };
1099
+ const p = projectSlugMapPath(workspaceDir);
1100
+ ensureDir(require('path').dirname(p));
1101
+ fs.writeFileSync(p, JSON.stringify(out, null, 2) + '\n', 'utf8');
1102
+ return safeJson(res, 200, { ok: true, map: out });
1103
+ }
1104
+
937
1105
  if (req.url === '/api/reports/list') {
938
1106
  const reports = listReports(workspaceDir);
939
1107
  return safeJson(res, 200, { reports });
@@ -1222,6 +1390,7 @@ async function cmdWeb({ port, dir, open, dev }) {
1222
1390
  const now = new Date().toISOString();
1223
1391
  const applyMode = String(payload.mode || 'all').trim();
1224
1392
  const applied = { tasks: 0, blockers: 0, tasksSkipped: 0, blockersSkipped: 0, reportsSuggested: [], oracleQueries: [], mode: applyMode };
1393
+ const slugMap = readProjectSlugMap(workspaceDir);
1225
1394
 
1226
1395
  function makeId(prefix) {
1227
1396
  const rand = Math.random().toString(16).slice(2, 8);
@@ -1256,7 +1425,7 @@ async function cmdWeb({ port, dir, open, dev }) {
1256
1425
  if (applyMode !== 'all' && applyMode !== 'tasks') continue;
1257
1426
  const description = normalizeWhitespace(a.description);
1258
1427
  if (!description) continue;
1259
- const projectSlug = String(a.projectSlug || '').trim();
1428
+ const projectSlug = String(a.projectSlug || '').trim() || inferProjectSlug(description, slugMap);
1260
1429
  const key = sha1(normalizeTextForKey((projectSlug ? projectSlug + ' ' : '') + description));
1261
1430
  if (existingTaskKeys24h.has(key)) { applied.tasksSkipped++; continue; }
1262
1431
  const category = validTaskCats.has(String(a.category || '').trim()) ? String(a.category).trim() : 'DO_NOW';
@@ -1278,7 +1447,7 @@ async function cmdWeb({ port, dir, open, dev }) {
1278
1447
  if (type === 'create_blocker') {
1279
1448
  if (applyMode !== 'all' && applyMode !== 'blockers') continue;
1280
1449
  const title = normalizeWhitespace(a.title);
1281
- const projectSlug = String(a.projectSlug || '').trim();
1450
+ const projectSlug = String(a.projectSlug || '').trim() || inferProjectSlug(title + ' ' + normalizeWhitespace(a.notes), slugMap);
1282
1451
  const key = sha1(normalizeTextForKey((projectSlug ? projectSlug + ' ' : '') + title));
1283
1452
  if (existingBlockerKeys24h.has(key)) { applied.blockersSkipped++; continue; }
1284
1453
  const notes = normalizeWhitespace(a.notes);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "Personal AI Assistant with local-first persistence",
5
5
  "scripts": {
6
6
  "health": "node scripts/validate-data.js",