@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.
- package/README.md +1 -1
- package/assets/skill/SKILL.md +4 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.1.zip → gno-browser-clipper-v2.8.3.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.3.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +9 -1
- package/spec/cli.md +81 -8
- package/spec/mcp.md +5 -6
- package/src/cli/commands/daemon.ts +7 -2
- package/src/cli/commands/doctor.ts +42 -24
- package/src/cli/commands/index-cmd.ts +10 -2
- package/src/cli/commands/links.ts +5 -22
- package/src/cli/commands/shared.ts +26 -1
- package/src/cli/commands/status.ts +60 -0
- package/src/cli/commands/update.ts +6 -1
- package/src/cli/commands/vec.ts +42 -2
- package/src/cli/program.ts +2 -2
- package/src/config/types.ts +19 -0
- package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
- package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
- package/src/converters/adapters/xlsx/adapter.ts +132 -0
- package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
- package/src/converters/native/markdown.ts +3 -1
- package/src/converters/registry.ts +62 -6
- package/src/converters/versions.ts +2 -0
- package/src/core/audit-freshness.ts +1 -1
- package/src/core/audit-links.ts +2 -1
- package/src/core/audit-outside-index.ts +91 -18
- package/src/core/change-diff.ts +18 -6
- package/src/core/link-destination-parse.ts +21 -4
- package/src/core/link-inventory-markdown.ts +23 -3
- package/src/core/link-inventory-opaque.ts +3 -1
- package/src/core/links.ts +74 -16
- package/src/core/network-boundary-inventory.ts +18 -0
- package/src/index.ts +10 -0
- package/src/ingestion/document-metadata.ts +256 -0
- package/src/ingestion/file-child-env.ts +9 -0
- package/src/ingestion/file-child.ts +106 -0
- package/src/ingestion/file-processor.ts +546 -0
- package/src/ingestion/frontmatter.ts +6 -2
- package/src/ingestion/index.ts +1 -0
- package/src/ingestion/prepare-convert.ts +32 -0
- package/src/ingestion/prepare-file.ts +272 -0
- package/src/ingestion/prepare-worker.ts +43 -0
- package/src/ingestion/record-container.ts +7 -2
- package/src/ingestion/strip.ts +149 -133
- package/src/ingestion/sync-options.ts +7 -1
- package/src/ingestion/sync.ts +208 -385
- package/src/ingestion/types.ts +18 -0
- package/src/llm/native-worker/embedding-identity.ts +15 -3
- package/src/mcp/http-transport.ts +11 -0
- package/src/mcp/tools/links.ts +10 -38
- package/src/serve/resident-request.ts +19 -0
- package/src/serve/resident-runtime.ts +84 -8
- package/src/serve/routes/links.ts +6 -24
- package/src/serve/server.ts +3 -2
- package/src/sessions/config-refresh.ts +3 -2
- package/src/store/sqlite/graph-similarity.ts +5 -13
- package/src/store/vector/partition-index.ts +100 -0
- package/src/store/vector/stored-vectors.ts +20 -15
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +0 -1
package/src/ingestion/sync.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 {
|
|
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 =
|
|
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.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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:
|
|
837
|
-
message:
|
|
838
|
-
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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:
|
|
873
|
-
lastErrorMessage:
|
|
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:
|
|
893
|
-
errorMessage:
|
|
665
|
+
errorCode: failure.code,
|
|
666
|
+
errorMessage: failure.message,
|
|
894
667
|
};
|
|
895
|
-
}
|
|
668
|
+
};
|
|
896
669
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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.
|
|
994
|
-
//
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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<
|
|
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
|
|
1198
|
-
return
|
|
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
|
/**
|