open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
449 lines (421 loc) • 18.1 kB
text/typescript
/**
* 2B/D26: submit — move outbox drafts into a git branch for review.
*
* The AI-driven sync loop:
* 1. `open-memex sync-status` — what is waiting (outbox drafts, in-review, published)
* 2. user picks memories to sync
* 3. `open-memex submit <ids>` — new branch (default `mem/sync-*`), copy
* drafts into the repo dir as `proposed`, commit locally, move the
* outbox originals out (copy + verify + delete)
* 4. push + open PR — done by the agent holding the user's approval, or by
* the human; the tool only prints the exact commands (D27)
*
* Git automation line (D27): the tool automates LOCAL git
* only (branch, copy, commit). Nothing leaves the machine without the user's
* explicit approval — push/PR are the agent's or the human's job.
*
* `submit` is idempotent: re-submitting an id whose file is already on the
* branch with identical content just completes the outbox move. A same-id
* file with DIFFERENT content is a conflict — abort, the human judges.
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { loadConfig } from "./config.ts";
import { inRepoMemoriesDir, memoriesDirPath, projectRoot } from "./paths.ts";
import { resolveProjectScope } from "./scope.ts";
import { contentHash } from "./store/lifecycle.ts";
import {
msToRfc3339,
parse as parseMemory,
readMemoryFile,
serialize,
atomicWriteTextSync,
type ReviewState,
} from "./store/markdown.ts";
import { upsertFromFile, recordSync, readSyncState } from "./store/sync.ts";
import { db } from "./store/db.ts";
import { currentAuthor } from "./review.ts";
function fail(msg: string): never {
throw new Error(`[open-memex] ${msg}`);
}
function git(root: string, args: string[]): string {
try {
return execFileSync("git", args, {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch (e) {
const err = e as { stderr?: string; message?: string };
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
}
}
function snippetOf(body: string): string {
const line = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
return line.length > 60 ? line.slice(0, 60) + "…" : line;
}
// ---------------------------------------------------------------------------
// status
// ---------------------------------------------------------------------------
export type OutboxLocation = "outbox" | "repo";
export type GitState = "uncommitted" | "committed" | "n/a";
export interface StatusEntry {
id: string;
title: string;
reviewState: ReviewState;
location: OutboxLocation;
gitState: GitState;
}
export interface SyncStatus {
scopeKey: string;
projectName: string;
outbox: StatusEntry[]; // appdata drafts waiting for submit
inRepo: StatusEntry[]; // files in the repo dir (any review_state)
uncommitted: StatusEntry[]; // repo files not yet committed (the untracked window)
}
/** Files under .ai/open-memex that git sees as new/modified/untracked. */
function uncommittedRepoFiles(root: string, repoDir: string): Set<string> {
const out = new Set<string>();
try {
const porcelain = git(root, ["status", "--porcelain", "--", repoDir]);
for (const line of porcelain.split("\n")) {
const m = line.match(/^.{2}\s+(.*)$/);
if (!m) continue;
let p = m[1].trim();
// handle renames: "R old -> new"
if (p.includes(" -> ")) p = p.split(" -> ")[1];
// strip quotes git adds around unusual names
if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
out.add(path.resolve(root, p));
}
} catch {
/* not a git repo or git unavailable — no git state */
}
return out;
}
export function getSyncStatus(): SyncStatus {
const root = projectRoot();
const scope = resolveProjectScope(root);
const cfg = loadConfig();
const outboxDir = memoriesDirPath(scope.key);
const repoDir = path.join(root, cfg.memoryDir);
const rows = db()
.prepare(
`SELECT id, review_state, file_path, content FROM memories WHERE scope_key = ? ORDER BY updated_at DESC`,
)
.all(scope.key) as Array<{
id: string;
review_state: string;
file_path: string;
content: string;
}>;
const uncommitted = fs.existsSync(repoDir) ? uncommittedRepoFiles(root, repoDir) : new Set<string>();
const outbox: StatusEntry[] = [];
const inRepo: StatusEntry[] = [];
const uncommittedList: StatusEntry[] = [];
for (const r of rows) {
const inOutbox = r.file_path.startsWith(outboxDir + path.sep);
const entry: StatusEntry = {
id: r.id,
title: snippetOf(r.content ?? ""),
reviewState: (r.review_state ?? "draft") as ReviewState,
location: inOutbox ? "outbox" : "repo",
gitState: "n/a",
};
if (inOutbox) {
outbox.push(entry);
} else {
entry.gitState = uncommitted.has(path.resolve(r.file_path)) ? "uncommitted" : "committed";
inRepo.push(entry);
if (entry.gitState === "uncommitted") uncommittedList.push(entry);
}
}
return {
scopeKey: scope.key,
projectName: scope.projectName,
outbox,
inRepo,
uncommitted: uncommittedList,
};
}
/**
* D53: cheap outbox draft count — one indexed query, no git I/O — for the
* push-not-poll note appended to mutating tool results and to the MCP
* session-start instructions. Same outbox definition as getSyncStatus
* (file under the scope's outbox dir), without the expensive parts.
*/
export function outboxDraftCount(scopeKey: string): number {
const outboxDir = memoriesDirPath(scopeKey);
const row = db()
.prepare(
`SELECT COUNT(*) AS n FROM memories WHERE scope_key = ? AND instr(file_path, ?) = 1`,
)
.get(scopeKey, outboxDir + path.sep) as { n: number } | undefined;
return row?.n ?? 0;
}
export function formatSyncStatus(st: SyncStatus, opts: { cap?: number } = {}): string {
const lines: string[] = [];
lines.push(`project: ${st.projectName} (${st.scopeKey})`);
// D31: when the index was last synced and what triggered it.
const last = readSyncState(st.scopeKey);
if (last) {
lines.push(
`last sync: ${last.lastSyncAt} (${last.lastSyncKind}) — +${last.added} ~${last.updated} -${last.removed} (scanned ${last.scanned})`,
);
} else {
lines.push(`last sync: never`);
}
// Agent-facing renders cap each section so a big store can't flood a
// tool result; the cap line points at the CLI, which shows everything.
const CAP = opts.cap ?? 20;
const more = (n: number) =>
` … and ${n} more (full list: open-memex sync-status)`;
lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
for (const e of st.outbox.slice(0, CAP)) {
lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
}
if (st.outbox.length > CAP) lines.push(more(st.outbox.length - CAP));
const byState = new Map<string, number>();
for (const e of st.inRepo) byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
const breakdown = [...byState.entries()].map(([s, n]) => `${n} ${s}`).join(", ") || "none";
lines.push(`repo .ai/open-memex: ${st.inRepo.length} (${breakdown})`);
for (const e of st.inRepo.slice(0, CAP)) {
const u = e.gitState === "uncommitted" ? " (uncommitted)" : "";
lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
}
if (st.inRepo.length > CAP) lines.push(more(st.inRepo.length - CAP));
if (st.uncommitted.length > 0) {
lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
}
if (st.outbox.length > 0) {
lines.push(`next: open-memex submit ${st.outbox.map((e) => e.id).join(" ")}`);
}
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// submit
// ---------------------------------------------------------------------------
export interface SubmitOptions {
/** Create this branch and submit onto it. If omitted, submit stays on the
current branch — D36: no auto-created branches; branch creation is the
human's call (or the agent's, only with explicit approval). */
branch?: string;
/** PR base override (default: the branch the submit ran on). */
base?: string;
}
export interface SubmitResult {
branch: string;
base: string;
/** true when --branch created a new branch for this submit */
createdBranch: boolean;
submitted: Array<{ id: string; filePath: string; reviewState: ReviewState }>;
/** already on the branch with identical content — outbox move completed */
skippedIdentical: string[];
/** false when everything was already on the branch (nothing new to commit) */
committed: boolean;
pushCommand: string;
prCommand: string;
}
interface Validated {
id: string;
srcPath: string;
body: string;
reviewState: ReviewState;
}
export function submitMemories(ids: string[], opts: SubmitOptions = {}): SubmitResult {
if (ids.length === 0) fail("submit needs at least one memory id");
const root = projectRoot();
// submit needs a git repo — the memories land in .ai/open-memex/ and are
// committed locally. Branch creation is never automatic (D36).
git(root, ["rev-parse", "--git-dir"]);
const scope = resolveProjectScope(root);
const cfg = loadConfig();
const outboxDir = memoriesDirPath(scope.key);
const repoDir = inRepoMemoriesDir(root, cfg.memoryDir);
// 1. Validate everything first (all-or-nothing — nothing is written yet).
// Read the outbox files directly from disk: the index resolves same-id
// collisions in favor of the repo copy, which would hide a genuine
// outbox-vs-branch conflict from us.
const problems: string[] = [];
const validated: Validated[] = [];
for (const id of ids) {
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
problems.push(`${id}: not a valid memory id`);
continue;
}
const srcPath = path.join(outboxDir, `${id}.md`);
if (!fs.existsSync(srcPath)) {
problems.push(`${id}: not in the outbox (never staged, or already submitted? see sync-status)`);
continue;
}
const mf = readMemoryFile(srcPath);
if (!mf) {
problems.push(`${id}: unreadable outbox file`);
continue;
}
if (mf.fm.id !== id) {
problems.push(`${id}: outbox file's frontmatter id is ${mf.fm.id} — not submitting a mislabeled file`);
continue;
}
if (mf.fm.scope !== "project") {
problems.push(`${id}: scope is ${mf.fm.scope}, only project memories can be submitted`);
continue;
}
const rs = (mf.fm.review_state ?? "draft") as ReviewState;
// draft/rejected move to proposed; a local-approved copy keeps its approval.
if (rs !== "draft" && rs !== "rejected" && rs !== "approved") {
problems.push(`${id}: review_state is ${rs}, only draft/rejected/approved can be submitted`);
continue;
}
validated.push({ id, srcPath, body: mf.body, reviewState: rs });
}
if (problems.length > 0) {
fail(`submit aborted — nothing was written:\n ${problems.join("\n ")}`);
}
// 2. Resolve the target branch.
// D36: submit never creates a branch on its own — it works on the branch
// you're already on. Pass --branch <name> (explicitly) only when the user
// approved the full chain (branch + push + PR).
const startBranch = git(root, ["branch", "--show-current"]) || git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
let branch: string = startBranch;
let createdBranch = false;
if (opts.branch) {
branch = opts.branch;
let n = 0;
let candidate = branch;
while (true) {
const exists = git(root, ["branch", "--list", candidate]);
if (!exists) break;
n++;
candidate = `${branch}-${n}`;
}
branch = candidate;
git(root, ["checkout", "-b", branch]);
createdBranch = true;
}
const base = opts.base ?? startBranch;
const author = currentAuthor();
// 3. Copy drafts into the repo dir as proposed (same id — one memory, one id).
// Same id + different content on the branch = conflict → abort, human judges.
const submitted: Array<{ id: string; filePath: string; reviewState: ReviewState }> = [];
const skippedIdentical: string[] = [];
const now = msToRfc3339(Date.now());
const written: Array<{ id: string; srcPath: string; destPath: string; hash: string }> = [];
const conflicts: string[] = [];
for (const v of validated) {
const destPath = path.join(repoDir, `${v.id}.md`);
const raw = fs.existsSync(destPath) ? fs.readFileSync(destPath, "utf8") : null;
if (raw !== null) {
const existing = parseMemory(raw);
const sameContent = existing !== null && contentHash(existing.body) === contentHash(v.body);
if (sameContent) {
skippedIdentical.push(v.id);
written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(v.body) });
continue;
}
conflicts.push(`${v.id}: already on branch ${branch} with different content — resolve it manually, then re-submit`);
continue;
}
const parsed = parseMemory(fs.readFileSync(v.srcPath, "utf8"));
if (!parsed) fail(`${v.id}: unreadable source file`);
// draft/rejected → proposed; a local-approved copy arrives already
// approved and keeps its approval (and its approved_by).
const destState: ReviewState = v.reviewState === "approved" ? "approved" : "proposed";
const fm = {
...parsed.fm,
review_state: destState,
updated_at: now,
proposed_by: parsed.fm.proposed_by ?? author,
// D29: the outbox → repo move is part of the audit trail.
review_history: [
...(parsed.fm.review_history ?? []),
{
at: now,
by: author,
from: parsed.fm.review_state ?? "draft",
to: destState,
note: `submitted to branch ${branch}`,
},
],
};
const text = serialize(fm, parsed.body);
atomicWriteTextSync(destPath, text);
written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(parsed.body) });
submitted.push({ id: v.id, filePath: destPath, reviewState: destState });
}
if (conflicts.length > 0) {
// Roll back the copies we just made — nothing half-submitted.
for (const w of written) {
if (submitted.some((s) => s.id === w.id) && fs.existsSync(w.destPath)) {
fs.unlinkSync(w.destPath);
}
}
// If we created the branch and never committed, remove it too — an
// aborted submit leaves no trace.
if (createdBranch) {
try {
git(root, ["checkout", "--quiet", startBranch]);
git(root, ["branch", "--quiet", "-D", branch]);
} catch {
/* best effort — the error below is what matters */
}
}
fail(`submit aborted — nothing was committed:\n ${conflicts.join("\n ")}`);
}
// 4. Commit locally (tool automates local git only — D27 line).
// Only our own files are committed: anything else the user staged stays
// staged and untouched.
const destPaths = written.map((w) => w.destPath);
const relPaths = destPaths.map((p: string) => path.relative(root, p));
git(root, ["add", "--", ...destPaths]);
const staged = git(root, ["diff", "--cached", "--name-only", "--", ...relPaths]);
let committed = false;
if (staged.trim()) {
const short = submitted.map((s) => s.id.slice(0, 8)).join(" ");
git(root, ["commit", "-m", `mem: submit ${submitted.length} ${submitted.length === 1 ? "memory" : "memories"} for review (${short})`, "--", ...relPaths]);
committed = true;
}
// 5. Verify the committed copies, then move the outbox originals out
// (copy + verify + delete — never a bare rename across the move).
for (const w of written) {
const back = fs.readFileSync(w.destPath, "utf8");
const bParsed = parseMemory(back);
if (!bParsed || contentHash(bParsed.body) !== w.hash) {
fail(`${w.id}: verification failed after commit — outbox copy kept at ${w.srcPath}`);
}
// Same id, new home: ON CONFLICT(id) rewrites the row to the repo path.
upsertFromFile({
fm: bParsed.fm,
body: bParsed.body,
filePath: w.destPath,
mtimeMs: fs.statSync(w.destPath).mtimeMs,
});
fs.unlinkSync(w.srcPath);
}
const ids8 = validated.map((v) => v.id.slice(0, 8)).join(" ");
// D31: the submit itself is a sync event — visible in sync-status.
recordSync(scope.key, "submit", {
added: submitted.length,
updated: skippedIdentical.length,
removed: 0,
scanned: validated.length,
});
// D36: no auto-branch — the commit sits on the branch you were already on.
// Next steps are printed, not run. For a separate memory PR, create a branch
// first (the commit comes along), then push + open the PR.
const branchCmd = `git checkout -b mem/sync-YYYYMMDD`;
return {
branch,
base,
submitted,
skippedIdentical,
committed,
createdBranch,
pushCommand: `git push -u origin ${branch}`,
prCommand: createdBranch
? `gh pr create --base ${base} --title "mem: review ${validated.length} ${validated.length === 1 ? "memory" : "memories"}" --body "Submitted from the open-memex outbox: ${ids8}."`
: `${branchCmd} # if you want a separate memory PR (else push ${branch} directly)\n git push -u origin <new-branch> && gh pr create --base ${base} --title "mem: review ${validated.length}" --body "Submitted from the open-memex outbox: ${ids8}."`,
};
}