@gmickel/gno 1.12.4 → 1.13.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 (61) hide show
  1. package/README.md +57 -30
  2. package/assets/skill/SKILL.md +5 -0
  3. package/assets/skill/cli-reference.md +16 -6
  4. package/assets/skill/mcp-reference.md +22 -3
  5. package/package.json +2 -1
  6. package/src/app/constants.ts +43 -10
  7. package/src/app/index-name.ts +127 -0
  8. package/src/cli/commands/doctor-activation.ts +151 -0
  9. package/src/cli/commands/doctor.ts +41 -16
  10. package/src/cli/commands/get.ts +18 -0
  11. package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
  12. package/src/cli/commands/mcp/config-discovery.ts +42 -0
  13. package/src/cli/commands/mcp/config-editors.ts +432 -0
  14. package/src/cli/commands/mcp/config.ts +63 -160
  15. package/src/cli/commands/mcp/install.ts +75 -37
  16. package/src/cli/commands/mcp/paths.ts +141 -136
  17. package/src/cli/commands/mcp/server-entry.ts +66 -0
  18. package/src/cli/commands/mcp/status.ts +189 -57
  19. package/src/cli/commands/mcp/target-display.ts +30 -0
  20. package/src/cli/commands/mcp/uninstall.ts +29 -31
  21. package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
  22. package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
  23. package/src/cli/commands/multi-get.ts +31 -6
  24. package/src/cli/commands/status.ts +107 -11
  25. package/src/cli/program.ts +66 -20
  26. package/src/core/activation-connector-health.ts +19 -0
  27. package/src/core/activation-probe-plan.ts +321 -0
  28. package/src/core/activation-probe.ts +138 -0
  29. package/src/core/activation-receipt-store.ts +39 -0
  30. package/src/core/activation-status.ts +513 -0
  31. package/src/core/activation-verifier.ts +416 -0
  32. package/src/core/connector-environment.ts +68 -0
  33. package/src/core/connector-policy.ts +233 -0
  34. package/src/core/connector-verification-target.ts +150 -0
  35. package/src/core/connector-verifier.ts +497 -0
  36. package/src/core/indexed-reference.ts +33 -8
  37. package/src/core/runtime-entrypoint.ts +24 -0
  38. package/src/mcp/activation-verification-mode.ts +4 -0
  39. package/src/mcp/server.ts +9 -2
  40. package/src/sdk/client.ts +7 -0
  41. package/src/sdk/types.ts +1 -0
  42. package/src/serve/activation-health.ts +91 -0
  43. package/src/serve/background-runtime.ts +11 -1
  44. package/src/serve/connectors.ts +164 -19
  45. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  46. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  47. package/src/serve/public/components/HealthCenter.tsx +8 -2
  48. package/src/serve/public/globals.built.css +1 -1
  49. package/src/serve/public/pages/Connectors.tsx +216 -55
  50. package/src/serve/public/pages/Dashboard.tsx +1 -0
  51. package/src/serve/routes/api.ts +152 -8
  52. package/src/serve/server.ts +44 -9
  53. package/src/serve/status-model.ts +4 -0
  54. package/src/serve/status.ts +79 -35
  55. package/src/store/activation-receipts.ts +390 -0
  56. package/src/store/index.ts +8 -0
  57. package/src/store/migrations/012-activation-receipts.ts +38 -0
  58. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  59. package/src/store/migrations/index.ts +4 -0
  60. package/src/store/sqlite/adapter.ts +313 -53
  61. package/src/store/types.ts +118 -0
package/README.md CHANGED
@@ -92,9 +92,13 @@ gno daemon --detach # headless continuous indexing (background; --status / --st
92
92
 
93
93
  ## What's New
94
94
 
95
- > Latest release: [v1.8.0](./CHANGELOG.md#180---2026-06-05)
95
+ > Latest release: see [CHANGELOG.md](./CHANGELOG.md)
96
96
  > Full release history: [CHANGELOG.md](./CHANGELOG.md)
97
97
 
98
+ - **Retrieval-proven activation**: `gno status`, `gno doctor`, REST, and the
99
+ Web/Desktop dashboard now share a per-folder lexical retrieval proof. Local
100
+ semantic readiness remains independent, and installed MCP targets can run an
101
+ explicit read-only retrieval smoke from Connectors.
98
102
  - **Second-brain capture**: `gno capture`, REST `/api/capture`, SDK
99
103
  `client.capture()`, MCP `gno_capture`, and Web UI Quick Capture write
100
104
  provenance-rich notes from text, stdin, or files, including typed presets for
@@ -209,12 +213,18 @@ bun install -g @gmickel/gno
209
213
  brew install sqlite3
210
214
  ```
211
215
 
212
- Verify everything works:
216
+ Verify the local installation and corpus-derived lexical retrieval:
213
217
 
214
218
  ```bash
215
219
  gno doctor
220
+ gno status --json
216
221
  ```
217
222
 
223
+ `gno status` is passive with respect to models and connectors and exits 0 even
224
+ when its structured activation state is degraded. `gno doctor` exits 2 when any
225
+ configured folder fails the lexical proof; semantic models may still be pending
226
+ without blocking BM25 search.
227
+
218
228
  **Windows**: current validated target is `windows-x64`, with a packaged
219
229
  desktop beta zip now published on GitHub Releases. See
220
230
  [docs/WINDOWS.md](./docs/WINDOWS.md) for support scope and validation notes.
@@ -248,9 +258,16 @@ gno mcp install --target codex # OpenAI Codex CLI
248
258
  gno mcp install --target opencode # OpenCode
249
259
  gno mcp install --target amp # Amp
250
260
  gno mcp install --target lmstudio # LM Studio
251
- gno mcp install --target librechat # LibreChat
261
+ gno mcp install --target librechat --scope project # LibreChat
252
262
  ```
253
263
 
264
+ Each install records an absolute Bun/package entrypoint plus the active index,
265
+ config, data directory, and model cache, so desktop clients open the same GNO
266
+ workspace without relying on shell `PATH` or `GNO_*` inheritance. Inspect the
267
+ exact command, arguments, and workspace values with
268
+ `gno mcp install --dry-run --json`. If GNO is already configured in that
269
+ target, add `--force` to preview the replacement without writing it.
270
+
254
271
  Check status: `gno mcp status`
255
272
 
256
273
  #### Skills (Claude Code, Codex, OpenCode, OpenClaw)
@@ -340,6 +357,7 @@ import { createGnoClient } from "@gmickel/gno";
340
357
 
341
358
  const client = await createGnoClient({
342
359
  configPath: "/Users/me/.config/gno/index.yml",
360
+ indexName: "research",
343
361
  });
344
362
 
345
363
  // Fast exact search
@@ -372,7 +390,7 @@ await client.close();
372
390
 
373
391
  Core SDK surface:
374
392
 
375
- - `createGnoClient({ config | configPath, dbPath? })`
393
+ - `createGnoClient({ config | configPath, dbPath?, indexName? })`
376
394
  - `search`, `vsearch`, `query`, `ask`
377
395
  - `get`, `multiGet`, `list`, `status`
378
396
  - `update`, `embed`, `index`
@@ -556,7 +574,11 @@ Open `http://localhost:3000` to:
556
574
  - **Same capture contract everywhere**: CLI, MCP `gno_capture`, REST `/api/capture`, SDK `client.capture()`, and Web UI Quick Capture return the same provenance receipt shape
557
575
  - **Ask**: AI-powered Q&A with citations
558
576
  - **Manage Collections**: Add, remove, and re-index collections
559
- - **Connect agents**: Install core Skill/MCP integrations from the app
577
+ - **Verify retrieval**: See each folder's lexical proof, exact failed stage,
578
+ and remediation without waiting for semantic models
579
+ - **Connect agents**: Install core Skill/MCP integrations; explicitly verify
580
+ configured MCP retrieval without changing client config. Skill installation
581
+ is visible, but client runtime execution cannot be proven automatically
560
582
  - **Manage files safely**: Rename, reveal, or move editable files to Trash with explicit index-vs-disk semantics
561
583
  - **Refactor files safely**: Move, duplicate, and organize editable notes with reference warnings
562
584
  - **Switch presets**: Change models live without restart
@@ -697,32 +719,37 @@ curl -X POST http://localhost:3000/api/ask \
697
719
 
698
720
  # Index status
699
721
  curl http://localhost:3000/api/status
700
- ```
701
722
 
702
- | Endpoint | Method | Description |
703
- | :---------------------------- | :----- | :-------------------------- |
704
- | `/api/query` | POST | Hybrid search (recommended) |
705
- | `/api/search` | POST | BM25 keyword search |
706
- | `/api/ask` | POST | AI-powered Q&A |
707
- | `/api/docs` | GET | List documents |
708
- | `/api/docs` | POST | Create document |
709
- | `/api/docs/:id` | PUT | Update document content |
710
- | `/api/docs/:id/move` | POST | Move editable document |
711
- | `/api/docs/:id/duplicate` | POST | Duplicate editable document |
712
- | `/api/docs/:id/refactor-plan` | POST | Preview file-op warnings |
713
- | `/api/docs/:id/deactivate` | POST | Remove from index |
714
- | `/api/doc` | GET | Get document content |
715
- | `/api/doc/:id/sections` | GET | Get document sections |
716
- | `/api/collections` | POST | Add collection |
717
- | `/api/collections/:name` | DELETE | Remove collection |
718
- | `/api/folders` | POST | Create folder |
719
- | `/api/sync` | POST | Trigger re-index |
720
- | `/api/status` | GET | Index statistics |
721
- | `/api/note-presets` | GET | List note presets |
722
- | `/api/presets` | GET | List model presets |
723
- | `/api/presets` | POST | Switch preset |
724
- | `/api/models/pull` | POST | Download models |
725
- | `/api/models/status` | GET | Download progress |
723
+ # Process liveness only
724
+ curl http://localhost:3000/api/health
725
+ ```
726
+
727
+ | Endpoint | Method | Description |
728
+ | :---------------------------- | :----- | :--------------------------- |
729
+ | `/api/query` | POST | Hybrid search (recommended) |
730
+ | `/api/search` | POST | BM25 keyword search |
731
+ | `/api/ask` | POST | AI-powered Q&A |
732
+ | `/api/docs` | GET | List documents |
733
+ | `/api/docs` | POST | Create document |
734
+ | `/api/docs/:id` | PUT | Update document content |
735
+ | `/api/docs/:id/move` | POST | Move editable document |
736
+ | `/api/docs/:id/duplicate` | POST | Duplicate editable document |
737
+ | `/api/docs/:id/refactor-plan` | POST | Preview file-op warnings |
738
+ | `/api/docs/:id/deactivate` | POST | Remove from index |
739
+ | `/api/doc` | GET | Get document content |
740
+ | `/api/doc/:id/sections` | GET | Get document sections |
741
+ | `/api/collections` | POST | Add collection |
742
+ | `/api/collections/:name` | DELETE | Remove collection |
743
+ | `/api/folders` | POST | Create folder |
744
+ | `/api/sync` | POST | Trigger re-index |
745
+ | `/api/status` | GET | Index and activation state |
746
+ | `/api/health` | GET | Process liveness only |
747
+ | `/api/connectors/verify` | POST | Explicit read-only MCP proof |
748
+ | `/api/note-presets` | GET | List note presets |
749
+ | `/api/presets` | GET | List model presets |
750
+ | `/api/presets` | POST | Switch preset |
751
+ | `/api/models/pull` | POST | Download models |
752
+ | `/api/models/status` | GET | Download progress |
726
753
 
727
754
  No authentication. No rate limits. Build custom tools, automate workflows, integrate with any language.
728
755
 
@@ -226,6 +226,11 @@ gno graph --from gno://notes/a.md --to gno://notes/b.md
226
226
  --no-pager Disable paging
227
227
  ```
228
228
 
229
+ Index names follow the CLI filesystem-identity contract: 1–64 UTF-16 code
230
+ units, letter/number first, no trailing space or `.`, no `..`, separators, or
231
+ platform-invalid punctuation. NFC/case-equivalent names share one identity.
232
+ See `docs/CLI.md` under Global Options for the complete byte limits.
233
+
229
234
  Non-default index search results may include `?index=<name>` on `gno://` URIs.
230
235
  Keep that query string when passing the URI to `gno get`, SDK `get()`, MCP
231
236
  `gno_get`, or an MCP resource read: it selects the named database. Batch reads
@@ -17,6 +17,11 @@ All commands accept:
17
17
  | `--no-pager` | Disable automatic paging |
18
18
  | `--offline` | Use cached models only |
19
19
 
20
+ Index names use 1–64 UTF-16 code units, start with a letter or number, and
21
+ reject trailing space/dot, `..`, separators, controls, and platform-invalid
22
+ punctuation. NFC/case-equivalent spellings share one identity. See
23
+ `docs/CLI.md` under Global Options for the complete canonical-byte contract.
24
+
20
25
  ## Initialization
21
26
 
22
27
  ### gno init
@@ -523,12 +528,17 @@ Install GNO as MCP server in client configurations.
523
528
  gno mcp install [options]
524
529
  ```
525
530
 
526
- | Option | Default | Description |
527
- | -------------- | -------------- | ------------------------------------------------ |
528
- | `-t, --target` | claude-desktop | Target: `claude-desktop`, `claude-code`, `codex` |
529
- | `-s, --scope` | user | Scope: `user`, `project` |
530
- | `-f, --force` | false | Overwrite existing config |
531
- | `--dry-run` | false | Preview changes |
531
+ | Option | Default | Description |
532
+ | -------------- | -------------- | ------------------------- |
533
+ | `-t, --target` | claude-desktop | Target client (see below) |
534
+ | `-s, --scope` | target default | Scope: `user`, `project` |
535
+ | `-f, --force` | false | Overwrite existing config |
536
+ | `--dry-run` | false | Preview changes |
537
+
538
+ Targets: `claude-desktop`, `claude-code`, `codex`, `cursor`, `zed`,
539
+ `windsurf`, `opencode`, `amp`, `lmstudio`, and `librechat`. Project scope is
540
+ supported by Claude Code, Codex, Cursor, OpenCode, and LibreChat.
541
+ LibreChat is project-only; all other targets default to user scope.
532
542
 
533
543
  Examples:
534
544
 
@@ -21,14 +21,27 @@ gno mcp install --enable-write
21
21
 
22
22
  ### Claude Desktop
23
23
 
24
- Add to `claude_desktop_config.json`:
24
+ Run `gno mcp install --dry-run --json`, then add the reported absolute values to
25
+ `claude_desktop_config.json`:
25
26
 
26
27
  ```json
27
28
  {
28
29
  "mcpServers": {
29
30
  "gno": {
30
- "command": "gno",
31
- "args": ["mcp"]
31
+ "command": "/absolute/path/to/bun",
32
+ "args": [
33
+ "run",
34
+ "/absolute/path/to/@gmickel/gno/src/index.ts",
35
+ "--index",
36
+ "default",
37
+ "--config",
38
+ "/absolute/path/to/index.yml",
39
+ "mcp"
40
+ ],
41
+ "env": {
42
+ "GNO_DATA_DIR": "/absolute/path/to/data",
43
+ "GNO_CACHE_DIR": "/absolute/path/to/cache"
44
+ }
32
45
  }
33
46
  }
34
47
  }
@@ -47,6 +60,12 @@ gno mcp install -t claude-code -s user # User scope
47
60
  gno mcp install -t claude-code -s project # Project scope
48
61
  ```
49
62
 
63
+ Installed entries deliberately pin the current Bun/package entrypoint, active
64
+ index, absolute config, data directory, and model cache. Standard clients use
65
+ `env`; OpenCode uses `environment`; Codex uses `~/.codex/config.toml` or project
66
+ `.codex/config.toml` with `[mcp_servers.gno]` and `[mcp_servers.gno.env]`.
67
+ Do not shorten a generated entry to `gno mcp`.
68
+
50
69
  ## Check Status
51
70
 
52
71
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.12.4",
3
+ "version": "1.13.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",
@@ -160,6 +160,7 @@
160
160
  "commander": "14.0.3",
161
161
  "embla-carousel-react": "8.6.0",
162
162
  "franc": "6.2.0",
163
+ "jsonc-parser": "3.3.1",
163
164
  "lucide-react": "1.8.0",
164
165
  "markitdown-ts": "0.0.9",
165
166
  "minimatch": "10.2.3",
@@ -10,6 +10,11 @@ import { basename, join } from "node:path";
10
10
 
11
11
  // Bun supports JSON imports natively - version single source of truth
12
12
  import pkg from "../../package.json";
13
+ import {
14
+ assertValidIndexName,
15
+ indexNamesMatch,
16
+ resolveIndexDbFilename,
17
+ } from "./index-name";
13
18
 
14
19
  // ─────────────────────────────────────────────────────────────────────────────
15
20
  // Brand / Product Identity
@@ -218,7 +223,26 @@ export function getIndexDbPath(
218
223
  indexName: string = DEFAULT_INDEX_NAME,
219
224
  dirs: ResolvedDirs = resolveDirs()
220
225
  ): string {
221
- return join(dirs.data, `index-${indexName}.sqlite`);
226
+ assertValidIndexName(indexName);
227
+ let existingFilenames: string[] = [];
228
+ try {
229
+ existingFilenames = [
230
+ ...new Bun.Glob("index-*.sqlite").scanSync({
231
+ cwd: dirs.data,
232
+ onlyFiles: false,
233
+ }),
234
+ ];
235
+ } catch (error) {
236
+ if (
237
+ !error ||
238
+ typeof error !== "object" ||
239
+ !("code" in error) ||
240
+ error.code !== "ENOENT"
241
+ ) {
242
+ throw error;
243
+ }
244
+ }
245
+ return join(dirs.data, resolveIndexDbFilename(indexName, existingFilenames));
222
246
  }
223
247
 
224
248
  /**
@@ -265,8 +289,12 @@ export function buildUri(
265
289
  .map((segment) => encodeURIComponent(segment))
266
290
  .join("/");
267
291
  const uri = `${URI_PREFIX}${collection}/${encodedPath}`;
268
- const indexName = options.indexName?.trim();
269
- if (!indexName || indexName === DEFAULT_INDEX_NAME) {
292
+ const indexName = options.indexName;
293
+ if (indexName === undefined) {
294
+ return uri;
295
+ }
296
+ assertValidIndexName(indexName);
297
+ if (indexNamesMatch(indexName, DEFAULT_INDEX_NAME)) {
270
298
  return uri;
271
299
  }
272
300
  return `${uri}?index=${encodeURIComponent(indexName)}`;
@@ -287,8 +315,8 @@ export function parseUri(uri: string): ParsedGnoUri | null {
287
315
  if (slashIndex === -1) {
288
316
  // gno://collection (no path)
289
317
  const [collectionWithQuery, query = ""] = rest.split("?", 2);
290
- const indexName = new URLSearchParams(query).get("index")?.trim();
291
- return indexName
318
+ const indexName = new URLSearchParams(query).get("index");
319
+ return indexName !== null
292
320
  ? {
293
321
  collection: collectionWithQuery ?? rest,
294
322
  path: "",
@@ -307,8 +335,10 @@ export function parseUri(uri: string): ParsedGnoUri | null {
307
335
  // decodeURIComponent throws on malformed percent-encoding
308
336
  try {
309
337
  const path = decodeURIComponent(encodedPath);
310
- const indexName = new URLSearchParams(query).get("index")?.trim();
311
- return indexName ? { collection, path, indexName } : { collection, path };
338
+ const indexName = new URLSearchParams(query).get("index");
339
+ return indexName !== null
340
+ ? { collection, path, indexName }
341
+ : { collection, path };
312
342
  } catch {
313
343
  return null;
314
344
  }
@@ -319,12 +349,15 @@ export function parseUri(uri: string): ParsedGnoUri | null {
319
349
  */
320
350
  export function decorateUriForIndex(uri: string, indexName?: string): string {
321
351
  const parsed = parseUri(uri);
322
- const normalizedIndex = indexName?.trim();
323
- if (!parsed || !normalizedIndex || normalizedIndex === DEFAULT_INDEX_NAME) {
352
+ if (!parsed || indexName === undefined) {
353
+ return stripUriIndex(uri);
354
+ }
355
+ assertValidIndexName(indexName);
356
+ if (indexNamesMatch(indexName, DEFAULT_INDEX_NAME)) {
324
357
  return stripUriIndex(uri);
325
358
  }
326
359
  return buildUri(parsed.collection, parsed.path, {
327
- indexName: normalizedIndex,
360
+ indexName,
328
361
  });
329
362
  }
330
363
 
@@ -0,0 +1,127 @@
1
+ /** Shared index-name contract for filesystem paths and connector trust checks. */
2
+
3
+ export const MAX_INDEX_NAME_LENGTH = 64;
4
+
5
+ const SAFE_INDEX_NAME_REGEX = /^[\p{L}\p{N}][\p{L}\p{M}\p{N} ._-]*$/u;
6
+ const INDEX_DB_PREFIX = "index-";
7
+ const INDEX_DB_SUFFIX = ".sqlite";
8
+ const MAX_PORTABLE_FILENAME_COMPONENT_LENGTH = 255;
9
+ const MAX_INDEX_IDENTITY_STORAGE_LENGTH =
10
+ MAX_PORTABLE_FILENAME_COMPONENT_LENGTH -
11
+ INDEX_DB_PREFIX.length -
12
+ INDEX_DB_SUFFIX.length;
13
+ const UTF8_ENCODER = new TextEncoder();
14
+
15
+ export const INDEX_NAME_REQUIREMENTS =
16
+ "use 1-64 letters, marks, numbers, internal spaces, '.', '_' or '-', start with a letter or number, do not end with a space or '.', do not include '..', and fit the portable database filename limit";
17
+
18
+ function hasSafeIndexNameSyntax(value: string): boolean {
19
+ return (
20
+ SAFE_INDEX_NAME_REGEX.test(value) &&
21
+ !/[ .]$/.test(value) &&
22
+ !value.includes("..")
23
+ );
24
+ }
25
+
26
+ function canonicalizeIndexNameUnchecked(value: string): string {
27
+ return value
28
+ .normalize("NFC")
29
+ .toLowerCase()
30
+ .toUpperCase()
31
+ .toLowerCase()
32
+ .normalize("NFC");
33
+ }
34
+
35
+ function fitsIndexIdentityStorage(value: string): boolean {
36
+ return (
37
+ value.length <= MAX_INDEX_IDENTITY_STORAGE_LENGTH &&
38
+ UTF8_ENCODER.encode(value).byteLength <= MAX_INDEX_IDENTITY_STORAGE_LENGTH
39
+ );
40
+ }
41
+
42
+ /** Return whether a value is a canonical, filesystem-safe GNO index name. */
43
+ export function isValidIndexName(value: unknown): value is string {
44
+ if (
45
+ typeof value !== "string" ||
46
+ value.length > MAX_INDEX_NAME_LENGTH ||
47
+ !hasSafeIndexNameSyntax(value)
48
+ ) {
49
+ return false;
50
+ }
51
+ return fitsIndexIdentityStorage(canonicalizeIndexNameUnchecked(value));
52
+ }
53
+
54
+ /** Fail closed before an index name can influence a database path. */
55
+ export function assertValidIndexName(value: unknown): asserts value is string {
56
+ if (!isValidIndexName(value)) {
57
+ throw new TypeError(`Invalid index name: ${INDEX_NAME_REQUIREMENTS}.`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Return the cross-platform logical identity for an index name.
63
+ *
64
+ * APFS and Windows filesystems collapse canonical Unicode and case variants,
65
+ * while common Linux filesystems do not. GNO applies one identity everywhere
66
+ * so URI routing and database selection cannot disagree by platform.
67
+ */
68
+ export function canonicalizeIndexName(value: string): string {
69
+ assertValidIndexName(value);
70
+ // The lower/upper/lower closure covers multi-character folds (ß/SS),
71
+ // compatibility case pairs (ſ/S), and positional forms (ς/Σ) that a plain
72
+ // lowercase pass misses but case-insensitive APFS aliases on disk.
73
+ return canonicalizeIndexNameUnchecked(value);
74
+ }
75
+
76
+ /** Compare two validated names using GNO's cross-platform identity rules. */
77
+ export function indexNamesMatch(left: string, right: string): boolean {
78
+ return canonicalizeIndexName(left) === canonicalizeIndexName(right);
79
+ }
80
+
81
+ function indexNameFromDbFilename(filename: string): string | null {
82
+ if (
83
+ !filename.startsWith(INDEX_DB_PREFIX) ||
84
+ !filename.endsWith(INDEX_DB_SUFFIX)
85
+ ) {
86
+ return null;
87
+ }
88
+ const name = filename.slice(INDEX_DB_PREFIX.length, -INDEX_DB_SUFFIX.length);
89
+ const isCanonicalStoredIdentity =
90
+ hasSafeIndexNameSyntax(name) &&
91
+ fitsIndexIdentityStorage(name) &&
92
+ canonicalizeIndexNameUnchecked(name) === name;
93
+ return isValidIndexName(name) || isCanonicalStoredIdentity ? name : null;
94
+ }
95
+
96
+ /**
97
+ * Select one database filename for a logical index identity.
98
+ *
99
+ * New indexes use the canonical filename. A single existing mixed-case or
100
+ * pre-normalized filename remains addressable for backward compatibility.
101
+ * Multiple legacy files with the same identity are unsafe on case-sensitive
102
+ * filesystems and fail closed instead of selecting one by directory order.
103
+ */
104
+ export function resolveIndexDbFilename(
105
+ indexName: string,
106
+ existingFilenames: Iterable<string> = []
107
+ ): string {
108
+ const identity = canonicalizeIndexName(indexName);
109
+ const matches: string[] = [];
110
+ for (const filename of existingFilenames) {
111
+ const existingName = indexNameFromDbFilename(filename);
112
+ if (
113
+ existingName !== null &&
114
+ canonicalizeIndexNameUnchecked(existingName) === identity
115
+ ) {
116
+ matches.push(filename);
117
+ }
118
+ }
119
+
120
+ const uniqueMatches = [...new Set(matches)].sort();
121
+ if (uniqueMatches.length > 1) {
122
+ throw new TypeError(
123
+ `Ambiguous index name "${indexName}": multiple database files share its canonical identity (${uniqueMatches.join(", ")}).`
124
+ );
125
+ }
126
+ return uniqueMatches[0] ?? `${INDEX_DB_PREFIX}${identity}${INDEX_DB_SUFFIX}`;
127
+ }
@@ -0,0 +1,151 @@
1
+ /** Passive retrieval activation diagnostics shared by `gno doctor`. */
2
+
3
+ import type { Config } from "../../config/types";
4
+ import type { ActivationStatus } from "../../core/activation-status";
5
+ import type { StorePort } from "../../store/types";
6
+ import type { DoctorCheck } from "./doctor";
7
+
8
+ import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
9
+ import { buildActivationStatus } from "../../core/activation-status";
10
+ import { ModelCache } from "../../llm/cache";
11
+ import { getActivePreset } from "../../llm/registry";
12
+ import { getConnectorVerificationTargets } from "../../serve/connectors";
13
+ import { SqliteAdapter } from "../../store/sqlite/adapter";
14
+
15
+ export interface DoctorActivationOptions {
16
+ configPath?: string;
17
+ indexName?: string;
18
+ }
19
+
20
+ async function unavailableActivation(
21
+ config: Config
22
+ ): Promise<ActivationStatus> {
23
+ return buildActivationStatus(
24
+ {} as StorePort,
25
+ config.collections.map(({ name }) => name),
26
+ {
27
+ verifyCollection: async () => ({
28
+ ok: false,
29
+ error: { code: "QUERY_FAILED", message: "Activation unavailable" },
30
+ }),
31
+ }
32
+ );
33
+ }
34
+
35
+ export async function buildDoctorActivation(
36
+ config: Config,
37
+ options: DoctorActivationOptions
38
+ ): Promise<ActivationStatus> {
39
+ const dbPath = getIndexDbPath(options.indexName);
40
+ if (!(await Bun.file(dbPath).exists())) {
41
+ return unavailableActivation(config);
42
+ }
43
+
44
+ const store = new SqliteAdapter();
45
+ store.setConfigPath(options.configPath ?? "");
46
+ const opened = await store.open(dbPath, config.ftsTokenizer);
47
+ if (!opened.ok) {
48
+ return unavailableActivation(config);
49
+ }
50
+
51
+ try {
52
+ const indexStatus = await store.getStatus();
53
+ const embedCached = await new ModelCache(getModelsCachePath()).isCached(
54
+ getActivePreset(config).embed
55
+ );
56
+ return await buildActivationStatus(
57
+ store,
58
+ config.collections.map(({ name }) => name),
59
+ {
60
+ semantic: {
61
+ modelsCached: embedCached,
62
+ embeddingBacklog: indexStatus.ok
63
+ ? indexStatus.value.embeddingBacklog
64
+ : 0,
65
+ },
66
+ connectorTargets: await getConnectorVerificationTargets(),
67
+ }
68
+ );
69
+ } finally {
70
+ await store.close();
71
+ }
72
+ }
73
+
74
+ export function checkRetrievalActivation(
75
+ activation: ActivationStatus
76
+ ): DoctorCheck {
77
+ if (activation.healthy) {
78
+ const semanticStates = [
79
+ ...new Set(
80
+ activation.collections.map(
81
+ ({ semanticAvailability }) => semanticAvailability.code
82
+ )
83
+ ),
84
+ ];
85
+ return {
86
+ name: "retrieval-activation",
87
+ status: "ok",
88
+ message: `${activation.collections.length} collection${activation.collections.length === 1 ? "" : "s"} passed lexical retrieval proof`,
89
+ details: [
90
+ `Semantic retrieval remains separate (${semanticStates.join(", ")}).`,
91
+ ],
92
+ };
93
+ }
94
+
95
+ const failed = activation.collections.filter(({ ready }) => !ready);
96
+ const details = failed.flatMap(({ collection, remediation }) =>
97
+ remediation
98
+ ? [
99
+ `${collection}: ${remediation.stage}/${remediation.code}`,
100
+ `Run: ${remediation.command}`,
101
+ ]
102
+ : [`${collection}: activation unavailable`]
103
+ );
104
+ return {
105
+ name: "retrieval-activation",
106
+ status: "error",
107
+ message:
108
+ activation.collections.length === 0
109
+ ? "No collections configured. Run: gno collection add"
110
+ : activation.usable
111
+ ? `${failed.length} collection${failed.length === 1 ? "" : "s"} failed lexical retrieval proof`
112
+ : "No configured collection passed lexical retrieval proof",
113
+ details,
114
+ };
115
+ }
116
+
117
+ export function checkConnectorActivation(
118
+ activation: ActivationStatus
119
+ ): DoctorCheck | null {
120
+ const { projected, total, truncated } = activation.connectorProjection;
121
+ const omitted = total - projected;
122
+ const observed = activation.connectors.filter(
123
+ ({ code }) =>
124
+ code !== "connector_not_configured" &&
125
+ code !== "target_runtime_unverifiable"
126
+ );
127
+ if (observed.length === 0 && !truncated) {
128
+ return null;
129
+ }
130
+ const incomplete = observed.filter(({ status }) => status !== "passed");
131
+ const details = incomplete.map(
132
+ ({ collection, target, status, code, remediation }) =>
133
+ `${target}/${collection}: ${status}${code ? `/${code}` : ""}${remediation ? `. ${remediation}` : ""}`
134
+ );
135
+ if (truncated) {
136
+ details.unshift(
137
+ `${omitted} target/collection checks were omitted by the bounded status projection; no result is claimed for them.`
138
+ );
139
+ }
140
+ return {
141
+ name: "connector-activation",
142
+ status: incomplete.length > 0 || truncated ? "warn" : "ok",
143
+ message:
144
+ incomplete.length > 0
145
+ ? `${incomplete.length} connector proof${incomplete.length === 1 ? "" : "s"} pending or failed`
146
+ : truncated
147
+ ? `${projected} of ${total} connector target/collection checks projected`
148
+ : `${observed.length} connector proof${observed.length === 1 ? "" : "s"} passed`,
149
+ details,
150
+ };
151
+ }