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
sessionwiki/audit.py ADDED
@@ -0,0 +1,174 @@
1
+ """Pre-publish privacy audit for raw/, wiki/, and site/.
2
+
3
+ The converter redacts known secrets at ingest time, but generated pages
4
+ and tool output can still contain a username, a home path, or a token
5
+ that appeared after redaction. This command is a read-only second look:
6
+
7
+ sessionwiki audit
8
+ sessionwiki audit --path site --json
9
+ sessionwiki audit --fail-on-findings
10
+
11
+ It never edits files. Exit code is 1 only with ``--fail-on-findings``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any, Iterable, Optional, Sequence
21
+
22
+ from sessionwiki import REPO_ROOT
23
+ from sessionwiki.config import resolve_config
24
+ from sessionwiki.convert import _DEFAULT_TOKEN_PATTERNS
25
+
26
+ DEFAULT_RELATIVE_PATHS = ("raw", "wiki", "site")
27
+ TEXT_EXTENSIONS = {
28
+ ".md", ".txt", ".json", ".jsonl", ".html", ".htm", ".xml",
29
+ ".yml", ".yaml", ".toml", ".css", ".js", ".log", ".csv",
30
+ }
31
+ SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".pytest_cache", ".ruff_cache"}
32
+
33
+
34
+ def _iter_files(paths: Sequence[Path]) -> Iterable[Path]:
35
+ for base in paths:
36
+ path = Path(base)
37
+ if path.is_file():
38
+ yield path
39
+ continue
40
+ if not path.is_dir():
41
+ continue
42
+ for child in path.rglob("*"):
43
+ if any(part in SKIP_DIRS for part in child.parts):
44
+ continue
45
+ if child.is_file() and child.suffix.lower() in TEXT_EXTENSIONS:
46
+ yield child
47
+
48
+
49
+ def _preview(line: str, start: int, end: int, limit: int = 180) -> str:
50
+ masked = line[:start] + "<MATCH>" + line[end:]
51
+ return " ".join(masked.split())[:limit]
52
+
53
+
54
+ def _compile_user_patterns(patterns: Sequence[str]) -> list[re.Pattern[str]]:
55
+ compiled: list[re.Pattern[str]] = []
56
+ for raw in patterns:
57
+ try:
58
+ compiled.append(re.compile(raw))
59
+ except re.error:
60
+ continue
61
+ return compiled
62
+
63
+
64
+ def audit_paths(
65
+ paths: Sequence[Path],
66
+ *,
67
+ username: Optional[str] = None,
68
+ extra_patterns: Sequence[str] = (),
69
+ max_findings: int = 200,
70
+ ) -> dict[str, Any]:
71
+ """Scan text files and return findings. Does not modify anything."""
72
+ findings: list[dict[str, Any]] = []
73
+ files_scanned = 0
74
+ secret_patterns = [
75
+ (pattern, "secret") for pattern in _DEFAULT_TOKEN_PATTERNS
76
+ ]
77
+ secret_patterns.extend(
78
+ (pattern, "pattern") for pattern in _compile_user_patterns(extra_patterns)
79
+ )
80
+ user_re = re.compile(re.escape(username), re.IGNORECASE) if username else None
81
+
82
+ for file_path in _iter_files(paths):
83
+ if len(findings) >= max_findings:
84
+ break
85
+ try:
86
+ raw = file_path.read_bytes()
87
+ except OSError:
88
+ continue
89
+ if b"\x00" in raw[:4096]:
90
+ continue
91
+ text = raw.decode("utf-8", errors="replace")
92
+ files_scanned += 1
93
+ for line_number, line in enumerate(text.splitlines(), 1):
94
+ if user_re is not None:
95
+ for match in user_re.finditer(line):
96
+ findings.append(
97
+ {
98
+ "path": str(file_path),
99
+ "line": line_number,
100
+ "kind": "username",
101
+ "preview": _preview(line, match.start(), match.end()),
102
+ }
103
+ )
104
+ if len(findings) >= max_findings:
105
+ break
106
+ for pattern, kind in secret_patterns:
107
+ for match in pattern.finditer(line):
108
+ findings.append(
109
+ {
110
+ "path": str(file_path),
111
+ "line": line_number,
112
+ "kind": kind,
113
+ "preview": _preview(line, match.start(), match.end()),
114
+ }
115
+ )
116
+ if len(findings) >= max_findings:
117
+ break
118
+ if len(findings) >= max_findings:
119
+ break
120
+
121
+ counts: dict[str, int] = {}
122
+ for finding in findings:
123
+ counts[finding["kind"]] = counts.get(finding["kind"], 0) + 1
124
+ return {
125
+ "files_scanned": files_scanned,
126
+ "username": username or "",
127
+ "findings": findings,
128
+ "counts": counts,
129
+ "truncated": len(findings) >= max_findings,
130
+ }
131
+
132
+
133
+ def cmd_audit(args: Any) -> int:
134
+ """Print the privacy report for the selected paths."""
135
+ configured = resolve_config()
136
+ redaction = configured.get("redaction") if isinstance(configured, dict) else None
137
+ redaction = redaction if isinstance(redaction, dict) else {}
138
+ username = (
139
+ getattr(args, "username", None)
140
+ or redaction.get("real_username")
141
+ or os.environ.get("USER")
142
+ or os.environ.get("USERNAME")
143
+ or ""
144
+ )
145
+ paths = getattr(args, "path", None)
146
+ if not paths:
147
+ paths = [REPO_ROOT / relative for relative in DEFAULT_RELATIVE_PATHS]
148
+
149
+ report = audit_paths(
150
+ paths,
151
+ username=username,
152
+ extra_patterns=redaction.get("extra_patterns") or [],
153
+ )
154
+
155
+ if getattr(args, "json", False):
156
+ print(json.dumps(report, ensure_ascii=False, indent=2))
157
+ else:
158
+ print(f"audit: scanned {report['files_scanned']} file(s)")
159
+ print(f"audit: {len(report['findings'])} finding(s)")
160
+ for kind, count in sorted(report["counts"].items()):
161
+ print(f" {kind}: {count}")
162
+ for finding in report["findings"][:50]:
163
+ print(
164
+ f" {finding['path']}:{finding['line']} "
165
+ f"[{finding['kind']}] {finding['preview']}"
166
+ )
167
+ if report["truncated"]:
168
+ print(" … findings truncated")
169
+ if report["findings"]:
170
+ print("audit: review before publishing raw/, wiki/, or site/")
171
+
172
+ if report["findings"] and getattr(args, "fail_on_findings", False):
173
+ return 1
174
+ return 0
@@ -0,0 +1,224 @@
1
+ """Backlink injection: every wiki page that's linked-to gets a managed
2
+ ``## Referenced by`` section listing its referrers (#328).
3
+
4
+ Today a source page links out to ``[[Pratiyush]]`` but the
5
+ ``entities/Pratiyush.md`` page doesn't know about it — 95% of pages
6
+ end up as graph orphans (575 / 596 sources with zero inbound links).
7
+
8
+ This module rebuilds the reverse-reference index
9
+ (:func:`sessionwiki.references.build_index`) and injects a managed
10
+ section into each target page bounded by HTML-comment sentinels so
11
+ rerun is idempotent:
12
+
13
+ <!-- BACKLINKS:START -->
14
+ ## Referenced by
15
+
16
+ - [[session-slug-1]] — Session: title 1 (2026-04-19)
17
+ - [[session-slug-2]] — Session: title 2 (2026-04-18)
18
+ - …
19
+ <!-- BACKLINKS:END -->
20
+
21
+ Characteristics:
22
+
23
+ * Stdlib-only.
24
+ * Safe on every page — we only write inside the sentinels; everything
25
+ else is preserved verbatim.
26
+ * Skips pages that are already inside a ``wiki/archive/`` subtree.
27
+ * Caps each list at 50 entries so model entity pages don't balloon
28
+ (the full list stays reachable via the graph + ``sessionwiki references
29
+ <slug>``).
30
+ * Sorts by date descending when the referrer has a ``date:`` field,
31
+ otherwise alphabetically by title.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any, Optional
40
+
41
+ from sessionwiki import REPO_ROOT
42
+
43
+
44
+ _START = "<!-- BACKLINKS:START -->"
45
+ _END = "<!-- BACKLINKS:END -->"
46
+ _BACKLINK_BLOCK_RE = re.compile(
47
+ re.escape(_START) + r".*?" + re.escape(_END) + r"\s*",
48
+ re.DOTALL,
49
+ )
50
+ DEFAULT_MAX_ENTRIES = 50
51
+
52
+
53
+ @dataclass
54
+ class BacklinkEntry:
55
+ """One row in a ``## Referenced by`` list."""
56
+
57
+ slug: str
58
+ title: str
59
+ date: str # may be empty
60
+
61
+ def sort_key(self) -> tuple[str, str]:
62
+ # Newest-first when date is ISO-8601; alphabetical by title otherwise.
63
+ return (self.date or "", self.title.lower())
64
+
65
+
66
+ def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
67
+ m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
68
+ if not m:
69
+ return {}, text
70
+ fm: dict[str, str] = {}
71
+ for line in m.group(1).splitlines():
72
+ mm = re.match(r"^([a-zA-Z_][\w-]*):\s*(.*)$", line)
73
+ if mm:
74
+ fm[mm.group(1)] = mm.group(2).strip().strip("'\"")
75
+ return fm, m.group(2)
76
+
77
+
78
+ def _collect_pages(wiki_dir: Path) -> dict[str, dict[str, Any]]:
79
+ """Gather every wiki page into ``{slug: {path, meta, body}}``.
80
+
81
+ Skips ``wiki/archive/`` (deprecated) and ``_context.md`` stubs —
82
+ neither wants a backlinks block.
83
+ """
84
+ out: dict[str, dict[str, Any]] = {}
85
+ if not wiki_dir.is_dir():
86
+ return out
87
+ for p in sorted(wiki_dir.rglob("*.md")):
88
+ if "archive" in p.parts:
89
+ continue
90
+ if p.name == "_context.md":
91
+ continue
92
+ try:
93
+ text = p.read_text(encoding="utf-8")
94
+ except OSError:
95
+ continue
96
+ meta, body = _parse_frontmatter(text)
97
+ out[p.stem] = {"path": p, "meta": meta, "body": body, "text": text}
98
+ return out
99
+
100
+
101
+ # Match `[[wikilink]]` or `[[wikilink|display]]` targets.
102
+ _WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]")
103
+
104
+
105
+ def build_reverse_index(pages: dict[str, dict[str, Any]]) -> dict[str, list[BacklinkEntry]]:
106
+ """Return ``{target_slug: [BacklinkEntry(referrer)]}``.
107
+
108
+ An entry is produced for every ``[[Target]]`` occurrence in any
109
+ page's body (even if the Target doesn't exist — the graph's
110
+ broken-link checker handles that separately). Self-references are
111
+ skipped (a page never appears in its own backlinks list).
112
+ """
113
+ rev: dict[str, list[BacklinkEntry]] = {}
114
+ for slug, page in pages.items():
115
+ body = page["body"]
116
+ for target in set(_WIKILINK_RE.findall(body)):
117
+ t = target.split("#")[0].strip()
118
+ if not t or t == slug:
119
+ continue
120
+ rev.setdefault(t, []).append(
121
+ BacklinkEntry(
122
+ slug=slug,
123
+ title=page["meta"].get("title", slug),
124
+ date=page["meta"].get("date", ""),
125
+ )
126
+ )
127
+ return rev
128
+
129
+
130
+ def _render_block(entries: list[BacklinkEntry], *, max_entries: int) -> str:
131
+ """Render the sentinel-bounded ``## Referenced by`` block."""
132
+ ordered = sorted(entries, key=lambda e: e.sort_key(), reverse=True)
133
+ truncated = len(ordered) > max_entries
134
+ ordered = ordered[:max_entries]
135
+ lines = [_START, "", "## Referenced by", ""]
136
+ for e in ordered:
137
+ suffix = f" — {e.title}" if e.title and e.title != e.slug else ""
138
+ if e.date:
139
+ suffix = f"{suffix} ({e.date})" if suffix else f" ({e.date})"
140
+ lines.append(f"- [[{e.slug}]]{suffix}")
141
+ if truncated:
142
+ lines.append(f"")
143
+ lines.append(
144
+ f"*…and {len(entries) - max_entries} more referrer(s) — "
145
+ f"run `sessionwiki references <slug>` for the full list.*"
146
+ )
147
+ lines.append("")
148
+ lines.append(_END)
149
+ return "\n".join(lines) + "\n"
150
+
151
+
152
+ def inject_block(text: str, block: str) -> str:
153
+ """Replace or append the backlinks block in ``text``.
154
+
155
+ Finds the existing sentinel-bounded section and swaps it; appends
156
+ to the end of the file with a leading blank line when the sentinels
157
+ aren't present. Idempotent.
158
+ """
159
+ stripped = text.rstrip()
160
+ if _START in text and _END in text:
161
+ new_text = _BACKLINK_BLOCK_RE.sub(block, text, count=1)
162
+ # Normalise trailing newlines.
163
+ if not new_text.endswith("\n"):
164
+ new_text += "\n"
165
+ return new_text
166
+ return stripped + "\n\n" + block
167
+
168
+
169
+ def remove_block(text: str) -> str:
170
+ """Strip any existing backlinks block (used by ``--prune``)."""
171
+ if _START not in text:
172
+ return text
173
+ return _BACKLINK_BLOCK_RE.sub("", text).rstrip() + "\n"
174
+
175
+
176
+ def inject_all(
177
+ wiki_dir: Optional[Path] = None,
178
+ *,
179
+ max_entries: int = DEFAULT_MAX_ENTRIES,
180
+ dry_run: bool = False,
181
+ ) -> dict[str, int]:
182
+ """Walk ``wiki_dir`` and inject backlinks into every target page.
183
+
184
+ Returns ``{slug: num_backlinks}`` for every page that got an
185
+ (added, updated, or unchanged) block.
186
+ """
187
+ root = wiki_dir or (REPO_ROOT / "wiki")
188
+ pages = _collect_pages(root)
189
+ rev = build_reverse_index(pages)
190
+ results: dict[str, int] = {}
191
+ for target_slug, entries in rev.items():
192
+ target = pages.get(target_slug)
193
+ if not target:
194
+ # Broken wikilink — skip.
195
+ continue
196
+ block = _render_block(entries, max_entries=max_entries)
197
+ new_text = inject_block(target["text"], block)
198
+ if new_text != target["text"] and not dry_run:
199
+ target["path"].write_text(new_text, encoding="utf-8")
200
+ results[target_slug] = len(entries)
201
+ return results
202
+
203
+
204
+ def prune_all(
205
+ wiki_dir: Optional[Path] = None, *, dry_run: bool = False
206
+ ) -> int:
207
+ """Strip backlink blocks from every page. Returns count touched."""
208
+ root = wiki_dir or (REPO_ROOT / "wiki")
209
+ count = 0
210
+ for p in root.rglob("*.md"):
211
+ if "archive" in p.parts:
212
+ continue
213
+ try:
214
+ text = p.read_text(encoding="utf-8")
215
+ except OSError:
216
+ continue
217
+ if _START not in text:
218
+ continue
219
+ new_text = remove_block(text)
220
+ if new_text != text:
221
+ count += 1
222
+ if not dry_run:
223
+ p.write_text(new_text, encoding="utf-8")
224
+ return count