@gmickel/gno 1.24.0 → 1.26.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 (96) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +40 -17
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +196 -4
  15. package/spec/db/schema.sql +102 -1
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  28. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  29. package/spec/output-schemas/query-diagnose.schema.json +7 -1
  30. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  31. package/spec/project-profile.schema.json +301 -0
  32. package/src/cli/commands/collection/add.ts +39 -45
  33. package/src/cli/commands/collection/remove.ts +28 -28
  34. package/src/cli/commands/collection/rename.ts +55 -73
  35. package/src/cli/commands/context/add.ts +37 -26
  36. package/src/cli/commands/context/rm.ts +47 -20
  37. package/src/cli/commands/init.ts +55 -125
  38. package/src/cli/commands/models/use.ts +43 -38
  39. package/src/cli/commands/profile-apply.ts +334 -0
  40. package/src/cli/commands/profile.ts +409 -0
  41. package/src/cli/commands/setup-activation.ts +205 -54
  42. package/src/cli/commands/setup-profile.ts +223 -0
  43. package/src/cli/commands/setup.ts +3 -0
  44. package/src/cli/program.ts +110 -9
  45. package/src/config/index.ts +3 -0
  46. package/src/config/project-profile.ts +367 -0
  47. package/src/config/saver.ts +16 -7
  48. package/src/config/types.ts +42 -0
  49. package/src/core/browser-clip-provenance.ts +139 -0
  50. package/src/core/browser-clip.ts +473 -0
  51. package/src/core/capture-write.ts +5 -0
  52. package/src/core/capture.ts +75 -18
  53. package/src/core/config-mutation.ts +138 -76
  54. package/src/core/config-write-lock.ts +89 -0
  55. package/src/core/context-identity.ts +16 -0
  56. package/src/core/context-resolver.ts +2 -12
  57. package/src/core/file-lock.ts +20 -6
  58. package/src/core/folder-setup-planning.ts +6 -21
  59. package/src/core/folder-setup.ts +30 -2
  60. package/src/core/path-rules.ts +53 -0
  61. package/src/core/project-affinity-surface.ts +102 -7
  62. package/src/core/project-profile-apply-state.ts +268 -0
  63. package/src/core/project-profile-apply-validation.ts +95 -0
  64. package/src/core/project-profile-apply.ts +408 -0
  65. package/src/core/project-profile-canonical.ts +71 -0
  66. package/src/core/project-profile-diff.ts +302 -0
  67. package/src/core/project-profile-discovery.ts +519 -0
  68. package/src/core/project-profile-file.ts +37 -0
  69. package/src/core/project-profile-parser.ts +98 -0
  70. package/src/core/project-profile.ts +490 -0
  71. package/src/ingestion/walker.ts +85 -44
  72. package/src/llm/cache.ts +21 -0
  73. package/src/serve/capture-service.ts +420 -0
  74. package/src/serve/clipper-body.ts +62 -0
  75. package/src/serve/clipper-capture.ts +248 -0
  76. package/src/serve/clipper-contract.ts +57 -0
  77. package/src/serve/clipper-idempotency.ts +35 -0
  78. package/src/serve/clipper-pairing.ts +297 -0
  79. package/src/serve/clipper-security-errors.ts +23 -0
  80. package/src/serve/clipper-security.ts +449 -0
  81. package/src/serve/config-sync.ts +2 -2
  82. package/src/serve/public/app.tsx +8 -1
  83. package/src/serve/public/globals.built.css +1 -1
  84. package/src/serve/public/index.html +1 -0
  85. package/src/serve/public/lib/clipper-approval.ts +206 -0
  86. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  87. package/src/serve/resident-runtime.ts +1 -0
  88. package/src/serve/routes/api.ts +20 -115
  89. package/src/serve/routes/clipper.ts +394 -0
  90. package/src/serve/server.ts +22 -0
  91. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  92. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  93. package/src/store/migrations/index.ts +4 -0
  94. package/src/store/sqlite/adapter.ts +83 -0
  95. package/src/store/sqlite/clipper-store-types.ts +104 -0
  96. package/src/store/sqlite/clipper-store.ts +496 -0
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <base href="/" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="referrer" content="no-referrer" />
7
8
  <title>GNO - Local Knowledge Index</title>
8
9
  <link rel="stylesheet" href="./globals.built.css" />
9
10
  </head>
@@ -0,0 +1,206 @@
1
+ const PAIR_ID = /^[a-f0-9]{64}$/u;
2
+ const PAIRING_CODE = /^\d{8}$/u;
3
+ const TOKEN = /^[a-f0-9]{64}$/u;
4
+ const EXTENSION_ORIGIN = /^chrome-extension:\/\/[a-p]{32}$/u;
5
+ const KNOWN_ERROR_CODES = new Set([
6
+ "CLIPPER_ABORTED",
7
+ "CLIPPER_BODY_TOO_LARGE",
8
+ "CLIPPER_BUSY",
9
+ "CLIPPER_FORBIDDEN",
10
+ "CLIPPER_INVALID_JSON",
11
+ "CLIPPER_RATE_LIMITED",
12
+ "CLIPPER_UNAUTHORIZED",
13
+ "CLIPPER_PAIRING_UNAVAILABLE",
14
+ "CLIPPER_CSRF",
15
+ "CLIPPER_INVALID_REQUEST",
16
+ "CLIPPER_PAIR_NOT_FOUND",
17
+ "CLIPPER_PAIR_EXPIRED",
18
+ "CLIPPER_PAIR_INVALID_CODE",
19
+ "CLIPPER_PAIR_ALREADY_USED",
20
+ "CLIPPER_PREVIEW_MISMATCH",
21
+ "CLIPPER_PREVIEW_REQUIRED",
22
+ "CLIPPER_IDEMPOTENCY_PENDING",
23
+ "CLIPPER_IDEMPOTENCY_CONFLICT",
24
+ "CLIPPER_IDEMPOTENCY_RECOVERY_CONFLICT",
25
+ "CLIPPER_IDEMPOTENCY_GRANT_INACTIVE",
26
+ "CLIPPER_CAPTURE_FAILED",
27
+ "NOT_FOUND",
28
+ "RUNTIME",
29
+ "VALIDATION",
30
+ ]);
31
+
32
+ export interface ClipperPairLaunch {
33
+ pairId: string | null;
34
+ valid: boolean;
35
+ }
36
+
37
+ export interface ClipperApproval {
38
+ origin: string;
39
+ expiresAt: string;
40
+ }
41
+
42
+ export class ClipperApprovalError extends Error {
43
+ readonly code: string;
44
+ readonly retryable: boolean;
45
+
46
+ constructor(code: string, message: string, retryable = false) {
47
+ super(message);
48
+ this.name = "ClipperApprovalError";
49
+ this.code = code;
50
+ this.retryable = retryable;
51
+ }
52
+ }
53
+
54
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
55
+ value !== null && typeof value === "object" && !Array.isArray(value);
56
+
57
+ const hasExactKeys = (
58
+ value: Record<string, unknown>,
59
+ keys: readonly string[]
60
+ ): boolean => {
61
+ const actual = Object.keys(value).sort();
62
+ const expected = [...keys].sort();
63
+ return (
64
+ actual.length === expected.length &&
65
+ actual.every((key, index) => key === expected[index])
66
+ );
67
+ };
68
+
69
+ const isDateTime = (value: unknown): value is string =>
70
+ typeof value === "string" &&
71
+ Number.isFinite(Date.parse(value)) &&
72
+ value.includes("T");
73
+
74
+ export const consumeClipperPairLaunch = (
75
+ location: Pick<Location, "pathname" | "search" | "hash">,
76
+ history: Pick<History, "replaceState">
77
+ ): ClipperPairLaunch => {
78
+ if (location.pathname !== "/clipper/pair") {
79
+ return { pairId: null, valid: false };
80
+ }
81
+ const match =
82
+ location.search === ""
83
+ ? /^#pairId=(?<pairId>[a-f0-9]{64})$/u.exec(location.hash)
84
+ : null;
85
+ history.replaceState({}, "", "/clipper/pair");
86
+ const pairId = match?.groups?.pairId ?? null;
87
+ return { pairId, valid: pairId !== null };
88
+ };
89
+
90
+ const parseError = (value: unknown): ClipperApprovalError | null => {
91
+ if (
92
+ !isRecord(value) ||
93
+ !hasExactKeys(value, ["error"]) ||
94
+ !isRecord(value.error) ||
95
+ !hasExactKeys(value.error, ["code", "message"]) ||
96
+ typeof value.error.code !== "string" ||
97
+ !KNOWN_ERROR_CODES.has(value.error.code) ||
98
+ typeof value.error.message !== "string" ||
99
+ value.error.message.length === 0
100
+ ) {
101
+ return null;
102
+ }
103
+ return new ClipperApprovalError(
104
+ value.error.code,
105
+ value.error.message,
106
+ value.error.code === "CLIPPER_PAIR_INVALID_CODE"
107
+ );
108
+ };
109
+
110
+ const readJson = async (response: Response): Promise<unknown> => {
111
+ if (
112
+ !(response.headers.get("content-type") ?? "").includes("application/json")
113
+ ) {
114
+ throw new ClipperApprovalError(
115
+ "CLIPPER_INVALID_RESPONSE",
116
+ "GNO returned a non-JSON pairing response."
117
+ );
118
+ }
119
+ try {
120
+ return await response.json();
121
+ } catch {
122
+ throw new ClipperApprovalError(
123
+ "CLIPPER_INVALID_RESPONSE",
124
+ "GNO returned malformed pairing data."
125
+ );
126
+ }
127
+ };
128
+
129
+ const requireSuccess = (response: Response, value: unknown): void => {
130
+ if (response.ok) return;
131
+ const parsed = parseError(value);
132
+ if (parsed) throw parsed;
133
+ throw new ClipperApprovalError(
134
+ "CLIPPER_INVALID_RESPONSE",
135
+ "GNO returned an unknown pairing error."
136
+ );
137
+ };
138
+
139
+ export async function approveClipperPair(
140
+ pairId: string,
141
+ pairingCode: string,
142
+ fetcher: typeof fetch = fetch
143
+ ): Promise<ClipperApproval> {
144
+ if (!PAIR_ID.test(pairId) || !PAIRING_CODE.test(pairingCode)) {
145
+ throw new ClipperApprovalError(
146
+ "CLIPPER_INVALID_REQUEST",
147
+ "Enter the exact eight-digit code shown by the extension."
148
+ );
149
+ }
150
+
151
+ const csrfResponse = await fetcher("/api/clipper/pair/csrf", {
152
+ method: "GET",
153
+ cache: "no-store",
154
+ credentials: "same-origin",
155
+ });
156
+ const csrfBody = await readJson(csrfResponse);
157
+ requireSuccess(csrfResponse, csrfBody);
158
+ if (
159
+ !isRecord(csrfBody) ||
160
+ !hasExactKeys(csrfBody, ["schemaVersion", "csrfToken"]) ||
161
+ csrfBody.schemaVersion !== "1.0" ||
162
+ typeof csrfBody.csrfToken !== "string" ||
163
+ !TOKEN.test(csrfBody.csrfToken)
164
+ ) {
165
+ throw new ClipperApprovalError(
166
+ "CLIPPER_INVALID_RESPONSE",
167
+ "GNO returned an unsupported pairing response."
168
+ );
169
+ }
170
+
171
+ const approvalResponse = await fetcher("/api/clipper/pair/approve", {
172
+ method: "POST",
173
+ cache: "no-store",
174
+ credentials: "same-origin",
175
+ headers: {
176
+ "Content-Type": "application/json",
177
+ "X-GNO-CSRF": csrfBody.csrfToken,
178
+ },
179
+ body: JSON.stringify({ pairId, pairingCode }),
180
+ });
181
+ const approvalBody = await readJson(approvalResponse);
182
+ requireSuccess(approvalResponse, approvalBody);
183
+ if (
184
+ !isRecord(approvalBody) ||
185
+ !hasExactKeys(approvalBody, [
186
+ "schemaVersion",
187
+ "status",
188
+ "origin",
189
+ "expiresAt",
190
+ ]) ||
191
+ approvalBody.schemaVersion !== "1.0" ||
192
+ approvalBody.status !== "approved" ||
193
+ typeof approvalBody.origin !== "string" ||
194
+ !EXTENSION_ORIGIN.test(approvalBody.origin) ||
195
+ !isDateTime(approvalBody.expiresAt)
196
+ ) {
197
+ throw new ClipperApprovalError(
198
+ "CLIPPER_INVALID_RESPONSE",
199
+ "GNO returned an unsupported pairing response."
200
+ );
201
+ }
202
+ return {
203
+ origin: approvalBody.origin,
204
+ expiresAt: approvalBody.expiresAt,
205
+ };
206
+ }
@@ -0,0 +1,210 @@
1
+ import {
2
+ AlertCircleIcon,
3
+ CheckCircle2Icon,
4
+ LinkIcon,
5
+ Loader2Icon,
6
+ } from "lucide-react";
7
+ import { useState } from "react";
8
+
9
+ import { GnoLogo } from "../components/GnoLogo";
10
+ import { Button } from "../components/ui/button";
11
+ import {
12
+ Card,
13
+ CardContent,
14
+ CardDescription,
15
+ CardFooter,
16
+ CardHeader,
17
+ CardTitle,
18
+ } from "../components/ui/card";
19
+ import { Input } from "../components/ui/input";
20
+ import {
21
+ approveClipperPair,
22
+ ClipperApprovalError,
23
+ type ClipperApproval,
24
+ } from "../lib/clipper-approval";
25
+
26
+ interface ClipperPairingProps {
27
+ pairId: string | null;
28
+ }
29
+
30
+ type PairingState = "ready" | "approving" | "approved" | "terminal";
31
+
32
+ const terminalCodes = new Set([
33
+ "CLIPPER_PAIR_EXPIRED",
34
+ "CLIPPER_PAIR_ALREADY_USED",
35
+ "CLIPPER_PAIR_NOT_FOUND",
36
+ ]);
37
+
38
+ export default function ClipperPairing({ pairId }: ClipperPairingProps) {
39
+ const [activePairId, setActivePairId] = useState(pairId);
40
+ const [pairingCode, setPairingCode] = useState("");
41
+ const [state, setState] = useState<PairingState>(
42
+ pairId === null ? "terminal" : "ready"
43
+ );
44
+ const [approval, setApproval] = useState<ClipperApproval | null>(null);
45
+ const [error, setError] = useState<string | null>(
46
+ pairId === null
47
+ ? "This pairing link is invalid. Start pairing again from the extension."
48
+ : null
49
+ );
50
+
51
+ const clearSensitiveState = () => {
52
+ setPairingCode("");
53
+ setActivePairId(null);
54
+ };
55
+
56
+ const approve = async () => {
57
+ if (activePairId === null || !/^\d{8}$/u.test(pairingCode)) return;
58
+ setState("approving");
59
+ setError(null);
60
+ try {
61
+ const result = await approveClipperPair(activePairId, pairingCode);
62
+ clearSensitiveState();
63
+ setApproval(result);
64
+ setState("approved");
65
+ } catch (cause) {
66
+ const parsed =
67
+ cause instanceof ClipperApprovalError
68
+ ? cause
69
+ : new ClipperApprovalError(
70
+ "CLIPPER_NETWORK",
71
+ "Could not reach the local GNO gateway. Keep the extension open and retry.",
72
+ true
73
+ );
74
+ setError(parsed.message);
75
+ if (terminalCodes.has(parsed.code) || !parsed.retryable) {
76
+ clearSensitiveState();
77
+ setState("terminal");
78
+ } else {
79
+ setPairingCode("");
80
+ setState("ready");
81
+ }
82
+ }
83
+ };
84
+
85
+ const cancel = () => {
86
+ clearSensitiveState();
87
+ setError("Pairing cancelled. Start again from the extension when ready.");
88
+ setState("terminal");
89
+ };
90
+
91
+ return (
92
+ <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-6">
93
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,oklch(0.68_0.11_55/0.12),transparent_42%),radial-gradient(circle_at_bottom_right,oklch(0.52_0.08_245/0.10),transparent_48%)]" />
94
+ <Card className="relative w-full max-w-lg border-border/70 shadow-2xl">
95
+ <CardHeader>
96
+ <div className="mb-3 flex items-center gap-3 text-primary">
97
+ <GnoLogo className="size-9" />
98
+ <span className="font-mono text-xs tracking-[0.2em] uppercase">
99
+ Local approval
100
+ </span>
101
+ </div>
102
+ <CardTitle className="font-semibold text-2xl">
103
+ Pair the GNO browser clipper
104
+ </CardTitle>
105
+ <CardDescription className="leading-6">
106
+ This extension may send only content you explicitly capture into
107
+ this local GNO. Access expires within 30 days and can be revoked
108
+ from the extension.
109
+ </CardDescription>
110
+ </CardHeader>
111
+ <CardContent className="space-y-5">
112
+ {state === "ready" || state === "approving" ? (
113
+ <>
114
+ <div className="rounded-lg border border-border/60 bg-muted/25 p-4 text-sm">
115
+ <div className="mb-2 flex items-center gap-2 font-medium">
116
+ <LinkIcon className="size-4 text-primary" />
117
+ Confirm this browser
118
+ </div>
119
+ <p className="text-muted-foreground">
120
+ Enter the eight-digit code shown in the extension. GNO never
121
+ gives this page the extension grant.
122
+ </p>
123
+ </div>
124
+ <label className="block space-y-2">
125
+ <span className="font-medium text-sm">Pairing code</span>
126
+ <Input
127
+ aria-label="Pairing code"
128
+ autoComplete="off"
129
+ autoFocus
130
+ className="h-12 font-mono text-lg tracking-[0.3em]"
131
+ disabled={state === "approving"}
132
+ inputMode="numeric"
133
+ maxLength={8}
134
+ onChange={(event) =>
135
+ setPairingCode(
136
+ event.currentTarget.value
137
+ .replaceAll(/\D/gu, "")
138
+ .slice(0, 8)
139
+ )
140
+ }
141
+ pattern="[0-9]{8}"
142
+ placeholder="00000000"
143
+ value={pairingCode}
144
+ />
145
+ </label>
146
+ </>
147
+ ) : null}
148
+
149
+ {approval ? (
150
+ <div
151
+ className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4"
152
+ role="status"
153
+ >
154
+ <div className="mb-2 flex items-center gap-2 font-medium text-emerald-700 dark:text-emerald-300">
155
+ <CheckCircle2Icon className="size-5" />
156
+ Browser paired
157
+ </div>
158
+ <p className="break-all text-muted-foreground text-sm">
159
+ {approval.origin}
160
+ </p>
161
+ <p className="mt-1 text-muted-foreground text-xs">
162
+ Expires {new Date(approval.expiresAt).toLocaleString()}
163
+ </p>
164
+ </div>
165
+ ) : null}
166
+
167
+ {error ? (
168
+ <div
169
+ className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm"
170
+ role="alert"
171
+ >
172
+ <AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
173
+ <span>{error}</span>
174
+ </div>
175
+ ) : null}
176
+ </CardContent>
177
+ <CardFooter className="justify-between gap-3">
178
+ {state === "ready" || state === "approving" ? (
179
+ <>
180
+ <Button
181
+ disabled={state === "approving"}
182
+ onClick={cancel}
183
+ type="button"
184
+ variant="ghost"
185
+ >
186
+ Cancel
187
+ </Button>
188
+ <Button
189
+ disabled={
190
+ state === "approving" || !/^\d{8}$/u.test(pairingCode)
191
+ }
192
+ onClick={() => void approve()}
193
+ type="button"
194
+ >
195
+ {state === "approving" ? (
196
+ <Loader2Icon className="animate-spin" />
197
+ ) : null}
198
+ Approve extension
199
+ </Button>
200
+ </>
201
+ ) : (
202
+ <Button asChild className="ml-auto" variant="outline">
203
+ <a href="/">Back to GNO</a>
204
+ </Button>
205
+ )}
206
+ </CardFooter>
207
+ </Card>
208
+ </main>
209
+ );
210
+ }
@@ -240,6 +240,7 @@ export async function startResidentRuntime(
240
240
  const ctxHolder: ContextHolder = {
241
241
  current: ctx,
242
242
  config: initialConfig,
243
+ actualConfigPath,
243
244
  scheduler: null,
244
245
  eventBus: options.eventBus ?? null,
245
246
  watchService: null,
@@ -6,9 +6,9 @@
6
6
  */
7
7
 
8
8
  // node:fs/promises structure ops have no Bun equivalent
9
- import { mkdir, readdir } from "node:fs/promises";
9
+ import { readdir } from "node:fs/promises";
10
10
  // node:path has no Bun equivalent
11
- import { dirname, join as pathJoin, posix as pathPosix } from "node:path";
11
+ import { posix as pathPosix } from "node:path";
12
12
 
13
13
  import type {
14
14
  Collection,
@@ -48,14 +48,7 @@ import {
48
48
  fingerprintContentTypeRules,
49
49
  normalizeContentTypes,
50
50
  } from "../../config";
51
- import {
52
- buildCaptureReceipt,
53
- listCaptureDiskRelPaths,
54
- planCapture,
55
- type CapturePlan,
56
- type PublicCaptureInput,
57
- } from "../../core/capture";
58
- import { writeCapturePlanFile } from "../../core/capture-write";
51
+ import { type PublicCaptureInput } from "../../core/capture";
59
52
  import {
60
53
  type ConnectorVerificationCode,
61
54
  getConnectorVerificationRemediation,
@@ -146,6 +139,10 @@ import {
146
139
  } from "../../publish/artifact";
147
140
  import { exportPublishArtifact } from "../../publish/export-service";
148
141
  import { buildBrowseTree, normalizeBrowsePath } from "../browse-tree";
142
+ import {
143
+ executeResidentCapturePlan,
144
+ planResidentCapture,
145
+ } from "../capture-service";
149
146
  import { applyConfigChange, applyConfigChangeTyped } from "../config-sync";
150
147
  import {
151
148
  getConnectorStatuses,
@@ -171,6 +168,7 @@ import { handleChanges, handleDiff, handleImpact } from "./changes";
171
168
  export interface ContextHolder {
172
169
  current: ServerContext;
173
170
  config: Config;
171
+ actualConfigPath?: string;
174
172
  scheduler: EmbedScheduler | null;
175
173
  eventBus: DocumentEventBus | null;
176
174
  watchService: CollectionWatchService | null;
@@ -3167,114 +3165,21 @@ export async function handleCreateCapture(
3167
3165
  );
3168
3166
  }
3169
3167
 
3170
- const collectionName = body.collection.toLowerCase();
3171
- const collection = ctxHolder.config.collections.find(
3172
- (candidate) => candidate.name.toLowerCase() === collectionName
3173
- );
3174
- if (!collection) {
3175
- return errorResponse(
3176
- "NOT_FOUND",
3177
- `Collection not found: ${body.collection}`,
3178
- 404
3179
- );
3180
- }
3181
-
3182
- let plan: CapturePlan;
3183
- try {
3184
- plan = planCapture({
3185
- input: {
3186
- ...body,
3187
- collection: collection.name,
3188
- },
3189
- existingRelPaths: await listCollectionRelPaths(store, collection.name),
3190
- diskRelPaths: await listCaptureDiskRelPaths(collection.path),
3191
- });
3192
- } catch (error) {
3193
- return errorResponse(
3194
- "VALIDATION",
3195
- error instanceof Error ? error.message : String(error),
3196
- 409
3197
- );
3198
- }
3199
-
3200
- const fullPath = pathJoin(collection.path, plan.relPath);
3201
- if (plan.openedExisting) {
3202
- const existingDoc = await store.getDocument(collection.name, plan.relPath);
3203
- if (!existingDoc.ok) {
3204
- return errorResponse("RUNTIME", existingDoc.error.message, 500);
3205
- }
3206
- return jsonResponse(
3207
- buildCaptureReceipt({
3208
- plan,
3209
- absPath: fullPath,
3210
- docid: existingDoc.value?.docid,
3211
- sync: existingDoc.value
3212
- ? { status: "completed" }
3213
- : {
3214
- status: "skipped",
3215
- reason: "Existing file is not indexed yet.",
3216
- },
3217
- })
3218
- );
3168
+ const planned = await planResidentCapture(ctxHolder, store, {
3169
+ ...body,
3170
+ collection: body.collection,
3171
+ });
3172
+ if (!planned.ok) {
3173
+ return errorResponse(planned.code, planned.message, planned.status);
3219
3174
  }
3220
-
3221
3175
  try {
3222
- await mkdir(dirname(fullPath), { recursive: true });
3223
- ctxHolder.watchService?.suppress(fullPath);
3224
- await writeCapturePlanFile(plan, fullPath);
3225
-
3226
- const gnoUri = `gno://${collection.name}/${plan.relPath}`;
3227
- const jobResult = await startJob(
3228
- "sync",
3229
- async (): Promise<SyncResult> => {
3230
- const result = await syncResidentCollection(
3231
- ctxHolder,
3232
- collection,
3233
- store,
3234
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
3235
- deps?.syncCollection
3236
- );
3237
- ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
3238
- ctxHolder.eventBus?.emit({
3239
- type: "document-changed",
3240
- uri: gnoUri,
3241
- collection: collection.name,
3242
- relPath: plan.relPath,
3243
- origin: "create",
3244
- changedAt: new Date().toISOString(),
3245
- });
3246
- return {
3247
- collections: [result],
3248
- totalDurationMs: result.durationMs,
3249
- totalFilesProcessed: result.filesProcessed,
3250
- totalFilesAdded: result.filesAdded,
3251
- totalFilesUpdated: result.filesUpdated,
3252
- totalFilesErrored: result.filesErrored,
3253
- totalFilesSkipped: result.filesSkipped,
3254
- };
3255
- },
3256
- ctxHolder.jobManager
3257
- );
3258
-
3259
- return jsonResponse(
3260
- buildCaptureReceipt({
3261
- plan,
3262
- absPath: fullPath,
3263
- sync: jobResult.ok
3264
- ? {
3265
- status: "pending",
3266
- jobId: jobResult.jobId,
3267
- reason: "Sync job started; poll /api/jobs/:id for status.",
3268
- }
3269
- : {
3270
- status: "skipped",
3271
- jobId: jobResult.activeJobId,
3272
- reason: "Sync skipped because another job is running.",
3273
- error: jobResult.error,
3274
- },
3275
- }),
3276
- 202
3176
+ const result = await executeResidentCapturePlan(
3177
+ ctxHolder,
3178
+ store,
3179
+ planned,
3180
+ deps
3277
3181
  );
3182
+ return jsonResponse(result.body, result.status);
3278
3183
  } catch (error) {
3279
3184
  return errorResponse(
3280
3185
  "RUNTIME",