superlocalmemory 3.6.2 → 3.6.4
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/CHANGELOG.md +180 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/consolidation_engine.py +8 -0
- package/src/superlocalmemory/core/engine.py +6 -1
- package/src/superlocalmemory/core/fact_consolidator.py +54 -13
- package/src/superlocalmemory/core/graph_pruner.py +55 -19
- package/src/superlocalmemory/core/maintenance.py +9 -0
- package/src/superlocalmemory/core/store_pipeline.py +120 -51
- package/src/superlocalmemory/mcp/_stdin_guard.py +60 -0
- package/src/superlocalmemory/mcp/server.py +34 -5
- package/src/superlocalmemory/optimize/adapters/wrap.py +12 -3
- package/src/superlocalmemory/optimize/cache/manager.py +63 -9
- package/src/superlocalmemory/optimize/compress/router.py +7 -9
- package/src/superlocalmemory/optimize/proxy/_helpers.py +236 -1
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +187 -5
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +373 -14
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +329 -7
- package/src/superlocalmemory/optimize/proxy/server.py +10 -3
- package/src/superlocalmemory/retrieval/engine.py +17 -6
- package/src/superlocalmemory/retrieval/entity_channel.py +10 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +3 -1
- package/src/superlocalmemory/storage/database.py +113 -9
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +3 -1
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
"""openai_surface.py — OpenAI /v1/chat/completions and /v1/embeddings.
|
|
1
|
+
"""openai_surface.py — OpenAI /v1/chat/completions and /v1/embeddings.
|
|
2
|
+
|
|
3
|
+
BUG-FIXES (v3.6.3):
|
|
4
|
+
- Streaming path previously bypassed cache AND had _safe_compress not imported
|
|
5
|
+
(NameError on any non-streaming compressed call). Same set of bugs as
|
|
6
|
+
anthropic_surface.py pre-v3.6.3. Fixed with the same pattern:
|
|
7
|
+
cache check → _stream_and_cache_forward → post-stream store.
|
|
8
|
+
- _safe_compress added to imports (was missing; NameError on compress path).
|
|
9
|
+
- Streaming cache: accumulate SSE, parse to JSON, store so future identical
|
|
10
|
+
calls are served from cache. OpenAI SSE format differs from Anthropic's
|
|
11
|
+
so a dedicated _parse_openai_sse_to_json / _openai_sse_from_cached helper
|
|
12
|
+
is used.
|
|
13
|
+
"""
|
|
2
14
|
|
|
3
15
|
from __future__ import annotations
|
|
4
16
|
|
|
@@ -6,7 +18,7 @@ import json
|
|
|
6
18
|
import logging
|
|
7
19
|
|
|
8
20
|
from fastapi.requests import Request
|
|
9
|
-
from fastapi.responses import Response
|
|
21
|
+
from fastapi.responses import Response, StreamingResponse
|
|
10
22
|
|
|
11
23
|
from superlocalmemory.optimize.proxy._helpers import (
|
|
12
24
|
_OPENAI_FORWARD_HEADERS,
|
|
@@ -18,6 +30,8 @@ from superlocalmemory.optimize.proxy._helpers import (
|
|
|
18
30
|
_safe_cache_check,
|
|
19
31
|
_safe_cache_hit_callbacks,
|
|
20
32
|
_safe_cache_store,
|
|
33
|
+
_safe_compress,
|
|
34
|
+
_stream_and_cache_forward,
|
|
21
35
|
_stream_forward,
|
|
22
36
|
)
|
|
23
37
|
from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
|
|
@@ -27,6 +41,269 @@ logger = logging.getLogger("slm.optimize.proxy.openai")
|
|
|
27
41
|
_UPSTREAM_BASE = "https://api.openai.com"
|
|
28
42
|
|
|
29
43
|
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# OpenAI SSE helpers (streaming cache hit/store)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _parse_openai_sse_to_json(sse_bytes: bytes) -> bytes | None:
|
|
49
|
+
"""Parse an accumulated OpenAI SSE stream into a single chat.completion JSON.
|
|
50
|
+
|
|
51
|
+
OpenAI streaming format:
|
|
52
|
+
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[
|
|
53
|
+
{"index":0,"delta":{"role":"assistant","content":"Hello"},...}]}
|
|
54
|
+
...
|
|
55
|
+
data: [DONE]
|
|
56
|
+
|
|
57
|
+
Assembles into the non-streaming chat.completion format so it can be stored
|
|
58
|
+
in the cache and replayed as SSE on the next identical call.
|
|
59
|
+
|
|
60
|
+
BUG-FIX (v3.6.4): Previously returned None for responses containing
|
|
61
|
+
tool_calls, meaning OpenAI-compatible tool-bearing clients (Codex CLI,
|
|
62
|
+
Antigravity) were NEVER cached. Now accumulates tool_calls by index and
|
|
63
|
+
includes them in the stored JSON so _openai_sse_from_cached_json can
|
|
64
|
+
replay them correctly.
|
|
65
|
+
|
|
66
|
+
Returns None only for incomplete streams (no [DONE], missing id).
|
|
67
|
+
"""
|
|
68
|
+
completion_id = ""
|
|
69
|
+
model = ""
|
|
70
|
+
created = 0
|
|
71
|
+
text_acc: dict[int, str] = {} # choice_index → accumulated content
|
|
72
|
+
finish_reasons: dict[int, str] = {}
|
|
73
|
+
# tool_calls_acc[choice_idx][tc_idx] = {id, type, function: {name, args_parts}}
|
|
74
|
+
tool_calls_acc: dict[int, dict[int, dict]] = {}
|
|
75
|
+
prompt_tokens = 0
|
|
76
|
+
completion_tokens = 0
|
|
77
|
+
done_seen = False
|
|
78
|
+
|
|
79
|
+
for raw_line in sse_bytes.decode("utf-8", errors="replace").split("\n"):
|
|
80
|
+
line = raw_line.rstrip("\r")
|
|
81
|
+
if not line.startswith("data: "):
|
|
82
|
+
continue
|
|
83
|
+
data_str = line[6:].strip()
|
|
84
|
+
if data_str == "[DONE]":
|
|
85
|
+
done_seen = True
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
chunk = json.loads(data_str)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if not completion_id:
|
|
93
|
+
completion_id = chunk.get("id", "")
|
|
94
|
+
if not model:
|
|
95
|
+
model = chunk.get("model", "")
|
|
96
|
+
if not created:
|
|
97
|
+
created = chunk.get("created", 0)
|
|
98
|
+
|
|
99
|
+
for choice in chunk.get("choices", []):
|
|
100
|
+
idx = choice.get("index", 0)
|
|
101
|
+
delta = choice.get("delta", {})
|
|
102
|
+
|
|
103
|
+
content = delta.get("content")
|
|
104
|
+
if content:
|
|
105
|
+
text_acc[idx] = text_acc.get(idx, "") + content
|
|
106
|
+
|
|
107
|
+
for tc in delta.get("tool_calls", []):
|
|
108
|
+
tc_idx = tc.get("index", 0)
|
|
109
|
+
if idx not in tool_calls_acc:
|
|
110
|
+
tool_calls_acc[idx] = {}
|
|
111
|
+
if tc_idx not in tool_calls_acc[idx]:
|
|
112
|
+
tool_calls_acc[idx][tc_idx] = {
|
|
113
|
+
"id": "",
|
|
114
|
+
"type": "function",
|
|
115
|
+
"function": {"name": "", "arguments_parts": []},
|
|
116
|
+
}
|
|
117
|
+
entry = tool_calls_acc[idx][tc_idx]
|
|
118
|
+
if tc.get("id"):
|
|
119
|
+
entry["id"] = tc["id"]
|
|
120
|
+
if tc.get("type"):
|
|
121
|
+
entry["type"] = tc["type"]
|
|
122
|
+
fn = tc.get("function", {})
|
|
123
|
+
if fn.get("name"):
|
|
124
|
+
entry["function"]["name"] = fn["name"]
|
|
125
|
+
if fn.get("arguments") is not None:
|
|
126
|
+
entry["function"]["arguments_parts"].append(fn["arguments"])
|
|
127
|
+
|
|
128
|
+
fr = choice.get("finish_reason")
|
|
129
|
+
if fr:
|
|
130
|
+
finish_reasons[idx] = fr
|
|
131
|
+
|
|
132
|
+
usage = chunk.get("usage") or {}
|
|
133
|
+
if usage:
|
|
134
|
+
prompt_tokens = usage.get("prompt_tokens", prompt_tokens)
|
|
135
|
+
completion_tokens = usage.get("completion_tokens", completion_tokens)
|
|
136
|
+
|
|
137
|
+
if not done_seen or not completion_id:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
all_choice_indices = sorted(set(list(text_acc.keys()) + list(tool_calls_acc.keys())))
|
|
141
|
+
if not all_choice_indices:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
choices = []
|
|
145
|
+
for i in all_choice_indices:
|
|
146
|
+
finish_reason = finish_reasons.get(i, "stop")
|
|
147
|
+
message: dict = {"role": "assistant"}
|
|
148
|
+
|
|
149
|
+
if i in tool_calls_acc:
|
|
150
|
+
tool_calls = [
|
|
151
|
+
{
|
|
152
|
+
"id": tool_calls_acc[i][ti]["id"],
|
|
153
|
+
"type": tool_calls_acc[i][ti].get("type", "function"),
|
|
154
|
+
"function": {
|
|
155
|
+
"name": tool_calls_acc[i][ti]["function"]["name"],
|
|
156
|
+
"arguments": "".join(
|
|
157
|
+
tool_calls_acc[i][ti]["function"]["arguments_parts"]
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
for ti in sorted(tool_calls_acc[i].keys())
|
|
162
|
+
]
|
|
163
|
+
message["content"] = None
|
|
164
|
+
message["tool_calls"] = tool_calls
|
|
165
|
+
else:
|
|
166
|
+
message["content"] = text_acc.get(i, "")
|
|
167
|
+
|
|
168
|
+
choices.append({
|
|
169
|
+
"index": i,
|
|
170
|
+
"message": message,
|
|
171
|
+
"logprobs": None,
|
|
172
|
+
"finish_reason": finish_reason,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
result = {
|
|
176
|
+
"id": completion_id,
|
|
177
|
+
"object": "chat.completion",
|
|
178
|
+
"created": created,
|
|
179
|
+
"model": model,
|
|
180
|
+
"choices": choices,
|
|
181
|
+
"usage": {
|
|
182
|
+
"prompt_tokens": prompt_tokens,
|
|
183
|
+
"completion_tokens": completion_tokens,
|
|
184
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
return json.dumps(result, separators=(",", ":")).encode("utf-8")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _openai_sse_from_cached_json(cached_bytes: bytes) -> "StreamingResponse | None":
|
|
191
|
+
"""Convert a stored chat.completion JSON back to an OpenAI SSE stream.
|
|
192
|
+
|
|
193
|
+
Used when an identical streaming request hits the cache — we replay the
|
|
194
|
+
stored JSON as the SSE events the OpenAI API would have emitted, preserving
|
|
195
|
+
the streaming contract with the client (e.g. Codex CLI, Antigravity).
|
|
196
|
+
|
|
197
|
+
BUG-FIX (v3.6.4): Handles tool_calls in the cached message, replaying
|
|
198
|
+
them as proper OpenAI SSE delta events with index/id/name/arguments chunks.
|
|
199
|
+
|
|
200
|
+
Returns None if the bytes are not a parseable chat.completion object.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
resp = json.loads(cached_bytes)
|
|
204
|
+
except (json.JSONDecodeError, ValueError):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
if resp.get("object") != "chat.completion":
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
async def _generate():
|
|
211
|
+
completion_id = resp.get("id", "")
|
|
212
|
+
model = resp.get("model", "")
|
|
213
|
+
created = resp.get("created", 0)
|
|
214
|
+
|
|
215
|
+
for choice in resp.get("choices", []):
|
|
216
|
+
idx = choice.get("index", 0)
|
|
217
|
+
message = choice.get("message", {})
|
|
218
|
+
tool_calls = message.get("tool_calls")
|
|
219
|
+
content = message.get("content") or ""
|
|
220
|
+
finish_reason = choice.get("finish_reason", "stop")
|
|
221
|
+
|
|
222
|
+
if tool_calls:
|
|
223
|
+
# First chunk: role + tool_call headers (id, type, name, empty args)
|
|
224
|
+
first_delta = {
|
|
225
|
+
"role": "assistant",
|
|
226
|
+
"content": None,
|
|
227
|
+
"tool_calls": [
|
|
228
|
+
{
|
|
229
|
+
"index": ti,
|
|
230
|
+
"id": tc["id"],
|
|
231
|
+
"type": tc.get("type", "function"),
|
|
232
|
+
"function": {
|
|
233
|
+
"name": tc["function"]["name"],
|
|
234
|
+
"arguments": "",
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
for ti, tc in enumerate(tool_calls)
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
first_chunk = {
|
|
241
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
242
|
+
"created": created, "model": model,
|
|
243
|
+
"choices": [{"index": idx, "delta": first_delta, "finish_reason": None}],
|
|
244
|
+
}
|
|
245
|
+
yield f"data: {json.dumps(first_chunk)}\n\n".encode()
|
|
246
|
+
|
|
247
|
+
# Argument chunks per tool call (50-char pieces)
|
|
248
|
+
chunk_size = 50
|
|
249
|
+
for ti, tc in enumerate(tool_calls):
|
|
250
|
+
args = tc.get("function", {}).get("arguments", "")
|
|
251
|
+
for start in range(0, max(len(args), 1), chunk_size):
|
|
252
|
+
piece = args[start: start + chunk_size]
|
|
253
|
+
arg_chunk = {
|
|
254
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
255
|
+
"created": created, "model": model,
|
|
256
|
+
"choices": [{
|
|
257
|
+
"index": idx,
|
|
258
|
+
"delta": {"tool_calls": [{"index": ti, "function": {"arguments": piece}}]},
|
|
259
|
+
"finish_reason": None,
|
|
260
|
+
}],
|
|
261
|
+
}
|
|
262
|
+
yield f"data: {json.dumps(arg_chunk)}\n\n".encode()
|
|
263
|
+
|
|
264
|
+
# Finish chunk
|
|
265
|
+
finish_chunk = {
|
|
266
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
267
|
+
"created": created, "model": model,
|
|
268
|
+
"choices": [{"index": idx, "delta": {}, "finish_reason": finish_reason}],
|
|
269
|
+
}
|
|
270
|
+
yield f"data: {json.dumps(finish_chunk)}\n\n".encode()
|
|
271
|
+
|
|
272
|
+
else:
|
|
273
|
+
# Text response replay
|
|
274
|
+
role_chunk = {
|
|
275
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
276
|
+
"created": created, "model": model,
|
|
277
|
+
"choices": [{"index": idx, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
|
|
278
|
+
}
|
|
279
|
+
yield f"data: {json.dumps(role_chunk)}\n\n".encode()
|
|
280
|
+
|
|
281
|
+
chunk_size = 100
|
|
282
|
+
for start in range(0, max(len(content), 1), chunk_size):
|
|
283
|
+
piece = content[start: start + chunk_size]
|
|
284
|
+
content_chunk = {
|
|
285
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
286
|
+
"created": created, "model": model,
|
|
287
|
+
"choices": [{"index": idx, "delta": {"content": piece}, "finish_reason": None}],
|
|
288
|
+
}
|
|
289
|
+
yield f"data: {json.dumps(content_chunk)}\n\n".encode()
|
|
290
|
+
|
|
291
|
+
finish_chunk = {
|
|
292
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
293
|
+
"created": created, "model": model,
|
|
294
|
+
"choices": [{"index": idx, "delta": {}, "finish_reason": finish_reason}],
|
|
295
|
+
}
|
|
296
|
+
yield f"data: {json.dumps(finish_chunk)}\n\n".encode()
|
|
297
|
+
|
|
298
|
+
yield b"data: [DONE]\n\n"
|
|
299
|
+
|
|
300
|
+
return StreamingResponse(_generate(), media_type="text/event-stream")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Route handlers
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
30
307
|
async def handle_chat_completions(proxy: object, request: Request) -> Response:
|
|
31
308
|
request_id = await proxy.next_request_id()
|
|
32
309
|
upstream_url = f"{_UPSTREAM_BASE}/v1/chat/completions"
|
|
@@ -47,14 +324,60 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
|
|
|
47
324
|
)
|
|
48
325
|
|
|
49
326
|
if stream:
|
|
327
|
+
# BUG-FIX (v3.6.3): streaming path previously bypassed cache
|
|
328
|
+
# entirely — same bug as anthropic_surface.py pre-fix. OpenAI
|
|
329
|
+
# clients (Codex CLI, Antigravity, openai-python) use stream=True
|
|
330
|
+
# by default, so savings were permanently 0.
|
|
331
|
+
|
|
332
|
+
# 1. Cache check
|
|
333
|
+
if proxy.hooks.cache:
|
|
334
|
+
cache_result = await _safe_cache_check(proxy.hooks, ctx)
|
|
335
|
+
if cache_result and cache_result.hit and cache_result.data:
|
|
336
|
+
logger.debug(
|
|
337
|
+
"[%s] OpenAI streaming cache HIT key=%s",
|
|
338
|
+
request_id, cache_result.cache_key,
|
|
339
|
+
)
|
|
340
|
+
await _safe_cache_hit_callbacks(
|
|
341
|
+
proxy.hooks, ctx, cache_result.data, tokens_saved=0
|
|
342
|
+
)
|
|
343
|
+
sse_resp = _openai_sse_from_cached_json(cache_result.data)
|
|
344
|
+
if sse_resp is not None:
|
|
345
|
+
return sse_resp
|
|
346
|
+
|
|
347
|
+
# 2. Compression on request body
|
|
348
|
+
outbound_bytes = body_bytes
|
|
349
|
+
if proxy.hooks.compress:
|
|
350
|
+
compress_result = await _safe_compress(proxy.hooks, ctx)
|
|
351
|
+
if compress_result.body_bytes != body_bytes:
|
|
352
|
+
outbound_bytes = compress_result.body_bytes
|
|
353
|
+
|
|
50
354
|
fwd_headers = _build_forward_headers(request, _OPENAI_FORWARD_HEADERS)
|
|
51
|
-
fwd_headers["content-length"] = str(len(
|
|
52
|
-
|
|
53
|
-
|
|
355
|
+
fwd_headers["content-length"] = str(len(outbound_bytes))
|
|
356
|
+
|
|
357
|
+
# 3. Stream + accumulate for cache store
|
|
358
|
+
store_callback = None
|
|
359
|
+
if proxy.hooks.cache:
|
|
360
|
+
_hooks = proxy.hooks
|
|
361
|
+
_ctx = ctx
|
|
362
|
+
async def _store_from_openai_sse(sse_bytes: bytes) -> None:
|
|
363
|
+
parsed = _parse_openai_sse_to_json(sse_bytes)
|
|
364
|
+
if parsed is None:
|
|
365
|
+
return
|
|
366
|
+
prov = ProviderResponse(
|
|
367
|
+
modified=False, body={}, body_bytes=parsed,
|
|
368
|
+
tokens_before=0, tokens_after=0, strategy="none",
|
|
369
|
+
)
|
|
370
|
+
await _safe_cache_store(_hooks, _ctx, prov)
|
|
371
|
+
store_callback = _store_from_openai_sse
|
|
372
|
+
|
|
373
|
+
return await _stream_and_cache_forward(
|
|
374
|
+
proxy, request_id, fwd_headers, outbound_bytes, upstream_url,
|
|
375
|
+
on_complete=store_callback,
|
|
54
376
|
)
|
|
55
377
|
|
|
378
|
+
# --- Non-streaming path ---
|
|
56
379
|
cache_result = None
|
|
57
|
-
if
|
|
380
|
+
if proxy.hooks.cache:
|
|
58
381
|
cache_result = await _safe_cache_check(proxy.hooks, ctx)
|
|
59
382
|
if cache_result.hit and cache_result.data:
|
|
60
383
|
await _safe_cache_hit_callbacks(
|
|
@@ -82,7 +405,6 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
|
|
|
82
405
|
|
|
83
406
|
if (
|
|
84
407
|
upstream_resp.status_code == 200
|
|
85
|
-
and not has_tools
|
|
86
408
|
and proxy.hooks.cache
|
|
87
409
|
and cache_result is not None
|
|
88
410
|
and cache_result.cache_key
|
|
@@ -17,7 +17,7 @@ from superlocalmemory.optimize.proxy.lifecycle import HookChain
|
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger("slm.optimize.proxy")
|
|
19
19
|
|
|
20
|
-
_PROXY_VERSION = "3.6.
|
|
20
|
+
_PROXY_VERSION = "3.6.3"
|
|
21
21
|
_REQUEST_TIMEOUT_S = 300.0
|
|
22
22
|
_CONNECT_TIMEOUT_S = 10.0
|
|
23
23
|
_MAX_CONNECTIONS = 100
|
|
@@ -145,7 +145,14 @@ def _load_hooks(config: OptimizeConfig) -> HookChain:
|
|
|
145
145
|
)
|
|
146
146
|
|
|
147
147
|
if config.compress_enabled:
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
try:
|
|
149
|
+
from superlocalmemory.optimize.compress.router import CompressRouter
|
|
150
|
+
from superlocalmemory.optimize.metrics.counters import MetricsCollector
|
|
151
|
+
compress_hook = CompressRouter.get_instance()
|
|
152
|
+
compress_hook.set_metrics(MetricsCollector.get_instance())
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.warning(
|
|
155
|
+
"compress hook load failed (proxy continues without compress): %s", exc
|
|
156
|
+
)
|
|
150
157
|
|
|
151
158
|
return HookChain(cache=cache_hook, compress=compress_hook)
|
|
@@ -182,16 +182,26 @@ class RetrievalEngine:
|
|
|
182
182
|
# V3.3.19: Only bridge.discover() (86ms). Removed bridge.spreading_activation()
|
|
183
183
|
# which did per-node SQL queries across 254K edges → 78s latency.
|
|
184
184
|
# The SYNAPSE SA channel already provides proper SA with in-memory caching.
|
|
185
|
+
# recall-retrieval-01: O(1) membership/score lookups instead of repeated
|
|
186
|
+
# O(N) `any(...)`/`next(...)` scans inside the bridge + scene loops
|
|
187
|
+
# (was O(N^2) per recall, ~400 ms on large sessions). Kept in sync as
|
|
188
|
+
# `fused` grows so behaviour is identical.
|
|
189
|
+
fused_ids = {fr.fact_id for fr in fused}
|
|
190
|
+
fused_scores = {fr.fact_id: fr.fused_score for fr in fused}
|
|
191
|
+
|
|
185
192
|
if self._bridge is not None and strat.query_type in ("multi_hop", "entity", "factual", "general"):
|
|
186
193
|
try:
|
|
187
194
|
seed_ids = [fr.fact_id for fr in fused[:10]]
|
|
188
195
|
bridges = self._bridge.discover(seed_ids, profile_id, max_bridges=10)
|
|
189
196
|
for fid, score in bridges:
|
|
190
|
-
if
|
|
197
|
+
if fid not in fused_ids:
|
|
198
|
+
new_score = score * 0.8
|
|
191
199
|
fused.append(FusionResult(
|
|
192
|
-
fact_id=fid, fused_score=
|
|
200
|
+
fact_id=fid, fused_score=new_score,
|
|
193
201
|
channel_ranks={}, channel_scores={},
|
|
194
202
|
))
|
|
203
|
+
fused_ids.add(fid)
|
|
204
|
+
fused_scores[fid] = new_score
|
|
195
205
|
except Exception as exc:
|
|
196
206
|
logger.warning("Bridge discovery: %s", exc)
|
|
197
207
|
|
|
@@ -206,14 +216,15 @@ class RetrievalEngine:
|
|
|
206
216
|
for fid in top_ids:
|
|
207
217
|
for scene in scenes_map.get(fid, [])[:2]:
|
|
208
218
|
for sfid in scene.fact_ids:
|
|
209
|
-
if
|
|
219
|
+
if sfid not in fused_ids and sfid not in expanded_ids:
|
|
210
220
|
expanded_ids.add(sfid)
|
|
221
|
+
new_score = fused_scores.get(fid, 0.5) * 0.8
|
|
211
222
|
fused.append(FusionResult(
|
|
212
|
-
fact_id=sfid, fused_score=
|
|
213
|
-
next((f.fused_score for f in fused if f.fact_id == fid), 0.5) * 0.8
|
|
214
|
-
),
|
|
223
|
+
fact_id=sfid, fused_score=new_score,
|
|
215
224
|
channel_ranks={}, channel_scores={},
|
|
216
225
|
))
|
|
226
|
+
fused_ids.add(sfid)
|
|
227
|
+
fused_scores[sfid] = new_score
|
|
217
228
|
except Exception as exc:
|
|
218
229
|
logger.warning("Scene expansion: %s", exc)
|
|
219
230
|
|
|
@@ -118,9 +118,17 @@ class EntityGraphChannel:
|
|
|
118
118
|
"""
|
|
119
119
|
# Check staleness: profile changed or new edges added since last load
|
|
120
120
|
current_count = self._get_edge_count(profile_id)
|
|
121
|
+
# memory-bounding-01: also reload if the cache is older than the TTL,
|
|
122
|
+
# even when the edge COUNT is unchanged. Edge weights/pruning can mutate
|
|
123
|
+
# the graph without changing the count (e.g. store_edge MAX-merge), and a
|
|
124
|
+
# count-stable window would otherwise serve a stale adjacency map.
|
|
125
|
+
import time as _t_ec
|
|
126
|
+
_now_ec = _t_ec.monotonic()
|
|
127
|
+
_fresh = (_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < 300.0
|
|
121
128
|
if (self._adj_profile == profile_id
|
|
122
129
|
and self._adj
|
|
123
|
-
and self._adj_edge_count == current_count
|
|
130
|
+
and self._adj_edge_count == current_count
|
|
131
|
+
and _fresh):
|
|
124
132
|
return
|
|
125
133
|
adj: dict[str, list[tuple[str, float]]] = defaultdict(list)
|
|
126
134
|
try:
|
|
@@ -138,6 +146,7 @@ class EntityGraphChannel:
|
|
|
138
146
|
self._adj = dict(adj) # Convert defaultdict to regular dict (no accidental growth)
|
|
139
147
|
self._adj_profile = profile_id
|
|
140
148
|
self._adj_edge_count = current_count
|
|
149
|
+
self._adj_loaded_at = _now_ec # memory-bounding-01: TTL reference
|
|
141
150
|
# Also load entity maps (same staleness lifecycle)
|
|
142
151
|
self._load_entity_maps(profile_id)
|
|
143
152
|
# v3.4.1: Load graph intelligence metrics (P0)
|
|
@@ -302,7 +302,9 @@ class HopfieldChannel:
|
|
|
302
302
|
return (self._cached_matrix, self._cached_fact_ids)
|
|
303
303
|
|
|
304
304
|
# Step 2: Load facts (V3.3.12: cap to most recent 5000 to bound memory)
|
|
305
|
-
|
|
305
|
+
# memory-bounding-02: push the cap into SQL (LIMIT) so we don't
|
|
306
|
+
# deserialize the whole table just to slice it.
|
|
307
|
+
facts = self._db.get_all_facts(profile_id, limit=5000)
|
|
306
308
|
if not facts:
|
|
307
309
|
return (None, [])
|
|
308
310
|
|
|
@@ -188,7 +188,47 @@ class DatabaseManager:
|
|
|
188
188
|
return ""
|
|
189
189
|
|
|
190
190
|
def store_fact(self, fact: AtomicFact) -> str:
|
|
191
|
-
"""Persist an atomic fact. Returns fact_id.
|
|
191
|
+
"""Persist an atomic fact. Returns fact_id.
|
|
192
|
+
|
|
193
|
+
v3.6.4 — idempotent on content. If an ACTIVE fact with identical
|
|
194
|
+
content already exists for this profile, reinforce it (bump
|
|
195
|
+
evidence_count + access_count) and return its fact_id instead of
|
|
196
|
+
inserting a duplicate row. The passed fact's ``fact_id`` is rewritten
|
|
197
|
+
to the canonical id so downstream writes keyed on it (embeddings,
|
|
198
|
+
graph edges, context) target the real fact rather than orphaning.
|
|
199
|
+
|
|
200
|
+
This enforces the memory-system invariant "storing the same fact
|
|
201
|
+
twice is one fact" — preventing the duplicate explosion that poisons
|
|
202
|
+
importance ranking and core-memory promotion. Empty/whitespace
|
|
203
|
+
content is exempt (handled by placeholder filtering, not dedup).
|
|
204
|
+
"""
|
|
205
|
+
if fact.content and fact.content.strip():
|
|
206
|
+
# Dedup across all LIVE lifecycle zones (active/warm/cold). Excludes
|
|
207
|
+
# 'archived' — that is soft-deleted/forgotten, so re-storing the same
|
|
208
|
+
# content correctly re-learns it as a fresh fact. Matching only
|
|
209
|
+
# 'active' (pre-3.6.4) re-opened the duplication window for every
|
|
210
|
+
# fact that aged to warm/cold (the bulk of the KB).
|
|
211
|
+
existing = self.execute(
|
|
212
|
+
"SELECT fact_id FROM atomic_facts "
|
|
213
|
+
"WHERE profile_id = ? AND content = ? "
|
|
214
|
+
"AND lifecycle IN ('active', 'warm', 'cold') "
|
|
215
|
+
"ORDER BY created_at LIMIT 1",
|
|
216
|
+
(fact.profile_id, fact.content),
|
|
217
|
+
)
|
|
218
|
+
if existing:
|
|
219
|
+
canonical_id = dict(existing[0])["fact_id"]
|
|
220
|
+
self.execute(
|
|
221
|
+
"UPDATE atomic_facts "
|
|
222
|
+
"SET evidence_count = evidence_count + 1, "
|
|
223
|
+
" access_count = access_count + 1 "
|
|
224
|
+
"WHERE fact_id = ?",
|
|
225
|
+
(canonical_id,),
|
|
226
|
+
)
|
|
227
|
+
# Rewrite caller's id so downstream embedding/graph/context
|
|
228
|
+
# writes target the canonical fact (idempotent), not an
|
|
229
|
+
# orphaned id that was never inserted.
|
|
230
|
+
fact.fact_id = canonical_id
|
|
231
|
+
return canonical_id
|
|
192
232
|
self.execute(
|
|
193
233
|
"""INSERT OR REPLACE INTO atomic_facts
|
|
194
234
|
(fact_id, memory_id, profile_id, content, fact_type,
|
|
@@ -259,12 +299,26 @@ class DatabaseManager:
|
|
|
259
299
|
)
|
|
260
300
|
return [self._row_to_fact(r) for r in rows]
|
|
261
301
|
|
|
262
|
-
def get_all_facts(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
302
|
+
def get_all_facts(
|
|
303
|
+
self, profile_id: str, limit: int | None = None,
|
|
304
|
+
) -> list[AtomicFact]:
|
|
305
|
+
"""All facts for a profile, newest first.
|
|
306
|
+
|
|
307
|
+
memory-bounding-02: optional SQL LIMIT so callers needing only the
|
|
308
|
+
most-recent N (e.g. the Hopfield channel's 5000 cap) don't deserialize
|
|
309
|
+
the entire table into AtomicFact objects. Default (None) = all facts.
|
|
310
|
+
"""
|
|
311
|
+
if limit is not None:
|
|
312
|
+
rows = self.execute(
|
|
313
|
+
"SELECT * FROM atomic_facts WHERE profile_id = ? "
|
|
314
|
+
"ORDER BY created_at DESC LIMIT ?",
|
|
315
|
+
(profile_id, int(limit)),
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
rows = self.execute(
|
|
319
|
+
"SELECT * FROM atomic_facts WHERE profile_id = ? ORDER BY created_at DESC",
|
|
320
|
+
(profile_id,),
|
|
321
|
+
)
|
|
268
322
|
return [self._row_to_fact(r) for r in rows]
|
|
269
323
|
|
|
270
324
|
_MAX_FACTS_PER_ENTITY_LOOKUP: int = 100
|
|
@@ -325,9 +379,38 @@ class DatabaseManager:
|
|
|
325
379
|
)
|
|
326
380
|
|
|
327
381
|
def delete_fact(self, fact_id: str) -> None:
|
|
328
|
-
"""Hard-delete a fact.
|
|
382
|
+
"""Hard-delete a fact.
|
|
383
|
+
|
|
384
|
+
DatabaseManager connections enforce FKs (PRAGMA foreign_keys=ON), so
|
|
385
|
+
embedding_metadata / fact_retention / edges cascade. The explicit
|
|
386
|
+
embedding_metadata delete below is belt-and-suspenders for the case a
|
|
387
|
+
future caller routes through a connection without FK enforcement.
|
|
388
|
+
"""
|
|
389
|
+
self.execute("DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,))
|
|
329
390
|
self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
|
|
330
391
|
|
|
392
|
+
def gc_orphaned_embedding_metadata(self) -> int:
|
|
393
|
+
"""Remove embedding_metadata rows whose parent atomic_fact is gone.
|
|
394
|
+
|
|
395
|
+
P1-3 (embeddings-vector-02): orphans accumulate when facts are deleted
|
|
396
|
+
through a connection that has FK enforcement OFF (the ON DELETE CASCADE
|
|
397
|
+
never fires). Vector search maps a vec0 rowid → fact_id via this table;
|
|
398
|
+
orphans return stale fact_ids that fail downstream fetch. This
|
|
399
|
+
maintenance sweep removes them regardless of how they were created.
|
|
400
|
+
Returns the number of rows deleted.
|
|
401
|
+
"""
|
|
402
|
+
rows = self.execute(
|
|
403
|
+
"SELECT COUNT(*) AS c FROM embedding_metadata "
|
|
404
|
+
"WHERE fact_id NOT IN (SELECT fact_id FROM atomic_facts)"
|
|
405
|
+
)
|
|
406
|
+
n = int(rows[0]["c"]) if rows else 0
|
|
407
|
+
if n:
|
|
408
|
+
self.execute(
|
|
409
|
+
"DELETE FROM embedding_metadata "
|
|
410
|
+
"WHERE fact_id NOT IN (SELECT fact_id FROM atomic_facts)"
|
|
411
|
+
)
|
|
412
|
+
return n
|
|
413
|
+
|
|
331
414
|
def get_fact_count(self, profile_id: str) -> int:
|
|
332
415
|
"""Total fact count for a profile."""
|
|
333
416
|
rows = self.execute(
|
|
@@ -408,7 +491,28 @@ class DatabaseManager:
|
|
|
408
491
|
return [self._row_to_fact(r) for r in rows]
|
|
409
492
|
|
|
410
493
|
def store_edge(self, edge: GraphEdge) -> str:
|
|
411
|
-
"""Persist a graph edge. Returns edge_id.
|
|
494
|
+
"""Persist a graph edge. Returns edge_id.
|
|
495
|
+
|
|
496
|
+
graph-integrity-02: dedup on the LOGICAL edge identity
|
|
497
|
+
(profile, source, target, type). The PK is a random edge_id, so
|
|
498
|
+
without this every re-link created a duplicate row, and NetworkX
|
|
499
|
+
builds read last-weight-wins — corrupting PageRank/centrality. On a
|
|
500
|
+
duplicate we keep the MAX weight (strongest association wins) and
|
|
501
|
+
return the existing edge_id.
|
|
502
|
+
"""
|
|
503
|
+
existing = self.execute(
|
|
504
|
+
"SELECT edge_id FROM graph_edges "
|
|
505
|
+
"WHERE profile_id = ? AND source_id = ? AND target_id = ? AND edge_type = ? "
|
|
506
|
+
"LIMIT 1",
|
|
507
|
+
(edge.profile_id, edge.source_id, edge.target_id, edge.edge_type.value),
|
|
508
|
+
)
|
|
509
|
+
if existing:
|
|
510
|
+
canonical_id = dict(existing[0])["edge_id"]
|
|
511
|
+
self.execute(
|
|
512
|
+
"UPDATE graph_edges SET weight = MAX(weight, ?) WHERE edge_id = ?",
|
|
513
|
+
(edge.weight, canonical_id),
|
|
514
|
+
)
|
|
515
|
+
return canonical_id
|
|
412
516
|
self.execute(
|
|
413
517
|
"""INSERT OR REPLACE INTO graph_edges
|
|
414
518
|
(edge_id, profile_id, source_id, target_id, edge_type, weight, created_at)
|