albert-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.
- albert/__init__.py +3 -0
- albert/admin.py +532 -0
- albert/api.py +698 -0
- albert/automatic_memory_release.json +14 -0
- albert/benchmark.py +272 -0
- albert/benchmark_cli.py +37 -0
- albert/capture.py +1051 -0
- albert/capture_installer.py +1000 -0
- albert/capture_promotion.py +298 -0
- albert/capture_retention.py +72 -0
- albert/capture_sdk.py +558 -0
- albert/capture_security.py +79 -0
- albert/chunking.py +79 -0
- albert/classifier.py +263 -0
- albert/claude_adapter.py +357 -0
- albert/claude_lifecycle.py +418 -0
- albert/claude_package.json +19 -0
- albert/claude_plugin/.claude-plugin/plugin.json +11 -0
- albert/claude_plugin/.mcp.json +13 -0
- albert/claude_plugin/hooks/hooks.json +70 -0
- albert/clusters.py +140 -0
- albert/codex_adapter.py +308 -0
- albert/codex_hooks/hooks.json +49 -0
- albert/codex_lifecycle.py +485 -0
- albert/codex_package.json +19 -0
- albert/compatibility.py +121 -0
- albert/config.py +144 -0
- albert/console.py +508 -0
- albert/console_backends.py +236 -0
- albert/console_map.py +518 -0
- albert/console_ops.py +919 -0
- albert/credentials.py +616 -0
- albert/cursor_adapter.py +320 -0
- albert/cursor_hooks/hooks.json +11 -0
- albert/cursor_rules/albert-memory.mdc +11 -0
- albert/db.py +68 -0
- albert/embeddings.py +91 -0
- albert/graph.py +623 -0
- albert/graph_builders.py +879 -0
- albert/graph_cypher.py +669 -0
- albert/graph_store.py +376 -0
- albert/host_targets/claude-code.json +45 -0
- albert/host_targets/codex.json +45 -0
- albert/host_targets/openclaw.json +45 -0
- albert/host_targets.py +271 -0
- albert/links.py +428 -0
- albert/mcp_server.py +457 -0
- albert/memory_safety.py +111 -0
- albert/models.py +611 -0
- albert/openclaw_adapter.py +326 -0
- albert/openclaw_adapter_trust.json +5 -0
- albert/openclaw_package.json +22 -0
- albert/openclaw_plugin/index.js +135 -0
- albert/openclaw_plugin/openclaw.plugin.json +25 -0
- albert/openclaw_plugin/package.json +12 -0
- albert/portability.py +291 -0
- albert/provenance.py +105 -0
- albert/recorder.py +166 -0
- albert/release_gate.py +83 -0
- albert/schemas.py +617 -0
- albert/scope.py +26 -0
- albert/search.py +447 -0
- albert/security.py +150 -0
- albert/services.py +1316 -0
- albert/session_context.py +162 -0
- albert/trace_feed.py +165 -0
- albert/trace_writer.py +131 -0
- albert/tracing.py +75 -0
- albert/worker.py +202 -0
- albert/workspaces.py +256 -0
- albert_memory-0.4.0.dist-info/METADATA +554 -0
- albert_memory-0.4.0.dist-info/RECORD +74 -0
- albert_memory-0.4.0.dist-info/WHEEL +4 -0
- albert_memory-0.4.0.dist-info/entry_points.txt +14 -0
albert/api.py
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Response, status
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
from sqlalchemy.orm import Session
|
|
13
|
+
|
|
14
|
+
from albert import __version__
|
|
15
|
+
from albert.capture import router as capture_router
|
|
16
|
+
from albert.config import get_settings
|
|
17
|
+
from albert.console import router as console_router
|
|
18
|
+
from albert.console_backends import router as console_backends_router
|
|
19
|
+
from albert.console_map import router as console_map_router
|
|
20
|
+
from albert.console_ops import router as console_ops_router
|
|
21
|
+
from albert.credentials import onboard_router, settings_router
|
|
22
|
+
from albert.db import get_session
|
|
23
|
+
from albert.graph_store import get_graph_store
|
|
24
|
+
from albert.models import Episode
|
|
25
|
+
from albert.portability import export_bundle, import_bundle
|
|
26
|
+
from albert.provenance import normalize_provenance
|
|
27
|
+
from albert.recorder import CANDIDATE_LIMIT, cap_candidates, current_recorder, span
|
|
28
|
+
from albert.schemas import (
|
|
29
|
+
ContextRequest,
|
|
30
|
+
ContextResponse,
|
|
31
|
+
EpisodeCreate,
|
|
32
|
+
EpisodeRead,
|
|
33
|
+
ExportBundle,
|
|
34
|
+
GraphQuery,
|
|
35
|
+
HealthResponse,
|
|
36
|
+
ImportRequest,
|
|
37
|
+
ImportResult,
|
|
38
|
+
LockAcquire,
|
|
39
|
+
LockRead,
|
|
40
|
+
LockRelease,
|
|
41
|
+
LockRenew,
|
|
42
|
+
MemoryCreate,
|
|
43
|
+
MemoryRead,
|
|
44
|
+
MemorySafetyReview,
|
|
45
|
+
MemoryUpdate,
|
|
46
|
+
RelationshipCreate,
|
|
47
|
+
RelationshipRead,
|
|
48
|
+
SearchRequest,
|
|
49
|
+
SearchResponse,
|
|
50
|
+
SessionContextRequest,
|
|
51
|
+
SessionContextResponse,
|
|
52
|
+
SubgraphQuery,
|
|
53
|
+
WorkingMemoryCreate,
|
|
54
|
+
WorkingMemoryCreateRead,
|
|
55
|
+
WorkingMemoryFinish,
|
|
56
|
+
WorkingMemoryRead,
|
|
57
|
+
WorkingMemoryUpdate,
|
|
58
|
+
)
|
|
59
|
+
from albert.scope import covers
|
|
60
|
+
from albert.search import hybrid_search
|
|
61
|
+
from albert.security import AuthContext, authenticate
|
|
62
|
+
from albert.services import (
|
|
63
|
+
acquire_lock,
|
|
64
|
+
add_explicit_relationship,
|
|
65
|
+
audit,
|
|
66
|
+
create_episode,
|
|
67
|
+
create_memory,
|
|
68
|
+
create_working_memory,
|
|
69
|
+
delete_episode,
|
|
70
|
+
delete_memory,
|
|
71
|
+
episode_read,
|
|
72
|
+
finish_working_memory,
|
|
73
|
+
get_episode,
|
|
74
|
+
get_memory,
|
|
75
|
+
get_working_memory,
|
|
76
|
+
release_lock,
|
|
77
|
+
renew_lock,
|
|
78
|
+
resolve_workspace,
|
|
79
|
+
review_memory_safety,
|
|
80
|
+
update_memory,
|
|
81
|
+
update_working_memory,
|
|
82
|
+
wait_for_indexing,
|
|
83
|
+
)
|
|
84
|
+
from albert.session_context import assemble_session_context
|
|
85
|
+
from albert.trace_writer import get_trace_writer
|
|
86
|
+
from albert.tracing import RecordingMiddleware
|
|
87
|
+
|
|
88
|
+
logging.basicConfig(level=logging.INFO)
|
|
89
|
+
logger = logging.getLogger("albert.api")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@asynccontextmanager
|
|
93
|
+
async def lifespan(_app: FastAPI): # type: ignore[no-untyped-def]
|
|
94
|
+
logger.info("Albert API starting", extra={"version": __version__})
|
|
95
|
+
writer = get_trace_writer()
|
|
96
|
+
writer.start()
|
|
97
|
+
yield
|
|
98
|
+
writer.stop()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
app = FastAPI(
|
|
102
|
+
title="Albert Memory API",
|
|
103
|
+
version=__version__,
|
|
104
|
+
description="Independent hybrid memory service for AI agents",
|
|
105
|
+
lifespan=lifespan,
|
|
106
|
+
)
|
|
107
|
+
app.add_middleware(RecordingMiddleware)
|
|
108
|
+
app.include_router(capture_router)
|
|
109
|
+
app.include_router(console_router)
|
|
110
|
+
app.include_router(console_backends_router)
|
|
111
|
+
app.include_router(console_map_router)
|
|
112
|
+
app.include_router(console_ops_router)
|
|
113
|
+
app.include_router(settings_router)
|
|
114
|
+
app.include_router(onboard_router)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _memory_ids(result: SearchResponse) -> list[str]:
|
|
118
|
+
"""Final memory hits in rank order, ids only, for the trace summary."""
|
|
119
|
+
ids: list[str] = []
|
|
120
|
+
for hit in result.hits:
|
|
121
|
+
if hit.memory_id is not None and str(hit.memory_id) not in ids:
|
|
122
|
+
ids.append(str(hit.memory_id))
|
|
123
|
+
return ids[:CANDIDATE_LIMIT]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _note(**fields: object) -> None:
|
|
127
|
+
recorder = current_recorder()
|
|
128
|
+
if recorder is not None:
|
|
129
|
+
recorder.note(**fields)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.get("/v1/health/live", response_model=HealthResponse, tags=["health"])
|
|
133
|
+
def live() -> HealthResponse:
|
|
134
|
+
from albert.graph_builders import builder_status
|
|
135
|
+
|
|
136
|
+
settings = get_settings()
|
|
137
|
+
builder = builder_status()
|
|
138
|
+
return HealthResponse(
|
|
139
|
+
status="ok",
|
|
140
|
+
database="unchecked",
|
|
141
|
+
version=__version__,
|
|
142
|
+
embedding_provider=settings.embedding_provider,
|
|
143
|
+
embedding_model=settings.embedding_model,
|
|
144
|
+
embedding_dimensions=settings.embedding_dimensions,
|
|
145
|
+
classifier_provider=settings.llm_provider,
|
|
146
|
+
graph_store=get_graph_store().name,
|
|
147
|
+
graph_builder=settings.graph_builder,
|
|
148
|
+
graph_builder_version=builder["version"] or "unavailable",
|
|
149
|
+
graph_builder_error=builder["error"],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.get("/v1/health/ready", response_model=HealthResponse, tags=["health"])
|
|
154
|
+
def ready(session: Session = Depends(get_session)) -> HealthResponse:
|
|
155
|
+
try:
|
|
156
|
+
session.execute(text("SELECT 1"))
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
raise HTTPException(status_code=503, detail="Database unavailable") from exc
|
|
159
|
+
from albert.graph_builders import builder_status
|
|
160
|
+
|
|
161
|
+
settings = get_settings()
|
|
162
|
+
builder = builder_status()
|
|
163
|
+
try:
|
|
164
|
+
get_graph_store().ping(session)
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
raise HTTPException(
|
|
167
|
+
status_code=503, detail=f"Graph backend unavailable ({type(exc).__name__})"
|
|
168
|
+
) from exc
|
|
169
|
+
return HealthResponse(
|
|
170
|
+
status="ok",
|
|
171
|
+
database="ok",
|
|
172
|
+
version=__version__,
|
|
173
|
+
embedding_provider=settings.embedding_provider,
|
|
174
|
+
embedding_model=settings.embedding_model,
|
|
175
|
+
embedding_dimensions=settings.embedding_dimensions,
|
|
176
|
+
classifier_provider=settings.llm_provider,
|
|
177
|
+
graph_store=get_graph_store().name,
|
|
178
|
+
graph_builder=settings.graph_builder,
|
|
179
|
+
graph_builder_version=builder["version"] or "unavailable",
|
|
180
|
+
graph_builder_error=builder["error"],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _preferred_wait(prefer: str | None) -> float | None:
|
|
185
|
+
"""Seconds from an RFC 7240 `Prefer: wait=N` header, capped by configuration."""
|
|
186
|
+
for preference in (prefer or "").split(","):
|
|
187
|
+
name, _, value = preference.strip().partition("=")
|
|
188
|
+
if name.strip().lower() == "wait":
|
|
189
|
+
try:
|
|
190
|
+
seconds = float(value.strip().strip('"'))
|
|
191
|
+
except ValueError:
|
|
192
|
+
return None
|
|
193
|
+
if seconds != seconds or seconds < 0: # NaN or negative
|
|
194
|
+
return None
|
|
195
|
+
return min(seconds, float(get_settings().max_index_wait_seconds))
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _episode_response(
|
|
200
|
+
session: Session, episode: Episode, response: Response, prefer: str | None
|
|
201
|
+
) -> EpisodeRead:
|
|
202
|
+
"""The episode with its indexing status, after waiting if the caller asked to.
|
|
203
|
+
|
|
204
|
+
Location always points at the episode; Retry-After is set while it is not
|
|
205
|
+
yet searchable, so a client that cannot wait knows when to look again.
|
|
206
|
+
"""
|
|
207
|
+
wait = _preferred_wait(prefer)
|
|
208
|
+
if wait is not None:
|
|
209
|
+
view = wait_for_indexing(session, episode, wait)
|
|
210
|
+
response.headers["Preference-Applied"] = f"wait={wait:g}"
|
|
211
|
+
else:
|
|
212
|
+
view = episode_read(session, episode)
|
|
213
|
+
response.headers["Location"] = f"/v1/episodes/{view.id}"
|
|
214
|
+
indexing = view.indexing
|
|
215
|
+
if indexing is not None and indexing.state not in ("complete", "failed"):
|
|
216
|
+
retry_after = 1
|
|
217
|
+
if indexing.next_attempt_at is not None:
|
|
218
|
+
delta = indexing.next_attempt_at.replace(
|
|
219
|
+
tzinfo=indexing.next_attempt_at.tzinfo or UTC
|
|
220
|
+
) - datetime.now(UTC)
|
|
221
|
+
retry_after = max(1, math.ceil(delta.total_seconds()))
|
|
222
|
+
response.headers["Retry-After"] = str(retry_after)
|
|
223
|
+
return view
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.post("/v1/episodes", response_model=EpisodeRead, status_code=201, tags=["episodes"])
|
|
227
|
+
def post_episode(
|
|
228
|
+
data: EpisodeCreate,
|
|
229
|
+
response: Response,
|
|
230
|
+
prefer: str | None = Header(default=None),
|
|
231
|
+
auth: AuthContext = Depends(authenticate),
|
|
232
|
+
session: Session = Depends(get_session),
|
|
233
|
+
) -> EpisodeRead:
|
|
234
|
+
episode, created = create_episode(session, auth, data)
|
|
235
|
+
if not created:
|
|
236
|
+
response.status_code = status.HTTP_200_OK
|
|
237
|
+
return _episode_response(session, episode, response, prefer)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.get("/v1/episodes/{episode_id}", response_model=EpisodeRead, tags=["episodes"])
|
|
241
|
+
def read_episode(
|
|
242
|
+
episode_id: UUID,
|
|
243
|
+
response: Response,
|
|
244
|
+
prefer: str | None = Header(default=None),
|
|
245
|
+
auth: AuthContext = Depends(authenticate),
|
|
246
|
+
session: Session = Depends(get_session),
|
|
247
|
+
) -> EpisodeRead:
|
|
248
|
+
episode = get_episode(session, auth, episode_id)
|
|
249
|
+
audit(session, auth, "episode.read", resource_type="episode", resource_id=str(episode.id))
|
|
250
|
+
session.commit()
|
|
251
|
+
return _episode_response(session, episode, response, prefer)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@app.delete("/v1/episodes/{episode_id}", status_code=204, tags=["episodes"])
|
|
255
|
+
def remove_episode(
|
|
256
|
+
episode_id: UUID,
|
|
257
|
+
auth: AuthContext = Depends(authenticate),
|
|
258
|
+
session: Session = Depends(get_session),
|
|
259
|
+
) -> Response:
|
|
260
|
+
delete_episode(session, auth, episode_id)
|
|
261
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.post("/v1/memories", response_model=MemoryRead, status_code=201, tags=["memories"])
|
|
265
|
+
def post_memory(
|
|
266
|
+
data: MemoryCreate,
|
|
267
|
+
auth: AuthContext = Depends(authenticate),
|
|
268
|
+
session: Session = Depends(get_session),
|
|
269
|
+
): # type: ignore[no-untyped-def]
|
|
270
|
+
memory = create_memory(session, auth, data)
|
|
271
|
+
_note(stored_ids=[str(memory.id)])
|
|
272
|
+
return memory
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.get("/v1/memories/{memory_id}", response_model=MemoryRead, tags=["memories"])
|
|
276
|
+
def read_memory(
|
|
277
|
+
memory_id: UUID,
|
|
278
|
+
auth: AuthContext = Depends(authenticate),
|
|
279
|
+
session: Session = Depends(get_session),
|
|
280
|
+
): # type: ignore[no-untyped-def]
|
|
281
|
+
memory = get_memory(session, auth, memory_id)
|
|
282
|
+
audit(session, auth, "memory.read", resource_type="memory", resource_id=str(memory.id))
|
|
283
|
+
session.commit()
|
|
284
|
+
return memory
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.patch("/v1/memories/{memory_id}", response_model=MemoryRead, tags=["memories"])
|
|
288
|
+
def patch_memory(
|
|
289
|
+
memory_id: UUID,
|
|
290
|
+
data: MemoryUpdate,
|
|
291
|
+
auth: AuthContext = Depends(authenticate),
|
|
292
|
+
session: Session = Depends(get_session),
|
|
293
|
+
): # type: ignore[no-untyped-def]
|
|
294
|
+
return update_memory(session, auth, memory_id, data)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.post(
|
|
298
|
+
"/v1/memories/{memory_id}/quarantine/review",
|
|
299
|
+
response_model=MemoryRead,
|
|
300
|
+
tags=["memories"],
|
|
301
|
+
)
|
|
302
|
+
def review_memory_quarantine(
|
|
303
|
+
memory_id: UUID,
|
|
304
|
+
data: MemorySafetyReview,
|
|
305
|
+
auth: AuthContext = Depends(authenticate),
|
|
306
|
+
session: Session = Depends(get_session),
|
|
307
|
+
): # type: ignore[no-untyped-def]
|
|
308
|
+
return review_memory_safety(session, auth, memory_id, data)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.delete("/v1/memories/{memory_id}", status_code=204, tags=["memories"])
|
|
312
|
+
def remove_memory(
|
|
313
|
+
memory_id: UUID,
|
|
314
|
+
auth: AuthContext = Depends(authenticate),
|
|
315
|
+
session: Session = Depends(get_session),
|
|
316
|
+
) -> Response:
|
|
317
|
+
delete_memory(session, auth, memory_id)
|
|
318
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.post("/v1/search", response_model=SearchResponse, tags=["retrieval"])
|
|
322
|
+
def search_memories(
|
|
323
|
+
data: SearchRequest,
|
|
324
|
+
auth: AuthContext = Depends(authenticate),
|
|
325
|
+
session: Session = Depends(get_session),
|
|
326
|
+
) -> SearchResponse:
|
|
327
|
+
auth.require("memory.read")
|
|
328
|
+
workspace_id = resolve_workspace(session, auth, data.workspace_id)
|
|
329
|
+
allowed_sensitivities = auth.allowed_sensitivities()
|
|
330
|
+
if data.sensitivity and not set(data.sensitivity).issubset(allowed_sensitivities):
|
|
331
|
+
raise HTTPException(status_code=403, detail="Requested sensitivity is not authorized")
|
|
332
|
+
effective = data.model_copy(update={"sensitivity": data.sensitivity or allowed_sensitivities})
|
|
333
|
+
_note(query=data.query)
|
|
334
|
+
result = hybrid_search(
|
|
335
|
+
session,
|
|
336
|
+
organization_id=auth.organization_id,
|
|
337
|
+
workspace_id=workspace_id,
|
|
338
|
+
request=effective,
|
|
339
|
+
allowed_sensitivities=allowed_sensitivities,
|
|
340
|
+
)
|
|
341
|
+
_note(hits=len(result.hits), degraded=result.degraded, memory_ids=_memory_ids(result))
|
|
342
|
+
audit(
|
|
343
|
+
session,
|
|
344
|
+
auth,
|
|
345
|
+
"memory.searched",
|
|
346
|
+
detail={"result_count": len(result.hits), "degraded": result.degraded},
|
|
347
|
+
)
|
|
348
|
+
session.commit()
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.post("/v1/context/assemble", response_model=ContextResponse, tags=["retrieval"])
|
|
353
|
+
def assemble_context(
|
|
354
|
+
data: ContextRequest,
|
|
355
|
+
auth: AuthContext = Depends(authenticate),
|
|
356
|
+
session: Session = Depends(get_session),
|
|
357
|
+
) -> ContextResponse:
|
|
358
|
+
auth.require("memory.read")
|
|
359
|
+
workspace_id = resolve_workspace(session, auth, data.workspace_id)
|
|
360
|
+
allowed_sensitivities = auth.allowed_sensitivities()
|
|
361
|
+
if data.sensitivity and not set(data.sensitivity).issubset(allowed_sensitivities):
|
|
362
|
+
raise HTTPException(status_code=403, detail="Requested sensitivity is not authorized")
|
|
363
|
+
effective = data.model_copy(update={"sensitivity": data.sensitivity or allowed_sensitivities})
|
|
364
|
+
_note(query=data.query)
|
|
365
|
+
result = hybrid_search(
|
|
366
|
+
session,
|
|
367
|
+
organization_id=auth.organization_id,
|
|
368
|
+
workspace_id=workspace_id,
|
|
369
|
+
request=effective,
|
|
370
|
+
allowed_sensitivities=allowed_sensitivities,
|
|
371
|
+
)
|
|
372
|
+
_note(hits=len(result.hits), degraded=result.degraded, memory_ids=_memory_ids(result))
|
|
373
|
+
sections: list[str] = []
|
|
374
|
+
citations: list[dict] = []
|
|
375
|
+
used = 0
|
|
376
|
+
for index, hit in enumerate(result.hits, start=1):
|
|
377
|
+
section = f"[{index}] {hit.subject}\n{hit.content}".strip()
|
|
378
|
+
if used + len(section) > data.max_characters:
|
|
379
|
+
break
|
|
380
|
+
sections.append(section)
|
|
381
|
+
used += len(section) + 2
|
|
382
|
+
citations.append(
|
|
383
|
+
{
|
|
384
|
+
"index": index,
|
|
385
|
+
"kind": hit.kind,
|
|
386
|
+
"memory_id": str(hit.memory_id) if hit.memory_id else None,
|
|
387
|
+
"episode_id": str(hit.source_episode_id) if hit.source_episode_id else None,
|
|
388
|
+
"backends": hit.backends,
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
audit(
|
|
392
|
+
session,
|
|
393
|
+
auth,
|
|
394
|
+
"context.assembled",
|
|
395
|
+
detail={"citations": len(citations), "characters": used, "degraded": result.degraded},
|
|
396
|
+
)
|
|
397
|
+
session.commit()
|
|
398
|
+
return ContextResponse(
|
|
399
|
+
context="\n\n".join(sections), citations=citations, degraded=result.degraded
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.post("/v1/context/session-start", response_model=SessionContextResponse, tags=["retrieval"])
|
|
404
|
+
def session_start_context(
|
|
405
|
+
data: SessionContextRequest,
|
|
406
|
+
auth: AuthContext = Depends(authenticate),
|
|
407
|
+
session: Session = Depends(get_session),
|
|
408
|
+
) -> SessionContextResponse:
|
|
409
|
+
auth.require("memory.read")
|
|
410
|
+
workspace_id = resolve_workspace(session, auth, data.workspace_id)
|
|
411
|
+
allowed_sensitivities = auth.allowed_sensitivities()
|
|
412
|
+
if data.sensitivity and not set(data.sensitivity).issubset(allowed_sensitivities):
|
|
413
|
+
raise HTTPException(status_code=403, detail="Requested sensitivity is not authorized")
|
|
414
|
+
if data.provenance is not None:
|
|
415
|
+
# Same normalization as capture, so a hook's recall scope matches the
|
|
416
|
+
# project identity its captured memories were stored under.
|
|
417
|
+
observed = normalize_provenance(
|
|
418
|
+
data.provenance,
|
|
419
|
+
organization_id=auth.organization_id,
|
|
420
|
+
allow_absolute_path=False,
|
|
421
|
+
)
|
|
422
|
+
data = data.model_copy(update={
|
|
423
|
+
"project_ref": data.project_ref or observed.get("project_id"),
|
|
424
|
+
"branch_ref": data.branch_ref or observed.get("branch") or observed.get("ref"),
|
|
425
|
+
})
|
|
426
|
+
# Task and run are ranking signals here, not hard filters: durable project truth
|
|
427
|
+
# remains eligible alongside the most specific current-task evidence.
|
|
428
|
+
request = SearchRequest(
|
|
429
|
+
query=data.query,
|
|
430
|
+
limit=min(100, max(data.max_items * 4, 20)),
|
|
431
|
+
workspace_id=data.workspace_id,
|
|
432
|
+
project_ref=data.project_ref,
|
|
433
|
+
task_ref=None,
|
|
434
|
+
run_ref=None,
|
|
435
|
+
branch_ref=data.branch_ref,
|
|
436
|
+
memory_types=data.memory_types,
|
|
437
|
+
sensitivity=data.sensitivity or allowed_sensitivities,
|
|
438
|
+
include_graph=False,
|
|
439
|
+
)
|
|
440
|
+
result = hybrid_search(
|
|
441
|
+
session,
|
|
442
|
+
organization_id=auth.organization_id,
|
|
443
|
+
workspace_id=workspace_id,
|
|
444
|
+
request=request,
|
|
445
|
+
allowed_sensitivities=allowed_sensitivities,
|
|
446
|
+
)
|
|
447
|
+
response = assemble_session_context(result, data)
|
|
448
|
+
_note(
|
|
449
|
+
capture_stage="injection",
|
|
450
|
+
injection_characters=response.characters,
|
|
451
|
+
injection_tokens=response.tokens,
|
|
452
|
+
injection_citations=len(response.citations),
|
|
453
|
+
injection_truncated=response.truncated,
|
|
454
|
+
)
|
|
455
|
+
audit(
|
|
456
|
+
session,
|
|
457
|
+
auth,
|
|
458
|
+
"context.session_started",
|
|
459
|
+
detail={
|
|
460
|
+
"citations": len(response.citations),
|
|
461
|
+
"characters": response.characters,
|
|
462
|
+
"tokens": response.tokens,
|
|
463
|
+
"truncated": response.truncated,
|
|
464
|
+
"degraded": response.degraded,
|
|
465
|
+
},
|
|
466
|
+
)
|
|
467
|
+
session.commit()
|
|
468
|
+
return response
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@app.post("/v1/relationships", response_model=RelationshipRead, status_code=201, tags=["graph"])
|
|
472
|
+
def post_relationship(
|
|
473
|
+
data: RelationshipCreate,
|
|
474
|
+
auth: AuthContext = Depends(authenticate),
|
|
475
|
+
session: Session = Depends(get_session),
|
|
476
|
+
) -> RelationshipRead:
|
|
477
|
+
return add_explicit_relationship(session, auth, data).read()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@app.post("/v1/graph/query", response_model=list[RelationshipRead], tags=["graph"])
|
|
481
|
+
def query_graph(
|
|
482
|
+
data: GraphQuery,
|
|
483
|
+
auth: AuthContext = Depends(authenticate),
|
|
484
|
+
session: Session = Depends(get_session),
|
|
485
|
+
) -> list[RelationshipRead]:
|
|
486
|
+
auth.require("graph.query")
|
|
487
|
+
workspace_id = resolve_workspace(session, auth, data.workspace_id)
|
|
488
|
+
_note(query=data.query)
|
|
489
|
+
with span("graph") as handle:
|
|
490
|
+
result = get_graph_store().query(
|
|
491
|
+
session,
|
|
492
|
+
organization_id=auth.organization_id,
|
|
493
|
+
workspace_id=workspace_id,
|
|
494
|
+
query=data.query,
|
|
495
|
+
temporal_as_of=data.temporal_as_of,
|
|
496
|
+
limit=data.limit,
|
|
497
|
+
sensitivities=auth.allowed_sensitivities(),
|
|
498
|
+
)
|
|
499
|
+
handle.set(
|
|
500
|
+
candidates_total=len(result),
|
|
501
|
+
candidates=cap_candidates(
|
|
502
|
+
[
|
|
503
|
+
{"id": str(r.id), "kind": "relationship", "score": r.confidence, "rank": i}
|
|
504
|
+
for i, r in enumerate(result, start=1)
|
|
505
|
+
]
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
_note(hits=len(result))
|
|
509
|
+
audit(session, auth, "graph.queried", detail={"result_count": len(result)})
|
|
510
|
+
session.commit()
|
|
511
|
+
return result
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@app.get(
|
|
515
|
+
"/v1/graph/entities/{entity_id}/subgraph",
|
|
516
|
+
response_model=list[RelationshipRead],
|
|
517
|
+
tags=["graph"],
|
|
518
|
+
)
|
|
519
|
+
def get_subgraph(
|
|
520
|
+
entity_id: UUID,
|
|
521
|
+
query: SubgraphQuery = Depends(),
|
|
522
|
+
auth: AuthContext = Depends(authenticate),
|
|
523
|
+
session: Session = Depends(get_session),
|
|
524
|
+
) -> list[RelationshipRead]:
|
|
525
|
+
auth.require("graph.query")
|
|
526
|
+
graph = get_graph_store()
|
|
527
|
+
entity = graph.get_entity(session, entity_id=entity_id, organization_id=auth.organization_id)
|
|
528
|
+
if entity is None or not covers(auth.workspace_id, entity.workspace_id):
|
|
529
|
+
raise HTTPException(status_code=404, detail="Entity not found")
|
|
530
|
+
return graph.subgraph(
|
|
531
|
+
session,
|
|
532
|
+
organization_id=auth.organization_id,
|
|
533
|
+
workspace_id=auth.workspace_id,
|
|
534
|
+
entity_id=entity.id,
|
|
535
|
+
hops=query.hops,
|
|
536
|
+
temporal_as_of=query.temporal_as_of,
|
|
537
|
+
limit=query.limit,
|
|
538
|
+
sensitivities=auth.allowed_sensitivities(),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@app.post(
|
|
543
|
+
"/v1/working-memory",
|
|
544
|
+
response_model=WorkingMemoryCreateRead,
|
|
545
|
+
status_code=201,
|
|
546
|
+
tags=["working-memory"],
|
|
547
|
+
)
|
|
548
|
+
def post_working_memory(
|
|
549
|
+
data: WorkingMemoryCreate,
|
|
550
|
+
auth: AuthContext = Depends(authenticate),
|
|
551
|
+
session: Session = Depends(get_session),
|
|
552
|
+
): # type: ignore[no-untyped-def]
|
|
553
|
+
working, lock_result = create_working_memory(session, auth, data)
|
|
554
|
+
lock_read = None
|
|
555
|
+
if lock_result is not None:
|
|
556
|
+
lock, token = lock_result
|
|
557
|
+
lock_read = LockRead(
|
|
558
|
+
id=lock.id,
|
|
559
|
+
resource_type=lock.resource_type,
|
|
560
|
+
resource_ref=lock.resource_ref,
|
|
561
|
+
fence=lock.fence,
|
|
562
|
+
expires_at=lock.expires_at,
|
|
563
|
+
token=token,
|
|
564
|
+
)
|
|
565
|
+
return WorkingMemoryCreateRead(
|
|
566
|
+
**WorkingMemoryRead.model_validate(working).model_dump(), lock=lock_read
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@app.patch(
|
|
571
|
+
"/v1/working-memory/{working_id}",
|
|
572
|
+
response_model=WorkingMemoryRead,
|
|
573
|
+
tags=["working-memory"],
|
|
574
|
+
)
|
|
575
|
+
def patch_working_memory(
|
|
576
|
+
working_id: UUID,
|
|
577
|
+
data: WorkingMemoryUpdate,
|
|
578
|
+
auth: AuthContext = Depends(authenticate),
|
|
579
|
+
session: Session = Depends(get_session),
|
|
580
|
+
): # type: ignore[no-untyped-def]
|
|
581
|
+
return update_working_memory(session, auth, working_id, data)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@app.get(
|
|
585
|
+
"/v1/working-memory/{working_id}",
|
|
586
|
+
response_model=WorkingMemoryRead,
|
|
587
|
+
tags=["working-memory"],
|
|
588
|
+
)
|
|
589
|
+
def read_working_memory(
|
|
590
|
+
working_id: UUID,
|
|
591
|
+
auth: AuthContext = Depends(authenticate),
|
|
592
|
+
session: Session = Depends(get_session),
|
|
593
|
+
): # type: ignore[no-untyped-def]
|
|
594
|
+
if "working_memory.read" not in auth.capabilities:
|
|
595
|
+
auth.require("working_memory.write")
|
|
596
|
+
return get_working_memory(session, auth, working_id)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@app.post(
|
|
600
|
+
"/v1/working-memory/{working_id}/complete",
|
|
601
|
+
response_model=WorkingMemoryRead,
|
|
602
|
+
tags=["working-memory"],
|
|
603
|
+
)
|
|
604
|
+
def complete_working_memory(
|
|
605
|
+
working_id: UUID,
|
|
606
|
+
data: WorkingMemoryFinish,
|
|
607
|
+
auth: AuthContext = Depends(authenticate),
|
|
608
|
+
session: Session = Depends(get_session),
|
|
609
|
+
): # type: ignore[no-untyped-def]
|
|
610
|
+
return finish_working_memory(session, auth, working_id, data, failed=False)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@app.post(
|
|
614
|
+
"/v1/working-memory/{working_id}/fail",
|
|
615
|
+
response_model=WorkingMemoryRead,
|
|
616
|
+
tags=["working-memory"],
|
|
617
|
+
)
|
|
618
|
+
def fail_working_memory(
|
|
619
|
+
working_id: UUID,
|
|
620
|
+
data: WorkingMemoryFinish,
|
|
621
|
+
auth: AuthContext = Depends(authenticate),
|
|
622
|
+
session: Session = Depends(get_session),
|
|
623
|
+
): # type: ignore[no-untyped-def]
|
|
624
|
+
return finish_working_memory(session, auth, working_id, data, failed=True)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@app.post("/v1/locks/acquire", response_model=LockRead, status_code=201, tags=["locks"])
|
|
628
|
+
def post_lock(
|
|
629
|
+
data: LockAcquire,
|
|
630
|
+
auth: AuthContext = Depends(authenticate),
|
|
631
|
+
session: Session = Depends(get_session),
|
|
632
|
+
) -> LockRead:
|
|
633
|
+
lock, token = acquire_lock(session, auth, data)
|
|
634
|
+
return LockRead(
|
|
635
|
+
id=lock.id,
|
|
636
|
+
resource_type=lock.resource_type,
|
|
637
|
+
resource_ref=lock.resource_ref,
|
|
638
|
+
fence=lock.fence,
|
|
639
|
+
expires_at=lock.expires_at,
|
|
640
|
+
token=token,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@app.get("/v1/export", response_model=ExportBundle, tags=["portability"])
|
|
645
|
+
def get_export(
|
|
646
|
+
workspace_id: UUID | None = None,
|
|
647
|
+
auth: AuthContext = Depends(authenticate),
|
|
648
|
+
session: Session = Depends(get_session),
|
|
649
|
+
) -> ExportBundle:
|
|
650
|
+
return export_bundle(session, auth, workspace_id)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@app.post("/v1/import", response_model=ImportResult, tags=["portability"])
|
|
654
|
+
def post_import(
|
|
655
|
+
data: ImportRequest,
|
|
656
|
+
auth: AuthContext = Depends(authenticate),
|
|
657
|
+
session: Session = Depends(get_session),
|
|
658
|
+
) -> ImportResult:
|
|
659
|
+
return import_bundle(session, auth, data)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@app.post("/v1/locks/{lock_id}/renew", response_model=LockRead, tags=["locks"])
|
|
663
|
+
def post_lock_renew(
|
|
664
|
+
lock_id: UUID,
|
|
665
|
+
data: LockRenew,
|
|
666
|
+
auth: AuthContext = Depends(authenticate),
|
|
667
|
+
session: Session = Depends(get_session),
|
|
668
|
+
) -> LockRead:
|
|
669
|
+
lock = renew_lock(
|
|
670
|
+
session,
|
|
671
|
+
auth,
|
|
672
|
+
lock_id,
|
|
673
|
+
token=data.token,
|
|
674
|
+
fence=data.fence,
|
|
675
|
+
ttl_seconds=data.ttl_seconds,
|
|
676
|
+
)
|
|
677
|
+
return LockRead(
|
|
678
|
+
id=lock.id,
|
|
679
|
+
resource_type=lock.resource_type,
|
|
680
|
+
resource_ref=lock.resource_ref,
|
|
681
|
+
fence=lock.fence,
|
|
682
|
+
expires_at=lock.expires_at,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@app.post("/v1/locks/{lock_id}/release", status_code=204, tags=["locks"])
|
|
687
|
+
def post_lock_release(
|
|
688
|
+
lock_id: UUID,
|
|
689
|
+
data: LockRelease,
|
|
690
|
+
auth: AuthContext = Depends(authenticate),
|
|
691
|
+
session: Session = Depends(get_session),
|
|
692
|
+
) -> Response:
|
|
693
|
+
release_lock(session, auth, lock_id, token=data.token, fence=data.fence)
|
|
694
|
+
return Response(status_code=204)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def run() -> None:
|
|
698
|
+
uvicorn.run("albert.api:app", host="0.0.0.0", port=8080)
|