sessionwiki 2.0.0__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.
- sessionwiki/__init__.py +74 -0
- sessionwiki/__main__.py +6 -0
- sessionwiki/_frontmatter.py +118 -0
- sessionwiki/_system_pages.py +44 -0
- sessionwiki/adapter_config.py +124 -0
- sessionwiki/adapters/__init__.py +134 -0
- sessionwiki/adapters/base.py +191 -0
- sessionwiki/adapters/claude_code.py +77 -0
- sessionwiki/adapters/codex_cli.py +226 -0
- sessionwiki/adapters/contrib/__init__.py +1 -0
- sessionwiki/adapters/contrib/chatgpt.py +252 -0
- sessionwiki/adapters/contrib/copilot_chat.py +121 -0
- sessionwiki/adapters/contrib/copilot_cli.py +115 -0
- sessionwiki/adapters/contrib/cursor.py +100 -0
- sessionwiki/adapters/contrib/gemini_cli.py +74 -0
- sessionwiki/adapters/contrib/obsidian.py +121 -0
- sessionwiki/adapters/opencode.py +169 -0
- sessionwiki/adapters/status.py +58 -0
- sessionwiki/adapters/zcode.py +45 -0
- sessionwiki/audit.py +174 -0
- sessionwiki/backlinks.py +224 -0
- sessionwiki/build.py +2786 -0
- sessionwiki/cache.py +401 -0
- sessionwiki/candidates.py +309 -0
- sessionwiki/categories.py +158 -0
- sessionwiki/changelog_timeline.py +422 -0
- sessionwiki/cli.py +1687 -0
- sessionwiki/compare.py +404 -0
- sessionwiki/completion.py +191 -0
- sessionwiki/confidence.py +179 -0
- sessionwiki/config.py +74 -0
- sessionwiki/config_schedule.py +77 -0
- sessionwiki/context_md.py +182 -0
- sessionwiki/convert.py +1746 -0
- sessionwiki/docs_pages.py +725 -0
- sessionwiki/doctor.py +113 -0
- sessionwiki/exporters.py +680 -0
- sessionwiki/freshness.py +153 -0
- sessionwiki/graph.py +1058 -0
- sessionwiki/graphify_bridge.py +509 -0
- sessionwiki/ingest_queue.py +101 -0
- sessionwiki/lifecycle.py +167 -0
- sessionwiki/link_checker.py +148 -0
- sessionwiki/lint/__init__.py +170 -0
- sessionwiki/lint/rules/__init__.py +69 -0
- sessionwiki/lint/rules/_helpers.py +119 -0
- sessionwiki/lint/rules/claim_verification.py +44 -0
- sessionwiki/lint/rules/content_freshness.py +41 -0
- sessionwiki/lint/rules/contradiction_detection.py +45 -0
- sessionwiki/lint/rules/duplicate_detection.py +164 -0
- sessionwiki/lint/rules/entity_consistency.py +29 -0
- sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
- sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
- sessionwiki/lint/rules/frontmatter_validity.py +71 -0
- sessionwiki/lint/rules/index_sync.py +68 -0
- sessionwiki/lint/rules/link_integrity.py +34 -0
- sessionwiki/lint/rules/orphan_detection.py +43 -0
- sessionwiki/lint/rules/stale_candidates.py +59 -0
- sessionwiki/lint/rules/stale_reference_detection.py +42 -0
- sessionwiki/lint/rules/summary_accuracy.py +36 -0
- sessionwiki/lint/rules/tags_topics_convention.py +58 -0
- sessionwiki/lint/rules/tools_consistency.py +65 -0
- sessionwiki/lock.py +126 -0
- sessionwiki/log_reader.py +129 -0
- sessionwiki/manifest.py +132 -0
- sessionwiki/mcp/__init__.py +29 -0
- sessionwiki/mcp/__main__.py +5 -0
- sessionwiki/mcp/server.py +1061 -0
- sessionwiki/models_page.py +291 -0
- sessionwiki/obsidian_output.py +208 -0
- sessionwiki/opencode/__init__.py +1 -0
- sessionwiki/opencode/autosync.py +102 -0
- sessionwiki/opencode/export.py +419 -0
- sessionwiki/opencode/install.py +152 -0
- sessionwiki/pipeline.py +150 -0
- sessionwiki/project_topics.py +213 -0
- sessionwiki/py.typed +0 -0
- sessionwiki/quarantine.py +273 -0
- sessionwiki/queue.py +29 -0
- sessionwiki/references.py +224 -0
- sessionwiki/render/__init__.py +20 -0
- sessionwiki/render/css.py +988 -0
- sessionwiki/render/docs_css.py +463 -0
- sessionwiki/render/js.py +1337 -0
- sessionwiki/schema.py +270 -0
- sessionwiki/search_facets.py +150 -0
- sessionwiki/search_tree.py +184 -0
- sessionwiki/serve.py +91 -0
- sessionwiki/session_export.py +333 -0
- sessionwiki/skill_installer.py +112 -0
- sessionwiki/sync/__init__.py +13 -0
- sessionwiki/sync/status.py +120 -0
- sessionwiki/synth/__init__.py +14 -0
- sessionwiki/synth/agent_delegate.py +327 -0
- sessionwiki/synth/base.py +125 -0
- sessionwiki/synth/cli_helpers.py +98 -0
- sessionwiki/synth/estimate.py +146 -0
- sessionwiki/synth/ollama.py +379 -0
- sessionwiki/synth/openai_compat.py +325 -0
- sessionwiki/synth/pipeline.py +1489 -0
- sessionwiki/tag_utils.py +62 -0
- sessionwiki/tags.py +359 -0
- sessionwiki/vault.py +347 -0
- sessionwiki/viz_heatmap.py +305 -0
- sessionwiki/viz_tokens.py +429 -0
- sessionwiki/viz_tools.py +241 -0
- sessionwiki/watch.py +154 -0
- sessionwiki/zcode/__init__.py +43 -0
- sessionwiki/zcode/autosync.py +150 -0
- sessionwiki/zcode/export.py +568 -0
- sessionwiki/zcode/install.py +386 -0
- sessionwiki-2.0.0.dist-info/METADATA +303 -0
- sessionwiki-2.0.0.dist-info/RECORD +118 -0
- sessionwiki-2.0.0.dist-info/WHEEL +5 -0
- sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
- sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
- sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
- sessionwiki-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Agent-delegate synthesizer (#316).
|
|
2
|
+
|
|
3
|
+
This backend implements :class:`BaseSynthesizer` but **never calls any
|
|
4
|
+
HTTP API**. Instead it writes the rendered prompt to a scratch file
|
|
5
|
+
under ``.llmwiki-pending-prompts/`` and returns a placeholder body.
|
|
6
|
+
The accompanying slash-command wrapper (``/wiki-sync``,
|
|
7
|
+
``/wiki-ingest``) is expected to read those pending prompts on the
|
|
8
|
+
next agent turn, synthesize the actual content inside the agent's
|
|
9
|
+
existing Claude Code / Codex CLI session (via the ``Skill`` tool or
|
|
10
|
+
inline generation), and write the final page back.
|
|
11
|
+
|
|
12
|
+
Why this exists
|
|
13
|
+
---------------
|
|
14
|
+
|
|
15
|
+
Mode A (``anthropic`` backend, issue #315) uses the user's API key
|
|
16
|
+
and counts against their token budget. Some users either don't have
|
|
17
|
+
an API key or would rather piggyback on their existing Claude Code
|
|
18
|
+
subscription — in that case Mode B (this module) defers the actual
|
|
19
|
+
generation to the agent that's already running, at zero incremental
|
|
20
|
+
cost.
|
|
21
|
+
|
|
22
|
+
Contract with the agent
|
|
23
|
+
-----------------------
|
|
24
|
+
|
|
25
|
+
1. On :meth:`synthesize_source_page` the backend:
|
|
26
|
+
|
|
27
|
+
* Allocates a UUID.
|
|
28
|
+
* Writes ``<repo>/.llmwiki-pending-prompts/<uuid>.md`` with the
|
|
29
|
+
full rendered prompt (body + meta).
|
|
30
|
+
* Returns a placeholder body whose first line is the sentinel
|
|
31
|
+
``<!-- sessionwiki-pending: <uuid> -->``.
|
|
32
|
+
|
|
33
|
+
2. The caller writes the page to ``wiki/sources/.../<slug>.md`` as
|
|
34
|
+
usual — the sentinel gives the slash-command layer a machine-
|
|
35
|
+
readable hook to find which pages are still pending.
|
|
36
|
+
|
|
37
|
+
3. The slash command reads the pending prompt inside the agent's
|
|
38
|
+
session, produces a synthesized body, and calls
|
|
39
|
+
:func:`complete_pending` to rewrite the source page in place.
|
|
40
|
+
|
|
41
|
+
The backend itself never calls the agent — that loop lives outside
|
|
42
|
+
this module. This file is pure file-I/O + sentinel handling.
|
|
43
|
+
|
|
44
|
+
Design goals
|
|
45
|
+
------------
|
|
46
|
+
|
|
47
|
+
* **No network.** The test suite can assert via ``socket`` that no
|
|
48
|
+
HTTP call ever happens.
|
|
49
|
+
* **No secrets.** Works when ``ANTHROPIC_API_KEY`` is unset.
|
|
50
|
+
* **Idempotent.** Re-running ``synthesize`` on the same slug
|
|
51
|
+
overwrites the pending prompt instead of accumulating orphans.
|
|
52
|
+
* **Graceful outside an agent.** ``is_available()`` returns ``False``
|
|
53
|
+
(with a helpful hint via :attr:`unavailable_reason`) when no agent
|
|
54
|
+
runtime is detected, so the pipeline can fall back to the dummy
|
|
55
|
+
backend instead of silently producing placeholders.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
import uuid
|
|
63
|
+
from dataclasses import dataclass
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
from typing import Any, Optional
|
|
66
|
+
|
|
67
|
+
from sessionwiki import REPO_ROOT
|
|
68
|
+
from sessionwiki.synth.base import BaseSynthesizer
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─── paths ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
PENDING_DIR_NAME = ".llmwiki-pending-prompts"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def pending_dir(override: Optional[Path] = None) -> Path:
|
|
77
|
+
"""Return the pending-prompts directory (created on demand).
|
|
78
|
+
|
|
79
|
+
If ``override`` is given, it's used **as-is** (tests pass a
|
|
80
|
+
``tmp_path``). Otherwise the standard location is
|
|
81
|
+
``REPO_ROOT/.llmwiki-pending-prompts``.
|
|
82
|
+
"""
|
|
83
|
+
d = override if override is not None else REPO_ROOT / PENDING_DIR_NAME
|
|
84
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
return d
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ─── sentinel ────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
# Emitted as the first line of the placeholder body so downstream
|
|
91
|
+
# tooling can find + replace pending pages cheaply.
|
|
92
|
+
_SENTINEL_RE = re.compile(
|
|
93
|
+
r"^\s*<!--\s*sessionwiki-pending:\s*(?P<uuid>[0-9a-f-]{36})\s*-->\s*$",
|
|
94
|
+
re.IGNORECASE | re.MULTILINE,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sentinel_for(uid: str) -> str:
|
|
99
|
+
return f"<!-- sessionwiki-pending: {uid} -->"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_pending(body: str) -> bool:
|
|
103
|
+
"""Return ``True`` iff ``body`` contains the pending sentinel."""
|
|
104
|
+
return bool(_SENTINEL_RE.search(body or ""))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_pending_uuid(body: str) -> Optional[str]:
|
|
108
|
+
"""Pull the ``<uuid>`` out of the sentinel, or ``None``."""
|
|
109
|
+
m = _SENTINEL_RE.search(body or "")
|
|
110
|
+
return m.group("uuid") if m else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ─── runtime detection ───────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _agent_runtime_detected() -> bool:
|
|
117
|
+
"""Heuristic: are we running inside an agent session?
|
|
118
|
+
|
|
119
|
+
We trust (in order):
|
|
120
|
+
|
|
121
|
+
* ``LLMWIKI_AGENT_MODE`` set to a truthy value — explicit opt-in,
|
|
122
|
+
wins over everything else.
|
|
123
|
+
* ``CLAUDE_CODE`` or ``CLAUDECODE`` — set by Claude Code sessions.
|
|
124
|
+
* ``CODEX_CLI`` — set by Codex CLI.
|
|
125
|
+
* ``CURSOR_AGENT`` — set by Cursor's chat pane.
|
|
126
|
+
|
|
127
|
+
If none of the above are set, we consider the backend unavailable
|
|
128
|
+
and return ``False`` so the pipeline falls back to dummy.
|
|
129
|
+
"""
|
|
130
|
+
explicit = os.environ.get("LLMWIKI_AGENT_MODE", "").strip().lower()
|
|
131
|
+
if explicit in {"1", "true", "yes", "on"}:
|
|
132
|
+
return True
|
|
133
|
+
if explicit in {"0", "false", "no", "off"}:
|
|
134
|
+
return False
|
|
135
|
+
for env_var in ("CLAUDE_CODE", "CLAUDECODE", "CODEX_CLI", "CURSOR_AGENT"):
|
|
136
|
+
if os.environ.get(env_var):
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ─── the backend ─────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class AgentDelegateSynthesizer(BaseSynthesizer):
|
|
146
|
+
"""Defer synthesis to the agent — no HTTP calls.
|
|
147
|
+
|
|
148
|
+
The backend's job is to write the rendered prompt to disk and
|
|
149
|
+
return a placeholder page. The slash-command layer picks up the
|
|
150
|
+
pending prompt on the next agent turn.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
pending_root: Optional[Path] = None
|
|
154
|
+
|
|
155
|
+
# Public so tests can assert on the message surface.
|
|
156
|
+
unavailable_reason: str = (
|
|
157
|
+
"Agent runtime not detected. Run /wiki-sync from inside Claude "
|
|
158
|
+
"Code or Codex CLI, or export LLMWIKI_AGENT_MODE=1 to force."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ─── BaseSynthesizer contract ────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def is_available(self) -> bool:
|
|
164
|
+
return _agent_runtime_detected()
|
|
165
|
+
|
|
166
|
+
def synthesize_source_page(
|
|
167
|
+
self,
|
|
168
|
+
raw_body: str,
|
|
169
|
+
meta: dict[str, Any],
|
|
170
|
+
prompt_template: str,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Defer synthesis: write the prompt + return a placeholder.
|
|
173
|
+
|
|
174
|
+
The placeholder's first line is a machine-readable sentinel
|
|
175
|
+
(``<!-- sessionwiki-pending: <uuid> -->``). Idempotency: if a
|
|
176
|
+
pending prompt already exists for this slug, we reuse its
|
|
177
|
+
uuid instead of orphaning it.
|
|
178
|
+
"""
|
|
179
|
+
slug = str(meta.get("slug", "unknown")).strip() or "unknown"
|
|
180
|
+
project = str(meta.get("project", "unknown")).strip() or "unknown"
|
|
181
|
+
date = str(meta.get("date", "unknown")).strip() or "unknown"
|
|
182
|
+
|
|
183
|
+
# Reuse the uuid for this slug if a pending prompt already exists.
|
|
184
|
+
pd = pending_dir(self.pending_root)
|
|
185
|
+
existing = _find_existing_uuid(pd, slug)
|
|
186
|
+
uid = existing or str(uuid.uuid4())
|
|
187
|
+
|
|
188
|
+
prompt_text = (
|
|
189
|
+
prompt_template
|
|
190
|
+
.replace("{body}", raw_body[:8000] if raw_body else "")
|
|
191
|
+
.replace(
|
|
192
|
+
"{meta}",
|
|
193
|
+
"\n".join(f"{k}: {v}" for k, v in (meta or {}).items()),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Write the prompt with a small header so a human can hand-process.
|
|
198
|
+
out_path = pd / f"{uid}.md"
|
|
199
|
+
out_path.write_text(
|
|
200
|
+
"\n".join([
|
|
201
|
+
f"<!-- pending-slug: {slug} -->",
|
|
202
|
+
f"<!-- pending-project: {project} -->",
|
|
203
|
+
f"<!-- pending-date: {date} -->",
|
|
204
|
+
"",
|
|
205
|
+
prompt_text,
|
|
206
|
+
]),
|
|
207
|
+
encoding="utf-8",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Return a placeholder the pipeline stores on disk. The
|
|
211
|
+
# sentinel MUST be on its own line so ``extract_pending_uuid``
|
|
212
|
+
# finds it.
|
|
213
|
+
return (
|
|
214
|
+
f"{sentinel_for(uid)}\n"
|
|
215
|
+
f"\n"
|
|
216
|
+
f"## Summary\n"
|
|
217
|
+
f"\n"
|
|
218
|
+
f"*Pending agent synthesis — uuid `{uid}`. Prompt at "
|
|
219
|
+
f"`{PENDING_DIR_NAME}/{uid}.md`.*\n"
|
|
220
|
+
f"\n"
|
|
221
|
+
f"The slash-command layer will rewrite this page on the next "
|
|
222
|
+
f"`/wiki-sync` turn.\n"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ─── completion helpers ──────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _find_existing_uuid(pending_root: Path, slug: str) -> Optional[str]:
|
|
230
|
+
"""Scan ``pending_root`` for an existing pending prompt tagged with
|
|
231
|
+
``slug``. Returns the uuid or ``None``.
|
|
232
|
+
"""
|
|
233
|
+
if not pending_root.exists():
|
|
234
|
+
return None
|
|
235
|
+
needle = f"<!-- pending-slug: {slug} -->"
|
|
236
|
+
for p in pending_root.glob("*.md"):
|
|
237
|
+
try:
|
|
238
|
+
text = p.read_text(encoding="utf-8", errors="ignore")
|
|
239
|
+
except OSError:
|
|
240
|
+
continue
|
|
241
|
+
if needle in text:
|
|
242
|
+
return p.stem
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def complete_pending(
|
|
247
|
+
uid: str,
|
|
248
|
+
synthesized_body: str,
|
|
249
|
+
target_page: Path,
|
|
250
|
+
pending_root: Optional[Path] = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Replace the placeholder body in ``target_page`` with the
|
|
253
|
+
synthesized body + delete the pending prompt file.
|
|
254
|
+
|
|
255
|
+
Preserves the frontmatter + appends any lines the agent wrote
|
|
256
|
+
after the sentinel (so hand edits survive). Raises
|
|
257
|
+
``FileNotFoundError`` if the target page doesn't exist;
|
|
258
|
+
``ValueError`` if the target page doesn't carry the pending
|
|
259
|
+
sentinel; :class:`OSError` for filesystem errors.
|
|
260
|
+
"""
|
|
261
|
+
if not target_page.exists():
|
|
262
|
+
raise FileNotFoundError(f"target page not found: {target_page}")
|
|
263
|
+
|
|
264
|
+
text = target_page.read_text(encoding="utf-8")
|
|
265
|
+
found_uid = extract_pending_uuid(text)
|
|
266
|
+
if found_uid is None:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"page has no pending sentinel: {target_page}"
|
|
269
|
+
)
|
|
270
|
+
if found_uid != uid:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"uuid mismatch: page carries {found_uid}, caller passed {uid}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Split frontmatter (``---\n...\n---\n``) from body.
|
|
276
|
+
if text.startswith("---\n"):
|
|
277
|
+
end = text.find("\n---\n", 4)
|
|
278
|
+
if end > 0:
|
|
279
|
+
frontmatter = text[: end + len("\n---\n")]
|
|
280
|
+
else:
|
|
281
|
+
frontmatter = ""
|
|
282
|
+
else:
|
|
283
|
+
frontmatter = ""
|
|
284
|
+
|
|
285
|
+
rest = text[len(frontmatter):] if frontmatter else text
|
|
286
|
+
# Strip the sentinel + placeholder body; keep whatever the body
|
|
287
|
+
# writer appended after ``\n`` (usually nothing).
|
|
288
|
+
_head, _sep, _tail = rest.partition(sentinel_for(uid))
|
|
289
|
+
# Replace with the agent's synthesized body.
|
|
290
|
+
new_body = synthesized_body.lstrip("\n")
|
|
291
|
+
target_page.write_text(frontmatter + new_body, encoding="utf-8")
|
|
292
|
+
|
|
293
|
+
# Delete the pending prompt file (best-effort — not an error if
|
|
294
|
+
# it's already gone).
|
|
295
|
+
pd = pending_root or pending_dir()
|
|
296
|
+
prompt_file = pd / f"{uid}.md"
|
|
297
|
+
if prompt_file.exists():
|
|
298
|
+
try:
|
|
299
|
+
prompt_file.unlink()
|
|
300
|
+
except OSError:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def list_pending(pending_root: Optional[Path] = None) -> list[dict[str, str]]:
|
|
305
|
+
"""Enumerate every pending prompt. Returns a list of dicts with
|
|
306
|
+
keys ``uuid``, ``slug``, ``project``, ``date``, ``path``.
|
|
307
|
+
"""
|
|
308
|
+
pd = pending_root or pending_dir()
|
|
309
|
+
if not pd.exists():
|
|
310
|
+
return []
|
|
311
|
+
out: list[dict[str, str]] = []
|
|
312
|
+
for p in sorted(pd.glob("*.md")):
|
|
313
|
+
try:
|
|
314
|
+
text = p.read_text(encoding="utf-8", errors="ignore")
|
|
315
|
+
except OSError:
|
|
316
|
+
continue
|
|
317
|
+
slug_m = re.search(r"^<!-- pending-slug:\s*(.*?)\s*-->", text, re.MULTILINE)
|
|
318
|
+
project_m = re.search(r"^<!-- pending-project:\s*(.*?)\s*-->", text, re.MULTILINE)
|
|
319
|
+
date_m = re.search(r"^<!-- pending-date:\s*(.*?)\s*-->", text, re.MULTILINE)
|
|
320
|
+
out.append({
|
|
321
|
+
"uuid": p.stem,
|
|
322
|
+
"slug": slug_m.group(1) if slug_m else "",
|
|
323
|
+
"project": project_m.group(1) if project_m else "",
|
|
324
|
+
"date": date_m.group(1) if date_m else "",
|
|
325
|
+
"path": str(p),
|
|
326
|
+
})
|
|
327
|
+
return out
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Synthesizer backends — ABC + built-in implementations (v0.5 · #36).
|
|
2
|
+
|
|
3
|
+
The `BaseSynthesizer` defines the contract: given a raw session markdown
|
|
4
|
+
body + its frontmatter, produce a wiki source-page body (the part under
|
|
5
|
+
the frontmatter). The concrete backend handles the actual LLM call.
|
|
6
|
+
|
|
7
|
+
Built-in backends:
|
|
8
|
+
- `DummySynthesizer` — returns a canned response. Used for testing and
|
|
9
|
+
for the `--dry-run` path so users can preview what would be generated.
|
|
10
|
+
- (Future) `OllamaSynthesizer` — calls a local Ollama instance (#35)
|
|
11
|
+
- (Future) `ClaudeAPISynthesizer` — calls the Anthropic API
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseSynthesizer(ABC):
|
|
23
|
+
"""Interface for LLM-backed wiki-page synthesizers."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def synthesize_source_page(
|
|
27
|
+
self,
|
|
28
|
+
raw_body: str,
|
|
29
|
+
meta: dict[str, Any],
|
|
30
|
+
prompt_template: str,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Given a raw session body + frontmatter, return a wiki
|
|
33
|
+
source-page body (markdown). The caller handles frontmatter
|
|
34
|
+
generation and file writing — the backend only generates the
|
|
35
|
+
prose content (Summary, Key Claims, Key Quotes, Connections).
|
|
36
|
+
|
|
37
|
+
`prompt_template` is the contents of `prompts/source_page.md`
|
|
38
|
+
with `{body}` and `{meta}` placeholders.
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def is_available(self) -> bool:
|
|
44
|
+
"""Return True if the backend is ready to use (e.g. the API
|
|
45
|
+
key is set, or the Ollama server is running)."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def name(self) -> str:
|
|
50
|
+
return self.__class__.__name__
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DummySynthesizer(BaseSynthesizer):
|
|
54
|
+
"""Test/preview backend — returns a canned wiki page without
|
|
55
|
+
calling any LLM. Useful for `--dry-run` and unit tests.
|
|
56
|
+
|
|
57
|
+
G-12 (#298): the dummy output used to copy every ``[[wikilink]]``
|
|
58
|
+
mention straight out of the raw body into ``## Connections``. That
|
|
59
|
+
fabricated 371 dangling links on the compiled demo site because
|
|
60
|
+
those targets almost never existed as wiki pages. The dummy now
|
|
61
|
+
emits only a single **real** connection — the project entity page,
|
|
62
|
+
which the ingest workflow guarantees exists — and surfaces raw
|
|
63
|
+
mentions as plain text in ``## Raw Mentions`` so the information
|
|
64
|
+
isn't lost but ``check-links`` doesn't cry wolf.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def _title_case_project(self, project: str) -> str:
|
|
68
|
+
"""``ai-newsletter`` → ``AiNewsletter`` (matches entity filenames)."""
|
|
69
|
+
return "".join(part.capitalize() for part in re.split(r"[-_\s]+", project) if part)
|
|
70
|
+
|
|
71
|
+
def synthesize_source_page(
|
|
72
|
+
self,
|
|
73
|
+
raw_body: str,
|
|
74
|
+
meta: dict[str, Any],
|
|
75
|
+
prompt_template: str,
|
|
76
|
+
) -> str:
|
|
77
|
+
slug = meta.get("slug", "unknown")
|
|
78
|
+
project = meta.get("project", "unknown")
|
|
79
|
+
date = meta.get("date", "unknown")
|
|
80
|
+
|
|
81
|
+
# Extract a naive summary from the first 500 chars
|
|
82
|
+
first_para = raw_body.strip().split("\n\n")[0][:500] if raw_body else ""
|
|
83
|
+
|
|
84
|
+
# Plain-text mentions — kept for human readers, but NOT emitted as
|
|
85
|
+
# [[wikilinks]] so check-links stays clean on auto-synthesized pages.
|
|
86
|
+
mentions = sorted(set(re.findall(r"\[\[([^\]]+)\]\]", raw_body)))
|
|
87
|
+
raw_mentions_block = (
|
|
88
|
+
"\n".join(f"- {m}" for m in mentions[:10])
|
|
89
|
+
if mentions
|
|
90
|
+
else "*(no mentions detected)*"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
project_entity = self._title_case_project(project) if project and project != "unknown" else ""
|
|
94
|
+
if project_entity:
|
|
95
|
+
connections_block = f"- [[{project_entity}]] — parent project"
|
|
96
|
+
else:
|
|
97
|
+
connections_block = "*(connections auto-extracted by a real synthesizer will appear here)*"
|
|
98
|
+
|
|
99
|
+
return f"""## Summary
|
|
100
|
+
|
|
101
|
+
Auto-synthesized from session `{slug}` on {date} (project: {project}).
|
|
102
|
+
|
|
103
|
+
{first_para}
|
|
104
|
+
|
|
105
|
+
## Key Claims
|
|
106
|
+
|
|
107
|
+
- Session covered project `{project}`
|
|
108
|
+
- Model: {meta.get('model', 'unknown')}
|
|
109
|
+
- {meta.get('user_messages', '?')} user messages, {meta.get('tool_calls', '?')} tool calls
|
|
110
|
+
|
|
111
|
+
## Key Quotes
|
|
112
|
+
|
|
113
|
+
> (Auto-synthesis — replace with actual quotes from the session)
|
|
114
|
+
|
|
115
|
+
## Connections
|
|
116
|
+
|
|
117
|
+
{connections_block}
|
|
118
|
+
|
|
119
|
+
## Raw Mentions
|
|
120
|
+
|
|
121
|
+
{raw_mentions_block}
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def is_available(self) -> bool:
|
|
125
|
+
return True # Always available — no external deps
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CLI helpers for ``sessionwiki synthesize`` extracted from cli.py
|
|
2
|
+
(#691 / #arch-h8).
|
|
3
|
+
|
|
4
|
+
These two helpers (`list_pending`, `complete`) wrap
|
|
5
|
+
``sessionwiki.synth.agent_delegate`` for the ``--list-pending`` and
|
|
6
|
+
``--complete <uuid>`` command-line subactions. They're synth-domain
|
|
7
|
+
logic with file I/O + stdin handling, not argparse glue, so they
|
|
8
|
+
move out of ``cli.py``. ``cli.py`` re-exports under the original
|
|
9
|
+
underscored names for back-compat.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from sessionwiki import REPO_ROOT
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def list_pending() -> int:
|
|
22
|
+
"""Print the pending-prompts table for ``--list-pending``.
|
|
23
|
+
|
|
24
|
+
Two-column layout: uuid │ slug · project · date. Exit 0 even when
|
|
25
|
+
empty — the slash-command layer treats "nothing pending" as a
|
|
26
|
+
success signal.
|
|
27
|
+
"""
|
|
28
|
+
from sessionwiki.synth.agent_delegate import list_pending as _list_pending
|
|
29
|
+
|
|
30
|
+
rows = _list_pending()
|
|
31
|
+
if not rows:
|
|
32
|
+
print("No pending prompts.")
|
|
33
|
+
return 0
|
|
34
|
+
# Max-width uuid column for alignment.
|
|
35
|
+
uuid_w = max(len(r["uuid"]) for r in rows)
|
|
36
|
+
print(f"{'UUID':<{uuid_w}} SLUG · PROJECT · DATE")
|
|
37
|
+
print(f"{'-' * uuid_w} " + "-" * 40)
|
|
38
|
+
for r in rows:
|
|
39
|
+
meta = " · ".join(
|
|
40
|
+
part for part in (r["slug"], r["project"], r["date"]) if part
|
|
41
|
+
)
|
|
42
|
+
print(f"{r['uuid']:<{uuid_w}} {meta}")
|
|
43
|
+
print(f"\n{len(rows)} pending prompt(s).")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def complete(args: argparse.Namespace) -> int:
|
|
48
|
+
"""Rewrite a placeholder wiki page with the agent's synthesis.
|
|
49
|
+
|
|
50
|
+
Reads the synthesized body from ``args.body`` (file) or stdin,
|
|
51
|
+
calls :func:`sessionwiki.synth.agent_delegate.complete_pending` to
|
|
52
|
+
replace the sentinel + prompt-file pair with the real content.
|
|
53
|
+
|
|
54
|
+
Exit codes:
|
|
55
|
+
|
|
56
|
+
* ``0`` — success
|
|
57
|
+
* ``1`` — missing --page, uuid mismatch, missing sentinel, or I/O
|
|
58
|
+
error
|
|
59
|
+
"""
|
|
60
|
+
from sessionwiki.synth.agent_delegate import complete_pending
|
|
61
|
+
|
|
62
|
+
if not args.page:
|
|
63
|
+
print("error: --complete requires --page <path>", file=sys.stderr)
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
page_path = Path(args.page)
|
|
67
|
+
if not page_path.is_absolute():
|
|
68
|
+
page_path = REPO_ROOT / page_path
|
|
69
|
+
|
|
70
|
+
if args.body:
|
|
71
|
+
body_path = Path(args.body)
|
|
72
|
+
if not body_path.is_absolute():
|
|
73
|
+
body_path = REPO_ROOT / body_path
|
|
74
|
+
try:
|
|
75
|
+
body = body_path.read_text(encoding="utf-8")
|
|
76
|
+
except OSError as e:
|
|
77
|
+
print(f"error: reading --body {body_path}: {e}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
else:
|
|
80
|
+
body = sys.stdin.read()
|
|
81
|
+
if not body:
|
|
82
|
+
print(
|
|
83
|
+
"error: --complete expects a body on stdin or via --body",
|
|
84
|
+
file=sys.stderr,
|
|
85
|
+
)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
complete_pending(args.complete, body, page_path)
|
|
90
|
+
except FileNotFoundError as e:
|
|
91
|
+
print(f"error: {e}", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
except ValueError as e:
|
|
94
|
+
print(f"error: {e}", file=sys.stderr)
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
print(f"completed: {page_path}")
|
|
98
|
+
return 0
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Synthesize cost-estimate report — pulled out of cli.py (#arch-h8 / #611).
|
|
2
|
+
|
|
3
|
+
Pre-#611 ``synthesize_estimate_report`` lived inside ``cli.py``. The
|
|
4
|
+
function is non-trivial business logic (G-07 / #293 cost-model walk)
|
|
5
|
+
that belongs next to the rest of the synth pipeline.
|
|
6
|
+
|
|
7
|
+
The function is re-exported from ``sessionwiki.cli`` so the existing
|
|
8
|
+
``from sessionwiki.cli import synthesize_estimate_report`` import path keeps
|
|
9
|
+
working for any test or caller that reached for it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from sessionwiki import REPO_ROOT
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def synthesize_estimate_report(
|
|
20
|
+
*,
|
|
21
|
+
raw_sessions: Optional[list[tuple[Any, dict, str]]] = None,
|
|
22
|
+
state_keys: Optional[set[str]] = None,
|
|
23
|
+
prefix_tokens: Optional[int] = None,
|
|
24
|
+
output_tokens_per_call: int = 1000,
|
|
25
|
+
model: Optional[str] = None,
|
|
26
|
+
) -> dict:
|
|
27
|
+
"""Compute the incremental vs full-force cost report (G-07 · #293).
|
|
28
|
+
|
|
29
|
+
Returns a plain dict so the CLI can render it AND tests can inspect
|
|
30
|
+
the numbers without parsing stdout. Keys:
|
|
31
|
+
|
|
32
|
+
* ``corpus`` — total raw sessions discovered under ``raw/sessions/``
|
|
33
|
+
* ``synthesized`` — count already synthesized (from state file)
|
|
34
|
+
* ``new`` — ``corpus - synthesized``
|
|
35
|
+
* ``incremental_usd`` — dollars to synthesize the ``new`` bucket
|
|
36
|
+
* ``full_force_usd`` — dollars to re-synthesize the **whole** corpus
|
|
37
|
+
with ``--force`` (one cache write + N-1 cache hits)
|
|
38
|
+
* ``prefix_tokens`` — tokens in the stable CLAUDE.md + index.md +
|
|
39
|
+
overview.md prefix
|
|
40
|
+
* ``model`` — model id used for pricing
|
|
41
|
+
* ``warnings`` — list of human-readable warnings (e.g. prefix too
|
|
42
|
+
small to be cached)
|
|
43
|
+
|
|
44
|
+
Any of the args can be injected for tests; the default reads from
|
|
45
|
+
disk and is what the CLI invokes.
|
|
46
|
+
"""
|
|
47
|
+
from sessionwiki.cache import (
|
|
48
|
+
DEFAULT_MODEL,
|
|
49
|
+
estimate_cost,
|
|
50
|
+
estimate_tokens,
|
|
51
|
+
warn_prefix_too_small,
|
|
52
|
+
)
|
|
53
|
+
from sessionwiki.synth.pipeline import RAW_SESSIONS as _RAW
|
|
54
|
+
from sessionwiki.synth.pipeline import _discover_raw_sessions, _load_state
|
|
55
|
+
|
|
56
|
+
chosen_model = model or DEFAULT_MODEL
|
|
57
|
+
warnings: list[str] = []
|
|
58
|
+
|
|
59
|
+
if prefix_tokens is None:
|
|
60
|
+
prefix_parts: list[str] = []
|
|
61
|
+
for rel in ("CLAUDE.md", "wiki/index.md", "wiki/overview.md"):
|
|
62
|
+
p = REPO_ROOT / rel
|
|
63
|
+
if p.is_file():
|
|
64
|
+
prefix_parts.append(p.read_text(encoding="utf-8"))
|
|
65
|
+
prefix_tokens = estimate_tokens("\n".join(prefix_parts))
|
|
66
|
+
prefix_warning = warn_prefix_too_small(prefix_tokens)
|
|
67
|
+
if prefix_warning:
|
|
68
|
+
warnings.append(prefix_warning)
|
|
69
|
+
|
|
70
|
+
if raw_sessions is None:
|
|
71
|
+
raw_sessions = _discover_raw_sessions()
|
|
72
|
+
if state_keys is None:
|
|
73
|
+
state_keys = set(_load_state().keys())
|
|
74
|
+
|
|
75
|
+
corpus = len(raw_sessions)
|
|
76
|
+
|
|
77
|
+
# The real synth state stores rel-paths under ``raw/sessions/``
|
|
78
|
+
# (e.g. ``proj/2026-04-09-slug.md``). Match against those first;
|
|
79
|
+
# fall back to bare filename + suffix-endswith for tests that
|
|
80
|
+
# inject simpler keys. A session counts as "synthesized" if any
|
|
81
|
+
# of those three keys already appears in state_keys.
|
|
82
|
+
# #py-m10 (#596): single-pass walk. The previous version walked
|
|
83
|
+
# raw_sessions twice — once to bucket new vs synthesised + collect
|
|
84
|
+
# body strings, once via a list comprehension to materialise the
|
|
85
|
+
# full-force body list — and then ran estimate_tokens(body) twice
|
|
86
|
+
# on each new session inside _bucket_usd. On a 5k-corpus that's
|
|
87
|
+
# 10k token-estimate calls + 2 full body materialisations in RAM.
|
|
88
|
+
# The pass below computes per-session tokens once, accumulates
|
|
89
|
+
# both bucket totals incrementally, and never holds more than one
|
|
90
|
+
# body string at a time.
|
|
91
|
+
synthed = 0
|
|
92
|
+
new = 0
|
|
93
|
+
incremental_usd = 0.0
|
|
94
|
+
full_force_usd = 0.0
|
|
95
|
+
incremental_first = True
|
|
96
|
+
full_force_first = True
|
|
97
|
+
|
|
98
|
+
def _add_to_bucket(fresh_tokens: int, first: bool) -> tuple[float, bool]:
|
|
99
|
+
"""Return (cost, was_first?). Cost-of-this-call uses cache_hit=
|
|
100
|
+
not first, mirroring the old _bucket_usd semantics."""
|
|
101
|
+
est = estimate_cost(
|
|
102
|
+
cached_tokens=prefix_tokens,
|
|
103
|
+
fresh_tokens=fresh_tokens,
|
|
104
|
+
output_tokens=output_tokens_per_call,
|
|
105
|
+
model=chosen_model,
|
|
106
|
+
cache_hit=not first,
|
|
107
|
+
)
|
|
108
|
+
return est.usd, False # second-and-later calls hit the cache
|
|
109
|
+
|
|
110
|
+
for p, _meta, body in raw_sessions:
|
|
111
|
+
keys_to_try: set[str] = set()
|
|
112
|
+
name = getattr(p, "name", str(p))
|
|
113
|
+
keys_to_try.add(name)
|
|
114
|
+
if hasattr(p, "relative_to"):
|
|
115
|
+
try:
|
|
116
|
+
keys_to_try.add(str(p.relative_to(_RAW)))
|
|
117
|
+
except (ValueError, AttributeError):
|
|
118
|
+
pass
|
|
119
|
+
keys_to_try.add(str(p))
|
|
120
|
+
matched = bool(keys_to_try & state_keys) or any(
|
|
121
|
+
isinstance(k, str) and k.endswith(name) for k in state_keys
|
|
122
|
+
)
|
|
123
|
+
body_tokens = estimate_tokens(body)
|
|
124
|
+
# Full-force bucket: every session contributes regardless of state.
|
|
125
|
+
ff_cost, full_force_first = _add_to_bucket(body_tokens, full_force_first)
|
|
126
|
+
full_force_usd += ff_cost
|
|
127
|
+
# Incremental bucket: only un-synthesised sessions contribute.
|
|
128
|
+
if matched:
|
|
129
|
+
synthed += 1
|
|
130
|
+
else:
|
|
131
|
+
new += 1
|
|
132
|
+
inc_cost, incremental_first = _add_to_bucket(
|
|
133
|
+
body_tokens, incremental_first
|
|
134
|
+
)
|
|
135
|
+
incremental_usd += inc_cost
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"corpus": corpus,
|
|
139
|
+
"synthesized": synthed,
|
|
140
|
+
"new": new,
|
|
141
|
+
"incremental_usd": incremental_usd,
|
|
142
|
+
"full_force_usd": full_force_usd,
|
|
143
|
+
"prefix_tokens": prefix_tokens,
|
|
144
|
+
"model": chosen_model,
|
|
145
|
+
"warnings": warnings,
|
|
146
|
+
}
|