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
sessionwiki/__init__.py
ADDED
|
@@ -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}")
|
sessionwiki/__main__.py
ADDED
|
@@ -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
|