@polderlabs/bizar 4.5.0 → 4.5.2

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 (77) hide show
  1. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-B4OfGAwz.js +361 -0
  4. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
  6. package/bizar-dash/dist/assets/{mobile--0FBIKX3.js → mobile-BRhoDOUz.js} +1 -2
  7. package/bizar-dash/dist/assets/{mobile--0FBIKX3.js.map → mobile-BRhoDOUz.js.map} +1 -1
  8. package/bizar-dash/dist/assets/{mobile-OgRp8VIb.js → mobile-lbH6szyX.js} +2 -3
  9. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +1 -0
  10. package/bizar-dash/dist/index.html +3 -3
  11. package/bizar-dash/dist/mobile.html +2 -2
  12. package/bizar-dash/skills/bizar/SKILL.md +20 -0
  13. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +11 -0
  15. package/bizar-dash/skills/obsidian/SKILL.md +13 -0
  16. package/bizar-dash/src/server/api.mjs +6 -0
  17. package/bizar-dash/src/server/headroom.mjs +647 -0
  18. package/bizar-dash/src/server/memory-lightrag.mjs +281 -6
  19. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  20. package/bizar-dash/src/server/memory-store.mjs +151 -0
  21. package/bizar-dash/src/server/mods-loader.mjs +13 -5
  22. package/bizar-dash/src/server/providers-store.mjs +73 -1
  23. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  24. package/bizar-dash/src/server/routes/chat.mjs +38 -0
  25. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  26. package/bizar-dash/src/server/routes/lightrag.mjs +6 -2
  27. package/bizar-dash/src/server/routes/memory.mjs +439 -4
  28. package/bizar-dash/src/server/schedules-runner.mjs +4 -4
  29. package/bizar-dash/src/server/server.mjs +55 -37
  30. package/bizar-dash/src/server/watcher.mjs +2 -2
  31. package/bizar-dash/src/web/App.tsx +42 -6
  32. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  33. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  34. package/bizar-dash/src/web/components/Toast.tsx +1 -1
  35. package/bizar-dash/src/web/components/Topbar.tsx +3 -1
  36. package/bizar-dash/src/web/lib/api.ts +12 -11
  37. package/bizar-dash/src/web/lib/types.ts +16 -0
  38. package/bizar-dash/src/web/main.tsx +1 -0
  39. package/bizar-dash/src/web/styles/chat.css +2 -0
  40. package/bizar-dash/src/web/styles/main.css +52 -23
  41. package/bizar-dash/src/web/styles/memory.css +955 -0
  42. package/bizar-dash/src/web/styles/tasks.css +2 -0
  43. package/bizar-dash/src/web/views/Memory.tsx +141 -0
  44. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
  45. package/bizar-dash/src/web/views/Overview.tsx +7 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +32 -1
  47. package/bizar-dash/src/web/views/Skills.tsx +3 -2
  48. package/bizar-dash/src/web/views/Tasks.tsx +4 -3
  49. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  50. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  51. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  52. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +356 -0
  53. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  54. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  55. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  56. package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
  57. package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
  58. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  59. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  60. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  61. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  62. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  63. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  64. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  65. package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
  66. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  67. package/cli/artifact.mjs +11 -4
  68. package/cli/bin.mjs +211 -12
  69. package/cli/doctor.mjs +5 -3
  70. package/cli/memory.mjs +65 -21
  71. package/cli/provision.mjs +8 -2
  72. package/install.sh +1 -2
  73. package/package.json +3 -3
  74. package/bizar-dash/dist/assets/main-CDFKHzBg.css +0 -1
  75. package/bizar-dash/dist/assets/main-NYFpS2wY.js +0 -312
  76. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +0 -1
  77. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +0 -1
@@ -12,7 +12,7 @@
12
12
  import { Router } from 'express';
13
13
  import { join, dirname } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
15
+ import { existsSync, mkdirSync, readFileSync, statSync, readdirSync } from 'node:fs';
16
16
  import { execFileSync } from 'node:child_process';
17
17
 
18
18
  const SERVER_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -78,7 +78,9 @@ export function createMemoryRouter({ projectRoot }) {
78
78
  try {
79
79
  const data = JSON.parse(readFileSync(reindexMarker, 'utf8'));
80
80
  lastSecretScan = data.attemptedAt || null;
81
- } catch { /* ignore */ }
81
+ } catch (err) {
82
+ console.warn('swallowed in memory reindex marker read:', err.message);
83
+ }
82
84
  }
83
85
 
84
86
  res.json({
@@ -486,7 +488,9 @@ export function createMemoryRouter({ projectRoot }) {
486
488
  conflicts.push({ relPath: note.relPath, reason: 'git conflict markers' });
487
489
  }
488
490
  }
489
- } catch { /* ignore */ }
491
+ } catch (err) {
492
+ console.warn('swallowed in memory conflict scan:', err.message);
493
+ }
490
494
 
491
495
  res.json({ conflicts });
492
496
  }));
@@ -513,11 +517,19 @@ export function createMemoryRouter({ projectRoot }) {
513
517
  const { searchVault, queryLightRAG } = memoryStore;
514
518
  const lexical = searchVault(projectRoot, q, { limit: topK });
515
519
  let semantic = null;
520
+ const startedAt = Date.now();
516
521
  try {
517
522
  semantic = await queryLightRAG(projectRoot, q, { topK });
518
523
  } catch (err) {
519
524
  semantic = { ok: false, error: err.message };
520
525
  }
526
+ // Record the query duration for the LightRAG stats panel. Best-effort.
527
+ try {
528
+ const { recordQuery } = await getMemoryLightrag();
529
+ recordQuery(projectRoot, Date.now() - startedAt);
530
+ } catch (err) {
531
+ console.warn('swallowed in memory recordQuery:', err.message);
532
+ }
521
533
  res.json({ ok: true, q, lexical, semantic });
522
534
  }));
523
535
 
@@ -599,7 +611,9 @@ export function createMemoryRouter({ projectRoot }) {
599
611
  const content = readFileSync(logFile, 'utf8');
600
612
  const lines = content.split('\n');
601
613
  logTail.push(...lines.slice(-30));
602
- } catch {}
614
+ } catch (err) {
615
+ console.warn('swallowed in memory log tail:', err.message);
616
+ }
603
617
  }
604
618
 
605
619
  let lastError = null;
@@ -894,5 +908,426 @@ export function createMemoryRouter({ projectRoot }) {
894
908
  });
895
909
  }));
896
910
 
911
+ // ── v4.7.0 — Memory tab endpoints ─────────────────────────────────────────
912
+ //
913
+ // These endpoints are the canonical surface for the dedicated Memory
914
+ // tab. They sit alongside the per-resource endpoints above and return
915
+ // the shapes the React panels expect (always objects, never arrays).
916
+
917
+ // GET /memory/health — composite health score (0..100) + breakdown.
918
+ // Components:
919
+ // - vaultExists (vault dir on disk)
920
+ // - vaultWritable (can write a temp file and unlink it)
921
+ // - gitClean (mode is managed/linked AND `git status` is clean)
922
+ // - lightragRunning (server up + health check ok)
923
+ // - schemaValid (no validation errors across the vault)
924
+ // - secretsClean (no HIGH-severity secret findings)
925
+ router.get('/memory/health', wrap(async (_req, res) => {
926
+ const { resolveVault, loadConfig, listNotes, validateAll, scanForSecrets } = memoryStore;
927
+ const { isGitInstalled, status: gitStatus } = memoryGit;
928
+
929
+ const checks = [];
930
+ let score = 0;
931
+
932
+ const { exists, config } = loadConfig(projectRoot);
933
+ if (!exists) {
934
+ res.json({
935
+ score: 0,
936
+ status: 'unconfigured',
937
+ checks: [{ name: 'config', pass: false, detail: 'memory not initialised' }],
938
+ message: 'memory not initialised — run `bizar memory init`',
939
+ });
940
+ return;
941
+ }
942
+
943
+ const { vaultRoot, mode } = resolveVault(projectRoot);
944
+ const vaultExists = existsSync(vaultRoot);
945
+ checks.push({
946
+ name: 'vault_exists',
947
+ pass: vaultExists,
948
+ detail: vaultExists ? vaultRoot : 'vault directory missing',
949
+ });
950
+ if (vaultExists) score += 20;
951
+
952
+ // Writable?
953
+ let writable = false;
954
+ if (vaultExists) {
955
+ try {
956
+ const probe = join(vaultRoot, '.health-probe.tmp');
957
+ writeFileSync(probe, 'ok');
958
+ try {
959
+ const { unlinkSync } = await import('node:fs');
960
+ unlinkSync(probe);
961
+ } catch { /* best effort */ }
962
+ writable = true;
963
+ } catch {
964
+ writable = false;
965
+ }
966
+ }
967
+ checks.push({ name: 'vault_writable', pass: writable, detail: writable ? 'yes' : 'no' });
968
+ if (writable) score += 10;
969
+
970
+ // Git clean?
971
+ let gitClean = null;
972
+ if ((mode === 'managed' || mode === 'linked') && vaultExists && isGitInstalled()) {
973
+ const gs = gitStatus(vaultRoot);
974
+ gitClean = gs.clean;
975
+ checks.push({
976
+ name: 'git_clean',
977
+ pass: gs.clean,
978
+ detail: gs.clean ? 'working tree clean' : `${(gs.modified?.length || 0) + (gs.untracked?.length || 0)} pending`,
979
+ });
980
+ if (gs.clean) score += 20;
981
+ } else if (mode === 'local-only') {
982
+ checks.push({ name: 'git_clean', pass: true, detail: 'local-only mode (no git)' });
983
+ // local-only mode shouldn't penalise — count as clean
984
+ score += 20;
985
+ } else {
986
+ checks.push({ name: 'git_clean', pass: false, detail: 'git not installed or vault missing' });
987
+ }
988
+
989
+ // LightRAG running?
990
+ let lightragRunning = false;
991
+ try {
992
+ const { resolveLightRAGConfig, isRunning } = await getMemoryLightrag();
993
+ const cfg = resolveLightRAGConfig(projectRoot);
994
+ lightragRunning = await isRunning(cfg);
995
+ } catch {
996
+ lightragRunning = false;
997
+ }
998
+ checks.push({
999
+ name: 'lightrag_running',
1000
+ pass: lightragRunning,
1001
+ detail: lightragRunning ? 'yes' : 'stopped or disabled',
1002
+ });
1003
+ if (lightragRunning) score += 20;
1004
+
1005
+ // Schema valid?
1006
+ let invalidCount = 0;
1007
+ if (vaultExists) {
1008
+ const validationResults = validateAll(projectRoot);
1009
+ invalidCount = validationResults.length;
1010
+ }
1011
+ checks.push({
1012
+ name: 'schema_valid',
1013
+ pass: invalidCount === 0,
1014
+ detail: invalidCount === 0 ? 'all notes valid' : `${invalidCount} invalid`,
1015
+ });
1016
+ if (invalidCount === 0 && vaultExists) score += 15;
1017
+
1018
+ // Secrets clean?
1019
+ let highFindings = 0;
1020
+ if (vaultExists) {
1021
+ for (const n of listNotes(projectRoot)) {
1022
+ const r = scanForSecrets(projectRoot, n.relPath);
1023
+ if (!r.safe) {
1024
+ for (const f of r.findings) {
1025
+ if (f.severity === 'HIGH') highFindings++;
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ checks.push({
1031
+ name: 'secrets_clean',
1032
+ pass: highFindings === 0,
1033
+ detail: highFindings === 0 ? 'no HIGH-severity secrets' : `${highFindings} HIGH finding(s)`,
1034
+ });
1035
+ if (highFindings === 0) score += 15;
1036
+
1037
+ const status = score >= 80 ? 'healthy' : score >= 50 ? 'degraded' : 'unhealthy';
1038
+ res.json({
1039
+ score,
1040
+ status,
1041
+ checks,
1042
+ message:
1043
+ status === 'healthy' ? 'memory system healthy' :
1044
+ status === 'degraded' ? 'one or more subsystems need attention' :
1045
+ 'multiple subsystems failing',
1046
+ });
1047
+ }));
1048
+
1049
+ // GET /memory/storage — disk-usage summary for the Memory tab.
1050
+ // Walks the vault + .bizar/memory-cache + lightrag working dir.
1051
+ router.get('/memory/storage', wrap(async (_req, res) => {
1052
+ const { resolveVault } = memoryStore;
1053
+ const { vaultRoot, mode } = resolveVault(projectRoot);
1054
+
1055
+ const targets = [];
1056
+ if (existsSync(vaultRoot)) targets.push({ name: 'vault', path: vaultRoot });
1057
+ const cacheDir = join(projectRoot, '.bizar', 'memory-cache');
1058
+ if (existsSync(cacheDir)) targets.push({ name: 'memory-cache', path: cacheDir });
1059
+ const lightragDir = join(projectRoot, '.bizar', 'lightrag');
1060
+ if (existsSync(lightragDir)) targets.push({ name: 'lightrag', path: lightragDir });
1061
+
1062
+ let total = 0;
1063
+ const breakdown = targets.map((t) => {
1064
+ const size = dirSize(t.path);
1065
+ total += size;
1066
+ return { name: t.name, path: t.path, size };
1067
+ });
1068
+
1069
+ res.json({
1070
+ total,
1071
+ breakdown,
1072
+ mode,
1073
+ vaultRoot,
1074
+ message: total === 0 ? 'no memory data on disk yet' : `${formatBytes(total)} on disk`,
1075
+ });
1076
+ }));
1077
+
1078
+ // GET /memory/git/diff — textual diff of the working tree (last commit vs HEAD).
1079
+ // Returns { hasDiff, lines, files } — `lines` is a flat unified-diff-ish view.
1080
+ router.get('/memory/git/diff', wrap(async (_req, res) => {
1081
+ const { resolveVault } = memoryStore;
1082
+ const { isGitInstalled } = memoryGit;
1083
+ const { vaultRoot, mode } = resolveVault(projectRoot);
1084
+
1085
+ if (mode === 'local-only') {
1086
+ res.json({ hasDiff: false, lines: [], files: [], mode: 'local-only' });
1087
+ return;
1088
+ }
1089
+ if (!isGitInstalled()) {
1090
+ res.status(503).json({ error: 'git_not_installed' });
1091
+ return;
1092
+ }
1093
+
1094
+ let raw = '';
1095
+ try {
1096
+ raw = execFileSync('git', ['diff', '--no-color', '--no-ext-diff'], {
1097
+ cwd: vaultRoot,
1098
+ encoding: 'utf8',
1099
+ maxBuffer: 8 * 1024 * 1024,
1100
+ });
1101
+ } catch (err) {
1102
+ res.status(500).json({ error: 'diff_failed', message: err.message });
1103
+ return;
1104
+ }
1105
+
1106
+ // Also include untracked file names.
1107
+ let untracked = [];
1108
+ try {
1109
+ const statusRaw = execFileSync('git', ['status', '--porcelain'], {
1110
+ cwd: vaultRoot,
1111
+ encoding: 'utf8',
1112
+ });
1113
+ for (const line of statusRaw.split('\n')) {
1114
+ if (line.startsWith('??')) untracked.push(line.slice(3).trim());
1115
+ }
1116
+ } catch {
1117
+ /* ignore */
1118
+ }
1119
+
1120
+ const lines = raw.split('\n');
1121
+ const files = [];
1122
+ for (const line of lines) {
1123
+ if (line.startsWith('diff --git ')) {
1124
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
1125
+ if (m) files.push(m[2]);
1126
+ }
1127
+ }
1128
+ for (const u of untracked) {
1129
+ if (!files.includes(u)) files.push(u);
1130
+ }
1131
+
1132
+ res.json({
1133
+ hasDiff: lines.length > 1 || untracked.length > 0,
1134
+ lines,
1135
+ files,
1136
+ mode,
1137
+ });
1138
+ }));
1139
+
1140
+ // GET /memory/lightrag/stats — aggregate stats for the LightRAG panel.
1141
+ router.get('/memory/lightrag/stats', wrap(async (_req, res) => {
1142
+ try {
1143
+ const { stats } = await getMemoryLightrag();
1144
+ const data = await stats(projectRoot);
1145
+ res.json(data);
1146
+ } catch (err) {
1147
+ res.status(500).json({ error: 'stats_failed', message: err.message });
1148
+ }
1149
+ }));
1150
+
1151
+ // POST /memory/lightrag/reindex — alias of /memory/reindex (the canonical
1152
+ // path lives there for backwards compat). Both return the same shape.
1153
+ router.post('/memory/lightrag/reindex', wrap(async (_req, res) => {
1154
+ const { reindexVault } = memoryStore;
1155
+ const result = await reindexVault(projectRoot, {});
1156
+ res.json(result);
1157
+ }));
1158
+
1159
+ // POST /memory/lightrag/rebuild-graph — nuke the working dir + reindex.
1160
+ router.post('/memory/lightrag/rebuild-graph', wrap(async (_req, res) => {
1161
+ try {
1162
+ const { rebuildGraph } = await getMemoryLightrag();
1163
+ const result = await rebuildGraph(projectRoot, {});
1164
+ res.json(result);
1165
+ } catch (err) {
1166
+ res.status(500).json({ error: 'rebuild_failed', message: err.message });
1167
+ }
1168
+ }));
1169
+
1170
+ // GET /memory/obsidian/tree — recursive folder tree for the Memory browser.
1171
+ router.get('/memory/obsidian/tree', wrap(async (_req, res) => {
1172
+ try {
1173
+ const obsidian = await import(`${SERVER_ROOT}/memory-obsidian.mjs`);
1174
+ const node = obsidian.tree(projectRoot);
1175
+ res.json({ tree: node });
1176
+ } catch (err) {
1177
+ res.status(500).json({ error: 'tree_failed', message: err.message });
1178
+ }
1179
+ }));
1180
+
1181
+ // GET /memory/obsidian/backlinks?note=path/to/note.md
1182
+ router.get('/memory/obsidian/backlinks', wrap(async (req, res) => {
1183
+ const note = String(req.query.note || '').trim();
1184
+ if (!note) {
1185
+ res.status(400).json({ error: 'bad_request', message: 'note query param required' });
1186
+ return;
1187
+ }
1188
+ try {
1189
+ const obsidian = await import(`${SERVER_ROOT}/memory-obsidian.mjs`);
1190
+ const links = obsidian.listBacklinks(projectRoot, note);
1191
+ res.json({ note, backlinks: links });
1192
+ } catch (err) {
1193
+ res.status(500).json({ error: 'backlinks_failed', message: err.message });
1194
+ }
1195
+ }));
1196
+
1197
+ // GET /memory/obsidian/notes?path=...&limit=...
1198
+ // Query-string variant of the canonical /memory/notes endpoint. Used by
1199
+ // the ObsidianPanel folder browser which knows the parent path.
1200
+ router.get('/memory/obsidian/notes', wrap(async (req, res) => {
1201
+ const { listNotes } = memoryStore;
1202
+ const all = listNotes(projectRoot);
1203
+ const path = String(req.query.path || '').trim();
1204
+ const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500);
1205
+ let notes = all;
1206
+ if (path) {
1207
+ notes = all.filter((n) => n.relPath.startsWith(path));
1208
+ }
1209
+ notes = notes.slice(0, limit);
1210
+ res.json({ path: path || null, count: notes.length, notes });
1211
+ }));
1212
+
1213
+ // PUT /memory/notes/* — update an existing note (or create).
1214
+ router.put('/memory/notes/*', wrap(async (req, res) => {
1215
+ const { writeNote } = memoryStore;
1216
+ const relPath = req.params[0];
1217
+ const { frontmatter, body } = req.body || {};
1218
+ if (!relPath) {
1219
+ res.status(400).json({ error: 'bad_request', message: 'path is required' });
1220
+ return;
1221
+ }
1222
+ if (!relPath.endsWith('.md')) {
1223
+ res.status(400).json({ error: 'bad_request', message: 'path must end in .md' });
1224
+ return;
1225
+ }
1226
+ try {
1227
+ const note = writeNote(projectRoot, relPath, { frontmatter: frontmatter || {}, body: body || '' });
1228
+ res.json(note);
1229
+ } catch (err) {
1230
+ if (err.code === 'SCHEMA_VALIDATION_FAILED' || err.code === 'SECRET_DETECTED') {
1231
+ res.status(400).json({ error: err.code, message: err.message, findings: err.findings });
1232
+ } else {
1233
+ res.status(400).json({ error: 'bad_request', message: err.message });
1234
+ }
1235
+ }
1236
+ }));
1237
+
1238
+ // POST /memory/semantic-search — cross-source search.
1239
+ // Body: { query: string, limit?: number, sources?: Array<'lightrag'|'obsidian'> }
1240
+ router.post('/memory/semantic-search', wrap(async (req, res) => {
1241
+ const body = req.body || {};
1242
+ const query = String(body.query || '').trim();
1243
+ if (!query) {
1244
+ res.status(400).json({ error: 'bad_request', message: 'query is required' });
1245
+ return;
1246
+ }
1247
+ const limit = Math.min(parseInt(body.limit, 10) || 10, 50);
1248
+ const requestedSources = Array.isArray(body.sources) && body.sources.length > 0
1249
+ ? new Set(body.sources.map((s) => String(s).toLowerCase()))
1250
+ : new Set(['lightrag', 'obsidian']);
1251
+
1252
+ const results = [];
1253
+ if (requestedSources.has('obsidian')) {
1254
+ const { searchVault } = memoryStore;
1255
+ const lex = searchVault(projectRoot, query, { limit });
1256
+ for (const r of lex) {
1257
+ results.push({
1258
+ source: 'obsidian',
1259
+ relPath: r.relPath,
1260
+ snippet: r.snippet,
1261
+ score: r.score,
1262
+ mtime: r.mtime,
1263
+ });
1264
+ }
1265
+ }
1266
+ if (requestedSources.has('lightrag')) {
1267
+ try {
1268
+ const { resolveLightRAGConfig, query: lightragQuery } = await getMemoryLightrag();
1269
+ const cfg = resolveLightRAGConfig(projectRoot);
1270
+ const r = await lightragQuery(cfg, query, { topK: limit });
1271
+ if (r.ok && r.response) {
1272
+ // LightRAG's response shape varies by mode; coerce to a snippet.
1273
+ const text = typeof r.response === 'string'
1274
+ ? r.response
1275
+ : (r.response?.response || r.response?.answer || JSON.stringify(r.response));
1276
+ results.push({
1277
+ source: 'lightrag',
1278
+ relPath: null,
1279
+ snippet: String(text).slice(0, 500),
1280
+ score: 1,
1281
+ mtime: null,
1282
+ raw: r.response,
1283
+ });
1284
+ }
1285
+ } catch (err) {
1286
+ results.push({ source: 'lightrag', error: err.message });
1287
+ }
1288
+ }
1289
+
1290
+ // Dedupe by (source, relPath), keep highest score.
1291
+ const seen = new Map();
1292
+ for (const r of results) {
1293
+ const key = `${r.source}|${r.relPath || '_query_'}`;
1294
+ const prev = seen.get(key);
1295
+ if (!prev || (r.score || 0) > (prev.score || 0)) {
1296
+ seen.set(key, r);
1297
+ }
1298
+ }
1299
+ const deduped = [...seen.values()].sort((a, b) => (b.score || 0) - (a.score || 0));
1300
+
1301
+ res.json({ query, count: deduped.length, results: deduped });
1302
+ }));
1303
+
897
1304
  return router;
898
1305
  }
1306
+
1307
+ /**
1308
+ * Recursively sum file sizes under `dir`. Returns 0 if dir doesn't exist.
1309
+ */
1310
+ function dirSize(dir) {
1311
+ let total = 0;
1312
+ function walk(d) {
1313
+ let st;
1314
+ try { st = statSync(d); } catch { return; }
1315
+ if (st.isFile()) { total += st.size; return; }
1316
+ if (!st.isDirectory()) return;
1317
+ let entries;
1318
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
1319
+ for (const e of entries) {
1320
+ if (e.name.startsWith('.')) continue;
1321
+ walk(join(d, e.name));
1322
+ }
1323
+ }
1324
+ walk(dir);
1325
+ return total;
1326
+ }
1327
+
1328
+ function formatBytes(n) {
1329
+ if (n < 1024) return `${n} B`;
1330
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
1331
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
1332
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
1333
+ }
@@ -34,8 +34,8 @@ function logLine(line) {
34
34
  try {
35
35
  mkdirSync(LOG_DIR, { recursive: true });
36
36
  appendFileSync(LOG_FILE, line + '\n', 'utf8');
37
- } catch {
38
- /* ignore */
37
+ } catch (err) {
38
+ console.warn('swallowed in schedule log append:', err.message);
39
39
  }
40
40
  }
41
41
 
@@ -288,8 +288,8 @@ export const schedulesRunner = {
288
288
  const projectIds = new Set(reg.projects.map((project) => project.id));
289
289
  try {
290
290
  if (schedulesStore.list('default').length > 0) projectIds.add('default');
291
- } catch {
292
- /* ignore */
291
+ } catch (err) {
292
+ console.warn('swallowed in schedules due-list:', err.message);
293
293
  }
294
294
  let skipped = 0;
295
295
  for (const projectId of projectIds) {
@@ -24,6 +24,7 @@ import { agentsStore } from './agents-store.mjs';
24
24
  import { tasksStore } from './tasks-store.mjs';
25
25
  import { schedulesStore } from './schedules-store.mjs';
26
26
  import { providersStore, mcpsStore } from './providers-store.mjs';
27
+ import { readOpencodeJsonCached } from './providers-store.mjs';
27
28
  import { homedir } from 'node:os';
28
29
  import { startBgPoller, stopBgPoller } from './bg-poller.mjs';
29
30
  import { startBgRetryLoop, stopBgRetryLoop } from './bg-retry.mjs';
@@ -281,8 +282,8 @@ export async function createServer({
281
282
  if (client.bufferedAmount > WS_BACKPRESSURE_LIMIT_BYTES) {
282
283
  try {
283
284
  client.terminate();
284
- } catch {
285
- /* ignore */
285
+ } catch (err) {
286
+ console.warn('swallowed in terminate (backpressure):', err.message);
286
287
  }
287
288
  return false;
288
289
  }
@@ -315,6 +316,22 @@ export async function createServer({
315
316
  broadcast: localBroadcast,
316
317
  });
317
318
 
319
+ // v5.0.0 — Headroom startup hook. Runs after api.mjs is loaded so the
320
+ // headroom routes are registered. Errors are caught and logged — startup
321
+ // must not fail if Headroom has issues.
322
+ const { headroomStartupHook } = await import('./headroom.mjs');
323
+ const { readSettings } = await import('./routes/_shared.mjs');
324
+ try {
325
+ const settings = readSettings();
326
+ if (settings?.data?.headroom) {
327
+ headroomStartupHook(settings.data.headroom).catch((err) => {
328
+ console.warn('[bizar-dash] headroomStartupHook error:', err?.message || err);
329
+ });
330
+ }
331
+ } catch (err) {
332
+ console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
333
+ }
334
+
318
335
  // All /api/* routes go through apiRouter (after mod routes are checked).
319
336
  // IMPORTANT: mount v2 router FIRST so `/api/v2/*` matches before
320
337
  // apiRouter's internal 404 catch-all (api.mjs line ~109) can swallow it.
@@ -380,8 +397,8 @@ export async function createServer({
380
397
  try {
381
398
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
382
399
  socket.destroy();
383
- } catch {
384
- /* ignore */
400
+ } catch (err) {
401
+ console.warn('swallowed in 403 destroy:', err.message);
385
402
  }
386
403
  return;
387
404
  }
@@ -389,8 +406,8 @@ export async function createServer({
389
406
  try {
390
407
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
391
408
  socket.destroy();
392
- } catch {
393
- /* ignore */
409
+ } catch (err) {
410
+ console.warn('swallowed in 401 destroy:', err.message);
394
411
  }
395
412
  return;
396
413
  }
@@ -404,8 +421,8 @@ export async function createServer({
404
421
  if (client.isAlive === false) {
405
422
  try {
406
423
  client.terminate();
407
- } catch {
408
- /* ignore */
424
+ } catch (err) {
425
+ console.warn('swallowed in heartbeat terminate:', err.message);
409
426
  }
410
427
  return;
411
428
  }
@@ -415,8 +432,8 @@ export async function createServer({
415
432
  } catch {
416
433
  try {
417
434
  client.terminate();
418
- } catch {
419
- /* ignore */
435
+ } catch (err) {
436
+ console.warn('swallowed in ping-fail terminate:', err.message);
420
437
  }
421
438
  }
422
439
  });
@@ -561,8 +578,8 @@ export async function createServer({
561
578
  }
562
579
  fileSize = newSize;
563
580
  }
564
- } catch {
565
- /* ignore */
581
+ } catch (err) {
582
+ console.warn('swallowed in sendLogChunk:', err.message);
566
583
  }
567
584
  }
568
585
 
@@ -601,8 +618,8 @@ export async function createServer({
601
618
  data: buildSnapshotSafe(state, opencodeConfigDir),
602
619
  }),
603
620
  );
604
- } catch {
605
- /* ignore */
621
+ } catch (err) {
622
+ console.warn('swallowed in ws snapshot send:', err.message);
606
623
  }
607
624
 
608
625
  ws.on('message', (raw) => {
@@ -662,34 +679,34 @@ export async function createServer({
662
679
  function close() {
663
680
  try {
664
681
  stopBgPoller();
665
- } catch {
666
- /* ignore */
682
+ } catch (err) {
683
+ console.warn('swallowed in stopBgPoller:', err.message);
667
684
  }
668
685
  try {
669
686
  stopBgRetryLoop();
670
- } catch {
671
- /* ignore */
687
+ } catch (err) {
688
+ console.warn('swallowed in stopBgRetryLoop:', err.message);
672
689
  }
673
690
  try {
674
691
  watcher.stop();
675
- } catch {
676
- /* ignore */
692
+ } catch (err) {
693
+ console.warn('swallowed in watcher.stop:', err.message);
677
694
  }
678
695
  try {
679
696
  wss.clients.forEach((c) => c.terminate());
680
697
  wss.close();
681
- } catch {
682
- /* ignore */
698
+ } catch (err) {
699
+ console.warn('swallowed in wss.close:', err.message);
683
700
  }
684
701
  try {
685
702
  clearInterval(heartbeatInterval);
686
- } catch {
687
- /* ignore */
703
+ } catch (err) {
704
+ console.warn('swallowed in clearInterval:', err.message);
688
705
  }
689
706
  try {
690
707
  server.close();
691
- } catch {
692
- /* ignore */
708
+ } catch (err) {
709
+ console.warn('swallowed in server.close:', err.message);
693
710
  }
694
711
  currentBroadcast = () => {};
695
712
  }
@@ -707,14 +724,15 @@ function buildSnapshotSafe(state, opencodeConfigDir) {
707
724
 
708
725
  function buildSnapshot(state, opencodeConfigDir) {
709
726
  const cfgFile = join(opencodeConfigDir, 'opencode.json');
710
- let cfg = null;
711
- if (existsSync(cfgFile)) {
712
- try {
713
- cfg = JSON.parse(readFileSync(cfgFile, 'utf8'));
714
- } catch {
715
- cfg = null;
716
- }
717
- }
727
+ // v5.0.0 Bug S2: read opencode.json via the 1s-debounced cache in
728
+ // providers-store.mjs. WS clients connect on snapshot delivery; with N
729
+ // clients the readFileSync here was the main per-connection blocker.
730
+ // The cache collapses all reads within a 1-second window to a single
731
+ // disk hit. Writes (settings change, providers-store mutations)
732
+ // invalidate the cache, so the snapshot always reflects the latest
733
+ // state within at most 1s.
734
+ const exists = existsSync(cfgFile);
735
+ const cfg = readOpencodeJsonCached();
718
736
  const activeProject = projectsStore.active();
719
737
  return {
720
738
  overview: state.getOverview(),
@@ -724,9 +742,9 @@ function buildSnapshot(state, opencodeConfigDir) {
724
742
  activeProject,
725
743
  config: {
726
744
  path: cfgFile,
727
- data: cfg,
728
- raw: cfg ? JSON.stringify(cfg, null, 2) : '',
729
- exists: existsSync(cfgFile),
745
+ data: exists ? cfg : null,
746
+ raw: exists && cfg ? JSON.stringify(cfg, null, 2) : '',
747
+ exists,
730
748
  },
731
749
  settings: readSettings(),
732
750
  tasks: activeProject ? tasksStore.loadTasks(activeProject.id) : [],