@bcmyguest/openmem 0.0.0-stage → 0.6.3
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.
- package/LICENSE +190 -0
- package/README.md +136 -2
- package/bin/hindsight-project-index +530 -0
- package/cli/coding-agent/access-policy.mjs +38 -0
- package/cli/coding-agent/agents.mjs +34 -0
- package/cli/coding-agent/bank-from-cwd.mjs +140 -0
- package/cli/coding-agent/capabilities.mjs +23 -0
- package/cli/coding-agent/hook-harnesses.mjs +87 -0
- package/cli/coding-agent/hook-runner.mjs +260 -0
- package/cli/coding-agent/install-access.mjs +93 -0
- package/cli/coding-agent/install-shim.mjs +97 -0
- package/cli/coding-agent/opencode-config.mjs +91 -0
- package/cli/coding-agent/opencode-plugin.mjs +96 -0
- package/cli/coding-agent/runtime.mjs +39 -0
- package/cli/coding-agent/session-facade.mjs +62 -0
- package/cli/cognee-mcp.mjs +260 -0
- package/cli/feed-sync.mjs +11 -0
- package/cli/install-coding-agent.mjs +165 -0
- package/cli/install-memory-mcp.mjs +426 -0
- package/cli/is-main.mjs +18 -0
- package/cli/mcp/context.mjs +61 -0
- package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
- package/cli/mcp/export/cognee.mjs +119 -0
- package/cli/mcp/export/hindsight.mjs +48 -0
- package/cli/mcp/export/index.mjs +26 -0
- package/cli/mcp/export/mem0.mjs +31 -0
- package/cli/mcp/llm-synthesize.mjs +50 -0
- package/cli/mcp/mem0-scope-id.mjs +22 -0
- package/cli/mcp/memory-facade.mjs +455 -0
- package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
- package/cli/mcp/notebooklm-banks.mjs +21 -0
- package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
- package/cli/mcp/notebooklm-provider.mjs +225 -0
- package/cli/mcp/pmm-records.mjs +23 -0
- package/cli/mcp/portability.mjs +158 -0
- package/cli/mcp/stdio.mjs +65 -0
- package/cli/mcp/unified-mcp-install.mjs +65 -0
- package/cli/mem0-mcp.mjs +108 -0
- package/cli/notebooklm-mcp.mjs +236 -0
- package/cli/openmem-mcp.mjs +35 -0
- package/cli/openmem.mjs +205 -0
- package/cli/roots.mjs +85 -0
- package/cli/stack.mjs +436 -0
- package/cli/switch-memory-provider.mjs +358 -0
- package/cli/wiring-report.mjs +125 -0
- package/compose.bench.yaml +12 -0
- package/compose.yaml +164 -0
- package/config/coding-agent.json +13 -0
- package/config/hindsight.env.example +24 -0
- package/config/index.yml +54 -0
- package/config/litellm.lemond.yaml +40 -0
- package/config/litellm.ollama.yaml +33 -0
- package/config/notebooklm.yml +33 -0
- package/config/openmem.toml.example +62 -0
- package/docs/OPENMEM.md +23 -0
- package/docs/architecture.md +219 -0
- package/docs/assets/README.md +10 -0
- package/docs/assets/setup-demo.gif +0 -0
- package/docs/coding-agent-hooks.md +51 -0
- package/docs/coding-agent-scopes.md +22 -0
- package/docs/coding-agents.md +51 -0
- package/docs/cognee.md +37 -0
- package/docs/concepts.md +112 -0
- package/docs/hindsight.md +74 -0
- package/docs/index-sources-roadmap.md +38 -0
- package/docs/index-sources.md +39 -0
- package/docs/indexer-sinks.md +122 -0
- package/docs/indexing.md +57 -0
- package/docs/manual-stack-setup.md +74 -0
- package/docs/mcp-install-guide.md +4 -0
- package/docs/mcp-setup.md +101 -0
- package/docs/mem0.md +103 -0
- package/docs/memory-portability.md +73 -0
- package/docs/memory-providers.md +246 -0
- package/docs/model-configuration.md +84 -0
- package/docs/notebooklm.md +83 -0
- package/docs/npx-install.md +170 -0
- package/docs/openmem-config.md +194 -0
- package/docs/provider-wizard-hooks.md +222 -0
- package/docs/quickstart.md +101 -0
- package/docs/setup-wizard.md +116 -0
- package/docs/sources-feeds.md +52 -0
- package/docs/sources-readwise.md +23 -0
- package/docs/sources-zotero.md +22 -0
- package/glama.json +4 -0
- package/indexer/Dockerfile +38 -0
- package/indexer/code_outline.py +146 -0
- package/indexer/feed_sync.py +107 -0
- package/indexer/index_config.py +290 -0
- package/indexer/pytest.ini +5 -0
- package/indexer/sinks/__init__.py +148 -0
- package/indexer/sinks/_http.py +44 -0
- package/indexer/sinks/cognee.py +329 -0
- package/indexer/sinks/hindsight.py +107 -0
- package/indexer/sinks/mem0.py +247 -0
- package/indexer/watch.py +240 -0
- package/package.json +95 -4
- package/providers/_template/compose.yaml +52 -0
- package/providers/cognee/OPERATOR.md +82 -0
- package/providers/cognee/compose.yaml +89 -0
- package/providers/cognee/lib/clack.mjs +157 -0
- package/providers/cognee/lib/coding-agent.mjs +26 -0
- package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
- package/providers/cognee/lib/config.mjs +98 -0
- package/providers/cognee/lib/env-file.mjs +194 -0
- package/providers/cognee/lib/env-setup.mjs +83 -0
- package/providers/cognee/lib/llm-env.mjs +382 -0
- package/providers/cognee/lib/ports.mjs +25 -0
- package/providers/cognee/lib/smoke.mjs +246 -0
- package/providers/cognee/lib/stack-env.mjs +134 -0
- package/providers/cognee/lib/state.mjs +101 -0
- package/providers/cognee/lib/summary.mjs +56 -0
- package/providers/cognee/lib/validate.mjs +97 -0
- package/providers/cognee/manifest.json +56 -0
- package/providers/cognee/wizard.mjs +134 -0
- package/providers/graphiti/manifest.json +15 -0
- package/providers/hindsight/lib/clack.mjs +245 -0
- package/providers/hindsight/lib/coding-agent.mjs +204 -0
- package/providers/hindsight/lib/config.mjs +73 -0
- package/providers/hindsight/lib/env-setup.mjs +71 -0
- package/providers/hindsight/lib/litellm-routes.mjs +187 -0
- package/providers/hindsight/lib/model-backend.mjs +214 -0
- package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
- package/providers/hindsight/lib/ollama-compose.mjs +51 -0
- package/providers/hindsight/lib/ports.mjs +30 -0
- package/providers/hindsight/lib/probe.mjs +33 -0
- package/providers/hindsight/lib/stack-env.mjs +132 -0
- package/providers/hindsight/lib/state.mjs +75 -0
- package/providers/hindsight/lib/summary.mjs +43 -0
- package/providers/hindsight/lib/validate.mjs +75 -0
- package/providers/hindsight/manifest.json +58 -0
- package/providers/hindsight/wizard.mjs +149 -0
- package/providers/letta/manifest.json +29 -0
- package/providers/mem0/OPERATOR.md +124 -0
- package/providers/mem0/compose.yaml +57 -0
- package/providers/mem0/lib/coding-agent.mjs +58 -0
- package/providers/mem0/lib/config.mjs +45 -0
- package/providers/mem0/lib/configure.mjs +135 -0
- package/providers/mem0/lib/dotenv.mjs +71 -0
- package/providers/mem0/lib/env-file.mjs +110 -0
- package/providers/mem0/lib/http.mjs +69 -0
- package/providers/mem0/lib/mcp-install.mjs +165 -0
- package/providers/mem0/lib/secret.mjs +30 -0
- package/providers/mem0/lib/smoke.mjs +179 -0
- package/providers/mem0/lib/stack-env.mjs +216 -0
- package/providers/mem0/lib/state.mjs +67 -0
- package/providers/mem0/lib/summary.mjs +60 -0
- package/providers/mem0/lib/url.mjs +106 -0
- package/providers/mem0/lib/validate.mjs +158 -0
- package/providers/mem0/manifest.json +47 -0
- package/providers/mem0/wizard.mjs +133 -0
- package/providers/zep/manifest.json +29 -0
- package/scripts/check-pack.mjs +218 -0
- package/scripts/gen-masks.mjs +83 -0
- package/scripts/gen-masks.sh +4 -0
- package/scripts/promotion-provider-smoke.mjs +34 -0
- package/scripts/run-setup.sh +30 -0
- package/scripts/setup.mjs +94 -0
- package/scripts/setup.sh +5 -0
- package/scripts/spin-up.sh +45 -0
- package/scripts/wizard.sh +5 -0
- package/server.json +34 -0
- package/skills/openmem/SKILL.md +29 -0
- package/sync/Dockerfile +27 -0
- package/sync/memory_backend.py +87 -0
- package/sync/notebooklm_sync.py +664 -0
- package/wizard/bin/pm.mjs +97 -0
- package/wizard/bin/setup.mjs +33 -0
- package/wizard/lib/compose-version.mjs +32 -0
- package/wizard/lib/compose.mjs +70 -0
- package/wizard/lib/curl.mjs +103 -0
- package/wizard/lib/docker-build.mjs +148 -0
- package/wizard/lib/exec.mjs +41 -0
- package/wizard/lib/indexer.mjs +6 -0
- package/wizard/lib/load-machine-env.mjs +4 -0
- package/wizard/lib/log.mjs +32 -0
- package/wizard/lib/notebooklm-agent-access.mjs +92 -0
- package/wizard/lib/notebooklm.mjs +214 -0
- package/wizard/lib/parse-args.mjs +87 -0
- package/wizard/lib/paths.mjs +39 -0
- package/wizard/lib/pm-config.mjs +163 -0
- package/wizard/lib/ports.mjs +94 -0
- package/wizard/lib/prompts.mjs +57 -0
- package/wizard/lib/provider-wizard/helpers.mjs +98 -0
- package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
- package/wizard/lib/providers/registry.mjs +262 -0
- package/wizard/lib/run-clack.mjs +108 -0
- package/wizard/lib/run-wizard.mjs +755 -0
- package/wizard/lib/validate-setup.mjs +62 -0
- package/wizard/lib/wizard-action.mjs +34 -0
- package/wizard/lib/wizard-conf.mjs +71 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge a usable mem0 MCP into ~/.cursor/mcp.json for the self-hosted REST server.
|
|
3
|
+
*
|
|
4
|
+
* Upstream stdio bridge: npx -y @yeyuan98/mem0-mcp (fronts MEM0_BASE_URL with X-API-Key).
|
|
5
|
+
* Official Mem0 MCP is cloud-only (https://mcp.mem0.ai); the OSS docs page is gone.
|
|
6
|
+
*
|
|
7
|
+
* mcp.json never holds MEM0_API_KEY. The launcher (cli/mem0-mcp.mjs) loads the key from
|
|
8
|
+
* MEM0_ENV_FILE / ~/.config/personal-memory/mem0.env when the process env lacks it.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { envValue } from "../../../wizard/lib/paths.mjs";
|
|
14
|
+
import { DEFAULT_API_URL, defaultEnvFile } from "./state.mjs";
|
|
15
|
+
|
|
16
|
+
/** Sibling MCP servers this merge must never rewrite. */
|
|
17
|
+
export const MEM0_MCP_PROTECTED = Object.freeze(["hindsight", "codegraph", "notebooklm"]);
|
|
18
|
+
|
|
19
|
+
/** npm package that speaks the open-source REST API (not platform mcp.mem0.ai). */
|
|
20
|
+
export const MEM0_MCP_NPM_PACKAGE = "@yeyuan98/mem0-mcp";
|
|
21
|
+
|
|
22
|
+
/** @param {string} [pkgRoot] absolute package / checkout root */
|
|
23
|
+
export function mem0McpScriptPath(pkgRoot) {
|
|
24
|
+
const root =
|
|
25
|
+
pkgRoot ||
|
|
26
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
27
|
+
return path.resolve(root, "cli/mem0-mcp.mjs");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* MEM0_API_URL from env, stack .env, wizard.conf, then default. Never returns secrets.
|
|
32
|
+
* @param {{ home: string, stackRoot: string, env?: NodeJS.ProcessEnv }} opts
|
|
33
|
+
*/
|
|
34
|
+
export function resolveMem0ApiUrl({ home, stackRoot, env = process.env }) {
|
|
35
|
+
const candidates = [
|
|
36
|
+
env.MEM0_API_URL,
|
|
37
|
+
env.PM_MEM0_API_URL,
|
|
38
|
+
envValue(path.join(stackRoot, ".env"), "MEM0_API_URL"),
|
|
39
|
+
envValue(path.join(home, ".config/hindsight/wizard.conf"), "MEM0_API_URL"),
|
|
40
|
+
];
|
|
41
|
+
const first = candidates.map((v) => String(v || "").trim()).find(Boolean);
|
|
42
|
+
return (first || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Path to the API key file (path only — never read the key here).
|
|
47
|
+
* @param {{ home: string, stackRoot: string, env?: NodeJS.ProcessEnv }} opts
|
|
48
|
+
*/
|
|
49
|
+
export function resolveMem0EnvFilePath({ home, stackRoot, env = process.env }) {
|
|
50
|
+
const candidates = [
|
|
51
|
+
env.MEM0_ENV_FILE,
|
|
52
|
+
envValue(path.join(stackRoot, ".env"), "MEM0_ENV_FILE"),
|
|
53
|
+
];
|
|
54
|
+
const raw = candidates.map((v) => String(v || "").trim()).find(Boolean);
|
|
55
|
+
if (!raw) return defaultEnvFile(home);
|
|
56
|
+
if (raw.startsWith("~/")) return path.join(home, raw.slice(2));
|
|
57
|
+
if (path.isAbsolute(raw)) return raw;
|
|
58
|
+
return path.resolve(stackRoot, raw);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Cursor mcp.json entry: node launcher → npx @yeyuan98/mem0-mcp.
|
|
63
|
+
* Sets MEM0_API_URL + MEM0_BASE_URL (upstream reads BASE_URL). Path to key file only.
|
|
64
|
+
* @param {{ apiUrl: string, scriptPath: string, envFile?: string }} opts
|
|
65
|
+
*/
|
|
66
|
+
export function mem0McpEntry({ apiUrl, scriptPath, envFile }) {
|
|
67
|
+
const url = String(apiUrl || "").replace(/\/+$/, "");
|
|
68
|
+
/** @type {Record<string, string>} */
|
|
69
|
+
const env = {
|
|
70
|
+
MEM0_API_URL: url,
|
|
71
|
+
MEM0_BASE_URL: url,
|
|
72
|
+
};
|
|
73
|
+
if (envFile) env.MEM0_ENV_FILE = envFile;
|
|
74
|
+
return {
|
|
75
|
+
command: "node",
|
|
76
|
+
args: [scriptPath],
|
|
77
|
+
env,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {Record<string, any>} mcp
|
|
83
|
+
* @param {{ apiUrl: string, scriptPath: string, envFile?: string }} opts
|
|
84
|
+
* @returns {{ mcp: Record<string, any>, changed: boolean, skipped?: string, refused?: string }}
|
|
85
|
+
*/
|
|
86
|
+
export function mergeMem0McpServer(mcp, opts) {
|
|
87
|
+
const entry = mem0McpEntry(opts);
|
|
88
|
+
const out = { ...mcp };
|
|
89
|
+
const servers = {
|
|
90
|
+
...(mcp.mcpServers && typeof mcp.mcpServers === "object" ? mcp.mcpServers : {}),
|
|
91
|
+
};
|
|
92
|
+
const before = Object.fromEntries(MEM0_MCP_PROTECTED.map((k) => [k, servers[k]]));
|
|
93
|
+
const prev = servers.mem0;
|
|
94
|
+
const same = prev && JSON.stringify(prev) === JSON.stringify(entry);
|
|
95
|
+
if (same) {
|
|
96
|
+
return { mcp: { ...out, mcpServers: servers }, changed: false, skipped: "already configured" };
|
|
97
|
+
}
|
|
98
|
+
servers.mem0 = entry;
|
|
99
|
+
for (const k of MEM0_MCP_PROTECTED) {
|
|
100
|
+
if (before[k] !== undefined && servers[k] !== before[k]) {
|
|
101
|
+
return { mcp, changed: false, refused: `refusing to alter ${k} MCP entry` };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { mcp: { ...out, mcpServers: servers }, changed: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {{
|
|
109
|
+
* home: string,
|
|
110
|
+
* repoRoot: string,
|
|
111
|
+
* stackRoot: string,
|
|
112
|
+
* dry?: boolean,
|
|
113
|
+
* env?: NodeJS.ProcessEnv,
|
|
114
|
+
* }} ctx
|
|
115
|
+
* @param {{ info: Function, ok: Function, warn: Function }} h
|
|
116
|
+
*/
|
|
117
|
+
export async function installMem0Mcp(ctx, h) {
|
|
118
|
+
const env = ctx.env || process.env;
|
|
119
|
+
const apiUrl = resolveMem0ApiUrl({ home: ctx.home, stackRoot: ctx.stackRoot, env });
|
|
120
|
+
const envFile = resolveMem0EnvFilePath({ home: ctx.home, stackRoot: ctx.stackRoot, env });
|
|
121
|
+
const scriptPath = mem0McpScriptPath(ctx.repoRoot);
|
|
122
|
+
if (!fs.existsSync(scriptPath)) {
|
|
123
|
+
h.warn(`mem0 MCP launcher missing: ${scriptPath}`);
|
|
124
|
+
return { changed: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const mcpPath = path.join(ctx.home, ".cursor", "mcp.json");
|
|
128
|
+
let existing = {};
|
|
129
|
+
if (fs.existsSync(mcpPath)) {
|
|
130
|
+
try {
|
|
131
|
+
existing = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
|
|
132
|
+
} catch (err) {
|
|
133
|
+
h.warn(`cannot parse ${mcpPath}: ${err instanceof Error ? err.message : err}`);
|
|
134
|
+
return { changed: false };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { mcp, changed, skipped, refused } = mergeMem0McpServer(existing, {
|
|
139
|
+
apiUrl,
|
|
140
|
+
scriptPath,
|
|
141
|
+
envFile,
|
|
142
|
+
});
|
|
143
|
+
if (refused) {
|
|
144
|
+
h.warn(refused);
|
|
145
|
+
return { changed: false, refused };
|
|
146
|
+
}
|
|
147
|
+
if (!changed) {
|
|
148
|
+
h.ok(`mem0 MCP already in ${mcpPath}${skipped ? ` (${skipped})` : ""}`);
|
|
149
|
+
return { changed: false, skipped };
|
|
150
|
+
}
|
|
151
|
+
if (ctx.dry) {
|
|
152
|
+
h.info(
|
|
153
|
+
`[dry-run] merge mem0 MCP into ${mcpPath} → node ${scriptPath}` +
|
|
154
|
+
` (MEM0_API_URL=${apiUrl}; key via ${envFile} or MEM0_API_KEY in the agent env — never written to mcp.json)`,
|
|
155
|
+
);
|
|
156
|
+
return { changed: true, dry: true };
|
|
157
|
+
}
|
|
158
|
+
fs.mkdirSync(path.dirname(mcpPath), { recursive: true });
|
|
159
|
+
fs.writeFileSync(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
160
|
+
h.ok(`registered mem0 MCP in ${mcpPath}`);
|
|
161
|
+
h.info(
|
|
162
|
+
`API key: export MEM0_API_KEY from ${envFile} (or leave it for the launcher to load). Never put the key in mcp.json.`,
|
|
163
|
+
);
|
|
164
|
+
return { changed: true };
|
|
165
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds the API key the operator handed over through the environment, out of process.env.
|
|
3
|
+
* The `init` hook runs before preflight spawns docker and curl, so capturing it there keeps
|
|
4
|
+
* the key out of every child process environment. The value lives in this module only: it is
|
|
5
|
+
* not a property of the wizard context and is never serialised.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let captured = "";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Move PM_MEM0_API_KEY (the Clack hand-off) and MEM0_API_KEY out of process.env. The hand-off
|
|
12
|
+
* wins. Nothing is restored. When neither variable is set an earlier capture is kept.
|
|
13
|
+
*/
|
|
14
|
+
export function captureKeyFromEnv() {
|
|
15
|
+
const handoff = (process.env.PM_MEM0_API_KEY || "").trim();
|
|
16
|
+
const plain = (process.env.MEM0_API_KEY || "").trim();
|
|
17
|
+
delete process.env.PM_MEM0_API_KEY;
|
|
18
|
+
delete process.env.MEM0_API_KEY;
|
|
19
|
+
if (handoff || plain) captured = handoff || plain;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The captured key, or "". */
|
|
23
|
+
export function capturedKey() {
|
|
24
|
+
return captured;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Forget the captured key (tests). */
|
|
28
|
+
export function clearCapturedKey() {
|
|
29
|
+
captured = "";
|
|
30
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for a mem0 REST server (replaces the manifest HTTP smoke, which has no header
|
|
3
|
+
* support): GET /docs, POST /memories (verbatim, infer false), POST /search must find the
|
|
4
|
+
* fact, then delete what was created. Cleanup runs on every outcome.
|
|
5
|
+
*/
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { readKey } from "./env-file.mjs";
|
|
8
|
+
import { clean, describeError, redact, request } from "./http.mjs";
|
|
9
|
+
import { capturedKey } from "./secret.mjs";
|
|
10
|
+
import { ensureState, resolveEnvFile } from "./state.mjs";
|
|
11
|
+
import { displayUrl, resolveApiUrl, urlProblem } from "./url.mjs";
|
|
12
|
+
|
|
13
|
+
/** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
|
|
14
|
+
|
|
15
|
+
export const SMOKE_USER = "wizard-smoke";
|
|
16
|
+
export const SMOKE_RUN = "wizard-smoke-doc";
|
|
17
|
+
export const SMOKE_FACT = "Wizard smoke test: the project codename is heron.";
|
|
18
|
+
|
|
19
|
+
const RETAIN_BODY = {
|
|
20
|
+
messages: [{ role: "user", content: SMOKE_FACT }],
|
|
21
|
+
user_id: SMOKE_USER,
|
|
22
|
+
agent_id: "openmem-wizard",
|
|
23
|
+
run_id: SMOKE_RUN,
|
|
24
|
+
infer: false,
|
|
25
|
+
};
|
|
26
|
+
const SEARCH_BODY = {
|
|
27
|
+
query: "What is the project codename?",
|
|
28
|
+
filters: { user_id: SMOKE_USER },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
class SmokeFailure extends Error {}
|
|
32
|
+
|
|
33
|
+
/** Server ids are UUIDs; anything else is never put in a URL or printed. */
|
|
34
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
35
|
+
|
|
36
|
+
/** @param {any} json @returns {string[]} */
|
|
37
|
+
function memoryIds(json) {
|
|
38
|
+
const list = Array.isArray(json) ? json : Array.isArray(json?.results) ? json.results : json?.id ? [json] : [];
|
|
39
|
+
return list.map((r) => r?.id).filter((id) => typeof id === "string" && SAFE_ID.test(id));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {any} json @returns {string} */
|
|
43
|
+
function memoryText(json) {
|
|
44
|
+
const list = Array.isArray(json) ? json : Array.isArray(json?.results) ? json.results : [];
|
|
45
|
+
return list.map((r) => String(r?.memory ?? r?.text ?? "")).join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The key the indexer would send: the env file first (the indexer reads only that), then the
|
|
50
|
+
* key captured from the environment, then process.env (a caller that skipped init).
|
|
51
|
+
* @param {Record<string, any>} ctx
|
|
52
|
+
* @param {Helpers} h
|
|
53
|
+
*/
|
|
54
|
+
function smokeKey(ctx, h) {
|
|
55
|
+
const fromFile = readKey(resolveEnvFile(ctx, h));
|
|
56
|
+
if (fromFile) return { key: fromFile, fromFile: true };
|
|
57
|
+
return { key: capturedKey() || (process.env.MEM0_API_KEY || "").trim(), fromFile: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Record<string, any>} ctx
|
|
62
|
+
* @param {Helpers} h
|
|
63
|
+
*/
|
|
64
|
+
export async function smoke(ctx, h) {
|
|
65
|
+
ensureState(ctx);
|
|
66
|
+
const raw = resolveApiUrl(ctx, h);
|
|
67
|
+
const issue = urlProblem(raw);
|
|
68
|
+
if (issue) {
|
|
69
|
+
h.problem(ctx, `mem0 API URL: ${issue}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const base = displayUrl(raw);
|
|
73
|
+
|
|
74
|
+
if (ctx.dry) {
|
|
75
|
+
h.info(`[dry-run] mem0 smoke test against ${base}:`);
|
|
76
|
+
h.info("[dry-run] 1. GET /docs must answer 2xx");
|
|
77
|
+
h.info(`[dry-run] 2. POST /memories one fact (infer false, user_id ${SMOKE_USER}, run_id ${SMOKE_RUN})`);
|
|
78
|
+
h.info("[dry-run] 3. POST /search the codename; the result must contain 'heron'");
|
|
79
|
+
h.info("[dry-run] 4. DELETE /memories/{id} for every memory created in step 2");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Everything that can throw sits inside the try, so cleanup always runs and an error never
|
|
84
|
+
// carries the key (messages are fixed text plus a short, key-free reason).
|
|
85
|
+
let key = "";
|
|
86
|
+
/** @type {string[]} */
|
|
87
|
+
let ids = [];
|
|
88
|
+
let failure = "";
|
|
89
|
+
/** @param {{ status: number, text: string }} res @param {string} what */
|
|
90
|
+
const failed = (res, what) => {
|
|
91
|
+
if (res.status === 401 || res.status === 403) {
|
|
92
|
+
return new SmokeFailure(
|
|
93
|
+
`${what}: the mem0 server answered ${res.status}. Put MEM0_API_KEY in ${resolveEnvFile(ctx, h)} ` +
|
|
94
|
+
"(mode 600) or run the server with AUTH_DISABLED, then rerun",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const body = clean(redact(res.text, key));
|
|
98
|
+
return new SmokeFailure(`${what}: the mem0 server answered ${res.status}${body ? ` (${body})` : ""}`);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
h.info(`smoke test: mem0 at ${base}`);
|
|
102
|
+
try {
|
|
103
|
+
let fromFile = true;
|
|
104
|
+
try {
|
|
105
|
+
({ key, fromFile } = smokeKey(ctx, h));
|
|
106
|
+
} catch (e) {
|
|
107
|
+
throw new SmokeFailure(
|
|
108
|
+
`cannot read the mem0 env file ${resolveEnvFile(ctx, h)} (${clean(/** @type {any} */ (e)?.code || "error", 40)})`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (key && !fromFile) {
|
|
112
|
+
h.warn(
|
|
113
|
+
`the API key comes from the environment only; the indexer reads ${path.basename(resolveEnvFile(ctx, h))} ` +
|
|
114
|
+
"(put MEM0_API_KEY there)",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 1. Reachability (open route, no key).
|
|
119
|
+
const docs = await request(base, "GET", "/docs", { timeoutMs: 10000 });
|
|
120
|
+
if (!docs.ok) throw failed(docs, "GET /docs");
|
|
121
|
+
h.ok("GET /docs answered");
|
|
122
|
+
|
|
123
|
+
// 2. Retain verbatim. infer:false must return at least one results[].id (same rule as the sink).
|
|
124
|
+
const retain = await request(base, "POST", "/memories", { key, body: RETAIN_BODY, timeoutMs: 120000 });
|
|
125
|
+
if (!retain.ok) throw failed(retain, "POST /memories");
|
|
126
|
+
ids = memoryIds(retain.json);
|
|
127
|
+
if (!ids.length) {
|
|
128
|
+
throw new SmokeFailure(
|
|
129
|
+
"POST /memories returned no memory ids (empty results); the server must store with infer:false and return results[].id",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
h.ok(`stored the smoke fact (${ids.length} memory)`);
|
|
133
|
+
|
|
134
|
+
// 3. Search must find it.
|
|
135
|
+
const search = await request(base, "POST", "/search", { key, body: SEARCH_BODY, timeoutMs: 120000 });
|
|
136
|
+
if (!search.ok) throw failed(search, "POST /search");
|
|
137
|
+
if (!/heron/i.test(memoryText(search.json))) {
|
|
138
|
+
throw new SmokeFailure(
|
|
139
|
+
"POST /search did not return the smoke fact: check the mem0 server's embedder key and provider " +
|
|
140
|
+
"configuration, and its logs",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
h.ok("POST /search found the smoke fact");
|
|
144
|
+
} catch (e) {
|
|
145
|
+
failure = e instanceof SmokeFailure ? e.message : `mem0 request failed (${clean(redact(describeError(e), key), 80)})`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 4. Cleanup, whatever happened above: delete this run's memories and any the wizard's fixed
|
|
149
|
+
// user_id/run_id left behind earlier (a failed cleanup, an interrupted run).
|
|
150
|
+
try {
|
|
151
|
+
const found = new Set(ids);
|
|
152
|
+
try {
|
|
153
|
+
const listed = await request(
|
|
154
|
+
base,
|
|
155
|
+
"GET",
|
|
156
|
+
`/memories?user_id=${SMOKE_USER}&run_id=${SMOKE_RUN}&top_k=1000`,
|
|
157
|
+
{ key, timeoutMs: 30000 },
|
|
158
|
+
);
|
|
159
|
+
if (listed.ok) for (const id of memoryIds(listed.json)) found.add(id);
|
|
160
|
+
} catch {
|
|
161
|
+
// The lookup is best effort; this run's own ids are still deleted below.
|
|
162
|
+
}
|
|
163
|
+
let removed = 0;
|
|
164
|
+
for (const id of found) {
|
|
165
|
+
try {
|
|
166
|
+
const res = await request(base, "DELETE", `/memories/${encodeURIComponent(id)}`, { key, timeoutMs: 30000 });
|
|
167
|
+
if (res.ok || res.status === 404) removed++;
|
|
168
|
+
else h.warn(`could not delete smoke memory ${clean(id, 128)}: the server answered ${res.status}`);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
h.warn(`could not delete smoke memory ${clean(id, 128)}: ${clean(redact(describeError(e), key), 80)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (found.size && removed === found.size) h.ok("deleted the smoke memories");
|
|
174
|
+
} catch (e) {
|
|
175
|
+
h.warn(`smoke cleanup failed: ${clean(redact(describeError(e), key), 80)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (failure) h.die(failure);
|
|
179
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mem0's contribution to scripts/setup.mjs: the stack .env entries that point the indexer at an
|
|
3
|
+
* existing mem0 server, and the end-of-run notices about the indexer's API key file and about
|
|
4
|
+
* a loopback-only server. Runs as a plain Node script step (no wizard context), so it only reads
|
|
5
|
+
* process environment and the paths it is given. It reads the key file only to see whether a key
|
|
6
|
+
* exists (the same parse as docker compose) and never prints or returns the key.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { envValue } from "../../../wizard/lib/paths.mjs";
|
|
12
|
+
import { parseEnvValue } from "./dotenv.mjs";
|
|
13
|
+
import { envFileProblem } from "./state.mjs";
|
|
14
|
+
|
|
15
|
+
/** Hostnames that mean "this machine" on the host and so must become the host gateway in a container. */
|
|
16
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "0.0.0.0", "[::1]"]);
|
|
17
|
+
|
|
18
|
+
/** Appended to every URL error. The messages never echo the URL, which may hold a secret. */
|
|
19
|
+
const SECRET_HINT = "put MEM0_API_KEY in the mem0 env file instead of the URL";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse and validate the mem0 server URL as the operator's host sees it.
|
|
23
|
+
* @param {string} hostUrl
|
|
24
|
+
* @returns {URL}
|
|
25
|
+
*/
|
|
26
|
+
function parseHostUrl(hostUrl) {
|
|
27
|
+
const text = String(hostUrl ?? "").trim();
|
|
28
|
+
// The URL parser silently drops tabs and newlines, so check the raw text: a newline would
|
|
29
|
+
// otherwise let one value write a second KEY=value line into the stack .env.
|
|
30
|
+
if (/[\x00-\x20\x7f]/.test(text)) {
|
|
31
|
+
throw new Error(`MEM0_API_URL must not contain spaces or control characters; ${SECRET_HINT}`);
|
|
32
|
+
}
|
|
33
|
+
if (text.includes("$")) {
|
|
34
|
+
throw new Error(`MEM0_API_URL must not contain '$' (compose would interpolate it); ${SECRET_HINT}`);
|
|
35
|
+
}
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(text);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error("MEM0_API_URL is not a valid URL (expected http://host:port or https://host:port)");
|
|
41
|
+
}
|
|
42
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
43
|
+
throw new Error(`MEM0_API_URL must use http or https (got: ${url.protocol.replace(":", "")})`);
|
|
44
|
+
}
|
|
45
|
+
if (url.username || url.password) {
|
|
46
|
+
// The stack .env is world-readable; secrets belong in the mode 600 env file.
|
|
47
|
+
throw new Error(`MEM0_API_URL must not contain credentials; ${SECRET_HINT}`);
|
|
48
|
+
}
|
|
49
|
+
if (url.search || url.hash) {
|
|
50
|
+
throw new Error(`MEM0_API_URL must not contain a query or fragment; ${SECRET_HINT}`);
|
|
51
|
+
}
|
|
52
|
+
return url;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The normalized host-view URL (scheme, host, port and path; no trailing slash). This is what
|
|
57
|
+
* the stack .env stores as MEM0_API_URL, never the raw string.
|
|
58
|
+
* @param {string} hostUrl
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeHostUrl(hostUrl) {
|
|
62
|
+
return parseHostUrl(hostUrl).toString().replace(/\/+$/, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The server URL as the indexer container sees it: loopback hosts become host.docker.internal;
|
|
67
|
+
* scheme, port and path are kept and a trailing slash is removed.
|
|
68
|
+
* @param {string} hostUrl URL as the operator's host sees it (for example http://127.0.0.1:18888)
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function indexerUrl(hostUrl) {
|
|
72
|
+
const url = parseHostUrl(hostUrl);
|
|
73
|
+
if (LOOPBACK_HOSTS.has(url.hostname)) url.hostname = "host.docker.internal";
|
|
74
|
+
return url.toString().replace(/\/+$/, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Absolute path of the shipped LiteLLM config under the code root. */
|
|
78
|
+
function shippedLitellmConfig() {
|
|
79
|
+
const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
80
|
+
const root = path.resolve(process.env.PM_PACKAGE_ROOT || moduleRoot);
|
|
81
|
+
const file = path.join(root, "config", "litellm.lemond.yaml");
|
|
82
|
+
if (!fs.existsSync(file)) {
|
|
83
|
+
throw new Error("shipped config/litellm.lemond.yaml missing: broken install");
|
|
84
|
+
}
|
|
85
|
+
return file;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The env file path for a custom location: MEM0_ENV_FILE from the process environment, with a
|
|
90
|
+
* leading ~ expanded, or "" when the process does not set one.
|
|
91
|
+
* @param {NodeJS.ProcessEnv} env
|
|
92
|
+
* @param {string} [home]
|
|
93
|
+
*/
|
|
94
|
+
function customEnvFile(env, home) {
|
|
95
|
+
const raw = (env.MEM0_ENV_FILE || "").trim();
|
|
96
|
+
if (!raw) return "";
|
|
97
|
+
if (raw === "~" || raw.startsWith("~/")) return path.join(home || env.HOME || "", raw.slice(2));
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {{ repo: string, home?: string, env: NodeJS.ProcessEnv }} opts
|
|
103
|
+
* @returns {{ entries: [string, string][], drop: string[], report: string[] }}
|
|
104
|
+
* entries: KEY=value lines to add to the stack .env; drop: keys to remove first;
|
|
105
|
+
* report: KEY=value strings echoed in the "wrote .env" line
|
|
106
|
+
*/
|
|
107
|
+
export function prepareStackEnv({ repo, home, env }) {
|
|
108
|
+
const stackEnv = path.join(repo, ".env");
|
|
109
|
+
const rawUrl = (env.MEM0_API_URL || envValue(stackEnv, "MEM0_API_URL")).trim();
|
|
110
|
+
if (!rawUrl) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"MEM0_API_URL is required (the mem0 server URL as this host sees it, e.g. http://127.0.0.1:18888); " +
|
|
113
|
+
"the wizard sets it before running setup",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const hostUrl = normalizeHostUrl(rawUrl);
|
|
117
|
+
const containerUrl = indexerUrl(rawUrl);
|
|
118
|
+
|
|
119
|
+
// compose.yaml interpolates every service before it filters by profile, and `litellm` mounts
|
|
120
|
+
// ${LITELLM_CONFIG:?...}. litellm is gated by the profile "hindsight", so under the mem0 profile it never
|
|
121
|
+
// runs and the value is inert; it only has to be set (to an existing file) for `docker compose config`
|
|
122
|
+
// and `up` to get past interpolation. Precedence: the operator's process env, then a non-empty value
|
|
123
|
+
// already in the stack .env, then the shipped config. LITELLM_CONFIG is always dropped and rewritten, which
|
|
124
|
+
// also removes an empty `LITELLM_CONFIG=` line that would still fail the `:?` check.
|
|
125
|
+
const litellmConfig =
|
|
126
|
+
(env.LITELLM_CONFIG || "").trim() || envValue(stackEnv, "LITELLM_CONFIG").trim() || shippedLitellmConfig();
|
|
127
|
+
|
|
128
|
+
const profiles = ["mem0"];
|
|
129
|
+
const notebookYml = path.join(home, ".config/hindsight/notebooklm.yml");
|
|
130
|
+
if (fs.existsSync(notebookYml) && /^enabled:\s*true\b/m.test(fs.readFileSync(notebookYml, "utf8"))) {
|
|
131
|
+
profiles.push("notebooklm");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @type {[string, string][]} */
|
|
135
|
+
const entries = [
|
|
136
|
+
["MEM0_API_URL", hostUrl],
|
|
137
|
+
["MEM0_INDEXER_URL", containerUrl],
|
|
138
|
+
["MEMORY_API_URL", containerUrl],
|
|
139
|
+
// Like Hindsight, name the profile in the stack .env so a plain `docker compose config` agrees with the wizard.
|
|
140
|
+
["COMPOSE_PROFILES", profiles.join(",")],
|
|
141
|
+
["MEMORY_SINK", "mem0"],
|
|
142
|
+
["LITELLM_CONFIG", litellmConfig],
|
|
143
|
+
];
|
|
144
|
+
const drop = ["MEM0_API_URL", "MEM0_INDEXER_URL", "COMPOSE_PROFILES", "LITELLM_CONFIG", "MEMORY_API_URL", "MEMORY_SINK"];
|
|
145
|
+
const report = [`MEM0_INDEXER_URL=${containerUrl}`, `COMPOSE_PROFILES=${profiles.join(",")}`, `LITELLM_CONFIG=${litellmConfig}`];
|
|
146
|
+
|
|
147
|
+
// A custom key file location is a path, not a secret: persist it so a hand-run `docker compose`
|
|
148
|
+
// and `./pm up` read the same env_file as this run. With no custom path in the process
|
|
149
|
+
// environment an existing line is left alone (the wizard reads it back too).
|
|
150
|
+
const envFile = customEnvFile(env, home);
|
|
151
|
+
if (envFile) {
|
|
152
|
+
const problem = envFileProblem(envFile);
|
|
153
|
+
if (problem) throw new Error(`MEM0_ENV_FILE: ${problem}`);
|
|
154
|
+
entries.push(["MEM0_ENV_FILE", envFile]);
|
|
155
|
+
drop.push("MEM0_ENV_FILE");
|
|
156
|
+
report.push(`MEM0_ENV_FILE=${envFile}`);
|
|
157
|
+
}
|
|
158
|
+
return { entries, drop, report };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Printed at the end of setup: the indexer's mem0 key file is missing, has no key or is too open,
|
|
163
|
+
* and the server URL points at a loopback address the container may not reach.
|
|
164
|
+
* @param {{ repo?: string, home: string, env: NodeJS.ProcessEnv }} opts
|
|
165
|
+
* @returns {string[]}
|
|
166
|
+
*/
|
|
167
|
+
export function setupNotices({ repo, home, env }) {
|
|
168
|
+
const stackEnvFile = repo ? path.join(repo, ".env") : "";
|
|
169
|
+
const configured = customEnvFile(env, home) || (stackEnvFile ? customEnvFile({ MEM0_ENV_FILE: envValue(stackEnvFile, "MEM0_ENV_FILE") }, home) : "");
|
|
170
|
+
const file = configured || path.join(home, ".config/personal-memory/mem0.env");
|
|
171
|
+
// Same parse as docker compose: an empty, commented or unterminated value is no key.
|
|
172
|
+
const hasKey = fs.existsSync(file) && parseEnvValue(fs.readFileSync(file, "utf8"), "MEM0_API_KEY") !== "";
|
|
173
|
+
/** @type {string[]} */
|
|
174
|
+
const notes = [];
|
|
175
|
+
if (!hasKey) {
|
|
176
|
+
notes.push(
|
|
177
|
+
`NOTE: mem0 needs an API key for the indexer: put MEM0_API_KEY=... in ${file} (mode 600).`,
|
|
178
|
+
" Not needed when the server runs with AUTH_DISABLED.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (fs.existsSync(file)) {
|
|
182
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
183
|
+
if (mode & 0o077) {
|
|
184
|
+
notes.push(
|
|
185
|
+
`NOTE: ${file} is readable by group or others (mode ${mode.toString(8)}). Run: chmod 600 ${file}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const dir = path.dirname(file);
|
|
189
|
+
const dirMode = fs.statSync(dir).mode & 0o777;
|
|
190
|
+
if (dirMode & 0o077) {
|
|
191
|
+
notes.push(`NOTE: ${dir} is accessible to group or others (mode ${dirMode.toString(8)}). Run: chmod 700 ${dir}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const rawUrl = (env.MEM0_API_URL || (stackEnvFile ? envValue(stackEnvFile, "MEM0_API_URL") : "")).trim();
|
|
196
|
+
if (rawUrl) {
|
|
197
|
+
try {
|
|
198
|
+
const url = parseHostUrl(rawUrl);
|
|
199
|
+
if (LOOPBACK_HOSTS.has(url.hostname)) {
|
|
200
|
+
notes.push(
|
|
201
|
+
"NOTE: MEM0_API_URL is a loopback address; the indexer container reaches it as host.docker.internal.",
|
|
202
|
+
" On Linux a mem0 server bound only to 127.0.0.1 is NOT reachable that way: bind it to 0.0.0.0",
|
|
203
|
+
" or the docker bridge IP, or use a hostname in MEM0_API_URL.",
|
|
204
|
+
);
|
|
205
|
+
if (url.protocol === "https:") {
|
|
206
|
+
notes.push(
|
|
207
|
+
" An https loopback URL becomes host.docker.internal, which will not match the server certificate name.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// prepareStackEnv already rejected an unusable URL; nothing to add here.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return notes;
|
|
216
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mem0 wizard state on the context object, the default URL, and the API key file path.
|
|
3
|
+
* mem0 v1 is URL-only: the operator runs a mem0 REST server and this stack points the
|
|
4
|
+
* indexer at it.
|
|
5
|
+
*/
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/** A stock mem0 server publishes 8888, the Hindsight default; this repo avoids it. */
|
|
9
|
+
export const DEFAULT_API_URL = "http://127.0.0.1:18888";
|
|
10
|
+
|
|
11
|
+
/** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fill the mem0 fields of the wizard context. Idempotent: a field that is already set
|
|
15
|
+
* (TOML mapping in `init`, a test) is kept.
|
|
16
|
+
* @param {Record<string, any>} ctx
|
|
17
|
+
*/
|
|
18
|
+
export function ensureState(ctx) {
|
|
19
|
+
const state = (ctx.mem0 ||= {});
|
|
20
|
+
const defaults = {
|
|
21
|
+
// [memory.mem0] api_url / env_file, filled by the init hook.
|
|
22
|
+
apiUrl: "",
|
|
23
|
+
tomlEnvFile: "",
|
|
24
|
+
// wizard.conf value, filled by the hydrate hook.
|
|
25
|
+
savedUrl: "",
|
|
26
|
+
// The validated host-view URL this run uses, set by resolvePorts / configureModelRouting.
|
|
27
|
+
url: "",
|
|
28
|
+
};
|
|
29
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
30
|
+
if (state[key] === undefined) state[key] = value;
|
|
31
|
+
}
|
|
32
|
+
return state;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {string} home */
|
|
36
|
+
export function defaultEnvFile(home) {
|
|
37
|
+
return path.join(home || "", ".config/personal-memory/mem0.env");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The file that holds MEM0_API_KEY for the indexer: MEM0_ENV_FILE, then [memory.mem0]
|
|
42
|
+
* env_file, then MEM0_ENV_FILE in the stack .env (written by scripts/setup.mjs), then
|
|
43
|
+
* ~/.config/personal-memory/mem0.env.
|
|
44
|
+
* @param {Record<string, any>} ctx
|
|
45
|
+
* @param {Helpers} h
|
|
46
|
+
*/
|
|
47
|
+
export function resolveEnvFile(ctx, h) {
|
|
48
|
+
const state = ensureState(ctx);
|
|
49
|
+
const stackValue = ctx.repoRoot ? h.envValue(path.join(ctx.repoRoot, ".env"), "MEM0_ENV_FILE") : "";
|
|
50
|
+
const raw = (process.env.MEM0_ENV_FILE || state.tomlEnvFile || stackValue || "").trim();
|
|
51
|
+
if (!raw) return defaultEnvFile(ctx.home);
|
|
52
|
+
return h.expandHome(raw, ctx.home);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What is wrong with an env file path, or "". Compose interpolates the path in the
|
|
57
|
+
* indexer's `env_file` and the stack .env stores it, so it must be absolute and free of control
|
|
58
|
+
* characters, quotes, '$', backslashes, ' #' and leading or trailing spaces.
|
|
59
|
+
* @param {string} file
|
|
60
|
+
*/
|
|
61
|
+
export function envFileProblem(file) {
|
|
62
|
+
if (!path.isAbsolute(file)) return "the mem0 env file path must be absolute (MEM0_ENV_FILE or env_file)";
|
|
63
|
+
if (/[\x00-\x1f\x7f$"'`\\]/.test(file) || file.includes(" #") || file !== file.trim()) {
|
|
64
|
+
return "the mem0 env file path must not contain control characters, quotes, '$', backslashes, ' #' or edge spaces";
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|