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.
Files changed (30) hide show
  1. {memry-0.2.3 → memry-0.2.4}/PKG-INFO +1 -1
  2. {memry-0.2.3 → memry-0.2.4}/pyproject.toml +1 -1
  3. {memry-0.2.3 → memry-0.2.4}/src/memry/__init__.py +1 -1
  4. {memry-0.2.3 → memry-0.2.4}/src/memry/cli.py +3 -18
  5. {memry-0.2.3 → memry-0.2.4}/src/memry/mcp_server.py +31 -19
  6. {memry-0.2.3 → memry-0.2.4}/src/memry/rest.py +69 -34
  7. {memry-0.2.3 → memry-0.2.4}/src/memry/store.py +94 -0
  8. {memry-0.2.3 → memry-0.2.4}/.gitignore +0 -0
  9. {memry-0.2.3 → memry-0.2.4}/LICENSE +0 -0
  10. {memry-0.2.3 → memry-0.2.4}/README.md +0 -0
  11. {memry-0.2.3 → memry-0.2.4}/src/memry/backends/__init__.py +0 -0
  12. {memry-0.2.3 → memry-0.2.4}/src/memry/backends/ann.py +0 -0
  13. {memry-0.2.3 → memry-0.2.4}/src/memry/backends/base.py +0 -0
  14. {memry-0.2.3 → memry-0.2.4}/src/memry/backends/local.py +0 -0
  15. {memry-0.2.3 → memry-0.2.4}/src/memry/backends/mem0_adapter.py +0 -0
  16. {memry-0.2.3 → memry-0.2.4}/src/memry/backends/postgres.py +0 -0
  17. {memry-0.2.3 → memry-0.2.4}/src/memry/config.py +0 -0
  18. {memry-0.2.3 → memry-0.2.4}/src/memry/evals/__init__.py +0 -0
  19. {memry-0.2.3 → memry-0.2.4}/src/memry/evals/harness.py +0 -0
  20. {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/__init__.py +0 -0
  21. {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/context.py +0 -0
  22. {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/decay.py +0 -0
  23. {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/entities.py +0 -0
  24. {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/extraction.py +0 -0
  25. {memry-0.2.3 → memry-0.2.4}/src/memry/intelligence/reconcile.py +0 -0
  26. {memry-0.2.3 → memry-0.2.4}/src/memry/models.py +0 -0
  27. {memry-0.2.3 → memry-0.2.4}/src/memry/providers/__init__.py +0 -0
  28. {memry-0.2.3 → memry-0.2.4}/src/memry/providers/embeddings.py +0 -0
  29. {memry-0.2.3 → memry-0.2.4}/src/memry/providers/llm.py +0 -0
  30. {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
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memry"
7
- version = "0.2.3"
7
+ version = "0.2.4"
8
8
  description = "The open, self-hostable memory layer for AI agents. MCP-native, local-first, research-grade."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -14,7 +14,7 @@ from .models import (
14
14
  )
15
15
  from .store import MemoryStore
16
16
 
17
- __version__ = "0.2.3"
17
+ __version__ = "0.2.4"
18
18
 
19
19
  __all__ = [
20
20
  "MemoryStore",
@@ -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
- line = line.strip()
266
- if not line:
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 = store.add(
88
- content,
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 = store.search(
121
- query,
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 = store.reconstruct_context(
142
- query, user_id=_uid(user_id), token_budget=token_budget
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(user_id=_uid(user_id), limit=limit)
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(memory_id, content=content)
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(memory_id)
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(memory_id)
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
- return json.dumps(store.stats(), ensure_ascii=False, default=str)
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></h1>
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
108
- // Add form and category map are opt-in panels; the choice sticks per browser.
109
- const panels={add:localStorage.getItem('memry_show_add')==='1',map:localStorage.getItem('memry_show_map')==='1'};
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 (same field mapping as
341
- // `memry import`); nothing is deleted or deduplicated.
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
- let ok=0,failed=0;
351
- for(const r of rows){
352
- const content=String(r.content||'').trim();
353
- if(!content){failed++;continue}
354
- const body={content,infer:false,
355
- user_id:r.user_id||uid()||undefined,
356
- memory_type:r.memory_type||'semantic',
357
- importance:r.importance??0.5,
358
- categories:(r.categories&&r.categories.length)?r.categories:undefined};
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
- result = store.add(
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 = store.distill(request.path_params["memory_id"])
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 = store.search(
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 = store.reconstruct_context(
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 store.get_all(include_invalid=True, limit=100_000)
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 = store.resolve_entities(
627
- user_id=_ns(request.state.tenant, body.get("user_id"))
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