@bcmyguest/openmem 0.0.0-stage → 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 (230) hide show
  1. package/.agents/skills/openmem/SKILL.md +34 -0
  2. package/LICENSE +190 -0
  3. package/README.md +140 -2
  4. package/bin/hindsight-project-index +530 -0
  5. package/cli/coding-agent/access-policy.mjs +38 -0
  6. package/cli/coding-agent/agents.mjs +34 -0
  7. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  8. package/cli/coding-agent/capabilities.mjs +34 -0
  9. package/cli/coding-agent/context-store.mjs +81 -0
  10. package/cli/coding-agent/context-trim.mjs +897 -0
  11. package/cli/coding-agent/file-io.mjs +72 -0
  12. package/cli/coding-agent/hook-harnesses.mjs +88 -0
  13. package/cli/coding-agent/hook-runner.mjs +284 -0
  14. package/cli/coding-agent/install-access.mjs +119 -0
  15. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  16. package/cli/coding-agent/install-rtk.mjs +60 -0
  17. package/cli/coding-agent/install-shim.mjs +201 -0
  18. package/cli/coding-agent/opencode-config.mjs +107 -0
  19. package/cli/coding-agent/opencode-plugin.mjs +132 -0
  20. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  21. package/cli/coding-agent/recall-frame.mjs +29 -0
  22. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  23. package/cli/coding-agent/runtime.mjs +40 -0
  24. package/cli/coding-agent/session-db.mjs +626 -0
  25. package/cli/coding-agent/session-facade.mjs +86 -0
  26. package/cli/coding-agent/toml-edit.mjs +161 -0
  27. package/cli/cognee-mcp.mjs +260 -0
  28. package/cli/context-management.mjs +254 -0
  29. package/cli/feed-sync.mjs +27 -0
  30. package/cli/install-coding-agent.mjs +298 -0
  31. package/cli/install-memory-mcp.mjs +426 -0
  32. package/cli/is-main.mjs +18 -0
  33. package/cli/mcp/context-sql-runner.mjs +58 -0
  34. package/cli/mcp/context-store.mjs +670 -0
  35. package/cli/mcp/context.mjs +61 -0
  36. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  37. package/cli/mcp/export/cognee.mjs +119 -0
  38. package/cli/mcp/export/hindsight.mjs +48 -0
  39. package/cli/mcp/export/index.mjs +26 -0
  40. package/cli/mcp/export/mem0.mjs +31 -0
  41. package/cli/mcp/knowledge.mjs +333 -0
  42. package/cli/mcp/llm-synthesize.mjs +50 -0
  43. package/cli/mcp/mem0-scope-id.mjs +22 -0
  44. package/cli/mcp/memory-facade.mjs +521 -0
  45. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  46. package/cli/mcp/notebooklm-banks.mjs +21 -0
  47. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  48. package/cli/mcp/notebooklm-provider.mjs +225 -0
  49. package/cli/mcp/pmm-records.mjs +23 -0
  50. package/cli/mcp/portability.mjs +158 -0
  51. package/cli/mcp/stdio.mjs +65 -0
  52. package/cli/mcp/unified-mcp-install.mjs +65 -0
  53. package/cli/mem0-mcp.mjs +108 -0
  54. package/cli/notebooklm-mcp.mjs +236 -0
  55. package/cli/openmem-mcp.mjs +38 -0
  56. package/cli/openmem.mjs +222 -0
  57. package/cli/roots.mjs +85 -0
  58. package/cli/schedule.mjs +176 -0
  59. package/cli/stack.mjs +438 -0
  60. package/cli/switch-memory-provider.mjs +358 -0
  61. package/cli/wiring-report.mjs +125 -0
  62. package/compose.bench.yaml +12 -0
  63. package/compose.yaml +227 -0
  64. package/config/coding-agent.json +13 -0
  65. package/config/hindsight.env.example +26 -0
  66. package/config/index.yml +54 -0
  67. package/config/litellm.lemond.yaml +40 -0
  68. package/config/litellm.ollama.yaml +33 -0
  69. package/config/notebooklm.yml +33 -0
  70. package/config/openmem.toml.example +124 -0
  71. package/docs/OPENMEM.md +23 -0
  72. package/docs/architecture.md +245 -0
  73. package/docs/assets/README.md +10 -0
  74. package/docs/assets/setup-demo.gif +0 -0
  75. package/docs/coding-agent-hooks.md +75 -0
  76. package/docs/coding-agent-scopes.md +22 -0
  77. package/docs/coding-agents.md +71 -0
  78. package/docs/cognee.md +37 -0
  79. package/docs/compatibility.md +80 -0
  80. package/docs/concepts.md +135 -0
  81. package/docs/context-management.md +255 -0
  82. package/docs/context-proxy.md +115 -0
  83. package/docs/context-research.md +215 -0
  84. package/docs/context-store.md +277 -0
  85. package/docs/hindsight.md +77 -0
  86. package/docs/index-sources-roadmap.md +38 -0
  87. package/docs/index-sources.md +39 -0
  88. package/docs/indexer-sinks.md +122 -0
  89. package/docs/indexing.md +57 -0
  90. package/docs/manual-stack-setup.md +74 -0
  91. package/docs/mcp-install-guide.md +4 -0
  92. package/docs/mcp-setup.md +104 -0
  93. package/docs/mem0.md +141 -0
  94. package/docs/memory-portability.md +73 -0
  95. package/docs/memory-providers.md +246 -0
  96. package/docs/meridian-litellm.md +198 -0
  97. package/docs/model-configuration.md +106 -0
  98. package/docs/notebooklm.md +83 -0
  99. package/docs/npx-install.md +172 -0
  100. package/docs/openmem-config.md +194 -0
  101. package/docs/provider-wizard-hooks.md +222 -0
  102. package/docs/quickstart.md +107 -0
  103. package/docs/setup-wizard.md +117 -0
  104. package/docs/sources-feeds.md +39 -0
  105. package/docs/sources-readwise.md +23 -0
  106. package/docs/sources-zotero.md +22 -0
  107. package/glama.json +4 -0
  108. package/indexer/Dockerfile +38 -0
  109. package/indexer/code_outline.py +146 -0
  110. package/indexer/feed_sync.py +107 -0
  111. package/indexer/index_config.py +290 -0
  112. package/indexer/pytest.ini +5 -0
  113. package/indexer/sinks/__init__.py +148 -0
  114. package/indexer/sinks/_http.py +44 -0
  115. package/indexer/sinks/cognee.py +329 -0
  116. package/indexer/sinks/hindsight.py +107 -0
  117. package/indexer/sinks/mem0.py +247 -0
  118. package/indexer/watch.py +240 -0
  119. package/package.json +96 -4
  120. package/providers/_template/compose.yaml +52 -0
  121. package/providers/cognee/OPERATOR.md +82 -0
  122. package/providers/cognee/compose.yaml +89 -0
  123. package/providers/cognee/lib/clack.mjs +157 -0
  124. package/providers/cognee/lib/coding-agent.mjs +26 -0
  125. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  126. package/providers/cognee/lib/config.mjs +98 -0
  127. package/providers/cognee/lib/env-file.mjs +194 -0
  128. package/providers/cognee/lib/env-setup.mjs +83 -0
  129. package/providers/cognee/lib/llm-env.mjs +382 -0
  130. package/providers/cognee/lib/ports.mjs +25 -0
  131. package/providers/cognee/lib/smoke.mjs +246 -0
  132. package/providers/cognee/lib/stack-env.mjs +134 -0
  133. package/providers/cognee/lib/state.mjs +101 -0
  134. package/providers/cognee/lib/summary.mjs +56 -0
  135. package/providers/cognee/lib/validate.mjs +97 -0
  136. package/providers/cognee/manifest.json +56 -0
  137. package/providers/cognee/wizard.mjs +134 -0
  138. package/providers/graphiti/manifest.json +15 -0
  139. package/providers/hindsight/lib/clack.mjs +347 -0
  140. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  141. package/providers/hindsight/lib/config.mjs +79 -0
  142. package/providers/hindsight/lib/env-setup.mjs +72 -0
  143. package/providers/hindsight/lib/litellm-routes.mjs +196 -0
  144. package/providers/hindsight/lib/meridian.mjs +307 -0
  145. package/providers/hindsight/lib/model-backend.mjs +243 -0
  146. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  147. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  148. package/providers/hindsight/lib/ports.mjs +30 -0
  149. package/providers/hindsight/lib/probe.mjs +54 -0
  150. package/providers/hindsight/lib/stack-env.mjs +125 -0
  151. package/providers/hindsight/lib/state.mjs +80 -0
  152. package/providers/hindsight/lib/summary.mjs +57 -0
  153. package/providers/hindsight/lib/validate.mjs +81 -0
  154. package/providers/hindsight/manifest.json +58 -0
  155. package/providers/hindsight/wizard.mjs +150 -0
  156. package/providers/letta/manifest.json +29 -0
  157. package/providers/mem0/OPERATOR.md +143 -0
  158. package/providers/mem0/compose.yaml +79 -0
  159. package/providers/mem0/lib/coding-agent.mjs +58 -0
  160. package/providers/mem0/lib/config.mjs +45 -0
  161. package/providers/mem0/lib/configure.mjs +201 -0
  162. package/providers/mem0/lib/dotenv.mjs +71 -0
  163. package/providers/mem0/lib/env-file.mjs +110 -0
  164. package/providers/mem0/lib/http.mjs +69 -0
  165. package/providers/mem0/lib/litellm.mjs +457 -0
  166. package/providers/mem0/lib/mcp-install.mjs +165 -0
  167. package/providers/mem0/lib/secret.mjs +30 -0
  168. package/providers/mem0/lib/smoke.mjs +179 -0
  169. package/providers/mem0/lib/stack-env.mjs +255 -0
  170. package/providers/mem0/lib/state.mjs +67 -0
  171. package/providers/mem0/lib/summary.mjs +88 -0
  172. package/providers/mem0/lib/url.mjs +106 -0
  173. package/providers/mem0/lib/validate.mjs +158 -0
  174. package/providers/mem0/manifest.json +47 -0
  175. package/providers/mem0/wizard.mjs +146 -0
  176. package/providers/zep/manifest.json +29 -0
  177. package/proxy/config.yaml +33 -0
  178. package/proxy/openmem_context/__init__.py +6 -0
  179. package/proxy/openmem_context/adapters.py +504 -0
  180. package/proxy/openmem_context/compress.py +53 -0
  181. package/proxy/openmem_context/core.py +606 -0
  182. package/proxy/openmem_context/embed.py +212 -0
  183. package/proxy/openmem_context/recall.py +49 -0
  184. package/proxy/openmem_context/session.py +70 -0
  185. package/proxy/openmem_context/sessiondb.py +276 -0
  186. package/proxy/openmem_context/settings.py +134 -0
  187. package/proxy/openmem_context/store.py +129 -0
  188. package/proxy/openmem_context/tokens.py +123 -0
  189. package/proxy/openmem_context/ttl.py +78 -0
  190. package/proxy/openmem_context_hook.py +34 -0
  191. package/scripts/check-pack.mjs +219 -0
  192. package/scripts/gen-masks.mjs +83 -0
  193. package/scripts/gen-masks.sh +4 -0
  194. package/scripts/promotion-provider-smoke.mjs +34 -0
  195. package/scripts/run-setup.sh +30 -0
  196. package/scripts/setup.mjs +107 -0
  197. package/scripts/setup.sh +5 -0
  198. package/scripts/spin-up.sh +45 -0
  199. package/scripts/wizard.sh +5 -0
  200. package/server.json +34 -0
  201. package/sync/Dockerfile +27 -0
  202. package/sync/memory_backend.py +87 -0
  203. package/sync/notebooklm_sync.py +664 -0
  204. package/wizard/bin/pm.mjs +97 -0
  205. package/wizard/bin/setup.mjs +33 -0
  206. package/wizard/lib/compose-version.mjs +32 -0
  207. package/wizard/lib/compose.mjs +70 -0
  208. package/wizard/lib/context-step.mjs +140 -0
  209. package/wizard/lib/curl.mjs +103 -0
  210. package/wizard/lib/docker-build.mjs +148 -0
  211. package/wizard/lib/exec.mjs +41 -0
  212. package/wizard/lib/indexer.mjs +6 -0
  213. package/wizard/lib/litellm-env.mjs +55 -0
  214. package/wizard/lib/load-machine-env.mjs +4 -0
  215. package/wizard/lib/log.mjs +32 -0
  216. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  217. package/wizard/lib/notebooklm.mjs +214 -0
  218. package/wizard/lib/parse-args.mjs +87 -0
  219. package/wizard/lib/paths.mjs +39 -0
  220. package/wizard/lib/pm-config.mjs +371 -0
  221. package/wizard/lib/ports.mjs +94 -0
  222. package/wizard/lib/prompts.mjs +85 -0
  223. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  224. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  225. package/wizard/lib/providers/registry.mjs +262 -0
  226. package/wizard/lib/run-clack.mjs +108 -0
  227. package/wizard/lib/run-wizard.mjs +750 -0
  228. package/wizard/lib/validate-setup.mjs +62 -0
  229. package/wizard/lib/wizard-action.mjs +34 -0
  230. package/wizard/lib/wizard-conf.mjs +71 -0
@@ -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
+ }
@@ -0,0 +1,201 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { SHIM_AGENT_IDS, SHIM_HARNESSES, SHIM_MARKER } from "./hook-harnesses.mjs";
4
+ import { SMART_COMPACT_MARKER, SMART_COMPACT_COMMAND } from "./context-store.mjs";
5
+ import { SILENT, readJson, writeFileAtomic, writeJson } from "./file-io.mjs";
6
+ import { mergeOpencodeConfig, opencodeConfigPath, parseJsonc } from "./opencode-config.mjs";
7
+
8
+ /** Upstream @vectorize-io/hindsight-coding-agents entries name its package or staged copy. */
9
+ const UPSTREAM_MARKER = /hindsight[-/]coding-agents/;
10
+ const OPENMEM_PLUGIN = /openmem|personal-memory/;
11
+ const OPENMEM_PLUGIN_FILE = "coding-agent/opencode-plugin.mjs";
12
+ const OPENMEM_PLUGIN2_DIR = "coding-agent/opencode2-plugin";
13
+ /** OpenCode 1 reads `plugin` (file paths); OpenCode 2 reads `plugins` (directory paths). */
14
+ const OPENCODE_PLUGIN_KEYS = ["plugin", "plugins"];
15
+
16
+ function hookCommand(root, harnessId, phase) {
17
+ return `node ${JSON.stringify(path.join(root, "cli", "coding-agent", "hook-runner.mjs"))} ${harnessId} ${phase}`;
18
+ }
19
+
20
+ function nestedHookBlock(root, harnessId, phase, timeout) {
21
+ return {
22
+ hooks: [{ type: "command", command: hookCommand(root, harnessId, phase), timeout }],
23
+ };
24
+ }
25
+
26
+ function mergeNestedHooks(existing, root, harnessId, spec) {
27
+ const hooks = existing && typeof existing === "object" ? { ...existing } : {};
28
+ for (const [phase, meta] of Object.entries(spec.events)) {
29
+ const list = Array.isArray(hooks[meta.hook]) ? hooks[meta.hook].filter((e) => !JSON.stringify(e).includes(SHIM_MARKER)) : [];
30
+ hooks[meta.hook] = [...list, nestedHookBlock(root, harnessId, phase, meta.timeout)];
31
+ }
32
+ return hooks;
33
+ }
34
+
35
+ function mergeFlatHooks(existing, root, harnessId, spec) {
36
+ const hooks = existing && typeof existing === "object" ? { ...existing } : {};
37
+ for (const [phase, meta] of Object.entries(spec.events)) {
38
+ const list = Array.isArray(hooks[meta.hook]) ? hooks[meta.hook].filter((e) => !String(e?.command).includes(SHIM_MARKER)) : [];
39
+ hooks[meta.hook] = [
40
+ ...list,
41
+ { command: hookCommand(root, harnessId, phase), timeout: meta.timeout },
42
+ ];
43
+ }
44
+ return hooks;
45
+ }
46
+
47
+ /** Drop matching entries from every event list; empty lists are deleted. Returns true on change. */
48
+ function stripHookEntries(hooks, matches) {
49
+ if (!hooks || typeof hooks !== "object") return false;
50
+ let changed = false;
51
+ for (const [event, list] of Object.entries(hooks)) {
52
+ if (!Array.isArray(list)) continue;
53
+ const kept = list.filter((e) => !matches(JSON.stringify(e)));
54
+ if (kept.length === list.length) continue;
55
+ changed = true;
56
+ if (kept.length) hooks[event] = kept;
57
+ else delete hooks[event];
58
+ }
59
+ return changed;
60
+ }
61
+
62
+ export function smartCompactCommandPath(home) {
63
+ return path.join(home, ".claude", "commands", "smart-compact.md");
64
+ }
65
+
66
+ const entryText = (entry) => (typeof entry === "string" ? entry : JSON.stringify(entry));
67
+
68
+ /**
69
+ * Remove matching entries from OpenCode's `plugin` (v1) and `plugins` (v2) lists; a file or key
70
+ * with nothing to remove is left untouched. Returns true on change.
71
+ */
72
+ function stripOpencodePlugins(home, matches, keys = OPENCODE_PLUGIN_KEYS) {
73
+ const file = opencodeConfigPath(home);
74
+ if (!fs.existsSync(file)) return false;
75
+ let changed = false;
76
+ for (const key of keys) {
77
+ const list = parseJsonc(fs.readFileSync(file, "utf8"))?.[key];
78
+ if (!Array.isArray(list) || !list.some((p) => matches(entryText(p)))) continue;
79
+ mergeOpencodeConfig(home, key, (existing) => existing.filter((p) => !matches(entryText(p))));
80
+ changed = true;
81
+ }
82
+ return changed;
83
+ }
84
+
85
+ /** Remove hook entries whose JSON text satisfies `matches` from each agent's hook config. */
86
+ function removeHooks({ home, agents, dry, h, matches, label }) {
87
+ const removed = [];
88
+ for (const agentId of agents) {
89
+ if (agentId === "opencode") {
90
+ if (dry) h.info(`[dry-run] remove ${label} opencode plugin entries from ${opencodeConfigPath(home)}`);
91
+ else if (stripOpencodePlugins(home, matches)) {
92
+ h.ok(`opencode: removed ${label} plugin entries`);
93
+ removed.push(agentId);
94
+ }
95
+ continue;
96
+ }
97
+ const spec = SHIM_HARNESSES[agentId];
98
+ if (!spec) continue;
99
+ const cfgPath = spec.settingsPath(home);
100
+ if (dry) {
101
+ h.info(`[dry-run] remove ${label} ${agentId} hooks from ${cfgPath}`);
102
+ continue;
103
+ }
104
+ if (!fs.existsSync(cfgPath)) continue;
105
+ const prev = readJson(cfgPath);
106
+ // Older OpenMem installs wrote Cursor events at the top level; check both places.
107
+ const nestedChanged = stripHookEntries(prev.hooks, matches);
108
+ const topChanged = spec.configStyle === "flat" && stripHookEntries(prev, matches);
109
+ if (!nestedChanged && !topChanged) continue;
110
+ writeJson(cfgPath, prev);
111
+ h.ok(`${agentId}: removed ${label} hooks from ${cfgPath}`);
112
+ removed.push(agentId);
113
+ }
114
+ return { removed };
115
+ }
116
+
117
+ /**
118
+ * @param {{ home: string, repoRoot: string, agents: string[], dry: boolean, opencodeMajor?: number, h?: { info: Function, ok: Function, warn: Function } }} opts
119
+ */
120
+ export function installSessionShim(opts) {
121
+ const home = opts.home;
122
+ const h = opts.h || SILENT;
123
+ const agents = opts.agents.filter((id) => SHIM_AGENT_IDS.includes(id) || id === "opencode");
124
+ const opencodeV2 = (opts.opencodeMajor || 1) >= 2;
125
+ const opencodePlugin = opencodeV2
126
+ ? path.join(opts.repoRoot, "cli", "coding-agent", "opencode2-plugin")
127
+ : path.join(opts.repoRoot, "cli", "coding-agent", "opencode-plugin.mjs");
128
+ const opencodeKey = opencodeV2 ? "plugins" : "plugin";
129
+ if (!agents.length) {
130
+ h.warn("no agents selected for session shim (claude-code, codex, cursor-cli, opencode)");
131
+ return { installed: [] };
132
+ }
133
+
134
+ const installed = [];
135
+ for (const agentId of agents) {
136
+ if (agentId === "opencode") {
137
+ const cfgPath = opencodeConfigPath(home);
138
+ if (opts.dry) {
139
+ h.info(`[dry-run] register opencode ${opencodeV2 ? "2" : "1"} plugin ${opencodePlugin} in ${cfgPath} ("${opencodeKey}")`);
140
+ } else {
141
+ // Remove the other major version's entry so exactly one OpenMem plugin loads.
142
+ stripOpencodePlugins(home, (text) => OPENMEM_PLUGIN.test(text), OPENCODE_PLUGIN_KEYS.filter((k) => k !== opencodeKey));
143
+ mergeOpencodeConfig(home, opencodeKey, (existing) => {
144
+ const plugins = Array.isArray(existing)
145
+ ? existing.filter((p) => !OPENMEM_PLUGIN.test(entryText(p)))
146
+ : [];
147
+ return [...plugins, opencodePlugin];
148
+ });
149
+ h.ok(`opencode: registered openmem ${opencodeV2 ? "OpenCode 2 " : ""}plugin in ${cfgPath}`);
150
+ }
151
+ installed.push("opencode");
152
+ continue;
153
+ }
154
+ const spec = SHIM_HARNESSES[agentId];
155
+ if (!spec) continue;
156
+ const cfgPath = spec.settingsPath(home);
157
+ if (opts.dry) {
158
+ h.info(`[dry-run] merge ${agentId} hooks in ${cfgPath}`);
159
+ if (agentId === "claude-code") h.info(`[dry-run] install /smart-compact command → ${smartCompactCommandPath(home)}`);
160
+ installed.push(agentId);
161
+ continue;
162
+ }
163
+ const prev = readJson(cfgPath);
164
+ if (spec.configStyle === "nested") {
165
+ prev[spec.rootKey] = mergeNestedHooks(prev[spec.rootKey], opts.repoRoot, agentId, spec);
166
+ } else {
167
+ stripHookEntries(prev, (text) => text.includes(SHIM_MARKER));
168
+ prev.version ??= 1;
169
+ prev[spec.rootKey] = mergeFlatHooks(prev[spec.rootKey], opts.repoRoot, agentId, spec);
170
+ }
171
+ writeJson(cfgPath, prev);
172
+ h.ok(`${agentId}: session hooks → ${cfgPath}`);
173
+ if (agentId === "claude-code") {
174
+ const command = smartCompactCommandPath(home);
175
+ writeFileAtomic(command, SMART_COMPACT_COMMAND);
176
+ h.ok(`claude-code: /smart-compact command → ${command}`);
177
+ }
178
+ installed.push(agentId);
179
+ }
180
+ return { installed };
181
+ }
182
+
183
+ /** Remove OpenMem's session hooks, OpenCode plugin entry and /smart-compact command. */
184
+ export function removeSessionShim({ home, agents, dry = false, h = SILENT }) {
185
+ const out = removeHooks({
186
+ home, agents, dry, h, label: "openmem",
187
+ matches: (text) => text.includes(SHIM_MARKER) || text.includes(OPENMEM_PLUGIN_FILE) || text.includes(OPENMEM_PLUGIN2_DIR),
188
+ });
189
+ const command = smartCompactCommandPath(home);
190
+ if (agents.includes("claude-code") && !dry && fs.existsSync(command)
191
+ && fs.readFileSync(command, "utf8").includes(SMART_COMPACT_MARKER)) {
192
+ fs.rmSync(command);
193
+ h.ok(`claude-code: removed ${command}`);
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /** Remove the upstream Hindsight coding-agent hooks (and its OpenCode plugin entry). */
199
+ export function removeUpstreamHooks({ home, agents, dry = false, h = SILENT }) {
200
+ return removeHooks({ home, agents, dry, h, label: "upstream Hindsight", matches: (text) => UPSTREAM_MARKER.test(text) });
201
+ }