open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
259 lines (247 loc) • 9.72 kB
text/typescript
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
export interface MyOMemoryConfig {
maxProjectMemories: number;
maxProfileItems: number;
injectOnFirstTurn: boolean;
keywordCaptureEnabled: boolean;
/**
* D61: capture-time aliasing (retrieval layer 3). When on, agents are
* asked to attach 2–4 aliases (alternate phrasings, other-language
* equivalents) to each memory they save; ops stores them and the index
* searches them, so differently-worded questions still hit. Asked once
* at init, default on; off = aliases passed by agents are dropped.
*/
captureAliases: boolean;
/** Patterns whose capture group 1 becomes the memory body; saved to the current scope. */
keywordPatterns: string[];
/** Same shape, but hits are forced into the personal scope (e.g. "remember for me"). */
keywordPersonalPatterns: string[];
redactPatterns: string[];
logLevel: "debug" | "info" | "warn" | "error";
/**
* In-repo project-memory directory, relative to the project root (2B/D23).
* Default `.ai/open-memex/`. Must stay relative and `..`-free so project
* memories can never escape the repo.
*/
memoryDir: string;
/**
* Sync behavior (§9, D12). Pulls are explicit by default; session start
* never touches the network unless autoPull is true — and even then a
* failed pull never blocks the session.
*/
sync: {
autoPull: boolean;
};
}
export const DEFAULT_CONFIG: MyOMemoryConfig = {
maxProjectMemories: 8,
maxProfileItems: 5,
injectOnFirstTurn: true,
keywordCaptureEnabled: true,
captureAliases: true,
keywordPatterns: [
"^\\s*remember(?!\\s+for\\s+me)(?:\\s+that|\\s+to)?[:,]?\\s+(.+)$",
"^\\s*(?:please\\s+)?note(?:\\s+that)?[:,]?\\s+(.+)$",
// D67: "don't forget the wifi password" is narration addressed to the
// agent, not a statement to the store. An explicit marker - "that" or a
// colon - makes it an instruction ("don't forget that ..." / "don't
// forget: ..."). Mirrors 别忘了 below; see D67.
"^\\s*(?:please\\s+)?don'?t\\s+forget(?:\\s+that\\s+|[:,]\\s+)(.+)$",
"^\\s*TIL[:,]?\\s+(.+)$",
"^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
// Chinese equivalents
"^\\s*(?:请)?记住(?!(个人))[::,,]?\\s*(.+)$",
"^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
// D67: separator required. "别忘了带伞" is narration ("don't forget your
// umbrella"); "别忘了:周五不发布" is an instruction to the store. Same
// rule as the English "don't forget" above.
"^\\s*别忘了[::,,]\\s*(.+)$",
// First-person plural: team/project context, NOT personal
"^\\s*我们认为[::,,]?\\s*(.+)$",
"^\\s*我们决定[::,,]?\\s*(.+)$",
// D67: the boundary applies to the BARE verb only. 记 + (住|录) + 一下 is a
// complete verb phrase, so the noun may follow directly ("帮我记住这个配置",
// "记一下这个约定"); only a bare 记 needs a separator, otherwise 帮我记得…
// would capture mid-word garbage. See D67.
"^\\s*(?:请)?帮我们记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
],
keywordPersonalPatterns: [
"^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
"^\\s*(?:请)?记住(个人)[::,,]?\\s*(.+)$",
// First-person singular: personal scope ("我" → 个人, "我们" → 项目)
"^\\s*(?:请)?记住我(?!们)[::,,]?\\s*(.+)$",
"^\\s*(?:请)?替我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
"^\\s*(?:请)?帮我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
"^\\s*help\\s+me\\s+remember(?:\\s+that)?[:,]?\\s+(.+)$",
"^\\s*我觉得[::,,]?\\s*(.+)$",
"^\\s*我喜欢[::,,]?\\s*(.+)$",
],
// Built-in provider patterns now live in src/redact.ts (always on).
// Add only your own extra patterns here.
redactPatterns: [],
logLevel: "info",
memoryDir: ".ai/open-memex",
sync: { autoPull: false },
};
/**
* Strip JSONC comments and trailing commas with a string-aware scanner.
* The previous regex version was not string-aware: a config value like
* "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
* corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
* with doctor.ts — one parser, not two.
*/
export function stripJsonComments(raw: string): string {
let out = "";
let i = 0;
let inStr = false;
let esc = false;
while (i < raw.length) {
const c = raw[i]!;
const n = raw[i + 1];
if (inStr) {
out += c;
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === '"') inStr = false;
i++;
continue;
}
if (c === '"') {
inStr = true;
out += c;
i++;
continue;
}
if (c === "/" && n === "/") {
while (i < raw.length && raw[i] !== "\n") i++;
continue;
}
if (c === "/" && n === "*") {
i += 2;
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
i += 2;
continue;
}
if (c === ",") {
// Trailing comma: drop it when the next significant char closes a
// container. Whitespace/comments between are tolerated.
let j = i + 1;
while (j < raw.length && /\s/.test(raw[j]!)) j++;
if (raw[j] === "}" || raw[j] === "]") {
i++;
continue;
}
}
out += c;
i++;
}
return out;
}
/**
* Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
* (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
*/
export function configFilePath(): string {
return (
process.env.OPEN_MEMEX_CONFIG ??
process.env.MY_O_MEMORY_CONFIG ??
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc")
);
}
function toBool(v: unknown): boolean {
if (typeof v === "boolean") return v;
if (v === "true" || v === "1") return true;
if (v === "false" || v === "0") return false;
throw new Error("must be true/false");
}
function toNonNegInt(v: unknown): number {
const n = Number(v);
if (!Number.isInteger(n) || n < 0) throw new Error("must be a non-negative integer");
return n;
}
function toRelativeDir(v: unknown): string {
if (typeof v !== "string" || !v.trim()) throw new Error("must be a non-empty relative directory");
const t = v.trim().replace(/\\/g, "/").replace(/\/+$/, "");
if (!t || path.isAbsolute(t) || t.split("/").includes(".."))
throw new Error('must be a relative path without ".." (e.g. ".ai/open-memex")');
return t;
}
/** Keys users may change via `open-memex config set <key> <value>`, with validators. */
export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
maxProjectMemories: toNonNegInt,
maxProfileItems: toNonNegInt,
injectOnFirstTurn: toBool,
keywordCaptureEnabled: toBool,
captureAliases: toBool,
memoryDir: toRelativeDir,
"sync.autoPull": toBool,
logLevel: (v) => {
if (v !== "info" && v !== "debug") throw new Error('must be "info" or "debug"');
return v;
},
};
/** Merge a patch into the config file (creates it if missing). Returns the file path. */
export function saveConfig(patch: Record<string, unknown>): string {
const file = configFilePath();
let cur: Record<string, unknown> = {};
if (fs.existsSync(file)) {
try {
cur = JSON.parse(stripJsonComments(fs.readFileSync(file, "utf8"))) as Record<
string,
unknown
>;
} catch {
console.error(`[open-memex] ${file} is not valid JSONC — it will be replaced`);
}
}
const next = { ...cur, ...patch };
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
"// Managed by `open-memex config set` / `open-memex init` — edit freely (JSONC).\n" +
JSON.stringify(next, null, 2) +
"\n",
);
return file;
}
/** Path of the config file in effect, or null when using built-in defaults. */
export function configSource(): string | null {
const candidates = [
process.env.OPEN_MEMEX_CONFIG,
process.env.MY_O_MEMORY_CONFIG,
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
].filter(Boolean) as string[];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
export function loadConfig(): MyOMemoryConfig {
const p = configSource();
if (p) {
try {
const raw = fs.readFileSync(p, "utf8");
const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
const merged = { ...DEFAULT_CONFIG, ...parsed };
// The config file is hand-editable, so the load path must enforce
// the same invariants `config set` does (P0 review, 2026-10-03):
// an unvalidated memoryDir like "../../shared" would put
// committed project memories outside the repo.
try {
merged.memoryDir = toRelativeDir(merged.memoryDir);
} catch {
console.error(
`[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`,
);
merged.memoryDir = DEFAULT_CONFIG.memoryDir;
}
return merged;
} catch (err) {
console.error(`[open-memex] failed to parse ${p}:`, err);
}
}
return DEFAULT_CONFIG;
}