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.
Files changed (74) hide show
  1. albert/__init__.py +3 -0
  2. albert/admin.py +532 -0
  3. albert/api.py +698 -0
  4. albert/automatic_memory_release.json +14 -0
  5. albert/benchmark.py +272 -0
  6. albert/benchmark_cli.py +37 -0
  7. albert/capture.py +1051 -0
  8. albert/capture_installer.py +1000 -0
  9. albert/capture_promotion.py +298 -0
  10. albert/capture_retention.py +72 -0
  11. albert/capture_sdk.py +558 -0
  12. albert/capture_security.py +79 -0
  13. albert/chunking.py +79 -0
  14. albert/classifier.py +263 -0
  15. albert/claude_adapter.py +357 -0
  16. albert/claude_lifecycle.py +418 -0
  17. albert/claude_package.json +19 -0
  18. albert/claude_plugin/.claude-plugin/plugin.json +11 -0
  19. albert/claude_plugin/.mcp.json +13 -0
  20. albert/claude_plugin/hooks/hooks.json +70 -0
  21. albert/clusters.py +140 -0
  22. albert/codex_adapter.py +308 -0
  23. albert/codex_hooks/hooks.json +49 -0
  24. albert/codex_lifecycle.py +485 -0
  25. albert/codex_package.json +19 -0
  26. albert/compatibility.py +121 -0
  27. albert/config.py +144 -0
  28. albert/console.py +508 -0
  29. albert/console_backends.py +236 -0
  30. albert/console_map.py +518 -0
  31. albert/console_ops.py +919 -0
  32. albert/credentials.py +616 -0
  33. albert/cursor_adapter.py +320 -0
  34. albert/cursor_hooks/hooks.json +11 -0
  35. albert/cursor_rules/albert-memory.mdc +11 -0
  36. albert/db.py +68 -0
  37. albert/embeddings.py +91 -0
  38. albert/graph.py +623 -0
  39. albert/graph_builders.py +879 -0
  40. albert/graph_cypher.py +669 -0
  41. albert/graph_store.py +376 -0
  42. albert/host_targets/claude-code.json +45 -0
  43. albert/host_targets/codex.json +45 -0
  44. albert/host_targets/openclaw.json +45 -0
  45. albert/host_targets.py +271 -0
  46. albert/links.py +428 -0
  47. albert/mcp_server.py +457 -0
  48. albert/memory_safety.py +111 -0
  49. albert/models.py +611 -0
  50. albert/openclaw_adapter.py +326 -0
  51. albert/openclaw_adapter_trust.json +5 -0
  52. albert/openclaw_package.json +22 -0
  53. albert/openclaw_plugin/index.js +135 -0
  54. albert/openclaw_plugin/openclaw.plugin.json +25 -0
  55. albert/openclaw_plugin/package.json +12 -0
  56. albert/portability.py +291 -0
  57. albert/provenance.py +105 -0
  58. albert/recorder.py +166 -0
  59. albert/release_gate.py +83 -0
  60. albert/schemas.py +617 -0
  61. albert/scope.py +26 -0
  62. albert/search.py +447 -0
  63. albert/security.py +150 -0
  64. albert/services.py +1316 -0
  65. albert/session_context.py +162 -0
  66. albert/trace_feed.py +165 -0
  67. albert/trace_writer.py +131 -0
  68. albert/tracing.py +75 -0
  69. albert/worker.py +202 -0
  70. albert/workspaces.py +256 -0
  71. albert_memory-0.4.0.dist-info/METADATA +554 -0
  72. albert_memory-0.4.0.dist-info/RECORD +74 -0
  73. albert_memory-0.4.0.dist-info/WHEEL +4 -0
  74. 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)