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,74 @@
1
+ """sessionwiki — LLM-powered knowledge base from Claude Code, Codex CLI, Cursor,
2
+ Gemini CLI, and Obsidian sessions.
3
+
4
+ Follows Andrej Karpathy's LLM Wiki pattern:
5
+ https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
6
+
7
+ Public API:
8
+ - sessionwiki.cli.main() — the command-line entry point
9
+ - sessionwiki.convert.convert_all() — .jsonl → markdown
10
+ - sessionwiki.build.build_site() — markdown → HTML
11
+ - sessionwiki.serve.serve_site() — local HTTP server
12
+ - sessionwiki.graph.build_and_report() — knowledge graph
13
+ - sessionwiki.exporters.export_all() — AI-consumable exports
14
+ - sessionwiki.adapters.REGISTRY — adapter registry
15
+ - sessionwiki.mcp.server.main() — MCP server (stdio)
16
+ """
17
+
18
+ __version__ = "2.0.0"
19
+ __author__ = "Igor Lashkevich" # based on llmwiki by Pratiyush (MIT)
20
+ __license__ = "MIT"
21
+
22
+ from pathlib import Path
23
+
24
+ # Repo root (sessionwiki/ clone), resolved from this file's location.
25
+ REPO_ROOT = Path(__file__).resolve().parent.parent
26
+ PACKAGE_ROOT = Path(__file__).resolve().parent
27
+
28
+ # #arch-m4 (#617): the docstring above promised a "public API" but
29
+ # nothing was actually exported. Wire the listed symbols up as real
30
+ # re-exports so `from sessionwiki import build_site` etc. actually works.
31
+ # Lazy via __getattr__ so importing sessionwiki itself stays cheap (most
32
+ # CLI invocations don't touch the public API surface, just REPO_ROOT).
33
+ __all__ = [
34
+ "REPO_ROOT",
35
+ "PACKAGE_ROOT",
36
+ "__version__",
37
+ "main",
38
+ "convert_all",
39
+ "build_site",
40
+ "serve_site",
41
+ "build_and_report",
42
+ "export_all",
43
+ "REGISTRY",
44
+ ]
45
+
46
+
47
+ def __getattr__(name: str):
48
+ """PEP 562 module-level lazy attribute access.
49
+
50
+ Lets `from sessionwiki import build_site` resolve without paying for
51
+ the full transitive import graph at `import sessionwiki` time.
52
+ """
53
+ if name == "main":
54
+ from sessionwiki.cli import main
55
+ return main
56
+ if name == "convert_all":
57
+ from sessionwiki.convert import convert_all
58
+ return convert_all
59
+ if name == "build_site":
60
+ from sessionwiki.build import build_site
61
+ return build_site
62
+ if name == "serve_site":
63
+ from sessionwiki.serve import serve_site
64
+ return serve_site
65
+ if name == "build_and_report":
66
+ from sessionwiki.graph import build_and_report
67
+ return build_and_report
68
+ if name == "export_all":
69
+ from sessionwiki.exporters import export_all
70
+ return export_all
71
+ if name == "REGISTRY":
72
+ from sessionwiki.adapters import REGISTRY
73
+ return REGISTRY
74
+ raise AttributeError(f"module 'sessionwiki' has no attribute {name!r}")
@@ -0,0 +1,6 @@
1
+ """Allow `python3 -m sessionwiki` to invoke the CLI."""
2
+ from sessionwiki.cli import main
3
+ import sys
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,118 @@
1
+ """Canonical frontmatter parser (#273).
2
+
3
+ Historically 8 copies of this function lived scattered across the
4
+ codebase with slightly different return shapes — dict vs
5
+ ``(dict, body)`` tuple vs ``(str | None, body)``. Consolidating in
6
+ one hit is risky (every caller makes specific assumptions about the
7
+ return shape), so this module ships the canonical implementation plus
8
+ thin wrappers that match the three existing signatures. New call
9
+ sites should use :func:`parse_frontmatter` or
10
+ :func:`parse_frontmatter_dict`; legacy sites can migrate over time.
11
+
12
+ Stdlib-only. No YAML dependency — we parse the minimal subset of
13
+ YAML we use in practice (scalars, inline lists, inline dicts, block
14
+ lists with `- ` bullets).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Any, Optional
21
+
22
+ # Accept LF, CRLF, or CR after each fence so Windows-authored (CRLF) and
23
+ # old-Mac (CR) files parse identically to LF input. The optional newline
24
+ # slots match the historical regex (`\n?(.*?)\n?---\n?`) so empty
25
+ # frontmatter (`---\n---\nbody`) still parses. BOM is handled separately
26
+ # in `_strip_bom()` before the regex runs. See #409, #423.
27
+ _FRONTMATTER_RE = re.compile(
28
+ r"^---[ \t]*(?:\r\n|\r|\n)?(.*?)(?:\r\n|\r|\n)?---[ \t]*(?:\r\n|\r|\n)?(.*)$",
29
+ re.DOTALL,
30
+ )
31
+
32
+
33
+ def _strip_bom(text: str) -> str:
34
+ """Strip leading UTF-8 BOM if present (#423)."""
35
+ if text and text[0] == "\ufeff":
36
+ return text[1:]
37
+ return text
38
+
39
+
40
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
41
+ """Return ``(meta, body)`` — the canonical shape.
42
+
43
+ Empty or malformed input returns ``({}, text)`` so callers can
44
+ treat every file uniformly. Handles UTF-8 BOM and CR/LF/CRLF
45
+ line endings transparently.
46
+ """
47
+ text = _strip_bom(text)
48
+ m = _FRONTMATTER_RE.match(text)
49
+ if not m:
50
+ return {}, text
51
+ meta_text = m.group(1)
52
+ body = m.group(2)
53
+ meta: dict[str, Any] = {}
54
+ for line in meta_text.splitlines():
55
+ mm = re.match(r"^([a-zA-Z_][\w-]*):\s*(.*)$", line)
56
+ if not mm:
57
+ continue
58
+ key, raw = mm.group(1), mm.group(2).strip()
59
+ meta[key] = _parse_scalar(raw)
60
+ return meta, body
61
+
62
+
63
+ def parse_frontmatter_dict(text: str) -> dict[str, Any]:
64
+ """Return just the metadata dict — convenience for callers that
65
+ don't need the body."""
66
+ return parse_frontmatter(text)[0]
67
+
68
+
69
+ def parse_frontmatter_or_none(text: str) -> tuple[Optional[str], str]:
70
+ """Return ``(raw_frontmatter_text | None, body)`` — legacy shape
71
+ used by ``sessionwiki/tags.py`` which does its own line-level parsing
72
+ inside the frontmatter block."""
73
+ text = _strip_bom(text)
74
+ m = _FRONTMATTER_RE.match(text)
75
+ if not m:
76
+ return None, text
77
+ return m.group(1), m.group(2)
78
+
79
+
80
+ def _parse_scalar(raw: str, *, coerce_bool: bool = True) -> Any:
81
+ """Parse a single YAML scalar value (best-effort, no external deps).
82
+
83
+ Handles: inline lists ``[a, b, c]``, quoted strings, bools, ints.
84
+ Everything else comes back as the stripped string.
85
+
86
+ #py-l1 (#599): pass ``coerce_bool=False`` when recursing into list
87
+ items so a tag list like ``[no, yes, maybe]`` doesn't become
88
+ ``[False, True, "maybe"]``. Top-level scalars still coerce.
89
+ """
90
+ s = raw.strip()
91
+ if not s:
92
+ return ""
93
+ # Quoted string
94
+ if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
95
+ return s[1:-1]
96
+ # Inline list: [a, b, c]
97
+ if s.startswith("[") and s.endswith("]"):
98
+ body = s[1:-1].strip()
99
+ if not body:
100
+ return []
101
+ return [_parse_scalar(x, coerce_bool=False) for x in body.split(",")]
102
+ # Bool
103
+ low = s.lower()
104
+ if coerce_bool and low in {"true", "yes"}:
105
+ return True
106
+ if coerce_bool and low in {"false", "no"}:
107
+ return False
108
+ # Int
109
+ try:
110
+ return int(s)
111
+ except ValueError:
112
+ pass
113
+ # Float
114
+ try:
115
+ return float(s)
116
+ except ValueError:
117
+ pass
118
+ return s
@@ -0,0 +1,44 @@
1
+ """Canonical list of system / nav / scaffolding pages used across
2
+ graph.py, lint/rules.py, and (potentially) future emitters.
3
+
4
+ #arch-l7 (#628 sibling): graph.py and lint/rules.py shipped two
5
+ hand-maintained lists of "pages that are exempt from the source /
6
+ entity / concept schema." The lists overlapped but drifted —
7
+ graph.py had `log-archive-2026`, lint missed it; lint had
8
+ `index.md`, graph used type-checks instead.
9
+
10
+ This module owns the single source of truth. Callers can request
11
+ either form via the helpers below.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ # Slugs (no `.md` suffix) — the form graph.py wants because it
16
+ # operates on graph-node ids that are already stripped of extension.
17
+ SYSTEM_PAGE_SLUGS: frozenset[str] = frozenset({
18
+ "CRITICAL_FACTS",
19
+ "MEMORY",
20
+ "SOUL",
21
+ "hints",
22
+ "hot",
23
+ "dashboard",
24
+ "overview",
25
+ "log",
26
+ "log-archive-2026",
27
+ "index",
28
+ })
29
+
30
+ # Filenames (with `.md`) — the form lint/rules.py wants because its
31
+ # input is page paths read off disk.
32
+ SYSTEM_PAGE_FILES: frozenset[str] = frozenset(
33
+ f"{slug}.md" for slug in SYSTEM_PAGE_SLUGS
34
+ )
35
+
36
+
37
+ def is_system_slug(slug: str) -> bool:
38
+ """True if ``slug`` (no extension) is a known system / nav page."""
39
+ return slug in SYSTEM_PAGE_SLUGS
40
+
41
+
42
+ def is_system_file(basename: str) -> bool:
43
+ """True if ``basename`` ends with ``.md`` and names a system page."""
44
+ return basename in SYSTEM_PAGE_FILES
@@ -0,0 +1,124 @@
1
+ """Adapter configuration validation (v1.0 · #177).
2
+
3
+ Consolidates the adapter enable/configure surface so users have a single
4
+ place to check what opt-in adapters can be configured and which are active.
5
+
6
+ Supported adapters:
7
+ - meeting (file-based, source_dirs + extensions)
8
+ - jira (network, server/email/api_token/jql)
9
+ - web_clipper (file-based intake, watch_dir + auto_queue)
10
+
11
+ The PDF adapter that used to live here was removed in the simplification
12
+ sweep — its dispatch path in ``convert.py`` was a phantom (no concrete
13
+ implementation; every adapter raised ``AttributeError`` on ``convert_pdf``).
14
+
15
+ All disabled by default — users must set ``<adapter>.enabled: true`` in
16
+ ``sessions_config.json`` to activate.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Optional
22
+
23
+
24
+ # ─── Schemas ───────────────────────────────────────────────────────────
25
+
26
+ ADAPTER_SCHEMAS: dict[str, dict[str, Any]] = {
27
+ "web_clipper": {
28
+ "required_if_enabled": ["watch_dir"],
29
+ "defaults": {"extensions": [".md"], "auto_queue": True},
30
+ "types": {
31
+ "watch_dir": str,
32
+ "extensions": list,
33
+ "auto_queue": bool,
34
+ },
35
+ },
36
+ }
37
+
38
+
39
+ def validate_adapter_config(
40
+ config: dict[str, Any],
41
+ adapter_name: str,
42
+ ) -> list[str]:
43
+ """Validate a single adapter's config section. Returns list of errors.
44
+
45
+ Empty list = valid. Disabled adapters (or missing sections) are
46
+ always valid — the check is about the *shape* of the config when
47
+ enabled.
48
+ """
49
+ schema = ADAPTER_SCHEMAS.get(adapter_name)
50
+ if schema is None:
51
+ return [f"unknown adapter {adapter_name!r}"]
52
+
53
+ section = config.get(adapter_name, {})
54
+ if not isinstance(section, dict):
55
+ return [f"{adapter_name!r} config must be a JSON object"]
56
+
57
+ # If not enabled, skip further checks
58
+ if not section.get("enabled"):
59
+ return []
60
+
61
+ errors: list[str] = []
62
+
63
+ # Required fields
64
+ for field in schema["required_if_enabled"]:
65
+ if field not in section:
66
+ errors.append(
67
+ f"{adapter_name!r} is enabled but missing required field: {field!r}"
68
+ )
69
+ elif not section[field]:
70
+ errors.append(
71
+ f"{adapter_name!r} is enabled but {field!r} is empty"
72
+ )
73
+
74
+ # Type checks
75
+ for field, expected_type in schema["types"].items():
76
+ if field in section:
77
+ value = section[field]
78
+ if not isinstance(value, expected_type):
79
+ errors.append(
80
+ f"{adapter_name!r} field {field!r} must be "
81
+ f"{expected_type.__name__} (got {type(value).__name__})"
82
+ )
83
+
84
+ return errors
85
+
86
+
87
+ def validate_all_adapters(config: dict[str, Any]) -> dict[str, list[str]]:
88
+ """Validate every known adapter's config section. Returns ``{name: errors}``.
89
+
90
+ Adapters with no errors map to an empty list. Use to display a full
91
+ status report.
92
+ """
93
+ return {
94
+ name: validate_adapter_config(config, name)
95
+ for name in ADAPTER_SCHEMAS
96
+ }
97
+
98
+
99
+ def is_adapter_enabled(config: dict[str, Any], adapter_name: str) -> bool:
100
+ """Return True if the adapter is explicitly enabled in config."""
101
+ section = config.get(adapter_name, {})
102
+ if not isinstance(section, dict):
103
+ return False
104
+ return bool(section.get("enabled", False))
105
+
106
+
107
+ def enabled_adapters(config: dict[str, Any]) -> list[str]:
108
+ """List adapter names that are enabled in config."""
109
+ return [name for name in ADAPTER_SCHEMAS if is_adapter_enabled(config, name)]
110
+
111
+
112
+ def apply_defaults(
113
+ config: dict[str, Any],
114
+ adapter_name: str,
115
+ ) -> dict[str, Any]:
116
+ """Return the config section merged with schema defaults."""
117
+ schema = ADAPTER_SCHEMAS.get(adapter_name)
118
+ if schema is None:
119
+ return config.get(adapter_name, {})
120
+ section = dict(config.get(adapter_name, {}))
121
+ for key, default in schema["defaults"].items():
122
+ if key not in section:
123
+ section[key] = default
124
+ return section
@@ -0,0 +1,134 @@
1
+ """Adapter registry.
2
+
3
+ Each adapter knows how to find .jsonl session files for one coding agent and
4
+ parse them into a stream of records. Adapters register themselves on import.
5
+
6
+ from sessionwiki.adapters import REGISTRY, discover_adapters
7
+ discover_adapters()
8
+ adapter_cls = REGISTRY["claude_code"]
9
+ adapter = adapter_cls(config)
10
+ for path in adapter.discover_sessions():
11
+ ...
12
+
13
+ Core adapters (always loaded):
14
+ claude_code, codex_cli, opencode, zcode
15
+
16
+ Contrib adapters (loaded on demand via --adapter flag):
17
+ chatgpt, copilot_chat, copilot_cli, cursor, gemini_cli, obsidian
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from sessionwiki.adapters.base import BaseAdapter
23
+
24
+ REGISTRY: dict[str, type[BaseAdapter]] = {}
25
+
26
+ # #v1378-review: aliases tracked separately so REGISTRY stays
27
+ # canonical-only. `cmd_adapters` and any other consumer that walks
28
+ # every adapter exactly once can iterate REGISTRY directly without
29
+ # de-duping. Lookup-by-alias still works via `resolve_adapter_name`.
30
+ REGISTRY_ALIASES: dict[str, str] = {}
31
+
32
+ # Contrib adapters that can be loaded on demand.
33
+ CONTRIB_ADAPTERS = {
34
+ "chatgpt", "copilot_chat", "copilot_cli",
35
+ "cursor", "gemini_cli", "obsidian",
36
+ }
37
+
38
+
39
+ def register(name: str, aliases: list[str] | None = None):
40
+ """Decorator used by adapter modules to register themselves.
41
+
42
+ ``aliases`` (#arch-l5 / #626): historical lookup-only names that
43
+ resolve to the same class. Used to keep kebab-case names like
44
+ ``copilot-chat`` working after the canonical name moves to
45
+ snake_case (``copilot_chat``). ``cls.name`` always reflects the
46
+ canonical name.
47
+
48
+ #v1378-review: aliases live in a SEPARATE ``REGISTRY_ALIASES`` map
49
+ so iterating ``REGISTRY`` walks each adapter exactly once. The
50
+ previous version inserted aliases into ``REGISTRY`` directly,
51
+ which made ``cmd_adapters`` print duplicate rows (one for the
52
+ canonical name, one for the alias) and made ``adapter_status``
53
+ look up the wrong config key on the alias row.
54
+
55
+ A collision guard prevents an alias from shadowing an existing
56
+ canonical adapter — e.g. ``register("copilot_chat",
57
+ aliases=["claude_code"])`` would raise ValueError, not silently
58
+ replace the real ``claude_code`` entry.
59
+ """
60
+ def decorator(cls):
61
+ REGISTRY[name] = cls
62
+ cls.name = name
63
+ for alias in aliases or ():
64
+ if alias in REGISTRY:
65
+ raise ValueError(
66
+ f"adapter alias {alias!r} would shadow existing "
67
+ f"canonical adapter {REGISTRY[alias].name!r}"
68
+ )
69
+ existing = REGISTRY_ALIASES.get(alias)
70
+ if existing is not None and existing != name:
71
+ raise ValueError(
72
+ f"adapter alias {alias!r} already mapped to "
73
+ f"{existing!r}; cannot remap to {name!r}"
74
+ )
75
+ REGISTRY_ALIASES[alias] = name
76
+ return cls
77
+ return decorator
78
+
79
+
80
+ def resolve_adapter_name(name: str) -> str | None:
81
+ """Return the canonical adapter name for ``name``, or None.
82
+
83
+ ``name`` may be the canonical name (returned as-is) or a registered
84
+ alias (returned as the canonical it maps to). Anything else returns
85
+ None, letting callers raise their own user-facing error.
86
+ """
87
+ if name in REGISTRY:
88
+ return name
89
+ canonical = REGISTRY_ALIASES.get(name)
90
+ if canonical and canonical in REGISTRY:
91
+ return canonical
92
+ return None
93
+
94
+
95
+ def discover_adapters() -> None:
96
+ """Import core adapters so they register themselves."""
97
+ from sessionwiki.adapters import (
98
+ claude_code, # noqa: F401
99
+ codex_cli, # noqa: F401
100
+ opencode, # noqa: F401
101
+ zcode, # noqa: F401
102
+ )
103
+
104
+
105
+ def discover_contrib(names: list[str] | None = None) -> None:
106
+ """Import contrib adapters on demand.
107
+
108
+ Parameters
109
+ ----------
110
+ names : list[str] | None
111
+ Specific adapter names to load. If None, loads all contrib adapters.
112
+ """
113
+ targets = names if names else CONTRIB_ADAPTERS
114
+ for name in targets:
115
+ if name not in CONTRIB_ADAPTERS:
116
+ continue
117
+ try:
118
+ __import__(f"sessionwiki.adapters.contrib.{name}")
119
+ except ImportError as e:
120
+ import sys
121
+ print(f" warning: contrib adapter {name!r} failed to load: {e}",
122
+ file=sys.stderr)
123
+
124
+
125
+ def discover_all() -> None:
126
+ """Import all adapters (core + contrib)."""
127
+ discover_adapters()
128
+ discover_contrib()
129
+
130
+
131
+ def get_available() -> dict[str, type[BaseAdapter]]:
132
+ """Return only adapters whose session store exists on this machine."""
133
+ discover_adapters()
134
+ return {name: cls for name, cls in REGISTRY.items() if cls.is_available()}
@@ -0,0 +1,191 @@
1
+ """Base class for session-store adapters.
2
+
3
+ An adapter knows two things about one coding agent:
4
+ 1. WHERE its .jsonl session transcripts live on disk
5
+ 2. HOW to discover them (walking the directory tree)
6
+
7
+ Everything else — record filtering, markdown rendering, redaction, state
8
+ tracking — is shared in `sessionwiki.convert` and operates on the iterator this
9
+ class returns.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re as _re
15
+ from pathlib import Path
16
+ from typing import Any, Iterator
17
+
18
+ # #sec-7 (#551): project slugs flow into raw/ + site/ paths. The same
19
+ # sanitiser regex is used by build.py for project_slug rendering — keep
20
+ # them aligned. Anything outside [A-Za-z0-9._-] gets replaced with `_`,
21
+ # leading dots are stripped so the slug can't form a hidden directory.
22
+ _PROJECT_SLUG_RE = _re.compile(r"[^A-Za-z0-9._-]")
23
+
24
+
25
+ def _safe_project_slug(raw: str) -> str:
26
+ """Drop path traversal + non-portable characters from a project slug."""
27
+ s = _PROJECT_SLUG_RE.sub("_", raw)
28
+ s = s.lstrip(".")
29
+ return s or "unnamed"
30
+
31
+
32
+ class BaseAdapter:
33
+ """Adapter interface.
34
+
35
+ Subclasses must set `session_store_path` (a Path or list of Paths) and
36
+ implement `is_available()`. The default `discover_sessions()` does a
37
+ recursive glob for `*.jsonl`; override only if your agent uses a different
38
+ extension or layout.
39
+ """
40
+
41
+ #: Unique adapter name (set by @register decorator).
42
+ name: str = "base"
43
+
44
+ #: Path or list of paths where this agent writes session transcripts.
45
+ #: Subclasses MUST override.
46
+ session_store_path: Path | list[Path] = Path("/dev/null")
47
+
48
+ #: True if this adapter wraps an **AI coding-agent session store**
49
+ #: (Claude Code, Codex CLI, Copilot, Cursor, Gemini, etc.). False
50
+ #: for adapters over user content (Obsidian vaults, Jira tickets,
51
+ #: meeting transcripts, PDFs) — those are opt-in only so
52
+ #: ``sessionwiki sync`` never silently ingests non-session content.
53
+ #: See #326.
54
+ is_ai_session: bool = True
55
+
56
+ #: #arch-m9 (#621): canonical declaration on BaseAdapter so subclasses
57
+ #: don't redeclare with format drift (`["v1"]` vs `["v1.0"]` vs
58
+ #: `["1.x"]`). Default is ``["v1"]`` — the schema version the
59
+ #: built-in adapters target. Subclasses that consume a different
60
+ #: agent-native format override (or extend) this list.
61
+ SUPPORTED_SCHEMA_VERSIONS: list[str] = ["v1"]
62
+
63
+ def __init__(self, config: dict[str, Any] | None = None):
64
+ self.config = config or {}
65
+
66
+ # ─── classmethods used by the registry + UI ────────────────────────
67
+
68
+ # #py-l3 (#601): subclasses can override _DESCRIPTION_OVERRIDE so
69
+ # `python3 -OO` (which strips __doc__) doesn't degrade the adapter
70
+ # listing to bare class names. The default reads __doc__ where
71
+ # available and falls back to a stable explicit string when not.
72
+ _DESCRIPTION_OVERRIDE: str = ""
73
+
74
+ @classmethod
75
+ def description(cls) -> str:
76
+ """One-line description shown in `sessionwiki adapters`."""
77
+ if cls._DESCRIPTION_OVERRIDE:
78
+ return cls._DESCRIPTION_OVERRIDE
79
+ if cls.__doc__:
80
+ return cls.__doc__.split("\n")[0]
81
+ return cls.__name__
82
+
83
+ @classmethod
84
+ def is_available(cls) -> bool:
85
+ """True if the session store exists on this machine.
86
+
87
+ #496: previously read ``cls.session_store_path`` directly. That
88
+ worked for ``ClaudeCodeAdapter`` (class attribute) but returned
89
+ the *property descriptor object* for the 8 contrib adapters
90
+ which override ``session_store_path`` as a ``@property`` — so
91
+ every contrib adapter had to re-implement its own
92
+ ``is_available()`` classmethod reading ``cls.DEFAULT_ROOTS``.
93
+
94
+ Fix: instantiate a config-less temp instance and read
95
+ ``self.session_store_path`` through the same code path
96
+ ``discover_sessions()`` uses. Both class-attribute and
97
+ ``@property``-overriding patterns now flow through this single
98
+ method; the 8 duplicate contrib overrides go away.
99
+
100
+ Adapters with expensive ``__init__()`` should override this
101
+ method directly, but no current adapter needs to.
102
+ """
103
+ try:
104
+ inst = cls()
105
+ except Exception:
106
+ # Defensive: an adapter whose __init__ raises (e.g.
107
+ # missing imports surfaced eagerly) is "unavailable" by
108
+ # definition rather than crashing the whole `adapters`
109
+ # listing.
110
+ return False
111
+ paths = inst.session_store_path
112
+ if isinstance(paths, Path):
113
+ paths = [paths]
114
+ return any(Path(p).expanduser().exists() for p in paths)
115
+
116
+ # ─── discovery ─────────────────────────────────────────────────────
117
+
118
+ def discover_sessions(self) -> list[Path]:
119
+ """Return a sorted list of all .jsonl files under the session store."""
120
+ paths: list[Path] = []
121
+ stores = self.session_store_path
122
+ if isinstance(stores, Path):
123
+ stores = [stores]
124
+ for store in stores:
125
+ store = Path(store).expanduser()
126
+ if store.exists():
127
+ paths.extend(sorted(store.rglob("*.jsonl")))
128
+ return paths
129
+
130
+ # ─── per-agent helpers ─────────────────────────────────────────────
131
+
132
+ def derive_project_slug(self, jsonl_path: Path) -> str:
133
+ """Derive a friendly project slug from a .jsonl file path.
134
+
135
+ Default: the immediate parent directory name under the store.
136
+ Override for agents that use flat or encoded directory names.
137
+
138
+ #sec-7 (#551): the returned slug is used downstream as a path
139
+ component (`raw/sessions/<slug>-...md`, `site/projects/<slug>.html`).
140
+ A user whose session store contains a directory named `..` or
141
+ `foo/bar` could traverse out of `raw/` or smuggle a sub-path.
142
+ Sanitise via the same regex rule the rest of the build uses.
143
+ """
144
+ stores = self.session_store_path
145
+ if isinstance(stores, Path):
146
+ stores = [stores]
147
+ raw = None
148
+ for store in stores:
149
+ store = Path(store).expanduser()
150
+ try:
151
+ rel = jsonl_path.relative_to(store)
152
+ raw = rel.parts[0] if rel.parts else jsonl_path.parent.name
153
+ break
154
+ except ValueError:
155
+ continue
156
+ if raw is None:
157
+ raw = jsonl_path.parent.name
158
+ return _safe_project_slug(raw)
159
+
160
+ def is_subagent(self, jsonl_path: Path) -> bool:
161
+ """Default: no adapter has a sub-agent concept — only Claude Code does
162
+ (#406). Subclasses that DO have sub-agents (currently only the
163
+ Claude Code adapter) override this method.
164
+
165
+ Why the default returned True for any path containing the substring
166
+ "subagent": that was a holdover from a Claude-specific assumption
167
+ when the adapter abstraction was introduced. It mis-tagged every
168
+ session in any user project named e.g. ``subagent-runner``,
169
+ demoting them on the project page and excluding them from session
170
+ counts. Subclassing fixes the bug at the source.
171
+ """
172
+ return False
173
+
174
+ def normalize_records(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
175
+ """Normalize agent-specific JSONL records into the shared Claude-style
176
+ format that ``sessionwiki.convert`` expects.
177
+
178
+ The shared renderer expects records shaped as:
179
+ - ``{"type": "user", "message": {"role": "user", "content": "..."}}``
180
+ - ``{"type": "assistant", "message": {"role": "assistant", "content": [...]}}``
181
+
182
+ The default implementation is a no-op (pass-through) — Claude Code
183
+ sessions already use this format. Adapters for agents with a different
184
+ schema (e.g. Codex CLI, Copilot) override this method to translate
185
+ their native records into the shared shape.
186
+
187
+ Called by ``convert.py`` after ``parse_jsonl()`` and before the
188
+ renderer, so the normalization is transparent to the rest of the
189
+ pipeline.
190
+ """
191
+ return records