@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
@@ -1,5 +1,7 @@
1
1
  import { useCallback, useState } from "react";
2
2
 
3
+ import { writeOutcomeUnknown } from "../lib/request-intent";
4
+
3
5
  interface ApiState<T> {
4
6
  data: T | null;
5
7
  loading: boolean;
@@ -86,6 +88,11 @@ export async function apiFetch<T>(
86
88
  error: string | null;
87
89
  /** `error.details` of the JSON error envelope, when the server sent one. */
88
90
  details?: Record<string, unknown>;
91
+ /**
92
+ * A write may or may not have committed: no readable response, a server
93
+ * error, or the request is still pending under its request ID.
94
+ */
95
+ outcomeUnknown?: true;
89
96
  }> {
90
97
  try {
91
98
  const res = await fetch(endpoint, {
@@ -96,17 +103,24 @@ export async function apiFetch<T>(
96
103
  const { json, parseError } = await parseJsonSafe(res);
97
104
 
98
105
  if (parseError) {
99
- return { data: null, error: parseError };
106
+ return { data: null, error: parseError, outcomeUnknown: true };
100
107
  }
101
108
 
102
109
  if (!res.ok) {
103
110
  const apiError = json as {
104
- error?: { message?: string; details?: Record<string, unknown> };
111
+ error?: {
112
+ code?: string;
113
+ message?: string;
114
+ details?: Record<string, unknown>;
115
+ };
105
116
  };
106
117
  return {
107
118
  data: null,
108
119
  error: apiError.error?.message || `Request failed: ${res.status}`,
109
120
  details: apiError.error?.details,
121
+ ...(writeOutcomeUnknown(res.status, apiError.error?.code)
122
+ ? { outcomeUnknown: true as const }
123
+ : {}),
110
124
  };
111
125
  }
112
126
 
@@ -115,6 +129,7 @@ export async function apiFetch<T>(
115
129
  return {
116
130
  data: null,
117
131
  error: err instanceof Error ? err.message : "Network error",
132
+ outcomeUnknown: true,
118
133
  };
119
134
  }
120
135
  }
@@ -67,3 +67,11 @@ export function clearRequestIntent(
67
67
  // Nothing persisted to clear.
68
68
  }
69
69
  }
70
+
71
+ /**
72
+ * Whether a failed write's HTTP answer leaves its commit unknown: a server
73
+ * error, or the request ID's earlier attempt is still in progress.
74
+ */
75
+ export function writeOutcomeUnknown(status: number, code?: string): boolean {
76
+ return status >= 500 || code === "REQUEST_PENDING";
77
+ }
@@ -14,10 +14,9 @@ export function unescapeMarkdown(text: string): string {
14
14
  /**
15
15
  * Render a search snippet: FTS `<mark>` highlights become real <mark>
16
16
  * elements, everything else stays React text (never parsed as HTML).
17
- * Mirrors the Search page renderer, plus Markdown-escape removal because
18
- * archived turns are indexed as escaped Markdown.
17
+ * Markdown backslash escapes are dropped because indexed text keeps them.
19
18
  */
20
- export function renderSessionSnippet(snippet: string): ReactNode[] {
19
+ export function renderSnippet(snippet: string): ReactNode[] {
21
20
  const parts: ReactNode[] = [];
22
21
  let remaining = snippet;
23
22
  let key = 0;
@@ -76,7 +76,8 @@ interface PageProps {
76
76
  interface CollectionStats {
77
77
  activePresetId?: string;
78
78
  name: string;
79
- path: string;
79
+ /** Host path; omitted for remote callers. */
80
+ path?: string;
80
81
  documentCount: number;
81
82
  chunkCount: number;
82
83
  embeddedCount: number;
@@ -262,18 +263,20 @@ function CollectionCard({
262
263
  </DropdownMenu>
263
264
  </div>
264
265
 
265
- <TooltipProvider>
266
- <Tooltip>
267
- <TooltipTrigger asChild>
268
- <p className="truncate font-mono text-muted-foreground text-xs">
269
- {truncatePath(collection.path)}
270
- </p>
271
- </TooltipTrigger>
272
- <TooltipContent className="max-w-xs break-all">
273
- <p className="font-mono text-xs">{collection.path}</p>
274
- </TooltipContent>
275
- </Tooltip>
276
- </TooltipProvider>
266
+ {collection.path && (
267
+ <TooltipProvider>
268
+ <Tooltip>
269
+ <TooltipTrigger asChild>
270
+ <p className="truncate font-mono text-muted-foreground text-xs">
271
+ {truncatePath(collection.path)}
272
+ </p>
273
+ </TooltipTrigger>
274
+ <TooltipContent className="max-w-xs break-all">
275
+ <p className="font-mono text-xs">{collection.path}</p>
276
+ </TooltipContent>
277
+ </Tooltip>
278
+ </TooltipProvider>
279
+ )}
277
280
  </CardHeader>
278
281
 
279
282
  <CardContent className="pt-2">
@@ -30,7 +30,8 @@ interface ConnectorStatus {
30
30
  target: string;
31
31
  scope: "user" | "project";
32
32
  installed: boolean;
33
- path: string;
33
+ /** Host path; omitted for remote callers. */
34
+ path?: string;
34
35
  summary: string;
35
36
  nextAction: string;
36
37
  mode: {
@@ -234,9 +235,11 @@ export default function Connectors({ navigate }: PageProps) {
234
235
  <span className="font-medium">Scope:</span>{" "}
235
236
  {connector.scope}
236
237
  </div>
237
- <div className="font-mono text-muted-foreground text-xs">
238
- {connector.path}
239
- </div>
238
+ {connector.path && (
239
+ <div className="font-mono text-muted-foreground text-xs">
240
+ {connector.path}
241
+ </div>
242
+ )}
240
243
  </div>
241
244
  {connector.error && (
242
245
  <p className="text-destructive text-sm">
@@ -788,25 +788,30 @@ export default function Dashboard({ navigate }: PageProps) {
788
788
  }
789
789
  style={{ animationDelay: `${0.4 + index * 0.1}s` }}
790
790
  >
791
- <CardContent className="flex items-center justify-between py-4">
792
- <div className="flex items-center gap-3">
791
+ <CardContent className="flex flex-wrap items-center justify-between gap-3 py-4">
792
+ <div className="flex min-w-0 flex-1 basis-48 items-center gap-3">
793
793
  {syncing ? (
794
- <Loader2Icon className="size-4 animate-spin text-amber-500" />
794
+ <Loader2Icon className="size-4 shrink-0 animate-spin text-amber-500" />
795
795
  ) : collection.embeddedCount >= collection.chunkCount ? (
796
- <CheckCircle2Icon className="size-4 text-green-500" />
796
+ <CheckCircle2Icon className="size-4 shrink-0 text-green-500" />
797
797
  ) : (
798
- <div className="size-4 rounded-full border-2 border-amber-500" />
798
+ <div className="size-4 shrink-0 rounded-full border-2 border-amber-500" />
799
799
  )}
800
- <div>
801
- <div className="font-medium text-lg transition-colors group-hover:text-primary">
800
+ <div className="min-w-0">
801
+ <div className="truncate font-medium text-lg transition-colors group-hover:text-primary">
802
802
  {collection.name}
803
803
  </div>
804
- <div className="font-mono text-muted-foreground text-sm">
805
- {collection.path}
806
- </div>
804
+ {collection.path && (
805
+ <div
806
+ className="truncate font-mono text-muted-foreground text-sm"
807
+ title={collection.path}
808
+ >
809
+ {collection.path}
810
+ </div>
811
+ )}
807
812
  </div>
808
813
  </div>
809
- <div className="flex items-center gap-3 text-right">
814
+ <div className="flex shrink-0 items-center gap-3 text-right">
810
815
  <Button
811
816
  onClick={(event) => {
812
817
  event.stopPropagation();
@@ -37,6 +37,7 @@ import {
37
37
  type FileRefactorPreviewPlan,
38
38
  } from "../../../core/file-refactor-contract";
39
39
  import { extractSections } from "../../../core/sections";
40
+ import { updateFrontmatterTags } from "../../../ingestion/frontmatter";
40
41
  import {
41
42
  CodeBlock,
42
43
  CodeBlockCopyButton,
@@ -583,11 +584,10 @@ export default function DocView({ navigate }: PageProps) {
583
584
  return buildDocAssetUrl(doc.uri, doc.relPath);
584
585
  }, [doc, isPdf]);
585
586
 
586
- // Remote "Open original" target for any document that has a source file:
587
- // /api/doc-asset serves any collection file inline, so this keeps the
588
- // previous file:// scope (every read-only source) for remote clients.
587
+ // Remote "Open original" target: /api/doc-asset serves any collection file
588
+ // inline by URI, and remote clients never receive the host absPath.
589
589
  const sourceAssetUrl = useMemo(() => {
590
- if (!doc?.source.absPath) {
590
+ if (!doc) {
591
591
  return null;
592
592
  }
593
593
  return buildDocAssetUrl(doc.uri, doc.relPath);
@@ -1243,10 +1243,15 @@ export default function DocView({ navigate }: PageProps) {
1243
1243
  }
1244
1244
 
1245
1245
  clearRequestIntent(tagIntentRef, TAG_INTENT_KEY);
1246
- // Update doc with new tags
1246
+ // Mirror the committed write-back so the frontmatter tag list is current
1247
+ // without waiting for the index to resync.
1247
1248
  setDoc({
1248
1249
  ...doc,
1249
1250
  tags: editedTags,
1251
+ content:
1252
+ data?.writeBack === "applied" && doc.content !== null
1253
+ ? updateFrontmatterTags(doc.content, editedTags)
1254
+ : doc.content,
1250
1255
  source: {
1251
1256
  ...doc.source,
1252
1257
  sourceHash: data?.version.sourceHash ?? doc.source.sourceHash,
@@ -207,8 +207,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
207
207
  const [saveError, setSaveError] = useState<string | null>(null);
208
208
  const [creatingCopy, setCreatingCopy] = useState(false);
209
209
  const [copyError, setCopyError] = useState<string | null>(null);
210
- const [externalChangeNotice, setExternalChangeNotice] = useState<
211
- string | null
210
+ /**
211
+ * `outside`: the file changed under the editor. `unconfirmed`: a save's
212
+ * response was lost, so a change on disk may be that save's own commit.
213
+ */
214
+ const [changeNotice, setChangeNotice] = useState<
215
+ "outside" | "unconfirmed" | null
212
216
  >(null);
213
217
  const [historyEntries, setHistoryEntries] = useState<LocalHistoryEntry[]>([]);
214
218
  const [historyDialogOpen, setHistoryDialogOpen] = useState(false);
@@ -322,12 +326,21 @@ export default function DocumentEditor({ navigate }: PageProps) {
322
326
  // read the current revision and last committed content from refs.
323
327
  const docRef = useRef(doc);
324
328
  const committedContentRef = useRef(originalContent);
329
+ const draftRef = useRef(content);
325
330
  useEffect(() => {
326
331
  docRef.current = doc;
327
332
  }, [doc]);
333
+ useEffect(() => {
334
+ draftRef.current = content;
335
+ }, [content]);
328
336
  // One request ID per (document revision, content) save intent: retrying a
329
337
  // save whose response was lost replays it instead of reporting a conflict.
330
338
  const saveIntentRef = useRef<RequestIntent | null>(null);
339
+ // Content of a save that is in flight or whose response was lost: its
340
+ // commit is unknown, so change events are held until a response says whose
341
+ // change they were. Retry save re-sends exactly this content.
342
+ const unknownSaveRef = useRef<string | null>(null);
343
+ const changedWhileUnknownRef = useRef(false);
331
344
 
332
345
  /** Save `contentToSave`; resolves true once it is committed on disk. */
333
346
  const persistContent = useCallback(
@@ -341,8 +354,13 @@ export default function DocumentEditor({ navigate }: PageProps) {
341
354
 
342
355
  setSaveStatus("saving");
343
356
  setSaveError(null);
357
+ unknownSaveRef.current = contentToSave;
344
358
 
345
- const { data, error: err } = await apiFetch<UpdateDocResponse>(
359
+ const {
360
+ data,
361
+ error: err,
362
+ outcomeUnknown,
363
+ } = await apiFetch<UpdateDocResponse>(
346
364
  `/api/docs/${encodeURIComponent(current.docid)}`,
347
365
  {
348
366
  method: "PUT",
@@ -363,23 +381,45 @@ export default function DocumentEditor({ navigate }: PageProps) {
363
381
  if (err) {
364
382
  setSaveStatus("error");
365
383
  setSaveError(err);
384
+ if (outcomeUnknown) {
385
+ // Never downgrade a warning about a change from elsewhere.
386
+ setChangeNotice((notice) =>
387
+ notice === "outside" ? notice : "unconfirmed"
388
+ );
389
+ return false;
390
+ }
391
+ // A definitive rejection: this save did not commit, so a change seen
392
+ // meanwhile came from elsewhere.
393
+ unknownSaveRef.current = null;
394
+ if (changedWhileUnknownRef.current) {
395
+ changedWhileUnknownRef.current = false;
396
+ setChangeNotice("outside");
397
+ } else {
398
+ setChangeNotice((notice) =>
399
+ notice === "unconfirmed" ? null : notice
400
+ );
401
+ }
366
402
  return false;
367
403
  }
368
404
 
405
+ unknownSaveRef.current = null;
406
+ changedWhileUnknownRef.current = false;
369
407
  if (data?.request?.replayed) {
370
408
  // A replay reports an earlier commit; disk may have moved on since.
371
- // Clear the change notice only if the file still holds that commit.
409
+ // Only a read showing a different hash is evidence of another writer.
372
410
  const { data: latest } = await apiFetch<DocData>(
373
411
  `/api/doc?uri=${encodeURIComponent(current.uri)}`
374
412
  );
375
- if (latest?.source.sourceHash === data.version.sourceHash) {
376
- setExternalChangeNotice(null);
377
- }
413
+ setChangeNotice(
414
+ latest && latest.source.sourceHash !== data.version.sourceHash
415
+ ? "outside"
416
+ : null
417
+ );
378
418
  } else {
379
419
  // Written now against the loaded revision: the next change event is
380
420
  // this save's own sync, and any earlier notice is stale.
381
421
  ignoreDocEventsUntilRef.current = Date.now() + 5_000;
382
- setExternalChangeNotice(null);
422
+ setChangeNotice(null);
383
423
  }
384
424
  const previous = committedContentRef.current;
385
425
  if (previous !== contentToSave) {
@@ -589,6 +629,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
589
629
  }
590
630
  }, [cancelAutosave, content, hasUnsavedChanges, persistContent]);
591
631
 
632
+ // Retry the unconfirmed save itself (same content, same request ID), even
633
+ // when the draft has since changed or returned to the loaded text; once it
634
+ // is confirmed, the draft as it is then is saved on top of it.
635
+ const retryUnconfirmedSave = useCallback(async () => {
636
+ const pending = unknownSaveRef.current;
637
+ if (pending === null) return;
638
+ cancelAutosave();
639
+ if (await persistContent(pending)) {
640
+ await persistContent(draftRef.current);
641
+ }
642
+ }, [cancelAutosave, persistContent]);
643
+
592
644
  const loadDocument = useCallback(() => {
593
645
  const uri = currentTarget.uri;
594
646
 
@@ -637,16 +689,20 @@ export default function DocumentEditor({ navigate }: PageProps) {
637
689
  return;
638
690
  }
639
691
  handledDocEventRef.current = latestDocEvent.changedAt;
692
+ if (unknownSaveRef.current !== null) {
693
+ changedWhileUnknownRef.current = true;
694
+ return;
695
+ }
640
696
  if (Date.now() < ignoreDocEventsUntilRef.current) {
641
697
  return;
642
698
  }
643
- setExternalChangeNotice(
644
- "This document changed on disk. Reload before continuing."
645
- );
699
+ setChangeNotice("outside");
646
700
  }, [doc, latestDocEvent?.changedAt, latestDocEvent?.uri]);
647
701
 
648
702
  const reloadDocument = useCallback(() => {
649
- setExternalChangeNotice(null);
703
+ unknownSaveRef.current = null;
704
+ changedWhileUnknownRef.current = false;
705
+ setChangeNotice(null);
650
706
  loadDocument();
651
707
  }, [loadDocument]);
652
708
 
@@ -1136,10 +1192,31 @@ export default function DocumentEditor({ navigate }: PageProps) {
1136
1192
  </p>
1137
1193
  )}
1138
1194
 
1139
- {externalChangeNotice && (
1195
+ {changeNotice === "unconfirmed" && (
1196
+ <div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
1197
+ <div className="flex flex-wrap items-center justify-between gap-3">
1198
+ <p className="text-amber-500 text-sm">
1199
+ The save may have completed, but its response was lost. Retry to
1200
+ confirm it.
1201
+ </p>
1202
+ <Button
1203
+ disabled={saveStatus === "saving"}
1204
+ onClick={retryUnconfirmedSave}
1205
+ size="sm"
1206
+ variant="outline"
1207
+ >
1208
+ Retry save
1209
+ </Button>
1210
+ </div>
1211
+ </div>
1212
+ )}
1213
+
1214
+ {changeNotice === "outside" && (
1140
1215
  <div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
1141
1216
  <div className="flex flex-wrap items-center justify-between gap-3">
1142
- <p className="text-amber-500 text-sm">{externalChangeNotice}</p>
1217
+ <p className="text-amber-500 text-sm">
1218
+ This document changed on disk. Reload before continuing.
1219
+ </p>
1143
1220
  <Button onClick={reloadDocument} size="sm" variant="outline">
1144
1221
  Reload
1145
1222
  </Button>
@@ -50,50 +50,10 @@ import {
50
50
  fetchServerCapabilities,
51
51
  type ServerCapabilities,
52
52
  } from "../lib/server-capabilities";
53
+ import { renderSnippet } from "../lib/snippet";
53
54
  import { cn } from "../lib/utils";
54
55
  import { AIModelSelector, TagFacets } from "./search-page-widgets";
55
56
 
56
- /**
57
- * Render snippet with <mark> tags as highlighted spans.
58
- * Only allows mark tags - strips all other HTML for safety.
59
- */
60
- function renderSnippet(snippet: string): React.ReactNode {
61
- const parts: React.ReactNode[] = [];
62
- let remaining = snippet;
63
- let key = 0;
64
-
65
- while (remaining.length > 0) {
66
- const markStart = remaining.indexOf("<mark>");
67
- if (markStart === -1) {
68
- parts.push(remaining);
69
- break;
70
- }
71
-
72
- if (markStart > 0) {
73
- parts.push(remaining.slice(0, markStart));
74
- }
75
-
76
- const markEnd = remaining.indexOf("</mark>", markStart);
77
- if (markEnd === -1) {
78
- parts.push(remaining.slice(markStart));
79
- break;
80
- }
81
-
82
- const highlighted = remaining.slice(markStart + 6, markEnd);
83
- parts.push(
84
- <mark
85
- className="rounded bg-primary/20 px-0.5 font-medium text-primary"
86
- key={key++}
87
- >
88
- {highlighted}
89
- </mark>
90
- );
91
- remaining = remaining.slice(markEnd + 7);
92
- }
93
-
94
- return parts;
95
- }
96
-
97
57
  interface PageProps {
98
58
  navigate: (to: string | number) => void;
99
59
  }
@@ -45,9 +45,11 @@ import { JobManager } from "../core/job-manager";
45
45
  import { recordContentMutation } from "../core/mutation-generations";
46
46
  import {
47
47
  shutdownDuration,
48
+ RESIDENT_BUSY_TIMEOUT_MS,
48
49
  SHUTDOWN_DRAIN_MS,
49
50
  SHUTDOWN_ABORT_MS,
50
51
  } from "../core/shutdown-budget";
52
+ import { acquireCliWriteLease } from "../core/write-lease";
51
53
  import { defaultSyncService, withContentTypeRules } from "../ingestion";
52
54
  import { withOwnedInferenceScope } from "../llm/inference-scope";
53
55
  import { getActivePreset } from "../llm/registry";
@@ -60,7 +62,7 @@ import {
60
62
  disposeServerContext,
61
63
  type ServerContext,
62
64
  } from "./context";
63
- import { createEmbedScheduler } from "./embed-scheduler";
65
+ import { createEmbedScheduler, embedSchedulerIssues } from "./embed-scheduler";
64
66
  import { FindingsScheduler, type FindingsPassResult } from "./findings-pass";
65
67
  import { AdmissionController, ReaderGate } from "./resident-admission";
66
68
  import { ResidentBackgroundWork } from "./resident-background-work";
@@ -189,10 +191,25 @@ export type ResidentRuntimeDeps = {
189
191
  eventBus?: DocumentEventBus | null;
190
192
  callbacks?: CollectionWatchCallbacks;
191
193
  syncOptions?: Parameters<typeof withContentTypeRules>[0];
194
+ acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
192
195
  }) => CollectionWatchService;
193
196
  modelManagerFactory?: (config: Config) => ModelManager;
194
197
  };
195
198
 
199
+ /** No-wait shared writer lease for background writes; null when held elsewhere. */
200
+ async function acquireBackgroundWriteLease(
201
+ dbPath: string,
202
+ command: string
203
+ ): Promise<(() => Promise<void>) | null> {
204
+ const lease = await acquireCliWriteLease({
205
+ dbPath,
206
+ waitMs: 0,
207
+ noWait: true,
208
+ command,
209
+ });
210
+ return lease.ok ? lease.release : null;
211
+ }
212
+
196
213
  export async function startResidentRuntime(
197
214
  options: ResidentRuntimeOptions = {},
198
215
  deps: ResidentRuntimeDeps = {}
@@ -274,6 +291,11 @@ export async function startResidentRuntime(
274
291
  if (!syncCollections.ok) return failStartup(syncCollections.error.message);
275
292
  const syncContexts = await store.syncContexts(initialConfig.contexts ?? []);
276
293
  if (!syncContexts.ok) return failStartup(syncContexts.error.message);
294
+ // SQLite busy waits block this event loop; keep each one short enough that
295
+ // a stop signal is still handled within the stop grace.
296
+ store.setBusyTimeout?.(RESIDENT_BUSY_TIMEOUT_MS);
297
+ const leaseFor = (command: string) => () =>
298
+ acquireBackgroundWriteLease(dbPath, command);
277
299
 
278
300
  let ctx: ServerContext;
279
301
  try {
@@ -322,6 +344,7 @@ export async function startResidentRuntime(
322
344
  onEmbedded: () => {
323
345
  generations.index += 1;
324
346
  },
347
+ acquireWriteLease: leaseFor(`gno ${mode} (background embed)`),
325
348
  });
326
349
  ctxHolder.scheduler = scheduler;
327
350
  ctxHolder.current.scheduler = scheduler;
@@ -351,8 +374,16 @@ export async function startResidentRuntime(
351
374
  },
352
375
  },
353
376
  syncOptions: withContentTypeRules({}, initialConfig),
377
+ acquireWriteLease: leaseFor(`gno ${mode} (watch sync)`),
354
378
  });
355
379
  watchService.start();
380
+ // A backlog left by `--no-embed` or an earlier run gets a pass without
381
+ // waiting for a file change.
382
+ const startupStatus = await store.getStatus({
383
+ embedModel: getActivePreset(initialConfig).embed,
384
+ });
385
+ if (startupStatus.ok && startupStatus.value.embeddingBacklog > 0)
386
+ scheduler.notifySyncComplete([]);
356
387
  ctxHolder.watchService = watchService;
357
388
  ctxHolder.current.watchService = watchService;
358
389
 
@@ -590,6 +621,7 @@ export async function startResidentRuntime(
590
621
  failed: jobs.recent.filter((job) => job.status === "failed").length,
591
622
  },
592
623
  generations: { ...generations },
624
+ backgroundIssues: embedSchedulerIssues(scheduler.getState()),
593
625
  });
594
626
  },
595
627
  setListenerPort(port) {
@@ -1,6 +1,10 @@
1
1
  /** Safe, transport-neutral resident lifecycle status projection. */
2
2
 
3
- import type { ResidentStatus, RuntimeMode } from "./status-model";
3
+ import type {
4
+ BackgroundIssue,
5
+ ResidentStatus,
6
+ RuntimeMode,
7
+ } from "./status-model";
4
8
 
5
9
  const EMPTY_MODELS: ResidentStatus["models"] = {
6
10
  activeLeases: 0,
@@ -50,6 +54,7 @@ export interface ResidentStatusSnapshotInput {
50
54
  models: ResidentStatus["models"];
51
55
  jobs: ResidentStatus["jobs"];
52
56
  generations: ResidentStatus["generations"];
57
+ backgroundIssues?: BackgroundIssue[];
53
58
  now?: number;
54
59
  }
55
60
 
@@ -72,6 +77,13 @@ export function buildResidentStatusSnapshot(
72
77
  models: { ...input.models },
73
78
  jobs: { ...input.jobs },
74
79
  generations: { ...input.generations },
80
+ ...(input.backgroundIssues?.length
81
+ ? {
82
+ backgroundIssues: input.backgroundIssues.map((issue) => ({
83
+ ...issue,
84
+ })),
85
+ }
86
+ : {}),
75
87
  };
76
88
  }
77
89