memry 0.2.3__tar.gz → 0.2.4__tar.gz
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.
- {memry-0.2.3 → memry-0.2.4}/PKG-INFO +1 -1
- {memry-0.2.3 → memry-0.2.4}/pyproject.toml +1 -1
- {memry-0.2.3 → memry-0.2.4}/src/memry/__init__.py +1 -1
- {memry-0.2.3 → memry-0.2.4}/src/memry/cli.py +3 -18
- {memry-0.2.3 → memry-0.2.4}/src/memry/mcp_server.py +31 -19
- {memry-0.2.3 → memry-0.2.4}/src/memry/rest.py +69 -34
- {memry-0.2.3 → memry-0.2.4}/src/memry/store.py +94 -0
- {memry-0.2.3 → memry-0.2.4}/.gitignore +0 -0
- {memry-0.2.3 → memry-0.2.4}/LICENSE +0 -0
- {memry-0.2.3 → memry-0.2.4}/README.md +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/backends/__init__.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/backends/ann.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/backends/base.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/backends/local.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/backends/mem0_adapter.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/backends/postgres.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/config.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/evals/__init__.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/evals/harness.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/__init__.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/context.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/decay.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/entities.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/extraction.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/reconcile.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/models.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/providers/__init__.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/providers/embeddings.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/providers/llm.py +0 -0
- {memry-0.2.3 → memry-0.2.4}/src/memry/retrieval.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memry
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: The open, self-hostable memory layer for AI agents. MCP-native, local-first, research-grade.
|
|
5
5
|
Project-URL: Homepage, https://memry.tech
|
|
6
6
|
Project-URL: Repository, https://github.com/cosmin-novac/memry
|
|
@@ -259,25 +259,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
259
259
|
):
|
|
260
260
|
print(memory.model_dump_json())
|
|
261
261
|
elif args.command == "import":
|
|
262
|
-
count = 0
|
|
263
262
|
with open(args.path, encoding="utf-8") as fh:
|
|
264
|
-
for line in fh
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
continue
|
|
268
|
-
record = json.loads(line)
|
|
269
|
-
store.add(
|
|
270
|
-
record["content"],
|
|
271
|
-
user_id=record.get("user_id"),
|
|
272
|
-
agent_id=record.get("agent_id"),
|
|
273
|
-
run_id=record.get("run_id"),
|
|
274
|
-
infer=False,
|
|
275
|
-
memory_type=record.get("memory_type", "semantic"),
|
|
276
|
-
importance=float(record.get("importance", 0.5)),
|
|
277
|
-
categories=record.get("categories"),
|
|
278
|
-
)
|
|
279
|
-
count += 1
|
|
280
|
-
_print({"imported": count})
|
|
263
|
+
rows = [json.loads(line) for line in fh if line.strip()]
|
|
264
|
+
result = store.import_verbatim(rows)
|
|
265
|
+
_print({k: v for k, v in result.items() if k != "memory_ids"})
|
|
281
266
|
finally:
|
|
282
267
|
store.close()
|
|
283
268
|
return 0
|
|
@@ -10,8 +10,10 @@ Cursor, Windsurf, Codex, ...) over stdio or streamable HTTP.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
from functools import partial
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
16
|
+
import anyio.to_thread
|
|
15
17
|
from mcp.server.fastmcp import FastMCP
|
|
16
18
|
from mcp.server.transport_security import TransportSecuritySettings
|
|
17
19
|
|
|
@@ -70,8 +72,14 @@ def create_server(
|
|
|
70
72
|
def _uid(user_id: str) -> str | None:
|
|
71
73
|
return user_id or default_user
|
|
72
74
|
|
|
75
|
+
# Store calls are synchronous (SQLite + provider HTTP). FastMCP runs sync
|
|
76
|
+
# tools directly on the event loop, so every tool below is async and hops
|
|
77
|
+
# to a worker thread - one slow LLM call must not stall the whole server.
|
|
78
|
+
async def _threaded(fn, /, **kwargs):
|
|
79
|
+
return await anyio.to_thread.run_sync(partial(fn, **kwargs))
|
|
80
|
+
|
|
73
81
|
@mcp.tool()
|
|
74
|
-
def save_memories(
|
|
82
|
+
async def save_memories(
|
|
75
83
|
content: str,
|
|
76
84
|
user_id: str = "",
|
|
77
85
|
agent_id: str = "",
|
|
@@ -84,8 +92,9 @@ def create_server(
|
|
|
84
92
|
reconciled against existing memories (duplicates skipped, contradictions
|
|
85
93
|
superseded). With infer=false the text is saved verbatim as one memory.
|
|
86
94
|
"""
|
|
87
|
-
result =
|
|
88
|
-
|
|
95
|
+
result = await _threaded(
|
|
96
|
+
store.add,
|
|
97
|
+
content=content,
|
|
89
98
|
user_id=_uid(user_id),
|
|
90
99
|
agent_id=agent_id or None,
|
|
91
100
|
run_id=run_id or None,
|
|
@@ -103,7 +112,7 @@ def create_server(
|
|
|
103
112
|
return json.dumps(payload, ensure_ascii=False)
|
|
104
113
|
|
|
105
114
|
@mcp.tool()
|
|
106
|
-
def search_memories(
|
|
115
|
+
async def search_memories(
|
|
107
116
|
query: str,
|
|
108
117
|
user_id: str = "",
|
|
109
118
|
agent_id: str = "",
|
|
@@ -117,8 +126,9 @@ def create_server(
|
|
|
117
126
|
Optionally restrict to categories (comma-separated, e.g. "diet,health").
|
|
118
127
|
"""
|
|
119
128
|
category_list = [c.strip() for c in categories.split(",") if c.strip()] or None
|
|
120
|
-
results =
|
|
121
|
-
|
|
129
|
+
results = await _threaded(
|
|
130
|
+
store.search,
|
|
131
|
+
query=query,
|
|
122
132
|
user_id=_uid(user_id),
|
|
123
133
|
agent_id=agent_id or None,
|
|
124
134
|
run_id=run_id or None,
|
|
@@ -130,7 +140,7 @@ def create_server(
|
|
|
130
140
|
)
|
|
131
141
|
|
|
132
142
|
@mcp.tool()
|
|
133
|
-
def get_memory_context(
|
|
143
|
+
async def get_memory_context(
|
|
134
144
|
query: str,
|
|
135
145
|
user_id: str = "",
|
|
136
146
|
token_budget: int = 1200,
|
|
@@ -138,39 +148,40 @@ def create_server(
|
|
|
138
148
|
"""Get a ready-to-use context block of the most relevant memories for a
|
|
139
149
|
task, packed to fit the given token budget. Prefer this over
|
|
140
150
|
search_memories when you just want background context injected."""
|
|
141
|
-
ctx =
|
|
142
|
-
|
|
151
|
+
ctx = await _threaded(
|
|
152
|
+
store.reconstruct_context,
|
|
153
|
+
query=query, user_id=_uid(user_id), token_budget=token_budget,
|
|
143
154
|
)
|
|
144
155
|
return ctx.text or "(no relevant memories yet)"
|
|
145
156
|
|
|
146
157
|
@mcp.tool()
|
|
147
|
-
def list_memories(user_id: str = "", limit: int = 50) -> str:
|
|
158
|
+
async def list_memories(user_id: str = "", limit: int = 50) -> str:
|
|
148
159
|
"""List the most recently updated memories for a user."""
|
|
149
|
-
memories = store.get_all
|
|
160
|
+
memories = await _threaded(store.get_all, user_id=_uid(user_id), limit=limit)
|
|
150
161
|
return json.dumps([_memory_row(m) for m in memories], ensure_ascii=False)
|
|
151
162
|
|
|
152
163
|
@mcp.tool()
|
|
153
|
-
def update_memory(memory_id: str, content: str) -> str:
|
|
164
|
+
async def update_memory(memory_id: str, content: str) -> str:
|
|
154
165
|
"""Rewrite the content of an existing memory (e.g. after the user
|
|
155
166
|
corrects a stored fact)."""
|
|
156
|
-
memory = store.update
|
|
167
|
+
memory = await _threaded(store.update, memory_id=memory_id, content=content)
|
|
157
168
|
if memory is None:
|
|
158
169
|
return json.dumps({"error": f"memory {memory_id} not found"})
|
|
159
170
|
return json.dumps(_memory_row(memory), ensure_ascii=False)
|
|
160
171
|
|
|
161
172
|
@mcp.tool()
|
|
162
|
-
def delete_memory(memory_id: str) -> str:
|
|
173
|
+
async def delete_memory(memory_id: str) -> str:
|
|
163
174
|
"""Forget a memory (soft delete: it is invalidated and kept in the
|
|
164
175
|
audit history, not destroyed). Use when the user asks you to forget
|
|
165
176
|
something."""
|
|
166
|
-
ok = store.delete
|
|
177
|
+
ok = await _threaded(store.delete, memory_id=memory_id)
|
|
167
178
|
return json.dumps({"deleted": ok, "memory_id": memory_id})
|
|
168
179
|
|
|
169
180
|
@mcp.tool()
|
|
170
|
-
def memory_history(memory_id: str) -> str:
|
|
181
|
+
async def memory_history(memory_id: str) -> str:
|
|
171
182
|
"""Show the full audit trail of a memory (ADD/UPDATE/SUPERSEDE/DELETE
|
|
172
183
|
events with old and new content)."""
|
|
173
|
-
events = store.history
|
|
184
|
+
events = await _threaded(store.history, memory_id=memory_id)
|
|
174
185
|
return json.dumps(
|
|
175
186
|
[
|
|
176
187
|
{
|
|
@@ -186,9 +197,10 @@ def create_server(
|
|
|
186
197
|
)
|
|
187
198
|
|
|
188
199
|
@mcp.tool()
|
|
189
|
-
def memory_stats() -> str:
|
|
200
|
+
async def memory_stats() -> str:
|
|
190
201
|
"""Show memory store statistics (counts, backend, models in use)."""
|
|
191
|
-
|
|
202
|
+
stats = await _threaded(store.stats)
|
|
203
|
+
return json.dumps(stats, ensure_ascii=False, default=str)
|
|
192
204
|
|
|
193
205
|
return mcp
|
|
194
206
|
|
|
@@ -26,10 +26,12 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import contextlib
|
|
28
28
|
import json
|
|
29
|
+
from functools import partial
|
|
29
30
|
from typing import Any
|
|
30
31
|
from urllib.parse import parse_qs
|
|
31
32
|
|
|
32
33
|
from starlette.applications import Starlette
|
|
34
|
+
from starlette.concurrency import run_in_threadpool
|
|
33
35
|
from starlette.requests import Request
|
|
34
36
|
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
35
37
|
from starlette.routing import Mount, Route
|
|
@@ -47,6 +49,9 @@ _DASHBOARD = """<!doctype html>
|
|
|
47
49
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,Segoe UI,sans-serif}
|
|
48
50
|
main{max-width:900px;margin:0 auto;padding:2rem 1rem}
|
|
49
51
|
h1{font-size:1.3rem;margin:.2rem 0 1rem}h1 span{color:var(--accent)}
|
|
52
|
+
h1 .datalinks{float:right;font-size:.75rem;font-weight:400;color:var(--dim)}
|
|
53
|
+
h1 .datalinks a{color:var(--dim);text-decoration:none;border-bottom:1px dotted var(--dim);cursor:help}
|
|
54
|
+
h1 .datalinks a:hover{color:var(--accent);border-bottom-color:var(--accent)}
|
|
50
55
|
.bar{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}
|
|
51
56
|
input,button,textarea{font:inherit;color:inherit;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:.5rem .7rem}
|
|
52
57
|
input:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
@@ -65,7 +70,8 @@ textarea{width:100%;min-height:70px;margin-bottom:.4rem}
|
|
|
65
70
|
#map{display:block;width:100%}
|
|
66
71
|
.maphint{color:var(--dim);font-size:.75rem;text-align:center;margin:.15rem 0 .35rem}
|
|
67
72
|
</style></head><body><main>
|
|
68
|
-
<h1><span>Mem</span>ry <small style="color:var(--dim);font-weight:400">memory dashboard</small
|
|
73
|
+
<h1><span>Mem</span>ry <small style="color:var(--dim);font-weight:400">memory dashboard</small>
|
|
74
|
+
<span class="datalinks"><a href="#" onclick="exportMemories();return false" title="Download memories as a .jsonl file: one JSON object per line with content, categories, user_id, type and importance. Same format as the CLI command memry export. Respects the user_id filter box.">export</a> · <a href="#" id="importbtn" onclick="document.getElementById('importfile').click();return false" title="Additive import from a memry export: a .jsonl file (one JSON object per line) or a JSON array. Each row needs a content field; categories, user_id, memory_type and importance are optional. Nothing is deleted or overwritten.">import</a></span></h1>
|
|
69
75
|
<div id="stats">loading…</div>
|
|
70
76
|
<div class="bar">
|
|
71
77
|
<input id="user" placeholder="user_id (all)" style="width:11rem">
|
|
@@ -74,8 +80,6 @@ textarea{width:100%;min-height:70px;margin-bottom:.4rem}
|
|
|
74
80
|
<button onclick="activeCat=null;loadAll()">All</button>
|
|
75
81
|
<button class="toggle" id="addbtn" onclick="togglePanel('add')">+ Add</button>
|
|
76
82
|
<button class="toggle" id="mapbtn" onclick="togglePanel('map')">Map</button>
|
|
77
|
-
<button onclick="exportMemories()" title="Download memories as JSONL (respects the user_id filter; same format as `memry export`)">Export</button>
|
|
78
|
-
<button id="importbtn" onclick="document.getElementById('importfile').click()" title="Additive import from a JSON/JSONL export; nothing is deleted">Import</button>
|
|
79
83
|
</div>
|
|
80
84
|
<input type="file" id="importfile" accept=".json,.jsonl,.txt,application/json" hidden onchange="importMemories(this.files[0]);this.value=''">
|
|
81
85
|
<div id="addpanel" hidden>
|
|
@@ -105,8 +109,8 @@ async function api(path, opts={}){
|
|
|
105
109
|
return r.json();
|
|
106
110
|
}
|
|
107
111
|
function esc(s){return (s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
|
|
108
|
-
// Add form
|
|
109
|
-
const panels={add:localStorage.getItem('memry_show_add')==='1',map:localStorage.getItem('memry_show_map')
|
|
112
|
+
// Add form is opt-in, map is opt-out; the choice sticks per browser.
|
|
113
|
+
const panels={add:localStorage.getItem('memry_show_add')==='1',map:localStorage.getItem('memry_show_map')!=='0'};
|
|
110
114
|
function syncPanels(){
|
|
111
115
|
document.getElementById('addpanel').hidden=!panels.add;
|
|
112
116
|
document.getElementById('addbtn').setAttribute('aria-pressed',panels.add);
|
|
@@ -337,8 +341,8 @@ async function exportMemories(){
|
|
|
337
341
|
a.download='memry-export-'+new Date().toISOString().slice(0,10)+'.jsonl';
|
|
338
342
|
a.click(); URL.revokeObjectURL(a.href);
|
|
339
343
|
}
|
|
340
|
-
// Additive: every row becomes a new verbatim memory
|
|
341
|
-
//
|
|
344
|
+
// Additive: every row becomes a new verbatim memory in ONE bulk request
|
|
345
|
+
// (server batches the embedding calls); nothing is deleted or deduplicated.
|
|
342
346
|
async function importMemories(file){
|
|
343
347
|
if(!file)return;
|
|
344
348
|
const text=((await file.text())||'').trim(); if(!text)return;
|
|
@@ -347,21 +351,15 @@ async function importMemories(file){
|
|
|
347
351
|
catch{alert('Not a valid JSON or JSONL export file.');return}
|
|
348
352
|
if(!Array.isArray(rows))rows=[rows];
|
|
349
353
|
const btn=document.getElementById('importbtn');
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
try{await api('/api/v1/memories',{method:'POST',body:JSON.stringify(body)});ok++}
|
|
360
|
-
catch{failed++}
|
|
361
|
-
btn.textContent='Import '+(ok+failed)+'/'+rows.length;
|
|
362
|
-
}
|
|
363
|
-
btn.textContent='Import';
|
|
364
|
-
alert('Imported '+ok+' of '+rows.length+(failed?' ('+failed+' failed or empty)':'')+'.');
|
|
354
|
+
btn.textContent='importing…';
|
|
355
|
+
try{
|
|
356
|
+
const res=await api('/api/v1/import',{method:'POST',
|
|
357
|
+
body:JSON.stringify({memories:rows,user_id:uid()||undefined})});
|
|
358
|
+
if(res&&res.imported!==undefined)
|
|
359
|
+
alert('Imported '+res.imported+' of '+rows.length+(res.skipped?' ('+res.skipped+' empty rows skipped)':'')+'.');
|
|
360
|
+
else alert((res&&res.error)||'Import failed.');
|
|
361
|
+
}catch{alert('Import failed.')}
|
|
362
|
+
btn.textContent='import';
|
|
365
363
|
loadAll(); loadStats();
|
|
366
364
|
}
|
|
367
365
|
async function loadStats(){
|
|
@@ -463,7 +461,11 @@ def create_app(store: MemoryStore | None = None) -> Starlette:
|
|
|
463
461
|
# defaults to config.default_user_id instead of storing NULL, which
|
|
464
462
|
# no scoped read (dashboard, clients) would ever find again. Reads
|
|
465
463
|
# keep None = "all users" as the admin view.
|
|
466
|
-
|
|
464
|
+
# Store calls that hit LLM/embedding providers run in the threadpool:
|
|
465
|
+
# blocking the event loop would stall every other request for the
|
|
466
|
+
# duration of a provider round-trip.
|
|
467
|
+
result = await run_in_threadpool(partial(
|
|
468
|
+
store.add,
|
|
467
469
|
content,
|
|
468
470
|
user_id=_ns(request.state.tenant, body.get("user_id")) or default_user,
|
|
469
471
|
agent_id=body.get("agent_id"),
|
|
@@ -473,7 +475,7 @@ def create_app(store: MemoryStore | None = None) -> Starlette:
|
|
|
473
475
|
memory_type=body.get("memory_type", "semantic"),
|
|
474
476
|
importance=float(body.get("importance", 0.5)),
|
|
475
477
|
categories=body.get("categories"),
|
|
476
|
-
)
|
|
478
|
+
))
|
|
477
479
|
return JSONResponse(result.model_dump(), status_code=201)
|
|
478
480
|
|
|
479
481
|
async def get_memory(request: Request) -> Response:
|
|
@@ -509,12 +511,38 @@ def create_app(store: MemoryStore | None = None) -> Starlette:
|
|
|
509
511
|
events = store.history(request.path_params["memory_id"])
|
|
510
512
|
return JSONResponse([e.model_dump() for e in events])
|
|
511
513
|
|
|
514
|
+
async def import_memories_route(request: Request) -> Response:
|
|
515
|
+
"""Bulk verbatim import: a JSON array of rows, or {memories: [...],
|
|
516
|
+
user_id?}. One request instead of one POST per memory."""
|
|
517
|
+
body = await request.json()
|
|
518
|
+
rows = body if isinstance(body, list) else body.get("memories")
|
|
519
|
+
if not isinstance(rows, list) or not rows:
|
|
520
|
+
return JSONResponse(
|
|
521
|
+
{"error": "provide a JSON array of rows or {\"memories\": [...]}"},
|
|
522
|
+
status_code=400,
|
|
523
|
+
)
|
|
524
|
+
default_uid = None if isinstance(body, list) else body.get("user_id")
|
|
525
|
+
tenant = request.state.tenant
|
|
526
|
+
sanitized = [
|
|
527
|
+
{**row, "user_id": _ns(tenant, row.get("user_id") or default_uid)}
|
|
528
|
+
for row in rows
|
|
529
|
+
if isinstance(row, dict)
|
|
530
|
+
]
|
|
531
|
+
result = await run_in_threadpool(partial(
|
|
532
|
+
store.import_verbatim,
|
|
533
|
+
sanitized,
|
|
534
|
+
user_id=_ns(tenant, default_uid) or default_user,
|
|
535
|
+
))
|
|
536
|
+
return JSONResponse(result, status_code=201)
|
|
537
|
+
|
|
512
538
|
async def distill_memory(request: Request) -> Response:
|
|
513
539
|
_, error = _memory_or_error(request)
|
|
514
540
|
if error:
|
|
515
541
|
return error
|
|
516
542
|
try:
|
|
517
|
-
result =
|
|
543
|
+
result = await run_in_threadpool(
|
|
544
|
+
partial(store.distill, request.path_params["memory_id"])
|
|
545
|
+
)
|
|
518
546
|
except ValueError as exc:
|
|
519
547
|
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
520
548
|
except Exception as exc: # LLM/provider failure: report, don't 500
|
|
@@ -527,7 +555,8 @@ def create_app(store: MemoryStore | None = None) -> Starlette:
|
|
|
527
555
|
|
|
528
556
|
async def search(request: Request) -> Response:
|
|
529
557
|
body = await request.json()
|
|
530
|
-
results =
|
|
558
|
+
results = await run_in_threadpool(partial(
|
|
559
|
+
store.search,
|
|
531
560
|
body.get("query", ""),
|
|
532
561
|
user_id=_ns(request.state.tenant, body.get("user_id")),
|
|
533
562
|
agent_id=body.get("agent_id"),
|
|
@@ -535,7 +564,7 @@ def create_app(store: MemoryStore | None = None) -> Starlette:
|
|
|
535
564
|
limit=int(body.get("limit", 10)),
|
|
536
565
|
include_invalid=bool(body.get("include_invalid", False)),
|
|
537
566
|
categories=_parse_categories(body.get("categories")),
|
|
538
|
-
)
|
|
567
|
+
))
|
|
539
568
|
return JSONResponse(
|
|
540
569
|
[
|
|
541
570
|
{"memory": r.memory.model_dump(), "score": r.score, "signals": r.signals}
|
|
@@ -545,21 +574,25 @@ def create_app(store: MemoryStore | None = None) -> Starlette:
|
|
|
545
574
|
|
|
546
575
|
async def context(request: Request) -> Response:
|
|
547
576
|
body = await request.json()
|
|
548
|
-
ctx =
|
|
577
|
+
ctx = await run_in_threadpool(partial(
|
|
578
|
+
store.reconstruct_context,
|
|
549
579
|
body.get("query", ""),
|
|
550
580
|
user_id=_ns(request.state.tenant, body.get("user_id")),
|
|
551
581
|
agent_id=body.get("agent_id"),
|
|
552
582
|
run_id=body.get("run_id"),
|
|
553
583
|
token_budget=int(body.get("token_budget", 1200)),
|
|
554
|
-
)
|
|
584
|
+
))
|
|
555
585
|
return JSONResponse(ctx.model_dump())
|
|
556
586
|
|
|
557
587
|
async def stats(request: Request) -> Response:
|
|
558
|
-
data = store.stats
|
|
588
|
+
data = await run_in_threadpool(store.stats)
|
|
559
589
|
if request.state.tenant is not None:
|
|
560
590
|
prefix = f"{request.state.tenant}::"
|
|
591
|
+
everything = await run_in_threadpool(
|
|
592
|
+
partial(store.get_all, include_invalid=True, limit=100_000)
|
|
593
|
+
)
|
|
561
594
|
mine = [
|
|
562
|
-
m for m in
|
|
595
|
+
m for m in everything
|
|
563
596
|
if (m.user_id or "").startswith(prefix)
|
|
564
597
|
]
|
|
565
598
|
data = {
|
|
@@ -623,9 +656,10 @@ def create_app(store: MemoryStore | None = None) -> Starlette:
|
|
|
623
656
|
|
|
624
657
|
async def resolve_entities_route(request: Request) -> Response:
|
|
625
658
|
body = await request.json() if await request.body() else {}
|
|
626
|
-
outcome =
|
|
627
|
-
|
|
628
|
-
|
|
659
|
+
outcome = await run_in_threadpool(partial(
|
|
660
|
+
store.resolve_entities,
|
|
661
|
+
user_id=_ns(request.state.tenant, body.get("user_id")),
|
|
662
|
+
))
|
|
629
663
|
return JSONResponse(outcome)
|
|
630
664
|
|
|
631
665
|
async def guarded_mcp_app(scope, receive, send):
|
|
@@ -680,6 +714,7 @@ def create_app(store: MemoryStore | None = None) -> Starlette:
|
|
|
680
714
|
Route("/api/v1/memories/{memory_id}", guarded(delete_memory), methods=["DELETE"]),
|
|
681
715
|
Route("/api/v1/memories/{memory_id}/history", guarded(memory_history), methods=["GET"]),
|
|
682
716
|
Route("/api/v1/memories/{memory_id}/distill", guarded(distill_memory), methods=["POST"]),
|
|
717
|
+
Route("/api/v1/import", guarded(import_memories_route), methods=["POST"]),
|
|
683
718
|
Route("/api/v1/search", guarded(search), methods=["POST"]),
|
|
684
719
|
Route("/api/v1/context", guarded(context), methods=["POST"]),
|
|
685
720
|
Route("/api/v1/stats", guarded(stats), methods=["GET"]),
|
|
@@ -24,6 +24,7 @@ from .intelligence.entities import resolve_mentions, resolve_open_proposals
|
|
|
24
24
|
from .intelligence.extraction import extract_facts, verbatim_candidates
|
|
25
25
|
from .intelligence.reconcile import reconcile_candidate
|
|
26
26
|
from .models import (
|
|
27
|
+
MEMORY_TYPES,
|
|
27
28
|
AddAction,
|
|
28
29
|
AddResult,
|
|
29
30
|
CandidateFact,
|
|
@@ -189,6 +190,99 @@ class MemoryStore:
|
|
|
189
190
|
)
|
|
190
191
|
return actions
|
|
191
192
|
|
|
193
|
+
def import_verbatim(
|
|
194
|
+
self, rows: list[dict[str, Any]], *, user_id: str | None = None
|
|
195
|
+
) -> dict[str, Any]:
|
|
196
|
+
"""Bulk verbatim import (dashboard/CLI import, restoring exports).
|
|
197
|
+
|
|
198
|
+
Rows are trusted as already-curated facts: no extraction, no
|
|
199
|
+
reconciliation, and embeddings are fetched in batched provider calls,
|
|
200
|
+
so importing N memories costs O(N/batch) HTTP round-trips instead of
|
|
201
|
+
two or three per row. Each row needs "content"; user_id, categories,
|
|
202
|
+
memory_type, and importance are optional (a row's own user_id wins
|
|
203
|
+
over the call-level default)."""
|
|
204
|
+
default_uid = user_id or self.config.default_user_id
|
|
205
|
+
prepared: list[dict[str, Any]] = []
|
|
206
|
+
skipped = 0
|
|
207
|
+
for row in rows:
|
|
208
|
+
content = str(row.get("content") or "").strip()
|
|
209
|
+
if not content:
|
|
210
|
+
skipped += 1
|
|
211
|
+
continue
|
|
212
|
+
categories = row.get("categories") or []
|
|
213
|
+
if isinstance(categories, str):
|
|
214
|
+
categories = [c.strip() for c in categories.split(",") if c.strip()]
|
|
215
|
+
memory_type = row.get("memory_type", "semantic")
|
|
216
|
+
if memory_type not in MEMORY_TYPES:
|
|
217
|
+
memory_type = "semantic"
|
|
218
|
+
try:
|
|
219
|
+
importance = float(row.get("importance", 0.5))
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
importance = 0.5
|
|
222
|
+
prepared.append(
|
|
223
|
+
{
|
|
224
|
+
"content": content,
|
|
225
|
+
"user_id": str(row.get("user_id") or default_uid),
|
|
226
|
+
"agent_id": row.get("agent_id") or None,
|
|
227
|
+
"run_id": row.get("run_id") or None,
|
|
228
|
+
"categories": [str(c) for c in categories],
|
|
229
|
+
"memory_type": memory_type,
|
|
230
|
+
"importance": importance,
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
if not prepared:
|
|
234
|
+
return {"imported": 0, "skipped": skipped, "memory_ids": []}
|
|
235
|
+
|
|
236
|
+
episodes = [
|
|
237
|
+
Episode(
|
|
238
|
+
content=p["content"],
|
|
239
|
+
user_id=p["user_id"],
|
|
240
|
+
agent_id=p["agent_id"],
|
|
241
|
+
run_id=p["run_id"],
|
|
242
|
+
metadata={"imported": True},
|
|
243
|
+
)
|
|
244
|
+
for p in prepared
|
|
245
|
+
]
|
|
246
|
+
self.backend.add_episodes(episodes)
|
|
247
|
+
|
|
248
|
+
vectors: list[list[float] | None] = [None] * len(prepared)
|
|
249
|
+
if self.embedder.dimensions:
|
|
250
|
+
chunk = 256 # stay well under provider batch limits
|
|
251
|
+
for start in range(0, len(prepared), chunk):
|
|
252
|
+
batch = prepared[start : start + chunk]
|
|
253
|
+
try:
|
|
254
|
+
embedded = self.embedder.embed([p["content"] for p in batch])
|
|
255
|
+
except Exception:
|
|
256
|
+
embedded = [] # import anyway; `memry reindex` can backfill
|
|
257
|
+
for offset, vec in enumerate(embedded):
|
|
258
|
+
vectors[start + offset] = vec or None
|
|
259
|
+
|
|
260
|
+
memory_ids: list[str] = []
|
|
261
|
+
for p, episode, vector in zip(prepared, episodes, vectors):
|
|
262
|
+
memory = Memory(
|
|
263
|
+
content=p["content"],
|
|
264
|
+
memory_type=p["memory_type"],
|
|
265
|
+
user_id=p["user_id"],
|
|
266
|
+
agent_id=p["agent_id"],
|
|
267
|
+
run_id=p["run_id"],
|
|
268
|
+
importance=p["importance"],
|
|
269
|
+
categories=p["categories"],
|
|
270
|
+
source_episode_ids=[episode.id],
|
|
271
|
+
)
|
|
272
|
+
if vector:
|
|
273
|
+
memory.embedding_model = self.embedder.model_id
|
|
274
|
+
stored = self.backend.insert_memory(memory, vector)
|
|
275
|
+
self.backend.add_event(
|
|
276
|
+
MemoryEvent(
|
|
277
|
+
memory_id=stored.id,
|
|
278
|
+
event="ADD",
|
|
279
|
+
new_content=stored.content,
|
|
280
|
+
reason="imported",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
memory_ids.append(stored.id)
|
|
284
|
+
return {"imported": len(memory_ids), "skipped": skipped, "memory_ids": memory_ids}
|
|
285
|
+
|
|
192
286
|
def distill(self, memory_id: str) -> AddResult | None:
|
|
193
287
|
"""Run extraction on a memory that was stored verbatim (LLM missing or
|
|
194
288
|
failing at save time) and replace it with the distilled facts.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|