open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
184 lines (183 loc) • 7.78 kB
JavaScript
import { loadConfig } from "./config.js";
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "./scope.js";
import { db } from "./store/db.js";
import { syncScope, upsertFromFile } from "./store/sync.js";
import { scopeHasFiles } from "./store/migrate.js";
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
import { buildContextBlock } from "./retrieve/inject.js";
import { detectKeywords } from "./capture/keywords.js";
import { formatHandoffBlock, freshCaptures, } from "./capture/handoff.js";
import { findDuplicates } from "./store/lifecycle.js";
import { redact } from "./redact.js";
/**
* Host-agnostic plugin core (D60). Both opencode host generations adapt to
* this module: the v1 plugin (src/index.ts `server`) and the v2 plugin
* (src/opencode-v2.ts `setup`). Host-specific glue — tool registration and
* hook shapes — stays in the adapters; everything memory-shaped lives here.
*/
export const PLUGIN_ID = "open-memex";
/**
* Resolve the project scope for a host working directory and bring the
* index up to date. Never throws: a broken store must not keep the host
* from starting — failures land in the host's log instead.
*/
export function bootstrapPlugin(root) {
const cfg = loadConfig();
const scope = resolveProjectScope(root);
try {
db();
syncScope(scope.key);
syncScope(PERSONAL_SCOPE.key);
if (cfg.logLevel === "debug") {
console.log(`[open-memex] loaded. scope=${scope.key}`);
}
// If a git remote was added *after* memories were first captured, the
// scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
// disk and tell the user how to migrate. We do NOT auto-migrate: two
// different repos at the same cwd would collide.
const cwdScope = resolveCwdScope(root);
if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
console.warn(`[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
`Current project scope is "${scope.key}". ` +
`To move them: npm run cli -- migrate --from ${cwdScope.key}`);
}
}
catch (err) {
console.error("[open-memex] init failed:", err);
}
return { cfg, scope, injectedSessions: new Set(), captures: new Map() };
}
/**
* Keyword capture (§ capture): scan one user message for "remember …"
* triggers and save each hit. Shared by the v1 `chat.message` hook and
* the v2 session `prompt` hook — both hand us the message text and the
* session id. The session id is what makes the D73 hand-off possible: without
* it the agent never learns the sentence is already stored.
*/
export function captureFromText(text, state, sessionID) {
const { cfg, scope } = state;
if (!cfg.keywordCaptureEnabled || !text)
return;
const hits = detectKeywords(text, cfg);
for (const h of hits) {
const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
// Secrets are masked (first 4 chars kept) and the capture proceeds;
// skip only when nothing usable remains.
if (content.length === 0)
continue;
if (hadSecret && cfg.logLevel === "debug") {
console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
}
// Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
const target = h.personal ? PERSONAL_SCOPE : scope;
// Dedup (§3.4): skip exact duplicates captured before.
if (findDuplicates(target.key, content).exact)
continue;
const now = Date.now();
const rfc = msToRfc3339(now);
const fm = {
id: ulid(),
schema_version: 2,
scope_key: target.key,
scope: target.kind === "project" ? "project" : "personal",
visibility: target.kind === "project" ? "internal" : "private",
project_name: target.projectName,
type: "fact",
role: "knowledge",
importance: "normal",
status: "active",
tags: ["keyword"],
source: "keyword",
created_at: rfc,
updated_at: rfc,
supersedes: null,
superseded_by: null,
review_state: "draft",
proposed_by: null,
approved_by: null,
derived_from: null,
review_note: null,
review_history: [],
};
try {
const { filePath } = writeMemoryFile(fm, content);
const mf = readMemoryFile(filePath);
if (mf)
upsertFromFile(mf);
// Capture feedback: always visible (not debug-only) — the user said
// "记住…", they should see that it landed. The opencode plugin API
// offers no toast channel, so the plugin log is the feedback surface.
const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
// D73 hand-off: the agent gets told about this capture in the turn's
// system context, so it does not save the same statement again.
recordCapture(state, sessionID, { id: fm.id, scopeKey: target.key, content, at: now });
}
catch (err) {
console.error("[open-memex] keyword capture failed:", err);
}
}
}
/** Note one hook capture against its session (D73). No-op without a session. */
function recordCapture(state, sessionID, record) {
if (!sessionID)
return;
const list = state.captures.get(sessionID) ?? [];
list.push(record);
state.captures.set(sessionID, list);
}
/**
* D73 hand-off note (a): what the keyword hook already stored for this
* session, delivered once per capture into the turn's system context. The
* agent cannot avoid re-saving a statement it cannot see is stored, so the
* store tells it. Returns "" when there is nothing new to hand off; stale
* records are pruned (in-memory only, like `injectedSessions`).
*
* Both host adapters push this from their system/context hook — the one that
* runs before the model request, i.e. after `chat.message` / `prompt`.
*/
export function captureHandoff(sessionID, state) {
if (!sessionID)
return "";
const list = state.captures.get(sessionID);
if (!list || list.length === 0)
return "";
const now = Date.now();
const fresh = freshCaptures(list, now);
if (fresh.length !== list.length) {
if (fresh.length === 0)
state.captures.delete(sessionID);
else
state.captures.set(sessionID, fresh);
}
const pending = fresh.filter((r) => !r.delivered);
if (pending.length === 0)
return "";
for (const r of pending)
r.delivered = true;
return formatHandoffBlock(pending);
}
/**
* First-turn context injection: exactly once per session. The host adapter
* supplies `push` (v1: output.system.push; v2: the session context hook's
* system array). Returns true when a block was pushed.
*/
export function injectOnce(sessionID, state, push) {
const { cfg, scope } = state;
if (!cfg.injectOnFirstTurn)
return false;
if (state.injectedSessions.has(sessionID))
return false;
state.injectedSessions.add(sessionID);
try {
const block = buildContextBlock(scope, cfg);
if (block) {
push(block);
return true;
}
}
catch (err) {
console.error("[open-memex] context injection failed:", err);
}
return false;
}