write.mjs
158 lines 5.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Write a note to the vault. Path validation, frontmatter merge, append. SPEC §4.1.
3 */
4
5 import fs from 'fs';
6 import path from 'path';
7 import yaml from 'js-yaml';
8 import { resolveVaultRelativePath, readNote, parseFrontmatterAndBody, listMarkdownFiles } from './vault.mjs';
9
10 /**
11 * Delete a note file under the vault. Path must be vault-relative and safe.
12 * @param {string} vaultPath - Absolute path to vault root
13 * @param {string} relativePath - Vault-relative path (e.g. inbox/foo.md)
14 * @returns {{ path: string, deleted: boolean }}
15 * @throws if path escapes vault, or file is missing / not a file
16 */
17 export function deleteNote(vaultPath, relativePath) {
18 const safe = resolveVaultRelativePath(vaultPath, relativePath);
19 const fullPath = path.join(vaultPath, safe);
20 if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
21 throw new Error(`Note not found: ${safe}`);
22 }
23 fs.unlinkSync(fullPath);
24 return { path: safe.replace(/\\/g, '/'), deleted: true };
25 }
26
27 /**
28 * Normalize vault-relative path prefix for project/folder bulk delete.
29 * @param {string} raw
30 * @returns {string}
31 * @throws if invalid or unsafe
32 */
33 export function normalizePathPrefix(raw) {
34 if (raw == null || typeof raw !== 'string') throw new Error('path_prefix required');
35 let s = raw.trim().replace(/\\/g, '/');
36 while (s.startsWith('/')) s = s.slice(1);
37 while (s.endsWith('/')) s = s.slice(0, -1);
38 if (!s) throw new Error('path_prefix cannot be empty');
39 for (const seg of s.split('/')) {
40 if (seg === '..' || seg === '.') throw new Error('Invalid path_prefix');
41 }
42 return s;
43 }
44
45 /**
46 * @param {string} notePath
47 * @param {string} prefixNorm
48 */
49 export function notePathMatchesPrefix(notePath, prefixNorm) {
50 const p = String(notePath || '').replace(/\\/g, '/');
51 if (p === prefixNorm) return true;
52 return p.startsWith(prefixNorm + '/');
53 }
54
55 /**
56 * @param {string} vaultPath
57 * @param {string} pathPrefixRaw
58 * @returns {{ deleted: number, paths: string[] }}
59 */
60 export function deleteNotesByPrefix(vaultPath, pathPrefixRaw, options = {}) {
61 const prefixNorm = normalizePathPrefix(pathPrefixRaw);
62 resolveVaultRelativePath(vaultPath, prefixNorm);
63 const paths = listMarkdownFiles(vaultPath, { ignore: options.ignore || [] });
64 const toDelete = paths.filter((rel) => notePathMatchesPrefix(rel, prefixNorm));
65 const deletedPaths = [];
66 for (const rel of toDelete) {
67 try {
68 deleteNote(vaultPath, rel);
69 deletedPaths.push(rel.replace(/\\/g, '/'));
70 } catch (e) {
71 if (e.message && e.message.includes('not found')) continue;
72 throw e;
73 }
74 }
75 return { deleted: deletedPaths.length, paths: deletedPaths };
76 }
77
78
79 /**
80 * Serialize frontmatter and body to Markdown file content.
81 * @param {{ [key: string]: unknown }} frontmatter
82 * @param {string} body
83 * @returns {string}
84 */
85 function toMarkdown(frontmatter, body) {
86 const y = yaml.dump(frontmatter, { lineWidth: -1, noRefs: true }).trimEnd();
87 return `---\n${y}\n---\n${body || ''}`;
88 }
89
90 /**
91 * Check if a vault-relative path is under inbox (global or project).
92 * @param {string} relativePath - vault-relative, forward slashes
93 * @returns {boolean}
94 */
95 export function isInboxPath(relativePath) {
96 const n = relativePath.replace(/\\/g, '/');
97 return n === 'inbox' || n.startsWith('inbox/') || /^projects\/[^/]+\/inbox(\/|$)/.test(n);
98 }
99
100 /**
101 * Write or update a note. Creates parent directories if needed.
102 *
103 * When `options.config` is provided and `config.air.enabled=true`, attestation is obtained via
104 * `attestBeforeWrite` before the file is written. A real (non-placeholder) attestation ID is
105 * stored as `air_id` in the note's frontmatter (Improvement A). If `air.required=true` and
106 * attestation fails, an `AttestationRequiredError` is thrown and no file is written.
107 *
108 * @param {string} vaultPath - Absolute path to vault root
109 * @param {string} relativePath - Vault-relative path (e.g. inbox/foo.md)
110 * @param {{ body?: string, frontmatter?: Record<string, string>, append?: boolean, config?: object }} options
111 * @returns {Promise<{ path: string, written: boolean }>}
112 * @throws if path escapes vault, write fails, or attestation is required but unavailable
113 */
114 export async function writeNote(vaultPath, relativePath, options = {}) {
115 const safe = resolveVaultRelativePath(vaultPath, relativePath);
116 const fullPath = path.join(vaultPath, safe);
117
118 let frontmatter = {};
119 let body = options.body ?? '';
120
121 const exists = fs.existsSync(fullPath) && fs.statSync(fullPath).isFile();
122 if (exists) {
123 const content = fs.readFileSync(fullPath, 'utf8');
124 const parsed = parseFrontmatterAndBody(content);
125 frontmatter = { ...parsed.frontmatter };
126 if (options.append) {
127 body = (parsed.body || '') + (options.body ?? '');
128 } else if (options.body === undefined) {
129 body = parsed.body || '';
130 }
131 }
132
133 const overrides = options.frontmatter ?? {};
134 for (const [k, v] of Object.entries(overrides)) {
135 if (v === undefined || v === null) continue;
136 frontmatter[k] = String(v).trim();
137 }
138
139 // Improvement A: obtain attestation and store returned id in frontmatter.
140 // attestBeforeWrite handles the enabled/inbox/required checks internally.
141 if (options.config) {
142 const { attestBeforeWrite } = await import('./air.mjs');
143 const airId = await attestBeforeWrite(options.config, safe.replace(/\\/g, '/'));
144 if (airId && airId !== 'air-placeholder-write') {
145 frontmatter.air_id = airId;
146 }
147 }
148
149 const dir = path.dirname(fullPath);
150 if (!fs.existsSync(dir)) {
151 fs.mkdirSync(dir, { recursive: true });
152 }
153
154 const out = toMarkdown(frontmatter, body);
155 fs.writeFileSync(fullPath, out, 'utf8');
156
157 return { path: safe.replace(/\\/g, '/'), written: true };
158 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago