UNPKG

open-memex

Version:

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

212 lines (204 loc) • 8.21 kB
import { loadConfig, type MyOMemoryConfig } from "./config.ts"; import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts"; import { db } from "./store/db.ts"; import { syncScope, upsertFromFile } from "./store/sync.ts"; import { scopeHasFiles } from "./store/migrate.ts"; import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, type Frontmatter, } from "./store/markdown.ts"; import { buildContextBlock } from "./retrieve/inject.ts"; import { detectKeywords } from "./capture/keywords.ts"; import { TURN_ECHO_WINDOW_MS, formatHandoffBlock, freshCaptures, type CaptureRecord, } from "./capture/handoff.ts"; import { findDuplicates } from "./store/lifecycle.ts"; import { redact } from "./redact.ts"; /** * 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"; export interface PluginState { cfg: MyOMemoryConfig; scope: Scope; /** Sessions that already received the one-time context injection. */ injectedSessions: Set<string>; /** * D73: what the keyword hook stored this session, so the agent can be told * before it re-saves the same statement. In memory only, like * `injectedSessions` — never persisted, pruned by the turn window. */ captures: Map<string, CaptureRecord[]>; } /** * 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: string): PluginState { 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<string>(), 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: string, state: PluginState, sessionID?: string, ): void { 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: Frontmatter = { 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: PluginState, sessionID: string | undefined, record: CaptureRecord, ): void { 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: string, state: PluginState): string { 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: string, state: PluginState, push: (block: string) => void, ): boolean { 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; }