@polderlabs/bizar 4.5.2 → 4.7.1

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 (123) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/logger.mjs +71 -0
  17. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  18. package/bizar-dash/src/server/metrics.mjs +193 -0
  19. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  20. package/bizar-dash/src/server/routes/chat.mjs +20 -3
  21. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  22. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  23. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  24. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  25. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  26. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  27. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  28. package/bizar-dash/src/server/server.mjs +79 -0
  29. package/bizar-dash/src/web/App.tsx +8 -1
  30. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  31. package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
  32. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  33. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  34. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  35. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  36. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  37. package/bizar-dash/src/web/locales/en.json +52 -0
  38. package/bizar-dash/src/web/main.tsx +5 -0
  39. package/bizar-dash/src/web/styles/main.css +70 -8
  40. package/bizar-dash/src/web/views/Activity.tsx +35 -18
  41. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  42. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  43. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  44. package/bizar-dash/src/web/views/History.tsx +94 -76
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  46. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  47. package/bizar-dash/src/web/views/Overview.tsx +19 -9
  48. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  49. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  50. package/bizar-dash/src/web/views/Settings.tsx +97 -1745
  51. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  52. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  53. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  54. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  55. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  56. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  57. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  58. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  59. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  60. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  61. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  62. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  63. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  64. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  65. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  66. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  67. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  68. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  69. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  70. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  71. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  72. package/bizar-dash/tests/a11y.test.tsx +206 -0
  73. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  74. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  75. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  76. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  77. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  78. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  79. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  80. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  81. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  82. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  83. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  84. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  85. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  86. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  87. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  88. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  89. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  90. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  91. package/bizar-dash/tests/logger.test.mjs +207 -0
  92. package/bizar-dash/tests/metrics.test.mjs +183 -0
  93. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  94. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  95. package/bizar-dash/tests/setup.ts +7 -0
  96. package/bizar-dash/vitest.config.ts +13 -0
  97. package/cli/artifact-cli.mjs +605 -0
  98. package/cli/artifact-render.mjs +621 -0
  99. package/cli/artifact-server.mjs +847 -0
  100. package/cli/artifact.mjs +38 -2096
  101. package/cli/bg.mjs +5 -13
  102. package/cli/bin.mjs +221 -1350
  103. package/cli/commands/artifact.mjs +20 -0
  104. package/cli/commands/dash.mjs +160 -0
  105. package/cli/commands/headroom.mjs +204 -0
  106. package/cli/commands/install.mjs +169 -0
  107. package/cli/commands/memory.mjs +25 -0
  108. package/cli/commands/minimax.mjs +285 -0
  109. package/cli/commands/mod.mjs +185 -0
  110. package/cli/commands/service.mjs +65 -0
  111. package/cli/commands/usage.mjs +109 -0
  112. package/cli/commands/util.mjs +459 -0
  113. package/cli/digest.mjs +149 -0
  114. package/cli/doctor.mjs +1 -13
  115. package/cli/provision.mjs +2 -13
  116. package/cli/service-controller.mjs +1 -11
  117. package/cli/service.mjs +1 -11
  118. package/cli/utils.mjs +41 -1
  119. package/package.json +6 -1
  120. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  121. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  122. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  123. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
@@ -0,0 +1,558 @@
1
+ /**
2
+ * src/server/digest-store.mjs
3
+ *
4
+ * v4.8.0 — Auto-generated weekly digests.
5
+ *
6
+ * Aggregates the last 7 days of activity (tasks, memory notes, chat
7
+ * sessions, schedules, background agents, token usage) into a human-
8
+ * readable markdown digest, saved to the digests directory.
9
+ *
10
+ * Storage:
11
+ * Primary: ~/.local/share/bizar/digests/weekly-YYYY-MM-DD.md
12
+ * Secondary: <projectRoot>/.obsidian/digests/ (if vault exists)
13
+ *
14
+ * The digest index metadata is stored in a companion JSON index at
15
+ * ~/.local/share/bizar/digests/.index.json so list/del operations
16
+ * stay fast without re-reading every markdown file.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync, renameSync } from 'node:fs';
20
+ import { join, basename, dirname } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import { tasksStore } from './tasks-store.mjs';
23
+ import { schedulesStore } from './schedules-store.mjs';
24
+ import { activityLog } from './activity-log.mjs';
25
+ import { backgroundStore } from './background-store.mjs';
26
+ import { queryUsage } from './minimax-usage-store.mjs';
27
+
28
+ // Allow test override without patching process.env.HOME (which can be racy
29
+ // with ESM module-level evaluation order in the Node test runner).
30
+ const STORE_HOME = process.env.BIZAR_STORE_HOME
31
+ ? process.env.BIZAR_STORE_HOME
32
+ : join(homedir(), '.local', 'share', 'bizar');
33
+ const DIGESTS_DIR = join(STORE_HOME, 'digests');
34
+ const INDEX_FILE = join(DIGESTS_DIR, '.index.json');
35
+
36
+ // ── Helpers ─────────────────────────────────────────────────────────────────
37
+
38
+ function ensureDir(dir) {
39
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
+ }
41
+
42
+ function atomicWriteJson(filePath, data) {
43
+ const tmp = `${filePath}.tmp.${process.pid}`;
44
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
45
+ renameSync(tmp, filePath);
46
+ }
47
+
48
+ function safeReadJSON(file, fallback = null) {
49
+ try {
50
+ if (!existsSync(file)) return fallback;
51
+ const text = readFileSync(file, 'utf8');
52
+ if (!text.trim()) return fallback;
53
+ return JSON.parse(text);
54
+ } catch {
55
+ return fallback;
56
+ }
57
+ }
58
+
59
+ function dateStr(d) {
60
+ const yr = d.getFullYear();
61
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
62
+ const da = String(d.getDate()).padStart(2, '0');
63
+ return `${yr}-${mo}-${da}`;
64
+ }
65
+
66
+ function humanDate(iso) {
67
+ if (!iso) return '';
68
+ const d = new Date(iso);
69
+ return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
70
+ }
71
+
72
+ function loadIndex() {
73
+ return safeReadJSON(INDEX_FILE, { digests: [] });
74
+ }
75
+
76
+ function saveIndex(index) {
77
+ ensureDir(DIGESTS_DIR);
78
+ atomicWriteJson(INDEX_FILE, index);
79
+ }
80
+
81
+ function digestFilename(weekStart) {
82
+ return `weekly-${weekStart}.md`;
83
+ }
84
+
85
+ function digestPath(weekStart) {
86
+ return join(DIGESTS_DIR, digestFilename(weekStart));
87
+ }
88
+
89
+ function buildDigestId() {
90
+ return 'dig_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
91
+ }
92
+
93
+ /**
94
+ * Compute the date range for digest generation.
95
+ * If weekStart/End are not provided, defaults to last 7 days.
96
+ */
97
+ export function computeWeekRange(weekStart, weekEnd) {
98
+ const now = new Date();
99
+ const end = weekEnd ? new Date(weekEnd) : new Date(now);
100
+ const start = weekStart
101
+ ? new Date(weekStart)
102
+ : new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000);
103
+ return { weekStart: dateStr(start), weekEnd: dateStr(end) };
104
+ }
105
+
106
+ // ── Section query helpers ───────────────────────────────────────────────────
107
+
108
+ function queryTasksCompleted({ weekStart, weekEnd }) {
109
+ const startMs = new Date(weekStart).getTime();
110
+ const endMs = new Date(weekEnd + 'T23:59:59.999Z').getTime();
111
+ try {
112
+ const tasks = tasksStore.loadTasks('default', { includeArchived: true });
113
+ return tasks
114
+ .filter((t) => {
115
+ if (t.status !== 'done') return false;
116
+ const completed = t.completedAt ? new Date(t.completedAt).getTime() : 0;
117
+ return completed >= startMs && completed <= endMs;
118
+ })
119
+ .map((t) => ({
120
+ id: t.id,
121
+ title: t.title,
122
+ completedAt: t.completedAt,
123
+ priority: t.priority,
124
+ }));
125
+ } catch {
126
+ return { error: 'tasks store unavailable' };
127
+ }
128
+ }
129
+
130
+ function queryTasksCreated({ weekStart, weekEnd }) {
131
+ const startMs = new Date(weekStart).getTime();
132
+ const endMs = new Date(weekEnd + 'T23:59:59.999Z').getTime();
133
+ try {
134
+ const tasks = tasksStore.loadTasks('default', { includeArchived: true });
135
+ return tasks
136
+ .filter((t) => {
137
+ const created = t.createdAt ? new Date(t.createdAt).getTime() : 0;
138
+ return created >= startMs && created <= endMs;
139
+ })
140
+ .map((t) => ({
141
+ id: t.id,
142
+ title: t.title,
143
+ createdAt: t.createdAt,
144
+ priority: t.priority,
145
+ }));
146
+ } catch {
147
+ return { error: 'tasks store unavailable' };
148
+ }
149
+ }
150
+
151
+ function queryMemoryWrites({ weekStart, weekEnd, projectRoot }) {
152
+ const startMs = new Date(weekStart).getTime();
153
+ const endMs = new Date(weekEnd + 'T23:59:59.999Z').getTime();
154
+ const vaultRoot = join(projectRoot, '.obsidian');
155
+ if (!existsSync(vaultRoot)) return [];
156
+ const notes = [];
157
+ function walk(dir, prefix) {
158
+ let entries;
159
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
160
+ for (const e of entries) {
161
+ const full = join(dir, e.name);
162
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
163
+ if (e.isDirectory()) {
164
+ if (e.name.startsWith('.')) continue;
165
+ walk(full, rel);
166
+ } else if (e.name.endsWith('.md')) {
167
+ try {
168
+ const st = statSync(full);
169
+ if (st.mtimeMs >= startMs && st.mtimeMs <= endMs) {
170
+ notes.push({ relPath: rel, mtime: st.mtimeMs });
171
+ }
172
+ } catch { /* skip unreadable */ }
173
+ }
174
+ }
175
+ }
176
+ walk(vaultRoot, '');
177
+ return notes;
178
+ }
179
+
180
+ function queryChatSessions({ weekStart, weekEnd }) {
181
+ const startMs = new Date(weekStart).getTime();
182
+ const endMs = new Date(weekEnd + 'T23:59:59.999Z').getTime();
183
+ try {
184
+ const recent = activityLog.recent(500);
185
+ return recent.filter((e) => {
186
+ const ts = e.ts ? new Date(e.ts).getTime() : 0;
187
+ return ts >= startMs && ts <= endMs;
188
+ });
189
+ } catch {
190
+ return [];
191
+ }
192
+ }
193
+
194
+ function querySchedulesFired({ weekStart, weekEnd }) {
195
+ const startMs = new Date(weekStart).getTime();
196
+ const endMs = new Date(weekEnd + 'T23:59:59.999Z').getTime();
197
+ const fired = [];
198
+ try {
199
+ const schedules = schedulesStore.list('default');
200
+ for (const s of schedules) {
201
+ const history = s.history || [];
202
+ for (const h of history) {
203
+ const ts = h.ts ? new Date(h.ts).getTime() : 0;
204
+ if (ts >= startMs && ts <= endMs) {
205
+ fired.push({ scheduleName: s.name, result: h.result, ts: h.ts });
206
+ }
207
+ }
208
+ }
209
+ } catch {
210
+ // ignore
211
+ }
212
+ return fired;
213
+ }
214
+
215
+ function queryBgAgentsCompleted({ weekStart, weekEnd }) {
216
+ const startMs = new Date(weekStart).getTime();
217
+ const endMs = new Date(weekEnd + 'T23:59:59.999Z').getTime();
218
+ try {
219
+ const agents = backgroundStore.list();
220
+ return agents.filter((a) => {
221
+ const ts = a.completedAt ? new Date(a.completedAt).getTime() : 0;
222
+ return ts >= startMs && ts <= endMs;
223
+ });
224
+ } catch {
225
+ return [];
226
+ }
227
+ }
228
+
229
+ function queryUsageStats({ weekStart, weekEnd }) {
230
+ try {
231
+ const startMs = new Date(weekStart).getTime();
232
+ const endMs = new Date(weekEnd + 'T23:59:59.999Z').getTime();
233
+ return queryUsage({ range: 'custom', from: startMs, to: endMs });
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ // ── Public API ──────────────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * The ordered list of digest sections and their label/query tuples.
243
+ */
244
+ export const DIGEST_SECTIONS = [
245
+ { kind: 'tasks', label: 'Tasks completed', query: queryTasksCompleted },
246
+ { kind: 'tasks-created', label: 'Tasks created', query: queryTasksCreated },
247
+ { kind: 'memory-writes', label: 'Memory notes written', query: queryMemoryWrites },
248
+ { kind: 'chat-sessions', label: 'Chat sessions', query: queryChatSessions },
249
+ { kind: 'schedules-fired', label: 'Schedules fired', query: querySchedulesFired },
250
+ { kind: 'bg-agents-completed', label: 'Background agents completed', query: queryBgAgentsCompleted },
251
+ { kind: 'usage-stats', label: 'Token usage', query: queryUsageStats },
252
+ ];
253
+
254
+ /**
255
+ * Generate a weekly digest for the given date range.
256
+ *
257
+ * @param {object} opts
258
+ * @param {string} [opts.weekStart] — ISO date string YYYY-MM-DD (inclusive)
259
+ * @param {string} [opts.weekEnd] — ISO date string YYYY-MM-DD (inclusive)
260
+ * @param {string} [opts.projectRoot]
261
+ * @param {boolean} [opts.dryRun] — if true, returns markdown but does NOT save
262
+ * @returns {{ markdown: string, sections: object, weekStart: string, weekEnd: string, dryRun: boolean }}
263
+ */
264
+ export async function generateWeeklyDigest({ weekStart, weekEnd, projectRoot = process.cwd(), dryRun = false } = {}) {
265
+ const range = computeWeekRange(weekStart, weekEnd);
266
+ const start = range.weekStart;
267
+ const end = range.weekEnd;
268
+ const ctx = { weekStart: start, weekEnd: end, projectRoot };
269
+
270
+ const sections = {};
271
+ for (const section of DIGEST_SECTIONS) {
272
+ try {
273
+ sections[section.kind] = await section.query(ctx);
274
+ } catch (err) {
275
+ sections[section.kind] = { error: err.message };
276
+ }
277
+ }
278
+
279
+ const markdown = buildDigestMarkdown({ weekStart: start, weekEnd: end, sections });
280
+
281
+ return { markdown, sections, weekStart: start, weekEnd: end, dryRun };
282
+ }
283
+
284
+ /**
285
+ * Build the markdown string from section data.
286
+ */
287
+ function buildDigestMarkdown({ weekStart, weekEnd, sections }) {
288
+ const now = new Date();
289
+ const lines = [];
290
+
291
+ lines.push('---');
292
+ lines.push(`title: "Weekly Digest ${weekStart} – ${weekEnd}"`);
293
+ lines.push(`date: ${dateStr(now)}`);
294
+ lines.push('type: digest');
295
+ lines.push(`weekStart: ${weekStart}`);
296
+ lines.push(`weekEnd: ${weekEnd}`);
297
+ lines.push('---');
298
+ lines.push('');
299
+ lines.push('# Weekly Digest');
300
+ lines.push('');
301
+ lines.push(`**Week:** ${humanDate(weekStart)} – ${humanDate(weekEnd)}`);
302
+ lines.push(`**Generated:** ${now.toLocaleString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}`);
303
+ lines.push('');
304
+
305
+ // ── Tasks completed ──
306
+ lines.push('## Tasks completed');
307
+ const tasksCompleted = sections['tasks'] || [];
308
+ if (Array.isArray(tasksCompleted) && tasksCompleted.length > 0) {
309
+ for (const t of tasksCompleted) {
310
+ const completedDate = t.completedAt ? humanDate(t.completedAt) : 'this week';
311
+ lines.push(`- **${t.title}** (\`#${t.id}\`) — Completed ${completedDate}`);
312
+ }
313
+ } else {
314
+ lines.push('*No tasks completed this week.*');
315
+ }
316
+ lines.push('');
317
+
318
+ // ── Tasks created ──
319
+ lines.push('## Tasks created');
320
+ const tasksCreated = sections['tasks-created'] || [];
321
+ if (Array.isArray(tasksCreated) && tasksCreated.length > 0) {
322
+ for (const t of tasksCreated) {
323
+ const createdDate = t.createdAt ? humanDate(t.createdAt) : 'this week';
324
+ lines.push(`- **${t.title}** (\`#${t.id}\`) — Created ${createdDate}`);
325
+ }
326
+ } else {
327
+ lines.push('*No tasks created this week.*');
328
+ }
329
+ lines.push('');
330
+
331
+ // ── Memory notes written ──
332
+ lines.push('## Memory notes written');
333
+ const memoryWrites = sections['memory-writes'] || [];
334
+ if (Array.isArray(memoryWrites) && memoryWrites.length > 0) {
335
+ for (const n of memoryWrites) {
336
+ const noteDate = n.mtime ? humanDate(new Date(n.mtime).toISOString()) : '';
337
+ lines.push(`- \`${n.relPath}\` — ${noteDate}`);
338
+ }
339
+ } else {
340
+ lines.push('*No memory notes written this week.*');
341
+ }
342
+ lines.push('');
343
+
344
+ // ── Chat sessions ──
345
+ lines.push('## Chat sessions');
346
+ const chatSessions = sections['chat-sessions'] || [];
347
+ if (Array.isArray(chatSessions) && chatSessions.length > 0) {
348
+ const byKind = {};
349
+ for (const s of chatSessions) {
350
+ const kind = s.kind || 'unknown';
351
+ byKind[kind] = (byKind[kind] || 0) + 1;
352
+ }
353
+ const total = chatSessions.length;
354
+ const kindSummary = Object.entries(byKind)
355
+ .map(([k, c]) => `${c} ${k}`)
356
+ .join(', ');
357
+ lines.push(`- **${total}** session(s) (${kindSummary})`);
358
+ } else {
359
+ lines.push('*No chat sessions recorded this week.*');
360
+ }
361
+ lines.push('');
362
+
363
+ // ── Schedules fired ──
364
+ lines.push('## Schedules fired');
365
+ const schedulesFired = sections['schedules-fired'] || [];
366
+ if (Array.isArray(schedulesFired) && schedulesFired.length > 0) {
367
+ const byName = {};
368
+ for (const s of schedulesFired) {
369
+ const name = s.scheduleName || 'unknown';
370
+ byName[name] = (byName[name] || 0) + 1;
371
+ }
372
+ for (const [name, count] of Object.entries(byName)) {
373
+ lines.push(`- **${name}** — fired ${count} time(s)`);
374
+ }
375
+ } else {
376
+ lines.push('*No schedules fired this week.*');
377
+ }
378
+ lines.push('');
379
+
380
+ // ── Background agents completed ──
381
+ lines.push('## Background agents completed');
382
+ const bgAgents = sections['bg-agents-completed'] || [];
383
+ const comp = Array.isArray(bgAgents) ? bgAgents.filter((a) => a.status === 'done').length : 0;
384
+ const fail = Array.isArray(bgAgents) ? bgAgents.filter((a) => a.status === 'failed').length : 0;
385
+ lines.push(`- **${comp}** completed, **${fail}** failed`);
386
+ if (Array.isArray(bgAgents) && bgAgents.length > 0) {
387
+ lines.push('');
388
+ for (const a of bgAgents) {
389
+ const agentDate = a.completedAt ? humanDate(a.completedAt) : '';
390
+ lines.push(`- \`${a.instanceId || 'unknown'}\` — ${a.status} — ${agentDate}`);
391
+ }
392
+ }
393
+ lines.push('');
394
+
395
+ // ── Token usage ──
396
+ lines.push('## Token usage');
397
+ const usageStats = sections['usage-stats'];
398
+ if (usageStats && usageStats.totals) {
399
+ const t = usageStats.totals;
400
+ lines.push(`- Total requests: **${t.requests}**`);
401
+ lines.push(`- Total tokens: **${t.totalTokens.toLocaleString()}**`);
402
+ lines.push(`- Prompt tokens: **${t.promptTokens.toLocaleString()}**`);
403
+ lines.push(`- Completion tokens: **${t.completionTokens.toLocaleString()}**`);
404
+ lines.push(`- Estimated cost: **$${t.costEstimate.toFixed(4)}**`);
405
+ lines.push(`- Errors: **${t.errors}**`);
406
+ } else {
407
+ lines.push('*Token usage data not available.*');
408
+ }
409
+ lines.push('');
410
+
411
+ // ── Footer ──
412
+ lines.push('---');
413
+ lines.push('_Generated by BizarHarness v4.8.0 — Weekly Digest_');
414
+
415
+ return lines.join('\n');
416
+ }
417
+
418
+ /**
419
+ * Save a digest to disk.
420
+ *
421
+ * @param {object} opts
422
+ * @param {string} opts.markdown — the markdown content
423
+ * @param {string} opts.weekStart — YYYY-MM-DD for the filename
424
+ * @param {string} [opts.projectRoot] — optional, to also write to vault
425
+ * @returns {{ ok: boolean, paths: string[] }}
426
+ */
427
+ export async function saveDigest({ markdown, weekStart, projectRoot }) {
428
+ ensureDir(DIGESTS_DIR);
429
+ const paths = [];
430
+
431
+ // Primary: digests dir
432
+ const primaryPath = digestPath(weekStart);
433
+ writeFileSync(primaryPath, markdown, 'utf8');
434
+ paths.push(primaryPath);
435
+
436
+ // Update the index
437
+ const index = loadIndex();
438
+ const st = statSync(primaryPath);
439
+ const existingIdx = index.digests.findIndex((d) => d.weekStart === weekStart);
440
+ const entry = {
441
+ id: buildDigestId(),
442
+ weekStart,
443
+ createdAt: new Date().toISOString(),
444
+ sizeBytes: st.size,
445
+ path: primaryPath,
446
+ filename: digestFilename(weekStart),
447
+ };
448
+ if (existingIdx >= 0) {
449
+ index.digests[existingIdx] = { ...index.digests[existingIdx], ...entry };
450
+ } else {
451
+ index.digests.push(entry);
452
+ }
453
+ index.digests.sort((a, b) => b.weekStart.localeCompare(a.weekStart));
454
+ saveIndex(index);
455
+
456
+ // Secondary: project vault
457
+ if (projectRoot) {
458
+ const vaultDigestsDir = join(projectRoot, '.obsidian', 'digests');
459
+ if (existsSync(join(projectRoot, '.obsidian'))) {
460
+ ensureDir(vaultDigestsDir);
461
+ const vaultFile = join(vaultDigestsDir, digestFilename(weekStart));
462
+ writeFileSync(vaultFile, markdown, 'utf8');
463
+ paths.push(vaultFile);
464
+ }
465
+ }
466
+
467
+ return { ok: true, paths };
468
+ }
469
+
470
+ /**
471
+ * List available digests, sorted by weekStart descending.
472
+ *
473
+ * @param {object} [opts]
474
+ * @param {number} [opts.limit=20]
475
+ * @returns {Array<{ path: string, weekStart: string, createdAt: string, sizeBytes: number }>}
476
+ */
477
+ export async function listDigests({ limit = 20 } = {}) {
478
+ const index = loadIndex();
479
+ return index.digests.slice(0, Math.max(1, limit));
480
+ }
481
+
482
+ /**
483
+ * Read a digest by its file path.
484
+ *
485
+ * @param {string} filePath
486
+ * @returns {{ content: string, weekStart: string, sizeBytes: number, path: string } | null}
487
+ */
488
+ export async function getDigest(filePath) {
489
+ if (!filePath || !existsSync(filePath)) return null;
490
+ try {
491
+ const content = readFileSync(filePath, 'utf8');
492
+ const st = statSync(filePath);
493
+ const fn = basename(filePath);
494
+ const m = fn.match(/weekly-(\d{4}-\d{2}-\d{2})/);
495
+ const weekStart = m ? m[1] : null;
496
+ return { content, weekStart, sizeBytes: st.size, path: filePath };
497
+ } catch {
498
+ return null;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Delete a digest by its file path.
504
+ *
505
+ * @param {string} filePath
506
+ * @returns {{ ok: boolean, error?: string }}
507
+ */
508
+ export async function deleteDigest(filePath) {
509
+ if (!filePath) return { ok: false, error: 'no path provided' };
510
+ const fn = basename(filePath);
511
+ const m = fn.match(/weekly-(\d{4}-\d{2}-\d{2})/);
512
+ const weekStart = m ? m[1] : null;
513
+
514
+ if (weekStart) {
515
+ const index = loadIndex();
516
+ index.digests = index.digests.filter((d) => d.weekStart !== weekStart);
517
+ saveIndex(index);
518
+ }
519
+
520
+ try {
521
+ if (existsSync(filePath)) unlinkSync(filePath);
522
+ return { ok: true };
523
+ } catch (err) {
524
+ return { ok: false, error: err.message };
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Generate and save a weekly digest in one call.
530
+ *
531
+ * @param {object} opts
532
+ * @param {string} [opts.weekStart]
533
+ * @param {string} [opts.weekEnd]
534
+ * @param {string} [opts.projectRoot]
535
+ * @param {boolean} [opts.dryRun] — if true, just return without saving
536
+ * @returns {Promise<object>}
537
+ */
538
+ export async function generateAndSave({ weekStart, weekEnd, projectRoot, dryRun = false } = {}) {
539
+ const result = await generateWeeklyDigest({ weekStart, weekEnd, projectRoot, dryRun });
540
+ if (dryRun) return result;
541
+ const saveResult = await saveDigest({ markdown: result.markdown, weekStart: result.weekStart, projectRoot });
542
+ return { ...result, saveResult };
543
+ }
544
+
545
+ // ── Test reset (for tests only) ─────────────────────────────────────────────
546
+
547
+ /**
548
+ * Wipe all digest data. For test isolation only.
549
+ */
550
+ export function __resetStoreForTests() {
551
+ try {
552
+ const index = loadIndex();
553
+ for (const d of index.digests) {
554
+ try { unlinkSync(d.path); } catch { /* ignore */ }
555
+ }
556
+ try { unlinkSync(INDEX_FILE); } catch { /* ignore */ }
557
+ } catch { /* ignore */ }
558
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * src/server/lib/rate-limit.mjs
3
+ *
4
+ * v4.8.0 — Per-IP token bucket rate limiter for /api/chat and /api/v2/event.
5
+ *
6
+ * Implementation notes
7
+ * ────────────────────
8
+ * Each scope + remote IP pair gets its own bucket. Buckets refill
9
+ * continuously at `refillPerSecond` tokens / second, capped at
10
+ * `capacity`. Every accepted request consumes one token; rejected
11
+ * requests do not consume, and include `Retry-After` so polite clients
12
+ * can back off.
13
+ *
14
+ * Why a token bucket and not a sliding window?
15
+ * - O(1) memory per IP (two floats + a timestamp).
16
+ * - Refill is implicit — no periodic timer, no sweep job.
17
+ * - Burst tolerance is built in (up to `capacity` in one go).
18
+ *
19
+ * Headers set on every accepted response:
20
+ * - X-RateLimit-Limit : capacity
21
+ * - X-RateLimit-Remaining : Math.floor(tokens) after consumption
22
+ * - X-RateLimit-Reset : seconds until full (omitted on success
23
+ * because it's not well-defined at
24
+ * `capacity`; only emitted on 429)
25
+ *
26
+ * Headers set on a 429 response:
27
+ * - Retry-After : seconds until 1 token is available
28
+ * - X-RateLimit-Limit : capacity
29
+ * - X-RateLimit-Remaining : 0
30
+ * - X-RateLimit-Reset : seconds until 1 token is available
31
+ *
32
+ * The bucket map is process-global. Tests call `clearBuckets()` to
33
+ * reset between cases so they don't leak state.
34
+ */
35
+
36
+ const buckets = new Map();
37
+
38
+ /**
39
+ * @param {object} opts
40
+ * @param {number} opts.capacity — max tokens per bucket
41
+ * @param {number} opts.refillPerSecond — tokens added per second
42
+ * @param {string} [opts.scope='global'] — bucket-key prefix; isolates chat vs event buckets
43
+ * @returns {import('express').RequestHandler}
44
+ */
45
+ export function createRateLimiter({ capacity, refillPerSecond, scope = 'global' }) {
46
+ if (!Number.isFinite(capacity) || capacity <= 0) {
47
+ throw new TypeError('createRateLimiter: capacity must be a positive number');
48
+ }
49
+ if (!Number.isFinite(refillPerSecond) || refillPerSecond <= 0) {
50
+ throw new TypeError('createRateLimiter: refillPerSecond must be a positive number');
51
+ }
52
+ if (typeof scope !== 'string' || !scope) {
53
+ throw new TypeError('createRateLimiter: scope must be a non-empty string');
54
+ }
55
+
56
+ return function rateLimit(req, res, next) {
57
+ const ip = req.ip || (req.socket && req.socket.remoteAddress) || 'unknown';
58
+ const key = `${scope}:${ip}`;
59
+ const now = Date.now();
60
+ const bucket = buckets.get(key) || { tokens: capacity, lastRefill: now };
61
+
62
+ // Refill based on elapsed wall-clock time. Cap at capacity.
63
+ const elapsedSec = (now - bucket.lastRefill) / 1000;
64
+ bucket.tokens = Math.min(capacity, bucket.tokens + elapsedSec * refillPerSecond);
65
+ bucket.lastRefill = now;
66
+
67
+ if (bucket.tokens < 1) {
68
+ // Compute retry-after: seconds until we have at least 1 token.
69
+ const deficit = 1 - bucket.tokens;
70
+ const retryAfterSec = Math.max(1, Math.ceil(deficit / refillPerSecond));
71
+ res.set('Retry-After', String(retryAfterSec));
72
+ res.set('X-RateLimit-Limit', String(capacity));
73
+ res.set('X-RateLimit-Remaining', '0');
74
+ res.set('X-RateLimit-Reset', String(retryAfterSec));
75
+ // Persist the (still-empty) bucket so subsequent calls also see 0.
76
+ buckets.set(key, bucket);
77
+ // The 429 response carries the rate-limit scope so the operator
78
+ // can tell chat-throttling from event-throttling in logs.
79
+ res.set('X-RateLimit-Scope', scope);
80
+ res.status(429).json({
81
+ error: 'rate_limited',
82
+ message: 'Too many requests',
83
+ scope,
84
+ retryAfter: retryAfterSec,
85
+ });
86
+ return;
87
+ }
88
+
89
+ bucket.tokens -= 1;
90
+ buckets.set(key, bucket);
91
+ res.set('X-RateLimit-Limit', String(capacity));
92
+ res.set('X-RateLimit-Remaining', String(Math.floor(bucket.tokens)));
93
+ res.set('X-RateLimit-Scope', scope);
94
+ next();
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Reset all buckets. Tests call this in setup/teardown to avoid
100
+ * leaking state between cases. Production code should not need it.
101
+ */
102
+ export function clearBuckets() {
103
+ buckets.clear();
104
+ }
105
+
106
+ /**
107
+ * Inspect a single bucket. Returns undefined if no requests have been
108
+ * recorded for `key` yet. Exported for tests only.
109
+ *
110
+ * @param {string} key
111
+ */
112
+ export function _peekBucket(key) {
113
+ return buckets.get(key);
114
+ }
115
+
116
+ /**
117
+ * Total number of buckets currently tracked. Exported for tests /
118
+ * observability.
119
+ */
120
+ export function _bucketSize() {
121
+ return buckets.size;
122
+ }