@polderlabs/bizar 4.7.0 → 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.
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
- package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
- package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/publishing/SKILL.md +146 -0
- package/bizar-dash/src/server/api.mjs +8 -0
- package/bizar-dash/src/server/backup-store.mjs +525 -0
- package/bizar-dash/src/server/digest-store.mjs +558 -0
- package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
- package/bizar-dash/src/server/routes/backup.mjs +112 -0
- package/bizar-dash/src/server/routes/chat.mjs +14 -0
- package/bizar-dash/src/server/routes/digests.mjs +82 -0
- package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
- package/bizar-dash/src/server/schedules-runner.mjs +126 -0
- package/bizar-dash/src/server/server.mjs +17 -0
- package/bizar-dash/src/web/App.tsx +8 -1
- package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
- package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
- package/bizar-dash/src/web/styles/main.css +70 -8
- package/bizar-dash/src/web/views/Activity.tsx +11 -1
- package/bizar-dash/src/web/views/Agents.tsx +57 -42
- package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
- package/bizar-dash/src/web/views/Chat.tsx +8 -0
- package/bizar-dash/src/web/views/History.tsx +4 -1
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
- package/bizar-dash/src/web/views/Mods.tsx +30 -17
- package/bizar-dash/src/web/views/Overview.tsx +8 -1
- package/bizar-dash/src/web/views/Providers.tsx +16 -16
- package/bizar-dash/src/web/views/Schedules.tsx +33 -15
- package/bizar-dash/src/web/views/Settings.tsx +97 -1751
- package/bizar-dash/src/web/views/Skills.tsx +4 -1
- package/bizar-dash/src/web/views/Tasks.tsx +11 -2
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
- package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
- package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
- package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
- package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
- package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
- package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
- package/bizar-dash/tests/a11y.test.tsx +206 -0
- package/bizar-dash/tests/backup-restore.test.mjs +217 -0
- package/bizar-dash/tests/backup-restore.test.tsx +123 -0
- package/bizar-dash/tests/backup-store.test.mjs +300 -0
- package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
- package/bizar-dash/tests/digest-generation.test.mjs +191 -0
- package/bizar-dash/tests/digest-store.test.mjs +264 -0
- package/bizar-dash/tests/rate-limit.test.mjs +298 -0
- package/cli/bin.mjs +84 -1
- package/cli/commands/minimax.mjs +20 -0
- package/cli/commands/util.mjs +154 -1
- package/cli/digest.mjs +149 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
- package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
- package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CWqPoGaT.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
|
+
}
|