agent-wiki-kb 0.7.2__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_wiki/__init__.py +1 -0
- agent_wiki/adapters/__init__.py +62 -0
- agent_wiki/adapters/claude_code.py +277 -0
- agent_wiki/adapters/drop_zone.py +114 -0
- agent_wiki/adapters/opencode.py +261 -0
- agent_wiki/cli.py +913 -0
- agent_wiki/config.py +243 -0
- agent_wiki/context.py +224 -0
- agent_wiki/conversation.py +246 -0
- agent_wiki/data/guide.md +20 -0
- agent_wiki/doctor.py +450 -0
- agent_wiki/fetch.py +173 -0
- agent_wiki/guide.py +54 -0
- agent_wiki/hooks/__init__.py +31 -0
- agent_wiki/hooks/claude.py +115 -0
- agent_wiki/hooks/manual.py +35 -0
- agent_wiki/index.py +56 -0
- agent_wiki/ingest.py +501 -0
- agent_wiki/lint.py +265 -0
- agent_wiki/locking.py +72 -0
- agent_wiki/log.py +33 -0
- agent_wiki/page.py +156 -0
- agent_wiki/redact.py +68 -0
- agent_wiki/remote.py +174 -0
- agent_wiki/search.py +161 -0
- agent_wiki/server/__init__.py +0 -0
- agent_wiki/server/app.py +52 -0
- agent_wiki/server/auth.py +23 -0
- agent_wiki/server/routes.py +158 -0
- agent_wiki/server/schemas.py +37 -0
- agent_wiki/server_config.py +82 -0
- agent_wiki/service.py +334 -0
- agent_wiki/show.py +47 -0
- agent_wiki/skills/awiki-ingest/SKILL.md +28 -0
- agent_wiki/skills/awiki-save/SKILL.md +71 -0
- agent_wiki/skills/awiki-search/SKILL.md +28 -0
- agent_wiki/summarize.py +159 -0
- agent_wiki/sync.py +174 -0
- agent_wiki/tag_fix.py +94 -0
- agent_wiki/tag_suggest.py +117 -0
- agent_wiki/tag_yaml.py +103 -0
- agent_wiki/tags.py +68 -0
- agent_wiki/vault.py +54 -0
- agent_wiki_kb-0.7.2.dist-info/METADATA +20 -0
- agent_wiki_kb-0.7.2.dist-info/RECORD +48 -0
- agent_wiki_kb-0.7.2.dist-info/WHEEL +4 -0
- agent_wiki_kb-0.7.2.dist-info/entry_points.txt +6 -0
- agent_wiki_kb-0.7.2.dist-info/licenses/LICENSE +625 -0
agent_wiki/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.2"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Conversation adapters: agent-native formats → canonical Conversation bundles.
|
|
2
|
+
|
|
3
|
+
Each adapter is instantiated with a config dict (from wiki.yaml `sources.<name>`)
|
|
4
|
+
and exposes:
|
|
5
|
+
|
|
6
|
+
- ``discover()`` → iterable of opaque ``SessionRef`` values (anything the
|
|
7
|
+
adapter understands internally — usually a ``Path`` or string session id).
|
|
8
|
+
- ``fingerprint(ref)`` → string used for idempotent sync state tracking.
|
|
9
|
+
Typically ``"<mtime>"`` or ``"<hash>"``.
|
|
10
|
+
- ``to_bundle(ref)`` → ``Conversation``.
|
|
11
|
+
|
|
12
|
+
The registry at the bottom of this module is how ``sync.py`` finds adapters
|
|
13
|
+
by name. Adding a new adapter = writing a class + one line in the registry.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import Any, Iterable
|
|
19
|
+
|
|
20
|
+
from agent_wiki.conversation import Conversation
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConversationAdapter(ABC):
|
|
24
|
+
"""Abstract base for all conversation sources."""
|
|
25
|
+
|
|
26
|
+
name: str = ""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
|
29
|
+
self.config = config or {}
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def discover(self) -> Iterable[Any]:
|
|
33
|
+
"""Yield session references (adapter-internal type)."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def fingerprint(self, ref: Any) -> str:
|
|
37
|
+
"""Return a stable string reflecting current session state."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def to_bundle(self, ref: Any) -> Conversation:
|
|
41
|
+
"""Convert a session reference into a Conversation."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_adapter(name: str, config: dict[str, Any] | None = None) -> ConversationAdapter:
|
|
45
|
+
"""Factory: name → adapter instance.
|
|
46
|
+
|
|
47
|
+
Raises KeyError for unknown names. Imports are deferred so adapters don't
|
|
48
|
+
all load unless asked for.
|
|
49
|
+
"""
|
|
50
|
+
if name in ("claude-code", "claude_code", "cc"):
|
|
51
|
+
from agent_wiki.adapters.claude_code import ClaudeCodeAdapter
|
|
52
|
+
return ClaudeCodeAdapter(config)
|
|
53
|
+
if name == "opencode":
|
|
54
|
+
from agent_wiki.adapters.opencode import OpencodeAdapter
|
|
55
|
+
return OpencodeAdapter(config)
|
|
56
|
+
if name in ("drop-zone", "drop_zone", "dropzone"):
|
|
57
|
+
from agent_wiki.adapters.drop_zone import DropZoneAdapter
|
|
58
|
+
return DropZoneAdapter(config)
|
|
59
|
+
raise KeyError(f"unknown adapter: {name!r}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
ADAPTER_NAMES = ("claude-code", "opencode", "drop-zone")
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Reads JSONL session transcripts from ``~/.claude/projects/<slug>/*.jsonl``.
|
|
4
|
+
|
|
5
|
+
Each line is a JSON record with a ``type`` field. We care about:
|
|
6
|
+
|
|
7
|
+
- ``user`` / ``assistant`` — the turns themselves.
|
|
8
|
+
- ``custom-title`` — human-set session title, if present.
|
|
9
|
+
|
|
10
|
+
User ``message.content`` is either a string (direct user input) or a list of
|
|
11
|
+
blocks (``text``, ``tool_result``). Assistant ``message.content`` is always a
|
|
12
|
+
list of blocks (``text``, ``thinking``, ``tool_use``).
|
|
13
|
+
|
|
14
|
+
We transcribe one section per turn, collapse tool_use / tool_result bodies,
|
|
15
|
+
drop ``thinking`` blocks (they're internal to the model), and skip non-turn
|
|
16
|
+
record types like ``permission-mode``, ``file-history-snapshot``, etc.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from datetime import datetime, timedelta, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Iterable
|
|
27
|
+
|
|
28
|
+
from agent_wiki.adapters import ConversationAdapter
|
|
29
|
+
from agent_wiki.conversation import Conversation
|
|
30
|
+
|
|
31
|
+
DEFAULT_ROOT = Path.home() / ".claude" / "projects"
|
|
32
|
+
LIVE_THRESHOLD = timedelta(minutes=60)
|
|
33
|
+
|
|
34
|
+
# Directory-name → project slug. Claude Code encodes cwd paths like:
|
|
35
|
+
# /home/user/AI/Projects/agent-wiki → -home-user-AI-Projects-agent-wiki
|
|
36
|
+
# The reverse is lossy (slashes vs dashes) so we just use the trailing segment
|
|
37
|
+
# as the friendly project slug.
|
|
38
|
+
_PROJECT_SLUG_RE = re.compile(r"[^A-Za-z0-9]+")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _friendly_project(encoded_dir: str) -> str:
|
|
42
|
+
# Keep the trailing alphanumeric segment as the human-readable project name.
|
|
43
|
+
parts = [p for p in encoded_dir.split("-") if p]
|
|
44
|
+
return parts[-1] if parts else encoded_dir
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClaudeCodeAdapter(ConversationAdapter):
|
|
48
|
+
name = "claude-code"
|
|
49
|
+
|
|
50
|
+
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
|
51
|
+
super().__init__(config)
|
|
52
|
+
path = self.config.get("path")
|
|
53
|
+
self.root = Path(path).expanduser() if path else DEFAULT_ROOT
|
|
54
|
+
self.include_live = bool(self.config.get("include_live", False))
|
|
55
|
+
self.since: datetime | None = None
|
|
56
|
+
|
|
57
|
+
def discover(self) -> Iterable[Path]:
|
|
58
|
+
if not self.root.exists():
|
|
59
|
+
return
|
|
60
|
+
now = datetime.now(timezone.utc)
|
|
61
|
+
for jsonl in sorted(self.root.rglob("*.jsonl")):
|
|
62
|
+
try:
|
|
63
|
+
mtime = datetime.fromtimestamp(jsonl.stat().st_mtime, tz=timezone.utc)
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
if not self.include_live and now - mtime < LIVE_THRESHOLD:
|
|
67
|
+
continue
|
|
68
|
+
if self.since and mtime < self.since:
|
|
69
|
+
continue
|
|
70
|
+
yield jsonl
|
|
71
|
+
|
|
72
|
+
def fingerprint(self, ref: Path) -> str:
|
|
73
|
+
return f"mtime:{int(ref.stat().st_mtime)}:size:{ref.stat().st_size}"
|
|
74
|
+
|
|
75
|
+
def to_bundle(self, ref: Path) -> Conversation:
|
|
76
|
+
return convert_jsonl(ref)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Conversion (pure function so it's easy to test without a real session dir)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def convert_jsonl(path: Path) -> Conversation:
|
|
85
|
+
"""Convert a Claude Code JSONL transcript to a Conversation bundle."""
|
|
86
|
+
session_id: str | None = None
|
|
87
|
+
custom_title: str | None = None
|
|
88
|
+
project: str | None = None
|
|
89
|
+
cwd: str | None = None
|
|
90
|
+
model: str | None = None
|
|
91
|
+
started: datetime | None = None
|
|
92
|
+
ended: datetime | None = None
|
|
93
|
+
tool_counts: Counter[str] = Counter()
|
|
94
|
+
input_tokens = 0
|
|
95
|
+
output_tokens = 0
|
|
96
|
+
turns = 0
|
|
97
|
+
|
|
98
|
+
sections: list[str] = []
|
|
99
|
+
|
|
100
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
101
|
+
for line in f:
|
|
102
|
+
line = line.strip()
|
|
103
|
+
if not line:
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
rec = json.loads(line)
|
|
107
|
+
except json.JSONDecodeError:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
rtype = rec.get("type")
|
|
111
|
+
|
|
112
|
+
if rtype == "custom-title":
|
|
113
|
+
custom_title = rec.get("customTitle") or custom_title
|
|
114
|
+
|
|
115
|
+
if not session_id:
|
|
116
|
+
session_id = rec.get("sessionId")
|
|
117
|
+
if not cwd:
|
|
118
|
+
cwd = rec.get("cwd")
|
|
119
|
+
|
|
120
|
+
if rtype not in ("user", "assistant"):
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
ts = _parse_ts(rec.get("timestamp"))
|
|
124
|
+
if ts:
|
|
125
|
+
started = started if started and started < ts else (started or ts)
|
|
126
|
+
ended = ts
|
|
127
|
+
|
|
128
|
+
msg = rec.get("message") or {}
|
|
129
|
+
role = msg.get("role") or rtype
|
|
130
|
+
content = msg.get("content")
|
|
131
|
+
|
|
132
|
+
if role == "assistant" and not model:
|
|
133
|
+
model = msg.get("model")
|
|
134
|
+
|
|
135
|
+
if role == "assistant":
|
|
136
|
+
usage = msg.get("usage") or {}
|
|
137
|
+
input_tokens += int(usage.get("input_tokens") or 0)
|
|
138
|
+
output_tokens += int(usage.get("output_tokens") or 0)
|
|
139
|
+
|
|
140
|
+
rendered, used_tools = _render_blocks(content)
|
|
141
|
+
for t in used_tools:
|
|
142
|
+
tool_counts[t] += 1
|
|
143
|
+
|
|
144
|
+
if not rendered:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
ts_str = ts.strftime("%H:%M:%S") if ts else ""
|
|
148
|
+
header = f"## [{ts_str}] {role}" if ts_str else f"## {role}"
|
|
149
|
+
sections.append(f"{header}\n\n{rendered}")
|
|
150
|
+
turns += 1
|
|
151
|
+
|
|
152
|
+
if not session_id:
|
|
153
|
+
session_id = path.stem
|
|
154
|
+
|
|
155
|
+
if cwd:
|
|
156
|
+
project = Path(cwd).name
|
|
157
|
+
elif path.parent != path:
|
|
158
|
+
project = _friendly_project(path.parent.name)
|
|
159
|
+
|
|
160
|
+
title = custom_title or _derive_title(sections) or (project or session_id)
|
|
161
|
+
|
|
162
|
+
body = "\n\n".join(sections).rstrip() + "\n"
|
|
163
|
+
|
|
164
|
+
return Conversation(
|
|
165
|
+
agent="claude-code",
|
|
166
|
+
session_id=session_id,
|
|
167
|
+
title=title,
|
|
168
|
+
body=body,
|
|
169
|
+
project=project,
|
|
170
|
+
started=started,
|
|
171
|
+
ended=ended,
|
|
172
|
+
model=model,
|
|
173
|
+
turns=turns,
|
|
174
|
+
tool_counts=dict(tool_counts),
|
|
175
|
+
token_totals={"input": input_tokens, "output": output_tokens} if (input_tokens or output_tokens) else {},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_ts(value: Any) -> datetime | None:
|
|
180
|
+
if not value or not isinstance(value, str):
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
184
|
+
except ValueError:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _render_blocks(content: Any) -> tuple[str, list[str]]:
|
|
189
|
+
"""Return (rendered_text, tool_names_used)."""
|
|
190
|
+
if content is None:
|
|
191
|
+
return "", []
|
|
192
|
+
if isinstance(content, str):
|
|
193
|
+
return content.strip(), []
|
|
194
|
+
if not isinstance(content, list):
|
|
195
|
+
return "", []
|
|
196
|
+
|
|
197
|
+
pieces: list[str] = []
|
|
198
|
+
tools: list[str] = []
|
|
199
|
+
for block in content:
|
|
200
|
+
if not isinstance(block, dict):
|
|
201
|
+
continue
|
|
202
|
+
bt = block.get("type")
|
|
203
|
+
if bt == "text":
|
|
204
|
+
txt = (block.get("text") or "").strip()
|
|
205
|
+
if txt:
|
|
206
|
+
pieces.append(txt)
|
|
207
|
+
elif bt == "thinking":
|
|
208
|
+
continue
|
|
209
|
+
elif bt == "tool_use":
|
|
210
|
+
name = block.get("name") or "tool"
|
|
211
|
+
tools.append(name)
|
|
212
|
+
summary = _summarize_tool_use(name, block.get("input") or {})
|
|
213
|
+
pieces.append(f"**tool_use:** `{name}` — {summary}")
|
|
214
|
+
elif bt == "tool_result":
|
|
215
|
+
txt = _summarize_tool_result(block.get("content"))
|
|
216
|
+
if txt:
|
|
217
|
+
pieces.append(f"**tool_result:**\n\n{txt}")
|
|
218
|
+
return "\n\n".join(p for p in pieces if p).strip(), tools
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _summarize_tool_use(name: str, inp: dict) -> str:
|
|
222
|
+
# Short, scannable summaries. Full details live in the raw JSONL if ever needed.
|
|
223
|
+
if name == "Bash":
|
|
224
|
+
cmd = (inp.get("command") or "").splitlines()
|
|
225
|
+
return f"`{cmd[0][:160]}`" if cmd else ""
|
|
226
|
+
if name == "Read":
|
|
227
|
+
return inp.get("file_path", "")
|
|
228
|
+
if name == "Write":
|
|
229
|
+
return inp.get("file_path", "")
|
|
230
|
+
if name == "Edit":
|
|
231
|
+
return inp.get("file_path", "")
|
|
232
|
+
if name == "Grep":
|
|
233
|
+
return f"pattern={inp.get('pattern','')!r}"
|
|
234
|
+
if name == "Glob":
|
|
235
|
+
return f"pattern={inp.get('pattern','')!r}"
|
|
236
|
+
# Fallback: first scalar value, truncated
|
|
237
|
+
for v in inp.values():
|
|
238
|
+
if isinstance(v, str):
|
|
239
|
+
return v[:160]
|
|
240
|
+
return ""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _summarize_tool_result(content: Any, max_chars: int = 500) -> str:
|
|
244
|
+
if content is None:
|
|
245
|
+
return ""
|
|
246
|
+
if isinstance(content, str):
|
|
247
|
+
return _truncate(content, max_chars)
|
|
248
|
+
if isinstance(content, list):
|
|
249
|
+
parts = []
|
|
250
|
+
for b in content:
|
|
251
|
+
if isinstance(b, dict):
|
|
252
|
+
if b.get("type") == "text":
|
|
253
|
+
parts.append(b.get("text") or "")
|
|
254
|
+
elif b.get("type") == "image":
|
|
255
|
+
parts.append("[image]")
|
|
256
|
+
else:
|
|
257
|
+
parts.append(str(b))
|
|
258
|
+
return _truncate("\n".join(parts), max_chars)
|
|
259
|
+
return _truncate(str(content), max_chars)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _truncate(text: str, max_chars: int) -> str:
|
|
263
|
+
text = text.strip()
|
|
264
|
+
if len(text) <= max_chars:
|
|
265
|
+
return text
|
|
266
|
+
return text[:max_chars].rstrip() + f"\n… [truncated {len(text)-max_chars} chars]"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _derive_title(sections: list[str]) -> str | None:
|
|
270
|
+
# First user message, first line, trimmed.
|
|
271
|
+
for s in sections:
|
|
272
|
+
if s.startswith("## ") and " user" in s.splitlines()[0]:
|
|
273
|
+
lines = [l for l in s.splitlines()[1:] if l.strip()]
|
|
274
|
+
if lines:
|
|
275
|
+
first = lines[0].lstrip("#").strip()
|
|
276
|
+
return first[:80] if first else None
|
|
277
|
+
return None
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Drop zone adapter.
|
|
2
|
+
|
|
3
|
+
External producers (e.g. a personal assistant) write conversation bundles
|
|
4
|
+
directly into a configured directory. This adapter validates them against
|
|
5
|
+
``Doc/conversation-bundle-schema.md`` and moves valid ones into
|
|
6
|
+
``<vault>/raw/sessions/``. Malformed bundles are quarantined under
|
|
7
|
+
``<drop_zone>/rejected/`` with a ``.reason`` sidecar.
|
|
8
|
+
|
|
9
|
+
Unlike the other adapters, this one mutates the filesystem inside
|
|
10
|
+
``to_bundle``: moving the file out of the drop zone is what makes a given
|
|
11
|
+
bundle "ingested" from the producer's perspective. The fingerprint is the
|
|
12
|
+
drop-zone filename itself so sync won't re-process a file once it has been
|
|
13
|
+
moved.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import shutil
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Iterable
|
|
22
|
+
|
|
23
|
+
from agent_wiki.adapters import ConversationAdapter
|
|
24
|
+
from agent_wiki.conversation import BUNDLE_SUBDIR, Conversation, read_bundle
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DropZoneRef:
|
|
29
|
+
path: Path
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"drop-zone:{self.path.name}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DropZoneAdapter(ConversationAdapter):
|
|
36
|
+
name = "drop-zone"
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
|
39
|
+
super().__init__(config)
|
|
40
|
+
self._vault_path: Path | None = None
|
|
41
|
+
self._drop_zone: Path | None = None
|
|
42
|
+
|
|
43
|
+
path = self.config.get("path")
|
|
44
|
+
if path:
|
|
45
|
+
p = Path(path).expanduser()
|
|
46
|
+
if p.is_absolute():
|
|
47
|
+
self._drop_zone = p
|
|
48
|
+
else:
|
|
49
|
+
# Resolve against vault later if we haven't been given one yet.
|
|
50
|
+
self._relative = p
|
|
51
|
+
else:
|
|
52
|
+
self._relative = Path("incoming")
|
|
53
|
+
|
|
54
|
+
# The sync layer passes vault_path implicitly by constructing the adapter
|
|
55
|
+
# after load_vault_config; we resolve the relative drop zone lazily.
|
|
56
|
+
def _resolve_zone(self) -> Path:
|
|
57
|
+
if self._drop_zone is not None:
|
|
58
|
+
return self._drop_zone
|
|
59
|
+
if self._vault_path is None:
|
|
60
|
+
# Fall back to CWD — adapters may also be used outside of sync.
|
|
61
|
+
return Path.cwd() / self._relative
|
|
62
|
+
return self._vault_path / self._relative
|
|
63
|
+
|
|
64
|
+
def set_vault(self, vault_path: Path) -> None:
|
|
65
|
+
self._vault_path = vault_path
|
|
66
|
+
|
|
67
|
+
def discover(self) -> Iterable[DropZoneRef]:
|
|
68
|
+
zone = self._resolve_zone()
|
|
69
|
+
if not zone.exists():
|
|
70
|
+
return
|
|
71
|
+
for p in sorted(zone.glob("*.md")):
|
|
72
|
+
if not p.is_file():
|
|
73
|
+
continue
|
|
74
|
+
yield DropZoneRef(path=p)
|
|
75
|
+
|
|
76
|
+
def fingerprint(self, ref: DropZoneRef) -> str:
|
|
77
|
+
# Content hash so re-dropping a file with the same name but different
|
|
78
|
+
# content is treated as an update.
|
|
79
|
+
return f"sha1:{_sha1(ref.path)}"
|
|
80
|
+
|
|
81
|
+
def to_bundle(self, ref: DropZoneRef) -> Conversation:
|
|
82
|
+
zone = self._resolve_zone()
|
|
83
|
+
try:
|
|
84
|
+
conv = read_bundle(ref.path)
|
|
85
|
+
except ValueError as e:
|
|
86
|
+
_quarantine(zone, ref.path, str(e))
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
if self._vault_path is not None:
|
|
90
|
+
dest_dir = self._vault_path / BUNDLE_SUBDIR
|
|
91
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
dest = dest_dir / f"{conv.bundle_id()}.md"
|
|
93
|
+
shutil.move(str(ref.path), str(dest))
|
|
94
|
+
|
|
95
|
+
return conv
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sha1(path: Path) -> str:
|
|
99
|
+
h = hashlib.sha1()
|
|
100
|
+
with open(path, "rb") as f:
|
|
101
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
102
|
+
h.update(chunk)
|
|
103
|
+
return h.hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _quarantine(zone: Path, path: Path, reason: str) -> None:
|
|
107
|
+
rej_dir = zone / "rejected"
|
|
108
|
+
rej_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
target = rej_dir / path.name
|
|
110
|
+
try:
|
|
111
|
+
shutil.move(str(path), str(target))
|
|
112
|
+
except Exception:
|
|
113
|
+
return
|
|
114
|
+
(target.with_suffix(target.suffix + ".reason")).write_text(reason + "\n")
|