@bcmyguest/openmem 0.0.0-stage → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/.agents/skills/openmem/SKILL.md +34 -0
  2. package/LICENSE +190 -0
  3. package/README.md +140 -2
  4. package/bin/hindsight-project-index +530 -0
  5. package/cli/coding-agent/access-policy.mjs +38 -0
  6. package/cli/coding-agent/agents.mjs +34 -0
  7. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  8. package/cli/coding-agent/capabilities.mjs +34 -0
  9. package/cli/coding-agent/context-store.mjs +81 -0
  10. package/cli/coding-agent/context-trim.mjs +897 -0
  11. package/cli/coding-agent/file-io.mjs +72 -0
  12. package/cli/coding-agent/hook-harnesses.mjs +88 -0
  13. package/cli/coding-agent/hook-runner.mjs +284 -0
  14. package/cli/coding-agent/install-access.mjs +119 -0
  15. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  16. package/cli/coding-agent/install-rtk.mjs +60 -0
  17. package/cli/coding-agent/install-shim.mjs +201 -0
  18. package/cli/coding-agent/opencode-config.mjs +107 -0
  19. package/cli/coding-agent/opencode-plugin.mjs +132 -0
  20. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  21. package/cli/coding-agent/recall-frame.mjs +29 -0
  22. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  23. package/cli/coding-agent/runtime.mjs +40 -0
  24. package/cli/coding-agent/session-db.mjs +626 -0
  25. package/cli/coding-agent/session-facade.mjs +86 -0
  26. package/cli/coding-agent/toml-edit.mjs +161 -0
  27. package/cli/cognee-mcp.mjs +260 -0
  28. package/cli/context-management.mjs +254 -0
  29. package/cli/feed-sync.mjs +27 -0
  30. package/cli/install-coding-agent.mjs +298 -0
  31. package/cli/install-memory-mcp.mjs +426 -0
  32. package/cli/is-main.mjs +18 -0
  33. package/cli/mcp/context-sql-runner.mjs +58 -0
  34. package/cli/mcp/context-store.mjs +670 -0
  35. package/cli/mcp/context.mjs +61 -0
  36. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  37. package/cli/mcp/export/cognee.mjs +119 -0
  38. package/cli/mcp/export/hindsight.mjs +48 -0
  39. package/cli/mcp/export/index.mjs +26 -0
  40. package/cli/mcp/export/mem0.mjs +31 -0
  41. package/cli/mcp/knowledge.mjs +333 -0
  42. package/cli/mcp/llm-synthesize.mjs +50 -0
  43. package/cli/mcp/mem0-scope-id.mjs +22 -0
  44. package/cli/mcp/memory-facade.mjs +521 -0
  45. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  46. package/cli/mcp/notebooklm-banks.mjs +21 -0
  47. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  48. package/cli/mcp/notebooklm-provider.mjs +225 -0
  49. package/cli/mcp/pmm-records.mjs +23 -0
  50. package/cli/mcp/portability.mjs +158 -0
  51. package/cli/mcp/stdio.mjs +65 -0
  52. package/cli/mcp/unified-mcp-install.mjs +65 -0
  53. package/cli/mem0-mcp.mjs +108 -0
  54. package/cli/notebooklm-mcp.mjs +236 -0
  55. package/cli/openmem-mcp.mjs +38 -0
  56. package/cli/openmem.mjs +222 -0
  57. package/cli/roots.mjs +85 -0
  58. package/cli/schedule.mjs +176 -0
  59. package/cli/stack.mjs +438 -0
  60. package/cli/switch-memory-provider.mjs +358 -0
  61. package/cli/wiring-report.mjs +125 -0
  62. package/compose.bench.yaml +12 -0
  63. package/compose.yaml +227 -0
  64. package/config/coding-agent.json +13 -0
  65. package/config/hindsight.env.example +26 -0
  66. package/config/index.yml +54 -0
  67. package/config/litellm.lemond.yaml +40 -0
  68. package/config/litellm.ollama.yaml +33 -0
  69. package/config/notebooklm.yml +33 -0
  70. package/config/openmem.toml.example +124 -0
  71. package/docs/OPENMEM.md +23 -0
  72. package/docs/architecture.md +245 -0
  73. package/docs/assets/README.md +10 -0
  74. package/docs/assets/setup-demo.gif +0 -0
  75. package/docs/coding-agent-hooks.md +75 -0
  76. package/docs/coding-agent-scopes.md +22 -0
  77. package/docs/coding-agents.md +71 -0
  78. package/docs/cognee.md +37 -0
  79. package/docs/compatibility.md +80 -0
  80. package/docs/concepts.md +135 -0
  81. package/docs/context-management.md +255 -0
  82. package/docs/context-proxy.md +115 -0
  83. package/docs/context-research.md +215 -0
  84. package/docs/context-store.md +277 -0
  85. package/docs/hindsight.md +77 -0
  86. package/docs/index-sources-roadmap.md +38 -0
  87. package/docs/index-sources.md +39 -0
  88. package/docs/indexer-sinks.md +122 -0
  89. package/docs/indexing.md +57 -0
  90. package/docs/manual-stack-setup.md +74 -0
  91. package/docs/mcp-install-guide.md +4 -0
  92. package/docs/mcp-setup.md +104 -0
  93. package/docs/mem0.md +141 -0
  94. package/docs/memory-portability.md +73 -0
  95. package/docs/memory-providers.md +246 -0
  96. package/docs/meridian-litellm.md +198 -0
  97. package/docs/model-configuration.md +106 -0
  98. package/docs/notebooklm.md +83 -0
  99. package/docs/npx-install.md +172 -0
  100. package/docs/openmem-config.md +194 -0
  101. package/docs/provider-wizard-hooks.md +222 -0
  102. package/docs/quickstart.md +107 -0
  103. package/docs/setup-wizard.md +117 -0
  104. package/docs/sources-feeds.md +39 -0
  105. package/docs/sources-readwise.md +23 -0
  106. package/docs/sources-zotero.md +22 -0
  107. package/glama.json +4 -0
  108. package/indexer/Dockerfile +38 -0
  109. package/indexer/code_outline.py +146 -0
  110. package/indexer/feed_sync.py +107 -0
  111. package/indexer/index_config.py +290 -0
  112. package/indexer/pytest.ini +5 -0
  113. package/indexer/sinks/__init__.py +148 -0
  114. package/indexer/sinks/_http.py +44 -0
  115. package/indexer/sinks/cognee.py +329 -0
  116. package/indexer/sinks/hindsight.py +107 -0
  117. package/indexer/sinks/mem0.py +247 -0
  118. package/indexer/watch.py +240 -0
  119. package/package.json +96 -4
  120. package/providers/_template/compose.yaml +52 -0
  121. package/providers/cognee/OPERATOR.md +82 -0
  122. package/providers/cognee/compose.yaml +89 -0
  123. package/providers/cognee/lib/clack.mjs +157 -0
  124. package/providers/cognee/lib/coding-agent.mjs +26 -0
  125. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  126. package/providers/cognee/lib/config.mjs +98 -0
  127. package/providers/cognee/lib/env-file.mjs +194 -0
  128. package/providers/cognee/lib/env-setup.mjs +83 -0
  129. package/providers/cognee/lib/llm-env.mjs +382 -0
  130. package/providers/cognee/lib/ports.mjs +25 -0
  131. package/providers/cognee/lib/smoke.mjs +246 -0
  132. package/providers/cognee/lib/stack-env.mjs +134 -0
  133. package/providers/cognee/lib/state.mjs +101 -0
  134. package/providers/cognee/lib/summary.mjs +56 -0
  135. package/providers/cognee/lib/validate.mjs +97 -0
  136. package/providers/cognee/manifest.json +56 -0
  137. package/providers/cognee/wizard.mjs +134 -0
  138. package/providers/graphiti/manifest.json +15 -0
  139. package/providers/hindsight/lib/clack.mjs +347 -0
  140. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  141. package/providers/hindsight/lib/config.mjs +79 -0
  142. package/providers/hindsight/lib/env-setup.mjs +72 -0
  143. package/providers/hindsight/lib/litellm-routes.mjs +196 -0
  144. package/providers/hindsight/lib/meridian.mjs +307 -0
  145. package/providers/hindsight/lib/model-backend.mjs +243 -0
  146. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  147. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  148. package/providers/hindsight/lib/ports.mjs +30 -0
  149. package/providers/hindsight/lib/probe.mjs +54 -0
  150. package/providers/hindsight/lib/stack-env.mjs +125 -0
  151. package/providers/hindsight/lib/state.mjs +80 -0
  152. package/providers/hindsight/lib/summary.mjs +57 -0
  153. package/providers/hindsight/lib/validate.mjs +81 -0
  154. package/providers/hindsight/manifest.json +58 -0
  155. package/providers/hindsight/wizard.mjs +150 -0
  156. package/providers/letta/manifest.json +29 -0
  157. package/providers/mem0/OPERATOR.md +143 -0
  158. package/providers/mem0/compose.yaml +79 -0
  159. package/providers/mem0/lib/coding-agent.mjs +58 -0
  160. package/providers/mem0/lib/config.mjs +45 -0
  161. package/providers/mem0/lib/configure.mjs +201 -0
  162. package/providers/mem0/lib/dotenv.mjs +71 -0
  163. package/providers/mem0/lib/env-file.mjs +110 -0
  164. package/providers/mem0/lib/http.mjs +69 -0
  165. package/providers/mem0/lib/litellm.mjs +457 -0
  166. package/providers/mem0/lib/mcp-install.mjs +165 -0
  167. package/providers/mem0/lib/secret.mjs +30 -0
  168. package/providers/mem0/lib/smoke.mjs +179 -0
  169. package/providers/mem0/lib/stack-env.mjs +255 -0
  170. package/providers/mem0/lib/state.mjs +67 -0
  171. package/providers/mem0/lib/summary.mjs +88 -0
  172. package/providers/mem0/lib/url.mjs +106 -0
  173. package/providers/mem0/lib/validate.mjs +158 -0
  174. package/providers/mem0/manifest.json +47 -0
  175. package/providers/mem0/wizard.mjs +146 -0
  176. package/providers/zep/manifest.json +29 -0
  177. package/proxy/config.yaml +33 -0
  178. package/proxy/openmem_context/__init__.py +6 -0
  179. package/proxy/openmem_context/adapters.py +504 -0
  180. package/proxy/openmem_context/compress.py +53 -0
  181. package/proxy/openmem_context/core.py +606 -0
  182. package/proxy/openmem_context/embed.py +212 -0
  183. package/proxy/openmem_context/recall.py +49 -0
  184. package/proxy/openmem_context/session.py +70 -0
  185. package/proxy/openmem_context/sessiondb.py +276 -0
  186. package/proxy/openmem_context/settings.py +134 -0
  187. package/proxy/openmem_context/store.py +129 -0
  188. package/proxy/openmem_context/tokens.py +123 -0
  189. package/proxy/openmem_context/ttl.py +78 -0
  190. package/proxy/openmem_context_hook.py +34 -0
  191. package/scripts/check-pack.mjs +219 -0
  192. package/scripts/gen-masks.mjs +83 -0
  193. package/scripts/gen-masks.sh +4 -0
  194. package/scripts/promotion-provider-smoke.mjs +34 -0
  195. package/scripts/run-setup.sh +30 -0
  196. package/scripts/setup.mjs +107 -0
  197. package/scripts/setup.sh +5 -0
  198. package/scripts/spin-up.sh +45 -0
  199. package/scripts/wizard.sh +5 -0
  200. package/server.json +34 -0
  201. package/sync/Dockerfile +27 -0
  202. package/sync/memory_backend.py +87 -0
  203. package/sync/notebooklm_sync.py +664 -0
  204. package/wizard/bin/pm.mjs +97 -0
  205. package/wizard/bin/setup.mjs +33 -0
  206. package/wizard/lib/compose-version.mjs +32 -0
  207. package/wizard/lib/compose.mjs +70 -0
  208. package/wizard/lib/context-step.mjs +140 -0
  209. package/wizard/lib/curl.mjs +103 -0
  210. package/wizard/lib/docker-build.mjs +148 -0
  211. package/wizard/lib/exec.mjs +41 -0
  212. package/wizard/lib/indexer.mjs +6 -0
  213. package/wizard/lib/litellm-env.mjs +55 -0
  214. package/wizard/lib/load-machine-env.mjs +4 -0
  215. package/wizard/lib/log.mjs +32 -0
  216. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  217. package/wizard/lib/notebooklm.mjs +214 -0
  218. package/wizard/lib/parse-args.mjs +87 -0
  219. package/wizard/lib/paths.mjs +39 -0
  220. package/wizard/lib/pm-config.mjs +371 -0
  221. package/wizard/lib/ports.mjs +94 -0
  222. package/wizard/lib/prompts.mjs +85 -0
  223. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  224. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  225. package/wizard/lib/providers/registry.mjs +262 -0
  226. package/wizard/lib/run-clack.mjs +108 -0
  227. package/wizard/lib/run-wizard.mjs +750 -0
  228. package/wizard/lib/validate-setup.mjs +62 -0
  229. package/wizard/lib/wizard-action.mjs +34 -0
  230. package/wizard/lib/wizard-conf.mjs +71 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Step "3. mem0 server URL and API key" (the hook is still named configureModelRouting).
3
+ * mem0 v1 is URL-only: this asks for the REST server URL and the API key the indexer sends,
4
+ * keeps the key in the mode 600 env file and never anywhere else. Keys can also be placed in
5
+ * that file by hand.
6
+ */
7
+ import path from "node:path";
8
+ import { applyConfig, configToml } from "./config.mjs";
9
+ import { dirTooOpen, fileMode, keyProblem, readKey, tooOpen, writeKey } from "./env-file.mjs";
10
+ import { EMBED_BACKENDS, storedChoice } from "./litellm.mjs";
11
+ import { capturedKey, captureKeyFromEnv } from "./secret.mjs";
12
+ import { ensureState, envFileProblem, resolveEnvFile } from "./state.mjs";
13
+ import { displayUrl, resolveApiUrl, urlProblem } from "./url.mjs";
14
+
15
+ /** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
16
+
17
+ /**
18
+ * @param {Record<string, any>} ctx
19
+ * @param {Helpers} h
20
+ */
21
+ export async function configureUrlAndKey(ctx, h) {
22
+ const state = ensureState(ctx);
23
+ h.step("3. mem0 server URL and API key");
24
+ const interactive = !ctx.yes && !ctx.skipBackendStep;
25
+ if (ctx.skipBackendStep) h.info("mem0 URL and key come from the setup UI (environment)");
26
+
27
+ // URL.
28
+ let raw = resolveApiUrl(ctx, h);
29
+ if (interactive) {
30
+ raw = String(await h.ask(ctx, "mem0 REST server URL (as this host sees it)", raw)).trim().replace(/\/+$/, "");
31
+ }
32
+ const urlIssue = urlProblem(raw);
33
+ if (urlIssue) {
34
+ h.problem(ctx, `mem0 API URL: ${urlIssue}`);
35
+ return;
36
+ }
37
+ const url = displayUrl(raw);
38
+ state.apiUrl = url;
39
+ state.url = url;
40
+ process.env.MEM0_API_URL = url;
41
+ h.ok(`mem0 server ${url} (an external server you run; this stack does not start it)`);
42
+
43
+ // Key file.
44
+ const envFile = resolveEnvFile(ctx, h);
45
+ const fileIssue = envFileProblem(envFile);
46
+ if (fileIssue) {
47
+ h.problem(ctx, fileIssue);
48
+ return;
49
+ }
50
+ const existing = readKey(envFile);
51
+ // Normally captured by the init hook (before any child process starts); this covers a caller
52
+ // that skipped init and is a no-op once the variables are gone.
53
+ captureKeyFromEnv();
54
+ const key = capturedKey();
55
+ if (key) h.info("using the API key from the environment");
56
+
57
+ if (key) {
58
+ const keyIssue = keyProblem(key);
59
+ if (keyIssue) {
60
+ h.problem(ctx, keyIssue);
61
+ return;
62
+ }
63
+ if (ctx.dry) {
64
+ h.info(`[dry-run] write MEM0_API_KEY to ${envFile} (mode 600, directory mode 700)`);
65
+ } else {
66
+ const result = writeKey(envFile, key);
67
+ if (result === "unchanged") h.ok(`kept ${envFile} (same key, mode 600)`);
68
+ else h.ok(`${result === "created" ? "created" : "updated"} ${envFile} with MEM0_API_KEY (mode 600)`);
69
+ }
70
+ } else if (existing) {
71
+ h.ok(`kept the API key already in ${envFile}`);
72
+ if (tooOpen(envFile)) {
73
+ h.warn(`${envFile} is group/other accessible (mode ${fileMode(envFile).toString(8)}); run: chmod 600 ${envFile}`);
74
+ }
75
+ } else {
76
+ // No prompt here: helpers.ask echoes what is typed. The setup UI (Clack) asks with a masked prompt.
77
+ h.info(
78
+ `no API key given. A server with auth enabled (the default) needs one: put MEM0_API_KEY=<key> in ${envFile} (mode 600), ` +
79
+ "or rerun with MEM0_API_KEY (or PM_MEM0_API_KEY) in the environment under --yes. " +
80
+ "A server run with AUTH_DISABLED does not need a key.",
81
+ );
82
+ }
83
+ if (dirTooOpen(envFile)) {
84
+ h.warn(`${path.dirname(envFile)} is accessible to group or others; run: chmod 700 ${path.dirname(envFile)}`);
85
+ }
86
+
87
+ // Non-secret state: wizard.conf (+ config.toml) record the URL only.
88
+ if (ctx.dry) h.info(`[dry-run] record MEM0_API_URL in ${ctx.wizardConf} and [memory.mem0] in config.toml`);
89
+ else h.saveWizardConf(ctx, { MEM0_API_URL: url }, { pmSections: configToml(ctx, url) });
90
+ }
91
+
92
+ /**
93
+ * Clack front end: ask the URL and (masked) key, hand them to the wizard through the
94
+ * environment and skip the wizard's own prompts.
95
+ * @param {{ repoRoot: string, home: string, wizardConf: string, pmMerged?: Record<string, unknown> }} ctx
96
+ * @param {typeof import("@clack/prompts")} ui
97
+ * @param {Helpers} h
98
+ * @returns {Promise<Record<string, string>>}
99
+ */
100
+ export async function clackSetup(ctx, ui, h) {
101
+ const stub = /** @type {Record<string, any>} */ (ctx);
102
+ ensureState(stub);
103
+ applyConfig(stub);
104
+ const current = resolveApiUrl(stub, h);
105
+ const existing = readKey(resolveEnvFile(stub, h));
106
+
107
+ const typed = await ui.text({
108
+ message: "mem0 REST server URL (as this host sees it)",
109
+ placeholder: current,
110
+ defaultValue: current,
111
+ validate: (v) => urlProblem(String(v || "").trim().replace(/\/+$/, "") || current) || undefined,
112
+ });
113
+ if (ui.isCancel(typed)) {
114
+ ui.cancel("Setup cancelled.");
115
+ process.exit(0);
116
+ }
117
+ const url = String(typed || "").trim().replace(/\/+$/, "") || current;
118
+
119
+ const secret = await ui.password({
120
+ message: existing
121
+ ? "mem0 API key (empty keeps the one in the env file)"
122
+ : "mem0 API key (empty if the server has auth disabled)",
123
+ validate: (v) => keyProblem(String(v || "").trim()) || undefined,
124
+ });
125
+ if (ui.isCancel(secret)) {
126
+ ui.cancel("Setup cancelled.");
127
+ process.exit(0);
128
+ }
129
+
130
+ /** @type {Record<string, string>} */
131
+ const handoff = { WIZARD_SKIP_BACKEND_STEP: "1", MEM0_API_URL: url };
132
+ const key = String(secret || "").trim();
133
+ if (key) handoff.PM_MEM0_API_KEY = key;
134
+ Object.assign(handoff, await clackLitellm(stub, ui, h));
135
+ ui.note("Next: Docker, indexing, and starting the stack.", "mem0 configured");
136
+ return handoff;
137
+ }
138
+
139
+ /**
140
+ * Clack questions for "route mem0's LLM through the stack LiteLLM". Returns the MEM0_LITELLM_*
141
+ * handoff (and ANTHROPIC_API_KEY when typed); the wizard step reads them under WIZARD_SKIP_BACKEND_STEP.
142
+ * @param {Record<string, any>} ctx
143
+ * @param {typeof import("@clack/prompts")} ui
144
+ * @param {Helpers} h
145
+ * @returns {Promise<Record<string, string>>}
146
+ */
147
+ async function clackLitellm(ctx, ui, h) {
148
+ const exitIfCancel = (v) => {
149
+ if (ui.isCancel(v)) {
150
+ ui.cancel("Setup cancelled.");
151
+ process.exit(0);
152
+ }
153
+ return v;
154
+ };
155
+ const current = storedChoice(ctx, h);
156
+ const on = exitIfCancel(
157
+ await ui.confirm({
158
+ message: "Route mem0's LLM through the stack LiteLLM (same gateway as Hindsight; 127.0.0.1 only)?",
159
+ initialValue: current.enabled,
160
+ }),
161
+ );
162
+ if (!on) return { MEM0_LITELLM: "0" };
163
+ const backend = /** @type {string} */ (
164
+ exitIfCancel(
165
+ await ui.select({
166
+ message: "LiteLLM backend for mem0's LLM",
167
+ options: [
168
+ { value: "ollama", label: "Ollama", hint: "LLM and embeddings (nomic-embed-text)" },
169
+ { value: "custom", label: "Custom OpenAI-compatible URL", hint: "lemond, vLLM, OpenAI, ..." },
170
+ { value: "anthropic", label: "Anthropic API key", hint: "Claude; no embeddings" },
171
+ { value: "meridian", label: "Claude subscription (Meridian)", hint: "Anthropic Consumer Terms apply; no embeddings" },
172
+ ],
173
+ initialValue: current.backend,
174
+ }),
175
+ )
176
+ );
177
+ const c = storedChoice(ctx, h, backend);
178
+ /** @type {Record<string, string>} */
179
+ const out = { MEM0_LITELLM: "1", MEM0_LITELLM_BACKEND: backend };
180
+ if (backend === "ollama" || backend === "custom") {
181
+ out.MEM0_LITELLM_API_BASE = String(
182
+ exitIfCancel(await ui.text({ message: "Model server base URL (as the litellm container sees it)", placeholder: c.apiBase, defaultValue: c.apiBase })) || c.apiBase,
183
+ ).trim();
184
+ }
185
+ out.MEM0_LITELLM_MODEL = String(
186
+ exitIfCancel(await ui.text({ message: "Model id for mem0's LLM", placeholder: c.model, defaultValue: c.model })) || c.model,
187
+ ).trim();
188
+ if (EMBED_BACKENDS.includes(backend)) {
189
+ const def = c.embedModel || "none";
190
+ out.MEM0_LITELLM_EMBED_MODEL = String(
191
+ exitIfCancel(await ui.text({ message: "Embedding model through LiteLLM (none keeps mem0's own embedder)", placeholder: def, defaultValue: def })) || def,
192
+ ).trim();
193
+ }
194
+ if (backend === "anthropic") {
195
+ const secret = String(
196
+ exitIfCancel(await ui.password({ message: "ANTHROPIC_API_KEY for litellm.env (empty keeps the one there)" })) || "",
197
+ ).trim();
198
+ if (secret) out.PM_ANTHROPIC_API_KEY = secret;
199
+ }
200
+ return out;
201
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Read one key from an env file the way docker compose's env_file loader does, so the key the
3
+ * wizard checks is the key the indexer container gets. Node builtins only: scripts/setup.mjs
4
+ * loads this through stack-env.mjs, without the wizard helpers.
5
+ *
6
+ * Rules (verified against `docker compose config`): an optional `export ` prefix and
7
+ * whitespace around the key and the `=`; CRLF line ends; a value starting with " or ' reads
8
+ * up to the matching quote (anything after it is ignored); otherwise leading whitespace is
9
+ * dropped, the value stops at a space followed by `#` (a tab or a leading `#` does not start a
10
+ * comment) and is trimmed; the last duplicate wins; an unterminated quote
11
+ * reads as empty (compose refuses to load such a file).
12
+ */
13
+
14
+ /** @param {string} s */
15
+ function escapeRegExp(s) {
16
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
+ }
18
+
19
+ /**
20
+ * The value after `=`.
21
+ * @param {string} rest
22
+ * @returns {string}
23
+ */
24
+ function parseValue(rest) {
25
+ const trimmed = rest.replace(/^\s+/, "");
26
+ const quote = trimmed[0];
27
+ if (quote === '"' || quote === "'") {
28
+ let out = "";
29
+ for (let i = 1; i < trimmed.length; i++) {
30
+ const c = trimmed[i];
31
+ if (quote === '"' && c === "\\" && i + 1 < trimmed.length) {
32
+ const next = trimmed[i + 1];
33
+ if (next === '"' || next === "\\") {
34
+ out += next;
35
+ i++;
36
+ continue;
37
+ }
38
+ }
39
+ if (c === quote) return out;
40
+ out += c;
41
+ }
42
+ return "";
43
+ }
44
+ return trimmed.replace(/ #.*$/, "").trim();
45
+ }
46
+
47
+ /**
48
+ * The value of `key` in env file text: the last matching line, "" when absent or empty.
49
+ * @param {string} text
50
+ * @param {string} key
51
+ * @returns {string}
52
+ */
53
+ export function parseEnvValue(text, key) {
54
+ const line = new RegExp(`^\\s*(?:export\\s+)?${escapeRegExp(key)}\\s*=(.*)$`);
55
+ let value = "";
56
+ for (const raw of String(text).split(/\r?\n/)) {
57
+ const m = line.exec(raw.replace(/\r$/, ""));
58
+ if (m) value = parseValue(m[1]);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ /**
64
+ * True when any line assigns `key` (even to an empty value).
65
+ * @param {string} text
66
+ * @param {string} key
67
+ */
68
+ export function assignsKey(text, key) {
69
+ const line = new RegExp(`^\\s*(?:export\\s+)?${escapeRegExp(key)}\\s*=`);
70
+ return String(text).split(/\r?\n/).some((l) => line.test(l));
71
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The mem0 env file (default ~/.config/personal-memory/mem0.env): the indexer's MEM0_API_KEY,
3
+ * mode 600 in a mode 700 directory, outside the repo. Nothing here prints a key. Reads follow
4
+ * docker compose's env_file rules (see dotenv.mjs) so the wizard sees the key the indexer gets.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { parseEnvValue } from "./dotenv.mjs";
9
+
10
+ const KEY_LINE = /^\s*(?:export\s+)?MEM0_API_KEY\s*=/;
11
+
12
+ /**
13
+ * The key must survive an env_file parser (compose interpolates '$', treats '#' and quotes
14
+ * specially) and a single line. The message never echoes the key.
15
+ * @param {string} key
16
+ * @returns {string} problem text, or ""
17
+ */
18
+ export function keyProblem(key) {
19
+ if (/[\x00-\x20\x7f"'`#$\\]/.test(key)) {
20
+ return "the API key has characters an env file cannot hold safely (whitespace, quotes, '#', '$', backslash)";
21
+ }
22
+ return "";
23
+ }
24
+
25
+ /**
26
+ * The MEM0_API_KEY value compose would load from the file (last duplicate wins, quotes and
27
+ * `export` handled), or "" when the file is absent, has no such line, or the value is empty.
28
+ * @param {string} file
29
+ */
30
+ export function readKey(file) {
31
+ if (!fs.existsSync(file)) return "";
32
+ return parseEnvValue(fs.readFileSync(file, "utf8"), "MEM0_API_KEY");
33
+ }
34
+
35
+ /** @param {string} file @returns {number} permission bits, or -1 when the file is absent */
36
+ export function fileMode(file) {
37
+ return fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : -1;
38
+ }
39
+
40
+ /** True when group or others can read or write the file. */
41
+ export function tooOpen(file) {
42
+ const mode = fileMode(file);
43
+ return mode >= 0 && (mode & 0o077) !== 0;
44
+ }
45
+
46
+ /**
47
+ * True when the file's directory exists and group or others can enter or list it. The file
48
+ * mode still protects the key; this is the extra hygiene the wizard warns about.
49
+ * @param {string} file
50
+ */
51
+ export function dirTooOpen(file) {
52
+ const dir = path.dirname(file);
53
+ return fs.existsSync(dir) && (fs.statSync(dir).mode & 0o077) !== 0;
54
+ }
55
+
56
+ /**
57
+ * Follow a chain of symlinks (also dangling ones) to the path that would hold the data.
58
+ * @param {string} p
59
+ */
60
+ function resolveLink(p) {
61
+ let current = p;
62
+ for (let i = 0; i < 16; i++) {
63
+ let stat;
64
+ try {
65
+ stat = fs.lstatSync(current);
66
+ } catch {
67
+ return current;
68
+ }
69
+ if (!stat.isSymbolicLink()) return current;
70
+ current = path.resolve(path.dirname(current), fs.readlinkSync(current));
71
+ }
72
+ throw new Error("too many symbolic links in the mem0 env file path");
73
+ }
74
+
75
+ /**
76
+ * Write `MEM0_API_KEY=<key>` into the file: other lines are kept, every existing key line is
77
+ * replaced by one. A symlinked file or directory keeps its link and the data lands at the
78
+ * target. The directory is created with mode 700; the file is replaced atomically through a
79
+ * temp file created exclusively (`wx`) with mode 600 beside it, so the key is never briefly
80
+ * readable by others.
81
+ * @param {string} file
82
+ * @param {string} key
83
+ * @returns {"created"|"updated"|"unchanged"}
84
+ */
85
+ export function writeKey(file, key) {
86
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
87
+ const target = resolveLink(path.join(fs.realpathSync(path.dirname(file)), path.basename(file)));
88
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
89
+ const existed = fs.existsSync(target);
90
+ /** @type {string[]} */
91
+ let lines = [];
92
+ let previous = "";
93
+ if (existed) {
94
+ previous = fs.readFileSync(target, "utf8");
95
+ lines = previous.split(/\r?\n/);
96
+ while (lines.length && lines.at(-1) === "") lines.pop();
97
+ }
98
+ const kept = lines.filter((l) => !KEY_LINE.test(l));
99
+ const text = `${[...kept, `MEM0_API_KEY=${key}`].join("\n")}\n`;
100
+ if (existed && previous === text && !tooOpen(target)) return "unchanged";
101
+ const tmp = `${target}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
102
+ try {
103
+ fs.writeFileSync(tmp, text, { flag: "wx", mode: 0o600 });
104
+ fs.chmodSync(tmp, 0o600);
105
+ fs.renameSync(tmp, target);
106
+ } finally {
107
+ fs.rmSync(tmp, { force: true });
108
+ }
109
+ return existed ? "updated" : "created";
110
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Small fetch wrapper for the mem0 REST server. Every call goes to a path under the
3
+ * resolved mem0 URL; redirects are never followed (a custom header such as X-API-Key is
4
+ * not stripped on a cross-origin redirect), and errors never carry the key.
5
+ */
6
+
7
+ /**
8
+ * @param {string} text
9
+ * @param {string} secret
10
+ */
11
+ export function redact(text, secret) {
12
+ let out = String(text ?? "");
13
+ if (secret) out = out.split(secret).join("***");
14
+ return out;
15
+ }
16
+
17
+ /**
18
+ * Text from the server made safe to print: control characters (terminal escapes, newlines)
19
+ * become spaces and the length is capped.
20
+ * @param {unknown} text
21
+ * @param {number} [max]
22
+ */
23
+ export function clean(text, max = 200) {
24
+ return String(text ?? "")
25
+ .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
26
+ .replace(/\s+/g, " ")
27
+ .trim()
28
+ .slice(0, max);
29
+ }
30
+
31
+ /** Short, key-free description of a fetch failure. @param {unknown} e */
32
+ export function describeError(e) {
33
+ const err = /** @type {{ name?: string, message?: string, cause?: { code?: string } }} */ (e);
34
+ if (err?.name === "TimeoutError" || err?.name === "AbortError") return "timed out";
35
+ return err?.cause?.code || err?.message || "request failed";
36
+ }
37
+
38
+ /**
39
+ * @param {string} base resolved, validated mem0 URL (no trailing slash)
40
+ * @param {string} method
41
+ * @param {string} route path (and query) under the base, starting with '/'
42
+ * @param {{ key?: string, body?: unknown, timeoutMs?: number }} [opts]
43
+ * @returns {Promise<{ status: number, ok: boolean, text: string, json: any }>}
44
+ */
45
+ export async function request(base, method, route, opts = {}) {
46
+ /** @type {Record<string, string>} */
47
+ const headers = { Accept: "application/json" };
48
+ if (opts.key) headers["X-API-Key"] = opts.key;
49
+ /** @type {RequestInit} */
50
+ const init = {
51
+ method,
52
+ headers,
53
+ redirect: "manual",
54
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15000),
55
+ };
56
+ if (opts.body !== undefined) {
57
+ headers["Content-Type"] = "application/json";
58
+ init.body = JSON.stringify(opts.body);
59
+ }
60
+ const res = await fetch(`${base}${route}`, init);
61
+ const text = await res.text();
62
+ let json = null;
63
+ try {
64
+ json = text ? JSON.parse(text) : null;
65
+ } catch {
66
+ json = null;
67
+ }
68
+ return { status: res.status, ok: res.status >= 200 && res.status < 300, text, json };
69
+ }