write.mjs
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