@polderlabs/bizar 4.0.0 → 4.2.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 (38) hide show
  1. package/README.md +11 -14
  2. package/bizar-dash/CHANGELOG.md +1 -1
  3. package/bizar-dash/src/server/api.mjs +2 -2
  4. package/bizar-dash/src/server/artifacts-store.mjs +4 -4
  5. package/bizar-dash/src/server/memory-git.mjs +80 -0
  6. package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
  7. package/bizar-dash/src/server/memory-store.mjs +47 -0
  8. package/bizar-dash/src/server/mod-security.mjs +2 -2
  9. package/bizar-dash/src/server/routes/memory.mjs +174 -15
  10. package/bizar-dash/src/server/server.mjs +1 -1
  11. package/bizar-dash/src/server/state.mjs +2 -2
  12. package/bizar-dash/src/web/views/Config.tsx +461 -1
  13. package/bizar-dash/tests/memory-cli.test.mjs +542 -0
  14. package/bizar-dash/tests/memory-config.test.mjs +422 -0
  15. package/bizar-dash/tests/memory-git.test.mjs +109 -1
  16. package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
  17. package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
  18. package/cli/banner.mjs +1 -1
  19. package/cli/bin.mjs +4 -4
  20. package/cli/bootstrap.mjs +1 -1
  21. package/cli/copy.mjs +22 -16
  22. package/cli/doctor.mjs +4 -4
  23. package/cli/doctor.test.mjs +2 -2
  24. package/cli/init.mjs +2 -2
  25. package/cli/install.mjs +21 -16
  26. package/cli/memory.mjs +710 -31
  27. package/cli/utils.mjs +6 -3
  28. package/config/AGENTS.md +7 -7
  29. package/config/agents/_shared/AGENT_BASELINE.md +59 -61
  30. package/config/opencode.json +13 -38
  31. package/config/skills/memory-protocol/SKILL.md +105 -0
  32. package/config/skills/obsidian/SKILL.md +58 -1
  33. package/install.sh +11 -1
  34. package/package.json +2 -2
  35. package/plugins/bizar/index.ts +7 -0
  36. package/plugins/bizar/src/commands.ts +42 -1
  37. package/plugins/bizar/src/tools/open-kb.ts +191 -0
  38. package/plugins/bizar/tests/commands.test.ts +36 -0
package/cli/memory.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  * cli/memory.mjs
3
3
  *
4
4
  * `bizar memory` subcommands. Delegates to memory-store.mjs and memory-git.mjs.
5
- * Supports: init, status, link, unlink, pull, commit, push, sync, reindex,
6
- * conflicts, doctor.
5
+ * Supports: init, status, link, unlink, write, pull, commit, push, sync,
6
+ * reindex, conflicts, doctor.
7
7
  */
8
8
 
9
9
  import chalk from 'chalk';
@@ -18,6 +18,7 @@ const memoryStore = await import(`${SERVER_ROOT}/memory-store.mjs`).then((m) =>
18
18
  const memorySchema = await import(`${SERVER_ROOT}/memory-schema.mjs`).then((m) => m);
19
19
  const memorySecrets = await import(`${SERVER_ROOT}/memory-secrets.mjs`).then((m) => m);
20
20
  const memoryGit = await import(`${SERVER_ROOT}/memory-git.mjs`).then((m) => m);
21
+ const memoryLightrag = await import(`${SERVER_ROOT}/memory-lightrag.mjs`).then((m) => m);
21
22
 
22
23
  /**
23
24
  * Get the project root. Assumes CWD is the project root.
@@ -26,6 +27,33 @@ function getProjectRoot() {
26
27
  return process.cwd();
27
28
  }
28
29
 
30
+ /**
31
+ * Validate a git remote URL. Accepts HTTPS, SSH (scp-style), and SSH URL form.
32
+ * Rejects file://, plain paths, and other schemes.
33
+ *
34
+ * @param {string} url
35
+ * @returns {{ valid: true, kind: 'ssh'|'https'|'ssh-url' } | { valid: false, error: string }}
36
+ */
37
+ export function validateRemoteUrl(url) {
38
+ if (typeof url !== 'string' || url.length === 0) {
39
+ return { valid: false, error: 'URL must be a non-empty string' };
40
+ }
41
+ const trimmed = url.trim();
42
+ // SSH scp-style: git@host:path
43
+ if (/^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:.+$/.test(trimmed)) {
44
+ return { valid: true, kind: 'ssh' };
45
+ }
46
+ // SSH URL form: ssh://[user@]host[:port]/path
47
+ if (/^ssh:\/\/(?:[a-zA-Z0-9._-]+@)?[a-zA-Z0-9._-]+(?::\d+)?\/.+$/.test(trimmed)) {
48
+ return { valid: true, kind: 'ssh-url' };
49
+ }
50
+ // HTTPS: https://host/path
51
+ if (/^https:\/\/[a-zA-Z0-9._-]+(?::\d+)?\/.+$/.test(trimmed)) {
52
+ return { valid: true, kind: 'https' };
53
+ }
54
+ return { valid: false, error: 'URL must be ssh://, https://, or git@host:path form' };
55
+ }
56
+
29
57
  // ─── Helper formatters ────────────────────────────────────────────────────────
30
58
 
31
59
  function success(msg) { console.log(chalk.green('✓'), msg); }
@@ -44,10 +72,178 @@ function kv(key, value) {
44
72
 
45
73
  // ─── Subcommand implementations ───────────────────────────────────────────────
46
74
 
75
+ /**
76
+ * `bizar memory write <relpath> [--type …] [--status …] [--confidence …]`
77
+ * `[--tag <tag>]… [--body <body>|--body-file <file>]`
78
+ * `[--title <title>] [--json]`
79
+ *
80
+ * Write a single note to the vault. Validates the path, type, status, and
81
+ * confidence against the schema, builds frontmatter via `defaultFrontmatter`,
82
+ * then delegates to `memoryStore.writeNote`. Designed for agents (and humans)
83
+ * that want to write memory notes without going through the dashboard UI.
84
+ */
85
+ async function cmdWrite(args) {
86
+ const projectRoot = getProjectRoot();
87
+ const { writeNote, loadConfig } = memoryStore;
88
+ const { defaultFrontmatter, VALID_TYPES, VALID_STATUSES, VALID_CONFIDENCES } = memorySchema;
89
+
90
+ if (args.includes('--help') || args.includes('-h')) {
91
+ console.log(`
92
+ Usage: bizar memory write <relpath> [options]
93
+
94
+ Write a single note to the vault. <relpath> must end in .md and is
95
+ resolved relative to the project namespace root (e.g.
96
+ decisions/0001-foo.md), NOT prefixed with the namespace.
97
+
98
+ Options:
99
+ --type <type> One of: ${VALID_TYPES.join(', ')}
100
+ (default: project_overview)
101
+ --status <status> One of: ${VALID_STATUSES.join(', ')}
102
+ (default: active)
103
+ --confidence <conf> One of: ${VALID_CONFIDENCES.join(', ')}
104
+ (default: verified)
105
+ --tag <tag> Tag to attach (may be passed multiple times)
106
+ --title <title> Frontmatter title field
107
+ --body <text> Note body as a string
108
+ --body-file <path> Path to a file containing the body
109
+ (exactly one of --body / --body-file is allowed)
110
+ --json Emit the full returned note as JSON
111
+ --help, -h Show this help
112
+
113
+ Exactly one of --body or --body-file is required, unless you intend
114
+ to write an empty body (in which case omit both).
115
+ `.trim());
116
+ return;
117
+ }
118
+
119
+ // Parse positional relpath (first non-flag arg)
120
+ const positional = args.filter((a) => !a.startsWith('-'));
121
+ const relpath = positional[0];
122
+ if (!relpath) {
123
+ error('relpath is required: `bizar memory write <relpath>`');
124
+ info('run `bizar memory write --help` for usage');
125
+ process.exit(1);
126
+ }
127
+ if (!relpath.endsWith('.md')) {
128
+ error(`relpath must end in .md: ${relpath}`);
129
+ process.exit(1);
130
+ }
131
+
132
+ // ── Parse flags ──────────────────────────────────────────────────────────
133
+ const optVal = (name) => {
134
+ const i = args.indexOf(name);
135
+ if (i === -1) return undefined;
136
+ const v = args[i + 1];
137
+ if (!v || v.startsWith('-')) return undefined;
138
+ return v;
139
+ };
140
+
141
+ const type = optVal('--type') ?? 'project_overview';
142
+ if (!VALID_TYPES.includes(type)) {
143
+ error(`invalid --type: '${type}'`);
144
+ info(`must be one of: ${VALID_TYPES.join(', ')}`);
145
+ process.exit(1);
146
+ }
147
+
148
+ const status = optVal('--status') ?? 'active';
149
+ if (!VALID_STATUSES.includes(status)) {
150
+ error(`invalid --status: '${status}'`);
151
+ info(`must be one of: ${VALID_STATUSES.join(', ')}`);
152
+ process.exit(1);
153
+ }
154
+
155
+ const confidence = optVal('--confidence') ?? 'verified';
156
+ if (!VALID_CONFIDENCES.includes(confidence)) {
157
+ error(`invalid --confidence: '${confidence}'`);
158
+ info(`must be one of: ${VALID_CONFIDENCES.join(', ')}`);
159
+ process.exit(1);
160
+ }
161
+
162
+ const title = optVal('--title');
163
+
164
+ // Multi-value --tag
165
+ const tags = [];
166
+ for (let i = 0; i < args.length; i++) {
167
+ if (args[i] === '--tag' && args[i + 1] && !args[i + 1].startsWith('-')) {
168
+ tags.push(args[i + 1]);
169
+ i++;
170
+ }
171
+ }
172
+
173
+ const inlineBody = optVal('--body');
174
+ const bodyFile = optVal('--body-file');
175
+ if (inlineBody !== undefined && bodyFile !== undefined) {
176
+ error('use either --body or --body-file, not both');
177
+ process.exit(1);
178
+ }
179
+
180
+ let body = '';
181
+ if (bodyFile) {
182
+ try {
183
+ body = readFileSync(bodyFile, 'utf8');
184
+ } catch (err) {
185
+ error(`failed to read --body-file ${bodyFile}: ${err.message}`);
186
+ process.exit(1);
187
+ }
188
+ } else if (inlineBody !== undefined) {
189
+ body = inlineBody;
190
+ }
191
+
192
+ // ── Build frontmatter ───────────────────────────────────────────────────
193
+ const { config } = loadConfig(projectRoot);
194
+ const projectId = config.projectId || projectRoot.split('/').pop() || 'unknown';
195
+
196
+ const frontmatter = defaultFrontmatter({
197
+ type,
198
+ project_id: projectId,
199
+ status,
200
+ confidence,
201
+ tags,
202
+ });
203
+ if (title) frontmatter.title = title;
204
+
205
+ // ── Write ───────────────────────────────────────────────────────────────
206
+ let result;
207
+ try {
208
+ result = writeNote(projectRoot, relpath, { frontmatter, body });
209
+ } catch (err) {
210
+ if (err.code === 'SCHEMA_VALIDATION_FAILED') {
211
+ error(`schema validation failed: ${err.message.replace(/^schema validation failed: /, '')}`);
212
+ } else if (err.code === 'SECRET_DETECTED') {
213
+ error(err.message);
214
+ } else {
215
+ error(err.message);
216
+ }
217
+ process.exit(1);
218
+ }
219
+
220
+ // ── Output ──────────────────────────────────────────────────────────────
221
+ if (args.includes('--json')) {
222
+ console.log(JSON.stringify(result, null, 2));
223
+ return;
224
+ }
225
+
226
+ success(`wrote ${result.relPath}`);
227
+ console.log();
228
+ console.log(chalk.bold(' Note'));
229
+ console.log(' ─────────────────────────────');
230
+ kv('relpath', result.relPath);
231
+ kv('type', String(result.frontmatter.type));
232
+ kv('status', String(result.frontmatter.status));
233
+ kv('confidence', String(result.frontmatter.confidence));
234
+ kv('memory_id', String(result.frontmatter.memory_id));
235
+ if (Array.isArray(result.frontmatter.tags) && result.frontmatter.tags.length > 0) {
236
+ kv('tags', result.frontmatter.tags.join(', '));
237
+ }
238
+ if (title) kv('title', title);
239
+ kv('size', `${result.size} bytes`);
240
+ console.log();
241
+ }
242
+
47
243
  /**
48
244
  * `bizar memory init`
49
- * Creates .bizar/memory.json with mode=local-only.
50
- * If mode=managed is desired, user passes --managed or --repo <name>.
245
+ * Creates .bizar/memory.json with mode=managed (default).
246
+ * Pass --memory-mode local-only to use local vault storage instead.
51
247
  *
52
248
  * Flags (added for install.sh bootstrap):
53
249
  * --yes Accept all defaults (skip prompts, skip if already initialized)
@@ -73,7 +269,7 @@ async function cmdInit(args) {
73
269
  }
74
270
 
75
271
  // --memory-mode takes precedence over --managed
76
- let mode = 'local-only';
272
+ let mode = 'managed';
77
273
  const modeIdx = args.indexOf('--memory-mode');
78
274
  if (modeIdx !== -1 && args[modeIdx + 1]) {
79
275
  mode = args[modeIdx + 1];
@@ -157,6 +353,356 @@ async function cmdInit(args) {
157
353
  }
158
354
  }
159
355
 
356
+ /**
357
+ * `bizar memory setup` — configure or reconfigure the memory vault.
358
+ *
359
+ * v4.2.0 — first-class bootstrap entry point. Two flows:
360
+ * - No existing config → behaves like `init` + adds the remote (managed mode).
361
+ * - Existing config → optionally updates the remote URL on a managed vault.
362
+ *
363
+ * Flags:
364
+ * --mode <managed|local-only> Vault mode (default: managed)
365
+ * --remote <url> Git remote URL (required for managed)
366
+ * --repo-name <name> Vault directory name (default: bizar-memory)
367
+ * --non-interactive No prompts; use flags or defaults
368
+ * --yes Same as --non-interactive (backward compat)
369
+ *
370
+ * The remote URL is written to BOTH `memoryRepo.remote` (canonical,
371
+ * read by resolveVault) and the top-level `gitRemote` (read by cmdPush).
372
+ * Both must agree or `bizar memory push` will fail silently.
373
+ *
374
+ * Connectivity is checked via `memoryGit.lsRemote` after `addRemote`. A
375
+ * failure here is informational only — credentials may not be configured
376
+ * yet — and does NOT abort the setup.
377
+ *
378
+ * @param {string[]} argv
379
+ */
380
+ export async function cmdSetup(argv) {
381
+ const projectRoot = getProjectRoot();
382
+ const { loadConfig, saveConfig, initVault, resolveVault } = memoryStore;
383
+
384
+ // ── Parse flags ──────────────────────────────────────────────────────────
385
+ const optVal = (name) => {
386
+ const i = argv.indexOf(name);
387
+ if (i === -1) return undefined;
388
+ const v = argv[i + 1];
389
+ if (!v || v.startsWith('-')) return undefined;
390
+ return v;
391
+ };
392
+ const hasFlag = (name) => argv.includes(name);
393
+
394
+ const nonInteractive = hasFlag('--non-interactive') || hasFlag('--yes');
395
+
396
+ // --mode (default managed). Accept --memory-mode alias too.
397
+ let mode = 'managed';
398
+ const modeRaw = optVal('--mode') ?? optVal('--memory-mode');
399
+ if (modeRaw) mode = modeRaw;
400
+ else if (hasFlag('--local-only')) mode = 'local-only';
401
+ else if (hasFlag('--managed')) mode = 'managed';
402
+ if (mode !== 'managed' && mode !== 'local-only') {
403
+ error(`invalid --mode: '${mode}' (must be 'managed' or 'local-only')`);
404
+ process.exit(1);
405
+ }
406
+
407
+ // --remote
408
+ let remote = optVal('--remote');
409
+ if (remote !== undefined) {
410
+ const v = validateRemoteUrl(remote);
411
+ if (!v.valid) {
412
+ error(`invalid --remote URL: ${v.error}`);
413
+ info('examples:');
414
+ info(' --remote git@github.com:user/repo.git');
415
+ info(' --remote ssh://git@github.com/user/repo.git');
416
+ info(' --remote https://github.com/user/repo.git');
417
+ process.exit(1);
418
+ }
419
+ // Store the trimmed canonical form
420
+ remote = remote.trim();
421
+ }
422
+
423
+ // --repo-name (or --memory-repo-name, --repo)
424
+ const repoName = optVal('--repo-name') ?? optVal('--memory-repo-name') ?? optVal('--repo') ?? 'bizar-memory';
425
+
426
+ // ── Help ────────────────────────────────────────────────────────────────
427
+ if (hasFlag('--help') || hasFlag('-h')) {
428
+ console.log(`
429
+ Usage: bizar memory setup [--remote <url>] [--mode <mode>] [--non-interactive]
430
+
431
+ Configure or reconfigure this project's memory vault.
432
+
433
+ Options:
434
+ --mode <managed|local-only> Vault mode (default: managed)
435
+ --remote <url> Git remote URL (required for managed)
436
+ Accepts: ssh://, https://, git@host:path
437
+ --repo-name <name> Vault directory name (default: bizar-memory)
438
+ --local-only Shortcut for --mode local-only
439
+ --non-interactive Skip prompts; use flags or defaults
440
+ --yes Alias for --non-interactive
441
+ --help, -h Show this help
442
+
443
+ Examples:
444
+ # Bootstrap: first-time init with a remote
445
+ bizar memory setup --non-interactive \\
446
+ --remote git@github.com:org/bizar-memory.git
447
+
448
+ # Reconfigure the remote on an existing managed vault
449
+ bizar memory setup --non-interactive \\
450
+ --remote https://github.com/org/bizar-memory.git
451
+
452
+ # Convert to local-only (removes remote but keeps notes)
453
+ bizar memory setup --non-interactive --local-only
454
+ `.trim());
455
+ return;
456
+ }
457
+
458
+ // ── Read existing config ────────────────────────────────────────────────
459
+ const { config: existingCfg, exists: configExists } = loadConfig(projectRoot);
460
+
461
+ // Decide whether this is a bootstrap (no config) or reconfigure (config
462
+ // already exists). The branch shape drives the rest of the function.
463
+ const isBootstrap = !configExists;
464
+
465
+ // Reconfigure path: if no --remote was passed AND --mode wasn't changed,
466
+ // just print status and exit. Otherwise apply the change.
467
+ if (!isBootstrap) {
468
+ const existingMode = existingCfg.memoryRepo?.mode || existingCfg.mode || 'local-only';
469
+ const existingRemote = existingCfg.memoryRepo?.remote || existingCfg.gitRemote || '';
470
+ const modeChanged = mode !== existingMode;
471
+ const remoteChanged = remote !== undefined && remote !== existingRemote;
472
+
473
+ if (!modeChanged && !remoteChanged) {
474
+ // Nothing to do — print status and exit
475
+ console.log(chalk.bold('\n Memory setup (no changes)'));
476
+ console.log(' ─────────────────────────────');
477
+ success('already configured');
478
+ kv('mode', existingMode);
479
+ kv('remote', existingRemote || '(none)');
480
+ kv('vault', existingCfg.memoryRepo?.path || existingCfg.repoName || '(unknown)');
481
+ console.log();
482
+ info(`run \`bizar memory setup --remote <url>\` to change the remote`);
483
+ return;
484
+ }
485
+
486
+ // ── Reconfigure path — apply in place ───────────────────────────────
487
+ const updatedCfg = { ...existingCfg };
488
+
489
+ if (modeChanged && remote === undefined) {
490
+ // Switching modes without a new remote is fine, but warn if going
491
+ // managed without a URL to push to.
492
+ if (mode === 'managed' && !existingRemote) {
493
+ warn('switching to managed mode with no remote configured — push will fail');
494
+ info('pass --remote <url> to set one');
495
+ }
496
+ }
497
+
498
+ if (remote !== undefined) {
499
+ // Dual-write: top-level + memoryRepo.*
500
+ updatedCfg.gitRemote = remote;
501
+ updatedCfg.memoryRepo = {
502
+ ...(existingCfg.memoryRepo || {}),
503
+ mode: mode === 'managed' ? 'managed' : (updatedCfg.memoryRepo?.mode || 'local-only'),
504
+ remote,
505
+ };
506
+ }
507
+ if (modeChanged) {
508
+ updatedCfg.mode = mode;
509
+ if (updatedCfg.memoryRepo) {
510
+ updatedCfg.memoryRepo.mode = mode;
511
+ }
512
+ }
513
+
514
+ const saveResult = saveConfig(projectRoot, updatedCfg);
515
+ if (!saveResult.ok) {
516
+ error(`failed to save config: ${saveResult.error}`);
517
+ process.exit(1);
518
+ }
519
+
520
+ // If we now have a remote on a managed vault, register it.
521
+ if (mode === 'managed' && remote) {
522
+ addRemoteToVault(projectRoot, remote);
523
+ }
524
+
525
+ printSetupResult({ mode, remote, vaultPath: updatedCfg.memoryRepo?.path });
526
+ return;
527
+ }
528
+
529
+ // ── Bootstrap path (no existing config) ─────────────────────────────────
530
+
531
+ // For managed bootstrap, we need a remote. If missing, prompt or error.
532
+ if (mode === 'managed' && !remote) {
533
+ if (nonInteractive) {
534
+ error('--remote is required for --mode managed in non-interactive setup');
535
+ info('pass --remote <url> (or run without --non-interactive to be prompted)');
536
+ process.exit(1);
537
+ }
538
+ // Prompt via readline
539
+ const readline = await import('node:readline');
540
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
541
+ const answer = await new Promise((resolve) => {
542
+ rl.question(chalk.cyan(' remote URL (ssh://, https://, or git@host:path): '), resolve);
543
+ });
544
+ rl.close();
545
+ const trimmed = (answer || '').trim();
546
+ if (!trimmed) {
547
+ error('a remote URL is required for managed mode');
548
+ process.exit(1);
549
+ }
550
+ const v = validateRemoteUrl(trimmed);
551
+ if (!v.valid) {
552
+ error(`invalid remote URL: ${v.error}`);
553
+ process.exit(1);
554
+ }
555
+ remote = trimmed;
556
+ }
557
+
558
+ // Build a config object mirroring cmdInit's shape, with the remote set.
559
+ const home = homedir();
560
+ const projectId = projectRoot.split('/').pop() || 'project';
561
+ const vaultPath = mode === 'managed'
562
+ ? join(home, '.local', 'share', 'bizar', 'memory', repoName)
563
+ : join(projectRoot, '.obsidian');
564
+
565
+ const cfg = {
566
+ version: 1,
567
+ backend: 'bizar-local',
568
+ projectId,
569
+ memoryRepo: {
570
+ mode,
571
+ path: vaultPath,
572
+ remote: mode === 'managed' ? (remote || null) : null,
573
+ branch: 'main',
574
+ namespace: mode === 'managed' ? `projects/${projectId}` : null,
575
+ },
576
+ namespaces: {
577
+ project: `projects/${projectId}`,
578
+ global: 'global/bizar',
579
+ user: `users/${process.env.USER || process.env.USERNAME || 'local'}`,
580
+ },
581
+ lightrag: {
582
+ enabled: false,
583
+ host: '127.0.0.1',
584
+ port: 9621,
585
+ workingDir: join(projectRoot, '.bizar', 'lightrag'),
586
+ },
587
+ git: {
588
+ autoPullOnSessionStart: false,
589
+ autoCommitOnMemoryWrite: false,
590
+ autoPushOnSessionEnd: false,
591
+ commitAuthor: 'Bizar Memory <bizar-memory@local>',
592
+ commitMessageTemplate: `memory(${projectId}): {summary}`,
593
+ },
594
+ };
595
+
596
+ // Top-level gitRemote mirrors memoryRepo.remote so cmdPush works.
597
+ if (mode === 'managed' && remote) {
598
+ cfg.gitRemote = remote;
599
+ }
600
+
601
+ const saveResult = saveConfig(projectRoot, cfg);
602
+ if (!saveResult.ok) {
603
+ error(`failed to write config: ${saveResult.error}`);
604
+ process.exit(1);
605
+ }
606
+
607
+ // Initialize the vault on disk.
608
+ const vaultResult = initVault(projectRoot);
609
+ if (!vaultResult.ok) {
610
+ error(`vault init failed: ${vaultResult.error || 'unknown'}`);
611
+ process.exit(1);
612
+ }
613
+ if (vaultResult.created.length > 0) {
614
+ for (const c of vaultResult.created) info(` created: ${c}`);
615
+ }
616
+
617
+ // For managed bootstrap with a remote, register it on the new vault.
618
+ if (mode === 'managed' && remote) {
619
+ addRemoteToVault(projectRoot, remote);
620
+ }
621
+
622
+ printSetupResult({ mode, remote, vaultPath });
623
+ }
624
+
625
+ /**
626
+ * Register `remote` as `origin` on the vault's git repo, then probe
627
+ * connectivity via `lsRemote`. Both operations are best-effort: we
628
+ * warn on failure but never abort the setup.
629
+ *
630
+ * @param {string} projectRoot
631
+ * @param {string} remote
632
+ */
633
+ function addRemoteToVault(projectRoot, remote) {
634
+ const { resolveVault } = memoryStore;
635
+ const { vaultRoot } = resolveVault(projectRoot);
636
+
637
+ // Sanity check: is the vault a git repo? initVault should have ensured
638
+ // this, but double-check before calling git remote.
639
+ const gitDir = join(vaultRoot, '.git');
640
+ if (!existsSync(gitDir)) {
641
+ warn('vault is not a git repo — skipping remote registration');
642
+ info('run `bizar memory init` or `bizar memory sync` to repair');
643
+ return;
644
+ }
645
+
646
+ if (typeof memoryGit.addRemote !== 'function') {
647
+ warn('memoryGit.addRemote is not available in this build');
648
+ info('(waiting for the addRemote/lsRemote stream to merge) — remote URL saved to config');
649
+ return;
650
+ }
651
+
652
+ const addResult = memoryGit.addRemote(vaultRoot, 'origin', remote);
653
+ if (!addResult || addResult.ok === false) {
654
+ warn(`failed to register remote: ${addResult?.error || 'unknown error'}`);
655
+ info('the remote URL is still saved in .bizar/memory.json');
656
+ return;
657
+ }
658
+ if (addResult.action === 'unchanged') {
659
+ info(`remote 'origin' already set to ${remote}`);
660
+ } else if (addResult.action === 'updated') {
661
+ success(`updated remote 'origin' → ${remote}`);
662
+ } else {
663
+ success(`registered remote 'origin' → ${remote}`);
664
+ }
665
+
666
+ if (typeof memoryGit.lsRemote !== 'function') {
667
+ info('skipping connectivity probe (lsRemote not available yet)');
668
+ return;
669
+ }
670
+
671
+ const probe = memoryGit.lsRemote(vaultRoot, 'origin', { timeoutMs: 5000 });
672
+ if (typeof probe === 'string' && probe.length > 0) {
673
+ const refCount = probe.split('\n').filter(Boolean).length;
674
+ success(`connectivity ok (${refCount} refs reachable)`);
675
+ } else {
676
+ warn('could not reach remote (auth, network, or unknown host)');
677
+ info('the vault is set up; configure credentials and run `bizar memory pull`');
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Render the standard "setup complete" report using the existing `kv`
683
+ * style. The connectivity line is omitted if not relevant.
684
+ *
685
+ * @param {{ mode: string, remote: string|undefined|null, vaultPath: string }} opts
686
+ */
687
+ function printSetupResult({ mode, remote, vaultPath }) {
688
+ console.log();
689
+ success('memory setup complete');
690
+ console.log(' ─────────────────────────────');
691
+ kv('mode', mode);
692
+ kv('vault', String(vaultPath || ''));
693
+ if (remote) kv('remote', remote);
694
+ console.log();
695
+ if (!remote) {
696
+ info('local-only mode — notes are stored in this project only');
697
+ info('run `bizar memory setup --remote <url>` to attach a remote later');
698
+ } else {
699
+ info('next steps:');
700
+ info(' 1. `bizar memory pull` to fetch the existing vault (if any)');
701
+ info(' 2. `bizar memory write <relpath>` to start adding notes');
702
+ info(' 3. `bizar memory push` to publish');
703
+ }
704
+ }
705
+
160
706
  /**
161
707
  * `bizar memory status`
162
708
  */
@@ -204,15 +750,28 @@ async function cmdStatus(_args) {
204
750
  }
205
751
  }
206
752
 
207
- // LightRAG stub
208
- kv('lightrag', chalk.gray('stub (Phase 2)'));
753
+ // LightRAG status
754
+ try {
755
+ const { isLightRAGRunning, isLightRAGInstalled, resolveLightRAGConfig } = memoryStore;
756
+ const cfg = resolveLightRAGConfig(projectRoot);
757
+ if (!cfg.enabled) {
758
+ kv('lightrag', chalk.gray('disabled'));
759
+ } else if (!(await isLightRAGInstalled())) {
760
+ kv('lightrag', chalk.yellow('not installed — `uv tool install "lightrag-hku[api]"`'));
761
+ } else {
762
+ const running = await isLightRAGRunning(cfg);
763
+ kv('lightrag', running ? chalk.green(`${cfg.host}:${cfg.port} (running)`) : chalk.yellow(`${cfg.host}:${cfg.port} (not running — run \`bizar memory reindex\`)`));
764
+ }
765
+ } catch (err) {
766
+ kv('lightrag', chalk.gray(`status unavailable (${err.message})`));
767
+ }
209
768
 
210
769
  // Last reindex attempt
211
- const reindexMarker = join(projectRoot, '.bizar', 'memory-cache', 'last-reindex-attempt.json');
770
+ const reindexMarker = join(projectRoot, '.bizar', 'memory-cache', 'last-reindex.json');
212
771
  if (existsSync(reindexMarker)) {
213
772
  try {
214
- const { attemptedAt, ok } = JSON.parse(readFileSync(reindexMarker, 'utf8'));
215
- kv('lastReindex', `${new Date(attemptedAt).toLocaleString()} — ${ok ? chalk.green('ok') : chalk.red('failed')}`);
773
+ const { finishedAt, ok, inserted, failed, noteCount } = JSON.parse(readFileSync(reindexMarker, 'utf8'));
774
+ kv('lastReindex', `${new Date(finishedAt).toLocaleString()} — ${ok ? chalk.green(`${inserted}/${noteCount} ok`) : chalk.red(`${failed} failed`)}`);
216
775
  } catch { /* ignore */ }
217
776
  }
218
777
 
@@ -543,24 +1102,115 @@ async function cmdSync(args) {
543
1102
 
544
1103
  /**
545
1104
  * `bizar memory reindex`
546
- * STUBPhase 2 will implement LightRAG integration.
1105
+ * v4.1.0populates the LightRAG server with every note in the vault.
1106
+ *
1107
+ * Steps:
1108
+ * 1. Resolve LightRAG config from `.bizar/memory.json`.
1109
+ * 2. Ensure `lightrag-server` is installed (searches common paths).
1110
+ * 3. Ensure server is running (auto-starts if not).
1111
+ * 4. List all notes from the vault.
1112
+ * 5. Insert each note via `POST /documents` with stable id
1113
+ * `bizar://<projectId>/<relPath>`.
1114
+ * 6. Write marker file with stats.
1115
+ *
1116
+ * The reindex is idempotent — re-running re-inserts the same notes under
1117
+ * the same ids; LightRAG's id-keyed storage handles this gracefully.
547
1118
  */
548
- async function cmdReindex(_args) {
1119
+ async function cmdReindex(args) {
549
1120
  const projectRoot = getProjectRoot();
550
- const { atomicWriteJson } = await import('./atomic.mjs').then((m) => m);
1121
+ const _atomic = await import('./atomic.mjs').then((m) => m);
551
1122
  const cacheDir = join(projectRoot, '.bizar', 'memory-cache');
552
- if (!existsSync(cacheDir)) {
553
- mkdirSync(cacheDir, { recursive: true });
554
- }
555
- const marker = join(cacheDir, 'last-reindex-attempt.json');
556
- const result = {
557
- attemptedAt: new Date().toISOString(),
558
- ok: false,
559
- reason: 'lightrag-not-implemented',
560
- };
561
- atomicWriteJson(marker, result);
562
- console.log(chalk.yellow('reindex: LightRAG runtime not yet implemented — Phase 2'));
563
- console.log(` marker written to ${marker}`);
1123
+ mkdirSync(cacheDir, { recursive: true });
1124
+
1125
+ const { isLightRAGInstalled, reindexVault, resolveLightRAGConfig } = memoryStore;
1126
+
1127
+ // Preflight: is lightrag-server installed?
1128
+ if (!(await isLightRAGInstalled())) {
1129
+ error('lightrag-server not installed');
1130
+ info('install it with: uv tool install "lightrag-hku[api]"');
1131
+ info('then run `bizar memory reindex` again');
1132
+ process.exit(1);
1133
+ }
1134
+
1135
+ // Preflight: is lightrag enabled in config?
1136
+ const config = resolveLightRAGConfig(projectRoot);
1137
+ if (!config.enabled) {
1138
+ warn('lightrag is disabled in .bizar/memory.json (lightrag.enabled = false)');
1139
+ info('set lightrag.enabled = true and run `bizar memory reindex` again');
1140
+ return;
1141
+ }
1142
+
1143
+ info(`reindexing vault into LightRAG at http://${config.host}:${config.port}…`);
1144
+ const result = await reindexVault(projectRoot, { logger: console });
1145
+
1146
+ if (!result.ok && result.inserted === 0) {
1147
+ error(`reindex failed: ${result.error || 'unknown'}`);
1148
+ if (result.failures?.length > 0) {
1149
+ info(`first failure: ${result.failures[0].relPath} — ${result.failures[0].error}`);
1150
+ }
1151
+ process.exit(1);
1152
+ }
1153
+
1154
+ if (result.started) {
1155
+ success(`started LightRAG server (pid ${result.pid})`);
1156
+ }
1157
+ success(`reindexed ${result.inserted}/${result.noteCount} notes in ${result.durationMs}ms`);
1158
+ if (result.failed > 0) {
1159
+ warn(`${result.failed} notes failed — see marker for details`);
1160
+ }
1161
+ info(`marker: ${result.markerPath}`);
1162
+ }
1163
+
1164
+ /**
1165
+ * `bizar memory search <query>`
1166
+ * v4.1.0 — merged lexical + semantic search.
1167
+ *
1168
+ * - Lexical: token-frequency matching against vault notes (instant, free).
1169
+ * - Semantic: LightRAG `/query` with `mode: 'mix'` (combines local entity
1170
+ * graph + global relation graph). Skipped if LightRAG isn't running.
1171
+ */
1172
+ async function cmdSearch(args) {
1173
+ const query = args.join(' ').trim();
1174
+ if (!query) {
1175
+ error('search requires a query: `bizar memory search <query>`');
1176
+ process.exit(1);
1177
+ }
1178
+ const projectRoot = getProjectRoot();
1179
+ const { searchVault } = memoryStore;
1180
+
1181
+ // Lexical
1182
+ const lexical = searchVault(projectRoot, query, { limit: 10 });
1183
+ console.log(chalk.bold('\n Lexical results (token-frequency):'));
1184
+ if (lexical.length === 0) {
1185
+ console.log(chalk.dim(' (no matches)'));
1186
+ } else {
1187
+ for (const r of lexical.slice(0, 5)) {
1188
+ console.log(` ${chalk.cyan(r.relPath)} ${chalk.dim(`(score ${r.score})`)}`);
1189
+ console.log(chalk.dim(` ${r.snippet}`));
1190
+ }
1191
+ }
1192
+
1193
+ // Semantic (best-effort)
1194
+ console.log(chalk.bold('\n Semantic answer (LightRAG):'));
1195
+ try {
1196
+ const cfg = memoryStore.resolveLightRAGConfig(projectRoot);
1197
+ const semantic = await memoryLightrag.query(cfg, query, { topK: 10 });
1198
+ if (!semantic.ok) {
1199
+ console.log(chalk.dim(` ${semantic.error || 'unavailable'}`));
1200
+ if (semantic.error?.includes('not running')) {
1201
+ console.log(chalk.dim(' tip: run `bizar memory reindex` to start + populate LightRAG'));
1202
+ }
1203
+ } else {
1204
+ const response = semantic.response?.response || semantic.response?.answer || '';
1205
+ if (response) {
1206
+ console.log(` ${response.split('\n').join('\n ')}`);
1207
+ } else {
1208
+ console.log(chalk.dim(' (no answer returned)'));
1209
+ }
1210
+ }
1211
+ } catch (err) {
1212
+ console.log(chalk.dim(` ${err.message}`));
1213
+ }
564
1214
  }
565
1215
 
566
1216
  /**
@@ -655,17 +1305,35 @@ async function cmdDoctor(_args) {
655
1305
  allPassed = false;
656
1306
  }
657
1307
 
658
- // Check 7: last secret scan
659
- const reindexMarker = join(projectRoot, '.bizar', 'memory-cache', 'last-reindex-attempt.json');
1308
+ // Check 7: last reindex
1309
+ const reindexMarker = join(projectRoot, '.bizar', 'memory-cache', 'last-reindex.json');
660
1310
  if (existsSync(reindexMarker)) {
661
1311
  try {
662
- const { attemptedAt } = JSON.parse(readFileSync(reindexMarker, 'utf8'));
663
- const ageMs = Date.now() - new Date(attemptedAt).getTime();
1312
+ const { finishedAt, ok, inserted, failed, noteCount } = JSON.parse(readFileSync(reindexMarker, 'utf8'));
1313
+ const ageMs = Date.now() - new Date(finishedAt).getTime();
664
1314
  const ageH = ageMs / 3600000;
665
- kv('lastReindex', ageH < 24 ? chalk.green(`${Math.round(ageH)}h ago`) : chalk.yellow(`${Math.round(ageH)}h ago`));
1315
+ const summary = `${inserted}/${noteCount} inserted${failed > 0 ? `, ${failed} failed` : ''}`;
1316
+ const status = ok ? chalk.green(summary) : chalk.red(summary);
1317
+ kv('lastReindex', ageH < 24 ? `${status} ${chalk.dim(`(${Math.round(ageH)}h ago)`)}` : `${status} ${chalk.yellow(`(${Math.round(ageH)}h ago)`)}`);
666
1318
  } catch { /* ignore */ }
667
1319
  } else {
668
- kv('lastReindex', chalk.yellow('never'));
1320
+ kv('lastReindex', chalk.yellow('never — run `bizar memory reindex`'));
1321
+ }
1322
+
1323
+ // Check 8: LightRAG status
1324
+ try {
1325
+ const { isLightRAGInstalled, isLightRAGRunning, resolveLightRAGConfig } = memoryStore;
1326
+ const cfg = resolveLightRAGConfig(projectRoot);
1327
+ if (!cfg.enabled) {
1328
+ kv('lightrag', chalk.gray('disabled'));
1329
+ } else if (!(await isLightRAGInstalled())) {
1330
+ kv('lightrag', chalk.yellow('not installed — `uv tool install "lightrag-hku[api]"`'));
1331
+ } else {
1332
+ const running = await isLightRAGRunning(cfg);
1333
+ kv('lightrag', running ? chalk.green(`${cfg.host}:${cfg.port} running`) : chalk.yellow(`not running — run \`bizar memory reindex\``));
1334
+ }
1335
+ } catch (err) {
1336
+ kv('lightrag', chalk.gray(`status check failed: ${err.message}`));
669
1337
  }
670
1338
  }
671
1339
 
@@ -689,6 +1357,9 @@ export async function runMemory(subcommand, args) {
689
1357
  case 'init':
690
1358
  await cmdInit(args);
691
1359
  break;
1360
+ case 'setup':
1361
+ await cmdSetup(args);
1362
+ break;
692
1363
  case 'status':
693
1364
  await cmdStatus(args);
694
1365
  break;
@@ -698,6 +1369,9 @@ export async function runMemory(subcommand, args) {
698
1369
  case 'unlink':
699
1370
  await cmdUnlink(args);
700
1371
  break;
1372
+ case 'write':
1373
+ await cmdWrite(args);
1374
+ break;
701
1375
  case 'pull':
702
1376
  await cmdPull(args);
703
1377
  break;
@@ -713,6 +1387,9 @@ export async function runMemory(subcommand, args) {
713
1387
  case 'reindex':
714
1388
  await cmdReindex(args);
715
1389
  break;
1390
+ case 'search':
1391
+ await cmdSearch(args);
1392
+ break;
716
1393
  case 'conflicts':
717
1394
  await cmdConflicts(args);
718
1395
  break;
@@ -740,9 +1417,11 @@ function showHelp() {
740
1417
 
741
1418
  Subcommands:
742
1419
  init Initialize memory config for this project
1420
+ setup Configure or reconfigure the memory vault (mode + remote)
743
1421
  status Show current memory configuration
744
1422
  link <path> Link to a shared memory repo (clone if URL)
745
1423
  unlink Revert to local-only mode
1424
+ write Write a single note to the vault
746
1425
  pull Git pull from the shared repo
747
1426
  commit [-m] Git commit staged changes
748
1427
  push Git push to the shared repo