@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.
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-B4OfGAwz.js +361 -0
- package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +1 -0
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
- package/bizar-dash/dist/assets/{mobile--0FBIKX3.js → mobile-BRhoDOUz.js} +1 -2
- package/bizar-dash/dist/assets/{mobile--0FBIKX3.js.map → mobile-BRhoDOUz.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-OgRp8VIb.js → mobile-lbH6szyX.js} +2 -3
- package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/bizar/SKILL.md +20 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +11 -0
- package/bizar-dash/skills/obsidian/SKILL.md +13 -0
- package/bizar-dash/src/server/api.mjs +6 -0
- package/bizar-dash/src/server/headroom.mjs +647 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +281 -6
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +151 -0
- package/bizar-dash/src/server/mods-loader.mjs +13 -5
- package/bizar-dash/src/server/providers-store.mjs +73 -1
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/chat.mjs +38 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +6 -2
- package/bizar-dash/src/server/routes/memory.mjs +439 -4
- package/bizar-dash/src/server/schedules-runner.mjs +4 -4
- package/bizar-dash/src/server/server.mjs +55 -37
- package/bizar-dash/src/server/watcher.mjs +2 -2
- package/bizar-dash/src/web/App.tsx +42 -6
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/Toast.tsx +1 -1
- package/bizar-dash/src/web/components/Topbar.tsx +3 -1
- package/bizar-dash/src/web/lib/api.ts +12 -11
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/styles/chat.css +2 -0
- package/bizar-dash/src/web/styles/main.css +52 -23
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/tasks.css +2 -0
- package/bizar-dash/src/web/views/Memory.tsx +141 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
- package/bizar-dash/src/web/views/Overview.tsx +7 -2
- package/bizar-dash/src/web/views/Settings.tsx +32 -1
- package/bizar-dash/src/web/views/Skills.tsx +3 -2
- package/bizar-dash/src/web/views/Tasks.tsx +4 -3
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +356 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/cli/artifact.mjs +11 -4
- package/cli/bin.mjs +211 -12
- package/cli/doctor.mjs +5 -3
- package/cli/memory.mjs +65 -21
- package/cli/provision.mjs +8 -2
- package/install.sh +1 -2
- package/package.json +3 -3
- package/bizar-dash/dist/assets/main-CDFKHzBg.css +0 -1
- package/bizar-dash/dist/assets/main-NYFpS2wY.js +0 -312
- package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +0 -1
- 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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
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 {
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn('swallowed in lightrag log stat:', err.message);
|
|
140
|
+
}
|
|
137
141
|
|
|
138
142
|
res.json({
|
|
139
143
|
running: alive,
|