UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

218 lines 8.3 kB
import { join } from 'node:path'; import { THE_FRAMEWORK_DIR } from './framework-dir.js'; import { nodeStoreFs } from './store/index.js'; /** * The `.the-framework/LOGS.md` project log (#378): a human-readable markdown * record of every loop, prompt, and build run in a project. Pure core over the * same {@link StoreFs} seam as the run store; the run-lifecycle wiring and UI * land in later issues (#379/#380). */ /** The directory, under the project root, that holds the log. Defined in its own node-free * module (#874) so the browser-reachable preset registry can share it; re-exported here * because this is where every existing import site expects to find it. */ export { THE_FRAMEWORK_DIR }; /** The markdown log file name. */ export const LOGS_FILE = 'LOGS.md'; /** * The `.the-framework/.gitignore` that keeps run state transient (#313): the dir * holds both the committed DB (LOGS.md) and the transient run logs, so this * ignores everything except LOGS.md and itself. * * An allow-list, so anything else meant to be committed needs its own negation — * see `CONVERSATIONS_GITIGNORE` (#908), which install appends to this. */ export const LOGS_GITIGNORE = '# The Framework: the committed project DB; session state is transient.\n*\n!.gitignore\n!LOGS.md\n'; const KINDS = ['loop', 'prompt', 'build']; const STATUSES = ['done', 'stopped', 'failed', 'running']; /** Heading field separator: a middle dot (U+00B7) with a space on each side. */ const SEP = ' · '; /** * Escape a free-text field so it stays on its own line (#897). A title is the run's prompt and a * prompt bullet is agent text, but the file is committed history parsed line by line: an unescaped * newline spills the rest of the prompt into the file, where a `## ` line forges an entry and a * `- status: ` line rewrites one. Reversed by {@link decodeField}. */ function encodeField(value) { return value.replace(/\\/g, '\\\\').replace(/\r\n|\r|\n/g, '\\n'); } /** Reverse {@link encodeField}. Entries written before #897 are unescaped, so a literal `\n` in * one of them decodes to a newline; harmless next to reading the rest of the prompt as entries. */ function decodeField(value) { return value.replace(/\\(\\|n)/g, (_, char) => (char === 'n' ? '\n' : '\\')); } /** The one-time first line of the file, written on the first append. */ export const LOGS_HEADER = '# The Framework logs\n'; /** The log path under `cwd`. */ export function logsPath(cwd) { return join(cwd, THE_FRAMEWORK_DIR, LOGS_FILE); } /** The `.gitignore` path under `cwd`'s `.the-framework/`. */ export function gitignorePath(cwd) { return join(cwd, THE_FRAMEWORK_DIR, '.gitignore'); } /** * The single-line `- key: ` fields, in render order, each owning both directions of its own * encoding. render and parse both walk this one list, so a field's name, prefix, and escaping * can never drift between the two sides — the file's one real hazard when they were hand-mirrored. * The heading and the `- prompts:` nested list are structurally different and stay bespoke. */ const LOG_FIELDS = [ { prefix: '- status: ', render: entry => entry.status, parse: (body, draft) => { draft.status = body.trim(); }, }, { prefix: '- run: ', render: entry => (entry.id ? encodeField(entry.id) : undefined), parse: (body, draft) => { draft.id = decodeField(body.trim()); }, }, { prefix: '- session: ', render: entry => entry.sessionId === undefined ? undefined : entry.sessionLink ? `[${entry.sessionId}](${entry.sessionLink})` : entry.sessionId, parse: (body, draft) => { const value = body.trim(); const linked = /^\[(.+)\]\((.+)\)$/.exec(value); if (linked) { draft.sessionId = linked[1]; draft.sessionLink = linked[2]; } else { draft.sessionId = value; } }, }, { prefix: '- name: ', render: entry => (entry.sessionName ? encodeField(entry.sessionName) : undefined), parse: (body, draft) => { draft.sessionName = decodeField(body.trim()); }, }, { prefix: '- branch: ', render: entry => (entry.branch ? encodeField(entry.branch) : undefined), parse: (body, draft) => { draft.branch = decodeField(body.trim()); }, }, ]; /** Markdown for one entry, starting at `## ` (no file header, no blank lines around it). */ export function renderLogEntry(entry) { const lines = [`## ${entry.at}${SEP}${entry.kind}${SEP}${encodeField(entry.title)}`, '']; for (const field of LOG_FIELDS) { const body = field.render(entry); if (body !== undefined) lines.push(`${field.prefix}${body}`); } if (entry.prompts && entry.prompts.length > 0) { lines.push('- prompts:'); for (const prompt of entry.prompts) lines.push(` - ${encodeField(prompt)}`); } return lines.join('\n'); } /** * Parse every entry out of the markdown, in file order (append order, so * oldest-first). Forgiving: a malformed or torn entry is skipped, never thrown. */ export function parseLogs(md) { const entries = []; let block; const flush = () => { const entry = block && parseEntry(block); if (entry) entries.push(entry); block = undefined; }; for (const line of md.split('\n')) { if (line.startsWith('## ')) { flush(); block = [line]; } else if (block) { block.push(line); } // Anything before the first `## ` (the file header) is ignored. } flush(); return entries; } /** Parse one `## `-headed block; `undefined` when a required field is missing/invalid. */ function parseEntry(lines) { const heading = lines[0]?.slice('## '.length) ?? ''; const parts = heading.split(SEP); const at = parts[0]; const kind = parts[1]; // Re-join the rest so a title containing the separator survives. const title = decodeField(parts.slice(2).join(SEP)); if (!at || !kind || !title || !KINDS.includes(kind)) return undefined; const draft = {}; let prompts; let inPrompts = false; for (const line of lines.slice(1)) { if (inPrompts && line.startsWith(' - ')) { prompts.push(decodeField(line.slice(' - '.length))); continue; } inPrompts = false; if (line.trim() === '- prompts:') { prompts = []; inPrompts = true; continue; } const field = LOG_FIELDS.find(f => line.startsWith(f.prefix)); if (field) field.parse(line.slice(field.prefix.length), draft); } if (!draft.status || !STATUSES.includes(draft.status)) return undefined; const entry = { at, kind: kind, title, status: draft.status, }; if (draft.id) entry.id = draft.id; if (draft.sessionId) entry.sessionId = draft.sessionId; if (draft.sessionLink) entry.sessionLink = draft.sessionLink; if (draft.sessionName) entry.sessionName = draft.sessionName; if (draft.branch) entry.branch = draft.branch; if (prompts && prompts.length > 0) entry.prompts = prompts; return entry; } /** * Append one entry to `.the-framework/LOGS.md`, creating the dir and the * one-time file header when absent. A raw write (may reject); the caller * decides best-effort. */ export async function appendLog(cwd, entry, fs = nodeStoreFs()) { await fs.mkdir(join(cwd, THE_FRAMEWORK_DIR)); const path = logsPath(cwd); if (!(await fs.exists(path))) await fs.write(path, LOGS_HEADER); await fs.append(path, '\n' + renderLogEntry(entry) + '\n'); } /** Read the project log, newest-first (append order reversed). Missing file yields `[]`. */ export async function readLogs(cwd, fs = nodeStoreFs()) { const path = logsPath(cwd); if (!(await fs.exists(path))) return []; return parseLogs(await fs.read(path)).reverse(); } //# sourceMappingURL=logs.js.map