@bcmyguest/openmem 0.6.3 → 0.9.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 +81 -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 +899 -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 +230 -0
- package/docs/context-proxy.md +115 -0
- package/docs/context-research.md +226 -0
- package/docs/context-store.md +277 -0
- package/docs/evidence-plan.md +216 -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 +608 -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/run-setup.sh +14 -5
- 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,212 @@
|
|
|
1
|
+
"""Background embeddings for session databases, off the request path (docs/context-store.md, "Vectors").
|
|
2
|
+
|
|
3
|
+
The request path calls Embedder.notify(path, ...) after it stores new rows; one daemon thread embeds rows that have
|
|
4
|
+
no vector for the configured model through an OpenAI-compatible POST <url>/embeddings and stores them. Trimmed rows
|
|
5
|
+
go first, newest first. Batches adapt to the endpoint: they start at START_BATCH, double while a call takes under
|
|
6
|
+
GROW_FRACTION of its timeout, and halve on a failure down to 1. A row that fails alone MAX_FAILURES times is skipped
|
|
7
|
+
and recorded as a dim-0 vector. Errors are logged (type and location only) and an endpoint that keeps failing is
|
|
8
|
+
retried after RETRY_S. Stdlib only; cli/coding-agent/session-db.mjs embeds the same way.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import traceback
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
from .sessiondb import BUSY_TIMEOUT_MS, pack_vector
|
|
21
|
+
from .settings import DEFAULT_EMBEDDING_MAX_CHARS as DEFAULT_MAX_CHARS
|
|
22
|
+
from .settings import DEFAULT_EMBEDDING_TIMEOUT_S as DEFAULT_TIMEOUT_S
|
|
23
|
+
|
|
24
|
+
START_BATCH = 4
|
|
25
|
+
MAX_BATCH = 32
|
|
26
|
+
# A call finishing under this fraction of its timeout doubles the next batch.
|
|
27
|
+
GROW_FRACTION = 0.25
|
|
28
|
+
# Single-row failures before a row is skipped; also the consecutive single-row failures that end a run.
|
|
29
|
+
MAX_FAILURES = 3
|
|
30
|
+
RETRY_S = 60
|
|
31
|
+
SKIPPED_BLOB = b""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _where(exc):
|
|
35
|
+
tb = traceback.extract_tb(exc.__traceback__)
|
|
36
|
+
return f"{tb[-1].filename.rsplit('/', 1)[-1]}:{tb[-1].lineno}" if tb else "?"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _log(message):
|
|
40
|
+
print(f"openmem-context: {message}", file=sys.stderr, flush=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _log_error(exc):
|
|
44
|
+
_log(f"embedding failed {type(exc).__name__} at {_where(exc)}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def urllib_embed(url, model, api_key, texts, timeout=DEFAULT_TIMEOUT_S):
|
|
48
|
+
"""Vectors for texts from an OpenAI-compatible embeddings endpoint, in input order."""
|
|
49
|
+
headers = {"Content-Type": "application/json"}
|
|
50
|
+
if api_key:
|
|
51
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
52
|
+
body = json.dumps({"model": model, "input": texts}).encode("utf-8")
|
|
53
|
+
req = urllib.request.Request(url.rstrip("/") + "/embeddings", data=body, headers=headers, method="POST")
|
|
54
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
55
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
56
|
+
data = sorted(payload["data"], key=lambda d: d.get("index", 0))
|
|
57
|
+
vectors = [[float(x) for x in d["embedding"]] for d in data]
|
|
58
|
+
if len(vectors) != len(texts):
|
|
59
|
+
raise ValueError("embedding count mismatch")
|
|
60
|
+
return vectors
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pending_rows(conn, model, limit, exclude=()):
|
|
64
|
+
"""Rows without a `model` vector, trimmed (in_context = 0) first, then newest first; seqs in exclude are left out."""
|
|
65
|
+
marks = ",".join("?" * len(exclude))
|
|
66
|
+
skip = f" AND m.seq NOT IN ({marks})" if exclude else ""
|
|
67
|
+
return conn.execute(
|
|
68
|
+
"SELECT m.seq, m.text FROM messages m LEFT JOIN vectors v ON v.message_seq = m.seq AND v.model = ? "
|
|
69
|
+
f"WHERE v.message_seq IS NULL AND length(trim(m.text)) > 0{skip} ORDER BY m.in_context, m.seq DESC LIMIT ?",
|
|
70
|
+
(model, *sorted(exclude), limit)).fetchall()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def embed_pending(path, model, url, api_key="", embed=urllib_embed, *, state=None, timeout_s=DEFAULT_TIMEOUT_S,
|
|
74
|
+
max_chars=DEFAULT_MAX_CHARS):
|
|
75
|
+
"""Embed every row of the database at path that lacks a `model` vector; returns the number stored.
|
|
76
|
+
|
|
77
|
+
state carries "batch" (the next batch size), "ok" (the endpoint's last call succeeded) and "failures"
|
|
78
|
+
({seq: single-row failures}) between runs. A failed size caps growth for the rest of the run.
|
|
79
|
+
Re-raises the last error once MAX_FAILURES single-row calls fail in a row with nothing stored this run.
|
|
80
|
+
"""
|
|
81
|
+
state = {} if state is None else state
|
|
82
|
+
state.setdefault("batch", START_BATCH)
|
|
83
|
+
state.setdefault("ok", False)
|
|
84
|
+
failures = state.setdefault("failures", {})
|
|
85
|
+
# mode=rw: never create a database that TTL cleanup removed meanwhile.
|
|
86
|
+
uri = "file:" + urllib.parse.quote(str(path)) + "?mode=rw"
|
|
87
|
+
conn = sqlite3.connect(uri, uri=True, isolation_level=None, timeout=BUSY_TIMEOUT_MS / 1000)
|
|
88
|
+
stored = 0
|
|
89
|
+
tried = set()
|
|
90
|
+
streak = 0
|
|
91
|
+
ceiling = MAX_BATCH
|
|
92
|
+
try:
|
|
93
|
+
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}")
|
|
94
|
+
while True:
|
|
95
|
+
batch = state["batch"]
|
|
96
|
+
# Rows that never failed first; earlier failures get another try once those are done.
|
|
97
|
+
rows = pending_rows(conn, model, batch, tried | set(failures)) or pending_rows(conn, model, batch, tried)
|
|
98
|
+
if not rows:
|
|
99
|
+
return stored
|
|
100
|
+
timeout = timeout_s * len(rows)
|
|
101
|
+
start = time.monotonic()
|
|
102
|
+
try:
|
|
103
|
+
vectors = embed(url, model, api_key, [text[:max_chars] for _, text in rows], timeout)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
seconds = time.monotonic() - start
|
|
106
|
+
if len(rows) > 1:
|
|
107
|
+
ceiling = max(1, len(rows) // 2)
|
|
108
|
+
state["batch"] = max(1, len(rows) // 2)
|
|
109
|
+
_log(f"embedding batch failed {type(exc).__name__} rows={len(rows)} seconds={seconds:.1f} "
|
|
110
|
+
f"batch={state['batch']}")
|
|
111
|
+
continue
|
|
112
|
+
seq = rows[0][0]
|
|
113
|
+
tried.add(seq)
|
|
114
|
+
failures[seq] = failures.get(seq, 0) + 1
|
|
115
|
+
streak += 1
|
|
116
|
+
_log(f"embedding row failed {type(exc).__name__} at {_where(exc)} seq={seq} failures={failures[seq]} "
|
|
117
|
+
f"seconds={seconds:.1f} batch=1")
|
|
118
|
+
# Blame the row only while the endpoint works for other rows.
|
|
119
|
+
if failures[seq] >= MAX_FAILURES and (stored or state["ok"]):
|
|
120
|
+
_insert(conn, [(seq, model, 0, SKIPPED_BLOB)])
|
|
121
|
+
del failures[seq]
|
|
122
|
+
_log(f"embedding skipped seq={seq} after {MAX_FAILURES} failures")
|
|
123
|
+
# The endpoint looks down: the caller retries the database after RETRY_S.
|
|
124
|
+
if streak >= MAX_FAILURES and not stored:
|
|
125
|
+
state["ok"] = False
|
|
126
|
+
raise
|
|
127
|
+
continue
|
|
128
|
+
seconds = time.monotonic() - start
|
|
129
|
+
_insert(conn, [(seq, model, len(vec), pack_vector(vec)) for (seq, _), vec in zip(rows, vectors)])
|
|
130
|
+
for seq, _ in rows:
|
|
131
|
+
failures.pop(seq, None)
|
|
132
|
+
stored += len(rows)
|
|
133
|
+
streak = 0
|
|
134
|
+
state["ok"] = True
|
|
135
|
+
if len(rows) == batch and seconds < GROW_FRACTION * timeout:
|
|
136
|
+
state["batch"] = min(ceiling, batch * 2)
|
|
137
|
+
_log(f"embedded rows={len(rows)} seconds={seconds:.1f} batch={state['batch']}")
|
|
138
|
+
finally:
|
|
139
|
+
conn.close()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _insert(conn, rows):
|
|
143
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
144
|
+
try:
|
|
145
|
+
conn.executemany("INSERT OR IGNORE INTO vectors (message_seq, model, dim, blob) VALUES (?, ?, ?, ?)", rows)
|
|
146
|
+
conn.execute("COMMIT")
|
|
147
|
+
except BaseException:
|
|
148
|
+
conn.execute("ROLLBACK")
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Embedder:
|
|
153
|
+
"""One daemon thread; notify() queues a database path and never blocks on the network."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, embed=urllib_embed):
|
|
156
|
+
self._embed = embed
|
|
157
|
+
self._queue = {}
|
|
158
|
+
self._retry_at = {}
|
|
159
|
+
# {"batch", "ok"} per (url, model); single-row failure counts per (path, model).
|
|
160
|
+
self._endpoint = {}
|
|
161
|
+
self._failures = {}
|
|
162
|
+
self._cond = threading.Condition()
|
|
163
|
+
self._thread = None
|
|
164
|
+
self._busy = False
|
|
165
|
+
|
|
166
|
+
def notify(self, path, model, url, api_key="", timeout_s=DEFAULT_TIMEOUT_S, max_chars=DEFAULT_MAX_CHARS):
|
|
167
|
+
if not model or not url:
|
|
168
|
+
return
|
|
169
|
+
with self._cond:
|
|
170
|
+
if time.monotonic() < self._retry_at.get(str(path), 0):
|
|
171
|
+
return
|
|
172
|
+
self._queue[str(path)] = (model, url, api_key, timeout_s, max_chars)
|
|
173
|
+
if self._thread is None or not self._thread.is_alive():
|
|
174
|
+
self._thread = threading.Thread(target=self._run, name="openmem-embed", daemon=True)
|
|
175
|
+
self._thread.start()
|
|
176
|
+
self._cond.notify()
|
|
177
|
+
|
|
178
|
+
def batch_size(self, url, model):
|
|
179
|
+
return self._endpoint.get((url, model), {}).get("batch", START_BATCH)
|
|
180
|
+
|
|
181
|
+
def drain(self, timeout=10):
|
|
182
|
+
"""Wait until the queue is empty (tests)."""
|
|
183
|
+
end = time.monotonic() + timeout
|
|
184
|
+
with self._cond:
|
|
185
|
+
while (self._queue or self._busy) and time.monotonic() < end:
|
|
186
|
+
self._cond.wait(0.05)
|
|
187
|
+
|
|
188
|
+
def _run(self):
|
|
189
|
+
while True:
|
|
190
|
+
with self._cond:
|
|
191
|
+
while not self._queue:
|
|
192
|
+
self._busy = False
|
|
193
|
+
self._cond.notify_all()
|
|
194
|
+
self._cond.wait()
|
|
195
|
+
path, job = next(iter(self._queue.items()))
|
|
196
|
+
del self._queue[path]
|
|
197
|
+
self._busy = True
|
|
198
|
+
model, url, api_key, timeout_s, max_chars = job
|
|
199
|
+
endpoint = self._endpoint.setdefault((url, model), {"batch": START_BATCH, "ok": False})
|
|
200
|
+
state = {**endpoint, "failures": self._failures.setdefault((path, model), {})}
|
|
201
|
+
try:
|
|
202
|
+
embed_pending(path, model, url, api_key, embed=self._embed, state=state, timeout_s=timeout_s,
|
|
203
|
+
max_chars=max_chars)
|
|
204
|
+
except Exception as exc: # never let the thread die
|
|
205
|
+
_log_error(exc)
|
|
206
|
+
with self._cond:
|
|
207
|
+
self._retry_at[path] = time.monotonic() + RETRY_S
|
|
208
|
+
finally:
|
|
209
|
+
endpoint.update(batch=state["batch"], ok=state["ok"])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
embedder = Embedder()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Hindsight recall and ingest-status calls (stdlib urllib)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.parse
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
OPS_TIMEOUT_S = 2.0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def urllib_http(method, url, body, timeout):
|
|
11
|
+
"""Default transport: returns (status, parsed JSON or None)."""
|
|
12
|
+
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
13
|
+
req = urllib.request.Request(url, data=data, method=method, headers={"content-type": "application/json"})
|
|
14
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
15
|
+
raw = resp.read()
|
|
16
|
+
return resp.status, (json.loads(raw.decode("utf-8")) if raw else None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bank_url(base, bank):
|
|
20
|
+
return f"{base}/v1/default/banks/{urllib.parse.quote(bank, safe='')}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def recall_text(http, base, bank, query, max_chars, timeout):
|
|
24
|
+
"""Recalled memory text, at most max_chars; '' when nothing came back."""
|
|
25
|
+
body = {"query": query, "budget": "low", "max_tokens": max(256, max_chars // 4)}
|
|
26
|
+
status, value = http("POST", f"{_bank_url(base, bank)}/memories/recall", body, timeout)
|
|
27
|
+
if status != 200 or not isinstance(value, dict):
|
|
28
|
+
return ""
|
|
29
|
+
hits = value.get("results") or []
|
|
30
|
+
texts = []
|
|
31
|
+
for hit in hits:
|
|
32
|
+
if isinstance(hit, dict):
|
|
33
|
+
text = hit.get("text") or hit.get("memory") or hit.get("content")
|
|
34
|
+
if isinstance(text, str) and text.strip():
|
|
35
|
+
texts.append(text.strip())
|
|
36
|
+
return "\n\n".join(texts)[:max_chars]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def active_operations(http, base, bank):
|
|
40
|
+
"""Pending + processing ingest operations for the bank."""
|
|
41
|
+
total = 0
|
|
42
|
+
for status in ("pending", "processing"):
|
|
43
|
+
code, value = http("GET", f"{_bank_url(base, bank)}/operations?status={status}&limit=1", None, OPS_TIMEOUT_S)
|
|
44
|
+
if code == 200 and isinstance(value, dict):
|
|
45
|
+
count = value.get("total")
|
|
46
|
+
if count is None:
|
|
47
|
+
count = len(value.get("operations") or [])
|
|
48
|
+
total += int(count)
|
|
49
|
+
return total
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Session id resolution, in contract order."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .store import InvalidSessionId, sanitize_sid
|
|
8
|
+
|
|
9
|
+
_LEGACY_USER_ID_RE = re.compile(r"_session_([0-9A-Za-z-]{8,})$")
|
|
10
|
+
_FALLBACK_HEADERS = ("session_id", "x-session-id", "conversation_id")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _headers(data):
|
|
14
|
+
psr = data.get("proxy_server_request") or {}
|
|
15
|
+
raw = psr.get("headers") or {}
|
|
16
|
+
return {str(k).lower(): v for k, v in raw.items()} if isinstance(raw, dict) else {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _body(data):
|
|
20
|
+
psr = data.get("proxy_server_request") or {}
|
|
21
|
+
body = psr.get("body")
|
|
22
|
+
return body if isinstance(body, dict) else {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _from_user_id(user_id):
|
|
26
|
+
if not isinstance(user_id, str) or not user_id:
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
parsed = json.loads(user_id)
|
|
30
|
+
except ValueError:
|
|
31
|
+
parsed = None
|
|
32
|
+
if isinstance(parsed, dict) and parsed.get("session_id"):
|
|
33
|
+
return str(parsed["session_id"])
|
|
34
|
+
m = _LEGACY_USER_ID_RE.search(user_id)
|
|
35
|
+
return m.group(1) if m else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _first_user_text(adapter, items):
|
|
39
|
+
for item in items or ():
|
|
40
|
+
if isinstance(item, dict) and adapter.is_turn_start(item):
|
|
41
|
+
return adapter.user_text(item)
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _candidates(data, headers, body):
|
|
46
|
+
"""Session id sources in contract order (docs/context-store.md, "Session id")."""
|
|
47
|
+
yield headers.get("x-openmem-session-id")
|
|
48
|
+
yield headers.get("x-claude-code-session-id")
|
|
49
|
+
for meta in (data.get("metadata"), body.get("metadata")):
|
|
50
|
+
if isinstance(meta, dict):
|
|
51
|
+
yield _from_user_id(meta.get("user_id"))
|
|
52
|
+
yield data.get("prompt_cache_key")
|
|
53
|
+
yield body.get("prompt_cache_key")
|
|
54
|
+
for name in _FALLBACK_HEADERS:
|
|
55
|
+
yield headers.get(name)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_session_id(data, adapter, items):
|
|
59
|
+
"""Sanitized session id from the first source that yields a non-empty one, else fp-<hash>."""
|
|
60
|
+
headers = _headers(data)
|
|
61
|
+
body = _body(data)
|
|
62
|
+
for raw in _candidates(data, headers, body):
|
|
63
|
+
if not raw:
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
return sanitize_sid(raw)
|
|
67
|
+
except InvalidSessionId:
|
|
68
|
+
continue
|
|
69
|
+
seed = adapter.system_text(data) + _first_user_text(adapter, items)
|
|
70
|
+
return "fp-" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Per-session SQLite store `sessions/<sid>.db` (docs/context-store.md, "Session database"). Stdlib only.
|
|
2
|
+
|
|
3
|
+
The proxy writes every request's messages here, idempotently; the MCP tools read it. The schema and the row
|
|
4
|
+
rules must match cli/coding-agent/session-db.mjs byte for byte (scripts/tests/test_context_parity.mjs).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sqlite3
|
|
11
|
+
import struct
|
|
12
|
+
import threading
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .tokens import fingerprint
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = 3
|
|
19
|
+
# Version 1, executed in order on an empty database. Same strings as SCHEMA in cli/coding-agent/session-db.mjs.
|
|
20
|
+
SCHEMA = (
|
|
21
|
+
"CREATE TABLE messages (seq INTEGER PRIMARY KEY, fingerprint TEXT NOT NULL UNIQUE, thread TEXT NOT NULL, "
|
|
22
|
+
"role TEXT NOT NULL, kind TEXT NOT NULL, name TEXT, text TEXT NOT NULL, blob_id TEXT, "
|
|
23
|
+
"in_context INTEGER NOT NULL DEFAULT 1, trimmed_at_seq INTEGER, trimmed_at TEXT, created_at TEXT NOT NULL)",
|
|
24
|
+
"CREATE INDEX messages_thread ON messages (thread, seq)",
|
|
25
|
+
"CREATE TABLE lines (id INTEGER PRIMARY KEY, message_seq INTEGER NOT NULL REFERENCES messages (seq), "
|
|
26
|
+
"line_no INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, UNIQUE (message_seq, line_no))",
|
|
27
|
+
"CREATE VIRTUAL TABLE messages_fts USING fts5 (text, content='messages', content_rowid='seq')",
|
|
28
|
+
"CREATE VIRTUAL TABLE lines_fts USING fts5 (text, content='lines', content_rowid='id')",
|
|
29
|
+
"CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN "
|
|
30
|
+
"INSERT INTO messages_fts (rowid, text) VALUES (new.seq, new.text); END",
|
|
31
|
+
"CREATE TRIGGER lines_fts_insert AFTER INSERT ON lines BEGIN "
|
|
32
|
+
"INSERT INTO lines_fts (rowid, text) VALUES (new.id, new.text); END",
|
|
33
|
+
"CREATE TABLE vectors (message_seq INTEGER NOT NULL REFERENCES messages (seq), model TEXT NOT NULL, "
|
|
34
|
+
"dim INTEGER NOT NULL, blob BLOB NOT NULL, PRIMARY KEY (message_seq, model))",
|
|
35
|
+
)
|
|
36
|
+
# (version, DDL) run in order after SCHEMA on a new database, or from the stored version up on an older one; then
|
|
37
|
+
# PRAGMA user_version = SCHEMA_VERSION. Same strings as MIGRATIONS in cli/coding-agent/session-db.mjs.
|
|
38
|
+
MIGRATIONS = (
|
|
39
|
+
# Trims and fail-opens: kind 'trim' | 'fail_open'; version names the writer ("proxy:<hash>", "opencode:<v>").
|
|
40
|
+
(2, ("CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL, kind TEXT NOT NULL, version TEXT NOT NULL, "
|
|
41
|
+
"location TEXT, error TEXT, tokens_before INTEGER, tokens_after INTEGER, dropped INTEGER)",)),
|
|
42
|
+
# Per-request usage: kind 'usage', with provider-reported token counts, prompt-cache reads and writes, whether
|
|
43
|
+
# the request carried a cut, and its cache_control breakpoints (Anthropic route).
|
|
44
|
+
(3, tuple(f"ALTER TABLE events ADD COLUMN {c}" for c in (
|
|
45
|
+
"model TEXT", "thread_root TEXT", "trimmed INTEGER", "input_tokens INTEGER", "cache_read_tokens INTEGER",
|
|
46
|
+
"cache_creation_tokens INTEGER", "output_tokens INTEGER", "breakpoints INTEGER", "breakpoint_at TEXT"))),
|
|
47
|
+
)
|
|
48
|
+
EVENT_COLUMNS = ("at", "kind", "version", "location", "error", "tokens_before", "tokens_after", "dropped", "model",
|
|
49
|
+
"thread_root", "trimmed", "input_tokens", "cache_read_tokens", "cache_creation_tokens", "output_tokens",
|
|
50
|
+
"breakpoints", "breakpoint_at")
|
|
51
|
+
_INSERT_EVENT = f"INSERT INTO events ({', '.join(EVENT_COLUMNS)}) VALUES ({', '.join('?' * len(EVENT_COLUMNS))})"
|
|
52
|
+
DB_SUFFIX = ".db"
|
|
53
|
+
FILE_MODE = 0o600
|
|
54
|
+
DIR_MODE = 0o700
|
|
55
|
+
BUSY_TIMEOUT_MS = 1000
|
|
56
|
+
MAX_OPEN = 16
|
|
57
|
+
_BLOB_REF_RE = re.compile(r"^\[blob:([0-9a-f]{16})\]")
|
|
58
|
+
_open = OrderedDict()
|
|
59
|
+
_open_guard = threading.Lock()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UnsupportedSchema(RuntimeError):
|
|
63
|
+
"""The database has a user_version this code does not know."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def blob_ref(text):
|
|
67
|
+
"""Blob id named by a stored tool-result text ("[blob:<id>]..."), else None."""
|
|
68
|
+
m = _BLOB_REF_RE.match(text) if isinstance(text, str) else None
|
|
69
|
+
return m.group(1) if m else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def chain(rows):
|
|
73
|
+
"""Give each row its fingerprint and thread, in order, in place; returns rows.
|
|
74
|
+
|
|
75
|
+
A row's fingerprint hashes the previous row's fingerprint with the row's own role, kind, name and text, so it
|
|
76
|
+
names the row and its position in the conversation: a resent history yields the same keys, and the same text
|
|
77
|
+
said twice yields two rows. thread is the fingerprint of the conversation's first row.
|
|
78
|
+
"""
|
|
79
|
+
prev = ""
|
|
80
|
+
for row in rows:
|
|
81
|
+
prev = row["fingerprint"] = fingerprint(
|
|
82
|
+
{"prev": prev, "role": row["role"], "kind": row["kind"], "name": row["name"], "text": row["text"]})
|
|
83
|
+
row["thread"] = rows[0]["fingerprint"]
|
|
84
|
+
return rows
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ensure_file(path):
|
|
88
|
+
path.parent.mkdir(mode=DIR_MODE, parents=True, exist_ok=True)
|
|
89
|
+
try:
|
|
90
|
+
os.chmod(path.parent, DIR_MODE)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
if not path.exists():
|
|
94
|
+
os.close(os.open(path, os.O_WRONLY | os.O_CREAT, FILE_MODE))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def connect(path, create=True):
|
|
98
|
+
"""Writer connection in WAL mode with the schema in place; raises UnsupportedSchema on a newer database."""
|
|
99
|
+
path = Path(path)
|
|
100
|
+
if create:
|
|
101
|
+
_ensure_file(path)
|
|
102
|
+
conn = sqlite3.connect(str(path), isolation_level=None, check_same_thread=False, timeout=BUSY_TIMEOUT_MS / 1000)
|
|
103
|
+
try:
|
|
104
|
+
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}")
|
|
105
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
106
|
+
conn.execute("PRAGMA synchronous = NORMAL")
|
|
107
|
+
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
|
|
108
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
109
|
+
try:
|
|
110
|
+
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
111
|
+
if version < SCHEMA_VERSION:
|
|
112
|
+
if version == 0:
|
|
113
|
+
for ddl in SCHEMA:
|
|
114
|
+
conn.execute(ddl)
|
|
115
|
+
for target, ddls in MIGRATIONS:
|
|
116
|
+
if target > max(version, 1):
|
|
117
|
+
for ddl in ddls:
|
|
118
|
+
conn.execute(ddl)
|
|
119
|
+
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
120
|
+
conn.execute("COMMIT")
|
|
121
|
+
except BaseException:
|
|
122
|
+
conn.execute("ROLLBACK")
|
|
123
|
+
raise
|
|
124
|
+
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
125
|
+
if version != SCHEMA_VERSION:
|
|
126
|
+
raise UnsupportedSchema(f"user_version {version}")
|
|
127
|
+
except BaseException:
|
|
128
|
+
conn.close()
|
|
129
|
+
raise
|
|
130
|
+
return conn
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _identity(path):
|
|
134
|
+
try:
|
|
135
|
+
st = os.stat(path)
|
|
136
|
+
except OSError:
|
|
137
|
+
return None
|
|
138
|
+
return st.st_dev, st.st_ino
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SessionDB:
|
|
142
|
+
"""One session's database plus an in-memory index of its rows (fingerprint -> [seq, in_context])."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, path):
|
|
145
|
+
self.path = Path(path)
|
|
146
|
+
self.conn = connect(self.path)
|
|
147
|
+
self.identity = _identity(self.path)
|
|
148
|
+
self.known = {}
|
|
149
|
+
self.data_version = None
|
|
150
|
+
|
|
151
|
+
def close(self):
|
|
152
|
+
try:
|
|
153
|
+
self.conn.close()
|
|
154
|
+
except sqlite3.Error:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def _refresh(self):
|
|
158
|
+
"""Reload the index when another connection committed since the last look."""
|
|
159
|
+
version = self.conn.execute("PRAGMA data_version").fetchone()[0]
|
|
160
|
+
if version != self.data_version:
|
|
161
|
+
self.known = {fp: [seq, flag] for fp, seq, flag in
|
|
162
|
+
self.conn.execute("SELECT fingerprint, seq, in_context FROM messages")}
|
|
163
|
+
self.data_version = version
|
|
164
|
+
|
|
165
|
+
def sync(self, rows, cut_row, ts):
|
|
166
|
+
"""Store chained rows and their in_context flags; returns the number of new rows.
|
|
167
|
+
|
|
168
|
+
rows: dicts with fingerprint, thread, role, kind, name, text, blob_id and keep (in context after this
|
|
169
|
+
request). cut_row: index into rows of the first row of the cut turn, or None. Rows already stored are left
|
|
170
|
+
alone except for in_context: a row that leaves context records trimmed_at_seq (the cut row's seq, NULL
|
|
171
|
+
without one) and trimmed_at; a row that returns clears both.
|
|
172
|
+
"""
|
|
173
|
+
self._refresh()
|
|
174
|
+
known = self.known
|
|
175
|
+
fresh = [r for r in rows if r["fingerprint"] not in known]
|
|
176
|
+
changes = [r for r in rows if r["fingerprint"] in known and known[r["fingerprint"]][1] != int(r["keep"])]
|
|
177
|
+
if not fresh and not changes:
|
|
178
|
+
return 0
|
|
179
|
+
conn = self.conn
|
|
180
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
181
|
+
try:
|
|
182
|
+
added = 0
|
|
183
|
+
for r in fresh:
|
|
184
|
+
cur = conn.execute(
|
|
185
|
+
"INSERT INTO messages (fingerprint, thread, role, kind, name, text, blob_id, created_at) "
|
|
186
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (fingerprint) DO NOTHING",
|
|
187
|
+
(r["fingerprint"], r["thread"], r["role"], r["kind"], r["name"], r["text"], r["blob_id"], ts))
|
|
188
|
+
if cur.rowcount == 1:
|
|
189
|
+
seq = cur.lastrowid
|
|
190
|
+
conn.executemany("INSERT INTO lines (message_seq, line_no, role, text) VALUES (?, ?, ?, ?)",
|
|
191
|
+
[(seq, n, r["role"], line) for n, line in enumerate(r["text"].split("\n"), 1)])
|
|
192
|
+
known[r["fingerprint"]] = [seq, 1]
|
|
193
|
+
added += 1
|
|
194
|
+
else:
|
|
195
|
+
seq, flag = conn.execute("SELECT seq, in_context FROM messages WHERE fingerprint = ?",
|
|
196
|
+
(r["fingerprint"],)).fetchone()
|
|
197
|
+
known[r["fingerprint"]] = [seq, flag]
|
|
198
|
+
cut_seq = known[rows[cut_row]["fingerprint"]][0] if cut_row is not None else None
|
|
199
|
+
for r in rows:
|
|
200
|
+
entry = known[r["fingerprint"]]
|
|
201
|
+
want = int(r["keep"])
|
|
202
|
+
if entry[1] == want:
|
|
203
|
+
continue
|
|
204
|
+
if want:
|
|
205
|
+
conn.execute("UPDATE messages SET in_context = 1, trimmed_at_seq = NULL, trimmed_at = NULL "
|
|
206
|
+
"WHERE seq = ?", (entry[0],))
|
|
207
|
+
else:
|
|
208
|
+
conn.execute("UPDATE messages SET in_context = 0, trimmed_at_seq = ?, trimmed_at = ? "
|
|
209
|
+
"WHERE seq = ?", (cut_seq, ts, entry[0]))
|
|
210
|
+
entry[1] = want
|
|
211
|
+
conn.execute("COMMIT")
|
|
212
|
+
except BaseException:
|
|
213
|
+
try:
|
|
214
|
+
conn.execute("ROLLBACK")
|
|
215
|
+
except sqlite3.Error:
|
|
216
|
+
pass
|
|
217
|
+
self.data_version = None
|
|
218
|
+
raise
|
|
219
|
+
self.data_version = conn.execute("PRAGMA data_version").fetchone()[0]
|
|
220
|
+
return added
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def add_event(self, kind, at, version, **fields):
|
|
224
|
+
"""Append one events row; fields are the other EVENT_COLUMNS (location, error, tokens_before, ...)."""
|
|
225
|
+
values = {"at": at, "kind": kind, "version": version, **fields}
|
|
226
|
+
self.conn.execute(_INSERT_EVENT, tuple(values.get(c) for c in EVENT_COLUMNS))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def open_session(path):
|
|
230
|
+
"""Cached SessionDB for path; reopened when the file was deleted or replaced (TTL cleanup)."""
|
|
231
|
+
path = Path(path)
|
|
232
|
+
with _open_guard:
|
|
233
|
+
db = _open.pop(str(path), None)
|
|
234
|
+
if db is not None and db.identity != _identity(path):
|
|
235
|
+
db.close()
|
|
236
|
+
db = None
|
|
237
|
+
if db is None:
|
|
238
|
+
db = SessionDB(path)
|
|
239
|
+
_open[str(path)] = db
|
|
240
|
+
while len(_open) > MAX_OPEN:
|
|
241
|
+
_open.popitem(last=False)[1].close()
|
|
242
|
+
return db
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def drop_session(path):
|
|
246
|
+
"""Forget a cached connection, after an error, so the next request reopens it."""
|
|
247
|
+
with _open_guard:
|
|
248
|
+
db = _open.pop(str(Path(path)), None)
|
|
249
|
+
if db is not None:
|
|
250
|
+
db.close()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Vectors: float32 little-endian, brute-force cosine (sessions hold thousands of rows).
|
|
254
|
+
def pack_vector(values):
|
|
255
|
+
return struct.pack(f"<{len(values)}f", *values)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def unpack_vector(blob, dim):
|
|
259
|
+
return struct.unpack(f"<{dim}f", blob)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cosine(a, b):
|
|
263
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
264
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
265
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
266
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def nearest(conn, query, model, k):
|
|
270
|
+
"""(seq, similarity) of the k stored vectors of `model` closest to query, best first; ties by seq."""
|
|
271
|
+
scored = []
|
|
272
|
+
for seq, dim, blob in conn.execute("SELECT message_seq, dim, blob FROM vectors WHERE model = ?", (model,)):
|
|
273
|
+
if dim == len(query):
|
|
274
|
+
scored.append((seq, cosine(query, unpack_vector(blob, dim))))
|
|
275
|
+
scored.sort(key=lambda x: (-x[1], x[0]))
|
|
276
|
+
return scored[:k]
|