UNPKG

open-memex

Version:

Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.

444 lines (443 loc) • 17.2 kB
/** * 2B/D26: propose → submit → promote → resolve workflow. * * - `propose <id> --to project`: copy a personal memory into the project * outbox (appdata) as a draft. Never moves — the personal original stays * put. Outbox drafts are git-invisible until submitted. * - `submit <ids>`: move outbox drafts into a git branch (new `mem/sync-*` * by default), as `proposed`, commit locally, print push + PR commands. * Push/PR need the user's explicit approval — the tool never pushes. * - `promote <id>`: advance a project memory one step up the review ladder * (proposed → approved → published), or --reject it with a note. * - `resolve [id]`: assist with git merge conflicts inside the in-repo * memory dir. Frontmatter gets a field-level 3-way merge; semantic * conflicts are reported, never auto-resolved. * * Git automation line (D25 as revised by D26): local git only (submit's * branch + commit). Nothing leaves the machine without approval. */ import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { loadConfig } from "./config.js"; import { inRepoMemoriesDirPath, projectRoot } from "./paths.js"; import { resolveProjectScope } from "./scope.js"; import { parse as parseMemory, serialize, atomicWriteTextSync, ulid, writeMemoryFile, } from "./store/markdown.js"; import { findMemoryFile } from "./store/lifecycle.js"; import { upsertFromFile } from "./store/sync.js"; /** Best-effort author identity: git user.name, else the OS user. */ export function currentAuthor() { try { const name = execFileSync("git", ["config", "user.name"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); if (name) return name; } catch { /* git unavailable — fall through */ } try { return os.userInfo().username || "unknown"; } catch { return "unknown"; } } function fail(msg) { throw new Error(`[open-memex] ${msg}`); } /** * Copy one personal memory into the project scope as a review candidate. * The source memory is untouched (copy, not move — D25). * Batch-friendly: validates nothing, copies one — use proposeMemories() * for the all-or-nothing multi-id path. */ export function proposeMemory(sourceId, opts = {}) { const src = findMemoryFile(sourceId); if (!src) fail(`no memory found with id "${sourceId}".`); return copyPersonalToProject(sourceId, src.fm, src.body, opts); } function copyPersonalToProject(sourceId, srcFm, srcBody, opts = {}) { if (srcFm.scope === "project") fail(`memory "${sourceId}" is already in project scope — nothing to propose.`); if (srcFm.scope === "org") fail(`memory "${sourceId}" is already shared at org scope.`); if (srcFm.status === "retracted" || srcFm.status === "archived") fail(`memory "${sourceId}" is ${srcFm.status} — cannot propose it.`); const project = resolveProjectScope(projectRoot()); const author = currentAuthor(); const now = new Date().toISOString(); // propose lands in the outbox as a draft (submit moves it to proposed); // --local-approve is the solo-dev shortcut straight to approved. const targetState = opts.localApprove ? "approved" : "draft"; const fm = { ...srcFm, id: ulid(), scope: "project", scope_key: project.key, // D64: the copy is headed for the team. Source visibility (usually // "private") must not ride along: export excludes private by default, // so a private copy would silently vanish from every bundle the team // builds. Proposing IS the act of sharing — the copy is internal; // the personal original keeps its own visibility, untouched. visibility: "internal", project_name: project.projectName, created_at: now, updated_at: now, supersedes: null, superseded_by: null, review_state: targetState, proposed_by: author, approved_by: opts.localApprove ? author : null, derived_from: srcFm.id, review_history: [ { at: now, by: author, from: "draft", to: targetState, note: opts.localApprove ? `proposed from personal memory ${srcFm.id} (local-approved)` : `proposed from personal memory ${srcFm.id}`, }, ], }; const { filePath } = writeMemoryFile(fm, srcBody); upsertFromFile({ fm, body: srcBody, filePath, mtimeMs: Date.now() }); return { id: fm.id, filePath, reviewState: fm.review_state }; } /** * Propose several personal memories at once — one branch, one PR. * All-or-nothing: every id is validated before anything is copied, so a bad * id never leaves a half-proposed batch behind. */ export function proposeMemories(sourceIds, opts = {}) { if (sourceIds.length === 0) fail("propose needs at least one memory id."); const seen = new Set(); const sources = sourceIds.map((sourceId) => { if (seen.has(sourceId)) fail(`duplicate id "${sourceId}" — list each memory once.`); seen.add(sourceId); const src = findMemoryFile(sourceId); if (!src) fail(`no memory found with id "${sourceId}".`); if (src.fm.scope !== "personal") fail(`only personal memories can be proposed (memory "${sourceId}" is ${src.fm.scope}).`); if (src.fm.status === "retracted" || src.fm.status === "archived") fail(`memory "${sourceId}" is ${src.fm.status} — cannot propose it.`); return { sourceId, fm: src.fm, body: src.body }; }); return sources.map(({ sourceId, fm, body }) => ({ sourceId, result: copyPersonalToProject(sourceId, fm, body, opts), })); } const NEXT_STATE = { proposed: "approved", approved: "published", }; /** * Advance a project memory one step up the review ladder * (proposed → approved → published), reject it with --reject, or send a * rejected memory back for another round with --resubmit. * * A rejection never deletes anything: the file stays on the author's branch. * What happens next is the human's call — accept it (close the PR, delete the * branch), revise + --resubmit, or keep the rejected file as a record. */ export function promoteMemory(id, opts = {}) { const mf = findMemoryFile(id); if (!mf) fail(`no memory found with id "${id}".`); if (mf.fm.scope !== "project") fail(`only project-scope memories go through review (memory "${id}" is ${mf.fm.scope}).`); const from = mf.fm.review_state ?? "draft"; const by = opts.by?.trim() || currentAuthor(); let to; if (opts.resubmit) { if (opts.reject) fail("choose one: --resubmit or --reject, not both."); if (from !== "rejected") fail(`only a "rejected" memory can be resubmitted (memory "${id}" is "${from}").`); to = "proposed"; } else if (opts.reject) { if (from !== "proposed" && from !== "approved") fail(`cannot reject from "${from}" — only "proposed" or "approved" can be rejected.`); to = "rejected"; } else { const next = NEXT_STATE[from]; if (!next) fail(from === "draft" ? `memory "${id}" is still a draft — run "open-memex propose ${id} --to project" first.` : `memory "${id}" is "${from}" — a terminal state, nothing to promote.`); to = next; } const fm = { ...mf.fm, review_state: to, updated_at: new Date().toISOString(), // A rejection withdraws any earlier approval; a resubmission clears it too // (the memory must earn approval again). approved_by: to === "approved" ? by : to === "rejected" || to === "proposed" ? null : mf.fm.approved_by, review_note: opts.note?.trim() ? opts.note.trim() : mf.fm.review_note, // D29: every transition is appended to the audit trail — the note is // preserved here even when review_note later gets overwritten. review_history: [ ...(mf.fm.review_history ?? []), { at: new Date().toISOString(), by, from, to, note: opts.note?.trim() ? opts.note.trim() : null, }, ], }; atomicWriteTextSync(mf.filePath, serialize(fm, mf.body)); upsertFromFile({ fm, body: mf.body, filePath: mf.filePath, mtimeMs: Date.now() }); return { id, from, to, history: fm.review_history }; } /** Render a memory's audit trail as compact human-readable lines (D29). */ export function formatReviewHistory(history) { return history.map((t) => ` ${t.at.slice(0, 10)} ${t.by}: ${t.from} → ${t.to}` + (t.note ? ` — ${t.note}` : "")); } function git(args, cwd) { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); } function inRepoDir() { const root = projectRoot(); try { git(["rev-parse", "--git-dir"], root); } catch { fail("resolve needs a git repo — this directory is not one."); } return { root, dir: inRepoMemoriesDirPath(root, loadConfig().memoryDir) }; } /** List in-repo memory files currently in an unmerged (conflicted) state. */ export function listConflicts() { const { root, dir } = inRepoDir(); let out; try { out = git(["diff", "--name-only", "--diff-filter=U", "--", dir], root); } catch { fail("git is unavailable or the repo state cannot be read."); } return out .split("\n") .map((s) => s.trim()) .filter((s) => s && s.endsWith(".md")) .map((rel) => ({ id: path.basename(rel, ".md"), filePath: path.join(root, rel), })); } function show(v) { if (v === null || v === undefined) return "(empty)"; if (Array.isArray(v)) return v.length ? v.join(", ") : "(empty)"; return String(v); } /** * Attempt a field-level 3-way merge of a conflicted memory file. * Never auto-resolves semantic conflicts (same field changed differently * on both sides) — those are reported for the human to decide. */ export function resolveConflict(target) { const { root, dir } = inRepoDir(); // Locate the file: by memory id, or as a direct path. Note findMemoryFile // returns null for a file with conflict markers (YAML won't parse), so // fall back to a direct <id>.md lookup inside the in-repo dir. let filePath = null; const byId = findMemoryFile(target); if (byId) { filePath = byId.filePath; } else { const base = path.basename(target).replace(/\.md$/, ""); const candidates = [ path.join(dir, `${base}.md`), path.isAbsolute(target) ? target : path.join(root, target), path.isAbsolute(target) ? `${target}.md` : path.join(root, `${target}.md`), ]; for (const c of candidates) { if (fs.existsSync(c) && fs.statSync(c).isFile()) { filePath = c; break; } } } if (!filePath) fail(`no memory or file found for "${target}".`); // Must actually be in conflict (stages 1/2/3 present). const stages = git(["ls-files", "-u", "--", filePath], root) .split("\n") .map((l) => l.trim()) .filter(Boolean); if (stages.length === 0) fail(`"${path.basename(filePath)}" is not in a merge-conflict state.`); const blob = (stage) => { // Git wants forward slashes even on Windows. const rel = path.relative(root, filePath).split(path.sep).join("/"); try { return git(["show", `:${stage}:${rel}`], root); } catch { fail(`cannot read git stage ${stage} for "${path.basename(filePath)}".`); throw new Error("unreachable"); } }; const base = parseMemory(blob("1")); const ours = parseMemory(blob("2")); const theirs = parseMemory(blob("3")); if (!base || !ours || !theirs) fail(`one of the conflict stages for "${path.basename(filePath)}" is not a valid memory file.`); const b = base.fm; const o = ours.fm; const t = theirs.fm; const conflicts = []; const autoMerged = []; const merged = {}; // Identity fields: both sides must agree, else this is not the same memory. for (const f of ["id", "scope_key", "scope", "project_name", "schema_version"]) { if (o[f] !== t[f]) { conflicts.push({ field: f, base: show(b[f]), ours: show(o[f]), theirs: show(t[f]) }); } else { merged[f] = o[f]; } } // Plain scalar fields: standard 3-way. const scalars = [ "visibility", "type", "role", "importance", "status", "source", "created_at", "proposed_by", "approved_by", "derived_from", "review_note", "supersedes", "superseded_by", ]; for (const f of scalars) { const bv = b[f]; const ov = o[f]; const tv = t[f]; if (eq(ov, tv)) { merged[f] = ov; } else if (eq(bv, ov)) { merged[f] = tv; autoMerged.push(f); } else if (eq(bv, tv)) { merged[f] = ov; autoMerged.push(f); } else { conflicts.push({ field: f, base: show(bv), ours: show(ov), theirs: show(tv) }); } } // review_state: 3-way, but never silently drop a rejection. { const bv = b.review_state; const ov = o.review_state; const tv = t.review_state; if (ov === tv) merged.review_state = ov; else if (bv === ov) merged.review_state = tv; else if (bv === tv) merged.review_state = ov; else if (ov === "rejected" || tv === "rejected") conflicts.push({ field: "review_state", base: bv, ours: ov, theirs: tv }); else { // Both advanced along the ladder; keep the further one. const order = ["draft", "proposed", "approved", "published"]; merged.review_state = order.indexOf(tv) > order.indexOf(ov) ? tv : ov; } if (!conflicts.some((c) => c.field === "review_state") && merged.review_state !== bv) autoMerged.push("review_state"); } // tags: union when both sides changed. { const bs = new Set(b.tags ?? []); const os = new Set(o.tags ?? []); const ts = new Set(t.tags ?? []); if (eq([...os].sort(), [...ts].sort())) merged.tags = [...os]; else { const union = [...new Set([...os, ...ts])].sort(); merged.tags = union; if (!eq([...bs].sort(), union)) autoMerged.push("tags"); } } // review_history (D29 audit trail): union by identity — a transition that // happened on either side is kept; identical entries collapse. { const key = (e) => `${e.at}|${e.by}|${e.from}|${e.to}`; const seen = new Set(); const union = []; const all = [...(b.review_history ?? []), ...(o.review_history ?? []), ...(t.review_history ?? [])]; for (const e of all) { const k = key(e); if (!seen.has(k)) { seen.add(k); union.push(e); } } merged.review_history = union; if (union.length !== (b.review_history ?? []).length) autoMerged.push("review_history"); } // updated_at: always moves forward — take the latest. { const latest = [b.updated_at, o.updated_at, t.updated_at].sort().at(-1); merged.updated_at = latest; if (latest !== b.updated_at) autoMerged.push("updated_at"); } // body: 3-way text merge; divergent edits are a human decision. const bb = base.body.trimEnd(); const ob = ours.body.trimEnd(); const tb = theirs.body.trimEnd(); let mergedBody; if (ob === tb) mergedBody = ob; else if (bb === ob) { mergedBody = tb; autoMerged.push("body"); } else if (bb === tb) { mergedBody = ob; autoMerged.push("body"); } else { conflicts.push({ field: "body", base: bb.slice(0, 120), ours: ob.slice(0, 120), theirs: tb.slice(0, 120) }); mergedBody = bb; // unused when conflicts exist } if (conflicts.length > 0) return { ok: false, filePath, conflicts }; const fm = merged; atomicWriteTextSync(filePath, serialize(fm, mergedBody)); upsertFromFile({ fm, body: mergedBody, filePath, mtimeMs: Date.now() }); return { ok: true, filePath, autoMerged: [...new Set(autoMerged)] }; } function eq(a, b) { return JSON.stringify(a) === JSON.stringify(b); }