@gmickel/gno 0.25.2 → 0.27.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 (36) hide show
  1. package/README.md +5 -3
  2. package/assets/skill/SKILL.md +5 -0
  3. package/assets/skill/cli-reference.md +8 -6
  4. package/package.json +1 -1
  5. package/src/cli/commands/get.ts +21 -0
  6. package/src/cli/commands/skill/install.ts +2 -2
  7. package/src/cli/commands/skill/paths.ts +26 -4
  8. package/src/cli/commands/skill/uninstall.ts +2 -2
  9. package/src/cli/program.ts +18 -12
  10. package/src/core/document-capabilities.ts +113 -0
  11. package/src/mcp/tools/get.ts +10 -0
  12. package/src/mcp/tools/index.ts +434 -110
  13. package/src/sdk/documents.ts +12 -0
  14. package/src/serve/doc-events.ts +69 -0
  15. package/src/serve/public/app.tsx +81 -24
  16. package/src/serve/public/components/CaptureModal.tsx +138 -3
  17. package/src/serve/public/components/QuickSwitcher.tsx +248 -0
  18. package/src/serve/public/components/ShortcutHelpModal.tsx +1 -0
  19. package/src/serve/public/components/ai-elements/code-block.tsx +74 -26
  20. package/src/serve/public/components/editor/CodeMirrorEditor.tsx +51 -0
  21. package/src/serve/public/components/ui/command.tsx +2 -2
  22. package/src/serve/public/hooks/use-doc-events.ts +34 -0
  23. package/src/serve/public/hooks/useCaptureModal.tsx +12 -3
  24. package/src/serve/public/hooks/useKeyboardShortcuts.ts +2 -2
  25. package/src/serve/public/lib/deep-links.ts +68 -0
  26. package/src/serve/public/lib/document-availability.ts +22 -0
  27. package/src/serve/public/lib/local-history.ts +44 -0
  28. package/src/serve/public/lib/wiki-link.ts +36 -0
  29. package/src/serve/public/pages/Browse.tsx +11 -0
  30. package/src/serve/public/pages/Dashboard.tsx +2 -2
  31. package/src/serve/public/pages/DocView.tsx +241 -18
  32. package/src/serve/public/pages/DocumentEditor.tsx +399 -9
  33. package/src/serve/public/pages/Search.tsx +20 -1
  34. package/src/serve/routes/api.ts +359 -28
  35. package/src/serve/server.ts +48 -1
  36. package/src/serve/watch-service.ts +149 -0
@@ -13,10 +13,17 @@ import type {
13
13
  SearchOptions,
14
14
  } from "../../pipeline/types";
15
15
  import type { SqliteAdapter } from "../../store/sqlite/adapter";
16
+ import type { DocumentEventBus } from "../doc-events";
16
17
  import type { EmbedScheduler } from "../embed-scheduler";
18
+ import type { CollectionWatchService } from "../watch-service";
17
19
 
18
20
  import { modelsPull } from "../../cli/commands/models/pull";
19
21
  import { addCollection, removeCollection } from "../../collection";
22
+ import {
23
+ buildEditableCopyContent,
24
+ deriveEditableCopyRelPath,
25
+ getDocumentCapabilities,
26
+ } from "../../core/document-capabilities";
20
27
  import { atomicWrite } from "../../core/file-ops";
21
28
  import { normalizeStructuredQueryInput } from "../../core/structured-query";
22
29
  import {
@@ -49,6 +56,8 @@ export interface ContextHolder {
49
56
  current: ServerContext;
50
57
  config: Config;
51
58
  scheduler: EmbedScheduler | null;
59
+ eventBus: DocumentEventBus | null;
60
+ watchService: CollectionWatchService | null;
52
61
  }
53
62
 
54
63
  // ─────────────────────────────────────────────────────────────────────────────
@@ -155,6 +164,15 @@ export interface UpdateDocRequestBody {
155
164
  content?: string;
156
165
  /** Tags to set (replaces existing tags) */
157
166
  tags?: string[];
167
+ /** Expected source hash for optimistic concurrency */
168
+ expectedSourceHash?: string;
169
+ /** Expected source modified timestamp for optimistic concurrency */
170
+ expectedModifiedAt?: string;
171
+ }
172
+
173
+ export interface CreateEditableCopyRequestBody {
174
+ collection?: string;
175
+ relPath?: string;
158
176
  }
159
177
 
160
178
  // ─────────────────────────────────────────────────────────────────────────────
@@ -180,6 +198,78 @@ function parseCommaSeparatedValues(input: string): string[] {
180
198
  );
181
199
  }
182
200
 
201
+ function hashContent(content: string): string {
202
+ const hasher = new Bun.CryptoHasher("sha256");
203
+ hasher.update(content);
204
+ return hasher.digest("hex");
205
+ }
206
+
207
+ interface SourceMeta {
208
+ absPath?: string;
209
+ mime: string;
210
+ ext: string;
211
+ modifiedAt?: string;
212
+ sizeBytes?: number;
213
+ sourceHash?: string;
214
+ }
215
+
216
+ function getCollectionByName(
217
+ collections: Config["collections"],
218
+ collectionName: string
219
+ ) {
220
+ return collections.find(
221
+ (c) => c.name.toLowerCase() === collectionName.toLowerCase()
222
+ );
223
+ }
224
+
225
+ async function resolveAbsoluteDocPath(
226
+ collections: Config["collections"],
227
+ doc: { collection: string; relPath: string }
228
+ ): Promise<{
229
+ collection: Config["collections"][number];
230
+ fullPath: string;
231
+ } | null> {
232
+ const collection = getCollectionByName(collections, doc.collection);
233
+ if (!collection) {
234
+ return null;
235
+ }
236
+
237
+ const nodePath = await import("node:path"); // no bun equivalent
238
+ let safeRelPath: string;
239
+ try {
240
+ safeRelPath = validateRelPath(doc.relPath);
241
+ } catch {
242
+ return null;
243
+ }
244
+ return {
245
+ collection,
246
+ fullPath: nodePath.join(collection.path, safeRelPath),
247
+ };
248
+ }
249
+
250
+ async function buildSourceMeta(
251
+ collections: Config["collections"],
252
+ doc: {
253
+ collection: string;
254
+ relPath: string;
255
+ sourceMime: string;
256
+ sourceExt: string;
257
+ sourceMtime?: string | null;
258
+ sourceSize?: number;
259
+ sourceHash?: string;
260
+ }
261
+ ): Promise<SourceMeta> {
262
+ const resolved = await resolveAbsoluteDocPath(collections, doc);
263
+ return {
264
+ absPath: resolved?.fullPath,
265
+ mime: doc.sourceMime,
266
+ ext: doc.sourceExt,
267
+ modifiedAt: doc.sourceMtime ?? undefined,
268
+ sizeBytes: doc.sourceSize,
269
+ sourceHash: doc.sourceHash,
270
+ };
271
+ }
272
+
183
273
  function parseQueryModesInput(value: unknown): {
184
274
  queryModes?: QueryModeInput[];
185
275
  error?: Response;
@@ -691,6 +781,48 @@ export async function handleDocs(
691
781
  });
692
782
  }
693
783
 
784
+ /**
785
+ * GET /api/docs/autocomplete
786
+ * Query params: query, collection, limit
787
+ */
788
+ export async function handleDocsAutocomplete(
789
+ store: SqliteAdapter,
790
+ url: URL
791
+ ): Promise<Response> {
792
+ const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
793
+ const collection = url.searchParams.get("collection") || undefined;
794
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? "8") || 8, 20);
795
+
796
+ const result = await store.listDocuments(collection);
797
+ if (!result.ok) {
798
+ return errorResponse("RUNTIME", result.error.message, 500);
799
+ }
800
+
801
+ const candidates = result.value
802
+ .filter((doc) => doc.active)
803
+ .map((doc) => ({
804
+ docid: doc.docid,
805
+ uri: doc.uri,
806
+ title:
807
+ doc.title ??
808
+ doc.relPath
809
+ .split("/")
810
+ .pop()
811
+ ?.replace(/\.[^.]+$/, "") ??
812
+ doc.relPath,
813
+ collection: doc.collection,
814
+ }))
815
+ .filter((doc) => {
816
+ if (!query) return true;
817
+ const haystack = `${doc.title} ${doc.uri}`.toLowerCase();
818
+ return haystack.includes(query);
819
+ })
820
+ .sort((a, b) => a.title.localeCompare(b.title))
821
+ .slice(0, limit);
822
+
823
+ return jsonResponse({ docs: candidates });
824
+ }
825
+
694
826
  /**
695
827
  * GET /api/doc
696
828
  * Query params: uri (required)
@@ -698,6 +830,7 @@ export async function handleDocs(
698
830
  */
699
831
  export async function handleDoc(
700
832
  store: SqliteAdapter,
833
+ config: Config,
701
834
  url: URL
702
835
  ): Promise<Response> {
703
836
  const uri = url.searchParams.get("uri");
@@ -730,21 +863,25 @@ export async function handleDoc(
730
863
  tags = tagsResult.value.map((t) => t.tag);
731
864
  }
732
865
 
866
+ const contentAvailable = content !== null;
867
+ const capabilities = getDocumentCapabilities({
868
+ sourceExt: doc.sourceExt,
869
+ sourceMime: doc.sourceMime,
870
+ contentAvailable,
871
+ });
872
+ const source = await buildSourceMeta(config.collections, doc);
873
+
733
874
  return jsonResponse({
734
875
  docid: doc.docid,
735
876
  uri: doc.uri,
736
877
  title: doc.title,
737
878
  content,
738
- contentAvailable: content !== null,
879
+ contentAvailable,
739
880
  collection: doc.collection,
740
881
  relPath: doc.relPath,
741
882
  tags,
742
- source: {
743
- mime: doc.sourceMime,
744
- ext: doc.sourceExt,
745
- modifiedAt: doc.sourceMtime,
746
- sizeBytes: doc.sourceSize,
747
- },
883
+ source,
884
+ capabilities,
748
885
  });
749
886
  }
750
887
 
@@ -862,6 +999,18 @@ export async function handleUpdateDoc(
862
999
  if (hasContent && typeof body.content !== "string") {
863
1000
  return errorResponse("VALIDATION", "content must be a string");
864
1001
  }
1002
+ if (
1003
+ body.expectedSourceHash !== undefined &&
1004
+ typeof body.expectedSourceHash !== "string"
1005
+ ) {
1006
+ return errorResponse("VALIDATION", "expectedSourceHash must be a string");
1007
+ }
1008
+ if (
1009
+ body.expectedModifiedAt !== undefined &&
1010
+ typeof body.expectedModifiedAt !== "string"
1011
+ ) {
1012
+ return errorResponse("VALIDATION", "expectedModifiedAt must be a string");
1013
+ }
865
1014
 
866
1015
  // Validate tags if provided
867
1016
  let normalizedTags: string[] | undefined;
@@ -896,29 +1045,18 @@ export async function handleUpdateDoc(
896
1045
 
897
1046
  const doc = docResult.value;
898
1047
 
899
- // Find collection config (case-insensitive)
900
- const collectionName = doc.collection.toLowerCase();
901
- const collection = ctxHolder.config.collections.find(
902
- (c) => c.name.toLowerCase() === collectionName
1048
+ const resolvedDocPath = await resolveAbsoluteDocPath(
1049
+ ctxHolder.config.collections,
1050
+ doc
903
1051
  );
904
- if (!collection) {
1052
+ if (!resolvedDocPath) {
905
1053
  return errorResponse(
906
1054
  "NOT_FOUND",
907
1055
  `Collection not found: ${doc.collection}`,
908
1056
  404
909
1057
  );
910
1058
  }
911
-
912
- // Validate and resolve full path
913
- // Critical: validate relPath from DB to prevent path traversal attacks
914
- const nodePath = await import("node:path"); // no bun equivalent
915
- let safeRelPath: string;
916
- try {
917
- safeRelPath = validateRelPath(doc.relPath);
918
- } catch {
919
- return errorResponse("VALIDATION", "Invalid document relPath in DB", 400);
920
- }
921
- const fullPath = nodePath.join(collection.path, safeRelPath);
1059
+ const { collection, fullPath } = resolvedDocPath;
922
1060
 
923
1061
  // Verify file exists
924
1062
  const file = Bun.file(fullPath);
@@ -926,9 +1064,49 @@ export async function handleUpdateDoc(
926
1064
  return errorResponse("FILE_NOT_FOUND", "Source file no longer exists", 404);
927
1065
  }
928
1066
 
929
- // Determine if we can write tags back to file
930
- const isMarkdown =
931
- doc.sourceMime === "text/markdown" || doc.sourceExt === ".md";
1067
+ if (body.expectedSourceHash || body.expectedModifiedAt) {
1068
+ const currentBytes = await file.bytes();
1069
+ const currentSourceHash = hashContent(
1070
+ new TextDecoder().decode(currentBytes)
1071
+ );
1072
+ const { stat } = await import("node:fs/promises"); // no Bun structure stat parity
1073
+ const currentModifiedAt = (await stat(fullPath)).mtime.toISOString();
1074
+
1075
+ if (
1076
+ (body.expectedSourceHash &&
1077
+ body.expectedSourceHash !== currentSourceHash) ||
1078
+ (body.expectedModifiedAt && body.expectedModifiedAt !== currentModifiedAt)
1079
+ ) {
1080
+ return jsonResponse(
1081
+ {
1082
+ error: {
1083
+ code: "CONFLICT",
1084
+ message: "Document changed on disk. Reload before saving.",
1085
+ },
1086
+ currentVersion: {
1087
+ sourceHash: currentSourceHash,
1088
+ modifiedAt: currentModifiedAt,
1089
+ },
1090
+ },
1091
+ 409
1092
+ );
1093
+ }
1094
+ }
1095
+
1096
+ const capabilities = getDocumentCapabilities({
1097
+ sourceExt: doc.sourceExt,
1098
+ sourceMime: doc.sourceMime,
1099
+ contentAvailable: doc.mirrorHash !== null,
1100
+ });
1101
+ if (hasContent && !capabilities.editable) {
1102
+ return errorResponse(
1103
+ "READ_ONLY",
1104
+ capabilities.reason ??
1105
+ "This document cannot be edited in place. Create an editable markdown copy instead.",
1106
+ 409
1107
+ );
1108
+ }
1109
+
932
1110
  let writeBack: "applied" | "skipped_unsupported" | undefined;
933
1111
 
934
1112
  try {
@@ -941,7 +1119,7 @@ export async function handleUpdateDoc(
941
1119
 
942
1120
  // Handle tag writeback for Markdown files
943
1121
  if (hasTags && normalizedTags) {
944
- if (isMarkdown) {
1122
+ if (capabilities.tagsWriteback) {
945
1123
  // Read current content if we're only updating tags
946
1124
  const source = contentToWrite ?? (await file.text());
947
1125
  contentToWrite = updateFrontmatterTags(source, normalizedTags);
@@ -957,9 +1135,16 @@ export async function handleUpdateDoc(
957
1135
  }
958
1136
  }
959
1137
 
1138
+ let currentSourceHash = doc.sourceHash;
1139
+ let currentModifiedAt = doc.sourceMtime;
1140
+
960
1141
  // Write file if we have content to write
961
1142
  if (contentToWrite !== undefined) {
1143
+ ctxHolder.watchService?.suppress(fullPath);
962
1144
  await atomicWrite(fullPath, contentToWrite);
1145
+ currentSourceHash = hashContent(contentToWrite);
1146
+ const { stat } = await import("node:fs/promises"); // no Bun structure stat parity
1147
+ currentModifiedAt = (await stat(fullPath)).mtime.toISOString();
963
1148
  }
964
1149
 
965
1150
  // Build proper file:// URI using node:url
@@ -978,6 +1163,14 @@ export async function handleUpdateDoc(
978
1163
  );
979
1164
  // Notify scheduler after sync completes
980
1165
  ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
1166
+ ctxHolder.eventBus?.emit({
1167
+ type: "document-changed",
1168
+ uri: doc.uri,
1169
+ collection: doc.collection,
1170
+ relPath: doc.relPath,
1171
+ origin: "save",
1172
+ changedAt: new Date().toISOString(),
1173
+ });
981
1174
  return {
982
1175
  collections: [result],
983
1176
  totalDurationMs: result.durationMs,
@@ -998,6 +1191,10 @@ export async function handleUpdateDoc(
998
1191
  path: fullPath,
999
1192
  jobId,
1000
1193
  writeBack,
1194
+ version: {
1195
+ sourceHash: currentSourceHash,
1196
+ modifiedAt: currentModifiedAt,
1197
+ },
1001
1198
  });
1002
1199
  } catch (e) {
1003
1200
  return errorResponse(
@@ -1008,6 +1205,126 @@ export async function handleUpdateDoc(
1008
1205
  }
1009
1206
  }
1010
1207
 
1208
+ /**
1209
+ * POST /api/docs/:id/editable-copy
1210
+ * Create a markdown copy for a read-only/converted document.
1211
+ */
1212
+ export async function handleCreateEditableCopy(
1213
+ ctxHolder: ContextHolder,
1214
+ store: SqliteAdapter,
1215
+ docId: string,
1216
+ req: Request
1217
+ ): Promise<Response> {
1218
+ let body: CreateEditableCopyRequestBody = {};
1219
+ try {
1220
+ const text = await req.text();
1221
+ if (text) {
1222
+ body = JSON.parse(text) as CreateEditableCopyRequestBody;
1223
+ }
1224
+ } catch {
1225
+ return errorResponse("VALIDATION", "Invalid JSON body");
1226
+ }
1227
+
1228
+ if (body.collection !== undefined && typeof body.collection !== "string") {
1229
+ return errorResponse("VALIDATION", "collection must be a string");
1230
+ }
1231
+ if (body.relPath !== undefined && typeof body.relPath !== "string") {
1232
+ return errorResponse("VALIDATION", "relPath must be a string");
1233
+ }
1234
+
1235
+ const docResult = await store.getDocumentByDocid(docId);
1236
+ if (!docResult.ok) {
1237
+ return errorResponse("RUNTIME", docResult.error.message, 500);
1238
+ }
1239
+ if (!docResult.value) {
1240
+ return errorResponse("NOT_FOUND", "Document not found", 404);
1241
+ }
1242
+
1243
+ const doc = docResult.value;
1244
+ const contentAvailable = doc.mirrorHash !== null;
1245
+ const capabilities = getDocumentCapabilities({
1246
+ sourceExt: doc.sourceExt,
1247
+ sourceMime: doc.sourceMime,
1248
+ contentAvailable,
1249
+ });
1250
+ if (capabilities.editable) {
1251
+ return errorResponse(
1252
+ "VALIDATION",
1253
+ "Document is already editable in place; use the normal update route instead."
1254
+ );
1255
+ }
1256
+ if (!doc.mirrorHash) {
1257
+ return errorResponse(
1258
+ "RUNTIME",
1259
+ "Editable copy unavailable because converted content is missing.",
1260
+ 409
1261
+ );
1262
+ }
1263
+
1264
+ const contentResult = await store.getContent(doc.mirrorHash);
1265
+ if (!contentResult.ok || contentResult.value === null) {
1266
+ return errorResponse(
1267
+ "RUNTIME",
1268
+ "Editable copy unavailable because converted content is missing.",
1269
+ 409
1270
+ );
1271
+ }
1272
+
1273
+ const tagsResult = await store.getTagsForDoc(doc.id);
1274
+ const tags = tagsResult.ok ? tagsResult.value.map((tag) => tag.tag) : [];
1275
+
1276
+ const targetCollectionName = body.collection ?? doc.collection;
1277
+ const targetCollection = getCollectionByName(
1278
+ ctxHolder.config.collections,
1279
+ targetCollectionName
1280
+ );
1281
+ if (!targetCollection) {
1282
+ return errorResponse(
1283
+ "NOT_FOUND",
1284
+ `Collection not found: ${targetCollectionName}`,
1285
+ 404
1286
+ );
1287
+ }
1288
+
1289
+ let relPath = body.relPath;
1290
+ if (!relPath) {
1291
+ const listResult = await store.listDocuments(targetCollection.name);
1292
+ const existingRelPaths = listResult.ok
1293
+ ? listResult.value.map((entry) => entry.relPath)
1294
+ : [];
1295
+ relPath = deriveEditableCopyRelPath(doc.relPath, existingRelPaths);
1296
+ }
1297
+
1298
+ const title =
1299
+ doc.title ??
1300
+ doc.relPath
1301
+ .split("/")
1302
+ .pop()
1303
+ ?.replace(/\.[^.]+$/, "") ??
1304
+ "Copy";
1305
+ const content = buildEditableCopyContent({
1306
+ title,
1307
+ sourceDocid: doc.docid,
1308
+ sourceUri: doc.uri,
1309
+ sourceMime: doc.sourceMime,
1310
+ sourceExt: doc.sourceExt,
1311
+ content: contentResult.value,
1312
+ tags,
1313
+ });
1314
+
1315
+ const createReq = new Request("http://localhost/api/docs", {
1316
+ method: "POST",
1317
+ body: JSON.stringify({
1318
+ collection: targetCollection.name,
1319
+ relPath,
1320
+ content,
1321
+ tags,
1322
+ } satisfies CreateDocRequestBody),
1323
+ });
1324
+
1325
+ return handleCreateDoc(ctxHolder, store, createReq);
1326
+ }
1327
+
1011
1328
  /**
1012
1329
  * POST /api/docs
1013
1330
  * Create a new document in a collection.
@@ -1102,6 +1419,7 @@ export async function handleCreateDoc(
1102
1419
  contentToWrite = updateFrontmatterTags(body.content, validatedTags);
1103
1420
  }
1104
1421
 
1422
+ ctxHolder.watchService?.suppress(fullPath);
1105
1423
  await atomicWrite(fullPath, contentToWrite);
1106
1424
 
1107
1425
  // Build gno:// URI for the created document
@@ -1119,6 +1437,14 @@ export async function handleCreateDoc(
1119
1437
  // The sync will create a proper docid, but we don't have it here yet
1120
1438
  // Using normalizedRelPath as identifier since docid is generated during sync
1121
1439
  ctxHolder.scheduler?.notifySyncComplete([normalizedRelPath]);
1440
+ ctxHolder.eventBus?.emit({
1441
+ type: "document-changed",
1442
+ uri: gnoUri,
1443
+ collection: collection.name,
1444
+ relPath: normalizedRelPath,
1445
+ origin: "create",
1446
+ changedAt: new Date().toISOString(),
1447
+ });
1122
1448
  return {
1123
1449
  collections: [result],
1124
1450
  totalDurationMs: result.durationMs,
@@ -1947,6 +2273,7 @@ export function handleEmbedStatus(scheduler: EmbedScheduler | null): Response {
1947
2273
  // oxlint-disable-next-line typescript-eslint/require-await -- handlers are async, kept for future use
1948
2274
  export async function routeApi(
1949
2275
  store: SqliteAdapter,
2276
+ config: Config,
1950
2277
  req: Request,
1951
2278
  url: URL
1952
2279
  ): Promise<Response | null> {
@@ -1999,8 +2326,12 @@ export async function routeApi(
1999
2326
  return handleDocs(store, url);
2000
2327
  }
2001
2328
 
2329
+ if (path === "/api/docs/autocomplete") {
2330
+ return handleDocsAutocomplete(store, url);
2331
+ }
2332
+
2002
2333
  if (path === "/api/doc") {
2003
- return handleDoc(store, url);
2334
+ return handleDoc(store, config, url);
2004
2335
  }
2005
2336
 
2006
2337
  if (path === "/api/search" && req.method === "POST") {
@@ -13,6 +13,7 @@ import { getConfigPaths, isInitialized, loadConfig } from "../config";
13
13
  import { getActivePreset } from "../llm/registry";
14
14
  import { SqliteAdapter } from "../store/sqlite/adapter";
15
15
  import { createServerContext, disposeServerContext } from "./context";
16
+ import { DocumentEventBus } from "./doc-events";
16
17
  import { createEmbedScheduler } from "./embed-scheduler";
17
18
  // HTML import - Bun handles bundling TSX/CSS automatically via routes
18
19
  import homepage from "./public/index.html";
@@ -21,10 +22,12 @@ import {
21
22
  handleCapabilities,
22
23
  handleCollections,
23
24
  handleCreateCollection,
25
+ handleCreateEditableCopy,
24
26
  handleCreateDoc,
25
27
  handleDeactivateDoc,
26
28
  handleDeleteCollection,
27
29
  handleDoc,
30
+ handleDocsAutocomplete,
28
31
  handleDocs,
29
32
  handleEmbed,
30
33
  handleEmbedStatus,
@@ -48,6 +51,7 @@ import {
48
51
  handleDocSimilar,
49
52
  } from "./routes/links";
50
53
  import { forbiddenResponse, isRequestAllowed } from "./security";
54
+ import { CollectionWatchService } from "./watch-service";
51
55
 
52
56
  export interface ServeOptions {
53
57
  /** Port to listen on (default: 3000) */
@@ -168,6 +172,8 @@ export async function startServer(
168
172
  current: ctx,
169
173
  config, // Keep original config for reloading
170
174
  scheduler: null, // Will be set below
175
+ eventBus: null,
176
+ watchService: null,
171
177
  };
172
178
 
173
179
  // Create embed scheduler with getters (survives context/preset reloads)
@@ -178,6 +184,16 @@ export async function startServer(
178
184
  getModelUri: () => getActivePreset(ctxHolder.config).embed,
179
185
  });
180
186
  ctxHolder.scheduler = scheduler;
187
+ const eventBus = new DocumentEventBus();
188
+ ctxHolder.eventBus = eventBus;
189
+ const watchService = new CollectionWatchService({
190
+ collections: config.collections,
191
+ store,
192
+ scheduler,
193
+ eventBus,
194
+ });
195
+ watchService.start();
196
+ ctxHolder.watchService = watchService;
181
197
 
182
198
  // Shutdown controller for clean lifecycle
183
199
  const shutdownController = new AbortController();
@@ -185,6 +201,8 @@ export async function startServer(
185
201
  // Graceful shutdown handler
186
202
  const shutdown = async () => {
187
203
  console.log("\nShutting down...");
204
+ watchService.dispose();
205
+ eventBus.close();
188
206
  scheduler.dispose();
189
207
  await disposeServerContext(ctxHolder.current);
190
208
  await store.close();
@@ -263,6 +281,15 @@ export async function startServer(
263
281
  );
264
282
  },
265
283
  },
284
+ "/api/docs/autocomplete": {
285
+ GET: async (req: Request) => {
286
+ const url = new URL(req.url);
287
+ return withSecurityHeaders(
288
+ await handleDocsAutocomplete(store, url),
289
+ isDev
290
+ );
291
+ },
292
+ },
266
293
  "/api/docs/:id/deactivate": {
267
294
  POST: async (req: Request) => {
268
295
  if (!isRequestAllowed(req, port)) {
@@ -278,6 +305,20 @@ export async function startServer(
278
305
  );
279
306
  },
280
307
  },
308
+ "/api/docs/:id/editable-copy": {
309
+ POST: async (req: Request) => {
310
+ if (!isRequestAllowed(req, port)) {
311
+ return withSecurityHeaders(forbiddenResponse(), isDev);
312
+ }
313
+ const url = new URL(req.url);
314
+ const parts = url.pathname.split("/");
315
+ const id = decodeURIComponent(parts[3] || "");
316
+ return withSecurityHeaders(
317
+ await handleCreateEditableCopy(ctxHolder, store, id, req),
318
+ isDev
319
+ );
320
+ },
321
+ },
281
322
  "/api/docs/:id": {
282
323
  PUT: async (req: Request) => {
283
324
  if (!isRequestAllowed(req, port)) {
@@ -295,9 +336,15 @@ export async function startServer(
295
336
  "/api/doc": {
296
337
  GET: async (req: Request) => {
297
338
  const url = new URL(req.url);
298
- return withSecurityHeaders(await handleDoc(store, url), isDev);
339
+ return withSecurityHeaders(
340
+ await handleDoc(store, ctxHolder.config, url),
341
+ isDev
342
+ );
299
343
  },
300
344
  },
345
+ "/api/events": {
346
+ GET: () => withSecurityHeaders(eventBus.createResponse(), isDev),
347
+ },
301
348
  "/api/tags": {
302
349
  GET: async (req: Request) => {
303
350
  const url = new URL(req.url);