UNPKG

open-memex

Version:

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

406 lines (373 loc) • 14.4 kB
import fs from "node:fs"; import path from "node:path"; import { randomBytes } from "node:crypto"; import yaml from "js-yaml"; import { memoriesDirFor, memoriesDirPath, inRepoMemoriesDirPath, } from "../paths.ts"; /** v2 content-kind taxonomy (V2-DESIGN §3.1). `type` = what the memory IS * (single-valued, drives behavior). D41: 11 types; `warning`→`gotcha`, * `workflow`→`howto`, `incident`→`lesson`, `architecture`→`knowledge`. */ export const MEMORY_TYPE_TAXONOMY = [ "fact", "preference", "decision", "constraint", "todo", "knowledge", "howto", "gotcha", "lesson", "observation", "reference", ] as const; const TAXONOMY = new Set<string>(MEMORY_TYPE_TAXONOMY); export type ScopeKind = "personal" | "project" | "org"; export type Visibility = "private" | "internal" | "shared"; export type MemoryRole = "knowledge" | "instruction"; export type Importance = "low" | "normal" | "high"; export type MemoryStatus = | "active" | "superseded" | "deprecated" | "retracted" | "archived"; /** v2 frontmatter (V2-DESIGN §3). Markdown is the source of truth; the * SQLite index is derived and rebuildable (D1). `scope_key` is kept as the * local storage address; `scope` is the semantic ownership. */ export interface Frontmatter { id: string; schema_version: 2; scope_key: string; scope: ScopeKind; visibility: Visibility; project_name: string; type: string; role: MemoryRole; importance: Importance; status: MemoryStatus; tags: string[]; /** * D61: capture-time aliases — alternate phrasings / other-language * equivalents of this memory, indexed alongside the body so a * differently-worded question still matches (retrieval layer 3). * Optional and additive: files without it parse unchanged. */ aliases?: string[]; /** * D81: user-defined alias vocabulary — `alias` is the nickname * (e.g. 香蕉计划), `target` is what it refers to * (e.g. 支付系统重构项目). Unlike D61 per-memory aliases (alternate * phrasings of one memory), an alias memory defines vocabulary once and * the query-time expansion (retrieve/alias-memory.ts) applies it to every * search in its scope — no reindex when the alias changes. Optional; * files without it parse unchanged. */ alias?: string; target?: string; source: string; created_at: string; // RFC 3339, never bare epoch (§3) updated_at: string; // RFC 3339 supersedes: string | null; // on the NEW memory → points BACK (§3.3) superseded_by: string | null; // on the OLD memory → points FORWARD /** 2B/D25: where this memory sits in the propose → promote workflow. */ review_state: ReviewState; /** Who proposed / approved it (git user.name, fallback OS user). */ proposed_by: string | null; approved_by: string | null; /** For propose-copies: the personal memory this was derived from. */ derived_from: string | null; /** Curator note on the latest review transition (e.g. rejection reason). */ review_note: string | null; /** 2B/D29: append-only audit trail of review transitions. Lives in the * file (source of truth), so it travels through branches and PRs. */ review_history: ReviewTransition[]; } /** One step in a memory's review lifecycle — who moved it, when, why. */ export interface ReviewTransition { at: string; // RFC 3339 by: string; // author identity (git user.name, fallback OS user) from: ReviewState; to: ReviewState; note: string | null; } /** Defensive parse: malformed entries are dropped, never fatal. */ export function asReviewHistory(v: unknown): ReviewTransition[] { if (!Array.isArray(v)) return []; const out: ReviewTransition[] = []; for (const e of v) { if (!e || typeof e !== "object") continue; const r = e as Record<string, unknown>; const from = asReviewState(r.from); const to = asReviewState(r.to); const at = typeof r.at === "string" && r.at ? r.at : null; const by = typeof r.by === "string" && r.by ? r.by : null; if (!at || !by) continue; out.push({ at, by, from, to, note: typeof r.note === "string" && r.note ? r.note : null, }); } return out; } /** Review lifecycle for shared memories (2B/D25): draft → proposed → * approved/rejected → published. Personal memories stay `draft`. */ export type ReviewState = "draft" | "proposed" | "approved" | "rejected" | "published"; const REVIEW_STATES: ReviewState[] = ["draft", "proposed", "approved", "rejected", "published"]; export function asReviewState(v: unknown): ReviewState { return typeof v === "string" && (REVIEW_STATES as string[]).includes(v) ? (v as ReviewState) : "draft"; } export interface MemoryFile { fm: Frontmatter; body: string; filePath: string; mtimeMs: number; } const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; /** ULID-ish: 10 chars of base32 timestamp + 16 chars of base32 randomness. */ export function ulid(): string { let ts = ""; let n = Date.now(); for (let i = 0; i < 10; i++) { ts = CROCKFORD[n % 32]! + ts; n = Math.floor(n / 32); } const bytes = randomBytes(16); let rand = ""; for (const b of bytes) rand += CROCKFORD[b % 32]!; return ts + rand; } /** epoch ms → RFC 3339 (v2 times). */ export function msToRfc3339(ms: number): string { return new Date(ms).toISOString().replace(/\.000Z$/, "Z"); } /** RFC 3339 (or epoch ms) → epoch ms. Falls back to now on garbage. */ export function timeToMs(v: unknown): number { if (typeof v === "number" && Number.isFinite(v)) return Math.round(v); if (typeof v === "string") { const t = Date.parse(v); if (Number.isFinite(t)) return t; } return Date.now(); } function asScopeKind(v: unknown, scopeKind: unknown): ScopeKind { if (v === "personal" || v === "project" || v === "org") return v; if (scopeKind === "user") return "personal"; // v1 → v2 (§19) if (scopeKind === "project") return "project"; return "personal"; } function asVisibility(v: unknown, scope: ScopeKind): Visibility { if (v === "private" || v === "internal" || v === "shared") return v; // v2 defaults (§4): personal stays local, project is internal. return scope === "personal" ? "private" : "internal"; } function asRole(v: unknown): MemoryRole { if (v === "knowledge" || v === "instruction") return v; return "knowledge"; } function asImportance(v: unknown, priority: unknown): Importance { if (v === "low" || v === "normal" || v === "high") return v; // v1 `priority: N` → importance (§19): 1–3 low, 8–10 high, else normal. if (typeof priority === "number" && Number.isFinite(priority)) { if (priority <= 3) return "low"; if (priority >= 8) return "high"; } return "normal"; } function asStatus(v: unknown): MemoryStatus { if ( v === "active" || v === "superseded" || v === "deprecated" || v === "retracted" || v === "archived" ) return v; return "active"; } /** * Normalize raw (possibly v1) frontmatter into v2 shape. Lenient on read: * v1 files (epoch times, scope_kind, priority, type: instruction) are mapped * per §19 so old files keep working even before `migrate --to-v2` rewrites * them. Use `planConversion` in v2migrate.ts for the explicit, reporting * migration path. */ export function normalizeFrontmatter( raw: Record<string, unknown>, ): Frontmatter { const scope = asScopeKind(raw.scope, raw.scope_kind); let scopeKey = typeof raw.scope_key === "string" && raw.scope_key ? raw.scope_key : scope; if (scopeKey === "user") scopeKey = "personal"; // v1 storage dir → v2 let type = typeof raw.type === "string" && raw.type ? raw.type : "fact"; let role = asRole(raw.role); if (type === "instruction") { // §19: v1 `type: instruction` → content-kind + role split (D11). type = "knowledge"; role = "instruction"; } return { id: String(raw.id), schema_version: 2, scope_key: scopeKey, scope, visibility: asVisibility(raw.visibility, scope), project_name: typeof raw.project_name === "string" ? raw.project_name : scopeKey, type, role, importance: asImportance(raw.importance, raw.priority), status: asStatus(raw.status), tags: Array.isArray(raw.tags) ? raw.tags.filter((t): t is string => typeof t === "string") : [], ...(Array.isArray(raw.aliases) ? (() => { const aliases = normalizeAliases( raw.aliases.filter((a): a is string => typeof a === "string"), ); return aliases.length > 0 ? { aliases } : {}; })() : {}), // D81: alias vocabulary — single trimmed strings, dropped when empty. ...(typeof raw.alias === "string" && raw.alias.trim() ? { alias: raw.alias.trim() } : {}), ...(typeof raw.target === "string" && raw.target.trim() ? { target: raw.target.trim() } : {}), source: typeof raw.source === "string" ? raw.source : "", created_at: msToRfc3339(timeToMs(raw.created_at)), updated_at: msToRfc3339(timeToMs(raw.updated_at)), supersedes: typeof raw.supersedes === "string" && raw.supersedes ? raw.supersedes : null, superseded_by: typeof raw.superseded_by === "string" && raw.superseded_by ? raw.superseded_by : null, review_state: asReviewState(raw.review_state), proposed_by: typeof raw.proposed_by === "string" && raw.proposed_by ? raw.proposed_by : null, approved_by: typeof raw.approved_by === "string" && raw.approved_by ? raw.approved_by : null, derived_from: typeof raw.derived_from === "string" && raw.derived_from ? raw.derived_from : null, review_note: typeof raw.review_note === "string" && raw.review_note ? raw.review_note : null, review_history: asReviewHistory(raw.review_history), }; } /** * D61: normalize capture-time aliases — trim, drop empties, dedupe * (case-insensitive), cap at 4. Bad aliases are noise, never an error. * Shared by the tool ops, the CLI, and frontmatter parsing. */ export function normalizeAliases(input?: string[]): string[] { if (!input) return []; const seen = new Set<string>(); const out: string[] = []; for (const a of input) { const t = a.trim(); if (!t) continue; const k = t.toLowerCase(); if (seen.has(k)) continue; seen.add(k); out.push(t); if (out.length >= 4) break; } return out; } export function isTaxonomyType(t: string): boolean { return TAXONOMY.has(t); } export function serialize(fm: Frontmatter, body: string): string { // D61: an empty aliases list is noise in the file — drop the key entirely // rather than relying on the YAML dumper to skip an undefined value // (parse only sets the field when non-empty; keep both ends consistent). let out: Frontmatter = fm; if (fm.aliases && fm.aliases.length === 0) { out = { ...fm }; delete out.aliases; } const yml = yaml.dump(out, { lineWidth: -1, quotingType: '"' }); return `---\n${yml}---\n\n${body.trimEnd()}\n`; } /** Raw frontmatter parse (no normalization) — for migration tooling. */ export function parseRawFrontmatter( raw: string, ): { rawFm: Record<string, unknown>; body: string } | null { const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) return null; try { const rawFm = yaml.load(m[1]!) as Record<string, unknown>; if (!rawFm || typeof rawFm !== "object" || !rawFm.id) return null; const body = (m[2] ?? "").replace(/^\n+/, ""); return { rawFm, body }; } catch { return null; } } export function parse(raw: string): { fm: Frontmatter; body: string } | null { const parsed = parseRawFrontmatter(raw); if (!parsed) return null; return { fm: normalizeFrontmatter(parsed.rawFm), body: parsed.body }; } /** * Atomic text publish: write a sibling tmp file and rename over the * target, so a concurrent reader (another process mid-sync) never sees a * torn half-written file. Same-directory rename is atomic on one fs. * Every memory-file write/rewrite must go through here (D57 for creates, * D64 extended to all rewrites: supersede flips, review transitions, * submit landings, status changes). */ export function atomicWriteTextSync(filePath: string, text: string): void { const tmpPath = `${filePath}.tmp-${process.pid}`; fs.writeFileSync(tmpPath, text, "utf8"); fs.renameSync(tmpPath, filePath); } export function writeMemoryFile( fm: Frontmatter, body: string, ): { filePath: string; mtimeMs: number } { // 2B/D26: project drafts live in appdata (the outbox) — the in-repo dir // only ever holds submitted memories (proposed/approved/published). // Personal stays in appdata and never leaves the machine. const dir = memoriesDirFor(fm.scope_key); const filePath = path.join(dir, `${fm.id}.md`); atomicWriteTextSync(filePath, serialize(fm, body)); const st = fs.statSync(filePath); return { filePath, mtimeMs: st.mtimeMs }; } /** Yield `*.md` files in the in-repo dir (read path — never creates it). */ export function* iterInRepoMemoryFiles( root: string, memoryDir: string, ): Generator<string> { const dir = inRepoMemoriesDirPath(root, memoryDir); if (!fs.existsSync(dir)) return; for (const name of fs.readdirSync(dir)) { if (name.endsWith(".md")) yield path.join(dir, name); } } export function readMemoryFile(filePath: string): MemoryFile | null { if (!fs.existsSync(filePath)) return null; const raw = fs.readFileSync(filePath, "utf8"); const parsed = parse(raw); if (!parsed) return null; const st = fs.statSync(filePath); return { fm: parsed.fm, body: parsed.body, filePath, mtimeMs: st.mtimeMs }; } export function* iterMemoryFiles(scopeKey: string): Generator<string> { const dir = memoriesDirPath(scopeKey); if (!fs.existsSync(dir)) return; for (const name of fs.readdirSync(dir)) { if (name.endsWith(".md")) yield path.join(dir, name); } }