@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,457 @@
1
+ /**
2
+ * Optional: route mem0's LLM (and, when the backend has one, its embedder) through the stack
3
+ * LiteLLM, the gateway Hindsight already uses. The wizard writes the LiteLLM route file, keeps
4
+ * provider keys in litellm.env (mode 600), turns on the compose profile "litellm" (published on
5
+ * 127.0.0.1 only by providers/mem0/compose.yaml) and prints the mem0 config blocks. It never
6
+ * edits the operator's mem0 server config.
7
+ *
8
+ * mem0 side (verified against mem0 main @ 26cdefb): the self-hosted REST server bundles only
9
+ * the openai, anthropic and gemini LLM providers and the openai and gemini embedders
10
+ * (server/main.py BUNDLED_*_PROVIDERS), so the snippet uses provider "openai" with
11
+ * openai_base_url pointing at LiteLLM, not provider "litellm". See docs/mem0.md.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { setLitellmEnvValue } from "../../../wizard/lib/litellm-env.mjs";
16
+ import {
17
+ bridgeReport,
18
+ containerApiBase,
19
+ dockerBridgeIp,
20
+ existingMeridianKey,
21
+ MERIDIAN_DEFAULT_BASE,
22
+ MERIDIAN_GUIDE,
23
+ MERIDIAN_NO_KEY,
24
+ MERIDIAN_RETAIN_DEFAULT,
25
+ MERIDIAN_TERMS_URL,
26
+ normalizeMeridianBase,
27
+ probeMeridian,
28
+ } from "../../hindsight/lib/meridian.mjs";
29
+
30
+ /** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
31
+
32
+ /** LiteLLM model_name aliases mem0 asks for (Hindsight uses memory-retain / reflect / consolidate). */
33
+ export const LLM_ALIAS = "memory-llm";
34
+ export const EMBED_ALIAS = "memory-embed";
35
+ export const DEFAULT_PORT = 4000;
36
+ export const BACKENDS = ["ollama", "custom", "anthropic", "meridian"];
37
+ /** Backends with an embedding model LiteLLM can serve. Claude (API or Meridian) has none. */
38
+ export const EMBED_BACKENDS = ["ollama", "custom"];
39
+
40
+ const OLLAMA_API_BASE = "http://host.docker.internal:11434";
41
+ const CUSTOM_API_BASE = "http://host.docker.internal:8080/v1";
42
+ const OLLAMA_DEFAULT_MODEL = "gemma4:e2b";
43
+ const OLLAMA_EMBED_DEFAULT = "nomic-embed-text";
44
+ /** Known output sizes, for vector_store.config.embedding_model_dims in the snippet. */
45
+ const EMBED_DIMS = { "nomic-embed-text": 768, "text-embedding-3-small": 1536, "text-embedding-3-large": 3072 };
46
+
47
+ /** wizard.conf keys this option owns. */
48
+ export const CONF_KEYS = [
49
+ "MEM0_LITELLM",
50
+ "MEM0_LITELLM_BACKEND",
51
+ "MEM0_LITELLM_MODEL",
52
+ "MEM0_LITELLM_API_BASE",
53
+ "MEM0_LITELLM_EMBED_MODEL",
54
+ "MEM0_LITELLM_PORT",
55
+ ];
56
+
57
+ /** The setup UI's masked ANTHROPIC_API_KEY answer, taken out of process.env by captureAnthropicKey. */
58
+ let capturedAnthropicKey = "";
59
+
60
+ /**
61
+ * Move PM_ANTHROPIC_API_KEY (the setup UI handoff) out of process.env so no child process
62
+ * (docker, curl) inherits it. Called from the init hook and again before the key is written.
63
+ */
64
+ export function captureAnthropicKey() {
65
+ const v = process.env.PM_ANTHROPIC_API_KEY;
66
+ if (v) capturedAnthropicKey = v.trim();
67
+ delete process.env.PM_ANTHROPIC_API_KEY;
68
+ }
69
+
70
+ /** @param {string} home */
71
+ export function routesFile(home) {
72
+ return path.join(home || "", ".config/personal-memory/mem0-litellm.yaml");
73
+ }
74
+
75
+ /** litellm.env: LITELLM_ENV_FILE, else Hindsight's default (one master key for the stack LiteLLM). */
76
+ export function litellmEnvFile(home) {
77
+ return process.env.LITELLM_ENV_FILE || path.join(home || "", ".config/hindsight/litellm.env");
78
+ }
79
+
80
+ /** @param {string} value */
81
+ export function portProblem(value) {
82
+ if (!/^\d+$/.test(String(value))) return "MEM0_LITELLM_PORT must be a port number";
83
+ const n = Number(value);
84
+ if (n < 1 || n > 65535) return "MEM0_LITELLM_PORT must be between 1 and 65535";
85
+ return "";
86
+ }
87
+
88
+ /** A value written into the YAML route file: no spaces, quotes or YAML syntax. */
89
+ function modelProblem(label, value) {
90
+ if (!value) return `${label} is empty`;
91
+ if (!/^[A-Za-z0-9._:/@+-]+$/.test(value)) return `${label} has characters a model id cannot have: ${value}`;
92
+ return "";
93
+ }
94
+
95
+ /** @param {string} value */
96
+ function apiBaseProblem(value) {
97
+ try {
98
+ const u = new URL(value);
99
+ if (u.protocol !== "http:" && u.protocol !== "https:") return "the API base must be http(s)";
100
+ if (u.username || u.password || u.search || u.hash) return "the API base must not hold credentials, a query or a fragment";
101
+ } catch {
102
+ return `the API base is not a URL: ${value}`;
103
+ }
104
+ return "";
105
+ }
106
+
107
+ /**
108
+ * Validate a choice. Returns the first problem or "".
109
+ * @param {{ backend: string, model: string, apiBase: string, embedModel: string, port: string|number }} c
110
+ */
111
+ export function choiceProblem(c) {
112
+ if (!BACKENDS.includes(c.backend)) return `MEM0_LITELLM_BACKEND must be one of ${BACKENDS.join(", ")} (got: ${c.backend})`;
113
+ const m = modelProblem("the model", c.model);
114
+ if (m) return m;
115
+ if (c.backend !== "anthropic") {
116
+ const a = apiBaseProblem(c.apiBase);
117
+ if (a) return a;
118
+ }
119
+ if (c.embedModel) {
120
+ if (!EMBED_BACKENDS.includes(c.backend)) return `the ${c.backend} backend has no embedding model; keep mem0's own embedder`;
121
+ const e = modelProblem("the embedding model", c.embedModel);
122
+ if (e) return e;
123
+ }
124
+ return portProblem(c.port);
125
+ }
126
+
127
+ /** One route entry. */
128
+ function route(alias, model, extra) {
129
+ return ` - model_name: ${alias}\n litellm_params:\n model: ${model}\n${extra}`;
130
+ }
131
+
132
+ /**
133
+ * The LiteLLM route file for mem0. Keys are referenced as os.environ/<NAME> and live in litellm.env.
134
+ * @param {{ backend: string, model: string, apiBase: string, embedModel: string }} c
135
+ * @returns {string}
136
+ */
137
+ export function renderRoutes(c) {
138
+ const head =
139
+ "# Written by ./setup (mem0, \"route mem0's LLM through the stack LiteLLM\"). Rerun ./setup to change it.\n" +
140
+ `# mem0 calls ${LLM_ALIAS}${c.embedModel ? ` and ${EMBED_ALIAS}` : ""}; keys live in litellm.env, never here.\n`;
141
+ let out = `${head}model_list:\n`;
142
+ switch (c.backend) {
143
+ case "ollama":
144
+ out += route(LLM_ALIAS, `ollama_chat/${c.model}`, ` api_base: ${c.apiBase}\n max_tokens: 4096\n timeout: 300\n`);
145
+ if (c.embedModel) out += route(EMBED_ALIAS, `ollama/${c.embedModel}`, ` api_base: ${c.apiBase}\n`);
146
+ break;
147
+ case "custom":
148
+ // Same key variable as Hindsight's custom route; set it in litellm.env when the server needs one.
149
+ out += route(
150
+ LLM_ALIAS,
151
+ `openai/${c.model}`,
152
+ ` api_base: ${c.apiBase}\n api_key: os.environ/LEMOND_API_KEY\n max_tokens: 4096\n timeout: 300\n`,
153
+ );
154
+ if (c.embedModel) {
155
+ out += route(EMBED_ALIAS, `openai/${c.embedModel}`, ` api_base: ${c.apiBase}\n api_key: os.environ/LEMOND_API_KEY\n`);
156
+ }
157
+ break;
158
+ case "anthropic":
159
+ out += route(LLM_ALIAS, `anthropic/${c.model}`, " api_key: os.environ/ANTHROPIC_API_KEY\n max_tokens: 4096\n timeout: 300\n");
160
+ break;
161
+ case "meridian":
162
+ out += route(
163
+ LLM_ALIAS,
164
+ `anthropic/${c.model}`,
165
+ ` api_base: ${c.apiBase}\n api_key: os.environ/MERIDIAN_API_KEY\n max_tokens: 4096\n timeout: 300\n` +
166
+ ' additional_drop_params: ["reasoning_effort"]\n',
167
+ );
168
+ break;
169
+ default:
170
+ throw new Error(`unknown backend ${c.backend}`);
171
+ }
172
+ return `${out}\ngeneral_settings:\n master_key: os.environ/LITELLM_MASTER_KEY\n`;
173
+ }
174
+
175
+ /**
176
+ * The mem0 config blocks that point mem0 at the stack LiteLLM. The key is a placeholder: the
177
+ * operator copies LITELLM_MASTER_KEY from litellm.env.
178
+ * @param {{ port: string|number, embedModel: string }} c
179
+ * @returns {Record<string, any>}
180
+ */
181
+ export function mem0Config(c) {
182
+ const base = `http://127.0.0.1:${c.port}/v1`;
183
+ const key = "<LITELLM_MASTER_KEY from litellm.env>";
184
+ /** @type {Record<string, any>} */
185
+ const cfg = { llm: { provider: "openai", config: { model: LLM_ALIAS, openai_base_url: base, api_key: key } } };
186
+ if (c.embedModel) {
187
+ cfg.embedder = { provider: "openai", config: { model: EMBED_ALIAS, openai_base_url: base, api_key: key } };
188
+ const dims = EMBED_DIMS[c.embedModel.replace(/:latest$/, "")];
189
+ cfg.vector_store = { config: { embedding_model_dims: dims || "<your embedding model's output size>" } };
190
+ }
191
+ return cfg;
192
+ }
193
+
194
+ /**
195
+ * Lines that print the snippet and how to apply it.
196
+ * @param {{ port: string|number, embedModel: string, backend: string }} c
197
+ * @param {string} envFile litellm.env
198
+ * @returns {string[]}
199
+ */
200
+ export function snippetLines(c, envFile) {
201
+ const json = JSON.stringify(mem0Config(c), null, 2);
202
+ const lines = [
203
+ "mem0 config for the stack LiteLLM (this wizard does not edit your mem0 server config):",
204
+ ...json.split("\n").map((l) => ` ${l}`),
205
+ ` api_key: the LITELLM_MASTER_KEY value in ${envFile} (not printed here).`,
206
+ " Self-hosted REST server: POST this JSON to /configure with an admin key (it deep-merges and persists).",
207
+ " Python SDK: merge the blocks into the dict you pass to Memory.from_config.",
208
+ ` The URL is loopback: it works for a mem0 server running on this host, not one inside another container.`,
209
+ ];
210
+ if (c.embedModel) {
211
+ lines.push(
212
+ " A different embedder changes the vector size: point vector_store at a new collection (or re-add the memories).",
213
+ );
214
+ } else {
215
+ lines.push(` Embeddings stay on mem0's own embedder: the ${c.backend} backend has no embedding model here.`);
216
+ }
217
+ lines.push(" mem0's openai LLM ignores openai_base_url when OPENROUTER_API_KEY is set in the server's environment.");
218
+ return lines;
219
+ }
220
+
221
+ /**
222
+ * The option's current answers: environment (--yes, setup UI handoff), then wizard.conf, then defaults.
223
+ * Model, API base and embedding model come from wizard.conf only when it was saved for the same backend.
224
+ * @param {Record<string, any>} ctx
225
+ * @param {Helpers} h
226
+ * @param {string} [backendOverride] the backend picked interactively
227
+ */
228
+ export function storedChoice(ctx, h, backendOverride = "") {
229
+ const conf = (k) => (ctx.wizardConf ? h.envValue(ctx.wizardConf, k) : "");
230
+ const stack = ctx.repoRoot ? path.join(ctx.repoRoot, ".env") : "";
231
+ const pick = (k, fallback = "") => String(process.env[k] ?? "").trim() || conf(k) || fallback;
232
+ const enabled = (process.env.MEM0_LITELLM ?? conf("MEM0_LITELLM")) === "1";
233
+ const backend = backendOverride || pick("MEM0_LITELLM_BACKEND", "ollama");
234
+ const sameBackend = conf("MEM0_LITELLM_BACKEND") === backend;
235
+ const fromConf = (k) => String(process.env[k] ?? "").trim() || (sameBackend ? conf(k) : "");
236
+ let apiBase = fromConf("MEM0_LITELLM_API_BASE");
237
+ let model = fromConf("MEM0_LITELLM_MODEL");
238
+ let embed = process.env.MEM0_LITELLM_EMBED_MODEL ?? (sameBackend ? conf("MEM0_LITELLM_EMBED_MODEL") : undefined);
239
+ if (backend === "ollama") {
240
+ apiBase ||= OLLAMA_API_BASE;
241
+ model ||= OLLAMA_DEFAULT_MODEL;
242
+ if (embed === undefined) embed = OLLAMA_EMBED_DEFAULT;
243
+ } else if (backend === "custom") {
244
+ apiBase ||= CUSTOM_API_BASE;
245
+ } else if (backend === "anthropic") {
246
+ apiBase = "";
247
+ model ||= MERIDIAN_RETAIN_DEFAULT;
248
+ } else if (backend === "meridian") {
249
+ model ||= MERIDIAN_RETAIN_DEFAULT;
250
+ }
251
+ embed = String(embed ?? "").trim();
252
+ if (embed === "none") embed = "";
253
+ const port =
254
+ String(process.env.MEM0_LITELLM_PORT ?? "").trim() ||
255
+ (stack && h.envValue(stack, "MEM0_LITELLM_PORT")) ||
256
+ conf("MEM0_LITELLM_PORT") ||
257
+ String(DEFAULT_PORT);
258
+ return { enabled, backend, model, apiBase, embedModel: embed, port };
259
+ }
260
+
261
+ /**
262
+ * Meridian: probe it the way Hindsight's wizard does, keep MERIDIAN_API_KEY in litellm.env.
263
+ * @param {Record<string, any>} ctx
264
+ * @param {Helpers} h
265
+ * @param {Record<string, any>} c
266
+ * @param {boolean} interactive
267
+ */
268
+ async function chooseMeridian(ctx, h, c, interactive) {
269
+ h.warn(`Claude subscription use through Meridian is governed by Anthropic's Consumer Terms: ${MERIDIAN_TERMS_URL}`);
270
+ h.info(`The wizard does not install or start Meridian; see ${MERIDIAN_GUIDE}.`);
271
+ const mctx = {
272
+ meridianEnvFile: path.join(ctx.home || "", ".config/meridian/meridian.env"),
273
+ litellmEnv: litellmEnvFile(ctx.home),
274
+ dockerBridgeIp: dockerBridgeIp(),
275
+ };
276
+ let hostBase = process.env.MERIDIAN_BASE || MERIDIAN_DEFAULT_BASE;
277
+ if (interactive) hostBase = await h.ask(ctx, "Meridian URL from this host", hostBase);
278
+ hostBase = normalizeMeridianBase(hostBase);
279
+ try {
280
+ // A saved or MEM0_LITELLM_API_BASE value wins under --yes; a typed or MERIDIAN_BASE URL replaces it.
281
+ if (interactive || process.env.MERIDIAN_BASE || !c.apiBase) c.apiBase = containerApiBase(hostBase);
282
+ } catch {
283
+ h.problem(ctx, `not a URL: ${hostBase}`);
284
+ return false;
285
+ }
286
+ const existing = existingMeridianKey(mctx, h);
287
+ c.meridianKey = existing.key;
288
+ if (ctx.dry) {
289
+ h.info(`[dry-run] probe Meridian at ${hostBase}`);
290
+ return true;
291
+ }
292
+ const probe = await probeMeridian(hostBase, existing.key, mctx.dockerBridgeIp);
293
+ if (!probe.answered) h.warn(`Meridian is not answering on ${hostBase}; start it first (${MERIDIAN_GUIDE}).`);
294
+ else if (probe.ids.length && !probe.ids.includes(c.model)) h.warn(`${c.model} is not listed by Meridian's /v1/models`);
295
+ const bridge = bridgeReport({ ...mctx, meridianBase: hostBase, apiBase: c.apiBase }, h.envValue, probe);
296
+ if (bridge) h[bridge.level](bridge.text);
297
+ return true;
298
+ }
299
+
300
+ /**
301
+ * Write litellm.env keys the backend needs (never printed): ANTHROPIC_API_KEY from the
302
+ * environment, MERIDIAN_API_KEY from the environment or Meridian's env file.
303
+ * @param {Record<string, any>} ctx
304
+ * @param {Helpers} h
305
+ * @param {Record<string, any>} c
306
+ */
307
+ function writeBackendKeys(ctx, h, c) {
308
+ const envFile = litellmEnvFile(ctx.home);
309
+ if (c.backend === "anthropic") {
310
+ captureAnthropicKey();
311
+ const key = String(capturedAnthropicKey || process.env.ANTHROPIC_API_KEY || "").trim();
312
+ if (ctx.dry) {
313
+ h.info(`[dry-run] ${key ? `set ANTHROPIC_API_KEY in ${envFile} (mode 600; value not printed)` : `check ANTHROPIC_API_KEY in ${envFile}`}`);
314
+ return;
315
+ }
316
+ if (key) {
317
+ const r = setLitellmEnvValue(envFile, "ANTHROPIC_API_KEY", key);
318
+ h.ok(`${r === "unchanged" ? "kept" : "set"} ANTHROPIC_API_KEY in ${envFile} (mode 600)`);
319
+ } else if (!h.envValue(envFile, "ANTHROPIC_API_KEY")) {
320
+ h.warn(`put ANTHROPIC_API_KEY=... in ${envFile} (mode 600), or rerun with ANTHROPIC_API_KEY in the environment`);
321
+ } else h.ok(`kept ANTHROPIC_API_KEY in ${envFile}`);
322
+ }
323
+ if (c.backend === "meridian") {
324
+ if (ctx.dry) {
325
+ h.info(`[dry-run] set MERIDIAN_API_KEY in ${envFile} (mode 600; value not printed)`);
326
+ return;
327
+ }
328
+ const value = c.meridianKey || h.envValue(envFile, "MERIDIAN_API_KEY") || MERIDIAN_NO_KEY;
329
+ setLitellmEnvValue(envFile, "MERIDIAN_API_KEY", value);
330
+ h.ok(`set MERIDIAN_API_KEY in ${envFile} (mode 600)`);
331
+ if (value === MERIDIAN_NO_KEY) h.warn("No MERIDIAN_API_KEY: anyone who can reach Meridian can use your subscription.");
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Write the route file (mode 644; it holds no secrets). Keeps a .bak of a different file.
337
+ * @param {Record<string, any>} ctx
338
+ * @param {Helpers} h
339
+ * @param {string} content
340
+ */
341
+ function writeRoutes(ctx, h, content) {
342
+ const file = routesFile(ctx.home);
343
+ if (ctx.dry) {
344
+ h.info(`[dry-run] write ${file}: ${LLM_ALIAS}${content.includes(EMBED_ALIAS) ? ` and ${EMBED_ALIAS}` : ""}`);
345
+ return;
346
+ }
347
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
348
+ if (fs.existsSync(file)) {
349
+ if (fs.readFileSync(file, "utf8") === content) {
350
+ h.ok(`${file} already has these routes`);
351
+ return;
352
+ }
353
+ fs.copyFileSync(file, `${file}.bak`);
354
+ }
355
+ const tmp = `${file}.${process.pid}.tmp`;
356
+ fs.writeFileSync(tmp, content, { mode: 0o644 });
357
+ fs.renameSync(tmp, file);
358
+ h.ok(`wrote ${file}`);
359
+ }
360
+
361
+ /**
362
+ * The option itself, run after the URL and key step. Fills ctx.mem0.litellm (null when off).
363
+ * @param {Record<string, any>} ctx
364
+ * @param {Helpers} h
365
+ * @returns {Promise<Record<string, string>|null>} wizard.conf keys to save, or null when off
366
+ */
367
+ export async function configureLitellm(ctx, h) {
368
+ const state = (ctx.mem0 ||= {});
369
+ const interactive = !ctx.yes && !ctx.skipBackendStep;
370
+ const c = storedChoice(ctx, h);
371
+ let enabled = c.enabled;
372
+ if (interactive) {
373
+ enabled = await h.confirm(ctx, "Route mem0's LLM through the stack LiteLLM (same gateway as Hindsight)?", c.enabled ? "y" : "n");
374
+ }
375
+ if (!enabled) {
376
+ state.litellm = null;
377
+ if (c.enabled) h.info("stack LiteLLM for mem0: off (the litellm profile is removed on this run)");
378
+ return null;
379
+ }
380
+ h.info("mem0's LLM goes through the stack LiteLLM (compose profile litellm, 127.0.0.1 only).");
381
+
382
+ if (interactive) {
383
+ h.info("1) Ollama 2) custom OpenAI-compatible URL 3) Anthropic API key 4) Claude subscription (Meridian)");
384
+ const defNum = String(BACKENDS.indexOf(c.backend) + 1 || 1);
385
+ const pick = await h.ask(ctx, "LiteLLM backend for mem0 (1-4)", defNum);
386
+ const backend = BACKENDS[Number(pick) - 1];
387
+ if (!backend) {
388
+ h.problem(ctx, "choose 1, 2, 3 or 4");
389
+ return null;
390
+ }
391
+ if (backend !== c.backend) Object.assign(c, storedChoice(ctx, h, backend), { enabled: true });
392
+ if (backend === "ollama" || backend === "custom") {
393
+ h.info("The litellm container calls this URL; host services are host.docker.internal.");
394
+ c.apiBase = String(await h.ask(ctx, "Model server base URL (as the container sees it)", c.apiBase)).trim();
395
+ }
396
+ c.model = String(await h.ask(ctx, "Model id for mem0's LLM", c.model)).trim();
397
+ if (EMBED_BACKENDS.includes(backend)) {
398
+ const e = String(await h.ask(ctx, "Embedding model through LiteLLM (none keeps mem0's own embedder)", c.embedModel || "none")).trim();
399
+ c.embedModel = e === "none" ? "" : e;
400
+ }
401
+ }
402
+ if (!EMBED_BACKENDS.includes(c.backend) && c.embedModel) {
403
+ h.info(`the ${c.backend} backend has no embedding model; mem0 keeps its own embedder`);
404
+ c.embedModel = "";
405
+ }
406
+ if (c.backend === "meridian" && !(await chooseMeridian(ctx, h, c, interactive))) return null;
407
+
408
+ const issue = choiceProblem(c);
409
+ if (issue) {
410
+ h.problem(ctx, `stack LiteLLM for mem0: ${issue}`);
411
+ return null;
412
+ }
413
+ // Loopback port for the mem0 server: kept when free or held by this project, else the next free one.
414
+ if (ctx.dry) {
415
+ const free = await h.portFree(Number(c.port));
416
+ h.info(`[dry-run] publish LiteLLM on 127.0.0.1:${c.port} (${free ? "free now" : "busy now: a real run offers the next free port"})`);
417
+ } else {
418
+ c.port = String(await h.choosePort(ctx, "LiteLLM (mem0)", Number(c.port), { envVars: ["MEM0_LITELLM_PORT"] }));
419
+ }
420
+ process.env.MEM0_LITELLM_PORT = c.port;
421
+
422
+ writeRoutes(ctx, h, renderRoutes(c));
423
+ writeBackendKeys(ctx, h, c);
424
+ state.litellm = { backend: c.backend, model: c.model, apiBase: c.apiBase, embedModel: c.embedModel, port: c.port };
425
+ for (const line of snippetLines(c, litellmEnvFile(ctx.home))) h.info(line);
426
+ return {
427
+ MEM0_LITELLM: "1",
428
+ MEM0_LITELLM_BACKEND: c.backend,
429
+ MEM0_LITELLM_MODEL: c.model,
430
+ MEM0_LITELLM_API_BASE: c.apiBase,
431
+ MEM0_LITELLM_EMBED_MODEL: c.embedModel,
432
+ MEM0_LITELLM_PORT: c.port,
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Environment for scripts/setup.mjs when the option is on.
438
+ * @param {Record<string, any>} ctx
439
+ * @returns {Record<string, string>}
440
+ */
441
+ export function stackEnvFor(ctx) {
442
+ const l = ctx.mem0?.litellm;
443
+ // Off: override an exported MEM0_LITELLM=1 the operator declined; otherwise add nothing.
444
+ if (!l) return process.env.MEM0_LITELLM === "1" ? { MEM0_LITELLM: "0" } : {};
445
+ return { MEM0_LITELLM: "1", MEM0_LITELLM_PORT: l.port, MEM0_LITELLM_CONFIG: routesFile(ctx.home) };
446
+ }
447
+
448
+ /**
449
+ * Add or remove the compose profile "litellm" on the wizard's up command.
450
+ * @param {Record<string, any>} ctx
451
+ * @param {boolean} on
452
+ */
453
+ export function applyProfile(ctx, on) {
454
+ const extra = (ctx.extraComposeProfiles || []).filter((p) => p !== "litellm");
455
+ if (on) extra.push("litellm");
456
+ ctx.extraComposeProfiles = extra;
457
+ }
@@ -8,6 +8,7 @@
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { ensureLitellmEnv } from "../../../wizard/lib/litellm-env.mjs";
11
12
  import { envValue } from "../../../wizard/lib/paths.mjs";
12
13
  import { parseEnvValue } from "./dotenv.mjs";
13
14
  import { envFileProblem } from "./state.mjs";
@@ -98,6 +99,30 @@ function customEnvFile(env, home) {
98
99
  return raw;
99
100
  }
100
101
 
102
+ /**
103
+ * Check the stack LiteLLM option's inputs and make sure litellm.env has a master key.
104
+ * @param {{ home?: string, env: NodeJS.ProcessEnv }} opts
105
+ * @returns {{ config: string, port: string }}
106
+ */
107
+ function litellmOption({ home, env }) {
108
+ const config = (env.MEM0_LITELLM_CONFIG || "").trim() || path.join(home || "", ".config/personal-memory/mem0-litellm.yaml");
109
+ if (!path.isAbsolute(config) || /[\x00-\x20\x7f$"'`\\]/.test(config)) {
110
+ throw new Error("MEM0_LITELLM_CONFIG must be an absolute path without spaces, quotes, '$' or backslashes");
111
+ }
112
+ if (!fs.existsSync(config)) throw new Error(`${config} is missing: rerun ./setup to write the mem0 LiteLLM routes`);
113
+ const port = (env.MEM0_LITELLM_PORT || "4000").trim();
114
+ if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
115
+ throw new Error("MEM0_LITELLM_PORT must be a port number (1-65535)");
116
+ }
117
+ const litellmEnv = (env.LITELLM_ENV_FILE || "").trim() || path.join(home || "", ".config/hindsight/litellm.env");
118
+ if (ensureLitellmEnv(litellmEnv)) console.log(`created ${litellmEnv} (mode 600)`);
119
+ const key = envValue(litellmEnv, "LITELLM_MASTER_KEY");
120
+ if (!/^sk-\S+$/.test(key)) {
121
+ throw new Error(`LITELLM_MASTER_KEY=sk-... is missing from ${litellmEnv}; the loopback LiteLLM refuses to start without it`);
122
+ }
123
+ return { config, port };
124
+ }
125
+
101
126
  /**
102
127
  * @param {{ repo: string, home?: string, env: NodeJS.ProcessEnv }} opts
103
128
  * @returns {{ entries: [string, string][], drop: string[], report: string[] }}
@@ -122,10 +147,19 @@ export function prepareStackEnv({ repo, home, env }) {
122
147
  // and `up` to get past interpolation. Precedence: the operator's process env, then a non-empty value
123
148
  // already in the stack .env, then the shipped config. LITELLM_CONFIG is always dropped and rewritten, which
124
149
  // also removes an empty `LITELLM_CONFIG=` line that would still fail the `:?` check.
150
+ //
151
+ // With the stack LiteLLM option on (MEM0_LITELLM=1, set by the wizard), litellm runs under the profile "litellm":
152
+ // LITELLM_CONFIG is the mem0 route file, litellm.env must hold an sk- master key (created here when missing, the
153
+ // same file and mechanism as Hindsight), and MEM0_LITELLM_PORT is the loopback port.
154
+ const litellm = env.MEM0_LITELLM === "1" ? litellmOption({ home, env }) : null;
125
155
  const litellmConfig =
126
- (env.LITELLM_CONFIG || "").trim() || envValue(stackEnv, "LITELLM_CONFIG").trim() || shippedLitellmConfig();
156
+ litellm?.config ||
157
+ (env.LITELLM_CONFIG || "").trim() ||
158
+ envValue(stackEnv, "LITELLM_CONFIG").trim() ||
159
+ shippedLitellmConfig();
127
160
 
128
161
  const profiles = ["mem0"];
162
+ if (litellm) profiles.push("litellm");
129
163
  const notebookYml = path.join(home, ".config/hindsight/notebooklm.yml");
130
164
  if (fs.existsSync(notebookYml) && /^enabled:\s*true\b/m.test(fs.readFileSync(notebookYml, "utf8"))) {
131
165
  profiles.push("notebooklm");
@@ -143,6 +177,11 @@ export function prepareStackEnv({ repo, home, env }) {
143
177
  ];
144
178
  const drop = ["MEM0_API_URL", "MEM0_INDEXER_URL", "COMPOSE_PROFILES", "LITELLM_CONFIG", "MEMORY_API_URL", "MEMORY_SINK"];
145
179
  const report = [`MEM0_INDEXER_URL=${containerUrl}`, `COMPOSE_PROFILES=${profiles.join(",")}`, `LITELLM_CONFIG=${litellmConfig}`];
180
+ if (litellm) {
181
+ entries.push(["MEM0_LITELLM_PORT", litellm.port]);
182
+ drop.push("MEM0_LITELLM_PORT");
183
+ report.push(`MEM0_LITELLM_PORT=${litellm.port}`);
184
+ }
146
185
 
147
186
  // A custom key file location is a path, not a secret: persist it so a hand-run `docker compose`
148
187
  // and `./pm up` read the same env_file as this run. With no custom path in the process
@@ -1,6 +1,8 @@
1
1
  /** mem0's contribution to the wizard's closing summary. */
2
2
  import path from "node:path";
3
3
  import { readKey } from "./env-file.mjs";
4
+ import { LLM_ALIAS, litellmEnvFile, routesFile } from "./litellm.mjs";
5
+ import { MERIDIAN_TERMS_URL } from "../../hindsight/lib/meridian.mjs";
4
6
  import { resolveEnvFile, ensureState } from "./state.mjs";
5
7
  import { containerUrl, displayUrl, resolveApiUrl, urlProblem } from "./url.mjs";
6
8
 
@@ -30,9 +32,27 @@ export function summary(ctx, h) {
30
32
  ` ${path.join(ctx.repoRoot, ".env")} (MEM0_API_URL, MEM0_INDEXER_URL; no secrets)`,
31
33
  ];
32
34
 
35
+ // The stack LiteLLM option: this run's answer, else what the last configure run saved (`--up`, `--down`).
36
+ const saved = (k) => (ctx.wizardConf ? h.envValue(ctx.wizardConf, k) : "");
37
+ const litellm =
38
+ ctx.mem0?.litellm !== undefined
39
+ ? ctx.mem0.litellm
40
+ : saved("MEM0_LITELLM") === "1"
41
+ ? { backend: saved("MEM0_LITELLM_BACKEND"), model: saved("MEM0_LITELLM_MODEL"), port: saved("MEM0_LITELLM_PORT") || "4000" }
42
+ : null;
43
+ /** @type {string[]} */
44
+ const logServices = ["indexer"];
45
+ if (litellm) {
46
+ running.push(
47
+ ` LiteLLM (mem0) http://127.0.0.1:${litellm.port}/v1 ${LLM_ALIAS} -> ${litellm.backend} ${litellm.model}; loopback only, master key required`,
48
+ );
49
+ configFiles.push(` ${routesFile(ctx.home)} (mem0 LiteLLM routes)`, ` ${litellmEnvFile(ctx.home)} (LITELLM_MASTER_KEY and provider keys, mode 600)`);
50
+ logServices.push("litellm");
51
+ }
52
+
33
53
  /** @type {string[]} */
34
54
  const notes = [];
35
- if (ctx.action === "down") return { running, configFiles, notes, logServices: ["indexer"] };
55
+ if (ctx.action === "down") return { running, configFiles, notes, logServices };
36
56
  let loopback = false;
37
57
  if (valid) {
38
58
  const parsed = new URL(raw);
@@ -56,5 +76,13 @@ export function summary(ctx, h) {
56
76
  ? "The indexer container reaches a loopback server as host.docker.internal; on Linux a server bound only to 127.0.0.1 is not reachable that way: bind it to 0.0.0.0 or the docker bridge IP, or use a hostname in MEM0_API_URL."
57
77
  : "The indexer container must be able to reach the server at the URL above (it uses the same host name).",
58
78
  );
59
- return { running, configFiles, notes, logServices: ["indexer"] };
79
+ if (litellm) {
80
+ notes.push(
81
+ `mem0's LLM: point your mem0 config at http://127.0.0.1:${litellm.port}/v1 (provider openai, model ${LLM_ALIAS}, api_key = LITELLM_MASTER_KEY); ./setup printed the blocks. See docs/mem0.md.`,
82
+ );
83
+ if (litellm.backend === "meridian") {
84
+ notes.push(`Claude subscription use through Meridian is governed by Anthropic's Consumer Terms: ${MERIDIAN_TERMS_URL}`);
85
+ }
86
+ }
87
+ return { running, configFiles, notes, logServices };
60
88
  }
@@ -6,7 +6,7 @@
6
6
  "settings": {
7
7
  "nativeDocs": "providers/mem0/OPERATOR.md (stack) and https://docs.mem0.ai/open-source/features/rest-api (upstream)",
8
8
  "nativeConfigPaths": ["~/.config/personal-memory/mem0.env"],
9
- "wizardOwns": ["indexer URL and the host-gateway rewrite", "API key file for the indexer (mode 600)", "index.yml sources (shared indexer)"]
9
+ "wizardOwns": ["indexer URL and the host-gateway rewrite", "API key file for the indexer (mode 600)", "index.yml sources (shared indexer)", "optional: stack LiteLLM routes for mem0's LLM on 127.0.0.1 (compose profile litellm); you apply the printed mem0 config"]
10
10
  },
11
11
  "compose": {
12
12
  "profile": "mem0",
@@ -10,6 +10,7 @@
10
10
  import { applyConfig, configToml as buildConfigToml } from "./lib/config.mjs";
11
11
  import { setupCodingAgent as printCodingAgentPaths } from "./lib/coding-agent.mjs";
12
12
  import { clackSetup as askInClack, configureUrlAndKey } from "./lib/configure.mjs";
13
+ import { applyProfile, captureAnthropicKey, configureLitellm, stackEnvFor } from "./lib/litellm.mjs";
13
14
  import { captureKeyFromEnv } from "./lib/secret.mjs";
14
15
  import { smoke as mem0Smoke } from "./lib/smoke.mjs";
15
16
  import { ensureState } from "./lib/state.mjs";
@@ -30,6 +31,7 @@ export { prepareStackEnv, setupNotices } from "./lib/stack-env.mjs";
30
31
  */
31
32
  export async function init(ctx) {
32
33
  captureKeyFromEnv();
34
+ captureAnthropicKey();
33
35
  ensureState(ctx);
34
36
  applyConfig(ctx);
35
37
  }
@@ -43,6 +45,8 @@ export async function hydrate(ctx, h) {
43
45
  const state = ensureState(ctx);
44
46
  applyConfig(ctx);
45
47
  state.savedUrl = (h.envValue(ctx.wizardConf, "MEM0_API_URL") || "").trim();
48
+ // `--up`: the stack LiteLLM option saved by the last configure run.
49
+ applyProfile(ctx, h.envValue(ctx.wizardConf, "MEM0_LITELLM") === "1");
46
50
  }
47
51
 
48
52
  /**
@@ -63,12 +67,21 @@ export function apiBase(ctx, h) {
63
67
  export const resolvePorts = resolveMem0Url;
64
68
 
65
69
  /**
66
- * Announced step "URL, key and login": server URL, API key into the mode 600 env file.
70
+ * Announced step "URL, key and login": server URL, API key into the mode 600 env file, then the
71
+ * optional "route mem0's LLM through the stack LiteLLM" (lib/litellm.mjs).
67
72
  * @param {Record<string, any>} ctx
68
73
  * @param {Helpers} h
69
74
  */
70
75
  export async function configureModelRouting(ctx, h) {
71
76
  await configureUrlAndKey(ctx, h);
77
+ const state = ensureState(ctx);
78
+ if (!state.url) return; // invalid URL: a dry run continues without the option
79
+ const keys = await configureLitellm(ctx, h);
80
+ applyProfile(ctx, Boolean(keys));
81
+ if (!keys) return;
82
+ // configureUrlAndKey saved MEM0_API_URL alone; save again with the option's keys (wizard.conf is rewritten whole).
83
+ if (ctx.dry) h.info(`[dry-run] record ${Object.keys(keys).join(", ")} in ${ctx.wizardConf}`);
84
+ else h.saveWizardConf(ctx, { MEM0_API_URL: state.url, ...keys }, { pmSections: buildConfigToml(ctx, state.url) });
72
85
  }
73
86
 
74
87
  /**
@@ -81,7 +94,7 @@ export function stackEnv(ctx, h) {
81
94
  return {
82
95
  description:
83
96
  "scripts/setup.mjs: write .env (MEM0_API_URL, MEM0_INDEXER_URL, no secrets), compose.override.yaml",
84
- env: { MEM0_API_URL: resolveApiUrl(ctx, h) },
97
+ env: { MEM0_API_URL: resolveApiUrl(ctx, h), ...stackEnvFor(ctx) },
85
98
  };
86
99
  }
87
100