@bcmyguest/openmem 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/{skills → .agents/skills}/openmem/SKILL.md +5 -0
  2. package/README.md +80 -76
  3. package/cli/coding-agent/capabilities.mjs +14 -3
  4. package/cli/coding-agent/context-store.mjs +81 -0
  5. package/cli/coding-agent/context-trim.mjs +897 -0
  6. package/cli/coding-agent/file-io.mjs +72 -0
  7. package/cli/coding-agent/hook-harnesses.mjs +2 -1
  8. package/cli/coding-agent/hook-runner.mjs +28 -4
  9. package/cli/coding-agent/install-access.mjs +43 -17
  10. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  11. package/cli/coding-agent/install-rtk.mjs +60 -0
  12. package/cli/coding-agent/install-shim.mjs +119 -15
  13. package/cli/coding-agent/opencode-config.mjs +21 -5
  14. package/cli/coding-agent/opencode-plugin.mjs +39 -3
  15. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  16. package/cli/coding-agent/recall-frame.mjs +29 -0
  17. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  18. package/cli/coding-agent/runtime.mjs +2 -1
  19. package/cli/coding-agent/session-db.mjs +626 -0
  20. package/cli/coding-agent/session-facade.mjs +30 -6
  21. package/cli/coding-agent/toml-edit.mjs +161 -0
  22. package/cli/context-management.mjs +254 -0
  23. package/cli/feed-sync.mjs +18 -2
  24. package/cli/install-coding-agent.mjs +173 -40
  25. package/cli/mcp/context-sql-runner.mjs +58 -0
  26. package/cli/mcp/context-store.mjs +670 -0
  27. package/cli/mcp/knowledge.mjs +333 -0
  28. package/cli/mcp/memory-facade.mjs +99 -33
  29. package/cli/openmem-mcp.mjs +3 -0
  30. package/cli/openmem.mjs +18 -1
  31. package/cli/schedule.mjs +176 -0
  32. package/cli/stack.mjs +2 -0
  33. package/cli/switch-memory-provider.mjs +1 -1
  34. package/compose.yaml +67 -4
  35. package/config/hindsight.env.example +2 -0
  36. package/config/openmem.toml.example +64 -2
  37. package/docs/architecture.md +35 -9
  38. package/docs/coding-agent-hooks.md +26 -2
  39. package/docs/coding-agents.md +27 -7
  40. package/docs/compatibility.md +80 -0
  41. package/docs/concepts.md +30 -7
  42. package/docs/context-management.md +255 -0
  43. package/docs/context-proxy.md +115 -0
  44. package/docs/context-research.md +215 -0
  45. package/docs/context-store.md +277 -0
  46. package/docs/hindsight.md +5 -2
  47. package/docs/mcp-setup.md +5 -2
  48. package/docs/mem0.md +39 -1
  49. package/docs/memory-providers.md +2 -2
  50. package/docs/meridian-litellm.md +198 -0
  51. package/docs/model-configuration.md +22 -0
  52. package/docs/npx-install.md +6 -4
  53. package/docs/openmem-config.md +2 -2
  54. package/docs/quickstart.md +12 -6
  55. package/docs/setup-wizard.md +5 -4
  56. package/docs/sources-feeds.md +9 -22
  57. package/package.json +5 -4
  58. package/providers/cognee/OPERATOR.md +2 -2
  59. package/providers/hindsight/lib/clack.mjs +122 -20
  60. package/providers/hindsight/lib/coding-agent.mjs +1 -1
  61. package/providers/hindsight/lib/config.mjs +6 -0
  62. package/providers/hindsight/lib/env-setup.mjs +2 -1
  63. package/providers/hindsight/lib/litellm-routes.mjs +21 -12
  64. package/providers/hindsight/lib/meridian.mjs +307 -0
  65. package/providers/hindsight/lib/model-backend.mjs +38 -9
  66. package/providers/hindsight/lib/probe.mjs +21 -0
  67. package/providers/hindsight/lib/stack-env.mjs +5 -12
  68. package/providers/hindsight/lib/state.mjs +6 -1
  69. package/providers/hindsight/lib/summary.mjs +16 -2
  70. package/providers/hindsight/lib/validate.mjs +6 -0
  71. package/providers/hindsight/wizard.mjs +3 -2
  72. package/providers/mem0/OPERATOR.md +21 -2
  73. package/providers/mem0/compose.yaml +24 -2
  74. package/providers/mem0/lib/coding-agent.mjs +1 -1
  75. package/providers/mem0/lib/configure.mjs +66 -0
  76. package/providers/mem0/lib/litellm.mjs +457 -0
  77. package/providers/mem0/lib/stack-env.mjs +40 -1
  78. package/providers/mem0/lib/summary.mjs +30 -2
  79. package/providers/mem0/manifest.json +1 -1
  80. package/providers/mem0/wizard.mjs +15 -2
  81. package/proxy/config.yaml +33 -0
  82. package/proxy/openmem_context/__init__.py +6 -0
  83. package/proxy/openmem_context/adapters.py +504 -0
  84. package/proxy/openmem_context/compress.py +53 -0
  85. package/proxy/openmem_context/core.py +606 -0
  86. package/proxy/openmem_context/embed.py +212 -0
  87. package/proxy/openmem_context/recall.py +49 -0
  88. package/proxy/openmem_context/session.py +70 -0
  89. package/proxy/openmem_context/sessiondb.py +276 -0
  90. package/proxy/openmem_context/settings.py +134 -0
  91. package/proxy/openmem_context/store.py +129 -0
  92. package/proxy/openmem_context/tokens.py +123 -0
  93. package/proxy/openmem_context/ttl.py +78 -0
  94. package/proxy/openmem_context_hook.py +34 -0
  95. package/scripts/check-pack.mjs +2 -1
  96. package/scripts/setup.mjs +14 -1
  97. package/server.json +2 -2
  98. package/wizard/lib/context-step.mjs +140 -0
  99. package/wizard/lib/litellm-env.mjs +55 -0
  100. package/wizard/lib/pm-config.mjs +212 -4
  101. package/wizard/lib/prompts.mjs +28 -0
  102. package/wizard/lib/run-wizard.mjs +26 -31
@@ -0,0 +1,72 @@
1
+ /**
2
+ * File helpers for the coding-agent installer: atomic rewrites of user config files
3
+ * (temp file in the same directory + rename), JSON read/write, and a silent logger.
4
+ */
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ export const SILENT = Object.freeze({ info: () => {}, ok: () => {}, warn: () => {} });
10
+
11
+ /**
12
+ * Replace `file` with `data` atomically. A symlinked file is written through to its target.
13
+ * Mode: `opts.mode` when given (also on an existing file), else the existing file's mode,
14
+ * else the process default (0666 minus umask).
15
+ * @param {string} file
16
+ * @param {string} data
17
+ * @param {{ mode?: number }} [opts]
18
+ */
19
+ export function writeFileAtomic(file, data, opts = {}) {
20
+ let target = file;
21
+ try {
22
+ target = fs.realpathSync(file);
23
+ } catch {
24
+ // Missing file: write it where asked.
25
+ }
26
+ const dir = path.dirname(target);
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ const existing = fs.statSync(target, { throwIfNoEntry: false });
29
+ const mode = opts.mode ?? (existing ? existing.mode & 0o7777 : undefined);
30
+ const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
31
+ try {
32
+ const fd = fs.openSync(tmp, "wx", mode ?? 0o666);
33
+ try {
34
+ fs.writeFileSync(fd, data);
35
+ if (mode !== undefined) fs.fchmodSync(fd, mode);
36
+ fs.fsyncSync(fd);
37
+ } finally {
38
+ fs.closeSync(fd);
39
+ }
40
+ fs.renameSync(tmp, target);
41
+ } catch (err) {
42
+ fs.rmSync(tmp, { force: true });
43
+ throw err;
44
+ }
45
+ }
46
+
47
+ /** Parsed JSON of `file`, or `{}` when it is missing. Malformed JSON throws. */
48
+ export function readJson(file) {
49
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
50
+ }
51
+
52
+ /** @param {string} file @param {unknown} value @param {{ mode?: number }} [opts] */
53
+ export function writeJson(file, value, opts = {}) {
54
+ writeFileAtomic(file, `${JSON.stringify(value, null, 2)}\n`, opts);
55
+ }
56
+
57
+ /**
58
+ * Set `KEY=value` lines in a dotenv file, replacing earlier lines for those keys and keeping
59
+ * every other line. The file keeps its mode; a missing file is created with `opts.mode`.
60
+ * @param {string} file
61
+ * @param {Record<string, string>} entries
62
+ * @param {{ mode?: number }} [opts]
63
+ */
64
+ export function upsertEnvFile(file, entries, opts = {}) {
65
+ const keys = Object.keys(entries);
66
+ const exists = fs.existsSync(file);
67
+ const lines = exists ? fs.readFileSync(file, "utf8").split(/\r?\n/) : [];
68
+ const kept = lines.filter((line) => !keys.some((k) => line.startsWith(`${k}=`)));
69
+ while (kept.length && kept[kept.length - 1] === "") kept.pop();
70
+ const text = `${[...kept, ...keys.map((k) => `${k}=${entries[k]}`)].join("\n")}\n`;
71
+ writeFileAtomic(file, text, exists ? {} : opts);
72
+ }
@@ -54,7 +54,8 @@ export const SHIM_HARNESSES = {
54
54
  "cursor-cli": {
55
55
  configStyle: "flat",
56
56
  settingsPath: (home) => `${home}/.cursor/hooks.json`,
57
- rootKey: null,
57
+ // Cursor reads `{ "version": 1, "hooks": { <event>: [{ command }] } }`.
58
+ rootKey: "hooks",
58
59
  events: {
59
60
  sessionStart: { hook: "sessionStart", timeout: 30 },
60
61
  prompt: { hook: "beforeSubmitPrompt", timeout: 30 },
@@ -9,8 +9,10 @@ import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { bankFromCwd } from "./bank-from-cwd.mjs";
11
11
  import { accessAllowed, readAccessConfig } from "./access-policy.mjs";
12
+ import { providerSupports } from "./capabilities.mjs";
13
+ import { isSmartCompactPrompt, registerSession, writeForceFlag } from "./context-store.mjs";
12
14
  import { SHIM_HARNESSES } from "./hook-harnesses.mjs";
13
- import { sessionRecall, sessionRetain } from "./session-facade.mjs";
15
+ import { hookRecallBlock, sessionRecall, sessionRetain } from "./session-facade.mjs";
14
16
  import { resolveMcpContext } from "../mcp/context.mjs";
15
17
  import { resolveStackRoot } from "../stack.mjs";
16
18
 
@@ -31,6 +33,17 @@ async function readStdinJson() {
31
33
  const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
32
34
  const TRANSCRIPT_MAX_CHARS = 12000;
33
35
 
36
+ /** Short SessionStart banner; names knowledge tools only when the provider has them. */
37
+ export function sessionBanner(provider, bank) {
38
+ const tools = ["memory_recall", "memory_add"];
39
+ if (providerSupports(provider, "knowledge_pages")) tools.push("memory_pages_search");
40
+ if (providerSupports(provider, "initiatives")) tools.push("memory_capture_initiative");
41
+ return [
42
+ `[openmem] Memory provider ${provider}, bank ${bank}.`,
43
+ `Tools: ${tools.join(", ")}. Pass bank "${bank}" for this project.`,
44
+ ].join("\n");
45
+ }
46
+
34
47
  function blockText(content, acceptedTypes) {
35
48
  if (typeof content === "string") return content;
36
49
  if (!Array.isArray(content)) return "";
@@ -186,6 +199,11 @@ export async function runHook(harnessId, phase, { home, env, repoRoot, ev, fetch
186
199
  const spec = SHIM_HARNESSES[harnessId];
187
200
  if (!spec) throw new Error(`unknown harness ${harnessId}`);
188
201
  const parsed = spec.parse[phase](ev);
202
+ const smartCompact = phase === "prompt" && isSmartCompactPrompt(parsed.prompt);
203
+ if (smartCompact) {
204
+ // Context trimming is independent of memory access; the flag never fails the hook.
205
+ try { writeForceFlag({ home, env, sessionId: parsed.sessionId }); } catch { /* best effort */ }
206
+ }
189
207
  const config = readAccessConfig(home);
190
208
  const access = config ? resolveAccess(parsed.cwd, config, home) : { allowed: false };
191
209
  if (!access.allowed) return spec.emit[phase](undefined);
@@ -202,16 +220,22 @@ export async function runHook(harnessId, phase, { home, env, repoRoot, ev, fetch
202
220
  let context = "";
203
221
 
204
222
  if (phase === "sessionStart") {
205
- context = await sessionRecall(
223
+ try {
224
+ registerSession({ home, env, sessionId: parsed.sessionId, agent: harnessId, cwd: parsed.cwd, bank });
225
+ } catch { /* Registration is optional for the proxy; never fail SessionStart. */ }
226
+ const recalled = await sessionRecall(
206
227
  ctx,
207
228
  { query: "What should I know about this project and recent decisions?", bank, limit: 8,
208
229
  budget: "low", maxTokens: 1200 },
209
230
  fetchFn,
210
231
  );
232
+ const banner = sessionBanner(ctx.provider, bank);
233
+ const block = hookRecallBlock(recalled);
234
+ context = block ? `${banner}\n\n${block}` : banner;
211
235
  } else if (phase === "prompt") {
212
236
  const q = String(parsed.prompt || "").trim();
213
- if (q) {
214
- context = await sessionRecall(ctx, { query: q, bank, limit: 8 }, fetchFn);
237
+ if (q && !smartCompact) {
238
+ context = hookRecallBlock(await sessionRecall(ctx, { query: q, bank, limit: 8 }, fetchFn));
215
239
  }
216
240
  } else if (phase === "stop") {
217
241
  const transcript = readNewTranscript(harnessId, parsed.transcriptPath, home, parsed.sessionId);
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import TOML from "@iarna/toml";
4
+ import { readJson, writeFileAtomic, writeJson } from "./file-io.mjs";
3
5
  import { mergeOpencodeConfig, opencodeConfigPath } from "./opencode-config.mjs";
6
+ import { removeSpans, tomlSpansUnder, tomlStatements } from "./toml-edit.mjs";
4
7
 
5
8
  const SKILL_TARGETS = {
6
9
  "claude-code": ".claude/skills/openmem",
@@ -9,21 +12,37 @@ const SKILL_TARGETS = {
9
12
  opencode: ".config/opencode/skills/openmem",
10
13
  };
11
14
 
12
- function readJson(file) {
13
- return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
15
+ /** Set `[table] key = true` unless the file sets it (to any value) already. */
16
+ function enableTomlFlag(text, table, key) {
17
+ const statements = tomlStatements(text);
18
+ if (statements.some((s) => s.kind === "kv" && s.path.length === 2 && s.path[0] === table && s.path[1] === key)) return text;
19
+ const at = statements.findIndex((s) => s.kind === "table" && s.path.length === 1 && s.path[0] === table);
20
+ if (at < 0) return `${text}${text && !text.endsWith("\n") ? "\n" : ""}[${table}]\n${key} = true\n`;
21
+ let end = statements[at].end;
22
+ for (let k = at + 1; k < statements.length && statements[k].kind === "kv"; k++) end = statements[k].end;
23
+ const head = text.slice(0, end);
24
+ return `${head}${head.endsWith("\n") ? "" : "\n"}${key} = true\n${text.slice(end)}`;
14
25
  }
15
26
 
16
- function writeJson(file, value) {
17
- fs.mkdirSync(path.dirname(file), { recursive: true });
18
- fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
27
+ /** Add top-level `key = value` (before the first table) unless the file sets `key` already. */
28
+ function setTomlDefault(text, key, value) {
29
+ const statements = tomlStatements(text);
30
+ if (statements.some((s) => s.kind === "kv" && s.path.length === 1 && s.path[0] === key)) return text;
31
+ const firstTable = statements.findIndex((s) => s.kind !== "kv");
32
+ const at = firstTable < 0 ? text.length : statements[firstTable].start;
33
+ const head = text.slice(0, at);
34
+ return `${head}${head && !head.endsWith("\n") ? "\n" : ""}${key} = ${JSON.stringify(value)}\n${text.slice(at)}`;
19
35
  }
20
36
 
37
+ /**
38
+ * Codex config with hooks enabled, the workspace-write sandbox with network allowed (Codex defaults to a
39
+ * read-only sandbox without network, so curl and package installs fail) and exactly one current
40
+ * `[mcp_servers.openmem]`; throws on a TOML it cannot edit.
41
+ */
21
42
  function updateCodexToml(text, scriptPath, stackRoot) {
22
- const blocks = text.split(/(?=^[ \t]*\[)/m);
23
- const featureIndex = blocks.findIndex((block) => /^[ \t]*\[features\][ \t]*(?:#.*)?(?:\r?\n|$)/.test(block));
24
- if (featureIndex < 0) blocks.push(`${text && !text.endsWith("\n") ? "\n" : ""}[features]\nhooks = true\n`);
25
- else if (!/^hooks\s*=/m.test(blocks[featureIndex])) blocks[featureIndex] = `${blocks[featureIndex].trimEnd()}\nhooks = true\n`;
26
- text = blocks.join("");
43
+ text = setTomlDefault(text, "sandbox_mode", "workspace-write");
44
+ text = enableTomlFlag(text, "features", "hooks");
45
+ text = enableTomlFlag(text, "sandbox_workspace_write", "network_access");
27
46
  const section = [
28
47
  "[mcp_servers.openmem]",
29
48
  'command = "node"',
@@ -32,16 +51,17 @@ function updateCodexToml(text, scriptPath, stackRoot) {
32
51
  `PM_STACK_ROOT = ${JSON.stringify(stackRoot)}`,
33
52
  ].join("\n");
34
53
  // Drop current openmem blocks and the pre-rebrand personal-memory name.
35
- const oldServer =
36
- /^[ \t]*\[mcp_servers\.(?:openmem|"openmem"|'openmem'|personal-memory|"personal-memory"|'personal-memory')(?:\.(?:env|"env"|'env'))?[ \t]*\]/;
37
- const retained = text.split(/(?=^[ \t]*\[)/m).filter((block) => !oldServer.test(block));
38
- return `${retained.join("").trimEnd()}\n\n${section}\n`;
54
+ const spans = ["openmem", "personal-memory"].flatMap((name) => tomlSpansUnder(text, ["mcp_servers", name]));
55
+ const retained = removeSpans(text, spans).trimEnd();
56
+ const out = `${retained ? `${retained}\n\n` : ""}${section}\n`;
57
+ TOML.parse(out);
58
+ return out;
39
59
  }
40
60
 
41
61
  /** Install one shared skill and the unified MCP in each selected agent's global config. */
42
62
  export function installAgentAccess({ home, runtimeRoot, stackRoot, agents, dry = false, h }) {
43
63
  const scriptPath = path.join(runtimeRoot, "cli", "openmem-mcp.mjs");
44
- const skillSource = path.join(runtimeRoot, "skills", "openmem", "SKILL.md");
64
+ const skillSource = path.join(runtimeRoot, ".agents", "skills", "openmem", "SKILL.md");
45
65
  for (const agent of agents) {
46
66
  const skillRel = SKILL_TARGETS[agent];
47
67
  if (!skillRel) continue;
@@ -58,8 +78,14 @@ export function installAgentAccess({ home, runtimeRoot, stackRoot, agents, dry =
58
78
  if (dry) h?.info(`[dry-run] merge openmem MCP into ${file}`);
59
79
  else {
60
80
  const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
61
- fs.mkdirSync(path.dirname(file), { recursive: true });
62
- fs.writeFileSync(file, updateCodexToml(current, scriptPath, stackRoot));
81
+ let next;
82
+ try {
83
+ next = updateCodexToml(current, scriptPath, stackRoot);
84
+ } catch (err) {
85
+ h?.warn(`codex: left ${file} unchanged; add [mcp_servers.openmem] by hand (${err.message})`);
86
+ continue;
87
+ }
88
+ writeFileAtomic(file, next);
63
89
  h?.ok(`codex MCP → ${file}`);
64
90
  }
65
91
  continue;
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Point Claude Code, OpenCode 1 and Codex at the local LiteLLM context proxy, or undo that.
3
+ *
4
+ * The proxy key travels as a request header. Claude Code reads ANTHROPIC_CUSTOM_HEADERS only
5
+ * from its environment or a settings `env` block (no helper hook for headers), so a settings
6
+ * file that holds the key is written mode 600; so are the OpenCode and Codex configs. Values that
7
+ * were there before and are not ours are backed up (mode 600) and restored on disable.
8
+ *
9
+ * Codex keeps its ChatGPT login: a custom `[model_providers.openmem]` with `requires_openai_auth`
10
+ * sends the ChatGPT bearer and ChatGPT-Account-ID as before, `http_headers` adds the LiteLLM key, and
11
+ * `base_url` is LiteLLM's subscription pass-through `/backend-api/codex` (PR #41522). The built-in
12
+ * openai provider cannot carry the extra header: Codex ignores `model_providers.openai` overrides.
13
+ */
14
+ import crypto from "node:crypto";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import TOML from "@iarna/toml";
18
+ import { contextRoot } from "./context-store.mjs";
19
+ import { readJson, writeFileAtomic, writeJson } from "./file-io.mjs";
20
+ import { mergeOpencodeConfig, opencodeConfigPath, parseJsonc } from "./opencode-config.mjs";
21
+ import { removeSpans, tomlSpansUnder, tomlStatements } from "./toml-edit.mjs";
22
+
23
+ const KEY_NAME = "LITELLM_MASTER_KEY";
24
+ const HEADER_NAME = "x-litellm-api-key";
25
+ const LOOPBACK_URL = /^http:\/\/127\.0\.0\.1:\d+(?:\/v1)?$/;
26
+ const CODEX_LOOPBACK_URL = /^http:\/\/127\.0\.0\.1:\d+\/backend-api\/codex$/;
27
+ /** `model_provider` id the installer owns in ~/.codex/config.toml. */
28
+ export const CODEX_PROVIDER_ID = "openmem";
29
+ /** Agent ids installContextProxy wires (install-coding-agent ids; opencode is OpenCode 1 or 2). */
30
+ const PROXY_AGENT_IDS = ["claude-code", "opencode", "codex"];
31
+ const SECRET_MODE = 0o600;
32
+ const PROBE_TIMEOUT_MS = 1500;
33
+
34
+ export function contextProxyEnvPath(home) {
35
+ return path.join(home, ".config", "personal-memory", "context-proxy.env");
36
+ }
37
+
38
+ /** Pre-proxy values of the settings the installer overwrites, restored on disable. */
39
+ export function contextProxyBackupPath(home) {
40
+ return path.join(home, ".config", "personal-memory", "context-proxy-backup.json");
41
+ }
42
+
43
+ /** Return the master key from context-proxy.env, generating it (file mode 600) once. */
44
+ export function ensureContextProxyKey(home) {
45
+ const file = contextProxyEnvPath(home);
46
+ const previous = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
47
+ const line = previous.split(/\r?\n/).find((l) => l.startsWith(`${KEY_NAME}=`));
48
+ const existing = line?.slice(KEY_NAME.length + 1).trim();
49
+ if (existing) {
50
+ fs.chmodSync(file, SECRET_MODE);
51
+ return existing;
52
+ }
53
+ const key = crypto.randomBytes(32).toString("hex");
54
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
55
+ const body = previous && !previous.endsWith("\n") ? `${previous}\n` : previous;
56
+ writeFileAtomic(file, `${body}${KEY_NAME}=${key}\n`, { mode: SECRET_MODE });
57
+ return key;
58
+ }
59
+
60
+ /** `docker compose` command that starts the proxy from the stack root. */
61
+ export function contextProxyStartCommand({ stackRoot, project = "openmem" } = {}) {
62
+ const dir = stackRoot ? ` --project-directory ${JSON.stringify(stackRoot)}` : "";
63
+ return `docker compose -p ${project}${dir} --profile hindsight --profile context-proxy up -d context-proxy`;
64
+ }
65
+
66
+ /** True when the proxy answers `/health/liveliness` on the loopback port within the timeout. */
67
+ export async function probeContextProxy(port, { fetch = globalThis.fetch, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
68
+ try {
69
+ const res = await fetch(`http://127.0.0.1:${port}/health/liveliness`, { signal: AbortSignal.timeout(timeoutMs) });
70
+ return Boolean(res?.ok);
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ export function codexConfigPath(home) {
77
+ return path.join(home, ".codex", "config.toml");
78
+ }
79
+
80
+ /** Parsed ~/.codex/config.toml (`{}` when missing); malformed TOML throws. */
81
+ function readCodexConfig(home) {
82
+ const file = codexConfigPath(home);
83
+ return fs.existsSync(file) ? TOML.parse(fs.readFileSync(file, "utf8")) : {};
84
+ }
85
+
86
+ /** True when the Codex config selects our provider or still has its loopback entry. */
87
+ function codexPointed(cfg) {
88
+ const ours = cfg.model_providers?.[CODEX_PROVIDER_ID];
89
+ return cfg.model_provider === CODEX_PROVIDER_ID || CODEX_LOOPBACK_URL.test(String(ours?.base_url || ""));
90
+ }
91
+
92
+ /**
93
+ * Agents whose config still points at the loopback proxy: Claude Code (settings.json `env`),
94
+ * OpenCode 1 (`provider.anthropic.options.baseURL`) and Codex (`model_provider = "openmem"`).
95
+ * Stopping the proxy breaks them.
96
+ * @returns {string[]}
97
+ */
98
+ export function agentsPointedAtContextProxy(home) {
99
+ const out = [];
100
+ try {
101
+ if (LOOPBACK_URL.test(String(readJson(path.join(home, ".claude", "settings.json")).env?.ANTHROPIC_BASE_URL || ""))) out.push("claude-code");
102
+ } catch {
103
+ // Unreadable settings: treat as not pointed.
104
+ }
105
+ try {
106
+ const file = opencodeConfigPath(home);
107
+ const options = fs.existsSync(file) ? parseJsonc(fs.readFileSync(file, "utf8"))?.provider?.anthropic?.options : undefined;
108
+ if (LOOPBACK_URL.test(String(options?.baseURL || ""))) out.push("opencode");
109
+ } catch {
110
+ // Unreadable config: treat as not pointed.
111
+ }
112
+ try {
113
+ if (codexPointed(readCodexConfig(home))) out.push("codex");
114
+ } catch {
115
+ // Unreadable config: treat as not pointed.
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function readBackup(home) {
121
+ try {
122
+ return readJson(contextProxyBackupPath(home));
123
+ } catch {
124
+ return {};
125
+ }
126
+ }
127
+
128
+ function writeBackup(home, backup) {
129
+ const file = contextProxyBackupPath(home);
130
+ if (!Object.keys(backup).length) fs.rmSync(file, { force: true });
131
+ else writeJson(file, backup, { mode: SECRET_MODE });
132
+ }
133
+
134
+ const ourHeaderLine = (line) => line.trim().toLowerCase().startsWith(`${HEADER_NAME}:`);
135
+
136
+ /** ANTHROPIC_CUSTOM_HEADERS without any x-litellm-api-key line. */
137
+ function otherHeaderLines(value) {
138
+ return String(value || "").split("\n").filter((l) => l.trim() && !ourHeaderLine(l));
139
+ }
140
+
141
+ function setOrDelete(obj, key, value) {
142
+ if (value === undefined || value === "") delete obj[key];
143
+ else obj[key] = value;
144
+ }
145
+
146
+ function wireClaude(home, { enabled, port, key }) {
147
+ const file = path.join(home, ".claude", "settings.json");
148
+ const settings = readJson(file);
149
+ const env = { ...(settings.env || {}) };
150
+ const backup = readBackup(home);
151
+ const pointed = LOOPBACK_URL.test(String(env.ANTHROPIC_BASE_URL || ""));
152
+ if (enabled) {
153
+ // Already pointed at the proxy: what is there is ours, nothing to back up.
154
+ const foreign = !pointed && (env.ANTHROPIC_BASE_URL !== undefined || env.ANTHROPIC_CUSTOM_HEADERS !== undefined);
155
+ if (foreign && !backup.claudeCode) {
156
+ backup.claudeCode = { ANTHROPIC_BASE_URL: env.ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS: env.ANTHROPIC_CUSTOM_HEADERS };
157
+ writeBackup(home, backup);
158
+ }
159
+ env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${port}`;
160
+ env.ANTHROPIC_CUSTOM_HEADERS = [...otherHeaderLines(env.ANTHROPIC_CUSTOM_HEADERS), `${HEADER_NAME}: Bearer ${key}`].join("\n");
161
+ } else {
162
+ const hasOurHeader = String(env.ANTHROPIC_CUSTOM_HEADERS || "").split("\n")
163
+ .some((l) => ourHeaderLine(l) && l.includes("Bearer "));
164
+ if (!pointed || !hasOurHeader) return false;
165
+ const saved = backup.claudeCode;
166
+ if (saved) {
167
+ setOrDelete(env, "ANTHROPIC_BASE_URL", saved.ANTHROPIC_BASE_URL);
168
+ setOrDelete(env, "ANTHROPIC_CUSTOM_HEADERS", saved.ANTHROPIC_CUSTOM_HEADERS);
169
+ delete backup.claudeCode;
170
+ writeBackup(home, backup);
171
+ } else {
172
+ delete env.ANTHROPIC_BASE_URL;
173
+ setOrDelete(env, "ANTHROPIC_CUSTOM_HEADERS", otherHeaderLines(env.ANTHROPIC_CUSTOM_HEADERS).join("\n"));
174
+ }
175
+ }
176
+ if (Object.keys(env).length) settings.env = env;
177
+ else delete settings.env;
178
+ writeJson(file, settings, enabled ? { mode: SECRET_MODE } : {});
179
+ return true;
180
+ }
181
+
182
+ /** OpenCode documents `provider.<id>.options.baseURL` and `options.headers`. */
183
+ function wireOpencode(home, { enabled, port, key }) {
184
+ const file = opencodeConfigPath(home);
185
+ const current = fs.existsSync(file) ? parseJsonc(fs.readFileSync(file, "utf8"))?.provider : undefined;
186
+ const options = current?.anthropic?.options || {};
187
+ const pointed = LOOPBACK_URL.test(String(options.baseURL || ""));
188
+ const backup = readBackup(home);
189
+ if (!enabled && !(pointed && options.headers?.[HEADER_NAME])) return false;
190
+ if (enabled && !pointed && !backup.opencode && (options.baseURL !== undefined || options.headers?.[HEADER_NAME] !== undefined)) {
191
+ backup.opencode = { baseURL: options.baseURL, header: options.headers?.[HEADER_NAME] };
192
+ writeBackup(home, backup);
193
+ }
194
+ const saved = enabled ? undefined : backup.opencode;
195
+ mergeOpencodeConfig(home, "provider", (existing) => {
196
+ const provider = { ...(existing || {}) };
197
+ const anthropic = { ...(provider.anthropic || {}) };
198
+ const next = { ...(anthropic.options || {}) };
199
+ const headers = { ...(next.headers || {}) };
200
+ if (enabled) {
201
+ next.baseURL = `http://127.0.0.1:${port}/v1`;
202
+ headers[HEADER_NAME] = `Bearer ${key}`;
203
+ } else {
204
+ setOrDelete(next, "baseURL", saved?.baseURL);
205
+ setOrDelete(headers, HEADER_NAME, saved?.header);
206
+ }
207
+ if (Object.keys(headers).length) next.headers = headers;
208
+ else delete next.headers;
209
+ if (Object.keys(next).length) anthropic.options = next;
210
+ else delete anthropic.options;
211
+ if (Object.keys(anthropic).length) provider.anthropic = anthropic;
212
+ else delete provider.anthropic;
213
+ return provider;
214
+ }, enabled ? { mode: SECRET_MODE } : {});
215
+ if (saved) {
216
+ delete backup.opencode;
217
+ writeBackup(home, backup);
218
+ }
219
+ return true;
220
+ }
221
+
222
+ /** `text` with `line` after the last top-level key/value pair (else at the start), so the key stays top-level. */
223
+ function withTopLevelLine(text, line) {
224
+ const statements = tomlStatements(text);
225
+ const firstTable = statements.findIndex((st) => st.kind !== "kv");
226
+ const topLevel = firstTable < 0 ? statements : statements.slice(0, firstTable);
227
+ const at = topLevel.length ? topLevel.at(-1).end : 0;
228
+ const head = text.slice(0, at);
229
+ return `${head}${head && !head.endsWith("\n") ? "\n" : ""}${line}\n${text.slice(at)}`;
230
+ }
231
+
232
+ /** `text` without the top-level `model_provider` and without `[model_providers.openmem]`. */
233
+ function withoutCodexWiring(text) {
234
+ const spans = [
235
+ ...tomlSpansUnder(text, ["model_providers", CODEX_PROVIDER_ID]),
236
+ ...tomlStatements(text).filter((st) => st.kind === "kv" && st.path.length === 1 && st.path[0] === "model_provider"),
237
+ ];
238
+ return removeSpans(text, spans);
239
+ }
240
+
241
+ /**
242
+ * Codex: `model_provider = "openmem"` and `[model_providers.openmem]` (ChatGPT login kept, LiteLLM key in
243
+ * `http_headers`). A foreign `model_provider` is backed up and restored on disable. A config the TOML
244
+ * parser rejects is left alone with a warning.
245
+ */
246
+ function wireCodex(home, { enabled, port, key, h }) {
247
+ const file = codexConfigPath(home);
248
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
249
+ let cfg;
250
+ try {
251
+ cfg = text ? TOML.parse(text) : {};
252
+ } catch (err) {
253
+ h?.warn(`codex: left ${file} unchanged; cannot parse it (${err.message})`);
254
+ return false;
255
+ }
256
+ const pointed = codexPointed(cfg);
257
+ if (!enabled && !pointed) return false;
258
+ const backup = readBackup(home);
259
+ const rest = withoutCodexWiring(text).trimEnd();
260
+ const base = rest ? `${rest}\n` : "";
261
+ let next;
262
+ if (enabled) {
263
+ if (!pointed && cfg.model_provider !== undefined && !backup.codex) {
264
+ backup.codex = { model_provider: cfg.model_provider };
265
+ writeBackup(home, backup);
266
+ }
267
+ const section = [
268
+ `[model_providers.${CODEX_PROVIDER_ID}]`,
269
+ 'name = "OpenMem context proxy"',
270
+ `base_url = ${JSON.stringify(`http://127.0.0.1:${port}/backend-api/codex`)}`,
271
+ 'wire_api = "responses"',
272
+ "requires_openai_auth = true",
273
+ "supports_websockets = false",
274
+ `http_headers = { ${JSON.stringify(HEADER_NAME)} = ${JSON.stringify(`Bearer ${key}`)} }`,
275
+ ].join("\n");
276
+ next = `${withTopLevelLine(base, `model_provider = ${JSON.stringify(CODEX_PROVIDER_ID)}`)}\n${section}\n`;
277
+ } else {
278
+ const saved = backup.codex?.model_provider;
279
+ next = typeof saved === "string" && saved ? withTopLevelLine(base, `model_provider = ${JSON.stringify(saved)}`) : base;
280
+ if (backup.codex) {
281
+ delete backup.codex;
282
+ writeBackup(home, backup);
283
+ }
284
+ }
285
+ TOML.parse(next);
286
+ writeFileAtomic(file, next, enabled ? { mode: SECRET_MODE } : {});
287
+ return true;
288
+ }
289
+
290
+ /** Base URL an agent is pointed at: the proxy root, or the Codex subscription pass-through. */
291
+ const proxyUrl = (id, port) => `http://127.0.0.1:${port}${id === "codex" ? "/backend-api/codex" : ""}`;
292
+
293
+ /**
294
+ * Enable: write the key file and proxy settings. Disable: remove exactly those settings
295
+ * when they still point at the loopback proxy, restoring backed-up values; the key file is kept.
296
+ * OpenCode 2 trims in OpenMem's plugin, so it is never pointed at the proxy and earlier
297
+ * proxy settings are removed; such agents are returned in `pluginTrimmed`.
298
+ */
299
+ export function installContextProxy({ home, agents, enabled, agentEnabled = {}, port, dry = false, h, opencodeMajor = 1 }) {
300
+ const targets = agents.filter((id) => PROXY_AGENT_IDS.includes(id));
301
+ const isOpencode2 = (id) => id === "opencode" && opencodeMajor >= 2;
302
+ // Per-agent choice ({ "claude-code"?, opencode?, codex? }); a missing entry uses `enabled`.
303
+ const on = (id) => (typeof agentEnabled[id] === "boolean" ? agentEnabled[id] : Boolean(enabled));
304
+ const pluginTrimmed = targets.filter(isOpencode2);
305
+ if (dry) {
306
+ for (const id of targets) {
307
+ if (isOpencode2(id)) h?.info(`[dry-run] ${id}: OpenCode 2 trims natively; not pointed at the context proxy`);
308
+ else h?.info(`[dry-run] ${on(id) ? "point" : "unpoint"} ${id} ${on(id) ? "at" : "from"} context proxy ${proxyUrl(id, port)}`);
309
+ }
310
+ return { changed: [], pluginTrimmed };
311
+ }
312
+ const anyOn = targets.some((id) => !isOpencode2(id) && on(id));
313
+ const key = anyOn ? ensureContextProxyKey(home) : "";
314
+ // compose mounts this with create_host_path: false, so it must exist (700, host-owned) before `up`.
315
+ if (anyOn) {
316
+ for (const sub of ["sessions", "blobs"]) fs.mkdirSync(path.join(contextRoot(home), sub), { recursive: true, mode: 0o700 });
317
+ }
318
+ const changed = [];
319
+ for (const id of targets) {
320
+ if (isOpencode2(id)) {
321
+ if (wireOpencode(home, { enabled: false, port, key })) h?.ok(`${id}: removed context proxy settings (OpenCode 2 trims natively)`);
322
+ if (on(id)) h?.info(`${id}: OpenCode 2 trims natively; not pointed at the context proxy`);
323
+ continue;
324
+ }
325
+ const enable = on(id);
326
+ const wire = { "claude-code": wireClaude, opencode: wireOpencode, codex: wireCodex }[id];
327
+ const done = wire(home, { enabled: enable, port, key, h });
328
+ if (!done) continue;
329
+ changed.push(id);
330
+ h?.ok(`${id}: context proxy ${enable ? `→ ${proxyUrl(id, port)}` : "settings removed"}`);
331
+ }
332
+ return { changed, pluginTrimmed };
333
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Wire rtk (https://github.com/rtk-ai/rtk) command rewriting through rtk's own installer.
3
+ * rtk adds PreToolUse-style entries and preserves other hooks, so it coexists with
4
+ * OpenMem's SessionStart / prompt / Stop entries. `rtk init` is idempotent.
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ /** `rtk init` arguments per agent, from rtk's README and src/hooks/init. */
11
+ export const RTK_INIT_ARGS = Object.freeze({
12
+ // Without --auto-patch, rtk asks before editing settings.json and answers "no" off a TTY.
13
+ "claude-code": ["init", "-g", "--auto-patch"],
14
+ codex: ["init", "-g", "--codex"],
15
+ "cursor-cli": ["init", "-g", "--agent", "cursor"],
16
+ opencode: ["init", "-g", "--opencode"],
17
+ });
18
+
19
+ /** First executable `name` on `env.PATH`, or null. */
20
+ export function findOnPath(name, env = process.env) {
21
+ for (const dir of String(env.PATH || "").split(path.delimiter)) {
22
+ if (!dir) continue;
23
+ const file = path.join(dir, name);
24
+ try {
25
+ fs.accessSync(file, fs.constants.X_OK);
26
+ if (fs.statSync(file).isFile()) return file;
27
+ } catch { /* keep looking */ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Run `rtk init` for each selected agent rtk supports. Missing rtk is a no-op.
34
+ * rtk's OpenCode integration targets OpenCode 1, so OpenCode 2 is skipped.
35
+ */
36
+ export function installRtk({ agents, env = process.env, dry = false, h, spawn = spawnSync, opencodeMajor = 1 }) {
37
+ const rtk = findOnPath("rtk", env);
38
+ if (!rtk) return { rtk: null, ran: [] };
39
+ const ran = [];
40
+ for (const agentId of agents) {
41
+ const args = RTK_INIT_ARGS[agentId];
42
+ if (!args) continue;
43
+ if (agentId === "opencode" && opencodeMajor >= 2) {
44
+ h?.info(`opencode: skipped rtk (rtk supports OpenCode 1; found OpenCode ${opencodeMajor})`);
45
+ continue;
46
+ }
47
+ if (dry) {
48
+ h?.info(`[dry-run] ${rtk} ${args.join(" ")}`);
49
+ continue;
50
+ }
51
+ const r = spawn(rtk, args, { stdio: ["ignore", "inherit", "inherit"], env });
52
+ if (r.status === 0) {
53
+ h?.ok(`${agentId}: rtk command rewriting (rtk ${args.join(" ")})`);
54
+ ran.push(agentId);
55
+ } else {
56
+ h?.warn(`${agentId}: rtk ${args.join(" ")} exited ${r.status ?? r.error?.message}; run it by hand`);
57
+ }
58
+ }
59
+ return { rtk, ran };
60
+ }