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
JavaScript
/**
* 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);
}