@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
@@ -1,10 +1,24 @@
1
1
  /**
2
- * Hindsight's questions for the Clack setup UI: model backend, models, reflect model.
2
+ * Hindsight's questions for the Clack setup UI: model backend, models, reflect model
3
+ * (or, for Meridian, its URL, key and the two Claude models).
3
4
  * The meta UI asks the provider-neutral questions (action, provider) and then calls
4
5
  * clackSetup; the returned environment is handed to the wizard so it skips its own
5
6
  * model prompts (WIZARD_SKIP_BACKEND_STEP).
6
7
  */
7
8
  import { applyConfigEnv } from "./config.mjs";
9
+ import {
10
+ bridgeReport,
11
+ containerApiBase,
12
+ dockerBridgeIp,
13
+ existingMeridianKey,
14
+ MERIDIAN_DEFAULT_BASE,
15
+ MERIDIAN_GUIDE,
16
+ MERIDIAN_REFLECT_DEFAULT,
17
+ MERIDIAN_RETAIN_DEFAULT,
18
+ normalizeMeridianBase,
19
+ pickModel,
20
+ probeMeridian,
21
+ } from "./meridian.mjs";
8
22
  import { fetchJson, normalizeOpenAiBase, ollamaModelNames, openAiModelIds } from "./probe.mjs";
9
23
  import {
10
24
  LEMOND_DEFAULT_MODEL,
@@ -51,10 +65,15 @@ export async function clackSetup(ctx, ui, h) {
51
65
  {
52
66
  value: "custom",
53
67
  label: "OpenAI-compatible URL",
54
- hint: "Meridian, hosted API, other",
68
+ hint: "hosted API, other",
69
+ },
70
+ {
71
+ value: "meridian",
72
+ label: "Claude subscription (Meridian)",
73
+ hint: "a Meridian proxy you already run on this host",
55
74
  },
56
75
  ],
57
- initialValue: ["lemond", "ollama", "custom"].includes(backendDefault)
76
+ initialValue: ["lemond", "ollama", "custom", "meridian"].includes(backendDefault)
58
77
  ? backendDefault
59
78
  : "lemond",
60
79
  });
@@ -67,7 +86,6 @@ export async function clackSetup(ctx, ui, h) {
67
86
  let apiBase = "";
68
87
  let reflectModel =
69
88
  process.env.PM_REFLECT_MODEL ||
70
- machine.PM_REFLECT_MODEL ||
71
89
  h.envValue(wizardConf, "MEMORY_REFLECT_MODEL") ||
72
90
  "";
73
91
 
@@ -85,8 +103,6 @@ export async function clackSetup(ctx, ui, h) {
85
103
  );
86
104
  }
87
105
  const defaultModel =
88
- machine.PM_DEFAULT_MODEL ||
89
- machine.MEMORY_MODEL ||
90
106
  h.envValue(wizardConf, "MEMORY_MODEL") ||
91
107
  LEMOND_DEFAULT_MODEL;
92
108
  if (ids.length > 0) {
@@ -108,7 +124,7 @@ export async function clackSetup(ctx, ui, h) {
108
124
  }
109
125
  apiBase = "http://host.docker.internal:13305/v1";
110
126
  } else if (backend === "ollama") {
111
- const ollamaBase = machine.OLLAMA_BASE || "http://127.0.0.1:11434";
127
+ const ollamaBase = "http://127.0.0.1:11434";
112
128
  const spin = ui.spinner();
113
129
  spin.start(`Checking Ollama at ${ollamaBase}`);
114
130
  const body = await fetchJson(`${ollamaBase}/api/tags`);
@@ -137,8 +153,6 @@ export async function clackSetup(ctx, ui, h) {
137
153
  }
138
154
  }
139
155
  const defaultModel =
140
- machine.PM_DEFAULT_MODEL ||
141
- machine.MEMORY_MODEL ||
142
156
  h.envValue(wizardConf, "MEMORY_MODEL") ||
143
157
  OLLAMA_DEFAULT_MODEL;
144
158
  if (names.length > 0) {
@@ -160,12 +174,15 @@ export async function clackSetup(ctx, ui, h) {
160
174
  }
161
175
  apiBase = ollamaInCompose ? OLLAMA_COMPOSE_API_BASE : OLLAMA_HOST_API_BASE;
162
176
  if (ollamaInCompose) handoff.MEMORY_OLLAMA_IN_COMPOSE = "1";
177
+ } else if (backend === "meridian") {
178
+ const m = await askMeridian(ctx, ui, h);
179
+ model = m.model;
180
+ reflectModel = m.reflectModel;
181
+ apiBase = m.apiBase;
182
+ Object.assign(handoff, m.env);
163
183
  } else {
164
- const defaultBase =
165
- machine.CUSTOM_DEFAULT_BASE || "http://host.docker.internal:3456/v1";
184
+ const defaultBase = "http://host.docker.internal:3456/v1";
166
185
  const defaultModel =
167
- machine.CUSTOM_DEFAULT_MODEL ||
168
- machine.PM_DEFAULT_MODEL ||
169
186
  h.envValue(wizardConf, "MEMORY_MODEL") ||
170
187
  LEMOND_DEFAULT_MODEL;
171
188
  const baseInput = await ui.text({
@@ -205,6 +222,24 @@ export async function clackSetup(ctx, ui, h) {
205
222
  }
206
223
  }
207
224
 
225
+ if (backend !== "meridian") reflectModel = await askReflect(ui, model, reflectModel);
226
+
227
+ ui.note("Next: Docker, secrets, indexing, and starting the stack.", "Backend configured");
228
+
229
+ handoff.WIZARD_SKIP_BACKEND_STEP = "1";
230
+ handoff.PM_BACKEND = /** @type {string} */ (backend);
231
+ handoff.PM_DEFAULT_MODEL = model;
232
+ handoff.MEMORY_API_BASE = apiBase;
233
+ handoff.PM_REFLECT_MODEL = reflectModel;
234
+ return handoff;
235
+ }
236
+
237
+ /**
238
+ * @param {typeof import("@clack/prompts")} ui
239
+ * @param {string} model retain model
240
+ * @param {string} reflectModel stored or env reflect model
241
+ */
242
+ async function askReflect(ui, model, reflectModel) {
208
243
  const reflectChoice = await ui.select({
209
244
  message: "Reflect model (needs tool calls; used for memory-reflect)",
210
245
  options: [
@@ -233,13 +268,80 @@ export async function clackSetup(ctx, ui, h) {
233
268
  } else {
234
269
  reflectModel = "";
235
270
  }
271
+ return reflectModel;
272
+ }
236
273
 
237
- ui.note("Next: Docker, secrets, indexing, and starting the stack.", "Backend configured");
274
+ /**
275
+ * Meridian URL, key and models. Detect only: Meridian must already run.
276
+ * @param {{ home: string, wizardConf: string }} ctx
277
+ * @param {typeof import("@clack/prompts")} ui
278
+ * @param {Helpers} h
279
+ */
280
+ async function askMeridian(ctx, ui, h) {
281
+ ui.log.info(`The wizard does not install or start Meridian; see ${MERIDIAN_GUIDE}.`);
282
+ const stored =
283
+ h.envValue(ctx.wizardConf, "MEMORY_BACKEND") === "meridian"
284
+ ? h.envValue(ctx.wizardConf, "MEMORY_MERIDIAN_URL")
285
+ : "";
286
+ const defaultBase = process.env.MERIDIAN_BASE || stored || MERIDIAN_DEFAULT_BASE;
287
+ const baseInput = await ui.text({
288
+ message: "Meridian URL from this host",
289
+ placeholder: defaultBase,
290
+ defaultValue: defaultBase,
291
+ });
292
+ if (ui.isCancel(baseInput)) process.exit(0);
293
+ const meridianBase = normalizeMeridianBase(/** @type {string} */ (baseInput));
294
+ const paths = {
295
+ meridianEnvFile: `${ctx.home}/.config/meridian/meridian.env`,
296
+ litellmEnv: `${ctx.home}/.config/hindsight/litellm.env`,
297
+ };
298
+ const existing = existingMeridianKey(paths, h);
299
+ const keyInput = await ui.password({
300
+ message: `MERIDIAN_API_KEY (blank uses ${existing.source || "no key"})`,
301
+ });
302
+ if (ui.isCancel(keyInput)) process.exit(0);
303
+ const typedKey = String(keyInput || "").trim();
304
+ const key = typedKey || existing.key;
238
305
 
239
- handoff.WIZARD_SKIP_BACKEND_STEP = "1";
240
- handoff.PM_BACKEND = /** @type {string} */ (backend);
241
- handoff.PM_DEFAULT_MODEL = model;
242
- handoff.MEMORY_API_BASE = apiBase;
243
- handoff.PM_REFLECT_MODEL = reflectModel;
244
- return handoff;
306
+ const bridgeIp = dockerBridgeIp();
307
+ const spin = ui.spinner();
308
+ spin.start(`Checking Meridian at ${meridianBase}`);
309
+ const probe = await probeMeridian(meridianBase, key, bridgeIp);
310
+ spin.stop(
311
+ probe.answered ? `Meridian answered (${probe.ids.length} models)` : "Meridian not reachable",
312
+ );
313
+ if (!probe.answered) {
314
+ ui.log.warn(`Start Meridian first (${MERIDIAN_GUIDE}); the route uses the guide's default models.`);
315
+ } else if (probe.modelsStatus === 401 || probe.modelsStatus === 403) {
316
+ ui.log.warn(`Meridian rejected the key on /v1/models (HTTP ${probe.modelsStatus}).`);
317
+ }
318
+ const apiBase = containerApiBase(meridianBase);
319
+ const report = bridgeReport({ ...paths, meridianBase, apiBase, dockerBridgeIp: bridgeIp }, h.envValue, probe);
320
+ if (report) (report.level === "warn" ? ui.log.warn : ui.log.info)(report.text);
321
+
322
+ const choose = async (message, preferred, family, candidates) => {
323
+ const def = pickModel(probe.ids, preferred, family, candidates);
324
+ const picked = probe.ids.length
325
+ ? await ui.select({
326
+ message,
327
+ options: probe.ids.map((id) => ({ value: id, label: id })),
328
+ initialValue: def,
329
+ })
330
+ : await ui.text({ message, placeholder: def, defaultValue: def });
331
+ if (ui.isCancel(picked)) process.exit(0);
332
+ return /** @type {string} */ (picked);
333
+ };
334
+ const sameBackend = h.envValue(ctx.wizardConf, "MEMORY_BACKEND") === "meridian";
335
+ const model = await choose("Retain / consolidate model", MERIDIAN_RETAIN_DEFAULT, /haiku/, [
336
+ process.env.PM_DEFAULT_MODEL,
337
+ sameBackend ? h.envValue(ctx.wizardConf, "MEMORY_MODEL") : "",
338
+ ]);
339
+ const reflectModel = await choose("Reflect model (needs tool calls)", MERIDIAN_REFLECT_DEFAULT, /sonnet/, [
340
+ process.env.PM_REFLECT_MODEL,
341
+ sameBackend ? h.envValue(ctx.wizardConf, "MEMORY_REFLECT_MODEL") : "",
342
+ ]);
343
+ /** @type {Record<string, string>} */
344
+ const env = { MERIDIAN_BASE: meridianBase };
345
+ if (typedKey) env.MERIDIAN_API_KEY = typedKey;
346
+ return { model, reflectModel, apiBase, env };
245
347
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Step "8. Coding-agent plugin", Hindsight half: ensure ~/.hindsight/coding-agent.json
2
+ * Step "9. Coding-agent plugin", Hindsight half: ensure ~/.hindsight/coding-agent.json
3
3
  * exists (seeded from the tracked template when missing) and check that its optInPaths
4
4
  * cover the indexed roots. Machine-local keys such as notebooklmAccess must live in a
5
5
  * real file — never in the repo template — so we do not replace a materialised config
@@ -12,6 +12,7 @@ export function defaultsFromConfig(cfg) {
12
12
  const model = /** @type {Record<string, unknown>} */ (cfg.model || {});
13
13
  const lemond = /** @type {Record<string, unknown>} */ (model.lemond || {});
14
14
  const ollama = /** @type {Record<string, unknown>} */ (model.ollama || {});
15
+ const meridian = /** @type {Record<string, unknown>} */ (model.meridian || {});
15
16
  return {
16
17
  apiPort: stack.api_port != null ? String(stack.api_port) : "",
17
18
  cpPort: stack.control_plane_port != null ? String(stack.control_plane_port) : "",
@@ -21,6 +22,7 @@ export function defaultsFromConfig(cfg) {
21
22
  modelApiBase: String(model.api_base || ""),
22
23
  lemondHost: String(lemond.host || ""),
23
24
  ollamaHost: String(ollama.host || ""),
25
+ meridianHost: String(meridian.host || ""),
24
26
  };
25
27
  }
26
28
 
@@ -43,6 +45,7 @@ export function applyConfigEnv(cfg) {
43
45
  process.env.MEMORY_API_BASE = d.modelApiBase;
44
46
  }
45
47
  if (d.lemondHost && !process.env.LEMOND_BASE) process.env.LEMOND_BASE = d.lemondHost;
48
+ if (d.meridianHost && !process.env.MERIDIAN_BASE) process.env.MERIDIAN_BASE = d.meridianHost;
46
49
  }
47
50
 
48
51
  /**
@@ -63,6 +66,9 @@ export function configToml(ctx) {
63
66
  if (ctx.backend === "ollama" || ctx.ollamaBase) {
64
67
  model.ollama = { host: ctx.ollamaBase };
65
68
  }
69
+ if (ctx.backend === "meridian" && ctx.meridianBase) {
70
+ model.meridian = { host: ctx.meridianBase };
71
+ }
66
72
  return {
67
73
  stack: {
68
74
  api_port: Number(ctx.portApi || 8888),
@@ -30,7 +30,8 @@ export function llmWired(ctx, h) {
30
30
  export function stackEnv(ctx) {
31
31
  ensureState(ctx);
32
32
  let backend = ctx.backend;
33
- if (backend === "custom") backend = "lemond";
33
+ // Own-route backends run from litellm.yaml; setup.mjs only knows lemond and ollama.
34
+ if (backend === "custom" || backend === "meridian") backend = "lemond";
34
35
  /** @type {Record<string, string>} */
35
36
  const env = { MEMORY_BACKEND: backend };
36
37
  if (ctx.ollamaInCompose) env.MEMORY_OLLAMA_IN_COMPOSE = "1";
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { configToml } from "./config.mjs";
4
+ import { renderMeridianRoutes } from "./meridian.mjs";
4
5
  import { OLLAMA_HOST_API_BASE } from "./state.mjs";
5
6
 
6
7
  /** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
@@ -80,6 +81,7 @@ function renderShipped(ctx, litellmModel, reflectLitellmModel) {
80
81
  * @returns {string}
81
82
  */
82
83
  export function litellmConfig(ctx) {
84
+ if (ctx.backend === "meridian") return renderMeridianRoutes(ctx);
83
85
  let prefix = "openai/";
84
86
  if (ctx.backend === "ollama") prefix = "ollama_chat/";
85
87
  if (ctx.backend === "custom") return renderOverride(ctx, `${prefix}${ctx.model}`);
@@ -112,6 +114,13 @@ export async function removeOverrideIfAsked(ctx, h) {
112
114
  * @param {Helpers} h
113
115
  */
114
116
  export async function writeOverride(ctx, h) {
117
+ if (ctx.dry && ctx.backend === "meridian") {
118
+ const backup = fs.existsSync(ctx.litellmOverride) ? " (backs up the existing file)" : "";
119
+ h.info(
120
+ `[dry-run] write ${ctx.litellmOverride}${backup}: Meridian routes, retain and consolidate -> anthropic/${ctx.model}, reflect -> anthropic/${ctx.reflectModel || ctx.model}, at ${ctx.apiBase}`,
121
+ );
122
+ return;
123
+ }
115
124
  if (ctx.dry) {
116
125
  h.info(
117
126
  `[dry-run] write ${ctx.litellmOverride} from config/litellm.${ctx.backend}.yaml: retain and consolidate -> ${ctx.model}, reflect -> ${ctx.reflectModel || "shipped"}, at ${ctx.apiBase}`,
@@ -150,7 +159,8 @@ export async function applyRoute(ctx, h) {
150
159
  if (ctx.backend === "ollama") shipped = ctx.ollamaDefaultModel;
151
160
  const defaultBase = shippedApiBase(ctx.backend);
152
161
  const apiMatchesShipped = !ctx.apiBase || ctx.apiBase === defaultBase;
153
- if (ctx.backend !== "custom" && ctx.model === shipped && !ctx.reflectModel && apiMatchesShipped) {
162
+ const ownRoute = ctx.backend === "custom" || ctx.backend === "meridian";
163
+ if (!ownRoute && ctx.model === shipped && !ctx.reflectModel && apiMatchesShipped) {
154
164
  await removeOverrideIfAsked(ctx, h);
155
165
  } else {
156
166
  await writeOverride(ctx, h);
@@ -173,15 +183,14 @@ export function saveConf(ctx, h) {
173
183
  h.info(`[dry-run] record backend ${ctx.backend} in ${ctx.wizardConf}`);
174
184
  return;
175
185
  }
176
- h.saveWizardConf(
177
- ctx,
178
- {
179
- MEMORY_BACKEND: ctx.backend,
180
- MEMORY_MODEL: ctx.model,
181
- MEMORY_API_BASE: ctx.apiBase,
182
- MEMORY_REFLECT_MODEL: ctx.reflectModel,
183
- MEMORY_OLLAMA_IN_COMPOSE: ctx.ollamaInCompose ? "1" : "0",
184
- },
185
- { pmSections: configToml(ctx) },
186
- );
186
+ /** @type {Record<string, string>} */
187
+ const keys = {
188
+ MEMORY_BACKEND: ctx.backend,
189
+ MEMORY_MODEL: ctx.model,
190
+ MEMORY_API_BASE: ctx.apiBase,
191
+ MEMORY_REFLECT_MODEL: ctx.reflectModel,
192
+ MEMORY_OLLAMA_IN_COMPOSE: ctx.ollamaInCompose ? "1" : "0",
193
+ };
194
+ if (ctx.backend === "meridian") keys.MEMORY_MERIDIAN_URL = ctx.meridianBase;
195
+ h.saveWizardConf(ctx, keys, { pmSections: configToml(ctx) });
187
196
  }
@@ -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
+ }