@polderlabs/bizar 4.5.0 → 4.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-B4OfGAwz.js +361 -0
  4. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
  6. package/bizar-dash/dist/assets/{mobile--0FBIKX3.js → mobile-BRhoDOUz.js} +1 -2
  7. package/bizar-dash/dist/assets/{mobile--0FBIKX3.js.map → mobile-BRhoDOUz.js.map} +1 -1
  8. package/bizar-dash/dist/assets/{mobile-OgRp8VIb.js → mobile-lbH6szyX.js} +2 -3
  9. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +1 -0
  10. package/bizar-dash/dist/index.html +3 -3
  11. package/bizar-dash/dist/mobile.html +2 -2
  12. package/bizar-dash/skills/bizar/SKILL.md +20 -0
  13. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +11 -0
  15. package/bizar-dash/skills/obsidian/SKILL.md +13 -0
  16. package/bizar-dash/src/server/api.mjs +6 -0
  17. package/bizar-dash/src/server/headroom.mjs +647 -0
  18. package/bizar-dash/src/server/memory-lightrag.mjs +281 -6
  19. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  20. package/bizar-dash/src/server/memory-store.mjs +151 -0
  21. package/bizar-dash/src/server/mods-loader.mjs +13 -5
  22. package/bizar-dash/src/server/providers-store.mjs +73 -1
  23. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  24. package/bizar-dash/src/server/routes/chat.mjs +38 -0
  25. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  26. package/bizar-dash/src/server/routes/lightrag.mjs +6 -2
  27. package/bizar-dash/src/server/routes/memory.mjs +439 -4
  28. package/bizar-dash/src/server/schedules-runner.mjs +4 -4
  29. package/bizar-dash/src/server/server.mjs +55 -37
  30. package/bizar-dash/src/server/watcher.mjs +2 -2
  31. package/bizar-dash/src/web/App.tsx +42 -6
  32. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  33. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  34. package/bizar-dash/src/web/components/Toast.tsx +1 -1
  35. package/bizar-dash/src/web/components/Topbar.tsx +3 -1
  36. package/bizar-dash/src/web/lib/api.ts +12 -11
  37. package/bizar-dash/src/web/lib/types.ts +16 -0
  38. package/bizar-dash/src/web/main.tsx +1 -0
  39. package/bizar-dash/src/web/styles/chat.css +2 -0
  40. package/bizar-dash/src/web/styles/main.css +52 -23
  41. package/bizar-dash/src/web/styles/memory.css +955 -0
  42. package/bizar-dash/src/web/styles/tasks.css +2 -0
  43. package/bizar-dash/src/web/views/Memory.tsx +141 -0
  44. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
  45. package/bizar-dash/src/web/views/Overview.tsx +7 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +32 -1
  47. package/bizar-dash/src/web/views/Skills.tsx +3 -2
  48. package/bizar-dash/src/web/views/Tasks.tsx +4 -3
  49. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  50. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  51. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  52. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +356 -0
  53. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  54. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  55. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  56. package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
  57. package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
  58. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  59. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  60. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  61. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  62. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  63. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  64. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  65. package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
  66. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  67. package/cli/artifact.mjs +11 -4
  68. package/cli/bin.mjs +211 -12
  69. package/cli/doctor.mjs +5 -3
  70. package/cli/memory.mjs +65 -21
  71. package/cli/provision.mjs +8 -2
  72. package/install.sh +1 -2
  73. package/package.json +3 -3
  74. package/bizar-dash/dist/assets/main-CDFKHzBg.css +0 -1
  75. package/bizar-dash/dist/assets/main-NYFpS2wY.js +0 -312
  76. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +0 -1
  77. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +0 -1
@@ -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';
@@ -82,7 +84,8 @@ function readLogTail(filePath, n = 30) {
82
84
  const content = readFileSync(filePath, 'utf8');
83
85
  const lines = content.split('\n');
84
86
  return lines.slice(-n);
85
- } catch {
87
+ } catch (err) {
88
+ console.warn('[lightrag] swallowed in readLogTail:', err?.message || err);
86
89
  return [];
87
90
  }
88
91
  }
@@ -204,7 +207,8 @@ export function resolveLightRAGConfig(projectRoot) {
204
207
  if (existsSync(path)) {
205
208
  try {
206
209
  userCfg = JSON.parse(readFileSync(path, 'utf8')).lightrag || {};
207
- } catch {
210
+ } catch (err) {
211
+ console.warn('[lightrag] swallowed in resolveLightRAGConfig.readMemoryJson:', err?.message || err);
208
212
  userCfg = {};
209
213
  }
210
214
  }
@@ -267,8 +271,9 @@ export function findLightragBinary() {
267
271
  timeout: 3000,
268
272
  }).trim();
269
273
  if (path) return path;
270
- } catch {
274
+ } catch (err) {
271
275
  // command returned non-zero — fall through
276
+ console.warn('[lightrag] swallowed in findLightragBinary.which:', err?.message || err);
272
277
  }
273
278
  return null;
274
279
  }
@@ -334,7 +339,8 @@ export async function isRunning(config) {
334
339
  try {
335
340
  const res = await httpGet(`http://${config.host}:${config.port}/health`, 3000);
336
341
  return res.status === 200;
337
- } catch {
342
+ } catch (err) {
343
+ console.warn('[lightrag] swallowed in isRunning.healthCheck:', err?.message || err);
338
344
  return false;
339
345
  }
340
346
  }
@@ -359,7 +365,8 @@ function readPidAlive(pidFile) {
359
365
  if (!Number.isFinite(pid) || pid <= 0) return { pid: null, alive: false };
360
366
  const alive = killPid(pid, 0); // signal 0 = probe
361
367
  return { pid, alive };
362
- } catch {
368
+ } catch (err) {
369
+ console.warn('[lightrag] swallowed in readPidAlive:', err?.message || err);
363
370
  return { pid: null, alive: false };
364
371
  }
365
372
  }
@@ -385,7 +392,9 @@ export async function startServer(config, { logger } = {}) {
385
392
  if (existing && !existingAlive) {
386
393
  try {
387
394
  unlinkSync(pidFile);
388
- } catch {}
395
+ } catch (err) {
396
+ console.warn('[lightrag] swallowed in startServer.unlinkStalePid:', err?.message || err);
397
+ }
389
398
  }
390
399
 
391
400
  if (!findLightragBinary()) {
@@ -767,4 +776,270 @@ export async function query(config, question, { mode = 'mix', topK = 10 } = {})
767
776
  } catch (err) {
768
777
  return { ok: false, mode, error: err.message };
769
778
  }
779
+ }
780
+
781
+ // ── v4.7.0 — Memory tab helpers ─────────────────────────────────────────────
782
+ //
783
+ // Lightweight status / stats / rebuild helpers used by the dedicated Memory
784
+ // view. The full reindexVault path above already produces a marker file with
785
+ // most of the numbers we want; these helpers are thin wrappers around it.
786
+
787
+ /**
788
+ * Aggregate stats for the Memory → LightRAG panel.
789
+ *
790
+ * Pulls from:
791
+ * - the last-reindex.json marker file (writeMarker: true leaves it)
792
+ * - the configured working dir (where chunks/index files live)
793
+ * - the running server (PID, alive)
794
+ *
795
+ * Returns a defensive default when nothing has been indexed yet.
796
+ *
797
+ * @param {string} projectRoot
798
+ * @returns {{
799
+ * running: boolean,
800
+ * pid: number|null,
801
+ * host: string,
802
+ * port: number,
803
+ * workingDir: string,
804
+ * lastReindexAt: string|null,
805
+ * lastReindexOk: boolean|null,
806
+ * lastReindexInserted: number|null,
807
+ * lastReindexFailed: number|null,
808
+ * noteCount: number,
809
+ * indexedApprox: number,
810
+ * queryCountLast24h: number,
811
+ * avgResponseMs: number|null,
812
+ * error?: string,
813
+ * }}
814
+ */
815
+ export async function stats(projectRoot) {
816
+ const config = resolveLightRAGConfig(projectRoot);
817
+ const cacheDir = join(projectRoot, '.bizar', 'memory-cache');
818
+ const markerPath = join(cacheDir, 'last-reindex.json');
819
+ const running = await isRunning(config);
820
+ let pid = null;
821
+ const pidFile = join(config.workingDir, 'lightrag.pid');
822
+ if (existsSync(pidFile)) {
823
+ const parsed = parseIntSafe(readFileSync(pidFile, 'utf8'));
824
+ if (parsed !== null && parsed > 0) {
825
+ try {
826
+ process.kill(parsed, 0);
827
+ pid = parsed;
828
+ } catch (err) {
829
+ console.warn('[lightrag] swallowed in stats.pidProbe:', err?.message || err);
830
+ pid = null;
831
+ }
832
+ }
833
+ }
834
+
835
+ let lastReindexAt = null;
836
+ let lastReindexOk = null;
837
+ let lastReindexInserted = null;
838
+ let lastReindexFailed = null;
839
+ if (existsSync(markerPath)) {
840
+ try {
841
+ const m = JSON.parse(readFileSync(markerPath, 'utf8'));
842
+ lastReindexAt = m.finishedAt || m.attemptedAt || null;
843
+ lastReindexOk = m.ok === true;
844
+ lastReindexInserted = typeof m.inserted === 'number' ? m.inserted : null;
845
+ lastReindexFailed = typeof m.failed === 'number' ? m.failed : null;
846
+ } catch (err) {
847
+ console.warn('[lightrag] swallowed in stats.markerRead:', err?.message || err);
848
+ }
849
+ }
850
+
851
+ // Approximate the indexed chunk count from the on-disk KV store. LightRAG
852
+ // writes its chunks to <workingDir>/kv_store_*.json. We sum the lengths of
853
+ // the document store as a proxy for "indexed documents"; this is best-effort
854
+ // and clearly labeled as approximate.
855
+ let indexedApprox = 0;
856
+ try {
857
+ const docStore = join(config.workingDir, 'kv_store_doc_status.json');
858
+ const fullDocs = join(config.workingDir, 'kv_store_full_docs.json');
859
+ for (const p of [docStore, fullDocs]) {
860
+ if (existsSync(p)) {
861
+ try {
862
+ const obj = JSON.parse(readFileSync(p, 'utf8'));
863
+ indexedApprox += Object.keys(obj || {}).length;
864
+ } catch (err) {
865
+ console.warn('[lightrag] swallowed in stats.kvStoreRead:', err?.message || err);
866
+ }
867
+ }
868
+ }
869
+ } catch (err) {
870
+ console.warn('[lightrag] swallowed in stats.kvStoreScan:', err?.message || err);
871
+ }
872
+
873
+ // Note count = listNotes from the vault.
874
+ let noteCount = 0;
875
+ try {
876
+ const memPath = join(projectRoot, '.bizar', 'memory.json');
877
+ if (existsSync(memPath)) {
878
+ const mem = JSON.parse(readFileSync(memPath, 'utf8'));
879
+ const projectId = mem.projectId || '';
880
+ const mode = mem.memoryRepo?.mode || 'local-only';
881
+ let vaultRoot;
882
+ if (mode === 'local-only') {
883
+ vaultRoot = join(projectRoot, '.obsidian');
884
+ } else {
885
+ const raw = mem.memoryRepo?.path || '';
886
+ let expanded;
887
+ if (!raw) expanded = join(projectRoot, '.bizar', 'memory');
888
+ else if (raw.startsWith('~')) expanded = join(homedir(), raw.slice(1));
889
+ else if (raw.startsWith('/')) expanded = raw;
890
+ else expanded = join(projectRoot, raw);
891
+ vaultRoot = join(expanded, 'projects', projectId);
892
+ }
893
+ if (existsSync(vaultRoot)) {
894
+ const { readdirSync } = await import('node:fs');
895
+ function walk(dir) {
896
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
897
+ if (e.name.startsWith('.')) continue;
898
+ const full = join(dir, e.name);
899
+ if (e.isDirectory()) walk(full);
900
+ else if (e.name.endsWith('.md')) noteCount++;
901
+ }
902
+ }
903
+ walk(vaultRoot);
904
+ }
905
+ }
906
+ } catch (err) {
907
+ console.warn('[lightrag] swallowed in stats.noteCount:', err?.message || err);
908
+ }
909
+
910
+ // Query stats: best-effort — LightRAG's /query endpoint doesn't expose
911
+ // query counts in the open-core build. We track our own running counter
912
+ // in a sidecar JSON file.
913
+ const queryLogPath = join(cacheDir, 'lightrag-query-log.jsonl');
914
+ let queryCountLast24h = 0;
915
+ let totalResponseMs = 0;
916
+ let counted = 0;
917
+ if (existsSync(queryLogPath)) {
918
+ try {
919
+ const lines = readFileSync(queryLogPath, 'utf8').split('\n').filter(Boolean);
920
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
921
+ for (const line of lines) {
922
+ try {
923
+ const rec = JSON.parse(line);
924
+ if (typeof rec.ts === 'number' && rec.ts >= cutoff) {
925
+ queryCountLast24h++;
926
+ if (typeof rec.ms === 'number' && Number.isFinite(rec.ms)) {
927
+ totalResponseMs += rec.ms;
928
+ counted++;
929
+ }
930
+ }
931
+ } catch (err) {
932
+ console.warn('[lightrag] swallowed in stats.queryLogLine:', err?.message || err);
933
+ }
934
+ }
935
+ } catch (err) {
936
+ console.warn('[lightrag] swallowed in stats.queryLogRead:', err?.message || err);
937
+ }
938
+ }
939
+ const avgResponseMs = counted > 0 ? Math.round(totalResponseMs / counted) : null;
940
+
941
+ return {
942
+ running,
943
+ pid: running ? pid : null,
944
+ host: config.host,
945
+ port: config.port,
946
+ workingDir: config.workingDir,
947
+ lastReindexAt,
948
+ lastReindexOk,
949
+ lastReindexInserted,
950
+ lastReindexFailed,
951
+ noteCount,
952
+ indexedApprox,
953
+ queryCountLast24h,
954
+ avgResponseMs,
955
+ };
956
+ }
957
+
958
+ function parseIntSafe(s) {
959
+ const n = parseInt(String(s || '').trim(), 10);
960
+ return Number.isFinite(n) ? n : null;
961
+ }
962
+
963
+ /**
964
+ * Record a query for stats tracking. Called by the /query endpoint so the
965
+ * Memory tab can show usage. Best-effort; never throws.
966
+ *
967
+ * @param {string} projectRoot
968
+ * @param {number} durationMs
969
+ */
970
+ export function recordQuery(projectRoot, durationMs) {
971
+ try {
972
+ const cacheDir = join(projectRoot, '.bizar', 'memory-cache');
973
+ mkdirSync(cacheDir, { recursive: true });
974
+ const path = join(cacheDir, 'lightrag-query-log.jsonl');
975
+ appendFileSafe(path, JSON.stringify({ ts: Date.now(), ms: Math.max(0, Math.round(durationMs)) }) + '\n');
976
+ // Truncate if file grows past 5MB — keep last ~50k queries.
977
+ try {
978
+ const st = statMaybe(path);
979
+ if (st && st.size > 5 * 1024 * 1024) {
980
+ const content = readFileSync(path, 'utf8');
981
+ const lines = content.split('\n');
982
+ const keep = lines.slice(-50000).join('\n');
983
+ writeFileSync(path, keep);
984
+ }
985
+ } catch (err) {
986
+ console.warn('[lightrag] swallowed in recordQuery.truncate:', err?.message || err);
987
+ }
988
+ } catch (err) {
989
+ console.warn('[lightrag] swallowed in recordQuery:', err?.message || err);
990
+ }
991
+ }
992
+
993
+ function appendFileSafe(path, content) {
994
+ try {
995
+ appendFileSync(path, content);
996
+ } catch (err) {
997
+ console.warn('[lightrag] appendFileSync failed, falling back to read+write:', err?.message || err);
998
+ try {
999
+ const cur = existsSync(path) ? readFileSync(path, 'utf8') : '';
1000
+ writeFileSync(path, cur + content);
1001
+ } catch (err2) {
1002
+ console.warn('[lightrag] swallowed in appendFileSafe.manualWrite:', err2?.message || err2);
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ function statMaybe(path) {
1008
+ try {
1009
+ return statSync(path);
1010
+ } catch {
1011
+ return null;
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Rebuild the graph from scratch. Stops the server, wipes the working dir,
1017
+ * and re-runs reindexVault. Returns { ok, started, error?, markerPath? }.
1018
+ *
1019
+ * Idempotent: safe to call when the server is already stopped.
1020
+ *
1021
+ * @param {string} projectRoot
1022
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [opts]
1023
+ */
1024
+ export async function rebuildGraph(projectRoot, opts = {}) {
1025
+ const config = resolveLightRAGConfig(projectRoot);
1026
+ // Stop server first.
1027
+ await stopServer(config, opts).catch(() => {});
1028
+ // Wipe the working dir contents (preserve the dir itself).
1029
+ try {
1030
+ const { readdirSync } = await import('node:fs');
1031
+ if (existsSync(config.workingDir)) {
1032
+ for (const e of readdirSync(config.workingDir)) {
1033
+ try {
1034
+ unlinkSync(join(config.workingDir, e));
1035
+ } catch (err) {
1036
+ console.warn('[lightrag] swallowed in rebuildGraph.unlinkEntry:', err?.message || err);
1037
+ }
1038
+ }
1039
+ }
1040
+ } catch (err) {
1041
+ console.warn('[lightrag] swallowed in rebuildGraph.wipe:', err?.message || err);
1042
+ }
1043
+ // Reindex from scratch.
1044
+ return reindexVault(projectRoot, opts);
770
1045
  }
@@ -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
+ }