@gmickel/gno 2.8.3 → 2.8.5

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
@@ -139,7 +139,7 @@ See the [guide](docs/COMPILED-CONTEXT.md).
139
139
 
140
140
  <!-- public-truth:current-version -->
141
141
 
142
- > Current source version: **v2.8.3**. See [CHANGELOG.md](./CHANGELOG.md).
142
+ > Current source version: **v2.8.5**. See [CHANGELOG.md](./CHANGELOG.md).
143
143
 
144
144
  <!-- /public-truth -->
145
145
 
Binary file
@@ -0,0 +1 @@
1
+ 347695588c724660218a91ae70cd7bd8420445d2f78dd293cf661626380db7f2 gno-browser-clipper-v2.8.5.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "2.8.3"
24
+ "version": "2.8.5"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "2.8.3",
3
+ "version": "2.8.5",
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",
package/spec/cli.md CHANGED
@@ -351,7 +351,12 @@ gno status [--json|--md]
351
351
  ```
352
352
 
353
353
  `activation.usable` means at least one configured collection passed its local
354
- lexical proof. `activation.healthy` means every configured collection passed.
354
+ lexical proof. `activation.healthy` means at least one collection is configured
355
+ and none failed its proof. A collection with no documents yet (index stage
356
+ `no_documents`, remediation `gno update`) is informational, not a failure: it
357
+ leaves `healthy` true, projects no connector proofs, and terminal output
358
+ lists it as `no documents yet` (`Lexical activation: NO DOCUMENTS YET` when no
359
+ collection has documents).
355
360
  Semantic and connector stages remain independent; passive status never starts a
356
361
  model runtime or connector process. `gno status` still exits 0 when activation
357
362
  is unhealthy so scripts can inspect the structured state.
@@ -401,11 +406,19 @@ Passive callers report `semantic_not_checked` when vector runtime availability
401
406
  is unknown. `vector_unavailable` is reserved for a resident runtime that has
402
407
  positively reported vector search unavailable.
403
408
 
404
- `connectorProjection.total` counts every configured collection and connector
405
- target pair before projection bounds. `projected` equals `connectors.length`,
406
- and `truncated` is true exactly when `total > projected`. No result is claimed
407
- for omitted pairs, and human-readable health output must not report connector
408
- proof as healthy while the projection is truncated.
409
+ `connectorProjection.total` counts every non-empty collection and connector
410
+ target pair. Every pair is evaluated from cached receipts; the 64-pair cap
411
+ bounds only the listed `connectors`. `projected` equals `connectors.length`,
412
+ and `truncated` is true exactly when `total > projected`. When truncated,
413
+ `omitted` counts the unlisted pairs by outcome (`passed`, `failed`,
414
+ `incomplete`, `notApplicable`; the counts sum to `total - projected`). Connector
415
+ completeness, and so overall health, is decided from the listed pairs plus
416
+ these counts: a truncated projection whose pairs all passed or are not
417
+ applicable is healthy, and a failed or pending pair beyond the list keeps it
418
+ degraded. Human-readable output keeps the `N/M ... shown; K omitted` line and
419
+ summarizes the omitted outcomes, for example `(108 passed, 1 failed)`. A
420
+ truncated projection without `omitted` claims no result for the unlisted pairs
421
+ and is never reported healthy.
409
422
 
410
423
  **Exit Codes:**
411
424
 
@@ -3022,7 +3035,11 @@ The additive `activation` object uses the same contract as `gno status` and
3022
3035
  `GET /api/status`. Doctor performs only the local lexical proof. It never starts
3023
3036
  connector children or initializes/downloads models. A failed lexical proof adds
3024
3037
  the `retrieval-activation` error check and exits 2 after writing the complete
3025
- result; no duplicate error is written to stderr. Connector failure or projection
3038
+ result; no duplicate error is written to stderr. A collection with no
3039
+ documents yet is not a failed proof: when it is the only activation problem,
3040
+ `retrieval-activation` has status `info` (terminal icon `i`) and lists the
3041
+ collection with its remediation (add files, then `gno update`). Check status
3042
+ is one of `ok`, `info`, `warn` or `error`; only `error` fails doctor. Connector failure or projection
3026
3043
  truncation adds a warning and makes the structured doctor result non-healthy,
3027
3044
  but preserves exit 0 when lexical proof and all other required checks pass. An
3028
3045
  omitted target/collection pair has no inferred result.
@@ -24,8 +24,14 @@
24
24
  ],
25
25
  "properties": {
26
26
  "schemaVersion": { "const": "1.0" },
27
- "usable": { "type": "boolean" },
28
- "healthy": { "type": "boolean" },
27
+ "usable": {
28
+ "type": "boolean",
29
+ "description": "At least one collection passed the lexical retrieval proof"
30
+ },
31
+ "healthy": {
32
+ "type": "boolean",
33
+ "description": "At least one collection is configured and none failed its lexical retrieval proof. A collection with no documents yet (remediation code no_documents, command gno update) is informational, not a failure."
34
+ },
29
35
  "collections": {
30
36
  "type": "array",
31
37
  "items": {
@@ -158,7 +164,7 @@
158
164
  "total": {
159
165
  "type": "integer",
160
166
  "minimum": 0,
161
- "description": "All configured collection and connector target pairs before projection bounds."
167
+ "description": "All non-empty collection and connector target pairs; every pair is evaluated from cached receipts."
162
168
  },
163
169
  "projected": {
164
170
  "type": "integer",
@@ -168,7 +174,27 @@
168
174
  },
169
175
  "truncated": {
170
176
  "type": "boolean",
171
- "description": "True exactly when total is greater than projected; omitted pairs have no claimed result."
177
+ "description": "True exactly when total is greater than projected. Omitted pairs are still evaluated; their outcomes are counted in omitted."
178
+ },
179
+ "omitted": {
180
+ "type": "object",
181
+ "additionalProperties": false,
182
+ "required": ["passed", "failed", "incomplete", "notApplicable"],
183
+ "description": "Present when truncated: outcome counts for the evaluated pairs not listed in connectors. The counts sum to total - projected. Connector completeness is decided from connectors plus these counts.",
184
+ "properties": {
185
+ "passed": { "type": "integer", "minimum": 0 },
186
+ "failed": { "type": "integer", "minimum": 0 },
187
+ "incomplete": {
188
+ "type": "integer",
189
+ "minimum": 0,
190
+ "description": "Pending, or skipped for a runtime reason such as lexical retrieval not ready."
191
+ },
192
+ "notApplicable": {
193
+ "type": "integer",
194
+ "minimum": 0,
195
+ "description": "Connector not configured, or a skill runtime that cannot be verified."
196
+ }
197
+ }
172
198
  }
173
199
  }
174
200
  }
@@ -229,8 +255,8 @@
229
255
  },
230
256
  "status": {
231
257
  "type": "string",
232
- "enum": ["ok", "warn", "error"],
233
- "description": "Check result status"
258
+ "enum": ["ok", "info", "warn", "error"],
259
+ "description": "Check result status. info reports a state that needs no repair, such as a collection with no documents yet"
234
260
  },
235
261
  "message": {
236
262
  "type": "string",
@@ -347,10 +347,12 @@
347
347
  "const": "1.0"
348
348
  },
349
349
  "usable": {
350
- "type": "boolean"
350
+ "type": "boolean",
351
+ "description": "At least one collection passed the lexical retrieval proof"
351
352
  },
352
353
  "healthy": {
353
- "type": "boolean"
354
+ "type": "boolean",
355
+ "description": "At least one collection is configured and none failed its lexical retrieval proof. A collection with no documents yet (remediation code no_documents, command gno update) is informational, not a failure."
354
356
  },
355
357
  "collections": {
356
358
  "type": "array",
@@ -512,7 +514,7 @@
512
514
  "total": {
513
515
  "type": "integer",
514
516
  "minimum": 0,
515
- "description": "All configured collection and connector target pairs before projection bounds."
517
+ "description": "All non-empty collection and connector target pairs; every pair is evaluated from cached receipts."
516
518
  },
517
519
  "projected": {
518
520
  "type": "integer",
@@ -522,7 +524,27 @@
522
524
  },
523
525
  "truncated": {
524
526
  "type": "boolean",
525
- "description": "True exactly when total is greater than projected; omitted pairs have no claimed result."
527
+ "description": "True exactly when total is greater than projected. Omitted pairs are still evaluated; their outcomes are counted in omitted."
528
+ },
529
+ "omitted": {
530
+ "type": "object",
531
+ "additionalProperties": false,
532
+ "required": ["passed", "failed", "incomplete", "notApplicable"],
533
+ "description": "Present when truncated: outcome counts for the evaluated pairs not listed in connectors. The counts sum to total - projected. Connector completeness is decided from connectors plus these counts.",
534
+ "properties": {
535
+ "passed": { "type": "integer", "minimum": 0 },
536
+ "failed": { "type": "integer", "minimum": 0 },
537
+ "incomplete": {
538
+ "type": "integer",
539
+ "minimum": 0,
540
+ "description": "Pending, or skipped for a runtime reason such as lexical retrieval not ready."
541
+ },
542
+ "notApplicable": {
543
+ "type": "integer",
544
+ "minimum": 0,
545
+ "description": "Connector not configured, or a skill runtime that cannot be verified."
546
+ }
547
+ }
526
548
  }
527
549
  }
528
550
  }
@@ -6,6 +6,12 @@ import type { StorePort } from "../../store/types";
6
6
  import type { DoctorCheck } from "./doctor";
7
7
 
8
8
  import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
9
+ import {
10
+ classifyConnectorProof,
11
+ describeOmittedConnectorPairs,
12
+ omittedConnectorSummary,
13
+ } from "../../core/activation-connector-health";
14
+ import { isEmptyActivationCollection } from "../../core/activation-empty";
9
15
  import { buildActivationStatus } from "../../core/activation-status";
10
16
  import { ModelCache } from "../../llm/cache";
11
17
  import { getActivePreset } from "../../llm/registry";
@@ -75,28 +81,54 @@ export async function buildDoctorActivation(
75
81
  }
76
82
  }
77
83
 
84
+ const plural = (count: number, noun: string): string =>
85
+ `${count} ${noun}${count === 1 ? "" : "s"}`;
86
+
78
87
  export function checkRetrievalActivation(
79
88
  activation: ActivationStatus
80
89
  ): DoctorCheck {
90
+ const ready = activation.collections.filter((collection) => collection.ready);
91
+ const empty = activation.collections.filter(isEmptyActivationCollection);
92
+ const emptyDetails = empty.flatMap(({ collection, remediation }) => [
93
+ `${collection}: no documents indexed yet (informational)`,
94
+ `Add files, then run: ${remediation?.command ?? "gno update"}`,
95
+ ]);
96
+
81
97
  if (activation.healthy) {
82
98
  const semanticStates = [
83
99
  ...new Set(
84
- activation.collections.map(
85
- ({ semanticAvailability }) => semanticAvailability.code
86
- )
100
+ ready.map(({ semanticAvailability }) => semanticAvailability.code)
87
101
  ),
88
102
  ];
103
+ const semanticDetail =
104
+ ready.length > 0
105
+ ? [
106
+ `Semantic retrieval remains separate (${semanticStates.join(", ")}).`,
107
+ ]
108
+ : [];
109
+ if (empty.length === 0) {
110
+ return {
111
+ name: "retrieval-activation",
112
+ status: "ok",
113
+ message: `${plural(ready.length, "collection")} passed lexical retrieval proof`,
114
+ details: semanticDetail,
115
+ };
116
+ }
89
117
  return {
90
118
  name: "retrieval-activation",
91
- status: "ok",
92
- message: `${activation.collections.length} collection${activation.collections.length === 1 ? "" : "s"} passed lexical retrieval proof`,
93
- details: [
94
- `Semantic retrieval remains separate (${semanticStates.join(", ")}).`,
95
- ],
119
+ status: "info",
120
+ message:
121
+ ready.length > 0
122
+ ? `${plural(ready.length, "collection")} passed lexical retrieval proof; ${plural(empty.length, "collection")} with no documents yet`
123
+ : `No documents indexed yet in ${plural(empty.length, "collection")}`,
124
+ details: [...emptyDetails, ...semanticDetail],
96
125
  };
97
126
  }
98
127
 
99
- const failed = activation.collections.filter(({ ready }) => !ready);
128
+ const failed = activation.collections.filter(
129
+ (collection) =>
130
+ !collection.ready && !isEmptyActivationCollection(collection)
131
+ );
100
132
  const details = failed.flatMap(({ collection, remediation }) =>
101
133
  remediation
102
134
  ? [
@@ -112,9 +144,9 @@ export function checkRetrievalActivation(
112
144
  activation.collections.length === 0
113
145
  ? "No collections configured. Run: gno collection add"
114
146
  : activation.usable
115
- ? `${failed.length} collection${failed.length === 1 ? "" : "s"} failed lexical retrieval proof`
147
+ ? `${plural(failed.length, "collection")} failed lexical retrieval proof`
116
148
  : "No configured collection passed lexical retrieval proof",
117
- details,
149
+ details: [...details, ...emptyDetails],
118
150
  };
119
151
  }
120
152
 
@@ -122,13 +154,14 @@ export function checkConnectorActivation(
122
154
  activation: ActivationStatus
123
155
  ): DoctorCheck | null {
124
156
  const { projected, total, truncated } = activation.connectorProjection;
125
- const omitted = total - projected;
157
+ const omitted = omittedConnectorSummary(activation);
126
158
  const observed = activation.connectors.filter(
127
- ({ code }) =>
128
- code !== "connector_not_configured" &&
129
- code !== "target_runtime_unverifiable"
159
+ (connector) => classifyConnectorProof(connector) !== "notApplicable"
130
160
  );
131
- if (observed.length === 0 && !truncated) {
161
+ const omittedObserved = omitted
162
+ ? omitted.passed + omitted.failed + omitted.incomplete
163
+ : 0;
164
+ if (observed.length === 0 && omittedObserved === 0 && omitted) {
132
165
  return null;
133
166
  }
134
167
  const incomplete = observed.filter(({ status }) => status !== "passed");
@@ -136,20 +169,23 @@ export function checkConnectorActivation(
136
169
  ({ collection, target, status, code, remediation }) =>
137
170
  `${target}/${collection}: ${status}${code ? `/${code}` : ""}${remediation ? `. ${remediation}` : ""}`
138
171
  );
139
- if (truncated) {
140
- details.unshift(
141
- `${omitted} target/collection checks were omitted by the bounded status projection; no result is claimed for them.`
142
- );
172
+ const omittedDetail = describeOmittedConnectorPairs(activation);
173
+ if (omittedDetail) {
174
+ details.unshift(omittedDetail);
143
175
  }
176
+ const unresolved =
177
+ incomplete.length + (omitted ? omitted.failed + omitted.incomplete : 0);
178
+ const passed = observed.length - incomplete.length + (omitted?.passed ?? 0);
179
+ const listing = truncated ? ` (${projected} of ${total} checks listed)` : "";
144
180
  return {
145
181
  name: "connector-activation",
146
- status: incomplete.length > 0 || truncated ? "warn" : "ok",
182
+ status: unresolved > 0 || !omitted ? "warn" : "ok",
147
183
  message:
148
- incomplete.length > 0
149
- ? `${incomplete.length} connector proof${incomplete.length === 1 ? "" : "s"} pending or failed`
150
- : truncated
151
- ? `${projected} of ${total} connector target/collection checks projected`
152
- : `${observed.length} connector proof${observed.length === 1 ? "" : "s"} passed`,
184
+ unresolved > 0
185
+ ? `${unresolved} connector proof${unresolved === 1 ? "" : "s"} pending or failed${listing}`
186
+ : omitted
187
+ ? `${passed} connector proof${passed === 1 ? "" : "s"} passed${listing}`
188
+ : `${projected} of ${total} connector target/collection checks projected`,
153
189
  details,
154
190
  };
155
191
  }
@@ -55,7 +55,8 @@ import {
55
55
  // Types
56
56
  // ─────────────────────────────────────────────────────────────────────────────
57
57
 
58
- export type DoctorCheckStatus = "ok" | "warn" | "error";
58
+ /** `info` reports a state that needs no repair, such as an empty collection. */
59
+ export type DoctorCheckStatus = "ok" | "info" | "warn" | "error";
59
60
 
60
61
  export interface DoctorCheck {
61
62
  name: string;
@@ -754,6 +755,8 @@ function statusIcon(status: DoctorCheckStatus): string {
754
755
  switch (status) {
755
756
  case "ok":
756
757
  return "✓";
758
+ case "info":
759
+ return "i";
757
760
  case "warn":
758
761
  return "!";
759
762
  case "error":
@@ -18,7 +18,12 @@ import {
18
18
  isInitialized,
19
19
  loadConfig,
20
20
  } from "../../config";
21
- import { isConnectorActivationComplete } from "../../core/activation-connector-health";
21
+ import {
22
+ describeConnectorSummary,
23
+ isConnectorActivationComplete,
24
+ omittedConnectorSummary,
25
+ } from "../../core/activation-connector-health";
26
+ import { isEmptyActivationCollection } from "../../core/activation-empty";
22
27
  import { buildActivationStatus } from "../../core/activation-status";
23
28
  import { formatChunkingStatus } from "../../core/chunking-status";
24
29
  import { formatLinkWorkspace } from "../../core/link-workspace";
@@ -145,7 +150,8 @@ function connectorProjectionLine(activation: ActivationStatus): string | null {
145
150
  if (!truncated) {
146
151
  return null;
147
152
  }
148
- return `Connector projection: ${projected}/${total} target/collection checks shown; ${total - projected} omitted`;
153
+ const omitted = omittedConnectorSummary(activation);
154
+ return `Connector projection: ${projected}/${total} target/collection checks shown; ${total - projected} omitted${omitted ? ` (${describeConnectorSummary(omitted)})` : ", no result claimed"}`;
149
155
  }
150
156
 
151
157
  function isStatusHealthy(
@@ -229,10 +235,21 @@ function formatTerminal(
229
235
  `Health: ${isStatusHealthy(indexStatus, activation) ? "OK" : "DEGRADED"}`
230
236
  );
231
237
  lines.push("");
232
- lines.push(
233
- `Lexical activation: ${activation.healthy ? "READY" : activation.usable ? "DEGRADED" : "BLOCKED"}`
234
- );
238
+ const activationState = activation.healthy
239
+ ? activation.usable
240
+ ? "READY"
241
+ : "NO DOCUMENTS YET"
242
+ : activation.usable
243
+ ? "DEGRADED"
244
+ : "BLOCKED";
245
+ lines.push(`Lexical activation: ${activationState}`);
235
246
  for (const collection of activation.collections) {
247
+ if (isEmptyActivationCollection(collection)) {
248
+ lines.push(
249
+ ` ${collection.collection}: no documents yet (add files, then run ${collection.remediation?.command})`
250
+ );
251
+ continue;
252
+ }
236
253
  const failedStage = collection.remediation?.stage;
237
254
  const suffix = failedStage
238
255
  ? ` (${failedStage}: ${collection.remediation?.code}; ${collection.remediation?.command})`
@@ -325,7 +342,7 @@ function formatMarkdown(
325
342
  lines.push(`- **Lexically healthy**: ${activation.healthy}`);
326
343
  for (const collection of activation.collections) {
327
344
  lines.push(
328
- `- **${collection.collection}**: ${collection.ready ? "lexical ready" : `${collection.remediation?.stage ?? "index"} ${collection.remediation?.code ?? "index_query_failed"}`}`
345
+ `- **${collection.collection}**: ${collection.ready ? "lexical ready" : isEmptyActivationCollection(collection) ? `no documents yet (${collection.remediation?.command})` : `${collection.remediation?.stage ?? "index"} ${collection.remediation?.code ?? "index_query_failed"}`}`
329
346
  );
330
347
  }
331
348
  for (const connector of activation.connectors) {
@@ -1,19 +1,111 @@
1
- import type { ActivationStatus } from "./activation-status";
1
+ import type {
2
+ ActivationConnectorStatus,
3
+ ActivationStatus,
4
+ ConnectorOmittedSummary,
5
+ } from "./activation-status";
2
6
 
3
7
  const NON_RUNTIME_CODES = new Set([
4
8
  "connector_not_configured",
5
9
  "target_runtime_unverifiable",
6
10
  ]);
7
11
 
8
- /** True when every observable proof passed and no target/collection pair was omitted. */
12
+ export type ConnectorProofOutcome = keyof ConnectorOmittedSummary;
13
+
14
+ /** One shared reading of a connector pair, used for listed and unlisted pairs alike. */
15
+ export function classifyConnectorProof({
16
+ code,
17
+ status,
18
+ }: Pick<ActivationConnectorStatus, "code" | "status">): ConnectorProofOutcome {
19
+ if (code !== undefined && NON_RUNTIME_CODES.has(code)) {
20
+ return "notApplicable";
21
+ }
22
+ if (status === "passed" || status === "failed") {
23
+ return status;
24
+ }
25
+ return "incomplete";
26
+ }
27
+
28
+ export function summarizeConnectorProofs(
29
+ connectors: readonly Pick<ActivationConnectorStatus, "code" | "status">[]
30
+ ): ConnectorOmittedSummary {
31
+ const summary: ConnectorOmittedSummary = {
32
+ passed: 0,
33
+ failed: 0,
34
+ incomplete: 0,
35
+ notApplicable: 0,
36
+ };
37
+ for (const connector of connectors) {
38
+ summary[classifyConnectorProof(connector)] += 1;
39
+ }
40
+ return summary;
41
+ }
42
+
43
+ /**
44
+ * Summary of the pairs past the display cap, or null when some were omitted
45
+ * without an evaluated result (fail closed).
46
+ */
47
+ export function omittedConnectorSummary(
48
+ activation: ActivationStatus
49
+ ): ConnectorOmittedSummary | null {
50
+ const { omitted, projected, total, truncated } =
51
+ activation.connectorProjection;
52
+ if (!truncated) {
53
+ return { passed: 0, failed: 0, incomplete: 0, notApplicable: 0 };
54
+ }
55
+ if (!omitted) {
56
+ return null;
57
+ }
58
+ const counted =
59
+ omitted.passed +
60
+ omitted.failed +
61
+ omitted.incomplete +
62
+ omitted.notApplicable;
63
+ return counted === total - projected ? omitted : null;
64
+ }
65
+
66
+ /** Human summary of unlisted pairs, e.g. "108 passed, 1 failed". */
67
+ export function describeConnectorSummary(
68
+ summary: ConnectorOmittedSummary
69
+ ): string {
70
+ const parts = [
71
+ summary.passed > 0 ? `${summary.passed} passed` : null,
72
+ summary.failed > 0 ? `${summary.failed} failed` : null,
73
+ summary.incomplete > 0 ? `${summary.incomplete} pending` : null,
74
+ summary.notApplicable > 0
75
+ ? `${summary.notApplicable} not configured or not verifiable`
76
+ : null,
77
+ ].filter(Boolean);
78
+ return parts.length > 0 ? parts.join(", ") : "none";
79
+ }
80
+
81
+ /** Sentence describing the pairs past the display cap, or null when none were omitted. */
82
+ export function describeOmittedConnectorPairs(
83
+ activation: ActivationStatus
84
+ ): string | null {
85
+ const { projected, total, truncated } = activation.connectorProjection;
86
+ if (!truncated) {
87
+ return null;
88
+ }
89
+ const omitted = total - projected;
90
+ const summary = omittedConnectorSummary(activation);
91
+ return summary
92
+ ? `${omitted} more target/collection checks are not listed: ${describeConnectorSummary(summary)}.`
93
+ : `${omitted} target/collection checks were omitted by the bounded status projection; no result is claimed for them.`;
94
+ }
95
+
96
+ /**
97
+ * True when every collection/target pair, listed or not, passed or has a
98
+ * non-runtime code. Pairs omitted without an evaluated result fail closed.
99
+ */
9
100
  export function isConnectorActivationComplete(
10
101
  activation: ActivationStatus
11
102
  ): boolean {
12
- if (activation.connectorProjection.truncated) {
103
+ const omitted = omittedConnectorSummary(activation);
104
+ if (!omitted || omitted.failed > 0 || omitted.incomplete > 0) {
13
105
  return false;
14
106
  }
15
- return activation.connectors.every(
16
- ({ code, status }) =>
17
- (code !== undefined && NON_RUNTIME_CODES.has(code)) || status === "passed"
18
- );
107
+ return activation.connectors.every((connector) => {
108
+ const outcome = classifyConnectorProof(connector);
109
+ return outcome === "passed" || outcome === "notApplicable";
110
+ });
19
111
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Empty-collection rule shared by server surfaces and the Web UI bundle, so
3
+ * it carries type imports only.
4
+ */
5
+
6
+ import type { ActivationCollectionStatus } from "./activation-status";
7
+
8
+ /**
9
+ * A collection with nothing indexed yet: its only activation problem is
10
+ * `no_documents`. Surfaces show it as informational, never as a failure.
11
+ */
12
+ export function isEmptyActivationCollection(
13
+ collection: Pick<ActivationCollectionStatus, "ready" | "remediation">
14
+ ): boolean {
15
+ return !collection.ready && collection.remediation?.code === "no_documents";
16
+ }
@@ -14,6 +14,8 @@ import type {
14
14
  ConnectorVerificationTarget,
15
15
  } from "./connector-verifier";
16
16
 
17
+ import { summarizeConnectorProofs } from "./activation-connector-health";
18
+ import { isEmptyActivationCollection } from "./activation-empty";
17
19
  import { createEphemeralActivationProbePlan } from "./activation-probe-plan";
18
20
  import { verifyLexicalActivation } from "./activation-verifier";
19
21
  import {
@@ -22,7 +24,7 @@ import {
22
24
  } from "./connector-verifier";
23
25
 
24
26
  const DEFAULT_CONCURRENCY = 4;
25
- const MAX_CONNECTOR_TARGETS = 16;
27
+ /** Bounds the listed pairs only; completeness is decided from every pair. */
26
28
  const MAX_CONNECTOR_PROJECTIONS = 64;
27
29
  const CONNECTOR_CODES = new Set<ActivationVerificationCode>([
28
30
  "connector_not_configured",
@@ -73,7 +75,13 @@ export interface ActivationConnectorStatus {
73
75
 
74
76
  export interface ActivationStatus {
75
77
  schemaVersion: "1.0";
78
+ /** At least one collection passed the lexical proof. */
76
79
  usable: boolean;
80
+ /**
81
+ * At least one collection is configured and none failed its proof. A
82
+ * collection with no documents yet is not a failure (see
83
+ * `isEmptyActivationCollection`), so it does not clear this flag.
84
+ */
77
85
  healthy: boolean;
78
86
  collections: ActivationCollectionStatus[];
79
87
  /** Only fingerprint-current persisted connector receipts may appear here. */
@@ -82,9 +90,21 @@ export interface ActivationStatus {
82
90
  total: number;
83
91
  projected: number;
84
92
  truncated: boolean;
93
+ /** Outcome counts for the evaluated pairs past the display cap; present when truncated. */
94
+ omitted?: ConnectorOmittedSummary;
85
95
  };
86
96
  }
87
97
 
98
+ /** Per-outcome counts of evaluated connector pairs that are not listed. */
99
+ export interface ConnectorOmittedSummary {
100
+ passed: number;
101
+ failed: number;
102
+ /** Pending, or skipped for a runtime reason (for example lexical not ready). */
103
+ incomplete: number;
104
+ /** Not configured, or a skill runtime that cannot be verified. */
105
+ notApplicable: number;
106
+ }
107
+
88
108
  export interface ActivationStatusOptions {
89
109
  concurrency?: number;
90
110
  semantic?: {
@@ -178,10 +198,13 @@ function remediationFor(
178
198
  stage: ActivationStageName,
179
199
  code: ActivationVerificationCode
180
200
  ): ActivationRemediation {
181
- const command = `gno index ${collection} --no-embed`;
201
+ const command =
202
+ code === "no_documents"
203
+ ? "gno update"
204
+ : `gno index ${collection} --no-embed`;
182
205
  const messages: Partial<Record<ActivationVerificationCode, string>> = {
183
206
  no_documents:
184
- "Index at least one supported text document in this collection.",
207
+ "No documents are indexed yet. Add supported files to this collection's folder, then run gno update.",
185
208
  no_probe_term:
186
209
  "Add searchable text or adjust the collection filters, then reindex.",
187
210
  index_query_failed:
@@ -319,14 +342,22 @@ async function buildConnectorStatuses(
319
342
  store: StorePort,
320
343
  collections: VerifiedCollection[],
321
344
  targets: readonly ConnectorVerificationTarget[]
322
- ): Promise<{ items: ActivationConnectorStatus[]; total: number }> {
345
+ ): Promise<{
346
+ items: ActivationConnectorStatus[];
347
+ omitted: ConnectorOmittedSummary;
348
+ total: number;
349
+ }> {
323
350
  const sortedTargets = [...targets].sort((a, b) => a.id.localeCompare(b.id));
324
- const boundedTargets = sortedTargets.slice(0, MAX_CONNECTOR_TARGETS);
325
- const allPairs = collections.flatMap((collection) =>
326
- boundedTargets.map((target) => ({ collection, target }))
351
+ // A collection with no documents gives a connector nothing to prove.
352
+ const provable = collections.filter(
353
+ ({ projected }) => !isEmptyActivationCollection(projected)
354
+ );
355
+ const allPairs = provable.flatMap((collection) =>
356
+ sortedTargets.map((target) => ({ collection, target }))
327
357
  );
328
- const items = await mapBounded(
329
- allPairs.slice(0, MAX_CONNECTOR_PROJECTIONS),
358
+ // Every pair is read from cached receipts only; the cap bounds the listing.
359
+ const evaluated = await mapBounded(
360
+ allPairs,
330
361
  DEFAULT_CONCURRENCY,
331
362
  async ({ collection, target }) => {
332
363
  const fallback = connectorFallback(
@@ -363,7 +394,13 @@ async function buildConnectorStatuses(
363
394
  };
364
395
  }
365
396
  );
366
- return { items, total: collections.length * sortedTargets.length };
397
+ return {
398
+ items: evaluated.slice(0, MAX_CONNECTOR_PROJECTIONS),
399
+ omitted: summarizeConnectorProofs(
400
+ evaluated.slice(MAX_CONNECTOR_PROJECTIONS)
401
+ ),
402
+ total: evaluated.length,
403
+ };
367
404
  }
368
405
 
369
406
  async function verifyCoalesced(
@@ -501,13 +538,20 @@ export async function buildActivationStatus(
501
538
  schemaVersion: "1.0",
502
539
  usable: projected.some((collection) => collection.ready),
503
540
  healthy:
504
- projected.length > 0 && projected.every((collection) => collection.ready),
541
+ projected.length > 0 &&
542
+ projected.every(
543
+ (collection) =>
544
+ collection.ready || isEmptyActivationCollection(collection)
545
+ ),
505
546
  collections: projected,
506
547
  connectors: connectorStatuses.items,
507
548
  connectorProjection: {
508
549
  total: connectorStatuses.total,
509
550
  projected: connectorStatuses.items.length,
510
551
  truncated: connectorStatuses.total > connectorStatuses.items.length,
552
+ ...(connectorStatuses.total > connectorStatuses.items.length
553
+ ? { omitted: connectorStatuses.omitted }
554
+ : {}),
511
555
  },
512
556
  };
513
557
  }
package/src/core/links.ts CHANGED
@@ -80,11 +80,53 @@ const UNSAFE_PERCENT_CODES = new Set(["%2F", "%5C", "%00", "%2f", "%5c"]);
80
80
  // Regex Patterns
81
81
  // ─────────────────────────────────────────────────────────────────────────────
82
82
 
83
+ interface WikiLinkMatch {
84
+ index: number;
85
+ raw: string;
86
+ content: string;
87
+ }
88
+
83
89
  /**
84
- * Wiki link: [[target]] or [[target|alias]] or [[target#anchor]] or [[collection:target]]
85
- * Captures: 1=content inside brackets
90
+ * Wiki links: [[target]], [[target|alias]], [[target#anchor]] or
91
+ * [[collection:target]]. Matches exactly what
92
+ * `/\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g` matches, in one pass: the content runs
93
+ * to the first `]`, which must be doubled; it must not start with `|`, and a
94
+ * `|` in it needs text after it. The regex rescanned to that `]` from every
95
+ * unclosed `[[`, so a note with thousands of them took minutes.
86
96
  */
87
- const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g;
97
+ export function* findWikiLinks(
98
+ markdown: string
99
+ ): Generator<WikiLinkMatch, void, undefined> {
100
+ let nextClose = -2;
101
+ let nextPipe = -2;
102
+ let start = markdown.indexOf("[[");
103
+ while (start !== -1) {
104
+ const contentStart = start + 2;
105
+ if (nextClose < contentStart) {
106
+ nextClose = markdown.indexOf("]", contentStart);
107
+ if (nextClose === -1) return;
108
+ }
109
+ if (nextPipe !== -1 && nextPipe < contentStart) {
110
+ nextPipe = markdown.indexOf("|", contentStart);
111
+ }
112
+ const pipeInContent = nextPipe !== -1 && nextPipe < nextClose;
113
+ const matched =
114
+ markdown.charCodeAt(nextClose + 1) === 93 /* ] */ &&
115
+ nextClose > contentStart &&
116
+ nextPipe !== contentStart &&
117
+ (!pipeInContent || nextClose > nextPipe + 1);
118
+ if (matched) {
119
+ yield {
120
+ index: start,
121
+ raw: markdown.slice(start, nextClose + 2),
122
+ content: markdown.slice(contentStart, nextClose),
123
+ };
124
+ start = markdown.indexOf("[[", nextClose + 2);
125
+ } else {
126
+ start = markdown.indexOf("[[", start + 1);
127
+ }
128
+ }
129
+ }
88
130
 
89
131
  /** Logseq embed: {{embed [[Page]]}} or {{embed ((block-id))}} */
90
132
  const LOGSEQ_EMBED_REGEX =
@@ -385,12 +427,9 @@ export function parseLinks(
385
427
  };
386
428
 
387
429
  // Parse wiki links
388
- WIKI_LINK_REGEX.lastIndex = 0;
389
- let match: RegExpExecArray | null;
390
-
391
- while ((match = WIKI_LINK_REGEX.exec(markdown)) !== null) {
392
- const startOffset = match.index;
393
- const endOffset = startOffset + match[0].length;
430
+ for (const wiki of findWikiLinks(markdown)) {
431
+ const startOffset = wiki.index;
432
+ const endOffset = startOffset + wiki.raw.length;
394
433
 
395
434
  // Skip [[target]] nested in Logseq alias syntax: [Display]([[target]])
396
435
  if (
@@ -405,12 +444,10 @@ export function parseLinks(
405
444
  continue;
406
445
  }
407
446
 
408
- const content = match[1];
409
- if (!content) continue;
410
-
411
447
  // Parse [[target|alias]] (and table-escaped [[target\|alias]]) format
412
- const { target: targetPart, alias: aliasText } =
413
- splitWikiLinkContent(content);
448
+ const { target: targetPart, alias: aliasText } = splitWikiLinkContent(
449
+ wiki.content
450
+ );
414
451
  // Only set displayText if different from target
415
452
  const displayText =
416
453
  aliasText !== undefined && aliasText !== targetPart
@@ -421,11 +458,12 @@ export function parseLinks(
421
458
  if (!trimmedTarget) {
422
459
  continue;
423
460
  }
424
- pushWikiLink(match[0], trimmedTarget, startOffset, endOffset, displayText);
461
+ pushWikiLink(wiki.raw, trimmedTarget, startOffset, endOffset, displayText);
425
462
  }
426
463
 
427
464
  // Parse Logseq embeds as links
428
465
  LOGSEQ_EMBED_REGEX.lastIndex = 0;
466
+ let match: RegExpExecArray | null;
429
467
 
430
468
  while ((match = LOGSEQ_EMBED_REGEX.exec(markdown)) !== null) {
431
469
  const startOffset = match.index;
@@ -14,8 +14,10 @@
14
14
  * Code regions come from the CommonMark + GFM parser. GFM table parsing is
15
15
  * quadratic in the cells of one table (a 20,000-row table took minutes), so
16
16
  * text with a table over the cell budget is parsed without the table
17
- * extension: code spans, fences, indented code, blockquotes and lists keep
18
- * their CommonMark meaning, and only table-cell boundaries are lost.
17
+ * extension and without inline links and inline HTML, which rescan a
18
+ * table-sized paragraph once per row: code spans, fences, indented code,
19
+ * blockquotes and lists keep their CommonMark meaning; table-cell
20
+ * boundaries, link destinations and HTML tags no longer hide a backtick.
19
21
  *
20
22
  * @module src/ingestion/strip
21
23
  */
@@ -59,9 +61,6 @@ export interface ExcludedRange {
59
61
  /** Frontmatter at start of file (YAML between --- delimiters) */
60
62
  const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
61
63
 
62
- /** HTML comments */
63
- const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
64
-
65
64
  /**
66
65
  * Most `|` characters one blank-line-delimited block may hold before the
67
66
  * GFM table extension is left out of the parse. A GFM table only ends at a
@@ -132,13 +131,33 @@ const hasOversizedTable = (markdown: string): boolean => {
132
131
  return false;
133
132
  };
134
133
 
135
- /** GFM without tables: every GFM construct except table rows and cells. */
134
+ /**
135
+ * Inline constructs left out with the table extension. Without table rules a
136
+ * large table is one paragraph, and these rescan it once per row: on every
137
+ * `]` link and image ends walk back through all the paragraph's earlier
138
+ * events looking for an opening `[` (an unmatched `]` per row made 8,000
139
+ * rows take 66 s), and inline HTML reads forward from every unclosed `<!--`
140
+ * to the paragraph's end. They only matter to code detection through a
141
+ * backtick inside a link destination or title or an inline HTML tag, which
142
+ * then reads as a code-span delimiter.
143
+ */
144
+ const PARAGRAPH_RESCANNING_CONSTRUCTS = [
145
+ "labelEnd",
146
+ "gfmPotentialFootnoteCall",
147
+ "htmlText",
148
+ ];
149
+
150
+ /**
151
+ * GFM without tables: every GFM construct except table rows and cells, and
152
+ * without the constructs in `PARAGRAPH_RESCANNING_CONSTRUCTS`.
153
+ */
136
154
  const GFM_WITHOUT_TABLES = {
137
155
  extensions: [
138
156
  gfmAutolinkLiteral(),
139
157
  gfmFootnote(),
140
158
  gfmStrikethrough(),
141
159
  gfmTaskListItem(),
160
+ { disable: { null: PARAGRAPH_RESCANNING_CONSTRUCTS } },
142
161
  ],
143
162
  mdastExtensions: [
144
163
  gfmAutolinkLiteralFromMarkdown(),
@@ -226,15 +245,15 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
226
245
  });
227
246
  }
228
247
 
229
- // 2. HTML comments
230
- HTML_COMMENT_REGEX.lastIndex = 0;
231
- let match: RegExpExecArray | null;
232
- while ((match = HTML_COMMENT_REGEX.exec(markdown)) !== null) {
233
- ranges.push({
234
- start: match.index,
235
- end: match.index + match[0].length,
236
- kind: "html_comment",
237
- });
248
+ // 2. HTML comments: each `<!--` to the first `-->` after it. Once no
249
+ // `-->` follows, no later comment can close either, so the scan stops;
250
+ // the regex /<!--[\s\S]*?-->/g rescanned to the end from every opener.
251
+ let open = markdown.indexOf("<!--");
252
+ while (open !== -1) {
253
+ const close = markdown.indexOf("-->", open + 4);
254
+ if (close === -1) break;
255
+ ranges.push({ start: open, end: close + 3, kind: "html_comment" });
256
+ open = markdown.indexOf("<!--", close + 3);
238
257
  }
239
258
 
240
259
  // 3. Code spans and fenced or indented code blocks, from the parser.
@@ -281,24 +300,47 @@ export function isExcluded(
281
300
  return false;
282
301
  }
283
302
 
303
+ /**
304
+ * Largest `end` among each prefix of a start-sorted range list, built once
305
+ * per list. Ranges can overlap (a comment around a code span), so a range
306
+ * list's ends are not sorted and the prefix maximum stands in for them.
307
+ */
308
+ const prefixMaxEnds = new WeakMap<readonly ExcludedRange[], Int32Array>();
309
+
310
+ const getPrefixMaxEnds = (ranges: readonly ExcludedRange[]): Int32Array => {
311
+ let maxEnds = prefixMaxEnds.get(ranges);
312
+ if (maxEnds?.length !== ranges.length) {
313
+ maxEnds = new Int32Array(ranges.length);
314
+ let maxEnd = -1;
315
+ for (const [index, range] of ranges.entries()) {
316
+ maxEnd = Math.max(maxEnd, range.end);
317
+ maxEnds[index] = maxEnd;
318
+ }
319
+ prefixMaxEnds.set(ranges, maxEnds);
320
+ }
321
+ return maxEnds;
322
+ };
323
+
284
324
  /**
285
325
  * Check if a range [start, end) intersects any excluded range.
286
- * More precise than isExcluded for multi-character matches.
326
+ * More precise than isExcluded for multi-character matches. Ranges must be
327
+ * sorted by start, as getExcludedRanges returns them; one lookup is a binary
328
+ * search, so checking every link of a large note stays linear.
287
329
  */
288
330
  export function rangeIntersectsExcluded(
289
331
  start: number,
290
332
  end: number,
291
- excludedRanges: ExcludedRange[]
333
+ excludedRanges: readonly ExcludedRange[]
292
334
  ): boolean {
293
- for (const range of excludedRanges) {
294
- // Two ranges [a, b) and [c, d) intersect if a < d && c < b
295
- if (start < range.end && range.start < end) {
296
- return true;
297
- }
298
- // Early exit if we've passed the range (ranges are sorted)
299
- if (range.start >= end) {
300
- break;
301
- }
335
+ // Ranges starting before `end` form a prefix; one of them intersects
336
+ // [start, end) exactly when the largest end among them passes `start`.
337
+ let low = 0;
338
+ let high = excludedRanges.length;
339
+ while (low < high) {
340
+ const mid = (low + high) >>> 1;
341
+ if ((excludedRanges[mid]?.start ?? end) < end) low = mid + 1;
342
+ else high = mid;
302
343
  }
303
- return false;
344
+ if (low === 0) return false;
345
+ return (getPrefixMaxEnds(excludedRanges)[low - 1] ?? -1) > start;
304
346
  }
@@ -3,6 +3,13 @@
3
3
  import type { ActivationStatus } from "../core/activation-status";
4
4
  import type { HealthCheck } from "./status-model";
5
5
 
6
+ import {
7
+ classifyConnectorProof,
8
+ describeOmittedConnectorPairs,
9
+ omittedConnectorSummary,
10
+ } from "../core/activation-connector-health";
11
+ import { isEmptyActivationCollection } from "../core/activation-empty";
12
+
6
13
  function countLabel(count: number, singular: string): string {
7
14
  return `${count} ${count === 1 ? singular : `${singular}s`}`;
8
15
  }
@@ -10,26 +17,42 @@ function countLabel(count: number, singular: string): string {
10
17
  export function buildActivationCheck(
11
18
  activation: ActivationStatus
12
19
  ): HealthCheck {
20
+ const ready = activation.collections.filter((collection) => collection.ready);
21
+ const empty = activation.collections.filter(isEmptyActivationCollection);
22
+ const emptyDetail =
23
+ empty.length > 0
24
+ ? `${countLabel(empty.length, "folder")} ${empty.length === 1 ? "has" : "have"} no documents yet (${empty.map(({ collection }) => collection).join(", ")}): add files, then run update.`
25
+ : null;
13
26
  if (activation.healthy) {
14
27
  const semanticReasons = [
15
28
  ...new Set(
16
- activation.collections.map(
17
- ({ semanticAvailability }) => semanticAvailability.code
18
- )
29
+ ready.map(({ semanticAvailability }) => semanticAvailability.code)
19
30
  ),
20
31
  ];
32
+ const semanticDetail =
33
+ ready.length > 0
34
+ ? `Lexical search is proven. Semantic availability is separate (${semanticReasons.join(", ")}).`
35
+ : null;
21
36
  return {
22
37
  id: "retrieval-activation",
23
38
  title: "Retrieval proof",
24
39
  status: "ok",
25
- summary: `${countLabel(activation.collections.length, "folder")} passed lexical retrieval`,
26
- detail: `Lexical search is proven. Semantic availability is separate (${semanticReasons.join(", ")}).`,
40
+ summary:
41
+ ready.length === 0
42
+ ? `No documents indexed yet in ${countLabel(empty.length, "folder")}`
43
+ : empty.length > 0
44
+ ? `${countLabel(ready.length, "folder")} passed lexical retrieval; ${countLabel(empty.length, "folder")} with no documents yet`
45
+ : `${countLabel(ready.length, "folder")} passed lexical retrieval`,
46
+ detail: [semanticDetail, emptyDetail].filter(Boolean).join(" "),
27
47
  actionLabel: "Run update",
28
48
  actionKind: "sync",
29
49
  };
30
50
  }
31
51
 
32
- const failed = activation.collections.filter(({ ready }) => !ready);
52
+ const failed = activation.collections.filter(
53
+ (collection) =>
54
+ !collection.ready && !isEmptyActivationCollection(collection)
55
+ );
33
56
  const first = failed[0];
34
57
  const detail = first?.remediation
35
58
  ? `${first.collection}: ${first.remediation.stage}/${first.remediation.code}. Run: ${first.remediation.command}`
@@ -41,7 +64,7 @@ export function buildActivationCheck(
41
64
  summary: activation.usable
42
65
  ? `${countLabel(failed.length, "folder")} failed lexical retrieval`
43
66
  : "No folder passed lexical retrieval",
44
- detail,
67
+ detail: [detail, emptyDetail].filter(Boolean).join(" "),
45
68
  actionLabel: "Run update",
46
69
  actionKind: "sync",
47
70
  };
@@ -51,41 +74,46 @@ export function buildConnectorActivationCheck(
51
74
  activation: ActivationStatus
52
75
  ): HealthCheck | null {
53
76
  const { projected, total, truncated } = activation.connectorProjection;
54
- const omitted = total - projected;
77
+ const omitted = omittedConnectorSummary(activation);
55
78
  const observed = activation.connectors.filter(
56
- ({ code }) =>
57
- code !== "connector_not_configured" &&
58
- code !== "target_runtime_unverifiable"
79
+ (connector) => classifyConnectorProof(connector) !== "notApplicable"
59
80
  );
60
- if (observed.length === 0 && !truncated) {
81
+ const omittedObserved = omitted
82
+ ? omitted.passed + omitted.failed + omitted.incomplete
83
+ : 0;
84
+ if (observed.length === 0 && omittedObserved === 0 && omitted) {
61
85
  return null;
62
86
  }
63
87
  const failed = observed.filter(({ status }) => status === "failed");
64
88
  const incomplete = observed.filter(({ status }) => status !== "passed");
89
+ const failedCount = failed.length + (omitted?.failed ?? 0);
90
+ const incompleteCount =
91
+ incomplete.length + (omitted?.incomplete ?? 0) + (omitted?.failed ?? 0);
92
+ const passedCount =
93
+ observed.length - incomplete.length + (omitted?.passed ?? 0);
65
94
  const first = failed[0] ?? incomplete[0] ?? observed[0];
66
95
  const firstDetail = first
67
96
  ? `${first.target} / ${first.collection}: ${first.status}${first.code ? `/${first.code}` : ""}${first.remediation ? `. ${first.remediation}` : ""}`
68
97
  : null;
69
- const projectionDetail = truncated
70
- ? `${omitted} target/collection checks were omitted by the bounded status projection; no result is claimed for them.`
71
- : null;
98
+ const projectionDetail = describeOmittedConnectorPairs(activation);
99
+ const listing = truncated ? ` (${projected} of ${total} checks listed)` : "";
72
100
  return {
73
101
  id: "connector-activation",
74
102
  title: "Connector proof",
75
103
  status:
76
- failed.length > 0
104
+ failedCount > 0
77
105
  ? "error"
78
- : incomplete.length > 0 || truncated
106
+ : incompleteCount > 0 || !omitted
79
107
  ? "warn"
80
108
  : "ok",
81
109
  summary:
82
- failed.length > 0
83
- ? `${countLabel(failed.length, "connector proof")} failed`
84
- : incomplete.length > 0
85
- ? `${countLabel(incomplete.length, "connector proof")} incomplete`
86
- : truncated
87
- ? `${projected} of ${total} connector target/collection checks projected`
88
- : `${countLabel(observed.length, "connector proof")} passed`,
110
+ failedCount > 0
111
+ ? `${countLabel(failedCount, "connector proof")} failed${listing}`
112
+ : incompleteCount > 0
113
+ ? `${countLabel(incompleteCount, "connector proof")} incomplete${listing}`
114
+ : omitted
115
+ ? `${countLabel(passedCount, "connector proof")} passed${listing}`
116
+ : `${projected} of ${total} connector target/collection checks projected`,
89
117
  detail: [projectionDetail, firstDetail].filter(Boolean).join(" "),
90
118
  };
91
119
  }
@@ -9,6 +9,11 @@ import {
9
9
 
10
10
  import type { AppStatusResponse } from "../../status-model";
11
11
 
12
+ import {
13
+ describeConnectorSummary,
14
+ omittedConnectorSummary as summarizeOmittedConnectors,
15
+ } from "../../../core/activation-connector-health";
16
+ import { isEmptyActivationCollection } from "../../../core/activation-empty";
12
17
  import { buildConnectorActivationCheck } from "../../activation-health";
13
18
  import { Button } from "./ui/button";
14
19
  import {
@@ -53,6 +58,7 @@ export function BootstrapStatus({
53
58
  const omittedConnectorCount =
54
59
  activation.connectorProjection.total -
55
60
  activation.connectorProjection.projected;
61
+ const omittedConnectorSummary = summarizeOmittedConnectors(activation);
56
62
  const connectorHealth = buildConnectorActivationCheck(activation);
57
63
  const connectorsHealthy =
58
64
  connectorHealth === null || connectorHealth.status === "ok";
@@ -147,9 +153,9 @@ export function BootstrapStatus({
147
153
  <Card className="border-border/60 bg-card/70">
148
154
  <CardHeader className="pb-3">
149
155
  <div className="flex items-center gap-2">
150
- {activation.healthy && connectorsHealthy ? (
156
+ {activation.healthy && activation.usable && connectorsHealthy ? (
151
157
  <CheckCircle2Icon className="size-4 text-emerald-500" />
152
- ) : activation.usable ? (
158
+ ) : activation.usable || activation.healthy ? (
153
159
  <ServerCogIcon className="size-4 text-amber-500" />
154
160
  ) : (
155
161
  <AlertCircleIcon className="size-4 text-destructive" />
@@ -158,7 +164,9 @@ export function BootstrapStatus({
158
164
  </div>
159
165
  <CardDescription>
160
166
  {activation.healthy
161
- ? "Lexical retrieval proven"
167
+ ? activation.usable
168
+ ? "Lexical retrieval proven"
169
+ : "No documents indexed yet"
162
170
  : activation.usable
163
171
  ? `Search usable in ${activation.collections.filter(({ ready }) => ready).length}/${activation.collections.length} folders`
164
172
  : "Retrieval proof failed"}
@@ -179,7 +187,9 @@ export function BootstrapStatus({
179
187
  <p className="text-muted-foreground text-xs">
180
188
  {collection.ready
181
189
  ? `Lexical passed; semantic ${collection.semanticAvailability.code}`
182
- : `${collection.remediation?.stage ?? "index"}/${collection.remediation?.code ?? "index_query_failed"}`}
190
+ : isEmptyActivationCollection(collection)
191
+ ? "No documents yet. Add files, then run update."
192
+ : `${collection.remediation?.stage ?? "index"}/${collection.remediation?.code ?? "index_query_failed"}`}
183
193
  </p>
184
194
  {collection.remediation && (
185
195
  <p className="mt-1 font-mono text-muted-foreground text-xs">
@@ -216,6 +226,9 @@ export function BootstrapStatus({
216
226
  <p className="text-muted-foreground text-xs">
217
227
  {omittedConnectorCount} additional target/collection checks
218
228
  omitted from this bounded status view
229
+ {omittedConnectorSummary
230
+ ? ` (${describeConnectorSummary(omittedConnectorSummary)})`
231
+ : ""}
219
232
  </p>
220
233
  )}
221
234
  </div>
@@ -14,6 +14,7 @@ import type {
14
14
 
15
15
  import { getModelsCachePath } from "../app/constants";
16
16
  import { buildContentTypeBoostStatus } from "../config/content-types";
17
+ import { isEmptyActivationCollection } from "../core/activation-empty";
17
18
  import {
18
19
  type ActivationStatus,
19
20
  buildActivationStatus,
@@ -555,7 +556,13 @@ function buildOnboarding(
555
556
  ): AppStatusResponse["onboarding"] {
556
557
  const foldersReady = activation.collections.length > 0;
557
558
  const modelsReady = modelCheck.status === "ok";
558
- const indexedReady = activation.healthy;
559
+ // An empty folder is not a failure, but a workspace with no documents yet
560
+ // has not finished its first proof.
561
+ const indexedReady = activation.healthy && activation.usable;
562
+ const readyCount = activation.collections.filter(({ ready }) => ready).length;
563
+ const emptyCount = activation.collections.filter(
564
+ isEmptyActivationCollection
565
+ ).length;
559
566
  const semanticStates = [
560
567
  ...new Set(
561
568
  activation.collections.map(
@@ -563,10 +570,22 @@ function buildOnboarding(
563
570
  )
564
571
  ),
565
572
  ];
566
- const failedActivation = activation.collections.find(({ ready }) => !ready);
567
- const activationDetail = failedActivation?.remediation
568
- ? `${failedActivation.collection}: ${failedActivation.remediation.stage}/${failedActivation.remediation.code}. Run: ${failedActivation.remediation.command}`
569
- : "Run the first sync to populate a searchable lexical index.";
573
+ const failedActivation =
574
+ activation.collections.find(
575
+ (collection) =>
576
+ !collection.ready && !isEmptyActivationCollection(collection)
577
+ ) ?? activation.collections.find(({ ready }) => !ready);
578
+ const activationDetail = !failedActivation?.remediation
579
+ ? "Run the first sync to populate a searchable lexical index."
580
+ : isEmptyActivationCollection(failedActivation)
581
+ ? `${failedActivation.collection} has no documents yet. Add files, then run: ${failedActivation.remediation.command}`
582
+ : `${failedActivation.collection}: ${failedActivation.remediation.stage}/${failedActivation.remediation.code}. Run: ${failedActivation.remediation.command}`;
583
+ const emptyNote =
584
+ emptyCount > 0
585
+ ? ` ${summarizeCount(emptyCount, "folder")} with no documents yet.`
586
+ : "";
587
+ const passedFolders =
588
+ emptyCount > 0 ? "Every folder with documents" : "Every folder";
570
589
 
571
590
  const steps = [
572
591
  {
@@ -600,7 +619,7 @@ function buildOnboarding(
600
619
  title: "Prove lexical retrieval",
601
620
  status: indexedReady ? "complete" : foldersReady ? "current" : "upcoming",
602
621
  detail: indexedReady
603
- ? `${summarizeCount(activation.collections.length, "folder")} passed a corpus-derived lexical proof.`
622
+ ? `${summarizeCount(readyCount, "folder")} passed a corpus-derived lexical proof.${emptyNote}`
604
623
  : activationDetail,
605
624
  action: "sync",
606
625
  },
@@ -636,8 +655,8 @@ function buildOnboarding(
636
655
  stage: "ready",
637
656
  headline: "Workspace ready",
638
657
  detail: modelsReady
639
- ? `Every folder passed lexical retrieval. Local model files are cached; semantic retrieval remains separate (${semanticStates.join(", ")}).`
640
- : `Every folder passed lexical retrieval. Semantic retrieval remains an optional next step (${semanticStates.join(", ")}).`,
658
+ ? `${passedFolders} passed lexical retrieval.${emptyNote} Local model files are cached; semantic retrieval remains separate (${semanticStates.join(", ")}).`
659
+ : `${passedFolders} passed lexical retrieval.${emptyNote} Semantic retrieval remains an optional next step (${semanticStates.join(", ")}).`,
641
660
  suggestedCollections: suggestions,
642
661
  steps,
643
662
  };
@@ -1 +0,0 @@
1
- 7fe15ce08a24f4dae4ac6694f62bb26d94bbc47d6abc09826f29267ace10558d gno-browser-clipper-v2.8.3.zip