@gmickel/gno 1.12.3 → 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 (69) hide show
  1. package/README.md +57 -30
  2. package/assets/skill/SKILL.md +6 -1
  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/context-resolver.ts +285 -0
  37. package/src/core/indexed-reference.ts +33 -8
  38. package/src/core/runtime-entrypoint.ts +24 -0
  39. package/src/mcp/activation-verification-mode.ts +4 -0
  40. package/src/mcp/server.ts +9 -2
  41. package/src/mcp/tools/index.ts +3 -3
  42. package/src/pipeline/answer-prompt.ts +80 -0
  43. package/src/pipeline/answer.ts +12 -26
  44. package/src/pipeline/hybrid.ts +2 -0
  45. package/src/pipeline/result-context.ts +51 -0
  46. package/src/pipeline/search.ts +5 -1
  47. package/src/pipeline/vsearch.ts +2 -0
  48. package/src/sdk/client.ts +7 -0
  49. package/src/sdk/types.ts +1 -0
  50. package/src/serve/activation-health.ts +91 -0
  51. package/src/serve/background-runtime.ts +11 -1
  52. package/src/serve/connectors.ts +164 -19
  53. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  54. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  55. package/src/serve/public/components/HealthCenter.tsx +8 -2
  56. package/src/serve/public/globals.built.css +1 -1
  57. package/src/serve/public/pages/Connectors.tsx +216 -55
  58. package/src/serve/public/pages/Dashboard.tsx +1 -0
  59. package/src/serve/routes/api.ts +152 -8
  60. package/src/serve/server.ts +44 -9
  61. package/src/serve/status-model.ts +4 -0
  62. package/src/serve/status.ts +79 -35
  63. package/src/store/activation-receipts.ts +390 -0
  64. package/src/store/index.ts +8 -0
  65. package/src/store/migrations/012-activation-receipts.ts +38 -0
  66. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  67. package/src/store/migrations/index.ts +4 -0
  68. package/src/store/sqlite/adapter.ts +320 -53
  69. package/src/store/types.ts +124 -0
@@ -13,9 +13,18 @@ import type {
13
13
  } from "./status-model";
14
14
 
15
15
  import { getModelsCachePath } from "../app/constants";
16
+ import {
17
+ type ActivationStatus,
18
+ buildActivationStatus,
19
+ } from "../core/activation-status";
16
20
  import { ModelCache } from "../llm/cache";
17
21
  import { envIsSet, resolveDownloadPolicy } from "../llm/policy";
18
22
  import { getActivePreset, resolveModelUri } from "../llm/registry";
23
+ import {
24
+ buildActivationCheck,
25
+ buildConnectorActivationCheck,
26
+ } from "./activation-health";
27
+ import { getConnectorVerificationTargets } from "./connectors";
19
28
  import { downloadState, type ServerContext } from "./context";
20
29
 
21
30
  const GIGABYTE = 1024 * 1024 * 1024;
@@ -60,6 +69,8 @@ export interface StatusBuildDeps {
60
69
  inspectDisk?: (path: string) => Promise<DiskSnapshot | null>;
61
70
  isModelCached?: (uri: string) => Promise<boolean>;
62
71
  listSuggestedCollections?: () => Promise<SuggestedCollection[]>;
72
+ buildActivation?: typeof buildActivationStatus;
73
+ listConnectorTargets?: typeof getConnectorVerificationTargets;
63
74
  }
64
75
 
65
76
  function formatBytes(bytes: number): string {
@@ -490,6 +501,7 @@ async function buildBootstrapState(
490
501
 
491
502
  const cachedCount = modelEntries.filter((entry) => entry.cached).length;
492
503
  const totalCount = modelEntries.length;
504
+ const totalSizeBytes = await cache.totalSize();
493
505
 
494
506
  const policySummary = policy.offline
495
507
  ? "Offline mode. Cached models only."
@@ -517,8 +529,8 @@ async function buildBootstrapState(
517
529
  },
518
530
  cache: {
519
531
  path: cache.dir,
520
- totalSizeBytes: await cache.totalSize(),
521
- totalSizeLabel: formatBytes(await cache.totalSize()),
532
+ totalSizeBytes,
533
+ totalSizeLabel: formatBytes(totalSizeBytes),
522
534
  },
523
535
  models: {
524
536
  activePresetId: preset.id,
@@ -540,12 +552,23 @@ function buildOnboarding(
540
552
  status: IndexStatus,
541
553
  modelCheck: HealthCheck,
542
554
  suggestions: SuggestedCollection[],
543
- presetName: string
555
+ presetName: string,
556
+ activation: ActivationStatus
544
557
  ): AppStatusResponse["onboarding"] {
545
- const foldersReady = status.collections.length > 0;
558
+ const foldersReady = activation.collections.length > 0;
546
559
  const modelsReady = modelCheck.status === "ok";
547
- const indexedReady =
548
- status.activeDocuments > 0 && status.embeddingBacklog === 0;
560
+ const indexedReady = activation.healthy;
561
+ const semanticStates = [
562
+ ...new Set(
563
+ activation.collections.map(
564
+ ({ semanticAvailability }) => semanticAvailability.code
565
+ )
566
+ ),
567
+ ];
568
+ const failedActivation = activation.collections.find(({ ready }) => !ready);
569
+ const activationDetail = failedActivation?.remediation
570
+ ? `${failedActivation.collection}: ${failedActivation.remediation.stage}/${failedActivation.remediation.code}. Run: ${failedActivation.remediation.command}`
571
+ : "Run the first sync to populate a searchable lexical index.";
549
572
 
550
573
  const steps = [
551
574
  {
@@ -555,6 +578,7 @@ function buildOnboarding(
555
578
  detail: foldersReady
556
579
  ? `${summarizeCount(status.collections.length, "folder")} connected.`
557
580
  : "Choose the folders you want GNO to watch and index.",
581
+ action: "add-collection",
558
582
  },
559
583
  {
560
584
  id: "preset",
@@ -567,21 +591,20 @@ function buildOnboarding(
567
591
  {
568
592
  id: "models",
569
593
  title: "Prepare local models",
570
- status: foldersReady ? "current" : "upcoming",
594
+ status: "upcoming",
571
595
  detail: modelCheck.detail,
596
+ action: "download-models",
572
597
  },
573
598
  ] satisfies AppStatusResponse["onboarding"]["steps"])
574
599
  : []),
575
600
  {
576
601
  id: "indexing",
577
- title: "Finish first index",
602
+ title: "Prove lexical retrieval",
578
603
  status: indexedReady ? "complete" : foldersReady ? "current" : "upcoming",
579
- detail:
580
- status.activeDocuments > 0 && status.embeddingBacklog > 0
581
- ? `${summarizeCount(status.embeddingBacklog, "chunk")} still waiting on embeddings.`
582
- : status.activeDocuments > 0
583
- ? `${summarizeCount(status.activeDocuments, "document")} indexed so far.`
584
- : "Run the first sync to scan your folders and build the search index.",
604
+ detail: indexedReady
605
+ ? `${summarizeCount(activation.collections.length, "folder")} passed a corpus-derived lexical proof.`
606
+ : activationDetail,
607
+ action: "sync",
585
608
  },
586
609
  ] satisfies AppStatusResponse["onboarding"]["steps"];
587
610
 
@@ -597,28 +620,14 @@ function buildOnboarding(
597
620
  };
598
621
  }
599
622
 
600
- if (!modelsReady) {
601
- return {
602
- ready: false,
603
- stage: "models",
604
- headline: "Your folders are connected. Finish model setup next",
605
- detail: modelCheck.detail,
606
- suggestedCollections: suggestions,
607
- steps,
608
- };
609
- }
610
-
611
623
  if (!indexedReady) {
612
624
  return {
613
625
  ready: false,
614
626
  stage: "indexing",
615
- headline: "GNO is almost ready. Finish the first indexing run",
616
- detail:
617
- status.activeDocuments > 0 && status.embeddingBacklog > 0
618
- ? `Finish embeddings for ${summarizeCount(status.embeddingBacklog, "chunk")} to unlock semantic search and local answers.`
619
- : status.activeDocuments > 0
620
- ? "The first sync started. Let embeddings finish so semantic search and answers can fully light up."
621
- : "Run the first sync to populate the index from the folders you connected.",
627
+ headline: activation.usable
628
+ ? "Search works in some folders. Repair the remaining retrieval proof"
629
+ : "Finish the first lexical retrieval proof",
630
+ detail: activationDetail,
622
631
  suggestedCollections: suggestions,
623
632
  steps,
624
633
  };
@@ -628,7 +637,9 @@ function buildOnboarding(
628
637
  ready: true,
629
638
  stage: "ready",
630
639
  headline: "Workspace ready",
631
- detail: "Your folders, local models, and first index are all in place.",
640
+ detail: modelsReady
641
+ ? `Every folder passed lexical retrieval. Local model files are cached; semantic retrieval remains separate (${semanticStates.join(", ")}).`
642
+ : `Every folder passed lexical retrieval. Semantic retrieval remains an optional next step (${semanticStates.join(", ")}).`,
632
643
  suggestedCollections: suggestions,
633
644
  steps,
634
645
  };
@@ -647,17 +658,43 @@ export async function buildAppStatus(
647
658
 
648
659
  const status = result.value;
649
660
  const preset = getActivePreset(ctx.config);
650
- const [modelCheck, diskCheck, suggestions, bootstrap] = await Promise.all([
661
+ const cache = new ModelCache(getModelsCachePath());
662
+ const isModelCached =
663
+ deps.isModelCached ?? ((uri: string) => cache.isCached(uri));
664
+ const [
665
+ modelCheck,
666
+ diskCheck,
667
+ suggestions,
668
+ bootstrap,
669
+ embedCached,
670
+ connectorTargets,
671
+ ] = await Promise.all([
651
672
  buildModelCheck(ctx, deps),
652
673
  buildDiskCheck(status, deps),
653
674
  deps.listSuggestedCollections?.() ?? listSuggestedCollections(),
654
675
  buildBootstrapState(ctx),
676
+ isModelCached(preset.embed),
677
+ (deps.listConnectorTargets ?? getConnectorVerificationTargets)(),
655
678
  ]);
679
+ const activation = await (deps.buildActivation ?? buildActivationStatus)(
680
+ ctx.store,
681
+ ctx.config.collections.map(({ name }) => name),
682
+ {
683
+ semantic: {
684
+ modelsCached: embedCached,
685
+ embeddingBacklog: status.embeddingBacklog,
686
+ vectorAvailable: ctx.capabilities.vector,
687
+ },
688
+ connectorTargets,
689
+ }
690
+ );
656
691
 
657
692
  const backgroundCheck = buildBackgroundCheck(ctx, status);
658
693
  const checks = [
659
694
  buildCollectionCheck(status),
660
695
  buildIndexingCheck(status),
696
+ buildActivationCheck(activation),
697
+ buildConnectorActivationCheck(activation),
661
698
  modelCheck,
662
699
  buildVectorCheck(ctx),
663
700
  diskCheck,
@@ -698,7 +735,14 @@ export async function buildAppStatus(
698
735
  name: preset.name,
699
736
  },
700
737
  capabilities: ctx.capabilities,
701
- onboarding: buildOnboarding(status, modelCheck, suggestions, preset.name),
738
+ activation,
739
+ onboarding: buildOnboarding(
740
+ status,
741
+ modelCheck,
742
+ suggestions,
743
+ preset.name,
744
+ activation
745
+ ),
702
746
  health: {
703
747
  state: healthState,
704
748
  summary: healthSummary,
@@ -0,0 +1,390 @@
1
+ import type {
2
+ ActivationStageReceipt,
3
+ ActivationVerificationReceipt,
4
+ } from "./types";
5
+
6
+ const ACTIVATION_RECEIPT_MAX_BYTES = 16_384;
7
+ const HASH_PATTERN = /^[a-f0-9]{64}$/;
8
+ const RESULT_URI_PATTERN = /^gno:\/\/[^/]+\/.+/;
9
+ const CONNECTOR_TARGET_PATTERN =
10
+ /^(?:mcp|skill):[a-z0-9][a-z0-9._-]{0,63}:(?:user|project):[a-f0-9]{64}$/;
11
+ const DATE_TIME_PATTERN =
12
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/;
13
+ const INDEX_FAILURE_CODES = new Set(["no_documents", "index_out_of_sync"]);
14
+ const LEXICAL_FAILURE_CODES = new Set([
15
+ "no_probe_term",
16
+ "index_query_failed",
17
+ "retrieval_mismatch",
18
+ ]);
19
+ const CONNECTOR_SKIPPED_CODES = new Set([
20
+ "connector_not_configured",
21
+ "connector_probe_unavailable",
22
+ "target_runtime_unverifiable",
23
+ ]);
24
+ const CONNECTOR_FAILURE_CODES = new Set([
25
+ "connector_probe_unavailable",
26
+ "connector_unsupported_config",
27
+ "connector_start_failed",
28
+ "connector_timeout",
29
+ "connector_missing_tools",
30
+ "connector_status_failed",
31
+ "connector_search_failed",
32
+ "connector_result_mismatch",
33
+ ]);
34
+
35
+ function projectStage(stage: ActivationStageReceipt): ActivationStageReceipt {
36
+ return {
37
+ status: stage.status,
38
+ startedAt: stage.startedAt,
39
+ completedAt: stage.completedAt,
40
+ latencyMs: stage.latencyMs,
41
+ ...(stage.code ? { code: stage.code } : {}),
42
+ };
43
+ }
44
+
45
+ function projectReceipt(
46
+ receipt: ActivationVerificationReceipt
47
+ ): ActivationVerificationReceipt {
48
+ return {
49
+ schemaVersion: "1.0",
50
+ collection: receipt.collection,
51
+ fingerprint: receipt.fingerprint,
52
+ ready: receipt.ready,
53
+ generatedAt: receipt.generatedAt,
54
+ stages: {
55
+ index: projectStage(receipt.stages.index),
56
+ lexical: projectStage(receipt.stages.lexical),
57
+ semantic: projectStage(receipt.stages.semantic),
58
+ connector: projectStage(receipt.stages.connector),
59
+ },
60
+ evidence: {
61
+ ...(receipt.evidence.probeHash
62
+ ? { probeHash: receipt.evidence.probeHash }
63
+ : {}),
64
+ ...(receipt.evidence.resultUri
65
+ ? { resultUri: receipt.evidence.resultUri }
66
+ : {}),
67
+ ...(receipt.evidence.resultSourceHash
68
+ ? { resultSourceHash: receipt.evidence.resultSourceHash }
69
+ : {}),
70
+ ...(receipt.evidence.connectorTarget
71
+ ? { connectorTarget: receipt.evidence.connectorTarget }
72
+ : {}),
73
+ },
74
+ };
75
+ }
76
+
77
+ function hasOnlyKeys(value: Record<string, unknown>, allowed: Set<string>) {
78
+ return Object.keys(value).every((key) => allowed.has(key));
79
+ }
80
+
81
+ function isDateTime(value: unknown): value is string {
82
+ if (typeof value !== "string") {
83
+ return false;
84
+ }
85
+ const match = DATE_TIME_PATTERN.exec(value);
86
+ if (!match) {
87
+ return false;
88
+ }
89
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText] =
90
+ match;
91
+ const year = Number(yearText);
92
+ const month = Number(monthText);
93
+ const day = Number(dayText);
94
+ const hour = Number(hourText);
95
+ const minute = Number(minuteText);
96
+ const second = Number(secondText);
97
+ const offsetHour = Number(match[7] ?? 0);
98
+ const offsetMinute = Number(match[8] ?? 0);
99
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
100
+ const daysInMonth = [
101
+ 31,
102
+ leapYear ? 29 : 28,
103
+ 31,
104
+ 30,
105
+ 31,
106
+ 30,
107
+ 31,
108
+ 31,
109
+ 30,
110
+ 31,
111
+ 30,
112
+ 31,
113
+ ];
114
+ return (
115
+ month >= 1 &&
116
+ month <= 12 &&
117
+ day >= 1 &&
118
+ day <= (daysInMonth[month - 1] ?? 0) &&
119
+ hour <= 23 &&
120
+ minute <= 59 &&
121
+ second <= 59 &&
122
+ offsetHour <= 23 &&
123
+ offsetMinute <= 59
124
+ );
125
+ }
126
+
127
+ function isNullableDateTime(value: unknown): value is string | null {
128
+ return value === null || isDateTime(value);
129
+ }
130
+
131
+ function isStageShape(value: unknown): value is ActivationStageReceipt {
132
+ if (!value || typeof value !== "object") {
133
+ return false;
134
+ }
135
+ const stage = value as Record<string, unknown>;
136
+ return (
137
+ hasOnlyKeys(
138
+ stage,
139
+ new Set(["status", "startedAt", "completedAt", "latencyMs", "code"])
140
+ ) &&
141
+ typeof stage.status === "string" &&
142
+ isNullableDateTime(stage.startedAt) &&
143
+ isNullableDateTime(stage.completedAt) &&
144
+ (stage.latencyMs === null ||
145
+ (typeof stage.latencyMs === "number" &&
146
+ Number.isInteger(stage.latencyMs) &&
147
+ stage.latencyMs >= 0)) &&
148
+ (stage.code === undefined || typeof stage.code === "string")
149
+ );
150
+ }
151
+
152
+ function isTimedStage(stage: ActivationStageReceipt): boolean {
153
+ return (
154
+ isDateTime(stage.startedAt) &&
155
+ isDateTime(stage.completedAt) &&
156
+ typeof stage.latencyMs === "number"
157
+ );
158
+ }
159
+
160
+ function isIndexStage(stage: ActivationStageReceipt): boolean {
161
+ if (!isTimedStage(stage)) {
162
+ return false;
163
+ }
164
+ return (
165
+ (stage.status === "passed" && stage.code === undefined) ||
166
+ (stage.status === "failed" &&
167
+ stage.code !== undefined &&
168
+ INDEX_FAILURE_CODES.has(stage.code))
169
+ );
170
+ }
171
+
172
+ function isLexicalStage(stage: ActivationStageReceipt): boolean {
173
+ if (stage.status === "passed") {
174
+ return isTimedStage(stage) && stage.code === undefined;
175
+ }
176
+ if (stage.status === "failed") {
177
+ return (
178
+ isTimedStage(stage) &&
179
+ stage.code !== undefined &&
180
+ LEXICAL_FAILURE_CODES.has(stage.code)
181
+ );
182
+ }
183
+ return (
184
+ stage.status === "skipped" &&
185
+ stage.startedAt === null &&
186
+ isDateTime(stage.completedAt) &&
187
+ stage.latencyMs === null &&
188
+ stage.code !== undefined &&
189
+ INDEX_FAILURE_CODES.has(stage.code)
190
+ );
191
+ }
192
+
193
+ function isSemanticStage(stage: ActivationStageReceipt): boolean {
194
+ return (
195
+ stage.status === "pending" &&
196
+ stage.startedAt === null &&
197
+ stage.completedAt === null &&
198
+ stage.latencyMs === null &&
199
+ stage.code === "semantic_not_checked"
200
+ );
201
+ }
202
+
203
+ function isConnectorStage(stage: ActivationStageReceipt): boolean {
204
+ if (stage.status === "passed") {
205
+ return isTimedStage(stage) && stage.code === undefined;
206
+ }
207
+ if (stage.status === "failed") {
208
+ return (
209
+ isTimedStage(stage) &&
210
+ stage.code !== undefined &&
211
+ CONNECTOR_FAILURE_CODES.has(stage.code)
212
+ );
213
+ }
214
+ if (stage.status !== "skipped") {
215
+ return false;
216
+ }
217
+ if (stage.code === "connector_not_requested") {
218
+ return (
219
+ stage.startedAt === null &&
220
+ stage.completedAt === null &&
221
+ stage.latencyMs === null
222
+ );
223
+ }
224
+ return (
225
+ isTimedStage(stage) &&
226
+ stage.code !== undefined &&
227
+ CONNECTOR_SKIPPED_CODES.has(stage.code)
228
+ );
229
+ }
230
+
231
+ function isReceipt(value: unknown): value is ActivationVerificationReceipt {
232
+ if (!value || typeof value !== "object") {
233
+ return false;
234
+ }
235
+ const receipt = value as Record<string, unknown>;
236
+ const stages = receipt.stages as Record<string, unknown> | undefined;
237
+ const evidence = receipt.evidence as Record<string, unknown> | undefined;
238
+ if (
239
+ !hasOnlyKeys(
240
+ receipt,
241
+ new Set([
242
+ "schemaVersion",
243
+ "collection",
244
+ "fingerprint",
245
+ "ready",
246
+ "generatedAt",
247
+ "stages",
248
+ "evidence",
249
+ ])
250
+ ) ||
251
+ receipt.schemaVersion !== "1.0" ||
252
+ typeof receipt.collection !== "string" ||
253
+ receipt.collection.length < 1 ||
254
+ receipt.collection.length > 128 ||
255
+ typeof receipt.fingerprint !== "string" ||
256
+ !HASH_PATTERN.test(receipt.fingerprint) ||
257
+ typeof receipt.ready !== "boolean" ||
258
+ !isDateTime(receipt.generatedAt) ||
259
+ !stages ||
260
+ !hasOnlyKeys(
261
+ stages,
262
+ new Set(["index", "lexical", "semantic", "connector"])
263
+ ) ||
264
+ !isStageShape(stages.index) ||
265
+ !isStageShape(stages.lexical) ||
266
+ !isStageShape(stages.semantic) ||
267
+ !isStageShape(stages.connector) ||
268
+ !evidence ||
269
+ !hasOnlyKeys(
270
+ evidence,
271
+ new Set(["probeHash", "resultUri", "resultSourceHash", "connectorTarget"])
272
+ )
273
+ ) {
274
+ return false;
275
+ }
276
+
277
+ const indexStage = stages.index as ActivationStageReceipt;
278
+ const lexicalStage = stages.lexical as ActivationStageReceipt;
279
+ const semanticStage = stages.semantic as ActivationStageReceipt;
280
+ const connectorStage = stages.connector as ActivationStageReceipt;
281
+ if (
282
+ !isIndexStage(indexStage) ||
283
+ !isLexicalStage(lexicalStage) ||
284
+ !isSemanticStage(semanticStage) ||
285
+ !isConnectorStage(connectorStage)
286
+ ) {
287
+ return false;
288
+ }
289
+ const expectedReady =
290
+ indexStage.status === "passed" && lexicalStage.status === "passed";
291
+ if (receipt.ready !== expectedReady) {
292
+ return false;
293
+ }
294
+ const connectorCode = connectorStage.code;
295
+ const connectorNeedsLexicalProof =
296
+ connectorStage.status === "passed" ||
297
+ (connectorStage.status === "failed" &&
298
+ connectorCode !== "connector_unsupported_config");
299
+ const unavailableBeforeConnectorProbe =
300
+ connectorStage.status === "skipped" &&
301
+ connectorCode === "connector_probe_unavailable";
302
+ if (
303
+ (connectorNeedsLexicalProof && !expectedReady) ||
304
+ (unavailableBeforeConnectorProbe && expectedReady)
305
+ ) {
306
+ return false;
307
+ }
308
+
309
+ const validEvidence =
310
+ (evidence.probeHash === undefined ||
311
+ (typeof evidence.probeHash === "string" &&
312
+ HASH_PATTERN.test(evidence.probeHash))) &&
313
+ (evidence.resultUri === undefined ||
314
+ (typeof evidence.resultUri === "string" &&
315
+ evidence.resultUri.length <= 2048 &&
316
+ RESULT_URI_PATTERN.test(evidence.resultUri))) &&
317
+ (evidence.resultSourceHash === undefined ||
318
+ (typeof evidence.resultSourceHash === "string" &&
319
+ HASH_PATTERN.test(evidence.resultSourceHash))) &&
320
+ (evidence.connectorTarget === undefined ||
321
+ (typeof evidence.connectorTarget === "string" &&
322
+ CONNECTOR_TARGET_PATTERN.test(evidence.connectorTarget)));
323
+ if (!validEvidence) {
324
+ return false;
325
+ }
326
+
327
+ const connectorWasRequested =
328
+ connectorStage.code !== "connector_not_requested";
329
+ if (
330
+ connectorWasRequested !==
331
+ (typeof evidence.connectorTarget === "string")
332
+ ) {
333
+ return false;
334
+ }
335
+
336
+ const hasProbeHash = typeof evidence.probeHash === "string";
337
+ const hasResultUri = typeof evidence.resultUri === "string";
338
+ const hasResultSourceHash = typeof evidence.resultSourceHash === "string";
339
+ if (expectedReady) {
340
+ return hasProbeHash && hasResultUri && hasResultSourceHash;
341
+ }
342
+ if (lexicalStage.code === "retrieval_mismatch") {
343
+ return hasProbeHash && !hasResultUri && !hasResultSourceHash;
344
+ }
345
+ return !hasProbeHash && !hasResultUri && !hasResultSourceHash;
346
+ }
347
+
348
+ export function parseActivationReceipt(
349
+ raw: string
350
+ ): ActivationVerificationReceipt | null {
351
+ try {
352
+ const parsed: unknown = JSON.parse(raw);
353
+ return isReceipt(parsed) ? projectReceipt(parsed) : null;
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+
359
+ export function serializeActivationReceipt(
360
+ receipt: ActivationVerificationReceipt
361
+ ):
362
+ | {
363
+ ok: true;
364
+ json: string;
365
+ projected: ActivationVerificationReceipt;
366
+ connectorTarget: string;
367
+ }
368
+ | { ok: false; error: string } {
369
+ let projected: ActivationVerificationReceipt;
370
+ try {
371
+ projected = projectReceipt(receipt);
372
+ } catch {
373
+ return { ok: false, error: "Activation receipt is schema-invalid" };
374
+ }
375
+ if (!isReceipt(projected)) {
376
+ return { ok: false, error: "Activation receipt is schema-invalid" };
377
+ }
378
+ const json = JSON.stringify(projected);
379
+ if (
380
+ new TextEncoder().encode(json).byteLength > ACTIVATION_RECEIPT_MAX_BYTES
381
+ ) {
382
+ return { ok: false, error: "Activation receipt exceeds 16 KiB" };
383
+ }
384
+ return {
385
+ ok: true,
386
+ json,
387
+ projected,
388
+ connectorTarget: projected.evidence.connectorTarget ?? "",
389
+ };
390
+ }
@@ -19,6 +19,14 @@ export {
19
19
  export { SqliteAdapter } from "./sqlite";
20
20
  // Types and interfaces
21
21
  export type {
22
+ ActivationIndexDocument,
23
+ ActivationIndexIdentity,
24
+ ActivationIndexSnapshot,
25
+ ActivationStageName,
26
+ ActivationStageReceipt,
27
+ ActivationStageStatus,
28
+ ActivationVerificationCode,
29
+ ActivationVerificationReceipt,
22
30
  ChunkInput,
23
31
  ChunkRow,
24
32
  CleanupStats,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Migration: privacy-bounded retrieval activation receipts.
3
+ *
4
+ * @module src/store/migrations/012-activation-receipts
5
+ */
6
+
7
+ import type { Database } from "bun:sqlite";
8
+
9
+ import type { Migration } from "./runner";
10
+
11
+ export const migration: Migration = {
12
+ version: 12,
13
+ name: "activation_receipts",
14
+
15
+ up(db: Database): void {
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS activation_receipts (
18
+ collection TEXT NOT NULL,
19
+ connector_target TEXT NOT NULL DEFAULT '',
20
+ schema_version TEXT NOT NULL,
21
+ fingerprint TEXT NOT NULL,
22
+ receipt_json TEXT NOT NULL CHECK (length(receipt_json) <= 16384),
23
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
24
+ PRIMARY KEY (collection, connector_target),
25
+ FOREIGN KEY (collection) REFERENCES collections(name) ON DELETE CASCADE
26
+ )
27
+ `);
28
+ db.exec(`
29
+ CREATE INDEX IF NOT EXISTS idx_activation_receipts_fingerprint
30
+ ON activation_receipts(fingerprint)
31
+ `);
32
+ },
33
+
34
+ down(db: Database): void {
35
+ db.exec("DROP INDEX IF EXISTS idx_activation_receipts_fingerprint");
36
+ db.exec("DROP TABLE IF EXISTS activation_receipts");
37
+ },
38
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Migration: metadata-only FTS synchronization marker.
3
+ *
4
+ * The marker records which mirror hash was transactionally written through the
5
+ * supported FTS writers. This one-time migration compares legacy FTS bodies
6
+ * before trusting them; passive activation checks remain metadata-only.
7
+ */
8
+
9
+ import type { Database } from "bun:sqlite";
10
+
11
+ import type { Migration } from "./runner";
12
+
13
+ export const migration: Migration = {
14
+ version: 13,
15
+ name: "fts_sync_marker",
16
+
17
+ up(db: Database): void {
18
+ db.exec("ALTER TABLE documents ADD COLUMN fts_mirror_hash TEXT");
19
+ db.exec(`
20
+ UPDATE documents
21
+ SET fts_mirror_hash = mirror_hash
22
+ WHERE active = 1
23
+ AND mirror_hash IS NOT NULL
24
+ AND EXISTS (
25
+ SELECT 1
26
+ FROM documents_fts f
27
+ JOIN content c ON c.mirror_hash = documents.mirror_hash
28
+ WHERE f.rowid = documents.id
29
+ AND f.filepath = documents.rel_path
30
+ AND f.title = COALESCE(documents.title, '')
31
+ AND f.body = c.markdown
32
+ )
33
+ `);
34
+ },
35
+
36
+ down(db: Database): void {
37
+ db.exec("ALTER TABLE documents DROP COLUMN fts_mirror_hash");
38
+ },
39
+ };