open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
212 lines (194 loc) • 6.95 kB
text/typescript
import fs from "node:fs";
import path from "node:path";
import { db } from "./db.ts";
import { cjkIndexText } from "../retrieve/cjk.ts";
import { contentHash, repairChain } from "./lifecycle.ts";
import {
iterMemoryFiles,
iterInRepoMemoryFiles,
readMemoryFile,
timeToMs,
type MemoryFile,
} from "./markdown.ts";
import { projectRoot, paths } from "../paths.ts";
import { loadConfig } from "../config.ts";
const UPSERT_SQL = `
INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, aliases, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
scope_key = excluded.scope_key,
scope = excluded.scope,
visibility = excluded.visibility,
project_name = excluded.project_name,
type = excluded.type,
role = excluded.role,
importance = excluded.importance,
status = excluded.status,
tags = excluded.tags,
aliases = excluded.aliases,
content = excluded.content,
cjk = excluded.cjk,
content_hash = excluded.content_hash,
superseded_by = excluded.superseded_by,
source = excluded.source,
review_state = excluded.review_state,
file_path = excluded.file_path,
mtime_ms = excluded.mtime_ms,
updated_at = excluded.updated_at
`;
export function upsertFromFile(mf: MemoryFile): void {
// Chain integrity (§3.3): every write path through Core validates the
// supersedes/superseded_by pair and auto-completes a missing side.
const { warnings, repaired } = repairChain(mf);
for (const w of warnings) console.warn(`[open-memex] chain: ${w}`);
const mtimeOf = (m: MemoryFile): number => {
try {
return fs.statSync(m.filePath).mtimeMs;
} catch {
return m.mtimeMs; // file vanished mid-repair; keep the old mtime
}
};
writeRow(
mf,
repaired.some((r) => r.filePath === mf.filePath) ? mtimeOf(mf) : mf.mtimeMs,
);
for (const r of repaired) {
if (r.filePath === mf.filePath) continue;
writeRow(r, mtimeOf(r));
}
}
function writeRow(mf: MemoryFile, mtimeMs: number): void {
const { fm, body, filePath } = mf;
const tags = (fm.tags ?? []).join(",");
const aliases = (fm.aliases ?? []).join(",");
db().prepare(UPSERT_SQL).run(
fm.id,
fm.scope_key,
fm.scope,
fm.visibility,
fm.project_name,
fm.type,
fm.role,
fm.importance,
fm.status,
tags,
aliases,
body,
cjkIndexText(body + "\n" + tags + "\n" + aliases),
contentHash(body),
fm.superseded_by ?? null,
fm.source ?? "",
filePath,
mtimeMs,
timeToMs(fm.created_at),
timeToMs(fm.updated_at),
fm.review_state ?? "draft",
);
}
export function deleteFromIndex(id: string): void {
db().prepare(`DELETE FROM memories WHERE id = ?`).run(id);
}
export interface SyncStats {
added: number;
updated: number;
removed: number;
scanned: number;
}
export function syncScope(scopeKey: string, kind: SyncKind = "auto"): SyncStats {
const d = db();
const stats: SyncStats = { added: 0, updated: 0, removed: 0, scanned: 0 };
const existing = d
.prepare(`SELECT id, file_path, mtime_ms FROM memories WHERE scope_key = ?`)
.all(scopeKey) as Array<{ id: string; file_path: string; mtime_ms: number }>;
const existingById = new Map(existing.map((r) => [r.id, r]));
const seen = new Set<string>();
// 2B/D26: project scopes have two homes — the appdata outbox (drafts,
// branch-independent) and the in-repo dir (submitted memories, following
// the current branch). No migration: appdata files stay put until an
// explicit `submit` moves them. On the near-impossible id collision the
// in-repo (submitted) copy wins, so scan appdata first.
const filePaths: string[] = [];
if (scopeKey.startsWith("project__")) {
const root = projectRoot();
const memoryDir = loadConfig().memoryDir;
for (const fp of iterMemoryFiles(scopeKey)) filePaths.push(fp);
for (const fp of iterInRepoMemoryFiles(root, memoryDir)) filePaths.push(fp);
} else {
for (const fp of iterMemoryFiles(scopeKey)) filePaths.push(fp);
}
for (const fp of filePaths) {
const mf = readMemoryFile(fp);
if (!mf) continue;
stats.scanned++;
seen.add(mf.fm.id);
const prev = existingById.get(mf.fm.id);
if (!prev) {
upsertFromFile(mf);
stats.added++;
} else if (prev.mtime_ms !== mf.mtimeMs || prev.file_path !== mf.filePath) {
upsertFromFile(mf);
stats.updated++;
}
}
for (const row of existing) {
if (!seen.has(row.id) || !fs.existsSync(row.file_path)) {
deleteFromIndex(row.id);
stats.removed++;
}
}
recordSync(scopeKey, kind, stats);
return stats;
}
// ---------------------------------------------------------------------------
// D31: last-sync visibility
// ---------------------------------------------------------------------------
/** What triggered the sync — shown in sync-status so the user can see it. */
export type SyncKind = "session" | "request" | "cli" | "submit" | "pull" | "push" | "auto";
export interface SyncStateEntry {
lastSyncAt: string; // RFC 3339
lastSyncKind: SyncKind;
added: number;
updated: number;
removed: number;
scanned: number;
}
function syncStatePath(): string {
return path.join(paths().root, "sync-state.json");
}
/** Best-effort: a failed write never breaks the sync itself. */
export function recordSync(scopeKey: string, kind: SyncKind, stats: SyncStats): void {
try {
const p = syncStatePath();
let all: Record<string, SyncStateEntry> = {};
if (fs.existsSync(p)) {
try {
all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, SyncStateEntry>;
} catch {
/* corrupt — start fresh */
}
}
all[scopeKey] = {
lastSyncAt: new Date().toISOString(),
lastSyncKind: kind,
added: stats.added,
updated: stats.updated,
removed: stats.removed,
scanned: stats.scanned,
};
fs.writeFileSync(p, JSON.stringify(all, null, 2), "utf8");
} catch {
/* visibility is advisory — never fail a sync over it */
}
}
export function readSyncState(scopeKey: string): SyncStateEntry | null {
try {
const p = syncStatePath();
if (!fs.existsSync(p)) return null;
const all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, unknown>;
const e = all?.[scopeKey] as SyncStateEntry | undefined;
if (!e || typeof e.lastSyncAt !== "string") return null;
return e;
} catch {
return null;
}
}