@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.
- package/.agents/skills/openmem/SKILL.md +34 -0
- package/LICENSE +190 -0
- package/README.md +140 -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 +34 -0
- package/cli/coding-agent/context-store.mjs +81 -0
- package/cli/coding-agent/context-trim.mjs +897 -0
- package/cli/coding-agent/file-io.mjs +72 -0
- package/cli/coding-agent/hook-harnesses.mjs +88 -0
- package/cli/coding-agent/hook-runner.mjs +284 -0
- package/cli/coding-agent/install-access.mjs +119 -0
- package/cli/coding-agent/install-context-proxy.mjs +333 -0
- package/cli/coding-agent/install-rtk.mjs +60 -0
- package/cli/coding-agent/install-shim.mjs +201 -0
- package/cli/coding-agent/opencode-config.mjs +107 -0
- package/cli/coding-agent/opencode-plugin.mjs +132 -0
- package/cli/coding-agent/opencode2-plugin/index.js +299 -0
- package/cli/coding-agent/recall-frame.mjs +29 -0
- package/cli/coding-agent/remove-upstream-access.mjs +122 -0
- package/cli/coding-agent/runtime.mjs +40 -0
- package/cli/coding-agent/session-db.mjs +626 -0
- package/cli/coding-agent/session-facade.mjs +86 -0
- package/cli/coding-agent/toml-edit.mjs +161 -0
- package/cli/cognee-mcp.mjs +260 -0
- package/cli/context-management.mjs +254 -0
- package/cli/feed-sync.mjs +27 -0
- package/cli/install-coding-agent.mjs +298 -0
- package/cli/install-memory-mcp.mjs +426 -0
- package/cli/is-main.mjs +18 -0
- package/cli/mcp/context-sql-runner.mjs +58 -0
- package/cli/mcp/context-store.mjs +670 -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/knowledge.mjs +333 -0
- package/cli/mcp/llm-synthesize.mjs +50 -0
- package/cli/mcp/mem0-scope-id.mjs +22 -0
- package/cli/mcp/memory-facade.mjs +521 -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 +38 -0
- package/cli/openmem.mjs +222 -0
- package/cli/roots.mjs +85 -0
- package/cli/schedule.mjs +176 -0
- package/cli/stack.mjs +438 -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 +227 -0
- package/config/coding-agent.json +13 -0
- package/config/hindsight.env.example +26 -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 +124 -0
- package/docs/OPENMEM.md +23 -0
- package/docs/architecture.md +245 -0
- package/docs/assets/README.md +10 -0
- package/docs/assets/setup-demo.gif +0 -0
- package/docs/coding-agent-hooks.md +75 -0
- package/docs/coding-agent-scopes.md +22 -0
- package/docs/coding-agents.md +71 -0
- package/docs/cognee.md +37 -0
- package/docs/compatibility.md +80 -0
- package/docs/concepts.md +135 -0
- package/docs/context-management.md +255 -0
- package/docs/context-proxy.md +115 -0
- package/docs/context-research.md +215 -0
- package/docs/context-store.md +277 -0
- package/docs/hindsight.md +77 -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 +104 -0
- package/docs/mem0.md +141 -0
- package/docs/memory-portability.md +73 -0
- package/docs/memory-providers.md +246 -0
- package/docs/meridian-litellm.md +198 -0
- package/docs/model-configuration.md +106 -0
- package/docs/notebooklm.md +83 -0
- package/docs/npx-install.md +172 -0
- package/docs/openmem-config.md +194 -0
- package/docs/provider-wizard-hooks.md +222 -0
- package/docs/quickstart.md +107 -0
- package/docs/setup-wizard.md +117 -0
- package/docs/sources-feeds.md +39 -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 +96 -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 +347 -0
- package/providers/hindsight/lib/coding-agent.mjs +204 -0
- package/providers/hindsight/lib/config.mjs +79 -0
- package/providers/hindsight/lib/env-setup.mjs +72 -0
- package/providers/hindsight/lib/litellm-routes.mjs +196 -0
- package/providers/hindsight/lib/meridian.mjs +307 -0
- package/providers/hindsight/lib/model-backend.mjs +243 -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 +54 -0
- package/providers/hindsight/lib/stack-env.mjs +125 -0
- package/providers/hindsight/lib/state.mjs +80 -0
- package/providers/hindsight/lib/summary.mjs +57 -0
- package/providers/hindsight/lib/validate.mjs +81 -0
- package/providers/hindsight/manifest.json +58 -0
- package/providers/hindsight/wizard.mjs +150 -0
- package/providers/letta/manifest.json +29 -0
- package/providers/mem0/OPERATOR.md +143 -0
- package/providers/mem0/compose.yaml +79 -0
- package/providers/mem0/lib/coding-agent.mjs +58 -0
- package/providers/mem0/lib/config.mjs +45 -0
- package/providers/mem0/lib/configure.mjs +201 -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/litellm.mjs +457 -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 +255 -0
- package/providers/mem0/lib/state.mjs +67 -0
- package/providers/mem0/lib/summary.mjs +88 -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 +146 -0
- package/providers/zep/manifest.json +29 -0
- package/proxy/config.yaml +33 -0
- package/proxy/openmem_context/__init__.py +6 -0
- package/proxy/openmem_context/adapters.py +504 -0
- package/proxy/openmem_context/compress.py +53 -0
- package/proxy/openmem_context/core.py +606 -0
- package/proxy/openmem_context/embed.py +212 -0
- package/proxy/openmem_context/recall.py +49 -0
- package/proxy/openmem_context/session.py +70 -0
- package/proxy/openmem_context/sessiondb.py +276 -0
- package/proxy/openmem_context/settings.py +134 -0
- package/proxy/openmem_context/store.py +129 -0
- package/proxy/openmem_context/tokens.py +123 -0
- package/proxy/openmem_context/ttl.py +78 -0
- package/proxy/openmem_context_hook.py +34 -0
- package/scripts/check-pack.mjs +219 -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 +107 -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/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/context-step.mjs +140 -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/litellm-env.mjs +55 -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 +371 -0
- package/wizard/lib/ports.mjs +94 -0
- package/wizard/lib/prompts.mjs +85 -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 +750 -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,247 @@
|
|
|
1
|
+
"""mem0 sink: the self-hosted mem0 REST server (`server/main.py` in github.com/mem0ai/mem0), stdlib only.
|
|
2
|
+
|
|
3
|
+
Namespace mapping. `bank` is the mem0 `user_id` and `doc_id` is the mem0 `run_id`, both through `scope_id`. mem0
|
|
4
|
+
trims an entity id and then rejects one that still contains whitespace (`_validate_and_trim_entity_id` in
|
|
5
|
+
`mem0/memory/main.py`; it has no length or charset rule), so `scope_id` is the identity for any id without
|
|
6
|
+
whitespace, and otherwise replaces each run of whitespace with `_` and appends `~` plus the first 8 hex digits of the
|
|
7
|
+
SHA-256 of the original id (`obsidian::My Vault` -> `obsidian::My_Vault~<8 hex>`). The suffix keeps two different
|
|
8
|
+
ids that differ only in whitespace apart, and the mapping is deterministic, so the same bank and document always
|
|
9
|
+
resolve to the same ids. It is applied to every use of the two ids (the lookup, the writes, the deletes). The
|
|
10
|
+
original text of a mapped id is not stored. Every memory also carries the fixed `agent_id`
|
|
11
|
+
`openmem-indexer`, for provenance only: lookups filter on `user_id` and `run_id` and never on `agent_id`.
|
|
12
|
+
mem0 has no bank to create (the `user_id` exists once a memory carries it), so `prepare` is a no-op.
|
|
13
|
+
|
|
14
|
+
Documents. mem0 gives every memory a server-generated id and has no upsert, so the sink resolves a document to its
|
|
15
|
+
memories with `GET /memories?user_id=<bank>&run_id=<doc_id>&top_k=1000` (percent-encoded). `retain` is
|
|
16
|
+
write-then-delete so that a failure never loses a document: it lists the document's current memory ids, writes the
|
|
17
|
+
new text, and only then deletes the listed ids (`DELETE /memories/{id}`; a 404 for an already-gone id is swallowed).
|
|
18
|
+
A failure part-way leaves the old memories, possibly next to some of the new ones; the indexer keeps the document
|
|
19
|
+
marked as changed, and its next run repeats the whole sequence, which converges on exactly the new chunks. Until
|
|
20
|
+
then a search can see duplicates. The text is split into chunks of at most 3000 characters (cut at a blank line,
|
|
21
|
+
else a newline, else a space, else mid-text; the chunks concatenate back to the original text, only chunks that are
|
|
22
|
+
pure whitespace are dropped), and each chunk is one `POST /memories` with `infer` literally false, so mem0 stores it
|
|
23
|
+
verbatim instead of asking an LLM to rewrite it. Each reply must be a JSON object whose `results` list is not empty
|
|
24
|
+
(infer=false answers one ADD result per message), otherwise the retain fails. A document of empty or whitespace-only
|
|
25
|
+
text has no chunks: its old memories are deleted and nothing is written (the document is simply absent, which is
|
|
26
|
+
what an empty document means to a search).
|
|
27
|
+
|
|
28
|
+
Limits and races. A document that would need more than 900 chunks (about 2.7 million characters) is rejected before
|
|
29
|
+
any request is sent, with a message naming the document id, so the 1000-entry listing can never hide a stale chunk
|
|
30
|
+
and a rejected retain leaves the previous version intact. The indexer retries a failed document on every run, so such
|
|
31
|
+
a document fails its whole source on every run until it is shortened or excluded. mem0 enforces no uniqueness on
|
|
32
|
+
`run_id`: two indexer runs that retain the same document concurrently (for example with different state files) can
|
|
33
|
+
both list the old memories and both write, leaving two sets of chunks. The next retain of that document lists and
|
|
34
|
+
removes all of them.
|
|
35
|
+
|
|
36
|
+
Metadata. The caller's metadata is copied; a key that mem0 reserves, drops or overwrites in the stored payload
|
|
37
|
+
(`data`, `user_id`, `agent_id`, `run_id`, `actor_id`, `hash`, `created_at`, `updated_at`, `expiration_date`, `role`,
|
|
38
|
+
`text_lemmatized`, `attributed_to`) is renamed with the prefix `meta_`. The sink then adds `tags` (a list, only when
|
|
39
|
+
there are tags), `context` and `timestamp` (only when not None; mem0 has no event-time field) and, for a document of
|
|
40
|
+
several chunks, `chunk` (0-based index) and `chunks` (the total). These sink-owned keys win over a caller key of
|
|
41
|
+
the same name.
|
|
42
|
+
|
|
43
|
+
Auth. `MEM0_API_KEY` is read from the environment inside `create`, never from a document or a path, and is sent as
|
|
44
|
+
`X-API-Key` on every request except the health check (`/docs` is open, so no key travels with it). Without a key no
|
|
45
|
+
auth header is sent. The key never appears in an error message. Over plain `http://` the key travels in cleartext:
|
|
46
|
+
use `https://` (or a loopback address) for any server that is not on the same host. The sink follows no redirects
|
|
47
|
+
(see `sinks/_http.py`; the mem0 server has `redirect_slashes=False` and API calls never redirect), so a 3xx is an
|
|
48
|
+
error carrying its status, and the key cannot be handed to another host.
|
|
49
|
+
|
|
50
|
+
URL. There is no default: mem0 publishes no standard port. `create` reads `MEMORY_API_URL` when no url is passed and
|
|
51
|
+
raises `MissingUrlError` (a `SinkError` and a `ValueError`, so the CLI reports it as a sink error) when both are empty,
|
|
52
|
+
when the URL has no http(s) scheme or host, or when it embeds credentials (`user:pass@host`; use `MEM0_API_KEY`).
|
|
53
|
+
Error messages never echo the URL given. Health is `GET /docs`, which the server leaves open (it has no dedicated
|
|
54
|
+
health route).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
import hashlib
|
|
58
|
+
import json
|
|
59
|
+
import os
|
|
60
|
+
import urllib.error
|
|
61
|
+
import urllib.parse
|
|
62
|
+
import urllib.request
|
|
63
|
+
from collections.abc import Mapping
|
|
64
|
+
|
|
65
|
+
from sinks import Capabilities, SinkError
|
|
66
|
+
from sinks._http import open_url, read_all
|
|
67
|
+
|
|
68
|
+
URL_ENV = "MEMORY_API_URL"
|
|
69
|
+
KEY_ENV = "MEM0_API_KEY"
|
|
70
|
+
AGENT_ID = "openmem-indexer"
|
|
71
|
+
CHUNK_CHARS = 3000
|
|
72
|
+
MAX_CHUNKS = 900
|
|
73
|
+
LIST_LIMIT = 1000
|
|
74
|
+
RESERVED_KEYS = frozenset(
|
|
75
|
+
{"data", "user_id", "agent_id", "run_id", "actor_id", "hash", "created_at", "updated_at", "expiration_date", "role", "text_lemmatized", "attributed_to"}
|
|
76
|
+
)
|
|
77
|
+
READ_TIMEOUT = 30
|
|
78
|
+
WRITE_TIMEOUT = 300
|
|
79
|
+
HEALTH_TIMEOUT = 5
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MissingUrlError(SinkError, ValueError):
|
|
83
|
+
"""No usable API URL. A ValueError too, so `bin/hindsight-project-index` reports it as a sink error (exit 2)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class HttpError(SinkError):
|
|
87
|
+
def __init__(self, method: str, url: str, status: int, body: str):
|
|
88
|
+
super().__init__(f"{method} {url}: HTTP {status}: {body}", status)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scope_id(value: str) -> str:
|
|
92
|
+
"""Map a bank or document id to a mem0 entity id: itself when it has no whitespace, else a whitespace-free form.
|
|
93
|
+
|
|
94
|
+
Each run of whitespace becomes `_`, then `~` and the first 8 hex digits of sha256(value) are appended.
|
|
95
|
+
"""
|
|
96
|
+
if not any(char.isspace() for char in value):
|
|
97
|
+
return value
|
|
98
|
+
pieces = []
|
|
99
|
+
in_space = False
|
|
100
|
+
for char in value:
|
|
101
|
+
if char.isspace():
|
|
102
|
+
if not in_space:
|
|
103
|
+
pieces.append("_")
|
|
104
|
+
in_space = True
|
|
105
|
+
else:
|
|
106
|
+
pieces.append(char)
|
|
107
|
+
in_space = False
|
|
108
|
+
return "".join(pieces) + "~" + hashlib.sha256(value.encode()).hexdigest()[:8]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def split_chunks(text: str, limit: int = CHUNK_CHARS) -> list[str]:
|
|
112
|
+
"""Cut `text` into pieces of at most `limit` characters whose concatenation is `text`, minus pure-whitespace pieces."""
|
|
113
|
+
pieces = []
|
|
114
|
+
position = 0
|
|
115
|
+
while len(text) - position > limit:
|
|
116
|
+
window = text[position : position + limit]
|
|
117
|
+
cut = limit
|
|
118
|
+
for separator in ("\n\n", "\n", " "):
|
|
119
|
+
found = window.rfind(separator)
|
|
120
|
+
if found >= 0:
|
|
121
|
+
cut = found + len(separator)
|
|
122
|
+
break
|
|
123
|
+
pieces.append(text[position : position + cut])
|
|
124
|
+
position += cut
|
|
125
|
+
pieces.append(text[position:])
|
|
126
|
+
return [piece for piece in pieces if piece.strip()]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Mem0Sink:
|
|
130
|
+
capabilities = Capabilities(async_ingest=False)
|
|
131
|
+
|
|
132
|
+
def __init__(self, url: str, api_key: str | None = None):
|
|
133
|
+
self.url = url.rstrip("/")
|
|
134
|
+
self._api_key = api_key or None
|
|
135
|
+
|
|
136
|
+
def _headers(self, with_body: bool) -> dict[str, str]:
|
|
137
|
+
headers = {"Content-Type": "application/json"} if with_body else {}
|
|
138
|
+
if self._api_key:
|
|
139
|
+
headers["X-API-Key"] = self._api_key
|
|
140
|
+
return headers
|
|
141
|
+
|
|
142
|
+
def _excerpt(self, body: bytes) -> str:
|
|
143
|
+
text = body.decode(errors="replace")[:300]
|
|
144
|
+
return text.replace(self._api_key, "***") if self._api_key else text
|
|
145
|
+
|
|
146
|
+
def _request(self, method: str, path: str, payload: dict | None = None, timeout: float = READ_TIMEOUT) -> object:
|
|
147
|
+
"""One request; returns the decoded JSON reply, or None for an empty or non-JSON body."""
|
|
148
|
+
url = self.url + path
|
|
149
|
+
data = json.dumps(payload).encode() if payload is not None else None
|
|
150
|
+
try:
|
|
151
|
+
with open_url(urllib.request.Request(url, data=data, headers=self._headers(data is not None), method=method), timeout) as response:
|
|
152
|
+
body = read_all(response)
|
|
153
|
+
except urllib.error.HTTPError as error:
|
|
154
|
+
try:
|
|
155
|
+
excerpt = self._excerpt(read_all(error))
|
|
156
|
+
except OSError:
|
|
157
|
+
excerpt = ""
|
|
158
|
+
raise HttpError(method, url, error.code, excerpt) from error
|
|
159
|
+
try:
|
|
160
|
+
return json.loads(body) if body else None
|
|
161
|
+
except ValueError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _memory_ids(self, user_id: str, run_id: str) -> list[str]:
|
|
165
|
+
query = urllib.parse.urlencode({"user_id": user_id, "run_id": run_id, "top_k": LIST_LIMIT}, quote_via=urllib.parse.quote)
|
|
166
|
+
reply = self._request("GET", "/memories?" + query)
|
|
167
|
+
results = reply.get("results") if isinstance(reply, dict) else None
|
|
168
|
+
if not isinstance(results, list) or not all(isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"] for item in results):
|
|
169
|
+
raise SinkError(f"GET {self.url}/memories: unexpected reply (want an object with a `results` list of memories that have an id); cannot tell which memories the document has")
|
|
170
|
+
return [item["id"] for item in results]
|
|
171
|
+
|
|
172
|
+
def _delete_ids(self, memory_ids: list[str]) -> None:
|
|
173
|
+
for memory_id in memory_ids:
|
|
174
|
+
try:
|
|
175
|
+
self._request("DELETE", "/memories/" + urllib.parse.quote(memory_id, safe=""))
|
|
176
|
+
except HttpError as error:
|
|
177
|
+
# 404: already gone (deleted server-side or by a concurrent run).
|
|
178
|
+
if error.status != 404:
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
def health(self) -> bool:
|
|
182
|
+
try:
|
|
183
|
+
with open_url(urllib.request.Request(self.url + "/docs"), HEALTH_TIMEOUT) as response:
|
|
184
|
+
return 200 <= response.status < 300
|
|
185
|
+
except (OSError, ValueError): # includes HTTPError, URLError and, via open_url, malformed HTTP replies
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def prepare(self, bank: str) -> None:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def retain(
|
|
192
|
+
self,
|
|
193
|
+
bank: str,
|
|
194
|
+
doc_id: str,
|
|
195
|
+
content: str,
|
|
196
|
+
*,
|
|
197
|
+
tags: list[str] | None = None,
|
|
198
|
+
metadata: Mapping[str, str] | None = None,
|
|
199
|
+
context: str | None = None,
|
|
200
|
+
timestamp: str | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
chunks = split_chunks(content)
|
|
203
|
+
if len(chunks) > MAX_CHUNKS:
|
|
204
|
+
raise SinkError(f"document {doc_id!r} needs {len(chunks)} chunks of at most {CHUNK_CHARS} characters; the mem0 sink accepts at most {MAX_CHUNKS} per document")
|
|
205
|
+
user_id, run_id = scope_id(bank), scope_id(doc_id)
|
|
206
|
+
base = {f"meta_{key}" if key in RESERVED_KEYS else key: value for key, value in (metadata or {}).items()}
|
|
207
|
+
if tags:
|
|
208
|
+
base["tags"] = list(tags)
|
|
209
|
+
if context is not None:
|
|
210
|
+
base["context"] = context
|
|
211
|
+
if timestamp is not None:
|
|
212
|
+
base["timestamp"] = timestamp
|
|
213
|
+
old_ids = self._memory_ids(user_id, run_id)
|
|
214
|
+
for index, chunk in enumerate(chunks):
|
|
215
|
+
meta = dict(base)
|
|
216
|
+
if len(chunks) > 1:
|
|
217
|
+
meta["chunk"] = index
|
|
218
|
+
meta["chunks"] = len(chunks)
|
|
219
|
+
body = {"messages": [{"role": "user", "content": chunk}], "user_id": user_id, "agent_id": AGENT_ID, "run_id": run_id, "infer": False, "metadata": meta}
|
|
220
|
+
reply = self._request("POST", "/memories", body, WRITE_TIMEOUT)
|
|
221
|
+
results = reply.get("results") if isinstance(reply, dict) else None
|
|
222
|
+
if not isinstance(results, list) or not results:
|
|
223
|
+
raise SinkError(f"POST {self.url}/memories: mem0 stored nothing for chunk {index + 1} of {len(chunks)} of document {doc_id!r} (want an object with a non-empty `results` list)")
|
|
224
|
+
self._delete_ids(old_ids)
|
|
225
|
+
|
|
226
|
+
def delete(self, bank: str, doc_id: str) -> None:
|
|
227
|
+
self._delete_ids(self._memory_ids(scope_id(bank), scope_id(doc_id)))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def create(url: str | None = None, **_cfg) -> Mem0Sink:
|
|
231
|
+
resolved = (url or os.environ.get(URL_ENV) or "").strip()
|
|
232
|
+
if not resolved:
|
|
233
|
+
raise MissingUrlError(f"mem0 sink needs an API URL: set {URL_ENV} or pass --url")
|
|
234
|
+
# No message below echoes the URL: it could carry a password.
|
|
235
|
+
if not resolved.lower().startswith(("http://", "https://")):
|
|
236
|
+
raise MissingUrlError("mem0 sink API URL must start with http:// or https://")
|
|
237
|
+
try:
|
|
238
|
+
parts = urllib.parse.urlsplit(resolved)
|
|
239
|
+
host = parts.hostname
|
|
240
|
+
_ = parts.port # raises ValueError for a malformed port
|
|
241
|
+
except ValueError:
|
|
242
|
+
raise MissingUrlError("mem0 sink API URL is not a valid URL") from None
|
|
243
|
+
if "@" in parts.netloc:
|
|
244
|
+
raise MissingUrlError(f"mem0 sink API URL must not contain credentials (user:pass@host); set {KEY_ENV} for the API key")
|
|
245
|
+
if not host:
|
|
246
|
+
raise MissingUrlError("mem0 sink API URL has no host")
|
|
247
|
+
return Mem0Sink(resolved, (os.environ.get(KEY_ENV) or "").strip() or None)
|
package/indexer/watch.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Keep Hindsight current: watch Obsidian vaults with inotify, poll git repositories, re-list sources periodically.
|
|
3
|
+
|
|
4
|
+
inotify is not used for repositories: they hold hundreds of thousands of directories, which exceeds the host's
|
|
5
|
+
max_user_watches limit. Repositories are polled by fingerprint (HEAD plus tracked-file status) instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import index_config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
INDEXER = "hindsight-project-index"
|
|
22
|
+
MAX_BACKOFF_SECONDS = 600
|
|
23
|
+
IGNORED_DIRS = {"node_modules", "build", "dist", "target", "vendor", "__pycache__"}
|
|
24
|
+
DRY_RUN = os.environ.get("INDEXER_DRY_RUN") == "1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def timings(settings: index_config.Config, environ: Mapping[str, str]) -> tuple[int, int, int]:
|
|
28
|
+
"""Return (poll, vault quiet, relist) seconds. Environment variables override index.yml."""
|
|
29
|
+
return (
|
|
30
|
+
int(environ.get("POLL_SECONDS", settings.poll_seconds)),
|
|
31
|
+
int(environ.get("VAULT_QUIET_SECONDS", settings.vault_quiet_seconds)),
|
|
32
|
+
int(environ.get("RELIST_SECONDS", settings.relist_seconds)),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Loaded at import, so an invalid config stops the watcher before it starts.
|
|
37
|
+
POLL_SECONDS, VAULT_QUIET_SECONDS, LIST_SECONDS = timings(index_config.load(), os.environ)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RepoWatch:
|
|
42
|
+
indexed: str | None # fingerprint at the last successful index
|
|
43
|
+
seen: str | None # fingerprint at the previous poll
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def log(message: str) -> None:
|
|
47
|
+
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def git_fingerprint(repo: Path) -> str:
|
|
51
|
+
"""HEAD plus a digest of tracked-file changes. Untracked files are ignored, matching the indexer."""
|
|
52
|
+
head = subprocess.run(["git", "rev-parse", "--verify", "-q", "HEAD"], cwd=repo, capture_output=True, text=True, check=False)
|
|
53
|
+
head_id = head.stdout.strip() if head.returncode == 0 else "unborn"
|
|
54
|
+
status = subprocess.run(["git", "status", "--porcelain=v1", "-uno"], cwd=repo, capture_output=True, check=True).stdout
|
|
55
|
+
return f"{head_id}:{hashlib.sha256(status).hexdigest()}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def step(state: RepoWatch, fingerprint: str) -> tuple[RepoWatch, bool]:
|
|
59
|
+
"""Advance one poll. Return the new state and whether to index now.
|
|
60
|
+
|
|
61
|
+
A change indexes only after the fingerprint stays unchanged for one more poll.
|
|
62
|
+
"""
|
|
63
|
+
new_state = RepoWatch(state.indexed, fingerprint)
|
|
64
|
+
if fingerprint == state.indexed:
|
|
65
|
+
return new_state, False
|
|
66
|
+
return new_state, fingerprint == state.seen
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def mark_indexed(state: RepoWatch, fingerprint: str) -> RepoWatch:
|
|
70
|
+
return RepoWatch(fingerprint, state.seen)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_sources() -> dict[Path, str]:
|
|
74
|
+
result = subprocess.run([INDEXER, "--list"], capture_output=True, text=True, check=True)
|
|
75
|
+
sources: dict[Path, str] = {}
|
|
76
|
+
for line in result.stdout.splitlines():
|
|
77
|
+
kind, _, path = line.partition("\t")
|
|
78
|
+
if kind and path:
|
|
79
|
+
sources[Path(path)] = kind
|
|
80
|
+
return sources
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def root_for(path: Path, roots: list[Path]) -> Path | None:
|
|
84
|
+
matches = [root for root in roots if path == root or root in path.parents]
|
|
85
|
+
return max(matches, key=lambda root: len(root.parts), default=None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def make_filter(roots: list[Path]):
|
|
89
|
+
def keep(_change, raw_path: str) -> bool:
|
|
90
|
+
path = Path(raw_path)
|
|
91
|
+
root = root_for(path, roots)
|
|
92
|
+
if root is None:
|
|
93
|
+
return False
|
|
94
|
+
# Dot-prefixed paths cover .git, .obsidian, .trash, .venv, and dotfile caches.
|
|
95
|
+
return not any(part.startswith(".") or part in IGNORED_DIRS for part in path.relative_to(root).parts)
|
|
96
|
+
|
|
97
|
+
return keep
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_indexer(kind: str, path: Path) -> int:
|
|
101
|
+
flag = {
|
|
102
|
+
"repo": "--repo",
|
|
103
|
+
"vault": "--vault",
|
|
104
|
+
"notes": "--notes",
|
|
105
|
+
"papers": "--papers",
|
|
106
|
+
"bookmarks": "--bookmarks",
|
|
107
|
+
"feed": "--feed",
|
|
108
|
+
}[kind]
|
|
109
|
+
command = [INDEXER, "--apply", flag, str(path)]
|
|
110
|
+
log(f"index {kind} {path}")
|
|
111
|
+
if DRY_RUN:
|
|
112
|
+
log(f"dry run, not executed: {' '.join(command)}")
|
|
113
|
+
return 0
|
|
114
|
+
try:
|
|
115
|
+
code = subprocess.run(command, check=False).returncode
|
|
116
|
+
except OSError as error:
|
|
117
|
+
log(f"index {kind} {path} could not start: {error}")
|
|
118
|
+
return 1
|
|
119
|
+
log(f"index {kind} {path} exited {code}")
|
|
120
|
+
return code
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def watch_vaults(vaults: list[Path], stop: threading.Event) -> None:
|
|
124
|
+
"""Index each vault after VAULT_QUIET_SECONDS without changes. Returns when stop is set."""
|
|
125
|
+
from watchfiles import watch # imported here so the pure helpers work without watchfiles
|
|
126
|
+
|
|
127
|
+
pending: dict[Path, float] = {}
|
|
128
|
+
for changes in watch(*vaults, watch_filter=make_filter(vaults), rust_timeout=1000, yield_on_timeout=True, stop_event=stop):
|
|
129
|
+
for _change, raw_path in changes:
|
|
130
|
+
root = root_for(Path(raw_path), vaults)
|
|
131
|
+
if root is not None:
|
|
132
|
+
pending[root] = time.monotonic()
|
|
133
|
+
for root, last in list(pending.items()):
|
|
134
|
+
if time.monotonic() - last >= VAULT_QUIET_SECONDS:
|
|
135
|
+
del pending[root]
|
|
136
|
+
run_indexer("vault", root)
|
|
137
|
+
# Changes made during the run arrive in later batches and re-trigger the vault.
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class VaultWatcher:
|
|
141
|
+
def __init__(self, vaults: list[Path]):
|
|
142
|
+
self.vaults = vaults
|
|
143
|
+
self.stop = threading.Event()
|
|
144
|
+
self.thread = threading.Thread(target=self._run, name="vault-watch", daemon=True)
|
|
145
|
+
|
|
146
|
+
def start(self) -> "VaultWatcher":
|
|
147
|
+
self.thread.start()
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
# Does not join: a running index may take minutes, and the poller must keep going.
|
|
152
|
+
self.stop.set()
|
|
153
|
+
|
|
154
|
+
def _run(self) -> None:
|
|
155
|
+
backoff = 1
|
|
156
|
+
while not self.stop.is_set():
|
|
157
|
+
try:
|
|
158
|
+
watch_vaults(self.vaults, self.stop)
|
|
159
|
+
return
|
|
160
|
+
except Exception as error:
|
|
161
|
+
log(f"vault watcher failed: {type(error).__name__}: {error}; retrying in {backoff}s")
|
|
162
|
+
self.stop.wait(backoff)
|
|
163
|
+
backoff = min(backoff * 2, MAX_BACKOFF_SECONDS)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def index_repo(path: Path, repos: dict[Path, RepoWatch]) -> None:
|
|
167
|
+
try:
|
|
168
|
+
baseline: str | None = git_fingerprint(path)
|
|
169
|
+
except Exception as error:
|
|
170
|
+
log(f"{path}: fingerprint failed: {type(error).__name__}: {error}")
|
|
171
|
+
baseline = None
|
|
172
|
+
indexed = baseline if run_indexer("repo", path) == 0 else None
|
|
173
|
+
repos[path] = RepoWatch(indexed, baseline)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def poll_repos(repos: dict[Path, RepoWatch]) -> None:
|
|
177
|
+
for path, state in list(repos.items()):
|
|
178
|
+
try:
|
|
179
|
+
fingerprint = git_fingerprint(path)
|
|
180
|
+
except Exception as error:
|
|
181
|
+
log(f"{path}: fingerprint failed: {type(error).__name__}: {error}")
|
|
182
|
+
continue
|
|
183
|
+
repos[path], should_index = step(state, fingerprint)
|
|
184
|
+
if should_index and run_indexer("repo", path) == 0:
|
|
185
|
+
repos[path] = mark_indexed(repos[path], fingerprint)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def refresh(kinds: dict[Path, str], repos: dict[Path, RepoWatch], watcher: VaultWatcher | None) -> tuple[dict[Path, str], VaultWatcher | None]:
|
|
189
|
+
try:
|
|
190
|
+
found = list_sources()
|
|
191
|
+
except Exception as error:
|
|
192
|
+
log(f"listing sources failed, keeping {len(kinds)} known: {type(error).__name__}: {error}")
|
|
193
|
+
return kinds, watcher
|
|
194
|
+
if not found:
|
|
195
|
+
log("no sources selected; nothing to index or watch")
|
|
196
|
+
for path in set(kinds) - set(found):
|
|
197
|
+
log(f"source removed: {path}")
|
|
198
|
+
repos.pop(path, None)
|
|
199
|
+
watch_kinds = ("vault", "notes", "papers", "bookmarks", "feed")
|
|
200
|
+
watched = sorted(path for path, kind in found.items() if kind in watch_kinds)
|
|
201
|
+
if watched != sorted(path for path, kind in kinds.items() if kind in watch_kinds):
|
|
202
|
+
# Restart the watcher before indexing new vaults, so edits made meanwhile are queued, not lost.
|
|
203
|
+
if watcher is not None:
|
|
204
|
+
watcher.close()
|
|
205
|
+
watcher = VaultWatcher(watched).start() if watched else None
|
|
206
|
+
for path, kind in found.items():
|
|
207
|
+
if path in kinds:
|
|
208
|
+
continue
|
|
209
|
+
log(f"new {kind} source: {path}")
|
|
210
|
+
if kind in watch_kinds:
|
|
211
|
+
run_indexer(kind, path)
|
|
212
|
+
else:
|
|
213
|
+
index_repo(path, repos)
|
|
214
|
+
return found, watcher
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main() -> int:
|
|
218
|
+
log(f"poll {POLL_SECONDS}s, vault quiet {VAULT_QUIET_SECONDS}s, source listing {LIST_SECONDS}s" + (", dry run" if DRY_RUN else ""))
|
|
219
|
+
kinds: dict[Path, str] = {}
|
|
220
|
+
repos: dict[Path, RepoWatch] = {}
|
|
221
|
+
watcher: VaultWatcher | None = None
|
|
222
|
+
next_list = 0.0
|
|
223
|
+
failures = 0
|
|
224
|
+
while True:
|
|
225
|
+
try:
|
|
226
|
+
if time.monotonic() >= next_list:
|
|
227
|
+
next_list = time.monotonic() + LIST_SECONDS
|
|
228
|
+
kinds, watcher = refresh(kinds, repos, watcher)
|
|
229
|
+
poll_repos(repos)
|
|
230
|
+
failures = 0
|
|
231
|
+
time.sleep(POLL_SECONDS)
|
|
232
|
+
except Exception as error:
|
|
233
|
+
failures += 1
|
|
234
|
+
delay = min(POLL_SECONDS * 2**failures, MAX_BACKOFF_SECONDS)
|
|
235
|
+
log(f"iteration failed: {type(error).__name__}: {error}; retrying in {delay}s")
|
|
236
|
+
time.sleep(delay)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,98 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bcmyguest/openmem",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "OpenMem — memory interface for coding agents: pluggable providers (Hindsight, mem0, Cognee, …), shared wizard/indexer, multi-format import/export (PMM and more).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "B Guest",
|
|
8
|
+
"homepage": "https://bcmyguest.github.io/openmem/",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/bcmyguest/openmem/issues"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/bcmyguest/openmem.git"
|
|
15
|
+
},
|
|
16
|
+
"mcpName": "io.github.bcmyguest/openmem",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"openmem",
|
|
19
|
+
"memory",
|
|
20
|
+
"ai-memory",
|
|
21
|
+
"hindsight",
|
|
22
|
+
"mem0",
|
|
23
|
+
"cognee",
|
|
24
|
+
"mcp",
|
|
25
|
+
"mcp-server",
|
|
26
|
+
"docker-compose",
|
|
27
|
+
"setup-wizard",
|
|
28
|
+
"coding-agent",
|
|
29
|
+
"pmm"
|
|
30
|
+
],
|
|
31
|
+
"bin": {
|
|
32
|
+
"openmem": "cli/openmem.mjs",
|
|
33
|
+
"openmem-setup": "wizard/bin/setup.mjs",
|
|
34
|
+
"openmem-mcp": "cli/openmem-mcp.mjs",
|
|
35
|
+
"openmem-install-mcp": "cli/install-memory-mcp.mjs",
|
|
36
|
+
"openmem-install-coding-agent": "cli/install-coding-agent.mjs",
|
|
37
|
+
"openmem-switch-provider": "cli/switch-memory-provider.mjs",
|
|
38
|
+
"openmem-notebooklm-mcp": "cli/notebooklm-mcp.mjs",
|
|
39
|
+
"openmem-cognee-mcp": "cli/cognee-mcp.mjs"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"cli/",
|
|
43
|
+
".agents/skills/openmem/",
|
|
44
|
+
"wizard/bin/",
|
|
45
|
+
"wizard/lib/",
|
|
46
|
+
"providers/",
|
|
47
|
+
"compose.yaml",
|
|
48
|
+
"compose.bench.yaml",
|
|
49
|
+
"indexer/",
|
|
50
|
+
"bin/",
|
|
51
|
+
"sync/",
|
|
52
|
+
"proxy/",
|
|
53
|
+
"config/*.example",
|
|
54
|
+
"config/index.yml",
|
|
55
|
+
"config/notebooklm.yml",
|
|
56
|
+
"config/litellm.*.yaml",
|
|
57
|
+
"config/coding-agent.json",
|
|
58
|
+
"scripts/*.mjs",
|
|
59
|
+
"scripts/*.sh",
|
|
60
|
+
"README.md",
|
|
61
|
+
"LICENSE",
|
|
62
|
+
"glama.json",
|
|
63
|
+
"server.json",
|
|
64
|
+
"docs/",
|
|
65
|
+
"!docs/ROADMAP*",
|
|
66
|
+
"!docs/WAVE*",
|
|
67
|
+
"!docs/ANNOUNCEMENT*",
|
|
68
|
+
"!docs/PUBLISH*",
|
|
69
|
+
"!**/__pycache__",
|
|
70
|
+
"!**/*.pyc",
|
|
71
|
+
"!**/tests",
|
|
72
|
+
"!**/.pytest_cache"
|
|
73
|
+
],
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=24.0.0"
|
|
76
|
+
},
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"access": "public"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"setup": "node wizard/bin/setup.mjs",
|
|
82
|
+
"pm": "node wizard/bin/pm.mjs",
|
|
83
|
+
"openmem": "node cli/openmem.mjs",
|
|
84
|
+
"install-mcp": "node cli/install-memory-mcp.mjs",
|
|
85
|
+
"install-coding-agent": "node cli/install-coding-agent.mjs",
|
|
86
|
+
"feed-sync": "node cli/feed-sync.mjs",
|
|
87
|
+
"switch-provider": "node cli/switch-memory-provider.mjs",
|
|
88
|
+
"test": "for t in scripts/tests/test_*.mjs; do node \"$t\" || exit 1; done && bash scripts/tests/test_wizard.sh",
|
|
89
|
+
"test:indexer": "cd indexer && python3 -m pytest -q",
|
|
90
|
+
"test:unit": "node scripts/tests/test_cli_package.mjs --unit",
|
|
91
|
+
"pack:check": "node scripts/check-pack.mjs"
|
|
92
|
+
},
|
|
93
|
+
"dependencies": {
|
|
94
|
+
"@clack/prompts": "^1.2.0",
|
|
95
|
+
"@iarna/toml": "^2.2.5",
|
|
96
|
+
"yaml": "^2.9.1"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# TEMPLATE compose fragment for a memory provider. Copy to providers/<id>/compose.yaml and replace every
|
|
2
|
+
# "example" / EXAMPLE_ below with the provider id. Directories starting with "_" are not providers; the wizard's
|
|
3
|
+
# provider registry must skip them.
|
|
4
|
+
#
|
|
5
|
+
# Layering (the wizard passes both, then `up -d`):
|
|
6
|
+
# docker compose -f compose.yaml -f providers/<id>/compose.yaml --profile <id> up -d
|
|
7
|
+
# Add `-f compose.override.yaml` when you use the generated secret masks (Compose skips that file once -f is given).
|
|
8
|
+
#
|
|
9
|
+
# What the base compose.yaml gives you:
|
|
10
|
+
# - `indexer` and `indexer-state-init` have no profile and no provider dependency, so they start for every
|
|
11
|
+
# provider. This fragment points the indexer at your service and makes it wait for it.
|
|
12
|
+
# - `litellm` and `hindsight` carry the profile "hindsight" and do not start under any other profile.
|
|
13
|
+
# Keep this fragment small: one service (or a few), its volumes, and the indexer wiring. Never copy the indexer
|
|
14
|
+
# build or image; `openmem-indexer:local` is shared.
|
|
15
|
+
|
|
16
|
+
services:
|
|
17
|
+
example:
|
|
18
|
+
profiles: ["example"]
|
|
19
|
+
# Pin a tag or digest. Bump deliberately.
|
|
20
|
+
image: example/memory-server:0.0.0
|
|
21
|
+
# Loopback only. Host port from .env (EXAMPLE_PORT); change the default to the provider's usual port + 10000.
|
|
22
|
+
ports:
|
|
23
|
+
- "127.0.0.1:${EXAMPLE_PORT:-18080}:8080"
|
|
24
|
+
# Secrets live in an env file outside the repo, like HINDSIGHT_ENV_FILE. Delete if the provider needs none.
|
|
25
|
+
# required: false lets `config` and `up` run before the wizard has written the file (Compose 2.24+).
|
|
26
|
+
env_file:
|
|
27
|
+
- path: ${EXAMPLE_ENV_FILE:-${HOME}/.config/personal-memory/example.env}
|
|
28
|
+
required: false
|
|
29
|
+
volumes:
|
|
30
|
+
- example-data:/data
|
|
31
|
+
restart: unless-stopped
|
|
32
|
+
healthcheck:
|
|
33
|
+
# The image has no curl in most cases; use the interpreter that is there.
|
|
34
|
+
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5)"]
|
|
35
|
+
interval: 15s
|
|
36
|
+
timeout: 10s
|
|
37
|
+
retries: 10
|
|
38
|
+
start_period: 30s
|
|
39
|
+
|
|
40
|
+
indexer:
|
|
41
|
+
environment:
|
|
42
|
+
# Selects indexer/sinks/example.py and gives it the API base URL (the compose service name, not localhost).
|
|
43
|
+
MEMORY_SINK: example
|
|
44
|
+
MEMORY_API_URL: http://example:8080
|
|
45
|
+
depends_on:
|
|
46
|
+
# Hard dependency (required defaults to true): this fragment is only layered together with --profile example,
|
|
47
|
+
# so a missing profile fails loudly instead of starting an indexer with nothing to write to.
|
|
48
|
+
example:
|
|
49
|
+
condition: service_healthy
|
|
50
|
+
|
|
51
|
+
volumes:
|
|
52
|
+
example-data:
|