@gmickel/gno 2.8.1 → 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 (61) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +4 -2
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.1.zip → gno-browser-clipper-v2.8.3.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.8.3.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +9 -1
  7. package/spec/cli.md +81 -8
  8. package/spec/mcp.md +5 -6
  9. package/src/cli/commands/daemon.ts +7 -2
  10. package/src/cli/commands/doctor.ts +42 -24
  11. package/src/cli/commands/index-cmd.ts +10 -2
  12. package/src/cli/commands/links.ts +5 -22
  13. package/src/cli/commands/shared.ts +26 -1
  14. package/src/cli/commands/status.ts +60 -0
  15. package/src/cli/commands/update.ts +6 -1
  16. package/src/cli/commands/vec.ts +42 -2
  17. package/src/cli/program.ts +2 -2
  18. package/src/config/types.ts +19 -0
  19. package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
  20. package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
  21. package/src/converters/adapters/xlsx/adapter.ts +132 -0
  22. package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
  23. package/src/converters/native/markdown.ts +3 -1
  24. package/src/converters/registry.ts +62 -6
  25. package/src/converters/versions.ts +2 -0
  26. package/src/core/audit-freshness.ts +1 -1
  27. package/src/core/audit-links.ts +2 -1
  28. package/src/core/audit-outside-index.ts +91 -18
  29. package/src/core/change-diff.ts +18 -6
  30. package/src/core/link-destination-parse.ts +21 -4
  31. package/src/core/link-inventory-markdown.ts +23 -3
  32. package/src/core/link-inventory-opaque.ts +3 -1
  33. package/src/core/links.ts +74 -16
  34. package/src/core/network-boundary-inventory.ts +18 -0
  35. package/src/index.ts +10 -0
  36. package/src/ingestion/document-metadata.ts +256 -0
  37. package/src/ingestion/file-child-env.ts +9 -0
  38. package/src/ingestion/file-child.ts +106 -0
  39. package/src/ingestion/file-processor.ts +546 -0
  40. package/src/ingestion/frontmatter.ts +6 -2
  41. package/src/ingestion/index.ts +1 -0
  42. package/src/ingestion/prepare-convert.ts +32 -0
  43. package/src/ingestion/prepare-file.ts +272 -0
  44. package/src/ingestion/prepare-worker.ts +43 -0
  45. package/src/ingestion/record-container.ts +7 -2
  46. package/src/ingestion/strip.ts +149 -133
  47. package/src/ingestion/sync-options.ts +7 -1
  48. package/src/ingestion/sync.ts +208 -385
  49. package/src/ingestion/types.ts +18 -0
  50. package/src/llm/native-worker/embedding-identity.ts +15 -3
  51. package/src/mcp/http-transport.ts +11 -0
  52. package/src/mcp/tools/links.ts +10 -38
  53. package/src/serve/resident-request.ts +19 -0
  54. package/src/serve/resident-runtime.ts +84 -8
  55. package/src/serve/routes/links.ts +6 -24
  56. package/src/serve/server.ts +3 -2
  57. package/src/sessions/config-refresh.ts +3 -2
  58. package/src/store/sqlite/graph-similarity.ts +5 -13
  59. package/src/store/vector/partition-index.ts +100 -0
  60. package/src/store/vector/stored-vectors.ts +20 -15
  61. package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.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,13 +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";
35
+ import { markdownConverter } from "../converters/native/markdown";
43
36
  import {
44
37
  type ConversionPipeline,
45
38
  getDefaultPipeline,
@@ -47,29 +40,33 @@ import {
47
40
  import { DEFAULT_LIMITS, type RecordAdapter } from "../converters/types";
48
41
  import {
49
42
  diffDocumentStructure,
50
- extractDocumentStructure,
43
+ type DocumentStructureSnapshot,
51
44
  } from "../core/change-diff";
52
45
  import { enforceCollectionEgress } from "../core/egress-enforcement";
53
- import {
54
- normalizeMarkdownPath,
55
- normalizeWikiName,
56
- parseLinks,
57
- } from "../core/links";
58
- import { extractMemoryScopes } from "../core/memory-record";
59
- import { normalizeTag, validateTag } from "../core/tags";
60
46
  import { defaultChunker } from "./chunker";
61
47
  import { persistChunkLayout, prepareChunking } from "./chunking";
62
48
  import {
63
49
  isCompiledContextPath,
64
50
  isCompiledContextContent,
65
51
  } from "./compiled-context";
52
+ import { extractDocumentMetadata } from "./document-metadata";
66
53
  import {
67
- extractHashtags,
68
- parseFrontmatter,
69
- stripFrontmatter,
70
- } from "./frontmatter";
54
+ defaultConversionMemoryMb,
55
+ type FileBudgetLimits,
56
+ type FileProcessorHooks,
57
+ memoryFailure,
58
+ prepareInProcessor,
59
+ timeoutFailure,
60
+ } from "./file-processor";
71
61
  import { projectGraph } from "./graph-reconciliation";
72
- 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";
73
70
  import { processRecordContainer } from "./record-container";
74
71
  import {
75
72
  createDirectoryAvailability,
@@ -84,11 +81,11 @@ import {
84
81
  type SourceContentReaderPort,
85
82
  type SourceReadFailure,
86
83
  } from "./source-availability";
87
- import { getExcludedRanges } from "./strip";
88
- import { extractTypedMetadata } from "./typed-metadata";
89
84
  import { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
90
85
  import { defaultWalker } from "./walker";
91
86
 
87
+ export { extractDocumentMetadata } from "./document-metadata";
88
+
92
89
  /** Default concurrency for file processing */
93
90
  const DEFAULT_CONCURRENCY = 1;
94
91
 
@@ -98,6 +95,14 @@ const TX_BATCH_SIZE = 50;
98
95
  /** Max concurrency to prevent resource exhaustion */
99
96
  const MAX_CONCURRENCY = 16;
100
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
+
101
106
  /**
102
107
  * Current ingest schema version.
103
108
  * Increment when ingestion adds new derived data (tags, metadata, etc.)
@@ -105,8 +110,13 @@ const MAX_CONCURRENCY = 16;
105
110
  * will be re-processed. Must stay >= TYPED_METADATA_INGEST_VERSION (tested).
106
111
  * 8: wiki links with a table-escaped alias (`[[Note\|Alias]]`) and Markdown
107
112
  * link text with square brackets parse the way Obsidian renders them.
113
+ * 9: links are extracted from Markdown sources only, and never from code
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.
108
118
  */
109
- export const INGEST_VERSION = 8;
119
+ export const INGEST_VERSION = 10;
110
120
  const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT =
111
121
  fingerprintContentTypeMetadataRules([]);
112
122
  const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
@@ -189,241 +199,6 @@ function decideAction(
189
199
  return { kind: "skip", reason: "unchanged" };
190
200
  }
191
201
 
192
- /**
193
- * Extract tags from markdown content.
194
- * Combines frontmatter tags and inline hashtags, normalized and validated.
195
- */
196
- function extractTags(markdown: string): string[] {
197
- const tags = new Set<string>();
198
-
199
- // 1. Extract from frontmatter
200
- const frontmatter = parseFrontmatter(markdown);
201
- for (const tag of frontmatter.tags) {
202
- const normalized = normalizeTag(tag);
203
- if (validateTag(normalized)) {
204
- tags.add(normalized);
205
- }
206
- }
207
-
208
- // 2. Extract hashtags from body (after stripping frontmatter)
209
- const body = stripFrontmatter(markdown);
210
- const hashtags = extractHashtags(body);
211
- for (const tag of hashtags) {
212
- const normalized = normalizeTag(tag);
213
- if (validateTag(normalized)) {
214
- tags.add(normalized);
215
- }
216
- }
217
-
218
- return [...tags];
219
- }
220
-
221
- interface DocumentMetadata {
222
- typedMetadata?: TypedMetadata;
223
- metadataError?: string;
224
- contentType?: string;
225
- contentTypeSource: ContentTypeSource;
226
- categories?: string[];
227
- author?: string;
228
- frontmatterDate?: string;
229
- dateFields?: Record<string, string>;
230
- }
231
-
232
- const CODE_EXTENSIONS = new Set([
233
- ".c",
234
- ".cc",
235
- ".cpp",
236
- ".cs",
237
- ".go",
238
- ".java",
239
- ".js",
240
- ".jsx",
241
- ".m",
242
- ".mm",
243
- ".php",
244
- ".py",
245
- ".rb",
246
- ".rs",
247
- ".swift",
248
- ".ts",
249
- ".tsx",
250
- ]);
251
-
252
- const AUTHOR_KEYS = ["author", "by", "owner", "creator"] as const;
253
- const DATE_KEYS = [
254
- "date",
255
- "published",
256
- "published_at",
257
- "created",
258
- "created_at",
259
- "updated",
260
- "updated_at",
261
- ] as const;
262
- const DATE_FIELD_KEY_REGEX =
263
- /(^|_)(date|time|created|updated|published|modified|deadline|expires|expiry|start|end)(_|$)/;
264
-
265
- function normalizeMetadataKey(rawKey: string): string {
266
- return rawKey
267
- .trim()
268
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
269
- .toLowerCase()
270
- .replace(/[^a-z0-9]+/g, "_")
271
- .replace(/^_+|_+$/g, "")
272
- .replace(/_+/g, "_");
273
- }
274
-
275
- function normalizeDate(value: unknown): string | undefined {
276
- if (value instanceof Date) {
277
- return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
278
- }
279
- if (typeof value !== "string" && typeof value !== "number") {
280
- return undefined;
281
- }
282
- const normalizedValue =
283
- typeof value === "string"
284
- ? value.trim().replace(/^["'](.*)["']$/, "$1")
285
- : value;
286
- const parsed = new Date(normalizedValue);
287
- if (Number.isNaN(parsed.getTime())) {
288
- return undefined;
289
- }
290
- return parsed.toISOString();
291
- }
292
-
293
- function inferPathContentType(
294
- relPath: string,
295
- ext: string
296
- ): {
297
- contentType: string;
298
- source: ContentTypeSource;
299
- } {
300
- const lowerPath = relPath.toLowerCase();
301
- if (CODE_EXTENSIONS.has(ext.toLowerCase())) {
302
- return { contentType: "code", source: "path-ext" };
303
- }
304
- if (/(meeting|standup|retro|minutes)/.test(lowerPath)) {
305
- return { contentType: "meeting", source: "path-ext" };
306
- }
307
- if (/(spec|rfc|adr|design)/.test(lowerPath)) {
308
- return { contentType: "spec", source: "path-ext" };
309
- }
310
- if (/(notes|journal|log)/.test(lowerPath)) {
311
- return { contentType: "notes", source: "path-ext" };
312
- }
313
- return { contentType: "prose", source: "fallback" };
314
- }
315
-
316
- function normalizeFrontmatterScalar(value: string): string {
317
- const trimmed = value.trim();
318
- if (trimmed.length < 2) {
319
- return trimmed;
320
- }
321
- const first = trimmed[0];
322
- const last = trimmed.at(-1);
323
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
324
- return trimmed.slice(1, -1).trim();
325
- }
326
- return trimmed;
327
- }
328
-
329
- function parseCategories(input: unknown): string[] {
330
- if (Array.isArray(input)) {
331
- return input
332
- .filter((v): v is string => typeof v === "string")
333
- .map((v) => normalizeFrontmatterScalar(v).toLowerCase())
334
- .filter((v) => v.length > 0);
335
- }
336
- if (typeof input === "string") {
337
- return input
338
- .split(",")
339
- .map((v) => normalizeFrontmatterScalar(v).toLowerCase())
340
- .filter((v) => v.length > 0);
341
- }
342
- return [];
343
- }
344
-
345
- export function extractDocumentMetadata(
346
- markdown: string,
347
- relPath: string,
348
- ext: string,
349
- contentTypeRules: NormalizedContentTypeRule[] = []
350
- ): DocumentMetadata {
351
- const parsed = parseFrontmatter(markdown);
352
- const metadata = parsed.metadata;
353
- const rawFrontmatterType =
354
- typeof metadata.type === "string"
355
- ? normalizeFrontmatterScalar(metadata.type)
356
- : "";
357
- const configuredRule = resolveContentTypeRule(
358
- rawFrontmatterType,
359
- relPath,
360
- contentTypeRules
361
- );
362
- const inferred = inferPathContentType(relPath, ext);
363
- const contentType = configuredRule?.rule.id ?? inferred.contentType;
364
- const contentTypeSource: ContentTypeSource =
365
- configuredRule?.source === "configured-id"
366
- ? "frontmatter-type"
367
- : configuredRule?.source === "prefix"
368
- ? "prefix"
369
- : inferred.source;
370
- const categories = new Set<string>([contentType]);
371
-
372
- const fmCategories = parseCategories(
373
- metadata.category ?? metadata.categories ?? metadata.type
374
- );
375
- for (const category of fmCategories) {
376
- categories.add(category);
377
- }
378
-
379
- let author: string | undefined;
380
- for (const key of AUTHOR_KEYS) {
381
- const value = metadata[key];
382
- if (typeof value === "string" && value.trim().length > 0) {
383
- author = value.trim();
384
- break;
385
- }
386
- }
387
-
388
- const normalizedMetadata = new Map<string, unknown>();
389
- for (const [rawKey, value] of Object.entries(metadata)) {
390
- const key = normalizeMetadataKey(rawKey);
391
- if (key.length > 0 && !normalizedMetadata.has(key)) {
392
- normalizedMetadata.set(key, value);
393
- }
394
- }
395
-
396
- let frontmatterDate: string | undefined;
397
- for (const key of DATE_KEYS) {
398
- const normalized = normalizeDate(normalizedMetadata.get(key));
399
- if (normalized) {
400
- frontmatterDate = normalized;
401
- break;
402
- }
403
- }
404
-
405
- const dateFields: Record<string, string> = {};
406
- for (const [key, value] of normalizedMetadata.entries()) {
407
- if (!DATE_FIELD_KEY_REGEX.test(key)) {
408
- continue;
409
- }
410
- const normalized = normalizeDate(value);
411
- if (normalized) {
412
- dateFields[key] = normalized;
413
- }
414
- }
415
-
416
- return {
417
- ...extractTypedMetadata(markdown),
418
- contentType,
419
- contentTypeSource,
420
- categories: [...categories],
421
- author,
422
- frontmatterDate,
423
- dateFields: Object.keys(dateFields).length > 0 ? dateFields : undefined,
424
- };
425
- }
426
-
427
202
  /**
428
203
  * Check if path is a git repository (supports worktrees and submodules).
429
204
  * Uses git rev-parse which handles all git directory layouts.
@@ -584,6 +359,8 @@ export class SyncService {
584
359
  private readonly chunker: ChunkerPort;
585
360
  private readonly mimeDetector: MimeDetector;
586
361
  private readonly pipeline: ConversionPipeline;
362
+ /** Default pipeline and chunker: files can be prepared in the worker. */
363
+ private readonly defaultPreparation: boolean;
587
364
  private readonly sourceReaderFactory: (
588
365
  mode: "any" | "local"
589
366
  ) => SourceContentReaderPort;
@@ -605,6 +382,7 @@ export class SyncService {
605
382
  this.chunker = chunker ?? defaultChunker;
606
383
  this.mimeDetector = mimeDetector ?? getDefaultMimeDetector();
607
384
  this.pipeline = pipeline ?? getDefaultPipeline();
385
+ this.defaultPreparation = pipeline === undefined && chunker === undefined;
608
386
  this.sourceReaderFactory =
609
387
  sourceReaderFactory ?? ((mode) => createSourceContentReader(mode));
610
388
  this.directoryAvailabilityFactory =
@@ -661,6 +439,7 @@ export class SyncService {
661
439
  maxOutputChars:
662
440
  options.limits?.maxOutputChars ?? DEFAULT_LIMITS.maxOutputChars,
663
441
  };
442
+ let slowNotice: ReturnType<typeof setTimeout> | undefined;
664
443
 
665
444
  try {
666
445
  // 1. Re-stat before read to enforce maxBytes on current file size
@@ -817,25 +596,22 @@ export class SyncService {
817
596
  return { relPath: entry.relPath, status: "unchanged" };
818
597
  }
819
598
 
820
- // 6. Convert via pipeline
821
- const convertResult = await this.pipeline.convert({
822
- sourcePath: entry.absPath,
823
- relativePath: entry.relPath,
824
- collection: collection.name,
825
- bytes,
826
- mime: mime.mime,
827
- ext: mime.ext,
828
- limits,
829
- });
830
-
831
- 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> => {
832
608
  // Record error (checked)
833
609
  const errorInput: IngestErrorInput = {
834
610
  collection: collection.name,
835
611
  relPath: entry.relPath,
836
- code: convertResult.error.code,
837
- message: convertResult.error.message,
838
- details: convertResult.error.details,
612
+ code: failure.code,
613
+ message: failure.message,
614
+ details: failure.details,
839
615
  };
840
616
  const recordResult = await store.recordError(errorInput);
841
617
  if (!recordResult.ok) {
@@ -844,15 +620,12 @@ export class SyncService {
844
620
 
845
621
  const hadRetrievableEvidence = Boolean(existing?.mirrorHash);
846
622
  const structureDelta = hadRetrievableEvidence
847
- ? diffDocumentStructure(
848
- await this.readPreviousStructure(store, existing),
849
- {
850
- headings: [],
851
- links: [],
852
- typedEdges: [],
853
- dates: {},
854
- }
855
- ).delta
623
+ ? diffDocumentStructure(previousStructure, {
624
+ headings: [],
625
+ links: [],
626
+ typedEdges: [],
627
+ dates: {},
628
+ }).delta
856
629
  : undefined;
857
630
 
858
631
  // Upsert document with error info, explicitly clear mirrorHash. This
@@ -869,8 +642,8 @@ export class SyncService {
869
642
  sourceSize,
870
643
  sourceMtime,
871
644
  sourceCtime,
872
- lastErrorCode: convertResult.error.code,
873
- lastErrorMessage: convertResult.error.message,
645
+ lastErrorCode: failure.code,
646
+ lastErrorMessage: failure.message,
874
647
  ingestVersion: INGEST_VERSION,
875
648
  contentTypeRulesFingerprint,
876
649
  changeJournal: structureDelta ? { structureDelta } : false,
@@ -889,27 +662,66 @@ export class SyncService {
889
662
  return {
890
663
  relPath: entry.relPath,
891
664
  status: "error",
892
- errorCode: convertResult.error.code,
893
- errorMessage: convertResult.error.message,
665
+ errorCode: failure.code,
666
+ errorMessage: failure.message,
894
667
  };
895
- }
668
+ };
896
669
 
897
- const artifact = convertResult.value;
898
- const extractedMetadata = extractDocumentMetadata(
899
- artifact.markdown,
900
- entry.relPath,
901
- mime.ext,
902
- contentTypeRules
903
- );
904
- const previousStructure = await this.readPreviousStructure(
905
- store,
906
- existing
907
- );
908
- const nextStructure = extractDocumentStructure(
909
- artifact.markdown,
910
- entry.relPath,
911
- extractedMetadata.dateFields
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
+ }
912
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;
913
725
  const structureDelta = diffDocumentStructure(
914
726
  previousStructure,
915
727
  nextStructure
@@ -961,25 +773,6 @@ export class SyncService {
961
773
  mirrorHash: artifact.mirrorHash,
962
774
  });
963
775
 
964
- // 9. Chunk content
965
- const chunks = this.chunker.chunk(
966
- artifact.markdown,
967
- options.chunkingToken?.params ?? DEFAULT_CHUNK_PARAMS,
968
- artifact.languageHint ?? collection.languageHint,
969
- entry.relPath
970
- );
971
-
972
- // 10. Convert to ChunkInput for store
973
- const chunkInputs: ChunkInput[] = chunks.map((c) => ({
974
- seq: c.seq,
975
- pos: c.pos,
976
- text: c.text,
977
- startLine: c.startLine,
978
- endLine: c.endLine,
979
- language: c.language ?? undefined,
980
- tokenCount: c.tokenCount ?? undefined,
981
- }));
982
-
983
776
  // 11-12. Apply chunks and lexical projection under the policy token.
984
777
  await persistChunkLayout(
985
778
  store,
@@ -990,9 +783,8 @@ export class SyncService {
990
783
  artifact.languageHint ?? collection.languageHint
991
784
  );
992
785
 
993
- // 13. Extract and store tags from frontmatter and body hashtags
994
- // Always call setDocTags to clear removed tags on re-sync
995
- 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.
996
788
  const tagsResult = await store.setDocTags(
997
789
  docId,
998
790
  extractedTags,
@@ -1007,8 +799,7 @@ export class SyncService {
1007
799
  // A record that passes the memory validator gets scope rows; a
1008
800
  // malformed file clears them and therefore drops out of managed
1009
801
  // recall while staying searchable.
1010
- if (collection.memoryManaged === true) {
1011
- const memoryScopes = extractMemoryScopes(artifact.markdown);
802
+ if (memoryScopes) {
1012
803
  const scopesResult = await store.setDocMemoryScopes(
1013
804
  docId,
1014
805
  memoryScopes
@@ -1019,52 +810,7 @@ export class SyncService {
1019
810
  });
1020
811
  }
1021
812
 
1022
- // 14. Extract and store links (wiki and markdown links)
1023
- const excludedRanges = getExcludedRanges(artifact.markdown);
1024
- const lineOffsets = buildLineOffsets(artifact.markdown);
1025
- const parsedLinks = parseLinks(
1026
- artifact.markdown,
1027
- lineOffsets,
1028
- excludedRanges
1029
- );
1030
-
1031
- const linkInputs: DocLinkInput[] = [];
1032
- for (const link of parsedLinks) {
1033
- // Compute target_ref_norm based on link type
1034
- let targetRefNorm: string;
1035
- if (link.kind === "wiki") {
1036
- targetRefNorm = normalizeWikiName(link.targetRef);
1037
- } else {
1038
- // Markdown links with collection prefix are not supported
1039
- // (use wiki links for cross-collection references)
1040
- if (link.targetCollection) {
1041
- continue;
1042
- }
1043
- const resolved = normalizeMarkdownPath(
1044
- link.targetRef,
1045
- entry.relPath
1046
- );
1047
- if (!resolved) {
1048
- // Link escapes collection root - skip silently
1049
- continue;
1050
- }
1051
- targetRefNorm = resolved;
1052
- }
1053
-
1054
- linkInputs.push({
1055
- targetRef: link.targetRef,
1056
- targetRefNorm,
1057
- targetAnchor: link.targetAnchor,
1058
- targetCollection: link.targetCollection,
1059
- linkType: link.kind,
1060
- linkText: link.displayText,
1061
- startLine: link.startLine,
1062
- startCol: link.startCol,
1063
- endLine: link.endLine,
1064
- endCol: link.endCol,
1065
- });
1066
- }
1067
-
813
+ // 14. Store links.
1068
814
  const linksResult = await store.setDocLinks(
1069
815
  docId,
1070
816
  linkInputs,
@@ -1146,6 +892,8 @@ export class SyncService {
1146
892
  errorCode: code,
1147
893
  errorMessage: message,
1148
894
  };
895
+ } finally {
896
+ clearTimeout(slowNotice);
1149
897
  }
1150
898
  }
1151
899
 
@@ -1181,25 +929,100 @@ export class SyncService {
1181
929
  };
1182
930
  }
1183
931
 
1184
- private async readPreviousStructure(
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
+ }
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(
1185
1009
  store: StorePort,
1186
1010
  existing: DocumentRow | null
1187
- ): Promise<ReturnType<typeof extractDocumentStructure> | null | undefined> {
1188
- if (!existing) return null;
1189
- if (!existing.mirrorHash) return undefined;
1190
-
1011
+ ): Promise<PreviousRevision | null> {
1012
+ if (!existing?.mirrorHash) return null;
1191
1013
  const content = await store.getContent(existing.mirrorHash);
1192
1014
  if (!content.ok) {
1193
1015
  throw new Error(`Store operation failed: ${content.error.message}`, {
1194
1016
  cause: content.error,
1195
1017
  });
1196
1018
  }
1197
- if (content.value === null) return undefined;
1198
- return extractDocumentStructure(
1199
- content.value,
1200
- existing.relPath,
1201
- existing.dateFields
1202
- );
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
+ };
1203
1026
  }
1204
1027
 
1205
1028
  /**