agent-filetree-memory-mcp 0.3.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.
- agent_filetree_memory/__init__.py +23 -0
- agent_filetree_memory/application/__init__.py +5 -0
- agent_filetree_memory/application/service.py +291 -0
- agent_filetree_memory/auth/__init__.py +5 -0
- agent_filetree_memory/auth/capabilities.py +426 -0
- agent_filetree_memory/cli.py +266 -0
- agent_filetree_memory/control_plane/__init__.py +65 -0
- agent_filetree_memory/control_plane/api.py +847 -0
- agent_filetree_memory/control_plane/management_store.py +2504 -0
- agent_filetree_memory/control_plane/namespace_store.py +1278 -0
- agent_filetree_memory/crypto/__init__.py +10 -0
- agent_filetree_memory/crypto/envelope.py +197 -0
- agent_filetree_memory/crypto/local_keyring.py +90 -0
- agent_filetree_memory/domain/__init__.py +5 -0
- agent_filetree_memory/domain/errors.py +41 -0
- agent_filetree_memory/domain/models.py +115 -0
- agent_filetree_memory/domain/paths.py +37 -0
- agent_filetree_memory/janitor_cli.py +119 -0
- agent_filetree_memory/mcp/__init__.py +9 -0
- agent_filetree_memory/mcp/payloads.py +106 -0
- agent_filetree_memory/mcp/server.py +449 -0
- agent_filetree_memory/mcp_app/__init__.py +5 -0
- agent_filetree_memory/mcp_app/app.py +1170 -0
- agent_filetree_memory/ports/__init__.py +14 -0
- agent_filetree_memory/ports/capabilities.py +22 -0
- agent_filetree_memory/ports/crypto.py +51 -0
- agent_filetree_memory/ports/store.py +80 -0
- agent_filetree_memory/postgres/__init__.py +30 -0
- agent_filetree_memory/postgres/janitor.py +263 -0
- agent_filetree_memory/postgres/migrations/README.md +16 -0
- agent_filetree_memory/postgres/migrations/__init__.py +149 -0
- agent_filetree_memory/postgres/migrations/versions/0001_encrypted_filetree.py +284 -0
- agent_filetree_memory/postgres/migrations/versions/0002_normalize_constraint_names.py +187 -0
- agent_filetree_memory/postgres/migrations/versions/0003_management_control_plane.py +275 -0
- agent_filetree_memory/postgres/migrations/versions/0004_workspace_authorization_policies.py +175 -0
- agent_filetree_memory/postgres/migrations/versions/__init__.py +1 -0
- agent_filetree_memory/postgres/runtime.py +125 -0
- agent_filetree_memory/postgres/schema.py +282 -0
- agent_filetree_memory/postgres/store.py +1592 -0
- agent_filetree_memory/web/__init__.py +15 -0
- agent_filetree_memory/web/application.py +65 -0
- agent_filetree_memory/web/dist/assets/index-BWOByJXH.js +37 -0
- agent_filetree_memory/web/dist/assets/index-Ca-eJNeX.css +1 -0
- agent_filetree_memory/web/dist/index.html +14 -0
- agent_filetree_memory/web/frontend.py +184 -0
- agent_filetree_memory_mcp-0.3.0.dist-info/METADATA +113 -0
- agent_filetree_memory_mcp-0.3.0.dist-info/RECORD +50 -0
- agent_filetree_memory_mcp-0.3.0.dist-info/WHEEL +4 -0
- agent_filetree_memory_mcp-0.3.0.dist-info/entry_points.txt +3 -0
- agent_filetree_memory_mcp-0.3.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Encrypted, capability-scoped file-tree memory for agents."""
|
|
2
|
+
|
|
3
|
+
from .domain.models import (
|
|
4
|
+
DeleteResult,
|
|
5
|
+
DocumentSnapshot,
|
|
6
|
+
MemoryAction,
|
|
7
|
+
MemoryEntry,
|
|
8
|
+
Scope,
|
|
9
|
+
VerifiedInvocation,
|
|
10
|
+
WriteResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DeleteResult",
|
|
15
|
+
"DocumentSnapshot",
|
|
16
|
+
"MemoryAction",
|
|
17
|
+
"MemoryEntry",
|
|
18
|
+
"Scope",
|
|
19
|
+
"VerifiedInvocation",
|
|
20
|
+
"WriteResult",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Authorization-first use cases for the memory tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from ..domain.errors import AuthorizationDenied
|
|
12
|
+
from ..domain.models import (
|
|
13
|
+
DeleteResult,
|
|
14
|
+
DocumentSnapshot,
|
|
15
|
+
MemoryAction,
|
|
16
|
+
MemoryEntry,
|
|
17
|
+
VerifiedInvocation,
|
|
18
|
+
WriteResult,
|
|
19
|
+
)
|
|
20
|
+
from ..domain.paths import normalize_memory_path
|
|
21
|
+
from ..ports.store import MemoryStore
|
|
22
|
+
|
|
23
|
+
_DEFAULT_MAX_CONTENT_BYTES = 1024 * 1024
|
|
24
|
+
_DEFAULT_MAX_APPEND_BYTES = 256 * 1024
|
|
25
|
+
_DEFAULT_MAX_IMPORT_DOCUMENTS = 1_000
|
|
26
|
+
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~:-]{0,254}$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryService:
|
|
30
|
+
"""Perform memory operations within one already-verified invocation.
|
|
31
|
+
|
|
32
|
+
The invocation is authorized before request validation and, critically,
|
|
33
|
+
before the persistence port is called. A transport adapter must never
|
|
34
|
+
accept scope fields from a model or UI and construct an invocation itself.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
store: MemoryStore,
|
|
40
|
+
*,
|
|
41
|
+
max_content_bytes: int = _DEFAULT_MAX_CONTENT_BYTES,
|
|
42
|
+
max_append_bytes: int = _DEFAULT_MAX_APPEND_BYTES,
|
|
43
|
+
max_import_documents: int = _DEFAULT_MAX_IMPORT_DOCUMENTS,
|
|
44
|
+
logger: logging.Logger | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
if not isinstance(max_content_bytes, int) or isinstance(
|
|
47
|
+
max_content_bytes, bool
|
|
48
|
+
):
|
|
49
|
+
raise ValueError("max_content_bytes must be a positive integer")
|
|
50
|
+
if not isinstance(max_append_bytes, int) or isinstance(max_append_bytes, bool):
|
|
51
|
+
raise ValueError("max_append_bytes must be a positive integer")
|
|
52
|
+
if max_content_bytes <= 0 or max_append_bytes <= 0:
|
|
53
|
+
raise ValueError("content limits must be positive")
|
|
54
|
+
if max_append_bytes > max_content_bytes:
|
|
55
|
+
raise ValueError("append limit cannot exceed the document limit")
|
|
56
|
+
if (
|
|
57
|
+
not isinstance(max_import_documents, int)
|
|
58
|
+
or isinstance(max_import_documents, bool)
|
|
59
|
+
or max_import_documents <= 0
|
|
60
|
+
):
|
|
61
|
+
raise ValueError("max_import_documents must be a positive integer")
|
|
62
|
+
self._store = store
|
|
63
|
+
self._max_content_bytes = max_content_bytes
|
|
64
|
+
self._max_append_bytes = max_append_bytes
|
|
65
|
+
self._max_import_documents = max_import_documents
|
|
66
|
+
self._logger = logger or logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
async def list(
|
|
69
|
+
self, invocation: VerifiedInvocation, path: str = "/"
|
|
70
|
+
) -> Sequence[MemoryEntry]:
|
|
71
|
+
self._authorize(invocation, MemoryAction.LIST)
|
|
72
|
+
normalized = normalize_memory_path(path)
|
|
73
|
+
result = await self._store.list(
|
|
74
|
+
invocation.scope,
|
|
75
|
+
normalized,
|
|
76
|
+
invocation_id=invocation.invocation_id,
|
|
77
|
+
principal_id=invocation.principal_id,
|
|
78
|
+
)
|
|
79
|
+
self._completed(MemoryAction.LIST)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
async def read(
|
|
83
|
+
self, invocation: VerifiedInvocation, path: str
|
|
84
|
+
) -> DocumentSnapshot:
|
|
85
|
+
self._authorize(invocation, MemoryAction.READ)
|
|
86
|
+
normalized = normalize_memory_path(path, allow_root=False)
|
|
87
|
+
result = await self._store.read(
|
|
88
|
+
invocation.scope,
|
|
89
|
+
normalized,
|
|
90
|
+
invocation_id=invocation.invocation_id,
|
|
91
|
+
principal_id=invocation.principal_id,
|
|
92
|
+
)
|
|
93
|
+
self._completed(MemoryAction.READ)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
async def write(
|
|
97
|
+
self,
|
|
98
|
+
invocation: VerifiedInvocation,
|
|
99
|
+
path: str,
|
|
100
|
+
content: str,
|
|
101
|
+
*,
|
|
102
|
+
expected_version: int | None = None,
|
|
103
|
+
idempotency_key: str,
|
|
104
|
+
) -> WriteResult:
|
|
105
|
+
self._authorize(invocation, MemoryAction.WRITE)
|
|
106
|
+
normalized = normalize_memory_path(path, allow_root=False)
|
|
107
|
+
validated_content = self._validate_content(
|
|
108
|
+
content, max_bytes=self._max_content_bytes, allow_empty=True
|
|
109
|
+
)
|
|
110
|
+
self._validate_expected_version(expected_version, allow_none=True)
|
|
111
|
+
validated_key = self._validate_idempotency_key(idempotency_key)
|
|
112
|
+
result = await self._store.write(
|
|
113
|
+
invocation.scope,
|
|
114
|
+
normalized,
|
|
115
|
+
validated_content,
|
|
116
|
+
expected_version=expected_version,
|
|
117
|
+
idempotency_key=validated_key,
|
|
118
|
+
invocation_id=invocation.invocation_id,
|
|
119
|
+
principal_id=invocation.principal_id,
|
|
120
|
+
)
|
|
121
|
+
self._completed(MemoryAction.WRITE)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
async def append(
|
|
125
|
+
self,
|
|
126
|
+
invocation: VerifiedInvocation,
|
|
127
|
+
path: str,
|
|
128
|
+
content: str,
|
|
129
|
+
*,
|
|
130
|
+
expected_version: int,
|
|
131
|
+
idempotency_key: str,
|
|
132
|
+
) -> WriteResult:
|
|
133
|
+
self._authorize(invocation, MemoryAction.APPEND)
|
|
134
|
+
normalized = normalize_memory_path(path, allow_root=False)
|
|
135
|
+
validated_content = self._validate_content(
|
|
136
|
+
content, max_bytes=self._max_append_bytes, allow_empty=False
|
|
137
|
+
)
|
|
138
|
+
self._validate_expected_version(expected_version, allow_none=False)
|
|
139
|
+
validated_key = self._validate_idempotency_key(idempotency_key)
|
|
140
|
+
result = await self._store.append(
|
|
141
|
+
invocation.scope,
|
|
142
|
+
normalized,
|
|
143
|
+
validated_content,
|
|
144
|
+
expected_version=expected_version,
|
|
145
|
+
idempotency_key=validated_key,
|
|
146
|
+
invocation_id=invocation.invocation_id,
|
|
147
|
+
principal_id=invocation.principal_id,
|
|
148
|
+
)
|
|
149
|
+
self._completed(MemoryAction.APPEND)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
async def delete(
|
|
153
|
+
self,
|
|
154
|
+
invocation: VerifiedInvocation,
|
|
155
|
+
path: str,
|
|
156
|
+
*,
|
|
157
|
+
expected_version: int,
|
|
158
|
+
idempotency_key: str,
|
|
159
|
+
) -> DeleteResult:
|
|
160
|
+
self._authorize(invocation, MemoryAction.DELETE)
|
|
161
|
+
normalized = normalize_memory_path(path, allow_root=False)
|
|
162
|
+
self._validate_expected_version(expected_version, allow_none=False)
|
|
163
|
+
validated_key = self._validate_idempotency_key(idempotency_key)
|
|
164
|
+
result = await self._store.delete(
|
|
165
|
+
invocation.scope,
|
|
166
|
+
normalized,
|
|
167
|
+
expected_version=expected_version,
|
|
168
|
+
idempotency_key=validated_key,
|
|
169
|
+
invocation_id=invocation.invocation_id,
|
|
170
|
+
principal_id=invocation.principal_id,
|
|
171
|
+
)
|
|
172
|
+
self._completed(MemoryAction.DELETE)
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
async def export(
|
|
176
|
+
self, invocation: VerifiedInvocation, path: str = "/"
|
|
177
|
+
) -> Sequence[DocumentSnapshot]:
|
|
178
|
+
self._authorize(invocation, MemoryAction.EXPORT)
|
|
179
|
+
normalized = normalize_memory_path(path)
|
|
180
|
+
result = await self._store.export_markdown_tree(
|
|
181
|
+
invocation.scope,
|
|
182
|
+
normalized,
|
|
183
|
+
invocation_id=invocation.invocation_id,
|
|
184
|
+
principal_id=invocation.principal_id,
|
|
185
|
+
)
|
|
186
|
+
self._completed(MemoryAction.EXPORT)
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
async def import_markdown_tree(
|
|
190
|
+
self,
|
|
191
|
+
invocation: VerifiedInvocation,
|
|
192
|
+
documents: Mapping[str, str],
|
|
193
|
+
*,
|
|
194
|
+
idempotency_namespace: str,
|
|
195
|
+
) -> Sequence[WriteResult]:
|
|
196
|
+
"""Create a portable Markdown tree through retry-safe package writes.
|
|
197
|
+
|
|
198
|
+
The import is intentionally create-only. Every path/content pair is
|
|
199
|
+
normalized and validated before the first database call. Each document
|
|
200
|
+
then receives a stable, opaque idempotency key derived from the caller's
|
|
201
|
+
namespace and normalized path, so an interrupted import can be retried
|
|
202
|
+
safely. Hosts that need an all-or-nothing bulk transaction should build
|
|
203
|
+
that policy above the persistence port.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
self._authorize(invocation, MemoryAction.IMPORT)
|
|
207
|
+
namespace = self._validate_idempotency_key(idempotency_namespace)
|
|
208
|
+
if not isinstance(documents, Mapping) or not documents:
|
|
209
|
+
raise ValueError("documents must be a non-empty path-to-content mapping")
|
|
210
|
+
if len(documents) > self._max_import_documents:
|
|
211
|
+
raise ValueError("documents exceed the configured import limit")
|
|
212
|
+
|
|
213
|
+
validated: dict[str, str] = {}
|
|
214
|
+
for path, content in documents.items():
|
|
215
|
+
normalized = normalize_memory_path(path, allow_root=False)
|
|
216
|
+
if normalized in validated:
|
|
217
|
+
raise ValueError("documents contain duplicate normalized paths")
|
|
218
|
+
validated[normalized] = self._validate_content(
|
|
219
|
+
content,
|
|
220
|
+
max_bytes=self._max_content_bytes,
|
|
221
|
+
allow_empty=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
results: list[WriteResult] = []
|
|
225
|
+
for path in sorted(validated):
|
|
226
|
+
digest = hashlib.sha256(
|
|
227
|
+
b"agent-filetree-memory-import-v1\x00"
|
|
228
|
+
+ namespace.encode("ascii")
|
|
229
|
+
+ b"\x00"
|
|
230
|
+
+ path.encode("utf-8")
|
|
231
|
+
).hexdigest()
|
|
232
|
+
results.append(
|
|
233
|
+
await self._store.write(
|
|
234
|
+
invocation.scope,
|
|
235
|
+
path,
|
|
236
|
+
validated[path],
|
|
237
|
+
expected_version=None,
|
|
238
|
+
idempotency_key=digest,
|
|
239
|
+
invocation_id=invocation.invocation_id,
|
|
240
|
+
principal_id=invocation.principal_id,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
self._completed(MemoryAction.IMPORT)
|
|
244
|
+
return tuple(results)
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _authorize(
|
|
248
|
+
invocation: VerifiedInvocation, required_action: MemoryAction
|
|
249
|
+
) -> None:
|
|
250
|
+
if not isinstance(invocation, VerifiedInvocation) or not isinstance(
|
|
251
|
+
invocation.allowed_actions, frozenset
|
|
252
|
+
):
|
|
253
|
+
raise AuthorizationDenied("memory operation is not authorized")
|
|
254
|
+
invocation.require(required_action)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _validate_content(
|
|
258
|
+
content: str, *, max_bytes: int, allow_empty: bool
|
|
259
|
+
) -> str:
|
|
260
|
+
if not isinstance(content, str) or "\x00" in content:
|
|
261
|
+
raise ValueError("content must be valid text within configured limits")
|
|
262
|
+
try:
|
|
263
|
+
encoded = content.encode("utf-8")
|
|
264
|
+
except UnicodeEncodeError:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"content must be valid text within configured limits"
|
|
267
|
+
) from None
|
|
268
|
+
if (not allow_empty and not encoded) or len(encoded) > max_bytes:
|
|
269
|
+
raise ValueError("content must be valid text within configured limits")
|
|
270
|
+
return content
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _validate_expected_version(
|
|
274
|
+
version: int | None, *, allow_none: bool
|
|
275
|
+
) -> None:
|
|
276
|
+
if version is None and allow_none:
|
|
277
|
+
return
|
|
278
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
|
279
|
+
raise ValueError("expected_version must be a positive integer")
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _validate_idempotency_key(value: str) -> str:
|
|
283
|
+
if not isinstance(value, str) or not _IDEMPOTENCY_KEY.fullmatch(value):
|
|
284
|
+
raise ValueError("idempotency_key must be an opaque identifier")
|
|
285
|
+
return value
|
|
286
|
+
|
|
287
|
+
def _completed(self, action: MemoryAction) -> None:
|
|
288
|
+
# Deliberately omit paths, scopes, idempotency keys, and document data.
|
|
289
|
+
self._logger.info(
|
|
290
|
+
"memory operation completed", extra={"memory_action": action.value}
|
|
291
|
+
)
|