@polderlabs/bizar 4.4.12 → 4.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 (91) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +427 -110
  27. package/bizar-dash/src/server/providers-store.mjs +966 -6
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/minimax.mjs +50 -57
  33. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  34. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  35. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  36. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  37. package/bizar-dash/src/server/routes/update.mjs +340 -0
  38. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  39. package/bizar-dash/src/server/serve-info.mjs +135 -4
  40. package/bizar-dash/src/server/server.mjs +4 -0
  41. package/bizar-dash/src/server/skills-store.mjs +152 -262
  42. package/bizar-dash/src/web/App.tsx +118 -29
  43. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  44. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  45. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  46. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  47. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  48. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  49. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  50. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  51. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  52. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  53. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  54. package/bizar-dash/src/web/lib/api.ts +43 -0
  55. package/bizar-dash/src/web/main.tsx +1 -0
  56. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  57. package/bizar-dash/src/web/styles/chat.css +135 -1
  58. package/bizar-dash/src/web/styles/main.css +46 -0
  59. package/bizar-dash/src/web/styles/minimax-usage.css +471 -0
  60. package/bizar-dash/src/web/styles/settings.css +418 -0
  61. package/bizar-dash/src/web/styles/skills.css +302 -0
  62. package/bizar-dash/src/web/styles/tasks.css +288 -0
  63. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  64. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  65. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +620 -240
  66. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  67. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  68. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  69. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  70. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  71. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  72. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  73. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  74. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  75. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  76. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  77. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  78. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  79. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  80. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  81. package/bizar-dash/tests/update-check.test.mjs +127 -0
  82. package/bizar-dash/tests/update-run.test.mjs +266 -0
  83. package/cli/bin.mjs +345 -4
  84. package/cli/provision.mjs +118 -4
  85. package/config/agents/_shared/SKILLS.md +109 -0
  86. package/package.json +1 -1
  87. package/bizar-dash/dist/assets/main-BKXEqU1w.css +0 -1
  88. package/bizar-dash/dist/assets/main-EK_fzXn_.js +0 -352
  89. package/bizar-dash/dist/assets/main-EK_fzXn_.js.map +0 -1
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  91. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -8,6 +8,8 @@
8
8
  * /api/config/providers (POST) — add
9
9
  * /api/config/providers/:id (PUT) — update
10
10
  * /api/config/providers/:id (DELETE) — remove
11
+ * /api/config/providers/auto (POST) — add with key, auto-discover models
12
+ * + apply systemLlm config
11
13
  * /api/config/mcps — list MCP servers
12
14
  * /api/config/mcps (POST) — add
13
15
  * /api/config/mcps/:id (PUT) — update
@@ -22,7 +24,12 @@
22
24
  import { Router } from 'express';
23
25
  import { existsSync, mkdirSync } from 'node:fs';
24
26
  import { dirname } from 'node:path';
25
- import { providersStore, mcpsStore } from '../providers-store.mjs';
27
+ import {
28
+ providersStore,
29
+ mcpsStore,
30
+ loadConfig,
31
+ saveConfig,
32
+ } from '../providers-store.mjs';
26
33
  import { OPENCODE_JSON, atomicWriteJson, safeReadJSON, wrap } from './_shared.mjs';
27
34
 
28
35
  /**
@@ -101,11 +108,55 @@ export function createConfigRouter({ state, watcher }) {
101
108
  res.status(204).end();
102
109
  }));
103
110
 
111
+ // v4.4.14 — "Add with key" — the user pastes a key, we figure out the
112
+ // rest. Auto-discovers the live model list (probes /v1/models), writes
113
+ // the full provider block, and (if requested) applies the system LLM
114
+ // config so the rest of the dashboard's /api/llm/* calls hit this
115
+ // provider+model.
116
+ router.post('/config/providers/auto', wrap(async (req, res) => {
117
+ const body = req.body || {};
118
+ const result = await providersStore.addWithAuto({
119
+ id: typeof body.id === 'string' ? body.id.trim() : undefined,
120
+ name: typeof body.name === 'string' ? body.name.trim() : undefined,
121
+ apiKey: typeof body.apiKey === 'string' ? body.apiKey : '',
122
+ groupId: typeof body.groupId === 'string' ? body.groupId : 'default',
123
+ setAsSystemLlm: body.setAsSystemLlm !== false,
124
+ preferredModel: typeof body.preferredModel === 'string' ? body.preferredModel : undefined,
125
+ systemLlmModel: typeof body.systemLlmModel === 'string' ? body.systemLlmModel : undefined,
126
+ });
127
+ if (!result.ok) {
128
+ res.status(result.error === 'unknown_provider' ? 400 : 400).json(result);
129
+ return;
130
+ }
131
+ res.status(201).json(result);
132
+ }));
133
+
104
134
  // ── /api/config/mcps ───────────────────────────────────────────────────
105
135
  router.get('/config/mcps', wrap(async (_req, res) => {
106
136
  res.json({ mcps: mcpsStore.list() });
107
137
  }));
108
138
 
139
+ // v4.4.14 — System LLM settings. The dashboard reads / writes the
140
+ // opencode.json#systemLlm block via this thin surface. The block
141
+ // controls which provider+model is used for the dashboard's own
142
+ // /api/llm/* calls (auto-title, enhance-prompt, summarization).
143
+ router.get('/llm/system-llm', wrap(async (_req, res) => {
144
+ const cfg = loadConfig();
145
+ res.json(cfg.systemLlm || { enabled: false, provider: null, model: null });
146
+ }));
147
+
148
+ router.put('/llm/system-llm', wrap(async (req, res) => {
149
+ const cfg = loadConfig();
150
+ const body = req.body || {};
151
+ cfg.systemLlm = {
152
+ enabled: body.enabled !== false,
153
+ provider: typeof body.provider === 'string' ? body.provider : (cfg.systemLlm?.provider || null),
154
+ model: typeof body.model === 'string' ? body.model : (cfg.systemLlm?.model || null),
155
+ };
156
+ saveConfig(cfg);
157
+ res.json(cfg.systemLlm);
158
+ }));
159
+
109
160
  router.post('/config/mcps', wrap(async (req, res) => {
110
161
  const mcp = mcpsStore.add(req.body || {});
111
162
  res.status(201).json(mcp);
@@ -0,0 +1,165 @@
1
+ /**
2
+ * src/server/routes/env-vars.mjs
3
+ *
4
+ * v1 — Bizar env vars stored in ~/.config/bizar/env.json (mode 0600).
5
+ *
6
+ * Endpoints:
7
+ * GET /api/env-vars — list all (masked values)
8
+ * POST /api/env-vars — create {name, value}
9
+ * PUT /api/env-vars/:name — update {value}
10
+ * DELETE /api/env-vars/:name — remove + delete from process.env
11
+ * POST /api/env-vars/:name/test — {referenced, inProcessEnv}
12
+ *
13
+ * Name must match /^BIZAR_[A-Z0-9_]+$/.
14
+ * On startup, loadEnvJson() sets process.env[name] = value for every entry.
15
+ */
16
+ import { Router } from 'express';
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { wrap } from './_shared.mjs';
21
+
22
+ const HOME = homedir();
23
+ const BIZAR_DIR = join(HOME, '.config', 'bizar');
24
+ const ENV_FILE = join(BIZAR_DIR, 'env.json');
25
+
26
+ // Lazy-load store
27
+ let _store = null;
28
+
29
+ function readStore() {
30
+ if (_store !== null) return _store;
31
+ if (!existsSync(ENV_FILE)) {
32
+ _store = {};
33
+ return _store;
34
+ }
35
+ try {
36
+ _store = JSON.parse(readFileSync(ENV_FILE, 'utf8')) || {};
37
+ } catch {
38
+ _store = {};
39
+ }
40
+ return _store;
41
+ }
42
+
43
+ function writeStore(store) {
44
+ mkdirSync(BIZAR_DIR, { recursive: true });
45
+ const tmp = `${ENV_FILE}.tmp.${process.pid}`;
46
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
47
+ renameSync(tmp, ENV_FILE);
48
+ _store = store;
49
+ }
50
+
51
+ /**
52
+ * Load env.json into process.env at startup.
53
+ * Called once from server.mjs after createApiRouter().
54
+ */
55
+ export function loadEnvJson() {
56
+ const store = readStore();
57
+ for (const [name, entry] of Object.entries(store)) {
58
+ if (name.startsWith('BIZAR_')) {
59
+ process.env[name] = entry.value;
60
+ }
61
+ }
62
+ }
63
+
64
+ /** Reset in-memory store — used by tests to ensure isolation between test cases. */
65
+ export function resetStore() {
66
+ _store = null;
67
+ }
68
+
69
+ /** Mask a value — return last 4 chars, prefixed with ***. */
70
+ function mask(value) {
71
+ if (!value || value.length <= 4) return '****';
72
+ return '*'.repeat(Math.max(0, value.length - 4)) + value.slice(-4);
73
+ }
74
+
75
+ const NAME_RE = /^BIZAR_[A-Z0-9_]+$/;
76
+
77
+ export function createEnvVarsRouter() {
78
+ const router = Router();
79
+
80
+ // GET /api/env-vars — list all (masked)
81
+ router.get('/env-vars', wrap(async (_req, res) => {
82
+ const store = readStore();
83
+ const list = Object.entries(store).map(([name, entry]) => ({
84
+ name,
85
+ value: mask(entry.value),
86
+ createdAt: entry.createdAt,
87
+ source: entry.source,
88
+ }));
89
+ res.json(list);
90
+ }));
91
+
92
+ // POST /api/env-vars — create
93
+ router.post('/env-vars', wrap(async (req, res) => {
94
+ const { name, value } = req.body || {};
95
+ if (!name || typeof name !== 'string') {
96
+ res.status(400).json({ error: 'name_required', message: 'name is required' });
97
+ return;
98
+ }
99
+ if (!NAME_RE.test(name)) {
100
+ res.status(400).json({
101
+ error: 'invalid_name',
102
+ message: 'name must match /^BIZAR_[A-Z0-9_]+$/',
103
+ });
104
+ return;
105
+ }
106
+ if (!value || typeof value !== 'string') {
107
+ res.status(400).json({ error: 'value_required', message: 'value is required' });
108
+ return;
109
+ }
110
+ const store = readStore();
111
+ if (store[name]) {
112
+ res.status(409).json({ error: 'already_exists', message: `${name} already exists` });
113
+ return;
114
+ }
115
+ const entry = { value, createdAt: new Date().toISOString(), source: 'dashboard' };
116
+ store[name] = entry;
117
+ writeStore(store);
118
+ process.env[name] = value;
119
+ res.status(201).json({ name, value: mask(value), createdAt: entry.createdAt, source: entry.source });
120
+ }));
121
+
122
+ // PUT /api/env-vars/:name — update
123
+ router.put('/env-vars/:name', wrap(async (req, res) => {
124
+ const { name } = req.params;
125
+ const { value } = req.body || {};
126
+ if (!value || typeof value !== 'string') {
127
+ res.status(400).json({ error: 'value_required', message: 'value is required' });
128
+ return;
129
+ }
130
+ const store = readStore();
131
+ if (!store[name]) {
132
+ res.status(404).json({ error: 'not_found', message: `${name} not found` });
133
+ return;
134
+ }
135
+ store[name] = { ...store[name], value };
136
+ writeStore(store);
137
+ process.env[name] = value;
138
+ res.json({ name, value: mask(value), createdAt: store[name].createdAt, source: store[name].source });
139
+ }));
140
+
141
+ // DELETE /api/env-vars/:name — remove
142
+ router.delete('/env-vars/:name', wrap(async (req, res) => {
143
+ const { name } = req.params;
144
+ const store = readStore();
145
+ if (!store[name]) {
146
+ res.status(404).json({ error: 'not_found', message: `${name} not found` });
147
+ return;
148
+ }
149
+ delete store[name];
150
+ writeStore(store);
151
+ delete process.env[name];
152
+ res.json({ ok: true });
153
+ }));
154
+
155
+ // POST /api/env-vars/:name/test — check if referenced
156
+ router.post('/env-vars/:name/test', wrap(async (req, res) => {
157
+ const { name } = req.params;
158
+ const store = readStore();
159
+ const inProcessEnv = name in process.env;
160
+ // Conservative: we don't statically analyze code, so referenced = inProcessEnv for now
161
+ res.json({ referenced: inProcessEnv, inProcessEnv });
162
+ }));
163
+
164
+ return router;
165
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * src/server/routes/lightrag.mjs
3
+ *
4
+ * v4.6.0 — LightRAG settings endpoints.
5
+ *
6
+ * Mounted at /api/lightrag/*. Used by the Settings view (handled by
7
+ * the sibling thor-settings agent) to surface and override the
8
+ * free-tier opencode Zen defaults that lightrag starts with.
9
+ *
10
+ * Endpoints:
11
+ * GET /lightrag/defaults — current default models + provenance
12
+ * PUT /lightrag/defaults — set env-var overrides (process-scoped, not persisted)
13
+ * GET /lightrag/status — current running config (delegates to resolveLightRAGConfig)
14
+ *
15
+ * The PUT endpoint mutates `process.env` for the lifetime of the
16
+ * dashboard server process. We do NOT persist this to disk because:
17
+ * 1. env-var overrides are conventionally process-scoped, and
18
+ * 2. the proper way to persist is via PUT /api/memory/config which
19
+ * writes to .bizar/memory.json (see routes/memory.mjs).
20
+ */
21
+ import { Router } from 'express';
22
+ import { existsSync, readFileSync, statSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import {
25
+ getDefaultLightRAGConfig,
26
+ LIGHTRAG_DEFAULT_LLM,
27
+ LIGHTRAG_DEFAULT_EMBEDDING,
28
+ } from '../memory-store.mjs';
29
+ import { resolveLightRAGConfig } from '../memory-lightrag.mjs';
30
+ import { wrap } from './_shared.mjs';
31
+
32
+ /**
33
+ * @param {object} deps
34
+ * @param {string} deps.projectRoot
35
+ * @returns {import('express').Router}
36
+ */
37
+ export function createLightragRouter({ projectRoot }) {
38
+ const router = Router();
39
+
40
+ // GET /api/lightrag/defaults — current defaults + where they came from.
41
+ router.get('/lightrag/defaults', wrap(async (_req, res) => {
42
+ const cfg = getDefaultLightRAGConfig();
43
+ res.json({
44
+ llm: cfg.llm,
45
+ embedding: cfg.embedding,
46
+ source: cfg.source,
47
+ llmSource: cfg.llmSource,
48
+ embeddingSource: cfg.embeddingSource,
49
+ builtin: {
50
+ llm: LIGHTRAG_DEFAULT_LLM,
51
+ embedding: LIGHTRAG_DEFAULT_EMBEDDING,
52
+ },
53
+ envVars: {
54
+ BIZAR_LIGHTRAG_LLM: process.env.BIZAR_LIGHTRAG_LLM || null,
55
+ BIZAR_LIGHTRAG_EMBEDDING: process.env.BIZAR_LIGHTRAG_EMBEDDING || null,
56
+ },
57
+ });
58
+ }));
59
+
60
+ // PUT /api/lightrag/defaults — set env-var overrides.
61
+ // Body: { llm?: string, embedding?: string }
62
+ // Both fields are optional; only the supplied ones are set. To clear
63
+ // an override, send an empty string ''.
64
+ router.put('/lightrag/defaults', wrap(async (req, res) => {
65
+ const body = req.body || {};
66
+ const updates = {};
67
+ if (Object.prototype.hasOwnProperty.call(body, 'llm')) {
68
+ const v = typeof body.llm === 'string' ? body.llm.trim() : '';
69
+ if (v) {
70
+ process.env.BIZAR_LIGHTRAG_LLM = v;
71
+ updates.llm = v;
72
+ } else {
73
+ delete process.env.BIZAR_LIGHTRAG_LLM;
74
+ updates.llmCleared = true;
75
+ }
76
+ }
77
+ if (Object.prototype.hasOwnProperty.call(body, 'embedding')) {
78
+ const v = typeof body.embedding === 'string' ? body.embedding.trim() : '';
79
+ if (v) {
80
+ process.env.BIZAR_LIGHTRAG_EMBEDDING = v;
81
+ updates.embedding = v;
82
+ } else {
83
+ delete process.env.BIZAR_LIGHTRAG_EMBEDDING;
84
+ updates.embeddingCleared = true;
85
+ }
86
+ }
87
+ const next = getDefaultLightRAGConfig();
88
+ res.json({
89
+ ok: true,
90
+ applied: updates,
91
+ llm: next.llm,
92
+ embedding: next.embedding,
93
+ source: next.source,
94
+ });
95
+ }));
96
+
97
+ // GET /api/lightrag/status — current resolved config + server status.
98
+ // Mirrors /api/memory/lightrag/status but is the canonical surface
99
+ // for the Settings view.
100
+ router.get('/lightrag/status', wrap(async (_req, res) => {
101
+ const cfg = resolveLightRAGConfig(projectRoot);
102
+ const pidFile = join(cfg.workingDir, 'lightrag.pid');
103
+ let pid = null;
104
+ let alive = false;
105
+ if (existsSync(pidFile)) {
106
+ try {
107
+ pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
108
+ if (Number.isFinite(pid) && pid > 0) {
109
+ try {
110
+ process.kill(pid, 0);
111
+ alive = true;
112
+ } catch {
113
+ alive = false;
114
+ }
115
+ } else {
116
+ pid = null;
117
+ }
118
+ } catch {
119
+ pid = null;
120
+ }
121
+ }
122
+
123
+ const logFile = join(cfg.workingDir, 'lightrag.log');
124
+ const logTail = [];
125
+ if (existsSync(logFile)) {
126
+ try {
127
+ const content = readFileSync(logFile, 'utf8');
128
+ const lines = content.split('\n');
129
+ logTail.push(...lines.slice(-30));
130
+ } catch { /* ignore */ }
131
+ }
132
+
133
+ let logSize = 0;
134
+ try {
135
+ if (existsSync(logFile)) logSize = statSync(logFile).size;
136
+ } catch { /* ignore */ }
137
+
138
+ res.json({
139
+ running: alive,
140
+ pid: alive ? pid : null,
141
+ host: cfg.host,
142
+ port: cfg.port,
143
+ llmBinding: cfg.llmBinding,
144
+ embeddingBinding: cfg.embeddingBinding,
145
+ llmModel: cfg.llmModel,
146
+ embeddingModel: cfg.embeddingModel,
147
+ workingDir: cfg.workingDir,
148
+ logSize,
149
+ logTail,
150
+ });
151
+ }));
152
+
153
+ return router;
154
+ }
@@ -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 } 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);
@@ -654,5 +655,244 @@ export function createMemoryRouter({ projectRoot }) {
654
655
  }
655
656
  }));
656
657
 
658
+ // ── v4.6.0 Global memory-config surface ─────────────────────────────────
659
+ //
660
+ // A second config file at `~/.config/bizar/memory-config.json` holds
661
+ // operator-facing settings that aren't tied to a single project —
662
+ // e.g. the global default LightRAG URL, the global obsidian vault
663
+ // path, and git push defaults. The Settings view (handled by the
664
+ // sibling thor-settings agent) reads + writes this file via these
665
+ // endpoints.
666
+ //
667
+ // Distinct from the per-project `.bizar/memory.json` (the existing
668
+ // /memory/config surfaces that). We use a separate path to keep the
669
+ // two systems cleanly separated.
670
+
671
+ const GLOBAL_MEMORY_CONFIG_PATH = join(
672
+ process.env.HOME || '/tmp',
673
+ '.config',
674
+ 'bizar',
675
+ 'memory-config.json',
676
+ );
677
+
678
+ function loadGlobalMemoryConfig() {
679
+ try {
680
+ if (!existsSync(GLOBAL_MEMORY_CONFIG_PATH)) {
681
+ return {
682
+ exists: false,
683
+ config: {
684
+ lightrag: { enabled: false, url: 'http://127.0.0.1:9621', llm: '', embedding: '' },
685
+ obsidian: { vaultPath: '', syncInterval: 300 },
686
+ git: { repoPath: '', remoteUrl: '', branch: 'main', autoSync: false },
687
+ },
688
+ };
689
+ }
690
+ const raw = JSON.parse(readFileSync(GLOBAL_MEMORY_CONFIG_PATH, 'utf8'));
691
+ return { exists: true, config: raw };
692
+ } catch {
693
+ return {
694
+ exists: false,
695
+ error: 'corrupt_json',
696
+ config: {
697
+ lightrag: { enabled: false, url: 'http://127.0.0.1:9621', llm: '', embedding: '' },
698
+ obsidian: { vaultPath: '', syncInterval: 300 },
699
+ git: { repoPath: '', remoteUrl: '', branch: 'main', autoSync: false },
700
+ },
701
+ };
702
+ }
703
+ }
704
+
705
+ function saveGlobalMemoryConfig(config) {
706
+ const dir = dirname(GLOBAL_MEMORY_CONFIG_PATH);
707
+ if (!existsSync(dir)) {
708
+ mkdirSync(dir, { recursive: true });
709
+ }
710
+ atomicWriteJson(GLOBAL_MEMORY_CONFIG_PATH, config);
711
+ }
712
+
713
+ // GET /memory/config/global — grouped shape (lightrag/obsidian/git).
714
+ router.get('/memory/config/global', wrap(async (_req, res) => {
715
+ const { exists, config, error } = loadGlobalMemoryConfig();
716
+ res.json({
717
+ exists,
718
+ path: GLOBAL_MEMORY_CONFIG_PATH,
719
+ config,
720
+ ...(error ? { error } : {}),
721
+ });
722
+ }));
723
+
724
+ // PUT /memory/config/global — partial update. Validates each block
725
+ // and merges on top of the existing config.
726
+ router.put('/memory/config/global', wrap(async (req, res) => {
727
+ const body = req.body || {};
728
+ const { exists, config: existing } = loadGlobalMemoryConfig();
729
+
730
+ // Light validation — no zod import (keep surface small). Each
731
+ // block is shallow-merged; if a block is present, we shallow-merge
732
+ // its top-level fields, rejecting unknown top-level keys.
733
+ const next = JSON.parse(JSON.stringify(existing));
734
+
735
+ const VALID_TOP_KEYS = new Set(['lightrag', 'obsidian', 'git']);
736
+ const EXTRA_KEYS = Object.keys(body).filter((k) => !VALID_TOP_KEYS.has(k));
737
+ if (EXTRA_KEYS.length > 0) {
738
+ res.status(400).json({
739
+ error: 'bad_request',
740
+ message: `unknown top-level keys: ${EXTRA_KEYS.join(', ')}`,
741
+ });
742
+ return;
743
+ }
744
+
745
+ if (body.lightrag !== undefined) {
746
+ if (typeof body.lightrag !== 'object' || body.lightrag === null || Array.isArray(body.lightrag)) {
747
+ res.status(400).json({ error: 'bad_request', message: 'lightrag must be an object' });
748
+ return;
749
+ }
750
+ const l = body.lightrag;
751
+ if ('enabled' in l && typeof l.enabled !== 'boolean') {
752
+ res.status(400).json({ error: 'bad_request', message: 'lightrag.enabled must be boolean' });
753
+ return;
754
+ }
755
+ if ('url' in l && typeof l.url !== 'string') {
756
+ res.status(400).json({ error: 'bad_request', message: 'lightrag.url must be a string' });
757
+ return;
758
+ }
759
+ if ('llm' in l && typeof l.llm !== 'string') {
760
+ res.status(400).json({ error: 'bad_request', message: 'lightrag.llm must be a string' });
761
+ return;
762
+ }
763
+ if ('embedding' in l && typeof l.embedding !== 'string') {
764
+ res.status(400).json({ error: 'bad_request', message: 'lightrag.embedding must be a string' });
765
+ return;
766
+ }
767
+ next.lightrag = { ...next.lightrag, ...l };
768
+ }
769
+
770
+ if (body.obsidian !== undefined) {
771
+ if (typeof body.obsidian !== 'object' || body.obsidian === null || Array.isArray(body.obsidian)) {
772
+ res.status(400).json({ error: 'bad_request', message: 'obsidian must be an object' });
773
+ return;
774
+ }
775
+ const o = body.obsidian;
776
+ if ('vaultPath' in o && typeof o.vaultPath !== 'string') {
777
+ res.status(400).json({ error: 'bad_request', message: 'obsidian.vaultPath must be a string' });
778
+ return;
779
+ }
780
+ if ('syncInterval' in o) {
781
+ const n = Number(o.syncInterval);
782
+ if (!Number.isFinite(n) || n < 0 || n > 86400 * 30) {
783
+ res.status(400).json({ error: 'bad_request', message: 'obsidian.syncInterval must be 0..2592000 seconds' });
784
+ return;
785
+ }
786
+ next.obsidian.syncInterval = n;
787
+ }
788
+ if ('vaultPath' in o) next.obsidian.vaultPath = o.vaultPath;
789
+ }
790
+
791
+ if (body.git !== undefined) {
792
+ if (typeof body.git !== 'object' || body.git === null || Array.isArray(body.git)) {
793
+ res.status(400).json({ error: 'bad_request', message: 'git must be an object' });
794
+ return;
795
+ }
796
+ const g = body.git;
797
+ if ('repoPath' in g && typeof g.repoPath !== 'string') {
798
+ res.status(400).json({ error: 'bad_request', message: 'git.repoPath must be a string' });
799
+ return;
800
+ }
801
+ if ('remoteUrl' in g && typeof g.remoteUrl !== 'string') {
802
+ res.status(400).json({ error: 'bad_request', message: 'git.remoteUrl must be a string' });
803
+ return;
804
+ }
805
+ if ('branch' in g) {
806
+ if (typeof g.branch !== 'string' || !/^[A-Za-z0-9._/-]{1,200}$/.test(g.branch)) {
807
+ res.status(400).json({ error: 'bad_request', message: 'git.branch must be a valid git ref name' });
808
+ return;
809
+ }
810
+ next.git.branch = g.branch;
811
+ }
812
+ if ('autoSync' in g && typeof g.autoSync !== 'boolean') {
813
+ res.status(400).json({ error: 'bad_request', message: 'git.autoSync must be boolean' });
814
+ return;
815
+ }
816
+ if ('repoPath' in g) next.git.repoPath = g.repoPath;
817
+ if ('remoteUrl' in g) next.git.remoteUrl = g.remoteUrl;
818
+ if ('autoSync' in g) next.git.autoSync = g.autoSync;
819
+ }
820
+
821
+ try {
822
+ saveGlobalMemoryConfig(next);
823
+ } catch (err) {
824
+ res.status(500).json({ error: 'write_failed', message: err.message });
825
+ return;
826
+ }
827
+ res.json({ ok: true, exists: true, path: GLOBAL_MEMORY_CONFIG_PATH, config: next });
828
+ }));
829
+
830
+ // POST /memory/test-git — test the configured git repo. Returns:
831
+ // { ok: bool, checks: [...], message?: string }
832
+ // Steps performed:
833
+ // 1. Path exists?
834
+ // 2. Is a directory?
835
+ // 3. Is a valid git repo (has .git/)?
836
+ // 4. (Optional) Has a configured remote?
837
+ // 5. (Optional) Can we push (skipped unless ?push=1)?
838
+ router.post('/memory/test-git', wrap(async (req, res) => {
839
+ const { config } = loadGlobalMemoryConfig();
840
+ const repoPath = config?.git?.repoPath || '';
841
+ const remoteUrl = config?.git?.remoteUrl || '';
842
+ const wantPush = req.query.push === '1' || req.body?.push === true;
843
+
844
+ const checks = [];
845
+ if (!repoPath) {
846
+ checks.push({ name: 'repo_path_set', pass: false, detail: 'git.repoPath is empty — set it in Settings first' });
847
+ res.json({ ok: false, checks, message: 'repo path not configured' });
848
+ return;
849
+ }
850
+
851
+ checks.push({
852
+ name: 'repo_path_exists',
853
+ pass: existsSync(repoPath),
854
+ detail: existsSync(repoPath) ? repoPath : `${repoPath} does not exist`,
855
+ });
856
+
857
+ let isGit = false;
858
+ let isDir = false;
859
+ try {
860
+ const s = statSync(repoPath);
861
+ isDir = s.isDirectory();
862
+ checks.push({ name: 'repo_is_directory', pass: isDir, detail: isDir ? 'yes' : 'not a directory' });
863
+ } catch (err) {
864
+ checks.push({ name: 'repo_is_directory', pass: false, detail: err.message });
865
+ }
866
+
867
+ if (isDir) {
868
+ isGit = existsSync(join(repoPath, '.git'));
869
+ checks.push({ name: 'is_git_repo', pass: isGit, detail: isGit ? 'yes' : 'no .git/ directory' });
870
+ } else {
871
+ checks.push({ name: 'is_git_repo', pass: false, detail: 'skipped (not a directory)' });
872
+ }
873
+
874
+ if (remoteUrl) {
875
+ checks.push({ name: 'remote_url_set', pass: true, detail: remoteUrl });
876
+ } else {
877
+ checks.push({ name: 'remote_url_set', pass: false, detail: 'no remote configured' });
878
+ }
879
+
880
+ if (wantPush && isGit && remoteUrl) {
881
+ try {
882
+ const out = execFileSync('git', ['ls-remote', '--heads', remoteUrl], { timeout: 5000 }).toString();
883
+ checks.push({ name: 'remote_reachable', pass: true, detail: `${out.split('\n').filter(Boolean).length} heads` });
884
+ } catch (err) {
885
+ checks.push({ name: 'remote_reachable', pass: false, detail: err.message });
886
+ }
887
+ }
888
+
889
+ const ok = checks.every((c) => c.pass);
890
+ res.json({
891
+ ok,
892
+ checks,
893
+ message: ok ? 'git config looks healthy' : 'one or more checks failed',
894
+ });
895
+ }));
896
+
657
897
  return router;
658
898
  }