@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
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Per-file preparation: everything CPU-bound between a file's bytes and its
3
+ * database write (fn-198).
4
+ *
5
+ * Conversion, metadata, tags, memory scopes, code regions, change-journal
6
+ * structure (previous and next), chunking and link parsing run here as one
7
+ * step. Sync runs it in the terminable file worker, so a file that overruns
8
+ * its budget is stopped mid-step; where no worker can start (a compiled
9
+ * executable, or injected test doubles) it runs in-process and the budget is
10
+ * checked between steps. The main thread keeps every database read and write.
11
+ *
12
+ * @module src/ingestion/prepare-file
13
+ */
14
+
15
+ import type { NormalizedContentTypeRule } from "../config";
16
+ import type {
17
+ ConversionArtifact,
18
+ ConvertInput,
19
+ PipelineResult,
20
+ } from "../converters/types";
21
+ import type { DocumentStructureSnapshot } from "../core/change-diff";
22
+ import type { ChunkInput, DocLinkInput } from "../store/types";
23
+ import type { DocumentMetadata } from "./document-metadata";
24
+ import type { ChunkerPort, ChunkParams } from "./types";
25
+
26
+ import { MARKDOWN_CONVERTER_ID } from "../converters/native/markdown";
27
+ import { extractDocumentStructure } from "../core/change-diff";
28
+ import {
29
+ normalizeMarkdownPath,
30
+ normalizeWikiName,
31
+ parseLinks,
32
+ } from "../core/links";
33
+ import { extractMemoryScopes } from "../core/memory-record";
34
+ import { extractDocumentMetadata, extractTags } from "./document-metadata";
35
+ import { buildLineOffsets } from "./position";
36
+ import { type ExcludedRange, getExcludedRanges } from "./strip";
37
+
38
+ /** The previously indexed revision, for the change journal. */
39
+ export interface PreviousRevision {
40
+ markdown: string;
41
+ relPath: string;
42
+ dateFields: Record<string, string> | null;
43
+ converterId: string | null;
44
+ }
45
+
46
+ export interface PrepareFileRequest {
47
+ input: ConvertInput;
48
+ /** Extension used for path-based content-type inference. */
49
+ metadataExt: string;
50
+ contentTypeRules: NormalizedContentTypeRule[];
51
+ chunkParams: ChunkParams;
52
+ collectionLanguageHint?: string;
53
+ memoryManaged: boolean;
54
+ /**
55
+ * Whether this file is a Markdown source (the Markdown converter handles
56
+ * it). The previous revision's links are read under this rule too, so a
57
+ * non-Markdown source never journals link additions or removals.
58
+ */
59
+ markdownSource: boolean;
60
+ previous: PreviousRevision | null;
61
+ }
62
+
63
+ export interface PreparedFile {
64
+ artifact: ConversionArtifact;
65
+ metadata: DocumentMetadata;
66
+ nextStructure: DocumentStructureSnapshot;
67
+ chunkInputs: ChunkInput[];
68
+ linkInputs: DocLinkInput[];
69
+ tags: string[];
70
+ /** Managed-memory scopes; null outside memory-managed collections. */
71
+ memoryScopes: string[] | null;
72
+ }
73
+
74
+ /** Why a file was not prepared: a conversion error or a budget overrun. */
75
+ export interface PrepareFailure {
76
+ code: string;
77
+ message: string;
78
+ details?: Record<string, unknown>;
79
+ }
80
+
81
+ export type PrepareOutcome =
82
+ | { ok: true; value: PreparedFile }
83
+ | { ok: false; error: PrepareFailure };
84
+
85
+ /** Named steps, in order; reported as progress and in overrun messages. */
86
+ export const PREPARE_PHASES = [
87
+ "previous revision",
88
+ "conversion",
89
+ "metadata extraction",
90
+ "code-region detection",
91
+ "change-journal structure",
92
+ "chunking",
93
+ "link extraction",
94
+ ] as const;
95
+
96
+ export type PreparePhase = (typeof PREPARE_PHASES)[number];
97
+
98
+ export interface PrepareHooks {
99
+ convert: (input: ConvertInput) => Promise<PipelineResult>;
100
+ chunker: ChunkerPort;
101
+ /** Called once the previous revision's structure is known (if any). */
102
+ onPrevious: (structure: DocumentStructureSnapshot) => void;
103
+ /** Called as each step starts. */
104
+ onPhase: (phase: PreparePhase) => void;
105
+ /** In-process budget checkpoint after a step; null to continue. */
106
+ check: (phase: PreparePhase) => PrepareFailure | null;
107
+ }
108
+
109
+ const toLinkInputs = (
110
+ markdown: string,
111
+ relPath: string,
112
+ excludedRanges: ExcludedRange[] | null
113
+ ): DocLinkInput[] => {
114
+ // Only Markdown sources carry links, as in Obsidian: link-shaped text in
115
+ // code, plain text or converted documents stays searchable prose.
116
+ if (!excludedRanges) return [];
117
+ const linkInputs: DocLinkInput[] = [];
118
+ for (const link of parseLinks(
119
+ markdown,
120
+ buildLineOffsets(markdown),
121
+ excludedRanges
122
+ )) {
123
+ let targetRefNorm: string;
124
+ if (link.kind === "wiki") {
125
+ targetRefNorm = normalizeWikiName(link.targetRef);
126
+ } else {
127
+ // Markdown links with a collection prefix are not supported, and a
128
+ // link that escapes the collection root is skipped.
129
+ if (link.targetCollection) continue;
130
+ const resolved = normalizeMarkdownPath(link.targetRef, relPath);
131
+ if (!resolved) continue;
132
+ targetRefNorm = resolved;
133
+ }
134
+ linkInputs.push({
135
+ targetRef: link.targetRef,
136
+ targetRefNorm,
137
+ targetAnchor: link.targetAnchor,
138
+ targetCollection: link.targetCollection,
139
+ linkType: link.kind,
140
+ linkText: link.displayText,
141
+ startLine: link.startLine,
142
+ startCol: link.startCol,
143
+ endLine: link.endLine,
144
+ endCol: link.endCol,
145
+ });
146
+ }
147
+ return linkInputs;
148
+ };
149
+
150
+ /** Prepare one file for persistence; see the module comment. */
151
+ export async function prepareFile(
152
+ request: PrepareFileRequest,
153
+ hooks: PrepareHooks
154
+ ): Promise<PrepareOutcome> {
155
+ const { input, previous } = request;
156
+ const relPath = input.relativePath;
157
+ const step = (phase: PreparePhase): PrepareFailure | null =>
158
+ hooks.check(phase);
159
+
160
+ // The previous revision's structure comes first, so a file stopped later
161
+ // still journals the evidence it had.
162
+ let previousRanges: ExcludedRange[] | null = null;
163
+ if (previous) {
164
+ hooks.onPhase("previous revision");
165
+ previousRanges = request.markdownSource
166
+ ? getExcludedRanges(previous.markdown)
167
+ : null;
168
+ hooks.onPrevious(
169
+ extractDocumentStructure(
170
+ previous.markdown,
171
+ previous.relPath,
172
+ previous.dateFields,
173
+ {
174
+ markdownSource: request.markdownSource,
175
+ excludedRanges: previousRanges ?? undefined,
176
+ }
177
+ )
178
+ );
179
+ const stop = step("previous revision");
180
+ if (stop) return { ok: false, error: stop };
181
+ }
182
+
183
+ hooks.onPhase("conversion");
184
+ const converted = await hooks.convert(input);
185
+ if (!converted.ok) {
186
+ return {
187
+ ok: false,
188
+ error: {
189
+ code: converted.error.code,
190
+ message: converted.error.message,
191
+ details: converted.error.details,
192
+ },
193
+ };
194
+ }
195
+ const artifact = converted.value;
196
+ const markdown = artifact.markdown;
197
+ const stopAfterConversion = step("conversion");
198
+ if (stopAfterConversion) return { ok: false, error: stopAfterConversion };
199
+
200
+ hooks.onPhase("metadata extraction");
201
+ const metadata = extractDocumentMetadata(
202
+ markdown,
203
+ relPath,
204
+ request.metadataExt,
205
+ request.contentTypeRules
206
+ );
207
+ const tags = extractTags(markdown);
208
+ const memoryScopes = request.memoryManaged
209
+ ? extractMemoryScopes(markdown)
210
+ : null;
211
+ const stopAfterMetadata = step("metadata extraction");
212
+ if (stopAfterMetadata) return { ok: false, error: stopAfterMetadata };
213
+
214
+ hooks.onPhase("code-region detection");
215
+ const markdownSource = artifact.meta.converterId === MARKDOWN_CONVERTER_ID;
216
+ // Unchanged content (a re-ingest) reuses the previous revision's parse.
217
+ const excludedRanges = !markdownSource
218
+ ? null
219
+ : previous && previousRanges && previous.markdown === markdown
220
+ ? previousRanges
221
+ : getExcludedRanges(markdown);
222
+ const stopAfterRanges = step("code-region detection");
223
+ if (stopAfterRanges) return { ok: false, error: stopAfterRanges };
224
+
225
+ hooks.onPhase("change-journal structure");
226
+ const nextStructure = extractDocumentStructure(
227
+ markdown,
228
+ relPath,
229
+ metadata.dateFields,
230
+ { markdownSource, excludedRanges: excludedRanges ?? undefined }
231
+ );
232
+ const stopAfterStructure = step("change-journal structure");
233
+ if (stopAfterStructure) return { ok: false, error: stopAfterStructure };
234
+
235
+ hooks.onPhase("chunking");
236
+ const chunkInputs: ChunkInput[] = hooks.chunker
237
+ .chunk(
238
+ markdown,
239
+ request.chunkParams,
240
+ artifact.languageHint ?? request.collectionLanguageHint,
241
+ relPath
242
+ )
243
+ .map((c) => ({
244
+ seq: c.seq,
245
+ pos: c.pos,
246
+ text: c.text,
247
+ startLine: c.startLine,
248
+ endLine: c.endLine,
249
+ language: c.language ?? undefined,
250
+ tokenCount: c.tokenCount ?? undefined,
251
+ }));
252
+ const stopAfterChunking = step("chunking");
253
+ if (stopAfterChunking) return { ok: false, error: stopAfterChunking };
254
+
255
+ hooks.onPhase("link extraction");
256
+ const linkInputs = toLinkInputs(markdown, relPath, excludedRanges);
257
+ const stopAfterLinks = step("link extraction");
258
+ if (stopAfterLinks) return { ok: false, error: stopAfterLinks };
259
+
260
+ return {
261
+ ok: true,
262
+ value: {
263
+ artifact,
264
+ metadata,
265
+ nextStructure,
266
+ chunkInputs,
267
+ linkInputs,
268
+ tags,
269
+ memoryScopes,
270
+ },
271
+ };
272
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Worker entry for per-file preparation (fn-198). See `file-processor.ts`.
3
+ *
4
+ * @module src/ingestion/prepare-worker
5
+ */
6
+
7
+ import type {
8
+ FileWorkerMessage,
9
+ FileWorkerRequest,
10
+ ReadyMessage,
11
+ } from "./file-processor";
12
+
13
+ import { defaultChunker } from "./chunker";
14
+ import { convertForPreparation } from "./prepare-convert";
15
+ import { PREPARE_PHASES, prepareFile } from "./prepare-file";
16
+
17
+ declare const self: Worker;
18
+
19
+ const post = (message: FileWorkerMessage | ReadyMessage): void => {
20
+ self.postMessage(message);
21
+ };
22
+
23
+ self.onmessage = async (event: MessageEvent<FileWorkerRequest>) => {
24
+ // Only the parent that created this worker may drive it; a Bun Worker sees
25
+ // its parent's messages with an empty origin.
26
+ if (event.origin !== "") return;
27
+ const { request, phaseSlot } = event.data;
28
+ const outcome = await prepareFile(request, {
29
+ convert: convertForPreparation,
30
+ chunker: defaultChunker,
31
+ onPrevious: (structure) => post({ type: "previous", structure }),
32
+ // Progress goes through shared memory, not messages: the parent only
33
+ // reads it when it stops a file.
34
+ onPhase: (phase) => {
35
+ Atomics.store(phaseSlot, 0, PREPARE_PHASES.indexOf(phase));
36
+ },
37
+ // The parent enforces the budget and terminates this worker.
38
+ check: () => null,
39
+ });
40
+ post({ type: "done", outcome });
41
+ };
42
+
43
+ post({ type: "ready" });
@@ -320,7 +320,10 @@ const loadPreviousStructure = async (
320
320
  return extractDocumentStructure(
321
321
  content,
322
322
  existing.relPath,
323
- existing.dateFields
323
+ existing.dateFields,
324
+ {
325
+ markdownSource: false,
326
+ }
324
327
  );
325
328
  };
326
329
 
@@ -347,10 +350,12 @@ const persistRecord = async (
347
350
  contentType
348
351
  );
349
352
  const previousStructure = await loadPreviousStructure(input.store, existing);
353
+ // Records are not Markdown sources: they store and journal no links.
350
354
  const nextStructure = extractDocumentStructure(
351
355
  record.markdown,
352
356
  virtualPath,
353
- dateFields
357
+ dateFields,
358
+ { markdownSource: false }
354
359
  );
355
360
  const structureDelta = diffDocumentStructure(
356
361
  previousStructure,
@@ -11,12 +11,26 @@
11
11
  * Returns EXCLUDED RANGES on the original string - does NOT modify content.
12
12
  * This preserves position information for accurate line/column tracking.
13
13
  *
14
+ * Code regions come from the CommonMark + GFM parser. GFM table parsing is
15
+ * quadratic in the cells of one table (a 20,000-row table took minutes), so
16
+ * text with a table over the cell budget is parsed without the table
17
+ * extension: code spans, fences, indented code, blockquotes and lists keep
18
+ * their CommonMark meaning, and only table-cell boundaries are lost.
19
+ *
14
20
  * @module src/ingestion/strip
15
21
  */
16
22
 
17
23
  import { fromMarkdown } from "mdast-util-from-markdown";
18
24
  import { gfmFromMarkdown } from "mdast-util-gfm";
25
+ import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal";
26
+ import { gfmFootnoteFromMarkdown } from "mdast-util-gfm-footnote";
27
+ import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
28
+ import { gfmTaskListItemFromMarkdown } from "mdast-util-gfm-task-list-item";
19
29
  import { gfm } from "micromark-extension-gfm";
30
+ import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal";
31
+ import { gfmFootnote } from "micromark-extension-gfm-footnote";
32
+ import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
33
+ import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item";
20
34
 
21
35
  // ─────────────────────────────────────────────────────────────────────────────
22
36
  // Types
@@ -48,6 +62,14 @@ const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
48
62
  /** HTML comments */
49
63
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
50
64
 
65
+ /**
66
+ * Most `|` characters one blank-line-delimited block may hold before the
67
+ * GFM table extension is left out of the parse. A GFM table only ends at a
68
+ * blank line (or another block), and its parse cost grows with the square
69
+ * of its cells: 5,000 cells take about 0.3 s, 26,000 about 8 s.
70
+ */
71
+ export const MAX_PARSED_TABLE_CELLS = 5000;
72
+
51
73
  /** Backtick (code span or fence) or tilde fence anywhere in the text. */
52
74
  const INLINE_OR_FENCE_TRIGGER_REGEX = /`|~~~/;
53
75
  /** Blockquote markers and indentation before a line's content. */
@@ -87,6 +109,45 @@ const mayContainCode = (markdown: string): boolean => {
87
109
  return false;
88
110
  };
89
111
 
112
+ /**
113
+ * Whether any blank-line-delimited block holds more table-cell pipes than
114
+ * the table budget. One linear pass, stopping as soon as the budget trips.
115
+ */
116
+ const hasOversizedTable = (markdown: string): boolean => {
117
+ let blockPipes = 0;
118
+ let lineHasContent = false;
119
+ for (let index = 0; index < markdown.length; index += 1) {
120
+ const code = markdown.charCodeAt(index);
121
+ if (code === 10 /* \n */) {
122
+ if (!lineHasContent) blockPipes = 0;
123
+ lineHasContent = false;
124
+ } else if (code === 124 /* | */) {
125
+ blockPipes += 1;
126
+ lineHasContent = true;
127
+ if (blockPipes > MAX_PARSED_TABLE_CELLS) return true;
128
+ } else if (code !== 32 && code !== 9 && code !== 13) {
129
+ lineHasContent = true;
130
+ }
131
+ }
132
+ return false;
133
+ };
134
+
135
+ /** GFM without tables: every GFM construct except table rows and cells. */
136
+ const GFM_WITHOUT_TABLES = {
137
+ extensions: [
138
+ gfmAutolinkLiteral(),
139
+ gfmFootnote(),
140
+ gfmStrikethrough(),
141
+ gfmTaskListItem(),
142
+ ],
143
+ mdastExtensions: [
144
+ gfmAutolinkLiteralFromMarkdown(),
145
+ gfmFootnoteFromMarkdown(),
146
+ gfmStrikethroughFromMarkdown(),
147
+ gfmTaskListItemFromMarkdown(),
148
+ ],
149
+ };
150
+
90
151
  /** Opening fence at the start of a code node: backtick or tilde fence. */
91
152
  const FENCE_START_REGEX = /^[\t ]*(?:`{3,}|~{3,})/;
92
153
 
@@ -106,16 +167,17 @@ const collectCodeRanges = (
106
167
  markdown: string,
107
168
  frontmatterEnd: number
108
169
  ): ExcludedRange[] => {
109
- if (!mayContainCode(markdown.slice(frontmatterEnd))) return [];
110
170
  const source =
111
171
  frontmatterEnd > 0
112
172
  ? markdown.slice(0, frontmatterEnd).replace(/[^\r\n]/gu, " ") +
113
173
  markdown.slice(frontmatterEnd)
114
174
  : markdown;
115
- const root = fromMarkdown(source, {
116
- extensions: [gfm()],
117
- mdastExtensions: [gfmFromMarkdown()],
118
- }) as CodeNode;
175
+ const root = fromMarkdown(
176
+ source,
177
+ hasOversizedTable(source)
178
+ ? GFM_WITHOUT_TABLES
179
+ : { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }
180
+ ) as CodeNode;
119
181
  const ranges: ExcludedRange[] = [];
120
182
  const visit = (node: CodeNode): void => {
121
183
  const start = node.position?.start.offset;
@@ -176,9 +238,10 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
176
238
  }
177
239
 
178
240
  // 3. Code spans and fenced or indented code blocks, from the parser.
179
- ranges.push(
180
- ...collectCodeRanges(markdown, frontmatterMatch?.[0].length ?? 0)
181
- );
241
+ const frontmatterEnd = frontmatterMatch?.[0].length ?? 0;
242
+ if (mayContainCode(markdown.slice(frontmatterEnd))) {
243
+ ranges.push(...collectCodeRanges(markdown, frontmatterEnd));
244
+ }
182
245
 
183
246
  // Sort by start position for efficient lookup
184
247
  ranges.sort((a, b) => a.start - b.start);
@@ -20,13 +20,19 @@ export function resolveContentTypeRules(
20
20
 
21
21
  export function withContentTypeRules(
22
22
  options: SyncOptions = {},
23
- config?: Pick<Config, "contentTypes" | "chunking">
23
+ config?: Pick<Config, "contentTypes" | "chunking" | "conversion">
24
24
  ): SyncOptions {
25
25
  const rules = options.contentTypeRules ?? resolveContentTypeRules(config);
26
26
  const chunking = options.chunking ?? config?.chunking;
27
+ const conversion = config?.conversion;
28
+ // Explicit per-call limits win over the configured conversion budget.
29
+ const limits = conversion
30
+ ? { ...conversion, ...options.limits }
31
+ : options.limits;
27
32
  return {
28
33
  ...options,
29
34
  ...(chunking ? { chunking } : {}),
35
+ ...(limits ? { limits } : {}),
30
36
  contentTypeRules: rules,
31
37
  contentTypeRulesFingerprint:
32
38
  options.contentTypeRulesFingerprint ??