@staix/vault-mem 0.0.0-stage → 0.1.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.
@@ -0,0 +1,285 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { sourceReferences, sourceWorkspaceRoot, missingSourceWarnings } from "./source.ts";
3
+ import { basename, resolve } from "node:path";
4
+ import type { VaultSpec, VmemConfig } from "./config.ts";
5
+ import { parseFrontmatter } from "./frontmatter.ts";
6
+ import { extractLinks, normalizeTarget, proseLines, resolveLinks, type Link, type LinkNote, type ResolvedLink } from "./links.ts";
7
+ import { canonicalPath, isDenied } from "./platform.ts";
8
+ import type { PlannedChange, ValidationContext, WriteIssue } from "./write-types.ts";
9
+
10
+ const TYPES = new Set(["insight", "decision", "observation", "person", "project", "method", "moc", "reference"]);
11
+ const DOMAINS = new Set(["research", "projects", "teaching", "operations", "people", "ai-practice"]);
12
+ const CONFIDENCE = new Set(["proven", "likely", "experimental"]);
13
+ const STATUS = new Set(["active", "superseded", "archived"]);
14
+ const CATEGORY = new Set(["gap", "prompt", "workflow"]);
15
+ const KEYS = new Set([
16
+ "description", "type", "domain", "topics", "source", "confidence", "status",
17
+ "methodology", "evidence_strength", "project", "alternatives", "superseded_by", "supersedes", "stakeholders",
18
+ "course", "semester", "student_level", "institution", "policy_area", "role", "affiliation", "last_contact",
19
+ "tool", "category", "resolution_status", "aliases", "tags", "cssclasses", "title", "created", "updated", "received", "entities",
20
+ "promoted_from", "promoted_at", "promoted_to", "source_ref",
21
+ // Adopted in the V2 cutover (#22): decision dates/owners and person contact fields.
22
+ "decided_at", "decided_by", "email", "phone",
23
+ ]);
24
+ /** Adopted keys valid only on one note type (contact fields additionally only in personal vaults, by PII policy). */
25
+ export const TYPED_KEYS: Readonly<Record<string, string>> = { decided_at: "decision", decided_by: "decision", email: "person", phone: "person" };
26
+ export const SCHEMA_KEYS: ReadonlySet<string> = KEYS;
27
+ const WIKI_FIELDS = ["topics", "project", "superseded_by", "supersedes", "source_ref"];
28
+ // Exact regex set from @staix/agent-mem v0.11.0 src/memory/screen.ts; parity-tested against its root export.
29
+ const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/, /\bgithub_pat_[A-Za-z0-9_]{40,}/, /\bsk-[A-Za-z0-9_-]{20,}/,
30
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{8,}/i];
31
+ export const SECRET_PATTERNS: readonly RegExp[] = SECRETS;
32
+ const EMAIL = /[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9.-]*[A-Z0-9])?\.[A-Z]{2,}/i;
33
+ export const EMAIL_PATTERN = EMAIL;
34
+ const MAX_BYTES = 256 * 1024;
35
+
36
+ function issue(code: string, message: string, severity: WriteIssue["severity"] = "error", path?: string): WriteIssue {
37
+ return { code, severity, message, ...(path ? { path } : {}) };
38
+ }
39
+ function hasPhone(text: string): boolean {
40
+ // A complete ISO calendar timestamp is metadata, not a phone-number candidate.
41
+ const withoutTimestamps = text.replace(/\b[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])[ T](?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9](?:\.[0-9]{1,3})?)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])?\b/g, "\n");
42
+ // The newline replacement is outside the candidate class, so neighbours of a timestamp never merge.
43
+ const digits = (value: string) => value.replace(/\D/g, "").length;
44
+ for (const match of withoutTimestamps.matchAll(/(?<![\p{L}\p{N}])\+?\d(?:[\d ().-]{6,}\d)(?![\p{L}\p{N}])/gu)) {
45
+ const count = digits(match[0]);
46
+ if (count >= 9 && count <= 15) return true;
47
+ if (count <= 15) continue;
48
+ // Oversized run, e.g. two phones separated by spaces: evaluate every contiguous run of
49
+ // whitespace-separated groups alone so each embedded phone is screened (fails closed).
50
+ const groups = match[0].split(/\s+/).filter(Boolean);
51
+ for (let start = 0; start < groups.length; start++) {
52
+ let total = 0;
53
+ for (let end = start; end < groups.length && total <= 15; end++) {
54
+ total += digits(groups[end]!);
55
+ if (total >= 9 && total <= 15) return true;
56
+ }
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+ /** Screen the whole prospective text, including frontmatter, without returning matched values. */
62
+ export function screenContent(text: string, vault: VaultSpec, config: VmemConfig): WriteIssue[] {
63
+ const issues: WriteIssue[] = [];
64
+ if (SECRETS.some(pattern => pattern.test(text))) issues.push(issue("V06", "secret-looking content is refused"));
65
+ const pii = vault.pii ?? (vault.kind === "org" ? "deny" : "allow");
66
+ if (pii === "deny") {
67
+ const normalized = text.normalize("NFKC");
68
+ if (EMAIL.test(normalized) || hasPhone(normalized)) issues.push(issue("V07", "PII is refused by the vault policy"));
69
+ for (const pattern of config.piiPatterns ?? []) {
70
+ try {
71
+ if (new RegExp(pattern).test(text)) { issues.push(issue("V07", "configured PII pattern matched")); break; }
72
+ } catch { issues.push(issue("V07", "invalid PII screening configuration")); break; }
73
+ }
74
+ }
75
+ return issues;
76
+ }
77
+
78
+ /** Quoted escapes must not hide semantic metadata from the same content policy. */
79
+ function screenMetadata(data:Record<string,unknown>,context:ValidationContext,path:string):WriteIssue[]{
80
+ const found=new Map<string,WriteIssue>(),active=new WeakSet<object>();let nodes=0,bytes=0;
81
+ const inspect=(text:string)=>{
82
+ bytes+=Buffer.byteLength(text);if(bytes>MAX_BYTES*4)throw new Error("budget");
83
+ for(const item of screenContent(text,context.vault,context.config))found.set(item.code,{...item,path});
84
+ };
85
+ const visit=(value:unknown,key:string|undefined,depth:number):void=>{
86
+ if(++nodes>10000||depth>32)throw new Error("budget");
87
+ if(value===null||typeof value!=="object"){
88
+ const text=String(value);inspect(text);if(key!==undefined)inspect(key+": "+text);return;
89
+ }
90
+ if(active.has(value))throw new Error("cycle");active.add(value);
91
+ try{if(Array.isArray(value))for(const item of value)visit(item,key,depth+1);
92
+ else for(const [name,item] of Object.entries(value)){inspect(name);visit(item,name,depth+1);}}
93
+ finally{active.delete(value);}
94
+ };
95
+ try{visit(data,undefined,0);}catch{return [...found.values(),issue("V09","frontmatter semantic screening exceeds safe traversal bounds","error",path)];}
96
+ return [...found.values()];
97
+ }
98
+
99
+ interface ParsedNote extends LinkNote { data: Record<string, unknown>; h1: string; text: string }
100
+ function strings(value: unknown): string[] {
101
+ return typeof value === "string" ? [value] : Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
102
+ }
103
+ function fold(value: string): string { return value.normalize("NFKC").toLowerCase().replace(/[-_\s]+/g, " ").trim(); }
104
+ /** Notes whose path and text match `reuse` keep that parsed object, so link analysis is shared too. */
105
+ function parseNotes(notes: Map<string, string>, reuse?: Map<string, ParsedNote>): Map<string, ParsedNote> {
106
+ const parsed = new Map<string, ParsedNote>();
107
+ for (const [path, text] of notes) {
108
+ const previous = reuse?.get(path);
109
+ if (previous?.text === text) { parsed.set(path, previous); continue; }
110
+ try {
111
+ const { data, body } = parseFrontmatter(text);
112
+ const h1 = proseLines(body).map(line => /^ {0,3}#(?:[ \t]+(.*)|[ \t]*)$/.exec(line)).find(match => match)?.[1]?.replace(/[ \t]+#+[ \t]*$/, "").trim() ?? "";
113
+ parsed.set(path, { path, title: typeof data.title === "string" ? data.title : h1 || basename(path, ".md"), aliases: strings(data.aliases), body, data, h1, text });
114
+ } catch { /* An invalid changed note is reported separately; unrelated legacy YAML is not exposed. */ }
115
+ }
116
+ return parsed;
117
+ }
118
+ function noteKey(value: string): string {
119
+ return normalizeTarget(value.replace(/^\.\//, "").replace(/^notes\//i, "").replace(/\.md$/i, ""));
120
+ }
121
+ function names(notes: Map<string, ParsedNote>): Map<string, Set<ParsedNote>> {
122
+ const result = new Map<string, Set<ParsedNote>>();
123
+ // Path-only, matching Obsidian, lint L01 and resolveLinks.
124
+ for (const note of notes.values()) {
125
+ const key = noteKey(note.path), candidates = result.get(key) ?? new Set<ParsedNote>();
126
+ candidates.add(note); result.set(key, candidates);
127
+ }
128
+ return result;
129
+ }
130
+ function target(value: unknown, index: Map<string, Set<ParsedNote>>): ParsedNote | undefined {
131
+ if (typeof value !== "string") return undefined;
132
+ const wiki = /^\[\[([^\]|#]+)\]\]$/.exec(value.trim());
133
+ if (!wiki || !wiki[1]!.trim()) return undefined;
134
+ const candidates = index.get(noteKey(wiki[1]!.trim()));
135
+ return candidates?.size === 1 ? candidates.values().next().value : undefined;
136
+ }
137
+ const metadataLinks = new WeakMap<ParsedNote, Link[]>();
138
+ function references(notes: Map<string, ParsedNote>): ResolvedLink[] {
139
+ const metadata = new Map([...notes].map(([path, note]) => {
140
+ let links = metadataLinks.get(note);
141
+ if (!links) metadataLinks.set(note, links = WIKI_FIELDS.flatMap(key => strings(note.data[key]).flatMap(value => extractLinks(value))));
142
+ return [path, links];
143
+ }));
144
+ return resolveLinks([...notes.values()], metadata);
145
+ }
146
+ function linkSignature(link: ResolvedLink, source: string = link.source): string {
147
+ return JSON.stringify([source, noteKey(link.target), link.heading ? normalizeTarget(link.heading) : null,
148
+ link.block ? normalizeTarget(link.block) : null, link.embed, link.reason]);
149
+ }
150
+ function changedBrokenLinks(oldNotes: Map<string, ParsedNote>, newNotes: Map<string, ParsedNote>, context: ValidationContext): WriteIssue[] {
151
+ const oldCounts = new Map<string, number>();
152
+ const move = context.operation.op === "move" ? context.operation : undefined;
153
+ const from = move ? `notes/${move.from.replace(/^notes\//, "").replace(/\.md$/, "")}.md` : "";
154
+ const to = move ? `notes/${move.to.replace(/^notes\//, "").replace(/\.md$/, "")}.md` : "";
155
+ const oldNames = move ? names(oldNotes) : undefined;
156
+ for (const link of references(oldNotes)) if (link.reason) {
157
+ const candidates = oldNames?.get(noteKey(link.target));
158
+ const renamedTarget = candidates?.size === 1 && [...candidates][0]!.path === from;
159
+ const signature = linkSignature(renamedTarget ? { ...link, target: to } : link, link.source === from ? to : link.source);
160
+ oldCounts.set(signature, (oldCounts.get(signature) ?? 0) + 1);
161
+ }
162
+ const issues: WriteIssue[] = [];
163
+ for (const link of references(newNotes)) if (link.reason) {
164
+ const signature = linkSignature(link), count = oldCounts.get(signature) ?? 0;
165
+ if (count) oldCounts.set(signature, count - 1);
166
+ issues.push(issue("V03", count ? "pre-existing unresolved link retained" : "new unresolved link is refused", count ? "warning" : "error", link.source));
167
+ }
168
+ return issues;
169
+ }
170
+ interface SchemaIssue extends WriteIssue { fields: string[] }
171
+ function schema(note: ParsedNote): SchemaIssue[] {
172
+ const issues: SchemaIssue[] = [], { data, path } = note;
173
+ const add=(field:string,message:string,dependencies=[field])=>issues.push({...issue("V01",message,"error",path),fields:dependencies});
174
+ for(const key of Object.keys(data))if(!KEYS.has(key)||(TYPED_KEYS[key]!==undefined&&data.type!==TYPED_KEYS[key]))add(key,"unknown frontmatter key",[key,"type"]);
175
+ const description = data.description;
176
+ if (typeof description !== "string" || !description.trim() || [...description].length > 200) add("description","description must contain 1..200 Unicode characters");
177
+ else if ([note.title, note.h1, basename(path, ".md")].some(value => value && fold(description) === fold(value))) add("description","description must supplement the title");
178
+ if (typeof data.type !== "string" || !TYPES.has(data.type)) add("type","invalid or missing note type");
179
+ if (data.type !== "moc" || data.domain !== undefined) {
180
+ if (typeof data.domain !== "string" || !DOMAINS.has(data.domain)) add("domain","invalid or missing domain");
181
+ }
182
+ for (const [key, allowed] of [["confidence", CONFIDENCE], ["status", STATUS], ["category", CATEGORY]] as const) {
183
+ if (data[key] !== undefined && (typeof data[key] !== "string" || !allowed.has(data[key]))) add(key,`invalid ${key} enum`);
184
+ }
185
+ if(data.source!==undefined)try{sourceReferences(data.source);}catch{add("source","source must contain workspace-relative paths, mem ids or URLs");}
186
+ return issues;
187
+ }
188
+ function schemaIssues(note:ParsedNote,old:ParsedNote|undefined,context:ValidationContext):WriteIssue[]{
189
+ const op=context.operation;
190
+ const explicit=op.op==="frontmatter"&&noteKey(op.path)===noteKey(note.path)?new Set(Object.keys(op.set)):new Set<string>();
191
+ const value=(n:ParsedNote,key:string)=>n.data[key];
192
+ const previous=old?schema(old):[];
193
+ return schema(note).map(({fields,...finding})=>{
194
+ const retained=op.op!=="create"&&old&&!fields.some(key=>explicit.has(key))&&fields.every(key=>isDeepStrictEqual(value(note,key),value(old,key)))&&previous.some(before=>before.message===finding.message&&isDeepStrictEqual(before.fields,fields));
195
+ return {...finding,severity:retained?"warning":"error"};
196
+ });
197
+ }
198
+ function validPath(path: string, report: boolean): boolean {
199
+ if (Buffer.byteLength(path) > 200 || path.includes("\\") || path.split("/").some(part => !part || part === "." || part === "..")) return false;
200
+ return report ? /^(?:reports|ops)\//.test(path) && path.split("/").slice(1).every(part => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(part)) : /^notes\/[a-z0-9][a-z0-9-]*\.md$/.test(path);
201
+ }
202
+ function topicIssues(note: ParsedNote, index: Map<string, Set<ParsedNote>>, severity: WriteIssue["severity"]): WriteIssue[] {
203
+ if (note.data.type === "moc" && note.data.topics === undefined) return [];
204
+ if (!Array.isArray(note.data.topics) || note.data.topics.length === 0 || note.data.topics.some(value => !target(value, index) || target(value, index)!.data.type !== "moc")) {
205
+ return [issue("V02", "topics must be nonempty wiki links to existing MOC notes", severity, note.path)];
206
+ }
207
+ return [];
208
+ }
209
+ function topicPlacement(note: ParsedNote): WriteIssue[] {
210
+ if (note.data.type === "moc" && note.data.topics === undefined) return [];
211
+ const lines = proseLines(note.body), h2s = lines.flatMap((line, index) => /^ {0,3}##[ \t]+/.test(line) ? [index] : []);
212
+ const topics = h2s.filter(index => /^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(lines[index]!));
213
+ let valid = topics.length === 1 && h2s[h2s.length - 1] === topics[0];
214
+ const actual: string[] = [];
215
+ if (valid) for (const line of lines.slice(topics[0]! + 1)) {
216
+ if (!line.trim()) continue;
217
+ const wiki = /^[ \t]*(?:[-*+][ \t]+)?(\[\[[^\]|#]+\]\])[ \t]*$/.exec(line);
218
+ if (!wiki) { valid = false; break; }
219
+ actual.push(noteKey(wiki[1]!.slice(2, -2)));
220
+ }
221
+ const expected = Array.isArray(note.data.topics) ? note.data.topics.map(value => typeof value === "string" ? noteKey(value.slice(2, -2)) : "") : [];
222
+ const sortedExpected = [...expected].sort();
223
+ if (actual.length !== expected.length || [...actual].sort().some((value, index) => value !== sortedExpected[index])) valid = false;
224
+ return valid ? [] : [issue("V05", "Topics must be the final H2 and list exactly the frontmatter topics", "warning", note.path)];
225
+ }
226
+
227
+ /** Validate a complete mutation plan against the final overlaid snapshot, without filesystem writes. */
228
+ export function validateWrite(changes: PlannedChange[], context: ValidationContext): WriteIssue[] {
229
+ const issues: WriteIssue[] = [], final = new Map(context.notes), touched = new Set(changes.map(change => change.path));
230
+ const report = context.operation.op === "report";
231
+ for (const change of changes) {
232
+ // Sanitize may rewrite an existing legacy-named note in place (L13 names stay until moved).
233
+ if (!validPath(change.path, report) && !(context.operation.op === "sanitize" && context.notes.has(change.path))) issues.push(issue("V04", "invalid mutation path"));
234
+ if (change.after === null) final.delete(change.path);
235
+ else if (change.path.startsWith("notes/")) final.set(change.path, change.after);
236
+ if (change.after === null) continue;
237
+ if (Buffer.byteLength(change.after) > MAX_BYTES) issues.push(issue("V09", "prospective file exceeds 256 KiB", "error", change.path));
238
+ issues.push(...screenContent(change.after, context.vault, context.config).map(value => ({ ...value, path: change.path })));
239
+ try{issues.push(...screenMetadata(parseFrontmatter(change.after).data,context,change.path));}catch{ /* Invalid note YAML is reported separately; reports remain schema-exempt. */ }
240
+ }
241
+ const sourceRoot=sourceWorkspaceRoot(context.vault);
242
+ const oldNotes = parseNotes(context.notes), newNotes = parseNotes(final, oldNotes), oldNames = names(oldNotes), newNames = names(newNotes);
243
+ const candidates=[...changes];
244
+ if(context.operation.op==="frontmatter"){
245
+ const key=noteKey(context.operation.path),existing=[...newNotes.values()].find(note=>noteKey(note.path)===key);
246
+ if(existing&&!candidates.some(change=>change.path===existing.path))candidates.push({path:existing.path,before:existing.text,after:existing.text});
247
+ }
248
+ for (const change of candidates) {
249
+ if (change.after === null || report) continue;
250
+ const note = newNotes.get(change.path);
251
+ if (!note) { issues.push(issue("V01", "invalid frontmatter", "error", change.path)); continue; }
252
+ const prior=context.operation.op==="move"&&noteKey(context.operation.to)===noteKey(note.path)?oldNotes.get(`notes/${noteKey(context.operation.from)}.md`):oldNotes.get(note.path);
253
+ issues.push(...schemaIssues(note,prior,context), ...topicPlacement(note));
254
+ const source = note.data.source;
255
+ let sourceDenied=strings(source).some(entry=>isDenied(entry.replaceAll("\\","/"),context.config.denyPaths));
256
+ if(source!==undefined&&!sourceDenied)try{
257
+ const references=sourceReferences(source);
258
+ if(sourceRoot&&references.some(ref=>ref.kind==="path"&&isDenied(canonicalPath(resolve(sourceRoot,ref.value)),context.config.denyPaths)))sourceDenied=true;
259
+ if(!sourceDenied)issues.push(...missingSourceWarnings(source,sourceRoot).map(message=>issue("V11",message,"warning",change.path)));
260
+ }catch{ /* Typed source errors are emitted as field-sensitive V01 above. */ }
261
+ if(sourceDenied)issues.push(issue("V06","source is denied by the path policy","error",change.path));
262
+ if (change.before === null && context.operation.op === "create") {
263
+ const stem = noteKey(change.path);
264
+ if (context.notes.has(change.path) || [...newNotes.values()].some(other => other.path !== note.path && (noteKey(other.path) === stem || (note.h1 && other.h1 && fold(note.h1) === fold(other.h1))))) {
265
+ issues.push(issue("V08", "a note with the same stem or normalized H1 already exists", "error", change.path));
266
+ }
267
+ }
268
+ }
269
+ if (!report) {
270
+ for (const note of newNotes.values()) {
271
+ const old = oldNotes.get(note.path);
272
+ const topicsPreviouslyValid = old && topicIssues(old, oldNames, "error").length === 0;
273
+ issues.push(...topicIssues(note, newNames, touched.has(note.path) || topicsPreviouslyValid ? "error" : "warning"));
274
+ if (note.data.status === "superseded" && !target(note.data.superseded_by, newNames)) {
275
+ const wasValid = old?.data.status === "superseded" && target(old.data.superseded_by, oldNames);
276
+ issues.push(issue("V10", "superseded notes require an existing superseded_by wiki target", touched.has(note.path) || wasValid ? "error" : "warning", note.path));
277
+ }
278
+ for (const field of ["supersedes", "superseded_by"] as const) if (note.data[field] !== undefined && (!strings(note.data[field]).length || !strings(note.data[field]).every(value => target(value, newNames)))) {
279
+ issues.push(issue("V10", "supersession references must resolve to existing notes", touched.has(note.path) ? "error" : "warning", note.path));
280
+ }
281
+ }
282
+ issues.push(...changedBrokenLinks(oldNotes, newNotes, context));
283
+ }
284
+ return issues;
285
+ }
package/src/writer.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { appendLog } from "./vault-log.ts";
2
+ import type { LogEvent } from "./log-types.ts";
3
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { dirname, join } from "node:path";
6
+ import { hostname } from "node:os";
7
+ import { Database } from "bun:sqlite";
8
+ import { loadRegistry, syncDirectory } from "./registry.ts";
9
+ import { VaultIndex, MAX_NOTE_BYTES } from "./vault-index.ts";
10
+ import { parseFrontmatter } from "./frontmatter.ts";
11
+ import { canonicalPath, isDenied } from "./platform.ts";
12
+ import { notePath, planOperation, refuse, WriteRefusal } from "./write-operations.ts";
13
+ import { validateWrite } from "./write-validation.ts";
14
+ import type { ChangeReceipt, PlannedChange, WriteIssue, WriteReceipt, WriteRequest } from "./write-types.ts";
15
+ export const MUTATION_LOCK = "mutation.lock";
16
+ export const MAX_WRITE_BYTES = 256 * 1024;
17
+ export function sha256(text: string): string { return createHash("sha256").update(text).digest("hex"); }
18
+ function canonical(value: unknown): string {
19
+ if (value === undefined) return "null";
20
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
21
+ if (value && typeof value === "object") return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
22
+ return JSON.stringify(value);
23
+ }
24
+ /** Reject linked parent components as well as linked final files. No directories are created here. */
25
+ function safePath(root: string, path: string): string {
26
+ const parts = path.split("/");
27
+ if (parts.some(part => !part || part === "." || part === "..") || path.startsWith("/") || path.includes("\\")) refuse("V04", "unsafe relative path");
28
+ let current = root;
29
+ for (let i = 0; i < parts.length; i++) {
30
+ current = join(current, parts[i]!);
31
+ try {
32
+ const stat = lstatSync(current);
33
+ if (stat.isSymbolicLink() || (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) refuse("V04", "path must use plain directories and files", path);
34
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
35
+ }
36
+ return current;
37
+ }
38
+ /** Nanosecond file identity used to detect a write racing a single read. */
39
+ function stamp(stat: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint }): string {
40
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
41
+ }
42
+ function readPlain(root: string, path: string): string | null {
43
+ const file = safePath(root, path);
44
+ let fd: number;
45
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
46
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
47
+ try {
48
+ const before = fstatSync(fd, { bigint: true });
49
+ if (!before.isFile() || before.size > BigInt(MAX_NOTE_BYTES)) refuse("V09", "file exceeds read bound or is not regular", path);
50
+ const bytes = Buffer.alloc(Math.min(Number(before.size) + 1, MAX_NOTE_BYTES + 1));
51
+ let size = 0;
52
+ while (size < bytes.length) {
53
+ const count = readSync(fd, bytes, size, bytes.length - size, null); size += count;
54
+ if (!count) break;
55
+ }
56
+ const after = fstatSync(fd, { bigint: true });
57
+ if (size > MAX_NOTE_BYTES || stamp(before) !== stamp(after)) refuse("V09", "file changed during read", path);
58
+ const buffer = bytes.subarray(0, size), text = buffer.toString("utf8");
59
+ if (!Buffer.from(text).equals(buffer)) refuse("V01", "note must be valid UTF-8", path);
60
+ return text;
61
+ } finally { closeSync(fd); }
62
+ }
63
+ /** Flat `notes/*.md` snapshot used by every mutation (other directories such as templates/ and self/ are never read). */
64
+ export function snapshot(root: string, denyPaths: string[] = []): Map<string, string> {
65
+ const dir = join(root, "notes");
66
+ if (realpathSync(dir) !== dir || !lstatSync(dir).isDirectory()) refuse("V04", "notes directory changed");
67
+ const notes = new Map<string, string>();
68
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
69
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
70
+ if (notes.size >= 10000) refuse("V09", "vault exceeds 10000-note mutation bound");
71
+ const path = `notes/${entry.name}`;
72
+ if (isDenied(join(root, path), denyPaths)) refuse("V06", "mutation snapshot contains a denied note path");
73
+ const source = readPlain(root, path);
74
+ if (source === null) refuse("V09", "note disappeared during snapshot", path);
75
+ notes.set(path, source);
76
+ }
77
+ return notes;
78
+ }
79
+ function receipts(changes: PlannedChange[]): ChangeReceipt[] {
80
+ return changes.map(change => ({ path: change.path, beforeSha: change.before === null ? null : sha256(change.before), sha256: change.after === null ? null : sha256(change.after), bytes: Buffer.byteLength(change.after ?? "") }));
81
+ }
82
+ function checkExpected(request: WriteRequest, notes: Map<string, string>): void {
83
+ const expected = { ...request.expectedShas };
84
+ if ("expectedSha" in request.operation && request.operation.expectedSha !== undefined && "path" in request.operation) expected[notePath(request.operation.path)] = request.operation.expectedSha;
85
+ for (const [path, hash] of Object.entries(expected)) {
86
+ // Reports verify an existing snapshot, including legacy names diagnosed by L13.
87
+ // Exact membership is safe here: snapshot() contains only existing flat regular notes.
88
+ // All note mutation targets still go through the stricter notePath naming policy.
89
+ const canonical = (request.operation.op === "report" || request.operation.op === "sanitize") && notes.has(path) ? path : notePath(path), source = notes.get(canonical);
90
+ if (typeof hash !== "string" || !/^[a-f0-9]{64}$/.test(hash) || source === undefined || sha256(source) !== hash) refuse("V09", "expected SHA mismatch", canonical);
91
+ }
92
+ }
93
+ function duplicateWarnings(request: WriteRequest, notes: Map<string, string>, changes: PlannedChange[]): WriteIssue[] {
94
+ if (request.operation.op !== "create" || !notes.size || !changes[0]?.after) return [];
95
+ const doc = parseFrontmatter(changes[0].after);
96
+ const title = typeof doc.data.title === "string" ? doc.data.title : /^#\s+(.+)$/m.exec(doc.body)?.[1] ?? "";
97
+ const terms = [...new Set(title.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])].slice(0, 12);
98
+ if (!terms.length) return [];
99
+ // Disposable FTS preserves dry-run's no-disk-write contract, including on a fresh home.
100
+ const db = new Database(":memory:");
101
+ try {
102
+ db.run("CREATE VIRTUAL TABLE candidates USING fts5(path UNINDEXED,title,description,body,tokenize='unicode61')");
103
+ const insert = db.query("INSERT INTO candidates VALUES (?,?,?,?)");
104
+ for (const [path, source] of notes) {
105
+ let value: ReturnType<typeof parseFrontmatter>;
106
+ try { value = parseFrontmatter(source); } catch { continue; } // Unrelated legacy YAML is not a duplicate candidate.
107
+ insert.run(path, typeof value.data.title === "string" ? value.data.title : path, typeof value.data.description === "string" ? value.data.description : "", value.body);
108
+ }
109
+ const hits = db.query<{ path: string; score: number }, [string]>("SELECT path,bm25(candidates,0,5,2,1) AS score FROM candidates WHERE candidates MATCH ? ORDER BY score,path LIMIT 3").all(terms.map(term => `"${term}"`).join(" AND "));
110
+ return hits.filter(hit => hit.score <= -0.000001).map(hit => ({ code: "V08", severity: "warning", path: hit.path, message: "possible duplicate: FTS title terms match an existing note" }));
111
+ } finally { db.close(); }
112
+ }
113
+ /** Single mutation entry point. Preview/dry-run perform no writes, including locks and caches. */
114
+ export function applyWrite(home: string, request: WriteRequest, options: { env?: NodeJS.ProcessEnv } = {}): WriteReceipt {
115
+ let lockFd: number | undefined, lockFile: string | undefined, lockIdentity: { ino: number; dev: number } | undefined;
116
+ let index: VaultIndex | undefined;
117
+ const temps = new Set<string>(), createdDirs: string[] = [], committed: PlannedChange[] = [];
118
+ let changes: PlannedChange[] = [], issues: WriteIssue[] = [], fingerprint: string | undefined, root = "", applied = false, indexUpdated = false;
119
+ let result: WriteReceipt | undefined;
120
+ const recordError = (code: string, message: string, path?: string) => issues.push({ code, severity: "error", message, ...(path ? { path } : {}) });
121
+ try {
122
+ if (!request || typeof request !== "object" || !request.operation || typeof request.operation !== "object" || typeof request.vault !== "string") refuse("V01", "invalid write request");
123
+ if (Object.keys(request).some(key => !["vault", "operation", "channel", "dryRun", "confirm", "fingerprint", "expectedShas", "log"].includes(key)) || [request.dryRun, request.confirm].some(value => value !== undefined && typeof value !== "boolean")) refuse("V01", "unknown request option or invalid boolean");
124
+ if (request.expectedShas !== undefined && (!request.expectedShas || typeof request.expectedShas !== "object" || Array.isArray(request.expectedShas))) refuse("V09", "expectedShas must be a mapping");
125
+ if (request.channel !== undefined && !["cli", "mcp"].includes(request.channel)) refuse("POLICY", "unknown write channel");
126
+ const config = loadRegistry(home), vault = config.vaults.find(value => value.id === request.vault);
127
+ if (!vault) refuse("V04", "unknown vault id");
128
+ if (request.operation.op !== "check" && (vault.write === "none" || (request.channel === "mcp" && vault.write !== "mcp"))) refuse("POLICY", "vault write policy refuses this channel");
129
+ root = realpathSync(vault.path);
130
+ if (root !== vault.path || isDenied(root, config.denyPaths)) refuse("V06", "vault path denied or changed");
131
+ if (config.vaults.some(value => canonicalPath(home) === value.path || canonicalPath(home).startsWith(`${value.path}/`))) refuse("V04", "VMEM_HOME must be outside registered vaults");
132
+ const twoStep = ["move", "delete", "tidy"].includes(request.operation.op);
133
+ const preview = request.operation.op === "check" || request.dryRun === true || (twoStep && request.confirm !== true);
134
+ if (!preview) {
135
+ lockFile = join(root, MUTATION_LOCK);
136
+ try { lockFd = openSync(lockFile, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
137
+ catch { lockFile = undefined; refuse("LOCKED", "vault mutation lock unavailable; existing lock is preserved"); }
138
+ const stat = fstatSync(lockFd); lockIdentity = { ino: Number(stat.ino), dev: Number(stat.dev) };
139
+ writeFileSync(lockFd, JSON.stringify({ pid: process.pid, host: hostname(), token: randomUUID(), created: new Date().toISOString() }) + "\n");
140
+ fsyncSync(lockFd);
141
+ }
142
+ const notes = snapshot(root, config.denyPaths);
143
+ checkExpected(request, notes);
144
+ let reportBefore: string | null | undefined;
145
+ if (request.operation.op === "report") {
146
+ // Planning validates the report's exact relative path before it can be read.
147
+ planOperation(request.operation, notes, null);
148
+ if (isDenied(join(root, request.operation.path), config.denyPaths)) refuse("V06", "report output path is denied");
149
+ reportBefore = readPlain(root, request.operation.path);
150
+ }
151
+ changes = planOperation(request.operation, notes, reportBefore);
152
+ for (const change of changes) if (isDenied(join(root, change.path), config.denyPaths)) refuse("V06", "planned mutation path is denied", change.path);
153
+ issues = validateWrite(changes, { vault, config, notes, operation: request.operation });
154
+ if (!issues.some(issue => issue.severity === "error")) issues.push(...duplicateWarnings(request, notes, changes));
155
+ const input = { vault: request.vault, channel: request.channel ?? "cli", operation: request.operation, expectedShas: request.expectedShas ?? {}, log: request.log ?? null };
156
+ const identity = lstatSync(root);
157
+ fingerprint = sha256(canonical({ input, vault, policy: { denyPaths: config.denyPaths ?? [], piiPatterns: config.piiPatterns ?? [] }, root, identity: [identity.dev, identity.ino], files: [...notes].map(([path, text]) => [path, sha256(text)]), changes: receipts(changes) }));
158
+ if (twoStep && request.confirm === true && (typeof request.fingerprint !== "string" || request.fingerprint !== fingerprint)) refuse("V09", "stale or missing preview fingerprint");
159
+ if (issues.some(issue => issue.severity === "error")) return result = { ok: false, applied: false, issues, changes: receipts(changes), fingerprint, indexUpdated: false };
160
+ if (preview) return result = { ok: true, applied: false, preview: true, ...(request.operation.op === "link" && !changes.length ? {unchanged:true} : {}), issues, changes: receipts(changes), fingerprint, indexUpdated: false };
161
+ if (!changes.length) return result = { ok: true, applied: false, ...(request.operation.op === "link" ? {unchanged:true} : {}), issues, changes: [], fingerprint, indexUpdated: false };
162
+ index = new VaultIndex(home, vault, config.vaults.map(value => value.path));
163
+ const backups = new Map<string, string>(), staged = new Map<string, string>();
164
+ const ensureDir = (dir: string) => {
165
+ if (dir === root) return;
166
+ try { if (!lstatSync(dir).isDirectory() || lstatSync(dir).isSymbolicLink()) refuse("V04", "unsafe output directory"); }
167
+ catch (error) {
168
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
169
+ ensureDir(dirname(dir)); mkdirSync(dir, { mode: 0o700 }); createdDirs.push(dir);
170
+ }
171
+ };
172
+ const stage = (file: string, text: string) => {
173
+ const temp = join(dirname(file), `.vmem-${randomUUID()}.tmp`);
174
+ const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
175
+ temps.add(temp);
176
+ try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
177
+ return temp;
178
+ };
179
+ for (const change of changes) {
180
+ const file = safePath(root, change.path); ensureDir(dirname(file));
181
+ if (change.before !== null) backups.set(change.path, stage(file, change.before));
182
+ if (change.after !== null) staged.set(change.path, stage(file, change.after));
183
+ }
184
+ try {
185
+ // Detect non-cooperating writers before any rename; cooperating callers use our lock.
186
+ const currentRoot = lstatSync(root);
187
+ if (currentRoot.dev !== identity.dev || currentRoot.ino !== identity.ino) refuse("V09", "vault identity changed while staging");
188
+ // Full content recheck: shared mmap writes can change bytes without moving any stat field.
189
+ const current = snapshot(root, config.denyPaths);
190
+ if (current.size !== notes.size || [...current].some(([path, text]) => notes.get(path) !== text)) refuse("V09", "vault changed while staging");
191
+ for (const change of changes) {
192
+ const file = safePath(root, change.path);
193
+ if (readPlain(root, change.path) !== change.before) refuse("V09", "file changed while staging", change.path);
194
+ if (change.after === null) unlinkSync(file);
195
+ else { const temp = staged.get(change.path)!; renameSync(temp, file); temps.delete(temp); }
196
+ committed.push(change);
197
+ }
198
+ for (const dir of new Set(changes.map(change => dirname(join(root, change.path))))) syncDirectory(dir);
199
+ const refreshed = index.scan(false, { skipInvalidFrontmatter: true });
200
+ for (const path of refreshed.skipped ?? []) issues.push({ code: "V01", severity: "warning", path: `notes/${path}`, message: "legacy invalid frontmatter omitted from derived index; note unchanged" });
201
+ indexUpdated = true; applied = true;
202
+ } catch (error) {
203
+ for (const change of [...committed].reverse()) {
204
+ try {
205
+ const file = safePath(root, change.path);
206
+ if (readPlain(root, change.path) !== change.after) throw new Error("external write detected before rollback");
207
+ if (change.before === null) unlinkSync(file);
208
+ else { const backup = backups.get(change.path)!; renameSync(backup, file); temps.delete(backup); }
209
+ } catch {
210
+ applied = true;
211
+ const backup = backups.get(change.path);
212
+ if (backup) temps.delete(backup); // Recovery copy must survive failed rollback or external drift.
213
+ recordError("ROLLBACK", "rollback refused or failed; external edits preserved and original backup retained when available", backup ? backup.slice(root.length + 1) : change.path);
214
+ }
215
+ }
216
+ for (const dir of new Set(committed.map(change => dirname(join(root, change.path))))) {
217
+ try { syncDirectory(dir); } catch { recordError("ROLLBACK", "rollback directory flush failed"); }
218
+ }
219
+ try { index.scan(false, { skipInvalidFrontmatter: true }); } catch { recordError("INDEX", "derived index refresh failed; rebuild after resolving filesystem error"); }
220
+ throw error;
221
+ }
222
+ result = { ok: true, applied, issues, changes: receipts(changes), fingerprint, indexUpdated };
223
+ if (request.log !== false) {
224
+ const op = request.operation;
225
+ const type = op.op === "link" ? "CONNECT" : op.op === "move" ? "ROUTE" : ["supersede", "tidy"].includes(op.op) ? "RETHINK" : "EXTRACT";
226
+ const source = op.op === "move" || op.op === "link" ? notePath(op.from) : op.op === "supersede" ? notePath(op.old) : op.op === "delete" ? notePath(op.path) : `vmem:${op.op}`;
227
+ const dest = op.op === "move" || op.op === "link" ? notePath(op.to) : op.op === "supersede" ? notePath(op.next) : op.op === "delete" ? undefined : changes.find(change => change.after !== null)?.path;
228
+ const event: LogEvent = request.log ?? { type, project: "-", source, ...(dest ? { dest } : {}), note: op.op };
229
+ try { result.log = appendLog(home, { vault: request.vault, event, channel: request.channel }, { env: options.env, binding: { vault, denyPaths: config.denyPaths ?? [], piiPatterns: config.piiPatterns ?? [], dev: Number(identity.dev), ino: Number(identity.ino) } }); }
230
+ catch { result.log = { ok: false, appended: null, synced: false, bytesWritten: null, issues: [{ code: "LOG_IO", severity: "error", message: "log append failed after successful mutation" }] }; }
231
+ result.logged = result.log.appended === true && result.log.synced;
232
+ }
233
+ } catch (error) {
234
+ if (error instanceof WriteRefusal) issues.push(...error.issues);
235
+ else recordError("IO", "write operation failed; inspect filesystem permissions and retry");
236
+ result = { ok: false, applied, issues, changes: receipts(changes), ...(fingerprint ? { fingerprint } : {}), indexUpdated };
237
+ } finally {
238
+ try { index?.close(); } catch { recordError("INDEX", "derived index close failed"); }
239
+ for (const temp of temps) {
240
+ try { unlinkSync(temp); } catch { recordError("CLEANUP", "owned temporary cleanup failed; inspect .vmem temporary files"); }
241
+ }
242
+ // Only empty directories created by this invocation are eligible for removal on failure.
243
+ if (!applied) for (const dir of createdDirs.reverse()) { try { rmdirSync(dir); } catch { recordError("CLEANUP", "owned empty directory cleanup failed"); } }
244
+ if (lockFd !== undefined) {
245
+ try { closeSync(lockFd); } catch { recordError("CLEANUP", "mutation lock descriptor close failed"); }
246
+ try {
247
+ const stat = lstatSync(lockFile!);
248
+ if (Number(stat.ino) !== lockIdentity?.ino || Number(stat.dev) !== lockIdentity?.dev || stat.isSymbolicLink()) recordError("CLEANUP", "mutation lock ownership changed; retained lock");
249
+ else unlinkSync(lockFile!);
250
+ } catch { recordError("CLEANUP", "mutation lock retained; inspect ownership before manual recovery"); }
251
+ }
252
+ if (result) {
253
+ const possibleDuplicates = issues.filter(issue => issue.code === "V08" && issue.severity === "warning" && issue.path).map(issue => issue.path!);
254
+ if (possibleDuplicates.length) result.possibleDuplicates = possibleDuplicates;
255
+ if (issues.some(issue => issue.severity === "error")) result.ok = false;
256
+ }
257
+ }
258
+ return result!;
259
+ }