@gmickel/gno 2.11.0 → 2.12.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 (61) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +3 -2
  3. package/assets/spa-production.json.gz +0 -0
  4. package/browser-extension/artifacts/{gno-browser-clipper-v2.11.0.zip → gno-browser-clipper-v2.12.1.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v2.12.1.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +8 -5
  8. package/spec/cli.md +100 -34
  9. package/spec/evals-agentic.md +3 -1
  10. package/spec/mcp.md +9 -3
  11. package/spec/output-schemas/status.schema.json +28 -0
  12. package/src/app/context-agent-projection.ts +10 -1
  13. package/src/cli/commands/agents/block.ts +2 -2
  14. package/src/cli/commands/agents/commands.ts +6 -5
  15. package/src/cli/commands/agents/engine.ts +15 -2
  16. package/src/cli/commands/agents/harnesses.ts +21 -14
  17. package/src/cli/commands/cleanup.ts +46 -6
  18. package/src/cli/commands/collection/remove.ts +1 -0
  19. package/src/cli/commands/collection/rename.ts +1 -0
  20. package/src/cli/commands/doctor.ts +37 -0
  21. package/src/cli/commands/mcp/paths.ts +2 -1
  22. package/src/cli/commands/setup-semantic.ts +6 -1
  23. package/src/cli/commands/shared.ts +26 -1
  24. package/src/cli/commands/skill/paths.ts +20 -2
  25. package/src/cli/commands/status.ts +89 -15
  26. package/src/cli/program.ts +25 -5
  27. package/src/core/activation-probe-plan.ts +0 -1
  28. package/src/core/config-mutation.ts +72 -55
  29. package/src/core/memory-remember.ts +0 -1
  30. package/src/core/module-path.ts +33 -0
  31. package/src/core/runtime-entrypoint.ts +3 -1
  32. package/src/ingestion/file-processor.ts +6 -3
  33. package/src/ingestion/prepare-file.ts +14 -4
  34. package/src/ingestion/source-availability/root.ts +138 -0
  35. package/src/ingestion/sync.ts +5 -19
  36. package/src/llm/native-worker/client.ts +8 -4
  37. package/src/llm/native-worker/errors.ts +26 -3
  38. package/src/mcp/http-transport.ts +7 -2
  39. package/src/mcp/server.ts +3 -0
  40. package/src/mcp/tool-descriptions-core.ts +2 -2
  41. package/src/mcp/tools/index.ts +30 -1
  42. package/src/mcp/tools/status.ts +17 -2
  43. package/src/pipeline/query-snippet.ts +266 -0
  44. package/src/pipeline/search.ts +149 -19
  45. package/src/sdk/client.ts +14 -2
  46. package/src/sdk/types.ts +6 -1
  47. package/src/serve/public/components/ThoroughnessSelector.tsx +1 -1
  48. package/src/serve/public/globals.built.css +1 -1
  49. package/src/serve/public/index.html +5 -0
  50. package/src/serve/public/pages/Collections.tsx +8 -0
  51. package/src/serve/public/pages/Search.tsx +9 -1
  52. package/src/serve/resident-request.ts +8 -0
  53. package/src/serve/resident-runtime.ts +25 -3
  54. package/src/serve/spa-production-build.ts +7 -3
  55. package/src/serve/status-model.ts +2 -0
  56. package/src/serve/status.ts +29 -0
  57. package/src/sessions/config-refresh.ts +6 -0
  58. package/src/sessions/import-child.ts +4 -1
  59. package/src/store/sqlite/adapter.ts +349 -200
  60. package/src/store/types.ts +45 -7
  61. package/browser-extension/artifacts/gno-browser-clipper-v2.11.0.zip.sha256 +0 -1
@@ -6,7 +6,7 @@
6
6
  * @module src/cli/commands/cleanup
7
7
  */
8
8
 
9
- import type { CleanupStats } from "../../store/types";
9
+ import type { CleanupStats, StaleCollection } from "../../store/types";
10
10
 
11
11
  import { getIndexDbPath } from "../../app/constants";
12
12
  import { isInitialized, loadConfig } from "../../config";
@@ -20,14 +20,26 @@ export interface CleanupOptions {
20
20
  configPath?: string;
21
21
  /** Index name */
22
22
  indexName?: string;
23
+ /** Called with the collections about to be deleted, before deleting them. */
24
+ onDropping?: (collections: StaleCollection[]) => void;
23
25
  }
24
26
 
25
27
  /**
26
28
  * Result of cleanup command.
27
29
  */
28
30
  export type CleanupResult =
29
- | { success: true; stats: CleanupStats }
30
- | { success: false; error: string };
31
+ | {
32
+ success: true;
33
+ stats: CleanupStats;
34
+ /** Indexed collections the config omits, deleted by this cleanup. */
35
+ droppedCollections: StaleCollection[];
36
+ }
37
+ | {
38
+ success: false;
39
+ error: string;
40
+ /** Collections already deleted when a later step failed. */
41
+ droppedCollections?: StaleCollection[];
42
+ };
31
43
 
32
44
  /**
33
45
  * Execute gno cleanup command.
@@ -64,17 +76,32 @@ export async function cleanup(
64
76
  try {
65
77
  // Drop collections no longer in the config first (their documents follow
66
78
  // by cascade), so their content, chunks and vectors count as orphans.
67
- const syncResult = await store.syncCollections(config.collections);
79
+ // This is the explicit removal path; it names what it deletes.
80
+ const stale = await store.listStaleCollections(
81
+ config.collections.map(({ name }) => name)
82
+ );
83
+ if (!stale.ok) {
84
+ return { success: false, error: stale.error.message };
85
+ }
86
+ const droppedCollections = stale.value;
87
+ if (droppedCollections.length > 0) options.onDropping?.(droppedCollections);
88
+ const syncResult = await store.syncCollections(config.collections, {
89
+ remove: "stale",
90
+ });
68
91
  if (!syncResult.ok) {
69
92
  return { success: false, error: syncResult.error.message };
70
93
  }
71
94
 
72
95
  const cleanupResult = await store.cleanupOrphans();
73
96
  if (!cleanupResult.ok) {
74
- return { success: false, error: cleanupResult.error.message };
97
+ return {
98
+ success: false,
99
+ error: cleanupResult.error.message,
100
+ droppedCollections,
101
+ };
75
102
  }
76
103
 
77
- return { success: true, stats: cleanupResult.value };
104
+ return { success: true, stats: cleanupResult.value, droppedCollections };
78
105
  } finally {
79
106
  await store.close();
80
107
  }
@@ -118,3 +145,16 @@ export function formatCleanup(result: CleanupResult): string {
118
145
 
119
146
  return lines.join("\n");
120
147
  }
148
+
149
+ /** Lines naming the collections cleanup is about to delete. */
150
+ export function formatDroppingCollections(
151
+ collections: readonly StaleCollection[]
152
+ ): string {
153
+ return [
154
+ "Deleting collections not in this config:",
155
+ ...collections.map(
156
+ (c) =>
157
+ ` ${c.name}: ${c.documents} ${c.documents === 1 ? "document" : "documents"}`
158
+ ),
159
+ ].join("\n");
160
+ }
@@ -19,6 +19,7 @@ export async function collectionRemove(
19
19
  configPath: options.configPath,
20
20
  indexName: options.indexName,
21
21
  syncConfig: false,
22
+ allowStaleCollections: true,
22
23
  allowEmptyCollections: true,
23
24
  });
24
25
  if (!initialized.ok) {
@@ -23,6 +23,7 @@ export async function collectionRename(
23
23
  configPath: options.configPath,
24
24
  indexName: options.indexName,
25
25
  syncConfig: false,
26
+ allowStaleCollections: true,
26
27
  allowEmptyCollections: true,
27
28
  });
28
29
  if (!initialized.ok) {
@@ -33,6 +33,7 @@ import {
33
33
  import { formatVectorPartitionLines } from "../../core/vector-partition-status";
34
34
  import { getEmbeddingFingerprint } from "../../embed/fingerprint";
35
35
  import { getCodeChunkingStatus } from "../../ingestion/chunker";
36
+ import { checkCollectionRoots } from "../../ingestion/source-availability/root";
36
37
  import { ModelCache } from "../../llm/cache";
37
38
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
38
39
  import { SqliteAdapter } from "../../store/sqlite/adapter";
@@ -739,6 +740,37 @@ export async function checkFindingsPass(
739
740
  };
740
741
  }
741
742
 
743
+ /** Configured collection roots that are missing or unreadable right now. */
744
+ export async function checkCollectionRootAccess(
745
+ config: Config
746
+ ): Promise<DoctorCheck> {
747
+ const name = "collection-roots";
748
+ const errors = await checkCollectionRoots(config.collections);
749
+ const unavailable = config.collections.flatMap((collection) => {
750
+ const error = errors[collection.name];
751
+ return error ? [{ collection, error }] : [];
752
+ });
753
+ if (unavailable.length === 0) {
754
+ return {
755
+ name,
756
+ status: "ok",
757
+ message: `${config.collections.length} collection root(s) available`,
758
+ };
759
+ }
760
+ return {
761
+ name,
762
+ status: "warn",
763
+ message: `${unavailable.length} collection root(s) missing or unreadable: ${unavailable.map(({ collection }) => collection.name).join(", ")}`,
764
+ details: [
765
+ ...unavailable.map(
766
+ ({ collection, error }) =>
767
+ `${collection.name}: ${collection.path} [${error.code}] ${error.message}`
768
+ ),
769
+ "Reconnect the drive or restore the folder; if it moved, change the collection's path in the config, then run: gno update",
770
+ ],
771
+ };
772
+ }
773
+
742
774
  // ─────────────────────────────────────────────────────────────────────────────
743
775
  // Implementation
744
776
  // ─────────────────────────────────────────────────────────────────────────────
@@ -779,6 +811,10 @@ export async function doctor(
779
811
  // Code chunking capability
780
812
  checks.push(checkCodeChunking());
781
813
 
814
+ // Collection roots, checked now (documents stay indexed while one is away)
815
+ const rootsCheck = await checkCollectionRootAccess(config);
816
+ checks.push(rootsCheck);
817
+
782
818
  // Embedding fingerprint freshness
783
819
  checks.push(...(await checkEmbeddingFingerprints(config, options.indexName)));
784
820
 
@@ -801,6 +837,7 @@ export async function doctor(
801
837
  return {
802
838
  healthy:
803
839
  !hasErrors &&
840
+ rootsCheck.status === "ok" &&
804
841
  activation.healthy &&
805
842
  isConnectorActivationComplete(activation),
806
843
  checks,
@@ -12,6 +12,7 @@ import type { ConnectorWorkspaceEnvironment } from "../../../core/connector-envi
12
12
 
13
13
  import { resolveDirs } from "../../../app/constants";
14
14
  import { assertValidIndexName } from "../../../app/index-name";
15
+ import { modulePath } from "../../../core/module-path";
15
16
  import { getCurrentGnoEntrypoint } from "../../../core/runtime-entrypoint";
16
17
  import {
17
18
  DEFAULT_MCP_TOOL_PROFILE,
@@ -459,7 +460,7 @@ export function resolveLaunchPaths(): {
459
460
  entrypoint: LaunchPath;
460
461
  } {
461
462
  const entrypoint = getCurrentGnoEntrypoint();
462
- if (isBunfsPath(import.meta.path)) {
463
+ if (isBunfsPath(modulePath(import.meta))) {
463
464
  return {
464
465
  bun: { path: process.execPath, pinned: false },
465
466
  entrypoint: { path: entrypoint, pinned: false },
@@ -21,6 +21,7 @@ import type { FolderSetupReceipt } from "../../core/setup-receipt";
21
21
  import { VERSION } from "../../app/constants";
22
22
  import { canonicalizeIndexName } from "../../app/index-name";
23
23
  import { withWriteLock } from "../../core/file-lock";
24
+ import { moduleDir } from "../../core/module-path";
24
25
  import {
25
26
  setupFingerprint,
26
27
  setupRootFingerprint,
@@ -427,7 +428,11 @@ function createSemanticReceipt(
427
428
  async function defaultSpawnWorker(
428
429
  receipt: SetupSemanticReceipt
429
430
  ): Promise<SpawnedSemanticWorker> {
430
- const workerPath = join(import.meta.dir, "..", "setup-semantic-worker.ts");
431
+ const workerPath = join(
432
+ moduleDir(import.meta),
433
+ "..",
434
+ "setup-semantic-worker.ts"
435
+ );
431
436
  const descriptor = openSync(receipt.logPath, "a", 0o600);
432
437
  try {
433
438
  const child = Bun.spawn({
@@ -22,7 +22,10 @@ import {
22
22
  } from "../../config";
23
23
  import { tracePhase } from "../../core/phase-trace";
24
24
  import { BUDGET_ERROR_CODES } from "../../ingestion/file-processor";
25
- import { SqliteAdapter } from "../../store/sqlite/adapter";
25
+ import {
26
+ SqliteAdapter,
27
+ staleCollectionsMessage,
28
+ } from "../../store/sqlite/adapter";
26
29
  import { assertCliSessionBinding } from "../session-binding";
27
30
 
28
31
  /**
@@ -53,6 +56,10 @@ export interface InitStoreOptions {
53
56
  syncConfig?: boolean;
54
57
  /** Permit management commands that only need an existing index database */
55
58
  allowEmptyCollections?: boolean;
59
+ /** Open without syncing even when the index holds collections the config
60
+ * omits: config mutations report them through their own sync, and a
61
+ * connector proof reads one collection through a subset config. */
62
+ allowStaleCollections?: boolean;
56
63
  }
57
64
 
58
65
  /**
@@ -132,7 +139,25 @@ export async function initStore(
132
139
  return { ok: false, error: openResult.error.message };
133
140
  }
134
141
 
142
+ if (options.syncConfig === false && options.allowStaleCollections) {
143
+ return { ok: true, store, config, collections, actualConfigPath };
144
+ }
135
145
  if (options.syncConfig === false) {
146
+ // Reads skip the sync but must not search collections another config
147
+ // indexed; they fail the same way a sync would.
148
+ const stale = await store.listStaleCollections(
149
+ config.collections.map(({ name }) => name)
150
+ );
151
+ const indexed = stale.ok ? stale.value.filter((c) => c.documents > 0) : [];
152
+ if (!stale.ok || indexed.length > 0) {
153
+ await store.close();
154
+ return {
155
+ ok: false,
156
+ error: stale.ok
157
+ ? staleCollectionsMessage(indexed)
158
+ : stale.error.message,
159
+ };
160
+ }
136
161
  return { ok: true, store, config, collections, actualConfigPath };
137
162
  }
138
163
 
@@ -11,6 +11,11 @@ import { existsSync, realpathSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
13
13
 
14
+ import {
15
+ harnessConfigDirFromEnv,
16
+ type HarnessId,
17
+ } from "../agents/harnesses.js";
18
+
14
19
  // ─────────────────────────────────────────────────────────────────────────────
15
20
  // Environment Variables
16
21
  // ─────────────────────────────────────────────────────────────────────────────
@@ -84,6 +89,11 @@ interface TargetPathConfig {
84
89
  userBase: string; // e.g., ".claude" (joined with homedir) or ".config/opencode"
85
90
  skillsSubdir: string; // e.g., "skills" or "skill"
86
91
  envVar: string;
92
+ /**
93
+ * Harness whose config-dir env var (CLAUDE_CONFIG_DIR, CODEX_HOME) replaces
94
+ * the user base, resolved exactly as `gno agents` does.
95
+ */
96
+ configDirHarness?: HarnessId;
87
97
  }
88
98
 
89
99
  const TARGET_CONFIGS: Record<SkillTarget, TargetPathConfig> = {
@@ -92,12 +102,14 @@ const TARGET_CONFIGS: Record<SkillTarget, TargetPathConfig> = {
92
102
  userBase: ".claude",
93
103
  skillsSubdir: "skills",
94
104
  envVar: ENV_CLAUDE_SKILLS_DIR,
105
+ configDirHarness: "claude",
95
106
  },
96
107
  codex: {
97
108
  projectBase: ".codex",
98
109
  userBase: ".codex",
99
110
  skillsSubdir: "skills",
100
111
  envVar: ENV_CODEX_SKILLS_DIR,
112
+ configDirHarness: "codex",
101
113
  },
102
114
  opencode: {
103
115
  projectBase: ".opencode",
@@ -150,8 +162,14 @@ export function resolveSkillPaths(opts: SkillPathOptions): SkillPaths {
150
162
  let base: string;
151
163
 
152
164
  if (scope === "user") {
153
- const home = homeDir ?? process.env[ENV_SKILLS_HOME_OVERRIDE] ?? homedir();
154
- base = join(home, config.userBase);
165
+ // Any home override (option or env) is an isolation request and
166
+ // suppresses the harness config-dir env vars, as in `gno agents`.
167
+ const explicitHome = homeDir ?? process.env[ENV_SKILLS_HOME_OVERRIDE];
168
+ const configDir =
169
+ explicitHome === undefined && config.configDirHarness
170
+ ? harnessConfigDirFromEnv(config.configDirHarness)
171
+ : undefined;
172
+ base = configDir ?? join(explicitHome ?? homedir(), config.userBase);
155
173
  } else {
156
174
  const projectRoot = cwd ?? process.cwd();
157
175
  base = join(projectRoot, config.projectBase);
@@ -9,7 +9,7 @@ import type { ContentTypeBoostStatus } from "../../config/content-types";
9
9
  import type { ActivationStatus } from "../../core/activation-status";
10
10
  import type { MemoryStatus } from "../../core/memory-diagnostics";
11
11
  import type { BackgroundIssue } from "../../serve/status-model";
12
- import type { IndexStatus } from "../../store/types";
12
+ import type { IndexStatus, StaleCollection } from "../../store/types";
13
13
 
14
14
  import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
15
15
  import {
@@ -33,6 +33,10 @@ import {
33
33
  } from "../../core/memory-diagnostics";
34
34
  import { formatVectorPartitionLines } from "../../core/vector-partition-status";
35
35
  import { BUDGET_ERROR_CODES } from "../../ingestion/file-processor";
36
+ import {
37
+ checkCollectionRoots,
38
+ type CollectionRootError,
39
+ } from "../../ingestion/source-availability/root";
36
40
  import { ModelCache } from "../../llm/cache";
37
41
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
38
42
  import { getConnectorVerificationTargets } from "../../serve/connectors";
@@ -80,6 +84,10 @@ export type StatusResult =
80
84
  runtimeIssues?: RuntimeIssue[];
81
85
  /** Files whose latest conversion stopped at the per-file budget. */
82
86
  budgetStops: BudgetStop[];
87
+ /** Indexed collections this config does not list (kept until cleanup). */
88
+ staleCollections?: StaleCollection[];
89
+ /** Configured collections whose root is missing or unreadable now. */
90
+ rootErrors?: Record<string, CollectionRootError>;
83
91
  }
84
92
  | { success: false; error: string };
85
93
 
@@ -91,6 +99,18 @@ export interface BudgetStop {
91
99
  message: string;
92
100
  }
93
101
 
102
+ /** Indexed collections the config omits; an unreadable list is not fatal. */
103
+ async function collectStaleCollections(
104
+ store: SqliteAdapter,
105
+ configured: string[]
106
+ ): Promise<StaleCollection[]> {
107
+ const stale = await store.listStaleCollections(configured);
108
+ return stale.ok ? stale.value.filter((c) => c.documents > 0) : [];
109
+ }
110
+
111
+ const STALE_COLLECTIONS_HINT =
112
+ 'not in this config; add them back, or run "gno cleanup" to delete their index data';
113
+
94
114
  /** Most recent ingest errors inspected for budget stops. */
95
115
  const BUDGET_STOP_SCAN_LIMIT = 100;
96
116
  /** Most budget stops listed in status output. */
@@ -164,12 +184,14 @@ function connectorProjectionLine(activation: ActivationStatus): string | null {
164
184
 
165
185
  function isStatusHealthy(
166
186
  indexStatus: IndexStatus,
167
- activation: ActivationStatus
187
+ activation: ActivationStatus,
188
+ rootErrors: Record<string, CollectionRootError> = {}
168
189
  ): boolean {
169
190
  return (
170
191
  indexStatus.healthy &&
171
192
  activation.healthy &&
172
- isConnectorActivationComplete(activation)
193
+ isConnectorActivationComplete(activation) &&
194
+ Object.keys(rootErrors).length === 0
173
195
  );
174
196
  }
175
197
 
@@ -180,7 +202,8 @@ function formatTerminal(
180
202
  indexStatus: IndexStatus,
181
203
  activation: ActivationStatus,
182
204
  contentTypeBoost: ContentTypeBoostStatus,
183
- memory: MemoryStatus
205
+ memory: MemoryStatus,
206
+ rootErrors: Record<string, CollectionRootError> = {}
184
207
  ): string {
185
208
  const lines: string[] = [];
186
209
 
@@ -203,6 +226,11 @@ function formatTerminal(
203
226
  ` ${c.name}: ${c.activeDocuments} docs, ${c.totalChunks} chunks` +
204
227
  (c.embeddedChunks > 0 ? `, ${c.embeddedChunks} embedded` : "")
205
228
  );
229
+ const rootError = rootErrors[c.name];
230
+ if (rootError) {
231
+ lines.push(` Root unavailable: ${c.path}`);
232
+ lines.push(` [${rootError.code}] ${rootError.message}`);
233
+ }
206
234
  const workspace = formatLinkWorkspace(c);
207
235
  if (workspace) lines.push(` Link workspace: ${workspace}`);
208
236
  }
@@ -240,7 +268,7 @@ function formatTerminal(
240
268
  lines.push(`Ranking fingerprint: ${contentTypeBoost.rulesFingerprint}`);
241
269
 
242
270
  lines.push(
243
- `Health: ${isStatusHealthy(indexStatus, activation) ? "OK" : "DEGRADED"}`
271
+ `Health: ${isStatusHealthy(indexStatus, activation, rootErrors) ? "OK" : "DEGRADED"}`
244
272
  );
245
273
  lines.push("");
246
274
  const activationState = activation.healthy
@@ -290,7 +318,8 @@ function formatMarkdown(
290
318
  indexStatus: IndexStatus,
291
319
  activation: ActivationStatus,
292
320
  contentTypeBoost: ContentTypeBoostStatus,
293
- memory: MemoryStatus
321
+ memory: MemoryStatus,
322
+ rootErrors: Record<string, CollectionRootError> = {}
294
323
  ): string {
295
324
  const lines: string[] = [];
296
325
 
@@ -299,7 +328,7 @@ function formatMarkdown(
299
328
  lines.push(`- **Config**: ${indexStatus.configPath}`);
300
329
  lines.push(`- **Database**: ${indexStatus.dbPath}`);
301
330
  lines.push(
302
- `- **Health**: ${isStatusHealthy(indexStatus, activation) ? "✓ OK" : "⚠ DEGRADED"}`
331
+ `- **Health**: ${isStatusHealthy(indexStatus, activation, rootErrors) ? "✓ OK" : "⚠ DEGRADED"}`
303
332
  );
304
333
  lines.push("");
305
334
 
@@ -314,6 +343,15 @@ function formatMarkdown(
314
343
  );
315
344
  }
316
345
  lines.push("");
346
+ for (const c of indexStatus.collections) {
347
+ const rootError = rootErrors[c.name];
348
+ if (rootError) {
349
+ lines.push(
350
+ `- **Root unavailable**: ${c.name} (${c.path}) [${rootError.code}] ${rootError.message}`
351
+ );
352
+ }
353
+ }
354
+ if (Object.keys(rootErrors).length > 0) lines.push("");
317
355
  }
318
356
 
319
357
  lines.push("## Summary");
@@ -390,6 +428,8 @@ export async function status(
390
428
  return { success: false, error: configResult.error.message };
391
429
  }
392
430
  const config = configResult.value;
431
+ // Read-time root check, overlapping the index reads below; it never rejects.
432
+ const rootErrors = checkCollectionRoots(config.collections);
393
433
 
394
434
  // Open database
395
435
  const store = new SqliteAdapter();
@@ -446,6 +486,11 @@ export async function status(
446
486
  ...(await checkResidentRuntimes()),
447
487
  ],
448
488
  budgetStops: await collectBudgetStops(store),
489
+ staleCollections: await collectStaleCollections(
490
+ store,
491
+ config.collections.map(({ name }) => name)
492
+ ),
493
+ rootErrors: await rootErrors,
449
494
  };
450
495
  } finally {
451
496
  await store.close();
@@ -482,6 +527,9 @@ export function formatStatus(
482
527
  documentCount: c.activeDocuments,
483
528
  chunkCount: c.totalChunks,
484
529
  embeddedCount: c.embeddedChunks,
530
+ ...(result.rootErrors?.[c.name]
531
+ ? { rootError: result.rootErrors[c.name] }
532
+ : {}),
485
533
  })),
486
534
  totalDocuments: s.activeDocuments,
487
535
  totalChunks: s.totalChunks,
@@ -491,7 +539,7 @@ export function formatStatus(
491
539
  typedMetadata: s.typedMetadata,
492
540
  chunking: s.chunking,
493
541
  lastUpdated: s.lastUpdatedAt,
494
- healthy: isStatusHealthy(s, result.activation),
542
+ healthy: isStatusHealthy(s, result.activation, result.rootErrors),
495
543
  contentTypeBoost: result.contentTypeBoost,
496
544
  activation: result.activation,
497
545
  memory: result.memory,
@@ -501,19 +549,37 @@ export function formatStatus(
501
549
  ...(result.runtimeIssues?.length
502
550
  ? { runtimeIssues: result.runtimeIssues }
503
551
  : {}),
552
+ ...(result.staleCollections?.length
553
+ ? { staleCollections: result.staleCollections }
554
+ : {}),
504
555
  },
505
556
  null,
506
557
  2
507
558
  );
508
559
  }
509
560
 
561
+ const staleLines = (result.staleCollections ?? []).map(
562
+ (c) =>
563
+ `${c.name}: ${c.documents} ${c.documents === 1 ? "document" : "documents"}`
564
+ );
510
565
  if (options.md) {
511
- return formatMarkdown(
512
- result.status,
513
- result.activation,
514
- result.contentTypeBoost,
515
- result.memory
516
- );
566
+ return [
567
+ formatMarkdown(
568
+ result.status,
569
+ result.activation,
570
+ result.contentTypeBoost,
571
+ result.memory,
572
+ result.rootErrors
573
+ ),
574
+ ...(staleLines.length
575
+ ? [
576
+ "",
577
+ `## Collections ${STALE_COLLECTIONS_HINT}`,
578
+ "",
579
+ ...staleLines.map((line) => `- ${line}`),
580
+ ]
581
+ : []),
582
+ ].join("\n");
517
583
  }
518
584
 
519
585
  const issueLines = result.backgroundIssues.map(
@@ -529,8 +595,16 @@ export function formatStatus(
529
595
  result.status,
530
596
  result.activation,
531
597
  result.contentTypeBoost,
532
- result.memory
598
+ result.memory,
599
+ result.rootErrors
533
600
  ),
601
+ ...(staleLines.length
602
+ ? [
603
+ "",
604
+ `Collections ${STALE_COLLECTIONS_HINT}:`,
605
+ ...staleLines.map((line) => ` ${line}`),
606
+ ]
607
+ : []),
534
608
  ...(stopLines.length
535
609
  ? [
536
610
  "",
@@ -3651,9 +3651,14 @@ function wireManagementCommands(program: Command): void {
3651
3651
 
3652
3652
  // cleanup - Clean stale data
3653
3653
  addWriteLeaseFlags(
3654
- program.command("cleanup").description("Clean orphaned data from index")
3654
+ program
3655
+ .command("cleanup")
3656
+ .description("Clean orphaned data from index")
3657
+ .option("--json", "JSON output")
3655
3658
  ).action(async (cmdOpts: Record<string, unknown>) => {
3656
- const { cleanup, formatCleanup } = await import("./commands/cleanup");
3659
+ const { cleanup, formatCleanup, formatDroppingCollections } =
3660
+ await import("./commands/cleanup");
3661
+ const json = Boolean(cmdOpts.json) || getGlobals().json;
3657
3662
  const lease = parseWriteLeaseFlags(cmdOpts);
3658
3663
  const result = await withCliWriteLease(
3659
3664
  {
@@ -3665,6 +3670,13 @@ function wireManagementCommands(program: Command): void {
3665
3670
  cleanup({
3666
3671
  configPath: getGlobals().config,
3667
3672
  indexName: getGlobals().index,
3673
+ // Name what is about to be deleted before deleting it.
3674
+ onDropping: (collections) => {
3675
+ const plan = json
3676
+ ? JSON.stringify({ deleting: collections })
3677
+ : formatDroppingCollections(collections);
3678
+ process.stderr.write(`${plan}\n`);
3679
+ },
3668
3680
  })
3669
3681
  );
3670
3682
  throwIfWriteLeaseBusy(result, false);
@@ -3672,7 +3684,11 @@ function wireManagementCommands(program: Command): void {
3672
3684
  if (!result.success) {
3673
3685
  throw new CliError("RUNTIME", result.error ?? "Cleanup failed");
3674
3686
  }
3675
- process.stdout.write(`${formatCleanup(result)}\n`);
3687
+ process.stdout.write(
3688
+ json
3689
+ ? `${JSON.stringify({ droppedCollections: result.droppedCollections, ...result.stats }, null, 2)}\n`
3690
+ : `${formatCleanup(result)}\n`
3691
+ );
3676
3692
  });
3677
3693
 
3678
3694
  // reset - Reset GNO to fresh state
@@ -4675,8 +4691,12 @@ function wireGraphCommand(program: Command): void {
4675
4691
  );
4676
4692
  }
4677
4693
 
4678
- const maxDepth = cmdOpts.maxDepth
4679
- ? parsePositiveInt("max-depth", cmdOpts.maxDepth)
4694
+ // `gno graph` declares --max-depth for paths, so Commander hands the
4695
+ // flag to the parent command.
4696
+ const rawMaxDepth: unknown =
4697
+ cmdOpts.maxDepth ?? command.parent?.opts().maxDepth;
4698
+ const maxDepth = rawMaxDepth
4699
+ ? parsePositiveInt("max-depth", rawMaxDepth)
4680
4700
  : undefined;
4681
4701
  const maxNodes = cmdOpts.maxNodes
4682
4702
  ? parsePositiveInt("max-nodes", cmdOpts.maxNodes)
@@ -281,7 +281,6 @@ export async function findEphemeralActivationProbeMatch(
281
281
  const search = await store.searchFts(candidate.term, {
282
282
  collection: plan.collection,
283
283
  limit: PROBE_RESULT_LIMIT,
284
- snippet: false,
285
284
  });
286
285
  if (!search.ok) {
287
286
  return err(search.error.code, search.error.message, search.error.cause);