@polderlabs/bizar 4.4.13 → 4.5.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/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +116 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +86 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +68 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +18 -0
- package/bizar-dash/src/server/headroom.mjs +645 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +189 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +668 -1
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +20 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +120 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- 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/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +2 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +2 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/Memory.tsx +140 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Overview.tsx +3 -0
- package/bizar-dash/src/web/views/Settings.tsx +36 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- 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 +354 -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/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -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/lightrag-defaults.node.test.mjs +118 -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/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +247 -1
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
|
@@ -12,7 +12,8 @@
|
|
|
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 } from 'node:fs';
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, statSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
16
17
|
|
|
17
18
|
const SERVER_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
19
|
const memoryStore = await import(`${SERVER_ROOT}/memory-store.mjs`).then((m) => m);
|
|
@@ -512,11 +513,17 @@ export function createMemoryRouter({ projectRoot }) {
|
|
|
512
513
|
const { searchVault, queryLightRAG } = memoryStore;
|
|
513
514
|
const lexical = searchVault(projectRoot, q, { limit: topK });
|
|
514
515
|
let semantic = null;
|
|
516
|
+
const startedAt = Date.now();
|
|
515
517
|
try {
|
|
516
518
|
semantic = await queryLightRAG(projectRoot, q, { topK });
|
|
517
519
|
} catch (err) {
|
|
518
520
|
semantic = { ok: false, error: err.message };
|
|
519
521
|
}
|
|
522
|
+
// Record the query duration for the LightRAG stats panel. Best-effort.
|
|
523
|
+
try {
|
|
524
|
+
const { recordQuery } = await getMemoryLightrag();
|
|
525
|
+
recordQuery(projectRoot, Date.now() - startedAt);
|
|
526
|
+
} catch { /* ignore */ }
|
|
520
527
|
res.json({ ok: true, q, lexical, semantic });
|
|
521
528
|
}));
|
|
522
529
|
|
|
@@ -654,5 +661,665 @@ export function createMemoryRouter({ projectRoot }) {
|
|
|
654
661
|
}
|
|
655
662
|
}));
|
|
656
663
|
|
|
664
|
+
// ── v4.6.0 Global memory-config surface ─────────────────────────────────
|
|
665
|
+
//
|
|
666
|
+
// A second config file at `~/.config/bizar/memory-config.json` holds
|
|
667
|
+
// operator-facing settings that aren't tied to a single project —
|
|
668
|
+
// e.g. the global default LightRAG URL, the global obsidian vault
|
|
669
|
+
// path, and git push defaults. The Settings view (handled by the
|
|
670
|
+
// sibling thor-settings agent) reads + writes this file via these
|
|
671
|
+
// endpoints.
|
|
672
|
+
//
|
|
673
|
+
// Distinct from the per-project `.bizar/memory.json` (the existing
|
|
674
|
+
// /memory/config surfaces that). We use a separate path to keep the
|
|
675
|
+
// two systems cleanly separated.
|
|
676
|
+
|
|
677
|
+
const GLOBAL_MEMORY_CONFIG_PATH = join(
|
|
678
|
+
process.env.HOME || '/tmp',
|
|
679
|
+
'.config',
|
|
680
|
+
'bizar',
|
|
681
|
+
'memory-config.json',
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
function loadGlobalMemoryConfig() {
|
|
685
|
+
try {
|
|
686
|
+
if (!existsSync(GLOBAL_MEMORY_CONFIG_PATH)) {
|
|
687
|
+
return {
|
|
688
|
+
exists: false,
|
|
689
|
+
config: {
|
|
690
|
+
lightrag: { enabled: false, url: 'http://127.0.0.1:9621', llm: '', embedding: '' },
|
|
691
|
+
obsidian: { vaultPath: '', syncInterval: 300 },
|
|
692
|
+
git: { repoPath: '', remoteUrl: '', branch: 'main', autoSync: false },
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const raw = JSON.parse(readFileSync(GLOBAL_MEMORY_CONFIG_PATH, 'utf8'));
|
|
697
|
+
return { exists: true, config: raw };
|
|
698
|
+
} catch {
|
|
699
|
+
return {
|
|
700
|
+
exists: false,
|
|
701
|
+
error: 'corrupt_json',
|
|
702
|
+
config: {
|
|
703
|
+
lightrag: { enabled: false, url: 'http://127.0.0.1:9621', llm: '', embedding: '' },
|
|
704
|
+
obsidian: { vaultPath: '', syncInterval: 300 },
|
|
705
|
+
git: { repoPath: '', remoteUrl: '', branch: 'main', autoSync: false },
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function saveGlobalMemoryConfig(config) {
|
|
712
|
+
const dir = dirname(GLOBAL_MEMORY_CONFIG_PATH);
|
|
713
|
+
if (!existsSync(dir)) {
|
|
714
|
+
mkdirSync(dir, { recursive: true });
|
|
715
|
+
}
|
|
716
|
+
atomicWriteJson(GLOBAL_MEMORY_CONFIG_PATH, config);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// GET /memory/config/global — grouped shape (lightrag/obsidian/git).
|
|
720
|
+
router.get('/memory/config/global', wrap(async (_req, res) => {
|
|
721
|
+
const { exists, config, error } = loadGlobalMemoryConfig();
|
|
722
|
+
res.json({
|
|
723
|
+
exists,
|
|
724
|
+
path: GLOBAL_MEMORY_CONFIG_PATH,
|
|
725
|
+
config,
|
|
726
|
+
...(error ? { error } : {}),
|
|
727
|
+
});
|
|
728
|
+
}));
|
|
729
|
+
|
|
730
|
+
// PUT /memory/config/global — partial update. Validates each block
|
|
731
|
+
// and merges on top of the existing config.
|
|
732
|
+
router.put('/memory/config/global', wrap(async (req, res) => {
|
|
733
|
+
const body = req.body || {};
|
|
734
|
+
const { exists, config: existing } = loadGlobalMemoryConfig();
|
|
735
|
+
|
|
736
|
+
// Light validation — no zod import (keep surface small). Each
|
|
737
|
+
// block is shallow-merged; if a block is present, we shallow-merge
|
|
738
|
+
// its top-level fields, rejecting unknown top-level keys.
|
|
739
|
+
const next = JSON.parse(JSON.stringify(existing));
|
|
740
|
+
|
|
741
|
+
const VALID_TOP_KEYS = new Set(['lightrag', 'obsidian', 'git']);
|
|
742
|
+
const EXTRA_KEYS = Object.keys(body).filter((k) => !VALID_TOP_KEYS.has(k));
|
|
743
|
+
if (EXTRA_KEYS.length > 0) {
|
|
744
|
+
res.status(400).json({
|
|
745
|
+
error: 'bad_request',
|
|
746
|
+
message: `unknown top-level keys: ${EXTRA_KEYS.join(', ')}`,
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (body.lightrag !== undefined) {
|
|
752
|
+
if (typeof body.lightrag !== 'object' || body.lightrag === null || Array.isArray(body.lightrag)) {
|
|
753
|
+
res.status(400).json({ error: 'bad_request', message: 'lightrag must be an object' });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const l = body.lightrag;
|
|
757
|
+
if ('enabled' in l && typeof l.enabled !== 'boolean') {
|
|
758
|
+
res.status(400).json({ error: 'bad_request', message: 'lightrag.enabled must be boolean' });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if ('url' in l && typeof l.url !== 'string') {
|
|
762
|
+
res.status(400).json({ error: 'bad_request', message: 'lightrag.url must be a string' });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if ('llm' in l && typeof l.llm !== 'string') {
|
|
766
|
+
res.status(400).json({ error: 'bad_request', message: 'lightrag.llm must be a string' });
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if ('embedding' in l && typeof l.embedding !== 'string') {
|
|
770
|
+
res.status(400).json({ error: 'bad_request', message: 'lightrag.embedding must be a string' });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
next.lightrag = { ...next.lightrag, ...l };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (body.obsidian !== undefined) {
|
|
777
|
+
if (typeof body.obsidian !== 'object' || body.obsidian === null || Array.isArray(body.obsidian)) {
|
|
778
|
+
res.status(400).json({ error: 'bad_request', message: 'obsidian must be an object' });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const o = body.obsidian;
|
|
782
|
+
if ('vaultPath' in o && typeof o.vaultPath !== 'string') {
|
|
783
|
+
res.status(400).json({ error: 'bad_request', message: 'obsidian.vaultPath must be a string' });
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if ('syncInterval' in o) {
|
|
787
|
+
const n = Number(o.syncInterval);
|
|
788
|
+
if (!Number.isFinite(n) || n < 0 || n > 86400 * 30) {
|
|
789
|
+
res.status(400).json({ error: 'bad_request', message: 'obsidian.syncInterval must be 0..2592000 seconds' });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
next.obsidian.syncInterval = n;
|
|
793
|
+
}
|
|
794
|
+
if ('vaultPath' in o) next.obsidian.vaultPath = o.vaultPath;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (body.git !== undefined) {
|
|
798
|
+
if (typeof body.git !== 'object' || body.git === null || Array.isArray(body.git)) {
|
|
799
|
+
res.status(400).json({ error: 'bad_request', message: 'git must be an object' });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const g = body.git;
|
|
803
|
+
if ('repoPath' in g && typeof g.repoPath !== 'string') {
|
|
804
|
+
res.status(400).json({ error: 'bad_request', message: 'git.repoPath must be a string' });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if ('remoteUrl' in g && typeof g.remoteUrl !== 'string') {
|
|
808
|
+
res.status(400).json({ error: 'bad_request', message: 'git.remoteUrl must be a string' });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if ('branch' in g) {
|
|
812
|
+
if (typeof g.branch !== 'string' || !/^[A-Za-z0-9._/-]{1,200}$/.test(g.branch)) {
|
|
813
|
+
res.status(400).json({ error: 'bad_request', message: 'git.branch must be a valid git ref name' });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
next.git.branch = g.branch;
|
|
817
|
+
}
|
|
818
|
+
if ('autoSync' in g && typeof g.autoSync !== 'boolean') {
|
|
819
|
+
res.status(400).json({ error: 'bad_request', message: 'git.autoSync must be boolean' });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if ('repoPath' in g) next.git.repoPath = g.repoPath;
|
|
823
|
+
if ('remoteUrl' in g) next.git.remoteUrl = g.remoteUrl;
|
|
824
|
+
if ('autoSync' in g) next.git.autoSync = g.autoSync;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
try {
|
|
828
|
+
saveGlobalMemoryConfig(next);
|
|
829
|
+
} catch (err) {
|
|
830
|
+
res.status(500).json({ error: 'write_failed', message: err.message });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
res.json({ ok: true, exists: true, path: GLOBAL_MEMORY_CONFIG_PATH, config: next });
|
|
834
|
+
}));
|
|
835
|
+
|
|
836
|
+
// POST /memory/test-git — test the configured git repo. Returns:
|
|
837
|
+
// { ok: bool, checks: [...], message?: string }
|
|
838
|
+
// Steps performed:
|
|
839
|
+
// 1. Path exists?
|
|
840
|
+
// 2. Is a directory?
|
|
841
|
+
// 3. Is a valid git repo (has .git/)?
|
|
842
|
+
// 4. (Optional) Has a configured remote?
|
|
843
|
+
// 5. (Optional) Can we push (skipped unless ?push=1)?
|
|
844
|
+
router.post('/memory/test-git', wrap(async (req, res) => {
|
|
845
|
+
const { config } = loadGlobalMemoryConfig();
|
|
846
|
+
const repoPath = config?.git?.repoPath || '';
|
|
847
|
+
const remoteUrl = config?.git?.remoteUrl || '';
|
|
848
|
+
const wantPush = req.query.push === '1' || req.body?.push === true;
|
|
849
|
+
|
|
850
|
+
const checks = [];
|
|
851
|
+
if (!repoPath) {
|
|
852
|
+
checks.push({ name: 'repo_path_set', pass: false, detail: 'git.repoPath is empty — set it in Settings first' });
|
|
853
|
+
res.json({ ok: false, checks, message: 'repo path not configured' });
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
checks.push({
|
|
858
|
+
name: 'repo_path_exists',
|
|
859
|
+
pass: existsSync(repoPath),
|
|
860
|
+
detail: existsSync(repoPath) ? repoPath : `${repoPath} does not exist`,
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
let isGit = false;
|
|
864
|
+
let isDir = false;
|
|
865
|
+
try {
|
|
866
|
+
const s = statSync(repoPath);
|
|
867
|
+
isDir = s.isDirectory();
|
|
868
|
+
checks.push({ name: 'repo_is_directory', pass: isDir, detail: isDir ? 'yes' : 'not a directory' });
|
|
869
|
+
} catch (err) {
|
|
870
|
+
checks.push({ name: 'repo_is_directory', pass: false, detail: err.message });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (isDir) {
|
|
874
|
+
isGit = existsSync(join(repoPath, '.git'));
|
|
875
|
+
checks.push({ name: 'is_git_repo', pass: isGit, detail: isGit ? 'yes' : 'no .git/ directory' });
|
|
876
|
+
} else {
|
|
877
|
+
checks.push({ name: 'is_git_repo', pass: false, detail: 'skipped (not a directory)' });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (remoteUrl) {
|
|
881
|
+
checks.push({ name: 'remote_url_set', pass: true, detail: remoteUrl });
|
|
882
|
+
} else {
|
|
883
|
+
checks.push({ name: 'remote_url_set', pass: false, detail: 'no remote configured' });
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (wantPush && isGit && remoteUrl) {
|
|
887
|
+
try {
|
|
888
|
+
const out = execFileSync('git', ['ls-remote', '--heads', remoteUrl], { timeout: 5000 }).toString();
|
|
889
|
+
checks.push({ name: 'remote_reachable', pass: true, detail: `${out.split('\n').filter(Boolean).length} heads` });
|
|
890
|
+
} catch (err) {
|
|
891
|
+
checks.push({ name: 'remote_reachable', pass: false, detail: err.message });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const ok = checks.every((c) => c.pass);
|
|
896
|
+
res.json({
|
|
897
|
+
ok,
|
|
898
|
+
checks,
|
|
899
|
+
message: ok ? 'git config looks healthy' : 'one or more checks failed',
|
|
900
|
+
});
|
|
901
|
+
}));
|
|
902
|
+
|
|
903
|
+
// ── v4.7.0 — Memory tab endpoints ─────────────────────────────────────────
|
|
904
|
+
//
|
|
905
|
+
// These endpoints are the canonical surface for the dedicated Memory
|
|
906
|
+
// tab. They sit alongside the per-resource endpoints above and return
|
|
907
|
+
// the shapes the React panels expect (always objects, never arrays).
|
|
908
|
+
|
|
909
|
+
// GET /memory/health — composite health score (0..100) + breakdown.
|
|
910
|
+
// Components:
|
|
911
|
+
// - vaultExists (vault dir on disk)
|
|
912
|
+
// - vaultWritable (can write a temp file and unlink it)
|
|
913
|
+
// - gitClean (mode is managed/linked AND `git status` is clean)
|
|
914
|
+
// - lightragRunning (server up + health check ok)
|
|
915
|
+
// - schemaValid (no validation errors across the vault)
|
|
916
|
+
// - secretsClean (no HIGH-severity secret findings)
|
|
917
|
+
router.get('/memory/health', wrap(async (_req, res) => {
|
|
918
|
+
const { resolveVault, loadConfig, listNotes, validateAll, scanForSecrets } = memoryStore;
|
|
919
|
+
const { isGitInstalled, status: gitStatus } = memoryGit;
|
|
920
|
+
|
|
921
|
+
const checks = [];
|
|
922
|
+
let score = 0;
|
|
923
|
+
|
|
924
|
+
const { exists, config } = loadConfig(projectRoot);
|
|
925
|
+
if (!exists) {
|
|
926
|
+
res.json({
|
|
927
|
+
score: 0,
|
|
928
|
+
status: 'unconfigured',
|
|
929
|
+
checks: [{ name: 'config', pass: false, detail: 'memory not initialised' }],
|
|
930
|
+
message: 'memory not initialised — run `bizar memory init`',
|
|
931
|
+
});
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const { vaultRoot, mode } = resolveVault(projectRoot);
|
|
936
|
+
const vaultExists = existsSync(vaultRoot);
|
|
937
|
+
checks.push({
|
|
938
|
+
name: 'vault_exists',
|
|
939
|
+
pass: vaultExists,
|
|
940
|
+
detail: vaultExists ? vaultRoot : 'vault directory missing',
|
|
941
|
+
});
|
|
942
|
+
if (vaultExists) score += 20;
|
|
943
|
+
|
|
944
|
+
// Writable?
|
|
945
|
+
let writable = false;
|
|
946
|
+
if (vaultExists) {
|
|
947
|
+
try {
|
|
948
|
+
const probe = join(vaultRoot, '.health-probe.tmp');
|
|
949
|
+
writeFileSync(probe, 'ok');
|
|
950
|
+
try {
|
|
951
|
+
const { unlinkSync } = await import('node:fs');
|
|
952
|
+
unlinkSync(probe);
|
|
953
|
+
} catch { /* best effort */ }
|
|
954
|
+
writable = true;
|
|
955
|
+
} catch {
|
|
956
|
+
writable = false;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
checks.push({ name: 'vault_writable', pass: writable, detail: writable ? 'yes' : 'no' });
|
|
960
|
+
if (writable) score += 10;
|
|
961
|
+
|
|
962
|
+
// Git clean?
|
|
963
|
+
let gitClean = null;
|
|
964
|
+
if ((mode === 'managed' || mode === 'linked') && vaultExists && isGitInstalled()) {
|
|
965
|
+
const gs = gitStatus(vaultRoot);
|
|
966
|
+
gitClean = gs.clean;
|
|
967
|
+
checks.push({
|
|
968
|
+
name: 'git_clean',
|
|
969
|
+
pass: gs.clean,
|
|
970
|
+
detail: gs.clean ? 'working tree clean' : `${(gs.modified?.length || 0) + (gs.untracked?.length || 0)} pending`,
|
|
971
|
+
});
|
|
972
|
+
if (gs.clean) score += 20;
|
|
973
|
+
} else if (mode === 'local-only') {
|
|
974
|
+
checks.push({ name: 'git_clean', pass: true, detail: 'local-only mode (no git)' });
|
|
975
|
+
// local-only mode shouldn't penalise — count as clean
|
|
976
|
+
score += 20;
|
|
977
|
+
} else {
|
|
978
|
+
checks.push({ name: 'git_clean', pass: false, detail: 'git not installed or vault missing' });
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// LightRAG running?
|
|
982
|
+
let lightragRunning = false;
|
|
983
|
+
try {
|
|
984
|
+
const { resolveLightRAGConfig, isRunning } = await getMemoryLightrag();
|
|
985
|
+
const cfg = resolveLightRAGConfig(projectRoot);
|
|
986
|
+
lightragRunning = await isRunning(cfg);
|
|
987
|
+
} catch {
|
|
988
|
+
lightragRunning = false;
|
|
989
|
+
}
|
|
990
|
+
checks.push({
|
|
991
|
+
name: 'lightrag_running',
|
|
992
|
+
pass: lightragRunning,
|
|
993
|
+
detail: lightragRunning ? 'yes' : 'stopped or disabled',
|
|
994
|
+
});
|
|
995
|
+
if (lightragRunning) score += 20;
|
|
996
|
+
|
|
997
|
+
// Schema valid?
|
|
998
|
+
let invalidCount = 0;
|
|
999
|
+
if (vaultExists) {
|
|
1000
|
+
const validationResults = validateAll(projectRoot);
|
|
1001
|
+
invalidCount = validationResults.length;
|
|
1002
|
+
}
|
|
1003
|
+
checks.push({
|
|
1004
|
+
name: 'schema_valid',
|
|
1005
|
+
pass: invalidCount === 0,
|
|
1006
|
+
detail: invalidCount === 0 ? 'all notes valid' : `${invalidCount} invalid`,
|
|
1007
|
+
});
|
|
1008
|
+
if (invalidCount === 0 && vaultExists) score += 15;
|
|
1009
|
+
|
|
1010
|
+
// Secrets clean?
|
|
1011
|
+
let highFindings = 0;
|
|
1012
|
+
if (vaultExists) {
|
|
1013
|
+
for (const n of listNotes(projectRoot)) {
|
|
1014
|
+
const r = scanForSecrets(projectRoot, n.relPath);
|
|
1015
|
+
if (!r.safe) {
|
|
1016
|
+
for (const f of r.findings) {
|
|
1017
|
+
if (f.severity === 'HIGH') highFindings++;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
checks.push({
|
|
1023
|
+
name: 'secrets_clean',
|
|
1024
|
+
pass: highFindings === 0,
|
|
1025
|
+
detail: highFindings === 0 ? 'no HIGH-severity secrets' : `${highFindings} HIGH finding(s)`,
|
|
1026
|
+
});
|
|
1027
|
+
if (highFindings === 0) score += 15;
|
|
1028
|
+
|
|
1029
|
+
const status = score >= 80 ? 'healthy' : score >= 50 ? 'degraded' : 'unhealthy';
|
|
1030
|
+
res.json({
|
|
1031
|
+
score,
|
|
1032
|
+
status,
|
|
1033
|
+
checks,
|
|
1034
|
+
message:
|
|
1035
|
+
status === 'healthy' ? 'memory system healthy' :
|
|
1036
|
+
status === 'degraded' ? 'one or more subsystems need attention' :
|
|
1037
|
+
'multiple subsystems failing',
|
|
1038
|
+
});
|
|
1039
|
+
}));
|
|
1040
|
+
|
|
1041
|
+
// GET /memory/storage — disk-usage summary for the Memory tab.
|
|
1042
|
+
// Walks the vault + .bizar/memory-cache + lightrag working dir.
|
|
1043
|
+
router.get('/memory/storage', wrap(async (_req, res) => {
|
|
1044
|
+
const { resolveVault } = memoryStore;
|
|
1045
|
+
const { vaultRoot, mode } = resolveVault(projectRoot);
|
|
1046
|
+
|
|
1047
|
+
const targets = [];
|
|
1048
|
+
if (existsSync(vaultRoot)) targets.push({ name: 'vault', path: vaultRoot });
|
|
1049
|
+
const cacheDir = join(projectRoot, '.bizar', 'memory-cache');
|
|
1050
|
+
if (existsSync(cacheDir)) targets.push({ name: 'memory-cache', path: cacheDir });
|
|
1051
|
+
const lightragDir = join(projectRoot, '.bizar', 'lightrag');
|
|
1052
|
+
if (existsSync(lightragDir)) targets.push({ name: 'lightrag', path: lightragDir });
|
|
1053
|
+
|
|
1054
|
+
let total = 0;
|
|
1055
|
+
const breakdown = targets.map((t) => {
|
|
1056
|
+
const size = dirSize(t.path);
|
|
1057
|
+
total += size;
|
|
1058
|
+
return { name: t.name, path: t.path, size };
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
res.json({
|
|
1062
|
+
total,
|
|
1063
|
+
breakdown,
|
|
1064
|
+
mode,
|
|
1065
|
+
vaultRoot,
|
|
1066
|
+
message: total === 0 ? 'no memory data on disk yet' : `${formatBytes(total)} on disk`,
|
|
1067
|
+
});
|
|
1068
|
+
}));
|
|
1069
|
+
|
|
1070
|
+
// GET /memory/git/diff — textual diff of the working tree (last commit vs HEAD).
|
|
1071
|
+
// Returns { hasDiff, lines, files } — `lines` is a flat unified-diff-ish view.
|
|
1072
|
+
router.get('/memory/git/diff', wrap(async (_req, res) => {
|
|
1073
|
+
const { resolveVault } = memoryStore;
|
|
1074
|
+
const { isGitInstalled } = memoryGit;
|
|
1075
|
+
const { vaultRoot, mode } = resolveVault(projectRoot);
|
|
1076
|
+
|
|
1077
|
+
if (mode === 'local-only') {
|
|
1078
|
+
res.json({ hasDiff: false, lines: [], files: [], mode: 'local-only' });
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (!isGitInstalled()) {
|
|
1082
|
+
res.status(503).json({ error: 'git_not_installed' });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
let raw = '';
|
|
1087
|
+
try {
|
|
1088
|
+
raw = execFileSync('git', ['diff', '--no-color', '--no-ext-diff'], {
|
|
1089
|
+
cwd: vaultRoot,
|
|
1090
|
+
encoding: 'utf8',
|
|
1091
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1092
|
+
});
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
res.status(500).json({ error: 'diff_failed', message: err.message });
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Also include untracked file names.
|
|
1099
|
+
let untracked = [];
|
|
1100
|
+
try {
|
|
1101
|
+
const statusRaw = execFileSync('git', ['status', '--porcelain'], {
|
|
1102
|
+
cwd: vaultRoot,
|
|
1103
|
+
encoding: 'utf8',
|
|
1104
|
+
});
|
|
1105
|
+
for (const line of statusRaw.split('\n')) {
|
|
1106
|
+
if (line.startsWith('??')) untracked.push(line.slice(3).trim());
|
|
1107
|
+
}
|
|
1108
|
+
} catch {
|
|
1109
|
+
/* ignore */
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const lines = raw.split('\n');
|
|
1113
|
+
const files = [];
|
|
1114
|
+
for (const line of lines) {
|
|
1115
|
+
if (line.startsWith('diff --git ')) {
|
|
1116
|
+
const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
1117
|
+
if (m) files.push(m[2]);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
for (const u of untracked) {
|
|
1121
|
+
if (!files.includes(u)) files.push(u);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
res.json({
|
|
1125
|
+
hasDiff: lines.length > 1 || untracked.length > 0,
|
|
1126
|
+
lines,
|
|
1127
|
+
files,
|
|
1128
|
+
mode,
|
|
1129
|
+
});
|
|
1130
|
+
}));
|
|
1131
|
+
|
|
1132
|
+
// GET /memory/lightrag/stats — aggregate stats for the LightRAG panel.
|
|
1133
|
+
router.get('/memory/lightrag/stats', wrap(async (_req, res) => {
|
|
1134
|
+
try {
|
|
1135
|
+
const { stats } = await getMemoryLightrag();
|
|
1136
|
+
const data = await stats(projectRoot);
|
|
1137
|
+
res.json(data);
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
res.status(500).json({ error: 'stats_failed', message: err.message });
|
|
1140
|
+
}
|
|
1141
|
+
}));
|
|
1142
|
+
|
|
1143
|
+
// POST /memory/lightrag/reindex — alias of /memory/reindex (the canonical
|
|
1144
|
+
// path lives there for backwards compat). Both return the same shape.
|
|
1145
|
+
router.post('/memory/lightrag/reindex', wrap(async (_req, res) => {
|
|
1146
|
+
const { reindexVault } = memoryStore;
|
|
1147
|
+
const result = await reindexVault(projectRoot, {});
|
|
1148
|
+
res.json(result);
|
|
1149
|
+
}));
|
|
1150
|
+
|
|
1151
|
+
// POST /memory/lightrag/rebuild-graph — nuke the working dir + reindex.
|
|
1152
|
+
router.post('/memory/lightrag/rebuild-graph', wrap(async (_req, res) => {
|
|
1153
|
+
try {
|
|
1154
|
+
const { rebuildGraph } = await getMemoryLightrag();
|
|
1155
|
+
const result = await rebuildGraph(projectRoot, {});
|
|
1156
|
+
res.json(result);
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
res.status(500).json({ error: 'rebuild_failed', message: err.message });
|
|
1159
|
+
}
|
|
1160
|
+
}));
|
|
1161
|
+
|
|
1162
|
+
// GET /memory/obsidian/tree — recursive folder tree for the Memory browser.
|
|
1163
|
+
router.get('/memory/obsidian/tree', wrap(async (_req, res) => {
|
|
1164
|
+
try {
|
|
1165
|
+
const obsidian = await import(`${SERVER_ROOT}/memory-obsidian.mjs`);
|
|
1166
|
+
const node = obsidian.tree(projectRoot);
|
|
1167
|
+
res.json({ tree: node });
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
res.status(500).json({ error: 'tree_failed', message: err.message });
|
|
1170
|
+
}
|
|
1171
|
+
}));
|
|
1172
|
+
|
|
1173
|
+
// GET /memory/obsidian/backlinks?note=path/to/note.md
|
|
1174
|
+
router.get('/memory/obsidian/backlinks', wrap(async (req, res) => {
|
|
1175
|
+
const note = String(req.query.note || '').trim();
|
|
1176
|
+
if (!note) {
|
|
1177
|
+
res.status(400).json({ error: 'bad_request', message: 'note query param required' });
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
const obsidian = await import(`${SERVER_ROOT}/memory-obsidian.mjs`);
|
|
1182
|
+
const links = obsidian.listBacklinks(projectRoot, note);
|
|
1183
|
+
res.json({ note, backlinks: links });
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
res.status(500).json({ error: 'backlinks_failed', message: err.message });
|
|
1186
|
+
}
|
|
1187
|
+
}));
|
|
1188
|
+
|
|
1189
|
+
// GET /memory/obsidian/notes?path=...&limit=...
|
|
1190
|
+
// Query-string variant of the canonical /memory/notes endpoint. Used by
|
|
1191
|
+
// the ObsidianPanel folder browser which knows the parent path.
|
|
1192
|
+
router.get('/memory/obsidian/notes', wrap(async (req, res) => {
|
|
1193
|
+
const { listNotes } = memoryStore;
|
|
1194
|
+
const all = listNotes(projectRoot);
|
|
1195
|
+
const path = String(req.query.path || '').trim();
|
|
1196
|
+
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500);
|
|
1197
|
+
let notes = all;
|
|
1198
|
+
if (path) {
|
|
1199
|
+
notes = all.filter((n) => n.relPath.startsWith(path));
|
|
1200
|
+
}
|
|
1201
|
+
notes = notes.slice(0, limit);
|
|
1202
|
+
res.json({ path: path || null, count: notes.length, notes });
|
|
1203
|
+
}));
|
|
1204
|
+
|
|
1205
|
+
// PUT /memory/notes/* — update an existing note (or create).
|
|
1206
|
+
router.put('/memory/notes/*', wrap(async (req, res) => {
|
|
1207
|
+
const { writeNote } = memoryStore;
|
|
1208
|
+
const relPath = req.params[0];
|
|
1209
|
+
const { frontmatter, body } = req.body || {};
|
|
1210
|
+
if (!relPath) {
|
|
1211
|
+
res.status(400).json({ error: 'bad_request', message: 'path is required' });
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (!relPath.endsWith('.md')) {
|
|
1215
|
+
res.status(400).json({ error: 'bad_request', message: 'path must end in .md' });
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
const note = writeNote(projectRoot, relPath, { frontmatter: frontmatter || {}, body: body || '' });
|
|
1220
|
+
res.json(note);
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
if (err.code === 'SCHEMA_VALIDATION_FAILED' || err.code === 'SECRET_DETECTED') {
|
|
1223
|
+
res.status(400).json({ error: err.code, message: err.message, findings: err.findings });
|
|
1224
|
+
} else {
|
|
1225
|
+
res.status(400).json({ error: 'bad_request', message: err.message });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}));
|
|
1229
|
+
|
|
1230
|
+
// POST /memory/semantic-search — cross-source search.
|
|
1231
|
+
// Body: { query: string, limit?: number, sources?: Array<'lightrag'|'obsidian'> }
|
|
1232
|
+
router.post('/memory/semantic-search', wrap(async (req, res) => {
|
|
1233
|
+
const body = req.body || {};
|
|
1234
|
+
const query = String(body.query || '').trim();
|
|
1235
|
+
if (!query) {
|
|
1236
|
+
res.status(400).json({ error: 'bad_request', message: 'query is required' });
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
const limit = Math.min(parseInt(body.limit, 10) || 10, 50);
|
|
1240
|
+
const requestedSources = Array.isArray(body.sources) && body.sources.length > 0
|
|
1241
|
+
? new Set(body.sources.map((s) => String(s).toLowerCase()))
|
|
1242
|
+
: new Set(['lightrag', 'obsidian']);
|
|
1243
|
+
|
|
1244
|
+
const results = [];
|
|
1245
|
+
if (requestedSources.has('obsidian')) {
|
|
1246
|
+
const { searchVault } = memoryStore;
|
|
1247
|
+
const lex = searchVault(projectRoot, query, { limit });
|
|
1248
|
+
for (const r of lex) {
|
|
1249
|
+
results.push({
|
|
1250
|
+
source: 'obsidian',
|
|
1251
|
+
relPath: r.relPath,
|
|
1252
|
+
snippet: r.snippet,
|
|
1253
|
+
score: r.score,
|
|
1254
|
+
mtime: r.mtime,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (requestedSources.has('lightrag')) {
|
|
1259
|
+
try {
|
|
1260
|
+
const { resolveLightRAGConfig, query: lightragQuery } = await getMemoryLightrag();
|
|
1261
|
+
const cfg = resolveLightRAGConfig(projectRoot);
|
|
1262
|
+
const r = await lightragQuery(cfg, query, { topK: limit });
|
|
1263
|
+
if (r.ok && r.response) {
|
|
1264
|
+
// LightRAG's response shape varies by mode; coerce to a snippet.
|
|
1265
|
+
const text = typeof r.response === 'string'
|
|
1266
|
+
? r.response
|
|
1267
|
+
: (r.response?.response || r.response?.answer || JSON.stringify(r.response));
|
|
1268
|
+
results.push({
|
|
1269
|
+
source: 'lightrag',
|
|
1270
|
+
relPath: null,
|
|
1271
|
+
snippet: String(text).slice(0, 500),
|
|
1272
|
+
score: 1,
|
|
1273
|
+
mtime: null,
|
|
1274
|
+
raw: r.response,
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
results.push({ source: 'lightrag', error: err.message });
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Dedupe by (source, relPath), keep highest score.
|
|
1283
|
+
const seen = new Map();
|
|
1284
|
+
for (const r of results) {
|
|
1285
|
+
const key = `${r.source}|${r.relPath || '_query_'}`;
|
|
1286
|
+
const prev = seen.get(key);
|
|
1287
|
+
if (!prev || (r.score || 0) > (prev.score || 0)) {
|
|
1288
|
+
seen.set(key, r);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
const deduped = [...seen.values()].sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
1292
|
+
|
|
1293
|
+
res.json({ query, count: deduped.length, results: deduped });
|
|
1294
|
+
}));
|
|
1295
|
+
|
|
657
1296
|
return router;
|
|
658
1297
|
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Recursively sum file sizes under `dir`. Returns 0 if dir doesn't exist.
|
|
1301
|
+
*/
|
|
1302
|
+
function dirSize(dir) {
|
|
1303
|
+
let total = 0;
|
|
1304
|
+
function walk(d) {
|
|
1305
|
+
let st;
|
|
1306
|
+
try { st = statSync(d); } catch { return; }
|
|
1307
|
+
if (st.isFile()) { total += st.size; return; }
|
|
1308
|
+
if (!st.isDirectory()) return;
|
|
1309
|
+
let entries;
|
|
1310
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
1311
|
+
for (const e of entries) {
|
|
1312
|
+
if (e.name.startsWith('.')) continue;
|
|
1313
|
+
walk(join(d, e.name));
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
walk(dir);
|
|
1317
|
+
return total;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function formatBytes(n) {
|
|
1321
|
+
if (n < 1024) return `${n} B`;
|
|
1322
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
1323
|
+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
1324
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
1325
|
+
}
|