@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,77 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync, mkdtempSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadRegistry } from "./registry.ts";
6
+ import { canonicalPath, isDenied } from "./platform.ts";
7
+ import { MAX_WRITE_BYTES } from "./writer.ts";
8
+ import { listPromotionCandidates, promoteFromAmem, type PromoteRequest } from "./promote.ts";
9
+
10
+ const VALUES = ["--project", "--limit", "--stem", "--type", "--domain", "--topics", "--description", "--title", "--body", "--link-to", "--log-project", "--fingerprint"];
11
+ const BOOLEANS = ["--all-projects", "--edit", "--confirm", "--json"];
12
+ function readBody(file: string, home: string): string {
13
+ if (file !== "-" && isDenied(canonicalPath(file), loadRegistry(home).denyPaths)) throw new Error("V06: body path denied");
14
+ const fd = file === "-" ? 0 : openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
15
+ try {
16
+ if (file !== "-" && (!fstatSync(fd).isFile() || fstatSync(fd).size > MAX_WRITE_BYTES)) throw new Error("body: expected regular file <=256 KiB");
17
+ const buffer = Buffer.alloc(MAX_WRITE_BYTES + 1); let size = 0;
18
+ while (size < buffer.length) { const n = readSync(fd, buffer, size, buffer.length - size, null); if (!n) break; size += n; }
19
+ if (size > MAX_WRITE_BYTES) throw new Error("body: exceeds 256 KiB");
20
+ const bytes = buffer.subarray(0, size), text = bytes.toString("utf8");
21
+ if (!Buffer.from(text).equals(bytes)) throw new Error("body: invalid UTF-8");
22
+ return text;
23
+ } finally { if (file !== "-") closeSync(fd); }
24
+ }
25
+ /** Optional editor branch: the only place that looks at whether stdin is a terminal. */
26
+ function editBody(draft: string, env: NodeJS.ProcessEnv, stdinIsTTY: boolean): string | undefined {
27
+ const editor = env.VISUAL?.trim() || env.EDITOR?.trim();
28
+ if (!stdinIsTTY || !editor) return undefined;
29
+ const dir = mkdtempSync(join(tmpdir(), "vmem-promote-")), file = join(dir, "note.md");
30
+ try {
31
+ writeFileSync(file, draft, { mode: 0o600 });
32
+ const result = spawnSync(editor, [file], { stdio: "inherit", shell: false });
33
+ if (result.status !== 0) throw new Error("editor exited with an error; nothing promoted");
34
+ return readFileSync(file, "utf8");
35
+ } finally { rmSync(dir, { recursive: true, force: true }); }
36
+ }
37
+ /** `vmem promote-from-amem <vault> [<mem-id>] ...`: lists candidates, previews, or confirms one promotion. */
38
+ export async function runPromoteCommand(args: string[], home: string, env: NodeJS.ProcessEnv, io: { stdinIsTTY?: boolean } = {}): Promise<{ text: string; code: number }> {
39
+ const flags: Record<string, string | boolean> = {}, positional: string[] = [];
40
+ for (let i = 0; i < args.length; i++) {
41
+ const arg = args[i]!;
42
+ if (!arg.startsWith("--")) { positional.push(arg); continue; }
43
+ if (Object.hasOwn(flags, arg) || (!VALUES.includes(arg) && !BOOLEANS.includes(arg))) throw new Error(`promote-from-amem: unknown or repeated option ${arg}`);
44
+ if (BOOLEANS.includes(arg)) { flags[arg] = true; continue; }
45
+ const value = args[++i];
46
+ if (value === undefined || (value.startsWith("--") && value !== "-")) throw new Error(`${arg}: value required`);
47
+ flags[arg] = value;
48
+ }
49
+ const value = (name: string) => typeof flags[name] === "string" ? flags[name] as string : undefined;
50
+ const format = (result: unknown) => JSON.stringify(result, null, flags["--json"] ? undefined : 2);
51
+ if (positional.length < 1 || positional.length > 2) throw new Error("promote-from-amem: requires vault id and optional mem id");
52
+ const [vault, rawId] = positional as [string, string | undefined];
53
+ if (!loadRegistry(home).vaults.some(v => v.id === vault)) throw new Error("promote-from-amem: unknown vault id");
54
+ if (rawId === undefined) {
55
+ if (flags["--all-projects"] && value("--project")) throw new Error("choose either --all-projects or --project");
56
+ const limit = value("--limit") === undefined ? undefined : /^[0-9]+$/.test(value("--limit")!) ? Number(value("--limit")) : NaN;
57
+ const list = await listPromotionCandidates({ env, allProjects: flags["--all-projects"] === true, ...(value("--project") ? { project: value("--project") } : {}), ...(limit !== undefined ? { limit } : {}), piiPatterns: loadRegistry(home).piiPatterns });
58
+ return { text: format(list), code: 0 };
59
+ }
60
+ if (flags["--all-projects"]) throw new Error("--all-projects lists candidates only");
61
+ const request: PromoteRequest = { vault, id: rawId.replace(/^mem:/, ""), channel: "cli", confirm: flags["--confirm"] === true };
62
+ for (const [flag, key] of [["--stem", "stem"], ["--type", "type"], ["--domain", "domain"], ["--description", "description"], ["--title", "title"], ["--link-to", "linkTo"], ["--log-project", "logProject"], ["--fingerprint", "fingerprint"]] as const) {
63
+ if (value(flag) !== undefined) (request as unknown as Record<string, unknown>)[key] = value(flag);
64
+ }
65
+ if (value("--topics") !== undefined) request.topics = value("--topics")!.split(",").map(t => t.trim()).filter(Boolean);
66
+ if (value("--body") !== undefined) request.body = readBody(value("--body")!, home);
67
+ const options = { env, ...(value("--project") ? { project: value("--project") } : {}) };
68
+ // The editor runs on the preview only; confirm always uses exactly what the preview showed.
69
+ if (flags["--edit"] && request.body === undefined && !request.confirm) {
70
+ const draft = await promoteFromAmem(home, request, options);
71
+ const edited = draft.text !== undefined ? editBody(draft.text, env, io.stdinIsTTY === true) : undefined;
72
+ if (edited !== undefined) request.body = edited;
73
+ }
74
+ const result = await promoteFromAmem(home, request, options);
75
+ const edited = flags["--edit"] && request.body !== undefined && value("--body") === undefined;
76
+ return { text: format(edited ? { ...result, hint: "edited text is only previewed: save it and confirm with --body <file> and this fingerprint" } : result), code: result.ok ? 0 : 1 };
77
+ }
package/src/promote.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { VaultSpec } from "./config.ts";
3
+ import { emitFrontmatter, parseFrontmatter } from "./frontmatter.ts";
4
+ import { loadRegistry } from "./registry.ts";
5
+ import { VaultIndex } from "./vault-index.ts";
6
+ import { applyWrite } from "./writer.ts";
7
+ import { notePath } from "./write-operations.ts";
8
+ import type { WriteIssue, WriteReceipt } from "./write-types.ts";
9
+ import type { AmemCandidate, AmemSeam, AmemOptions } from "./amem/seam.ts";
10
+
11
+ /** Explicit metadata that overrides the prefill from the agent-mem entry. */
12
+ export interface PromoteMetadata { stem?: string; type?: string; domain?: string; topics?: string[]; description?: string; title?: string; body?: string; logProject?: string }
13
+ export interface PromoteRequest extends PromoteMetadata {
14
+ vault: string;
15
+ id: string;
16
+ /** Link the candidate to an existing note instead of creating one. */
17
+ linkTo?: string;
18
+ confirm?: boolean;
19
+ fingerprint?: string;
20
+ channel?: "cli" | "mcp";
21
+ }
22
+ export interface PromoteResult {
23
+ ok: boolean;
24
+ action: "create" | "link";
25
+ applied: boolean;
26
+ preview?: boolean;
27
+ id: string;
28
+ path: string | null;
29
+ text?: string;
30
+ fingerprint?: string;
31
+ issues: WriteIssue[];
32
+ possibleDuplicates?: string[];
33
+ /** Choices offered when the note may already exist in the vault. */
34
+ choices?: string[];
35
+ note?: WriteReceipt;
36
+ linked?: string | null;
37
+ alreadyLinked?: boolean;
38
+ }
39
+ export interface SeamFactory { (options: AmemOptions): Promise<AmemSeam> }
40
+ const defaultSeam: SeamFactory = async options => (await import("./amem/seam.ts")).openAmem(options);
41
+ const ID = /^[a-f0-9]{32}$/;
42
+ const sha = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
43
+ const issue = (code: string, message: string, path?: string): WriteIssue => ({ code, severity: "error", message, ...(path ? { path } : {}) });
44
+
45
+ export function stemFor(title: string): string {
46
+ const stem = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/, "");
47
+ return /^[a-z0-9]/.test(stem) ? stem : "";
48
+ }
49
+ /** The vault log's project field: an explicit value, else the last segment of the agent-mem project key. */
50
+ export function logProjectFor(project: string, explicit?: string): string {
51
+ if (explicit !== undefined) return explicit;
52
+ const slug = (project.split(/[/:]/).filter(Boolean).at(-1) ?? "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
53
+ return /^[a-z0-9][a-z0-9-]*$/.test(slug) ? slug : "-";
54
+ }
55
+ function firstSentence(text: string): string {
56
+ const line = text.replace(/\s+/g, " ").trim(), cut = /^(.{20,200}?[.!?])(?:\s|$)/.exec(line)?.[1] ?? line.slice(0, 200);
57
+ return cut.trim();
58
+ }
59
+ /** Prefilled note text: H1 from the title, body from the entry text, about[] as hints, provenance in frontmatter. */
60
+ export function draftNote(entry: AmemCandidate, meta: PromoteMetadata, now = new Date()): { stem: string; frontmatter: Record<string, unknown>; text: string } {
61
+ const title = meta.title ?? entry.title ?? firstSentence(entry.text);
62
+ const stem = meta.stem ?? stemFor(title);
63
+ const topics = (meta.topics ?? []).map(value => value.startsWith("[[") ? value : `[[${value}]]`);
64
+ const frontmatter: Record<string, unknown> = {
65
+ title, description: meta.description ?? firstSentence(entry.text), type: meta.type ?? (entry.kind === "decision" ? "decision" : entry.kind === "fail" ? "method" : "insight"),
66
+ ...(meta.domain ? { domain: meta.domain } : {}), topics, promoted_from: `mem:${entry.id}`, promoted_at: now.toISOString().slice(0, 10),
67
+ };
68
+ const hints = entry.about.length ? `${entry.about.slice(0, 20).map(node => `- ${node.replace(/[\r\n[\]]/g, " ")}`).join("\n")}\n` : "";
69
+ const body = meta.body ?? `# ${title}\n\n${entry.text.trim()}\n\n## Relevant Notes\n\n${hints}\n## Topics\n\n${topics.map(topic => `- ${topic}`).join("\n")}\n`;
70
+ return { stem, frontmatter, text: emitFrontmatter(parseFrontmatter(body), frontmatter) };
71
+ }
72
+ function vaultSpec(home: string, id: string): { spec: VaultSpec; all: VaultSpec[] } {
73
+ const all = loadRegistry(home).vaults, spec = all.find(v => v.id === id);
74
+ if (!spec) throw new Error("promote: unknown vault id");
75
+ return { spec, all };
76
+ }
77
+ const fold = (value: string) => value.normalize("NFKC").toLowerCase().replace(/[-_\s]+/g, " ").trim();
78
+ /** Existing notes with the same stem or title, plus close FTS title matches: the link-existing choices. */
79
+ function similar(home: string, spec: VaultSpec, all: VaultSpec[], title: string, stem: string): string[] {
80
+ const index = new VaultIndex(home, spec, all.map(v => v.path));
81
+ try {
82
+ index.scan();
83
+ const words = title.normalize("NFKC").split(/[^\p{L}\p{N}]+/u).filter(word => word.length > 2).slice(0, 8);
84
+ const hits = words.length ? index.search(words.join(" "), { limit: 5 }) : [];
85
+ const exact = hits.filter(hit => fold(hit.title) === fold(title) || hit.path === `${stem}.md`);
86
+ return [...exact, ...hits].map(hit => `notes/${hit.path}`).filter((path, i, list) => list.indexOf(path) === i).slice(0, 5);
87
+ } catch { return []; } finally { index.close(); }
88
+ }
89
+ function lookup(home: string, spec: VaultSpec, all: VaultSpec[], id: string, linkTo?: string): { promoted: string[]; target?: string } {
90
+ const index = new VaultIndex(home, spec, all.map(v => v.path));
91
+ try {
92
+ index.scan();
93
+ const promoted = index.promotedFrom(`mem:${id}`).map(path => `notes/${path}`);
94
+ let target: string | undefined;
95
+ if (linkTo !== undefined) target = `notes/${index.show(linkTo, 1).path}`;
96
+ return { promoted, ...(target ? { target } : {}) };
97
+ } finally { index.close(); }
98
+ }
99
+
100
+ /**
101
+ * Two-step promotion of one agent-mem entry. Without `confirm` nothing is written and the result carries
102
+ * the full note text (or the link to record) and a fingerprint. With `confirm` the fingerprint must match
103
+ * the recomputed preview; then the note is created through applyWrite, one LEARN event is appended, and
104
+ * the vault link is recorded last. If that last step fails the candidate stays listed, and a rerun finds
105
+ * the note through `promoted_from` and offers link-only instead of a second note.
106
+ */
107
+ export async function promoteFromAmem(home: string, request: PromoteRequest, options: { env?: NodeJS.ProcessEnv; project?: string; cwd?: string; seam?: SeamFactory; now?: Date } = {}): Promise<PromoteResult> {
108
+ const base: PromoteResult = { ok: false, action: "create", applied: false, id: request.id, path: null, issues: [] };
109
+ if (!ID.test(request.id)) return { ...base, issues: [issue("AMEM", "expected a 32-hex agent-mem entry id")] };
110
+ const { spec, all } = vaultSpec(home, request.vault);
111
+ if (spec.write === "none" || (request.channel === "mcp" && spec.write !== "mcp")) return { ...base, issues: [issue("POLICY", "vault write policy refuses this channel")] };
112
+ const seam = await (options.seam ?? defaultSeam)({ env: options.env, ...(options.project ? { project: options.project } : {}), ...(options.cwd ? { cwd: options.cwd } : {}), piiPatterns: loadRegistry(home).piiPatterns });
113
+ try {
114
+ const entry = await seam.entry(request.id);
115
+ if (!entry) return { ...base, issues: [issue("AMEM", "entry is not an available curated entry of this project")] };
116
+ const linked = seam.linkedPath(request.id) ?? null;
117
+ const { promoted, target } = lookup(home, spec, all, request.id, request.linkTo);
118
+ // Link-only: an earlier promotion already created the note, or the caller chose an existing note.
119
+ const existing = target ?? promoted[0];
120
+ if (existing) {
121
+ const fingerprint = sha({ action: "link", id: request.id, path: existing, linked, vault: spec.id });
122
+ const result: PromoteResult = { ...base, action: "link", path: existing, fingerprint, linked, alreadyLinked: linked === existing, ok: true,
123
+ ...(promoted.length && !target ? { choices: [`link-only ${existing}`] } : {}) };
124
+ if (request.confirm !== true) return { ...result, preview: true };
125
+ if (request.fingerprint !== fingerprint) return { ...result, ok: false, issues: [issue("V09", "stale or missing preview fingerprint")] };
126
+ if (linked === existing) return { ...result, applied: false };
127
+ await seam.recordLink(request.id, existing);
128
+ return { ...result, applied: true, linked: existing };
129
+ }
130
+ const draft = draftNote(entry, request, options.now);
131
+ if (!draft.stem) return { ...base, issues: [issue("V04", "cannot derive an ASCII stem from the title; pass --stem")] };
132
+ let path: string;
133
+ try { path = notePath(draft.stem); } catch { return { ...base, issues: [issue("V04", "expected a flat lowercase ASCII stem")] }; }
134
+ const operation = { op: "create" as const, path: draft.stem, frontmatter: {}, body: draft.text };
135
+ const planned = applyWrite(home, { vault: spec.id, operation, channel: request.channel ?? "cli", dryRun: true }, { env: options.env });
136
+ const v08 = planned.issues.some(i => i.code === "V08") || (planned.possibleDuplicates?.length ?? 0) > 0;
137
+ const possibleDuplicates = v08 ? [...new Set([...(planned.possibleDuplicates ?? []), ...similar(home, spec, all, String(draft.frontmatter.title), draft.stem)])].filter(p => p !== path) : [];
138
+ const fingerprint = sha({ action: "create", id: request.id, write: planned.fingerprint, text: draft.text, linked, vault: spec.id });
139
+ const result: PromoteResult = { ...base, ok: planned.ok, path, text: draft.text, fingerprint, issues: planned.issues, linked,
140
+ ...(possibleDuplicates.length ? { possibleDuplicates, choices: ["create", ...possibleDuplicates.map(p => `link-existing ${p}`)] } : {}) };
141
+ if (request.confirm !== true || !planned.ok) return { ...result, preview: request.confirm !== true };
142
+ if (request.fingerprint !== fingerprint) return { ...result, ok: false, issues: [issue("V09", "stale or missing preview fingerprint")] };
143
+ const note = applyWrite(home, { vault: spec.id, operation, channel: request.channel ?? "cli",
144
+ log: { type: "LEARN", project: logProjectFor(seam.project, request.logProject), source: `mem:${request.id}`, dest: path, note: `promoted: ${entry.kind}` } }, { env: options.env });
145
+ if (!note.ok || !note.applied) return { ...result, ok: false, note, issues: note.issues };
146
+ try {
147
+ await seam.recordLink(request.id, path);
148
+ return { ...result, ok: note.log?.ok !== false, applied: true, note, linked: path, issues: note.log?.ok === false ? note.log.issues : [] };
149
+ } catch (error) {
150
+ return { ...result, ok: false, applied: true, note, linked: null, choices: [`link-only ${path}`],
151
+ issues: [issue("LINK", `note created but the agent-mem link failed (${error instanceof Error ? error.message.slice(0, 200) : "error"}); rerun to record the link only`)] };
152
+ }
153
+ } finally { seam.close(); }
154
+ }
155
+ /** Candidate table: one project (default: the current one) or every readable project. */
156
+ export async function listPromotionCandidates(options: { env?: NodeJS.ProcessEnv; project?: string; cwd?: string; allProjects?: boolean; limit?: number; piiPatterns?: string[]; seam?: SeamFactory }): Promise<{ projects: { project: string; candidates: { id: string; project: string; kind: string; title: string | null; at: string }[] }[] }> {
157
+ const limit = options.limit ?? 50;
158
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new Error("limit: expected integer 1..200");
159
+ const brief = (c: AmemCandidate) => ({ id: c.id, project: c.project, kind: c.kind, title: c.title ?? firstSentence(c.text), at: c.at });
160
+ if (options.allProjects) {
161
+ const { allProjectCandidates } = await import("./amem/seam.ts");
162
+ return { projects: (await allProjectCandidates({ env: options.env, limit, piiPatterns: options.piiPatterns })).map(p => ({ project: p.project, candidates: p.candidates.map(brief) })) };
163
+ }
164
+ const seam = await (options.seam ?? defaultSeam)({ env: options.env, ...(options.project ? { project: options.project } : {}), ...(options.cwd ? { cwd: options.cwd } : {}), piiPatterns: options.piiPatterns });
165
+ try { return { projects: [{ project: seam.project, candidates: (await seam.candidates(limit)).map(brief) }] }; }
166
+ finally { seam.close(); }
167
+ }
@@ -0,0 +1,43 @@
1
+ import { loadRegistry } from "./registry.ts";
2
+ import { VaultIndex, MAX_NOTE_BYTES } from "./vault-index.ts";
3
+ const READ_COMMANDS = new Set(["rebuild", "search", "show", "links"]);
4
+ export function isReadCommand(command: string): boolean { return READ_COMMANDS.has(command); }
5
+ export function runReadCommand(command: string, args: string[], home: string): { text: string; code: number } {
6
+ const options: Record<string, string | boolean> = {}, positional: string[] = [];
7
+ const allowed = command === "search" ? ["--limit", "--type", "--domain"] : command === "show" ? ["--max-bytes", "--section", "--offset"] : command === "links" ? ["--limit"] : [];
8
+ for (let i = 0; i < args.length; i++) {
9
+ const arg = args[i]!;
10
+ if (!arg.startsWith("-")) { positional.push(arg); continue; }
11
+ if (Object.hasOwn(options, arg) || (arg !== "--json" && !allowed.includes(arg))) throw new Error(`${command}: unknown or repeated option ${arg}`);
12
+ if (arg === "--json") { options[arg] = true; continue; }
13
+ const value = args[++i];
14
+ if (!value || value.startsWith("--")) throw new Error(`${arg}: value required`);
15
+ options[arg] = value;
16
+ }
17
+ if (positional.length !== (command === "rebuild" ? 1 : 2)) throw new Error(`${command}: requires vault id${command === "rebuild" ? "" : " and query/target"}`);
18
+ const registry = loadRegistry(home), vault = registry.vaults.find(v => v.id === positional[0]);
19
+ if (!vault) throw new Error(`${command}: unknown vault id`);
20
+ const number = (name: string): number | undefined => {
21
+ if (options[name] === undefined) return undefined;
22
+ if (!/^[0-9]+$/.test(String(options[name]))) throw new Error(`${name}: expected integer`);
23
+ return Number(options[name]);
24
+ };
25
+ const limit = number("--limit"), maxBytes = number("--max-bytes"), offset = number("--offset");
26
+ // Validate bounds before opening or updating a derived cache.
27
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)) throw new Error("limit: expected integer 1..500");
28
+ if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 65536)) throw new Error("max-bytes: expected integer 1..65536");
29
+ if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_NOTE_BYTES)) throw new Error(`offset: expected integer 0..${MAX_NOTE_BYTES}`);
30
+ const section = options["--section"] as string | undefined;
31
+ if (section !== undefined && (!section.trim() || /[\r\n]/.test(section) || Buffer.byteLength(section) > 1024)) throw new Error("section: expected one exact H2 heading <=1024 bytes");
32
+ const index = new VaultIndex(home, vault, registry.vaults.map(v => v.path));
33
+ let result: unknown;
34
+ try {
35
+ const scan = index.scan(command === "rebuild");
36
+ if (command === "rebuild") result = { vault: vault.id, ...scan };
37
+ else if (command === "search") result = index.search(positional[1]!, { limit, type: options["--type"] as string | undefined, domain: options["--domain"] as string | undefined });
38
+ else if (command === "show") result = index.show(positional[1]!, maxBytes, section, offset);
39
+ else if (command === "links") result = index.links(positional[1]!, limit);
40
+ else throw new Error("unknown read command");
41
+ } finally { index.close(); }
42
+ return { text: JSON.stringify(result, null, options["--json"] ? undefined : 2), code: 0 };
43
+ }
@@ -0,0 +1,170 @@
1
+ import { closeSync, constants, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parseVault, readConfig, type VaultSpec, type VmemConfig } from "./config.ts";
6
+ export const LOCK_FILE = "config.lock";
7
+ const TEMP = /^\.config-[0-9a-f-]{36}\.tmp$/;
8
+ export interface LockOwner { pid: number; host: string; created: string }
9
+ export function loadRegistry(home: string): VmemConfig {
10
+ const config = readConfig(home);
11
+ if (config.home) throw new Error(`${join(home, CONFIG_FILE)}: redirected home cannot contain another home pointer`);
12
+ return config;
13
+ }
14
+ /** Effective pii policy: explicit value, else deny for org and allow for personal. */
15
+ export function effectivePii(vault: VaultSpec): "allow" | "deny" { return vault.pii ?? (vault.kind === "org" ? "deny" : "allow"); }
16
+ function message(error: unknown): string { return error instanceof Error ? error.message : String(error); }
17
+ /**
18
+ * Directory fsync makes the rename durable on Linux. On macOS fsync(2) on a directory
19
+ * succeeds but does not force the drive cache (no F_FULLFSYNC); Windows cannot open
20
+ * directories for fsync, so the rename is atomic but durability is left to the OS there.
21
+ */
22
+ export function syncDirectory(dir: string, platform: NodeJS.Platform = process.platform): boolean {
23
+ if (platform === "win32") return false;
24
+ const fd = openSync(dir, constants.O_RDONLY);
25
+ try { fsyncSync(fd); return true; } finally { closeSync(fd); }
26
+ }
27
+ function mutate<T>(home: string, update: (config: VmemConfig) => T): T {
28
+ mkdirSync(home, { recursive: true, mode: 0o700 });
29
+ const lock = join(home, LOCK_FILE);
30
+ let lockFd: number;
31
+ try { lockFd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); }
32
+ catch (error) {
33
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`${lock}: registry is locked by another writer; run \`vmem recover\` if no writer is running`, { cause: error });
34
+ throw new Error(`${lock}: cannot create registry lock`, { cause: error });
35
+ }
36
+ let temporary: string | undefined;
37
+ let primary: unknown;
38
+ let failed = false;
39
+ try {
40
+ const owner: LockOwner = { pid: process.pid, host: hostname(), created: new Date().toISOString() };
41
+ writeFileSync(lockFd, JSON.stringify(owner) + "\n");
42
+ const config = loadRegistry(home), result = update(config);
43
+ parseConfig(config);
44
+ const text = JSON.stringify(config, null, 2) + "\n";
45
+ if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) throw new Error(`${join(home, CONFIG_FILE)}: serialized config exceeds 64 KiB`);
46
+ const candidate = join(home, `.config-${randomUUID()}.tmp`);
47
+ const fd = openSync(candidate, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
48
+ temporary = candidate;
49
+ try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
50
+ renameSync(temporary, join(home, CONFIG_FILE));
51
+ temporary = undefined;
52
+ syncDirectory(home);
53
+ return result;
54
+ } catch (error) {
55
+ failed = true; primary = error;
56
+ throw error;
57
+ } finally {
58
+ // Each cleanup is independent: a temp cleanup error must not strand our lock.
59
+ const cleanupErrors: string[] = [];
60
+ for (const [label, cleanup] of [
61
+ ["temporary file", () => { if (temporary) unlinkSync(temporary); }],
62
+ ["lock descriptor", () => closeSync(lockFd)],
63
+ [`lock retained at ${lock}`, () => unlinkSync(lock)],
64
+ ] as const) {
65
+ try { cleanup(); } catch (error) { cleanupErrors.push(`${label}: ${message(error)}`); }
66
+ }
67
+ if (cleanupErrors.length) {
68
+ const note = `cleanup failed (${cleanupErrors.join("; ")})`;
69
+ // Keep the primary error as the thrown error and add the cleanup failure to it.
70
+ if (failed && primary instanceof Error) primary.message = `${primary.message}; ${note}`;
71
+ else if (!failed) throw new Error(`${home}: registry updated but ${note}`);
72
+ }
73
+ }
74
+ }
75
+ function lockOwner(lock: string): LockOwner | undefined {
76
+ try {
77
+ const value = JSON.parse(readFileSync(lock, "utf8")) as Partial<LockOwner>;
78
+ if (typeof value.pid === "number" && Number.isInteger(value.pid) && value.pid > 0 && typeof value.host === "string" && typeof value.created === "string") return value as LockOwner;
79
+ } catch { /* unreadable or legacy lock */ }
80
+ return undefined;
81
+ }
82
+ function alive(pid: number): boolean {
83
+ try { process.kill(pid, 0); return true; }
84
+ catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
85
+ }
86
+ export interface RecoveryReport {
87
+ applied: boolean;
88
+ lock: { path: string; state: "absent" | "live" | "stale" | "unknown"; owner?: LockOwner; removed: boolean };
89
+ temporaryFiles: string[];
90
+ removedTemporaryFiles: string[];
91
+ blocked?: string;
92
+ }
93
+ /**
94
+ * Crash recovery. Preview unless confirm. A lock is stale only when its owner record names this host
95
+ * and a pid that no longer exists; live, foreign-host and unreadable locks are never removed.
96
+ * Leftover `.config-<uuid>.tmp` files are removed only when no live or unknown lock is held.
97
+ */
98
+ export function recoverRegistry(home: string, confirm = false): RecoveryReport {
99
+ const path = join(home, LOCK_FILE);
100
+ let entries: string[] = [];
101
+ try { entries = readdirSync(home); }
102
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error(`${home}: cannot list registry home`, { cause: error }); }
103
+ const temporaryFiles = entries.filter(name => TEMP.test(name)).sort();
104
+ const report: RecoveryReport = { applied: false, lock: { path, state: "absent", removed: false }, temporaryFiles, removedTemporaryFiles: [] };
105
+ if (entries.includes(LOCK_FILE)) {
106
+ const owner = lockOwner(path);
107
+ if (owner) report.lock.owner = owner;
108
+ report.lock.state = !owner || owner.host !== hostname() ? "unknown" : alive(owner.pid) ? "live" : "stale";
109
+ }
110
+ if (report.lock.state === "live" || report.lock.state === "unknown") {
111
+ report.blocked = report.lock.state === "live" ? "a live writer holds the lock" : "lock owner cannot be verified on this host; confirm no writer runs, then remove the lock manually";
112
+ return report;
113
+ }
114
+ if (!confirm) return report;
115
+ report.applied = true;
116
+ if (report.lock.state === "stale") {
117
+ // Re-check immediately before removal to narrow the window against a new writer.
118
+ const again = lockOwner(path);
119
+ if (!again || again.pid !== report.lock.owner?.pid || again.created !== report.lock.owner.created) { report.applied = false; report.blocked = "lock changed during recovery"; return report; }
120
+ unlinkSync(path); report.lock.removed = true;
121
+ }
122
+ for (const name of temporaryFiles) {
123
+ try { unlinkSync(join(home, name)); report.removedTemporaryFiles.push(name); }
124
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error(`${join(home, name)}: cannot remove temporary file`, { cause: error }); }
125
+ }
126
+ return report;
127
+ }
128
+ export function addVault(home: string, value: unknown): VaultSpec {
129
+ const vault = parseVault(value);
130
+ try { vault.path = realpathSync(vault.path); }
131
+ catch (error) { throw new Error(`vault ${vault.id}: path ${vault.path} is unavailable`, { cause: error }); }
132
+ const notes = join(vault.path, "notes");
133
+ let isDir = false;
134
+ try { isDir = statSync(notes).isDirectory(); } catch { /* reported below */ }
135
+ if (!isDir) throw new Error(`vault ${vault.id}: ${vault.path} must contain notes directory`);
136
+ if (realpathSync(notes) !== notes) throw new Error(`vault ${vault.id}: ${notes} must not be a symlink`);
137
+ return mutate(home, config => {
138
+ if (config.vaults.some(v => v.id === vault.id)) throw new Error(`vault ${vault.id}: id already registered`);
139
+ if (config.vaults.some(v => {
140
+ if (v.path === vault.path) return true;
141
+ try { return realpathSync(v.path) === vault.path; }
142
+ catch (error) {
143
+ if (["ENOENT", "ENOTDIR", "EACCES", "ELOOP"].includes((error as NodeJS.ErrnoException).code ?? "")) return false;
144
+ throw error;
145
+ }
146
+ })) throw new Error(`vault ${vault.id}: path ${vault.path} already registered`);
147
+ config.vaults.push(vault);
148
+ return vault;
149
+ });
150
+ }
151
+ export function removeVault(home: string, id: string, confirm = false): { applied: boolean; vault: VaultSpec } {
152
+ const remove = (config: VmemConfig) => {
153
+ const vault = config.vaults.find(v => v.id === id);
154
+ if (!vault) throw new Error(`${join(home, CONFIG_FILE)}: unknown vault id ${id}`);
155
+ if (confirm) config.vaults = config.vaults.filter(v => v.id !== id);
156
+ return { applied: confirm, vault };
157
+ };
158
+ return confirm ? mutate(home, remove) : remove(loadRegistry(home));
159
+ }
160
+ export function registryStatus(home: string) {
161
+ const config = loadRegistry(home);
162
+ return config.vaults.map(vault => {
163
+ const base = { ...vault, pii: effectivePii(vault) };
164
+ try {
165
+ const root = realpathSync(vault.path), notes = join(root, "notes");
166
+ if (root !== vault.path || realpathSync(notes) !== notes || !statSync(notes).isDirectory()) throw new Error("changed");
167
+ return { ...base, healthy: true };
168
+ } catch { return { ...base, healthy: false, issue: "vault path or notes directory unavailable or changed" }; }
169
+ });
170
+ }