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/__init__.py +3 -0
- agentdir/__main__.py +6 -0
- agentdir/actors.py +50 -0
- agentdir/artifacts.py +56 -0
- agentdir/audit.py +339 -0
- agentdir/capture.py +187 -0
- agentdir/cli.py +1900 -0
- agentdir/context.py +654 -0
- agentdir/control.py +729 -0
- agentdir/daemon.py +253 -0
- agentdir/doctor.py +115 -0
- agentdir/envelope.py +147 -0
- agentdir/events.py +72 -0
- agentdir/federation.py +530 -0
- agentdir/git.py +45 -0
- agentdir/gitignore.py +106 -0
- agentdir/hooks.py +215 -0
- agentdir/index.py +362 -0
- agentdir/mailbox.py +84 -0
- agentdir/memory.py +1274 -0
- agentdir/query.py +65 -0
- agentdir/redaction.py +42 -0
- agentdir/rendering.py +89 -0
- agentdir/replay.py +31 -0
- agentdir/retention.py +319 -0
- agentdir/review.py +288 -0
- agentdir/secrets.py +158 -0
- agentdir/sessions.py +171 -0
- agentdir/skills.py +685 -0
- agentdir/store.py +270 -0
- agentdir/upgrade.py +252 -0
- agentdir_cli-0.7.5.dist-info/METADATA +393 -0
- agentdir_cli-0.7.5.dist-info/RECORD +37 -0
- agentdir_cli-0.7.5.dist-info/WHEEL +5 -0
- agentdir_cli-0.7.5.dist-info/entry_points.txt +2 -0
- agentdir_cli-0.7.5.dist-info/licenses/LICENSE +21 -0
- agentdir_cli-0.7.5.dist-info/top_level.txt +1 -0
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
|
+
|