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/__init__.py
ADDED
albert/admin.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any, NoReturn
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from sqlalchemy import Engine, inspect, or_, select, text
|
|
11
|
+
|
|
12
|
+
from albert import __version__
|
|
13
|
+
from albert.db import SessionLocal, engine, initialize_database
|
|
14
|
+
from albert.embeddings import get_embedder
|
|
15
|
+
from albert.models import APIKey, Job, Memory, Organization, Principal, Workspace
|
|
16
|
+
from albert.security import ALL_CAPABILITIES, KNOWN_CAPABILITIES, issue_api_key
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Albert administrative commands", no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
# Version of the machine-readable `--json` output. Changes within a version are
|
|
21
|
+
# additive only; see contracts/admin-v1.schema.json.
|
|
22
|
+
ADMIN_CONTRACT_VERSION = "1"
|
|
23
|
+
JSON_COMMANDS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"contract",
|
|
26
|
+
"create-key",
|
|
27
|
+
"create-principal",
|
|
28
|
+
"create-workspace",
|
|
29
|
+
"list-keys",
|
|
30
|
+
"list-principals",
|
|
31
|
+
"revoke-key",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
JSON_OPTION = typer.Option(
|
|
35
|
+
False, "--json", help="Print one machine-readable JSON object (admin contract)"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _emit(payload: dict[str, Any]) -> None:
|
|
40
|
+
typer.echo(
|
|
41
|
+
json.dumps({"admin_contract": ADMIN_CONTRACT_VERSION, **payload}, separators=(",", ":"))
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fail(json_mode: bool, code: str, message: str) -> NoReturn:
|
|
46
|
+
"""Exit with code 2: a JSON error on stderr, or typer's usual BadParameter."""
|
|
47
|
+
if not json_mode:
|
|
48
|
+
raise typer.BadParameter(message)
|
|
49
|
+
error = {"admin_contract": ADMIN_CONTRACT_VERSION, "error": {"code": code, "message": message}}
|
|
50
|
+
typer.echo(json.dumps(error, separators=(",", ":")), err=True)
|
|
51
|
+
raise typer.Exit(code=2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_id(value: str, json_mode: bool) -> UUID:
|
|
55
|
+
try:
|
|
56
|
+
return UUID(value)
|
|
57
|
+
except ValueError:
|
|
58
|
+
if not json_mode:
|
|
59
|
+
raise # Text mode keeps its historical behavior: an uncaught ValueError.
|
|
60
|
+
_fail(json_mode, "invalid_id", f"Invalid id: {value!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _timestamp(value: datetime | None) -> str | None:
|
|
64
|
+
if value is None:
|
|
65
|
+
return None
|
|
66
|
+
if value.tzinfo is None: # SQLite drops the offset; stored values are UTC.
|
|
67
|
+
value = value.replace(tzinfo=UTC)
|
|
68
|
+
return value.astimezone(UTC).isoformat()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _workspace_json(workspace: Workspace) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"id": str(workspace.id),
|
|
74
|
+
"organization_id": str(workspace.organization_id),
|
|
75
|
+
"name": workspace.name,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _principal_json(principal: Principal) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"id": str(principal.id),
|
|
82
|
+
"organization_id": str(principal.organization_id),
|
|
83
|
+
"name": principal.name,
|
|
84
|
+
"principal_type": principal.principal_type,
|
|
85
|
+
"workspace_id": str(principal.workspace_id) if principal.workspace_id else None,
|
|
86
|
+
"active": principal.active,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _key_json(key: APIKey) -> dict[str, Any]:
|
|
91
|
+
# Never includes key_hash or any credential material.
|
|
92
|
+
return {
|
|
93
|
+
"id": str(key.id),
|
|
94
|
+
"principal_id": str(key.principal_id),
|
|
95
|
+
"name": key.name,
|
|
96
|
+
"prefix": key.prefix,
|
|
97
|
+
"capabilities": list(key.capabilities),
|
|
98
|
+
"active": key.active,
|
|
99
|
+
"expires_at": _timestamp(key.expires_at),
|
|
100
|
+
"created_at": _timestamp(key.created_at),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("compat")
|
|
105
|
+
def compat(
|
|
106
|
+
json_output: bool = typer.Option(
|
|
107
|
+
True, "--json", help="Print JSON (the only format; accepted for symmetry)"
|
|
108
|
+
),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Print this image's runtime compatibility manifest as compact JSON.
|
|
111
|
+
|
|
112
|
+
Needs no environment and no database. Consumers decide rollback with: a
|
|
113
|
+
Brain migrated by image T can run image O iff every revision in
|
|
114
|
+
T.migrations.heads is in O.migrations.known_revisions.
|
|
115
|
+
"""
|
|
116
|
+
from albert.compatibility import (
|
|
117
|
+
MigrationsUnavailable,
|
|
118
|
+
compatibility_error,
|
|
119
|
+
compatibility_manifest,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
manifest = compatibility_manifest()
|
|
124
|
+
except MigrationsUnavailable as exc:
|
|
125
|
+
error = compatibility_error("migrations_unavailable", str(exc))
|
|
126
|
+
sys.stderr.write(json.dumps(error, separators=(",", ":")) + "\n")
|
|
127
|
+
raise typer.Exit(code=2) from exc
|
|
128
|
+
sys.stdout.write(json.dumps(manifest, separators=(",", ":")) + "\n")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SchemaNotReady(RuntimeError):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def ensure_schema_ready(engine: Engine) -> None:
|
|
136
|
+
"""Refuse to run against a database Alembic has not built.
|
|
137
|
+
|
|
138
|
+
Creating tables here would leave no alembic_version row and make the next
|
|
139
|
+
`alembic upgrade head` fail on tables that already exist.
|
|
140
|
+
"""
|
|
141
|
+
if not inspect(engine).has_table("memories"):
|
|
142
|
+
raise SchemaNotReady(
|
|
143
|
+
"Database schema is missing; run `alembic upgrade head` before this command"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("init-db")
|
|
148
|
+
def init_db() -> None:
|
|
149
|
+
"""Create tables directly for throwaway development databases only.
|
|
150
|
+
|
|
151
|
+
Deployed databases must use `alembic upgrade head`; this command records no
|
|
152
|
+
migration version.
|
|
153
|
+
"""
|
|
154
|
+
initialize_database()
|
|
155
|
+
typer.echo("Database initialized (development only; no Alembic version recorded)")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def bootstrap(
|
|
160
|
+
organization: str = typer.Option(..., help="Initial organization name"),
|
|
161
|
+
workspace: str = typer.Option(..., help="Initial workspace name"),
|
|
162
|
+
principal: str = typer.Option("Administrator", help="Administrator display name"),
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Create the first tenant and print its administrator key exactly once."""
|
|
165
|
+
try:
|
|
166
|
+
ensure_schema_ready(engine)
|
|
167
|
+
except SchemaNotReady as exc:
|
|
168
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
169
|
+
with SessionLocal() as session:
|
|
170
|
+
org = session.scalar(select(Organization).where(Organization.name == organization))
|
|
171
|
+
if org is None:
|
|
172
|
+
org = Organization(name=organization)
|
|
173
|
+
session.add(org)
|
|
174
|
+
session.flush()
|
|
175
|
+
work = session.scalar(
|
|
176
|
+
select(Workspace).where(
|
|
177
|
+
Workspace.organization_id == org.id, Workspace.name == workspace
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
if work is None:
|
|
181
|
+
work = Workspace(organization_id=org.id, name=workspace)
|
|
182
|
+
session.add(work)
|
|
183
|
+
session.flush()
|
|
184
|
+
actor = session.scalar(
|
|
185
|
+
select(Principal).where(
|
|
186
|
+
Principal.organization_id == org.id,
|
|
187
|
+
Principal.workspace_id == work.id,
|
|
188
|
+
Principal.name == principal,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
if actor is None:
|
|
192
|
+
actor = Principal(
|
|
193
|
+
organization_id=org.id,
|
|
194
|
+
workspace_id=work.id,
|
|
195
|
+
name=principal,
|
|
196
|
+
principal_type="human",
|
|
197
|
+
)
|
|
198
|
+
session.add(actor)
|
|
199
|
+
session.flush()
|
|
200
|
+
raw, prefix, digest = issue_api_key()
|
|
201
|
+
session.add(
|
|
202
|
+
APIKey(
|
|
203
|
+
principal_id=actor.id,
|
|
204
|
+
prefix=prefix,
|
|
205
|
+
key_hash=digest,
|
|
206
|
+
name="bootstrap administrator",
|
|
207
|
+
capabilities=ALL_CAPABILITIES,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
session.commit()
|
|
211
|
+
typer.echo("Bootstrap complete. Store this key securely; it will not be shown again:")
|
|
212
|
+
typer.echo(raw)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command("create-key")
|
|
216
|
+
def create_key(
|
|
217
|
+
principal_id: str = typer.Option(...),
|
|
218
|
+
name: str = typer.Option("agent key"),
|
|
219
|
+
capabilities: str = typer.Option("memory.read,memory.write,graph.query"),
|
|
220
|
+
json_mode: bool = JSON_OPTION,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Issue an additional API key for an existing principal."""
|
|
223
|
+
requested_capabilities = [item.strip() for item in capabilities.split(",") if item.strip()]
|
|
224
|
+
unknown = sorted(set(requested_capabilities) - KNOWN_CAPABILITIES)
|
|
225
|
+
if unknown:
|
|
226
|
+
_fail(json_mode, "unknown_capabilities", f"Unknown capabilities: {', '.join(unknown)}")
|
|
227
|
+
with SessionLocal() as session:
|
|
228
|
+
actor = session.get(Principal, _parse_id(principal_id, json_mode))
|
|
229
|
+
if actor is None:
|
|
230
|
+
_fail(json_mode, "principal_not_found", "Principal not found")
|
|
231
|
+
raw, prefix, digest = issue_api_key()
|
|
232
|
+
key = APIKey(
|
|
233
|
+
principal_id=actor.id,
|
|
234
|
+
prefix=prefix,
|
|
235
|
+
key_hash=digest,
|
|
236
|
+
name=name,
|
|
237
|
+
capabilities=requested_capabilities,
|
|
238
|
+
)
|
|
239
|
+
session.add(key)
|
|
240
|
+
session.commit()
|
|
241
|
+
if json_mode:
|
|
242
|
+
session.refresh(key)
|
|
243
|
+
_emit({"key": _key_json(key), "credential": raw})
|
|
244
|
+
return
|
|
245
|
+
typer.echo(raw)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.command("create-workspace")
|
|
249
|
+
def create_workspace(
|
|
250
|
+
organization: str = typer.Option(..., help="Organization name"),
|
|
251
|
+
name: str = typer.Option(..., help="Workspace name"),
|
|
252
|
+
json_mode: bool = JSON_OPTION,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Create a workspace in an existing organization."""
|
|
255
|
+
with SessionLocal() as session:
|
|
256
|
+
org = session.scalar(select(Organization).where(Organization.name == organization))
|
|
257
|
+
if org is None:
|
|
258
|
+
_fail(json_mode, "organization_not_found", "Organization not found")
|
|
259
|
+
existing = session.scalar(
|
|
260
|
+
select(Workspace).where(
|
|
261
|
+
Workspace.organization_id == org.id,
|
|
262
|
+
Workspace.name == name,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
if existing is not None:
|
|
266
|
+
if json_mode:
|
|
267
|
+
_emit({"workspace": _workspace_json(existing), "created": False})
|
|
268
|
+
else:
|
|
269
|
+
typer.echo(str(existing.id))
|
|
270
|
+
return
|
|
271
|
+
workspace = Workspace(organization_id=org.id, name=name)
|
|
272
|
+
session.add(workspace)
|
|
273
|
+
session.commit()
|
|
274
|
+
session.refresh(workspace)
|
|
275
|
+
if json_mode:
|
|
276
|
+
_emit({"workspace": _workspace_json(workspace), "created": True})
|
|
277
|
+
else:
|
|
278
|
+
typer.echo(str(workspace.id))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.command("create-principal")
|
|
282
|
+
def create_principal(
|
|
283
|
+
organization: str = typer.Option(..., help="Organization name"),
|
|
284
|
+
name: str = typer.Option(..., help="Principal display name"),
|
|
285
|
+
workspace: str | None = typer.Option(None, help="Optional workspace name"),
|
|
286
|
+
principal_type: str = typer.Option("agent", help="agent, human, or service"),
|
|
287
|
+
json_mode: bool = JSON_OPTION,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Create an organization- or workspace-scoped principal."""
|
|
290
|
+
if principal_type not in {"agent", "human", "service"}:
|
|
291
|
+
_fail(
|
|
292
|
+
json_mode,
|
|
293
|
+
"invalid_principal_type",
|
|
294
|
+
"principal-type must be agent, human, or service",
|
|
295
|
+
)
|
|
296
|
+
with SessionLocal() as session:
|
|
297
|
+
org = session.scalar(select(Organization).where(Organization.name == organization))
|
|
298
|
+
if org is None:
|
|
299
|
+
_fail(json_mode, "organization_not_found", "Organization not found")
|
|
300
|
+
workspace_id = None
|
|
301
|
+
if workspace is not None:
|
|
302
|
+
record = session.scalar(
|
|
303
|
+
select(Workspace).where(
|
|
304
|
+
Workspace.organization_id == org.id,
|
|
305
|
+
Workspace.name == workspace,
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
if record is None:
|
|
309
|
+
_fail(json_mode, "workspace_not_found", "Workspace not found")
|
|
310
|
+
workspace_id = record.id
|
|
311
|
+
principal = Principal(
|
|
312
|
+
organization_id=org.id,
|
|
313
|
+
workspace_id=workspace_id,
|
|
314
|
+
name=name,
|
|
315
|
+
principal_type=principal_type,
|
|
316
|
+
)
|
|
317
|
+
session.add(principal)
|
|
318
|
+
session.commit()
|
|
319
|
+
session.refresh(principal)
|
|
320
|
+
if json_mode:
|
|
321
|
+
_emit({"principal": _principal_json(principal)})
|
|
322
|
+
else:
|
|
323
|
+
typer.echo(str(principal.id))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@app.command("list-principals")
|
|
327
|
+
def list_principals(
|
|
328
|
+
organization: str = typer.Option(..., help="Organization name"),
|
|
329
|
+
json_mode: bool = JSON_OPTION,
|
|
330
|
+
) -> None:
|
|
331
|
+
"""List principals and their workspace scope."""
|
|
332
|
+
with SessionLocal() as session:
|
|
333
|
+
org = session.scalar(select(Organization).where(Organization.name == organization))
|
|
334
|
+
if org is None:
|
|
335
|
+
_fail(json_mode, "organization_not_found", "Organization not found")
|
|
336
|
+
principals = session.scalars(
|
|
337
|
+
select(Principal).where(Principal.organization_id == org.id).order_by(Principal.name)
|
|
338
|
+
)
|
|
339
|
+
if json_mode:
|
|
340
|
+
_emit(
|
|
341
|
+
{
|
|
342
|
+
"organization": {"id": str(org.id), "name": org.name},
|
|
343
|
+
"principals": [_principal_json(principal) for principal in principals],
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
return
|
|
347
|
+
for principal in principals:
|
|
348
|
+
typer.echo(
|
|
349
|
+
f"{principal.id}\t{principal.name}\t{principal.principal_type}\t"
|
|
350
|
+
f"workspace={principal.workspace_id or '*'}\tactive={principal.active}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@app.command("list-keys")
|
|
355
|
+
def list_keys(
|
|
356
|
+
principal_id: str = typer.Option(...),
|
|
357
|
+
json_mode: bool = JSON_OPTION,
|
|
358
|
+
) -> None:
|
|
359
|
+
"""List non-secret API-key metadata for one principal."""
|
|
360
|
+
with SessionLocal() as session:
|
|
361
|
+
actor = session.get(Principal, _parse_id(principal_id, json_mode))
|
|
362
|
+
if actor is None:
|
|
363
|
+
_fail(json_mode, "principal_not_found", "Principal not found")
|
|
364
|
+
keys = session.scalars(
|
|
365
|
+
select(APIKey).where(APIKey.principal_id == actor.id).order_by(APIKey.created_at)
|
|
366
|
+
)
|
|
367
|
+
if json_mode:
|
|
368
|
+
_emit({"principal_id": str(actor.id), "keys": [_key_json(key) for key in keys]})
|
|
369
|
+
return
|
|
370
|
+
for key in keys:
|
|
371
|
+
typer.echo(
|
|
372
|
+
f"{key.id}\t{key.name}\tprefix={key.prefix}\tactive={key.active}\t"
|
|
373
|
+
f"expires={key.expires_at or '-'}"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@app.command("revoke-key")
|
|
378
|
+
def revoke_key(
|
|
379
|
+
key_id: str = typer.Option(...),
|
|
380
|
+
json_mode: bool = JSON_OPTION,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Revoke an API key without deleting its audit identity."""
|
|
383
|
+
with SessionLocal() as session:
|
|
384
|
+
key = session.get(APIKey, _parse_id(key_id, json_mode))
|
|
385
|
+
if key is None:
|
|
386
|
+
_fail(json_mode, "api_key_not_found", "API key not found")
|
|
387
|
+
key.active = False
|
|
388
|
+
session.commit()
|
|
389
|
+
if json_mode:
|
|
390
|
+
_emit({"key": {"id": str(key.id), "active": key.active}})
|
|
391
|
+
return
|
|
392
|
+
typer.echo("API key revoked")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.command("contract")
|
|
396
|
+
def contract(json_mode: bool = JSON_OPTION) -> None:
|
|
397
|
+
"""Print the machine-readable admin contract version (always JSON)."""
|
|
398
|
+
_emit(
|
|
399
|
+
{
|
|
400
|
+
"package": "albert-memory",
|
|
401
|
+
"package_version": __version__,
|
|
402
|
+
"json_commands": sorted(JSON_COMMANDS),
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@app.command("validate-runtime")
|
|
408
|
+
def validate_runtime(
|
|
409
|
+
probe_embedding: bool = typer.Option(
|
|
410
|
+
False, "--probe-embedding", help="Load and execute the configured embedding provider"
|
|
411
|
+
),
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Validate configured providers and optionally execute one embedding."""
|
|
414
|
+
from albert.config import get_settings
|
|
415
|
+
from albert.graph_builders import get_graph_builder
|
|
416
|
+
from albert.graph_store import get_graph_store
|
|
417
|
+
|
|
418
|
+
settings = get_settings()
|
|
419
|
+
typer.echo(
|
|
420
|
+
f"embedding={settings.embedding_provider}:{settings.embedding_model} "
|
|
421
|
+
f"dimensions={settings.embedding_dimensions} classifier={settings.llm_provider}"
|
|
422
|
+
)
|
|
423
|
+
with SessionLocal() as session:
|
|
424
|
+
store_version = get_graph_store().ping(session)
|
|
425
|
+
typer.echo(f"graph store probe succeeded ({store_version})")
|
|
426
|
+
if session.bind is not None and session.bind.dialect.name == "postgresql":
|
|
427
|
+
vector_type = session.scalar(
|
|
428
|
+
text(
|
|
429
|
+
"SELECT format_type(attribute.atttypid, attribute.atttypmod) "
|
|
430
|
+
"FROM pg_attribute AS attribute "
|
|
431
|
+
"JOIN pg_class AS relation ON relation.oid = attribute.attrelid "
|
|
432
|
+
"WHERE relation.relname = 'memory_chunks' "
|
|
433
|
+
"AND attribute.attname = 'embedding' "
|
|
434
|
+
"AND NOT attribute.attisdropped"
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
expected = f"vector({settings.embedding_dimensions})"
|
|
438
|
+
if vector_type != expected:
|
|
439
|
+
typer.echo(
|
|
440
|
+
f"database embedding type is {vector_type!r}; expected {expected!r}",
|
|
441
|
+
err=True,
|
|
442
|
+
)
|
|
443
|
+
raise typer.Exit(code=1)
|
|
444
|
+
hnsw_exists = session.scalar(
|
|
445
|
+
text(
|
|
446
|
+
"SELECT EXISTS (SELECT 1 FROM pg_indexes "
|
|
447
|
+
"WHERE indexname = 'ix_memory_chunks_embedding_hnsw')"
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
if not hnsw_exists:
|
|
451
|
+
typer.echo("database HNSW embedding index is missing", err=True)
|
|
452
|
+
raise typer.Exit(code=1)
|
|
453
|
+
typer.echo("database vector schema and HNSW index are valid")
|
|
454
|
+
if probe_embedding:
|
|
455
|
+
try:
|
|
456
|
+
embedder = get_embedder()
|
|
457
|
+
vector = embedder.embed("Albert runtime readiness probe")
|
|
458
|
+
except Exception as exc:
|
|
459
|
+
typer.echo(f"embedding probe failed ({type(exc).__name__})", err=True)
|
|
460
|
+
raise typer.Exit(code=1) from exc
|
|
461
|
+
if len(vector) != settings.embedding_dimensions:
|
|
462
|
+
raise typer.Exit(code=1)
|
|
463
|
+
typer.echo(f"embedding probe succeeded ({len(vector)} dimensions)")
|
|
464
|
+
if settings.graph_builder == "graphiti":
|
|
465
|
+
try:
|
|
466
|
+
builder_version = get_graph_builder().readiness()
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
typer.echo(f"graph builder probe failed ({type(exc).__name__})", err=True)
|
|
469
|
+
raise typer.Exit(code=1) from exc
|
|
470
|
+
typer.echo(f"graph builder probe succeeded (graphiti-core {builder_version})")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@app.command("reindex-memories")
|
|
474
|
+
def reindex_memories(
|
|
475
|
+
all_memories: bool = typer.Option(
|
|
476
|
+
False, "--all", help="Re-enrich every active memory, including current embeddings"
|
|
477
|
+
),
|
|
478
|
+
workspace: str | None = typer.Option(
|
|
479
|
+
None, help="Limit to workspaces with this name (in every organization)"
|
|
480
|
+
),
|
|
481
|
+
) -> None:
|
|
482
|
+
"""Queue active memories whose chunks are absent or embedded by another model."""
|
|
483
|
+
embedder = get_embedder()
|
|
484
|
+
with SessionLocal() as session:
|
|
485
|
+
statement = select(Memory).where(Memory.status == "active")
|
|
486
|
+
if not all_memories:
|
|
487
|
+
statement = statement.where(
|
|
488
|
+
or_(Memory.embedding_model.is_(None), Memory.embedding_model != embedder.name)
|
|
489
|
+
)
|
|
490
|
+
if workspace is not None:
|
|
491
|
+
workspace_ids = list(
|
|
492
|
+
session.scalars(select(Workspace.id).where(Workspace.name == workspace))
|
|
493
|
+
)
|
|
494
|
+
if not workspace_ids:
|
|
495
|
+
raise typer.BadParameter("Workspace not found")
|
|
496
|
+
statement = statement.where(Memory.workspace_id.in_(workspace_ids))
|
|
497
|
+
selected = list(session.scalars(statement))
|
|
498
|
+
for memory in selected:
|
|
499
|
+
session.add(
|
|
500
|
+
Job(
|
|
501
|
+
organization_id=memory.organization_id,
|
|
502
|
+
job_type="enrich_memory",
|
|
503
|
+
payload={"memory_id": str(memory.id)},
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
session.commit()
|
|
507
|
+
typer.echo(f"queued {len(selected)} memories for enrichment with {embedder.name}")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@app.command("rebuild-links")
|
|
511
|
+
def rebuild_links_command(
|
|
512
|
+
organization: str | None = typer.Option(None, help="Limit to one organization by name"),
|
|
513
|
+
) -> None:
|
|
514
|
+
"""Recompute the memory map's similarity and sequence links from stored chunks."""
|
|
515
|
+
from albert.links import rebuild_links
|
|
516
|
+
|
|
517
|
+
with SessionLocal() as session:
|
|
518
|
+
organization_id = None
|
|
519
|
+
if organization is not None:
|
|
520
|
+
org = session.scalar(select(Organization).where(Organization.name == organization))
|
|
521
|
+
if org is None:
|
|
522
|
+
raise typer.BadParameter("Organization not found")
|
|
523
|
+
organization_id = org.id
|
|
524
|
+
result = rebuild_links(session, organization_id)
|
|
525
|
+
session.commit()
|
|
526
|
+
typer.echo(
|
|
527
|
+
f"memories={result['memories']} similar={result['similar']} sequence={result['sequence']}"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
app()
|