@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,124 @@
|
|
|
1
|
+
# mem0 operator runbook (URL-only)
|
|
2
|
+
|
|
3
|
+
This stack does **not** run a mem0 container. You run a [self-hosted mem0 REST
|
|
4
|
+
server](https://docs.mem0.ai/open-source/features/rest-api); `./setup` only points
|
|
5
|
+
the shared indexer at it.
|
|
6
|
+
|
|
7
|
+
**Not** the hosted platform at `https://api.mem0.ai`. That API shape differs
|
|
8
|
+
(`/v1/…`); the indexer sink and wizard smoke talk to the open-source REST
|
|
9
|
+
server only (no `/v1/` prefix).
|
|
10
|
+
|
|
11
|
+
## What `./setup` owns vs what you own
|
|
12
|
+
|
|
13
|
+
| You (provider-native) | This stack |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| Start the mem0 REST server (JWT, Postgres/pgvector, dashboard) | Write `MEM0_API_URL` / `MEM0_INDEXER_URL` into the stack `.env` |
|
|
16
|
+
| Embedder / LLM keys for mem0 itself | Indexer sink (`MEMORY_SINK=mem0`) and host-gateway rewrite |
|
|
17
|
+
| Create an API key (dashboard or `POST /api-keys`) | Store `MEM0_API_KEY` in `~/.config/personal-memory/mem0.env` (mode **600**) |
|
|
18
|
+
| Optional `AUTH_DISABLED=true` (dev only) | Smoke: `GET /docs`, retain, search, delete-by-id |
|
|
19
|
+
|
|
20
|
+
## URLs
|
|
21
|
+
|
|
22
|
+
- **`MEM0_API_URL`** — host view (what you curl from this machine), e.g.
|
|
23
|
+
`http://127.0.0.1:18888`. Default port in the manifest is **18888**.
|
|
24
|
+
- **`MEM0_INDEXER_URL`** — container view. `./setup` derives it from
|
|
25
|
+
`MEM0_API_URL` by rewriting loopback hosts (`127.0.0.1`, `localhost`,
|
|
26
|
+
`0.0.0.0`, `[::1]`) to `host.docker.internal`. Do not hand-edit it to
|
|
27
|
+
`127.0.0.1` inside the container; validation requires it to match that rewrite.
|
|
28
|
+
|
|
29
|
+
## Linux loopback
|
|
30
|
+
|
|
31
|
+
`host.docker.internal` is the docker bridge gateway, **not** the host's
|
|
32
|
+
`127.0.0.1`. A mem0 process bound only to `127.0.0.1` is unreachable from the
|
|
33
|
+
indexer container. Bind the server to `0.0.0.0` (or the bridge IP), or put a
|
|
34
|
+
real hostname in `MEM0_API_URL`. An `https://localhost` URL becomes
|
|
35
|
+
`https://host.docker.internal`, which usually fails TLS name checks — use a
|
|
36
|
+
hostname the certificate covers.
|
|
37
|
+
|
|
38
|
+
## API key file
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
~/.config/personal-memory/mem0.env # mode 600; directory mode 700
|
|
42
|
+
MEM0_API_KEY=<key>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Never put `MEM0_API_KEY` in the stack `.env` (mode 644), `wizard.conf`, or
|
|
46
|
+
`config.toml`. Override the path with `MEM0_ENV_FILE` / `[memory.mem0] env_file`
|
|
47
|
+
if needed; then `./setup` must record that path in the stack `.env`.
|
|
48
|
+
|
|
49
|
+
With **`AUTH_DISABLED=true`** on the server (development only), no key is
|
|
50
|
+
required. Auth-enabled servers need a key or smoke/indexer calls return 401.
|
|
51
|
+
|
|
52
|
+
## Embedder
|
|
53
|
+
|
|
54
|
+
Search fails if the server's embedder is misconfigured. Smoke looks for the
|
|
55
|
+
fact stored with `infer: false`; a miss points at the embedder key and server
|
|
56
|
+
logs, not at this stack.
|
|
57
|
+
|
|
58
|
+
## Cleartext keys
|
|
59
|
+
|
|
60
|
+
Over plain `http://` to a non-loopback host, `X-API-Key` travels in cleartext.
|
|
61
|
+
Prefer `https://`, or keep the server on loopback / the same host.
|
|
62
|
+
|
|
63
|
+
## Coding agents
|
|
64
|
+
|
|
65
|
+
There is no `pluginPackage`. Agents talk to your server via the Python SDK,
|
|
66
|
+
the self-hosted MCP bridge, or direct REST (`POST /memories`, `POST /search`). The
|
|
67
|
+
wizard's coding-agent step prints those paths and can register Cursor MCP; see
|
|
68
|
+
`providers/mem0/lib/coding-agent.mjs`.
|
|
69
|
+
|
|
70
|
+
### Cursor MCP (`~/.cursor/mcp.json`)
|
|
71
|
+
|
|
72
|
+
Mem0's official MCP (`https://mcp.mem0.ai`) is **platform/cloud only**. For a
|
|
73
|
+
self-hosted REST server this repo registers [`@yeyuan98/mem0-mcp`](https://www.npmjs.com/package/@yeyuan98/mem0-mcp)
|
|
74
|
+
via the launcher `cli/mem0-mcp.mjs` (loads the key from the env file, then
|
|
75
|
+
`npx -y @yeyuan98/mem0-mcp`).
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
MEMORY_PROVIDER=mem0 npx openmem install-mcp --yes
|
|
79
|
+
# checkout: MEMORY_PROVIDER=mem0 npx . install-mcp --yes
|
|
80
|
+
# or accept the coding-agent MCP confirm in setup
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Shape written (URL and env-file **path** only — never the key value):
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"mcpServers": {
|
|
88
|
+
"mem0": {
|
|
89
|
+
"command": "node",
|
|
90
|
+
"args": ["/path/to/openmem/cli/mem0-mcp.mjs"],
|
|
91
|
+
"env": {
|
|
92
|
+
"MEM0_API_URL": "http://127.0.0.1:18888",
|
|
93
|
+
"MEM0_BASE_URL": "http://127.0.0.1:18888",
|
|
94
|
+
"MEM0_ENV_FILE": "~/.config/personal-memory/mem0.env"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**API key:** the launcher reads `MEM0_API_KEY` from `MEM0_ENV_FILE` when the
|
|
102
|
+
variable is not already in the process environment. You can also
|
|
103
|
+
`export MEM0_API_KEY=…` (e.g. `set -a; source ~/.config/personal-memory/mem0.env; set +a`)
|
|
104
|
+
before starting Cursor so the agent inherits it. **Never** put `MEM0_API_KEY` in
|
|
105
|
+
`mcp.json`. Existing `hindsight` / `codegraph` / `notebooklm` servers are left alone.
|
|
106
|
+
|
|
107
|
+
Restart Cursor Agent after install.
|
|
108
|
+
|
|
109
|
+
### Switch to / from mem0
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
npx openmem switch mem0 --yes
|
|
113
|
+
# later: npx openmem switch hindsight --yes
|
|
114
|
+
# checkout: npx . switch mem0 --yes
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Updates `wizard.conf` (and `config.toml` `[memory].provider` when present), restarts the stack
|
|
118
|
+
unless `--no-stack`, and runs `install-mcp --provider-only` unless `--no-mcp`. Does **not**
|
|
119
|
+
migrate memories. Flags: [README — Switch memory provider](../../README.md#switch-memory-provider).
|
|
120
|
+
|
|
121
|
+
## Compose
|
|
122
|
+
|
|
123
|
+
`providers/mem0/compose.yaml` defines no mem0 service — only env +
|
|
124
|
+
`extra_hosts` on the shared `indexer`. Profile `mem0` selects that fragment.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# mem0 compose fragment (URL-only mode). Layered over compose.yaml; the wizard passes both, then `up -d`:
|
|
2
|
+
# docker compose -f compose.yaml -f providers/mem0/compose.yaml --profile mem0 up -d
|
|
3
|
+
# Add `-f compose.override.yaml` when you use the generated secret masks (Compose skips that file once -f is given).
|
|
4
|
+
#
|
|
5
|
+
# URL-only: this fragment defines NO service. The indexer talks to a mem0 REST server you already run (usually on
|
|
6
|
+
# the host, or on another machine); nothing here starts, builds or pulls a mem0 container. The reason is that the
|
|
7
|
+
# current self-hosted mem0 server (JWT/API-key auth, dashboard, Postgres + pgvector) has no current published image:
|
|
8
|
+
# `mem0/mem0-api-server:latest` on Docker Hub predates the auth. A mem0 service is a documented follow-up: build
|
|
9
|
+
# `server/` from a pinned mem0 git ref next to `pgvector/pgvector:pg17`, pinned and verified. Until then there is no
|
|
10
|
+
# `mem0` profile service, so `--profile mem0` only selects this fragment; it starts the provider-neutral indexer
|
|
11
|
+
# and its state-init step and nothing else.
|
|
12
|
+
#
|
|
13
|
+
# What the base compose.yaml gives you:
|
|
14
|
+
# - `indexer` and `indexer-state-init` have no profile and no provider dependency, so they start for every
|
|
15
|
+
# provider. This fragment points the indexer at the mem0 server.
|
|
16
|
+
# - `litellm` and `hindsight` carry the profile "hindsight" and do not start under this profile.
|
|
17
|
+
# Compose still interpolates every service before it filters by profile, so the stack .env needs LITELLM_CONFIG
|
|
18
|
+
# even though litellm never runs here. scripts/setup.mjs (providers/mem0/lib/stack-env.mjs) writes an inert value.
|
|
19
|
+
#
|
|
20
|
+
# Loopback caveat (Linux): host.docker.internal resolves to the docker bridge gateway, not to the host's 127.0.0.1.
|
|
21
|
+
# A mem0 server bound only to 127.0.0.1 is therefore NOT reachable from the indexer container. Bind it to 0.0.0.0
|
|
22
|
+
# or to the docker bridge IP, or give MEM0_API_URL a real hostname. An https://localhost URL is rewritten to
|
|
23
|
+
# https://host.docker.internal, which will not match the server certificate name (use a hostname the certificate
|
|
24
|
+
# covers). ./setup prints a note when MEM0_API_URL is a loopback address.
|
|
25
|
+
#
|
|
26
|
+
# Stack .env keys read here (written by ./setup):
|
|
27
|
+
# MEM0_INDEXER_URL the server as the indexer container sees it. The wizard derives it from MEM0_API_URL (the host
|
|
28
|
+
# view) by rewriting localhost, 127.0.0.1, 0.0.0.0 and [::1] to host.docker.internal.
|
|
29
|
+
# MEM0_ENV_FILE optional; default ~/.config/personal-memory/mem0.env. Holds ONLY MEM0_API_KEY=... for the
|
|
30
|
+
# indexer (mode 600, outside the repo). Not needed when the server runs with AUTH_DISABLED.
|
|
31
|
+
# mem0's own server secrets (JWT_SECRET, ADMIN_API_KEY, embedder keys) are the operator's concern
|
|
32
|
+
# and never go through this stack.
|
|
33
|
+
|
|
34
|
+
services:
|
|
35
|
+
indexer:
|
|
36
|
+
environment:
|
|
37
|
+
# Selects indexer/sinks/mem0.py and gives it the server URL (container view, not the host view).
|
|
38
|
+
MEMORY_SINK: mem0
|
|
39
|
+
MEMORY_API_URL: ${MEM0_INDEXER_URL:?run ./setup (mem0 API URL)}
|
|
40
|
+
# required: false lets `config` and `up` run before the key file exists (Compose 2.24+).
|
|
41
|
+
env_file:
|
|
42
|
+
- path: ${MEM0_ENV_FILE:-${HOME}/.config/personal-memory/mem0.env}
|
|
43
|
+
required: false
|
|
44
|
+
# Reach a server on the host through host.docker.internal.
|
|
45
|
+
extra_hosts:
|
|
46
|
+
- "host.docker.internal:host-gateway"
|
|
47
|
+
# No depends_on: the server is external, and the indexer's watcher retries until it answers.
|
|
48
|
+
|
|
49
|
+
notebooklm:
|
|
50
|
+
environment:
|
|
51
|
+
MEMORY_SINK: mem0
|
|
52
|
+
MEMORY_API_URL: ${MEM0_INDEXER_URL:?run ./setup (mem0 API URL)}
|
|
53
|
+
env_file:
|
|
54
|
+
- path: ${MEM0_ENV_FILE:-${HOME}/.config/personal-memory/mem0.env}
|
|
55
|
+
required: false
|
|
56
|
+
extra_hosts:
|
|
57
|
+
- "host.docker.internal:host-gateway"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step "8. Coding-agent plugin", mem0 half: there is no npx pluginPackage for the
|
|
3
|
+
* self-hosted REST server. Print concrete SDK / MCP / direct-REST paths, then
|
|
4
|
+
* optionally merge the mem0 MCP into ~/.cursor/mcp.json (--yes or confirm).
|
|
5
|
+
*/
|
|
6
|
+
import { installMem0Mcp } from "./mcp-install.mjs";
|
|
7
|
+
import { ensureState } from "./state.mjs";
|
|
8
|
+
import { displayUrl, resolveApiUrl, urlProblem } from "./url.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
|
|
11
|
+
|
|
12
|
+
const SDK_DOCS = "https://docs.mem0.ai/open-source/python-quickstart";
|
|
13
|
+
const MCP_PACKAGE = "https://www.npmjs.com/package/@yeyuan98/mem0-mcp";
|
|
14
|
+
const REST_DOCS = "https://docs.mem0.ai/open-source/features/rest-api";
|
|
15
|
+
const OPERATOR = "providers/mem0/OPERATOR.md";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Record<string, any>} ctx
|
|
19
|
+
* @param {Helpers} h
|
|
20
|
+
*/
|
|
21
|
+
export async function setupCodingAgent(ctx, h) {
|
|
22
|
+
ensureState(ctx);
|
|
23
|
+
const raw = resolveApiUrl(ctx, h);
|
|
24
|
+
const base = urlProblem(raw) ? "(set MEM0_API_URL)" : displayUrl(raw);
|
|
25
|
+
|
|
26
|
+
h.info("mem0 has no coding-agent pluginPackage; use the self-hosted server directly:");
|
|
27
|
+
h.info(` 1. SDK — pip install mem0ai, point Memory at ${base} (see ${SDK_DOCS})`);
|
|
28
|
+
h.info(
|
|
29
|
+
` 2. MCP — MEMORY_PROVIDER=mem0 npm run install-mcp -- --yes` +
|
|
30
|
+
` (npx @yeyuan98/mem0-mcp via cli/mem0-mcp.mjs; see ${MCP_PACKAGE} and ${OPERATOR})`,
|
|
31
|
+
);
|
|
32
|
+
h.info(
|
|
33
|
+
` 3. REST — POST ${base}/memories and POST ${base}/search with X-API-Key` +
|
|
34
|
+
` (see ${REST_DOCS}; stack wiring in ${OPERATOR})`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const codeRoot = ctx.packageRoot || ctx.repoRoot;
|
|
38
|
+
const stackRoot = ctx.repoRoot;
|
|
39
|
+
const wantMcp =
|
|
40
|
+
Boolean(ctx.yes) ||
|
|
41
|
+
(await h.confirm(ctx, "Register mem0 MCP in ~/.cursor/mcp.json now?", "y"));
|
|
42
|
+
if (wantMcp) {
|
|
43
|
+
await installMem0Mcp(
|
|
44
|
+
{
|
|
45
|
+
home: ctx.home,
|
|
46
|
+
repoRoot: codeRoot,
|
|
47
|
+
stackRoot,
|
|
48
|
+
dry: Boolean(ctx.dry),
|
|
49
|
+
env: process.env,
|
|
50
|
+
},
|
|
51
|
+
h,
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
h.info("skip MCP merge; later: MEMORY_PROVIDER=mem0 npm run install-mcp -- --yes");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
h.ok("coding-agent: documented SDK / MCP / REST paths (pluginPackage remains null)");
|
|
58
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mem0's slice of ~/.config/personal-memory/config.toml: the [memory.mem0] table
|
|
3
|
+
* (api_url, optional env_file). Never a key.
|
|
4
|
+
*/
|
|
5
|
+
import { ensureState } from "./state.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {Record<string, unknown>} cfg merged TOML config
|
|
9
|
+
* @returns {{ apiUrl: string, envFile: string }}
|
|
10
|
+
*/
|
|
11
|
+
export function defaultsFromConfig(cfg) {
|
|
12
|
+
const memory = /** @type {Record<string, unknown>} */ (cfg?.memory || {});
|
|
13
|
+
const mem0 = /** @type {Record<string, unknown>} */ (memory.mem0 || {});
|
|
14
|
+
return {
|
|
15
|
+
apiUrl: mem0.api_url != null ? String(mem0.api_url).trim() : "",
|
|
16
|
+
envFile: mem0.env_file != null ? String(mem0.env_file).trim() : "",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Seed ctx.mem0 from [memory.mem0]. Env vars still win over these (see resolveApiUrl).
|
|
22
|
+
* @param {Record<string, any>} ctx
|
|
23
|
+
*/
|
|
24
|
+
export function applyConfig(ctx) {
|
|
25
|
+
const state = ensureState(ctx);
|
|
26
|
+
const d = defaultsFromConfig(ctx.pmMerged || {});
|
|
27
|
+
if (d.apiUrl && !state.apiUrl) state.apiUrl = d.apiUrl;
|
|
28
|
+
if (d.envFile && !state.tomlEnvFile) state.tomlEnvFile = d.envFile;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tables merged into config.toml: `memory.mem0`, which the wizard merges into [memory] (it
|
|
33
|
+
* always sets `provider` itself).
|
|
34
|
+
* @param {Record<string, any>} ctx
|
|
35
|
+
* @param {string} url the resolved host-view server URL
|
|
36
|
+
* @returns {Record<string, Record<string, unknown>>}
|
|
37
|
+
*/
|
|
38
|
+
export function configToml(ctx, url) {
|
|
39
|
+
const state = ensureState(ctx);
|
|
40
|
+
/** @type {Record<string, unknown>} */
|
|
41
|
+
const mem0 = { api_url: url };
|
|
42
|
+
const envFile = (process.env.MEM0_ENV_FILE || state.tomlEnvFile || "").trim();
|
|
43
|
+
if (envFile) mem0.env_file = envFile;
|
|
44
|
+
return { memory: { mem0 } };
|
|
45
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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 { capturedKey, captureKeyFromEnv } from "./secret.mjs";
|
|
11
|
+
import { ensureState, envFileProblem, resolveEnvFile } from "./state.mjs";
|
|
12
|
+
import { displayUrl, resolveApiUrl, urlProblem } from "./url.mjs";
|
|
13
|
+
|
|
14
|
+
/** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Record<string, any>} ctx
|
|
18
|
+
* @param {Helpers} h
|
|
19
|
+
*/
|
|
20
|
+
export async function configureUrlAndKey(ctx, h) {
|
|
21
|
+
const state = ensureState(ctx);
|
|
22
|
+
h.step("3. mem0 server URL and API key");
|
|
23
|
+
const interactive = !ctx.yes && !ctx.skipBackendStep;
|
|
24
|
+
if (ctx.skipBackendStep) h.info("mem0 URL and key come from the setup UI (environment)");
|
|
25
|
+
|
|
26
|
+
// URL.
|
|
27
|
+
let raw = resolveApiUrl(ctx, h);
|
|
28
|
+
if (interactive) {
|
|
29
|
+
raw = String(await h.ask(ctx, "mem0 REST server URL (as this host sees it)", raw)).trim().replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
const urlIssue = urlProblem(raw);
|
|
32
|
+
if (urlIssue) {
|
|
33
|
+
h.problem(ctx, `mem0 API URL: ${urlIssue}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const url = displayUrl(raw);
|
|
37
|
+
state.apiUrl = url;
|
|
38
|
+
state.url = url;
|
|
39
|
+
process.env.MEM0_API_URL = url;
|
|
40
|
+
h.ok(`mem0 server ${url} (an external server you run; this stack does not start it)`);
|
|
41
|
+
|
|
42
|
+
// Key file.
|
|
43
|
+
const envFile = resolveEnvFile(ctx, h);
|
|
44
|
+
const fileIssue = envFileProblem(envFile);
|
|
45
|
+
if (fileIssue) {
|
|
46
|
+
h.problem(ctx, fileIssue);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const existing = readKey(envFile);
|
|
50
|
+
// Normally captured by the init hook (before any child process starts); this covers a caller
|
|
51
|
+
// that skipped init and is a no-op once the variables are gone.
|
|
52
|
+
captureKeyFromEnv();
|
|
53
|
+
const key = capturedKey();
|
|
54
|
+
if (key) h.info("using the API key from the environment");
|
|
55
|
+
|
|
56
|
+
if (key) {
|
|
57
|
+
const keyIssue = keyProblem(key);
|
|
58
|
+
if (keyIssue) {
|
|
59
|
+
h.problem(ctx, keyIssue);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (ctx.dry) {
|
|
63
|
+
h.info(`[dry-run] write MEM0_API_KEY to ${envFile} (mode 600, directory mode 700)`);
|
|
64
|
+
} else {
|
|
65
|
+
const result = writeKey(envFile, key);
|
|
66
|
+
if (result === "unchanged") h.ok(`kept ${envFile} (same key, mode 600)`);
|
|
67
|
+
else h.ok(`${result === "created" ? "created" : "updated"} ${envFile} with MEM0_API_KEY (mode 600)`);
|
|
68
|
+
}
|
|
69
|
+
} else if (existing) {
|
|
70
|
+
h.ok(`kept the API key already in ${envFile}`);
|
|
71
|
+
if (tooOpen(envFile)) {
|
|
72
|
+
h.warn(`${envFile} is group/other accessible (mode ${fileMode(envFile).toString(8)}); run: chmod 600 ${envFile}`);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
// No prompt here: helpers.ask echoes what is typed. The setup UI (Clack) asks with a masked prompt.
|
|
76
|
+
h.info(
|
|
77
|
+
`no API key given. A server with auth enabled (the default) needs one: put MEM0_API_KEY=<key> in ${envFile} (mode 600), ` +
|
|
78
|
+
"or rerun with MEM0_API_KEY (or PM_MEM0_API_KEY) in the environment under --yes. " +
|
|
79
|
+
"A server run with AUTH_DISABLED does not need a key.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (dirTooOpen(envFile)) {
|
|
83
|
+
h.warn(`${path.dirname(envFile)} is accessible to group or others; run: chmod 700 ${path.dirname(envFile)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Non-secret state: wizard.conf (+ config.toml) record the URL only.
|
|
87
|
+
if (ctx.dry) h.info(`[dry-run] record MEM0_API_URL in ${ctx.wizardConf} and [memory.mem0] in config.toml`);
|
|
88
|
+
else h.saveWizardConf(ctx, { MEM0_API_URL: url }, { pmSections: configToml(ctx, url) });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Clack front end: ask the URL and (masked) key, hand them to the wizard through the
|
|
93
|
+
* environment and skip the wizard's own prompts.
|
|
94
|
+
* @param {{ repoRoot: string, home: string, wizardConf: string, pmMerged?: Record<string, unknown> }} ctx
|
|
95
|
+
* @param {typeof import("@clack/prompts")} ui
|
|
96
|
+
* @param {Helpers} h
|
|
97
|
+
* @returns {Promise<Record<string, string>>}
|
|
98
|
+
*/
|
|
99
|
+
export async function clackSetup(ctx, ui, h) {
|
|
100
|
+
const stub = /** @type {Record<string, any>} */ (ctx);
|
|
101
|
+
ensureState(stub);
|
|
102
|
+
applyConfig(stub);
|
|
103
|
+
const current = resolveApiUrl(stub, h);
|
|
104
|
+
const existing = readKey(resolveEnvFile(stub, h));
|
|
105
|
+
|
|
106
|
+
const typed = await ui.text({
|
|
107
|
+
message: "mem0 REST server URL (as this host sees it)",
|
|
108
|
+
placeholder: current,
|
|
109
|
+
defaultValue: current,
|
|
110
|
+
validate: (v) => urlProblem(String(v || "").trim().replace(/\/+$/, "") || current) || undefined,
|
|
111
|
+
});
|
|
112
|
+
if (ui.isCancel(typed)) {
|
|
113
|
+
ui.cancel("Setup cancelled.");
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
const url = String(typed || "").trim().replace(/\/+$/, "") || current;
|
|
117
|
+
|
|
118
|
+
const secret = await ui.password({
|
|
119
|
+
message: existing
|
|
120
|
+
? "mem0 API key (empty keeps the one in the env file)"
|
|
121
|
+
: "mem0 API key (empty if the server has auth disabled)",
|
|
122
|
+
validate: (v) => keyProblem(String(v || "").trim()) || undefined,
|
|
123
|
+
});
|
|
124
|
+
if (ui.isCancel(secret)) {
|
|
125
|
+
ui.cancel("Setup cancelled.");
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @type {Record<string, string>} */
|
|
130
|
+
const handoff = { WIZARD_SKIP_BACKEND_STEP: "1", MEM0_API_URL: url };
|
|
131
|
+
const key = String(secret || "").trim();
|
|
132
|
+
if (key) handoff.PM_MEM0_API_KEY = key;
|
|
133
|
+
ui.note("Next: Docker, indexing, and starting the stack.", "mem0 configured");
|
|
134
|
+
return handoff;
|
|
135
|
+
}
|
|
@@ -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
|
+
}
|