@bcmyguest/openmem 0.6.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{skills → .agents/skills}/openmem/SKILL.md +5 -0
- package/README.md +80 -76
- package/cli/coding-agent/capabilities.mjs +14 -3
- 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 +2 -1
- package/cli/coding-agent/hook-runner.mjs +28 -4
- package/cli/coding-agent/install-access.mjs +43 -17
- 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 +119 -15
- package/cli/coding-agent/opencode-config.mjs +21 -5
- package/cli/coding-agent/opencode-plugin.mjs +39 -3
- 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 +2 -1
- package/cli/coding-agent/session-db.mjs +626 -0
- package/cli/coding-agent/session-facade.mjs +30 -6
- package/cli/coding-agent/toml-edit.mjs +161 -0
- package/cli/context-management.mjs +254 -0
- package/cli/feed-sync.mjs +18 -2
- package/cli/install-coding-agent.mjs +173 -40
- package/cli/mcp/context-sql-runner.mjs +58 -0
- package/cli/mcp/context-store.mjs +670 -0
- package/cli/mcp/knowledge.mjs +333 -0
- package/cli/mcp/memory-facade.mjs +99 -33
- package/cli/openmem-mcp.mjs +3 -0
- package/cli/openmem.mjs +18 -1
- package/cli/schedule.mjs +176 -0
- package/cli/stack.mjs +2 -0
- package/cli/switch-memory-provider.mjs +1 -1
- package/compose.yaml +67 -4
- package/config/hindsight.env.example +2 -0
- package/config/openmem.toml.example +64 -2
- package/docs/architecture.md +35 -9
- package/docs/coding-agent-hooks.md +26 -2
- package/docs/coding-agents.md +27 -7
- package/docs/compatibility.md +80 -0
- package/docs/concepts.md +30 -7
- 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 +5 -2
- package/docs/mcp-setup.md +5 -2
- package/docs/mem0.md +39 -1
- package/docs/memory-providers.md +2 -2
- package/docs/meridian-litellm.md +198 -0
- package/docs/model-configuration.md +22 -0
- package/docs/npx-install.md +6 -4
- package/docs/openmem-config.md +2 -2
- package/docs/quickstart.md +12 -6
- package/docs/setup-wizard.md +5 -4
- package/docs/sources-feeds.md +9 -22
- package/package.json +5 -4
- package/providers/cognee/OPERATOR.md +2 -2
- package/providers/hindsight/lib/clack.mjs +122 -20
- package/providers/hindsight/lib/coding-agent.mjs +1 -1
- package/providers/hindsight/lib/config.mjs +6 -0
- package/providers/hindsight/lib/env-setup.mjs +2 -1
- package/providers/hindsight/lib/litellm-routes.mjs +21 -12
- package/providers/hindsight/lib/meridian.mjs +307 -0
- package/providers/hindsight/lib/model-backend.mjs +38 -9
- package/providers/hindsight/lib/probe.mjs +21 -0
- package/providers/hindsight/lib/stack-env.mjs +5 -12
- package/providers/hindsight/lib/state.mjs +6 -1
- package/providers/hindsight/lib/summary.mjs +16 -2
- package/providers/hindsight/lib/validate.mjs +6 -0
- package/providers/hindsight/wizard.mjs +3 -2
- package/providers/mem0/OPERATOR.md +21 -2
- package/providers/mem0/compose.yaml +24 -2
- package/providers/mem0/lib/coding-agent.mjs +1 -1
- package/providers/mem0/lib/configure.mjs +66 -0
- package/providers/mem0/lib/litellm.mjs +457 -0
- package/providers/mem0/lib/stack-env.mjs +40 -1
- package/providers/mem0/lib/summary.mjs +30 -2
- package/providers/mem0/manifest.json +1 -1
- package/providers/mem0/wizard.mjs +15 -2
- 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 +2 -1
- package/scripts/setup.mjs +14 -1
- package/server.json +2 -2
- package/wizard/lib/context-step.mjs +140 -0
- package/wizard/lib/litellm-env.mjs +55 -0
- package/wizard/lib/pm-config.mjs +212 -4
- package/wizard/lib/prompts.mjs +28 -0
- package/wizard/lib/run-wizard.mjs +26 -31
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Runtime settings for the context proxy, read from environment variables."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .store import ASCII_WHITESPACE
|
|
9
|
+
from .tokens import DEFAULT_CHARS_PER_TOKEN
|
|
10
|
+
|
|
11
|
+
DEFAULT_TRIM_AT_TOKENS = 120000
|
|
12
|
+
# Trim thresholds by model family (a substring of the lowercased model id), used while CONTEXT_TRIM_AT_TOKENS is
|
|
13
|
+
# unset. Haiku's prompts over 100k tokens bill at a higher tier, so its sessions trim below it.
|
|
14
|
+
MODEL_TRIM_AT_TOKENS = (("haiku", 95000),)
|
|
15
|
+
# A trim keeps older in-turn exchanges while the request stays under this fraction of trim_at_tokens.
|
|
16
|
+
DEFAULT_TRIM_TO_FRACTION = 0.3
|
|
17
|
+
DEFAULT_COMPRESS_OVER_TOKENS = 4000
|
|
18
|
+
DEFAULT_COMPACTION_MAX_CHARS = 12000
|
|
19
|
+
DEFAULT_RECALL_TIMEOUT_S = 5.0
|
|
20
|
+
DEFAULT_HINDSIGHT_URL = "http://127.0.0.1:8888"
|
|
21
|
+
DEFAULT_TTL_DAYS = 30
|
|
22
|
+
# OpenAI-compatible embeddings base URL (POST <url>/embeddings): Ollama on the host. compose.yaml sets the
|
|
23
|
+
# container's view (host.docker.internal).
|
|
24
|
+
DEFAULT_EMBEDDING_URL = "http://127.0.0.1:11434/v1"
|
|
25
|
+
# Seconds per row of an embeddings call, and characters of a row's head sent (docs/context-store.md, "Vectors").
|
|
26
|
+
DEFAULT_EMBEDDING_TIMEOUT_S = 30.0
|
|
27
|
+
DEFAULT_EMBEDDING_MAX_CHARS = 4000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _int(env, name, default):
|
|
31
|
+
raw = str(env.get(name, "")).strip()
|
|
32
|
+
if not raw:
|
|
33
|
+
return default
|
|
34
|
+
try:
|
|
35
|
+
return int(raw)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _float(env, name, default):
|
|
41
|
+
raw = str(env.get(name, "")).strip()
|
|
42
|
+
if not raw:
|
|
43
|
+
return default
|
|
44
|
+
try:
|
|
45
|
+
return float(raw)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _positive_float(env, name, default):
|
|
51
|
+
value = _float(env, name, default)
|
|
52
|
+
return value if math.isfinite(value) and value > 0 else default
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fraction(env, name, default):
|
|
56
|
+
"""A float strictly between 0 and 1, else default."""
|
|
57
|
+
value = _float(env, name, default)
|
|
58
|
+
return value if math.isfinite(value) and 0 < value < 1 else default
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _positive_int(env, name, default):
|
|
62
|
+
value = _int(env, name, default)
|
|
63
|
+
return value if value > 0 else default
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _flag(env, name):
|
|
67
|
+
"""True unless the variable is 0, false, no or off."""
|
|
68
|
+
return str(env.get(name, "1")).strip().lower() not in ("0", "false", "no", "off")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Settings:
|
|
73
|
+
enabled: bool = True
|
|
74
|
+
# STANDING_INSTRUCTION in every request's system prompt (CONTEXT_STANDING_INSTRUCTION=0 turns it off).
|
|
75
|
+
standing_instruction: bool = True
|
|
76
|
+
trim_at_tokens: int = DEFAULT_TRIM_AT_TOKENS
|
|
77
|
+
# MODEL_TRIM_AT_TOKENS picks the threshold, trim_at_tokens being the fallback (CONTEXT_TRIM_AT_TOKENS unset).
|
|
78
|
+
trim_at_by_model: bool = False
|
|
79
|
+
# A trim lands under this fraction of trim_at_tokens, 0 < x < 1 (CONTEXT_TRIM_TO_FRACTION).
|
|
80
|
+
trim_to_fraction: float = DEFAULT_TRIM_TO_FRACTION
|
|
81
|
+
compress_over_tokens: int = DEFAULT_COMPRESS_OVER_TOKENS
|
|
82
|
+
# Characters per token for the trim estimate until the session reports usage (CONTEXT_CHARS_PER_TOKEN).
|
|
83
|
+
chars_per_token: float = DEFAULT_CHARS_PER_TOKEN
|
|
84
|
+
compaction_max_chars: int = DEFAULT_COMPACTION_MAX_CHARS
|
|
85
|
+
# Bounded provider recall in the trim pointer (CONTEXT_COMPACTION_RECALL=0 turns it off).
|
|
86
|
+
compaction_recall: bool = True
|
|
87
|
+
recall_timeout_s: float = DEFAULT_RECALL_TIMEOUT_S
|
|
88
|
+
hindsight_url: str = DEFAULT_HINDSIGHT_URL
|
|
89
|
+
context_dir: Path = Path.home() / ".local" / "share" / "openmem" / "context"
|
|
90
|
+
# Session files older than this are deleted at start, at most once a day; 0 keeps them (CONTEXT_STORE_TTL_DAYS).
|
|
91
|
+
ttl_days: int = DEFAULT_TTL_DAYS
|
|
92
|
+
# Embedding model for the session databases' vectors; empty turns vectors off (CONTEXT_EMBEDDING_MODEL).
|
|
93
|
+
embedding_model: str = ""
|
|
94
|
+
embedding_url: str = DEFAULT_EMBEDDING_URL
|
|
95
|
+
embedding_api_key: str = ""
|
|
96
|
+
# An embeddings call of n rows times out after n times this (CONTEXT_EMBEDDING_TIMEOUT_S).
|
|
97
|
+
embedding_timeout_s: float = DEFAULT_EMBEDDING_TIMEOUT_S
|
|
98
|
+
# Each row's first this many characters are embedded (CONTEXT_EMBEDDING_MAX_CHARS).
|
|
99
|
+
embedding_max_chars: int = DEFAULT_EMBEDDING_MAX_CHARS
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_env(cls, env=None):
|
|
103
|
+
env = os.environ if env is None else env
|
|
104
|
+
state_dir = str(env.get("OPENMEM_STATE_DIR", "")).strip(ASCII_WHITESPACE)
|
|
105
|
+
root = Path(state_dir) if state_dir else Path.home() / ".local" / "share" / "openmem"
|
|
106
|
+
return cls(
|
|
107
|
+
enabled=_flag(env, "CONTEXT_PROXY_ENABLED"),
|
|
108
|
+
standing_instruction=_flag(env, "CONTEXT_STANDING_INSTRUCTION"),
|
|
109
|
+
trim_at_tokens=_int(env, "CONTEXT_TRIM_AT_TOKENS", DEFAULT_TRIM_AT_TOKENS),
|
|
110
|
+
trim_at_by_model=_int(env, "CONTEXT_TRIM_AT_TOKENS", None) is None,
|
|
111
|
+
trim_to_fraction=_fraction(env, "CONTEXT_TRIM_TO_FRACTION", DEFAULT_TRIM_TO_FRACTION),
|
|
112
|
+
compress_over_tokens=_int(env, "CONTEXT_COMPRESS_OVER_TOKENS", DEFAULT_COMPRESS_OVER_TOKENS),
|
|
113
|
+
chars_per_token=_positive_float(env, "CONTEXT_CHARS_PER_TOKEN", DEFAULT_CHARS_PER_TOKEN),
|
|
114
|
+
compaction_max_chars=_int(env, "CONTEXT_COMPACTION_MAX_CHARS", DEFAULT_COMPACTION_MAX_CHARS),
|
|
115
|
+
compaction_recall=_flag(env, "CONTEXT_COMPACTION_RECALL"),
|
|
116
|
+
recall_timeout_s=_float(env, "CONTEXT_RECALL_TIMEOUT_S", DEFAULT_RECALL_TIMEOUT_S),
|
|
117
|
+
hindsight_url=(str(env.get("HINDSIGHT_URL", "")).strip() or DEFAULT_HINDSIGHT_URL).rstrip("/"),
|
|
118
|
+
context_dir=root / "context",
|
|
119
|
+
ttl_days=_int(env, "CONTEXT_STORE_TTL_DAYS", DEFAULT_TTL_DAYS),
|
|
120
|
+
embedding_model=str(env.get("CONTEXT_EMBEDDING_MODEL", "")).strip(),
|
|
121
|
+
embedding_url=(str(env.get("CONTEXT_EMBEDDING_URL", "")).strip() or DEFAULT_EMBEDDING_URL).rstrip("/"),
|
|
122
|
+
embedding_api_key=str(env.get("CONTEXT_EMBEDDING_API_KEY", "")).strip(),
|
|
123
|
+
embedding_timeout_s=_positive_float(env, "CONTEXT_EMBEDDING_TIMEOUT_S", DEFAULT_EMBEDDING_TIMEOUT_S),
|
|
124
|
+
embedding_max_chars=_positive_int(env, "CONTEXT_EMBEDDING_MAX_CHARS", DEFAULT_EMBEDDING_MAX_CHARS),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def trim_at_for(self, model):
|
|
128
|
+
"""The trim threshold for a request to model (its id, or None)."""
|
|
129
|
+
if self.trim_at_by_model and isinstance(model, str):
|
|
130
|
+
name = model.lower()
|
|
131
|
+
for family, tokens in MODEL_TRIM_AT_TOKENS:
|
|
132
|
+
if family in name:
|
|
133
|
+
return tokens
|
|
134
|
+
return self.trim_at_tokens
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Context store files, per docs/context-store.md. Directories 700, files 600."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DIR_MODE = 0o700
|
|
10
|
+
FILE_MODE = 0o600
|
|
11
|
+
TRIM_STATE_VERSION = 1
|
|
12
|
+
_SID_RE = re.compile(r"[^A-Za-z0-9._-]")
|
|
13
|
+
# ASCII whitespace only, so the hooks (JS) and the proxy trim the same characters.
|
|
14
|
+
ASCII_WHITESPACE = " \t\n\v\f\r"
|
|
15
|
+
_locks = {}
|
|
16
|
+
_locks_guard = threading.Lock()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidSessionId(ValueError):
|
|
20
|
+
"""The session id is empty after trimming."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RegistrationNotFound(LookupError):
|
|
24
|
+
"""No readable registration for the session."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sanitize_sid(sid):
|
|
28
|
+
"""Contract sanitizer (docs/context-store.md): trim, replace characters outside [A-Za-z0-9._-], reject empty."""
|
|
29
|
+
clean = _SID_RE.sub("_", str(sid).strip(ASCII_WHITESPACE))
|
|
30
|
+
if not clean:
|
|
31
|
+
raise InvalidSessionId("empty session id")
|
|
32
|
+
return clean
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def session_lock(sid):
|
|
36
|
+
"""In-process lock per session. The proxy runs one LiteLLM worker."""
|
|
37
|
+
with _locks_guard:
|
|
38
|
+
lock = _locks.get(sid)
|
|
39
|
+
if lock is None:
|
|
40
|
+
lock = _locks[sid] = threading.Lock()
|
|
41
|
+
return lock
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_dir(path):
|
|
45
|
+
path.mkdir(mode=DIR_MODE, parents=True, exist_ok=True)
|
|
46
|
+
try:
|
|
47
|
+
os.chmod(path, DIR_MODE)
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _write_atomic(path, data):
|
|
53
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
|
54
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE)
|
|
55
|
+
try:
|
|
56
|
+
with os.fdopen(fd, "wb") as fh:
|
|
57
|
+
fh.write(data)
|
|
58
|
+
os.replace(tmp, path)
|
|
59
|
+
except BaseException:
|
|
60
|
+
try:
|
|
61
|
+
os.unlink(tmp)
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Store:
|
|
68
|
+
def __init__(self, root):
|
|
69
|
+
self.root = Path(root)
|
|
70
|
+
self.sessions = self.root / "sessions"
|
|
71
|
+
self.blobs = self.root / "blobs"
|
|
72
|
+
|
|
73
|
+
def _session_path(self, sid, suffix):
|
|
74
|
+
return self.sessions / f"{sid}{suffix}"
|
|
75
|
+
|
|
76
|
+
# Registration (written by the SessionStart hook)
|
|
77
|
+
def registration(self, sid):
|
|
78
|
+
"""Registration dict; raises RegistrationNotFound when it is missing, unreadable or not an object."""
|
|
79
|
+
try:
|
|
80
|
+
with open(self._session_path(sid, ".json"), "rb") as fh:
|
|
81
|
+
value = json.loads(fh.read().decode("utf-8"))
|
|
82
|
+
except (OSError, ValueError) as exc:
|
|
83
|
+
raise RegistrationNotFound(sid) from exc
|
|
84
|
+
if not isinstance(value, dict):
|
|
85
|
+
raise RegistrationNotFound(sid)
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
def db_path(self, sid):
|
|
89
|
+
"""Session database (sessiondb.py)."""
|
|
90
|
+
return self._session_path(sid, ".db")
|
|
91
|
+
|
|
92
|
+
def ensure_sessions(self):
|
|
93
|
+
_ensure_dir(self.sessions)
|
|
94
|
+
|
|
95
|
+
# Force flag (written by the prompt hook)
|
|
96
|
+
def force_requested(self, sid):
|
|
97
|
+
return self._session_path(sid, ".force").exists()
|
|
98
|
+
|
|
99
|
+
def clear_force(self, sid):
|
|
100
|
+
try:
|
|
101
|
+
os.unlink(self._session_path(sid, ".force"))
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Proxy-private trim state
|
|
106
|
+
def load_trim(self, sid):
|
|
107
|
+
try:
|
|
108
|
+
with open(self._session_path(sid, ".trim.json"), "rb") as fh:
|
|
109
|
+
value = json.loads(fh.read().decode("utf-8"))
|
|
110
|
+
except (OSError, ValueError):
|
|
111
|
+
value = None
|
|
112
|
+
if not isinstance(value, dict) or value.get("version") != TRIM_STATE_VERSION:
|
|
113
|
+
return {"version": TRIM_STATE_VERSION, "threads": {}}
|
|
114
|
+
if not isinstance(value.get("threads"), dict):
|
|
115
|
+
value["threads"] = {}
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
def save_trim(self, sid, state):
|
|
119
|
+
_ensure_dir(self.sessions)
|
|
120
|
+
data = json.dumps(state, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
121
|
+
_write_atomic(self._session_path(sid, ".trim.json"), data)
|
|
122
|
+
|
|
123
|
+
# Blobs
|
|
124
|
+
def write_blob(self, bid, text):
|
|
125
|
+
path = self.blobs / f"{bid}.txt"
|
|
126
|
+
if path.exists():
|
|
127
|
+
return
|
|
128
|
+
_ensure_dir(self.blobs)
|
|
129
|
+
_write_atomic(path, text.encode("utf-8"))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Request size in characters (flat charge for binary attachments), the prompt-token estimate, and fingerprints.
|
|
2
|
+
|
|
3
|
+
text_tokens (characters / 4) sizes single tool results for compression. The trim threshold uses
|
|
4
|
+
estimate_prompt_tokens, calibrated per session from the provider's reported usage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
# Block types whose payload is binary (base64) rather than text.
|
|
12
|
+
BINARY_BLOCK_TYPES = frozenset({"image", "document", "input_image", "input_file", "image_url", "file"})
|
|
13
|
+
# Flat charge per binary block, in characters (about 1600 tokens).
|
|
14
|
+
BINARY_BLOCK_CHARS = 6400
|
|
15
|
+
# Characters per prompt token before a session has reported usage. Claude Code requests measured about 2.3.
|
|
16
|
+
DEFAULT_CHARS_PER_TOKEN = 2.5
|
|
17
|
+
# Bounds on an observed ratio, so one odd usage report cannot make the estimate absurd.
|
|
18
|
+
MIN_CHARS_PER_TOKEN = 1.0
|
|
19
|
+
MAX_CHARS_PER_TOKEN = 8.0
|
|
20
|
+
# Usage samples kept per session (one per conversation thread).
|
|
21
|
+
MAX_USAGE_THREADS = 32
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def text_tokens(text):
|
|
25
|
+
return (len(text) + 3) // 4
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _chars(value):
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
return len(value)
|
|
31
|
+
if isinstance(value, dict):
|
|
32
|
+
if isinstance(value.get("type"), str) and value["type"] in BINARY_BLOCK_TYPES:
|
|
33
|
+
return BINARY_BLOCK_CHARS
|
|
34
|
+
return sum(len(str(k)) + _chars(v) for k, v in value.items())
|
|
35
|
+
if isinstance(value, (list, tuple)):
|
|
36
|
+
return sum(_chars(v) for v in value)
|
|
37
|
+
if value is None:
|
|
38
|
+
return 0
|
|
39
|
+
return len(str(value))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def request_chars(*values):
|
|
43
|
+
"""Characters in the values, as the trim measures a request."""
|
|
44
|
+
return sum(_chars(v) for v in values)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sample(value):
|
|
48
|
+
"""(chars, tokens) of a stored usage sample, or None when it is missing or malformed."""
|
|
49
|
+
if not isinstance(value, dict):
|
|
50
|
+
return None
|
|
51
|
+
chars, tokens = value.get("chars"), value.get("tokens")
|
|
52
|
+
if type(chars) is int and type(tokens) is int and chars > 0 and tokens > 0:
|
|
53
|
+
return chars, tokens
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ratio(sample, default):
|
|
58
|
+
if sample is None:
|
|
59
|
+
return default
|
|
60
|
+
return min(MAX_CHARS_PER_TOKEN, max(MIN_CHARS_PER_TOKEN, sample[0] / sample[1]))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _usage_threads(usage):
|
|
64
|
+
threads = usage.get("threads") if isinstance(usage, dict) else None
|
|
65
|
+
return threads if isinstance(threads, dict) else {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def estimate_prompt_tokens(chars, usage, root, default_ratio):
|
|
69
|
+
"""Prompt tokens of a request of `chars` characters in conversation thread `root`.
|
|
70
|
+
|
|
71
|
+
usage is the session's calibration (record_usage). With a sample for this thread: its reported tokens plus
|
|
72
|
+
the characters added since, at that sample's chars-per-token ratio. Otherwise chars at the ratio of the
|
|
73
|
+
session's latest sample, else default_ratio.
|
|
74
|
+
"""
|
|
75
|
+
own = _sample(_usage_threads(usage).get(root))
|
|
76
|
+
if own is not None:
|
|
77
|
+
return max(0, own[1] + math.ceil((chars - own[0]) / _ratio(own, default_ratio)))
|
|
78
|
+
last = _sample(usage.get("last")) if isinstance(usage, dict) else None
|
|
79
|
+
return math.ceil(chars / _ratio(last, default_ratio))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def record_usage(state, root, chars, tokens):
|
|
83
|
+
"""Store a reported prompt size (tokens) for a request of `chars` characters in thread `root` into state["usage"].
|
|
84
|
+
|
|
85
|
+
Returns False, leaving state alone, when either value is not a positive integer.
|
|
86
|
+
"""
|
|
87
|
+
if _sample({"chars": chars, "tokens": tokens}) is None:
|
|
88
|
+
return False
|
|
89
|
+
usage = state.get("usage")
|
|
90
|
+
if not isinstance(usage, dict):
|
|
91
|
+
usage = state["usage"] = {}
|
|
92
|
+
threads = _usage_threads(usage)
|
|
93
|
+
sample = {"chars": chars, "tokens": tokens}
|
|
94
|
+
threads.pop(root, None)
|
|
95
|
+
threads[root] = sample
|
|
96
|
+
while len(threads) > MAX_USAGE_THREADS:
|
|
97
|
+
threads.pop(next(iter(threads)))
|
|
98
|
+
usage["threads"] = threads
|
|
99
|
+
usage["last"] = dict(sample)
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _normalize(value):
|
|
104
|
+
if isinstance(value, dict):
|
|
105
|
+
out = {k: _normalize(v) for k, v in value.items() if k != "cache_control"}
|
|
106
|
+
content = out.get("content")
|
|
107
|
+
if (isinstance(content, list) and len(content) == 1 and isinstance(content[0], dict)
|
|
108
|
+
and content[0].get("type") == "text" and set(content[0]) == {"type", "text"}):
|
|
109
|
+
out["content"] = content[0]["text"]
|
|
110
|
+
return out
|
|
111
|
+
if isinstance(value, list):
|
|
112
|
+
return [_normalize(v) for v in value]
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fingerprint(value):
|
|
117
|
+
"""Stable hash of a message, ignoring key order, cache_control markers and single-text-block wrapping.
|
|
118
|
+
|
|
119
|
+
Claude Code marks the newest message with cache_control and sends its content as one text block; on later
|
|
120
|
+
requests the marker is gone and the same content is a plain string. Both shapes must hash the same.
|
|
121
|
+
"""
|
|
122
|
+
blob = json.dumps(_normalize(value), sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
123
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:32]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""TTL cleanup of session files (docs/context-store.md, "Retention"). Same rules as sweepContextStore in
|
|
2
|
+
cli/coding-agent/session-db.mjs."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DAY_S = 86400
|
|
9
|
+
STAMP = ".ttl-stamp"
|
|
10
|
+
# Longest first, so `x.trim.json` groups under `x`, not `x.trim`.
|
|
11
|
+
SESSION_SUFFIXES = (".log.jsonl", ".trim.json", ".db-wal", ".db-shm", ".force", ".json", ".db")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def session_of(name):
|
|
15
|
+
"""(sid, suffix) for a session file name, or None for anything else."""
|
|
16
|
+
if name.startswith("."):
|
|
17
|
+
return None
|
|
18
|
+
for suffix in SESSION_SUFFIXES:
|
|
19
|
+
if name.endswith(suffix) and len(name) > len(suffix):
|
|
20
|
+
return name[: -len(suffix)], suffix
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sweep(root, ttl_days, now=None):
|
|
25
|
+
"""Delete every session whose newest file is older than ttl_days; returns the deleted sids, sorted."""
|
|
26
|
+
if not ttl_days or ttl_days <= 0:
|
|
27
|
+
return []
|
|
28
|
+
now = time.time() if now is None else now
|
|
29
|
+
sessions = Path(root) / "sessions"
|
|
30
|
+
groups = {}
|
|
31
|
+
try:
|
|
32
|
+
entries = list(os.scandir(sessions))
|
|
33
|
+
except OSError:
|
|
34
|
+
return []
|
|
35
|
+
for entry in entries:
|
|
36
|
+
parsed = session_of(entry.name)
|
|
37
|
+
if parsed is None or not entry.is_file(follow_symlinks=False):
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
mtime = entry.stat(follow_symlinks=False).st_mtime
|
|
41
|
+
except OSError:
|
|
42
|
+
continue
|
|
43
|
+
group = groups.setdefault(parsed[0], [0.0, []])
|
|
44
|
+
group[0] = max(group[0], mtime)
|
|
45
|
+
group[1].append(entry.path)
|
|
46
|
+
cutoff = now - ttl_days * DAY_S
|
|
47
|
+
deleted = []
|
|
48
|
+
for sid, (newest, paths) in groups.items():
|
|
49
|
+
if newest >= cutoff:
|
|
50
|
+
continue
|
|
51
|
+
for path in paths:
|
|
52
|
+
try:
|
|
53
|
+
os.unlink(path)
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
pass
|
|
56
|
+
deleted.append(sid)
|
|
57
|
+
return sorted(deleted)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def maybe_sweep(root, ttl_days, now=None):
|
|
61
|
+
"""sweep() at most once a day, recorded by the mtime of <root>/.ttl-stamp; returns the deleted sids."""
|
|
62
|
+
if not ttl_days or ttl_days <= 0:
|
|
63
|
+
return []
|
|
64
|
+
now = time.time() if now is None else now
|
|
65
|
+
stamp = Path(root) / STAMP
|
|
66
|
+
try:
|
|
67
|
+
if now - stamp.stat().st_mtime < DAY_S:
|
|
68
|
+
return []
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
pass
|
|
71
|
+
except OSError:
|
|
72
|
+
return []
|
|
73
|
+
if not Path(root).is_dir():
|
|
74
|
+
return []
|
|
75
|
+
fd = os.open(stamp, os.O_WRONLY | os.O_CREAT, 0o600)
|
|
76
|
+
os.close(fd)
|
|
77
|
+
os.utime(stamp, (now, now))
|
|
78
|
+
return sweep(root, ttl_days, now)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""LiteLLM callback: `litellm_settings.callbacks: openmem_context_hook.handler`.
|
|
2
|
+
|
|
3
|
+
Thin wrapper; all logic lives in the dependency-free openmem_context package next to this file.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
|
|
12
|
+
from litellm.integrations.custom_logger import CustomLogger # noqa: E402
|
|
13
|
+
|
|
14
|
+
from openmem_context import process_request, process_usage, startup # noqa: E402
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenMemContextHook(CustomLogger):
|
|
18
|
+
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
|
19
|
+
# File and Hindsight I/O run off the event loop; process_request never raises. A pass-through call (the
|
|
20
|
+
# Codex subscription route) is told apart by its route, and its call id lives on the logging object.
|
|
21
|
+
route = getattr(user_api_key_dict, "request_route", None)
|
|
22
|
+
call_id = getattr(data.get("litellm_logging_obj"), "litellm_call_id", None) if isinstance(data, dict) else None
|
|
23
|
+
return await asyncio.to_thread(process_request, data, call_type, route=route, call_id=call_id)
|
|
24
|
+
|
|
25
|
+
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
26
|
+
# Streams arrive here once, with the assembled response. kwargs carries the litellm_call_id that
|
|
27
|
+
# async_pre_call_hook saw in data; process_usage never raises.
|
|
28
|
+
await asyncio.to_thread(process_usage, kwargs, response_obj)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Session-file TTL cleanup, once per proxy start (and at most once a day); never blocks or raises.
|
|
32
|
+
startup()
|
|
33
|
+
|
|
34
|
+
handler = OpenMemContextHook()
|
package/scripts/check-pack.mjs
CHANGED
|
@@ -31,6 +31,7 @@ export const REQUIRED = [
|
|
|
31
31
|
"cli/switch-memory-provider.mjs",
|
|
32
32
|
"cli/stack.mjs",
|
|
33
33
|
"cli/roots.mjs",
|
|
34
|
+
".agents/skills/openmem/SKILL.md",
|
|
34
35
|
"wizard/bin/setup.mjs",
|
|
35
36
|
"wizard/bin/pm.mjs",
|
|
36
37
|
"wizard/lib/run-wizard.mjs",
|
|
@@ -67,7 +68,7 @@ export const FORBIDDEN = [
|
|
|
67
68
|
[/(^|\/)node_modules(\/|$)/, "node_modules"],
|
|
68
69
|
[/(^|\/)\.codegraph(\/|$)/, ".codegraph"],
|
|
69
70
|
[/(^|\/)\.git(\/|$)/, ".git"],
|
|
70
|
-
[/(^|\/)\.agents(
|
|
71
|
+
[/(^|\/)\.agents\/(?!skills\/openmem\/)/, ".agents except openmem skill"],
|
|
71
72
|
[/^site(\/|$)/, "site/"],
|
|
72
73
|
[/ROADMAP/, "ROADMAP*"],
|
|
73
74
|
[/WAVE2/, "WAVE2*"],
|
package/scripts/setup.mjs
CHANGED
|
@@ -52,7 +52,19 @@ try {
|
|
|
52
52
|
process.exit(1);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
// Context-proxy settings from config.toml ([context_proxy], [compaction]) for compose; keys the
|
|
56
|
+
// config leaves unset keep any hand-written .env line. Shell env still overrides at `compose up`.
|
|
57
|
+
/** @type {Record<string, string>} */
|
|
58
|
+
let proxyEnv = {};
|
|
59
|
+
try {
|
|
60
|
+
// Imported here: pm-config needs npm dependencies, and setup.mjs also runs from bare checkouts.
|
|
61
|
+
const { contextProxyStackEnvFromPmConfig, loadPmConfig } = await import("../wizard/lib/pm-config.mjs");
|
|
62
|
+
proxyEnv = contextProxyStackEnvFromPmConfig(loadPmConfig(home, repo).merged);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.warn(`skipped context-proxy settings: ${/** @type {Error} */ (e).message}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const dropRe = new RegExp(`^(${["HOST_UID", "HOST_GID", ...spec.drop, ...Object.keys(proxyEnv)].join("|")})=`);
|
|
56
68
|
|
|
57
69
|
const kept = [];
|
|
58
70
|
if (fs.existsSync(envFile)) {
|
|
@@ -64,6 +76,7 @@ const uid = os.userInfo().uid;
|
|
|
64
76
|
const gid = os.userInfo().gid;
|
|
65
77
|
kept.push(`HOST_UID=${uid}`, `HOST_GID=${gid}`);
|
|
66
78
|
for (const [key, value] of spec.entries) kept.push(`${key}=${value}`);
|
|
79
|
+
for (const [key, value] of Object.entries(proxyEnv)) kept.push(`${key}=${value}`);
|
|
67
80
|
fs.mkdirSync(repo, { recursive: true });
|
|
68
81
|
fs.writeFileSync(tmp, `${kept.filter(Boolean).join("\n")}\n`, { mode: 0o644 });
|
|
69
82
|
fs.renameSync(tmp, envFile);
|
package/server.json
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
"name": "io.github.bcmyguest/openmem",
|
|
4
4
|
"title": "OpenMem",
|
|
5
5
|
"description": "Memory interface for coding agents \u2014 stable memory_* tools over Hindsight, mem0, or Cognee.",
|
|
6
|
-
"version": "0.6.
|
|
6
|
+
"version": "0.6.4",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"identifier": "@bcmyguest/openmem",
|
|
11
|
-
"version": "0.6.
|
|
11
|
+
"version": "0.6.4",
|
|
12
12
|
"transport": {
|
|
13
13
|
"type": "stdio"
|
|
14
14
|
},
|