@gmickel/gno 2.8.2 → 2.8.4

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 (66) hide show
  1. package/README.md +1 -1
  2. package/assets/spa-production.json.gz +0 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.4.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.8.4.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +9 -1
  7. package/spec/cli.md +77 -6
  8. package/spec/mcp.md +5 -6
  9. package/spec/output-schemas/doctor.schema.json +10 -4
  10. package/spec/output-schemas/status.schema.json +4 -2
  11. package/src/cli/commands/daemon.ts +7 -2
  12. package/src/cli/commands/doctor-activation.ts +38 -11
  13. package/src/cli/commands/doctor.ts +46 -25
  14. package/src/cli/commands/index-cmd.ts +10 -2
  15. package/src/cli/commands/links.ts +5 -22
  16. package/src/cli/commands/shared.ts +26 -1
  17. package/src/cli/commands/status.ts +76 -4
  18. package/src/cli/commands/update.ts +6 -1
  19. package/src/cli/commands/vec.ts +42 -2
  20. package/src/cli/program.ts +2 -2
  21. package/src/config/types.ts +19 -0
  22. package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
  23. package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
  24. package/src/converters/adapters/xlsx/adapter.ts +132 -0
  25. package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
  26. package/src/converters/registry.ts +62 -6
  27. package/src/converters/versions.ts +2 -0
  28. package/src/core/activation-empty.ts +16 -0
  29. package/src/core/activation-status.ts +23 -5
  30. package/src/core/audit-freshness.ts +1 -1
  31. package/src/core/audit-outside-index.ts +48 -3
  32. package/src/core/change-diff.ts +16 -4
  33. package/src/core/link-destination-parse.ts +21 -4
  34. package/src/core/link-inventory-markdown.ts +23 -3
  35. package/src/core/links.ts +127 -31
  36. package/src/core/network-boundary-inventory.ts +18 -0
  37. package/src/index.ts +10 -0
  38. package/src/ingestion/document-metadata.ts +256 -0
  39. package/src/ingestion/file-child-env.ts +9 -0
  40. package/src/ingestion/file-child.ts +106 -0
  41. package/src/ingestion/file-processor.ts +546 -0
  42. package/src/ingestion/frontmatter.ts +6 -2
  43. package/src/ingestion/index.ts +1 -0
  44. package/src/ingestion/prepare-convert.ts +32 -0
  45. package/src/ingestion/prepare-file.ts +272 -0
  46. package/src/ingestion/prepare-worker.ts +43 -0
  47. package/src/ingestion/record-container.ts +7 -2
  48. package/src/ingestion/strip.ts +136 -31
  49. package/src/ingestion/sync-options.ts +7 -1
  50. package/src/ingestion/sync.ts +207 -400
  51. package/src/ingestion/types.ts +18 -0
  52. package/src/llm/native-worker/embedding-identity.ts +15 -3
  53. package/src/mcp/http-transport.ts +11 -0
  54. package/src/mcp/tools/links.ts +10 -38
  55. package/src/serve/activation-health.ts +25 -7
  56. package/src/serve/public/components/BootstrapStatus.tsx +9 -4
  57. package/src/serve/resident-request.ts +19 -0
  58. package/src/serve/resident-runtime.ts +84 -8
  59. package/src/serve/routes/links.ts +6 -24
  60. package/src/serve/server.ts +3 -2
  61. package/src/serve/status.ts +27 -8
  62. package/src/sessions/config-refresh.ts +3 -2
  63. package/src/store/sqlite/graph-similarity.ts +5 -13
  64. package/src/store/vector/partition-index.ts +100 -0
  65. package/src/store/vector/stored-vectors.ts +20 -15
  66. package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
@@ -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;
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
@@ -74,30 +80,77 @@ const UNSAFE_PERCENT_CODES = new Set(["%2F", "%5C", "%00", "%2f", "%5c"]);
74
80
  // Regex Patterns
75
81
  // ─────────────────────────────────────────────────────────────────────────────
76
82
 
83
+ interface WikiLinkMatch {
84
+ index: number;
85
+ raw: string;
86
+ content: string;
87
+ }
88
+
77
89
  /**
78
- * Wiki link: [[target]] or [[target|alias]] or [[target#anchor]] or [[collection:target]]
79
- * Captures: 1=content inside brackets
90
+ * Wiki links: [[target]], [[target|alias]], [[target#anchor]] or
91
+ * [[collection:target]]. Matches exactly what
92
+ * `/\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g` matches, in one pass: the content runs
93
+ * to the first `]`, which must be doubled; it must not start with `|`, and a
94
+ * `|` in it needs text after it. The regex rescanned to that `]` from every
95
+ * unclosed `[[`, so a note with thousands of them took minutes.
80
96
  */
81
- const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g;
97
+ export function* findWikiLinks(
98
+ markdown: string
99
+ ): Generator<WikiLinkMatch, void, undefined> {
100
+ let nextClose = -2;
101
+ let nextPipe = -2;
102
+ let start = markdown.indexOf("[[");
103
+ while (start !== -1) {
104
+ const contentStart = start + 2;
105
+ if (nextClose < contentStart) {
106
+ nextClose = markdown.indexOf("]", contentStart);
107
+ if (nextClose === -1) return;
108
+ }
109
+ if (nextPipe !== -1 && nextPipe < contentStart) {
110
+ nextPipe = markdown.indexOf("|", contentStart);
111
+ }
112
+ const pipeInContent = nextPipe !== -1 && nextPipe < nextClose;
113
+ const matched =
114
+ markdown.charCodeAt(nextClose + 1) === 93 /* ] */ &&
115
+ nextClose > contentStart &&
116
+ nextPipe !== contentStart &&
117
+ (!pipeInContent || nextClose > nextPipe + 1);
118
+ if (matched) {
119
+ yield {
120
+ index: start,
121
+ raw: markdown.slice(start, nextClose + 2),
122
+ content: markdown.slice(contentStart, nextClose),
123
+ };
124
+ start = markdown.indexOf("[[", nextClose + 2);
125
+ } else {
126
+ start = markdown.indexOf("[[", start + 1);
127
+ }
128
+ }
129
+ }
82
130
 
83
131
  /** Logseq embed: {{embed [[Page]]}} or {{embed ((block-id))}} */
84
132
  const LOGSEQ_EMBED_REGEX =
85
133
  /\{\{\s*embed\s+(\[\[[^\]]+\]\]|\(\([^)]+\)\))\s*\}\}/gi;
86
134
 
87
135
  /**
88
- * Markdown inline link: [text](url)
89
- * Captures: 1=text, 2=url (path and optional anchor)
136
+ * Markdown inline link opener: `[text](`, up to and including the `(`.
137
+ * Captures: 1=text. The destination after `(` is read by
138
+ * parseParenthesizedDestination, as CommonMark reads it.
90
139
  * Negative lookbehind to avoid image links ![]()
91
140
  * Link text may contain balanced square brackets one level deep
92
- * (`[see [1]](note.md)`), as CommonMark allows.
141
+ * (`[see [1]](note.md)`), as CommonMark allows; a backslash-escaped bracket
142
+ * (`\]`, `\[`) in the text is literal and never opens or closes it.
93
143
  *
94
144
  * SCOPE LIMITATIONS:
95
145
  * - Only matches simple inline links [text](url)
96
146
  * - Does NOT match reference-style links [text][ref] or [text]
97
147
  * - Does NOT match autolinks <url> or bare URLs
98
- * - Parens in URLs not supported (use %28 %29 encoding)
99
148
  */
100
- const MARKDOWN_LINK_REGEX = /(?<!!)\[((?:[^[\]]|\[[^[\]]*\])*)\]\(([^)]+)\)/g;
149
+ const MARKDOWN_LINK_REGEX =
150
+ /(?<!!)\[((?:\\[\s\S]|[^[\]\\]|\[(?:\\[\s\S]|[^[\]\\])*\])*)\]\(/g;
151
+
152
+ /** Logseq alias destination at the `(`: `([[Target]])`. */
153
+ const LOGSEQ_ALIAS_DESTINATION_REGEX = /\((\[\[[^)]*\]\])\)/y;
101
154
 
102
155
  /** Square brackets in a destination mean the text was split, not a path. */
103
156
  const BRACKET_IN_DESTINATION_REGEX = /[[\]]/;
@@ -287,6 +340,40 @@ export function parseTargetParts(target: string): TargetParts {
287
340
  // Link Parsing
288
341
  // ─────────────────────────────────────────────────────────────────────────────
289
342
 
343
+ /**
344
+ * Read an inline link destination at the `(` after `[text]`, the way
345
+ * CommonMark reads it: optional title, balanced parentheses, and no
346
+ * unescaped whitespace unless wrapped in `<...>`. Returns the unescaped
347
+ * destination (angle brackets removed) and the offset after the closing `)`,
348
+ * or null when the text is not a link. A Logseq alias `([[Target]])` is
349
+ * returned verbatim.
350
+ */
351
+ function readInlineDestination(
352
+ markdown: string,
353
+ parenOffset: number
354
+ ): { url: string; endOffset: number; logseqAlias: boolean } | null {
355
+ LOGSEQ_ALIAS_DESTINATION_REGEX.lastIndex = parenOffset;
356
+ const logseq = LOGSEQ_ALIAS_DESTINATION_REGEX.exec(markdown);
357
+ if (logseq) {
358
+ return {
359
+ url: logseq[1] ?? "",
360
+ endOffset: parenOffset + logseq[0].length,
361
+ logseqAlias: true,
362
+ };
363
+ }
364
+ const parsed = parseParenthesizedDestination(markdown, parenOffset);
365
+ if (!parsed || parsed.destinationRaw.includes("\n")) {
366
+ return null;
367
+ }
368
+ return {
369
+ url: unescapeCommonMarkDestination(
370
+ stripAngleBracketDestination(parsed.destinationRaw).path
371
+ ),
372
+ endOffset: parsed.closeParenOffset + 1,
373
+ logseqAlias: false,
374
+ };
375
+ }
376
+
290
377
  /**
291
378
  * Parse all links from markdown content.
292
379
  * Skips links inside excluded ranges (code blocks, frontmatter, etc.).
@@ -340,12 +427,9 @@ export function parseLinks(
340
427
  };
341
428
 
342
429
  // Parse wiki links
343
- WIKI_LINK_REGEX.lastIndex = 0;
344
- let match: RegExpExecArray | null;
345
-
346
- while ((match = WIKI_LINK_REGEX.exec(markdown)) !== null) {
347
- const startOffset = match.index;
348
- const endOffset = startOffset + match[0].length;
430
+ for (const wiki of findWikiLinks(markdown)) {
431
+ const startOffset = wiki.index;
432
+ const endOffset = startOffset + wiki.raw.length;
349
433
 
350
434
  // Skip [[target]] nested in Logseq alias syntax: [Display]([[target]])
351
435
  if (
@@ -360,12 +444,10 @@ export function parseLinks(
360
444
  continue;
361
445
  }
362
446
 
363
- const content = match[1];
364
- if (!content) continue;
365
-
366
447
  // Parse [[target|alias]] (and table-escaped [[target\|alias]]) format
367
- const { target: targetPart, alias: aliasText } =
368
- splitWikiLinkContent(content);
448
+ const { target: targetPart, alias: aliasText } = splitWikiLinkContent(
449
+ wiki.content
450
+ );
369
451
  // Only set displayText if different from target
370
452
  const displayText =
371
453
  aliasText !== undefined && aliasText !== targetPart
@@ -376,11 +458,12 @@ export function parseLinks(
376
458
  if (!trimmedTarget) {
377
459
  continue;
378
460
  }
379
- pushWikiLink(match[0], trimmedTarget, startOffset, endOffset, displayText);
461
+ pushWikiLink(wiki.raw, trimmedTarget, startOffset, endOffset, displayText);
380
462
  }
381
463
 
382
464
  // Parse Logseq embeds as links
383
465
  LOGSEQ_EMBED_REGEX.lastIndex = 0;
466
+ let match: RegExpExecArray | null;
384
467
 
385
468
  while ((match = LOGSEQ_EMBED_REGEX.exec(markdown)) !== null) {
386
469
  const startOffset = match.index;
@@ -409,32 +492,45 @@ export function parseLinks(
409
492
 
410
493
  while ((match = MARKDOWN_LINK_REGEX.exec(markdown)) !== null) {
411
494
  const startOffset = match.index;
412
- const endOffset = startOffset + match[0].length;
495
+ const parenOffset = startOffset + match[0].length - 1;
496
+
497
+ // `\[` is literal text in CommonMark: it cannot open a link. A link
498
+ // may still start at a later `[` inside the text.
499
+ if (isBackslashEscaped(markdown, startOffset)) {
500
+ MARKDOWN_LINK_REGEX.lastIndex = startOffset + 1;
501
+ continue;
502
+ }
503
+
504
+ const destination = readInlineDestination(markdown, parenOffset);
505
+ // Not a link: a destination with an unescaped space outside `<...>`
506
+ // (`[x](my note.md)`) or no closing parenthesis. It is plain text; a link
507
+ // may still start at a later `[` inside the text.
508
+ if (!destination) {
509
+ MARKDOWN_LINK_REGEX.lastIndex = startOffset + 1;
510
+ continue;
511
+ }
512
+ const endOffset = destination.endOffset;
513
+ MARKDOWN_LINK_REGEX.lastIndex = endOffset;
413
514
 
414
515
  // Skip if inside excluded range
415
516
  if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) {
416
517
  continue;
417
518
  }
418
519
 
520
+ const raw = markdown.slice(startOffset, endOffset);
419
521
  const linkText = match[1] ?? "";
420
- const url = match[2];
522
+ const { url } = destination;
421
523
  if (!url) continue;
422
524
 
423
525
  // Logseq alias syntax: [Display]([[Target]])
424
- if (url.startsWith("[[") && url.endsWith("]]")) {
526
+ if (destination.logseqAlias) {
425
527
  const innerTarget = url.slice(2, -2).trim();
426
528
  if (innerTarget.length > 0) {
427
529
  const displayText =
428
530
  linkText && linkText !== innerTarget
429
531
  ? truncateText(linkText, MAX_DISPLAY_TEXT_GRAPHEMES)
430
532
  : undefined;
431
- pushWikiLink(
432
- match[0],
433
- innerTarget,
434
- startOffset,
435
- endOffset,
436
- displayText
437
- );
533
+ pushWikiLink(raw, innerTarget, startOffset, endOffset, displayText);
438
534
  }
439
535
  continue;
440
536
  }
@@ -491,7 +587,7 @@ export function parseLinks(
491
587
 
492
588
  links.push({
493
589
  kind: "markdown",
494
- raw: match[0],
590
+ raw,
495
591
  targetRef: parts.ref,
496
592
  targetAnchor: anchor ?? parts.anchor,
497
593
  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";