@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
@@ -523,6 +523,157 @@ export function validateAll(projectRoot) {
523
523
  return results;
524
524
  }
525
525
 
526
+ // ── v4.7.0 — Memory tab helpers ─────────────────────────────────────────────
527
+ //
528
+ // Lightweight helpers used by the dedicated Memory view: backlinks (wikilink
529
+ // reverse index) and a vault-stats summary for the overview card.
530
+
531
+ /**
532
+ * Find every note in the vault that links TO `targetRelPath` via a wikilink.
533
+ *
534
+ * A wikilink matches one of:
535
+ * - `[[target]]`
536
+ * - `[[target|alias]]`
537
+ * - `[[target#heading]]`
538
+ * - `[[target#heading|alias]]`
539
+ *
540
+ * The match is by basename without extension (Obsidian's wikilink semantics)
541
+ * AND by full relPath-without-extension, so `[[notes/foo]]` matches
542
+ * `notes/foo.md` and `[[foo]]` matches `foo.md` anywhere in the vault.
543
+ *
544
+ * @param {string} projectRoot
545
+ * @param {string} targetRelPath — the note we want backlinks for
546
+ * @returns {Array<{ fromRelPath: string, fromTitle: string, snippet: string, mtime: number }>}
547
+ */
548
+ export function findBacklinks(projectRoot, targetRelPath) {
549
+ if (!targetRelPath) return [];
550
+ const targetBase = basenameOf(targetRelPath).replace(/\.md$/i, '');
551
+ const targetStripped = targetRelPath.replace(/\.md$/i, '');
552
+ if (!targetBase && !targetStripped) return [];
553
+
554
+ const out = [];
555
+ const WIKILINK_RE = /\[\[([^\]\n|]+?)(?:\|[^\]\n]+?)?(?:#[^\]\n]+?)?\]\]/g;
556
+
557
+ for (const note of listNotes(projectRoot)) {
558
+ if (note.relPath === targetRelPath) continue; // skip self
559
+ const body = note.body || '';
560
+ const matches = [];
561
+ let m;
562
+ WIKILINK_RE.lastIndex = 0;
563
+ while ((m = WIKILINK_RE.exec(body)) !== null) {
564
+ const raw = (m[1] || '').trim();
565
+ if (!raw) continue;
566
+ const stripped = raw.split('#')[0].trim();
567
+ if (!stripped) continue;
568
+ const baseOnly = stripped.split('/').pop() || stripped;
569
+ if (baseOnly === targetBase || stripped === targetStripped) {
570
+ matches.push({ raw, idx: m.index });
571
+ }
572
+ }
573
+ if (matches.length === 0) continue;
574
+
575
+ // Build a snippet around the FIRST match — 80 chars each side.
576
+ const first = matches[0];
577
+ const start = Math.max(0, first.idx - 80);
578
+ const end = Math.min(body.length, first.idx + first.raw.length + 80);
579
+ const snippet = (start > 0 ? '…' : '') +
580
+ body.slice(start, end).replace(/\s+/g, ' ').trim() +
581
+ (end < body.length ? '…' : '');
582
+
583
+ out.push({
584
+ fromRelPath: note.relPath,
585
+ fromTitle: note.frontmatter?.title || basenameOf(note.relPath).replace(/\.md$/i, ''),
586
+ snippet,
587
+ mtime: note.mtime,
588
+ });
589
+ }
590
+
591
+ return out.sort((a, b) => b.mtime - a.mtime);
592
+ }
593
+
594
+ /** Minimal basename helper — avoids pulling `path.basename` into the bundle. */
595
+ function basenameOf(p) {
596
+ if (!p) return '';
597
+ const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
598
+ return idx >= 0 ? p.slice(idx + 1) : p;
599
+ }
600
+
601
+ /**
602
+ * High-level stats used by the Memory tab and Overview status card.
603
+ *
604
+ * @param {string} projectRoot
605
+ * @returns {{ exists: boolean, vaultRoot: string, mode: string, noteCount: number, totalSize: number, folderCount: number, folders: string[], lastModified: number|null, gitClean: boolean|null, gitBranch: string|null }}
606
+ */
607
+ export function vaultStats(projectRoot) {
608
+ const { exists, config } = loadConfig(projectRoot);
609
+ if (!exists) {
610
+ return {
611
+ exists: false,
612
+ vaultRoot: '',
613
+ mode: 'local-only',
614
+ noteCount: 0,
615
+ totalSize: 0,
616
+ folderCount: 0,
617
+ folders: [],
618
+ lastModified: null,
619
+ gitClean: null,
620
+ gitBranch: null,
621
+ };
622
+ }
623
+
624
+ const { vaultRoot, mode, branch } = resolveVault(projectRoot);
625
+ if (!vaultRoot || !existsSync(vaultRoot)) {
626
+ return {
627
+ exists: true,
628
+ vaultRoot: vaultRoot || '',
629
+ mode,
630
+ noteCount: 0,
631
+ totalSize: 0,
632
+ folderCount: 0,
633
+ folders: [],
634
+ lastModified: null,
635
+ gitClean: null,
636
+ gitBranch: branch || config?.branch || 'main',
637
+ };
638
+ }
639
+
640
+ const notes = listNotes(projectRoot);
641
+ const totalSize = notes.reduce((acc, n) => acc + (n.size || 0), 0);
642
+ const folders = new Set();
643
+ for (const n of notes) {
644
+ const seg = n.relPath.split('/');
645
+ if (seg.length > 1) folders.add(seg[0]);
646
+ }
647
+
648
+ let gitClean = null;
649
+ let gitBranch = null;
650
+ if (mode === 'managed' || mode === 'linked') {
651
+ try {
652
+ const { isGitInstalled, status: gitStatus } = memoryGit;
653
+ if (isGitInstalled()) {
654
+ const gs = gitStatus(vaultRoot);
655
+ gitClean = gs.clean;
656
+ gitBranch = gs.branch;
657
+ }
658
+ } catch {
659
+ // ignore — git is optional
660
+ }
661
+ }
662
+
663
+ return {
664
+ exists: true,
665
+ vaultRoot,
666
+ mode,
667
+ noteCount: notes.length,
668
+ totalSize,
669
+ folderCount: folders.size,
670
+ folders: [...folders].sort(),
671
+ lastModified: notes[0]?.mtime || null,
672
+ gitClean,
673
+ gitBranch: gitBranch || branch || config?.branch || 'main',
674
+ };
675
+ }
676
+
526
677
  // ── LightRAG integration (v4.1.0) ──────────────────────────────────────────
527
678
  //
528
679
  // Re-export the LightRAG orchestrator from the memory-store module so the
@@ -596,7 +596,8 @@ export const modsLoader = {
596
596
  const res = await fetch(modJsonUrl);
597
597
  if (!res.ok) return null;
598
598
  modJson = await res.json();
599
- } catch {
599
+ } catch (err) {
600
+ console.warn('swallowed in fetchModMetadata:', err.message);
600
601
  return null;
601
602
  }
602
603
  const id = modJson.id;
@@ -674,7 +675,8 @@ export const modsLoader = {
674
675
  let entries;
675
676
  try {
676
677
  entries = readdirSync(p, { withFileTypes: true });
677
- } catch {
678
+ } catch (err) {
679
+ console.warn('swallowed in mod walk:', err.message);
678
680
  return;
679
681
  }
680
682
  for (const e of entries) {
@@ -913,20 +915,26 @@ export const modsLoader = {
913
915
  try {
914
916
  out.agents = readdirSync(OPENCODE_AGENTS_DIR)
915
917
  .filter((f) => f.startsWith(prefix));
916
- } catch { /* ignore */ }
918
+ } catch (err) {
919
+ console.warn('swallowed in agents readdir:', err.message);
920
+ }
917
921
  }
918
922
  if (existsSync(OPENCODE_COMMANDS_DIR)) {
919
923
  try {
920
924
  out.commands = readdirSync(OPENCODE_COMMANDS_DIR)
921
925
  .filter((f) => f.startsWith(prefix));
922
- } catch { /* ignore */ }
926
+ } catch (err) {
927
+ console.warn('swallowed in commands readdir:', err.message);
928
+ }
923
929
  }
924
930
  if (existsSync(OPENCODE_SKILLS_DIR)) {
925
931
  try {
926
932
  out.skills = readdirSync(OPENCODE_SKILLS_DIR, { withFileTypes: true })
927
933
  .filter((e) => e.isDirectory() && e.name.startsWith(skillPrefix))
928
934
  .map((e) => e.name);
929
- } catch { /* ignore */ }
935
+ } catch (err) {
936
+ console.warn('swallowed in skills readdir:', err.message);
937
+ }
930
938
  }
931
939
  return out;
932
940
  },
@@ -38,6 +38,7 @@ import {
38
38
  renameSync,
39
39
  mkdirSync,
40
40
  readdirSync,
41
+ statSync,
41
42
  } from 'node:fs';
42
43
  import { dirname, join } from 'node:path';
43
44
  import { homedir } from 'node:os';
@@ -66,13 +67,80 @@ function atomicWriteJson(filePath, data) {
66
67
  renameSync(tmp, filePath);
67
68
  }
68
69
 
70
+ // v5.0.0 — Bug S1: 1-second debounced cache for opencode.json reads.
71
+ // `list()`/`listAll()` and other consumers call `loadConfig()` on every
72
+ // WS poll (every ~5s per client) and on every route hit. With N clients
73
+ // that becomes O(N) reads per minute. The cache collapses all reads
74
+ // within a 1-second window to a single read. Writes invalidate so
75
+ // subsequent reads see the new state.
76
+ //
77
+ // The cache lives on `globalThis` so it is shared across all module
78
+ // instances of providers-store.mjs — tests and code that re-imports
79
+ // the module (e.g. via `?cb=` cache-bust) must see the same cache,
80
+ // otherwise a write on one instance becomes invisible to another.
81
+ //
82
+ // The cache also tracks the file's mtime+size and invalidates if the
83
+ // on-disk file has been modified externally (e.g. an editor saving
84
+ // opencode.json, or a test setup writing a fresh file). This prevents
85
+ // stale reads when the file changes outside our write paths.
86
+ const OPENCODE_JSON_CACHE_GLOBAL_KEY = '__bizar_opencode_json_cache__';
87
+ function _opencodeCacheSlot() {
88
+ if (!globalThis[OPENCODE_JSON_CACHE_GLOBAL_KEY]) {
89
+ globalThis[OPENCODE_JSON_CACHE_GLOBAL_KEY] = { entry: null, at: 0 };
90
+ }
91
+ return globalThis[OPENCODE_JSON_CACHE_GLOBAL_KEY];
92
+ }
93
+
94
+ const OPENCODE_JSON_CACHE_TTL_MS = 1000;
95
+
96
+ function _fileStamp(filePath) {
97
+ // Best-effort stat — if stat fails (file missing, etc.), the caller
98
+ // will re-read and re-populate the cache.
99
+ try {
100
+ const st = statSync(filePath);
101
+ return `${st.size}:${st.mtimeMs}`;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function readOpencodeJsonCached(filePath = OPENCODE_JSON) {
108
+ const slot = _opencodeCacheSlot();
109
+ const now = Date.now();
110
+ if (slot.entry && slot.entry.filePath === filePath) {
111
+ const age = now - slot.at;
112
+ if (age < OPENCODE_JSON_CACHE_TTL_MS) {
113
+ // Fast path: still within TTL. Verify the file hasn't been
114
+ // modified externally (e.g. another process, an editor, or a
115
+ // test that writes the file directly). The stamp check is cheap
116
+ // and protects correctness when the file is touched outside
117
+ // our write paths.
118
+ const stamp = _fileStamp(filePath);
119
+ if (stamp === null || stamp === slot.entry.stamp) {
120
+ return slot.entry.data;
121
+ }
122
+ }
123
+ }
124
+ const data = safeReadJSON(filePath, {});
125
+ slot.entry = { filePath, data, stamp: _fileStamp(filePath) };
126
+ slot.at = now;
127
+ return data;
128
+ }
129
+
130
+ function invalidateOpencodeJsonCache() {
131
+ const slot = _opencodeCacheSlot();
132
+ slot.entry = null;
133
+ slot.at = 0;
134
+ }
135
+
69
136
  function loadConfig() {
70
- return safeReadJSON(OPENCODE_JSON, {});
137
+ return readOpencodeJsonCached(OPENCODE_JSON);
71
138
  }
72
139
 
73
140
  function saveConfig(data) {
74
141
  mkdirSync(dirname(OPENCODE_JSON), { recursive: true });
75
142
  atomicWriteJson(OPENCODE_JSON, data);
143
+ invalidateOpencodeJsonCache();
76
144
  }
77
145
 
78
146
  // v4.6.0 — expose the on-disk load/save helpers so route modules can
@@ -81,6 +149,10 @@ function saveConfig(data) {
81
149
  // were not exported and config.mjs referenced them implicitly.
82
150
  export { loadConfig, saveConfig };
83
151
 
152
+ // v5.0.0 — expose cache helpers for tests and other consumers that need
153
+ // to force a re-read (e.g. settings-store after a write).
154
+ export { readOpencodeJsonCached, invalidateOpencodeJsonCache, OPENCODE_JSON_CACHE_TTL_MS };
155
+
84
156
  // ── v4.6.0 Backup-key rotation constants ────────────────────────────────────
85
157
  //
86
158
  // ERROR_COOLDOWN_THRESHOLD: after this many recorded errors on the same
@@ -148,6 +148,22 @@ export const DEFAULT_SETTINGS = {
148
148
  baseUrl: 'https://www.minimax.io',
149
149
  chatBaseUrl: 'https://api.minimax.io/v1',
150
150
  },
151
+ // v5.0.0 — Headroom context compression integration.
152
+ // Headroom sits between opencode and LLM providers, compressing tool
153
+ // outputs, logs, RAG chunks, and conversation history by 60–95%.
154
+ headroom: {
155
+ enabled: true,
156
+ autoInstall: true,
157
+ port: 8787,
158
+ host: '127.0.0.1',
159
+ outputShaper: false,
160
+ telemetry: false,
161
+ budget: 0,
162
+ backend: 'anthropic',
163
+ autoStart: true,
164
+ autoWrap: true,
165
+ routeAllProviders: true,
166
+ },
151
167
  };
152
168
 
153
169
  /**
@@ -174,6 +190,7 @@ export function mergeSettings(existing) {
174
190
  merged.agents = { ...DEFAULT_SETTINGS.agents, ...(existing.agents || {}) };
175
191
  merged.systemLlm = { ...DEFAULT_SETTINGS.systemLlm, ...(existing.systemLlm || {}) };
176
192
  merged.minimax = { ...DEFAULT_SETTINGS.minimax, ...(existing.minimax || {}) };
193
+ merged.headroom = { ...DEFAULT_SETTINGS.headroom, ...(existing.headroom || {}) };
177
194
  // Always use the package version — never let user settings override it
178
195
  merged.about.version = DEFAULT_SETTINGS.about.version;
179
196
  return merged;
@@ -53,6 +53,36 @@ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,120}$/;
53
53
  const MAX_CHAT_SUBSCRIPTIONS = 50;
54
54
  let activeChatSubscriptions = 0;
55
55
 
56
+ /**
57
+ * v5.0.0 — Bug S3: per-chat-session backpressure cap on the SSE→WS
58
+ * forwarding pipeline. The upstream SSE can pump deltas faster than
59
+ * the WS broadcast can flush them if a connected WS client is slow.
60
+ * `safeSend()` in server.mjs already terminates slow WS clients based
61
+ * on the byte-level `bufferedAmount`, but we also need a defensive
62
+ * message-count cap so a single chat session can't accumulate an
63
+ * unbounded number of queued deltas in this process. When a session
64
+ * exceeds CHAT_DELTA_BUFFER_CAP, additional deltas are dropped with
65
+ * a warning log so operators see the issue.
66
+ */
67
+ const CHAT_DELTA_BUFFER_CAP = 1000;
68
+ const chatDeltaCounts = new Map(); // chatSessionId -> count since idle
69
+
70
+ function noteChatDelta(chatSessionId) {
71
+ const cur = chatDeltaCounts.get(chatSessionId) || 0;
72
+ if (cur >= CHAT_DELTA_BUFFER_CAP) {
73
+ console.warn(
74
+ `[chat] dropped delta for session ${chatSessionId}: per-session cap (${CHAT_DELTA_BUFFER_CAP}) exceeded; client is too slow`,
75
+ );
76
+ return false;
77
+ }
78
+ chatDeltaCounts.set(chatSessionId, cur + 1);
79
+ return true;
80
+ }
81
+
82
+ function resetChatDeltaCount(chatSessionId) {
83
+ chatDeltaCounts.delete(chatSessionId);
84
+ }
85
+
56
86
  /**
57
87
  * @param {object} deps
58
88
  * @param {object} deps.state
@@ -489,6 +519,11 @@ async function streamOpencodeSession({
489
519
  // Forward text part deltas as chat:delta
490
520
  const textDelta = extractTextDelta(envelope);
491
521
  if (textDelta) {
522
+ // Bug S3 — drop the delta (with warning) if this session
523
+ // has hit the per-connection cap. The upstream SSE pump
524
+ // continues, but we stop forwarding to WS to avoid
525
+ // unbounded buffering here.
526
+ if (!noteChatDelta(chatSessionId)) return;
492
527
  broadcast({
493
528
  type: 'chat:delta',
494
529
  sessionId: chatSessionId,
@@ -501,6 +536,9 @@ async function streamOpencodeSession({
501
536
  onIdle(envelope) {
502
537
  if (done) return;
503
538
  done = true;
539
+ // Bug S3 — release the per-session delta counter on idle so
540
+ // the next prompt starts with a fresh budget.
541
+ resetChatDeltaCount(chatSessionId);
504
542
  // Fetch the final message list and extract the assistant reply.
505
543
  void (async () => {
506
544
  try {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * src/server/routes/headroom.mjs
3
+ *
4
+ * v1.0.0 — REST endpoints for Headroom management.
5
+ *
6
+ * Mounted at /api/headroom/* by api.mjs.
7
+ */
8
+ import { Router } from 'express';
9
+ import {
10
+ getHeadroomStatus,
11
+ getHeadroomStats,
12
+ installHeadroom,
13
+ wrapOpencode,
14
+ unwrapOpencode,
15
+ startProxy,
16
+ stopProxy,
17
+ getOpencodeConfig,
18
+ headroomStartupHook,
19
+ } from '../headroom.mjs';
20
+ import { wrap } from './_shared.mjs';
21
+
22
+ export function createHeadroomRouter() {
23
+ const router = Router();
24
+
25
+ // GET /api/headroom/status
26
+ router.get('/status', wrap(async (_req, res) => {
27
+ const status = await getHeadroomStatus();
28
+ res.json(status);
29
+ }));
30
+
31
+ // GET /api/headroom/stats?hours=24
32
+ router.get('/stats', wrap(async (req, res) => {
33
+ const hours = Math.max(1, Math.min(parseInt(req.query.hours, 10) || 24, 720));
34
+ const stats = await getHeadroomStats({ hours });
35
+ res.json(stats);
36
+ }));
37
+
38
+ // POST /api/headroom/install
39
+ router.post('/install', wrap(async (req, res) => {
40
+ const { force = false } = req.body || {};
41
+ const result = await installHeadroom({ force: Boolean(force) });
42
+ res.json(result);
43
+ }));
44
+
45
+ // POST /api/headroom/wrap
46
+ router.post('/wrap', wrap(async (req, res) => {
47
+ const { port } = req.body || {};
48
+ const result = await wrapOpencode({
49
+ port: port ? Math.max(1, Math.min(parseInt(port, 10), 65535)) : 8787,
50
+ });
51
+ res.json(result);
52
+ }));
53
+
54
+ // POST /api/headroom/unwrap
55
+ router.post('/unwrap', wrap(async (_req, res) => {
56
+ const result = await unwrapOpencode();
57
+ res.json(result);
58
+ }));
59
+
60
+ // POST /api/headroom/proxy/start
61
+ router.post('/proxy/start', wrap(async (req, res) => {
62
+ const { port, host } = req.body || {};
63
+ const result = await startProxy({
64
+ port: port ? Math.max(1, Math.min(parseInt(port, 10), 65535)) : 8787,
65
+ host: typeof host === 'string' && host ? host : '127.0.0.1',
66
+ });
67
+ res.json(result);
68
+ }));
69
+
70
+ // POST /api/headroom/proxy/stop
71
+ router.post('/proxy/stop', wrap(async (_req, res) => {
72
+ const result = await stopProxy();
73
+ res.json(result);
74
+ }));
75
+
76
+ // GET /api/headroom/opencode-config
77
+ router.get('/opencode-config', wrap(async (_req, res) => {
78
+ const config = await getOpencodeConfig();
79
+ res.json(config);
80
+ }));
81
+
82
+ // POST /api/headroom/auto-route — configure all providers to route through Headroom
83
+ router.post('/auto-route', wrap(async (_req, res) => {
84
+ const { loadConfig, saveConfig } = await import('../providers-store.mjs');
85
+ const cfg = loadConfig();
86
+ const headroomPort = 8787;
87
+ const proxyUrl = `http://127.0.0.1:${headroomPort}/v1`;
88
+
89
+ // For each provider, prepend the headroom proxy URL to baseURL
90
+ const providers = cfg.provider || {};
91
+ let changed = 0;
92
+ for (const [id, p] of Object.entries(providers)) {
93
+ if (!p || typeof p !== 'object') continue;
94
+ const baseURL = p.baseURL || p.options?.baseURL || '';
95
+ // Only update if not already routed
96
+ if (baseURL && !baseURL.includes('127.0.0.1:8787') && !baseURL.includes('localhost:8787')) {
97
+ const newBaseURL = `${proxyUrl}/${baseURL.replace(/^https?:\/\//, '')}`;
98
+ if (p.options) {
99
+ providers[id] = { ...p, options: { ...p.options, baseURL: newBaseURL } };
100
+ } else {
101
+ providers[id] = { ...p, baseURL: newBaseURL };
102
+ }
103
+ changed++;
104
+ }
105
+ }
106
+ cfg.provider = providers;
107
+ saveConfig(cfg);
108
+
109
+ res.json({ ok: true, changed, message: `Updated ${changed} provider(s) to route through Headroom proxy.` });
110
+ }));
111
+
112
+ // POST /api/headroom/startup-hook — trigger the startup hook programmatically
113
+ router.post('/startup-hook', wrap(async (_req, res) => {
114
+ const { readSettings } = await import('./_shared.mjs');
115
+ const settings = readSettings();
116
+ const headroomSettings = settings?.data?.headroom;
117
+ if (!headroomSettings) {
118
+ res.status(400).json({ error: 'bad_request', message: 'Headroom settings not found in settings.json' });
119
+ return;
120
+ }
121
+ const result = await headroomStartupHook(headroomSettings);
122
+ res.json(result);
123
+ }));
124
+
125
+ return router;
126
+ }
@@ -127,13 +127,17 @@ export function createLightragRouter({ projectRoot }) {
127
127
  const content = readFileSync(logFile, 'utf8');
128
128
  const lines = content.split('\n');
129
129
  logTail.push(...lines.slice(-30));
130
- } catch { /* ignore */ }
130
+ } catch (err) {
131
+ console.warn('swallowed in lightrag log tail:', err.message);
132
+ }
131
133
  }
132
134
 
133
135
  let logSize = 0;
134
136
  try {
135
137
  if (existsSync(logFile)) logSize = statSync(logFile).size;
136
- } catch { /* ignore */ }
138
+ } catch (err) {
139
+ console.warn('swallowed in lightrag log stat:', err.message);
140
+ }
137
141
 
138
142
  res.json({
139
143
  running: alive,