@bcmyguest/openmem 0.6.3 → 0.9.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 (104) hide show
  1. package/{skills → .agents/skills}/openmem/SKILL.md +5 -0
  2. package/README.md +81 -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 +899 -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 +230 -0
  43. package/docs/context-proxy.md +115 -0
  44. package/docs/context-research.md +226 -0
  45. package/docs/context-store.md +277 -0
  46. package/docs/evidence-plan.md +216 -0
  47. package/docs/hindsight.md +5 -2
  48. package/docs/mcp-setup.md +5 -2
  49. package/docs/mem0.md +39 -1
  50. package/docs/memory-providers.md +2 -2
  51. package/docs/meridian-litellm.md +198 -0
  52. package/docs/model-configuration.md +22 -0
  53. package/docs/npx-install.md +6 -4
  54. package/docs/openmem-config.md +2 -2
  55. package/docs/quickstart.md +12 -6
  56. package/docs/setup-wizard.md +5 -4
  57. package/docs/sources-feeds.md +9 -22
  58. package/package.json +5 -4
  59. package/providers/cognee/OPERATOR.md +2 -2
  60. package/providers/hindsight/lib/clack.mjs +122 -20
  61. package/providers/hindsight/lib/coding-agent.mjs +1 -1
  62. package/providers/hindsight/lib/config.mjs +6 -0
  63. package/providers/hindsight/lib/env-setup.mjs +2 -1
  64. package/providers/hindsight/lib/litellm-routes.mjs +21 -12
  65. package/providers/hindsight/lib/meridian.mjs +307 -0
  66. package/providers/hindsight/lib/model-backend.mjs +38 -9
  67. package/providers/hindsight/lib/probe.mjs +21 -0
  68. package/providers/hindsight/lib/stack-env.mjs +5 -12
  69. package/providers/hindsight/lib/state.mjs +6 -1
  70. package/providers/hindsight/lib/summary.mjs +16 -2
  71. package/providers/hindsight/lib/validate.mjs +6 -0
  72. package/providers/hindsight/wizard.mjs +3 -2
  73. package/providers/mem0/OPERATOR.md +21 -2
  74. package/providers/mem0/compose.yaml +24 -2
  75. package/providers/mem0/lib/coding-agent.mjs +1 -1
  76. package/providers/mem0/lib/configure.mjs +66 -0
  77. package/providers/mem0/lib/litellm.mjs +457 -0
  78. package/providers/mem0/lib/stack-env.mjs +40 -1
  79. package/providers/mem0/lib/summary.mjs +30 -2
  80. package/providers/mem0/manifest.json +1 -1
  81. package/providers/mem0/wizard.mjs +15 -2
  82. package/proxy/config.yaml +33 -0
  83. package/proxy/openmem_context/__init__.py +6 -0
  84. package/proxy/openmem_context/adapters.py +504 -0
  85. package/proxy/openmem_context/compress.py +53 -0
  86. package/proxy/openmem_context/core.py +608 -0
  87. package/proxy/openmem_context/embed.py +212 -0
  88. package/proxy/openmem_context/recall.py +49 -0
  89. package/proxy/openmem_context/session.py +70 -0
  90. package/proxy/openmem_context/sessiondb.py +276 -0
  91. package/proxy/openmem_context/settings.py +134 -0
  92. package/proxy/openmem_context/store.py +129 -0
  93. package/proxy/openmem_context/tokens.py +123 -0
  94. package/proxy/openmem_context/ttl.py +78 -0
  95. package/proxy/openmem_context_hook.py +34 -0
  96. package/scripts/check-pack.mjs +2 -1
  97. package/scripts/run-setup.sh +14 -5
  98. package/scripts/setup.mjs +14 -1
  99. package/server.json +2 -2
  100. package/wizard/lib/context-step.mjs +140 -0
  101. package/wizard/lib/litellm-env.mjs +55 -0
  102. package/wizard/lib/pm-config.mjs +212 -4
  103. package/wizard/lib/prompts.mjs +28 -0
  104. package/wizard/lib/run-wizard.mjs +26 -31
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Detect-only "Claude subscription (Meridian)" backend: probe a Meridian proxy the user
3
+ * already runs, write the LiteLLM route from docs/meridian-litellm.md, and keep
4
+ * MERIDIAN_API_KEY in litellm.env. The wizard never installs or starts Meridian.
5
+ */
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { fetchStatus, openAiModelIds } from "./probe.mjs";
10
+ import { ensureLitellmEnv } from "./stack-env.mjs";
11
+ import { storedOr } from "./state.mjs";
12
+
13
+ /** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
14
+
15
+ export const MERIDIAN_DEFAULT_PORT = "3456";
16
+ export const MERIDIAN_DEFAULT_BASE = `http://127.0.0.1:${MERIDIAN_DEFAULT_PORT}`;
17
+ /** Guide defaults (docs/meridian-litellm.md#route-file). */
18
+ export const MERIDIAN_RETAIN_DEFAULT = "claude-haiku-5-5";
19
+ export const MERIDIAN_REFLECT_DEFAULT = "claude-sonnet-5-5";
20
+ export const MERIDIAN_TERMS_URL = "https://www.anthropic.com/legal/consumer-terms";
21
+ export const MERIDIAN_GUIDE = "docs/meridian-litellm.md";
22
+ /** litellm.env value when Meridian runs without a key; LiteLLM still needs a non-empty one. */
23
+ export const MERIDIAN_NO_KEY = "placeholder";
24
+
25
+ const BRIDGE_TIMEOUT_MS = 2000;
26
+ const PROBE_TIMEOUT_MS = 5000;
27
+
28
+ /** IPv4 address of the default Docker bridge (docker0), which `host-gateway` resolves to. */
29
+ export function dockerBridgeIp() {
30
+ const nics = os.networkInterfaces().docker0 || [];
31
+ const v4 = nics.find((n) => n.family === "IPv4" || n.family === 4);
32
+ return v4 ? v4.address : "";
33
+ }
34
+
35
+ /** @param {string} base */
36
+ export function normalizeMeridianBase(base) {
37
+ return base.trim().replace(/\/+$/, "").replace(/\/v1$/, "");
38
+ }
39
+
40
+ /** @param {string} hostname */
41
+ function isLoopback(hostname) {
42
+ return hostname === "localhost" || hostname === "[::1]" || hostname.startsWith("127.");
43
+ }
44
+
45
+ /** @param {URL} u */
46
+ function portOf(u) {
47
+ return u.port || (u.protocol === "https:" ? "443" : "80");
48
+ }
49
+
50
+ /**
51
+ * The api_base the LiteLLM container uses: host.docker.internal for a loopback URL,
52
+ * otherwise the URL itself. No /v1: LiteLLM appends /v1/messages.
53
+ * @param {string} hostBase
54
+ */
55
+ export function containerApiBase(hostBase) {
56
+ const u = new URL(hostBase);
57
+ if (!isLoopback(u.hostname)) return `${u.protocol}//${u.host}`;
58
+ return `http://host.docker.internal:${portOf(u)}`;
59
+ }
60
+
61
+ /**
62
+ * First usable model: a valid candidate, else `preferred` when listed, else the first id
63
+ * matching `family`, else the first id, else `preferred`.
64
+ * @param {string[]} ids ids from Meridian's /v1/models (may be empty)
65
+ * @param {string} preferred
66
+ * @param {RegExp} family
67
+ * @param {string[]} candidates explicit or stored choices, in priority order
68
+ */
69
+ export function pickModel(ids, preferred, family, candidates = []) {
70
+ const valid = (m) => Boolean(m) && (ids.length ? ids.includes(m) : m.startsWith("claude-"));
71
+ const hit = candidates.find(valid);
72
+ if (hit) return hit;
73
+ if (!ids.length || ids.includes(preferred)) return preferred;
74
+ return ids.find((id) => family.test(id)) || ids[0];
75
+ }
76
+
77
+ /**
78
+ * Where an existing Meridian key comes from: the environment, Meridian's own env file
79
+ * (the guide's ~/.config/meridian/meridian.env), or litellm.env.
80
+ * @param {Record<string, any>} ctx
81
+ * @param {Helpers} h
82
+ * @returns {{ key: string, source: string }}
83
+ */
84
+ export function existingMeridianKey(ctx, h) {
85
+ if (process.env.MERIDIAN_API_KEY) {
86
+ return { key: process.env.MERIDIAN_API_KEY, source: "MERIDIAN_API_KEY from the environment" };
87
+ }
88
+ const fromMeridian = h.envValue(ctx.meridianEnvFile, "MERIDIAN_API_KEY");
89
+ if (fromMeridian) return { key: fromMeridian, source: ctx.meridianEnvFile };
90
+ const fromLitellm = h.envValue(ctx.litellmEnv, "MERIDIAN_API_KEY");
91
+ if (fromLitellm && fromLitellm !== MERIDIAN_NO_KEY) return { key: fromLitellm, source: ctx.litellmEnv };
92
+ return { key: "", source: "" };
93
+ }
94
+
95
+ /**
96
+ * Probe Meridian from the host: /health (open), /v1/models (needs the key when set), and
97
+ * /health on the Docker bridge address to tell a loopback-only bind from 0.0.0.0.
98
+ * @param {string} base host URL without /v1
99
+ * @param {string} key MERIDIAN_API_KEY or ""
100
+ * @param {string} bridgeIp docker0 IPv4, or "" when unknown
101
+ */
102
+ export async function probeMeridian(base, key, bridgeIp) {
103
+ const headers = key ? { "x-api-key": key } : {};
104
+ const health = await fetchStatus(`${base}/health`, { timeoutMs: PROBE_TIMEOUT_MS });
105
+ const models = await fetchStatus(`${base}/v1/models`, { headers, timeoutMs: PROBE_TIMEOUT_MS });
106
+ const u = new URL(base);
107
+ /** @type {"ok"|"unreachable"|"unknown"|"n/a"} */
108
+ let bridge = "n/a";
109
+ const answered = health.status > 0 || models.status > 0;
110
+ if (answered && isLoopback(u.hostname)) {
111
+ if (!bridgeIp) bridge = "unknown";
112
+ else {
113
+ const viaBridge = await fetchStatus(`http://${bridgeIp}:${portOf(u)}/health`, {
114
+ timeoutMs: BRIDGE_TIMEOUT_MS,
115
+ });
116
+ bridge = viaBridge.status > 0 ? "ok" : "unreachable";
117
+ }
118
+ }
119
+ return {
120
+ answered,
121
+ healthOk: health.status >= 200 && health.status < 300,
122
+ modelsStatus: models.status,
123
+ ids: models.status === 200 ? openAiModelIds(models.json) : [],
124
+ bridge,
125
+ };
126
+ }
127
+
128
+ /** @param {string[]} names @param {Helpers} h */
129
+ function showModels(names, h) {
130
+ for (const n of names) h.info(` ${n}`);
131
+ }
132
+
133
+ /**
134
+ * What the probe says about reachability from the LiteLLM container.
135
+ * @param {{ meridianBase: string, apiBase: string, dockerBridgeIp: string, meridianEnvFile: string }} ctx
136
+ * @param {(file: string, key: string) => string} envValue
137
+ * @param {Awaited<ReturnType<typeof probeMeridian>>} probe
138
+ * @returns {{ level: "ok"|"warn"|"info", text: string } | null}
139
+ */
140
+ export function bridgeReport(ctx, envValue, probe) {
141
+ const u = new URL(ctx.meridianBase);
142
+ const port = portOf(u);
143
+ const bindHint = `Restart Meridian with MERIDIAN_HOST=0.0.0.0 (${MERIDIAN_GUIDE}#run-meridian).`;
144
+ const declaredHost = process.env.MERIDIAN_HOST || envValue(ctx.meridianEnvFile, "MERIDIAN_HOST");
145
+ if (probe.bridge === "ok") {
146
+ return {
147
+ level: "ok",
148
+ text: `Meridian also answers on the Docker bridge address ${ctx.dockerBridgeIp}:${port}; LiteLLM calls ${ctx.apiBase}. A firewall can still block containers; run the guide's smoke test.`,
149
+ };
150
+ }
151
+ if (probe.bridge === "unreachable") {
152
+ return {
153
+ level: "warn",
154
+ text: `Meridian answers on ${u.host} but not on the Docker bridge address ${ctx.dockerBridgeIp}:${port}: it binds 127.0.0.1 only, so the LiteLLM container cannot reach ${ctx.apiBase}. ${bindHint}`,
155
+ };
156
+ }
157
+ if (declaredHost && isLoopback(declaredHost)) {
158
+ return {
159
+ level: "warn",
160
+ text: `MERIDIAN_HOST is ${declaredHost}: the LiteLLM container cannot reach a loopback-only Meridian. ${bindHint}`,
161
+ };
162
+ }
163
+ if (isLoopback(u.hostname)) {
164
+ return {
165
+ level: "info",
166
+ text: `LiteLLM calls Meridian at ${ctx.apiBase}. On Linux, a Meridian bound to 127.0.0.1 (its default) is unreachable from the container; run it with MERIDIAN_HOST=0.0.0.0.`,
167
+ };
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Interactive and --yes Meridian questions. Fills ctx.meridianBase, ctx.meridianKey,
174
+ * ctx.apiBase, ctx.model and ctx.reflectModel.
175
+ * @param {Record<string, any>} ctx
176
+ * @param {Helpers} h
177
+ */
178
+ export async function chooseMeridian(ctx, h) {
179
+ h.info(`The wizard does not install or start Meridian; see ${MERIDIAN_GUIDE}.`);
180
+ if (ctx.dockerBridgeIp === undefined) ctx.dockerBridgeIp = dockerBridgeIp();
181
+ const port = h.envValue(ctx.meridianEnvFile, "MERIDIAN_PORT") || MERIDIAN_DEFAULT_PORT;
182
+ const defaultBase =
183
+ process.env.MERIDIAN_BASE ||
184
+ storedOr(ctx, h, "meridian", "MEMORY_MERIDIAN_URL", `http://127.0.0.1:${port}`);
185
+ ctx.meridianBase = normalizeMeridianBase(await h.ask(ctx, "Meridian URL from this host", defaultBase));
186
+ try {
187
+ ctx.apiBase = containerApiBase(ctx.meridianBase);
188
+ } catch {
189
+ h.die(`not a URL: ${ctx.meridianBase}`);
190
+ }
191
+
192
+ const existing = existingMeridianKey(ctx, h);
193
+ const typed = await h.ask(
194
+ ctx,
195
+ `MERIDIAN_API_KEY (typed input is visible; blank uses ${existing.source || "no key"})`,
196
+ "",
197
+ );
198
+ ctx.meridianKey = typed || existing.key;
199
+
200
+ const probe = await probeMeridian(ctx.meridianBase, ctx.meridianKey, ctx.dockerBridgeIp);
201
+ if (!probe.answered) {
202
+ h.warn(
203
+ `Meridian is not answering on ${ctx.meridianBase}. Start it first (npm install -g @rynfar/meridian; claude login; meridian), see ${MERIDIAN_GUIDE}. The route uses the guide's default models.`,
204
+ );
205
+ } else {
206
+ h.ok(`Meridian answers on ${ctx.meridianBase}${probe.healthOk ? "" : " (/health not OK)"}`);
207
+ if (probe.modelsStatus === 401 || probe.modelsStatus === 403) {
208
+ h.warn(
209
+ `Meridian rejected ${ctx.meridianKey ? "the key" : "a request without a key"} on /v1/models (HTTP ${probe.modelsStatus}): set MERIDIAN_API_KEY to the key Meridian runs with.`,
210
+ );
211
+ } else if (probe.ids.length) {
212
+ h.ok(`Meridian lists ${probe.ids.length} models`);
213
+ showModels(probe.ids, h);
214
+ } else h.warn(`Meridian returned no model list (HTTP ${probe.modelsStatus}); using the guide's defaults`);
215
+ }
216
+ const bridge = bridgeReport(ctx, h.envValue, probe);
217
+ if (bridge) h[bridge.level](bridge.text);
218
+ if (!ctx.meridianKey) {
219
+ h.warn("No MERIDIAN_API_KEY: with MERIDIAN_HOST=0.0.0.0 anyone on the network can use your subscription.");
220
+ }
221
+
222
+ const retainDefault = pickModel(probe.ids, MERIDIAN_RETAIN_DEFAULT, /haiku/, [
223
+ process.env.PM_DEFAULT_MODEL,
224
+ storedOr(ctx, h, "meridian", "MEMORY_MODEL", ""),
225
+ ]);
226
+ const reflectDefault = pickModel(probe.ids, MERIDIAN_REFLECT_DEFAULT, /sonnet/, [
227
+ process.env.PM_REFLECT_MODEL,
228
+ storedOr(ctx, h, "meridian", "MEMORY_REFLECT_MODEL", ""),
229
+ ]);
230
+ ctx.model = await h.ask(ctx, "Meridian retain / consolidate model", retainDefault);
231
+ ctx.reflectModel = await h.ask(ctx, "Meridian reflect model (needs tool calls)", reflectDefault);
232
+ for (const m of [ctx.model, ctx.reflectModel]) {
233
+ if (probe.ids.length && !probe.ids.includes(m)) h.warn(`${m} is not listed by Meridian's /v1/models`);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * One alias of the guide's route file.
239
+ * @param {string} alias
240
+ * @param {string} model
241
+ * @param {string} apiBase
242
+ */
243
+ function renderAlias(alias, model, apiBase) {
244
+ return (
245
+ ` - model_name: ${alias}\n` +
246
+ " litellm_params:\n" +
247
+ ` model: anthropic/${model}\n` +
248
+ ` api_base: ${apiBase}\n` +
249
+ " api_key: os.environ/MERIDIAN_API_KEY\n" +
250
+ " max_tokens: 4096\n" +
251
+ " timeout: 300\n" +
252
+ ' additional_drop_params: ["reasoning_effort"]\n'
253
+ );
254
+ }
255
+
256
+ /**
257
+ * The route file from docs/meridian-litellm.md#route-file. The key stays in litellm.env.
258
+ * @param {Record<string, any>} ctx
259
+ */
260
+ export function renderMeridianRoutes(ctx) {
261
+ const reflect = ctx.reflectModel || ctx.model;
262
+ return (
263
+ "# Written by ./setup: LiteLLM routes for Hindsight, backend Meridian on the host (Claude subscription).\n" +
264
+ `# See ${MERIDIAN_GUIDE}. MERIDIAN_API_KEY lives in ~/.config/hindsight/litellm.env.\n` +
265
+ "# No thinking parameter: Meridian rejects thinking: disabled on Haiku 5.5, so reasoning_effort is dropped.\n" +
266
+ "model_list:\n" +
267
+ renderAlias("memory-retain", ctx.model, ctx.apiBase) +
268
+ renderAlias("memory-reflect", reflect, ctx.apiBase) +
269
+ renderAlias("memory-consolidate", ctx.model, ctx.apiBase) +
270
+ "\ngeneral_settings:\n master_key: os.environ/LITELLM_MASTER_KEY\n"
271
+ );
272
+ }
273
+
274
+ /**
275
+ * Set MERIDIAN_API_KEY in litellm.env (mode 600), creating the file with a master key
276
+ * when it does not exist yet. Never prints the value.
277
+ * @param {Record<string, any>} ctx
278
+ * @param {Helpers} h
279
+ */
280
+ export function writeMeridianKey(ctx, h) {
281
+ const value = ctx.meridianKey || h.envValue(ctx.litellmEnv, "MERIDIAN_API_KEY") || MERIDIAN_NO_KEY;
282
+ if (/[\r\n]/.test(value)) h.die("MERIDIAN_API_KEY must be one line");
283
+ if (ctx.dry) {
284
+ h.info(`[dry-run] set MERIDIAN_API_KEY in ${ctx.litellmEnv} (mode 600; value not printed)`);
285
+ return;
286
+ }
287
+ h.ensureConfigDir(ctx);
288
+ ensureLitellmEnv(ctx.litellmEnv);
289
+ const lines = fs.readFileSync(ctx.litellmEnv, "utf8").replace(/\n$/, "").split("\n");
290
+ const line = `MERIDIAN_API_KEY=${value}`;
291
+ const at = lines.findIndex((l) => l.startsWith("MERIDIAN_API_KEY="));
292
+ if (at >= 0 && lines[at] === line) {
293
+ fs.chmodSync(ctx.litellmEnv, 0o600);
294
+ h.ok(`${ctx.litellmEnv} already has MERIDIAN_API_KEY`);
295
+ return;
296
+ }
297
+ if (at >= 0) lines[at] = line;
298
+ else {
299
+ if (value === MERIDIAN_NO_KEY) lines.push("# Meridian runs without MERIDIAN_API_KEY; LiteLLM needs a non-empty value.");
300
+ lines.push(line);
301
+ }
302
+ const tmp = path.join(path.dirname(ctx.litellmEnv), `.litellm.env.${process.pid}`);
303
+ fs.writeFileSync(tmp, `${lines.join("\n")}\n`, { mode: 0o600 });
304
+ fs.chmodSync(tmp, 0o600);
305
+ fs.renameSync(tmp, ctx.litellmEnv);
306
+ h.ok(`set MERIDIAN_API_KEY in ${ctx.litellmEnv} (mode 600)`);
307
+ }
@@ -1,9 +1,18 @@
1
1
  /**
2
2
  * Hindsight model routing: how the LiteLLM proxy in front of Hindsight reaches an LLM
3
- * (lemond, Ollama or a custom OpenAI-compatible URL), plus the reflect-model question and
3
+ * (lemond, Ollama, a custom OpenAI-compatible URL or Meridian), plus the reflect-model question and
4
4
  * `lemonade pull`. The meta wizard knows none of this; it calls configureModelRouting.
5
5
  */
6
6
  import { applyRoute, litellmConfig, saveConf } from "./litellm-routes.mjs";
7
+ import {
8
+ chooseMeridian,
9
+ containerApiBase,
10
+ existingMeridianKey,
11
+ MERIDIAN_DEFAULT_BASE,
12
+ MERIDIAN_REFLECT_DEFAULT,
13
+ normalizeMeridianBase,
14
+ writeMeridianKey,
15
+ } from "./meridian.mjs";
7
16
  import { ollamaModelNames, openAiModelIds } from "./probe.mjs";
8
17
  import {
9
18
  backendDefault,
@@ -26,9 +35,9 @@ async function pickBackend(ctx, h) {
26
35
  return;
27
36
  }
28
37
  h.info("Pick a backend first; only that server is contacted.");
29
- h.info("1) lemond 2) ollama 3) custom OpenAI-compatible URL");
30
- const defNum = def === "lemond" ? "1" : def === "ollama" ? "2" : "3";
31
- const choice = await h.ask(ctx, "Model backend (1-3)", defNum);
38
+ h.info("1) lemond 2) ollama 3) custom OpenAI-compatible URL 4) Claude subscription (Meridian)");
39
+ const defNum = { lemond: "1", ollama: "2", custom: "3", meridian: "4" }[def] || "1";
40
+ const choice = await h.ask(ctx, "Model backend (1-4)", defNum);
32
41
  switch (choice) {
33
42
  case "1":
34
43
  ctx.backend = "lemond";
@@ -39,8 +48,11 @@ async function pickBackend(ctx, h) {
39
48
  case "3":
40
49
  ctx.backend = "custom";
41
50
  break;
51
+ case "4":
52
+ ctx.backend = "meridian";
53
+ break;
42
54
  default:
43
- h.die("choose 1, 2 or 3");
55
+ h.die("choose 1, 2, 3 or 4");
44
56
  }
45
57
  }
46
58
 
@@ -125,7 +137,12 @@ async function chooseOllama(ctx, h) {
125
137
  "MEMORY_MODEL",
126
138
  process.env.PM_DEFAULT_MODEL || ctx.ollamaDefaultModel,
127
139
  );
128
- if (ctx.ollamaJson) {
140
+ // Compose Ollama publishes 127.0.0.1:11434, so a saved compose choice must win over the host probe.
141
+ if (storedOr(ctx, h, "ollama", "MEMORY_OLLAMA_IN_COMPOSE", "0") === "1") {
142
+ ctx.ollamaInCompose = true;
143
+ ctx.apiBase = OLLAMA_COMPOSE_API_BASE;
144
+ h.info("Keeping Ollama in Docker (compose profile ollama); LiteLLM calls http://ollama:11434.");
145
+ } else if (ctx.ollamaJson) {
129
146
  ctx.ollamaInCompose = false;
130
147
  ctx.apiBase = OLLAMA_HOST_API_BASE;
131
148
  } else {
@@ -150,17 +167,18 @@ async function chooseCustom(ctx, h) {
150
167
  const defaultBase = storedOr(ctx, h, "custom", "MEMORY_API_BASE", ctx.customDefaultBase);
151
168
  const defaultModel = storedOr(ctx, h, "custom", "MEMORY_MODEL", ctx.lemondDefaultModel);
152
169
  h.info("The litellm container calls this URL. Host services use host.docker.internal.");
153
- h.info("Examples: lemond http://host.docker.internal:13305/v1 ; Meridian http://host.docker.internal:3456/v1");
170
+ h.info("Example: lemond http://host.docker.internal:13305/v1. For a Claude subscription through Meridian, pick backend 4.");
154
171
  ctx.apiBase = await h.ask(ctx, "OpenAI-compatible base URL", defaultBase);
155
172
  ctx.model = await h.ask(ctx, "Model id the server serves", defaultModel);
156
173
  await askReflectModel(ctx, h);
157
174
  }
158
175
 
159
176
  /**
160
- * Step "3. Model backend": choose lemond, Ollama or a custom URL, write the LiteLLM route
177
+ * Step "3. Model backend": choose lemond, Ollama, a custom URL or Meridian, write the LiteLLM route
161
178
  * override when the choice differs from the shipped routes, and record it in wizard.conf.
162
179
  * With the Clack UI done (ctx.skipBackendStep) the answers come from PM_BACKEND,
163
- * PM_DEFAULT_MODEL, MEMORY_API_BASE and PM_REFLECT_MODEL instead.
180
+ * PM_DEFAULT_MODEL, MEMORY_API_BASE and PM_REFLECT_MODEL instead (Meridian adds
181
+ * MERIDIAN_BASE and MERIDIAN_API_KEY).
164
182
  *
165
183
  * @param {Record<string, any>} ctx
166
184
  * @param {Helpers} h
@@ -179,8 +197,15 @@ export async function chooseModelBackend(ctx, h) {
179
197
  if (ctx.backend === "ollama" && !ctx.apiBase) {
180
198
  ctx.apiBase = ctx.ollamaInCompose ? OLLAMA_COMPOSE_API_BASE : OLLAMA_HOST_API_BASE;
181
199
  }
200
+ if (ctx.backend === "meridian") {
201
+ ctx.meridianBase = normalizeMeridianBase(process.env.MERIDIAN_BASE || MERIDIAN_DEFAULT_BASE);
202
+ ctx.apiBase ||= containerApiBase(ctx.meridianBase);
203
+ ctx.reflectModel ||= MERIDIAN_REFLECT_DEFAULT;
204
+ ctx.meridianKey = existingMeridianKey(ctx, h).key;
205
+ }
182
206
  h.ok(`backend ${ctx.backend}, model ${ctx.model} (setup UI)`);
183
207
  await applyRoute(ctx, h);
208
+ if (ctx.backend === "meridian") writeMeridianKey(ctx, h);
184
209
  saveConf(ctx, h);
185
210
  return;
186
211
  }
@@ -205,10 +230,14 @@ export async function chooseModelBackend(ctx, h) {
205
230
  case "custom":
206
231
  await chooseCustom(ctx, h);
207
232
  break;
233
+ case "meridian":
234
+ await chooseMeridian(ctx, h);
235
+ break;
208
236
  default:
209
237
  h.die(`unknown backend ${ctx.backend}`);
210
238
  }
211
239
  h.ok(`backend ${ctx.backend}, model ${ctx.model}`);
212
240
  await applyRoute(ctx, h);
241
+ if (ctx.backend === "meridian") writeMeridianKey(ctx, h);
213
242
  saveConf(ctx, h);
214
243
  }
@@ -31,3 +31,24 @@ export function normalizeOpenAiBase(base) {
31
31
  if (!b.endsWith("/v1")) b += "/v1";
32
32
  return b;
33
33
  }
34
+
35
+ /**
36
+ * GET `url` and report the HTTP status (0 when nothing answered) and the parsed JSON body.
37
+ * @param {string} url
38
+ * @param {{ headers?: Record<string, string>, timeoutMs?: number }} [opts]
39
+ * @returns {Promise<{ status: number, json: any }>}
40
+ */
41
+ export async function fetchStatus(url, { headers = {}, timeoutMs = TIMEOUT_MS } = {}) {
42
+ try {
43
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
44
+ let json = null;
45
+ try {
46
+ json = await res.json();
47
+ } catch {
48
+ json = null;
49
+ }
50
+ return { status: res.status, json };
51
+ } catch {
52
+ return { status: 0, json: null };
53
+ }
54
+ }
@@ -4,11 +4,14 @@
4
4
  * Runs as a plain Node script step (no wizard context), so it only reads process
5
5
  * environment and the paths it is given.
6
6
  */
7
- import crypto from "node:crypto";
8
7
  import fs from "node:fs";
9
8
  import path from "node:path";
9
+ import { ensureLitellmEnv } from "../../../wizard/lib/litellm-env.mjs";
10
10
  import { envValue } from "../../../wizard/lib/paths.mjs";
11
11
 
12
+ // Moved to wizard/lib/litellm-env.mjs (mem0 shares it); re-exported for existing importers.
13
+ export { ensureLitellmEnv };
14
+
12
15
  /**
13
16
  * @param {{ repo: string, home: string, env: NodeJS.ProcessEnv }} opts
14
17
  * @returns {{ entries: [string, string][], drop: string[], report: string[] }}
@@ -25,17 +28,7 @@ export function prepareStackEnv({ repo, home, env }) {
25
28
  throw new Error(`MEMORY_BACKEND must be lemond or ollama (got: ${backend})`);
26
29
  }
27
30
 
28
- if (!fs.existsSync(litellmEnv)) {
29
- const key = crypto.randomBytes(24).toString("hex");
30
- const body =
31
- "# LiteLLM proxy secrets. Read by the litellm compose service. Do not commit.\n" +
32
- `LITELLM_MASTER_KEY=sk-${key}\n` +
33
- "# lemond ignores the key; any non-empty value works.\n" +
34
- "LEMOND_API_KEY=placeholder\n" +
35
- "# Hosted models: add the provider key here, e.g. ANTHROPIC_API_KEY=..., and reference it as os.environ/ANTHROPIC_API_KEY.\n";
36
- fs.writeFileSync(litellmEnv, body, { mode: 0o600 });
37
- console.log(`created ${litellmEnv} (mode 600)`);
38
- }
31
+ if (ensureLitellmEnv(litellmEnv)) console.log(`created ${litellmEnv} (mode 600)`);
39
32
 
40
33
  const litellmConfig = fs.existsSync(overrideConfig)
41
34
  ? overrideConfig
@@ -9,7 +9,7 @@ export const OLLAMA_HOST_API_BASE = "http://host.docker.internal:11434";
9
9
  /** LiteLLM api_base when Ollama is the compose `ollama` service. */
10
10
  export const OLLAMA_COMPOSE_API_BASE = "http://ollama:11434";
11
11
 
12
- export const BACKENDS = ["lemond", "ollama", "custom"];
12
+ export const BACKENDS = ["lemond", "ollama", "custom", "meridian"];
13
13
 
14
14
  /**
15
15
  * Fill the Hindsight fields of the wizard context. Idempotent: a field that is already
@@ -23,6 +23,8 @@ export function ensureState(ctx) {
23
23
  litellmEnv: path.join(hs, "litellm.env"),
24
24
  litellmOverride: path.join(hs, "litellm.yaml"),
25
25
  agentJson: path.join(ctx.home || "", ".hindsight/coding-agent.json"),
26
+ /** Meridian's own env file from docs/meridian-litellm.md (read for MERIDIAN_API_KEY/HOST/PORT). */
27
+ meridianEnvFile: path.join(ctx.home || "", ".config/meridian/meridian.env"),
26
28
  lemondBase: "http://127.0.0.1:13305",
27
29
  ollamaBase: "http://127.0.0.1:11434",
28
30
  lemondDefaultModel: LEMOND_DEFAULT_MODEL,
@@ -41,6 +43,9 @@ export function ensureState(ctx) {
41
43
  portApi: "",
42
44
  portCp: "",
43
45
  hindsightUrl: "",
46
+ /** Meridian URL as seen from the host, and the key for litellm.env (backend meridian). */
47
+ meridianBase: "",
48
+ meridianKey: "",
44
49
  };
45
50
  for (const [key, value] of Object.entries(defaults)) {
46
51
  if (ctx[key] === undefined) ctx[key] = value;
@@ -1,6 +1,7 @@
1
1
  /** Hindsight's contribution to the wizard's closing summary. */
2
2
  import fs from "node:fs";
3
3
  import { composeCommandText } from "../../../wizard/lib/compose.mjs";
4
+ import { MERIDIAN_GUIDE, MERIDIAN_TERMS_URL } from "./meridian.mjs";
4
5
  import { ensureState } from "./state.mjs";
5
6
 
6
7
  /**
@@ -19,14 +20,20 @@ export function summary(ctx) {
19
20
  ` control plane http://127.0.0.1:${ctx.portCp}`,
20
21
  ` model ${ctx.backend}, ${ctx.model} via ${modelWhere}`,
21
22
  ];
23
+ if (ctx.backend === "meridian") {
24
+ running.push(` reflect model ${ctx.reflectModel || ctx.model}`);
25
+ running.push(
26
+ ` Meridian ${ctx.meridianBase || "(host URL not recorded)"} on the host (not managed by setup; LiteLLM calls ${ctx.apiBase})`,
27
+ );
28
+ }
22
29
  if (ctx.ollamaInCompose) {
23
30
  running.push(` Ollama http://127.0.0.1:11434 (compose service; LiteLLM uses http://ollama:11434)`);
24
31
  }
25
32
  const configFiles = [` ${ctx.envFile}`];
26
33
  if (fs.existsSync(ctx.litellmOverride)) {
27
34
  configFiles.push(` ${ctx.litellmOverride} (model routes; replaces config/litellm.*.yaml)`);
28
- } else if (ctx.backend === "custom") {
29
- configFiles.push(` ${ctx.litellmOverride} (missing: custom backend needs it)`);
35
+ } else if (ctx.backend === "custom" || ctx.backend === "meridian") {
36
+ configFiles.push(` ${ctx.litellmOverride} (missing: ${ctx.backend} backend needs it)`);
30
37
  } else configFiles.push(` config/litellm.${ctx.backend}.yaml (shipped routes)`);
31
38
  const litellmUp = composeCommandText(ctx, "up", "-d", "litellm");
32
39
  const notes = [
@@ -34,6 +41,13 @@ export function summary(ctx) {
34
41
  `Reflect needs a capable model (memory-reflect alias). On a weak or remote LLM, edit ${ctx.litellmOverride} and point memory-reflect at Meridian, an OpenAI-compatible host, or a stronger local model — then: ${litellmUp}`,
35
42
  `Change later: rerun ./setup. For a route-only edit, run: ${litellmUp}`,
36
43
  ];
44
+ if (ctx.backend === "meridian") {
45
+ configFiles.push(` ${ctx.litellmEnv} (MERIDIAN_API_KEY)`);
46
+ notes.push(
47
+ `Meridian must keep running on this host with MERIDIAN_HOST=0.0.0.0 so the LiteLLM container reaches it; see ${MERIDIAN_GUIDE}.`,
48
+ `Claude subscription use through Meridian is governed by Anthropic's Consumer Terms: ${MERIDIAN_TERMS_URL}`,
49
+ );
50
+ }
37
51
  if (ctx.smokeFailed) {
38
52
  notes.unshift(
39
53
  `Smoke retain/recall did not finish — stack is up; fix the model/LLM queue via http://127.0.0.1:${ctx.portCp} then re-run setup or: curl the wizard-smoke retain against ${ctx.hindsightUrl}`,
@@ -69,6 +69,12 @@ export function validateHindsight(ctx, h) {
69
69
  const model = conf("MEMORY_MODEL");
70
70
  const shipped = backend === "ollama" ? ctx.ollamaDefaultModel : ctx.lemondDefaultModel;
71
71
  const reflect = conf("MEMORY_REFLECT_MODEL");
72
+ if (backend === "meridian") {
73
+ if (!fs.existsSync(ctx.litellmOverride)) errors.push("meridian backend without litellm.yaml override");
74
+ if (!h.envValue(ctx.litellmEnv, "MERIDIAN_API_KEY")) errors.push("litellm.env missing MERIDIAN_API_KEY");
75
+ checks.push("Meridian route and key");
76
+ return;
77
+ }
72
78
  if (backend !== "custom" && model !== shipped && !reflect && !fs.existsSync(ctx.litellmOverride)) {
73
79
  errors.push("custom model without litellm.yaml override");
74
80
  }
@@ -44,8 +44,8 @@ export async function afterProvider(ctx, h) {
44
44
  }
45
45
 
46
46
  /**
47
- * Step "3. Model backend": lemond, Ollama or a custom OpenAI-compatible URL behind the
48
- * LiteLLM proxy.
47
+ * Step "3. Model backend": lemond, Ollama, a custom OpenAI-compatible URL or a Claude
48
+ * subscription through Meridian, behind the LiteLLM proxy.
49
49
  * @param {Record<string, any>} ctx
50
50
  * @param {Helpers} h
51
51
  */
@@ -78,6 +78,7 @@ export async function hydrate(ctx, h) {
78
78
  ctx.model = confGet(ctx, h, "MEMORY_MODEL") || ctx.lemondDefaultModel;
79
79
  ctx.apiBase = confGet(ctx, h, "MEMORY_API_BASE") || "";
80
80
  ctx.reflectModel = confGet(ctx, h, "MEMORY_REFLECT_MODEL") || "";
81
+ ctx.meridianBase = confGet(ctx, h, "MEMORY_MERIDIAN_URL") || "";
81
82
  ctx.ollamaInCompose = confGet(ctx, h, "MEMORY_OLLAMA_IN_COMPOSE") === "1";
82
83
  applyComposeProfiles(ctx);
83
84
  }
@@ -16,6 +16,7 @@ server only (no `/v1/` prefix).
16
16
  | Embedder / LLM keys for mem0 itself | Indexer sink (`MEMORY_SINK=mem0`) and host-gateway rewrite |
17
17
  | Create an API key (dashboard or `POST /api-keys`) | Store `MEM0_API_KEY` in `~/.config/personal-memory/mem0.env` (mode **600**) |
18
18
  | Optional `AUTH_DISABLED=true` (dev only) | Smoke: `GET /docs`, retain, search, delete-by-id |
19
+ | Apply the printed `llm` / `embedder` blocks to mem0 (optional) | Optional stack LiteLLM for mem0's LLM: routes, `litellm.env`, profile `litellm` on `127.0.0.1` |
19
20
 
20
21
  ## URLs
21
22
 
@@ -76,7 +77,7 @@ via the launcher `cli/mem0-mcp.mjs` (loads the key from the env file, then
76
77
 
77
78
  ```sh
78
79
  MEMORY_PROVIDER=mem0 npx openmem install-mcp --yes
79
- # checkout: MEMORY_PROVIDER=mem0 npx . install-mcp --yes
80
+ # checkout: MEMORY_PROVIDER=mem0 node cli/openmem.mjs install-mcp --yes
80
81
  # or accept the coding-agent MCP confirm in setup
81
82
  ```
82
83
 
@@ -111,14 +112,32 @@ Restart Cursor Agent after install.
111
112
  ```sh
112
113
  npx openmem switch mem0 --yes
113
114
  # later: npx openmem switch hindsight --yes
114
- # checkout: npx . switch mem0 --yes
115
+ # checkout: node cli/openmem.mjs switch mem0 --yes
115
116
  ```
116
117
 
117
118
  Updates `wizard.conf` (and `config.toml` `[memory].provider` when present), restarts the stack
118
119
  unless `--no-stack`, and runs `install-mcp --provider-only` unless `--no-mcp`. Does **not**
119
120
  migrate memories. Flags: [README — Switch memory provider](../../README.md#switch-memory-provider).
120
121
 
122
+ ## Models through the stack LiteLLM
123
+
124
+ Optional (wizard step 3, or `MEM0_LITELLM=1` under `--yes`): mem0's LLM, and
125
+ its embedder when the backend has one, go through the stack LiteLLM. Inputs
126
+ under `--yes`: `MEM0_LITELLM_BACKEND` (`ollama`, `custom`, `anthropic`,
127
+ `meridian`), `MEM0_LITELLM_MODEL`, `MEM0_LITELLM_API_BASE`,
128
+ `MEM0_LITELLM_EMBED_MODEL` (`none` keeps mem0's embedder),
129
+ `MEM0_LITELLM_PORT` (default 4000), `ANTHROPIC_API_KEY`. The wizard writes
130
+ `~/.config/personal-memory/mem0-litellm.yaml`, keeps keys in
131
+ `~/.config/hindsight/litellm.env` (mode 600), and prints the mem0 `llm` /
132
+ `embedder` blocks (provider `openai`, `openai_base_url`
133
+ `http://127.0.0.1:<port>/v1`, `api_key` = `LITELLM_MASTER_KEY`). You apply
134
+ them; the wizard never edits the mem0 server config. Key sources and limits:
135
+ [docs/mem0.md](../../docs/mem0.md#models-through-the-stack-litellm-optional).
136
+
121
137
  ## Compose
122
138
 
123
139
  `providers/mem0/compose.yaml` defines no mem0 service — only env +
124
140
  `extra_hosts` on the shared `indexer`. Profile `mem0` selects that fragment.
141
+ It also overlays the base `litellm` service: under profile `litellm` it
142
+ publishes `127.0.0.1:${MEM0_LITELLM_PORT:-4000}` (never `0.0.0.0`) and its
143
+ entrypoint exits unless `LITELLM_MASTER_KEY` starts with `sk-`.