agentdir-cli 0.7.5__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.
agentdir/hooks.py ADDED
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import stat
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from .events import emit_event
10
+ from .git import git_branch, git_head, git_output, git_status_short
11
+ from .sessions import end_session, read_current_session, start_session
12
+ from .store import AgentDirError, init_root
13
+
14
+ DEFAULT_HOOKS = ("pre-commit", "post-commit", "pre-push", "post-checkout", "post-merge")
15
+ MANAGED_MARKER = "# AgentDir managed hook"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class HookInfo:
20
+ hook: str
21
+ path: str
22
+ installed: bool
23
+ managed: bool
24
+ original: str | None = None
25
+
26
+
27
+ def git_hooks_dir(cwd: str | Path | None = None) -> Path:
28
+ git_dir = git_output(["rev-parse", "--git-dir"], cwd)
29
+ if not git_dir:
30
+ raise AgentDirError("AgentDir hooks require a git repository")
31
+ path = Path(git_dir)
32
+ if not path.is_absolute():
33
+ root = git_output(["rev-parse", "--show-toplevel"], cwd)
34
+ base = Path(root) if root else Path(cwd or Path.cwd())
35
+ path = base / path
36
+ hooks = path.resolve() / "hooks"
37
+ hooks.mkdir(parents=True, exist_ok=True)
38
+ return hooks
39
+
40
+
41
+ def hook_status(cwd: str | Path | None = None, hooks: list[str] | None = None) -> list[HookInfo]:
42
+ hooks_dir = git_hooks_dir(cwd)
43
+ names = hooks or list(DEFAULT_HOOKS)
44
+ statuses: list[HookInfo] = []
45
+ for name in names:
46
+ path = hooks_dir / name
47
+ original = hooks_dir / f"{name}.agentdir-original"
48
+ managed = path.is_file() and MANAGED_MARKER in path.read_text(encoding="utf-8", errors="ignore")
49
+ statuses.append(
50
+ HookInfo(
51
+ hook=name,
52
+ path=str(path),
53
+ installed=path.exists(),
54
+ managed=managed,
55
+ original=str(original) if original.exists() else None,
56
+ )
57
+ )
58
+ return statuses
59
+
60
+
61
+ def install_hooks(
62
+ root: str | Path,
63
+ *,
64
+ cwd: str | Path | None = None,
65
+ hooks: list[str] | None = None,
66
+ force: bool = False,
67
+ ) -> list[HookInfo]:
68
+ init_root(root)
69
+ hooks_dir = git_hooks_dir(cwd)
70
+ installed: list[HookInfo] = []
71
+ for name in hooks or list(DEFAULT_HOOKS):
72
+ target = hooks_dir / name
73
+ original = hooks_dir / f"{name}.agentdir-original"
74
+ if target.exists():
75
+ managed = target.is_file() and MANAGED_MARKER in target.read_text(encoding="utf-8", errors="ignore")
76
+ if not managed:
77
+ if original.exists() and not force:
78
+ raise AgentDirError(f"Refusing to overwrite existing hook and backup: {target}")
79
+ if original.exists() and force:
80
+ original.unlink()
81
+ target.rename(original)
82
+ target.write_text(_hook_script(name, original), encoding="utf-8")
83
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
84
+ installed.append(
85
+ HookInfo(
86
+ hook=name,
87
+ path=str(target),
88
+ installed=True,
89
+ managed=True,
90
+ original=str(original) if original.exists() else None,
91
+ )
92
+ )
93
+ return installed
94
+
95
+
96
+ def uninstall_hooks(
97
+ *,
98
+ cwd: str | Path | None = None,
99
+ hooks: list[str] | None = None,
100
+ ) -> list[HookInfo]:
101
+ hooks_dir = git_hooks_dir(cwd)
102
+ removed: list[HookInfo] = []
103
+ for name in hooks or list(DEFAULT_HOOKS):
104
+ target = hooks_dir / name
105
+ original = hooks_dir / f"{name}.agentdir-original"
106
+ managed = target.is_file() and MANAGED_MARKER in target.read_text(encoding="utf-8", errors="ignore")
107
+ if managed:
108
+ target.unlink()
109
+ if original.exists():
110
+ original.rename(target)
111
+ removed.append(
112
+ HookInfo(
113
+ hook=name,
114
+ path=str(target),
115
+ installed=target.exists(),
116
+ managed=target.is_file() and MANAGED_MARKER in target.read_text(encoding="utf-8", errors="ignore"),
117
+ original=str(original) if original.exists() else None,
118
+ )
119
+ )
120
+ return removed
121
+
122
+
123
+ def record_hook_event(
124
+ root: str | Path,
125
+ *,
126
+ hook: str,
127
+ original_exit_code: int,
128
+ stdin_file: str | Path | None = None,
129
+ hook_args: list[str] | None = None,
130
+ cwd: str | Path | None = None,
131
+ ) -> None:
132
+ session = read_current_session(root)
133
+ created_hook_session = session is None or session.status != "active"
134
+ if created_hook_session:
135
+ session = start_session(
136
+ root,
137
+ title=f"Git hook {hook}",
138
+ actor="git",
139
+ note="AgentDir background git hook session.",
140
+ cwd=cwd,
141
+ )
142
+ stdin_text = ""
143
+ if stdin_file:
144
+ path = Path(stdin_file)
145
+ if path.is_file():
146
+ stdin_text = path.read_text(encoding="utf-8", errors="replace")[:32_000]
147
+ body = "\n".join(
148
+ [
149
+ f"hook={hook}",
150
+ f"original_exit_code={original_exit_code}",
151
+ f"args={json.dumps(hook_args or [])}",
152
+ f"branch={git_branch(cwd) or ''}",
153
+ f"git_head={git_head(cwd) or ''}",
154
+ f"recorded_at={datetime.now(UTC).isoformat()}",
155
+ "",
156
+ "git_status_short:",
157
+ git_status_short(cwd),
158
+ "",
159
+ "stdin:",
160
+ stdin_text,
161
+ ]
162
+ )
163
+ emit_event(
164
+ root,
165
+ session_id=session.session_id,
166
+ event_type=f"git.hook.{hook}",
167
+ subject=f"git hook {hook} exit {original_exit_code}",
168
+ from_actor="git",
169
+ body=body,
170
+ git_head=git_head(cwd),
171
+ tool="git",
172
+ tool_exit_code=original_exit_code,
173
+ )
174
+ if created_hook_session:
175
+ end_session(
176
+ root,
177
+ status="completed",
178
+ summary=f"Recorded git hook {hook} exit {original_exit_code}.",
179
+ actor="git",
180
+ )
181
+
182
+
183
+ def _hook_script(name: str, original: Path) -> str:
184
+ return f"""#!/bin/sh
185
+ {MANAGED_MARKER}: {name}
186
+ hook_name="{name}"
187
+ stdin_file="$(mktemp "${{TMPDIR:-/tmp}}/agentdir-hook.XXXXXX")" || stdin_file=""
188
+ if [ -n "$stdin_file" ]; then
189
+ cat > "$stdin_file"
190
+ fi
191
+
192
+ original="{original}"
193
+ original_status=0
194
+ if [ -x "$original" ]; then
195
+ if [ -n "$stdin_file" ]; then
196
+ "$original" "$@" < "$stdin_file"
197
+ else
198
+ "$original" "$@"
199
+ fi
200
+ original_status=$?
201
+ fi
202
+
203
+ if command -v agentdir >/dev/null 2>&1; then
204
+ if [ -n "$stdin_file" ]; then
205
+ agentdir hooks record --hook "$hook_name" --original-exit-code "$original_status" --stdin-file "$stdin_file" -- "$@" >/dev/null 2>&1 || true
206
+ else
207
+ agentdir hooks record --hook "$hook_name" --original-exit-code "$original_status" -- "$@" >/dev/null 2>&1 || true
208
+ fi
209
+ fi
210
+
211
+ if [ -n "$stdin_file" ]; then
212
+ rm -f "$stdin_file"
213
+ fi
214
+ exit "$original_status"
215
+ """
agentdir/index.py ADDED
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import time
7
+ from collections import defaultdict
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from email.utils import parsedate_to_datetime
12
+ from pathlib import Path
13
+
14
+ try:
15
+ import fcntl
16
+ except ImportError: # pragma: no cover - AgentDir targets Unix-like developer machines.
17
+ fcntl = None
18
+
19
+ from .envelope import parse_envelope, validate_required
20
+ from .mailbox import iter_records
21
+ from .memory import index_memory_document, index_session_summaries, memory_schema_sql
22
+ from .store import AgentDirError, discover_mailboxes, paths_for, require_root
23
+
24
+ SCHEMA = """
25
+ pragma foreign_keys = on;
26
+ create table if not exists metadata (key text primary key, value text not null);
27
+ create table if not exists messages (
28
+ id integer primary key,
29
+ message_id text,
30
+ mailbox_kind text not null,
31
+ mailbox_path text not null,
32
+ file_path text not null unique,
33
+ state text not null,
34
+ event_type text,
35
+ subject text,
36
+ from_actor text,
37
+ to_actor text,
38
+ session_id text,
39
+ task_id text,
40
+ parent_message_id text,
41
+ date_header text,
42
+ date_utc text,
43
+ created_ns integer,
44
+ git_head text,
45
+ workspace text,
46
+ tool text,
47
+ tool_exit_code integer,
48
+ body_sha256 text,
49
+ body_text text,
50
+ indexed_at text not null,
51
+ malformed integer not null default 0,
52
+ errors text not null default '[]'
53
+ );
54
+ create index if not exists messages_message_id_idx on messages(message_id);
55
+ create index if not exists messages_session_idx on messages(session_id);
56
+ create index if not exists messages_event_type_idx on messages(event_type);
57
+ create index if not exists messages_task_idx on messages(task_id);
58
+ create table if not exists headers (
59
+ message_rowid integer not null references messages(id) on delete cascade,
60
+ name text not null,
61
+ value text not null
62
+ );
63
+ create table if not exists artifacts (
64
+ sha256 text primary key,
65
+ path text not null,
66
+ bytes integer,
67
+ mime_type text,
68
+ created_at text not null
69
+ );
70
+ create table if not exists message_artifacts (
71
+ message_rowid integer not null references messages(id) on delete cascade,
72
+ sha256 text not null
73
+ );
74
+ """
75
+
76
+
77
+ @dataclass
78
+ class IndexResult:
79
+ indexed: int = 0
80
+ malformed: int = 0
81
+ duplicates: dict[str, list[str]] = field(default_factory=dict)
82
+
83
+
84
+ def now_iso() -> str:
85
+ return datetime.now(UTC).isoformat()
86
+
87
+
88
+ def initialize_schema(conn: sqlite3.Connection) -> None:
89
+ conn.executescript(SCHEMA)
90
+ conn.executescript(memory_schema_sql())
91
+ conn.execute("insert or replace into metadata(key, value) values('schema_version', '3')")
92
+ conn.execute("insert or replace into metadata(key, value) values('vector_memory', 'yes')")
93
+ conn.execute("insert or replace into metadata(key, value) values('hybrid_passages', 'yes')")
94
+ conn.execute("insert or replace into metadata(key, value) values('memory_backend', 'local-hybrid')")
95
+ conn.execute("insert or replace into metadata(key, value) values('semantic_embeddings', 'optional')")
96
+ try:
97
+ conn.execute(
98
+ "create virtual table if not exists message_fts using fts5(message_id, subject, body_text)"
99
+ )
100
+ conn.execute("insert or replace into metadata(key, value) values('fts5', 'yes')")
101
+ except sqlite3.DatabaseError:
102
+ conn.execute("insert or replace into metadata(key, value) values('fts5', 'no')")
103
+
104
+
105
+ def connect_index(root: str | Path) -> sqlite3.Connection:
106
+ paths = require_root(root)
107
+ conn = sqlite3.connect(paths.index_path)
108
+ conn.row_factory = sqlite3.Row
109
+ conn.execute("pragma foreign_keys = on")
110
+ return conn
111
+
112
+
113
+ @contextmanager
114
+ def index_rebuild_lock(indexes: Path):
115
+ indexes.mkdir(parents=True, exist_ok=True)
116
+ lock_path = indexes / ".agentdir-index.lock"
117
+ with lock_path.open("a+", encoding="utf-8") as lock:
118
+ if fcntl is not None:
119
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
120
+ try:
121
+ yield
122
+ finally:
123
+ if fcntl is not None:
124
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
125
+
126
+
127
+ def rebuild_index(root: str | Path) -> IndexResult:
128
+ paths = require_root(root)
129
+ with index_rebuild_lock(paths.indexes):
130
+ temp_path = paths.index_path.with_name(
131
+ f"{paths.index_path.name}.{os.getpid()}.{time.time_ns()}.tmp"
132
+ )
133
+ conn = sqlite3.connect(temp_path)
134
+ conn.row_factory = sqlite3.Row
135
+ replaced = False
136
+ try:
137
+ conn.execute("pragma foreign_keys = on")
138
+ initialize_schema(conn)
139
+ result = _index_into(conn, root)
140
+ conn.commit()
141
+ _carry_forward_semantic_embeddings(conn, paths.index_path)
142
+ conn.close()
143
+ os.replace(temp_path, paths.index_path)
144
+ replaced = True
145
+ return result
146
+ finally:
147
+ if not replaced:
148
+ conn.close()
149
+ if not replaced and temp_path.exists():
150
+ temp_path.unlink()
151
+
152
+
153
+ def update_index(root: str | Path) -> IndexResult:
154
+ return rebuild_index(root)
155
+
156
+
157
+ def _carry_forward_semantic_embeddings(conn: sqlite3.Connection, previous_index: Path) -> None:
158
+ # Rebuilds start from an empty temp database, which would discard the
159
+ # semantic embedding cache and force a full re-embed on the next semantic
160
+ # query. Embeddings are content-addressed by (source_id, model,
161
+ # text_sha256), so rows whose document text is unchanged stay valid; the
162
+ # join drops rows for documents that no longer exist.
163
+ if not previous_index.exists():
164
+ return
165
+ try:
166
+ conn.execute("attach database ? as previous", (str(previous_index),))
167
+ except sqlite3.DatabaseError:
168
+ return
169
+ try:
170
+ conn.execute(
171
+ """
172
+ insert or replace into semantic_embeddings(
173
+ source_id, model, text_sha256, dimensions, vector_json, indexed_at
174
+ )
175
+ select prev.source_id, prev.model, prev.text_sha256,
176
+ prev.dimensions, prev.vector_json, prev.indexed_at
177
+ from previous.semantic_embeddings prev
178
+ join memory_documents md
179
+ on md.source_id = prev.source_id and md.text_sha256 = prev.text_sha256
180
+ """
181
+ )
182
+ conn.commit()
183
+ except sqlite3.DatabaseError:
184
+ conn.rollback()
185
+ finally:
186
+ conn.execute("detach database previous")
187
+
188
+
189
+ def _index_into(conn: sqlite3.Connection, root: str | Path) -> IndexResult:
190
+ conn.execute("delete from memory_terms")
191
+ conn.execute("delete from memory_passages")
192
+ conn.execute("delete from memory_documents")
193
+ conn.execute("delete from message_artifacts")
194
+ conn.execute("delete from headers")
195
+ conn.execute("delete from messages")
196
+ try:
197
+ conn.execute("delete from message_fts")
198
+ except sqlite3.DatabaseError:
199
+ pass
200
+
201
+ paths = paths_for(root)
202
+ result = IndexResult()
203
+ seen: dict[str, list[str]] = defaultdict(list)
204
+ for kind, mailbox in discover_mailboxes(root):
205
+ for record in iter_records(mailbox):
206
+ rowid, message_id, malformed = _insert_record(
207
+ conn=conn,
208
+ root=paths.root,
209
+ kind=kind,
210
+ mailbox=record.mailbox,
211
+ state=record.state,
212
+ path=record.path,
213
+ )
214
+ result.indexed += 1
215
+ if malformed:
216
+ result.malformed += 1
217
+ if message_id:
218
+ seen[message_id].append(str(record.path.relative_to(paths.root)))
219
+
220
+ index_session_summaries(conn)
221
+ result.duplicates = {mid: files for mid, files in seen.items() if len(files) > 1}
222
+ return result
223
+
224
+
225
+ def _insert_record(
226
+ *,
227
+ conn: sqlite3.Connection,
228
+ root: Path,
229
+ kind: str,
230
+ mailbox: Path,
231
+ state: str,
232
+ path: Path,
233
+ ) -> tuple[int, str | None, bool]:
234
+ relative_mailbox = str(mailbox.relative_to(root))
235
+ relative_file = str(path.relative_to(root))
236
+ errors: list[str] = []
237
+ try:
238
+ parsed = parse_envelope(path)
239
+ errors.extend(validate_required(parsed))
240
+ msg = parsed.message
241
+ body_text = parsed.body_text
242
+ body_sha = parsed.body_sha256
243
+ except Exception as exc:
244
+ msg = None
245
+ body_text = ""
246
+ body_sha = None
247
+ errors.append(f"parse error: {exc}")
248
+
249
+ get = msg.get if msg is not None else lambda _name, _default=None: None
250
+ malformed = bool(errors)
251
+ message_id = get("Message-ID")
252
+ event_type = get("X-AgentDir-Event-Type")
253
+ subject = get("Subject")
254
+ from_actor = get("From")
255
+ to_actor = get("To")
256
+ session_id = get("X-AgentDir-Session")
257
+ task_id = get("X-AgentDir-Task")
258
+ date_header = get("Date")
259
+ date_utc = _date_to_utc_iso(date_header)
260
+ git_head = get("X-AgentDir-Git-Head")
261
+ workspace = get("X-AgentDir-Workspace")
262
+ tool = get("X-AgentDir-Tool")
263
+ tool_exit_code = _int_or_none(get("X-AgentDir-Tool-Exit-Code"))
264
+ indexed_at = now_iso()
265
+ cursor = conn.execute(
266
+ """
267
+ insert into messages(
268
+ message_id, mailbox_kind, mailbox_path, file_path, state, event_type,
269
+ subject, from_actor, to_actor, session_id, task_id, parent_message_id,
270
+ date_header, date_utc, created_ns, git_head, workspace, tool,
271
+ tool_exit_code, body_sha256, body_text, indexed_at,
272
+ malformed, errors
273
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
274
+ """,
275
+ (
276
+ message_id,
277
+ kind,
278
+ relative_mailbox,
279
+ relative_file,
280
+ state,
281
+ event_type,
282
+ subject,
283
+ from_actor,
284
+ to_actor,
285
+ session_id,
286
+ task_id,
287
+ get("In-Reply-To"),
288
+ date_header,
289
+ date_utc,
290
+ _int_or_none(get("X-AgentDir-Created-Ns")),
291
+ git_head,
292
+ workspace,
293
+ tool,
294
+ tool_exit_code,
295
+ body_sha,
296
+ body_text,
297
+ indexed_at,
298
+ 1 if malformed else 0,
299
+ json.dumps(errors),
300
+ ),
301
+ )
302
+ rowid = int(cursor.lastrowid)
303
+ if msg is not None:
304
+ for name, value in msg.items():
305
+ conn.execute(
306
+ "insert into headers(message_rowid, name, value) values (?, ?, ?)",
307
+ (rowid, name, value),
308
+ )
309
+ for sha in msg.get_all("X-AgentDir-Blob-SHA256", []):
310
+ conn.execute(
311
+ "insert into message_artifacts(message_rowid, sha256) values (?, ?)",
312
+ (rowid, sha),
313
+ )
314
+ try:
315
+ conn.execute(
316
+ "insert into message_fts(rowid, message_id, subject, body_text) values (?, ?, ?, ?)",
317
+ (rowid, message_id, subject, body_text),
318
+ )
319
+ except sqlite3.DatabaseError:
320
+ pass
321
+ index_memory_document(
322
+ conn,
323
+ message_rowid=rowid,
324
+ message_id=message_id,
325
+ session_id=session_id,
326
+ event_type=event_type,
327
+ subject=subject,
328
+ from_actor=from_actor,
329
+ to_actor=to_actor,
330
+ task_id=task_id,
331
+ tool=tool,
332
+ tool_exit_code=tool_exit_code,
333
+ workspace=workspace,
334
+ git_head=git_head,
335
+ date_header=date_header,
336
+ date_utc=date_utc,
337
+ file_path=relative_file,
338
+ body_text=body_text,
339
+ indexed_at=indexed_at,
340
+ )
341
+ return rowid, message_id, malformed
342
+
343
+
344
+ def _int_or_none(value: str | None) -> int | None:
345
+ if value is None:
346
+ return None
347
+ try:
348
+ return int(value)
349
+ except ValueError:
350
+ return None
351
+
352
+
353
+ def _date_to_utc_iso(value: str | None) -> str | None:
354
+ if value is None:
355
+ return None
356
+ try:
357
+ parsed = parsedate_to_datetime(value)
358
+ except (TypeError, ValueError):
359
+ return None
360
+ if parsed.tzinfo is None:
361
+ parsed = parsed.replace(tzinfo=UTC)
362
+ return parsed.astimezone(UTC).isoformat()
agentdir/mailbox.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ import socket
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+
11
+ from .store import AgentDirError, ensure_mailbox
12
+
13
+ _SEQUENCE = 0
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MaildirRecord:
18
+ mailbox: Path
19
+ state: str
20
+ path: Path
21
+
22
+
23
+ def _safe_hostname() -> str:
24
+ hostname = socket.gethostname() or "localhost"
25
+ return hostname.replace("/", "\\057").replace(":", "\\072").lstrip(".") or "localhost"
26
+
27
+
28
+ def unique_basename() -> str:
29
+ global _SEQUENCE
30
+ _SEQUENCE += 1
31
+ basename = f"{time.time_ns()}.P{os.getpid()}Q{_SEQUENCE}R{secrets.token_hex(8)}.{_safe_hostname()}"
32
+ if "/" in basename or ":" in basename or basename.startswith("."):
33
+ raise AgentDirError("Generated unsafe Maildir basename")
34
+ return basename
35
+
36
+
37
+ def fsync_directory(path: Path) -> None:
38
+ if not hasattr(os, "O_DIRECTORY"):
39
+ return
40
+ try:
41
+ fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
42
+ except OSError:
43
+ return
44
+ try:
45
+ os.fsync(fd)
46
+ finally:
47
+ os.close(fd)
48
+
49
+
50
+ def atomic_deliver(mailbox: Path, data: bytes, basename: str | None = None) -> Path:
51
+ ensure_mailbox(mailbox)
52
+ for _ in range(20):
53
+ name = basename or unique_basename()
54
+ tmp_path = mailbox / "tmp" / name
55
+ new_path = mailbox / "new" / name
56
+ try:
57
+ fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
58
+ except FileExistsError:
59
+ basename = None
60
+ continue
61
+ with os.fdopen(fd, "wb") as handle:
62
+ handle.write(data)
63
+ handle.flush()
64
+ os.fsync(handle.fileno())
65
+ os.replace(tmp_path, new_path)
66
+ fsync_directory(mailbox / "tmp")
67
+ fsync_directory(mailbox / "new")
68
+ return new_path
69
+ raise AgentDirError("Unable to create a unique Maildir filename")
70
+
71
+
72
+ def iter_records(mailbox: Path, states: Iterable[str] = ("new", "cur")) -> list[MaildirRecord]:
73
+ records: list[MaildirRecord] = []
74
+ for state in states:
75
+ if state not in {"new", "cur"}:
76
+ continue
77
+ directory = mailbox / state
78
+ if not directory.is_dir():
79
+ continue
80
+ for path in sorted(directory.iterdir()):
81
+ if path.is_file() and not path.name.startswith("."):
82
+ records.append(MaildirRecord(mailbox=mailbox, state=state, path=path))
83
+ return records
84
+