@gmickel/gno 0.29.0 → 0.30.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
@@ -39,6 +39,13 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
39
39
  - **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
40
  - **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
41
  - **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`
42
+ - **Workspace UI Polish**: richer scholarly-dusk presentation across dashboard, tabs, search, ask, footer, and global styling without introducing external font or asset dependencies
43
+
44
+ ## What's New in v0.30
45
+
46
+ - **Headless Daemon Mode**: `gno daemon` keeps your index fresh continuously without opening the Web UI
47
+ - **CLI Concurrency Hardening**: read-only commands no longer trip transient `database is locked` errors when they overlap with `gno update`
48
+ - **Web/Desktop UI Polish**: sharper workspace styling across dashboard, tabs, search, ask, and footer surfaces
42
49
 
43
50
  ### v0.24
44
51
 
@@ -144,6 +151,7 @@ gno query "ECONNREFUSED 127.0.0.1:5432" --thorough
144
151
  ```bash
145
152
  gno init ~/notes --name notes # Point at your docs
146
153
  gno index # Build search index
154
+ gno daemon # Keep index fresh in background (foreground process)
147
155
  gno query "auth best practices" # Hybrid search
148
156
  gno ask "summarize the API" --answer # AI answer with citations
149
157
  ```
@@ -174,6 +182,15 @@ Verify everything works:
174
182
  gno doctor
175
183
  ```
176
184
 
185
+ Keep an index fresh continuously without opening the Web UI:
186
+
187
+ ```bash
188
+ gno daemon
189
+ ```
190
+
191
+ `gno daemon` runs as a foreground watcher/sync/embed process. Use `nohup`,
192
+ launchd, or systemd if you want it supervised long-term.
193
+
177
194
  ### Connect to AI Agents
178
195
 
179
196
  #### MCP Server (Claude Desktop, Cursor, Zed, etc.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.29.0",
3
+ "version": "0.30.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
+ }
@@ -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
  }
@@ -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")
@@ -5,11 +5,11 @@
5
5
  */
6
6
 
7
7
  // node:fs/promises for rename/unlink (no Bun equivalent for structure ops)
8
- import { rename, unlink } from "node:fs/promises";
9
- // node:os platform: no Bun equivalent
10
- import { platform } from "node:os";
11
- // node:path dirname: no Bun equivalent
12
- import { dirname } from "node:path";
8
+ import { mkdir, rename, unlink } from "node:fs/promises";
9
+ // node:os platform/homedir/tmpdir: no Bun equivalent
10
+ import { homedir, platform as getPlatform, tmpdir } from "node:os";
11
+ // node:path dirname/join/parse: no Bun equivalent
12
+ import { dirname, join, parse } from "node:path";
13
13
 
14
14
  export async function atomicWrite(
15
15
  path: string,
@@ -48,12 +48,176 @@ export async function renameFilePath(
48
48
  await rename(currentPath, nextPath);
49
49
  }
50
50
 
51
- export async function trashFilePath(path: string): Promise<void> {
52
- await runCommand(["trash", path]);
51
+ type TrashFileDeps = {
52
+ homeDir?: string;
53
+ platform?: ReturnType<typeof getPlatform>;
54
+ runCommand?: typeof runCommand;
55
+ tempDir?: string;
56
+ };
57
+
58
+ function isCrossDeviceRenameError(
59
+ error: unknown
60
+ ): error is NodeJS.ErrnoException {
61
+ return (
62
+ error instanceof Error &&
63
+ "code" in error &&
64
+ typeof error.code === "string" &&
65
+ error.code === "EXDEV"
66
+ );
67
+ }
68
+
69
+ async function nextAvailableTrashPath(
70
+ trashDir: string,
71
+ sourcePath: string
72
+ ): Promise<string> {
73
+ const { ext, name, base } = parse(sourcePath);
74
+ let candidate = join(trashDir, base);
75
+ let suffix = 2;
76
+ while (await Bun.file(candidate).exists()) {
77
+ candidate = join(trashDir, `${name} ${suffix}${ext}`);
78
+ suffix += 1;
79
+ }
80
+ return candidate;
81
+ }
82
+
83
+ async function moveFilePath(
84
+ sourcePath: string,
85
+ targetPath: string
86
+ ): Promise<void> {
87
+ try {
88
+ await rename(sourcePath, targetPath);
89
+ return;
90
+ } catch (error) {
91
+ if (!isCrossDeviceRenameError(error)) {
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ await Bun.write(targetPath, Bun.file(sourcePath));
97
+ await unlink(sourcePath);
98
+ }
99
+
100
+ async function trashFilePathOnDarwin(
101
+ path: string,
102
+ homeDir: string
103
+ ): Promise<void> {
104
+ const trashDir = join(homeDir, ".Trash");
105
+ await mkdir(trashDir, { recursive: true });
106
+ const targetPath = await nextAvailableTrashPath(trashDir, path);
107
+ await moveFilePath(path, targetPath);
108
+ }
109
+
110
+ function encodeTrashInfoPath(path: string): string {
111
+ return path
112
+ .split("/")
113
+ .map((segment, index) =>
114
+ index === 0 && segment.length === 0 ? "" : encodeURIComponent(segment)
115
+ )
116
+ .join("/");
117
+ }
118
+
119
+ async function trashFilePathOnLinux(
120
+ path: string,
121
+ homeDir: string
122
+ ): Promise<void> {
123
+ const trashRoot = join(homeDir, ".local", "share", "Trash");
124
+ const filesDir = join(trashRoot, "files");
125
+ const infoDir = join(trashRoot, "info");
126
+ await mkdir(filesDir, { recursive: true });
127
+ await mkdir(infoDir, { recursive: true });
128
+
129
+ const targetPath = await nextAvailableTrashPath(filesDir, path);
130
+ const infoPath = join(infoDir, `${parse(targetPath).base}.trashinfo`);
131
+ const infoContent = [
132
+ "[Trash Info]",
133
+ `Path=${encodeTrashInfoPath(path)}`,
134
+ `DeletionDate=${new Date().toISOString().slice(0, 19)}`,
135
+ "",
136
+ ].join("\n");
137
+
138
+ await moveFilePath(path, targetPath);
139
+ try {
140
+ await Bun.write(infoPath, infoContent);
141
+ } catch (error) {
142
+ await moveFilePath(targetPath, path).catch(() => {
143
+ /* ignore rollback errors */
144
+ });
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ async function trashFilePathOnWindows(
150
+ path: string,
151
+ deps: Required<Pick<TrashFileDeps, "runCommand" | "tempDir">>
152
+ ): Promise<void> {
153
+ const scriptPath = join(deps.tempDir, `gno-trash-${crypto.randomUUID()}.ps1`);
154
+ const script = `param([string]$LiteralPath)
155
+ Add-Type -AssemblyName Microsoft.VisualBasic
156
+ if (Test-Path -LiteralPath $LiteralPath -PathType Container) {
157
+ [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(
158
+ $LiteralPath,
159
+ [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,
160
+ [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin
161
+ )
162
+ } else {
163
+ [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile(
164
+ $LiteralPath,
165
+ [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,
166
+ [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin
167
+ )
168
+ }
169
+ `;
170
+
171
+ await Bun.write(scriptPath, script);
172
+ try {
173
+ await deps.runCommand([
174
+ "powershell",
175
+ "-NoProfile",
176
+ "-ExecutionPolicy",
177
+ "Bypass",
178
+ "-File",
179
+ scriptPath,
180
+ path,
181
+ ]);
182
+ } finally {
183
+ await unlink(scriptPath).catch(() => {
184
+ /* ignore cleanup errors */
185
+ });
186
+ }
187
+ }
188
+
189
+ export async function trashFilePath(
190
+ path: string,
191
+ deps: TrashFileDeps = {}
192
+ ): Promise<void> {
193
+ const platform = deps.platform ?? getPlatform();
194
+ const homeDir = deps.homeDir ?? homedir();
195
+ const runner = deps.runCommand ?? runCommand;
196
+ const tempDir = deps.tempDir ?? tmpdir();
197
+
198
+ if (platform === "darwin") {
199
+ await trashFilePathOnDarwin(path, homeDir);
200
+ return;
201
+ }
202
+
203
+ if (platform === "linux") {
204
+ await trashFilePathOnLinux(path, homeDir);
205
+ return;
206
+ }
207
+
208
+ if (platform === "win32") {
209
+ await trashFilePathOnWindows(path, {
210
+ runCommand: runner,
211
+ tempDir,
212
+ });
213
+ return;
214
+ }
215
+
216
+ throw new Error(`Trash is not supported on platform: ${platform}`);
53
217
  }
54
218
 
55
219
  export async function revealFilePath(path: string): Promise<void> {
56
- if (platform() === "darwin") {
220
+ if (getPlatform() === "darwin") {
57
221
  await runCommand(["open", "-R", path]);
58
222
  return;
59
223
  }