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.
Files changed (48) hide show
  1. agent_wiki/__init__.py +1 -0
  2. agent_wiki/adapters/__init__.py +62 -0
  3. agent_wiki/adapters/claude_code.py +277 -0
  4. agent_wiki/adapters/drop_zone.py +114 -0
  5. agent_wiki/adapters/opencode.py +261 -0
  6. agent_wiki/cli.py +913 -0
  7. agent_wiki/config.py +243 -0
  8. agent_wiki/context.py +224 -0
  9. agent_wiki/conversation.py +246 -0
  10. agent_wiki/data/guide.md +20 -0
  11. agent_wiki/doctor.py +450 -0
  12. agent_wiki/fetch.py +173 -0
  13. agent_wiki/guide.py +54 -0
  14. agent_wiki/hooks/__init__.py +31 -0
  15. agent_wiki/hooks/claude.py +115 -0
  16. agent_wiki/hooks/manual.py +35 -0
  17. agent_wiki/index.py +56 -0
  18. agent_wiki/ingest.py +501 -0
  19. agent_wiki/lint.py +265 -0
  20. agent_wiki/locking.py +72 -0
  21. agent_wiki/log.py +33 -0
  22. agent_wiki/page.py +156 -0
  23. agent_wiki/redact.py +68 -0
  24. agent_wiki/remote.py +174 -0
  25. agent_wiki/search.py +161 -0
  26. agent_wiki/server/__init__.py +0 -0
  27. agent_wiki/server/app.py +52 -0
  28. agent_wiki/server/auth.py +23 -0
  29. agent_wiki/server/routes.py +158 -0
  30. agent_wiki/server/schemas.py +37 -0
  31. agent_wiki/server_config.py +82 -0
  32. agent_wiki/service.py +334 -0
  33. agent_wiki/show.py +47 -0
  34. agent_wiki/skills/awiki-ingest/SKILL.md +28 -0
  35. agent_wiki/skills/awiki-save/SKILL.md +71 -0
  36. agent_wiki/skills/awiki-search/SKILL.md +28 -0
  37. agent_wiki/summarize.py +159 -0
  38. agent_wiki/sync.py +174 -0
  39. agent_wiki/tag_fix.py +94 -0
  40. agent_wiki/tag_suggest.py +117 -0
  41. agent_wiki/tag_yaml.py +103 -0
  42. agent_wiki/tags.py +68 -0
  43. agent_wiki/vault.py +54 -0
  44. agent_wiki_kb-0.7.2.dist-info/METADATA +20 -0
  45. agent_wiki_kb-0.7.2.dist-info/RECORD +48 -0
  46. agent_wiki_kb-0.7.2.dist-info/WHEEL +4 -0
  47. agent_wiki_kb-0.7.2.dist-info/entry_points.txt +6 -0
  48. 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")