@polderlabs/bizar 5.4.0 → 5.5.0

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 (99) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
  6. package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
  7. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
  10. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
  12. package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
  13. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
  14. package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
  19. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
  20. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
  21. package/bizar-dash/dist/index.html +8 -8
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/backup-store.mjs +6 -1
  25. package/bizar-dash/src/server/bg-spawner.mjs +574 -0
  26. package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
  27. package/bizar-dash/src/server/memory-store.mjs +43 -3
  28. package/bizar-dash/src/server/routes/background.mjs +143 -0
  29. package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
  30. package/bizar-dash/src/server/routes/memory.mjs +8 -0
  31. package/bizar-dash/src/server/server.mjs +22 -0
  32. package/bizar-dash/src/web/App.tsx +3 -0
  33. package/bizar-dash/src/web/MobileApp.tsx +1 -10
  34. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
  35. package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
  36. package/bizar-dash/src/web/lib/types.ts +39 -1
  37. package/bizar-dash/src/web/styles/main.css +43 -0
  38. package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
  39. package/bizar-dash/src/web/styles/settings.css +46 -0
  40. package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
  41. package/bizar-dash/src/web/views/Settings.tsx +12 -12
  42. package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
  43. package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
  44. package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
  45. package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
  46. package/bizar-dash/tests/background-steer.test.mjs +42 -0
  47. package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
  48. package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
  49. package/bizar-dash/tests/memory-cli.test.mjs +1 -1
  50. package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
  51. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
  52. package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
  53. package/bizar-dash/tests/settings-layout.test.tsx +3 -3
  54. package/bizar-dash/tests/settings-nav.test.tsx +7 -5
  55. package/cli/bin.mjs +16 -0
  56. package/cli/commands/deploy/docker.mjs +2 -1
  57. package/cli/commands/lightrag.mjs +171 -0
  58. package/cli/commands/service.mjs +7 -0
  59. package/cli/commands/util.mjs +8 -1
  60. package/cli/init.mjs +1 -1
  61. package/cli/post-install-smoke.mjs +230 -0
  62. package/cli/provision.mjs +177 -0
  63. package/cli/provision.test.mjs +180 -0
  64. package/cli/service-controller.mjs +378 -28
  65. package/cli/service-controller.test.mjs +100 -0
  66. package/cli/service-env.mjs +139 -0
  67. package/cli/service.mjs +23 -0
  68. package/config/agents/_shared/AGENT_BASELINE.md +6 -6
  69. package/install.sh +87 -6
  70. package/package.json +1 -1
  71. package/plugins/bizar/index.ts +147 -0
  72. package/plugins/bizar/src/background-state.ts +84 -5
  73. package/plugins/bizar/src/background.ts +388 -10
  74. package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
  75. package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
  76. package/plugins/bizar/src/opencode-runner.ts +87 -1
  77. package/plugins/bizar/src/tools/bg-pause.ts +77 -0
  78. package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
  79. package/plugins/bizar/src/tools/bg-resume.ts +72 -0
  80. package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
  81. package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
  82. package/plugins/bizar/src/tools/bg-status.ts +10 -0
  83. package/plugins/bizar/src/tools/memory-list.ts +135 -0
  84. package/plugins/bizar/src/tools/memory-read.ts +142 -0
  85. package/plugins/bizar/src/tools/memory-search.ts +228 -0
  86. package/plugins/bizar/src/tools/memory-write.ts +183 -0
  87. package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
  88. package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
  89. package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
  90. package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
  91. package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
  92. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
  93. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
  94. package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
  95. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
  96. package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
  97. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
  98. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
  99. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
@@ -217,6 +217,69 @@ function redactLightRAG(cfg) {
217
217
  };
218
218
  }
219
219
 
220
+ /**
221
+ * v5.x — Detect which LLM provider is available for LightRAG.
222
+ *
223
+ * Decision order:
224
+ * 1. If `llmBinding === 'ollama'`, do a quick async probe of
225
+ * localhost:11434 (or the configured `llmBindingHost`).
226
+ * If reachable → ollama available. If unreachable but an API key
227
+ * is configured for OpenAI / Anthropic / MiniMax → use that instead.
228
+ * 2. For cloud bindings (openai / anthropic / minimax / etc.),
229
+ * check whether the corresponding env var is set. If yes → available.
230
+ * 3. If no provider is detectable, return null and the caller logs
231
+ * a warning and skips reindex.
232
+ *
233
+ * Returns { provider, available, detail } or null.
234
+ */
235
+ export async function detectAvailableLLM(config) {
236
+ const binding = config.llmBinding || 'ollama';
237
+
238
+ if (binding === 'ollama') {
239
+ const ollamaHost = config.llmBindingHost || 'http://localhost:11434';
240
+ // Quick async probe — give it 1.5s to connect.
241
+ try {
242
+ const controller = new AbortController();
243
+ const t = setTimeout(() => controller.abort(), 1500);
244
+ const res = await fetch(`${ollamaHost}/`, {
245
+ method: 'HEAD',
246
+ signal: controller.signal,
247
+ });
248
+ clearTimeout(t);
249
+ if (res.ok || res.status < 500) {
250
+ return { provider: 'ollama', available: true, detail: ollamaHost };
251
+ }
252
+ } catch {
253
+ // Fall through — ollama not reachable.
254
+ }
255
+ // ollama not reachable; check for cloud API keys as fallback.
256
+ const hasOpenAI = !!(process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY_2);
257
+ const hasAnthropic = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY_2);
258
+ const hasMinimax = !!(process.env.MINIMAX_API_KEY || process.env.MINIMAX_API_KEY_2);
259
+ if (hasOpenAI || hasAnthropic || hasMinimax) {
260
+ const provider = hasOpenAI ? 'openai' : hasAnthropic ? 'anthropic' : 'minimax';
261
+ return { provider, available: true, detail: `${provider} (ollama unreachable, using API key)` };
262
+ }
263
+ return null; // no ollama, no API key
264
+ }
265
+
266
+ // Cloud bindings — available if API key is set.
267
+ const apiKeyEnvVars = {
268
+ openai: ['OPENAI_API_KEY', 'OPENAI_API_KEY_2'],
269
+ anthropic: ['ANTHROPIC_API_KEY', 'ANTHROPIC_API_KEY_2'],
270
+ minimax: ['MINIMAX_API_KEY', 'MINIMAX_API_KEY_2'],
271
+ azure_openai: ['AZURE_OPENAI_API_KEY'],
272
+ gemini: ['GEMINI_API_KEY'],
273
+ };
274
+ const relevantKeys = apiKeyEnvVars[binding] || [];
275
+ const hasKey = relevantKeys.some((k) => process.env[k]);
276
+ if (hasKey) {
277
+ return { provider: binding, available: true, detail: binding };
278
+ }
279
+
280
+ return null; // configured binding but no key detected
281
+ }
282
+
220
283
  /**
221
284
  * Resolve the LightRAG config for a project.
222
285
  * Pulls from `.bizar/memory.json` (lightrag block) and applies defaults.
@@ -539,6 +602,11 @@ export async function stopServer(config, { logger } = {}) {
539
602
  /**
540
603
  * Idempotent: returns immediately if the server is healthy. Otherwise
541
604
  * starts it. Returns { ok, started, pid?, error? }.
605
+ *
606
+ * v5.x — Before starting, calls `detectAvailableLLM` to check whether
607
+ * the configured LLM provider is reachable. If no provider is detected
608
+ * (ollama unreachable + no API key), returns { ok: false } with a
609
+ * descriptive error rather than starting LightRAG in a broken state.
542
610
  */
543
611
  export async function ensureRunning(config, opts) {
544
612
  if (!config.enabled) {
@@ -547,10 +615,116 @@ export async function ensureRunning(config, opts) {
547
615
  if (await isRunning(config)) {
548
616
  return { ok: true, started: false };
549
617
  }
618
+ // v5.x — probe LLM availability before attempting to start.
619
+ const llm = await detectAvailableLLM(config);
620
+ if (!llm) {
621
+ const binding = config.llmBinding || 'ollama';
622
+ return {
623
+ ok: false,
624
+ started: false,
625
+ error: `lightrag cannot start: ${binding} is not reachable and no API key is configured. `
626
+ + `Set an API key env var (OPENAI_API_KEY, ANTHROPIC_API_KEY, or MINIMAX_API_KEY) `
627
+ + `or ensure ollama is running, then try again.`,
628
+ };
629
+ }
550
630
  const r = await startServer(config, opts);
551
631
  return { ok: r.ok, started: r.ok, pid: r.pid, error: r.error };
552
632
  }
553
633
 
634
+ /**
635
+ * v5.x — LightRAG startup hook (issue #6).
636
+ *
637
+ * Called by server.mjs on dashboard boot (and exposed via
638
+ * `bizar lightrag autostart`). Reads the active project from
639
+ * `state.projectRoot` (or whatever is passed in), resolves the
640
+ * LightRAG config, and starts the server if the user's settings
641
+ * allow it.
642
+ *
643
+ * Decision flow:
644
+ * 1. Resolve config from .bizar/memory.json — if `lightrag.enabled`
645
+ * is `false`, skip silently (return ok=true, started=false,
646
+ * reason='disabled').
647
+ * 2. Read the env override `BIZAR_LIGHTRAG_AUTOSTART` — if set to
648
+ * '0' / 'false' / 'no', skip.
649
+ * 3. Check if the server is already running — if so, return
650
+ * ok=true, started=false, reason='already-running'.
651
+ * 4. Otherwise call `startServer(config)` and return its result.
652
+ *
653
+ * Errors are caught and logged as warnings — startup must not fail
654
+ * the dashboard just because LightRAG couldn't be started (the user
655
+ * may not have the binary installed, may be running the dashboard
656
+ * in an environment where LightRAG isn't needed, etc.).
657
+ *
658
+ * @param {string} projectRoot — path to the active project root
659
+ * (where `.bizar/memory.json` lives). When omitted, the hook
660
+ * returns ok=false with reason='no-project' (the caller didn't
661
+ * tell us where to look).
662
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [opts]
663
+ * @returns {Promise<{ ok: boolean, started: boolean, pid?: number|null, reason?: string, error?: string }>}
664
+ */
665
+ export async function lightragStartupHook(projectRoot, opts = {}) {
666
+ const log = (...a) => opts?.logger?.info?.('[lightrag]', ...a) || console.log('[lightrag]', ...a);
667
+ const warn = (...a) => opts?.logger?.warn?.('[lightrag]', ...a) || console.warn('[lightrag]', ...a);
668
+
669
+ // 1. No project → can't resolve config.
670
+ if (!projectRoot || typeof projectRoot !== 'string') {
671
+ return { ok: false, started: false, reason: 'no-project' };
672
+ }
673
+
674
+ // 2. Resolve config (from .bizar/memory.json).
675
+ let config;
676
+ try {
677
+ config = resolveLightRAGConfig(projectRoot);
678
+ } catch (err) {
679
+ warn('startup hook: failed to resolve config:', err?.message || err);
680
+ return { ok: false, started: false, error: err?.message || String(err) };
681
+ }
682
+
683
+ // 3. Check lightrag.enabled flag.
684
+ if (config.enabled === false) {
685
+ log('startup hook: lightrag disabled in .bizar/memory.json, skipping');
686
+ return { ok: true, started: false, reason: 'disabled' };
687
+ }
688
+
689
+ // 4. Check env-var override.
690
+ const envAuto = process.env.BIZAR_LIGHTRAG_AUTOSTART;
691
+ if (typeof envAuto === 'string' && /^(0|false|no|off)$/i.test(envAuto.trim())) {
692
+ log(`startup hook: BIZAR_LIGHTRAG_AUTOSTART=${envAuto}, skipping`);
693
+ return { ok: true, started: false, reason: 'env-disabled' };
694
+ }
695
+
696
+ // 5. Already running? Nothing to do.
697
+ try {
698
+ if (await isRunning(config)) {
699
+ log('startup hook: lightrag already running');
700
+ return { ok: true, started: false, reason: 'already-running' };
701
+ }
702
+ } catch (err) {
703
+ warn('startup hook: isRunning probe failed:', err?.message || err);
704
+ }
705
+
706
+ // 6. Try to start.
707
+ try {
708
+ const r = await startServer(config, opts);
709
+ if (r.ok) {
710
+ if (r.alreadyRunning) {
711
+ log(`startup hook: lightrag already running (pid=${r.pid})`);
712
+ return { ok: true, started: false, reason: 'already-running', pid: r.pid };
713
+ }
714
+ log(`startup hook: lightrag started (pid=${r.pid})`);
715
+ return { ok: true, started: true, pid: r.pid };
716
+ }
717
+ warn(`startup hook: start failed: ${r.error || 'unknown'}`);
718
+ return { ok: false, started: false, error: r.error || 'start failed' };
719
+ } catch (err) {
720
+ // Catch-all: dashboard boot must not fail because LightRAG couldn't
721
+ // start. Common causes: binary missing (lightrag-server not on PATH),
722
+ // port already in use, working-dir not writable.
723
+ warn('startup hook: caught error:', err?.message || err);
724
+ return { ok: false, started: false, error: err?.message || String(err) };
725
+ }
726
+ }
727
+
554
728
  // ── Document insertion ────────────────────────────────────────────────────
555
729
 
556
730
  /**
@@ -784,6 +958,74 @@ export async function reindexVault(projectRoot, opts = {}) {
784
958
  };
785
959
  }
786
960
 
961
+ /**
962
+ * v5.x — Re-index a single note into LightRAG.
963
+ *
964
+ * Use this for incremental updates (e.g. after a note is written or
965
+ * updated via the REST API). Unlike `reindexVault` which walks the entire
966
+ * vault, this only inserts one document.
967
+ *
968
+ * Returns { ok, inserted, failed, error? }.
969
+ */
970
+ export async function reindexSingleNote(projectRoot, relPath, opts = {}) {
971
+ const config = resolveLightRAGConfig(projectRoot);
972
+ if (!config.enabled) {
973
+ return { ok: false, error: 'lightrag disabled' };
974
+ }
975
+
976
+ // Resolve vault path (same logic as reindexVault).
977
+ const memPath = join(projectRoot, '.bizar', 'memory.json');
978
+ if (!existsSync(memPath)) {
979
+ return { ok: false, error: 'memory not initialized' };
980
+ }
981
+ const mem = JSON.parse(readFileSync(memPath, 'utf8'));
982
+ const mode = mem.memoryRepo?.mode || 'local-only';
983
+ let vaultRoot;
984
+ const projectId = mem.projectId || basename(projectRoot);
985
+ if (mode === 'local-only') {
986
+ vaultRoot = join(projectRoot, '.obsidian');
987
+ } else {
988
+ const rawPath = mem.memoryRepo?.path || '';
989
+ let expanded;
990
+ if (!rawPath) {
991
+ expanded = join(projectRoot, '.bizar', 'memory');
992
+ } else if (rawPath.startsWith('~')) {
993
+ expanded = join(homedir(), rawPath.slice(1));
994
+ } else if (rawPath.startsWith('/')) {
995
+ expanded = rawPath;
996
+ } else {
997
+ expanded = join(projectRoot, rawPath);
998
+ }
999
+ vaultRoot = join(expanded, 'projects', projectId);
1000
+ }
1001
+
1002
+ const fullPath = join(vaultRoot, relPath);
1003
+ if (!fullPath.startsWith(vaultRoot)) {
1004
+ return { ok: false, error: 'path traversal attempt blocked' };
1005
+ }
1006
+ if (!existsSync(fullPath)) {
1007
+ return { ok: false, error: `note not found: ${relPath}` };
1008
+ }
1009
+
1010
+ // Ensure server is running (uses the same LLM-detection logic as reindexVault).
1011
+ const runResult = await ensureRunning(config, opts);
1012
+ if (!runResult.ok) {
1013
+ return { ok: false, error: runResult.error || 'lightrag server unavailable' };
1014
+ }
1015
+
1016
+ const raw = readFileSync(fullPath, 'utf8');
1017
+ const { frontmatter, body } = parseFrontmatter(raw);
1018
+ const note = { relPath, frontmatter, body };
1019
+ const r = await insertNote(config, projectId, note);
1020
+
1021
+ return {
1022
+ ok: r.ok,
1023
+ inserted: r.ok ? 1 : 0,
1024
+ failed: r.ok ? 0 : 1,
1025
+ error: r.error,
1026
+ };
1027
+ }
1028
+
787
1029
  /**
788
1030
  * Query LightRAG. Returns { ok, mode, response, error? }.
789
1031
  *
@@ -4,11 +4,17 @@
4
4
  * MarkdownMemoryStore — reads/writes Obsidian-compatible `.md` notes with YAML
5
5
  * frontmatter for the Bizar Memory Service. Supports three modes:
6
6
  * local-only — vault at <projectRoot>/.obsidian/
7
- * managed — vault at ~/.local/share/bizar/memory/<repoName>/projects/<projectId>/
7
+ * managed — vault at ~/.bizar_memory/<repoName>/projects/<projectId>/
8
8
  * linked — alias for managed
9
9
  *
10
10
  * The project namespace directory is created LAZY on first writeNote, not at
11
11
  * initVault time (F7 invariant).
12
+ *
13
+ * v5.x — Default vault root relocated from `~/.local/share/bizar/memory` to
14
+ * `~/.bizar_memory` per issue #5. The old path still works if `BIZAR_MEMORY_VAULT`
15
+ * is set; on first run, `ensureVaultExists()` emits a one-time notice when the
16
+ * old path exists and the new one doesn't, but does NOT auto-migrate (too
17
+ * risky to move user data without consent).
12
18
  */
13
19
 
14
20
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
@@ -27,15 +33,26 @@ const HOME = homedir();
27
33
  /**
28
34
  * Default vault root — auto-created on first use.
29
35
  * Override with BIZAR_MEMORY_VAULT env var.
36
+ *
37
+ * v5.x — moved from `~/.local/share/bizar/memory` to `~/.bizar_memory` so the
38
+ * vault lives at a memorable, top-level location in the user's home.
30
39
  */
31
- export const DEFAULT_MEMORY_VAULT = join(HOME, '.local', 'share', 'bizar', 'memory');
40
+ export const DEFAULT_MEMORY_VAULT = join(HOME, '.bizar_memory');
41
+
42
+ /**
43
+ * v5.x — Legacy vault path kept for migration detection only. The old
44
+ * `~/.local/share/bizar/memory` location is no longer the default; if a
45
+ * user still has notes there, `ensureVaultExists()` logs a one-time
46
+ * notice pointing them at the migration command.
47
+ */
48
+ export const LEGACY_MEMORY_VAULT = join(HOME, '.local', 'share', 'bizar', 'memory');
32
49
 
33
50
  /**
34
51
  * Default git remote — set via BIZAR_MEMORY_GIT_REMOTE to enable sync.
35
52
  */
36
53
  export const DEFAULT_GIT_REMOTE = process.env.BIZAR_MEMORY_GIT_REMOTE || null;
37
54
 
38
- const BIZAR_MEMORY_ROOT = join(HOME, '.local', 'share', 'bizar', 'memory');
55
+ const BIZAR_MEMORY_ROOT = join(HOME, '.bizar_memory');
39
56
 
40
57
  /**
41
58
  * Return the effective vault root for the default memory vault.
@@ -51,11 +68,34 @@ export function currentVault() {
51
68
  * Ensure the default memory vault directory exists and is git-initialised.
52
69
  * Idempotent — calling multiple times is safe.
53
70
  *
71
+ * v5.x — On first call with a fresh default vault, if the legacy
72
+ * `~/.local/share/bizar/memory` path exists and the new
73
+ * `~/.bizar_memory` doesn't, log a one-time migration notice. We do
74
+ * NOT auto-migrate: the user owns the data and may have already moved
75
+ * it, or may want to keep the old path via `BIZAR_MEMORY_VAULT`.
76
+ *
54
77
  * @returns {string} the vault path that was ensured
55
78
  */
56
79
  export function ensureVaultExists() {
57
80
  const vault = currentVault();
58
81
  if (!existsSync(vault)) {
82
+ // v5.x — Migration notice: legacy path exists, new default does not.
83
+ // Only emit when the user is using the default (no env override) and
84
+ // the new path doesn't exist yet.
85
+ if (
86
+ !process.env.BIZAR_MEMORY_VAULT &&
87
+ vault === DEFAULT_MEMORY_VAULT &&
88
+ existsSync(LEGACY_MEMORY_VAULT)
89
+ ) {
90
+ console.warn(
91
+ `[memory-store] Detected a legacy memory vault at ${LEGACY_MEMORY_VAULT}.\n` +
92
+ `[memory-store] The default location has moved to ${DEFAULT_MEMORY_VAULT}.\n` +
93
+ `[memory-store] To keep using the old vault, set BIZAR_MEMORY_VAULT=${LEGACY_MEMORY_VAULT}.\n` +
94
+ `[memory-store] To migrate, move the contents manually: ` +
95
+ `mv ${LEGACY_MEMORY_VAULT}/* ${DEFAULT_MEMORY_VAULT}/\n` +
96
+ `[memory-store] (not auto-migrated — please review before moving user data).`,
97
+ );
98
+ }
59
99
  mkdirSync(vault, { recursive: true, mode: 0o700 });
60
100
  if (!existsSync(join(vault, '.git'))) {
61
101
  try {
@@ -2,10 +2,15 @@
2
2
  * src/server/routes/background.mjs
3
3
  *
4
4
  * /api/background — list
5
+ * /api/background (POST) — spawn from UI (v5.x)
5
6
  * /api/background/:id — single instance
6
7
  * /api/background/:id/output — tail captured output
8
+ * /api/background/:id/tool-calls (GET) — tool call history (v5.x)
7
9
  * /api/background/:id/tmux — tmux attach metadata
8
10
  * /api/background/:id/message (POST) — send a follow-up message
11
+ * /api/background/:id/pause (POST) — pause subprocess (v5.x)
12
+ * /api/background/:id/resume (POST) — resume subprocess (v5.x)
13
+ * /api/background/:id/steer (POST) — kill+respawn with new prompt (v5.x)
9
14
  * /api/background/:id/retry (POST) — manual unstick (v3.11.0)
10
15
  * /api/background/:id (DELETE) — kill
11
16
  *
@@ -23,12 +28,68 @@ import { wrap } from './_shared.mjs';
23
28
  export function createBackgroundRouter({ broadcast }) {
24
29
  const router = Router();
25
30
 
31
+ // Wire bg-spawner's broadcast so spawned agents broadcast on the
32
+ // existing WS bus (mirroring the way `bg-poller.mjs` does it).
33
+ import('../bg-spawner.mjs').then((m) => {
34
+ if (m && typeof m.configureSpawner === 'function') {
35
+ m.configureSpawner({ broadcast });
36
+ }
37
+ }).catch(() => { /* ignore — spawner optional */ });
38
+
26
39
  router.get('/background', wrap(async (_req, res) => {
27
40
  const { backgroundStore } = await import('../background-store.mjs');
28
41
  const instances = await backgroundStore.list();
29
42
  res.json({ instances, status: backgroundStore.status() });
30
43
  }));
31
44
 
45
+ // v5.x — Spawn from UI. Body: { agent, prompt, model?, persistent?, maxRestarts?, tags?, timeoutMs?, worktree? }
46
+ router.post('/background', wrap(async (req, res) => {
47
+ const body = req.body || {};
48
+ const agent = String(body.agent || '').trim();
49
+ const prompt = String(body.prompt || '').trim();
50
+ const worktree = body.worktree ? String(body.worktree) : process.cwd();
51
+ if (!agent || !prompt) {
52
+ res.status(400).json({ error: 'bad_request', message: 'agent and prompt are required' });
53
+ return;
54
+ }
55
+ let model;
56
+ if (body.model && typeof body.model === 'string') {
57
+ const idx = body.model.indexOf('/');
58
+ if (idx > 0) {
59
+ model = {
60
+ providerID: body.model.slice(0, idx).trim(),
61
+ modelID: body.model.slice(idx + 1).trim(),
62
+ };
63
+ }
64
+ }
65
+ const tags = Array.isArray(body.tags)
66
+ ? body.tags.filter((t) => typeof t === 'string').slice(0, 10)
67
+ : undefined;
68
+ const { spawnBgAgent } = await import('../bg-spawner.mjs');
69
+ const result = await spawnBgAgent({
70
+ agent,
71
+ prompt,
72
+ worktree,
73
+ model,
74
+ timeoutMs: Number(body.timeoutMs) || 300_000,
75
+ persistent: Boolean(body.persistent),
76
+ maxRestarts: Number(body.maxRestarts) || 3,
77
+ tags,
78
+ });
79
+ if (result.error) {
80
+ res.status(500).json({ error: 'spawn_failed', message: result.error, instanceId: result.instanceId });
81
+ return;
82
+ }
83
+ res.status(201).json({
84
+ instanceId: result.instanceId,
85
+ sessionId: result.sessionId ?? null,
86
+ processId: result.processId ?? null,
87
+ status: 'pending',
88
+ agent,
89
+ worktree,
90
+ });
91
+ }));
92
+
32
93
  router.get('/background/:id', wrap(async (req, res) => {
33
94
  const { backgroundStore } = await import('../background-store.mjs');
34
95
  const inst = backgroundStore.get(req.params.id);
@@ -46,6 +107,73 @@ export function createBackgroundRouter({ broadcast }) {
46
107
  res.json(result);
47
108
  }));
48
109
 
110
+ // v5.x — Tool-call history. The state file carries a `toolCalls`
111
+ // array populated by the plugin's InstanceManager as it observes
112
+ // opencode events. This endpoint is read-only; the plugin owns the
113
+ // shape.
114
+ router.get('/background/:id/tool-calls', wrap(async (req, res) => {
115
+ const { backgroundStore } = await import('../background-store.mjs');
116
+ const inst = backgroundStore.get(req.params.id);
117
+ if (!inst) {
118
+ res.status(404).json({ error: 'not_found' });
119
+ return;
120
+ }
121
+ const calls = Array.isArray(inst.toolCalls) ? inst.toolCalls : [];
122
+ res.json({ instanceId: req.params.id, toolCalls: calls, count: calls.length });
123
+ }));
124
+
125
+ // v5.x — Pause. SIGSTOP the subprocess.
126
+ router.post('/background/:id/pause', wrap(async (req, res) => {
127
+ const { pauseBgAgent } = await import('../bg-spawner.mjs');
128
+ const result = pauseBgAgent(req.params.id);
129
+ if (!result.ok) {
130
+ res.status(result.error === 'unsupported_on_win32' ? 501 : 400).json({ ok: false, error: result.error });
131
+ return;
132
+ }
133
+ broadcast({ type: 'background:change', id: req.params.id, status: 'paused' });
134
+ res.json({ ok: true, status: 'paused' });
135
+ }));
136
+
137
+ // v5.x — Resume. SIGCONT the subprocess.
138
+ router.post('/background/:id/resume', wrap(async (req, res) => {
139
+ const { resumeBgAgent } = await import('../bg-spawner.mjs');
140
+ const result = resumeBgAgent(req.params.id);
141
+ if (!result.ok) {
142
+ res.status(result.error === 'unsupported_on_win32' ? 501 : 400).json({ ok: false, error: result.error });
143
+ return;
144
+ }
145
+ broadcast({ type: 'background:change', id: req.params.id, status: 'running' });
146
+ res.json({ ok: true, status: 'running' });
147
+ }));
148
+
149
+ // v5.x — Steer (kill+restart with appended prompt). Body: { message }.
150
+ router.post('/background/:id/steer', wrap(async (req, res) => {
151
+ const message = String((req.body && req.body.message) || '').trim();
152
+ if (!message) {
153
+ res.status(400).json({ ok: false, error: 'message_empty' });
154
+ return;
155
+ }
156
+ const { steerBgAgent } = await import('../bg-spawner.mjs');
157
+ const result = await steerBgAgent(req.params.id, message);
158
+ if (!result.ok) {
159
+ res.status(400).json({ ok: false, error: result.error });
160
+ return;
161
+ }
162
+ broadcast({
163
+ type: 'background:change',
164
+ id: req.params.id,
165
+ status: 'steered',
166
+ newInstanceId: result.newInstanceId,
167
+ });
168
+ broadcast({
169
+ type: 'background:change',
170
+ id: result.newInstanceId,
171
+ status: 'pending',
172
+ parentInstanceId: req.params.id,
173
+ });
174
+ res.json({ ok: true, status: 'steered', newInstanceId: result.newInstanceId, processId: result.processId });
175
+ }));
176
+
49
177
  // v3.5.5 — Tmux session metadata. The UI uses this to render an
50
178
  // "Attach" button next to a running bg instance. Returns the
51
179
  // computed session name, the local attach command, and whether
@@ -126,6 +254,21 @@ export function createBackgroundRouter({ broadcast }) {
126
254
  }));
127
255
 
128
256
  router.delete('/background/:id', wrap(async (req, res) => {
257
+ // v5.x — Prefer the dashboard-side spawner's kill for instances
258
+ // that we own (spawned via POST /background). Falls back to the
259
+ // plugin's tmux/abort path for instances spawned by the plugin.
260
+ try {
261
+ const { isAlive: spawnerAlive, killBgAgent } = await import('../bg-spawner.mjs');
262
+ if (spawnerAlive(req.params.id)) {
263
+ const r = await killBgAgent(req.params.id, { signal: 'SIGTERM' });
264
+ if (r.ok) {
265
+ broadcast({ type: 'background:change', action: 'kill', id: req.params.id, source: 'dashboard' });
266
+ res.json({ ok: true, instanceId: req.params.id, source: 'dashboard' });
267
+ return;
268
+ }
269
+ }
270
+ } catch { /* ignore — fall through to legacy */ }
271
+
129
272
  const { backgroundStore } = await import('../background-store.mjs');
130
273
  // v3.5.4 (bug #3) — `kill()` is now async (it awaits the abortSession
131
274
  // HTTP call to opencode serve, then deletes the state file, then
@@ -155,5 +155,15 @@ export function createLightragRouter({ projectRoot }) {
155
155
  });
156
156
  }));
157
157
 
158
+ // POST /api/lightrag/autostart — v5.x — issue #6.
159
+ // Runs lightragStartupHook() against the dashboard's projectRoot.
160
+ // Used by `bizar lightrag autostart` (CLI) and by `install.sh` to
161
+ // ensure the LightRAG server is up after install/update.
162
+ router.post('/lightrag/autostart', wrap(async (_req, res) => {
163
+ const { lightragStartupHook } = await import('../memory-lightrag.mjs');
164
+ const r = await lightragStartupHook(projectRoot);
165
+ res.status(r.ok ? 200 : 503).json(r);
166
+ }));
167
+
158
168
  return router;
159
169
  }
@@ -198,6 +198,10 @@ export function createMemoryRouter({ projectRoot }) {
198
198
  try {
199
199
  const note = writeNote(projectRoot, relPath, { frontmatter: frontmatter || {}, body: body || '' });
200
200
  res.status(201).json(note);
201
+ // v5.x — auto-reindex the written note in the background (fire-and-forget).
202
+ getMemoryLightrag().then((m) => {
203
+ void m.reindexSingleNote(projectRoot, relPath).catch(() => {});
204
+ });
201
205
  } catch (err) {
202
206
  if (err.code === 'SCHEMA_VALIDATION_FAILED' || err.code === 'SECRET_DETECTED') {
203
207
  res.status(400).json({ error: err.code, message: err.message, findings: err.findings });
@@ -1276,6 +1280,10 @@ export function createMemoryRouter({ projectRoot }) {
1276
1280
  try {
1277
1281
  const note = writeNote(projectRoot, relPath, { frontmatter: frontmatter || {}, body: body || '' });
1278
1282
  res.json(note);
1283
+ // v5.x — auto-reindex the updated note in the background (fire-and-forget).
1284
+ getMemoryLightrag().then((m) => {
1285
+ void m.reindexSingleNote(projectRoot, relPath).catch(() => {});
1286
+ });
1279
1287
  } catch (err) {
1280
1288
  if (err.code === 'SCHEMA_VALIDATION_FAILED' || err.code === 'SECRET_DETECTED') {
1281
1289
  res.status(400).json({ error: err.code, message: err.message, findings: err.findings });
@@ -473,6 +473,28 @@ export async function createServer({
473
473
  console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
474
474
  }
475
475
 
476
+ // v5.x — LightRAG startup hook (issue #6). Mirrors the headroom hook:
477
+ // reads config from .bizar/memory.json + env vars, then calls
478
+ // lightragStartupHook() which respects `lightrag.enabled` and the
479
+ // BIZAR_LIGHTRAG_AUTOSTART env override. All errors are caught — the
480
+ // dashboard must boot even when LightRAG can't start.
481
+ try {
482
+ const { lightragStartupHook } = await import('./memory-lightrag.mjs');
483
+ lightragStartupHook(projectRoot).then((r) => {
484
+ if (!r.ok) {
485
+ console.warn('[bizar-dash] lightragStartupHook:', r.error || r.reason || 'not started');
486
+ } else if (r.started) {
487
+ console.log(`[bizar-dash] lightrag auto-started (pid=${r.pid})`);
488
+ } else {
489
+ console.log(`[bizar-dash] lightrag: ${r.reason || 'not started'}`);
490
+ }
491
+ }).catch((err) => {
492
+ console.warn('[bizar-dash] lightragStartupHook error:', err?.message || err);
493
+ });
494
+ } catch (err) {
495
+ console.warn('[bizar-dash] lightrag startup hook skipped:', err?.message || err);
496
+ }
497
+
476
498
  // v5.2 — Background transcription worker for voice notes. Uploads
477
499
  // save audio immediately and enqueue the noteId here; the worker
478
500
  // drains the queue, calls Whisper (or BIZAR_WHISPER_ENDPOINT), and
@@ -30,6 +30,7 @@ import { Tasks } from './views/Tasks';
30
30
  import { Activity } from './views/Activity';
31
31
  import { Config } from './views/Config';
32
32
  import { SettingsView } from './views/Settings';
33
+ import { Marketplace } from './views/Marketplace';
33
34
  import { Mods } from './views/Mods';
34
35
  import { Plugins } from './views/Plugins';
35
36
  import { ModView, type ModView as ModViewType } from './views/ModView';
@@ -47,6 +48,7 @@ import { Spinner } from './components/Spinner';
47
48
  import { Button } from './components/Button';
48
49
  import { AlertTriangle, Globe, LayoutTemplate, X } from 'lucide-react';
49
50
  import './styles/main.css';
51
+ import './styles/settings.css';
50
52
 
51
53
  type ViewProps = {
52
54
  snapshot: Snapshot;
@@ -90,6 +92,7 @@ const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
90
92
  background: BackgroundAgents,
91
93
  config: Config,
92
94
  settings: SettingsView,
95
+ marketplace: Marketplace,
93
96
  mods: Mods,
94
97
  schedules: Schedules,
95
98
  skills: Skills,
@@ -13,7 +13,6 @@ import type { Notification, Settings, Snapshot, WsMessage } from './lib/types';
13
13
  import { applyTheme, applyThemeTokens } from './lib/types';
14
14
  import { Ws } from './lib/ws';
15
15
  import { MobileBottomNav, type MobileTab } from './mobile/MobileBottomNav';
16
- import { MobileTopbar } from './mobile/MobileTopbar';
17
16
  import { MobileLayout, type DrawerTab } from './components/MobileLayout';
18
17
  import { MobileActivity } from './mobile/views/MobileActivity';
19
18
  import { MobileAgents } from './mobile/views/MobileAgents';
@@ -401,20 +400,12 @@ export function MobileApp() {
401
400
  onExitSettings={() => goToTab('activity')}
402
401
  onSearch={() => setSearchOpen(true)}
403
402
  >
404
- <MobileTopbar
405
- activeTab={currentView.id}
406
- snapshot={snapshot}
407
- onSearch={() => setSearchOpen(true)}
408
- onNavigate={handleNavigate}
409
- />
410
-
411
- <main className="mobile-content">{renderView()}</main>
412
-
413
403
  {stack.length > 0 && (
414
404
  <button type="button" className="mobile-back-btn" onClick={popView} aria-label="Go back">
415
405
  ← Back
416
406
  </button>
417
407
  )}
408
+ {renderView()}
418
409
  </MobileLayout>
419
410
 
420
411
  <MobileSearchModal open={searchOpen} onClose={() => setSearchOpen(false)} onNavigate={handleNavigate} />
@@ -27,7 +27,7 @@ export function InstallConfirmDialog({ plugin, onConfirm, onCancel, installing }
27
27
 
28
28
  modal.open({
29
29
  title: `Install ${plugin.name}?`,
30
- width: 480,
30
+ width: 560,
31
31
  onClose: () => {
32
32
  openedForRef.current = null;
33
33
  onCancel();