@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.
Files changed (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. 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
+ }