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.
176 lines • 8.14 kB
JavaScript
import { join } from 'node:path';
import { THE_FRAMEWORK_DIR } from './framework-dir.js';
import { gitignorePath, LOGS_GITIGNORE } from './logs.js';
import { isSafeRunId, nodeStoreFs } from './store/index.js';
/**
* The committed conversations (#908): the human turns and the agent's replies of a run, kept in
* the Git repo so a clone carries the chat and not just the fact a run happened (#857).
*
* Deliberately not the verbose transcript — #857 leaves the tool-call-level log to the model
* provider, which is also the standing policy in run-store.ts. What lands here is what a person
* would reread: what was asked, and what came back.
*
* One file per run rather than one shared file, because run worktrees are live concurrently and
* each auto-commits its own pending work on teardown; a shared file would be a merge conflict
* every time two runs chatted at once. The run id is the join key back to the `- run:` field
* LOGS.md records (#898), so the committed session list and the committed chat line up.
*
* Pure core over the same {@link StoreFs} seam as logs.ts.
*/
/** The directory, under `.the-framework/`, that holds one markdown file per conversation. */
export const CONVERSATIONS_DIR = 'conversations';
/**
* The `.the-framework/.gitignore` that keeps run state transient while committing the DB. The
* conversations rules need both entries: the `*` rule makes git skip the directory without ever
* descending into it, so un-ignoring the files alone would never be reached.
*/
export const CONVERSATIONS_GITIGNORE = `!${CONVERSATIONS_DIR}/\n!${CONVERSATIONS_DIR}/**\n`;
const ROLES = ['user', 'agent'];
/** Heading field separator, matching LOGS.md: a middle dot (U+00B7) with a space on each side. */
const SEP = ' · ';
/** A transport name is a plain word, so it can never carry the heading separator into a heading. */
const VIA = /^[A-Za-z0-9_-]+$/;
/**
* Whether a transport name is safe to record. Exported because #917 lets a surface name itself
* over the control channel, so the name now arrives from outside this module: a `via` carrying
* the heading separator, a newline or a `#` would forge structure in a file whose entries are
* line-parsed, exactly the way #897 forged a LOGS.md title. Checked at the boundary rather than
* trusted, and the same predicate {@link parseConversation} reads back with.
*/
export function isSafeVia(via) {
return typeof via === 'string' && VIA.test(via);
}
/**
* Escape a message body so it cannot forge structure (#897's threat model, applied to a
* transcript). LOGS.md collapses free text to one line, which is right for a line-parsed record
* and wrong here: a multi-paragraph reply has to stay readable in a `git diff`. So the text stays
* as written and only a line's leading `#` or `\` is escaped, which is enough — an entry is only
* ever started by a line beginning `## `. Reversed by {@link unescapeBody}.
*/
export function escapeBody(text) {
return text.replace(/^([\\#])/gm, '\\$1');
}
/** Reverse {@link escapeBody}. */
export function unescapeBody(text) {
return text.replace(/^\\([\\#])/gm, '$1');
}
/** The one-time first line of a conversation file. */
function conversationHeader(runId) {
return `# Conversation ${runId}\n`;
}
/** The directory holding every conversation under `cwd`. */
export function conversationsDir(cwd) {
return join(cwd, THE_FRAMEWORK_DIR, CONVERSATIONS_DIR);
}
/**
* One conversation's path. `undefined` for an unsafe run id: the id reaches this from a run
* store and, once #680 lands, indirectly from a chat surface, so it is checked rather than
* trusted into a path.
*/
export function conversationPath(cwd, runId) {
if (!isSafeRunId(runId))
return undefined;
return join(conversationsDir(cwd), `${runId}.md`);
}
/** Markdown for one message, starting at `## ` (no file header, no blank lines around it). */
export function renderMessage(message) {
return `## ${message.at}${SEP}${message.role}${SEP}${message.via}\n\n${escapeBody(message.text)}`;
}
/**
* Parse every message out of the markdown, in file order (append order, so oldest-first — a
* transcript reads forwards, unlike the newest-first project log). Forgiving: a malformed or
* torn message is skipped, never thrown.
*/
export function parseConversation(md) {
const messages = [];
let block;
const flush = () => {
const message = block && parseMessage(block);
if (message)
messages.push(message);
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 messages;
}
/** Parse one `## `-headed block; `undefined` when a required field is missing/invalid. */
function parseMessage(lines) {
const heading = lines[0]?.slice('## '.length) ?? '';
const parts = heading.split(SEP);
const at = parts[0];
const role = parts[1];
const via = parts[2];
if (!at || !role || !via || !ROLES.includes(role) || !VIA.test(via))
return undefined;
// A heading carries exactly three fields; anything more is not one of ours.
if (parts.length !== 3)
return undefined;
// Drop the blank line the renderer puts under the heading, and any trailing blank lines that
// are really the separator before the next message.
const body = lines.slice(1).join('\n').replace(/^\n/, '').replace(/\n+$/, '');
return { at, role: role, via, text: unescapeBody(body) };
}
/**
* Make sure `.the-framework/.gitignore` un-ignores the conversations dir, returning whether it
* wrote. Done lazily on append rather than only at install time: the seeded ignore file is
* written once and only when absent (`install.ts`), so every repo activated before this feature
* still carries the old three-line allow-list and would silently drop its own conversations.
*
* Only ours is upgraded — a file we do not recognize is left alone rather than appended to.
*/
export async function ensureConversationsIgnored(cwd, fs = nodeStoreFs()) {
const path = gitignorePath(cwd);
if (!(await fs.exists(path))) {
await fs.write(path, LOGS_GITIGNORE + CONVERSATIONS_GITIGNORE);
return true;
}
const current = await fs.read(path);
if (current.includes(`!${CONVERSATIONS_DIR}/**`))
return false;
if (!current.includes('!LOGS.md'))
return false;
await fs.write(path, current.endsWith('\n') ? current + CONVERSATIONS_GITIGNORE : current + '\n' + CONVERSATIONS_GITIGNORE);
return true;
}
/**
* Append one message to `.the-framework/conversations/<runId>.md`, creating the dir, the
* one-time file header, and the ignore rule when absent. A raw write (may reject); the caller
* decides best-effort. A no-op for an unsafe run id.
*/
export async function appendMessage(cwd, runId, message, fs = nodeStoreFs()) {
const path = conversationPath(cwd, runId);
if (!path)
return;
await fs.mkdir(conversationsDir(cwd));
await ensureConversationsIgnored(cwd, fs);
if (!(await fs.exists(path)))
await fs.write(path, conversationHeader(runId));
await fs.append(path, '\n' + renderMessage(message) + '\n');
}
/** Read one conversation, oldest-first. Missing file (or unsafe id) yields `[]`. */
export async function readConversation(cwd, runId, fs = nodeStoreFs()) {
const path = conversationPath(cwd, runId);
if (!path || !(await fs.exists(path)))
return [];
return parseConversation(await fs.read(path));
}
/** The run ids that have a committed conversation, sorted. Missing dir yields `[]`. */
export async function listConversations(cwd, fs = nodeStoreFs()) {
const names = await fs.readdir(conversationsDir(cwd)).catch(() => []);
return names
.filter(name => name.endsWith('.md'))
.map(name => name.slice(0, -'.md'.length))
.filter(isSafeRunId)
.sort();
}
//# sourceMappingURL=conversations.js.map