@hraness/wordcell 0.20.0-bootstrap.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.
Files changed (215) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +749 -0
  3. package/dist/agent-context.js +35 -0
  4. package/dist/agent-guide-audit.js +18 -0
  5. package/dist/attachments.js +19 -0
  6. package/dist/authoring.js +33 -0
  7. package/dist/benchmark.js +19 -0
  8. package/dist/capture.js +49 -0
  9. package/dist/cli.js +57 -0
  10. package/dist/clip/acquire.js +57 -0
  11. package/dist/clip/args.js +21 -0
  12. package/dist/clip/bounded-byte-buffer.js +9 -0
  13. package/dist/clip/browser-profiles.js +17 -0
  14. package/dist/clip/bundle-reader.js +11 -0
  15. package/dist/clip/cli.js +27 -0
  16. package/dist/clip/cookies.js +21 -0
  17. package/dist/clip/defuddle-worker.js +84 -0
  18. package/dist/clip/doctor.js +32 -0
  19. package/dist/clip/jobs.js +784 -0
  20. package/dist/clip/network-proxy.js +9 -0
  21. package/dist/clip/network.js +28 -0
  22. package/dist/clip/persist.js +30 -0
  23. package/dist/clip/refresh.js +11 -0
  24. package/dist/clip/terminal.js +11 -0
  25. package/dist/evaluation-builder.js +12238 -0
  26. package/dist/evaluation-kb.js +26 -0
  27. package/dist/evaluation.js +35 -0
  28. package/dist/git.js +39 -0
  29. package/dist/graph.js +41 -0
  30. package/dist/index-054mb7d3.js +1974 -0
  31. package/dist/index-06c9ctr6.js +738 -0
  32. package/dist/index-180zbkkt.js +2480 -0
  33. package/dist/index-1gwbassd.js +743 -0
  34. package/dist/index-1xxnjn0d.js +171 -0
  35. package/dist/index-23z4zxgg.js +119 -0
  36. package/dist/index-2gv8y733.js +432 -0
  37. package/dist/index-3ehtt59c.js +199 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-48pz4jpc.js +392 -0
  40. package/dist/index-4j3tt0c3.js +234 -0
  41. package/dist/index-5n05se68.js +307 -0
  42. package/dist/index-5vwpzb5a.js +764 -0
  43. package/dist/index-6jcz0m1c.js +712 -0
  44. package/dist/index-6sw24nvv.js +1189 -0
  45. package/dist/index-8a08v6x8.js +56 -0
  46. package/dist/index-adx6khj5.js +488 -0
  47. package/dist/index-b88v3vtm.js +805 -0
  48. package/dist/index-bcknqxrq.js +1943 -0
  49. package/dist/index-bs0yf4pp.js +711 -0
  50. package/dist/index-by0pbant.js +827 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dfag79p7.js +370 -0
  53. package/dist/index-e4fagfjz.js +44 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-ekpwvbra.js +1070 -0
  56. package/dist/index-f984hw45.js +927 -0
  57. package/dist/index-gh719d91.js +63 -0
  58. package/dist/index-h170byqw.js +371 -0
  59. package/dist/index-hgve9rh2.js +2046 -0
  60. package/dist/index-hya40gb2.js +474 -0
  61. package/dist/index-j4zgmzjr.js +152 -0
  62. package/dist/index-m7et4rz4.js +966 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n05s3wsb.js +2728 -0
  65. package/dist/index-nd6nynv2.js +1162 -0
  66. package/dist/index-npg9z1a4.js +431 -0
  67. package/dist/index-pj501bh1.js +140 -0
  68. package/dist/index-rdd5f7jj.js +70 -0
  69. package/dist/index-sy0zety7.js +3630 -0
  70. package/dist/index-tkpxp48t.js +1166 -0
  71. package/dist/index-w2zc0vwa.js +571 -0
  72. package/dist/index-why54q5p.js +462 -0
  73. package/dist/index-x3fthpsc.js +585 -0
  74. package/dist/index-x6m57nfc.js +1041 -0
  75. package/dist/index.js +784 -0
  76. package/dist/kb-alias.js +55 -0
  77. package/dist/navigation.js +13 -0
  78. package/dist/pdf.js +85 -0
  79. package/dist/percolate.js +44 -0
  80. package/dist/portfolio.js +77 -0
  81. package/dist/query.js +29 -0
  82. package/dist/repository-memory.js +60 -0
  83. package/dist/sdk.js +36 -0
  84. package/dist/search-rules.js +34 -0
  85. package/dist/search.js +22 -0
  86. package/dist/semantic.js +39 -0
  87. package/dist/source-inbox.js +17 -0
  88. package/dist/untrusted-content.js +32 -0
  89. package/dist/url-intelligence.js +74 -0
  90. package/dist/workflow.js +23 -0
  91. package/dist/workflows/decision-context.js +20 -0
  92. package/dist/workflows/explain-change.js +8 -0
  93. package/dist/workflows/index.js +28 -0
  94. package/dist/workflows/plan-radar.js +10 -0
  95. package/package.json +421 -0
  96. package/skills/wordcell/AGENTS.md +22 -0
  97. package/skills/wordcell/SKILL.md +91 -0
  98. package/skills/wordcell/agents/openai.yaml +4 -0
  99. package/skills/wordcell/references/companion-skills.md +96 -0
  100. package/skills/wordcell/references/customize.md +123 -0
  101. package/skills/wordcell/references/pdf-review.md +51 -0
  102. package/skills/wordcell/references/percolate.md +176 -0
  103. package/skills/wordcell/references/plan-structure.md +105 -0
  104. package/skills/wordcell/references/plan.md +115 -0
  105. package/skills/wordcell/references/query.md +252 -0
  106. package/skills/wordcell/references/refresh.md +133 -0
  107. package/skills/wordcell/references/save-pdf.md +125 -0
  108. package/skills/wordcell/references/save-url.md +178 -0
  109. package/skills/wordcell/references/url-authentication.md +75 -0
  110. package/skills/wordcell/references/url-platforms.md +25 -0
  111. package/skills/wordcell/templates/companion-skill.template.md +57 -0
  112. package/src/agent-context.ts +1241 -0
  113. package/src/agent-guide-audit.ts +750 -0
  114. package/src/attachments.ts +723 -0
  115. package/src/authoring-model.ts +798 -0
  116. package/src/authoring-platform.ts +549 -0
  117. package/src/authoring-program.ts +278 -0
  118. package/src/authoring-runtime.ts +54 -0
  119. package/src/authoring.ts +75 -0
  120. package/src/benchmark.ts +362 -0
  121. package/src/capture.ts +68 -0
  122. package/src/cli-program.ts +3662 -0
  123. package/src/cli.ts +6 -0
  124. package/src/clip/acquire.ts +1273 -0
  125. package/src/clip/archive-today.ts +402 -0
  126. package/src/clip/args.ts +423 -0
  127. package/src/clip/assets.ts +262 -0
  128. package/src/clip/bounded-byte-buffer.ts +75 -0
  129. package/src/clip/browser-profiles.ts +372 -0
  130. package/src/clip/bundle-reader.ts +608 -0
  131. package/src/clip/capture.ts +1054 -0
  132. package/src/clip/cli.ts +181 -0
  133. package/src/clip/cookies.ts +512 -0
  134. package/src/clip/doctor.ts +1110 -0
  135. package/src/clip/extract.ts +868 -0
  136. package/src/clip/jobs.ts +959 -0
  137. package/src/clip/lib.ts +861 -0
  138. package/src/clip/media.ts +1474 -0
  139. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  140. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  141. package/src/clip/metadata-search-tool/runner.ts +685 -0
  142. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  143. package/src/clip/metadata-search.ts +687 -0
  144. package/src/clip/network-proxy.ts +627 -0
  145. package/src/clip/network.ts +950 -0
  146. package/src/clip/package-root.ts +54 -0
  147. package/src/clip/persist.ts +1472 -0
  148. package/src/clip/platforms.ts +1824 -0
  149. package/src/clip/refresh.ts +197 -0
  150. package/src/clip/structured.ts +432 -0
  151. package/src/clip/terminal.ts +197 -0
  152. package/src/clip/url-intelligence.ts +581 -0
  153. package/src/clip/url-metadata-backfill.ts +616 -0
  154. package/src/clip/url-metadata-cli.ts +251 -0
  155. package/src/clip/url-metadata.ts +1397 -0
  156. package/src/evaluation-analysis-v2.ts +1440 -0
  157. package/src/evaluation-builder.ts +2539 -0
  158. package/src/evaluation-corpus-authoring.ts +2052 -0
  159. package/src/evaluation-evidence.ts +1147 -0
  160. package/src/evaluation-execution-v2.ts +426 -0
  161. package/src/evaluation-implementation.ts +131 -0
  162. package/src/evaluation-kb-closure.ts +1430 -0
  163. package/src/evaluation-kb-runner-v2.ts +735 -0
  164. package/src/evaluation-kb-v2.ts +2567 -0
  165. package/src/evaluation-kb.ts +1018 -0
  166. package/src/evaluation-measurement-v2.ts +1462 -0
  167. package/src/evaluation-packing-v2.ts +452 -0
  168. package/src/evaluation-redaction.ts +28 -0
  169. package/src/evaluation-v2.ts +3857 -0
  170. package/src/evaluation.ts +1220 -0
  171. package/src/git.ts +1276 -0
  172. package/src/graph.ts +1494 -0
  173. package/src/index.ts +21 -0
  174. package/src/init.ts +123 -0
  175. package/src/kb-alias.ts +14 -0
  176. package/src/navigation.ts +277 -0
  177. package/src/note-lock.ts +529 -0
  178. package/src/oh-adoption.ts +444 -0
  179. package/src/pdf/args.ts +202 -0
  180. package/src/pdf/capture.ts +298 -0
  181. package/src/pdf/cli.ts +159 -0
  182. package/src/pdf/extract.ts +303 -0
  183. package/src/pdf/layout.ts +618 -0
  184. package/src/pdf/markdown.ts +154 -0
  185. package/src/pdf/model.ts +234 -0
  186. package/src/pdf/ocr.ts +190 -0
  187. package/src/pdf/persist.ts +314 -0
  188. package/src/pdf/source.ts +135 -0
  189. package/src/pdf/tools.ts +146 -0
  190. package/src/pdf.ts +92 -0
  191. package/src/percolate.ts +1948 -0
  192. package/src/portfolio-audit.ts +574 -0
  193. package/src/portfolio-identity.ts +162 -0
  194. package/src/portfolio-registry.ts +473 -0
  195. package/src/portfolio.ts +765 -0
  196. package/src/query.ts +646 -0
  197. package/src/repository-memory.ts +1150 -0
  198. package/src/sdk.ts +1452 -0
  199. package/src/search-rules.ts +712 -0
  200. package/src/search.ts +710 -0
  201. package/src/semantic-runtime.ts +1139 -0
  202. package/src/semantic.ts +2480 -0
  203. package/src/source-inbox.ts +232 -0
  204. package/src/untrusted-content.ts +401 -0
  205. package/src/url-intelligence.ts +104 -0
  206. package/src/vault.ts +636 -0
  207. package/src/workflow-model.ts +444 -0
  208. package/src/workflow-platform.ts +74 -0
  209. package/src/workflow-program.ts +183 -0
  210. package/src/workflow-runtime.ts +51 -0
  211. package/src/workflow.ts +23 -0
  212. package/src/workflows/decision-context.ts +81 -0
  213. package/src/workflows/explain-change.ts +73 -0
  214. package/src/workflows/index.ts +3 -0
  215. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,798 @@
1
+ import { createHash } from "node:crypto";
2
+ import { posix, relative, resolve, sep } from "node:path";
3
+ import { Document, isMap, isScalar, isSeq, parseDocument, type YAMLMap, type YAMLSeq } from "yaml";
4
+ import { type NoteLockOptions } from "./note-lock.js";
5
+ import { isCanonicalNoteId, isCanonicalRelationPredicate } from "./graph.js";
6
+ import { parseDocumentId, parseQualifiedDocumentUri } from "./portfolio-identity.js";
7
+
8
+ export const MAX_NOTE_BYTES = 16 * 1024 * 1024;
9
+ export const NOTE_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u;
10
+ export const MAX_PARENT_DIRECTORY_ENTRIES = 100_000;
11
+ export const MAX_RECOVERY_LOCATIONS_PER_NOTE = 8;
12
+
13
+ export type NoteRevision = `sha256:${string}`;
14
+
15
+ export interface NoteRelation {
16
+ readonly predicate: string;
17
+ readonly target: string;
18
+ }
19
+
20
+ export interface NoteAuthoringResult {
21
+ readonly changed: boolean;
22
+ /** Exact vault-relative Markdown path. */
23
+ readonly path: string;
24
+ readonly revision: NoteRevision;
25
+ readonly relations: readonly NoteRelation[];
26
+ /** Stable authored identity when the note has one valid document_id. */
27
+ readonly documentId?: string;
28
+ }
29
+
30
+ export interface CreateNoteInput {
31
+ /** Exact extensionless vault-root note ID, for example `notes/local-first`. */
32
+ readonly id: string;
33
+ /** Stable ID independent of note path. Generated for new notes when omitted. */
34
+ readonly documentId?: string;
35
+ readonly title: string;
36
+ readonly type: string;
37
+ readonly tags?: readonly string[];
38
+ /** Markdown after frontmatter. Defaults to one H1; a final newline is added. */
39
+ readonly body?: string;
40
+ }
41
+
42
+ export interface CreateConceptNoteInput {
43
+ readonly id: string;
44
+ readonly documentId?: string;
45
+ readonly title: string;
46
+ readonly tags?: readonly string[];
47
+ readonly body?: string;
48
+ }
49
+
50
+ export interface AuthoringInstallContext {
51
+ readonly operation: "create" | "replace";
52
+ readonly path: string;
53
+ readonly temporaryPath: string;
54
+ /**
55
+ * Private recovery path used by replacements. It is absent for creates and
56
+ * remains on disk only when restoring it without clobbering a raced writer
57
+ * is impossible.
58
+ */
59
+ readonly recoveryPath?: string;
60
+ }
61
+
62
+ export interface AuthoringDependencies {
63
+ /** Stable authored document identity. Distinct from transaction filenames. */
64
+ readonly documentId: () => string;
65
+ /** Private transaction/recovery filename token. */
66
+ readonly token: () => string;
67
+ /**
68
+ * Test and embedding seam immediately before ownership and source revision
69
+ * are rechecked. Callers should normally omit this.
70
+ */
71
+ readonly beforeInstall?: (context: AuthoringInstallContext) => Promise<void>;
72
+ /**
73
+ * Deterministic test seam after the final optimistic read but immediately
74
+ * before the no-clobber create or replacement transaction starts.
75
+ */
76
+ readonly beforeCommit?: (context: AuthoringInstallContext) => Promise<void>;
77
+ /**
78
+ * Deterministic test seam after an expected replacement source has been
79
+ * moved and verified at recoveryPath, before the new content is linked.
80
+ */
81
+ readonly afterSourceQuarantined?: (
82
+ context: Required<AuthoringInstallContext>,
83
+ ) => Promise<void>;
84
+ }
85
+
86
+ export interface AuthoringOptions {
87
+ readonly expectedRevision?: NoteRevision;
88
+ readonly lock?: NoteLockOptions;
89
+ readonly dependencies?: Partial<AuthoringDependencies>;
90
+ }
91
+
92
+ export class InvalidCanonicalNoteIdError extends TypeError {
93
+ readonly noteId: string;
94
+
95
+ constructor(noteId: string) {
96
+ super(`not an exact canonical note ID: ${JSON.stringify(noteId)}`);
97
+ this.name = "InvalidCanonicalNoteIdError";
98
+ this.noteId = noteId;
99
+ }
100
+ }
101
+
102
+ export class NoteRevisionConflictError extends Error {
103
+ readonly path: string;
104
+ readonly expected: NoteRevision | null;
105
+ readonly actual: NoteRevision | null;
106
+ /** Vault-relative path retaining the displaced bytes, when restoration raced. */
107
+ readonly recoveryPath: string | null;
108
+
109
+ constructor(
110
+ path: string,
111
+ expected: NoteRevision | null,
112
+ actual: NoteRevision | null,
113
+ recoveryPath: string | null = null,
114
+ ) {
115
+ super(recoveryPath === null
116
+ ? "the note changed during authoring; retry from its current revision"
117
+ : `the note changed during authoring; displaced bytes remain at ${recoveryPath}`);
118
+ this.name = "NoteRevisionConflictError";
119
+ this.path = path;
120
+ this.expected = expected;
121
+ this.actual = actual;
122
+ this.recoveryPath = recoveryPath;
123
+ }
124
+ }
125
+
126
+ export class NoteAlreadyExistsError extends Error {
127
+ readonly path: string;
128
+
129
+ constructor(path: string, reason: string) {
130
+ super(`the existing note is incompatible with this create request: ${reason}`);
131
+ this.name = "NoteAlreadyExistsError";
132
+ this.path = path;
133
+ }
134
+ }
135
+
136
+ export class NoteRecoveryRequiredError extends Error {
137
+ readonly path: string;
138
+ readonly recoveryPath: string;
139
+
140
+ constructor(path: string, recoveryPath: string, cause: unknown) {
141
+ super(`authoring stopped; displaced bytes remain at ${recoveryPath}`, { cause });
142
+ this.name = "NoteRecoveryRequiredError";
143
+ this.path = path;
144
+ this.recoveryPath = recoveryPath;
145
+ }
146
+ }
147
+
148
+ export interface Vault {
149
+ readonly root: string;
150
+ }
151
+
152
+ export interface NoteSnapshot {
153
+ readonly path: string;
154
+ readonly relativePath: string;
155
+ readonly content: string;
156
+ readonly revision: NoteRevision;
157
+ readonly device: bigint;
158
+ readonly inode: bigint;
159
+ readonly size: bigint;
160
+ readonly modifiedAtNs: bigint;
161
+ readonly changedAtNs: bigint;
162
+ readonly mode: number;
163
+ }
164
+
165
+ export interface FrontmatterParts {
166
+ readonly document: Document;
167
+ readonly hadFrontmatter: boolean;
168
+ readonly openingDelimiter: string;
169
+ readonly closingDelimiter: string;
170
+ readonly newline: "\n" | "\r\n";
171
+ /** Exact bytes after the existing closing delimiter, including its newline. */
172
+ readonly bodySuffix: string;
173
+ }
174
+
175
+ export interface RelationNodes {
176
+ readonly root: YAMLMap;
177
+ readonly relations: YAMLMap | null;
178
+ }
179
+
180
+ export function isErrno(error: unknown, code: string): boolean {
181
+ return typeof error === "object"
182
+ && error !== null
183
+ && "code" in error
184
+ && error.code === code;
185
+ }
186
+
187
+ export function sha256(value: string | Uint8Array): string {
188
+ return createHash("sha256").update(value).digest("hex");
189
+ }
190
+
191
+ export function revisionFor(bytes: Uint8Array): NoteRevision {
192
+ return `sha256:${sha256(bytes)}`;
193
+ }
194
+
195
+ export function inside(root: string, candidate: string): boolean {
196
+ const fromRoot = relative(root, candidate);
197
+ return fromRoot !== ""
198
+ && fromRoot !== ".."
199
+ && !fromRoot.startsWith(`..${sep}`);
200
+ }
201
+
202
+ /** Validate and return an exact extensionless canonical vault note ID. */
203
+ export function canonicalNoteId(value: string): string {
204
+ if (!isCanonicalNoteId(value)) {
205
+ throw new InvalidCanonicalNoteIdError(value);
206
+ }
207
+ return value;
208
+ }
209
+
210
+ /** Validate a local exact note ID or a stable canonical cross-vault URI. */
211
+ export function canonicalRelationTarget(value: string): string {
212
+ if (value.startsWith("kb://")) return parseQualifiedDocumentUri(value).uri;
213
+ return canonicalNoteId(value);
214
+ }
215
+
216
+ /** Normalize a caller predicate to the strict lower-kebab authored form. */
217
+ export function normalizeRelationPredicate(value: string): string {
218
+ const normalized = value
219
+ .trim()
220
+ .normalize("NFC")
221
+ .toLocaleLowerCase("en-US")
222
+ .replaceAll("_", "-")
223
+ .replace(/\s+/gu, "-")
224
+ .replace(/-{2,}/gu, "-");
225
+ if (!isCanonicalRelationPredicate(normalized)) {
226
+ throw new TypeError(`not a valid relation predicate: ${JSON.stringify(value)}`);
227
+ }
228
+ return normalized;
229
+ }
230
+
231
+ export function exactPredicate(value: string): string {
232
+ const normalized = normalizeRelationPredicate(value);
233
+ if (value !== normalized) {
234
+ throw new Error(`authored relation predicate is not canonical kebab-case: ${value}`);
235
+ }
236
+ return value;
237
+ }
238
+
239
+ export function requireRevision(value: string): NoteRevision {
240
+ if (!NOTE_REVISION_PATTERN.test(value)) {
241
+ throw new TypeError("expectedRevision is not a Wordcell note revision");
242
+ }
243
+ return value as NoteRevision;
244
+ }
245
+
246
+ export function pathFor(vault: Vault, id: string): {
247
+ readonly path: string;
248
+ readonly relativePath: string;
249
+ } {
250
+ const canonicalId = canonicalNoteId(id);
251
+ const relativePath = `${canonicalId}.md`;
252
+ const path = resolve(vault.root, ...relativePath.split("/"));
253
+ if (!inside(vault.root, path)) {
254
+ throw new InvalidCanonicalNoteIdError(id);
255
+ }
256
+ return { path, relativePath };
257
+ }
258
+
259
+ export function sameSnapshot(left: NoteSnapshot, right: NoteSnapshot): boolean {
260
+ return left.device === right.device
261
+ && left.inode === right.inode
262
+ && left.size === right.size
263
+ && left.modifiedAtNs === right.modifiedAtNs
264
+ && left.changedAtNs === right.changedAtNs
265
+ && left.mode === right.mode
266
+ && left.revision === right.revision;
267
+ }
268
+
269
+ export function frontmatter(content: string, relativePath: string): FrontmatterParts {
270
+ const firstLineEnd = content.indexOf("\n");
271
+ const openingEnd = firstLineEnd === -1 ? content.length : firstLineEnd;
272
+ const openingContentEnd = content[openingEnd - 1] === "\r"
273
+ ? openingEnd - 1
274
+ : openingEnd;
275
+ const opening = content.slice(0, openingContentEnd);
276
+ if (opening.trim() !== "---") {
277
+ return {
278
+ document: parseFrontmatterDocument("", relativePath),
279
+ hadFrontmatter: false,
280
+ openingDelimiter: "---",
281
+ closingDelimiter: "---",
282
+ newline: content.includes("\r\n") ? "\r\n" : "\n",
283
+ bodySuffix: content,
284
+ };
285
+ }
286
+ if (firstLineEnd === -1) {
287
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: missing closing delimiter`);
288
+ }
289
+ const newline = content[firstLineEnd - 1] === "\r" ? "\r\n" : "\n";
290
+ let cursor = firstLineEnd + 1;
291
+ for (;;) {
292
+ const nextNewline = content.indexOf("\n", cursor);
293
+ const lineEnd = nextNewline === -1 ? content.length : nextNewline;
294
+ const lineContentEnd = content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
295
+ const line = content.slice(cursor, lineContentEnd);
296
+ if (line.trim() === "---") {
297
+ const yamlSource = content.slice(firstLineEnd + 1, cursor);
298
+ return {
299
+ document: parseFrontmatterDocument(yamlSource, relativePath),
300
+ hadFrontmatter: true,
301
+ openingDelimiter: content.slice(0, openingContentEnd),
302
+ closingDelimiter: content.slice(cursor, lineContentEnd),
303
+ newline,
304
+ bodySuffix: content.slice(lineContentEnd),
305
+ };
306
+ }
307
+ if (nextNewline === -1) break;
308
+ cursor = nextNewline + 1;
309
+ }
310
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: missing closing delimiter`);
311
+ }
312
+
313
+ export function parseFrontmatterDocument(source: string, relativePath: string): Document {
314
+ const document = parseDocument(source, {
315
+ keepSourceTokens: true,
316
+ schema: "core",
317
+ uniqueKeys: true,
318
+ });
319
+ if (document.errors.length > 0) {
320
+ throw new Error(`invalid YAML frontmatter in ${relativePath}`);
321
+ }
322
+ if (document.contents !== null && !isMap(document.contents)) {
323
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: expected a mapping`);
324
+ }
325
+ if (isMap(document.contents)) {
326
+ const seen = new Set<string>();
327
+ for (const pair of document.contents.items) {
328
+ if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
329
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: keys must be strings`);
330
+ }
331
+ const folded = pair.key.value.toLocaleLowerCase("en-US");
332
+ if (seen.has(folded)) {
333
+ throw new Error(
334
+ `invalid YAML frontmatter in ${relativePath}: keys must not differ only by case`,
335
+ );
336
+ }
337
+ seen.add(folded);
338
+ }
339
+ }
340
+ return document;
341
+ }
342
+
343
+ export function relationNodes(
344
+ parts: FrontmatterParts,
345
+ relativePath: string,
346
+ create: boolean,
347
+ ): RelationNodes {
348
+ const { document } = parts;
349
+ if (document.contents === null) {
350
+ if (!create) {
351
+ const detached = document.createNode({});
352
+ if (!isMap(detached)) throw new Error("YAML did not create a mapping");
353
+ return { root: detached, relations: null };
354
+ }
355
+ document.contents = document.createNode({});
356
+ }
357
+ if (!isMap(document.contents)) {
358
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: expected a mapping`);
359
+ }
360
+ const root = document.contents;
361
+ const relationPair = root.items.find((pair) =>
362
+ isScalar(pair.key)
363
+ && typeof pair.key.value === "string"
364
+ && pair.key.value.toLocaleLowerCase("en-US") === "relations");
365
+ const existing = relationPair?.value;
366
+ if (existing === undefined) {
367
+ if (!create) return { root, relations: null };
368
+ const created = document.createNode({});
369
+ if (!isMap(created)) throw new Error("YAML did not create a relation mapping");
370
+ root.set("relations", created);
371
+ return { root, relations: created };
372
+ }
373
+ if (!isMap(existing)) {
374
+ throw new Error(`invalid relations in ${relativePath}: expected a mapping`);
375
+ }
376
+ return { root, relations: existing };
377
+ }
378
+
379
+ export function scalarString(value: unknown): string | null {
380
+ return isScalar(value) && typeof value.value === "string" ? value.value : null;
381
+ }
382
+
383
+ export function relationsFromParts(
384
+ parts: FrontmatterParts,
385
+ relativePath: string,
386
+ ): readonly NoteRelation[] {
387
+ const { relations } = relationNodes(parts, relativePath, false);
388
+ if (relations === null) return [];
389
+ const output: NoteRelation[] = [];
390
+ const seen = new Set<string>();
391
+ for (const pair of relations.items) {
392
+ const predicateValue = scalarString(pair.key);
393
+ if (predicateValue === null) {
394
+ throw new Error(`invalid relations in ${relativePath}: predicates must be strings`);
395
+ }
396
+ const predicate = exactPredicate(predicateValue);
397
+ const scalarTarget = scalarString(pair.value);
398
+ if (scalarTarget !== null) {
399
+ const target = canonicalRelationTarget(scalarTarget);
400
+ const key = `${predicate}\0${target}`;
401
+ if (!seen.has(key)) {
402
+ seen.add(key);
403
+ output.push({ predicate, target });
404
+ }
405
+ continue;
406
+ }
407
+ if (!isSeq(pair.value)) {
408
+ throw new Error(
409
+ `invalid relations in ${relativePath}: ${predicate} targets must be a string or array`,
410
+ );
411
+ }
412
+ for (const item of pair.value.items) {
413
+ const targetValue = scalarString(item);
414
+ if (targetValue === null) {
415
+ throw new Error(
416
+ `invalid relations in ${relativePath}: ${predicate} targets must be strings`,
417
+ );
418
+ }
419
+ const target = canonicalRelationTarget(targetValue);
420
+ const key = `${predicate}\0${target}`;
421
+ if (seen.has(key)) continue;
422
+ seen.add(key);
423
+ output.push({ predicate, target });
424
+ }
425
+ }
426
+ return output.toSorted((left, right) =>
427
+ left.predicate.localeCompare(right.predicate)
428
+ || left.target.localeCompare(right.target));
429
+ }
430
+
431
+ export type RelationValue =
432
+ | { readonly kind: "scalar"; readonly target: string }
433
+ | { readonly kind: "sequence"; readonly sequence: YAMLSeq }
434
+ | null;
435
+
436
+ export function relationValue(
437
+ relations: YAMLMap,
438
+ predicate: string,
439
+ relativePath: string,
440
+ ): RelationValue {
441
+ const value = relations.get(predicate, true);
442
+ if (value === undefined) return null;
443
+ const scalarTarget = scalarString(value);
444
+ if (scalarTarget !== null) {
445
+ return { kind: "scalar", target: canonicalRelationTarget(scalarTarget) };
446
+ }
447
+ if (!isSeq(value)) {
448
+ throw new Error(
449
+ `invalid relations in ${relativePath}: ${predicate} targets must be a string or array`,
450
+ );
451
+ }
452
+ for (const item of value.items) {
453
+ if (scalarString(item) === null) {
454
+ throw new Error(
455
+ `invalid relations in ${relativePath}: ${predicate} targets must be strings`,
456
+ );
457
+ }
458
+ }
459
+ return { kind: "sequence", sequence: value };
460
+ }
461
+
462
+ export function renderFrontmatter(parts: FrontmatterParts): string {
463
+ let yaml = parts.document.toString({ lineWidth: 0 });
464
+ if (parts.newline === "\r\n") yaml = yaml.replaceAll("\n", "\r\n");
465
+ if (!yaml.endsWith(parts.newline)) yaml += parts.newline;
466
+ if (parts.hadFrontmatter) {
467
+ return parts.openingDelimiter
468
+ + parts.newline
469
+ + yaml
470
+ + parts.closingDelimiter
471
+ + parts.bodySuffix;
472
+ }
473
+ return parts.openingDelimiter
474
+ + parts.newline
475
+ + yaml
476
+ + parts.closingDelimiter
477
+ + parts.newline
478
+ + parts.bodySuffix;
479
+ }
480
+
481
+ export function compareScalarNodes(left: unknown, right: unknown): number {
482
+ return (scalarString(left) ?? "").localeCompare(scalarString(right) ?? "");
483
+ }
484
+
485
+ export function addRelationToParts(
486
+ parts: FrontmatterParts,
487
+ relativePath: string,
488
+ predicate: string,
489
+ target: string,
490
+ ): boolean {
491
+ const { relations } = relationNodes(parts, relativePath, true);
492
+ if (relations === null) throw new Error("YAML did not create relations");
493
+ const existing = relationValue(relations, predicate, relativePath);
494
+ if (existing === null) {
495
+ const created = parts.document.createNode([target], { flow: true });
496
+ if (!isSeq(created)) throw new Error("YAML did not create a relation sequence");
497
+ relations.set(predicate, created);
498
+ return true;
499
+ }
500
+ if (existing.kind === "scalar") {
501
+ if (existing.target === target) return false;
502
+ const created = parts.document.createNode(
503
+ [existing.target, target].toSorted((left, right) => left.localeCompare(right)),
504
+ { flow: true },
505
+ );
506
+ if (!isSeq(created)) throw new Error("YAML did not create a relation sequence");
507
+ relations.set(predicate, created);
508
+ return true;
509
+ }
510
+ const sequence = existing.sequence;
511
+ if (sequence.items.some((item) => scalarString(item) === target)) return false;
512
+ sequence.add(parts.document.createNode(target));
513
+ sequence.items.sort(compareScalarNodes);
514
+ return true;
515
+ }
516
+
517
+ export function removeRelationFromParts(
518
+ parts: FrontmatterParts,
519
+ relativePath: string,
520
+ predicate: string,
521
+ target: string,
522
+ sourceId: string,
523
+ ): boolean {
524
+ const { root, relations } = relationNodes(parts, relativePath, false);
525
+ if (relations === null) return false;
526
+ const value = relations.get(predicate, true);
527
+ if (value === undefined) return false;
528
+
529
+ const repairableTarget = (raw: string): string | null => {
530
+ if (raw.startsWith("kb://")) {
531
+ try {
532
+ return canonicalRelationTarget(raw);
533
+ } catch {
534
+ return null;
535
+ }
536
+ }
537
+ let candidate = raw;
538
+ if (candidate.toLocaleLowerCase("en-US").endsWith(".md")) {
539
+ candidate = candidate.slice(0, -3);
540
+ }
541
+ if (candidate.startsWith(".")) {
542
+ candidate = posix.normalize(posix.join(posix.dirname(sourceId), candidate));
543
+ }
544
+ return isCanonicalNoteId(candidate) ? candidate : null;
545
+ };
546
+ const matches = (node: unknown): boolean => {
547
+ const raw = scalarString(node);
548
+ return raw !== null && repairableTarget(raw) === target;
549
+ };
550
+
551
+ if (!isSeq(value)) {
552
+ if (!matches(value)) return false;
553
+ relations.delete(predicate);
554
+ if (relations.items.length === 0) root.delete("relations");
555
+ return true;
556
+ }
557
+ const sequence = value;
558
+ const retained = sequence.items.filter((item) => !matches(item));
559
+ if (retained.length === sequence.items.length) return false;
560
+ if (retained.length === 0) {
561
+ relations.delete(predicate);
562
+ if (relations.items.length === 0) root.delete("relations");
563
+ } else {
564
+ sequence.items = retained;
565
+ }
566
+ return true;
567
+ }
568
+
569
+ export interface RecoveryLocation {
570
+ readonly directory: string;
571
+ readonly path: string;
572
+ readonly relativePath: string;
573
+ readonly device: bigint;
574
+ readonly inode: bigint;
575
+ }
576
+
577
+ export interface DirectoryIdentity {
578
+ readonly device: bigint;
579
+ readonly inode: bigint;
580
+ }
581
+
582
+ export function recoveryRelativePath(vault: Vault, path: string): string {
583
+ return relative(vault.root, path).split(sep).join("/");
584
+ }
585
+
586
+ export function sameQuarantinedSnapshot(
587
+ quarantined: NoteSnapshot,
588
+ expected: NoteSnapshot,
589
+ ): boolean {
590
+ // A rename may update ctime on some supported filesystems. Identity, bytes,
591
+ // mode, size, and mtime still prove that the entry moved was the snapshot
592
+ // accepted by the optimistic read.
593
+ return quarantined.device === expected.device
594
+ && quarantined.inode === expected.inode
595
+ && quarantined.size === expected.size
596
+ && quarantined.modifiedAtNs === expected.modifiedAtNs
597
+ && quarantined.mode === expected.mode
598
+ && quarantined.revision === expected.revision;
599
+ }
600
+
601
+ export function withRecoveryPath(
602
+ error: unknown,
603
+ relativePath: string,
604
+ recoveryPath: string,
605
+ ): Error {
606
+ if (error instanceof NoteRevisionConflictError) {
607
+ return new NoteRevisionConflictError(
608
+ error.path,
609
+ error.expected,
610
+ error.actual,
611
+ recoveryPath,
612
+ );
613
+ }
614
+ return new NoteRecoveryRequiredError(relativePath, recoveryPath, error);
615
+ }
616
+
617
+ export function checkedExpectedRevision(options: AuthoringOptions): NoteRevision | undefined {
618
+ return options.expectedRevision === undefined
619
+ ? undefined
620
+ : requireRevision(options.expectedRevision);
621
+ }
622
+
623
+ export function assertExpected(
624
+ snapshot: NoteSnapshot,
625
+ expected: NoteRevision | undefined,
626
+ ): void {
627
+ if (expected !== undefined && snapshot.revision !== expected) {
628
+ throw new NoteRevisionConflictError(
629
+ snapshot.relativePath,
630
+ expected,
631
+ snapshot.revision,
632
+ );
633
+ }
634
+ }
635
+
636
+ export function noteResult(
637
+ snapshot: Pick<NoteSnapshot, "relativePath" | "revision">,
638
+ relations: readonly NoteRelation[],
639
+ changed: boolean,
640
+ documentId?: string,
641
+ ): NoteAuthoringResult {
642
+ return {
643
+ changed,
644
+ path: snapshot.relativePath,
645
+ revision: snapshot.revision,
646
+ relations,
647
+ ...(documentId === undefined ? {} : { documentId }),
648
+ };
649
+ }
650
+
651
+ export function validateTitle(title: string): string {
652
+ if (
653
+ title === ""
654
+ || title !== title.trim()
655
+ || title.includes("\n")
656
+ || title.includes("\r")
657
+ || title.length > 512
658
+ ) {
659
+ throw new TypeError("a note title must be a non-empty single line");
660
+ }
661
+ return title;
662
+ }
663
+
664
+ export function validateType(type: string): string {
665
+ const canonical = normalizeRelationPredicate(type);
666
+ if (canonical !== type) throw new TypeError("a note type must be canonical kebab-case");
667
+ return type;
668
+ }
669
+
670
+ export function validateTags(tags: readonly string[] | undefined): readonly string[] {
671
+ const result: string[] = [];
672
+ const seen = new Set<string>();
673
+ for (const candidate of tags ?? []) {
674
+ const tag = candidate.trim().replace(/^#+/u, "").normalize("NFC");
675
+ if (
676
+ tag === ""
677
+ || tag.includes("\n")
678
+ || tag.includes("\r")
679
+ || tag.length > 128
680
+ ) {
681
+ throw new TypeError(`not a valid note tag: ${JSON.stringify(candidate)}`);
682
+ }
683
+ const folded = tag.toLocaleLowerCase("en-US");
684
+ if (seen.has(folded)) continue;
685
+ seen.add(folded);
686
+ result.push(tag);
687
+ }
688
+ return result;
689
+ }
690
+
691
+ export function normalizedRequestedBody(body: string): string {
692
+ return body.endsWith("\n") ? body : `${body}\n`;
693
+ }
694
+
695
+ export function renderCreatedNote(input: CreateNoteInput, documentId: string): string {
696
+ const title = validateTitle(input.title);
697
+ const type = validateType(input.type);
698
+ const tags = validateTags(input.tags);
699
+ const metadata: Record<string, unknown> = { document_id: documentId, type, title };
700
+ if (tags.length > 0) metadata["tags"] = tags;
701
+ const document = new Document(metadata, { schema: "core" });
702
+ const body = normalizedRequestedBody(input.body ?? `# ${title}\n`);
703
+ return `---\n${document.toString({ lineWidth: 0 })}---\n\n${body}`;
704
+ }
705
+
706
+ export function topLevelScalar(
707
+ parts: FrontmatterParts,
708
+ key: string,
709
+ ): string | null {
710
+ if (!isMap(parts.document.contents)) return null;
711
+ return scalarString(parts.document.contents.get(key, true));
712
+ }
713
+
714
+ export function topLevelStrings(
715
+ parts: FrontmatterParts,
716
+ key: string,
717
+ ): readonly string[] {
718
+ if (!isMap(parts.document.contents)) return [];
719
+ const value = parts.document.contents.get(key, true);
720
+ if (value === undefined) return [];
721
+ if (isScalar(value) && typeof value.value === "string") return [value.value];
722
+ if (!isSeq(value)) return [];
723
+ return value.items.flatMap((item) => {
724
+ const candidate = scalarString(item);
725
+ return candidate === null ? [] : [candidate];
726
+ });
727
+ }
728
+
729
+ export type ExistingDocumentId =
730
+ | { readonly kind: "invalid" }
731
+ | { readonly kind: "missing" }
732
+ | { readonly kind: "valid"; readonly documentId: string };
733
+
734
+ export function existingDocumentId(parts: FrontmatterParts): ExistingDocumentId {
735
+ if (!isMap(parts.document.contents)) return { kind: "missing" };
736
+ const values = parts.document.contents.items.flatMap((pair) => {
737
+ const key = scalarString(pair.key);
738
+ if (key?.normalize("NFC").toLocaleLowerCase("en-US") !== "document_id") return [];
739
+ const value = scalarString(pair.value);
740
+ return value === null ? [null] : [value];
741
+ });
742
+ if (values.length === 0) return { kind: "missing" };
743
+ if (values.length !== 1 || values[0] === null) return { kind: "invalid" };
744
+ try {
745
+ return { kind: "valid", documentId: parseDocumentId(values[0]) };
746
+ } catch {
747
+ return { kind: "invalid" };
748
+ }
749
+ }
750
+
751
+ export type CompatibleCreate = {
752
+ readonly documentId?: string;
753
+ readonly relations: readonly NoteRelation[];
754
+ };
755
+
756
+ export function assertCompatibleCreate(
757
+ snapshot: NoteSnapshot,
758
+ input: CreateNoteInput,
759
+ requestedDocumentId: string | undefined,
760
+ ): CompatibleCreate {
761
+ const parts = frontmatter(snapshot.content, snapshot.relativePath);
762
+ const requestedType = validateType(input.type);
763
+ const requestedTitle = validateTitle(input.title);
764
+ if (topLevelScalar(parts, "type") !== requestedType) {
765
+ throw new NoteAlreadyExistsError(snapshot.relativePath, "type differs");
766
+ }
767
+ if (topLevelScalar(parts, "title") !== requestedTitle) {
768
+ throw new NoteAlreadyExistsError(snapshot.relativePath, "title differs");
769
+ }
770
+ const presentTags = new Set(
771
+ topLevelStrings(parts, "tags").map((tag) => tag.toLocaleLowerCase("en-US")),
772
+ );
773
+ const missingTag = validateTags(input.tags)
774
+ .find((tag) => !presentTags.has(tag.toLocaleLowerCase("en-US")));
775
+ if (missingTag !== undefined) {
776
+ throw new NoteAlreadyExistsError(snapshot.relativePath, `tag is missing: ${missingTag}`);
777
+ }
778
+ if (
779
+ input.body !== undefined
780
+ && parts.bodySuffix !== `${parts.newline}${parts.newline}${normalizedRequestedBody(input.body)}`
781
+ ) {
782
+ throw new NoteAlreadyExistsError(snapshot.relativePath, "body differs");
783
+ }
784
+ const existingId = existingDocumentId(parts);
785
+ if (
786
+ requestedDocumentId !== undefined
787
+ && (existingId.kind !== "valid" || existingId.documentId !== requestedDocumentId)
788
+ ) {
789
+ throw new NoteAlreadyExistsError(
790
+ snapshot.relativePath,
791
+ existingId.kind === "missing" ? "document_id is missing" : "document_id differs",
792
+ );
793
+ }
794
+ return {
795
+ relations: relationsFromParts(parts, snapshot.relativePath),
796
+ ...(existingId.kind === "valid" ? { documentId: existingId.documentId } : {}),
797
+ };
798
+ }