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.
Files changed (118) hide show
  1. sessionwiki/__init__.py +74 -0
  2. sessionwiki/__main__.py +6 -0
  3. sessionwiki/_frontmatter.py +118 -0
  4. sessionwiki/_system_pages.py +44 -0
  5. sessionwiki/adapter_config.py +124 -0
  6. sessionwiki/adapters/__init__.py +134 -0
  7. sessionwiki/adapters/base.py +191 -0
  8. sessionwiki/adapters/claude_code.py +77 -0
  9. sessionwiki/adapters/codex_cli.py +226 -0
  10. sessionwiki/adapters/contrib/__init__.py +1 -0
  11. sessionwiki/adapters/contrib/chatgpt.py +252 -0
  12. sessionwiki/adapters/contrib/copilot_chat.py +121 -0
  13. sessionwiki/adapters/contrib/copilot_cli.py +115 -0
  14. sessionwiki/adapters/contrib/cursor.py +100 -0
  15. sessionwiki/adapters/contrib/gemini_cli.py +74 -0
  16. sessionwiki/adapters/contrib/obsidian.py +121 -0
  17. sessionwiki/adapters/opencode.py +169 -0
  18. sessionwiki/adapters/status.py +58 -0
  19. sessionwiki/adapters/zcode.py +45 -0
  20. sessionwiki/audit.py +174 -0
  21. sessionwiki/backlinks.py +224 -0
  22. sessionwiki/build.py +2786 -0
  23. sessionwiki/cache.py +401 -0
  24. sessionwiki/candidates.py +309 -0
  25. sessionwiki/categories.py +158 -0
  26. sessionwiki/changelog_timeline.py +422 -0
  27. sessionwiki/cli.py +1687 -0
  28. sessionwiki/compare.py +404 -0
  29. sessionwiki/completion.py +191 -0
  30. sessionwiki/confidence.py +179 -0
  31. sessionwiki/config.py +74 -0
  32. sessionwiki/config_schedule.py +77 -0
  33. sessionwiki/context_md.py +182 -0
  34. sessionwiki/convert.py +1746 -0
  35. sessionwiki/docs_pages.py +725 -0
  36. sessionwiki/doctor.py +113 -0
  37. sessionwiki/exporters.py +680 -0
  38. sessionwiki/freshness.py +153 -0
  39. sessionwiki/graph.py +1058 -0
  40. sessionwiki/graphify_bridge.py +509 -0
  41. sessionwiki/ingest_queue.py +101 -0
  42. sessionwiki/lifecycle.py +167 -0
  43. sessionwiki/link_checker.py +148 -0
  44. sessionwiki/lint/__init__.py +170 -0
  45. sessionwiki/lint/rules/__init__.py +69 -0
  46. sessionwiki/lint/rules/_helpers.py +119 -0
  47. sessionwiki/lint/rules/claim_verification.py +44 -0
  48. sessionwiki/lint/rules/content_freshness.py +41 -0
  49. sessionwiki/lint/rules/contradiction_detection.py +45 -0
  50. sessionwiki/lint/rules/duplicate_detection.py +164 -0
  51. sessionwiki/lint/rules/entity_consistency.py +29 -0
  52. sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
  53. sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
  54. sessionwiki/lint/rules/frontmatter_validity.py +71 -0
  55. sessionwiki/lint/rules/index_sync.py +68 -0
  56. sessionwiki/lint/rules/link_integrity.py +34 -0
  57. sessionwiki/lint/rules/orphan_detection.py +43 -0
  58. sessionwiki/lint/rules/stale_candidates.py +59 -0
  59. sessionwiki/lint/rules/stale_reference_detection.py +42 -0
  60. sessionwiki/lint/rules/summary_accuracy.py +36 -0
  61. sessionwiki/lint/rules/tags_topics_convention.py +58 -0
  62. sessionwiki/lint/rules/tools_consistency.py +65 -0
  63. sessionwiki/lock.py +126 -0
  64. sessionwiki/log_reader.py +129 -0
  65. sessionwiki/manifest.py +132 -0
  66. sessionwiki/mcp/__init__.py +29 -0
  67. sessionwiki/mcp/__main__.py +5 -0
  68. sessionwiki/mcp/server.py +1061 -0
  69. sessionwiki/models_page.py +291 -0
  70. sessionwiki/obsidian_output.py +208 -0
  71. sessionwiki/opencode/__init__.py +1 -0
  72. sessionwiki/opencode/autosync.py +102 -0
  73. sessionwiki/opencode/export.py +419 -0
  74. sessionwiki/opencode/install.py +152 -0
  75. sessionwiki/pipeline.py +150 -0
  76. sessionwiki/project_topics.py +213 -0
  77. sessionwiki/py.typed +0 -0
  78. sessionwiki/quarantine.py +273 -0
  79. sessionwiki/queue.py +29 -0
  80. sessionwiki/references.py +224 -0
  81. sessionwiki/render/__init__.py +20 -0
  82. sessionwiki/render/css.py +988 -0
  83. sessionwiki/render/docs_css.py +463 -0
  84. sessionwiki/render/js.py +1337 -0
  85. sessionwiki/schema.py +270 -0
  86. sessionwiki/search_facets.py +150 -0
  87. sessionwiki/search_tree.py +184 -0
  88. sessionwiki/serve.py +91 -0
  89. sessionwiki/session_export.py +333 -0
  90. sessionwiki/skill_installer.py +112 -0
  91. sessionwiki/sync/__init__.py +13 -0
  92. sessionwiki/sync/status.py +120 -0
  93. sessionwiki/synth/__init__.py +14 -0
  94. sessionwiki/synth/agent_delegate.py +327 -0
  95. sessionwiki/synth/base.py +125 -0
  96. sessionwiki/synth/cli_helpers.py +98 -0
  97. sessionwiki/synth/estimate.py +146 -0
  98. sessionwiki/synth/ollama.py +379 -0
  99. sessionwiki/synth/openai_compat.py +325 -0
  100. sessionwiki/synth/pipeline.py +1489 -0
  101. sessionwiki/tag_utils.py +62 -0
  102. sessionwiki/tags.py +359 -0
  103. sessionwiki/vault.py +347 -0
  104. sessionwiki/viz_heatmap.py +305 -0
  105. sessionwiki/viz_tokens.py +429 -0
  106. sessionwiki/viz_tools.py +241 -0
  107. sessionwiki/watch.py +154 -0
  108. sessionwiki/zcode/__init__.py +43 -0
  109. sessionwiki/zcode/autosync.py +150 -0
  110. sessionwiki/zcode/export.py +568 -0
  111. sessionwiki/zcode/install.py +386 -0
  112. sessionwiki-2.0.0.dist-info/METADATA +303 -0
  113. sessionwiki-2.0.0.dist-info/RECORD +118 -0
  114. sessionwiki-2.0.0.dist-info/WHEEL +5 -0
  115. sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
  116. sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
  117. sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
  118. 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
+ }