@gmickel/gno 2.8.2 → 2.8.3

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 (57) hide show
  1. package/README.md +1 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.3.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v2.8.3.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +9 -1
  6. package/spec/cli.md +66 -4
  7. package/spec/mcp.md +5 -6
  8. package/src/cli/commands/daemon.ts +7 -2
  9. package/src/cli/commands/doctor.ts +42 -24
  10. package/src/cli/commands/index-cmd.ts +10 -2
  11. package/src/cli/commands/links.ts +5 -22
  12. package/src/cli/commands/shared.ts +26 -1
  13. package/src/cli/commands/status.ts +60 -0
  14. package/src/cli/commands/update.ts +6 -1
  15. package/src/cli/commands/vec.ts +42 -2
  16. package/src/cli/program.ts +2 -2
  17. package/src/config/types.ts +19 -0
  18. package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
  19. package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
  20. package/src/converters/adapters/xlsx/adapter.ts +132 -0
  21. package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
  22. package/src/converters/registry.ts +62 -6
  23. package/src/converters/versions.ts +2 -0
  24. package/src/core/audit-freshness.ts +1 -1
  25. package/src/core/audit-outside-index.ts +48 -3
  26. package/src/core/change-diff.ts +16 -4
  27. package/src/core/link-destination-parse.ts +21 -4
  28. package/src/core/link-inventory-markdown.ts +23 -3
  29. package/src/core/links.ts +74 -16
  30. package/src/core/network-boundary-inventory.ts +18 -0
  31. package/src/index.ts +10 -0
  32. package/src/ingestion/document-metadata.ts +256 -0
  33. package/src/ingestion/file-child-env.ts +9 -0
  34. package/src/ingestion/file-child.ts +106 -0
  35. package/src/ingestion/file-processor.ts +546 -0
  36. package/src/ingestion/frontmatter.ts +6 -2
  37. package/src/ingestion/index.ts +1 -0
  38. package/src/ingestion/prepare-convert.ts +32 -0
  39. package/src/ingestion/prepare-file.ts +272 -0
  40. package/src/ingestion/prepare-worker.ts +43 -0
  41. package/src/ingestion/record-container.ts +7 -2
  42. package/src/ingestion/strip.ts +71 -8
  43. package/src/ingestion/sync-options.ts +7 -1
  44. package/src/ingestion/sync.ts +207 -400
  45. package/src/ingestion/types.ts +18 -0
  46. package/src/llm/native-worker/embedding-identity.ts +15 -3
  47. package/src/mcp/http-transport.ts +11 -0
  48. package/src/mcp/tools/links.ts +10 -38
  49. package/src/serve/resident-request.ts +19 -0
  50. package/src/serve/resident-runtime.ts +84 -8
  51. package/src/serve/routes/links.ts +6 -24
  52. package/src/serve/server.ts +3 -2
  53. package/src/sessions/config-refresh.ts +3 -2
  54. package/src/store/sqlite/graph-similarity.ts +5 -13
  55. package/src/store/vector/partition-index.ts +100 -0
  56. package/src/store/vector/stored-vectors.ts +20 -15
  57. package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
@@ -9,12 +9,8 @@ import { realpath, stat } from "node:fs/promises";
9
9
  // node:path for join (no Bun path utils)
10
10
  import { isAbsolute, join, relative, sep } from "node:path";
11
11
 
12
- import type { NormalizedContentTypeRule } from "../config";
13
12
  import type { Collection } from "../config/types";
14
- import type { TypedMetadata } from "../core/typed-metadata";
15
13
  import type {
16
- ChunkInput,
17
- DocLinkInput,
18
14
  DocumentInput,
19
15
  DocumentRow,
20
16
  IngestErrorInput,
@@ -24,7 +20,6 @@ import type {
24
20
  import type {
25
21
  ChunkerPort,
26
22
  CollectionSyncResult,
27
- ContentTypeSource,
28
23
  FileSyncResult,
29
24
  ProcessDecision,
30
25
  SyncOptions,
@@ -33,14 +28,11 @@ import type {
33
28
  WalkerPort,
34
29
  } from "./types";
35
30
 
36
- import {
37
- fingerprintContentTypeMetadataRules,
38
- resolveContentTypeRule,
39
- } from "../config";
31
+ import { fingerprintContentTypeMetadataRules } from "../config";
40
32
  import { createJsonlAdapter } from "../converters/adapters/jsonl/adapter";
41
33
  import { createTranscriptAdapter } from "../converters/adapters/transcript/adapter";
42
34
  import { getDefaultMimeDetector, type MimeDetector } from "../converters/mime";
43
- import { MARKDOWN_CONVERTER_ID } from "../converters/native/markdown";
35
+ import { markdownConverter } from "../converters/native/markdown";
44
36
  import {
45
37
  type ConversionPipeline,
46
38
  getDefaultPipeline,
@@ -48,29 +40,33 @@ import {
48
40
  import { DEFAULT_LIMITS, type RecordAdapter } from "../converters/types";
49
41
  import {
50
42
  diffDocumentStructure,
51
- extractDocumentStructure,
43
+ type DocumentStructureSnapshot,
52
44
  } from "../core/change-diff";
53
45
  import { enforceCollectionEgress } from "../core/egress-enforcement";
54
- import {
55
- normalizeMarkdownPath,
56
- normalizeWikiName,
57
- parseLinks,
58
- } from "../core/links";
59
- import { extractMemoryScopes } from "../core/memory-record";
60
- import { normalizeTag, validateTag } from "../core/tags";
61
46
  import { defaultChunker } from "./chunker";
62
47
  import { persistChunkLayout, prepareChunking } from "./chunking";
63
48
  import {
64
49
  isCompiledContextPath,
65
50
  isCompiledContextContent,
66
51
  } from "./compiled-context";
52
+ import { extractDocumentMetadata } from "./document-metadata";
67
53
  import {
68
- extractHashtags,
69
- parseFrontmatter,
70
- stripFrontmatter,
71
- } from "./frontmatter";
54
+ defaultConversionMemoryMb,
55
+ type FileBudgetLimits,
56
+ type FileProcessorHooks,
57
+ memoryFailure,
58
+ prepareInProcessor,
59
+ timeoutFailure,
60
+ } from "./file-processor";
72
61
  import { projectGraph } from "./graph-reconciliation";
73
- import { buildLineOffsets } from "./position";
62
+ import {
63
+ type PrepareFailure,
64
+ prepareFile,
65
+ type PrepareFileRequest,
66
+ type PrepareOutcome,
67
+ type PreparePhase,
68
+ type PreviousRevision,
69
+ } from "./prepare-file";
74
70
  import { processRecordContainer } from "./record-container";
75
71
  import {
76
72
  createDirectoryAvailability,
@@ -85,11 +81,11 @@ import {
85
81
  type SourceContentReaderPort,
86
82
  type SourceReadFailure,
87
83
  } from "./source-availability";
88
- import { type ExcludedRange, getExcludedRanges } from "./strip";
89
- import { extractTypedMetadata } from "./typed-metadata";
90
84
  import { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
91
85
  import { defaultWalker } from "./walker";
92
86
 
87
+ export { extractDocumentMetadata } from "./document-metadata";
88
+
93
89
  /** Default concurrency for file processing */
94
90
  const DEFAULT_CONCURRENCY = 1;
95
91
 
@@ -99,6 +95,14 @@ const TX_BATCH_SIZE = 50;
99
95
  /** Max concurrency to prevent resource exhaustion */
100
96
  const MAX_CONCURRENCY = 16;
101
97
 
98
+ interface FileBudget extends FileBudgetLimits {
99
+ startedAt: number;
100
+ }
101
+
102
+ /** A file still being indexed this long (or half its budget) is reported. */
103
+ export const SLOW_CONVERSION_NOTICE_MS = 10_000;
104
+ const BYTES_PER_MB = 1_048_576;
105
+
102
106
  /**
103
107
  * Current ingest schema version.
104
108
  * Increment when ingestion adds new derived data (tags, metadata, etc.)
@@ -108,8 +112,11 @@ const MAX_CONCURRENCY = 16;
108
112
  * link text with square brackets parse the way Obsidian renders them.
109
113
  * 9: links are extracted from Markdown sources only, and never from code
110
114
  * spans or indented code blocks.
115
+ * 10: Markdown links follow CommonMark: an escaped `[` opens no link, a
116
+ * destination with an unescaped space is text unless wrapped in `<...>`, and
117
+ * the destination ends before a link title.
111
118
  */
112
- export const INGEST_VERSION = 9;
119
+ export const INGEST_VERSION = 10;
113
120
  const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT =
114
121
  fingerprintContentTypeMetadataRules([]);
115
122
  const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
@@ -192,241 +199,6 @@ function decideAction(
192
199
  return { kind: "skip", reason: "unchanged" };
193
200
  }
194
201
 
195
- /**
196
- * Extract tags from markdown content.
197
- * Combines frontmatter tags and inline hashtags, normalized and validated.
198
- */
199
- function extractTags(markdown: string): string[] {
200
- const tags = new Set<string>();
201
-
202
- // 1. Extract from frontmatter
203
- const frontmatter = parseFrontmatter(markdown);
204
- for (const tag of frontmatter.tags) {
205
- const normalized = normalizeTag(tag);
206
- if (validateTag(normalized)) {
207
- tags.add(normalized);
208
- }
209
- }
210
-
211
- // 2. Extract hashtags from body (after stripping frontmatter)
212
- const body = stripFrontmatter(markdown);
213
- const hashtags = extractHashtags(body);
214
- for (const tag of hashtags) {
215
- const normalized = normalizeTag(tag);
216
- if (validateTag(normalized)) {
217
- tags.add(normalized);
218
- }
219
- }
220
-
221
- return [...tags];
222
- }
223
-
224
- interface DocumentMetadata {
225
- typedMetadata?: TypedMetadata;
226
- metadataError?: string;
227
- contentType?: string;
228
- contentTypeSource: ContentTypeSource;
229
- categories?: string[];
230
- author?: string;
231
- frontmatterDate?: string;
232
- dateFields?: Record<string, string>;
233
- }
234
-
235
- const CODE_EXTENSIONS = new Set([
236
- ".c",
237
- ".cc",
238
- ".cpp",
239
- ".cs",
240
- ".go",
241
- ".java",
242
- ".js",
243
- ".jsx",
244
- ".m",
245
- ".mm",
246
- ".php",
247
- ".py",
248
- ".rb",
249
- ".rs",
250
- ".swift",
251
- ".ts",
252
- ".tsx",
253
- ]);
254
-
255
- const AUTHOR_KEYS = ["author", "by", "owner", "creator"] as const;
256
- const DATE_KEYS = [
257
- "date",
258
- "published",
259
- "published_at",
260
- "created",
261
- "created_at",
262
- "updated",
263
- "updated_at",
264
- ] as const;
265
- const DATE_FIELD_KEY_REGEX =
266
- /(^|_)(date|time|created|updated|published|modified|deadline|expires|expiry|start|end)(_|$)/;
267
-
268
- function normalizeMetadataKey(rawKey: string): string {
269
- return rawKey
270
- .trim()
271
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
272
- .toLowerCase()
273
- .replace(/[^a-z0-9]+/g, "_")
274
- .replace(/^_+|_+$/g, "")
275
- .replace(/_+/g, "_");
276
- }
277
-
278
- function normalizeDate(value: unknown): string | undefined {
279
- if (value instanceof Date) {
280
- return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
281
- }
282
- if (typeof value !== "string" && typeof value !== "number") {
283
- return undefined;
284
- }
285
- const normalizedValue =
286
- typeof value === "string"
287
- ? value.trim().replace(/^["'](.*)["']$/, "$1")
288
- : value;
289
- const parsed = new Date(normalizedValue);
290
- if (Number.isNaN(parsed.getTime())) {
291
- return undefined;
292
- }
293
- return parsed.toISOString();
294
- }
295
-
296
- function inferPathContentType(
297
- relPath: string,
298
- ext: string
299
- ): {
300
- contentType: string;
301
- source: ContentTypeSource;
302
- } {
303
- const lowerPath = relPath.toLowerCase();
304
- if (CODE_EXTENSIONS.has(ext.toLowerCase())) {
305
- return { contentType: "code", source: "path-ext" };
306
- }
307
- if (/(meeting|standup|retro|minutes)/.test(lowerPath)) {
308
- return { contentType: "meeting", source: "path-ext" };
309
- }
310
- if (/(spec|rfc|adr|design)/.test(lowerPath)) {
311
- return { contentType: "spec", source: "path-ext" };
312
- }
313
- if (/(notes|journal|log)/.test(lowerPath)) {
314
- return { contentType: "notes", source: "path-ext" };
315
- }
316
- return { contentType: "prose", source: "fallback" };
317
- }
318
-
319
- function normalizeFrontmatterScalar(value: string): string {
320
- const trimmed = value.trim();
321
- if (trimmed.length < 2) {
322
- return trimmed;
323
- }
324
- const first = trimmed[0];
325
- const last = trimmed.at(-1);
326
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
327
- return trimmed.slice(1, -1).trim();
328
- }
329
- return trimmed;
330
- }
331
-
332
- function parseCategories(input: unknown): string[] {
333
- if (Array.isArray(input)) {
334
- return input
335
- .filter((v): v is string => typeof v === "string")
336
- .map((v) => normalizeFrontmatterScalar(v).toLowerCase())
337
- .filter((v) => v.length > 0);
338
- }
339
- if (typeof input === "string") {
340
- return input
341
- .split(",")
342
- .map((v) => normalizeFrontmatterScalar(v).toLowerCase())
343
- .filter((v) => v.length > 0);
344
- }
345
- return [];
346
- }
347
-
348
- export function extractDocumentMetadata(
349
- markdown: string,
350
- relPath: string,
351
- ext: string,
352
- contentTypeRules: NormalizedContentTypeRule[] = []
353
- ): DocumentMetadata {
354
- const parsed = parseFrontmatter(markdown);
355
- const metadata = parsed.metadata;
356
- const rawFrontmatterType =
357
- typeof metadata.type === "string"
358
- ? normalizeFrontmatterScalar(metadata.type)
359
- : "";
360
- const configuredRule = resolveContentTypeRule(
361
- rawFrontmatterType,
362
- relPath,
363
- contentTypeRules
364
- );
365
- const inferred = inferPathContentType(relPath, ext);
366
- const contentType = configuredRule?.rule.id ?? inferred.contentType;
367
- const contentTypeSource: ContentTypeSource =
368
- configuredRule?.source === "configured-id"
369
- ? "frontmatter-type"
370
- : configuredRule?.source === "prefix"
371
- ? "prefix"
372
- : inferred.source;
373
- const categories = new Set<string>([contentType]);
374
-
375
- const fmCategories = parseCategories(
376
- metadata.category ?? metadata.categories ?? metadata.type
377
- );
378
- for (const category of fmCategories) {
379
- categories.add(category);
380
- }
381
-
382
- let author: string | undefined;
383
- for (const key of AUTHOR_KEYS) {
384
- const value = metadata[key];
385
- if (typeof value === "string" && value.trim().length > 0) {
386
- author = value.trim();
387
- break;
388
- }
389
- }
390
-
391
- const normalizedMetadata = new Map<string, unknown>();
392
- for (const [rawKey, value] of Object.entries(metadata)) {
393
- const key = normalizeMetadataKey(rawKey);
394
- if (key.length > 0 && !normalizedMetadata.has(key)) {
395
- normalizedMetadata.set(key, value);
396
- }
397
- }
398
-
399
- let frontmatterDate: string | undefined;
400
- for (const key of DATE_KEYS) {
401
- const normalized = normalizeDate(normalizedMetadata.get(key));
402
- if (normalized) {
403
- frontmatterDate = normalized;
404
- break;
405
- }
406
- }
407
-
408
- const dateFields: Record<string, string> = {};
409
- for (const [key, value] of normalizedMetadata.entries()) {
410
- if (!DATE_FIELD_KEY_REGEX.test(key)) {
411
- continue;
412
- }
413
- const normalized = normalizeDate(value);
414
- if (normalized) {
415
- dateFields[key] = normalized;
416
- }
417
- }
418
-
419
- return {
420
- ...extractTypedMetadata(markdown),
421
- contentType,
422
- contentTypeSource,
423
- categories: [...categories],
424
- author,
425
- frontmatterDate,
426
- dateFields: Object.keys(dateFields).length > 0 ? dateFields : undefined,
427
- };
428
- }
429
-
430
202
  /**
431
203
  * Check if path is a git repository (supports worktrees and submodules).
432
204
  * Uses git rev-parse which handles all git directory layouts.
@@ -587,6 +359,8 @@ export class SyncService {
587
359
  private readonly chunker: ChunkerPort;
588
360
  private readonly mimeDetector: MimeDetector;
589
361
  private readonly pipeline: ConversionPipeline;
362
+ /** Default pipeline and chunker: files can be prepared in the worker. */
363
+ private readonly defaultPreparation: boolean;
590
364
  private readonly sourceReaderFactory: (
591
365
  mode: "any" | "local"
592
366
  ) => SourceContentReaderPort;
@@ -608,6 +382,7 @@ export class SyncService {
608
382
  this.chunker = chunker ?? defaultChunker;
609
383
  this.mimeDetector = mimeDetector ?? getDefaultMimeDetector();
610
384
  this.pipeline = pipeline ?? getDefaultPipeline();
385
+ this.defaultPreparation = pipeline === undefined && chunker === undefined;
611
386
  this.sourceReaderFactory =
612
387
  sourceReaderFactory ?? ((mode) => createSourceContentReader(mode));
613
388
  this.directoryAvailabilityFactory =
@@ -664,6 +439,7 @@ export class SyncService {
664
439
  maxOutputChars:
665
440
  options.limits?.maxOutputChars ?? DEFAULT_LIMITS.maxOutputChars,
666
441
  };
442
+ let slowNotice: ReturnType<typeof setTimeout> | undefined;
667
443
 
668
444
  try {
669
445
  // 1. Re-stat before read to enforce maxBytes on current file size
@@ -820,25 +596,22 @@ export class SyncService {
820
596
  return { relPath: entry.relPath, status: "unchanged" };
821
597
  }
822
598
 
823
- // 6. Convert via pipeline
824
- const convertResult = await this.pipeline.convert({
825
- sourcePath: entry.absPath,
826
- relativePath: entry.relPath,
827
- collection: collection.name,
828
- bytes,
829
- mime: mime.mime,
830
- ext: mime.ext,
831
- limits,
832
- });
833
-
834
- if (!convertResult.ok) {
599
+ // 6. Prepare the file (convert, then everything CPU-bound before the
600
+ // database write) under one per-file time and memory budget. The file
601
+ // processor is stopped mid-step at the budget; in-process preparation
602
+ // (injected test doubles only) checks it between steps.
603
+ const budget = this.fileBudget(options, limits.timeoutMs);
604
+ const recordFailure = async (
605
+ failure: PrepareFailure,
606
+ previousStructure: DocumentStructureSnapshot | null | undefined
607
+ ): Promise<FileSyncResult> => {
835
608
  // Record error (checked)
836
609
  const errorInput: IngestErrorInput = {
837
610
  collection: collection.name,
838
611
  relPath: entry.relPath,
839
- code: convertResult.error.code,
840
- message: convertResult.error.message,
841
- details: convertResult.error.details,
612
+ code: failure.code,
613
+ message: failure.message,
614
+ details: failure.details,
842
615
  };
843
616
  const recordResult = await store.recordError(errorInput);
844
617
  if (!recordResult.ok) {
@@ -847,15 +620,12 @@ export class SyncService {
847
620
 
848
621
  const hadRetrievableEvidence = Boolean(existing?.mirrorHash);
849
622
  const structureDelta = hadRetrievableEvidence
850
- ? diffDocumentStructure(
851
- await this.readPreviousStructure(store, existing),
852
- {
853
- headings: [],
854
- links: [],
855
- typedEdges: [],
856
- dates: {},
857
- }
858
- ).delta
623
+ ? diffDocumentStructure(previousStructure, {
624
+ headings: [],
625
+ links: [],
626
+ typedEdges: [],
627
+ dates: {},
628
+ }).delta
859
629
  : undefined;
860
630
 
861
631
  // Upsert document with error info, explicitly clear mirrorHash. This
@@ -872,8 +642,8 @@ export class SyncService {
872
642
  sourceSize,
873
643
  sourceMtime,
874
644
  sourceCtime,
875
- lastErrorCode: convertResult.error.code,
876
- lastErrorMessage: convertResult.error.message,
645
+ lastErrorCode: failure.code,
646
+ lastErrorMessage: failure.message,
877
647
  ingestVersion: INGEST_VERSION,
878
648
  contentTypeRulesFingerprint,
879
649
  changeJournal: structureDelta ? { structureDelta } : false,
@@ -892,32 +662,66 @@ export class SyncService {
892
662
  return {
893
663
  relPath: entry.relPath,
894
664
  status: "error",
895
- errorCode: convertResult.error.code,
896
- errorMessage: convertResult.error.message,
665
+ errorCode: failure.code,
666
+ errorMessage: failure.message,
897
667
  };
898
- }
668
+ };
899
669
 
900
- const artifact = convertResult.value;
901
- const extractedMetadata = extractDocumentMetadata(
902
- artifact.markdown,
903
- entry.relPath,
904
- mime.ext,
905
- contentTypeRules
906
- );
907
- // Code ranges come from a full Markdown parse: compute them once and
908
- // share them with the change journal and link extraction.
909
- const excludedRanges = getExcludedRanges(artifact.markdown);
910
- const previousStructure = await this.readPreviousStructure(
911
- store,
912
- existing,
913
- { markdown: artifact.markdown, excludedRanges }
914
- );
915
- const nextStructure = extractDocumentStructure(
916
- artifact.markdown,
917
- entry.relPath,
918
- extractedMetadata.dateFields,
919
- excludedRanges
670
+ // No previous revision: null (a new document). A previous revision whose
671
+ // structure was not computed before a stop: undefined (history
672
+ // unavailable), as for a missing mirror.
673
+ let previousStructure: DocumentStructureSnapshot | null | undefined =
674
+ existing ? undefined : null;
675
+ const previous = await this.readPreviousRevision(store, existing);
676
+ const prepared = await this.prepare(
677
+ {
678
+ input: {
679
+ sourcePath: entry.absPath,
680
+ relativePath: entry.relPath,
681
+ collection: collection.name,
682
+ bytes,
683
+ mime: mime.mime,
684
+ ext: mime.ext,
685
+ limits,
686
+ },
687
+ metadataExt: mime.ext,
688
+ contentTypeRules,
689
+ chunkParams: options.chunkingToken?.params ?? DEFAULT_CHUNK_PARAMS,
690
+ collectionLanguageHint: collection.languageHint,
691
+ memoryManaged: collection.memoryManaged === true,
692
+ // The default registry tries the Markdown converter first.
693
+ markdownSource: markdownConverter.canHandle(mime.mime, mime.ext),
694
+ previous,
695
+ },
696
+ budget,
697
+ {
698
+ onPrevious: (structure) => {
699
+ previousStructure = structure;
700
+ },
701
+ // The file's clock and slow-file notice start when processing does.
702
+ onStarted: () => {
703
+ budget.startedAt = performance.now();
704
+ slowNotice = this.scheduleSlowNotice(
705
+ collection,
706
+ entry,
707
+ options,
708
+ budget
709
+ );
710
+ },
711
+ }
920
712
  );
713
+ if (!prepared.ok) {
714
+ return await recordFailure(prepared.error, previousStructure);
715
+ }
716
+ const {
717
+ artifact,
718
+ metadata: extractedMetadata,
719
+ nextStructure,
720
+ chunkInputs,
721
+ linkInputs,
722
+ tags: extractedTags,
723
+ memoryScopes,
724
+ } = prepared.value;
921
725
  const structureDelta = diffDocumentStructure(
922
726
  previousStructure,
923
727
  nextStructure
@@ -969,25 +773,6 @@ export class SyncService {
969
773
  mirrorHash: artifact.mirrorHash,
970
774
  });
971
775
 
972
- // 9. Chunk content
973
- const chunks = this.chunker.chunk(
974
- artifact.markdown,
975
- options.chunkingToken?.params ?? DEFAULT_CHUNK_PARAMS,
976
- artifact.languageHint ?? collection.languageHint,
977
- entry.relPath
978
- );
979
-
980
- // 10. Convert to ChunkInput for store
981
- const chunkInputs: ChunkInput[] = chunks.map((c) => ({
982
- seq: c.seq,
983
- pos: c.pos,
984
- text: c.text,
985
- startLine: c.startLine,
986
- endLine: c.endLine,
987
- language: c.language ?? undefined,
988
- tokenCount: c.tokenCount ?? undefined,
989
- }));
990
-
991
776
  // 11-12. Apply chunks and lexical projection under the policy token.
992
777
  await persistChunkLayout(
993
778
  store,
@@ -998,9 +783,8 @@ export class SyncService {
998
783
  artifact.languageHint ?? collection.languageHint
999
784
  );
1000
785
 
1001
- // 13. Extract and store tags from frontmatter and body hashtags
1002
- // Always call setDocTags to clear removed tags on re-sync
1003
- const extractedTags = extractTags(artifact.markdown);
786
+ // 13. Store tags from frontmatter and body hashtags. Always call
787
+ // setDocTags to clear removed tags on re-sync.
1004
788
  const tagsResult = await store.setDocTags(
1005
789
  docId,
1006
790
  extractedTags,
@@ -1015,8 +799,7 @@ export class SyncService {
1015
799
  // A record that passes the memory validator gets scope rows; a
1016
800
  // malformed file clears them and therefore drops out of managed
1017
801
  // recall while staying searchable.
1018
- if (collection.memoryManaged === true) {
1019
- const memoryScopes = extractMemoryScopes(artifact.markdown);
802
+ if (memoryScopes) {
1020
803
  const scopesResult = await store.setDocMemoryScopes(
1021
804
  docId,
1022
805
  memoryScopes
@@ -1027,55 +810,7 @@ export class SyncService {
1027
810
  });
1028
811
  }
1029
812
 
1030
- // 14. Extract and store links (wiki and markdown links). Only
1031
- // Markdown sources carry links, as in Obsidian: link-shaped text in
1032
- // code, plain text or converted documents stays searchable prose.
1033
- const parsedLinks =
1034
- artifact.meta.converterId === MARKDOWN_CONVERTER_ID
1035
- ? parseLinks(
1036
- artifact.markdown,
1037
- buildLineOffsets(artifact.markdown),
1038
- excludedRanges
1039
- )
1040
- : [];
1041
-
1042
- const linkInputs: DocLinkInput[] = [];
1043
- for (const link of parsedLinks) {
1044
- // Compute target_ref_norm based on link type
1045
- let targetRefNorm: string;
1046
- if (link.kind === "wiki") {
1047
- targetRefNorm = normalizeWikiName(link.targetRef);
1048
- } else {
1049
- // Markdown links with collection prefix are not supported
1050
- // (use wiki links for cross-collection references)
1051
- if (link.targetCollection) {
1052
- continue;
1053
- }
1054
- const resolved = normalizeMarkdownPath(
1055
- link.targetRef,
1056
- entry.relPath
1057
- );
1058
- if (!resolved) {
1059
- // Link escapes collection root - skip silently
1060
- continue;
1061
- }
1062
- targetRefNorm = resolved;
1063
- }
1064
-
1065
- linkInputs.push({
1066
- targetRef: link.targetRef,
1067
- targetRefNorm,
1068
- targetAnchor: link.targetAnchor,
1069
- targetCollection: link.targetCollection,
1070
- linkType: link.kind,
1071
- linkText: link.displayText,
1072
- startLine: link.startLine,
1073
- startCol: link.startCol,
1074
- endLine: link.endLine,
1075
- endCol: link.endCol,
1076
- });
1077
- }
1078
-
813
+ // 14. Store links.
1079
814
  const linksResult = await store.setDocLinks(
1080
815
  docId,
1081
816
  linkInputs,
@@ -1157,6 +892,8 @@ export class SyncService {
1157
892
  errorCode: code,
1158
893
  errorMessage: message,
1159
894
  };
895
+ } finally {
896
+ clearTimeout(slowNotice);
1160
897
  }
1161
898
  }
1162
899
 
@@ -1192,30 +929,100 @@ export class SyncService {
1192
929
  };
1193
930
  }
1194
931
 
1195
- private async readPreviousStructure(
1196
- store: StorePort,
1197
- existing: DocumentRow | null,
1198
- current?: { markdown: string; excludedRanges: ExcludedRange[] }
1199
- ): Promise<ReturnType<typeof extractDocumentStructure> | null | undefined> {
1200
- if (!existing) return null;
1201
- if (!existing.mirrorHash) return undefined;
932
+ /** The per-file budget, from the sync limits and config. */
933
+ private fileBudget(options: SyncOptions, timeoutMs: number): FileBudget {
934
+ return {
935
+ timeoutMs,
936
+ maxMemoryBytes:
937
+ (options.limits?.maxMemoryMb ?? defaultConversionMemoryMb()) *
938
+ BYTES_PER_MB,
939
+ startedAt: performance.now(),
940
+ };
941
+ }
1202
942
 
943
+ /**
944
+ * Report a file still being indexed after `SLOW_CONVERSION_NOTICE_MS` (or
945
+ * half its budget), so an operator can see which file a run is stuck on.
946
+ * Preparation runs in the file worker, so the timer fires even while a
947
+ * step is still running.
948
+ */
949
+ private scheduleSlowNotice(
950
+ collection: Collection,
951
+ entry: WalkEntry,
952
+ options: SyncOptions,
953
+ budget: FileBudget
954
+ ): ReturnType<typeof setTimeout> | undefined {
955
+ const report = options.onSlowConversion;
956
+ if (!report) return undefined;
957
+ return setTimeout(
958
+ () => {
959
+ report({
960
+ collection: collection.name,
961
+ relPath: entry.relPath,
962
+ elapsedMs: Math.round(performance.now() - budget.startedAt),
963
+ budgetMs: budget.timeoutMs,
964
+ });
965
+ },
966
+ Math.min(SLOW_CONVERSION_NOTICE_MS, budget.timeoutMs / 2)
967
+ );
968
+ }
969
+
970
+ /**
971
+ * Prepare one file. With the default pipeline and chunker it runs in the
972
+ * terminable file processor (worker, or a child process for compiled
973
+ * executables), which fails closed if it cannot start. A service built
974
+ * with injected test doubles prepares in-process, checking the budget
975
+ * between steps.
976
+ */
977
+ private prepare(
978
+ request: PrepareFileRequest,
979
+ budget: FileBudget,
980
+ hooks: Required<FileProcessorHooks>
981
+ ): Promise<PrepareOutcome> {
982
+ if (this.defaultPreparation) {
983
+ return prepareInProcessor(request, budget, hooks);
984
+ }
985
+ hooks.onStarted();
986
+ let phase: PreparePhase = "previous revision";
987
+ return prepareFile(request, {
988
+ convert: (input) => this.pipeline.convert(input),
989
+ chunker: this.chunker,
990
+ onPrevious: hooks.onPrevious,
991
+ onPhase: (next) => {
992
+ phase = next;
993
+ },
994
+ check: () => {
995
+ const elapsedMs = performance.now() - budget.startedAt;
996
+ if (elapsedMs > budget.timeoutMs) {
997
+ return timeoutFailure(phase, elapsedMs, budget.timeoutMs);
998
+ }
999
+ const rss = process.memoryUsage.rss();
1000
+ return rss > budget.maxMemoryBytes
1001
+ ? memoryFailure(phase, rss, budget.maxMemoryBytes)
1002
+ : null;
1003
+ },
1004
+ });
1005
+ }
1006
+
1007
+ /** The previously indexed revision's content, when its mirror exists. */
1008
+ private async readPreviousRevision(
1009
+ store: StorePort,
1010
+ existing: DocumentRow | null
1011
+ ): Promise<PreviousRevision | null> {
1012
+ if (!existing?.mirrorHash) return null;
1203
1013
  const content = await store.getContent(existing.mirrorHash);
1204
1014
  if (!content.ok) {
1205
1015
  throw new Error(`Store operation failed: ${content.error.message}`, {
1206
1016
  cause: content.error,
1207
1017
  });
1208
1018
  }
1209
- if (content.value === null) return undefined;
1210
- return extractDocumentStructure(
1211
- content.value,
1212
- existing.relPath,
1213
- existing.dateFields,
1214
- // Unchanged content (a re-ingest) reuses the current parse.
1215
- current !== undefined && content.value === current.markdown
1216
- ? current.excludedRanges
1217
- : getExcludedRanges(content.value)
1218
- );
1019
+ if (content.value === null) return null;
1020
+ return {
1021
+ markdown: content.value,
1022
+ relPath: existing.relPath,
1023
+ dateFields: existing.dateFields ?? null,
1024
+ converterId: existing.converterId,
1025
+ };
1219
1026
  }
1220
1027
 
1221
1028
  /**