@cccarv82/freya 3.1.0 → 3.3.0

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 +98 -4
  2. package/cli/web.js +135 -9
  3. package/package.json +3 -3
package/cli/web-ui.js CHANGED
@@ -163,6 +163,45 @@
163
163
  return html;
164
164
  }
165
165
 
166
+ // Merge multiple plan outputs from batch processing into a single plan JSON.
167
+ // Each plan may be a JSON string with { actions: [...] } or raw text.
168
+ function mergeBatchPlans(plans) {
169
+ var merged = [];
170
+ for (var pi = 0; pi < plans.length; pi++) {
171
+ var raw = String(plans[pi] || '').trim();
172
+ if (!raw) continue;
173
+ try {
174
+ // Extract first JSON object from the plan text
175
+ var start = raw.indexOf('{');
176
+ if (start !== -1) {
177
+ var depth = 0, inStr = false, esc = false, jsonStr = null;
178
+ for (var i = start; i < raw.length; i++) {
179
+ var ch = raw[i];
180
+ if (esc) { esc = false; continue; }
181
+ if (ch === '\\') { esc = true; continue; }
182
+ if (ch === '"') { inStr = !inStr; continue; }
183
+ if (inStr) continue;
184
+ if (ch === '{') depth++;
185
+ if (ch === '}') { depth--; if (depth === 0) { jsonStr = raw.slice(start, i + 1); break; } }
186
+ }
187
+ if (jsonStr) {
188
+ var obj = JSON.parse(jsonStr);
189
+ var actions = Array.isArray(obj.actions) ? obj.actions : [];
190
+ for (var ai = 0; ai < actions.length; ai++) merged.push(actions[ai]);
191
+ continue;
192
+ }
193
+ }
194
+ } catch (_) { /* fall through — keep raw text */ }
195
+ // If not parseable, keep as-is (will show raw in preview)
196
+ if (!merged._raw) merged._raw = '';
197
+ merged._raw += raw + '\n';
198
+ }
199
+ // Return combined JSON string
200
+ var result = JSON.stringify({ actions: merged });
201
+ if (merged._raw) result += '\n\n' + merged._raw;
202
+ return result;
203
+ }
204
+
166
205
  function formatPlanForDisplay(rawPlan) {
167
206
  var text = String(rawPlan || '');
168
207
  if (!text) return null;
@@ -2383,12 +2422,57 @@
2383
2422
 
2384
2423
  setPill('run', 'salvando…');
2385
2424
  var inboxPayload = { dir: dirOrDefault(), text };
2386
- if (pendingImg) inboxPayload.imagePath = 'data/attachments/' + pendingImg.filename;
2425
+ var savedImagePath = pendingImg ? 'data/attachments/' + pendingImg.filename : null;
2426
+ if (savedImagePath) inboxPayload.imagePath = savedImagePath;
2387
2427
  state.pendingImage = null;
2388
2428
  await api('/api/inbox/add', inboxPayload);
2389
2429
 
2390
- setPill('run', 'processando…');
2391
- const r = await api('/api/agents/plan', { dir: dirOrDefault(), text });
2430
+ // --- Chunked processing for large inputs (ENAMETOOLONG fix) ---
2431
+ var CHUNK_THRESHOLD = 20000; // 20KB if text is larger, split into batches
2432
+ var CHUNK_SIZE = 15000; // ~15KB per chunk (leave headroom for rules text)
2433
+ var chunks = [];
2434
+ if (text.length > CHUNK_THRESHOLD) {
2435
+ // Split at paragraph boundaries (\n\n), fall back to newlines, then hard-split
2436
+ var remaining = text;
2437
+ while (remaining.length > CHUNK_SIZE) {
2438
+ var cut = remaining.lastIndexOf('\n\n', CHUNK_SIZE);
2439
+ if (cut < CHUNK_SIZE * 0.3) cut = remaining.lastIndexOf('\n', CHUNK_SIZE);
2440
+ if (cut < CHUNK_SIZE * 0.3) cut = CHUNK_SIZE; // hard cut
2441
+ chunks.push(remaining.slice(0, cut).trim());
2442
+ remaining = remaining.slice(cut).trim();
2443
+ }
2444
+ if (remaining) chunks.push(remaining);
2445
+ } else {
2446
+ chunks = [text];
2447
+ }
2448
+
2449
+ var allPlans = [];
2450
+ var anyOk = false;
2451
+ var lastR = null;
2452
+
2453
+ for (var ci = 0; ci < chunks.length; ci++) {
2454
+ if (chunks.length > 1) {
2455
+ setPill('run', 'batch ' + (ci + 1) + '/' + chunks.length + '…');
2456
+ } else {
2457
+ setPill('run', 'processando…');
2458
+ }
2459
+ var planPayload = { dir: dirOrDefault(), text: chunks[ci] };
2460
+ // Send image path only with the first chunk
2461
+ if (ci === 0 && savedImagePath) planPayload.imagePath = savedImagePath;
2462
+ var chunkR = await api('/api/agents/plan', planPayload);
2463
+ if (chunkR.ok !== false) anyOk = true;
2464
+ if (chunkR.plan) allPlans.push(chunkR.plan);
2465
+ lastR = chunkR;
2466
+ }
2467
+
2468
+ // Merge plans: combine JSON action arrays from all chunks
2469
+ var r;
2470
+ if (chunks.length > 1 && anyOk) {
2471
+ var mergedPlan = mergeBatchPlans(allPlans);
2472
+ r = { ok: true, plan: mergedPlan };
2473
+ } else {
2474
+ r = lastR || { ok: false, plan: '' };
2475
+ }
2392
2476
 
2393
2477
  // Remove typing indicator
2394
2478
  var typingEl = $(typingId);
@@ -2397,9 +2481,10 @@
2397
2481
  state.lastPlan = r.plan || '';
2398
2482
 
2399
2483
  // Show plan output in Preview panel
2484
+ var batchNote = chunks.length > 1 ? '> 📦 Processado em **' + chunks.length + ' batches** (input grande)\n\n' : '';
2400
2485
  const header = r.ok === false ? '## Agent Plan (planner unavailable)\n\n' : '## Agent Plan (draft)\n\n';
2401
2486
  const formatted = r.ok !== false ? formatPlanForDisplay(r.plan) : null;
2402
- const planOut = header + (formatted || r.plan || '');
2487
+ const planOut = header + batchNote + (formatted || r.plan || '');
2403
2488
  setOut(planOut);
2404
2489
  chatAppend('assistant', planOut, { markdown: true });
2405
2490
  ta.value = '';
@@ -2498,6 +2583,15 @@
2498
2583
  msg += 'Contexto registrado no log diário. Nenhuma tarefa ou blocker identificado.\n';
2499
2584
  }
2500
2585
 
2586
+ // Show semantic duplicates detected
2587
+ if (summary && Array.isArray(summary.semanticDups) && summary.semanticDups.length > 0) {
2588
+ msg += '\n⚠️ **Duplicatas detectadas** (não criadas):\n';
2589
+ for (var di = 0; di < summary.semanticDups.length; di++) {
2590
+ var dup = summary.semanticDups[di];
2591
+ msg += '- "' + dup.newDesc + '" → já existe: "' + dup.existingDesc + '" (' + dup.similarity + ' similar)\n';
2592
+ }
2593
+ }
2594
+
2501
2595
  if (summary && Array.isArray(summary.reportsSuggested) && summary.reportsSuggested.length) {
2502
2596
  msg += '\n**Relatórios sugeridos:** ' + summary.reportsSuggested.join(', ');
2503
2597
  msg += '\n\nUse: **Rodar relatórios sugeridos** (barra lateral)';
package/cli/web.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const http = require('http');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const os = require('os');
6
7
  const crypto = require('crypto');
7
8
  const { spawn } = require('child_process');
8
9
  const { searchWorkspace } = require('../scripts/lib/search-utils');
@@ -628,7 +629,7 @@ function readBody(req, maxBytes = 4 * 1024 * 1024) {
628
629
  });
629
630
  }
630
631
 
631
- function run(cmd, args, cwd, extraEnv) {
632
+ function run(cmd, args, cwd, extraEnv, stdinData) {
632
633
  return new Promise((resolve) => {
633
634
  let child;
634
635
 
@@ -646,6 +647,12 @@ function run(cmd, args, cwd, extraEnv) {
646
647
  return resolve({ code: 1, stdout: '', stderr: e.message || String(e) });
647
648
  }
648
649
 
650
+ // Pipe data to stdin if provided (avoids ENAMETOOLONG for large prompts)
651
+ if (stdinData && child.stdin) {
652
+ child.stdin.write(stdinData);
653
+ child.stdin.end();
654
+ }
655
+
649
656
  let stdout = '';
650
657
  let stderr = '';
651
658
 
@@ -3272,6 +3279,7 @@ async function cmdWeb({ port, dir, open, dev }) {
3272
3279
 
3273
3280
  if (req.url === '/api/agents/plan') {
3274
3281
  const text = String(payload.text || '').trim();
3282
+ const planImagePath = payload.imagePath ? String(payload.imagePath).trim() : null;
3275
3283
  if (!text) return safeJson(res, 400, { error: 'Missing text' });
3276
3284
 
3277
3285
  // Build planner prompt from agent rules.
@@ -3293,6 +3301,17 @@ async function cmdWeb({ port, dir, open, dev }) {
3293
3301
  return `\n\n---\nFILE: ${rel}\n---\n` + fs.readFileSync(p, 'utf8');
3294
3302
  }).join('');
3295
3303
 
3304
+ // Build image context for the prompt (Copilot reads files via --allow-all-tools)
3305
+ let planImageContext = '';
3306
+ let planImageDir = null;
3307
+ if (planImagePath) {
3308
+ const absImg = path.isAbsolute(planImagePath) ? planImagePath : path.join(workspaceDir, planImagePath);
3309
+ if (exists(absImg)) {
3310
+ planImageContext = `\n\n[IMAGEM ANEXADA]\nO usuário anexou uma imagem. Leia e analise o arquivo de imagem localizado em: ${absImg}\nInclua o conteúdo da imagem como contexto adicional na análise.\n`;
3311
+ planImageDir = path.dirname(absImg);
3312
+ }
3313
+ }
3314
+
3296
3315
  const schema = {
3297
3316
  actions: [
3298
3317
  { type: 'append_daily_log', text: '<string>' },
@@ -3303,7 +3322,7 @@ async function cmdWeb({ port, dir, open, dev }) {
3303
3322
  ]
3304
3323
  };
3305
3324
 
3306
- const prompt = `Você é o planner do sistema F.R.E.Y.A.\n\nContexto: vamos receber um input bruto do usuário e propor ações estruturadas.\nRegras: siga os arquivos de regras abaixo.\nSaída: retorne APENAS JSON válido no formato: ${JSON.stringify(schema)}\n\nRestrições:\n- NÃO use code fences (\`\`\`)\n- NÃO inclua texto extra antes/depois do JSON\n- NÃO use quebras de linha dentro de strings (transforme em uma frase única)\n\nREGRAS:${rulesText}\n\nINPUT DO USUÁRIO:\n${text}\n`;
3325
+ const prompt = `Você é o planner do sistema F.R.E.Y.A.\n\nContexto: vamos receber um input bruto do usuário e propor ações estruturadas.\nRegras: siga os arquivos de regras abaixo.\nSaída: retorne APENAS JSON válido no formato: ${JSON.stringify(schema)}\n\nRestrições:\n- NÃO use code fences (\`\`\`)\n- NÃO inclua texto extra antes/depois do JSON\n- NÃO use quebras de linha dentro de strings (transforme em uma frase única)${planImageContext}\n\nREGRAS:${rulesText}\n\nINPUT DO USUÁRIO:\n${text}\n`;
3307
3326
 
3308
3327
  // Prefer COPILOT_CMD if provided, otherwise try 'copilot'
3309
3328
  const cmd = process.env.COPILOT_CMD || 'copilot';
@@ -3312,8 +3331,26 @@ async function cmdWeb({ port, dir, open, dev }) {
3312
3331
  // so the UI can show actionable next steps instead of hard-failing.
3313
3332
  // BUG-48: pass FREYA_WORKSPACE_DIR so the Copilot subprocess uses correct DB
3314
3333
  const agentEnv = { FREYA_WORKSPACE_DIR: workspaceDir };
3334
+
3335
+ // ENAMETOOLONG fix: when prompt exceeds safe CLI arg length,
3336
+ // write to temp file and pipe via stdin instead of -p argument.
3337
+ const SAFE_ARG_LEN = 24000; // ~24KB safe threshold (Windows CreateProcess limit is 32KB)
3338
+ const useTempFile = prompt.length > SAFE_ARG_LEN;
3339
+ let tmpFile = null;
3340
+
3315
3341
  try {
3316
- const r = await run(cmd, ['-s', '--no-color', '--stream', 'off', '-p', prompt, '--allow-all-tools'], workspaceDir, agentEnv);
3342
+ let r;
3343
+ const baseArgs = ['-s', '--no-color', '--stream', 'off'];
3344
+ if (planImageDir) baseArgs.push('--add-dir', planImageDir);
3345
+
3346
+ if (useTempFile) {
3347
+ tmpFile = path.join(os.tmpdir(), `freya-prompt-${Date.now()}.txt`);
3348
+ fs.writeFileSync(tmpFile, prompt, 'utf8');
3349
+ // Use -p with short hint + pipe full prompt via stdin
3350
+ r = await run(cmd, [...baseArgs, '-p', `Read the full prompt from the file: ${tmpFile}`, '--allow-all-tools'], workspaceDir, agentEnv, prompt);
3351
+ } else {
3352
+ r = await run(cmd, [...baseArgs, '-p', prompt, '--allow-all-tools'], workspaceDir, agentEnv);
3353
+ }
3317
3354
  const out = (r.stdout + r.stderr).trim();
3318
3355
  if (r.code !== 0) {
3319
3356
  return safeJson(res, 200, {
@@ -3329,6 +3366,9 @@ async function cmdWeb({ port, dir, open, dev }) {
3329
3366
  plan: `Copilot CLI não disponível (cmd: ${cmd}).\n\nPara habilitar:\n- Windows (winget): winget install GitHub.Copilot\n- npm: npm i -g @github/copilot\n\nDepois rode \"copilot\" uma vez e faça /login.`,
3330
3367
  details: e.message || String(e)
3331
3368
  });
3369
+ } finally {
3370
+ // Clean up temp file
3371
+ if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ } }
3332
3372
  }
3333
3373
  }
3334
3374
 
@@ -3491,6 +3531,49 @@ async function cmdWeb({ port, dir, open, dev }) {
3491
3531
  const insertTask = dl.db.prepare(`INSERT INTO tasks (id, project_slug, description, category, status, metadata) VALUES (?, ?, ?, ?, ?, ?)`);
3492
3532
  const insertBlocker = dl.db.prepare(`INSERT INTO blockers (id, project_slug, title, severity, status, owner, next_action, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
3493
3533
 
3534
+ // ── Semantic deduplication (async, runs BEFORE the sync transaction) ──
3535
+ // Load pending tasks for cosine similarity comparison
3536
+ const pendingTasks = dl.db.prepare("SELECT id, description, project_slug FROM tasks WHERE status = 'PENDING'").all();
3537
+ let pendingEmbeddings = []; // [{ id, description, project_slug, vector }]
3538
+ try {
3539
+ const { defaultEmbedder } = require(path.join(workspaceDir, 'scripts', 'lib', 'Embedder.js'));
3540
+ for (const pt of pendingTasks) {
3541
+ try {
3542
+ const vec = await defaultEmbedder.embedText(pt.description);
3543
+ pendingEmbeddings.push({ id: pt.id, description: pt.description, project_slug: pt.project_slug, vector: vec });
3544
+ } catch { /* skip if embedding fails for individual task */ }
3545
+ }
3546
+ } catch (embErr) {
3547
+ // Embedder not available — fall back to exact-match only
3548
+ console.error('[dedup] Semantic dedup unavailable:', embErr.message);
3549
+ }
3550
+
3551
+ // Pre-compute semantic duplicates for each action
3552
+ const semanticDupMap = new Map(); // action index → existing task id (if duplicate)
3553
+ const SIMILARITY_THRESHOLD = 0.78;
3554
+ if (pendingEmbeddings.length > 0) {
3555
+ try {
3556
+ const { defaultEmbedder } = require(path.join(workspaceDir, 'scripts', 'lib', 'Embedder.js'));
3557
+ for (let ai = 0; ai < actions.length; ai++) {
3558
+ const a = actions[ai];
3559
+ if (!a || a.type !== 'create_task') continue;
3560
+ const desc = normalizeWhitespace(a.description);
3561
+ if (!desc) continue;
3562
+ try {
3563
+ const newVec = await defaultEmbedder.embedText(desc);
3564
+ let bestScore = 0, bestMatch = null;
3565
+ for (const pe of pendingEmbeddings) {
3566
+ const score = defaultEmbedder.cosineSimilarity(newVec, pe.vector);
3567
+ if (score > bestScore) { bestScore = score; bestMatch = pe; }
3568
+ }
3569
+ if (bestScore >= SIMILARITY_THRESHOLD && bestMatch) {
3570
+ semanticDupMap.set(ai, { existingId: bestMatch.id, existingDesc: bestMatch.description, score: bestScore });
3571
+ }
3572
+ } catch { /* skip */ }
3573
+ }
3574
+ } catch { /* embedder not available */ }
3575
+ }
3576
+
3494
3577
  // BUG-31: Move deduplication queries INSIDE the transaction to eliminate TOCTOU race
3495
3578
  const applyTx = dl.db.transaction((actionsToApply) => {
3496
3579
  // Query for existing keys inside the transaction for atomicity
@@ -3499,7 +3582,8 @@ async function cmdWeb({ port, dir, open, dev }) {
3499
3582
  const recentBlockers = dl.db.prepare("SELECT title FROM blockers WHERE created_at >= datetime('now', '-1 day')").all();
3500
3583
  const existingBlockerKeys24h = new Set(recentBlockers.map(b => sha1(normalizeTextForKey(b.title))));
3501
3584
 
3502
- for (const a of actionsToApply) {
3585
+ for (let ai = 0; ai < actionsToApply.length; ai++) {
3586
+ const a = actionsToApply[ai];
3503
3587
  if (!a || typeof a !== 'object') continue;
3504
3588
  const type = String(a.type || '').trim();
3505
3589
 
@@ -3509,8 +3593,25 @@ async function cmdWeb({ port, dir, open, dev }) {
3509
3593
  if (!description) continue;
3510
3594
  const projectSlug = String(a.projectSlug || '').trim() || inferProjectSlug(description, slugMap);
3511
3595
  const streamSlug = String(a.streamSlug || '').trim();
3596
+
3597
+ // Exact-match dedup (24h window)
3512
3598
  const key = sha1(normalizeTextForKey((projectSlug ? projectSlug + ' ' : '') + description));
3513
3599
  if (existingTaskKeys24h.has(key)) { applied.tasksSkipped++; continue; }
3600
+
3601
+ // Semantic dedup (all pending tasks)
3602
+ if (semanticDupMap.has(ai)) {
3603
+ const dup = semanticDupMap.get(ai);
3604
+ applied.tasksSkipped++;
3605
+ if (!applied.semanticDups) applied.semanticDups = [];
3606
+ applied.semanticDups.push({
3607
+ newDesc: description,
3608
+ existingId: dup.existingId,
3609
+ existingDesc: dup.existingDesc,
3610
+ similarity: Math.round(dup.score * 100) + '%'
3611
+ });
3612
+ continue;
3613
+ }
3614
+
3514
3615
  const category = validTaskCats.has(String(a.category || '').trim()) ? String(a.category).trim() : 'DO_NOW';
3515
3616
  const priority = normPriority(a.priority);
3516
3617
 
@@ -3710,23 +3811,48 @@ async function cmdWeb({ port, dir, open, dev }) {
3710
3811
  console.error('[oracle] RAG search failed (embedder/sharp unavailable), continuing without context:', ragErr.message);
3711
3812
  }
3712
3813
 
3713
- const prompt = `Você é o agente Oracle do sistema F.R.E.Y.A.\n\nSiga estritamente os arquivos de regras abaixo.\nResponda de forma analítica e consultiva.\n${ragContext}\n\nREGRAS:${rulesText}\n\nCONSULTA DO USUÁRIO:\n${query}\n`;
3814
+ // Build image context for the prompt (Copilot reads files via --allow-all-tools)
3815
+ let imageContext = '';
3816
+ if (imagePath) {
3817
+ const absImg = path.isAbsolute(imagePath) ? imagePath : path.join(workspaceDir, imagePath);
3818
+ if (exists(absImg)) {
3819
+ imageContext = `\n\n[IMAGEM ANEXADA]\nO usuário anexou uma imagem. Leia e analise o arquivo de imagem localizado em: ${absImg}\nInclua a análise da imagem na sua resposta.\n`;
3820
+ }
3821
+ }
3822
+
3823
+ const prompt = `Você é o agente Oracle do sistema F.R.E.Y.A.\n\nSiga estritamente os arquivos de regras abaixo.\nResponda de forma analítica e consultiva.\n${ragContext}${imageContext}\n\nREGRAS:${rulesText}\n\nCONSULTA DO USUÁRIO:\n${query}\n`;
3714
3824
 
3715
3825
  const cmd = process.env.COPILOT_CMD || 'copilot';
3716
3826
 
3717
3827
  // BUG-48: pass FREYA_WORKSPACE_DIR so the Copilot subprocess uses correct DB
3718
3828
  const oracleEnv = { FREYA_WORKSPACE_DIR: workspaceDir };
3719
3829
  try {
3720
- // Build copilot args; add image if user pasted a screenshot
3721
3830
  const copilotArgs = ['-s', '--no-color', '--stream', 'off'];
3831
+ // Allow Copilot to access the image file via its built-in tools
3722
3832
  if (imagePath) {
3723
3833
  const absImg = path.isAbsolute(imagePath) ? imagePath : path.join(workspaceDir, imagePath);
3724
3834
  if (exists(absImg)) {
3725
- copilotArgs.push('--add-image', absImg);
3835
+ copilotArgs.push('--add-dir', path.dirname(absImg));
3726
3836
  }
3727
3837
  }
3728
- copilotArgs.push('-p', prompt);
3729
- const r = await run(cmd, copilotArgs, workspaceDir, oracleEnv);
3838
+ copilotArgs.push('--allow-all-tools', '-p', prompt);
3839
+
3840
+ // ENAMETOOLONG fix: use stdin for large prompts
3841
+ const SAFE_ARG_LEN = 24000;
3842
+ let oracleTmpFile = null;
3843
+ let r;
3844
+ if (prompt.length > SAFE_ARG_LEN) {
3845
+ oracleTmpFile = path.join(os.tmpdir(), `freya-oracle-${Date.now()}.txt`);
3846
+ fs.writeFileSync(oracleTmpFile, prompt, 'utf8');
3847
+ // Replace -p with short hint; pipe full prompt via stdin
3848
+ const idx = copilotArgs.indexOf('-p');
3849
+ if (idx !== -1) copilotArgs.splice(idx, 2); // remove -p and prompt
3850
+ copilotArgs.push('-p', `Read the full prompt from: ${oracleTmpFile}`);
3851
+ r = await run(cmd, copilotArgs, workspaceDir, oracleEnv, prompt);
3852
+ } else {
3853
+ r = await run(cmd, copilotArgs, workspaceDir, oracleEnv);
3854
+ }
3855
+ if (oracleTmpFile) { try { fs.unlinkSync(oracleTmpFile); } catch (_) { /* ignore */ } }
3730
3856
  const out = (r.stdout + r.stderr).trim();
3731
3857
  if (r.code !== 0) {
3732
3858
  return safeJson(res, 200, { ok: false, answer: 'Falha na busca do agente Oracle:\n' + (out || 'Exit code != 0'), sessionId });
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
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",
7
7
  "migrate": "node scripts/migrate-data.js",
8
- "sm-weekly": "node scripts/generate-sm-weekly-report.js",
8
+ "sm-weekly": "node scripts/generate-sm-weekly-report.js",
9
9
  "daily": "node scripts/generate-daily-summary.js",
10
10
  "status": "node scripts/generate-executive-report.js",
11
11
  "blockers": "node scripts/generate-blockers-report.js",
@@ -34,4 +34,4 @@
34
34
  "pdf-lib": "^1.17.1",
35
35
  "sql.js": "^1.12.0"
36
36
  }
37
- }
37
+ }