@hraness/kb 0.17.1
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/DISCLOSURE +43 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/agent-context.js +35 -0
- package/dist/agent-guide-audit.js +18 -0
- package/dist/attachments.js +19 -0
- package/dist/authoring.js +33 -0
- package/dist/benchmark.js +19 -0
- package/dist/capture.js +49 -0
- package/dist/cli.js +3646 -0
- package/dist/clip/acquire.js +57 -0
- package/dist/clip/args.js +21 -0
- package/dist/clip/bounded-byte-buffer.js +9 -0
- package/dist/clip/browser-profiles.js +17 -0
- package/dist/clip/bundle-reader.js +11 -0
- package/dist/clip/cli.js +27 -0
- package/dist/clip/cookies.js +21 -0
- package/dist/clip/defuddle-worker.js +84 -0
- package/dist/clip/doctor.js +32 -0
- package/dist/clip/jobs.js +784 -0
- package/dist/clip/network-proxy.js +9 -0
- package/dist/clip/network.js +28 -0
- package/dist/clip/persist.js +30 -0
- package/dist/clip/refresh.js +11 -0
- package/dist/clip/terminal.js +11 -0
- package/dist/evaluation-builder.js +12238 -0
- package/dist/evaluation-kb.js +26 -0
- package/dist/evaluation.js +35 -0
- package/dist/git.js +39 -0
- package/dist/graph.js +39 -0
- package/dist/index-01jj6rbv.js +1103 -0
- package/dist/index-06c9ctr6.js +738 -0
- package/dist/index-0kavxzqj.js +2480 -0
- package/dist/index-1gwbassd.js +743 -0
- package/dist/index-1n418kb9.js +827 -0
- package/dist/index-1vrd1rmn.js +56 -0
- package/dist/index-1xxnjn0d.js +171 -0
- package/dist/index-3rm7cz6h.js +373 -0
- package/dist/index-3v2z4f0q.js +328 -0
- package/dist/index-48pz4jpc.js +392 -0
- package/dist/index-4j3tt0c3.js +234 -0
- package/dist/index-5n05se68.js +307 -0
- package/dist/index-5vwpzb5a.js +764 -0
- package/dist/index-6g2pv9d2.js +370 -0
- package/dist/index-7fzc32gf.js +712 -0
- package/dist/index-84x0vjjp.js +432 -0
- package/dist/index-adx6khj5.js +488 -0
- package/dist/index-b88v3vtm.js +805 -0
- package/dist/index-cv6fh7z5.js +462 -0
- package/dist/index-cxfrakt7.js +1067 -0
- package/dist/index-d13v9ckt.js +150 -0
- package/dist/index-dycjbhw2.js +1943 -0
- package/dist/index-dyqwejk5.js +531 -0
- package/dist/index-e5fbsywq.js +658 -0
- package/dist/index-f984hw45.js +927 -0
- package/dist/index-gh719d91.js +63 -0
- package/dist/index-hgve9rh2.js +2046 -0
- package/dist/index-hya40gb2.js +474 -0
- package/dist/index-j4zgmzjr.js +152 -0
- package/dist/index-jsmvyyvf.js +1166 -0
- package/dist/index-kh0pwvdd.js +1974 -0
- package/dist/index-mqx4nd6v.js +119 -0
- package/dist/index-mxxxytys.js +1254 -0
- package/dist/index-n5dd7r0v.js +711 -0
- package/dist/index-npg9z1a4.js +431 -0
- package/dist/index-pj501bh1.js +140 -0
- package/dist/index-qry4vhxk.js +1041 -0
- package/dist/index-s2gw5aw9.js +199 -0
- package/dist/index-vvdj6kca.js +70 -0
- package/dist/index-w2zc0vwa.js +571 -0
- package/dist/index-x3fthpsc.js +585 -0
- package/dist/index-zr53sf63.js +44 -0
- package/dist/index-zxdy5pby.js +2728 -0
- package/dist/index-zzhgcwyt.js +966 -0
- package/dist/index.js +453 -0
- package/dist/navigation.js +13 -0
- package/dist/pdf.js +85 -0
- package/dist/percolate.js +24 -0
- package/dist/portfolio.js +77 -0
- package/dist/query.js +29 -0
- package/dist/repository-memory.js +60 -0
- package/dist/sdk.js +36 -0
- package/dist/search-rules.js +34 -0
- package/dist/search.js +22 -0
- package/dist/semantic.js +39 -0
- package/dist/source-inbox.js +17 -0
- package/dist/untrusted-content.js +32 -0
- package/dist/url-intelligence.js +74 -0
- package/dist/workflow.js +23 -0
- package/dist/workflows/decision-context.js +20 -0
- package/dist/workflows/explain-change.js +8 -0
- package/dist/workflows/index.js +28 -0
- package/dist/workflows/plan-radar.js +10 -0
- package/package.json +409 -0
- package/skills/kb/AGENTS.md +19 -0
- package/skills/kb/SKILL.md +82 -0
- package/skills/kb/agents/openai.yaml +4 -0
- package/skills/kb/references/pdf-review.md +51 -0
- package/skills/kb/references/percolate.md +144 -0
- package/skills/kb/references/plan-structure.md +105 -0
- package/skills/kb/references/plan.md +115 -0
- package/skills/kb/references/query.md +231 -0
- package/skills/kb/references/refresh.md +133 -0
- package/skills/kb/references/save-pdf.md +125 -0
- package/skills/kb/references/save-url.md +178 -0
- package/skills/kb/references/url-authentication.md +75 -0
- package/skills/kb/references/url-platforms.md +25 -0
- package/src/agent-context.ts +1241 -0
- package/src/agent-guide-audit.ts +750 -0
- package/src/attachments.ts +723 -0
- package/src/authoring.ts +1725 -0
- package/src/benchmark.ts +362 -0
- package/src/capture.ts +68 -0
- package/src/cli.ts +3660 -0
- package/src/clip/acquire.ts +1273 -0
- package/src/clip/archive-today.ts +402 -0
- package/src/clip/args.ts +423 -0
- package/src/clip/assets.ts +262 -0
- package/src/clip/bounded-byte-buffer.ts +75 -0
- package/src/clip/browser-profiles.ts +372 -0
- package/src/clip/bundle-reader.ts +608 -0
- package/src/clip/capture.ts +1054 -0
- package/src/clip/cli.ts +181 -0
- package/src/clip/cookies.ts +512 -0
- package/src/clip/doctor.ts +1110 -0
- package/src/clip/extract.ts +868 -0
- package/src/clip/jobs.ts +959 -0
- package/src/clip/lib.ts +861 -0
- package/src/clip/media.ts +1474 -0
- package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
- package/src/clip/metadata-search-tool/Cargo.toml +18 -0
- package/src/clip/metadata-search-tool/runner.ts +685 -0
- package/src/clip/metadata-search-tool/src/main.rs +389 -0
- package/src/clip/metadata-search.ts +687 -0
- package/src/clip/network-proxy.ts +627 -0
- package/src/clip/network.ts +950 -0
- package/src/clip/package-root.ts +54 -0
- package/src/clip/persist.ts +1472 -0
- package/src/clip/platforms.ts +1824 -0
- package/src/clip/refresh.ts +197 -0
- package/src/clip/structured.ts +432 -0
- package/src/clip/terminal.ts +197 -0
- package/src/clip/url-intelligence.ts +581 -0
- package/src/clip/url-metadata-backfill.ts +616 -0
- package/src/clip/url-metadata-cli.ts +251 -0
- package/src/clip/url-metadata.ts +1397 -0
- package/src/evaluation-analysis-v2.ts +1440 -0
- package/src/evaluation-builder.ts +2539 -0
- package/src/evaluation-corpus-authoring.ts +2052 -0
- package/src/evaluation-evidence.ts +1147 -0
- package/src/evaluation-execution-v2.ts +426 -0
- package/src/evaluation-implementation.ts +131 -0
- package/src/evaluation-kb-closure.ts +1430 -0
- package/src/evaluation-kb-runner-v2.ts +735 -0
- package/src/evaluation-kb-v2.ts +2567 -0
- package/src/evaluation-kb.ts +1018 -0
- package/src/evaluation-measurement-v2.ts +1462 -0
- package/src/evaluation-packing-v2.ts +452 -0
- package/src/evaluation-redaction.ts +28 -0
- package/src/evaluation-v2.ts +3857 -0
- package/src/evaluation.ts +1220 -0
- package/src/git.ts +1276 -0
- package/src/graph.ts +1487 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/navigation.ts +277 -0
- package/src/note-lock.ts +529 -0
- package/src/pdf/args.ts +202 -0
- package/src/pdf/capture.ts +298 -0
- package/src/pdf/cli.ts +159 -0
- package/src/pdf/extract.ts +303 -0
- package/src/pdf/layout.ts +618 -0
- package/src/pdf/markdown.ts +154 -0
- package/src/pdf/model.ts +234 -0
- package/src/pdf/ocr.ts +190 -0
- package/src/pdf/persist.ts +314 -0
- package/src/pdf/source.ts +135 -0
- package/src/pdf/tools.ts +146 -0
- package/src/pdf.ts +92 -0
- package/src/percolate.ts +877 -0
- package/src/portfolio-audit.ts +574 -0
- package/src/portfolio-identity.ts +162 -0
- package/src/portfolio-registry.ts +473 -0
- package/src/portfolio.ts +765 -0
- package/src/query.ts +646 -0
- package/src/repository-memory.ts +1150 -0
- package/src/sdk.ts +1452 -0
- package/src/search-rules.ts +712 -0
- package/src/search.ts +710 -0
- package/src/semantic-runtime.ts +1139 -0
- package/src/semantic.ts +2480 -0
- package/src/source-inbox.ts +232 -0
- package/src/untrusted-content.ts +401 -0
- package/src/url-intelligence.ts +104 -0
- package/src/vault.ts +636 -0
- package/src/workflow.ts +653 -0
- package/src/workflows/decision-context.ts +81 -0
- package/src/workflows/explain-change.ts +73 -0
- package/src/workflows/index.ts +3 -0
- package/src/workflows/plan-radar.ts +101 -0
package/src/graph.ts
ADDED
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
isMap,
|
|
4
|
+
isNode,
|
|
5
|
+
isScalar,
|
|
6
|
+
isSeq,
|
|
7
|
+
LineCounter,
|
|
8
|
+
parseDocument,
|
|
9
|
+
type Node,
|
|
10
|
+
} from "yaml";
|
|
11
|
+
import { parseQualifiedDocumentUri, type QualifiedDocumentUri } from "./portfolio-identity.js";
|
|
12
|
+
|
|
13
|
+
export const catalogStart = "<!-- kb:catalog:start -->";
|
|
14
|
+
export const catalogEnd = "<!-- kb:catalog:end -->";
|
|
15
|
+
|
|
16
|
+
export const MAX_ANALYZED_NOTES = 10_000;
|
|
17
|
+
export const MAX_CONNECTION_OBSERVATIONS = 250_000;
|
|
18
|
+
export const MAX_MENTION_PAIRS = 1_000_000;
|
|
19
|
+
export const MAX_MENTIONS = 50_000;
|
|
20
|
+
|
|
21
|
+
export type VaultAnalysisBudgetKind =
|
|
22
|
+
| "notes"
|
|
23
|
+
| "connection-observations"
|
|
24
|
+
| "mention-pairs"
|
|
25
|
+
| "mentions";
|
|
26
|
+
|
|
27
|
+
/** A stable failure for callers that need to distinguish bounded graph work. */
|
|
28
|
+
export class VaultAnalysisBudgetError extends RangeError {
|
|
29
|
+
readonly kind: VaultAnalysisBudgetKind;
|
|
30
|
+
readonly limit: number;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
kind: VaultAnalysisBudgetKind,
|
|
34
|
+
limit: number,
|
|
35
|
+
message: string,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "VaultAnalysisBudgetError";
|
|
39
|
+
this.kind = kind;
|
|
40
|
+
this.limit = limit;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type WikiLink = {
|
|
45
|
+
target: string;
|
|
46
|
+
line: number;
|
|
47
|
+
embedded: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type RelationDeclaration = {
|
|
51
|
+
/** Strict lower-kebab predicate as authored after Unicode normalization. */
|
|
52
|
+
readonly predicate: string;
|
|
53
|
+
/** Authored target, normalized and trimmed but not yet resolved. */
|
|
54
|
+
readonly target: string;
|
|
55
|
+
readonly line: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type RelationProvenance = {
|
|
59
|
+
readonly kind: "frontmatter";
|
|
60
|
+
/** Vault-relative Markdown path that owns the outbound assertion. */
|
|
61
|
+
readonly source: string;
|
|
62
|
+
readonly line: number;
|
|
63
|
+
/** Target spelling retained for diagnostics and auditable projection. */
|
|
64
|
+
readonly authoredTarget: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type AuthoredRelation = {
|
|
68
|
+
/** Canonical extensionless note ID. */
|
|
69
|
+
readonly source: string;
|
|
70
|
+
/** Canonical extensionless note ID. */
|
|
71
|
+
readonly target: string;
|
|
72
|
+
readonly predicate: string;
|
|
73
|
+
readonly provenance: RelationProvenance;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type ExternalAuthoredRelation = {
|
|
77
|
+
/** Canonical extensionless note ID in the current vault. */
|
|
78
|
+
readonly source: string;
|
|
79
|
+
/** Stable cross-vault document identity; portfolio audit resolves availability. */
|
|
80
|
+
readonly target: QualifiedDocumentUri;
|
|
81
|
+
readonly predicate: string;
|
|
82
|
+
readonly provenance: RelationProvenance;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type MetadataScalar = string | number | boolean | null;
|
|
86
|
+
|
|
87
|
+
export type MetadataValue =
|
|
88
|
+
| MetadataScalar
|
|
89
|
+
| readonly MetadataValue[]
|
|
90
|
+
| MetadataObject;
|
|
91
|
+
|
|
92
|
+
export interface MetadataObject {
|
|
93
|
+
readonly [key: string]: MetadataValue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isMetadataObject(value: MetadataValue): value is MetadataObject {
|
|
97
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type Note = {
|
|
101
|
+
path: string;
|
|
102
|
+
id: string;
|
|
103
|
+
title: string;
|
|
104
|
+
aliases: readonly string[];
|
|
105
|
+
tags: readonly string[];
|
|
106
|
+
properties: Readonly<Record<string, string>>;
|
|
107
|
+
metadata: MetadataObject;
|
|
108
|
+
content: string;
|
|
109
|
+
summary: string;
|
|
110
|
+
searchableText: string;
|
|
111
|
+
links: readonly WikiLink[];
|
|
112
|
+
/**
|
|
113
|
+
* Always present on parser-produced notes. Optionality preserves compatibility
|
|
114
|
+
* for callers that construct the older structural Note shape themselves.
|
|
115
|
+
*/
|
|
116
|
+
relationDeclarations?: readonly RelationDeclaration[];
|
|
117
|
+
/** Parser diagnostics promoted into VaultAnalysis.relationIssues. */
|
|
118
|
+
relationIssues?: readonly MalformedRelationIssue[];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type ResolvedLink = {
|
|
122
|
+
source: string;
|
|
123
|
+
target: string;
|
|
124
|
+
line: number;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type LinkIssue =
|
|
128
|
+
| {
|
|
129
|
+
kind: "broken";
|
|
130
|
+
source: string;
|
|
131
|
+
line: number;
|
|
132
|
+
target: string;
|
|
133
|
+
}
|
|
134
|
+
| {
|
|
135
|
+
kind: "ambiguous";
|
|
136
|
+
source: string;
|
|
137
|
+
line: number;
|
|
138
|
+
target: string;
|
|
139
|
+
candidates: readonly string[];
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type MalformedRelationIssue = {
|
|
143
|
+
readonly kind: "malformed";
|
|
144
|
+
readonly source: string;
|
|
145
|
+
readonly line: number;
|
|
146
|
+
readonly predicate?: string;
|
|
147
|
+
readonly target?: string;
|
|
148
|
+
readonly message: string;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type RelationIssue =
|
|
152
|
+
| MalformedRelationIssue
|
|
153
|
+
| {
|
|
154
|
+
readonly kind: "broken";
|
|
155
|
+
readonly source: string;
|
|
156
|
+
readonly line: number;
|
|
157
|
+
readonly predicate: string;
|
|
158
|
+
readonly target: string;
|
|
159
|
+
}
|
|
160
|
+
| {
|
|
161
|
+
readonly kind: "ambiguous";
|
|
162
|
+
readonly source: string;
|
|
163
|
+
readonly line: number;
|
|
164
|
+
readonly predicate: string;
|
|
165
|
+
readonly target: string;
|
|
166
|
+
readonly candidates: readonly string[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export type MentionCandidate = {
|
|
170
|
+
source: string;
|
|
171
|
+
line: number;
|
|
172
|
+
target: string;
|
|
173
|
+
phrase: string;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export type Backlink = {
|
|
177
|
+
source: string;
|
|
178
|
+
target: string;
|
|
179
|
+
line: number;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export type NoteConnections = {
|
|
183
|
+
id: string;
|
|
184
|
+
path: string;
|
|
185
|
+
inboundContextualCount: number;
|
|
186
|
+
outboundContextualCount: number;
|
|
187
|
+
backlinks: readonly Backlink[];
|
|
188
|
+
inboundRelationCount: number;
|
|
189
|
+
outboundRelationCount: number;
|
|
190
|
+
relationBacklinks: readonly AuthoredRelation[];
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export type NoteLookupResult =
|
|
194
|
+
| { kind: "found"; note: Note }
|
|
195
|
+
| { kind: "ambiguous"; query: string; candidates: readonly Note[] }
|
|
196
|
+
| { kind: "missing"; query: string };
|
|
197
|
+
|
|
198
|
+
export type AnalyzeVaultOptions = {
|
|
199
|
+
includeInSuggestions?: (note: Note) => boolean;
|
|
200
|
+
/**
|
|
201
|
+
* Restrict mention discovery to pairs touching an included note. An empty
|
|
202
|
+
* scope skips mention pairing while retaining links and authored relations.
|
|
203
|
+
*/
|
|
204
|
+
mentionScope?: (note: Note) => boolean;
|
|
205
|
+
/** Maximum notes accepted by this analysis, up to the hard package limit. */
|
|
206
|
+
maxNotes?: number;
|
|
207
|
+
/** Maximum parsed wikilinks and relation records inspected in aggregate. */
|
|
208
|
+
maxConnectionObservations?: number;
|
|
209
|
+
/** Maximum source/target phrase pairs inspected for prose mentions. */
|
|
210
|
+
maxMentionPairs?: number;
|
|
211
|
+
/** Maximum mention candidates materialized in the result. */
|
|
212
|
+
maxMentions?: number;
|
|
213
|
+
/** Vault-relative path of the generated navigation catalog. */
|
|
214
|
+
catalogNoteId?: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export type VaultAnalysis = {
|
|
218
|
+
noteCount: number;
|
|
219
|
+
contextualLinks: readonly ResolvedLink[];
|
|
220
|
+
backlinks: readonly Backlink[];
|
|
221
|
+
authoredRelations: readonly AuthoredRelation[];
|
|
222
|
+
readonly externalAuthoredRelations: readonly ExternalAuthoredRelation[];
|
|
223
|
+
noteConnections: readonly NoteConnections[];
|
|
224
|
+
issues: readonly LinkIssue[];
|
|
225
|
+
relationIssues: readonly RelationIssue[];
|
|
226
|
+
orphans: readonly string[];
|
|
227
|
+
mentions: readonly MentionCandidate[];
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
type Frontmatter = {
|
|
231
|
+
values: ReadonlyMap<string, string>;
|
|
232
|
+
aliases: readonly string[];
|
|
233
|
+
tags: readonly string[];
|
|
234
|
+
metadata: MetadataObject;
|
|
235
|
+
relationDeclarations: readonly RelationDeclaration[];
|
|
236
|
+
relationIssues: readonly MalformedRelationIssue[];
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
type MetadataParseResult =
|
|
240
|
+
| { readonly ok: true; readonly value: MetadataValue }
|
|
241
|
+
| { readonly ok: false };
|
|
242
|
+
|
|
243
|
+
function parsedMetadataValue(
|
|
244
|
+
value: unknown,
|
|
245
|
+
ancestors: WeakSet<object>,
|
|
246
|
+
): MetadataParseResult {
|
|
247
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
248
|
+
return { ok: true, value };
|
|
249
|
+
}
|
|
250
|
+
if (typeof value === "number") {
|
|
251
|
+
return Number.isFinite(value)
|
|
252
|
+
&& (!Number.isInteger(value) || Number.isSafeInteger(value))
|
|
253
|
+
? { ok: true, value }
|
|
254
|
+
: { ok: false };
|
|
255
|
+
}
|
|
256
|
+
if (typeof value !== "object" || ancestors.has(value)) return { ok: false };
|
|
257
|
+
|
|
258
|
+
ancestors.add(value);
|
|
259
|
+
try {
|
|
260
|
+
if (Array.isArray(value)) {
|
|
261
|
+
const parsed: MetadataValue[] = [];
|
|
262
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
263
|
+
if (ownKeys.some((key) =>
|
|
264
|
+
typeof key !== "string" || (key !== "length" && !/^(?:0|[1-9]\d*)$/u.test(key)))) {
|
|
265
|
+
return { ok: false };
|
|
266
|
+
}
|
|
267
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
268
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
269
|
+
if (descriptor === undefined || !("value" in descriptor)) return { ok: false };
|
|
270
|
+
const result = parsedMetadataValue(descriptor.value, ancestors);
|
|
271
|
+
if (!result.ok) return result;
|
|
272
|
+
parsed.push(result.value);
|
|
273
|
+
}
|
|
274
|
+
return { ok: true, value: parsed };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
278
|
+
if (prototype !== Object.prototype && prototype !== null) return { ok: false };
|
|
279
|
+
const parsed = Object.create(null) as Record<string, MetadataValue>;
|
|
280
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
281
|
+
if (typeof key !== "string") return { ok: false };
|
|
282
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
283
|
+
if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
|
|
284
|
+
return { ok: false };
|
|
285
|
+
}
|
|
286
|
+
const result = parsedMetadataValue(descriptor.value, ancestors);
|
|
287
|
+
if (!result.ok) return result;
|
|
288
|
+
Object.defineProperty(parsed, key, {
|
|
289
|
+
configurable: false,
|
|
290
|
+
enumerable: true,
|
|
291
|
+
value: result.value,
|
|
292
|
+
writable: false,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return { ok: true, value: parsed };
|
|
296
|
+
} finally {
|
|
297
|
+
ancestors.delete(value);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Narrow foreign parser output to the JSON-like metadata values owned by the graph. */
|
|
302
|
+
export function metadataValueFromUnknown(value: unknown): MetadataValue | undefined {
|
|
303
|
+
try {
|
|
304
|
+
const parsed = parsedMetadataValue(value, new WeakSet());
|
|
305
|
+
return parsed.ok ? parsed.value : undefined;
|
|
306
|
+
} catch {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function metadataObjectFromUnknown(value: unknown): MetadataObject | null {
|
|
312
|
+
if (value === null || Array.isArray(value) || typeof value !== "object") return null;
|
|
313
|
+
const parsed = metadataValueFromUnknown(value);
|
|
314
|
+
return parsed !== undefined && isMetadataObject(parsed) ? parsed : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function emptyMetadata(): MetadataObject {
|
|
318
|
+
return Object.create(null) as MetadataObject;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
type ParsedMetadata = {
|
|
322
|
+
readonly metadata: MetadataObject;
|
|
323
|
+
readonly relationDeclarations: readonly RelationDeclaration[];
|
|
324
|
+
readonly relationIssues: readonly MalformedRelationIssue[];
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const relationPredicatePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
328
|
+
const maxNoteIdLength = 2_048;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Whether a value is the exact, extensionless vault-root ID used on disk.
|
|
332
|
+
*
|
|
333
|
+
* Relationship metadata and the authoring API share this predicate so a
|
|
334
|
+
* value accepted by the reader can always be passed back to the writer
|
|
335
|
+
* without a second normalization language.
|
|
336
|
+
*/
|
|
337
|
+
export function isCanonicalNoteId(value: string): boolean {
|
|
338
|
+
if (
|
|
339
|
+
value === ""
|
|
340
|
+
|| value.length > maxNoteIdLength
|
|
341
|
+
|| value !== value.trim()
|
|
342
|
+
|| value !== value.normalize("NFC")
|
|
343
|
+
|| value.includes("\\")
|
|
344
|
+
|| value.includes("\0")
|
|
345
|
+
|| value.includes("\n")
|
|
346
|
+
|| value.includes("\r")
|
|
347
|
+
|| value.startsWith("/")
|
|
348
|
+
|| value.endsWith("/")
|
|
349
|
+
|| value.toLocaleLowerCase("en-US").endsWith(".md")
|
|
350
|
+
) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
const segments = value.split("/");
|
|
354
|
+
return !segments.some((segment) =>
|
|
355
|
+
segment === ""
|
|
356
|
+
|| segment === "."
|
|
357
|
+
|| segment === ".."
|
|
358
|
+
|| segment.startsWith("."))
|
|
359
|
+
&& posix.normalize(value) === value
|
|
360
|
+
&& posix.basename(`${value}.md`) !== "AGENTS.md";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function frontmatterSourceLine(
|
|
364
|
+
lineCounter: LineCounter,
|
|
365
|
+
node: Node | null | undefined,
|
|
366
|
+
fallback = 2,
|
|
367
|
+
): number {
|
|
368
|
+
const offset = node?.range?.[0];
|
|
369
|
+
if (offset === undefined) return fallback;
|
|
370
|
+
// The parsed source starts immediately after the opening frontmatter marker.
|
|
371
|
+
return lineCounter.linePos(offset).line + 1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function malformedRelation(
|
|
375
|
+
source: string,
|
|
376
|
+
line: number,
|
|
377
|
+
message: string,
|
|
378
|
+
details: {
|
|
379
|
+
readonly predicate?: string;
|
|
380
|
+
readonly target?: string;
|
|
381
|
+
} = {},
|
|
382
|
+
): MalformedRelationIssue {
|
|
383
|
+
return {
|
|
384
|
+
kind: "malformed",
|
|
385
|
+
source,
|
|
386
|
+
line,
|
|
387
|
+
...details,
|
|
388
|
+
message,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function relationTarget(
|
|
393
|
+
node: Node | null,
|
|
394
|
+
predicate: string,
|
|
395
|
+
source: string,
|
|
396
|
+
lineCounter: LineCounter,
|
|
397
|
+
fallbackLine: number,
|
|
398
|
+
): RelationDeclaration | MalformedRelationIssue {
|
|
399
|
+
const line = frontmatterSourceLine(lineCounter, node, fallbackLine);
|
|
400
|
+
if (!isScalar(node) || typeof node.value !== "string") {
|
|
401
|
+
return malformedRelation(
|
|
402
|
+
source,
|
|
403
|
+
line,
|
|
404
|
+
`Relation "${predicate}" targets must be non-empty strings.`,
|
|
405
|
+
{ predicate },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const target = node.value;
|
|
409
|
+
let qualified = false;
|
|
410
|
+
if (target.startsWith("kb://")) {
|
|
411
|
+
try {
|
|
412
|
+
parseQualifiedDocumentUri(target);
|
|
413
|
+
qualified = true;
|
|
414
|
+
} catch {
|
|
415
|
+
qualified = false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!isCanonicalNoteId(target) && !qualified) {
|
|
419
|
+
return malformedRelation(
|
|
420
|
+
source,
|
|
421
|
+
line,
|
|
422
|
+
`Relation "${predicate}" target ${JSON.stringify(target)} must be an exact `
|
|
423
|
+
+ "extensionless vault-root note ID or canonical kb:// document URI.",
|
|
424
|
+
{ predicate, target: node.value },
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
return { predicate, target, line };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function parsedRelations(
|
|
431
|
+
contents: Node | null,
|
|
432
|
+
source: string,
|
|
433
|
+
lineCounter: LineCounter,
|
|
434
|
+
): Pick<ParsedMetadata, "relationDeclarations" | "relationIssues"> {
|
|
435
|
+
if (!isMap(contents)) return { relationDeclarations: [], relationIssues: [] };
|
|
436
|
+
const relationsPair = contents.items.find((pair) =>
|
|
437
|
+
isScalar(pair.key)
|
|
438
|
+
&& typeof pair.key.value === "string"
|
|
439
|
+
&& pair.key.value.toLocaleLowerCase("en-US") === "relations");
|
|
440
|
+
if (relationsPair === undefined) {
|
|
441
|
+
return { relationDeclarations: [], relationIssues: [] };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const relationsLine = frontmatterSourceLine(
|
|
445
|
+
lineCounter,
|
|
446
|
+
isScalar(relationsPair.key) ? relationsPair.key : null,
|
|
447
|
+
);
|
|
448
|
+
if (!isMap(relationsPair.value)) {
|
|
449
|
+
return {
|
|
450
|
+
relationDeclarations: [],
|
|
451
|
+
relationIssues: [
|
|
452
|
+
malformedRelation(
|
|
453
|
+
source,
|
|
454
|
+
relationsLine,
|
|
455
|
+
"Relations must map strict lower-kebab predicates to a string or string list.",
|
|
456
|
+
),
|
|
457
|
+
],
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const relationDeclarations: RelationDeclaration[] = [];
|
|
462
|
+
const relationIssues: MalformedRelationIssue[] = [];
|
|
463
|
+
for (const pair of relationsPair.value.items) {
|
|
464
|
+
const predicateLine = frontmatterSourceLine(
|
|
465
|
+
lineCounter,
|
|
466
|
+
isNode(pair.key) ? pair.key : null,
|
|
467
|
+
relationsLine,
|
|
468
|
+
);
|
|
469
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
|
|
470
|
+
relationIssues.push(malformedRelation(
|
|
471
|
+
source,
|
|
472
|
+
predicateLine,
|
|
473
|
+
"Relation predicates must be strict lower kebab-case strings.",
|
|
474
|
+
));
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const predicate = pair.key.value.normalize("NFC");
|
|
479
|
+
if (!relationPredicatePattern.test(predicate)) {
|
|
480
|
+
relationIssues.push(malformedRelation(
|
|
481
|
+
source,
|
|
482
|
+
predicateLine,
|
|
483
|
+
`Invalid relation predicate "${predicate}"; use strict lower kebab-case.`,
|
|
484
|
+
{ predicate },
|
|
485
|
+
));
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const nodes = isSeq(pair.value) ? pair.value.items : [pair.value];
|
|
490
|
+
for (const node of nodes) {
|
|
491
|
+
const parsed = relationTarget(
|
|
492
|
+
isNode(node) ? node : null,
|
|
493
|
+
predicate,
|
|
494
|
+
source,
|
|
495
|
+
lineCounter,
|
|
496
|
+
predicateLine,
|
|
497
|
+
);
|
|
498
|
+
if ("kind" in parsed) relationIssues.push(parsed);
|
|
499
|
+
else relationDeclarations.push(parsed);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
relationDeclarations: relationDeclarations.toSorted((left, right) =>
|
|
505
|
+
left.predicate.localeCompare(right.predicate)
|
|
506
|
+
|| left.target.localeCompare(right.target)
|
|
507
|
+
|| left.line - right.line),
|
|
508
|
+
relationIssues: relationIssues.toSorted((left, right) =>
|
|
509
|
+
left.line - right.line
|
|
510
|
+
|| (left.predicate ?? "").localeCompare(right.predicate ?? "")
|
|
511
|
+
|| (left.target ?? "").localeCompare(right.target ?? "")),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function parseMetadata(source: string, path: string): ParsedMetadata {
|
|
516
|
+
if (source.trim() === "") {
|
|
517
|
+
return {
|
|
518
|
+
metadata: emptyMetadata(),
|
|
519
|
+
relationDeclarations: [],
|
|
520
|
+
relationIssues: [],
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const lineCounter = new LineCounter();
|
|
525
|
+
const document = parseDocument(source, {
|
|
526
|
+
lineCounter,
|
|
527
|
+
schema: "core",
|
|
528
|
+
uniqueKeys: true,
|
|
529
|
+
});
|
|
530
|
+
if (document.errors.length > 0) {
|
|
531
|
+
throw new Error("the YAML parser reported an error");
|
|
532
|
+
}
|
|
533
|
+
if (document.contents === null) {
|
|
534
|
+
return {
|
|
535
|
+
metadata: emptyMetadata(),
|
|
536
|
+
relationDeclarations: [],
|
|
537
|
+
relationIssues: [],
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const parsed: unknown = document.toJS({ mapAsMap: false, maxAliasCount: 50 });
|
|
541
|
+
const metadata = metadataObjectFromUnknown(parsed);
|
|
542
|
+
if (metadata === null) throw new Error("the YAML document is not a JSON-like object");
|
|
543
|
+
return {
|
|
544
|
+
metadata,
|
|
545
|
+
...parsedRelations(document.contents, path, lineCounter),
|
|
546
|
+
};
|
|
547
|
+
} catch (error) {
|
|
548
|
+
throw new Error(`Invalid YAML frontmatter in ${path}.`, { cause: error });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function metadataProperty(
|
|
553
|
+
metadata: MetadataObject,
|
|
554
|
+
name: string,
|
|
555
|
+
): MetadataValue | undefined {
|
|
556
|
+
if (Object.hasOwn(metadata, name)) return metadata[name];
|
|
557
|
+
const lowerName = name.toLocaleLowerCase("en-US");
|
|
558
|
+
const matches = Object.keys(metadata).filter(
|
|
559
|
+
(key) => key.toLocaleLowerCase("en-US") === lowerName,
|
|
560
|
+
);
|
|
561
|
+
return matches.length === 1 ? metadata[matches[0] ?? ""] : undefined;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function normalizedTags(metadata: MetadataObject): readonly string[] {
|
|
565
|
+
const value = metadataProperty(metadata, "tags");
|
|
566
|
+
const candidates = typeof value === "string"
|
|
567
|
+
? [value]
|
|
568
|
+
: Array.isArray(value)
|
|
569
|
+
? value.filter((candidate): candidate is string => typeof candidate === "string")
|
|
570
|
+
: [];
|
|
571
|
+
const tags: string[] = [];
|
|
572
|
+
const seen = new Set<string>();
|
|
573
|
+
for (const candidate of candidates) {
|
|
574
|
+
const tag = candidate.trim().replace(/^#+/u, "").normalize("NFC").toLocaleLowerCase("en-US");
|
|
575
|
+
if (tag === "" || seen.has(tag)) continue;
|
|
576
|
+
seen.add(tag);
|
|
577
|
+
tags.push(tag);
|
|
578
|
+
}
|
|
579
|
+
return tags;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export const normalizeVaultPath = (path: string): string =>
|
|
583
|
+
posix.normalize(path.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
584
|
+
|
|
585
|
+
const withoutMarkdownExtension = (path: string): string =>
|
|
586
|
+
path.toLowerCase().endsWith(".md") ? path.slice(0, -3) : path;
|
|
587
|
+
|
|
588
|
+
function legacyPropertyValue(value: MetadataValue): string | undefined {
|
|
589
|
+
if (typeof value === "string") return value;
|
|
590
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
591
|
+
if (value === null) return "null";
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function aliasesFromMetadata(metadata: MetadataObject): readonly string[] {
|
|
596
|
+
const value = metadataProperty(metadata, "aliases");
|
|
597
|
+
if (typeof value === "string") return value.trim() === "" ? [] : [value];
|
|
598
|
+
if (!Array.isArray(value)) return [];
|
|
599
|
+
return value.filter((candidate): candidate is string =>
|
|
600
|
+
typeof candidate === "string" && candidate.trim() !== "");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function frontmatterOf(content: string, path: string): Frontmatter {
|
|
604
|
+
const lines = content.split("\n");
|
|
605
|
+
if (lines[0]?.trim() !== "---") {
|
|
606
|
+
return {
|
|
607
|
+
values: new Map(),
|
|
608
|
+
aliases: [],
|
|
609
|
+
tags: [],
|
|
610
|
+
metadata: emptyMetadata(),
|
|
611
|
+
relationDeclarations: [],
|
|
612
|
+
relationIssues: [],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
616
|
+
if (end === -1) {
|
|
617
|
+
throw new Error(`Invalid YAML frontmatter in ${path}: missing closing delimiter.`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const parsed = parseMetadata(lines.slice(1, end).join("\n"), path);
|
|
621
|
+
const metadata = parsed.metadata;
|
|
622
|
+
|
|
623
|
+
const values = new Map<string, string>();
|
|
624
|
+
const seenKeys = new Set<string>();
|
|
625
|
+
for (const [authoredKey, typedValue] of Object.entries(metadata)) {
|
|
626
|
+
const key = authoredKey.toLocaleLowerCase("en-US");
|
|
627
|
+
if (seenKeys.has(key)) {
|
|
628
|
+
throw new Error(`Invalid YAML frontmatter in ${path}: keys must not differ only by case.`);
|
|
629
|
+
}
|
|
630
|
+
seenKeys.add(key);
|
|
631
|
+
if (key === "aliases") continue;
|
|
632
|
+
const value = legacyPropertyValue(typedValue);
|
|
633
|
+
if (value !== undefined && value !== "") values.set(key, value);
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
values,
|
|
637
|
+
aliases: aliasesFromMetadata(metadata),
|
|
638
|
+
tags: normalizedTags(metadata),
|
|
639
|
+
metadata,
|
|
640
|
+
relationDeclarations: parsed.relationDeclarations,
|
|
641
|
+
relationIssues: parsed.relationIssues,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Blank frontmatter, fenced code, inline code, and HTML comments while
|
|
647
|
+
* preserving newlines so diagnostic line numbers remain stable.
|
|
648
|
+
*/
|
|
649
|
+
export function searchableMarkdown(content: string): string {
|
|
650
|
+
const lines = content.split("\n");
|
|
651
|
+
let inFrontmatter = lines[0]?.trim() === "---";
|
|
652
|
+
let inFence: { readonly character: string; readonly length: number } | null = null;
|
|
653
|
+
let inComment = false;
|
|
654
|
+
|
|
655
|
+
const blockMasked = lines.map((line, index) => {
|
|
656
|
+
if (inFrontmatter) {
|
|
657
|
+
if (index > 0 && line.trim() === "---") inFrontmatter = false;
|
|
658
|
+
return "";
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
662
|
+
if (inFence !== null) {
|
|
663
|
+
const delimiter = /^\s{0,3}(`{3,}|~{3,})[\t ]*$/.exec(line)?.[1];
|
|
664
|
+
if (
|
|
665
|
+
delimiter !== undefined
|
|
666
|
+
&& delimiter[0] === inFence.character
|
|
667
|
+
&& delimiter.length >= inFence.length
|
|
668
|
+
) inFence = null;
|
|
669
|
+
return "";
|
|
670
|
+
}
|
|
671
|
+
const openingDelimiter = fence?.[1];
|
|
672
|
+
const openingRemainder = fence === null ? "" : line.slice(fence[0].length);
|
|
673
|
+
if (
|
|
674
|
+
openingDelimiter !== undefined
|
|
675
|
+
&& (openingDelimiter[0] === "~" || !openingRemainder.includes("`"))
|
|
676
|
+
) {
|
|
677
|
+
const delimiter = openingDelimiter;
|
|
678
|
+
inFence = { character: delimiter[0] ?? "`", length: delimiter.length };
|
|
679
|
+
return "";
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (/^(?: {4,}|\t)/u.test(line)) return "";
|
|
683
|
+
|
|
684
|
+
let output = line;
|
|
685
|
+
if (inComment) {
|
|
686
|
+
const close = output.indexOf("-->");
|
|
687
|
+
if (close === -1) return "";
|
|
688
|
+
output = output.slice(close + 3);
|
|
689
|
+
inComment = false;
|
|
690
|
+
}
|
|
691
|
+
for (;;) {
|
|
692
|
+
const open = output.indexOf("<!--");
|
|
693
|
+
if (open === -1) break;
|
|
694
|
+
const close = output.indexOf("-->", open + 4);
|
|
695
|
+
if (close === -1) {
|
|
696
|
+
output = output.slice(0, open);
|
|
697
|
+
inComment = true;
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
output = output.slice(0, open) + output.slice(close + 3);
|
|
701
|
+
}
|
|
702
|
+
return output;
|
|
703
|
+
}).join("\n");
|
|
704
|
+
return maskInlineCodeBlocks(maskHtmlCodeBlocks(blockMasked));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
type BacktickRun = {
|
|
708
|
+
readonly start: number;
|
|
709
|
+
readonly end: number;
|
|
710
|
+
readonly length: number;
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
function maskInlineCodeSpans(value: string): string {
|
|
714
|
+
const runs: BacktickRun[] = [];
|
|
715
|
+
for (let index = 0; index < value.length;) {
|
|
716
|
+
if (value[index] !== "`") {
|
|
717
|
+
index += 1;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
const start = index;
|
|
721
|
+
while (value[index] === "`") index += 1;
|
|
722
|
+
let precedingBackslashes = 0;
|
|
723
|
+
for (let before = start - 1; before >= 0 && value[before] === "\\"; before -= 1) {
|
|
724
|
+
precedingBackslashes += 1;
|
|
725
|
+
}
|
|
726
|
+
if (precedingBackslashes % 2 === 1) continue;
|
|
727
|
+
runs.push({ start, end: index, length: index - start });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const remaining = new Map<number, number>();
|
|
731
|
+
for (const run of runs) remaining.set(run.length, (remaining.get(run.length) ?? 0) + 1);
|
|
732
|
+
const output: string[] = [];
|
|
733
|
+
let cursor = 0;
|
|
734
|
+
let delimiterLength: number | null = null;
|
|
735
|
+
const appendSegment = (segment: string): void => {
|
|
736
|
+
output.push(delimiterLength === null ? segment : segment.replace(/[^\n]/gu, ""));
|
|
737
|
+
};
|
|
738
|
+
for (const run of runs) {
|
|
739
|
+
appendSegment(value.slice(cursor, run.start));
|
|
740
|
+
remaining.set(run.length, (remaining.get(run.length) ?? 1) - 1);
|
|
741
|
+
if (delimiterLength === null) {
|
|
742
|
+
if ((remaining.get(run.length) ?? 0) > 0) delimiterLength = run.length;
|
|
743
|
+
else output.push(value.slice(run.start, run.end));
|
|
744
|
+
} else if (run.length === delimiterLength) {
|
|
745
|
+
delimiterLength = null;
|
|
746
|
+
}
|
|
747
|
+
cursor = run.end;
|
|
748
|
+
}
|
|
749
|
+
appendSegment(value.slice(cursor));
|
|
750
|
+
return output.join("");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function maskInlineCodeBlocks(value: string): string {
|
|
754
|
+
const output: string[] = [];
|
|
755
|
+
let paragraph: string[] = [];
|
|
756
|
+
const flush = (): void => {
|
|
757
|
+
if (paragraph.length === 0) return;
|
|
758
|
+
output.push(...maskInlineCodeSpans(paragraph.join("\n")).split("\n"));
|
|
759
|
+
paragraph = [];
|
|
760
|
+
};
|
|
761
|
+
const startsIndependentBlock = (line: string): boolean =>
|
|
762
|
+
/^\s{0,3}(?:#{1,6}(?:\s|$)|>|(?:[-+*]|\d+[.)])\s+)/u.test(line);
|
|
763
|
+
|
|
764
|
+
for (const line of value.split("\n")) {
|
|
765
|
+
if (line.trim() === "") {
|
|
766
|
+
flush();
|
|
767
|
+
output.push("");
|
|
768
|
+
} else if (startsIndependentBlock(line)) {
|
|
769
|
+
flush();
|
|
770
|
+
output.push(maskInlineCodeSpans(line));
|
|
771
|
+
} else {
|
|
772
|
+
paragraph.push(line);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
flush();
|
|
776
|
+
return output.join("\n");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function maskHtmlCodeBlocks(value: string): string {
|
|
780
|
+
const tagPattern = /<\/?(pre|code|script|style)\b[^>]*>/giu;
|
|
781
|
+
const output: string[] = [];
|
|
782
|
+
let cursor = 0;
|
|
783
|
+
let activeTag: string | null = null;
|
|
784
|
+
let depth = 0;
|
|
785
|
+
const append = (segment: string): void => {
|
|
786
|
+
output.push(activeTag === null ? segment : segment.replace(/[^\n]/gu, ""));
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
for (const match of value.matchAll(tagPattern)) {
|
|
790
|
+
const start = match.index ?? 0;
|
|
791
|
+
append(value.slice(cursor, start));
|
|
792
|
+
const tag = (match[1] ?? "").toLowerCase();
|
|
793
|
+
const closing = match[0].startsWith("</");
|
|
794
|
+
if (activeTag === null && !closing) {
|
|
795
|
+
activeTag = tag;
|
|
796
|
+
depth = 1;
|
|
797
|
+
} else if (activeTag === tag) {
|
|
798
|
+
if (closing) depth -= 1;
|
|
799
|
+
else depth += 1;
|
|
800
|
+
if (depth === 0) activeTag = null;
|
|
801
|
+
}
|
|
802
|
+
output.push(match[0].replace(/[^\n]/gu, ""));
|
|
803
|
+
cursor = start + match[0].length;
|
|
804
|
+
}
|
|
805
|
+
append(value.slice(cursor));
|
|
806
|
+
return output.join("");
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function markdownText(value: string): string {
|
|
810
|
+
return value
|
|
811
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
|
812
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
813
|
+
.replace(/\[\[([^|\]#]+)(?:#[^|\]]+)?(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label: string | undefined) =>
|
|
814
|
+
label ?? posix.basename(target))
|
|
815
|
+
.replace(/[*_~]/g, "")
|
|
816
|
+
.replace(/\s+/g, " ")
|
|
817
|
+
.trim();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function firstHeading(searchable: string): string | null {
|
|
821
|
+
for (const line of searchable.split("\n")) {
|
|
822
|
+
const heading = /^#\s+(.+?)\s*$/.exec(line);
|
|
823
|
+
if (heading !== null) return markdownText(heading[1] ?? "");
|
|
824
|
+
}
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function firstParagraph(searchable: string): string {
|
|
829
|
+
const paragraphs = searchable.split(/\n\s*\n/);
|
|
830
|
+
for (const paragraph of paragraphs) {
|
|
831
|
+
const lines = paragraph
|
|
832
|
+
.split("\n")
|
|
833
|
+
.map((line) => line.trim())
|
|
834
|
+
.filter((line) =>
|
|
835
|
+
line !== ""
|
|
836
|
+
&& !line.startsWith("#")
|
|
837
|
+
&& !line.startsWith("- ")
|
|
838
|
+
&& !line.startsWith("* ")
|
|
839
|
+
&& !line.startsWith("|")
|
|
840
|
+
&& !line.startsWith(">"));
|
|
841
|
+
const text = markdownText(lines.join(" "));
|
|
842
|
+
if (text !== "") return text;
|
|
843
|
+
}
|
|
844
|
+
return "";
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function concise(value: string, limit = 180): string {
|
|
848
|
+
const text = markdownText(value);
|
|
849
|
+
if (text.length <= limit) return text;
|
|
850
|
+
const prefix = text.slice(0, limit + 1);
|
|
851
|
+
const lastSpace = prefix.lastIndexOf(" ");
|
|
852
|
+
return (lastSpace >= Math.floor(limit * 0.65) ? prefix.slice(0, lastSpace) : text.slice(0, limit)).trimEnd() + "…";
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export function wikiLinks(searchable: string): readonly WikiLink[] {
|
|
856
|
+
const links: WikiLink[] = [];
|
|
857
|
+
for (const match of searchable.matchAll(/(!?)\[\[([^\]\n]+)\]\]/g)) {
|
|
858
|
+
const offset = match.index ?? 0;
|
|
859
|
+
let precedingBackslashes = 0;
|
|
860
|
+
for (let before = offset - 1; before >= 0 && searchable[before] === "\\"; before -= 1) {
|
|
861
|
+
precedingBackslashes += 1;
|
|
862
|
+
}
|
|
863
|
+
if (precedingBackslashes % 2 === 1) continue;
|
|
864
|
+
const inside = match[2] ?? "";
|
|
865
|
+
const separator = inside.indexOf("|");
|
|
866
|
+
const target = (separator === -1 ? inside : inside.slice(0, separator)).trim();
|
|
867
|
+
links.push({
|
|
868
|
+
target,
|
|
869
|
+
line: searchable.slice(0, offset).split("\n").length,
|
|
870
|
+
embedded: (match[1] ?? "") === "!",
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
return links;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
export function parseNote(path: string, content: string): Note {
|
|
877
|
+
const notePath = normalizeVaultPath(path);
|
|
878
|
+
const metadata = frontmatterOf(content, notePath);
|
|
879
|
+
const searchable = searchableMarkdown(content);
|
|
880
|
+
const fallback = posix.basename(withoutMarkdownExtension(notePath)).replaceAll("-", " ");
|
|
881
|
+
const title = metadata.values.get("title") ?? firstHeading(searchable) ?? fallback;
|
|
882
|
+
const summary = concise(metadata.values.get("description") ?? firstParagraph(searchable));
|
|
883
|
+
return {
|
|
884
|
+
path: notePath,
|
|
885
|
+
id: withoutMarkdownExtension(notePath),
|
|
886
|
+
title,
|
|
887
|
+
aliases: metadata.aliases,
|
|
888
|
+
tags: metadata.tags,
|
|
889
|
+
properties: Object.fromEntries(metadata.values),
|
|
890
|
+
metadata: metadata.metadata,
|
|
891
|
+
content,
|
|
892
|
+
summary,
|
|
893
|
+
searchableText: searchable,
|
|
894
|
+
links: wikiLinks(searchable),
|
|
895
|
+
relationDeclarations: metadata.relationDeclarations,
|
|
896
|
+
relationIssues: metadata.relationIssues,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
type LinkResolution =
|
|
901
|
+
| { kind: "note"; id: string }
|
|
902
|
+
| { kind: "attachment" }
|
|
903
|
+
| { kind: "broken" }
|
|
904
|
+
| { kind: "ambiguous"; candidates: readonly string[] };
|
|
905
|
+
|
|
906
|
+
function decoded(value: string): string {
|
|
907
|
+
try {
|
|
908
|
+
return decodeURIComponent(value);
|
|
909
|
+
} catch {
|
|
910
|
+
return value;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export function lookupNote(notes: readonly Note[], query: string): NoteLookupResult {
|
|
915
|
+
const trimmed = decoded(query.trim()).replaceAll("\\", "/").replace(/^\/+/, "");
|
|
916
|
+
if (trimmed === "") return { kind: "missing", query };
|
|
917
|
+
|
|
918
|
+
const queryId = withoutMarkdownExtension(normalizeVaultPath(trimmed));
|
|
919
|
+
const exact = notes.find((note) => note.id === queryId);
|
|
920
|
+
if (exact !== undefined) return { kind: "found", note: exact };
|
|
921
|
+
|
|
922
|
+
const lowerQueryId = queryId.toLocaleLowerCase("en-US");
|
|
923
|
+
const idMatches = notes.filter((note) => note.id.toLocaleLowerCase("en-US") === lowerQueryId);
|
|
924
|
+
const onlyIdMatch = idMatches.length === 1 ? idMatches[0] : undefined;
|
|
925
|
+
if (onlyIdMatch !== undefined) return { kind: "found", note: onlyIdMatch };
|
|
926
|
+
if (idMatches.length > 1) {
|
|
927
|
+
return {
|
|
928
|
+
kind: "ambiguous",
|
|
929
|
+
query,
|
|
930
|
+
candidates: idMatches.toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const lowerLabel = query.trim().toLocaleLowerCase("en-US");
|
|
935
|
+
const lowerBasename = posix.basename(queryId).toLocaleLowerCase("en-US");
|
|
936
|
+
const candidates = notes.filter((note) => {
|
|
937
|
+
const labels = [posix.basename(note.id), note.title, ...note.aliases]
|
|
938
|
+
.map((value) => value.toLocaleLowerCase("en-US"));
|
|
939
|
+
return labels.includes(lowerLabel) || labels.includes(lowerBasename);
|
|
940
|
+
}).toSorted((left, right) => left.path.localeCompare(right.path));
|
|
941
|
+
|
|
942
|
+
const onlyCandidate = candidates.length === 1 ? candidates[0] : undefined;
|
|
943
|
+
if (onlyCandidate !== undefined) return { kind: "found", note: onlyCandidate };
|
|
944
|
+
if (candidates.length > 1) return { kind: "ambiguous", query, candidates };
|
|
945
|
+
return { kind: "missing", query };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function resolveTarget(
|
|
949
|
+
source: Note,
|
|
950
|
+
rawTarget: string,
|
|
951
|
+
byId: ReadonlyMap<string, Note>,
|
|
952
|
+
byBasename: ReadonlyMap<string, readonly string[]>,
|
|
953
|
+
): LinkResolution {
|
|
954
|
+
const targetWithoutAnchor = rawTarget.split("#", 1)[0]?.split("^", 1)[0]?.trim() ?? "";
|
|
955
|
+
if (targetWithoutAnchor === "") return { kind: "note", id: source.id };
|
|
956
|
+
|
|
957
|
+
let target = decoded(targetWithoutAnchor).replaceAll("\\", "/");
|
|
958
|
+
const extension = posix.extname(target).toLowerCase();
|
|
959
|
+
if (extension !== "" && extension !== ".md") return { kind: "attachment" };
|
|
960
|
+
target = withoutMarkdownExtension(target).replace(/^\//, "");
|
|
961
|
+
|
|
962
|
+
if (target.startsWith(".")) {
|
|
963
|
+
const relativeTarget = posix.normalize(posix.join(posix.dirname(source.id), target));
|
|
964
|
+
return byId.has(relativeTarget) ? { kind: "note", id: relativeTarget } : { kind: "broken" };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (byId.has(target)) return { kind: "note", id: target };
|
|
968
|
+
if (target.includes("/")) return { kind: "broken" };
|
|
969
|
+
|
|
970
|
+
const candidates = byBasename.get(target) ?? [];
|
|
971
|
+
if (candidates.length === 1) return { kind: "note", id: candidates[0] ?? target };
|
|
972
|
+
if (candidates.length > 1) return { kind: "ambiguous", candidates };
|
|
973
|
+
return { kind: "broken" };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const pairKey = (left: string, right: string): string =>
|
|
977
|
+
left.localeCompare(right) <= 0 ? `${left}\0${right}` : `${right}\0${left}`;
|
|
978
|
+
|
|
979
|
+
function compareAuthoredRelations(
|
|
980
|
+
left: AuthoredRelation,
|
|
981
|
+
right: AuthoredRelation,
|
|
982
|
+
): number {
|
|
983
|
+
return left.source.localeCompare(right.source)
|
|
984
|
+
|| left.predicate.localeCompare(right.predicate)
|
|
985
|
+
|| left.target.localeCompare(right.target)
|
|
986
|
+
|| left.provenance.line - right.provenance.line
|
|
987
|
+
|| left.provenance.authoredTarget.localeCompare(right.provenance.authoredTarget);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function compareExternalAuthoredRelations(
|
|
991
|
+
left: ExternalAuthoredRelation,
|
|
992
|
+
right: ExternalAuthoredRelation,
|
|
993
|
+
): number {
|
|
994
|
+
return left.source.localeCompare(right.source)
|
|
995
|
+
|| left.predicate.localeCompare(right.predicate)
|
|
996
|
+
|| left.target.localeCompare(right.target)
|
|
997
|
+
|| left.provenance.line - right.provenance.line
|
|
998
|
+
|| left.provenance.authoredTarget.localeCompare(right.provenance.authoredTarget);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function compareRelationIssues(left: RelationIssue, right: RelationIssue): number {
|
|
1002
|
+
const predicateComparison = (left.predicate ?? "").localeCompare(right.predicate ?? "");
|
|
1003
|
+
const targetComparison = (left.target ?? "").localeCompare(right.target ?? "");
|
|
1004
|
+
return left.source.localeCompare(right.source)
|
|
1005
|
+
|| left.line - right.line
|
|
1006
|
+
|| left.kind.localeCompare(right.kind)
|
|
1007
|
+
|| predicateComparison
|
|
1008
|
+
|| targetComparison;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const wordCharacter = (value: string): boolean => /[A-Za-z0-9]/.test(value);
|
|
1012
|
+
|
|
1013
|
+
function phraseOffset(lowerHaystack: string, lowerPhrase: string): number {
|
|
1014
|
+
let offset = lowerHaystack.indexOf(lowerPhrase);
|
|
1015
|
+
while (offset !== -1) {
|
|
1016
|
+
const before = offset === 0 ? "" : lowerHaystack[offset - 1] ?? "";
|
|
1017
|
+
const afterIndex = offset + lowerPhrase.length;
|
|
1018
|
+
const after = afterIndex >= lowerHaystack.length ? "" : lowerHaystack[afterIndex] ?? "";
|
|
1019
|
+
const startsClean = !wordCharacter(lowerPhrase[0] ?? "") || !wordCharacter(before);
|
|
1020
|
+
const endsClean = !wordCharacter(lowerPhrase.at(-1) ?? "") || !wordCharacter(after);
|
|
1021
|
+
if (startsClean && endsClean) return offset;
|
|
1022
|
+
offset = lowerHaystack.indexOf(lowerPhrase, offset + 1);
|
|
1023
|
+
}
|
|
1024
|
+
return -1;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function candidatePhrases(note: Note): readonly string[] {
|
|
1028
|
+
const values = [note.title, ...note.aliases]
|
|
1029
|
+
.map((value) => value.trim())
|
|
1030
|
+
.filter((value) => value.length >= 4 && /[A-Za-z]/.test(value));
|
|
1031
|
+
return [...new Set(values)].sort((left, right) => right.length - left.length);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
type UniquePhrase = {
|
|
1035
|
+
readonly phrase: string;
|
|
1036
|
+
readonly lowerPhrase: string;
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
function uniquePhrasesByTarget(notes: readonly Note[]): ReadonlyMap<string, readonly UniquePhrase[]> {
|
|
1040
|
+
const ownersByPhrase = new Map<string, Map<string, string>>();
|
|
1041
|
+
for (const note of notes) {
|
|
1042
|
+
for (const phrase of candidatePhrases(note)) {
|
|
1043
|
+
const lowerPhrase = phrase.toLocaleLowerCase("en-US");
|
|
1044
|
+
const owners = ownersByPhrase.get(lowerPhrase) ?? new Map<string, string>();
|
|
1045
|
+
if (!owners.has(note.id)) owners.set(note.id, phrase);
|
|
1046
|
+
ownersByPhrase.set(lowerPhrase, owners);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const phrasesByTarget = new Map<string, UniquePhrase[]>();
|
|
1051
|
+
for (const [lowerPhrase, owners] of ownersByPhrase) {
|
|
1052
|
+
if (owners.size !== 1) continue;
|
|
1053
|
+
const owner = owners.entries().next().value;
|
|
1054
|
+
if (owner === undefined) continue;
|
|
1055
|
+
const [targetId, phrase] = owner;
|
|
1056
|
+
const phrases = phrasesByTarget.get(targetId) ?? [];
|
|
1057
|
+
phrases.push({ phrase, lowerPhrase });
|
|
1058
|
+
phrasesByTarget.set(targetId, phrases);
|
|
1059
|
+
}
|
|
1060
|
+
for (const phrases of phrasesByTarget.values()) {
|
|
1061
|
+
phrases.sort((left, right) =>
|
|
1062
|
+
right.lowerPhrase.length - left.lowerPhrase.length
|
|
1063
|
+
|| left.lowerPhrase.localeCompare(right.lowerPhrase));
|
|
1064
|
+
}
|
|
1065
|
+
return phrasesByTarget;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function checkedAnalysisLimit(
|
|
1069
|
+
value: number | undefined,
|
|
1070
|
+
hardMaximum: number,
|
|
1071
|
+
option:
|
|
1072
|
+
| "maxNotes"
|
|
1073
|
+
| "maxConnectionObservations"
|
|
1074
|
+
| "maxMentionPairs"
|
|
1075
|
+
| "maxMentions",
|
|
1076
|
+
): number {
|
|
1077
|
+
const limit = value ?? hardMaximum;
|
|
1078
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > hardMaximum) {
|
|
1079
|
+
throw new RangeError(
|
|
1080
|
+
`${option} must be a safe integer from 0 through ${hardMaximum}.`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
return limit;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export function analyzeVault(
|
|
1087
|
+
notes: readonly Note[],
|
|
1088
|
+
options: AnalyzeVaultOptions = {},
|
|
1089
|
+
): VaultAnalysis {
|
|
1090
|
+
const maxNotes = checkedAnalysisLimit(
|
|
1091
|
+
options.maxNotes,
|
|
1092
|
+
MAX_ANALYZED_NOTES,
|
|
1093
|
+
"maxNotes",
|
|
1094
|
+
);
|
|
1095
|
+
const maxMentionPairs = checkedAnalysisLimit(
|
|
1096
|
+
options.maxMentionPairs,
|
|
1097
|
+
MAX_MENTION_PAIRS,
|
|
1098
|
+
"maxMentionPairs",
|
|
1099
|
+
);
|
|
1100
|
+
const maxMentions = checkedAnalysisLimit(
|
|
1101
|
+
options.maxMentions,
|
|
1102
|
+
MAX_MENTIONS,
|
|
1103
|
+
"maxMentions",
|
|
1104
|
+
);
|
|
1105
|
+
const maxConnectionObservations = checkedAnalysisLimit(
|
|
1106
|
+
options.maxConnectionObservations,
|
|
1107
|
+
MAX_CONNECTION_OBSERVATIONS,
|
|
1108
|
+
"maxConnectionObservations",
|
|
1109
|
+
);
|
|
1110
|
+
if (notes.length > maxNotes) {
|
|
1111
|
+
throw new VaultAnalysisBudgetError(
|
|
1112
|
+
"notes",
|
|
1113
|
+
maxNotes,
|
|
1114
|
+
`Vault analysis exceeds the ${maxNotes} note limit.`,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
const catalogNoteId = withoutMarkdownExtension(
|
|
1118
|
+
normalizeVaultPath(options.catalogNoteId ?? "index"),
|
|
1119
|
+
);
|
|
1120
|
+
const byId = new Map(notes.map((note) => [note.id, note]));
|
|
1121
|
+
const byBasename = new Map<string, string[]>();
|
|
1122
|
+
for (const note of notes) {
|
|
1123
|
+
const basename = posix.basename(note.id);
|
|
1124
|
+
const matches = byBasename.get(basename) ?? [];
|
|
1125
|
+
matches.push(note.id);
|
|
1126
|
+
byBasename.set(basename, matches);
|
|
1127
|
+
}
|
|
1128
|
+
for (const matches of byBasename.values()) matches.sort();
|
|
1129
|
+
|
|
1130
|
+
let connectionObservations = 0;
|
|
1131
|
+
const observeConnection = (): void => {
|
|
1132
|
+
if (connectionObservations >= maxConnectionObservations) {
|
|
1133
|
+
throw new VaultAnalysisBudgetError(
|
|
1134
|
+
"connection-observations",
|
|
1135
|
+
maxConnectionObservations,
|
|
1136
|
+
`Vault analysis exceeds the ${maxConnectionObservations} `
|
|
1137
|
+
+ "connection-observation limit.",
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
connectionObservations += 1;
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
const issues: LinkIssue[] = [];
|
|
1144
|
+
const relationIssues: RelationIssue[] = [];
|
|
1145
|
+
for (const note of notes) {
|
|
1146
|
+
for (const issue of note.relationIssues ?? []) {
|
|
1147
|
+
observeConnection();
|
|
1148
|
+
relationIssues.push(issue);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const contextualLinks: ResolvedLink[] = [];
|
|
1152
|
+
const edgeKeys = new Set<string>();
|
|
1153
|
+
for (const source of notes) {
|
|
1154
|
+
for (const link of source.links) {
|
|
1155
|
+
observeConnection();
|
|
1156
|
+
const resolution = resolveTarget(source, link.target, byId, byBasename);
|
|
1157
|
+
if (resolution.kind === "attachment") continue;
|
|
1158
|
+
if (resolution.kind === "broken") {
|
|
1159
|
+
issues.push({ kind: "broken", source: source.path, line: link.line, target: link.target });
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
if (resolution.kind === "ambiguous") {
|
|
1163
|
+
issues.push({
|
|
1164
|
+
kind: "ambiguous",
|
|
1165
|
+
source: source.path,
|
|
1166
|
+
line: link.line,
|
|
1167
|
+
target: link.target,
|
|
1168
|
+
candidates: resolution.candidates,
|
|
1169
|
+
});
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
if (
|
|
1173
|
+
source.id === catalogNoteId
|
|
1174
|
+
|| resolution.id === catalogNoteId
|
|
1175
|
+
|| source.id === resolution.id
|
|
1176
|
+
) continue;
|
|
1177
|
+
const edgeKey = `${source.id}\0${resolution.id}`;
|
|
1178
|
+
if (edgeKeys.has(edgeKey)) continue;
|
|
1179
|
+
edgeKeys.add(edgeKey);
|
|
1180
|
+
contextualLinks.push({ source: source.path, target: `${resolution.id}.md`, line: link.line });
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const authoredRelations: AuthoredRelation[] = [];
|
|
1185
|
+
const externalAuthoredRelations: ExternalAuthoredRelation[] = [];
|
|
1186
|
+
const declarationKeys = new Set<string>();
|
|
1187
|
+
const relationEdgeKeys = new Set<string>();
|
|
1188
|
+
for (const source of notes) {
|
|
1189
|
+
const declarations: RelationDeclaration[] = [];
|
|
1190
|
+
for (const declaration of source.relationDeclarations ?? []) {
|
|
1191
|
+
observeConnection();
|
|
1192
|
+
declarations.push(declaration);
|
|
1193
|
+
}
|
|
1194
|
+
declarations.sort((left, right) =>
|
|
1195
|
+
left.line - right.line
|
|
1196
|
+
|| left.predicate.localeCompare(right.predicate)
|
|
1197
|
+
|| left.target.localeCompare(right.target));
|
|
1198
|
+
for (const declaration of declarations) {
|
|
1199
|
+
const declarationKey = `${source.id}\0${declaration.predicate}\0${declaration.target}`;
|
|
1200
|
+
if (declarationKeys.has(declarationKey)) continue;
|
|
1201
|
+
declarationKeys.add(declarationKey);
|
|
1202
|
+
|
|
1203
|
+
if (declaration.target.startsWith("kb://")) {
|
|
1204
|
+
if (source.id !== catalogNoteId) {
|
|
1205
|
+
externalAuthoredRelations.push({
|
|
1206
|
+
source: source.id,
|
|
1207
|
+
target: parseQualifiedDocumentUri(declaration.target).uri,
|
|
1208
|
+
predicate: declaration.predicate,
|
|
1209
|
+
provenance: {
|
|
1210
|
+
kind: "frontmatter",
|
|
1211
|
+
source: source.path,
|
|
1212
|
+
line: declaration.line,
|
|
1213
|
+
authoredTarget: declaration.target,
|
|
1214
|
+
},
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const exactTarget = byId.get(declaration.target);
|
|
1221
|
+
if (exactTarget === undefined) {
|
|
1222
|
+
const basenameMatches = declaration.target.includes("/")
|
|
1223
|
+
? []
|
|
1224
|
+
: (byBasename.get(declaration.target) ?? [])
|
|
1225
|
+
.filter((candidate) => candidate !== declaration.target);
|
|
1226
|
+
if (basenameMatches.length > 0) {
|
|
1227
|
+
relationIssues.push(malformedRelation(
|
|
1228
|
+
source.path,
|
|
1229
|
+
declaration.line,
|
|
1230
|
+
`Relation "${declaration.predicate}" target `
|
|
1231
|
+
+ `${JSON.stringify(declaration.target)} is only a basename; `
|
|
1232
|
+
+ "store the exact vault-root note ID.",
|
|
1233
|
+
{
|
|
1234
|
+
predicate: declaration.predicate,
|
|
1235
|
+
target: declaration.target,
|
|
1236
|
+
},
|
|
1237
|
+
));
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
relationIssues.push({
|
|
1241
|
+
kind: "broken",
|
|
1242
|
+
source: source.path,
|
|
1243
|
+
line: declaration.line,
|
|
1244
|
+
predicate: declaration.predicate,
|
|
1245
|
+
target: declaration.target,
|
|
1246
|
+
});
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
if (source.id === catalogNoteId || exactTarget.id === catalogNoteId) continue;
|
|
1250
|
+
|
|
1251
|
+
const edgeKey = `${source.id}\0${declaration.predicate}\0${exactTarget.id}`;
|
|
1252
|
+
if (relationEdgeKeys.has(edgeKey)) continue;
|
|
1253
|
+
relationEdgeKeys.add(edgeKey);
|
|
1254
|
+
authoredRelations.push({
|
|
1255
|
+
source: source.id,
|
|
1256
|
+
target: exactTarget.id,
|
|
1257
|
+
predicate: declaration.predicate,
|
|
1258
|
+
provenance: {
|
|
1259
|
+
kind: "frontmatter",
|
|
1260
|
+
source: source.path,
|
|
1261
|
+
line: declaration.line,
|
|
1262
|
+
authoredTarget: declaration.target,
|
|
1263
|
+
},
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const sortedContextualLinks = contextualLinks.toSorted((left, right) =>
|
|
1269
|
+
left.source.localeCompare(right.source)
|
|
1270
|
+
|| left.target.localeCompare(right.target)
|
|
1271
|
+
|| left.line - right.line);
|
|
1272
|
+
const sortedAuthoredRelations = authoredRelations.toSorted(compareAuthoredRelations);
|
|
1273
|
+
const sortedExternalAuthoredRelations = externalAuthoredRelations
|
|
1274
|
+
.toSorted(compareExternalAuthoredRelations);
|
|
1275
|
+
const backlinks = sortedContextualLinks.toSorted((left, right) =>
|
|
1276
|
+
left.target.localeCompare(right.target)
|
|
1277
|
+
|| left.source.localeCompare(right.source)
|
|
1278
|
+
|| left.line - right.line);
|
|
1279
|
+
const contentNotes = notes.filter((note) => note.id !== catalogNoteId);
|
|
1280
|
+
const connected = new Set<string>();
|
|
1281
|
+
const linkedPairs = new Set<string>();
|
|
1282
|
+
const inboundById = new Map<string, Backlink[]>();
|
|
1283
|
+
const outboundById = new Map<string, number>();
|
|
1284
|
+
const inboundRelationsById = new Map<string, AuthoredRelation[]>();
|
|
1285
|
+
const outboundRelationsById = new Map<string, number>();
|
|
1286
|
+
for (const link of sortedContextualLinks) {
|
|
1287
|
+
const sourceId = withoutMarkdownExtension(link.source);
|
|
1288
|
+
const targetId = withoutMarkdownExtension(link.target);
|
|
1289
|
+
connected.add(sourceId);
|
|
1290
|
+
connected.add(targetId);
|
|
1291
|
+
linkedPairs.add(pairKey(sourceId, targetId));
|
|
1292
|
+
const inbound = inboundById.get(targetId) ?? [];
|
|
1293
|
+
inbound.push(link);
|
|
1294
|
+
inboundById.set(targetId, inbound);
|
|
1295
|
+
outboundById.set(sourceId, (outboundById.get(sourceId) ?? 0) + 1);
|
|
1296
|
+
}
|
|
1297
|
+
for (const relation of sortedAuthoredRelations) {
|
|
1298
|
+
connected.add(relation.source);
|
|
1299
|
+
connected.add(relation.target);
|
|
1300
|
+
linkedPairs.add(pairKey(relation.source, relation.target));
|
|
1301
|
+
const inbound = inboundRelationsById.get(relation.target) ?? [];
|
|
1302
|
+
inbound.push(relation);
|
|
1303
|
+
inboundRelationsById.set(relation.target, inbound);
|
|
1304
|
+
outboundRelationsById.set(
|
|
1305
|
+
relation.source,
|
|
1306
|
+
(outboundRelationsById.get(relation.source) ?? 0) + 1,
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
for (const relation of sortedExternalAuthoredRelations) {
|
|
1310
|
+
connected.add(relation.source);
|
|
1311
|
+
outboundRelationsById.set(
|
|
1312
|
+
relation.source,
|
|
1313
|
+
(outboundRelationsById.get(relation.source) ?? 0) + 1,
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const includeInSuggestions = options.includeInSuggestions ?? (() => true);
|
|
1318
|
+
const suggestionNotes = contentNotes.filter(includeInSuggestions);
|
|
1319
|
+
const scopedMentionNotes = options.mentionScope === undefined
|
|
1320
|
+
? suggestionNotes
|
|
1321
|
+
: suggestionNotes.filter(options.mentionScope);
|
|
1322
|
+
const scopedMentionIds = new Set(scopedMentionNotes.map((note) => note.id));
|
|
1323
|
+
const phrasesByTarget = scopedMentionNotes.length === 0
|
|
1324
|
+
? new Map<string, readonly UniquePhrase[]>()
|
|
1325
|
+
: uniquePhrasesByTarget(suggestionNotes);
|
|
1326
|
+
const mentions: MentionCandidate[] = [];
|
|
1327
|
+
let mentionPairs = 0;
|
|
1328
|
+
for (const source of suggestionNotes) {
|
|
1329
|
+
const targets = options.mentionScope === undefined
|
|
1330
|
+
|| scopedMentionIds.has(source.id)
|
|
1331
|
+
? suggestionNotes
|
|
1332
|
+
: scopedMentionNotes;
|
|
1333
|
+
let lowerSearchableText: string | undefined;
|
|
1334
|
+
for (const target of targets) {
|
|
1335
|
+
if (source.id === target.id) continue;
|
|
1336
|
+
if (mentionPairs >= maxMentionPairs) {
|
|
1337
|
+
throw new VaultAnalysisBudgetError(
|
|
1338
|
+
"mention-pairs",
|
|
1339
|
+
maxMentionPairs,
|
|
1340
|
+
`Vault analysis exceeds the ${maxMentionPairs} mention-pair limit.`,
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
mentionPairs += 1;
|
|
1344
|
+
if (linkedPairs.has(pairKey(source.id, target.id))) continue;
|
|
1345
|
+
lowerSearchableText ??= source.searchableText.toLocaleLowerCase("en-US");
|
|
1346
|
+
for (const { phrase, lowerPhrase } of phrasesByTarget.get(target.id) ?? []) {
|
|
1347
|
+
const offset = phraseOffset(lowerSearchableText, lowerPhrase);
|
|
1348
|
+
if (offset === -1) continue;
|
|
1349
|
+
if (mentions.length >= maxMentions) {
|
|
1350
|
+
throw new VaultAnalysisBudgetError(
|
|
1351
|
+
"mentions",
|
|
1352
|
+
maxMentions,
|
|
1353
|
+
`Vault analysis exceeds the ${maxMentions} mention limit.`,
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
mentions.push({
|
|
1357
|
+
source: source.path,
|
|
1358
|
+
line: source.searchableText.slice(0, offset).split("\n").length,
|
|
1359
|
+
target: target.path,
|
|
1360
|
+
phrase,
|
|
1361
|
+
});
|
|
1362
|
+
break;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
return {
|
|
1368
|
+
noteCount: contentNotes.length,
|
|
1369
|
+
contextualLinks: sortedContextualLinks,
|
|
1370
|
+
backlinks,
|
|
1371
|
+
authoredRelations: sortedAuthoredRelations,
|
|
1372
|
+
externalAuthoredRelations: sortedExternalAuthoredRelations,
|
|
1373
|
+
noteConnections: contentNotes
|
|
1374
|
+
.map((note): NoteConnections => ({
|
|
1375
|
+
id: note.id,
|
|
1376
|
+
path: note.path,
|
|
1377
|
+
inboundContextualCount: inboundById.get(note.id)?.length ?? 0,
|
|
1378
|
+
outboundContextualCount: outboundById.get(note.id) ?? 0,
|
|
1379
|
+
backlinks: inboundById.get(note.id) ?? [],
|
|
1380
|
+
inboundRelationCount: inboundRelationsById.get(note.id)?.length ?? 0,
|
|
1381
|
+
outboundRelationCount: outboundRelationsById.get(note.id) ?? 0,
|
|
1382
|
+
relationBacklinks: inboundRelationsById.get(note.id) ?? [],
|
|
1383
|
+
}))
|
|
1384
|
+
.toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
1385
|
+
issues: issues.sort((left, right) =>
|
|
1386
|
+
left.source.localeCompare(right.source) || left.line - right.line || left.target.localeCompare(right.target)),
|
|
1387
|
+
relationIssues: relationIssues.toSorted(compareRelationIssues),
|
|
1388
|
+
orphans: suggestionNotes
|
|
1389
|
+
.filter((note) => !connected.has(note.id))
|
|
1390
|
+
.map((note) => note.path)
|
|
1391
|
+
.sort(),
|
|
1392
|
+
mentions: mentions.sort((left, right) =>
|
|
1393
|
+
left.source.localeCompare(right.source) || left.line - right.line || left.target.localeCompare(right.target)),
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const sectionTitle = (directory: string): string =>
|
|
1398
|
+
directory
|
|
1399
|
+
.split("-")
|
|
1400
|
+
.map((part) => part === "" ? "" : (part[0]?.toUpperCase() ?? "") + part.slice(1))
|
|
1401
|
+
.join(" ");
|
|
1402
|
+
|
|
1403
|
+
function safeCatalogCharacter(character: string): string {
|
|
1404
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
1405
|
+
return codePoint <= 0x1f
|
|
1406
|
+
|| (codePoint >= 0x7f && codePoint <= 0x9f)
|
|
1407
|
+
|| codePoint === 0x061c
|
|
1408
|
+
|| codePoint === 0x200e
|
|
1409
|
+
|| codePoint === 0x200f
|
|
1410
|
+
|| (codePoint >= 0x2028 && codePoint <= 0x202e)
|
|
1411
|
+
|| (codePoint >= 0x2066 && codePoint <= 0x2069)
|
|
1412
|
+
? " "
|
|
1413
|
+
: character;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function safeCatalogText(value: string, limit: number): string {
|
|
1417
|
+
return [...concise(value, limit)]
|
|
1418
|
+
.map(safeCatalogCharacter)
|
|
1419
|
+
.join("")
|
|
1420
|
+
.replaceAll("<!--", "‹!--")
|
|
1421
|
+
.replaceAll("-->", "--›")
|
|
1422
|
+
.replaceAll("|", " — ")
|
|
1423
|
+
.replaceAll("]]", "]")
|
|
1424
|
+
.replace(/\s+/gu, " ")
|
|
1425
|
+
.trim();
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function safeWikiTarget(value: string): string {
|
|
1429
|
+
return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
export function renderCatalog(
|
|
1433
|
+
notes: readonly Note[],
|
|
1434
|
+
catalogNoteId = "index",
|
|
1435
|
+
): string {
|
|
1436
|
+
const normalizedCatalogNoteId = withoutMarkdownExtension(
|
|
1437
|
+
normalizeVaultPath(catalogNoteId),
|
|
1438
|
+
);
|
|
1439
|
+
const groups = new Map<string, Note[]>();
|
|
1440
|
+
for (const note of notes.filter((candidate) => candidate.id !== normalizedCatalogNoteId)) {
|
|
1441
|
+
const directory = note.id.includes("/") ? note.id.split("/", 1)[0] ?? "Notes" : "Notes";
|
|
1442
|
+
const group = groups.get(directory) ?? [];
|
|
1443
|
+
group.push(note);
|
|
1444
|
+
groups.set(directory, group);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const lines = [catalogStart, "## Note catalog", ""];
|
|
1448
|
+
if (groups.size === 0) {
|
|
1449
|
+
lines.push("_No durable notes have been filed yet._", "", catalogEnd);
|
|
1450
|
+
return lines.join("\n");
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
for (const [directory, group] of [...groups].sort(([left], [right]) => left.localeCompare(right))) {
|
|
1454
|
+
lines.push(`### ${safeCatalogText(sectionTitle(directory), 120) || "Notes"}`, "");
|
|
1455
|
+
for (const note of group.sort((left, right) =>
|
|
1456
|
+
left.title.localeCompare(right.title) || left.id.localeCompare(right.id))) {
|
|
1457
|
+
const details = [
|
|
1458
|
+
note.properties.type === "plan" && note.properties.status !== undefined
|
|
1459
|
+
? `Status: ${safeCatalogText(note.properties.status, 60)}.`
|
|
1460
|
+
: "",
|
|
1461
|
+
safeCatalogText(note.summary, 180),
|
|
1462
|
+
].filter((value) => value !== "").join(" ");
|
|
1463
|
+
const suffix = details === "" ? "" : ` — ${details}`;
|
|
1464
|
+
const label = safeCatalogText(note.title, 240)
|
|
1465
|
+
|| safeCatalogText(posix.basename(note.id), 240)
|
|
1466
|
+
|| "Untitled";
|
|
1467
|
+
lines.push(`- [[${safeWikiTarget(note.id)}|${label}]]${suffix}`);
|
|
1468
|
+
}
|
|
1469
|
+
lines.push("");
|
|
1470
|
+
}
|
|
1471
|
+
lines.push(catalogEnd);
|
|
1472
|
+
return lines.join("\n");
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
export function replaceCatalog(indexContent: string, catalog: string): string {
|
|
1476
|
+
const start = indexContent.indexOf(catalogStart);
|
|
1477
|
+
const end = indexContent.indexOf(catalogEnd);
|
|
1478
|
+
if (start === -1 && end === -1) return indexContent.trimEnd() + "\n\n" + catalog + "\n";
|
|
1479
|
+
if (start === -1 || end === -1 || end < start) {
|
|
1480
|
+
throw new Error("kb/index.md has a malformed managed catalog boundary");
|
|
1481
|
+
}
|
|
1482
|
+
if (indexContent.indexOf(catalogStart, start + catalogStart.length) !== -1
|
|
1483
|
+
|| indexContent.indexOf(catalogEnd, end + catalogEnd.length) !== -1) {
|
|
1484
|
+
throw new Error("kb/index.md has duplicate managed catalog boundaries");
|
|
1485
|
+
}
|
|
1486
|
+
return indexContent.slice(0, start) + catalog + indexContent.slice(end + catalogEnd.length);
|
|
1487
|
+
}
|