@bcmyguest/openmem 0.0.0-stage → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/.agents/skills/openmem/SKILL.md +34 -0
  2. package/LICENSE +190 -0
  3. package/README.md +140 -2
  4. package/bin/hindsight-project-index +530 -0
  5. package/cli/coding-agent/access-policy.mjs +38 -0
  6. package/cli/coding-agent/agents.mjs +34 -0
  7. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  8. package/cli/coding-agent/capabilities.mjs +34 -0
  9. package/cli/coding-agent/context-store.mjs +81 -0
  10. package/cli/coding-agent/context-trim.mjs +897 -0
  11. package/cli/coding-agent/file-io.mjs +72 -0
  12. package/cli/coding-agent/hook-harnesses.mjs +88 -0
  13. package/cli/coding-agent/hook-runner.mjs +284 -0
  14. package/cli/coding-agent/install-access.mjs +119 -0
  15. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  16. package/cli/coding-agent/install-rtk.mjs +60 -0
  17. package/cli/coding-agent/install-shim.mjs +201 -0
  18. package/cli/coding-agent/opencode-config.mjs +107 -0
  19. package/cli/coding-agent/opencode-plugin.mjs +132 -0
  20. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  21. package/cli/coding-agent/recall-frame.mjs +29 -0
  22. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  23. package/cli/coding-agent/runtime.mjs +40 -0
  24. package/cli/coding-agent/session-db.mjs +626 -0
  25. package/cli/coding-agent/session-facade.mjs +86 -0
  26. package/cli/coding-agent/toml-edit.mjs +161 -0
  27. package/cli/cognee-mcp.mjs +260 -0
  28. package/cli/context-management.mjs +254 -0
  29. package/cli/feed-sync.mjs +27 -0
  30. package/cli/install-coding-agent.mjs +298 -0
  31. package/cli/install-memory-mcp.mjs +426 -0
  32. package/cli/is-main.mjs +18 -0
  33. package/cli/mcp/context-sql-runner.mjs +58 -0
  34. package/cli/mcp/context-store.mjs +670 -0
  35. package/cli/mcp/context.mjs +61 -0
  36. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  37. package/cli/mcp/export/cognee.mjs +119 -0
  38. package/cli/mcp/export/hindsight.mjs +48 -0
  39. package/cli/mcp/export/index.mjs +26 -0
  40. package/cli/mcp/export/mem0.mjs +31 -0
  41. package/cli/mcp/knowledge.mjs +333 -0
  42. package/cli/mcp/llm-synthesize.mjs +50 -0
  43. package/cli/mcp/mem0-scope-id.mjs +22 -0
  44. package/cli/mcp/memory-facade.mjs +521 -0
  45. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  46. package/cli/mcp/notebooklm-banks.mjs +21 -0
  47. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  48. package/cli/mcp/notebooklm-provider.mjs +225 -0
  49. package/cli/mcp/pmm-records.mjs +23 -0
  50. package/cli/mcp/portability.mjs +158 -0
  51. package/cli/mcp/stdio.mjs +65 -0
  52. package/cli/mcp/unified-mcp-install.mjs +65 -0
  53. package/cli/mem0-mcp.mjs +108 -0
  54. package/cli/notebooklm-mcp.mjs +236 -0
  55. package/cli/openmem-mcp.mjs +38 -0
  56. package/cli/openmem.mjs +222 -0
  57. package/cli/roots.mjs +85 -0
  58. package/cli/schedule.mjs +176 -0
  59. package/cli/stack.mjs +438 -0
  60. package/cli/switch-memory-provider.mjs +358 -0
  61. package/cli/wiring-report.mjs +125 -0
  62. package/compose.bench.yaml +12 -0
  63. package/compose.yaml +227 -0
  64. package/config/coding-agent.json +13 -0
  65. package/config/hindsight.env.example +26 -0
  66. package/config/index.yml +54 -0
  67. package/config/litellm.lemond.yaml +40 -0
  68. package/config/litellm.ollama.yaml +33 -0
  69. package/config/notebooklm.yml +33 -0
  70. package/config/openmem.toml.example +124 -0
  71. package/docs/OPENMEM.md +23 -0
  72. package/docs/architecture.md +245 -0
  73. package/docs/assets/README.md +10 -0
  74. package/docs/assets/setup-demo.gif +0 -0
  75. package/docs/coding-agent-hooks.md +75 -0
  76. package/docs/coding-agent-scopes.md +22 -0
  77. package/docs/coding-agents.md +71 -0
  78. package/docs/cognee.md +37 -0
  79. package/docs/compatibility.md +80 -0
  80. package/docs/concepts.md +135 -0
  81. package/docs/context-management.md +255 -0
  82. package/docs/context-proxy.md +115 -0
  83. package/docs/context-research.md +215 -0
  84. package/docs/context-store.md +277 -0
  85. package/docs/hindsight.md +77 -0
  86. package/docs/index-sources-roadmap.md +38 -0
  87. package/docs/index-sources.md +39 -0
  88. package/docs/indexer-sinks.md +122 -0
  89. package/docs/indexing.md +57 -0
  90. package/docs/manual-stack-setup.md +74 -0
  91. package/docs/mcp-install-guide.md +4 -0
  92. package/docs/mcp-setup.md +104 -0
  93. package/docs/mem0.md +141 -0
  94. package/docs/memory-portability.md +73 -0
  95. package/docs/memory-providers.md +246 -0
  96. package/docs/meridian-litellm.md +198 -0
  97. package/docs/model-configuration.md +106 -0
  98. package/docs/notebooklm.md +83 -0
  99. package/docs/npx-install.md +172 -0
  100. package/docs/openmem-config.md +194 -0
  101. package/docs/provider-wizard-hooks.md +222 -0
  102. package/docs/quickstart.md +107 -0
  103. package/docs/setup-wizard.md +117 -0
  104. package/docs/sources-feeds.md +39 -0
  105. package/docs/sources-readwise.md +23 -0
  106. package/docs/sources-zotero.md +22 -0
  107. package/glama.json +4 -0
  108. package/indexer/Dockerfile +38 -0
  109. package/indexer/code_outline.py +146 -0
  110. package/indexer/feed_sync.py +107 -0
  111. package/indexer/index_config.py +290 -0
  112. package/indexer/pytest.ini +5 -0
  113. package/indexer/sinks/__init__.py +148 -0
  114. package/indexer/sinks/_http.py +44 -0
  115. package/indexer/sinks/cognee.py +329 -0
  116. package/indexer/sinks/hindsight.py +107 -0
  117. package/indexer/sinks/mem0.py +247 -0
  118. package/indexer/watch.py +240 -0
  119. package/package.json +96 -4
  120. package/providers/_template/compose.yaml +52 -0
  121. package/providers/cognee/OPERATOR.md +82 -0
  122. package/providers/cognee/compose.yaml +89 -0
  123. package/providers/cognee/lib/clack.mjs +157 -0
  124. package/providers/cognee/lib/coding-agent.mjs +26 -0
  125. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  126. package/providers/cognee/lib/config.mjs +98 -0
  127. package/providers/cognee/lib/env-file.mjs +194 -0
  128. package/providers/cognee/lib/env-setup.mjs +83 -0
  129. package/providers/cognee/lib/llm-env.mjs +382 -0
  130. package/providers/cognee/lib/ports.mjs +25 -0
  131. package/providers/cognee/lib/smoke.mjs +246 -0
  132. package/providers/cognee/lib/stack-env.mjs +134 -0
  133. package/providers/cognee/lib/state.mjs +101 -0
  134. package/providers/cognee/lib/summary.mjs +56 -0
  135. package/providers/cognee/lib/validate.mjs +97 -0
  136. package/providers/cognee/manifest.json +56 -0
  137. package/providers/cognee/wizard.mjs +134 -0
  138. package/providers/graphiti/manifest.json +15 -0
  139. package/providers/hindsight/lib/clack.mjs +347 -0
  140. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  141. package/providers/hindsight/lib/config.mjs +79 -0
  142. package/providers/hindsight/lib/env-setup.mjs +72 -0
  143. package/providers/hindsight/lib/litellm-routes.mjs +196 -0
  144. package/providers/hindsight/lib/meridian.mjs +307 -0
  145. package/providers/hindsight/lib/model-backend.mjs +243 -0
  146. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  147. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  148. package/providers/hindsight/lib/ports.mjs +30 -0
  149. package/providers/hindsight/lib/probe.mjs +54 -0
  150. package/providers/hindsight/lib/stack-env.mjs +125 -0
  151. package/providers/hindsight/lib/state.mjs +80 -0
  152. package/providers/hindsight/lib/summary.mjs +57 -0
  153. package/providers/hindsight/lib/validate.mjs +81 -0
  154. package/providers/hindsight/manifest.json +58 -0
  155. package/providers/hindsight/wizard.mjs +150 -0
  156. package/providers/letta/manifest.json +29 -0
  157. package/providers/mem0/OPERATOR.md +143 -0
  158. package/providers/mem0/compose.yaml +79 -0
  159. package/providers/mem0/lib/coding-agent.mjs +58 -0
  160. package/providers/mem0/lib/config.mjs +45 -0
  161. package/providers/mem0/lib/configure.mjs +201 -0
  162. package/providers/mem0/lib/dotenv.mjs +71 -0
  163. package/providers/mem0/lib/env-file.mjs +110 -0
  164. package/providers/mem0/lib/http.mjs +69 -0
  165. package/providers/mem0/lib/litellm.mjs +457 -0
  166. package/providers/mem0/lib/mcp-install.mjs +165 -0
  167. package/providers/mem0/lib/secret.mjs +30 -0
  168. package/providers/mem0/lib/smoke.mjs +179 -0
  169. package/providers/mem0/lib/stack-env.mjs +255 -0
  170. package/providers/mem0/lib/state.mjs +67 -0
  171. package/providers/mem0/lib/summary.mjs +88 -0
  172. package/providers/mem0/lib/url.mjs +106 -0
  173. package/providers/mem0/lib/validate.mjs +158 -0
  174. package/providers/mem0/manifest.json +47 -0
  175. package/providers/mem0/wizard.mjs +146 -0
  176. package/providers/zep/manifest.json +29 -0
  177. package/proxy/config.yaml +33 -0
  178. package/proxy/openmem_context/__init__.py +6 -0
  179. package/proxy/openmem_context/adapters.py +504 -0
  180. package/proxy/openmem_context/compress.py +53 -0
  181. package/proxy/openmem_context/core.py +606 -0
  182. package/proxy/openmem_context/embed.py +212 -0
  183. package/proxy/openmem_context/recall.py +49 -0
  184. package/proxy/openmem_context/session.py +70 -0
  185. package/proxy/openmem_context/sessiondb.py +276 -0
  186. package/proxy/openmem_context/settings.py +134 -0
  187. package/proxy/openmem_context/store.py +129 -0
  188. package/proxy/openmem_context/tokens.py +123 -0
  189. package/proxy/openmem_context/ttl.py +78 -0
  190. package/proxy/openmem_context_hook.py +34 -0
  191. package/scripts/check-pack.mjs +219 -0
  192. package/scripts/gen-masks.mjs +83 -0
  193. package/scripts/gen-masks.sh +4 -0
  194. package/scripts/promotion-provider-smoke.mjs +34 -0
  195. package/scripts/run-setup.sh +30 -0
  196. package/scripts/setup.mjs +107 -0
  197. package/scripts/setup.sh +5 -0
  198. package/scripts/spin-up.sh +45 -0
  199. package/scripts/wizard.sh +5 -0
  200. package/server.json +34 -0
  201. package/sync/Dockerfile +27 -0
  202. package/sync/memory_backend.py +87 -0
  203. package/sync/notebooklm_sync.py +664 -0
  204. package/wizard/bin/pm.mjs +97 -0
  205. package/wizard/bin/setup.mjs +33 -0
  206. package/wizard/lib/compose-version.mjs +32 -0
  207. package/wizard/lib/compose.mjs +70 -0
  208. package/wizard/lib/context-step.mjs +140 -0
  209. package/wizard/lib/curl.mjs +103 -0
  210. package/wizard/lib/docker-build.mjs +148 -0
  211. package/wizard/lib/exec.mjs +41 -0
  212. package/wizard/lib/indexer.mjs +6 -0
  213. package/wizard/lib/litellm-env.mjs +55 -0
  214. package/wizard/lib/load-machine-env.mjs +4 -0
  215. package/wizard/lib/log.mjs +32 -0
  216. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  217. package/wizard/lib/notebooklm.mjs +214 -0
  218. package/wizard/lib/parse-args.mjs +87 -0
  219. package/wizard/lib/paths.mjs +39 -0
  220. package/wizard/lib/pm-config.mjs +371 -0
  221. package/wizard/lib/ports.mjs +94 -0
  222. package/wizard/lib/prompts.mjs +85 -0
  223. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  224. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  225. package/wizard/lib/providers/registry.mjs +262 -0
  226. package/wizard/lib/run-clack.mjs +108 -0
  227. package/wizard/lib/run-wizard.mjs +750 -0
  228. package/wizard/lib/validate-setup.mjs +62 -0
  229. package/wizard/lib/wizard-action.mjs +34 -0
  230. package/wizard/lib/wizard-conf.mjs +71 -0
@@ -0,0 +1,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()