loop-memory 0.4.0__py3-none-any.whl
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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Route group: memories.
|
|
2
|
+
|
|
3
|
+
Memory CRUD + recall + v1/memories + v1/recall + /api/memories/page.
|
|
4
|
+
|
|
5
|
+
All routes were extracted from ``serve/app.py`` as part of the O1
|
|
6
|
+
refactor to keep the central ``create_app`` small. Each block lives
|
|
7
|
+
inside ``register(app, store, scheduler=None)`` so closures over the
|
|
8
|
+
three captured variables work unchanged from the original layout.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from fastapi import FastAPI, HTTPException
|
|
16
|
+
from fastapi.responses import JSONResponse
|
|
17
|
+
|
|
18
|
+
from ...storage.sqlite_store import MemoryStore
|
|
19
|
+
from ._shared import _memory_to_dict, _export_safe_segment
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None) -> None:
|
|
23
|
+
"""Mount every route in this bucket onto ``app``.
|
|
24
|
+
|
|
25
|
+
``store`` and ``scheduler`` are captured in the route closures so
|
|
26
|
+
the function bodies stay byte-identical to the pre-split layout.
|
|
27
|
+
"""
|
|
28
|
+
@app.get("/api/memories/{mid}/score")
|
|
29
|
+
def memory_score_components(mid: str):
|
|
30
|
+
"""Return the v2 score breakdown (importance / recency / usage /
|
|
31
|
+
feedback) so the UI can render a radar / breakdown chart per
|
|
32
|
+
memory."""
|
|
33
|
+
m = store.get_memory(mid)
|
|
34
|
+
if m is None:
|
|
35
|
+
raise HTTPException(404, "memory not found")
|
|
36
|
+
sig = store.get_signal(mid)
|
|
37
|
+
comps = MemoryStore.score_components(
|
|
38
|
+
importance=m.importance or 0.0,
|
|
39
|
+
created_at=m.created_at,
|
|
40
|
+
recall_count=sig["recall_count"],
|
|
41
|
+
last_recalled_at=sig["last_recalled_at"],
|
|
42
|
+
positive=sig["positive"],
|
|
43
|
+
negative=sig["negative"],
|
|
44
|
+
half_life_days=30.0,
|
|
45
|
+
)
|
|
46
|
+
return {"memory_id": mid, **comps}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.get("/api/memories")
|
|
50
|
+
def memories(
|
|
51
|
+
source: str | None = None,
|
|
52
|
+
session_id: str | None = None,
|
|
53
|
+
kind: str | None = None,
|
|
54
|
+
min_score: float | None = None,
|
|
55
|
+
since: float | None = None,
|
|
56
|
+
until: float | None = None,
|
|
57
|
+
q: str | None = None,
|
|
58
|
+
limit: int = 200,
|
|
59
|
+
):
|
|
60
|
+
if source:
|
|
61
|
+
rows = store.list_memories(
|
|
62
|
+
kind=kind,
|
|
63
|
+
min_score=min_score,
|
|
64
|
+
since=since,
|
|
65
|
+
until=until,
|
|
66
|
+
query=q,
|
|
67
|
+
limit=limit * 4,
|
|
68
|
+
)
|
|
69
|
+
rows = [r for r in rows if (r.source == source)]
|
|
70
|
+
rows = rows[:limit]
|
|
71
|
+
else:
|
|
72
|
+
rows = store.list_memories(
|
|
73
|
+
session_id=session_id,
|
|
74
|
+
kind=kind,
|
|
75
|
+
min_score=min_score,
|
|
76
|
+
since=since,
|
|
77
|
+
until=until,
|
|
78
|
+
query=q,
|
|
79
|
+
limit=limit,
|
|
80
|
+
)
|
|
81
|
+
# If a session_id was passed, drop the source-coded full scan and
|
|
82
|
+
# apply the session filter on top of either branch (catches the
|
|
83
|
+
# ``source=`` branch where list_memories was called without
|
|
84
|
+
# session_id so the filter didn't propagate).
|
|
85
|
+
if session_id:
|
|
86
|
+
rows = [r for r in rows if r.session_id == session_id]
|
|
87
|
+
return [_memory_to_dict(m) for m in rows]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.get("/api/memories/page")
|
|
91
|
+
def memories_page(
|
|
92
|
+
before_id: str | None = None,
|
|
93
|
+
after_id: str | None = None,
|
|
94
|
+
session_id: str | None = None,
|
|
95
|
+
source: str | None = None,
|
|
96
|
+
kind: str | None = None,
|
|
97
|
+
min_score: float | None = None,
|
|
98
|
+
limit: int = 100,
|
|
99
|
+
):
|
|
100
|
+
"""Cursor-paginated memory list (audit O11).
|
|
101
|
+
|
|
102
|
+
Returns ``{rows: [...], next_before_id, next_after_id}`` so
|
|
103
|
+
the UI can request additional pages with a stable, opaque
|
|
104
|
+
cursor (a memory id) instead of an offset that breaks on
|
|
105
|
+
concurrent inserts.
|
|
106
|
+
|
|
107
|
+
Pass ``before_id`` to fetch rows older than the boundary;
|
|
108
|
+
pass ``after_id`` to fetch rows newer. Omit both to start
|
|
109
|
+
from the most-recent page.
|
|
110
|
+
"""
|
|
111
|
+
from fastapi import HTTPException as _HE
|
|
112
|
+
try:
|
|
113
|
+
rows, next_before_id, next_after_id = store.list_memories_cursor(
|
|
114
|
+
limit=min(max(limit, 1), 500),
|
|
115
|
+
before_id=before_id,
|
|
116
|
+
after_id=after_id,
|
|
117
|
+
session_id=session_id,
|
|
118
|
+
source=source,
|
|
119
|
+
kind=kind,
|
|
120
|
+
min_score=min_score,
|
|
121
|
+
)
|
|
122
|
+
except ValueError as e:
|
|
123
|
+
raise _HE(404, str(e))
|
|
124
|
+
return {
|
|
125
|
+
"rows": [_memory_to_dict(m) for m in rows],
|
|
126
|
+
"next_before_id": next_before_id,
|
|
127
|
+
"next_after_id": next_after_id,
|
|
128
|
+
"count": len(rows),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.post("/api/memories/{mid}/feedback")
|
|
133
|
+
def memory_feedback(mid: str, value: str = "up", reason: str | None = None):
|
|
134
|
+
"""Record 👍/👎 feedback on a memory.
|
|
135
|
+
|
|
136
|
+
``value`` is one of:
|
|
137
|
+
- ``up`` — bump positive counter (user kept / liked this)
|
|
138
|
+
- ``down`` — bump negative counter (user rejected this)
|
|
139
|
+
- ``ignore``— same as down + soft-delete the memory
|
|
140
|
+
"""
|
|
141
|
+
v = (value or "").strip().lower()
|
|
142
|
+
if v not in ("up", "down", "ignore"):
|
|
143
|
+
raise HTTPException(400, f"value must be up|down|ignore (got {value!r})")
|
|
144
|
+
try:
|
|
145
|
+
store.record_signal(mid, positive=(v == "up"))
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise HTTPException(500, f"signal failed: {e}")
|
|
148
|
+
deleted = 0
|
|
149
|
+
if v == "ignore":
|
|
150
|
+
deleted = store.delete_memory(mid)
|
|
151
|
+
return {"ok": True, "value": v, "deleted": deleted, "reason": reason}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.post("/api/v1/memories")
|
|
155
|
+
def v1_create_memory(body: dict):
|
|
156
|
+
"""Idempotent remember(). Body: {text, kind?, importance?,
|
|
157
|
+
tags?, source?, session_id?, external_id?, agent_id?, user_id?,
|
|
158
|
+
ttl?}. When ``external_id`` is set, the (agent_id, user_id,
|
|
159
|
+
external_id) tuple must be unique; re-pushing updates the
|
|
160
|
+
row in place.
|
|
161
|
+
"""
|
|
162
|
+
text = (body.get("text") or "").strip()
|
|
163
|
+
if not text:
|
|
164
|
+
raise HTTPException(400, "text is required")
|
|
165
|
+
kind = (body.get("kind") or "fact").strip()
|
|
166
|
+
importance = body.get("importance")
|
|
167
|
+
try:
|
|
168
|
+
importance_f = float(importance) if importance is not None else 0.5
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
raise HTTPException(400, f"importance must be a number, got {importance!r}")
|
|
171
|
+
importance_f = max(0.0, min(1.0, importance_f))
|
|
172
|
+
ttl = body.get("ttl")
|
|
173
|
+
try:
|
|
174
|
+
ttl_f = float(ttl) if ttl is not None else None
|
|
175
|
+
except (TypeError, ValueError):
|
|
176
|
+
raise HTTPException(400, f"ttl must be a number, got {ttl!r}")
|
|
177
|
+
ext = body.get("external_id")
|
|
178
|
+
if ext is not None:
|
|
179
|
+
ext = str(ext).strip() or None
|
|
180
|
+
_ca = body.get("created_at")
|
|
181
|
+
try:
|
|
182
|
+
_ca_f = float(_ca) if _ca is not None else None
|
|
183
|
+
except (TypeError, ValueError):
|
|
184
|
+
raise HTTPException(400, "created_at must be a number, got " + repr(_ca))
|
|
185
|
+
stored = store.upsert_memory(
|
|
186
|
+
kind=kind,
|
|
187
|
+
text=text,
|
|
188
|
+
importance=importance_f,
|
|
189
|
+
source=body.get("source"),
|
|
190
|
+
session_id=body.get("session_id"),
|
|
191
|
+
tags=list(body.get("tags") or []),
|
|
192
|
+
agent_id=body.get("agent_id"),
|
|
193
|
+
user_id=body.get("user_id"),
|
|
194
|
+
external_id=ext,
|
|
195
|
+
ttl=ttl_f,
|
|
196
|
+
)
|
|
197
|
+
return _memory_to_dict(stored)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.post("/api/v1/memories:batch")
|
|
201
|
+
def v1_create_memories_batch(body: dict):
|
|
202
|
+
"""Bulk remember(). Body: {items: [ {...same as single...}, ... ]}.
|
|
203
|
+
|
|
204
|
+
Returns a per-item list with either the created memory or an
|
|
205
|
+
error string. The HTTP status is always 200 so a single
|
|
206
|
+
malformed item doesn't fail the whole batch — callers should
|
|
207
|
+
inspect each ``items[].error`` field.
|
|
208
|
+
"""
|
|
209
|
+
items = body.get("items") or []
|
|
210
|
+
if not isinstance(items, list):
|
|
211
|
+
raise HTTPException(400, "items must be a list")
|
|
212
|
+
if len(items) > 500:
|
|
213
|
+
raise HTTPException(400, "batch size capped at 500 per request")
|
|
214
|
+
out = []
|
|
215
|
+
for raw in items:
|
|
216
|
+
try:
|
|
217
|
+
if not isinstance(raw, dict):
|
|
218
|
+
raise ValueError("item must be an object")
|
|
219
|
+
text = (raw.get("text") or "").strip()
|
|
220
|
+
if not text:
|
|
221
|
+
raise ValueError("text is required")
|
|
222
|
+
ext = raw.get("external_id")
|
|
223
|
+
if ext is not None:
|
|
224
|
+
ext = str(ext).strip() or None
|
|
225
|
+
importance = raw.get("importance")
|
|
226
|
+
try:
|
|
227
|
+
importance_f = float(importance) if importance is not None else 0.5
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
raise ValueError(f"importance must be a number, got {importance!r}")
|
|
230
|
+
importance_f = max(0.0, min(1.0, importance_f))
|
|
231
|
+
_ca = raw.get("created_at")
|
|
232
|
+
try:
|
|
233
|
+
_ca_f = float(_ca) if _ca is not None else None
|
|
234
|
+
except (TypeError, ValueError):
|
|
235
|
+
raise ValueError("created_at must be a number, got " + repr(_ca))
|
|
236
|
+
stored = store.upsert_memory(
|
|
237
|
+
kind=(raw.get("kind") or "fact").strip(),
|
|
238
|
+
text=text,
|
|
239
|
+
importance=importance_f,
|
|
240
|
+
source=raw.get("source"),
|
|
241
|
+
session_id=raw.get("session_id"),
|
|
242
|
+
tags=list(raw.get("tags") or []),
|
|
243
|
+
agent_id=raw.get("agent_id"),
|
|
244
|
+
user_id=raw.get("user_id"),
|
|
245
|
+
external_id=ext,
|
|
246
|
+
created_at=_ca_f,
|
|
247
|
+
)
|
|
248
|
+
out.append(_memory_to_dict(stored))
|
|
249
|
+
except Exception as e:
|
|
250
|
+
out.append({"error": str(e), "input": raw})
|
|
251
|
+
return {"items": out, "count": len(out)}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@app.get("/api/v1/memories")
|
|
255
|
+
def v1_list_memories(
|
|
256
|
+
agent_id: str | None = None,
|
|
257
|
+
user_id: str | None = None,
|
|
258
|
+
session_id: str | None = None,
|
|
259
|
+
kind: str | None = None,
|
|
260
|
+
external_id: str | None = None,
|
|
261
|
+
min_score: float | None = None,
|
|
262
|
+
q: str | None = None,
|
|
263
|
+
limit: int = 50,
|
|
264
|
+
):
|
|
265
|
+
"""List memories with simple filters. ``external_id`` is exact-
|
|
266
|
+
match; combine with ``agent_id`` / ``user_id`` to address a
|
|
267
|
+
specific row pushed by an external Agent."""
|
|
268
|
+
rows = store.list_memories(
|
|
269
|
+
agent_id=agent_id,
|
|
270
|
+
user_id=user_id,
|
|
271
|
+
session_id=session_id,
|
|
272
|
+
kind=kind,
|
|
273
|
+
external_id=external_id,
|
|
274
|
+
min_score=min_score,
|
|
275
|
+
query=q,
|
|
276
|
+
limit=min(max(limit, 1), 500),
|
|
277
|
+
)
|
|
278
|
+
return {"memories": [_memory_to_dict(m) for m in rows], "count": len(rows)}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.get("/api/v1/recall")
|
|
282
|
+
def v1_recall(
|
|
283
|
+
q: str,
|
|
284
|
+
limit: int = 8,
|
|
285
|
+
include: str = "memories,wiki,entities",
|
|
286
|
+
source: str | None = None,
|
|
287
|
+
agent_id: str | None = None,
|
|
288
|
+
user_id: str | None = None,
|
|
289
|
+
mode: str = "hybrid",
|
|
290
|
+
):
|
|
291
|
+
"""Unified recall filtered to a single Agent namespace.
|
|
292
|
+
|
|
293
|
+
``agent_id`` and ``user_id`` are optional narrowing filters
|
|
294
|
+
applied on top of the existing BM25+semantic+entity hybrid
|
|
295
|
+
pipeline. Memories with no ``agent_id`` set are treated as
|
|
296
|
+
"global" and visible to every caller (matches the SDK
|
|
297
|
+
semantics).
|
|
298
|
+
"""
|
|
299
|
+
wanted = tuple(s.strip() for s in include.split(",") if s.strip())
|
|
300
|
+
if not wanted:
|
|
301
|
+
wanted = ("memories", "wiki", "entities")
|
|
302
|
+
if mode == "legacy" or not hasattr(store, "recall_hybrid"):
|
|
303
|
+
r = store.recall(
|
|
304
|
+
q,
|
|
305
|
+
limit=limit,
|
|
306
|
+
include=wanted,
|
|
307
|
+
bump_signals=True,
|
|
308
|
+
source=source,
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
r = store.recall_hybrid(
|
|
312
|
+
q, limit=limit, include=wanted,
|
|
313
|
+
bump_signals=True, source=source, level=1,
|
|
314
|
+
)
|
|
315
|
+
if agent_id is not None or user_id is not None:
|
|
316
|
+
def _own(m: dict) -> bool:
|
|
317
|
+
ag = m.get("agent_id")
|
|
318
|
+
ur = m.get("user_id")
|
|
319
|
+
# Global memories (no agent_id) are visible to all.
|
|
320
|
+
if ag is None and ur is None:
|
|
321
|
+
return True
|
|
322
|
+
if agent_id is not None and ag not in (None, agent_id):
|
|
323
|
+
return False
|
|
324
|
+
if user_id is not None and ur not in (None, user_id):
|
|
325
|
+
return False
|
|
326
|
+
return True
|
|
327
|
+
r["memories"] = [m for m in r.get("memories", []) if _own(m)]
|
|
328
|
+
return {
|
|
329
|
+
"query": q,
|
|
330
|
+
"tokens": r.get("tokens", []),
|
|
331
|
+
"memories": r.get("memories", []),
|
|
332
|
+
"wiki": r.get("wiki", []),
|
|
333
|
+
"entities": r.get("entities", []),
|
|
334
|
+
"mode": mode,
|
|
335
|
+
"source": source,
|
|
336
|
+
"temporal_intent": r.get("temporal_intent", "any"),
|
|
337
|
+
"temporal_confidence": r.get("temporal_confidence", 0.0),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@app.post("/api/v1/memories/{mid}/feedback")
|
|
342
|
+
def v1_feedback(mid: str, body: dict):
|
|
343
|
+
"""Record 👍/👎 on a memory by id. Body: {value, reason?}."""
|
|
344
|
+
value = (body.get("value") or "up").strip().lower()
|
|
345
|
+
if value not in ("up", "down", "ignore"):
|
|
346
|
+
raise HTTPException(400, "value must be up|down|ignore")
|
|
347
|
+
row = store.get_memory(mid)
|
|
348
|
+
if row is None:
|
|
349
|
+
raise HTTPException(404, "memory not found")
|
|
350
|
+
try:
|
|
351
|
+
store.record_signal(mid, positive=(value == "up"))
|
|
352
|
+
except Exception as e:
|
|
353
|
+
raise HTTPException(500, f"signal failed: {e}")
|
|
354
|
+
deleted = 0
|
|
355
|
+
if value == "ignore":
|
|
356
|
+
deleted = store.delete_memory(mid)
|
|
357
|
+
return {"ok": True, "value": value, "deleted": deleted}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@app.post("/api/v1/memories/feedback")
|
|
361
|
+
def v1_feedback_by_external(body: dict):
|
|
362
|
+
"""Record 👍/👎 on a memory addressed by ``(agent_id, user_id,
|
|
363
|
+
external_id)``. Body: {external_id, agent_id?, user_id?,
|
|
364
|
+
value, reason?}. Returns 404 when no memory matches.
|
|
365
|
+
"""
|
|
366
|
+
ext = (body.get("external_id") or "").strip()
|
|
367
|
+
if not ext:
|
|
368
|
+
raise HTTPException(400, "external_id is required")
|
|
369
|
+
agent_id = body.get("agent_id")
|
|
370
|
+
user_id = body.get("user_id")
|
|
371
|
+
row = store.find_memory_by_external_id(agent_id or "", ext, user_id=user_id)
|
|
372
|
+
if row is None:
|
|
373
|
+
raise HTTPException(404, "no memory matches that external_id")
|
|
374
|
+
value = (body.get("value") or "up").strip().lower()
|
|
375
|
+
if value not in ("up", "down", "ignore"):
|
|
376
|
+
raise HTTPException(400, "value must be up|down|ignore")
|
|
377
|
+
store.record_signal(row.id, positive=(value == "up"))
|
|
378
|
+
deleted = 0
|
|
379
|
+
if value == "ignore":
|
|
380
|
+
deleted = store.delete_memory(row.id)
|
|
381
|
+
return {"ok": True, "memory_id": row.id, "value": value, "deleted": deleted}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@app.delete("/api/v1/memories")
|
|
385
|
+
def v1_delete_memory_by_external(
|
|
386
|
+
external_id: str,
|
|
387
|
+
agent_id: str | None = None,
|
|
388
|
+
user_id: str | None = None,
|
|
389
|
+
):
|
|
390
|
+
"""Delete a memory by its external triple. Returns 404 if no
|
|
391
|
+
row matches so the Agent can retry with a corrected tuple."""
|
|
392
|
+
if not external_id:
|
|
393
|
+
raise HTTPException(400, "external_id is required")
|
|
394
|
+
row = store.find_memory_by_external_id(agent_id or "", external_id, user_id=user_id)
|
|
395
|
+
if row is None:
|
|
396
|
+
raise HTTPException(404, "no memory matches that external_id")
|
|
397
|
+
deleted = store.delete_memory(row.id)
|
|
398
|
+
return {"deleted": deleted, "memory_id": row.id}
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
# /api/v1 — graph, cognitive sleep, export/import/fork (v7)
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
# Closes the gaps the article called out: 3D adaptive scoring,
|
|
404
|
+
# semantic graph edges, cognitive audit, white-box MEMORY.md
|
|
405
|
+
# bundles, and a fork primitive so any Agent can snapshot the
|
|
406
|
+
# wiki and `git revert` later.
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@app.delete("/api/memories/{mid}")
|
|
410
|
+
def delete_memory(mid: str):
|
|
411
|
+
n = store.delete_memory(mid)
|
|
412
|
+
if n == 0:
|
|
413
|
+
raise HTTPException(404, "memory not found")
|
|
414
|
+
return {"deleted": n}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@app.get("/api/memories/{mid}", name="memory_drill_down")
|
|
418
|
+
def memory_drill_down(mid: str):
|
|
419
|
+
"""L2 drill-down for a single memory row (full text)."""
|
|
420
|
+
mem = store.get_memory(mid)
|
|
421
|
+
if not mem:
|
|
422
|
+
from fastapi import HTTPException
|
|
423
|
+
raise HTTPException(status_code=404, detail="memory not found")
|
|
424
|
+
return {
|
|
425
|
+
"id": mem.id,
|
|
426
|
+
"kind": "memory",
|
|
427
|
+
"text": mem.text,
|
|
428
|
+
"importance": mem.importance,
|
|
429
|
+
"score": mem.score,
|
|
430
|
+
"source": mem.source,
|
|
431
|
+
"tags": mem.tags,
|
|
432
|
+
"created_at": mem.created_at,
|
|
433
|
+
"updated_at": mem.updated_at,
|
|
434
|
+
}
|
|
435
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Route group: sessions.
|
|
2
|
+
|
|
3
|
+
Session listing + per-source counts.
|
|
4
|
+
|
|
5
|
+
All routes were extracted from ``serve/app.py`` as part of the O1
|
|
6
|
+
refactor to keep the central ``create_app`` small. Each block lives
|
|
7
|
+
inside ``register(app, store, scheduler=None)`` so closures over the
|
|
8
|
+
three captured variables work unchanged from the original layout.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from fastapi import FastAPI, HTTPException
|
|
16
|
+
from fastapi.responses import JSONResponse
|
|
17
|
+
|
|
18
|
+
from ...storage.sqlite_store import MemoryStore
|
|
19
|
+
from ._shared import _memory_to_dict, _session_to_dict, _export_safe_segment
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None) -> None:
|
|
23
|
+
"""Mount every route in this bucket onto ``app``.
|
|
24
|
+
|
|
25
|
+
``store`` and ``scheduler`` are captured in the route closures so
|
|
26
|
+
the function bodies stay byte-identical to the pre-split layout.
|
|
27
|
+
"""
|
|
28
|
+
@app.get("/api/sessions")
|
|
29
|
+
def sessions(source: str | None = None, limit: int = 100):
|
|
30
|
+
return [
|
|
31
|
+
_session_to_dict(s)
|
|
32
|
+
for s in store.list_sessions(source=source, limit=limit)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.get("/api/sessions/counts")
|
|
37
|
+
def session_counts():
|
|
38
|
+
"""Per-source session + memory counts, for the sidebar filter UI."""
|
|
39
|
+
try:
|
|
40
|
+
with store._conn() as c:
|
|
41
|
+
rows = c.execute(
|
|
42
|
+
"""SELECT source,
|
|
43
|
+
COUNT(*) AS sessions,
|
|
44
|
+
COALESCE(SUM(message_count), 0) AS turns
|
|
45
|
+
FROM sessions
|
|
46
|
+
GROUP BY source"""
|
|
47
|
+
).fetchall()
|
|
48
|
+
except Exception:
|
|
49
|
+
rows = []
|
|
50
|
+
out = {"all": {"sessions": 0, "turns": 0}, "by_source": {}}
|
|
51
|
+
for r in rows:
|
|
52
|
+
src = r["source"] or "unknown"
|
|
53
|
+
out["by_source"][src] = {
|
|
54
|
+
"sessions": r["sessions"],
|
|
55
|
+
"turns": r["turns"],
|
|
56
|
+
}
|
|
57
|
+
out["all"]["sessions"] += r["sessions"]
|
|
58
|
+
out["all"]["turns"] += r["turns"]
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/api/sessions/{session_id}/memories")
|
|
63
|
+
def session_memories(session_id: str, limit: int = 1000):
|
|
64
|
+
return [
|
|
65
|
+
_memory_to_dict(m)
|
|
66
|
+
for m in store.list_memories(session_id=session_id, limit=limit)
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.delete("/api/sessions/{sid}")
|
|
71
|
+
def delete_session(sid: str):
|
|
72
|
+
n = store.delete_session(sid)
|
|
73
|
+
return {"deleted": n}
|
|
74
|
+
|
|
75
|
+
|