rrft 0.1.0a0__tar.gz

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 (48) hide show
  1. rrft-0.1.0a0/.gitignore +11 -0
  2. rrft-0.1.0a0/CHANGELOG.md +5 -0
  3. rrft-0.1.0a0/PKG-INFO +23 -0
  4. rrft-0.1.0a0/README.md +7 -0
  5. rrft-0.1.0a0/pyproject.toml +60 -0
  6. rrft-0.1.0a0/src/rrft/__init__.py +34 -0
  7. rrft-0.1.0a0/src/rrft/agent.py +55 -0
  8. rrft-0.1.0a0/src/rrft/boot.py +25 -0
  9. rrft-0.1.0a0/src/rrft/cli.py +258 -0
  10. rrft-0.1.0a0/src/rrft/errors.py +13 -0
  11. rrft-0.1.0a0/src/rrft/event_log.py +95 -0
  12. rrft-0.1.0a0/src/rrft/harness.py +222 -0
  13. rrft-0.1.0a0/src/rrft/path_jail.py +37 -0
  14. rrft-0.1.0a0/src/rrft/protocol.py +39 -0
  15. rrft-0.1.0a0/src/rrft/scopes.py +37 -0
  16. rrft-0.1.0a0/src/rrft/tools/__init__.py +0 -0
  17. rrft-0.1.0a0/src/rrft/tools/base.py +41 -0
  18. rrft-0.1.0a0/src/rrft/tools/grep.py +98 -0
  19. rrft-0.1.0a0/src/rrft/tools/ls.py +51 -0
  20. rrft-0.1.0a0/src/rrft/tools/read.py +61 -0
  21. rrft-0.1.0a0/src/rrft/tools/skill.py +32 -0
  22. rrft-0.1.0a0/tests/__init__.py +0 -0
  23. rrft-0.1.0a0/tests/_mock.py +32 -0
  24. rrft-0.1.0a0/tests/conftest.py +21 -0
  25. rrft-0.1.0a0/tests/fixtures/agents/minimal/AGENT.md +8 -0
  26. rrft-0.1.0a0/tests/fixtures/agents/minimal/INDEX.md +6 -0
  27. rrft-0.1.0a0/tests/fixtures/agents/minimal/instructions/_index.md +3 -0
  28. rrft-0.1.0a0/tests/fixtures/agents/minimal/knowledge/_index.md +3 -0
  29. rrft-0.1.0a0/tests/fixtures/agents/minimal/skills/_index.md +3 -0
  30. rrft-0.1.0a0/tests/test_agent.py +64 -0
  31. rrft-0.1.0a0/tests/test_agent_turn_e2e.py +70 -0
  32. rrft-0.1.0a0/tests/test_boot.py +55 -0
  33. rrft-0.1.0a0/tests/test_build_history.py +61 -0
  34. rrft-0.1.0a0/tests/test_cli.py +99 -0
  35. rrft-0.1.0a0/tests/test_errors.py +15 -0
  36. rrft-0.1.0a0/tests/test_event_log.py +144 -0
  37. rrft-0.1.0a0/tests/test_harness.py +203 -0
  38. rrft-0.1.0a0/tests/test_harness_resume.py +154 -0
  39. rrft-0.1.0a0/tests/test_path_jail.py +91 -0
  40. rrft-0.1.0a0/tests/test_public_api.py +23 -0
  41. rrft-0.1.0a0/tests/test_scopes.py +53 -0
  42. rrft-0.1.0a0/tests/tools/__init__.py +0 -0
  43. rrft-0.1.0a0/tests/tools/test_base.py +34 -0
  44. rrft-0.1.0a0/tests/tools/test_grep.py +74 -0
  45. rrft-0.1.0a0/tests/tools/test_ls.py +62 -0
  46. rrft-0.1.0a0/tests/tools/test_read.py +63 -0
  47. rrft-0.1.0a0/tests/tools/test_skill.py +39 -0
  48. rrft-0.1.0a0/uv.lock +442 -0
@@ -0,0 +1,11 @@
1
+ .venv
2
+ venv
3
+ __pycache__
4
+ .claude
5
+ .ruff_cache
6
+ .pytest_cache
7
+ .mypy_cache
8
+ dist
9
+ *.egg-info
10
+ /docs/plans
11
+ .hypothesis
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0a0
4
+
5
+ First version.
rrft-0.1.0a0/PKG-INFO ADDED
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: rrft
3
+ Version: 0.1.0a0
4
+ Summary: rrft - file tree agent engine
5
+ Author-email: 0x0064 <user.frndvrgs@gmail.com>
6
+ Keywords: agent,ai,assistant,file,python,rrft,sdk,tree
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: pydantic>=2.6
9
+ Provides-Extra: dev
10
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
11
+ Requires-Dist: mypy>=1.20; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Requires-Dist: ruff>=0.5; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # rrft
18
+
19
+ rrft - file tree agent engine
20
+
21
+ ## License
22
+
23
+ MIT (pending).
rrft-0.1.0a0/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # rrft
2
+
3
+ rrft - file tree agent engine
4
+
5
+ ## License
6
+
7
+ MIT (pending).
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "rrft"
3
+ version = "0.1.0a0"
4
+ description = "rrft - file tree agent engine"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ authors = [
8
+ {name = "0x0064", email = "user.frndvrgs@gmail.com"},
9
+ ]
10
+ keywords = [
11
+ "rrft",
12
+ "file",
13
+ "tree",
14
+ "assistant",
15
+ "agent",
16
+ "sdk",
17
+ "ai",
18
+ "python"
19
+ ]
20
+
21
+ dependencies = [
22
+ "pydantic>=2.6",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8.0",
28
+ "pytest-asyncio>=0.23",
29
+ "hypothesis>=6.100",
30
+ "ruff>=0.5",
31
+ "mypy>=1.20",
32
+ ]
33
+
34
+ [project.scripts]
35
+ rrft = "rrft.cli:main"
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/rrft"]
43
+
44
+ [tool.pytest.ini_options]
45
+ asyncio_mode = "auto"
46
+ testpaths = ["tests"]
47
+ pythonpath = ["src"]
48
+
49
+ [tool.ruff]
50
+ line-length = 100
51
+ target-version = "py311"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "I", "W", "B", "UP", "SIM"]
55
+ ignore = ["E501"]
56
+
57
+ [dependency-groups]
58
+ dev = [
59
+ "mypy>=1.20.0",
60
+ ]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from rrft.agent import Agent
4
+ from rrft.errors import BaseError, ProviderError, ScopeError
5
+ from rrft.protocol import (
6
+ Message,
7
+ Provider,
8
+ ProviderReply,
9
+ ToolCall,
10
+ ToolSpec,
11
+ )
12
+ from rrft.tools.grep import GrepTool
13
+ from rrft.tools.ls import LSTool
14
+ from rrft.tools.read import ReadTool
15
+ from rrft.tools.skill import SkillTool
16
+
17
+ __version__ = "0.0.0"
18
+
19
+ __all__ = [
20
+ "Agent",
21
+ "Message",
22
+ "ToolCall",
23
+ "ToolSpec",
24
+ "GrepTool",
25
+ "LSTool",
26
+ "Provider",
27
+ "ProviderError",
28
+ "ProviderReply",
29
+ "ReadTool",
30
+ "ScopeError",
31
+ "SkillTool",
32
+ "BaseError",
33
+ "__version__",
34
+ ]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field, PrivateAttr
8
+
9
+ from rrft.harness import Harness
10
+ from rrft.scopes import build_scope
11
+ from rrft.tools.base import Tool
12
+ from rrft.tools.grep import GrepTool
13
+ from rrft.tools.ls import LSTool
14
+ from rrft.tools.read import ReadTool
15
+ from rrft.tools.skill import SkillTool
16
+
17
+
18
+ def _default_tools() -> list[Tool]:
19
+ return [ReadTool(), GrepTool(), LSTool(), SkillTool()]
20
+
21
+
22
+ class Agent(BaseModel):
23
+ root: Path
24
+ provider: Any
25
+ namespaces: list[str] = Field(default_factory=list)
26
+ tools: list[Tool] = Field(default_factory=_default_tools)
27
+
28
+ model_config = {"arbitrary_types_allowed": True}
29
+
30
+ _harness: Harness = PrivateAttr()
31
+ _locks: dict[str, asyncio.Lock] = PrivateAttr(default_factory=dict)
32
+ _locks_guard: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
33
+
34
+ def model_post_init(self, __context: object) -> None:
35
+ self._harness = Harness(agent_root=self.root, provider=self.provider, tools=self.tools)
36
+
37
+ async def _lock_for(self, key: str) -> asyncio.Lock:
38
+ async with self._locks_guard:
39
+ lock = self._locks.get(key)
40
+ if lock is None:
41
+ lock = asyncio.Lock()
42
+ self._locks[key] = lock
43
+ return lock
44
+
45
+ async def turn(self, session_id: str, message: str, scope: dict[str, str]) -> str:
46
+ parsed = build_scope(self.namespaces, scope)
47
+ lock = await self._lock_for(f"{parsed.leaf}::{session_id}")
48
+ async with lock:
49
+ return await self._harness.turn(scope=parsed, session_id=session_id, message=message)
50
+
51
+ async def resume(self, session_id: str, scope: dict[str, str]) -> str:
52
+ parsed = build_scope(self.namespaces, scope)
53
+ lock = await self._lock_for(f"{parsed.leaf}::{session_id}")
54
+ async with lock:
55
+ return await self._harness.resume(scope=parsed, session_id=session_id)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from rrft.scopes import Scope
6
+
7
+ _LABELS = ["AGENT", "INDEX", "INSTRUCTIONS", "SKILLS", "KNOWLEDGE", "MEMORY", "SESSIONS"]
8
+
9
+
10
+ def build_boot_bundle(agent_root: Path, scope: Scope) -> str:
11
+ scope_dir = agent_root / "data" / scope.leaf
12
+ sources = [
13
+ agent_root / "AGENT.md",
14
+ agent_root / "INDEX.md",
15
+ agent_root / "instructions" / "_index.md",
16
+ agent_root / "skills" / "_index.md",
17
+ agent_root / "knowledge" / "_index.md",
18
+ scope_dir / "memory" / "MEMORY.md",
19
+ scope_dir / "sessions" / "_index.md",
20
+ ]
21
+ sections: list[str] = []
22
+ for label, path in zip(_LABELS, sources, strict=True):
23
+ if path.exists():
24
+ sections.append(f"<<<{label}>>>\n{path.read_text(encoding='utf-8')}")
25
+ return "\n\n".join(sections)
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from rrft import __version__
9
+ from rrft.boot import build_boot_bundle
10
+ from rrft.path_jail import PathJail
11
+ from rrft.scopes import build_scope
12
+ from rrft.tools.base import Tool, ToolContext, ToolResult
13
+ from rrft.tools.grep import GrepInput, GrepTool
14
+ from rrft.tools.ls import LSInput, LSTool
15
+ from rrft.tools.read import ReadInput, ReadTool
16
+ from rrft.tools.skill import SkillInput, SkillTool
17
+
18
+ _AGENT_TEMPLATE = """\
19
+ ---
20
+ name: my-agent
21
+ persona: write your agent's persona here
22
+ ---
23
+
24
+ # My Agent
25
+
26
+ You are a [describe what this agent does].
27
+
28
+ ## How you work
29
+
30
+ - Read `INDEX.md` first to orient yourself in the filetree.
31
+ - Use `Grep` to find relevant passages in `knowledge/`.
32
+ - Use `Read` to pull the full text of a document when needed.
33
+ - Use `Skill` when the user's request matches a named procedure in `skills/`.
34
+ """
35
+
36
+ _INDEX_TEMPLATE = """\
37
+ # Map
38
+
39
+ - `AGENT.md` — who you are, how you work
40
+ - `instructions/` — behavioral rules (tone, escalation, refusal policy)
41
+ - `skills/` — triggerable procedures, one folder per skill
42
+ - `knowledge/` — reference material, organized by topic
43
+ """
44
+
45
+ _SUB_INDEX_TEMPLATES = {
46
+ "instructions": "# Instructions\n\n(empty — add behavioral docs here)\n",
47
+ "skills": "# Skills\n\n(empty — add skills as folders containing `SKILL.md`)\n",
48
+ "knowledge": "# Knowledge\n\n(empty — add reference material here)\n",
49
+ }
50
+
51
+ _REQUIRED_FILES = (
52
+ "AGENT.md",
53
+ "INDEX.md",
54
+ "instructions/_index.md",
55
+ "skills/_index.md",
56
+ "knowledge/_index.md",
57
+ )
58
+
59
+
60
+ def main(argv: list[str] | None = None) -> int:
61
+ parser = argparse.ArgumentParser(
62
+ prog="rrft",
63
+ description="Filesystem-backed agent SDK — direct tool access for debugging and scaffolding.",
64
+ )
65
+ parser.add_argument("--version", action="version", version=f"rrft {__version__}")
66
+ sub = parser.add_subparsers(dest="cmd", required=True)
67
+
68
+ p_init = sub.add_parser("init", help="scaffold a new agent tree")
69
+ p_init.add_argument("path", help="directory to create")
70
+
71
+ p_inspect = sub.add_parser("inspect", help="validate an agent tree and show its shape")
72
+ p_inspect.add_argument("root", help="path to an agent tree")
73
+
74
+ p_boot = sub.add_parser("boot", help="print the boot bundle for a scope")
75
+ p_boot.add_argument("root")
76
+ p_boot.add_argument(
77
+ "--namespace",
78
+ action="append",
79
+ default=[],
80
+ metavar="KEY",
81
+ help="declared namespace key (repeatable, order matters)",
82
+ )
83
+ p_boot.add_argument(
84
+ "--scope",
85
+ action="append",
86
+ default=[],
87
+ metavar="KEY=VALUE",
88
+ help="scope value (repeatable)",
89
+ )
90
+
91
+ p_read = sub.add_parser("read", help="run the Read tool directly")
92
+ p_read.add_argument("root")
93
+ p_read.add_argument("file", help="file path inside the agent tree")
94
+ p_read.add_argument("--offset", type=int, default=0)
95
+ p_read.add_argument("--limit", type=int, default=None)
96
+
97
+ p_grep = sub.add_parser("grep", help="run the Grep tool directly (ripgrep-backed)")
98
+ p_grep.add_argument("root")
99
+ p_grep.add_argument("pattern")
100
+ p_grep.add_argument("--path", default=None, help="restrict to this subtree")
101
+ p_grep.add_argument("--glob", default=None, help="filename filter, e.g. '*.md'")
102
+ p_grep.add_argument("-i", "--case-insensitive", action="store_true")
103
+ p_grep.add_argument("--mode", choices=["files", "content"], default="files")
104
+ p_grep.add_argument("--limit", type=int, default=250)
105
+
106
+ p_ls = sub.add_parser("ls", help="run the LS tool directly")
107
+ p_ls.add_argument("root")
108
+ p_ls.add_argument("--path", default=None)
109
+ p_ls.add_argument("--limit", type=int, default=250)
110
+
111
+ p_skill = sub.add_parser("skill", help="load a skill body by name")
112
+ p_skill.add_argument("root")
113
+ p_skill.add_argument("name", help="skill folder name under skills/")
114
+
115
+ args = parser.parse_args(argv)
116
+ handlers = {
117
+ "init": _init,
118
+ "inspect": _inspect,
119
+ "boot": _boot,
120
+ "read": _read,
121
+ "grep": _grep,
122
+ "ls": _ls,
123
+ "skill": _skill,
124
+ }
125
+ return handlers[args.cmd](args)
126
+
127
+
128
+ def _init(args: argparse.Namespace) -> int:
129
+ root = Path(args.path)
130
+ if root.exists() and any(root.iterdir()):
131
+ print(f"error: {root} exists and is not empty", file=sys.stderr)
132
+ return 2
133
+ root.mkdir(parents=True, exist_ok=True)
134
+ (root / "AGENT.md").write_text(_AGENT_TEMPLATE)
135
+ (root / "INDEX.md").write_text(_INDEX_TEMPLATE)
136
+ for name, template in _SUB_INDEX_TEMPLATES.items():
137
+ folder = root / name
138
+ folder.mkdir()
139
+ (folder / "_index.md").write_text(template)
140
+ print(f"scaffolded agent at {root.resolve()}")
141
+ return 0
142
+
143
+
144
+ def _inspect(args: argparse.Namespace) -> int:
145
+ root = Path(args.root)
146
+ if not root.exists():
147
+ print(f"error: not found: {root}", file=sys.stderr)
148
+ return 2
149
+ missing = [rel for rel in _REQUIRED_FILES if not (root / rel).exists()]
150
+ if missing:
151
+ print(f"invalid agent tree at {root}: missing", file=sys.stderr)
152
+ for rel in missing:
153
+ print(f" {rel}", file=sys.stderr)
154
+ return 1
155
+ print(f"valid agent tree: {root.resolve()}")
156
+ for entry in sorted(root.iterdir()):
157
+ if entry.name.startswith("."):
158
+ continue
159
+ if entry.is_dir():
160
+ md_count = sum(1 for _ in entry.rglob("*.md"))
161
+ print(f" {entry.name}/ ({md_count} .md files)")
162
+ else:
163
+ print(f" {entry.name}")
164
+ return 0
165
+
166
+
167
+ def _boot(args: argparse.Namespace) -> int:
168
+ root = Path(args.root)
169
+ scope_values = _parse_scope(args.scope)
170
+ scope = build_scope(args.namespace, scope_values)
171
+ print(build_boot_bundle(root, scope))
172
+ return 0
173
+
174
+
175
+ def _read(args: argparse.Namespace) -> int:
176
+ return asyncio.run(
177
+ _run_tool(
178
+ Path(args.root),
179
+ ReadTool(),
180
+ ReadInput(path=args.file, offset=args.offset, limit=args.limit),
181
+ )
182
+ )
183
+
184
+
185
+ def _grep(args: argparse.Namespace) -> int:
186
+ return asyncio.run(
187
+ _run_tool(
188
+ Path(args.root),
189
+ GrepTool(),
190
+ GrepInput(
191
+ pattern=args.pattern,
192
+ path=args.path,
193
+ glob=args.glob,
194
+ case_insensitive=args.case_insensitive,
195
+ mode=args.mode,
196
+ head_limit=args.limit,
197
+ ),
198
+ )
199
+ )
200
+
201
+
202
+ def _ls(args: argparse.Namespace) -> int:
203
+ return asyncio.run(
204
+ _run_tool(
205
+ Path(args.root),
206
+ LSTool(),
207
+ LSInput(path=args.path, limit=args.limit),
208
+ )
209
+ )
210
+
211
+
212
+ def _skill(args: argparse.Namespace) -> int:
213
+ return asyncio.run(_run_tool(Path(args.root), SkillTool(), SkillInput(name=args.name)))
214
+
215
+
216
+ async def _run_tool(root: Path, tool: Tool, input_obj) -> int: # type: ignore[type-arg]
217
+ context = ToolContext(
218
+ path_jail=PathJail(
219
+ read_roots=[
220
+ root / "AGENT.md",
221
+ root / "INDEX.md",
222
+ root / "instructions",
223
+ root / "skills",
224
+ root / "knowledge",
225
+ ],
226
+ write_roots=[],
227
+ ),
228
+ agent_root=root.resolve(),
229
+ cancel=asyncio.Event(),
230
+ )
231
+ result: ToolResult = await tool.call(input_obj, context)
232
+ return _emit(result)
233
+
234
+
235
+ def _emit(result: ToolResult) -> int:
236
+ if result.is_error:
237
+ print(result.content, file=sys.stderr)
238
+ return 1
239
+ print(result.content)
240
+ return 0
241
+
242
+
243
+ def _parse_scope(items: list[str]) -> dict[str, str]:
244
+ out: dict[str, str] = {}
245
+ for item in items:
246
+ if "=" not in item:
247
+ print(
248
+ f"error: invalid --scope '{item}' (expected KEY=VALUE)",
249
+ file=sys.stderr,
250
+ )
251
+ sys.exit(2)
252
+ key, value = item.split("=", 1)
253
+ out[key] = value
254
+ return out
255
+
256
+
257
+ if __name__ == "__main__":
258
+ sys.exit(main())
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class BaseError(Exception):
5
+ pass
6
+
7
+
8
+ class ScopeError(BaseError):
9
+ pass
10
+
11
+
12
+ class ProviderError(BaseError):
13
+ pass
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections import deque
6
+ from datetime import datetime
7
+ from pathlib import Path as _Path
8
+ from typing import Annotated, Any, Literal
9
+
10
+ from pydantic import BaseModel, Discriminator, Field, PrivateAttr, TypeAdapter, ValidationError
11
+
12
+ from rrft.errors import BaseError
13
+
14
+
15
+ class _EventBase(BaseModel):
16
+ at: datetime
17
+ turn: int
18
+
19
+ model_config = {"frozen": True}
20
+
21
+
22
+ class MessageEvent(_EventBase):
23
+ type: Literal["message"] = "message"
24
+ role: Literal["user", "assistant", "system"]
25
+ content: str
26
+ user_id: str | None = None
27
+ usage: dict[str, int] | None = None
28
+
29
+
30
+ class ToolCallEvent(_EventBase):
31
+ type: Literal["tool_call"] = "tool_call"
32
+ role: Literal["assistant"] = "assistant"
33
+ id: str
34
+ tool: str
35
+ args: dict[str, Any] = Field(default_factory=dict)
36
+
37
+
38
+ class ToolResultEvent(_EventBase):
39
+ type: Literal["tool_result"] = "tool_result"
40
+ role: Literal["tool"] = "tool"
41
+ id: str
42
+ is_error: bool
43
+ content: str
44
+
45
+
46
+ Event = Annotated[
47
+ MessageEvent | ToolCallEvent | ToolResultEvent,
48
+ Discriminator("type"),
49
+ ]
50
+
51
+ _ADAPTER: TypeAdapter[Event] = TypeAdapter(Event)
52
+
53
+
54
+ def dump_event(event: Event) -> str:
55
+ return (
56
+ _ADAPTER.dump_json(event, warnings="error")
57
+ .decode("utf-8")
58
+ .replace("\u2028", "\\u2028")
59
+ .replace("\u2029", "\\u2029")
60
+ )
61
+
62
+
63
+ def load_event(line: str) -> Event:
64
+ try:
65
+ return _ADAPTER.validate_json(line)
66
+ except (json.JSONDecodeError, ValidationError) as exc:
67
+ raise BaseError(f"malformed event: {line[:100]}") from exc
68
+
69
+
70
+ class EventLog(BaseModel):
71
+ path: _Path
72
+ _lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
73
+
74
+ model_config = {"arbitrary_types_allowed": True}
75
+
76
+ async def append(self, event: Event) -> None:
77
+ async with self._lock:
78
+ self.path.parent.mkdir(parents=True, exist_ok=True)
79
+ with self.path.open("a", encoding="utf-8") as f:
80
+ f.write(dump_event(event) + "\n")
81
+
82
+ async def tail(self, n: int) -> list[Event]:
83
+ if not self.path.exists():
84
+ return []
85
+ window: deque[str] = deque(maxlen=n)
86
+ with self.path.open("r", encoding="utf-8") as f:
87
+ for line in f:
88
+ line = line.strip()
89
+ if line:
90
+ window.append(line)
91
+ return [load_event(line) for line in window]
92
+
93
+ async def last(self) -> Event | None:
94
+ tail = await self.tail(1)
95
+ return tail[0] if tail else None