orkmind 0.1.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 (53) hide show
  1. orkmind/__init__.py +3 -0
  2. orkmind/api/__init__.py +5 -0
  3. orkmind/api/app.py +324 -0
  4. orkmind/cli/__init__.py +1 -0
  5. orkmind/cli/main.py +788 -0
  6. orkmind/core/__init__.py +1 -0
  7. orkmind/core/config.py +183 -0
  8. orkmind/core/conflict.py +105 -0
  9. orkmind/core/dag.py +136 -0
  10. orkmind/core/detectors.py +158 -0
  11. orkmind/core/encryption.py +203 -0
  12. orkmind/core/extraction.py +139 -0
  13. orkmind/core/injection.py +156 -0
  14. orkmind/core/layers.py +113 -0
  15. orkmind/core/models.py +170 -0
  16. orkmind/core/ontology.py +175 -0
  17. orkmind/core/search.py +62 -0
  18. orkmind/core/semantic_layer.py +544 -0
  19. orkmind/core/snapshots.py +34 -0
  20. orkmind/embeddings/__init__.py +13 -0
  21. orkmind/embeddings/backfill.py +152 -0
  22. orkmind/embeddings/provider.py +123 -0
  23. orkmind/guardrails/__init__.py +76 -0
  24. orkmind/guardrails/engine.py +256 -0
  25. orkmind/guardrails/handoff.py +402 -0
  26. orkmind/guardrails/models.py +87 -0
  27. orkmind/guardrails/rules.py +182 -0
  28. orkmind/guardrails/session.py +132 -0
  29. orkmind/hermes/__init__.py +1 -0
  30. orkmind/hermes/provider.py +251 -0
  31. orkmind/mcp/__init__.py +1 -0
  32. orkmind/mcp/__main__.py +8 -0
  33. orkmind/mcp/server.py +334 -0
  34. orkmind/mcp/tools.py +203 -0
  35. orkmind/py.typed +0 -0
  36. orkmind/spool.py +331 -0
  37. orkmind/store/__init__.py +1 -0
  38. orkmind/store/base.py +231 -0
  39. orkmind/store/capabilities.py +91 -0
  40. orkmind/store/errors.py +25 -0
  41. orkmind/store/factory.py +121 -0
  42. orkmind/store/governance.py +228 -0
  43. orkmind/store/governed.py +471 -0
  44. orkmind/store/memory_adapter.py +515 -0
  45. orkmind/store/postgres_adapter.py +1153 -0
  46. orkmind/store/profiles.py +266 -0
  47. orkmind/store/qdrant_adapter.py +861 -0
  48. orkmind/store/transfer.py +534 -0
  49. orkmind-0.1.0.dist-info/METADATA +327 -0
  50. orkmind-0.1.0.dist-info/RECORD +53 -0
  51. orkmind-0.1.0.dist-info/WHEEL +4 -0
  52. orkmind-0.1.0.dist-info/entry_points.txt +2 -0
  53. orkmind-0.1.0.dist-info/licenses/LICENSE +190 -0
orkmind/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """OrkMind -- Semantic memory layer for AI agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """API HTTP local e idempotente do OrkMind."""
2
+
3
+ from orkmind.api.app import create_app
4
+
5
+ __all__ = ["create_app"]
orkmind/api/app.py ADDED
@@ -0,0 +1,324 @@
1
+ """API HTTP local do OrkMind: ponto unico de escrita idempotente.
2
+
3
+ Esta API existe para sustentar a solucao "sempre gravar". O drainer da
4
+ spool (scripts/orkmind_drain.py) grava preferencialmente por aqui, e cai
5
+ para o CLI apenas se a API estiver fora.
6
+
7
+ Endpoints:
8
+
9
+ GET /health vivacidade
10
+ GET /entries/by-hash?collection=&hash= lookup de idempotencia
11
+ POST /entries escrita idempotente
12
+
13
+ Contrato de idempotencia do POST /entries: a resposta sempre traz
14
+ ``{entry_id, content_hash, status}``, com ``status`` em ``created`` ou
15
+ ``duplicate``. O cliente pode repetir a mesma requisicao a vontade sem
16
+ gerar memoria duplicada, o que e exatamente o que permite ao drainer
17
+ reprocessar a fila sem medo.
18
+
19
+ Postura de seguranca (a API escuta em loopback, mas escrita nunca fica
20
+ aberta):
21
+
22
+ - Autenticacao obrigatoria por token Bearer (env ORKMIND_API_TOKEN).
23
+ Sem token configurado a API sobe em modo somente-leitura-negada: toda
24
+ rota protegida responde 503, nunca "aberta por omissao".
25
+ - Comparacao de token em tempo constante (hmac.compare_digest).
26
+ - Simetria de hash: se o cliente enviar content_hash, o servidor
27
+ RECOMPUTA o SHA-256 do conteudo e rejeita divergencia com 400. Sem
28
+ isso um cliente poderia registrar conteudo sob o hash de outro e
29
+ envenenar a chave de deduplicacao.
30
+ - O lookup por hash devolve apenas metadados (entry_id, colecao,
31
+ timestamps). Nunca o conteudo, para que o endpoint nao vire um oraculo
32
+ de confirmacao de conteudo alheio.
33
+ - mandatory=true e priority=critical sao recusados quando source=agent,
34
+ espelhando a regra de que governanca so vem de humano autenticado.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import contextlib
40
+ import hmac
41
+ import json
42
+ import logging
43
+ import os
44
+ from typing import Any, AsyncIterator, Optional
45
+
46
+ from starlette.applications import Starlette
47
+ from starlette.requests import Request
48
+ from starlette.responses import JSONResponse
49
+ from starlette.routing import Route
50
+
51
+ from orkmind.core.config import OrkMindConfig, load_config
52
+ from orkmind.core.injection import compute_content_hash
53
+ from orkmind.core.models import VALID_COLLECTIONS, MemoryEntry
54
+ from orkmind.core.semantic_layer import SemanticLayer
55
+ from orkmind.store.factory import create_embedder, create_store
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ #: Env com o token Bearer exigido nas rotas protegidas.
60
+ API_TOKEN_ENV = "ORKMIND_API_TOKEN"
61
+
62
+ _VALID_PRIORITIES = ("critical", "high", "medium", "low")
63
+ _VALID_SOURCES = ("human", "agent", "system", "bootstrap")
64
+ _VALID_SCOPES = ("global", "project", "session")
65
+
66
+
67
+ def _error(message: str, status: int, **extra: Any) -> JSONResponse:
68
+ payload: dict[str, Any] = {"error": message}
69
+ payload.update(extra)
70
+ return JSONResponse(payload, status_code=status)
71
+
72
+
73
+ def _expected_token() -> str:
74
+ return os.environ.get(API_TOKEN_ENV, "").strip()
75
+
76
+
77
+ def _check_auth(request: Request) -> Optional[JSONResponse]:
78
+ """Valida o Bearer token. Devolve a resposta de erro, ou None se ok."""
79
+ expected = _expected_token()
80
+ if not expected:
81
+ # Falha fechada de proposito: sem token configurado nao ha
82
+ # escrita nem lookup, em vez de expor a API sem autenticacao.
83
+ return _error(
84
+ f"API sem token configurado. Defina {API_TOKEN_ENV} no ambiente "
85
+ f"do servico para habilitar as rotas protegidas.",
86
+ 503,
87
+ )
88
+
89
+ header = request.headers.get("authorization", "")
90
+ scheme, _, presented = header.partition(" ")
91
+ if scheme.lower() != "bearer" or not presented:
92
+ return _error("Credencial ausente. Use 'Authorization: Bearer <token>'.", 401)
93
+
94
+ if not hmac.compare_digest(presented.strip(), expected):
95
+ return _error("Credencial invalida.", 401)
96
+
97
+ return None
98
+
99
+
100
+ async def health(request: Request) -> JSONResponse:
101
+ """Vivacidade. Nao exige token e nao revela nada do acervo."""
102
+ return JSONResponse({"status": "ok", "service": "orkmind-api"})
103
+
104
+
105
+ async def lookup_by_hash(request: Request) -> JSONResponse:
106
+ """GET /entries/by-hash?collection=&hash= - lookup de idempotencia.
107
+
108
+ Responde apenas com metadados. O conteudo nunca e devolvido aqui.
109
+ """
110
+ if (denied := _check_auth(request)) is not None:
111
+ return denied
112
+
113
+ content_hash = (request.query_params.get("hash") or "").strip()
114
+ collection = (request.query_params.get("collection") or "").strip() or None
115
+
116
+ if not content_hash:
117
+ return _error("Parametro 'hash' e obrigatorio.", 400)
118
+ if collection and collection not in VALID_COLLECTIONS:
119
+ return _error(f"Colecao invalida: '{collection}'.", 400)
120
+
121
+ layer: SemanticLayer = request.app.state.layer
122
+ existing = await layer.store.find_by_content_hash(content_hash, collection)
123
+
124
+ if existing is None:
125
+ return JSONResponse(
126
+ {"status": "not_found", "content_hash": content_hash, "collection": collection},
127
+ status_code=404,
128
+ )
129
+
130
+ return JSONResponse(
131
+ {
132
+ "status": "found",
133
+ "entry_id": existing.id,
134
+ "content_hash": existing.content_hash,
135
+ "collection": existing.collection,
136
+ "created_at": existing.created_at.isoformat(),
137
+ "version": existing.version,
138
+ }
139
+ )
140
+
141
+
142
+ async def create_entry(request: Request) -> JSONResponse:
143
+ """POST /entries - escrita idempotente por content_hash."""
144
+ if (denied := _check_auth(request)) is not None:
145
+ return denied
146
+
147
+ try:
148
+ body = await request.json()
149
+ except (json.JSONDecodeError, ValueError):
150
+ return _error("Corpo da requisicao nao e JSON valido.", 400)
151
+ if not isinstance(body, dict):
152
+ return _error("Corpo da requisicao deve ser um objeto JSON.", 400)
153
+
154
+ content = body.get("content") or ""
155
+ if not isinstance(content, str) or not content.strip():
156
+ return _error("Campo 'content' e obrigatorio e nao pode ser vazio.", 400)
157
+
158
+ collection = body.get("collection") or "content"
159
+ if collection not in VALID_COLLECTIONS:
160
+ return _error(
161
+ f"Colecao invalida: '{collection}'. "
162
+ f"Validas: {', '.join(VALID_COLLECTIONS)}",
163
+ 400,
164
+ )
165
+
166
+ tags = body.get("tags") or {}
167
+ if not isinstance(tags, dict) or not all(
168
+ isinstance(v, list) and all(isinstance(i, str) for i in v) for v in tags.values()
169
+ ):
170
+ return _error("Campo 'tags' deve ser um objeto de listas de strings.", 400)
171
+
172
+ priority = body.get("priority") or "medium"
173
+ if priority not in _VALID_PRIORITIES:
174
+ return _error(f"Prioridade invalida: '{priority}'.", 400)
175
+
176
+ source = body.get("source") or "agent"
177
+ if source not in _VALID_SOURCES:
178
+ return _error(f"Source invalido: '{source}'.", 400)
179
+
180
+ scope = body.get("scope") or "global"
181
+ if scope not in _VALID_SCOPES:
182
+ return _error(f"Scope invalido: '{scope}'.", 400)
183
+
184
+ mandatory = bool(body.get("mandatory", False))
185
+
186
+ # Governanca so vem de humano autenticado. Um agente (ou o drainer
187
+ # drenando conteudo de agente) nunca cria regra mandatoria/critica.
188
+ if source == "agent" and (mandatory or priority == "critical"):
189
+ return _error(
190
+ "source='agent' nao pode criar memoria mandatory=true nem "
191
+ "priority='critical'. Governanca exige humano autenticado.",
192
+ 403,
193
+ )
194
+
195
+ # Simetria de hash: o servidor e a autoridade sobre o hash. Se o
196
+ # cliente mandou um, tem que bater com o conteudo enviado.
197
+ computed_hash = compute_content_hash(content)
198
+ declared_hash = (body.get("content_hash") or "").strip()
199
+ if declared_hash and not hmac.compare_digest(declared_hash, computed_hash):
200
+ return _error(
201
+ "content_hash divergente do conteudo enviado.",
202
+ 400,
203
+ expected=computed_hash,
204
+ received=declared_hash,
205
+ )
206
+
207
+ layer: SemanticLayer = request.app.state.layer
208
+
209
+ # 1. Caminho rapido de idempotencia: ja existe com este hash?
210
+ existing = await layer.store.find_by_content_hash(computed_hash, collection)
211
+ if existing is not None:
212
+ return JSONResponse(
213
+ {
214
+ "status": "duplicate",
215
+ "entry_id": existing.id,
216
+ "content_hash": computed_hash,
217
+ "collection": existing.collection,
218
+ }
219
+ )
220
+
221
+ entry = MemoryEntry(
222
+ content=content,
223
+ collection=collection, # type: ignore[arg-type]
224
+ tags=tags,
225
+ priority=priority, # type: ignore[arg-type]
226
+ mandatory=mandatory,
227
+ scope=scope, # type: ignore[arg-type]
228
+ source=source, # type: ignore[arg-type]
229
+ content_hash=computed_hash,
230
+ metadata=body.get("metadata") or {},
231
+ author_id=body.get("author_id"),
232
+ url=body.get("url"),
233
+ )
234
+
235
+ try:
236
+ entry_id, warnings = await layer.add_memory(entry)
237
+ except Exception as exc: # noqa: BLE001
238
+ # 2. Corrida entre dois gravadores: o indice unico parcial
239
+ # (collection, content_hash) barra o segundo INSERT. Isso nao
240
+ # e erro, e a idempotencia dura funcionando - reconsultamos e
241
+ # devolvemos o entry_id real de quem chegou primeiro.
242
+ duplicate = await layer.store.find_by_content_hash(computed_hash, collection)
243
+ if duplicate is not None:
244
+ return JSONResponse(
245
+ {
246
+ "status": "duplicate",
247
+ "entry_id": duplicate.id,
248
+ "content_hash": computed_hash,
249
+ "collection": duplicate.collection,
250
+ }
251
+ )
252
+ logger.exception("Falha ao gravar entry na colecao %s", collection)
253
+ return _error(f"Falha ao gravar entry: {exc}", 500)
254
+
255
+ return JSONResponse(
256
+ {
257
+ "status": "created",
258
+ "entry_id": entry_id,
259
+ "content_hash": computed_hash,
260
+ "collection": collection,
261
+ "warnings": warnings,
262
+ },
263
+ status_code=201,
264
+ )
265
+
266
+
267
+ def create_app(
268
+ config: Optional[OrkMindConfig] = None,
269
+ layer: Optional[SemanticLayer] = None,
270
+ ) -> Starlette:
271
+ """Monta a aplicacao Starlette da API local do OrkMind.
272
+
273
+ `layer` permite injetar uma SemanticLayer pronta (usado nos testes),
274
+ caso em que nenhuma conexao nova de banco e aberta no startup.
275
+ """
276
+ cfg = config if config is not None else (OrkMindConfig() if layer else load_config())
277
+
278
+ @contextlib.asynccontextmanager
279
+ async def _lifespan(app: Starlette) -> AsyncIterator[None]:
280
+ if layer is not None:
281
+ app.state.layer = layer
282
+ else:
283
+ store = create_store(cfg)
284
+ await store.initialize()
285
+ embedder = create_embedder(cfg)
286
+ app.state.layer = SemanticLayer(
287
+ store, token_budget=cfg.token_budget, embedder=embedder
288
+ )
289
+ if not _expected_token():
290
+ logger.warning(
291
+ "API OrkMind iniciada SEM %s: rotas protegidas responderao 503. "
292
+ "Defina o token para habilitar escrita.",
293
+ API_TOKEN_ENV,
294
+ )
295
+ try:
296
+ yield
297
+ finally:
298
+ # Uma layer injetada pertence a quem a criou; nao fechamos aqui.
299
+ if layer is None:
300
+ ativa = getattr(app.state, "layer", None)
301
+ if ativa is not None:
302
+ await ativa.store.close()
303
+
304
+ routes: list[Route] = [
305
+ Route("/health", health, methods=["GET"]),
306
+ Route("/entries/by-hash", lookup_by_hash, methods=["GET"]),
307
+ Route("/entries", create_entry, methods=["POST"]),
308
+ ]
309
+
310
+ return Starlette(routes=routes, lifespan=_lifespan)
311
+
312
+
313
+ def serve(host: str = "127.0.0.1", port: int = 8077, log_level: str = "info") -> None:
314
+ """Sobe a API com uvicorn.
315
+
316
+ O default de host e 127.0.0.1 de proposito: esta API grava memoria e
317
+ nunca deve ficar exposta fora do loopback.
318
+ """
319
+ import uvicorn
320
+
321
+ uvicorn.run(create_app(), host=host, port=port, log_level=log_level)
322
+
323
+
324
+ __all__ = ["create_app", "serve"]
@@ -0,0 +1 @@
1
+ """OrkMind CLI."""