@gmickel/gno 2.6.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +25 -30
  2. package/assets/skill/SKILL.md +5 -3
  3. package/assets/skill/cli-reference.md +9 -2
  4. package/assets/skill/mcp-reference.md +2 -1
  5. package/assets/spa-production.json.gz +0 -0
  6. package/browser-extension/artifacts/{gno-browser-clipper-v2.6.0.zip → gno-browser-clipper-v2.7.1.zip} +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +1 -0
  8. package/browser-extension/dist/manifest.json +1 -1
  9. package/package.json +2 -1
  10. package/spec/cli.md +109 -18
  11. package/spec/mcp.md +36 -2
  12. package/spec/output-schemas/ask.schema.json +1 -1
  13. package/spec/output-schemas/capture-receipt.schema.json +1 -1
  14. package/spec/output-schemas/collection-list.schema.json +2 -2
  15. package/spec/output-schemas/doctor.schema.json +88 -0
  16. package/spec/output-schemas/error.schema.json +11 -2
  17. package/spec/output-schemas/get.schema.json +1 -1
  18. package/spec/output-schemas/mcp-capture-result.schema.json +1 -2
  19. package/spec/output-schemas/memory-remember.schema.json +2 -2
  20. package/spec/output-schemas/multi-get.schema.json +4 -1
  21. package/spec/output-schemas/peek.schema.json +2 -9
  22. package/spec/output-schemas/resident-status.schema.json +22 -0
  23. package/spec/output-schemas/search-result.schema.json +1 -1
  24. package/spec/output-schemas/search-results.schema.json +1 -1
  25. package/spec/output-schemas/status.schema.json +110 -16
  26. package/src/cli/commands/ask.ts +31 -12
  27. package/src/cli/commands/doctor.ts +54 -20
  28. package/src/cli/commands/embed.ts +41 -3
  29. package/src/cli/commands/ls.ts +6 -1
  30. package/src/cli/commands/query.ts +5 -0
  31. package/src/cli/commands/status.ts +63 -5
  32. package/src/cli/commands/vec.ts +54 -0
  33. package/src/cli/detach.ts +29 -1
  34. package/src/cli/errors.ts +13 -9
  35. package/src/cli/program.ts +53 -1
  36. package/src/core/capture-sync.ts +9 -2
  37. package/src/core/host-paths.ts +49 -0
  38. package/src/core/memory-remember.ts +4 -3
  39. package/src/core/request-receipts.ts +63 -9
  40. package/src/core/shutdown-budget.ts +6 -0
  41. package/src/core/vector-partition-status.ts +52 -0
  42. package/src/core/windows-private-path.ts +136 -1
  43. package/src/embed/backlog.ts +145 -27
  44. package/src/embed/fingerprint.ts +6 -3
  45. package/src/embed/retry.ts +66 -27
  46. package/src/embed/variant-backlog.ts +48 -18
  47. package/src/embed/variant-retry.ts +31 -22
  48. package/src/index.ts +21 -2
  49. package/src/llm/inference-scope.ts +18 -0
  50. package/src/llm/native-worker/dispatcher.ts +2 -0
  51. package/src/llm/native-worker/embedding-identity.ts +42 -0
  52. package/src/llm/native-worker/protocol.ts +1 -0
  53. package/src/llm/types.ts +3 -0
  54. package/src/mcp/context.ts +9 -0
  55. package/src/mcp/resources/index.ts +6 -5
  56. package/src/mcp/tool-descriptions-core.ts +1 -1
  57. package/src/mcp/tools/capture.ts +1 -3
  58. package/src/mcp/tools/index.ts +11 -4
  59. package/src/mcp/tools/memory-remember.ts +1 -1
  60. package/src/mcp/tools/status.ts +23 -6
  61. package/src/pipeline/hybrid.ts +37 -7
  62. package/src/pipeline/vsearch.ts +14 -2
  63. package/src/serve/embed-scheduler.ts +133 -19
  64. package/src/serve/host-path-redaction.ts +116 -0
  65. package/src/serve/public/components/BootstrapStatus.tsx +5 -3
  66. package/src/serve/public/components/CaptureModal.tsx +1 -1
  67. package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
  68. package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
  69. package/src/serve/public/components/FirstRunWizard.tsx +4 -2
  70. package/src/serve/public/components/sessions/SessionSearch.tsx +2 -2
  71. package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
  72. package/src/serve/public/globals.built.css +1 -1
  73. package/src/serve/public/hooks/use-api.ts +17 -2
  74. package/src/serve/public/lib/request-intent.ts +8 -0
  75. package/src/serve/public/{components/sessions → lib}/snippet.tsx +2 -3
  76. package/src/serve/public/pages/Collections.tsx +16 -13
  77. package/src/serve/public/pages/Connectors.tsx +7 -4
  78. package/src/serve/public/pages/Dashboard.tsx +16 -11
  79. package/src/serve/public/pages/DocView.tsx +10 -5
  80. package/src/serve/public/pages/DocumentEditor.tsx +91 -14
  81. package/src/serve/public/pages/Search.tsx +1 -41
  82. package/src/serve/resident-runtime.ts +33 -1
  83. package/src/serve/resident-status.ts +13 -1
  84. package/src/serve/routes/sessions.ts +61 -5
  85. package/src/serve/server.ts +15 -12
  86. package/src/serve/status-model.ts +26 -6
  87. package/src/serve/status.ts +4 -7
  88. package/src/serve/watch-reconciliation-shared.ts +3 -0
  89. package/src/serve/watch-service-events.ts +3 -2
  90. package/src/serve/watch-service-run-flush.ts +35 -2
  91. package/src/serve/watch-service.ts +5 -0
  92. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  93. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  94. package/src/store/migrations/index.ts +4 -0
  95. package/src/store/sqlite/adapter.ts +24 -3
  96. package/src/store/sqlite/change-journal-store.ts +1 -1
  97. package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
  98. package/src/store/types.ts +11 -1
  99. package/src/store/vector/lazy.ts +46 -43
  100. package/src/store/vector/runtime-compat.ts +651 -0
  101. package/src/store/vector/sqlite-vec.ts +20 -2
  102. package/src/store/vector/status.ts +276 -35
  103. package/src/store/vector/types.ts +2 -0
  104. package/src/store/vector/variant-search.ts +71 -23
  105. package/src/store/vector/variants.ts +49 -14
  106. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +0 -1
@@ -9,6 +9,7 @@ import type { Database } from "bun:sqlite";
9
9
 
10
10
  import type { EmbeddingPort } from "../llm/types";
11
11
  import type { VectorIndexPort } from "../store/vector";
12
+ import type { BackgroundIssue } from "./status-model";
12
13
 
13
14
  import { embedBacklog } from "../embed";
14
15
  import {
@@ -24,6 +25,15 @@ import { createVectorStatsPort } from "../store/vector";
24
25
  const DEBOUNCE_MS = 30_000; // 30 seconds
25
26
  const MAX_WAIT_MS = 300_000; // 5 minutes
26
27
  const BATCH_SIZE = 32;
28
+ /** Consecutive failed passes after which automatic retries stop (parked). */
29
+ export const MAX_FAILED_PASSES = 5;
30
+ /** A pass running longer than this is reported as overrunning. */
31
+ export const PASS_OVERRUN_MS = 15 * 60_000;
32
+
33
+ /** Delay before the automatic retry that follows `failures` failed passes. */
34
+ export function failedPassRetryDelayMs(failures: number): number {
35
+ return DEBOUNCE_MS * 2 ** Math.max(0, failures - 1);
36
+ }
27
37
 
28
38
  // ─────────────────────────────────────────────────────────────────────────────
29
39
  // Types
@@ -35,6 +45,12 @@ export interface EmbedSchedulerState {
35
45
  nextRunAt?: number;
36
46
  lastRunAt?: number;
37
47
  lastResult?: EmbedResult;
48
+ /** Start of the pass in flight. */
49
+ runningSince?: number;
50
+ /** Failed passes since the last clean pass (0 when healthy). */
51
+ consecutiveFailures: number;
52
+ /** Automatic retries stopped; chunks stay pending for new work or `gno embed`. */
53
+ parked: boolean;
38
54
  }
39
55
 
40
56
  export interface EmbedResult {
@@ -52,6 +68,47 @@ export interface EmbedSchedulerDeps {
52
68
  getModelUri: () => string;
53
69
  onEmbedded?: (result: EmbedResult) => void;
54
70
  embedBacklogFn?: typeof embedBacklog;
71
+ /**
72
+ * Shared writer lease for one page of background writes; null when another
73
+ * writer holds it, which defers the rest of the pass instead of waiting.
74
+ */
75
+ acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
76
+ }
77
+
78
+ /** Project scheduler state onto status issues; empty while healthy. */
79
+ export function embedSchedulerIssues(
80
+ state: EmbedSchedulerState,
81
+ now = Date.now()
82
+ ): BackgroundIssue[] {
83
+ const issues: BackgroundIssue[] = [];
84
+ if (state.parked || state.consecutiveFailures > 0) {
85
+ issues.push({
86
+ job: "embed",
87
+ state: state.parked ? "parked" : "failing",
88
+ consecutiveFailures: state.consecutiveFailures,
89
+ runningSeconds: null,
90
+ });
91
+ }
92
+ if (
93
+ state.runningSince !== undefined &&
94
+ now - state.runningSince > PASS_OVERRUN_MS
95
+ ) {
96
+ issues.push({
97
+ job: "embed",
98
+ state: "overrunning",
99
+ consecutiveFailures: state.consecutiveFailures,
100
+ runningSeconds: Math.floor((now - state.runningSince) / 1000),
101
+ });
102
+ }
103
+ return issues;
104
+ }
105
+
106
+ interface PassOutcome {
107
+ result: EmbedResult;
108
+ /** Present when the pass failed; the message is logged once per retry step. */
109
+ failure?: string;
110
+ /** Another writer held the lease; the rest of the backlog waits for a rerun. */
111
+ deferred?: boolean;
55
112
  }
56
113
 
57
114
  // ─────────────────────────────────────────────────────────────────────────────
@@ -86,6 +143,7 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
86
143
  getModelUri,
87
144
  onEmbedded,
88
145
  embedBacklogFn = embedBacklog,
146
+ acquireWriteLease,
89
147
  } = deps;
90
148
 
91
149
  // State
@@ -99,6 +157,9 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
99
157
  let currentRun: Promise<EmbedResult | null> | null = null;
100
158
  let lastRunAt: number | null = null;
101
159
  let lastResult: EmbedResult | null = null;
160
+ let runningSince: number | null = null;
161
+ let consecutiveFailures = 0;
162
+ let parked = false;
102
163
  const controller = new AbortController();
103
164
 
104
165
  const stats = createVectorStatsPort(db);
@@ -110,14 +171,14 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
110
171
  * 2. Filtering by docId would require joining through documents table
111
172
  * 3. Simpler to just embed all backlog when triggered
112
173
  */
113
- async function runEmbed(): Promise<EmbedResult> {
174
+ async function runEmbed(): Promise<PassOutcome> {
114
175
  // Resolve dependencies at execution time (survives context reloads)
115
176
  const embedPort = getEmbedPort();
116
177
  const vectorIndex = getVectorIndex();
117
178
  const modelUri = getModelUri();
118
179
 
119
180
  if (!embedPort || !vectorIndex) {
120
- return { embedded: 0, errors: 0 };
181
+ return { result: { embedded: 0, errors: 0 } };
121
182
  }
122
183
 
123
184
  let result: Awaited<ReturnType<typeof embedBacklogFn>>;
@@ -130,6 +191,7 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
130
191
  vectorIndex,
131
192
  modelUri,
132
193
  batchSize: BATCH_SIZE,
194
+ acquireWriteTurn: acquireWriteLease,
133
195
  identityStillCurrent: () =>
134
196
  getEmbedPort() === embedPort &&
135
197
  getVectorIndex() === vectorIndex &&
@@ -138,14 +200,21 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
138
200
  )
139
201
  );
140
202
  } catch (cause) {
141
- if (controller.signal.aborted) return { embedded: 0, errors: 0 };
142
- throw cause;
203
+ if (controller.signal.aborted)
204
+ return { result: { embedded: 0, errors: 0 } };
205
+ // A worker exit aborts the whole pass (a deadline fails only its page).
206
+ // It is a failed pass like any other, never a silently dropped rejection.
207
+ return {
208
+ result: { embedded: 0, errors: 0 },
209
+ failure: cause instanceof Error ? cause.message : String(cause),
210
+ };
143
211
  }
144
212
 
145
213
  if (!result.ok) {
146
- needsRerun = true;
147
- console.error("[embed-scheduler] Embed failed:", result.error.message);
148
- return { embedded: 0, errors: 0 };
214
+ return {
215
+ result: { embedded: 0, errors: 0 },
216
+ failure: result.error.message,
217
+ };
149
218
  }
150
219
  if (
151
220
  getEmbedPort() !== embedPort ||
@@ -153,21 +222,42 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
153
222
  getModelUri() !== modelUri
154
223
  )
155
224
  needsRerun = true;
156
- if ((result.value.contentionErrors ?? 0) > 0 || result.value.errors > 0) {
157
- // Provider failures and contended checkpoints remain durably pending.
225
+ if (result.value.deferred) needsRerun = true;
226
+ if (result.value.embedded > 0) onEmbedded?.(result.value);
227
+ const contended = result.value.contentionErrors ?? 0;
228
+ const deferred = result.value.deferred === true;
229
+ // Provider failures and contended checkpoints remain durably pending.
230
+ return result.value.errors > 0 || contended > 0
231
+ ? {
232
+ result: result.value,
233
+ deferred,
234
+ failure: `${result.value.errors} embedding errors, ${contended} contended writes`,
235
+ }
236
+ : { result: result.value, deferred };
237
+ }
238
+
239
+ /** Count a failed pass and schedule its bounded retry, logging each step once. */
240
+ function recordFailure(message: string): void {
241
+ consecutiveFailures += 1;
242
+ if (parked) return;
243
+ if (consecutiveFailures >= MAX_FAILED_PASSES) {
244
+ parked = true;
158
245
  console.error(
159
- `[embed-scheduler] ${result.value.errors} embedding errors, ${result.value.contentionErrors ?? 0} contended writes; rescheduling`
246
+ `[embed-scheduler] Embed pass failed ${consecutiveFailures} times (${message}); automatic retries parked. Pending chunks stay queued for new changes or \`gno embed\`.`
160
247
  );
161
- needsRerun = true;
248
+ return;
162
249
  }
163
- if (result.value.embedded > 0) onEmbedded?.(result.value);
164
- return result.value;
250
+ const delay = failedPassRetryDelayMs(consecutiveFailures);
251
+ console.error(
252
+ `[embed-scheduler] Embed pass failed (${consecutiveFailures}/${MAX_FAILED_PASSES}): ${message}; retrying in ${Math.round(delay / 1000)}s`
253
+ );
254
+ scheduleRun(delay);
165
255
  }
166
256
 
167
257
  /**
168
258
  * Schedule or reschedule the debounced embed run.
169
259
  */
170
- function scheduleRun(): void {
260
+ function scheduleRun(retryDelay?: number): void {
171
261
  if (disposed) {
172
262
  return;
173
263
  }
@@ -180,7 +270,7 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
180
270
 
181
271
  // Calculate delay
182
272
  const now = Date.now();
183
- let delay = DEBOUNCE_MS;
273
+ let delay = retryDelay ?? DEBOUNCE_MS;
184
274
 
185
275
  // Check max-wait
186
276
  if (firstPendingAt !== null) {
@@ -226,17 +316,38 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
226
316
  pendingCount = 0;
227
317
  firstPendingAt = null;
228
318
 
229
- let result: EmbedResult;
319
+ runningSince = Date.now();
320
+ let outcome: PassOutcome;
230
321
  try {
231
- result = await runEmbed();
322
+ outcome = await runEmbed();
232
323
  lastRunAt = Date.now();
233
- lastResult = result;
324
+ lastResult = outcome.result;
234
325
  } finally {
235
326
  running = false;
327
+ runningSince = null;
236
328
  }
329
+ const result = outcome.result;
237
330
 
238
- // Check if we need to rerun (notifications arrived while running)
239
331
  // Must be AFTER running=false so scheduleRun() actually schedules
332
+ if (outcome.failure !== undefined && !disposed) {
333
+ // A failed pass never reruns immediately: the bounded retry covers work
334
+ // that arrived meanwhile, and once parked only fresh work earns a pass.
335
+ needsRerun = false;
336
+ recordFailure(outcome.failure);
337
+ // Parked stops retries of failed work, never a lease-deferred remainder.
338
+ if (parked && (pendingCount > 0 || outcome.deferred)) {
339
+ firstPendingAt ??= Date.now();
340
+ scheduleRun();
341
+ }
342
+ return result;
343
+ }
344
+ // Only a pass that reached the end of the backlog cleanly clears failures.
345
+ if (outcome.failure === undefined && !outcome.deferred) {
346
+ consecutiveFailures = 0;
347
+ parked = false;
348
+ }
349
+
350
+ // Check if we need to rerun (notifications arrived while running)
240
351
  if ((needsRerun || pendingCount > 0) && !disposed) {
241
352
  needsRerun = false;
242
353
  // Set firstPendingAt if we have pending work
@@ -313,7 +424,10 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
313
424
  const state: EmbedSchedulerState = {
314
425
  pendingDocCount: pendingCount,
315
426
  running,
427
+ consecutiveFailures,
428
+ parked,
316
429
  };
430
+ if (runningSince !== null) state.runningSince = runningSince;
317
431
 
318
432
  // Use accurate nextRunAt from timer scheduling
319
433
  if (nextRunAt !== null) {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * REST boundary for host paths: a caller the request-locality rule judges
3
+ * remote never receives an `absPath` field from any `/api/*` JSON response,
4
+ * nor the host `path` fields of the status, collection, connector, and
5
+ * document-mutation routes (plus `configPath`/`dbPath` on status and the
6
+ * `file://` `uri` of a document save). Same-host callers (the loopback Web
7
+ * UI) keep them for Reveal, "Open original", and the Collections page.
8
+ * Routes that stream original source bytes are exempt: their body is the
9
+ * owner's file, not an API envelope.
10
+ */
11
+
12
+ import {
13
+ HOST_PATH_FIELDS,
14
+ OWNER_CONFIG_PATH_FIELDS,
15
+ withoutFields,
16
+ } from "../core/host-paths";
17
+ import {
18
+ isLocalClientRequest,
19
+ type RequestPeerServer,
20
+ } from "./request-locality";
21
+
22
+ const SOURCE_BYTE_ROUTES = new Set(["/api/doc-asset"]);
23
+ const OWNER_CONFIG_FIELDS: ReadonlySet<string> = new Set([
24
+ ...HOST_PATH_FIELDS,
25
+ ...OWNER_CONFIG_PATH_FIELDS,
26
+ ]);
27
+ /** Document mutations answer with the file's host `path`. */
28
+ const DOC_PATH_FIELDS: ReadonlySet<string> = new Set([
29
+ ...HOST_PATH_FIELDS,
30
+ "path",
31
+ ]);
32
+ /** A document save also names the file by a `file://` `uri`. */
33
+ const DOC_SAVE_FIELDS: ReadonlySet<string> = new Set([
34
+ ...DOC_PATH_FIELDS,
35
+ "uri",
36
+ ]);
37
+ const ROUTE_FIELDS = new Map<string, ReadonlySet<string>>([
38
+ ["/api/status", OWNER_CONFIG_FIELDS],
39
+ ["/api/collections", OWNER_CONFIG_FIELDS],
40
+ ["/api/collections/:name", OWNER_CONFIG_FIELDS],
41
+ ["/api/connectors", OWNER_CONFIG_FIELDS],
42
+ ["/api/connectors/install", OWNER_CONFIG_FIELDS],
43
+ ["/api/docs", DOC_PATH_FIELDS],
44
+ ["/api/docs/:id", DOC_SAVE_FIELDS],
45
+ ["/api/docs/:id/rename", DOC_PATH_FIELDS],
46
+ ["/api/docs/:id/move", DOC_PATH_FIELDS],
47
+ ["/api/docs/:id/duplicate", DOC_PATH_FIELDS],
48
+ ["/api/docs/:id/trash", DOC_PATH_FIELDS],
49
+ ["/api/docs/:id/editable-copy", DOC_PATH_FIELDS],
50
+ ["/api/folders", DOC_PATH_FIELDS],
51
+ ]);
52
+
53
+ type RouteHandler = (
54
+ req: Request,
55
+ server: RequestPeerServer
56
+ ) => Response | Promise<Response>;
57
+
58
+ /** The JSON response with every key in `fields` removed. */
59
+ export async function redactResponseHostPaths(
60
+ response: Response,
61
+ fields: ReadonlySet<string>
62
+ ): Promise<Response> {
63
+ const contentType = response.headers.get("content-type") ?? "";
64
+ if (!contentType.includes("application/json") || response.body === null) {
65
+ return response;
66
+ }
67
+ const text = await response.text();
68
+ const body = [...fields].some((field) => text.includes(`"${field}"`))
69
+ ? JSON.stringify(withoutFields(JSON.parse(text) as unknown, fields))
70
+ : text;
71
+ const headers = new Headers(response.headers);
72
+ headers.delete("content-length");
73
+ return new Response(body, {
74
+ status: response.status,
75
+ statusText: response.statusText,
76
+ headers,
77
+ });
78
+ }
79
+
80
+ const redacting =
81
+ (handler: RouteHandler, fields: ReadonlySet<string>): RouteHandler =>
82
+ async (req, server) => {
83
+ const response = await handler(req, server);
84
+ return isLocalClientRequest(req, server)
85
+ ? response
86
+ : redactResponseHostPaths(response, fields);
87
+ };
88
+
89
+ const isMethodTable = (route: unknown): route is Record<string, unknown> =>
90
+ route !== null &&
91
+ typeof route === "object" &&
92
+ Object.getPrototypeOf(route) === Object.prototype;
93
+
94
+ /** Wrap every `/api/*` handler in the remote host-path redaction. */
95
+ export function withRemoteHostPathRedaction<T extends Record<string, unknown>>(
96
+ routes: T
97
+ ): T {
98
+ const wrapped: Record<string, unknown> = { ...routes };
99
+ for (const [path, route] of Object.entries(routes)) {
100
+ if (!path.startsWith("/api/") || SOURCE_BYTE_ROUTES.has(path)) continue;
101
+ const fields = ROUTE_FIELDS.get(path) ?? HOST_PATH_FIELDS;
102
+ if (typeof route === "function") {
103
+ wrapped[path] = redacting(route as RouteHandler, fields);
104
+ } else if (isMethodTable(route)) {
105
+ wrapped[path] = Object.fromEntries(
106
+ Object.entries(route).map(([method, handler]) => [
107
+ method,
108
+ typeof handler === "function"
109
+ ? redacting(handler as RouteHandler, fields)
110
+ : handler,
111
+ ])
112
+ );
113
+ }
114
+ }
115
+ return wrapped as T;
116
+ }
@@ -105,9 +105,11 @@ export function BootstrapStatus({
105
105
  </CardHeader>
106
106
  <CardContent className="space-y-2 text-sm">
107
107
  <p>Total cache: {bootstrap.cache.totalSizeLabel}</p>
108
- <p className="font-mono text-muted-foreground text-xs">
109
- {bootstrap.cache.path}
110
- </p>
108
+ {bootstrap.cache.path && (
109
+ <p className="font-mono text-muted-foreground text-xs">
110
+ {bootstrap.cache.path}
111
+ </p>
112
+ )}
111
113
  </CardContent>
112
114
  </Card>
113
115
 
@@ -75,7 +75,7 @@ export interface CaptureModalProps {
75
75
 
76
76
  interface Collection {
77
77
  name: string;
78
- path: string;
78
+ path?: string;
79
79
  }
80
80
 
81
81
  interface CreateDocResponse {
@@ -54,7 +54,8 @@ export interface CollectionModelDetails {
54
54
  modelSources?: Record<ModelRole, ModelSource>;
55
55
  models?: Partial<Record<ModelRole, string>>;
56
56
  name: string;
57
- path: string;
57
+ /** Host path; omitted for remote callers. */
58
+ path?: string;
58
59
  pattern?: string;
59
60
  egressPolicy?: {
60
61
  schemaVersion: "1.0";
@@ -110,7 +111,7 @@ const CODE_EXT_HINTS = [
110
111
  ] as const;
111
112
 
112
113
  function collectionLooksCodeHeavy(collection: CollectionModelDetails): boolean {
113
- const path = collection.path.toLowerCase();
114
+ const path = (collection.path ?? "").toLowerCase();
114
115
  if (
115
116
  CODE_PATH_HINTS.some(
116
117
  (hint) => path.endsWith(hint) || path.includes(`${hint}/`)
@@ -284,17 +285,19 @@ export function CollectionModelDialog({
284
285
  active preset for the rest of the workspace.
285
286
  </DialogDescription>
286
287
  </div>
287
- <div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
288
- <p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
289
- Path
290
- </p>
291
- <p
292
- className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
293
- title={collection?.path}
294
- >
295
- {collection?.path}
296
- </p>
297
- </div>
288
+ {collection?.path && (
289
+ <div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
290
+ <p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
291
+ Path
292
+ </p>
293
+ <p
294
+ className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
295
+ title={collection.path}
296
+ >
297
+ {collection.path}
298
+ </p>
299
+ </div>
300
+ )}
298
301
  </div>
299
302
  </DialogHeader>
300
303
 
@@ -13,6 +13,8 @@ export function CollectionsEmptyState({
13
13
  suggestedCollections,
14
14
  onAddCollection,
15
15
  }: CollectionsEmptyStateProps) {
16
+ // A remote caller's suggestions carry no host path to prefill.
17
+ const quickPicks = suggestedCollections.filter(({ path }) => path);
16
18
  return (
17
19
  <div className="mx-auto max-w-3xl py-16 text-center">
18
20
  <div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
@@ -29,7 +31,7 @@ export function CollectionsEmptyState({
29
31
  <FolderPlusIcon className="mr-2 size-4" />
30
32
  Add Folder
31
33
  </Button>
32
- {suggestedCollections.map((suggestion) => (
34
+ {quickPicks.map((suggestion) => (
33
35
  <Button
34
36
  key={suggestion.path}
35
37
  onClick={() => onAddCollection(suggestion.path)}
@@ -40,9 +42,9 @@ export function CollectionsEmptyState({
40
42
  ))}
41
43
  </div>
42
44
 
43
- {suggestedCollections.length > 0 && (
45
+ {quickPicks.length > 0 && (
44
46
  <div className="grid gap-3 text-left md:grid-cols-2">
45
- {suggestedCollections.map((suggestion) => (
47
+ {quickPicks.map((suggestion) => (
46
48
  <div
47
49
  className="rounded-xl border border-border/60 bg-background/70 p-4"
48
50
  key={`${suggestion.path}-detail`}
@@ -235,6 +235,8 @@ export function FirstRunWizard({
235
235
  ? steps[activeIndex + 1]
236
236
  : null;
237
237
  const progressValue = getStepProgress(onboarding);
238
+ // A remote caller's suggestions carry no host path to prefill.
239
+ const quickPicks = onboarding.suggestedCollections.filter(({ path }) => path);
238
240
  const isShowingRecommended = activeStep?.id === recommendedStepId;
239
241
  const runRecommendedAction = () => {
240
242
  if (!activeStep) {
@@ -277,7 +279,7 @@ export function FirstRunWizard({
277
279
  </Button>
278
280
  </div>
279
281
 
280
- {onboarding.suggestedCollections.length > 0 && (
282
+ {quickPicks.length > 0 && (
281
283
  <div className="space-y-3">
282
284
  <div className="flex items-center justify-between gap-3">
283
285
  <div>
@@ -295,7 +297,7 @@ export function FirstRunWizard({
295
297
  <Badge variant="outline">Quick picks</Badge>
296
298
  </div>
297
299
  <div className="grid gap-3 md:grid-cols-2">
298
- {onboarding.suggestedCollections.map((suggestion) => (
300
+ {quickPicks.map((suggestion) => (
299
301
  <button
300
302
  className="rounded-xl border border-border/70 bg-background/80 p-4 text-left shadow-sm transition-all hover:border-primary/35 hover:bg-background hover:shadow-md"
301
303
  key={suggestion.path}
@@ -8,10 +8,10 @@ import {
8
8
  } from "../../../../sessions/types";
9
9
  import { apiFetch } from "../../hooks/use-api";
10
10
  import { buildDocDeepLink } from "../../lib/deep-links";
11
+ import { renderSnippet } from "../../lib/snippet";
11
12
  import { Badge } from "../ui/badge";
12
13
  import { Button } from "../ui/button";
13
14
  import { Input } from "../ui/input";
14
- import { renderSessionSnippet } from "./snippet";
15
15
 
16
16
  interface SessionSearchResult {
17
17
  docid: string;
@@ -274,7 +274,7 @@ export function SessionSearch({ collections, navigate }: SessionSearchProps) {
274
274
  {result.title || result.uri}
275
275
  </a>
276
276
  <p className="line-clamp-3 break-words text-muted-foreground text-sm">
277
- {renderSessionSnippet(result.snippet)}
277
+ {renderSnippet(result.snippet)}
278
278
  </p>
279
279
  </li>
280
280
  );