@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
@@ -42,6 +42,19 @@ const COMMONMARK_ESCAPABLE = new Set([
42
42
  " ",
43
43
  ]);
44
44
 
45
+ /**
46
+ * True when the character at `index` is backslash-escaped: preceded by an odd
47
+ * number of backslashes. An escaped `[` is literal text in CommonMark and
48
+ * cannot open a link.
49
+ */
50
+ export function isBackslashEscaped(text: string, index: number): boolean {
51
+ let backslashes = 0;
52
+ for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) {
53
+ backslashes += 1;
54
+ }
55
+ return backslashes % 2 === 1;
56
+ }
57
+
45
58
  export interface LinkEncodingStyle {
46
59
  spaces: "percent" | "backslash" | "raw";
47
60
  parens: "percent" | "backslash" | "raw";
@@ -220,10 +233,10 @@ export function parseParenthesizedDestination(
220
233
  i += 1;
221
234
  continue;
222
235
  }
223
- if (
224
- depth === 0 &&
225
- (ch === " " || ch === "\t" || ch === "\n" || ch === "\r")
226
- ) {
236
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
237
+ // CommonMark: a bare destination has no unescaped whitespace, inside
238
+ // nested parentheses too (`(foo(bar baz).md)` is not a link).
239
+ if (depth > 0) return null;
227
240
  destEnd = i;
228
241
  break;
229
242
  }
@@ -262,6 +275,10 @@ export function parseParenthesizedDestination(
262
275
  close = j;
263
276
  }
264
277
 
278
+ // The destination and optional title must end at a real `)`:
279
+ // `[x](<note.md>` or `[x](<note.md>.` is not a link.
280
+ if (markdown[close] !== ")") return null;
281
+
265
282
  const destinationRaw = markdown.slice(destStart, destEnd);
266
283
  if (!destinationRaw) return null;
267
284
  return {
@@ -11,6 +11,7 @@ import { offsetToPosition } from "../ingestion/position";
11
11
  import { rangeIntersectsExcluded } from "../ingestion/strip";
12
12
  import {
13
13
  detectEncodingStyle,
14
+ isBackslashEscaped,
14
15
  parseParenthesizedDestination,
15
16
  splitDestinationPath,
16
17
  stripAngleBracketDestination,
@@ -298,12 +299,31 @@ export function inventoryInlineMarkdown(
298
299
  while (searchFrom < markdown.length) {
299
300
  const labelOpen = markdown.indexOf("[", searchFrom);
300
301
  if (labelOpen < 0) break;
301
- if (labelOpen > 0 && markdown[labelOpen - 1] === "!") {
302
+ if (
303
+ (labelOpen > 0 && markdown[labelOpen - 1] === "!") ||
304
+ isBackslashEscaped(markdown, labelOpen)
305
+ ) {
306
+ searchFrom = labelOpen + 1;
307
+ continue;
308
+ }
309
+ // Match link extraction: escaped brackets are literal label text, and
310
+ // nested brackets must balance (`[see [1]](note.md)` is one link).
311
+ let labelClose = -1;
312
+ let depth = 0;
313
+ for (let i = labelOpen + 1; i < markdown.length; i += 1) {
314
+ const ch = markdown[i];
315
+ if ((ch !== "]" && ch !== "[") || isBackslashEscaped(markdown, i))
316
+ continue;
317
+ if (ch === "[") depth += 1;
318
+ else if (depth === 0) {
319
+ labelClose = i;
320
+ break;
321
+ } else depth -= 1;
322
+ }
323
+ if (labelClose < 0) {
302
324
  searchFrom = labelOpen + 1;
303
325
  continue;
304
326
  }
305
- const labelClose = markdown.indexOf("]", labelOpen + 1);
306
- if (labelClose < 0) break;
307
327
  if (markdown[labelClose + 1] !== "(") {
308
328
  searchFrom = labelOpen + 1;
309
329
  continue;
@@ -81,7 +81,9 @@ export function findExcludedKind(
81
81
  export function codeContextReason(
82
82
  kind: ExcludedRange["kind"] | null
83
83
  ): "code_fence_context" | "inline_code_context" | null {
84
- if (kind === "fenced_code") return "code_fence_context";
84
+ if (kind === "fenced_code" || kind === "indented_code") {
85
+ return "code_fence_context";
86
+ }
85
87
  if (kind === "inline_code") return "inline_code_context";
86
88
  return null;
87
89
  }
package/src/core/links.ts CHANGED
@@ -14,6 +14,12 @@ import type { ExcludedRange } from "../ingestion/strip";
14
14
 
15
15
  import { buildLineOffsets, offsetToPosition } from "../ingestion/position";
16
16
  import { rangeIntersectsExcluded } from "../ingestion/strip";
17
+ import {
18
+ isBackslashEscaped,
19
+ parseParenthesizedDestination,
20
+ stripAngleBracketDestination,
21
+ unescapeCommonMarkDestination,
22
+ } from "./link-destination-parse";
17
23
 
18
24
  // ─────────────────────────────────────────────────────────────────────────────
19
25
  // Types
@@ -85,19 +91,24 @@ const LOGSEQ_EMBED_REGEX =
85
91
  /\{\{\s*embed\s+(\[\[[^\]]+\]\]|\(\([^)]+\)\))\s*\}\}/gi;
86
92
 
87
93
  /**
88
- * Markdown inline link: [text](url)
89
- * Captures: 1=text, 2=url (path and optional anchor)
94
+ * Markdown inline link opener: `[text](`, up to and including the `(`.
95
+ * Captures: 1=text. The destination after `(` is read by
96
+ * parseParenthesizedDestination, as CommonMark reads it.
90
97
  * Negative lookbehind to avoid image links ![]()
91
98
  * Link text may contain balanced square brackets one level deep
92
- * (`[see [1]](note.md)`), as CommonMark allows.
99
+ * (`[see [1]](note.md)`), as CommonMark allows; a backslash-escaped bracket
100
+ * (`\]`, `\[`) in the text is literal and never opens or closes it.
93
101
  *
94
102
  * SCOPE LIMITATIONS:
95
103
  * - Only matches simple inline links [text](url)
96
104
  * - Does NOT match reference-style links [text][ref] or [text]
97
105
  * - Does NOT match autolinks <url> or bare URLs
98
- * - Parens in URLs not supported (use %28 %29 encoding)
99
106
  */
100
- const MARKDOWN_LINK_REGEX = /(?<!!)\[((?:[^[\]]|\[[^[\]]*\])*)\]\(([^)]+)\)/g;
107
+ const MARKDOWN_LINK_REGEX =
108
+ /(?<!!)\[((?:\\[\s\S]|[^[\]\\]|\[(?:\\[\s\S]|[^[\]\\])*\])*)\]\(/g;
109
+
110
+ /** Logseq alias destination at the `(`: `([[Target]])`. */
111
+ const LOGSEQ_ALIAS_DESTINATION_REGEX = /\((\[\[[^)]*\]\])\)/y;
101
112
 
102
113
  /** Square brackets in a destination mean the text was split, not a path. */
103
114
  const BRACKET_IN_DESTINATION_REGEX = /[[\]]/;
@@ -287,6 +298,40 @@ export function parseTargetParts(target: string): TargetParts {
287
298
  // Link Parsing
288
299
  // ─────────────────────────────────────────────────────────────────────────────
289
300
 
301
+ /**
302
+ * Read an inline link destination at the `(` after `[text]`, the way
303
+ * CommonMark reads it: optional title, balanced parentheses, and no
304
+ * unescaped whitespace unless wrapped in `<...>`. Returns the unescaped
305
+ * destination (angle brackets removed) and the offset after the closing `)`,
306
+ * or null when the text is not a link. A Logseq alias `([[Target]])` is
307
+ * returned verbatim.
308
+ */
309
+ function readInlineDestination(
310
+ markdown: string,
311
+ parenOffset: number
312
+ ): { url: string; endOffset: number; logseqAlias: boolean } | null {
313
+ LOGSEQ_ALIAS_DESTINATION_REGEX.lastIndex = parenOffset;
314
+ const logseq = LOGSEQ_ALIAS_DESTINATION_REGEX.exec(markdown);
315
+ if (logseq) {
316
+ return {
317
+ url: logseq[1] ?? "",
318
+ endOffset: parenOffset + logseq[0].length,
319
+ logseqAlias: true,
320
+ };
321
+ }
322
+ const parsed = parseParenthesizedDestination(markdown, parenOffset);
323
+ if (!parsed || parsed.destinationRaw.includes("\n")) {
324
+ return null;
325
+ }
326
+ return {
327
+ url: unescapeCommonMarkDestination(
328
+ stripAngleBracketDestination(parsed.destinationRaw).path
329
+ ),
330
+ endOffset: parsed.closeParenOffset + 1,
331
+ logseqAlias: false,
332
+ };
333
+ }
334
+
290
335
  /**
291
336
  * Parse all links from markdown content.
292
337
  * Skips links inside excluded ranges (code blocks, frontmatter, etc.).
@@ -409,32 +454,45 @@ export function parseLinks(
409
454
 
410
455
  while ((match = MARKDOWN_LINK_REGEX.exec(markdown)) !== null) {
411
456
  const startOffset = match.index;
412
- const endOffset = startOffset + match[0].length;
457
+ const parenOffset = startOffset + match[0].length - 1;
458
+
459
+ // `\[` is literal text in CommonMark: it cannot open a link. A link
460
+ // may still start at a later `[` inside the text.
461
+ if (isBackslashEscaped(markdown, startOffset)) {
462
+ MARKDOWN_LINK_REGEX.lastIndex = startOffset + 1;
463
+ continue;
464
+ }
465
+
466
+ const destination = readInlineDestination(markdown, parenOffset);
467
+ // Not a link: a destination with an unescaped space outside `<...>`
468
+ // (`[x](my note.md)`) or no closing parenthesis. It is plain text; a link
469
+ // may still start at a later `[` inside the text.
470
+ if (!destination) {
471
+ MARKDOWN_LINK_REGEX.lastIndex = startOffset + 1;
472
+ continue;
473
+ }
474
+ const endOffset = destination.endOffset;
475
+ MARKDOWN_LINK_REGEX.lastIndex = endOffset;
413
476
 
414
477
  // Skip if inside excluded range
415
478
  if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) {
416
479
  continue;
417
480
  }
418
481
 
482
+ const raw = markdown.slice(startOffset, endOffset);
419
483
  const linkText = match[1] ?? "";
420
- const url = match[2];
484
+ const { url } = destination;
421
485
  if (!url) continue;
422
486
 
423
487
  // Logseq alias syntax: [Display]([[Target]])
424
- if (url.startsWith("[[") && url.endsWith("]]")) {
488
+ if (destination.logseqAlias) {
425
489
  const innerTarget = url.slice(2, -2).trim();
426
490
  if (innerTarget.length > 0) {
427
491
  const displayText =
428
492
  linkText && linkText !== innerTarget
429
493
  ? truncateText(linkText, MAX_DISPLAY_TEXT_GRAPHEMES)
430
494
  : undefined;
431
- pushWikiLink(
432
- match[0],
433
- innerTarget,
434
- startOffset,
435
- endOffset,
436
- displayText
437
- );
495
+ pushWikiLink(raw, innerTarget, startOffset, endOffset, displayText);
438
496
  }
439
497
  continue;
440
498
  }
@@ -491,7 +549,7 @@ export function parseLinks(
491
549
 
492
550
  links.push({
493
551
  kind: "markdown",
494
- raw: match[0],
552
+ raw,
495
553
  targetRef: parts.ref,
496
554
  targetAnchor: anchor ?? parts.anchor,
497
555
  targetCollection: parts.collection,
@@ -305,6 +305,24 @@ export const NETWORK_BOUNDARY_INVENTORY = [
305
305
  action: null,
306
306
  enforcement: "local_process_only",
307
307
  },
308
+ {
309
+ // `ps` for the file processor child's resident memory (non-Linux Unix).
310
+ id: "file-processor-rss-probe",
311
+ key: "src/ingestion/file-processor.ts::child_process#1",
312
+ path: "src/ingestion/file-processor.ts",
313
+ primitive: "child_process",
314
+ action: null,
315
+ enforcement: "local_process_only",
316
+ },
317
+ {
318
+ // The same executable re-run as the file processor child (compiled builds).
319
+ id: "file-processor-child",
320
+ key: "src/ingestion/file-processor.ts::child_process#2",
321
+ path: "src/ingestion/file-processor.ts",
322
+ primitive: "child_process",
323
+ action: null,
324
+ enforcement: "local_process_only",
325
+ },
308
326
  {
309
327
  id: "http-mcp-tools",
310
328
  key: "logical::http-mcp-tools",
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { runCli } from "./cli/run";
10
+ import { FILE_PROCESSOR_CHILD_ENV } from "./ingestion/file-child-env";
10
11
  import { resetModelManager } from "./llm/nodeLlamaCpp/lifecycle";
11
12
  import { IMPORT_CHILD_ENV } from "./sessions/import-child-env";
12
13
 
@@ -78,6 +79,15 @@ if (process.env[IMPORT_CHILD_ENV] === "1") {
78
79
  await cleanupAndExit(0);
79
80
  }
80
81
 
82
+ // A compiled executable re-run as the file processor child (see
83
+ // src/ingestion/file-processor.ts) prepares files for its parent until the
84
+ // parent disconnects.
85
+ if (process.env[FILE_PROCESSOR_CHILD_ENV] === "1") {
86
+ const { runFileProcessorChild } = await import("./ingestion/file-child");
87
+ await runFileProcessorChild();
88
+ await cleanupAndExit(0);
89
+ }
90
+
81
91
  // Await module completion so pending piped stdin keeps Windows Bun alive.
82
92
  await runCli(process.argv)
83
93
  .then((code) => cleanupAndExit(interruptExitCode || code))
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Document metadata and tag extraction from canonical Markdown.
3
+ *
4
+ * Pure functions shared by sync and the per-file worker (fn-198), kept out
5
+ * of sync.ts so the worker does not load the store.
6
+ *
7
+ * @module src/ingestion/document-metadata
8
+ */
9
+
10
+ import type { NormalizedContentTypeRule } from "../config";
11
+ import type { TypedMetadata } from "../core/typed-metadata";
12
+ import type { ContentTypeSource } from "./types";
13
+
14
+ import { resolveContentTypeRule } from "../config";
15
+ import { normalizeTag, validateTag } from "../core/tags";
16
+ import {
17
+ extractHashtags,
18
+ parseFrontmatter,
19
+ stripFrontmatter,
20
+ } from "./frontmatter";
21
+ import { extractTypedMetadata } from "./typed-metadata";
22
+
23
+ /**
24
+ * Extract tags from markdown content.
25
+ * Combines frontmatter tags and inline hashtags, normalized and validated.
26
+ */
27
+ export function extractTags(markdown: string): string[] {
28
+ const tags = new Set<string>();
29
+
30
+ // 1. Extract from frontmatter
31
+ const frontmatter = parseFrontmatter(markdown);
32
+ for (const tag of frontmatter.tags) {
33
+ const normalized = normalizeTag(tag);
34
+ if (validateTag(normalized)) {
35
+ tags.add(normalized);
36
+ }
37
+ }
38
+
39
+ // 2. Extract hashtags from body (after stripping frontmatter)
40
+ const body = stripFrontmatter(markdown);
41
+ const hashtags = extractHashtags(body);
42
+ for (const tag of hashtags) {
43
+ const normalized = normalizeTag(tag);
44
+ if (validateTag(normalized)) {
45
+ tags.add(normalized);
46
+ }
47
+ }
48
+
49
+ return [...tags];
50
+ }
51
+
52
+ export interface DocumentMetadata {
53
+ typedMetadata?: TypedMetadata;
54
+ metadataError?: string;
55
+ contentType?: string;
56
+ contentTypeSource: ContentTypeSource;
57
+ categories?: string[];
58
+ author?: string;
59
+ frontmatterDate?: string;
60
+ dateFields?: Record<string, string>;
61
+ }
62
+
63
+ const CODE_EXTENSIONS = new Set([
64
+ ".c",
65
+ ".cc",
66
+ ".cpp",
67
+ ".cs",
68
+ ".go",
69
+ ".java",
70
+ ".js",
71
+ ".jsx",
72
+ ".m",
73
+ ".mm",
74
+ ".php",
75
+ ".py",
76
+ ".rb",
77
+ ".rs",
78
+ ".swift",
79
+ ".ts",
80
+ ".tsx",
81
+ ]);
82
+
83
+ const AUTHOR_KEYS = ["author", "by", "owner", "creator"] as const;
84
+ const DATE_KEYS = [
85
+ "date",
86
+ "published",
87
+ "published_at",
88
+ "created",
89
+ "created_at",
90
+ "updated",
91
+ "updated_at",
92
+ ] as const;
93
+ const DATE_FIELD_KEY_REGEX =
94
+ /(^|_)(date|time|created|updated|published|modified|deadline|expires|expiry|start|end)(_|$)/;
95
+
96
+ function normalizeMetadataKey(rawKey: string): string {
97
+ return rawKey
98
+ .trim()
99
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9]+/g, "_")
102
+ .replace(/^_+|_+$/g, "")
103
+ .replace(/_+/g, "_");
104
+ }
105
+
106
+ function normalizeDate(value: unknown): string | undefined {
107
+ if (value instanceof Date) {
108
+ return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
109
+ }
110
+ if (typeof value !== "string" && typeof value !== "number") {
111
+ return undefined;
112
+ }
113
+ const normalizedValue =
114
+ typeof value === "string"
115
+ ? value.trim().replace(/^["'](.*)["']$/, "$1")
116
+ : value;
117
+ const parsed = new Date(normalizedValue);
118
+ if (Number.isNaN(parsed.getTime())) {
119
+ return undefined;
120
+ }
121
+ return parsed.toISOString();
122
+ }
123
+
124
+ function inferPathContentType(
125
+ relPath: string,
126
+ ext: string
127
+ ): {
128
+ contentType: string;
129
+ source: ContentTypeSource;
130
+ } {
131
+ const lowerPath = relPath.toLowerCase();
132
+ if (CODE_EXTENSIONS.has(ext.toLowerCase())) {
133
+ return { contentType: "code", source: "path-ext" };
134
+ }
135
+ if (/(meeting|standup|retro|minutes)/.test(lowerPath)) {
136
+ return { contentType: "meeting", source: "path-ext" };
137
+ }
138
+ if (/(spec|rfc|adr|design)/.test(lowerPath)) {
139
+ return { contentType: "spec", source: "path-ext" };
140
+ }
141
+ if (/(notes|journal|log)/.test(lowerPath)) {
142
+ return { contentType: "notes", source: "path-ext" };
143
+ }
144
+ return { contentType: "prose", source: "fallback" };
145
+ }
146
+
147
+ function normalizeFrontmatterScalar(value: string): string {
148
+ const trimmed = value.trim();
149
+ if (trimmed.length < 2) {
150
+ return trimmed;
151
+ }
152
+ const first = trimmed[0];
153
+ const last = trimmed.at(-1);
154
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
155
+ return trimmed.slice(1, -1).trim();
156
+ }
157
+ return trimmed;
158
+ }
159
+
160
+ function parseCategories(input: unknown): string[] {
161
+ if (Array.isArray(input)) {
162
+ return input
163
+ .filter((v): v is string => typeof v === "string")
164
+ .map((v) => normalizeFrontmatterScalar(v).toLowerCase())
165
+ .filter((v) => v.length > 0);
166
+ }
167
+ if (typeof input === "string") {
168
+ return input
169
+ .split(",")
170
+ .map((v) => normalizeFrontmatterScalar(v).toLowerCase())
171
+ .filter((v) => v.length > 0);
172
+ }
173
+ return [];
174
+ }
175
+
176
+ export function extractDocumentMetadata(
177
+ markdown: string,
178
+ relPath: string,
179
+ ext: string,
180
+ contentTypeRules: NormalizedContentTypeRule[] = []
181
+ ): DocumentMetadata {
182
+ const parsed = parseFrontmatter(markdown);
183
+ const metadata = parsed.metadata;
184
+ const rawFrontmatterType =
185
+ typeof metadata.type === "string"
186
+ ? normalizeFrontmatterScalar(metadata.type)
187
+ : "";
188
+ const configuredRule = resolveContentTypeRule(
189
+ rawFrontmatterType,
190
+ relPath,
191
+ contentTypeRules
192
+ );
193
+ const inferred = inferPathContentType(relPath, ext);
194
+ const contentType = configuredRule?.rule.id ?? inferred.contentType;
195
+ const contentTypeSource: ContentTypeSource =
196
+ configuredRule?.source === "configured-id"
197
+ ? "frontmatter-type"
198
+ : configuredRule?.source === "prefix"
199
+ ? "prefix"
200
+ : inferred.source;
201
+ const categories = new Set<string>([contentType]);
202
+
203
+ const fmCategories = parseCategories(
204
+ metadata.category ?? metadata.categories ?? metadata.type
205
+ );
206
+ for (const category of fmCategories) {
207
+ categories.add(category);
208
+ }
209
+
210
+ let author: string | undefined;
211
+ for (const key of AUTHOR_KEYS) {
212
+ const value = metadata[key];
213
+ if (typeof value === "string" && value.trim().length > 0) {
214
+ author = value.trim();
215
+ break;
216
+ }
217
+ }
218
+
219
+ const normalizedMetadata = new Map<string, unknown>();
220
+ for (const [rawKey, value] of Object.entries(metadata)) {
221
+ const key = normalizeMetadataKey(rawKey);
222
+ if (key.length > 0 && !normalizedMetadata.has(key)) {
223
+ normalizedMetadata.set(key, value);
224
+ }
225
+ }
226
+
227
+ let frontmatterDate: string | undefined;
228
+ for (const key of DATE_KEYS) {
229
+ const normalized = normalizeDate(normalizedMetadata.get(key));
230
+ if (normalized) {
231
+ frontmatterDate = normalized;
232
+ break;
233
+ }
234
+ }
235
+
236
+ const dateFields: Record<string, string> = {};
237
+ for (const [key, value] of normalizedMetadata.entries()) {
238
+ if (!DATE_FIELD_KEY_REGEX.test(key)) {
239
+ continue;
240
+ }
241
+ const normalized = normalizeDate(value);
242
+ if (normalized) {
243
+ dateFields[key] = normalized;
244
+ }
245
+ }
246
+
247
+ return {
248
+ ...extractTypedMetadata(markdown),
249
+ contentType,
250
+ contentTypeSource,
251
+ categories: [...categories],
252
+ author,
253
+ frontmatterDate,
254
+ dateFields: Object.keys(dateFields).length > 0 ? dateFields : undefined,
255
+ };
256
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Environment flag that makes a compiled GNO executable serve as the file
3
+ * processor child instead of the CLI (fn-198). Kept in its own module so the
4
+ * CLI entry can check it without loading the ingestion stack.
5
+ *
6
+ * @module src/ingestion/file-child-env
7
+ */
8
+
9
+ export const FILE_PROCESSOR_CHILD_ENV = "GNO_INTERNAL_FILE_PROCESSOR_CHILD";
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Child-process backend of the file processor (fn-198).
3
+ *
4
+ * Where a TypeScript worker cannot start (a standalone compiled executable),
5
+ * sync prepares each file in a child process instead: the same executable
6
+ * re-run with `FILE_PROCESSOR_CHILD_ENV` set (see src/index.ts), or this
7
+ * module under a source runtime. It serves `prepareFile` requests over IPC,
8
+ * one at a time, until its parent disconnects; the parent kills it when a
9
+ * file overruns its budget, exactly as it terminates the worker, and on
10
+ * every shutdown path it controls.
11
+ *
12
+ * A child busy in a synchronous step cannot notice its parent dying. On
13
+ * Linux it asks the kernel to SIGKILL it when the parent dies
14
+ * (PR_SET_PDEATHSIG, through Bun's built-in FFI), which holds even if the
15
+ * parent is SIGKILLed. Elsewhere it checks between steps that its parent is
16
+ * still there and exits if not; a parent killed with SIGKILL on macOS or
17
+ * Windows can leave the child running until its current step ends.
18
+ *
19
+ * @module src/ingestion/file-child
20
+ */
21
+
22
+ import type {
23
+ FileChildMessage,
24
+ FileWorkerRequest,
25
+ ReadyMessage,
26
+ } from "./file-processor";
27
+
28
+ import { defaultChunker } from "./chunker";
29
+ import { FILE_PROCESSOR_CHILD_ENV } from "./file-child-env";
30
+ import { convertForPreparation } from "./prepare-convert";
31
+ import { prepareFile } from "./prepare-file";
32
+
33
+ const send = (message: FileChildMessage | ReadyMessage): void => {
34
+ process.send?.(message);
35
+ };
36
+
37
+ const PR_SET_PDEATHSIG = 1;
38
+ const SIGKILL = 9;
39
+ const LINUX_LIBC_NAMES = ["libc.so.6", "libc.so"];
40
+
41
+ /** Linux: have the kernel SIGKILL this process when its parent dies. */
42
+ async function killWithParent(): Promise<void> {
43
+ if (process.platform !== "linux") return;
44
+ try {
45
+ // bun:ffi is built into Bun (no native dependency); loaded only here.
46
+ const { dlopen, FFIType } = await import("bun:ffi");
47
+ for (const name of LINUX_LIBC_NAMES) {
48
+ try {
49
+ const libc = dlopen(name, {
50
+ prctl: {
51
+ args: [
52
+ FFIType.i32,
53
+ FFIType.u64,
54
+ FFIType.u64,
55
+ FFIType.u64,
56
+ FFIType.u64,
57
+ ],
58
+ returns: FFIType.i32,
59
+ },
60
+ });
61
+ libc.symbols.prctl(PR_SET_PDEATHSIG, BigInt(SIGKILL), 0n, 0n, 0n);
62
+ libc.close();
63
+ return;
64
+ } catch {
65
+ // Try the next libc name; the between-step check still applies.
66
+ }
67
+ }
68
+ } catch {
69
+ // No FFI in this runtime: the between-step check still applies.
70
+ }
71
+ }
72
+
73
+ /** Serve prepare requests from the parent until it disconnects. */
74
+ export async function runFileProcessorChild(): Promise<void> {
75
+ // Nothing this child starts may re-enter child mode.
76
+ delete process.env[FILE_PROCESSOR_CHILD_ENV];
77
+ const parentPid = process.ppid;
78
+ await killWithParent();
79
+ // The parent may have died before the death signal was armed.
80
+ const parentGone = (): boolean => process.ppid !== parentPid;
81
+ if (parentGone()) process.exit(1);
82
+ return new Promise((resolve) => {
83
+ process.on("message", async (job: Pick<FileWorkerRequest, "request">) => {
84
+ const outcome = await prepareFile(job.request, {
85
+ convert: convertForPreparation,
86
+ chunker: defaultChunker,
87
+ onPrevious: (structure) => send({ type: "previous", structure }),
88
+ onPhase: (phase) => send({ type: "phase", phase }),
89
+ // The parent enforces the budget and kills this process; between
90
+ // steps the child only checks that its parent is still alive.
91
+ check: () => {
92
+ if (parentGone()) process.exit(1);
93
+ return null;
94
+ },
95
+ });
96
+ send({ type: "done", outcome });
97
+ });
98
+ process.on("disconnect", () => resolve());
99
+ send({ type: "ready" });
100
+ });
101
+ }
102
+
103
+ if (import.meta.main) {
104
+ await runFileProcessorChild();
105
+ process.exit(0);
106
+ }