@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
@@ -42,6 +42,8 @@ import {
42
42
  closeSync,
43
43
  unlinkSync,
44
44
  readdirSync,
45
+ statSync,
46
+ appendFileSync,
45
47
  } from 'node:fs';
46
48
  import { join, dirname, basename } from 'node:path';
47
49
  import { homedir } from 'node:os';
@@ -99,8 +101,11 @@ export const LIGHTRAG_DEFAULTS = Object.freeze({
99
101
  startupTimeoutMs: 30_000,
100
102
  llmBinding: 'ollama', // ollama | openai | lollms | azure_openai | bedrock | gemini
101
103
  embeddingBinding: 'ollama', // ollama | openai | azure_openai | bedrock | jina | gemini | voyageai
102
- llmModel: 'minimax/MiniMax-M3',
103
- embeddingModel: 'text-embedding-3-small',
104
+ // v4.6.0 — Default to free opencode Zen models (no API key required).
105
+ // Per-project overrides via .bizar/memory.json#lightrag.{llmModel,embeddingModel}
106
+ // or env vars BIZAR_LIGHTRAG_LLM / BIZAR_LIGHTRAG_EMBEDDING.
107
+ llmModel: 'opencode/gpt-5-nano',
108
+ embeddingModel: 'opencode/text-embedding-3-small',
104
109
  llmBindingHost: null, // for non-ollama bindings
105
110
  embeddingBindingHost: null,
106
111
  apiKey: 'env', // 'env' means read from env var; any other string is written to file
@@ -764,4 +769,269 @@ export async function query(config, question, { mode = 'mix', topK = 10 } = {})
764
769
  } catch (err) {
765
770
  return { ok: false, mode, error: err.message };
766
771
  }
772
+ }
773
+
774
+ // ── v4.7.0 — Memory tab helpers ─────────────────────────────────────────────
775
+ //
776
+ // Lightweight status / stats / rebuild helpers used by the dedicated Memory
777
+ // view. The full reindexVault path above already produces a marker file with
778
+ // most of the numbers we want; these helpers are thin wrappers around it.
779
+
780
+ /**
781
+ * Aggregate stats for the Memory → LightRAG panel.
782
+ *
783
+ * Pulls from:
784
+ * - the last-reindex.json marker file (writeMarker: true leaves it)
785
+ * - the configured working dir (where chunks/index files live)
786
+ * - the running server (PID, alive)
787
+ *
788
+ * Returns a defensive default when nothing has been indexed yet.
789
+ *
790
+ * @param {string} projectRoot
791
+ * @returns {{
792
+ * running: boolean,
793
+ * pid: number|null,
794
+ * host: string,
795
+ * port: number,
796
+ * workingDir: string,
797
+ * lastReindexAt: string|null,
798
+ * lastReindexOk: boolean|null,
799
+ * lastReindexInserted: number|null,
800
+ * lastReindexFailed: number|null,
801
+ * noteCount: number,
802
+ * indexedApprox: number,
803
+ * queryCountLast24h: number,
804
+ * avgResponseMs: number|null,
805
+ * error?: string,
806
+ * }}
807
+ */
808
+ export async function stats(projectRoot) {
809
+ const config = resolveLightRAGConfig(projectRoot);
810
+ const cacheDir = join(projectRoot, '.bizar', 'memory-cache');
811
+ const markerPath = join(cacheDir, 'last-reindex.json');
812
+ const running = await isRunning(config);
813
+ let pid = null;
814
+ const pidFile = join(config.workingDir, 'lightrag.pid');
815
+ if (existsSync(pidFile)) {
816
+ const parsed = parseIntSafe(readFileSync(pidFile, 'utf8'));
817
+ if (parsed !== null && parsed > 0) {
818
+ try {
819
+ process.kill(parsed, 0);
820
+ pid = parsed;
821
+ } catch {
822
+ pid = null;
823
+ }
824
+ }
825
+ }
826
+
827
+ let lastReindexAt = null;
828
+ let lastReindexOk = null;
829
+ let lastReindexInserted = null;
830
+ let lastReindexFailed = null;
831
+ if (existsSync(markerPath)) {
832
+ try {
833
+ const m = JSON.parse(readFileSync(markerPath, 'utf8'));
834
+ lastReindexAt = m.finishedAt || m.attemptedAt || null;
835
+ lastReindexOk = m.ok === true;
836
+ lastReindexInserted = typeof m.inserted === 'number' ? m.inserted : null;
837
+ lastReindexFailed = typeof m.failed === 'number' ? m.failed : null;
838
+ } catch {
839
+ /* marker corrupt — ignore */
840
+ }
841
+ }
842
+
843
+ // Approximate the indexed chunk count from the on-disk KV store. LightRAG
844
+ // writes its chunks to <workingDir>/kv_store_*.json. We sum the lengths of
845
+ // the document store as a proxy for "indexed documents"; this is best-effort
846
+ // and clearly labeled as approximate.
847
+ let indexedApprox = 0;
848
+ try {
849
+ const docStore = join(config.workingDir, 'kv_store_doc_status.json');
850
+ const fullDocs = join(config.workingDir, 'kv_store_full_docs.json');
851
+ for (const p of [docStore, fullDocs]) {
852
+ if (existsSync(p)) {
853
+ try {
854
+ const obj = JSON.parse(readFileSync(p, 'utf8'));
855
+ indexedApprox += Object.keys(obj || {}).length;
856
+ } catch {
857
+ /* skip */
858
+ }
859
+ }
860
+ }
861
+ } catch {
862
+ /* ignore */
863
+ }
864
+
865
+ // Note count = listNotes from the vault.
866
+ let noteCount = 0;
867
+ try {
868
+ const memPath = join(projectRoot, '.bizar', 'memory.json');
869
+ if (existsSync(memPath)) {
870
+ const mem = JSON.parse(readFileSync(memPath, 'utf8'));
871
+ const projectId = mem.projectId || '';
872
+ const mode = mem.memoryRepo?.mode || 'local-only';
873
+ let vaultRoot;
874
+ if (mode === 'local-only') {
875
+ vaultRoot = join(projectRoot, '.obsidian');
876
+ } else {
877
+ const raw = mem.memoryRepo?.path || '';
878
+ let expanded;
879
+ if (!raw) expanded = join(projectRoot, '.bizar', 'memory');
880
+ else if (raw.startsWith('~')) expanded = join(homedir(), raw.slice(1));
881
+ else if (raw.startsWith('/')) expanded = raw;
882
+ else expanded = join(projectRoot, raw);
883
+ vaultRoot = join(expanded, 'projects', projectId);
884
+ }
885
+ if (existsSync(vaultRoot)) {
886
+ const { readdirSync } = await import('node:fs');
887
+ function walk(dir) {
888
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
889
+ if (e.name.startsWith('.')) continue;
890
+ const full = join(dir, e.name);
891
+ if (e.isDirectory()) walk(full);
892
+ else if (e.name.endsWith('.md')) noteCount++;
893
+ }
894
+ }
895
+ walk(vaultRoot);
896
+ }
897
+ }
898
+ } catch {
899
+ /* ignore — leave noteCount at 0 */
900
+ }
901
+
902
+ // Query stats: best-effort — LightRAG's /query endpoint doesn't expose
903
+ // query counts in the open-core build. We track our own running counter
904
+ // in a sidecar JSON file.
905
+ const queryLogPath = join(cacheDir, 'lightrag-query-log.jsonl');
906
+ let queryCountLast24h = 0;
907
+ let totalResponseMs = 0;
908
+ let counted = 0;
909
+ if (existsSync(queryLogPath)) {
910
+ try {
911
+ const lines = readFileSync(queryLogPath, 'utf8').split('\n').filter(Boolean);
912
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
913
+ for (const line of lines) {
914
+ try {
915
+ const rec = JSON.parse(line);
916
+ if (typeof rec.ts === 'number' && rec.ts >= cutoff) {
917
+ queryCountLast24h++;
918
+ if (typeof rec.ms === 'number' && Number.isFinite(rec.ms)) {
919
+ totalResponseMs += rec.ms;
920
+ counted++;
921
+ }
922
+ }
923
+ } catch {
924
+ /* skip */
925
+ }
926
+ }
927
+ } catch {
928
+ /* ignore */
929
+ }
930
+ }
931
+ const avgResponseMs = counted > 0 ? Math.round(totalResponseMs / counted) : null;
932
+
933
+ return {
934
+ running,
935
+ pid: running ? pid : null,
936
+ host: config.host,
937
+ port: config.port,
938
+ workingDir: config.workingDir,
939
+ lastReindexAt,
940
+ lastReindexOk,
941
+ lastReindexInserted,
942
+ lastReindexFailed,
943
+ noteCount,
944
+ indexedApprox,
945
+ queryCountLast24h,
946
+ avgResponseMs,
947
+ };
948
+ }
949
+
950
+ function parseIntSafe(s) {
951
+ const n = parseInt(String(s || '').trim(), 10);
952
+ return Number.isFinite(n) ? n : null;
953
+ }
954
+
955
+ /**
956
+ * Record a query for stats tracking. Called by the /query endpoint so the
957
+ * Memory tab can show usage. Best-effort; never throws.
958
+ *
959
+ * @param {string} projectRoot
960
+ * @param {number} durationMs
961
+ */
962
+ export function recordQuery(projectRoot, durationMs) {
963
+ try {
964
+ const cacheDir = join(projectRoot, '.bizar', 'memory-cache');
965
+ mkdirSync(cacheDir, { recursive: true });
966
+ const path = join(cacheDir, 'lightrag-query-log.jsonl');
967
+ appendFileSafe(path, JSON.stringify({ ts: Date.now(), ms: Math.max(0, Math.round(durationMs)) }) + '\n');
968
+ // Truncate if file grows past 5MB — keep last ~50k queries.
969
+ try {
970
+ const st = statMaybe(path);
971
+ if (st && st.size > 5 * 1024 * 1024) {
972
+ const content = readFileSync(path, 'utf8');
973
+ const lines = content.split('\n');
974
+ const keep = lines.slice(-50000).join('\n');
975
+ writeFileSync(path, keep);
976
+ }
977
+ } catch {
978
+ /* ignore */
979
+ }
980
+ } catch {
981
+ /* never throw */
982
+ }
983
+ }
984
+
985
+ function appendFileSafe(path, content) {
986
+ try {
987
+ appendFileSync(path, content);
988
+ } catch {
989
+ /* fall back to manual write */
990
+ try {
991
+ const cur = existsSync(path) ? readFileSync(path, 'utf8') : '';
992
+ writeFileSync(path, cur + content);
993
+ } catch {
994
+ /* swallow — stats are best-effort */
995
+ }
996
+ }
997
+ }
998
+
999
+ function statMaybe(path) {
1000
+ try {
1001
+ return statSync(path);
1002
+ } catch {
1003
+ return null;
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Rebuild the graph from scratch. Stops the server, wipes the working dir,
1009
+ * and re-runs reindexVault. Returns { ok, started, error?, markerPath? }.
1010
+ *
1011
+ * Idempotent: safe to call when the server is already stopped.
1012
+ *
1013
+ * @param {string} projectRoot
1014
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [opts]
1015
+ */
1016
+ export async function rebuildGraph(projectRoot, opts = {}) {
1017
+ const config = resolveLightRAGConfig(projectRoot);
1018
+ // Stop server first.
1019
+ await stopServer(config, opts).catch(() => {});
1020
+ // Wipe the working dir contents (preserve the dir itself).
1021
+ try {
1022
+ const { readdirSync } = await import('node:fs');
1023
+ if (existsSync(config.workingDir)) {
1024
+ for (const e of readdirSync(config.workingDir)) {
1025
+ try {
1026
+ unlinkSync(join(config.workingDir, e));
1027
+ } catch {
1028
+ /* skip */
1029
+ }
1030
+ }
1031
+ }
1032
+ } catch {
1033
+ /* ignore */
1034
+ }
1035
+ // Reindex from scratch.
1036
+ return reindexVault(projectRoot, opts);
767
1037
  }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * src/server/memory-obsidian.mjs
3
+ *
4
+ * v4.7.0 — Obsidian-flavoured façade for the Bizar Memory Service.
5
+ *
6
+ * The Memory tab treats Obsidian as one of four memory sources (alongside
7
+ * LightRAG, git, and semantic search). This module gives that source a
8
+ * single import surface that the dashboard can hit without re-deriving the
9
+ * vault layout or wikilink semantics from raw `memory-store` calls.
10
+ *
11
+ * The Obsidian CRUD itself lives in `memory-store.mjs` — re-exporting from
12
+ * there keeps the schema/secret/git-side-effects behaviour in one place.
13
+ * This façade adds:
14
+ * - tree() — recursive folder tree for the Memory tab browser
15
+ * - listBacklinks() — wikilink reverse index
16
+ * - linkGraph() — node/edge dump for graph visualisation
17
+ * - diffVault() — quick textual diff of two notes
18
+ *
19
+ * No new state, no new files on disk — these are all derived from existing
20
+ * memory-store primitives.
21
+ */
22
+
23
+ import { statSync, readdirSync, existsSync } from 'node:fs';
24
+ import { join, basename as pathBasename, relative } from 'node:path';
25
+ import {
26
+ listNotes,
27
+ readNote,
28
+ writeNote,
29
+ deleteNote,
30
+ searchVault,
31
+ findBacklinks as _findBacklinks,
32
+ vaultStats,
33
+ resolveVault,
34
+ } from './memory-store.mjs';
35
+
36
+ // ── Re-exports (verbatim) ──────────────────────────────────────────────────
37
+
38
+ export {
39
+ listNotes,
40
+ readNote,
41
+ writeNote,
42
+ deleteNote,
43
+ searchVault,
44
+ vaultStats,
45
+ resolveVault,
46
+ };
47
+
48
+ // ── Folder tree ────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Build a recursive folder tree of the vault, useful for the Memory tab
52
+ * folder browser. Each node is either a folder (with children) or a leaf
53
+ * (with size + mtime).
54
+ *
55
+ * @param {string} projectRoot
56
+ * @param {{ maxDepth?: number }} [opts]
57
+ * @returns {{
58
+ * name: string,
59
+ * path: string,
60
+ * type: 'folder'|'note',
61
+ * size?: number,
62
+ * mtime?: number,
63
+ * children?: Array<ReturnType<typeof tree>>,
64
+ * }|null}
65
+ */
66
+ export function tree(projectRoot, { maxDepth = 6 } = {}) {
67
+ const info = vaultStats(projectRoot);
68
+ if (!info.exists || !info.vaultRoot || !existsSync(info.vaultRoot)) return null;
69
+ return buildNode(info.vaultRoot, info.vaultRoot, maxDepth, 0);
70
+ }
71
+
72
+ function buildNode(absPath, vaultRoot, maxDepth, depth) {
73
+ const stat = statMaybe(absPath);
74
+ const name = pathBasename(absPath) || absPath;
75
+ const rel = relative(vaultRoot, absPath).split('\\').join('/');
76
+
77
+ if (stat && stat.isFile()) {
78
+ return {
79
+ name: name.replace(/\.md$/i, ''),
80
+ path: rel || name,
81
+ type: 'note',
82
+ size: stat.size,
83
+ mtime: stat.mtimeMs,
84
+ };
85
+ }
86
+
87
+ // Directory
88
+ const children = [];
89
+ if (depth < maxDepth) {
90
+ let entries;
91
+ try {
92
+ entries = readdirSync(absPath, { withFileTypes: true });
93
+ } catch {
94
+ entries = [];
95
+ }
96
+ for (const e of entries) {
97
+ if (e.name.startsWith('.')) continue;
98
+ const childPath = join(absPath, e.name);
99
+ children.push(buildNode(childPath, vaultRoot, maxDepth, depth + 1));
100
+ }
101
+ children.sort((a, b) => {
102
+ if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
103
+ return a.name.localeCompare(b.name);
104
+ });
105
+ }
106
+
107
+ return {
108
+ name,
109
+ path: rel,
110
+ type: 'folder',
111
+ children,
112
+ };
113
+ }
114
+
115
+ function statMaybe(p) {
116
+ try { return statSync(p); } catch { return null; }
117
+ }
118
+
119
+ // ── Backlinks ──────────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * List notes that link TO `targetRelPath` via wikilinks.
123
+ *
124
+ * @param {string} projectRoot
125
+ * @param {string} targetRelPath
126
+ * @returns {Array<{ fromRelPath: string, fromTitle: string, snippet: string, mtime: number }>}
127
+ */
128
+ export function listBacklinks(projectRoot, targetRelPath) {
129
+ return _findBacklinks(projectRoot, targetRelPath);
130
+ }
131
+
132
+ // ── Link graph ─────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Build a simple node/edge representation of wikilinks across the vault,
136
+ * suitable for the Memory tab link-graph visualizer.
137
+ *
138
+ * Each node is a note (identified by relPath). Each edge is a wikilink
139
+ * reference from one note to another. Self-links and dangling links
140
+ * (wikilinks to non-existent notes) are included with `dangling: true`.
141
+ *
142
+ * @param {string} projectRoot
143
+ * @param {{ limit?: number }} [opts]
144
+ * @returns {{ nodes: Array<{ id: string, label: string, mtime: number }>, edges: Array<{ from: string, to: string, raw: string, dangling: boolean }> }}
145
+ */
146
+ export function linkGraph(projectRoot, { limit = 500 } = {}) {
147
+ const notes = listNotes(projectRoot);
148
+ const known = new Set();
149
+ for (const n of notes) known.add(n.relPath.replace(/\.md$/i, ''));
150
+
151
+ const nodes = notes.slice(0, limit).map((n) => ({
152
+ id: n.relPath,
153
+ label: n.frontmatter?.title || pathBasename(n.relPath).replace(/\.md$/i, ''),
154
+ mtime: n.mtime,
155
+ }));
156
+
157
+ const edges = [];
158
+ const WIKILINK_RE = /\[\[([^\]\n|#]+?)(?:#[^\]\n|]+?)?(?:\|[^\]\n]+?)?\]\]/g;
159
+
160
+ for (const note of notes) {
161
+ const body = note.body || '';
162
+ WIKILINK_RE.lastIndex = 0;
163
+ let m;
164
+ while ((m = WIKILINK_RE.exec(body)) !== null) {
165
+ const raw = (m[1] || '').trim();
166
+ if (!raw) continue;
167
+ const target = resolveWikilinkTarget(raw, note.relPath);
168
+ if (!target) continue;
169
+ const dangling = !known.has(target);
170
+ edges.push({ from: note.relPath, to: `${target}.md`, raw, dangling });
171
+ if (edges.length >= limit * 4) break;
172
+ }
173
+ if (edges.length >= limit * 4) break;
174
+ }
175
+
176
+ return { nodes, edges };
177
+ }
178
+
179
+ /**
180
+ * Resolve a wikilink target string against Obsidian's rules.
181
+ * Returns the relPath-without-extension if the target exists, or `null` if
182
+ * the wikilink is empty / unparseable.
183
+ */
184
+ function resolveWikilinkTarget(raw, fromRelPath) {
185
+ if (!raw) return null;
186
+ const stripped = raw.split('#')[0].split('|')[0].trim();
187
+ if (!stripped) return null;
188
+ if (stripped.includes('/')) return stripped.replace(/\.md$/i, '');
189
+ return stripped.replace(/\.md$/i, '');
190
+ }
191
+
192
+ // ── Diff (textual) ─────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Lightweight textual diff between two notes. Returns { from, to, lines }.
196
+ * `lines` is an array of { kind: 'same'|'add'|'del', text } entries.
197
+ *
198
+ * Used by the Memory → Git Sync panel to show the working-tree diff of
199
+ * a single file. Not a full Myers diff — just a sorted-set diff which is
200
+ * good enough for "what changed?" UI.
201
+ *
202
+ * @param {string} projectRoot
203
+ * @param {string} fromPath — '' for "no previous version"
204
+ * @param {string} toPath
205
+ */
206
+ export function diffVault(projectRoot, fromPath, toPath) {
207
+ const from = fromPath ? readNote(projectRoot, fromPath) : null;
208
+ const to = toPath ? readNote(projectRoot, toPath) : null;
209
+ const fromLines = splitLines(from?.body || '');
210
+ const toLines = splitLines(to?.body || '');
211
+
212
+ const fromSet = new Set(fromLines);
213
+ const toSet = new Set(toLines);
214
+
215
+ const lines = [];
216
+ for (const t of toLines) {
217
+ if (!fromSet.has(t)) lines.push({ kind: 'add', text: t });
218
+ else lines.push({ kind: 'same', text: t });
219
+ }
220
+ for (const f of fromLines) {
221
+ if (!toSet.has(f)) lines.push({ kind: 'del', text: f });
222
+ }
223
+
224
+ return { from: fromPath || null, to: toPath || null, lines };
225
+ }
226
+
227
+ function splitLines(s) {
228
+ if (!s) return [];
229
+ return s.split(/\r?\n/).filter((l) => l.length > 0);
230
+ }