@gmickel/gno 0.29.1 → 0.31.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.
package/README.md CHANGED
@@ -20,6 +20,7 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
20
20
 
21
21
  - [Quick Start](#quick-start)
22
22
  - [Installation](#installation)
23
+ - [Daemon Mode](#daemon-mode)
23
24
  - [Search Modes](#search-modes)
24
25
  - [Agent Integration](#agent-integration)
25
26
  - [Web UI](#web-ui)
@@ -39,6 +40,20 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
39
40
  - **GNO Desktop Beta**: first mac-first desktop beta shell with deep-link routing, singleton handoff, and the same onboarding/search/edit flows as `gno serve`
40
41
  - **Desktop Onboarding Polish**: guided setup now covers folders, presets, model readiness, indexing, connectors, import preview, app tabs, file actions, and recovery without drift between web and desktop
41
42
  - **Default Preset Upgrade**: `slim-tuned` is now the built-in default, using the fine-tuned retrieval expansion model while keeping the same embed, rerank, and answer stack as `slim`
43
+ - **Workspace UI Polish**: richer scholarly-dusk presentation across dashboard, tabs, search, ask, footer, and global styling without introducing external font or asset dependencies
44
+
45
+ ## What's New in v0.30
46
+
47
+ - **Headless Daemon Mode**: `gno daemon` keeps your index fresh continuously without opening the Web UI
48
+ - **CLI Concurrency Hardening**: read-only commands no longer trip transient `database is locked` errors when they overlap with `gno update`
49
+ - **Web/Desktop UI Polish**: sharper workspace styling across dashboard, tabs, search, ask, and footer surfaces
50
+
51
+ ## What's New in v0.31
52
+
53
+ - **Windows Desktop Beta Artifact**: release flow now includes a packaged `windows-x64` desktop beta zip, not just source-level support claims
54
+ - **Packaged Runtime Proof**: Windows desktop packaging validates bundled Bun + staged GNO runtime + FTS5 + vendored snowball + `sqlite-vec`
55
+ - **Scoped Index Fix**: `gno index <collection>` now embeds only that collection instead of accidentally burning through unrelated backlog from other collections
56
+ - **CLI Reporting Fix**: long embed runs now report sane durations instead of bogus sub-second summaries
42
57
 
43
58
  ### v0.24
44
59
 
@@ -144,6 +159,7 @@ gno query "ECONNREFUSED 127.0.0.1:5432" --thorough
144
159
  ```bash
145
160
  gno init ~/notes --name notes # Point at your docs
146
161
  gno index # Build search index
162
+ gno daemon # Keep index fresh in background (foreground process)
147
163
  gno query "auth best practices" # Hybrid search
148
164
  gno ask "summarize the API" --answer # AI answer with citations
149
165
  ```
@@ -174,6 +190,21 @@ Verify everything works:
174
190
  gno doctor
175
191
  ```
176
192
 
193
+ **Windows**: current validated target is `windows-x64`. See
194
+ [docs/WINDOWS.md](./docs/WINDOWS.md) for the support stance and packaged desktop
195
+ beta notes.
196
+
197
+ Keep an index fresh continuously without opening the Web UI:
198
+
199
+ ```bash
200
+ gno daemon
201
+ ```
202
+
203
+ `gno daemon` runs as a foreground watcher/sync/embed process. Use `nohup`,
204
+ launchd, or systemd if you want it supervised long-term.
205
+
206
+ See also: [docs/DAEMON.md](./docs/DAEMON.md)
207
+
177
208
  ### Connect to AI Agents
178
209
 
179
210
  #### MCP Server (Claude Desktop, Cursor, Zed, etc.)
@@ -211,6 +242,25 @@ gno skill install --target all # All targets
211
242
 
212
243
  ---
213
244
 
245
+ ## Daemon Mode
246
+
247
+ Use `gno daemon` when you want continuous indexing without the browser or
248
+ desktop shell open.
249
+
250
+ ```bash
251
+ gno daemon
252
+ gno daemon --no-sync-on-start
253
+ nohup gno daemon > /tmp/gno-daemon.log 2>&1 &
254
+ ```
255
+
256
+ It reuses the same watch/sync/embed runtime as `gno serve`, but stays
257
+ headless. In v0.30 it is foreground-only and does not expose built-in
258
+ `start/stop/status` management.
259
+
260
+ [Daemon guide →](https://gno.sh/docs/DAEMON/)
261
+
262
+ ---
263
+
214
264
  ## SDK
215
265
 
216
266
  Embed GNO directly in another Bun or TypeScript app. No CLI subprocesses. No local server required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.29.1",
3
+ "version": "0.31.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -75,6 +75,7 @@ export async function ask(
75
75
  const initResult = await initStore({
76
76
  configPath: options.configPath,
77
77
  collection: options.collection,
78
+ syncConfig: false,
78
79
  });
79
80
 
80
81
  if (!initResult.ok) {
@@ -28,6 +28,7 @@ const COMMANDS = [
28
28
  "get",
29
29
  "multi-get",
30
30
  "ls",
31
+ "daemon",
31
32
  "serve",
32
33
  "mcp",
33
34
  "mcp serve",
@@ -330,6 +331,7 @@ function getCommandDescription(cmd: string): string {
330
331
  get: "Get document by URI",
331
332
  "multi-get": "Get multiple documents",
332
333
  ls: "List indexed documents",
334
+ daemon: "Start headless continuous indexing",
333
335
  serve: "Start web UI server",
334
336
  mcp: "MCP server and configuration",
335
337
  "mcp serve": "Start MCP server",
@@ -0,0 +1,159 @@
1
+ import type { CollectionSyncResult } from "../../ingestion";
2
+ import type { BackgroundRuntimeResult } from "../../serve/background-runtime";
3
+
4
+ import { startBackgroundRuntime } from "../../serve/background-runtime";
5
+
6
+ export interface DaemonOptions {
7
+ configPath?: string;
8
+ index?: string;
9
+ offline?: boolean;
10
+ verbose?: boolean;
11
+ quiet?: boolean;
12
+ noSyncOnStart?: boolean;
13
+ signal?: AbortSignal;
14
+ }
15
+
16
+ export type DaemonResult =
17
+ | { success: true }
18
+ | { success: false; error: string };
19
+
20
+ type DaemonLogger = {
21
+ log: (message: string) => void;
22
+ error: (message: string) => void;
23
+ };
24
+
25
+ type DaemonDeps = {
26
+ startBackgroundRuntime?: typeof startBackgroundRuntime;
27
+ logger?: DaemonLogger;
28
+ };
29
+
30
+ function formatCollectionSyncSummary(result: CollectionSyncResult): string {
31
+ return `${result.collection}: ${result.filesAdded} added, ${result.filesUpdated} updated, ${result.filesUnchanged} unchanged, ${result.filesErrored} errors`;
32
+ }
33
+
34
+ function createSignalPromise(
35
+ signal: AbortSignal | undefined,
36
+ logger: DaemonLogger,
37
+ quiet: boolean
38
+ ): Promise<void> {
39
+ return new Promise((resolve) => {
40
+ if (signal?.aborted) {
41
+ resolve();
42
+ return;
43
+ }
44
+
45
+ const complete = (message?: string): void => {
46
+ signal?.removeEventListener("abort", onAbort);
47
+ process.off("SIGINT", onSigint);
48
+ process.off("SIGTERM", onSigterm);
49
+ if (message && !quiet) {
50
+ logger.log(message);
51
+ }
52
+ resolve();
53
+ };
54
+
55
+ const onAbort = (): void => complete("Daemon stopped.");
56
+ const onSigint = (): void => complete("Received SIGINT. Shutting down...");
57
+ const onSigterm = (): void =>
58
+ complete("Received SIGTERM. Shutting down...");
59
+
60
+ signal?.addEventListener("abort", onAbort, { once: true });
61
+ process.once("SIGINT", onSigint);
62
+ process.once("SIGTERM", onSigterm);
63
+ });
64
+ }
65
+
66
+ export async function daemon(
67
+ options: DaemonOptions = {},
68
+ deps: DaemonDeps = {}
69
+ ): Promise<DaemonResult> {
70
+ const logger = deps.logger ?? {
71
+ log: (message: string) => {
72
+ console.log(message);
73
+ },
74
+ error: (message: string) => {
75
+ console.error(message);
76
+ },
77
+ };
78
+
79
+ const runtimeResult: BackgroundRuntimeResult = await (
80
+ deps.startBackgroundRuntime ?? startBackgroundRuntime
81
+ )({
82
+ configPath: options.configPath,
83
+ index: options.index,
84
+ requireCollections: true,
85
+ offline: options.offline,
86
+ watchCallbacks: {
87
+ onSyncStart: ({ collection, relPaths }) => {
88
+ if (!options.quiet) {
89
+ logger.log(
90
+ `watch sync started: ${collection} (${relPaths.length} path${relPaths.length === 1 ? "" : "s"})`
91
+ );
92
+ }
93
+ },
94
+ onSyncComplete: ({ result }) => {
95
+ if (!options.quiet) {
96
+ logger.log(formatCollectionSyncSummary(result));
97
+ }
98
+ },
99
+ onSyncError: ({ collection, error }) => {
100
+ logger.error(
101
+ `watch sync failed: ${collection}: ${error instanceof Error ? error.message : String(error)}`
102
+ );
103
+ },
104
+ },
105
+ });
106
+ if (!runtimeResult.success) {
107
+ return { success: false, error: runtimeResult.error };
108
+ }
109
+
110
+ const { runtime } = runtimeResult;
111
+ try {
112
+ if (!options.quiet) {
113
+ logger.log(
114
+ `GNO daemon started for index "${options.index ?? "default"}" using ${runtime.config.collections.length} collection${runtime.config.collections.length === 1 ? "" : "s"}.`
115
+ );
116
+ const watchState = runtime.watchService.getState();
117
+ if (watchState.activeCollections.length > 0) {
118
+ logger.log(`watching: ${watchState.activeCollections.join(", ")}`);
119
+ }
120
+ if (watchState.failedCollections.length > 0) {
121
+ for (const failed of watchState.failedCollections) {
122
+ logger.error(`watch failed: ${failed.collection}: ${failed.reason}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ if (!options.noSyncOnStart) {
128
+ if (!options.quiet) {
129
+ logger.log("Running initial sync...");
130
+ }
131
+ const { syncResult, embedResult } = await runtime.syncAll({
132
+ runUpdateCmd: true,
133
+ triggerEmbed: true,
134
+ });
135
+ if (!options.quiet) {
136
+ logger.log(
137
+ `sync totals: ${syncResult.totalFilesAdded} added, ${syncResult.totalFilesUpdated} updated, ${syncResult.totalFilesErrored} errors, ${syncResult.totalFilesSkipped} skipped`
138
+ );
139
+ }
140
+ if (!options.quiet && embedResult) {
141
+ logger.log(
142
+ `embed: ${embedResult.embedded} embedded, ${embedResult.errors} errors`
143
+ );
144
+ }
145
+ } else if (!options.quiet) {
146
+ logger.log("Skipping initial sync (--no-sync-on-start).");
147
+ }
148
+
149
+ await createSignalPromise(options.signal, logger, options.quiet ?? false);
150
+ return { success: true };
151
+ } catch (error) {
152
+ return {
153
+ success: false,
154
+ error: error instanceof Error ? error.message : String(error),
155
+ };
156
+ } finally {
157
+ await runtime.dispose();
158
+ }
159
+ }
@@ -16,6 +16,7 @@ import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
16
16
  import { getConfigPaths, isInitialized, loadConfig } from "../../config";
17
17
  import { ModelCache } from "../../llm/cache";
18
18
  import { getActivePreset } from "../../llm/registry";
19
+ import { loadFts5Snowball } from "../../store/sqlite/fts5-snowball";
19
20
  import {
20
21
  getCustomSqlitePath,
21
22
  getExtensionLoadingMode,
@@ -221,6 +222,17 @@ async function checkSqliteExtensions(): Promise<DoctorCheck[]> {
221
222
  message: jsonAvailable ? "JSON1 available" : "JSON1 not available",
222
223
  });
223
224
 
225
+ // Probe vendored fts5-snowball extension
226
+ const snowballResult = loadFts5Snowball(db);
227
+ checks.push({
228
+ name: "fts5-snowball",
229
+ status: snowballResult.loaded ? "ok" : "error",
230
+ message: snowballResult.loaded
231
+ ? "fts5-snowball loaded"
232
+ : (snowballResult.error ?? "fts5-snowball failed to load"),
233
+ details: snowballResult.path ? [`Path: ${snowballResult.path}`] : undefined,
234
+ });
235
+
224
236
  // Probe sqlite-vec extension
225
237
  let sqliteVecAvailable = false;
226
238
  let sqliteVecVersion = "";
@@ -44,6 +44,8 @@ import {
44
44
  export interface EmbedOptions {
45
45
  /** Override config path */
46
46
  configPath?: string;
47
+ /** Restrict embedding work to a single collection */
48
+ collection?: string;
47
49
  /** Override model URI */
48
50
  model?: string;
49
51
  /** Batch size for embedding */
@@ -102,6 +104,7 @@ interface BatchContext {
102
104
  embedPort: EmbeddingPort;
103
105
  vectorIndex: VectorIndexPort;
104
106
  modelUri: string;
107
+ collection?: string;
105
108
  batchSize: number;
106
109
  force: boolean;
107
110
  showProgress: boolean;
@@ -127,10 +130,11 @@ async function processBatches(ctx: BatchContext): Promise<BatchResult> {
127
130
  while (embedded + errors < ctx.totalToEmbed) {
128
131
  // Get next batch using seek pagination (cursor-based)
129
132
  const batchResult = ctx.force
130
- ? await getActiveChunks(ctx.db, ctx.batchSize, cursor)
133
+ ? await getActiveChunks(ctx.db, ctx.batchSize, cursor, ctx.collection)
131
134
  : await ctx.stats.getBacklog(ctx.modelUri, {
132
135
  limit: ctx.batchSize,
133
136
  after: cursor,
137
+ collection: ctx.collection,
134
138
  });
135
139
 
136
140
  if (!batchResult.ok) {
@@ -247,6 +251,7 @@ interface EmbedContext {
247
251
  */
248
252
  async function initEmbedContext(
249
253
  configPath?: string,
254
+ collection?: string,
250
255
  model?: string
251
256
  ): Promise<({ ok: true } & EmbedContext) | { ok: false; error: string }> {
252
257
  const initialized = await isInitialized(configPath);
@@ -259,6 +264,12 @@ async function initEmbedContext(
259
264
  return { ok: false, error: configResult.error.message };
260
265
  }
261
266
  const config = configResult.value;
267
+ if (
268
+ collection &&
269
+ !config.collections.some((candidate) => candidate.name === collection)
270
+ ) {
271
+ return { ok: false, error: `Collection not found: ${collection}` };
272
+ }
262
273
 
263
274
  const preset = getActivePreset(config);
264
275
  const modelUri = model ?? preset.embed;
@@ -289,7 +300,11 @@ export async function embed(options: EmbedOptions = {}): Promise<EmbedResult> {
289
300
  const dryRun = options.dryRun ?? false;
290
301
 
291
302
  // Initialize config and store
292
- const initResult = await initEmbedContext(options.configPath, options.model);
303
+ const initResult = await initEmbedContext(
304
+ options.configPath,
305
+ options.collection,
306
+ options.model
307
+ );
293
308
  if (!initResult.ok) {
294
309
  return { success: false, error: initResult.error };
295
310
  }
@@ -306,8 +321,8 @@ export async function embed(options: EmbedOptions = {}): Promise<EmbedResult> {
306
321
 
307
322
  // Get backlog count first (before loading model)
308
323
  const backlogResult = force
309
- ? await getActiveChunkCount(db)
310
- : await stats.countBacklog(modelUri);
324
+ ? await getActiveChunkCount(db, options.collection)
325
+ : await stats.countBacklog(modelUri, { collection: options.collection });
311
326
 
312
327
  if (!backlogResult.ok) {
313
328
  return { success: false, error: backlogResult.error.message };
@@ -392,6 +407,7 @@ export async function embed(options: EmbedOptions = {}): Promise<EmbedResult> {
392
407
  embedPort,
393
408
  vectorIndex,
394
409
  modelUri,
410
+ collection: options.collection,
395
411
  batchSize,
396
412
  force,
397
413
  showProgress: !options.json,
@@ -443,19 +459,23 @@ export async function embed(options: EmbedOptions = {}): Promise<EmbedResult> {
443
459
  // Helper: Get all active chunks (for --force mode)
444
460
  // ─────────────────────────────────────────────────────────────────────────────
445
461
 
446
- function getActiveChunkCount(db: Database): Promise<StoreResult<number>> {
462
+ function getActiveChunkCount(
463
+ db: Database,
464
+ collection?: string
465
+ ): Promise<StoreResult<number>> {
447
466
  try {
467
+ const collectionClause = collection ? " AND d.collection = ?" : "";
448
468
  const result = db
449
469
  .prepare(
450
470
  `
451
471
  SELECT COUNT(*) as count FROM content_chunks c
452
472
  WHERE EXISTS (
453
473
  SELECT 1 FROM documents d
454
- WHERE d.mirror_hash = c.mirror_hash AND d.active = 1
474
+ WHERE d.mirror_hash = c.mirror_hash AND d.active = 1${collectionClause}
455
475
  )
456
476
  `
457
477
  )
458
- .get() as { count: number };
478
+ .get(...(collection ? [collection] : [])) as { count: number };
459
479
  return Promise.resolve(ok(result.count));
460
480
  } catch (e) {
461
481
  return Promise.resolve(
@@ -470,9 +490,11 @@ function getActiveChunkCount(db: Database): Promise<StoreResult<number>> {
470
490
  function getActiveChunks(
471
491
  db: Database,
472
492
  limit: number,
473
- after?: { mirrorHash: string; seq: number }
493
+ after?: { mirrorHash: string; seq: number },
494
+ collection?: string
474
495
  ): Promise<StoreResult<BacklogItem[]>> {
475
496
  try {
497
+ const collectionClause = collection ? " AND d.collection = ?" : "";
476
498
  // Include title for contextual embedding
477
499
  const sql = after
478
500
  ? `
@@ -482,7 +504,7 @@ function getActiveChunks(
482
504
  FROM content_chunks c
483
505
  WHERE EXISTS (
484
506
  SELECT 1 FROM documents d
485
- WHERE d.mirror_hash = c.mirror_hash AND d.active = 1
507
+ WHERE d.mirror_hash = c.mirror_hash AND d.active = 1${collectionClause}
486
508
  )
487
509
  AND (c.mirror_hash > ? OR (c.mirror_hash = ? AND c.seq > ?))
488
510
  ORDER BY c.mirror_hash, c.seq
@@ -495,15 +517,21 @@ function getActiveChunks(
495
517
  FROM content_chunks c
496
518
  WHERE EXISTS (
497
519
  SELECT 1 FROM documents d
498
- WHERE d.mirror_hash = c.mirror_hash AND d.active = 1
520
+ WHERE d.mirror_hash = c.mirror_hash AND d.active = 1${collectionClause}
499
521
  )
500
522
  ORDER BY c.mirror_hash, c.seq
501
523
  LIMIT ?
502
524
  `;
503
525
 
504
526
  const params = after
505
- ? [after.mirrorHash, after.mirrorHash, after.seq, limit]
506
- : [limit];
527
+ ? [
528
+ ...(collection ? [collection] : []),
529
+ after.mirrorHash,
530
+ after.mirrorHash,
531
+ after.seq,
532
+ limit,
533
+ ]
534
+ : [...(collection ? [collection] : []), limit];
507
535
 
508
536
  const results = db.prepare(sql).all(...params) as BacklogItem[];
509
537
  return Promise.resolve(ok(results));
@@ -110,7 +110,10 @@ export async function get(
110
110
  return { success: false, error: parsed.error, isValidation: true };
111
111
  }
112
112
 
113
- const initResult = await initStore({ configPath: options.configPath });
113
+ const initResult = await initStore({
114
+ configPath: options.configPath,
115
+ syncConfig: false,
116
+ });
114
117
  if (!initResult.ok) {
115
118
  return { success: false, error: initResult.error };
116
119
  }
@@ -49,7 +49,10 @@ export type GraphCommandResult =
49
49
  export async function graph(
50
50
  options: GraphOptions = {}
51
51
  ): Promise<GraphCommandResult> {
52
- const initResult = await initStore({ configPath: options.configPath });
52
+ const initResult = await initStore({
53
+ configPath: options.configPath,
54
+ syncConfig: false,
55
+ });
53
56
  if (!initResult.ok) {
54
57
  return { success: false, error: initResult.error };
55
58
  }
@@ -71,6 +71,7 @@ export async function index(options: IndexOptions = {}): Promise<IndexResult> {
71
71
  const { embed } = await import("./embed");
72
72
  const result = await embed({
73
73
  configPath: options.configPath,
74
+ collection: options.collection,
74
75
  verbose: options.verbose,
75
76
  });
76
77
  if (result.success) {
@@ -95,6 +96,15 @@ export function formatIndex(
95
96
  result: IndexResult,
96
97
  options: IndexOptions
97
98
  ): string {
99
+ function formatDuration(seconds: number): string {
100
+ if (seconds < 60) {
101
+ return `${seconds.toFixed(1)}s`;
102
+ }
103
+ const mins = Math.floor(seconds / 60);
104
+ const secs = seconds % 60;
105
+ return `${mins}m ${secs.toFixed(0)}s`;
106
+ }
107
+
98
108
  if (!result.success) {
99
109
  return `Error: ${result.error}`;
100
110
  }
@@ -110,10 +120,12 @@ export function formatIndex(
110
120
  } else if (result.embedResult) {
111
121
  lines.push("");
112
122
  const { embedded, errors, duration } = result.embedResult;
113
- const errPart = errors > 0 ? ` (${errors} errors)` : "";
114
123
  lines.push(
115
- `Embedded ${embedded} chunks in ${(duration / 1000).toFixed(1)}s${errPart}`
124
+ `Embedded ${embedded.toLocaleString()} chunks in ${formatDuration(duration)}`
116
125
  );
126
+ if (errors > 0) {
127
+ lines.push(`${errors.toLocaleString()} chunks failed to embed.`);
128
+ }
117
129
  }
118
130
 
119
131
  return lines.join("\n");
@@ -315,7 +315,10 @@ export async function linksList(
315
315
  docRef: string,
316
316
  options: LinksListOptions = {}
317
317
  ): Promise<LinksListResult> {
318
- const initResult = await initStore({ configPath: options.configPath });
318
+ const initResult = await initStore({
319
+ configPath: options.configPath,
320
+ syncConfig: false,
321
+ });
319
322
  if (!initResult.ok) {
320
323
  return { success: false, error: initResult.error };
321
324
  }
@@ -428,7 +431,10 @@ export async function backlinks(
428
431
  docRef: string,
429
432
  options: BacklinksOptions = {}
430
433
  ): Promise<BacklinksResult> {
431
- const initResult = await initStore({ configPath: options.configPath });
434
+ const initResult = await initStore({
435
+ configPath: options.configPath,
436
+ syncConfig: false,
437
+ });
432
438
  if (!initResult.ok) {
433
439
  return { success: false, error: initResult.error };
434
440
  }
@@ -507,7 +513,10 @@ export async function similar(
507
513
  const threshold = options.threshold ?? 0.7;
508
514
  const crossCollection = options.crossCollection ?? false;
509
515
 
510
- const initResult = await initStore({ configPath: options.configPath });
516
+ const initResult = await initStore({
517
+ configPath: options.configPath,
518
+ syncConfig: false,
519
+ });
511
520
  if (!initResult.ok) {
512
521
  return { success: false, error: initResult.error };
513
522
  }
@@ -109,7 +109,10 @@ export async function ls(
109
109
  }
110
110
  }
111
111
 
112
- const initResult = await initStore({ configPath: options.configPath });
112
+ const initResult = await initStore({
113
+ configPath: options.configPath,
114
+ syncConfig: false,
115
+ });
113
116
  if (!initResult.ok) {
114
117
  return { success: false, error: initResult.error };
115
118
  }
@@ -250,7 +250,10 @@ export async function multiGet(
250
250
  const maxBytes = options.maxBytes ?? 10_240;
251
251
  const allRefs = splitRefs(refs);
252
252
 
253
- const initResult = await initStore({ configPath: options.configPath });
253
+ const initResult = await initStore({
254
+ configPath: options.configPath,
255
+ syncConfig: false,
256
+ });
254
257
  if (!initResult.ok) {
255
258
  return { success: false, error: initResult.error };
256
259
  }
@@ -83,6 +83,7 @@ export async function query(
83
83
  const initResult = await initStore({
84
84
  configPath: options.configPath,
85
85
  collection: options.collection,
86
+ syncConfig: false,
86
87
  });
87
88
 
88
89
  if (!initResult.ok) {
@@ -56,6 +56,7 @@ export async function search(
56
56
  const initResult = await initStore({
57
57
  configPath: options.configPath,
58
58
  collection: options.collection,
59
+ syncConfig: false,
59
60
  });
60
61
 
61
62
  if (!initResult.ok) {
@@ -36,6 +36,8 @@ export interface InitStoreOptions {
36
36
  indexName?: string;
37
37
  /** Filter to single collection by name */
38
38
  collection?: string;
39
+ /** Sync collections/contexts from config into DB on open */
40
+ syncConfig?: boolean;
39
41
  }
40
42
 
41
43
  /**
@@ -99,6 +101,10 @@ export async function initStore(
99
101
  return { ok: false, error: openResult.error.message };
100
102
  }
101
103
 
104
+ if (options.syncConfig === false) {
105
+ return { ok: true, store, config, collections, actualConfigPath };
106
+ }
107
+
102
108
  // Sync collections from config to DB
103
109
  const syncCollResult = await store.syncCollections(config.collections);
104
110
  if (!syncCollResult.ok) {
@@ -65,6 +65,7 @@ export async function vsearch(
65
65
  const initResult = await initStore({
66
66
  configPath: options.configPath,
67
67
  collection: options.collection,
68
+ syncConfig: false,
68
69
  });
69
70
 
70
71
  if (!initResult.ok) {
@@ -188,6 +188,7 @@ export function createProgram(): Command {
188
188
  wireGraphCommand(program);
189
189
  wireMcpCommand(program);
190
190
  wireSkillCommands(program);
191
+ wireDaemonCommand(program);
191
192
  wireServeCommand(program);
192
193
  wireCompletionCommand(program);
193
194
 
@@ -2038,6 +2039,31 @@ function wireGraphCommand(program: Command): void {
2038
2039
  // Serve Command (web UI)
2039
2040
  // ─────────────────────────────────────────────────────────────────────────────
2040
2041
 
2042
+ function wireDaemonCommand(program: Command): void {
2043
+ program
2044
+ .command("daemon")
2045
+ .description("Start headless continuous indexing")
2046
+ .option(
2047
+ "--no-sync-on-start",
2048
+ "skip initial sync and only watch future file changes"
2049
+ )
2050
+ .action(async (cmdOpts: Record<string, unknown>) => {
2051
+ const globals = getGlobals();
2052
+ const { daemon } = await import("./commands/daemon.js");
2053
+ const result = await daemon({
2054
+ configPath: globals.config,
2055
+ index: globals.index,
2056
+ offline: globals.offline,
2057
+ verbose: globals.verbose,
2058
+ quiet: globals.quiet,
2059
+ noSyncOnStart: cmdOpts.syncOnStart === false,
2060
+ });
2061
+ if (!result.success) {
2062
+ throw new CliError("RUNTIME", result.error);
2063
+ }
2064
+ });
2065
+ }
2066
+
2041
2067
  function wireServeCommand(program: Command): void {
2042
2068
  program
2043
2069
  .command("serve")