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