@gmickel/gno 0.28.2 → 0.29.1

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 (52) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -1
  3. package/src/app/constants.ts +4 -2
  4. package/src/cli/commands/mcp/install.ts +4 -4
  5. package/src/cli/commands/mcp/status.ts +7 -7
  6. package/src/cli/commands/skill/install.ts +5 -5
  7. package/src/cli/program.ts +2 -2
  8. package/src/collection/add.ts +10 -0
  9. package/src/collection/types.ts +1 -0
  10. package/src/config/types.ts +12 -2
  11. package/src/core/depth-policy.ts +1 -1
  12. package/src/core/file-ops.ts +203 -1
  13. package/src/llm/registry.ts +20 -4
  14. package/src/serve/AGENTS.md +16 -16
  15. package/src/serve/CLAUDE.md +16 -16
  16. package/src/serve/config-sync.ts +32 -1
  17. package/src/serve/connectors.ts +243 -0
  18. package/src/serve/context.ts +9 -0
  19. package/src/serve/doc-events.ts +31 -1
  20. package/src/serve/embed-scheduler.ts +12 -0
  21. package/src/serve/import-preview.ts +173 -0
  22. package/src/serve/public/app.tsx +101 -7
  23. package/src/serve/public/components/AIModelSelector.tsx +383 -145
  24. package/src/serve/public/components/AddCollectionDialog.tsx +123 -7
  25. package/src/serve/public/components/BootstrapStatus.tsx +133 -0
  26. package/src/serve/public/components/CaptureModal.tsx +5 -2
  27. package/src/serve/public/components/CollectionsEmptyState.tsx +63 -0
  28. package/src/serve/public/components/FirstRunWizard.tsx +622 -0
  29. package/src/serve/public/components/HealthCenter.tsx +128 -0
  30. package/src/serve/public/components/IndexingProgress.tsx +21 -2
  31. package/src/serve/public/components/QuickSwitcher.tsx +62 -36
  32. package/src/serve/public/components/TagInput.tsx +5 -1
  33. package/src/serve/public/components/WikiLinkAutocomplete.tsx +15 -6
  34. package/src/serve/public/components/WorkspaceTabs.tsx +60 -0
  35. package/src/serve/public/hooks/use-doc-events.ts +48 -4
  36. package/src/serve/public/lib/local-history.ts +40 -7
  37. package/src/serve/public/lib/navigation-state.ts +156 -0
  38. package/src/serve/public/lib/workspace-tabs.ts +235 -0
  39. package/src/serve/public/pages/Ask.tsx +11 -1
  40. package/src/serve/public/pages/Browse.tsx +73 -0
  41. package/src/serve/public/pages/Collections.tsx +29 -13
  42. package/src/serve/public/pages/Connectors.tsx +178 -0
  43. package/src/serve/public/pages/Dashboard.tsx +493 -67
  44. package/src/serve/public/pages/DocView.tsx +192 -34
  45. package/src/serve/public/pages/DocumentEditor.tsx +127 -5
  46. package/src/serve/public/pages/Search.tsx +12 -1
  47. package/src/serve/routes/api.ts +541 -62
  48. package/src/serve/server.ts +79 -2
  49. package/src/serve/status-model.ts +149 -0
  50. package/src/serve/status.ts +706 -0
  51. package/src/serve/watch-service.ts +73 -8
  52. package/src/types/electrobun-shell.d.ts +43 -0
@@ -0,0 +1,706 @@
1
+ // node:fs/promises stat/statfs: no Bun API for directory inspection or filesystem capacity
2
+ import { stat, statfs } from "node:fs/promises";
3
+ // node:os homedir: no Bun equivalent
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+
7
+ import type { IndexStatus } from "../store/types";
8
+ import type {
9
+ AppStatusResponse,
10
+ HealthCenterState,
11
+ HealthCheck,
12
+ SuggestedCollection,
13
+ } from "./status-model";
14
+
15
+ import { getModelsCachePath } from "../app/constants";
16
+ import { ModelCache } from "../llm/cache";
17
+ import { envIsSet, resolveDownloadPolicy } from "../llm/policy";
18
+ import { getActivePreset } from "../llm/registry";
19
+ import { downloadState, type ServerContext } from "./context";
20
+
21
+ const GIGABYTE = 1024 * 1024 * 1024;
22
+ const DISK_WARN_BYTES = 4 * GIGABYTE;
23
+ const DISK_ERROR_BYTES = 2 * GIGABYTE;
24
+ const SIZE_REGEX = /~[\d.]+GB/;
25
+ const SUGGESTED_FOLDERS = [
26
+ {
27
+ label: "Documents",
28
+ suffix: "Documents",
29
+ reason: "Good default for notes and docs",
30
+ },
31
+ {
32
+ label: "Desktop",
33
+ suffix: "Desktop",
34
+ reason: "Useful for quick tests and imports",
35
+ },
36
+ {
37
+ label: "Downloads",
38
+ suffix: "Downloads",
39
+ reason: "Good for PDFs and imports",
40
+ },
41
+ {
42
+ label: "Obsidian",
43
+ suffix: "Documents/Obsidian",
44
+ reason: "Common Obsidian vault path",
45
+ },
46
+ {
47
+ label: "Obsidian Vault",
48
+ suffix: "Documents/Obsidian Vault",
49
+ reason: "Another common Obsidian vault path",
50
+ },
51
+ ] as const;
52
+
53
+ interface DiskSnapshot {
54
+ freeBytes: number;
55
+ totalBytes: number;
56
+ path: string;
57
+ }
58
+
59
+ export interface StatusBuildDeps {
60
+ inspectDisk?: (path: string) => Promise<DiskSnapshot | null>;
61
+ isModelCached?: (uri: string) => Promise<boolean>;
62
+ listSuggestedCollections?: () => Promise<SuggestedCollection[]>;
63
+ }
64
+
65
+ function formatBytes(bytes: number): string {
66
+ if (bytes >= GIGABYTE) {
67
+ return `${(bytes / GIGABYTE).toFixed(1)} GB`;
68
+ }
69
+ const megabyte = 1024 * 1024;
70
+ if (bytes >= megabyte) {
71
+ return `${Math.round(bytes / megabyte)} MB`;
72
+ }
73
+ return `${bytes} B`;
74
+ }
75
+
76
+ function summarizeCount(
77
+ count: number,
78
+ singular: string,
79
+ plural = `${singular}s`
80
+ ): string {
81
+ return `${count} ${count === 1 ? singular : plural}`;
82
+ }
83
+
84
+ function toDisplayPath(path: string): string {
85
+ const home = homedir();
86
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
87
+ }
88
+
89
+ function extractEstimatedFootprint(name: string): string | null {
90
+ const match = name.match(SIZE_REGEX);
91
+ return match ? match[0] : null;
92
+ }
93
+
94
+ function resolvePolicySource(
95
+ env: Record<string, string | undefined>
96
+ ): AppStatusResponse["bootstrap"]["policy"]["source"] {
97
+ if (envIsSet(env, "HF_HUB_OFFLINE")) {
98
+ return "hf-hub-offline";
99
+ }
100
+ if (envIsSet(env, "GNO_OFFLINE")) {
101
+ return "gno-offline";
102
+ }
103
+ if (envIsSet(env, "GNO_NO_AUTO_DOWNLOAD")) {
104
+ return "no-auto-download";
105
+ }
106
+ return "default";
107
+ }
108
+
109
+ async function pathExists(path: string): Promise<boolean> {
110
+ try {
111
+ await stat(path);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ async function findInspectablePath(path: string): Promise<string | null> {
119
+ let current = path;
120
+ while (true) {
121
+ if (await pathExists(current)) {
122
+ return current;
123
+ }
124
+ const parent = dirname(current);
125
+ if (parent === current) {
126
+ return null;
127
+ }
128
+ current = parent;
129
+ }
130
+ }
131
+
132
+ async function inspectDisk(path: string): Promise<DiskSnapshot | null> {
133
+ const target = await findInspectablePath(path);
134
+ if (!target) {
135
+ return null;
136
+ }
137
+
138
+ try {
139
+ const snapshot = await statfs(target);
140
+ const freeBytes = Number(snapshot.bavail) * Number(snapshot.bsize);
141
+ const totalBytes = Number(snapshot.blocks) * Number(snapshot.bsize);
142
+ return {
143
+ freeBytes,
144
+ totalBytes,
145
+ path: target,
146
+ };
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ async function listSuggestedCollections(): Promise<SuggestedCollection[]> {
153
+ const home = homedir();
154
+ const suggestions: SuggestedCollection[] = [];
155
+
156
+ for (const candidate of SUGGESTED_FOLDERS) {
157
+ const path = join(home, candidate.suffix);
158
+ if (await pathExists(path)) {
159
+ suggestions.push({
160
+ label: candidate.label,
161
+ path,
162
+ reason: candidate.reason,
163
+ });
164
+ }
165
+ }
166
+
167
+ return suggestions.slice(0, 4);
168
+ }
169
+
170
+ function getCheckState(checks: HealthCheck[]): HealthCenterState["state"] {
171
+ const hasError = checks.some((check) => check.status === "error");
172
+ const hasWarn = checks.some((check) => check.status === "warn");
173
+ const needsSetup =
174
+ checks.find((check) => check.id === "collections")?.status !== "ok";
175
+
176
+ if (needsSetup) {
177
+ return "setup-required";
178
+ }
179
+ if (hasError || hasWarn) {
180
+ return "needs-attention";
181
+ }
182
+ return "healthy";
183
+ }
184
+
185
+ function buildBackgroundCheck(
186
+ ctx: ServerContext,
187
+ status: IndexStatus
188
+ ): HealthCheck | null {
189
+ const watchState = ctx.watchService?.getState();
190
+ if (!watchState || status.collections.length === 0) {
191
+ return null;
192
+ }
193
+
194
+ if (watchState.failedCollections.length > 0) {
195
+ return {
196
+ id: "background",
197
+ title: "Background service",
198
+ status: "warn",
199
+ summary: `${summarizeCount(watchState.failedCollections.length, "watcher")} failed to start`,
200
+ detail:
201
+ "Some folders are not being watched live. Manual sync still works, but automatic refresh may be incomplete until the watcher recovers.",
202
+ };
203
+ }
204
+
205
+ if (
206
+ watchState.syncingCollections.length > 0 ||
207
+ watchState.queuedCollections.length > 0
208
+ ) {
209
+ return {
210
+ id: "background",
211
+ title: "Background service",
212
+ status: "warn",
213
+ summary: "Watcher activity is still being processed",
214
+ detail:
215
+ "Recent file changes are queued or syncing. The workspace should catch up automatically without a restart.",
216
+ };
217
+ }
218
+
219
+ return {
220
+ id: "background",
221
+ title: "Background service",
222
+ status: "ok",
223
+ summary: `${summarizeCount(watchState.activeCollections.length, "folder")} watched live`,
224
+ detail: watchState.lastEventAt
225
+ ? `Last file event: ${watchState.lastEventAt}.`
226
+ : "Live watching is armed and waiting for file changes.",
227
+ };
228
+ }
229
+
230
+ function buildCollectionCheck(status: IndexStatus): HealthCheck {
231
+ if (status.collections.length === 0) {
232
+ return {
233
+ id: "collections",
234
+ title: "Folders",
235
+ status: "warn",
236
+ summary: "No folders connected yet",
237
+ detail:
238
+ "Add at least one folder so GNO has something to index and search.",
239
+ actionLabel: "Add folder",
240
+ actionKind: "add-collection",
241
+ };
242
+ }
243
+
244
+ return {
245
+ id: "collections",
246
+ title: "Folders",
247
+ status: "ok",
248
+ summary: `${summarizeCount(status.collections.length, "folder")} connected`,
249
+ detail: `${summarizeCount(status.activeDocuments, "document")} active across your current sources.`,
250
+ actionLabel: "Manage folders",
251
+ actionKind: "open-collections",
252
+ };
253
+ }
254
+
255
+ function buildIndexingCheck(status: IndexStatus): HealthCheck {
256
+ if (status.recentErrors > 0) {
257
+ return {
258
+ id: "indexing",
259
+ title: "Indexing",
260
+ status: "error",
261
+ summary: `${summarizeCount(status.recentErrors, "recent error")} need attention`,
262
+ detail:
263
+ "GNO saw ingest failures in the last 24 hours. Re-run indexing after fixing the affected files or folder settings.",
264
+ actionLabel: "Run update",
265
+ actionKind: "sync",
266
+ };
267
+ }
268
+
269
+ if (status.collections.length > 0 && status.activeDocuments === 0) {
270
+ return {
271
+ id: "indexing",
272
+ title: "Indexing",
273
+ status: "warn",
274
+ summary: "Folders connected, but nothing is indexed yet",
275
+ detail:
276
+ "Start a sync to scan your folders and build the first searchable index.",
277
+ actionLabel: "Run update",
278
+ actionKind: "sync",
279
+ };
280
+ }
281
+
282
+ if (status.embeddingBacklog > 0) {
283
+ return {
284
+ id: "indexing",
285
+ title: "Indexing",
286
+ status: "warn",
287
+ summary: `${summarizeCount(status.embeddingBacklog, "chunk")} still waiting on embeddings`,
288
+ detail:
289
+ "Search works, but semantic and answer features will improve once embeddings finish.",
290
+ actionLabel: "Finish embeddings",
291
+ actionKind: "embed",
292
+ };
293
+ }
294
+
295
+ return {
296
+ id: "indexing",
297
+ title: "Indexing",
298
+ status: "ok",
299
+ summary:
300
+ status.activeDocuments > 0
301
+ ? `${summarizeCount(status.activeDocuments, "document")} indexed`
302
+ : "Ready for your first sync",
303
+ detail:
304
+ status.lastUpdatedAt !== null
305
+ ? `Last update: ${status.lastUpdatedAt}.`
306
+ : "No indexing runs recorded yet.",
307
+ actionLabel: "Run update",
308
+ actionKind: "sync",
309
+ };
310
+ }
311
+
312
+ async function buildModelCheck(
313
+ ctx: ServerContext,
314
+ deps: StatusBuildDeps
315
+ ): Promise<HealthCheck> {
316
+ const preset = getActivePreset(ctx.config);
317
+ const cache = new ModelCache(getModelsCachePath());
318
+ const isModelCached =
319
+ deps.isModelCached ?? ((uri: string) => cache.isCached(uri));
320
+
321
+ const [embedCached, rerankCached, genCached] = await Promise.all([
322
+ isModelCached(preset.embed),
323
+ isModelCached(preset.rerank),
324
+ isModelCached(preset.gen),
325
+ ]);
326
+
327
+ if (downloadState.active) {
328
+ return {
329
+ id: "models",
330
+ title: "Models",
331
+ status: "warn",
332
+ summary: "Model download in progress",
333
+ detail:
334
+ "GNO is pulling the active preset now. Leave this page open until downloads finish.",
335
+ actionLabel: "Download models",
336
+ actionKind: "download-models",
337
+ };
338
+ }
339
+
340
+ if (!embedCached) {
341
+ return {
342
+ id: "models",
343
+ title: "Models",
344
+ status: "error",
345
+ summary: `${preset.name} still needs core local models`,
346
+ detail:
347
+ "Download the active preset so semantic search and indexing embeddings can run locally.",
348
+ actionLabel: "Download models",
349
+ actionKind: "download-models",
350
+ };
351
+ }
352
+
353
+ if (!rerankCached || !genCached) {
354
+ const missing = [
355
+ !rerankCached ? "rerank" : null,
356
+ !genCached ? "answer" : null,
357
+ ].filter(Boolean);
358
+
359
+ return {
360
+ id: "models",
361
+ title: "Models",
362
+ status: "warn",
363
+ summary: `${preset.name} is usable, but ${missing.join(" + ")} models are still missing`,
364
+ detail:
365
+ "Core search is ready. Download the rest of the preset for best ranking and local AI answers.",
366
+ actionLabel: "Download models",
367
+ actionKind: "download-models",
368
+ };
369
+ }
370
+
371
+ return {
372
+ id: "models",
373
+ title: "Models",
374
+ status: "ok",
375
+ summary: `${preset.name} is ready`,
376
+ detail: ctx.capabilities.answer
377
+ ? "Search, reranking, and local answers are available."
378
+ : "Search models are ready. Answer generation is currently unavailable.",
379
+ actionLabel: "Download models",
380
+ actionKind: "download-models",
381
+ };
382
+ }
383
+
384
+ async function buildDiskCheck(
385
+ status: IndexStatus,
386
+ deps: StatusBuildDeps
387
+ ): Promise<HealthCheck> {
388
+ const snapshot =
389
+ (await deps.inspectDisk?.(getModelsCachePath())) ??
390
+ (await inspectDisk(getModelsCachePath()));
391
+
392
+ if (!snapshot) {
393
+ return {
394
+ id: "disk",
395
+ title: "Disk",
396
+ status: "warn",
397
+ summary: "Disk space could not be inspected",
398
+ detail: `GNO could not read filesystem capacity near ${toDisplayPath(getModelsCachePath())}.`,
399
+ };
400
+ }
401
+
402
+ const summary = `${formatBytes(snapshot.freeBytes)} free near ${toDisplayPath(snapshot.path)}`;
403
+
404
+ if (snapshot.freeBytes < DISK_ERROR_BYTES) {
405
+ return {
406
+ id: "disk",
407
+ title: "Disk",
408
+ status: "error",
409
+ summary,
410
+ detail:
411
+ "Local models and future indexes need more room. Free at least 2-4 GB before continuing.",
412
+ };
413
+ }
414
+
415
+ if (snapshot.freeBytes < DISK_WARN_BYTES) {
416
+ return {
417
+ id: "disk",
418
+ title: "Disk",
419
+ status: "warn",
420
+ summary,
421
+ detail:
422
+ status.collections.length === 0
423
+ ? "You can keep going, but model downloads may fail if space gets tighter."
424
+ : "Search works now, but future model downloads or large syncs may hit storage limits.",
425
+ };
426
+ }
427
+
428
+ return {
429
+ id: "disk",
430
+ title: "Disk",
431
+ status: "ok",
432
+ summary,
433
+ detail:
434
+ "Enough headroom for the active local models and routine indexing work.",
435
+ };
436
+ }
437
+
438
+ async function buildBootstrapState(
439
+ ctx: ServerContext
440
+ ): Promise<AppStatusResponse["bootstrap"]> {
441
+ const preset = getActivePreset(ctx.config);
442
+ const cache = new ModelCache(getModelsCachePath());
443
+ const entries = await cache.list();
444
+ const policy = resolveDownloadPolicy(process.env, {});
445
+ const policySource = resolvePolicySource(process.env);
446
+ const roleUris = [
447
+ { role: "embed" as const, uri: preset.embed },
448
+ { role: "rerank" as const, uri: preset.rerank },
449
+ { role: "expand" as const, uri: preset.expand ?? preset.gen },
450
+ { role: "gen" as const, uri: preset.gen },
451
+ ];
452
+
453
+ const modelEntries = await Promise.all(
454
+ roleUris.map(async ({ role, uri }) => {
455
+ const path = await cache.getCachedPath(uri);
456
+ const entry = entries.find((candidate) => candidate.uri === uri);
457
+ return {
458
+ role,
459
+ uri,
460
+ cached: path !== null,
461
+ path,
462
+ sizeBytes: entry?.size ?? null,
463
+ statusLabel: path !== null ? "Ready" : "Needs download",
464
+ };
465
+ })
466
+ );
467
+
468
+ const cachedCount = modelEntries.filter((entry) => entry.cached).length;
469
+ const totalCount = modelEntries.length;
470
+
471
+ const policySummary = policy.offline
472
+ ? "Offline mode. Cached models only."
473
+ : policy.allowDownload
474
+ ? "Models can auto-download on first use."
475
+ : "Manual model download only. Auto-download is disabled.";
476
+
477
+ return {
478
+ runtime: {
479
+ kind: "bun",
480
+ strategy: "manual-install-beta",
481
+ currentVersion: Bun.version,
482
+ requiredVersion: ">=1.3.0",
483
+ ready: true,
484
+ managedByApp: false,
485
+ summary: `This beta runs on Bun ${Bun.version}.`,
486
+ detail:
487
+ "Current beta installs still expect Bun to be present on the machine. Final desktop packaging work is separate.",
488
+ },
489
+ policy: {
490
+ offline: policy.offline,
491
+ allowDownload: policy.allowDownload,
492
+ source: policySource,
493
+ summary: policySummary,
494
+ },
495
+ cache: {
496
+ path: cache.dir,
497
+ totalSizeBytes: await cache.totalSize(),
498
+ totalSizeLabel: formatBytes(await cache.totalSize()),
499
+ },
500
+ models: {
501
+ activePresetId: preset.id,
502
+ activePresetName: preset.name,
503
+ estimatedFootprint: extractEstimatedFootprint(preset.name),
504
+ downloading: downloadState.active,
505
+ cachedCount,
506
+ totalCount,
507
+ summary:
508
+ cachedCount === totalCount
509
+ ? `${preset.name} is fully cached.`
510
+ : `${cachedCount}/${totalCount} preset roles are cached for ${preset.name}.`,
511
+ entries: modelEntries,
512
+ },
513
+ };
514
+ }
515
+
516
+ function buildOnboarding(
517
+ status: IndexStatus,
518
+ modelCheck: HealthCheck,
519
+ suggestions: SuggestedCollection[],
520
+ presetName: string
521
+ ): AppStatusResponse["onboarding"] {
522
+ const foldersReady = status.collections.length > 0;
523
+ const modelsReady = modelCheck.status === "ok";
524
+ const indexedReady =
525
+ status.activeDocuments > 0 && status.embeddingBacklog === 0;
526
+
527
+ const steps = [
528
+ {
529
+ id: "folders",
530
+ title: "Pick folders",
531
+ status: foldersReady ? "complete" : "current",
532
+ detail: foldersReady
533
+ ? `${summarizeCount(status.collections.length, "folder")} connected.`
534
+ : "Choose the folders you want GNO to watch and index.",
535
+ },
536
+ {
537
+ id: "preset",
538
+ title: "Choose speed vs quality",
539
+ status: "complete",
540
+ detail: `Current preset: ${presetName}. You can change this any time.`,
541
+ },
542
+ ...(!modelsReady
543
+ ? ([
544
+ {
545
+ id: "models",
546
+ title: "Prepare local models",
547
+ status: foldersReady ? "current" : "upcoming",
548
+ detail: modelCheck.detail,
549
+ },
550
+ ] satisfies AppStatusResponse["onboarding"]["steps"])
551
+ : []),
552
+ {
553
+ id: "indexing",
554
+ title: "Finish first index",
555
+ status: indexedReady ? "complete" : foldersReady ? "current" : "upcoming",
556
+ detail:
557
+ status.activeDocuments > 0 && status.embeddingBacklog > 0
558
+ ? `${summarizeCount(status.embeddingBacklog, "chunk")} still waiting on embeddings.`
559
+ : status.activeDocuments > 0
560
+ ? `${summarizeCount(status.activeDocuments, "document")} indexed so far.`
561
+ : "Run the first sync to scan your folders and build the search index.",
562
+ },
563
+ ] satisfies AppStatusResponse["onboarding"]["steps"];
564
+
565
+ if (!foldersReady) {
566
+ return {
567
+ ready: false,
568
+ stage: "add-collection",
569
+ headline: "Start by connecting the folders you care about",
570
+ detail:
571
+ "Pick notes, docs, or a project directory. GNO will scan them and build search automatically.",
572
+ suggestedCollections: suggestions,
573
+ steps,
574
+ };
575
+ }
576
+
577
+ if (!modelsReady) {
578
+ return {
579
+ ready: false,
580
+ stage: "models",
581
+ headline: "Your folders are connected. Finish model setup next",
582
+ detail: modelCheck.detail,
583
+ suggestedCollections: suggestions,
584
+ steps,
585
+ };
586
+ }
587
+
588
+ if (!indexedReady) {
589
+ return {
590
+ ready: false,
591
+ stage: "indexing",
592
+ headline: "GNO is almost ready. Finish the first indexing run",
593
+ detail:
594
+ status.activeDocuments > 0 && status.embeddingBacklog > 0
595
+ ? `Finish embeddings for ${summarizeCount(status.embeddingBacklog, "chunk")} to unlock semantic search and local answers.`
596
+ : status.activeDocuments > 0
597
+ ? "The first sync started. Let embeddings finish so semantic search and answers can fully light up."
598
+ : "Run the first sync to populate the index from the folders you connected.",
599
+ suggestedCollections: suggestions,
600
+ steps,
601
+ };
602
+ }
603
+
604
+ return {
605
+ ready: true,
606
+ stage: "ready",
607
+ headline: "Workspace ready",
608
+ detail: "Your folders, local models, and first index are all in place.",
609
+ suggestedCollections: suggestions,
610
+ steps,
611
+ };
612
+ }
613
+
614
+ export async function buildAppStatus(
615
+ ctx: ServerContext,
616
+ deps: StatusBuildDeps = {}
617
+ ): Promise<AppStatusResponse> {
618
+ const result = await ctx.store.getStatus();
619
+ if (!result.ok) {
620
+ throw result.error;
621
+ }
622
+
623
+ const status = result.value;
624
+ const preset = getActivePreset(ctx.config);
625
+ const [modelCheck, diskCheck, suggestions, bootstrap] = await Promise.all([
626
+ buildModelCheck(ctx, deps),
627
+ buildDiskCheck(status, deps),
628
+ deps.listSuggestedCollections?.() ?? listSuggestedCollections(),
629
+ buildBootstrapState(ctx),
630
+ ]);
631
+
632
+ const backgroundCheck = buildBackgroundCheck(ctx, status);
633
+ const checks = [
634
+ buildCollectionCheck(status),
635
+ buildIndexingCheck(status),
636
+ modelCheck,
637
+ diskCheck,
638
+ backgroundCheck,
639
+ ].filter((check): check is HealthCheck => check !== null);
640
+
641
+ const embedState = ctx.scheduler?.getState();
642
+ const watchState = ctx.watchService?.getState();
643
+ const eventState = ctx.eventBus?.getState();
644
+
645
+ const healthState = getCheckState(checks);
646
+ const healthSummary =
647
+ healthState === "healthy"
648
+ ? "Folders, local models, and disk all look ready."
649
+ : healthState === "setup-required"
650
+ ? "Finish first-run setup to make GNO useful without touching the terminal."
651
+ : "GNO works, but a few issues still need attention before it feels reliable.";
652
+
653
+ return {
654
+ indexName: status.indexName,
655
+ configPath: status.configPath,
656
+ dbPath: status.dbPath,
657
+ collections: status.collections.map((collection) => ({
658
+ name: collection.name,
659
+ path: collection.path,
660
+ documentCount: collection.activeDocuments,
661
+ chunkCount: collection.totalChunks,
662
+ embeddedCount: collection.embeddedChunks,
663
+ })),
664
+ totalDocuments: status.activeDocuments,
665
+ totalChunks: status.totalChunks,
666
+ embeddingBacklog: status.embeddingBacklog,
667
+ lastUpdated: status.lastUpdatedAt,
668
+ recentErrors: status.recentErrors,
669
+ healthy: checks.every((check) => check.status === "ok"),
670
+ activePreset: {
671
+ id: preset.id,
672
+ name: preset.name,
673
+ },
674
+ capabilities: ctx.capabilities,
675
+ onboarding: buildOnboarding(status, modelCheck, suggestions, preset.name),
676
+ health: {
677
+ state: healthState,
678
+ summary: healthSummary,
679
+ checks,
680
+ },
681
+ background: {
682
+ watcher: {
683
+ expectedCollections: watchState?.expectedCollections ?? [],
684
+ activeCollections: watchState?.activeCollections ?? [],
685
+ failedCollections: watchState?.failedCollections ?? [],
686
+ queuedCollections: watchState?.queuedCollections ?? [],
687
+ syncingCollections: watchState?.syncingCollections ?? [],
688
+ lastEventAt: watchState?.lastEventAt ?? null,
689
+ lastSyncAt: watchState?.lastSyncAt ?? null,
690
+ },
691
+ embedding: {
692
+ available: ctx.scheduler != null,
693
+ pendingDocCount: embedState?.pendingDocCount ?? 0,
694
+ running: embedState?.running ?? false,
695
+ nextRunAt: embedState?.nextRunAt ?? null,
696
+ lastRunAt: embedState?.lastRunAt ?? null,
697
+ lastResult: embedState?.lastResult ?? null,
698
+ },
699
+ events: {
700
+ connectedClients: eventState?.connectedClients ?? 0,
701
+ retryMs: eventState?.retryMs ?? 0,
702
+ },
703
+ },
704
+ bootstrap,
705
+ };
706
+ }