@staix/vault-mem 0.0.0-bootstrap.0 → 0.1.0

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,229 @@
1
+ import { compileLogFilter } from "./log-filter.ts";
2
+ import { closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, openSync, readSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { hostname } from "node:os";
5
+ import { loadRegistry, syncDirectory } from "./registry.ts";
6
+ import { canonicalPath, isDenied } from "./platform.ts";
7
+ import { formatLogEvent, LOG_TYPES } from "./log-format.ts";
8
+ import type { LogBinding, LogContext, LogReadOptions, LogReadResult, LogReceipt, LogRequest } from "./log-types.ts";
9
+ import type { WriteIssue } from "./write-types.ts";
10
+ export const LOG_LOCK = "log.lock";
11
+ export const MAX_LOG_READ_BYTES = 32 * 1024 * 1024;
12
+ export const LARGE_LOG_LINES = 20000;
13
+ const MAX_READ_LINE_BYTES = 4096;
14
+ export const MAX_LOG_RESULT_BYTES = 128 * 1024;
15
+ class LogRefused extends Error {
16
+ constructor(readonly issues: WriteIssue[]) { super(issues[0]?.message ?? "log refused"); }
17
+ }
18
+ function refuse(code: string, message: string): never { throw new LogRefused([{ code, severity: "error", message }]); }
19
+ function boundedPlain(file: string, maximum: number): string {
20
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
21
+ try {
22
+ const before = fstatSync(fd);
23
+ if (!before.isFile() || before.size > maximum) refuse("L09", "registry must be a bounded plain file");
24
+ const buffer = Buffer.alloc(before.size + 1); let size = 0;
25
+ while (size < buffer.length) { const count = readSync(fd, buffer, size, buffer.length - size, null); if (!count) break; size += count; }
26
+ const after = fstatSync(fd);
27
+ if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) refuse("L09", "registry changed while reading");
28
+ const bytes = buffer.subarray(0, size), text = bytes.toString("utf8");
29
+ if (!Buffer.from(text).equals(bytes)) refuse("L09", "registry must be UTF-8");
30
+ return text;
31
+ } finally { closeSync(fd); }
32
+ }
33
+ function registryIds(file: string, denyPaths: string[] = []): Set<string> {
34
+ if (isDenied(canonicalPath(file), denyPaths)) refuse("L09", "registry path is denied");
35
+ let parsed: unknown;
36
+ try { parsed = Bun.YAML.parse(boundedPlain(file, 1024 * 1024)); }
37
+ catch (error) { if (error instanceof LogRefused) throw error; refuse("L09", "cannot read project registry"); }
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Array.isArray((parsed as Record<string, unknown>).projects)) refuse("L09", "registry requires a projects array");
39
+ const result = new Set<string>(), visited = new Set<object>();
40
+ function collect(items: unknown[], depth: number): void {
41
+ if (depth > 8 || visited.has(items)) refuse("L09", "registry nesting is invalid");
42
+ visited.add(items);
43
+ for (const item of items) {
44
+ if (!item || typeof item !== "object" || Array.isArray(item)) refuse("L09", "registry projects must be objects");
45
+ const entry = item as Record<string, unknown>;
46
+ if (typeof entry.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(entry.id) || result.has(entry.id) || result.size >= 10000) refuse("L09", "registry project id is invalid, duplicated or exceeds bound");
47
+ result.add(entry.id);
48
+ if (entry.sub_projects !== undefined) {
49
+ if (!Array.isArray(entry.sub_projects)) refuse("L09", "registry sub_projects must be an array");
50
+ collect(entry.sub_projects, depth + 1);
51
+ }
52
+ }
53
+ }
54
+ collect((parsed as { projects: unknown[] }).projects, 0);
55
+ return result;
56
+ }
57
+ function context(home: string, id: string, channel: "cli" | "mcp" | undefined, write: boolean): LogContext {
58
+ const config = loadRegistry(home), vault = config.vaults.find(item => item.id === id);
59
+ if (!vault) refuse("L09", "unknown vault id");
60
+ if (channel !== undefined && channel !== "cli" && channel !== "mcp") refuse("POLICY", "unknown log channel");
61
+ if (write && (vault.write === "none" || (channel === "mcp" && vault.write !== "mcp"))) refuse("POLICY", "vault write policy refuses log append");
62
+ if (realpathSync(vault.path) !== vault.path || !lstatSync(vault.path).isDirectory()) refuse("L09", "registered vault path changed");
63
+ if (isDenied(join(vault.path, "log"), config.denyPaths)) refuse("V06", "log path is denied");
64
+ return { vault, config, channel, ...(write && vault.registry ? { projectIds: registryIds(vault.registry, config.denyPaths) } : {}) };
65
+ }
66
+ /** One O_APPEND write per event. A separate cooperative lock permits safe short-write rollback. */
67
+ export function appendLog(home: string, request: LogRequest, options: { env?: NodeJS.ProcessEnv; now?: Date; binding?: LogBinding } = {}): LogReceipt {
68
+ const result: LogReceipt = { ok: false, appended: false, synced: false, bytesWritten: 0, issues: [] };
69
+ let fd: number | undefined, lockFd: number | undefined, lockFile: string | undefined, identity: { ino: number; dev: number } | undefined;
70
+ const error = (code: string, message: string) => result.issues.push({ code, severity: "error", message });
71
+ try {
72
+ if (!request || typeof request !== "object" || typeof request.vault !== "string" || Object.keys(request).some(key => !["vault", "event", "channel"].includes(key))) refuse("L09", "invalid log request");
73
+ const ctx = context(home, request.vault, request.channel, true);
74
+ const rootIdentity = lstatSync(ctx.vault.path);
75
+ if (options.binding) {
76
+ const bound = options.binding;
77
+ if (JSON.stringify(ctx.vault) !== JSON.stringify(bound.vault) || rootIdentity.dev !== bound.dev || rootIdentity.ino !== bound.ino || JSON.stringify(ctx.config.denyPaths ?? []) !== JSON.stringify(bound.denyPaths) || JSON.stringify(ctx.config.piiPatterns ?? []) !== JSON.stringify(bound.piiPatterns)) refuse("LOG_DRIFT", "vault identity or policy changed after mutation; log not appended");
78
+ }
79
+ const formatted = formatLogEvent(request.event, { ...ctx, env: options.env ?? process.env, now: options.now });
80
+ if (!formatted.line || formatted.issues.some(issue => issue.severity === "error")) { result.issues.push(...formatted.issues); return result; }
81
+ const buffer = Buffer.from(formatted.line), root = ctx.vault.path;
82
+ lockFile = join(root, LOG_LOCK);
83
+ const deadline = Date.now() + 2000, sleeper = new Int32Array(new SharedArrayBuffer(4));
84
+ for (;;) {
85
+ try { lockFd = openSync(lockFile, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); break; }
86
+ catch (caught) {
87
+ if ((caught as NodeJS.ErrnoException).code !== "EEXIST" || Date.now() >= deadline) { lockFile = undefined; refuse("LOG_LOCKED", "log lock unavailable; existing lock is preserved"); }
88
+ Atomics.wait(sleeper, 0, 0, 5);
89
+ }
90
+ }
91
+ const lockStat = fstatSync(lockFd); identity = { ino: Number(lockStat.ino), dev: Number(lockStat.dev) };
92
+ writeFileSync(lockFd, JSON.stringify({ pid: process.pid, host: hostname(), created: new Date().toISOString() }) + "\n");
93
+ const stillRoot = lstatSync(root);
94
+ if (stillRoot.dev !== rootIdentity.dev || stillRoot.ino !== rootIdentity.ino) refuse("LOG_DRIFT", "vault identity changed while acquiring log lock");
95
+ const file = join(root, "log"); let existed = true;
96
+ try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) refuse("L09", "log must be a plain nonlinked file"); }
97
+ catch (caught) { if ((caught as NodeJS.ErrnoException).code !== "ENOENT") throw caught; existed = false; }
98
+ fd = openSync(file, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
99
+ const before = fstatSync(fd);
100
+ if (!before.isFile() || before.nlink !== 1) refuse("L09", "log must be a plain nonlinked file");
101
+ if (before.size > 0) {
102
+ const last = Buffer.alloc(1); if (readSync(fd, last, 0, 1, before.size - 1) !== 1 || last[0] !== 10) refuse("L09", "log ends in an incomplete line; inspect before appending");
103
+ }
104
+ let written: number;
105
+ try { written = writeSync(fd, buffer, 0, buffer.length, null); result.bytesWritten = written; }
106
+ catch {
107
+ // Preserve unknown state unless a bounded read proves our entire append or prefix.
108
+ result.appended = null; result.bytesWritten = null;
109
+ const after = fstatSync(fd), linked = lstatSync(file), added = after.size - before.size;
110
+ if (linked.ino === before.ino && linked.dev === before.dev && added >= 0 && added <= buffer.length) {
111
+ const tail = Buffer.alloc(added);
112
+ if (readSync(fd, tail, 0, tail.length, before.size) === tail.length && tail.equals(buffer.subarray(0, added))) {
113
+ result.bytesWritten = added;
114
+ if (added === buffer.length) result.appended = true;
115
+ else {
116
+ result.appended = false;
117
+ if (added) { ftruncateSync(fd, before.size); fsyncSync(fd); result.rolledBack = true; }
118
+ }
119
+ }
120
+ }
121
+ refuse("LOG_WRITE", "log write failed; inspect append state before retrying");
122
+ }
123
+ if (written !== buffer.length) {
124
+ const after = fstatSync(fd), linked = lstatSync(file);
125
+ if (written >= 0 && after.size === before.size + written && linked.ino === before.ino && linked.dev === before.dev) {
126
+ const tail = Buffer.alloc(written);
127
+ if (readSync(fd, tail, 0, written, before.size) === written && tail.equals(buffer.subarray(0, written))) {
128
+ ftruncateSync(fd, before.size); fsyncSync(fd); result.rolledBack = true;
129
+ }
130
+ }
131
+ refuse("LOG_WRITE", "short log write refused; inspect rollback state before retrying");
132
+ }
133
+ result.appended = true;
134
+ fsyncSync(fd);
135
+ if (!existed) syncDirectory(root);
136
+ let sameIdentity = false;
137
+ try {
138
+ const finalLog = lstatSync(file), finalRoot = lstatSync(root);
139
+ sameIdentity = !finalLog.isSymbolicLink() && finalLog.ino === before.ino && finalLog.dev === before.dev && finalRoot.ino === rootIdentity.ino && finalRoot.dev === rootIdentity.dev;
140
+ } catch { /* A removed path is drift, not proof that the fd's event is accessible. */ }
141
+ if (!sameIdentity) {
142
+ result.appended = null;
143
+ refuse("LOG_DRIFT", "log or vault identity changed during append; current path persistence is unknown");
144
+ }
145
+ result.synced = true; result.ok = true;
146
+ } catch (caught) {
147
+ if (caught instanceof LogRefused) result.issues.push(...caught.issues);
148
+ else error("LOG_IO", "log operation failed; inspect file permissions and append state");
149
+ } finally {
150
+ if (fd !== undefined) { try { closeSync(fd); } catch { error("LOG_CLOSE", "log descriptor close failed"); } }
151
+ if (lockFd !== undefined) {
152
+ try { closeSync(lockFd); } catch { error("LOG_CLEANUP", "log lock descriptor close failed"); }
153
+ try {
154
+ const stat = lstatSync(lockFile!);
155
+ if (stat.isSymbolicLink() || Number(stat.ino) !== identity?.ino || Number(stat.dev) !== identity?.dev) error("LOG_CLEANUP", "log lock ownership changed; retained lock");
156
+ else unlinkSync(lockFile!);
157
+ } catch { error("LOG_CLEANUP", "log lock retained; inspect before manual recovery"); }
158
+ }
159
+ if (result.issues.some(issue => issue.severity === "error")) result.ok = false;
160
+ }
161
+ return result;
162
+ }
163
+ /** Bounded-memory streaming tail/filter reader. It never creates a home, lock or log file. */
164
+ export function readLog(home: string, options: LogReadOptions): LogReadResult {
165
+ const result: LogReadResult = { ok: false, lines: [], dataLines: null, warnings: [], issues: [] };
166
+ let fd: number | undefined;
167
+ try {
168
+ if (!options || typeof options !== "object" || typeof options.vault !== "string" || Object.keys(options).some(key => !["vault", "limit", "grep", "type"].includes(key))) refuse("L09", "invalid log read options");
169
+ const limit = options.limit ?? 50;
170
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) refuse("L09", "log limit must be an integer 1..200");
171
+ if (options.type !== undefined && !(LOG_TYPES as readonly string[]).includes(options.type)) refuse("L09", "unknown log type filter");
172
+ let filter: ((line: string) => boolean) | undefined;
173
+ if (options.grep !== undefined) {
174
+ if (typeof options.grep !== "string" || options.grep.length > 256) refuse("L09", "grep pattern exceeds 256 characters");
175
+ try { filter = compileLogFilter(options.grep); } catch { refuse("L09", "invalid or unsupported bounded grep expression"); }
176
+ }
177
+ const ctx = context(home, options.vault, undefined, false), file = join(ctx.vault.path, "log");
178
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
179
+ catch (caught) { if ((caught as NodeJS.ErrnoException).code === "ENOENT") return { ...result, ok: true, dataLines: 0 }; throw caught; }
180
+ const stat = fstatSync(fd);
181
+ if (!stat.isFile()) refuse("L09", "log must be a plain file");
182
+ if (stat.size > MAX_LOG_READ_BYTES) refuse("LOG_SIZE", "log exceeds 32 MiB read bound; archive manually");
183
+ const decoder = new TextDecoder("utf-8", { fatal: true }), buffer = Buffer.alloc(64 * 1024);
184
+ let pending = "", oversized = false, nonblank = false, comment: boolean | undefined, dataLines = 0, offset = 0, resultBytes = 2;
185
+ const consume = (piece: string, end: boolean, incomplete = false) => {
186
+ if (piece.trim()) nonblank = true;
187
+ if (comment === undefined) { const leading = piece.trimStart(); if (leading) comment = leading.startsWith("#"); }
188
+ if (!oversized) {
189
+ if (Buffer.byteLength(pending) + Buffer.byteLength(piece) > MAX_READ_LINE_BYTES) { oversized = true; pending = ""; }
190
+ else pending += piece;
191
+ }
192
+ if (!end) return;
193
+ if (nonblank && !comment) {
194
+ dataLines++;
195
+ if (incomplete || oversized) {
196
+ const warning = incomplete ? "incomplete final log line omitted" : "historical log line over 4096 bytes omitted";
197
+ if (!result.warnings.includes(warning)) result.warnings.push(warning);
198
+ } else {
199
+ const line = pending.endsWith("\r") ? pending.slice(0, -1) : pending;
200
+ const type = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}\s+(\S+)/.exec(line)?.[1];
201
+ if ((!options.type || options.type === type) && (!filter || filter(line))) {
202
+ result.lines.push(line); resultBytes += Buffer.byteLength(JSON.stringify(line)) + 1;
203
+ while (result.lines.length > limit || resultBytes > MAX_LOG_RESULT_BYTES) resultBytes -= Buffer.byteLength(JSON.stringify(result.lines.shift()!)) + 1;
204
+ }
205
+ }
206
+ }
207
+ pending = ""; oversized = false; nonblank = false; comment = undefined;
208
+ };
209
+ // Read a fixed descriptor snapshot; concurrent appends belong to the next call.
210
+ while (offset < stat.size) {
211
+ const count = readSync(fd, buffer, 0, Math.min(buffer.length, stat.size - offset), offset);
212
+ if (!count) refuse("LOG_READ", "log changed while reading");
213
+ offset += count;
214
+ const parts = decoder.decode(buffer.subarray(0, count), { stream: true }).split("\n");
215
+ for (let i = 0; i < parts.length; i++) consume(parts[i]!, i < parts.length - 1);
216
+ }
217
+ const final = decoder.decode(); if (final) consume(final, false);
218
+ if (pending || oversized || nonblank) consume("", true, true);
219
+ if (dataLines > LARGE_LOG_LINES) result.warnings.push("log exceeds 20000 data lines; manual archival recommended");
220
+ result.dataLines = dataLines; result.ok = true;
221
+ } catch (caught) {
222
+ result.lines = [];
223
+ if (caught instanceof LogRefused) result.issues.push(...caught.issues);
224
+ else result.issues.push({ code: "LOG_READ", severity: "error", message: "cannot read plain UTF-8 log" });
225
+ } finally {
226
+ if (fd !== undefined) { try { closeSync(fd); } catch { result.ok = false; result.issues.push({ code: "LOG_CLOSE", severity: "error", message: "log descriptor close failed" }); } }
227
+ }
228
+ return result;
229
+ }
@@ -0,0 +1,93 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
+ import { canonicalPath, isDenied } from "./platform.ts";
3
+ import { loadRegistry } from "./registry.ts";
4
+ import { basename } from "node:path";
5
+ import { applyWrite, MAX_WRITE_BYTES } from "./writer.ts";
6
+ import { notePath } from "./write-operations.ts";
7
+ import type { WriteOp, WriteRequest } from "./write-types.ts";
8
+ import { parseTidyMap, tidyVault, TIDY_RULES } from "./tidy.ts";
9
+ const COMMANDS = new Set(["new", "patch", "append", "frontmatter", "link", "supersede", "move", "delete", "tidy", "report", "check"]);
10
+ export function isWriteCommand(command: string): boolean { return COMMANDS.has(command); }
11
+ function object(text: string, label: string): Record<string, unknown> {
12
+ let value: unknown;
13
+ try { value = JSON.parse(text); } catch { throw new Error(`${label}: invalid JSON`); }
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected object`);
15
+ return value as Record<string, unknown>;
16
+ }
17
+ function input(file: string, home: string): string {
18
+ if (file !== "-" && isDenied(canonicalPath(file), loadRegistry(home).denyPaths)) throw new Error("V06: input path denied");
19
+ const fd = file === "-" ? 0 : openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
20
+ try {
21
+ const stat = fstatSync(fd);
22
+ if (file !== "-" && (!stat.isFile() || stat.size > MAX_WRITE_BYTES)) throw new Error("input: expected regular file <=256 KiB");
23
+ const buffer = Buffer.alloc(MAX_WRITE_BYTES + 1); let size = 0;
24
+ while (size < buffer.length) { const count = readSync(fd, buffer, size, buffer.length - size, null); if (!count) break; size += count; }
25
+ if (size > MAX_WRITE_BYTES) throw new Error("input: exceeds 256 KiB");
26
+ const bytes = buffer.subarray(0, size), text = bytes.toString("utf8");
27
+ if (!Buffer.from(text).equals(bytes)) throw new Error("input: invalid UTF-8");
28
+ return text;
29
+ } finally { if (file !== "-") closeSync(fd); }
30
+ }
31
+ export function runWriteCommand(command: string, args: string[], home: string, env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
32
+ const valueOptions: Record<string, string[]> = {
33
+ new: ["--type", "--domain", "--topics", "--description", "--title", "--source", "--confidence", "--status", "--extra", "--body"],
34
+ patch: ["--old", "--new", "--expected-sha"], append: ["--section", "--markdown", "--expected-sha"],
35
+ frontmatter: ["--set", "--unset", "--expected-sha"], link: ["--relation", "--context"],
36
+ move: ["--fingerprint"], delete: ["--fingerprint"], tidy: ["--fingerprint", "--map"], report: ["--body"], check: ["--path"], supersede: [],
37
+ };
38
+ // For tidy, --log selects the log-redaction rule; tidy always writes its own LINT event.
39
+ const values = valueOptions[command] ? [...valueOptions[command]!, ...(command === "tidy" ? [] : ["--log"])] : undefined;
40
+ if (!values) throw new Error("unknown writer command");
41
+ const twoStep = ["move", "delete", "tidy"].includes(command);
42
+ const booleans = ["--json", "--dry-run", ...(twoStep ? ["--confirm"] : []), ...(command === "link" ? ["--one-way"] : []), ...(command === "tidy" ? TIDY_RULES.map(rule => `--${rule}`).concat("--all") : [])];
43
+ const flags: Record<string, string | boolean> = {}, positional: string[] = [];
44
+ for (let i = 0; i < args.length; i++) {
45
+ const arg = args[i]!;
46
+ if (!arg.startsWith("--")) { positional.push(arg); continue; }
47
+ if (Object.hasOwn(flags, arg) || (!values.includes(arg) && !booleans.includes(arg))) throw new Error(`${command}: unknown or repeated option`);
48
+ if (booleans.includes(arg)) flags[arg] = true;
49
+ else {
50
+ const value = args[++i];
51
+ if (value === undefined || value.startsWith("--")) throw new Error(`${arg}: value required`);
52
+ flags[arg] = value;
53
+ }
54
+ }
55
+ const required = (name: string): string => { if (typeof flags[name] !== "string") throw new Error(`${name}: required`); return flags[name] as string; };
56
+ const optional = (name: string): string | undefined => typeof flags[name] === "string" ? flags[name] as string : undefined;
57
+ const count = ["move", "link", "supersede"].includes(command) ? 3 : command === "tidy" ? 1 : 2;
58
+ if (positional.length !== count) throw new Error(`${command}: requires vault id${count > 1 ? " and target(s)" : ""}`);
59
+ const vault = positional[0]!, target = positional[1]!;
60
+ if (command === "tidy") {
61
+ const all = flags["--all"] === true, rules = TIDY_RULES.filter(rule => all || flags[`--${rule}`] === true);
62
+ if (all && TIDY_RULES.some(rule => flags[`--${rule}`] === true)) throw new Error("tidy: use rule flags or --all, not both");
63
+ if (!rules.length) throw new Error("tidy: select at least one rule (--connections --escapes --enums --keys --sources --topics --secrets --log) or --all");
64
+ if (flags["--dry-run"] && flags["--confirm"]) throw new Error("tidy: --dry-run and --confirm are exclusive");
65
+ const mapText = optional("--map") !== undefined ? input(required("--map"), home) : undefined;
66
+ const result = tidyVault(home, { vault, rules, ...(mapText !== undefined ? { map: parseTidyMap(mapText), mapText } : {}), confirm: flags["--confirm"] === true && !flags["--dry-run"], fingerprint: optional("--fingerprint"), channel: "cli" }, { env });
67
+ return { text: JSON.stringify(result, null, flags["--json"] ? undefined : 2), code: result.ok ? 0 : 1 };
68
+ }
69
+ let operation: WriteOp;
70
+ switch (command) {
71
+ case "new": {
72
+ const frontmatter = optional("--extra") ? object(required("--extra"), "extra") : {};
73
+ for (const key of ["type", "domain", "description", "title", "source", "confidence", "status"]) if (optional(`--${key}`) !== undefined) frontmatter[key] = optional(`--${key}`);
74
+ if(optional("--source")?.trim().startsWith("["))try{frontmatter.source=JSON.parse(required("--source"));}catch{throw new Error("--source: expected a string or JSON array of strings");}
75
+ if (optional("--topics") !== undefined) frontmatter.topics = required("--topics").split(",").filter(Boolean).map(value => value.startsWith("[[") ? value : `[[${value}]]`);
76
+ operation = { op: "create", path: target, frontmatter, ...(optional("--body") ? { body: input(required("--body"), home) } : {}) }; break;
77
+ }
78
+ case "patch": operation = { op: "patch", path: target, oldString: required("--old"), newString: required("--new"), expectedSha: optional("--expected-sha") }; break;
79
+ case "append": operation = { op: "append", path: target, section: required("--section"), markdown: required("--markdown"), expectedSha: optional("--expected-sha") }; break;
80
+ case "frontmatter": operation = { op: "frontmatter", path: target, set: optional("--set") ? object(required("--set"), "set") : {}, unset: optional("--unset")?.split(","), expectedSha: optional("--expected-sha") }; break;
81
+ case "link": operation = { op: "link", from: target, to: positional[2]!, relation: required("--relation") as Extract<WriteOp, { op: "link" }>["relation"], context: optional("--context"), bidirectional: !flags["--one-way"] }; break;
82
+ case "supersede": operation = { op: "supersede", old: target, next: positional[2]! }; break;
83
+ case "move": operation = { op: "move", from: target, to: positional[2]! }; break;
84
+ case "delete": operation = { op: "delete", path: target }; break;
85
+ case "tidy": operation = { op: "tidy" }; break;
86
+ case "report": operation = { op: "report", path: target, text: input(required("--body"), home) }; break;
87
+ case "check": operation = { op: "check", path: notePath(optional("--path") ?? (target === "-" ? "checked-note" : basename(target))), text: input(target, home) }; break;
88
+ default: throw new Error("unknown writer command");
89
+ }
90
+ const request: WriteRequest = { vault, operation, channel: "cli", dryRun: flags["--dry-run"] === true, confirm: flags["--confirm"] === true, fingerprint: optional("--fingerprint"), ...(optional("--log") ? { log: object(required("--log"), "log") as unknown as import("./log-types.ts").LogEvent } : {}) };
91
+ const result = applyWrite(home, request, { env });
92
+ return { text: JSON.stringify(result, null, flags["--json"] ? undefined : 2), code: result.ok && result.log?.ok !== false ? 0 : 1 };
93
+ }
@@ -0,0 +1,181 @@
1
+ import { normalizeSource } from "./source.ts";
2
+ import { parseFrontmatter, emitFrontmatter } from "./frontmatter.ts";
3
+ import { normalizeTarget, proseLines, rewriteLinkTargets, extractLinks, resolveLinks, type LinkNote } from "./links.ts";
4
+ import type { PlannedChange, WriteIssue, WriteOp } from "./write-types.ts";
5
+ export class WriteRefusal extends Error {
6
+ constructor(readonly issues: WriteIssue[]) { super(issues[0]?.message ?? "write refused"); }
7
+ }
8
+ export function refuse(code: string, message: string, path?: string): never {
9
+ throw new WriteRefusal([{ code, severity: "error", message, ...(path ? { path } : {}) }]);
10
+ }
11
+ export function notePath(path: string): string {
12
+ if (typeof path !== "string") refuse("V04", "expected note path");
13
+ const value = path.startsWith("notes/") ? path : `notes/${path.endsWith(".md") ? path : `${path}.md`}`;
14
+ if (!/^notes\/[a-z0-9][a-z0-9-]*\.md$/.test(value) || Buffer.byteLength(value) > 200) refuse("V04", "expected flat lowercase ASCII note path <=200 bytes");
15
+ return value;
16
+ }
17
+ function needed(notes: Map<string, string>, path: string): string {
18
+ const source = notes.get(path);
19
+ if (source === undefined) refuse("V04", "note does not exist", path);
20
+ return source;
21
+ }
22
+ function document(source: string, path: string) {
23
+ try { return parseFrontmatter(source); } catch { return refuse("V01", "invalid frontmatter", path); }
24
+ }
25
+ export function h2Sections(body: string): { name: string; start: number; end: number }[] {
26
+ const visible = proseLines(body), raw = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
27
+ const sections: { name: string; start: number; end: number }[] = [];
28
+ let offset = 0;
29
+ for (let i = 0; i < raw.length; i++) {
30
+ const heading = /^ {0,3}##[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/.exec(visible[i] ?? "");
31
+ if (heading) {
32
+ if (sections.length) sections[sections.length - 1]!.end = offset;
33
+ sections.push({ name: heading[1]!, start: offset, end: body.length });
34
+ }
35
+ offset += raw[i]!.length;
36
+ }
37
+ return sections;
38
+ }
39
+ export function appendSection(source: string, section: string, markdown: string, path: string): string {
40
+ if (typeof section !== "string" || !section.trim() || section.length > 120 || /[\r\n]/.test(section) || section.trim().replace(/[ \t]+#+$/, "").trim().toLowerCase() === "topics") refuse("V05", "append cannot target Topics or an invalid section");
41
+ if (typeof markdown !== "string" || Buffer.byteLength(markdown) > 16384 || proseLines(markdown).some(line => /^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(line))) refuse("V05", "append cannot introduce a Topics section or exceed 16384 bytes");
42
+ const doc = document(source, path), sections = h2Sections(doc.body);
43
+ const found = sections.filter(item => normalizeTarget(item.name) === normalizeTarget(section.trim()));
44
+ if (found.length > 1) refuse("V05", "append section is ambiguous");
45
+ const end = found[0]?.end ?? sections.find(item => item.name.toLowerCase() === "topics")?.start ?? doc.body.length;
46
+ const addition = found.length ? `\n${markdown.trimEnd()}\n\n` : `\n## ${section.trim()}\n\n${markdown.trimEnd()}\n\n`;
47
+ return (doc.frontmatter ?? "") + doc.body.slice(0, end).trimEnd() + "\n" + addition + doc.body.slice(end);
48
+ }
49
+ /** Fold scattered `## Connections (...)` sections into `## Relevant Notes`. */
50
+ export function foldConnections(source: string, path: string): string {
51
+ const doc = document(source, path), sections = h2Sections(doc.body);
52
+ const connections = sections.filter(section => /^Connections(?:\s*\(.*\))?$/i.test(section.name));
53
+ if (!connections.length) return source;
54
+ let body = doc.body, collected: string[] = [];
55
+ for (const section of connections) collected.push(body.slice(section.start, section.end).replace(/^[^\n]*(?:\n|$)/, "").trim());
56
+ for (const section of [...connections].reverse()) body = body.slice(0, section.start) + body.slice(section.end);
57
+ return appendSection((doc.frontmatter ?? "") + body, "Relevant Notes", collected.join("\n\n"), path);
58
+ }
59
+ function normalizedSourceChanges(data:Record<string,unknown>):Record<string,unknown>{
60
+ const result={...data};
61
+ if(Object.hasOwn(result,"source")&&result.source!==undefined)try{result.source=normalizeSource(result.source);}catch{refuse("V01","source must contain workspace-relative paths, mem ids or URLs");}
62
+ return result;
63
+ }
64
+ function bodyAlreadyLinks(notes:Map<string,string>,from:string,to:string):boolean{
65
+ const doc=document(needed(notes,from),from);let body=doc.body;
66
+ for(const section of h2Sections(body).filter(section=>section.name.toLowerCase()==="topics").reverse())body=body.slice(0,section.start)+body.slice(section.start,section.end).replace(/[^\r\n]/g," ")+body.slice(section.end);
67
+ const references=extractLinks(body).map(link=>({...link,heading:null,block:null}));
68
+ if(!references.length)return false;
69
+ const metadata:LinkNote[]=[];
70
+ for(const [path,source] of notes)try{
71
+ const parsed=parseFrontmatter(source),aliases=parsed.data.aliases??parsed.data.alias;
72
+ metadata.push({path,title:typeof parsed.data.title==="string"?parsed.data.title:path,aliases:typeof aliases==="string"?[aliases]:Array.isArray(aliases)?aliases.filter((value):value is string=>typeof value==="string"):[],body:""});
73
+ }catch{ /* Unrelated malformed legacy YAML cannot resolve an alias. */ }
74
+ return resolveLinks(metadata,new Map([[from,references]])).some(link=>link.source===from&&link.resolved===to);
75
+ }
76
+ /** Plan transformations in memory. This function never touches files. */
77
+ export function planOperation(operation: WriteOp, notes: Map<string, string>, reportBefore?: string | null): PlannedChange[] {
78
+ const planned = new Map<string, PlannedChange>();
79
+ const set = (path: string, after: string | null) => {
80
+ const before = notes.get(path) ?? (operation.op === "report" ? reportBefore ?? null : null);
81
+ if (after !== before) planned.set(path, { path, before, after });
82
+ };
83
+ const existing = (value: string) => { const path = notePath(value); return { path, source: needed(notes, path) }; };
84
+ switch (operation.op) {
85
+ case "check": {
86
+ const path = notePath(operation.path);
87
+ if (typeof operation.text !== "string") refuse("V01", "check input must be text");
88
+ // Include unchanged candidates too, so check still validates existing content.
89
+ planned.set(path, { path, before: notes.get(path) ?? null, after: operation.text });
90
+ break;
91
+ }
92
+ case "create": {
93
+ const path = notePath(operation.path);
94
+ if (notes.has(path)) refuse("V08", "note already exists", path);
95
+ if (!operation.frontmatter || typeof operation.frontmatter !== "object" || Array.isArray(operation.frontmatter)) refuse("V01", "frontmatter must be a mapping");
96
+ const title = typeof operation.frontmatter.title === "string" ? operation.frontmatter.title : path.slice(6, -3).replaceAll("-", " ");
97
+ const topics = Array.isArray(operation.frontmatter.topics) ? operation.frontmatter.topics.filter(v => typeof v === "string").join("\n- ") : "";
98
+ const body = operation.body ?? `# ${title}\n\nThis note argues that ${title}.\n\n## Relevant Notes\n\n## Topics\n\n${topics ? `- ${topics}\n` : ""}`;
99
+ if (typeof body !== "string") refuse("V01", "body must be text");
100
+ try { const doc=parseFrontmatter(body),fields={...operation.frontmatter};if(!Object.hasOwn(fields,"source")&&doc.data.source!==undefined)fields.source=doc.data.source;set(path, emitFrontmatter(doc, normalizedSourceChanges(fields))); }
101
+ catch (error) { if (error instanceof WriteRefusal) throw error; refuse("V01", "cannot emit frontmatter", path); }
102
+ break;
103
+ }
104
+ case "patch": {
105
+ const { path, source } = existing(operation.path);
106
+ if (typeof operation.oldString !== "string" || !operation.oldString || typeof operation.newString !== "string" || Buffer.byteLength(operation.oldString) > 8192 || Buffer.byteLength(operation.newString) > 8192) refuse("V09", "patch strings must be bounded and oldString nonempty", path);
107
+ const first = source.indexOf(operation.oldString);
108
+ if (first < 0 || source.indexOf(operation.oldString, first + 1) >= 0) refuse("V09", "oldString must match exactly once", path);
109
+ if(first < (document(source,path).frontmatter?.length ?? 0))refuse("V01","patch cannot target frontmatter; use vmem_set_frontmatter or the frontmatter command",path);
110
+ set(path, source.slice(0, first) + operation.newString + source.slice(first + operation.oldString.length));
111
+ break;
112
+ }
113
+ case "append": { const { path, source } = existing(operation.path); set(path, appendSection(source, operation.section, operation.markdown, path)); break; }
114
+ case "frontmatter": {
115
+ const { path, source } = existing(operation.path);
116
+ if (!operation.set || typeof operation.set !== "object" || Array.isArray(operation.set) || (operation.unset !== undefined && (!Array.isArray(operation.unset) || operation.unset.some(key => typeof key !== "string")))) refuse("V01", "invalid frontmatter update");
117
+ const changes = {...operation.set};
118
+ for (const key of operation.unset ?? []) changes[key] = undefined;
119
+ try { set(path, emitFrontmatter(document(source, path), normalizedSourceChanges(changes))); } catch (error) { if (error instanceof WriteRefusal) throw error; refuse("V01", "cannot emit frontmatter", path); }
120
+ break;
121
+ }
122
+ case "link": {
123
+ const from = existing(operation.from), to = existing(operation.to);
124
+ if (!["supports", "contradicts", "extends", "applies-to", "context-for"].includes(operation.relation)) refuse("V01", "invalid relation");
125
+ if (operation.context !== undefined && (typeof operation.context !== "string" || /[\r\n]/.test(operation.context) || operation.context.length > 500)) refuse("V01", "link context must be one line <=500 characters");
126
+ if (from.path === to.path) refuse("V01", "link endpoints must differ");
127
+ const line = (path: string) => `- [[${path.slice(6, -3)}]] (${operation.relation})${operation.context ? `: ${operation.context}` : ""}`;
128
+ if(!bodyAlreadyLinks(notes,from.path,to.path))set(from.path, appendSection(from.source, "Relevant Notes", line(to.path), from.path));
129
+ if (operation.bidirectional !== false && !bodyAlreadyLinks(notes,to.path,from.path)) set(to.path, appendSection(to.source, "Relevant Notes", line(from.path), to.path));
130
+ break;
131
+ }
132
+ case "supersede": {
133
+ const old = existing(operation.old), next = existing(operation.next);
134
+ if (old.path === next.path) refuse("V10", "supersession endpoints must differ");
135
+ set(old.path, emitFrontmatter(document(old.source, old.path), { status: "superseded", superseded_by: `[[${next.path.slice(6, -3)}]]` }));
136
+ set(next.path, appendSection(next.source, "Relevant Notes", `- Supersedes [[${old.path.slice(6, -3)}]]`, next.path));
137
+ break;
138
+ }
139
+ case "delete": { const { path } = existing(operation.path); set(path, null); break; }
140
+ case "move": {
141
+ const from = existing(operation.from), to = notePath(operation.to);
142
+ if (notes.has(to)) refuse("V08", "move destination already exists", to);
143
+ const names = new Map<string, Set<string>>();
144
+ for (const [path, source] of notes) {
145
+ document(source, path); // Every note must still parse before a move rewrites links.
146
+ // Links resolve by path only, so alias-only links are left untouched.
147
+ const key = normalizeTarget(path.slice(6, -3)), values = names.get(key) ?? new Set<string>(); values.add(path); names.set(key, values);
148
+ }
149
+ const replace = (target: string): string | undefined => {
150
+ const candidates = names.get(normalizeTarget(target.replace(/^\.\//, "").replace(/^notes\//i, "").replace(/\.md$/i, "")));
151
+ return candidates?.size === 1 && candidates.has(from.path) ? to.slice(6, -3) : undefined;
152
+ };
153
+ const rewriteValue = (value: unknown): unknown => {
154
+ if (typeof value === "string") return rewriteLinkTargets(value, replace);
155
+ if (Array.isArray(value)) return value.map(rewriteValue);
156
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteValue(item)]));
157
+ return value;
158
+ };
159
+ for (const [path, source] of notes) {
160
+ const doc = document(source, path), changes: Record<string, unknown> = {};
161
+ for (const [key, value] of Object.entries(doc.data)) changes[key] = rewriteValue(value);
162
+ const frontmatter = emitFrontmatter(doc, changes).slice(0, -doc.body.length || undefined);
163
+ const output = (doc.body.length ? frontmatter : emitFrontmatter(doc, changes)) + rewriteLinkTargets(doc.body, replace);
164
+ if (path === from.path) { set(path, null); set(to, output); }
165
+ else set(path, output);
166
+ }
167
+ break;
168
+ }
169
+ case "tidy": { for (const [path, source] of notes) set(path, foldConnections(source, path)); break; }
170
+ case "sanitize": {
171
+ if (typeof operation.path !== "string" || !notes.has(operation.path) || typeof operation.text !== "string") refuse("V04", "sanitize requires an existing snapshot note path and text");
172
+ set(operation.path, operation.text); break;
173
+ }
174
+ case "report": {
175
+ if (typeof operation.path !== "string" || !/^(?:reports|ops)\/[a-z0-9][a-z0-9./-]*$/.test(operation.path) || operation.path.split("/").some(part => !part || part === "." || part === "..") || Buffer.byteLength(operation.path) > 200 || typeof operation.text !== "string") refuse("V04", "invalid report path");
176
+ set(operation.path, operation.text); break;
177
+ }
178
+ default: refuse("V01", "unknown write operation");
179
+ }
180
+ return [...planned.values()].sort((a, b) => a.path.localeCompare(b.path));
181
+ }
@@ -0,0 +1,50 @@
1
+ import type { LogEvent, LogReceipt } from "./log-types.ts";
2
+ import type { VaultSpec, VmemConfig } from "./config.ts";
3
+ export type WriteOp =
4
+ | { op: "check"; path: string; text: string }
5
+ | { op: "create"; path: string; frontmatter: Record<string, unknown>; body?: string }
6
+ | { op: "patch"; path: string; oldString: string; newString: string; expectedSha?: string }
7
+ | { op: "append"; path: string; section: string; markdown: string; expectedSha?: string }
8
+ | { op: "frontmatter"; path: string; set: Record<string, unknown>; unset?: string[]; expectedSha?: string }
9
+ | { op: "link"; from: string; to: string; relation: "supports" | "contradicts" | "extends" | "applies-to" | "context-for"; context?: string; bidirectional?: boolean }
10
+ | { op: "supersede"; old: string; next: string }
11
+ | { op: "move"; from: string; to: string }
12
+ | { op: "delete"; path: string }
13
+ | { op: "tidy" }
14
+ /** Whole-note replacement used by `tidy` rules; the text is validated like any other write. */
15
+ | { op: "sanitize"; path: string; text: string }
16
+ | { op: "report"; path: string; text: string };
17
+ export interface WriteRequest {
18
+ vault: string;
19
+ operation: WriteOp;
20
+ channel?: "cli" | "mcp";
21
+ dryRun?: boolean;
22
+ confirm?: boolean;
23
+ fingerprint?: string;
24
+ expectedShas?: Record<string, string>;
25
+ log?: LogEvent | false;
26
+ }
27
+ export interface WriteIssue { code: string; severity: "error" | "warning"; path?: string; message: string }
28
+ export interface PlannedChange { path: string; before: string | null; after: string | null }
29
+ export interface ValidationContext {
30
+ vault: VaultSpec;
31
+ config: VmemConfig;
32
+ /** Original flat notes snapshot, paths prefixed with notes/. */
33
+ notes: Map<string, string>;
34
+ operation: WriteOp;
35
+ }
36
+ export interface ChangeReceipt { path: string; beforeSha: string | null; sha256: string | null; bytes: number }
37
+ export interface WriteReceipt {
38
+ ok: boolean;
39
+ applied: boolean;
40
+ /** A link request whose requested sides already have body references made no changes. */
41
+ unchanged?: boolean;
42
+ issues: WriteIssue[];
43
+ changes: ChangeReceipt[];
44
+ fingerprint?: string;
45
+ preview?: boolean;
46
+ possibleDuplicates?: string[];
47
+ indexUpdated: boolean;
48
+ logged?: boolean;
49
+ log?: LogReceipt;
50
+ }