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,115 @@
1
+ """GitHub Copilot CLI adapter.
2
+
3
+ Reads session event logs from the Copilot CLI's local state directory.
4
+
5
+ Copilot CLI stores per-session event logs under:
6
+ ~/.copilot/session-state/<session-id>/events.jsonl
7
+
8
+ The COPILOT_HOME environment variable, when set, overrides the default
9
+ ~/.copilot base directory.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from sessionwiki.adapters import register
19
+ from sessionwiki.adapters.base import BaseAdapter
20
+
21
+
22
+ def _build_default_roots() -> list[Path]:
23
+ """Build DEFAULT_ROOTS, incorporating COPILOT_HOME if set."""
24
+ home = Path.home()
25
+ roots: list[Path] = [
26
+ home / ".copilot" / "session-state",
27
+ ]
28
+ copilot_home = os.environ.get("COPILOT_HOME")
29
+ if copilot_home:
30
+ roots.append(Path(copilot_home).expanduser() / "session-state")
31
+ return roots
32
+
33
+
34
+ @register("copilot_cli", aliases=["copilot-cli"])
35
+ class CopilotCliAdapter(BaseAdapter):
36
+ """GitHub Copilot CLI — reads ~/.copilot/session-state/*/events.jsonl.
37
+
38
+ #arch-l5 (#626): canonical name is ``copilot_cli`` (snake_case,
39
+ matching every other adapter). The kebab-case ``copilot-cli`` is
40
+ kept as a REGISTRY alias and still recognised in
41
+ ``sessions_config.json`` so existing user configs don't break.
42
+ """
43
+
44
+ # #arch-m9 (#621): SUPPORTED_SCHEMA_VERSIONS = ["v1"] is the
45
+ # BaseAdapter default — declaration removed here.
46
+
47
+ DEFAULT_ROOTS: list[Path] = _build_default_roots()
48
+
49
+ def __init__(self, config: dict[str, Any] | None = None):
50
+ super().__init__(config)
51
+ adapters_cfg = (config or {}).get("adapters", {}) or {}
52
+ # #626: read snake_case config first; fall back to legacy
53
+ # kebab-case so existing sessions_config.json keeps working.
54
+ ad_cfg = adapters_cfg.get("copilot_cli") or adapters_cfg.get("copilot-cli") or {}
55
+ paths = ad_cfg.get("roots") or []
56
+ self.roots: list[Path] = (
57
+ [Path(p).expanduser() for p in paths] if paths else self.DEFAULT_ROOTS
58
+ )
59
+
60
+ @property
61
+ def session_store_path(self): # type: ignore[override]
62
+ return self.roots
63
+
64
+ # #496: kept as an explicit override because it has special
65
+ # COPILOT_HOME env-var handling that BaseAdapter.is_available()
66
+ # (which only checks self.session_store_path) doesn't cover. The
67
+ # base impl is still reachable via super().is_available() if the
68
+ # env-var check fails.
69
+ @classmethod
70
+ def is_available(cls) -> bool:
71
+ for p in cls.DEFAULT_ROOTS:
72
+ if Path(p).expanduser().exists():
73
+ return True
74
+ # Also check COPILOT_HOME at call time (may be set after import)
75
+ copilot_home = os.environ.get("COPILOT_HOME")
76
+ if copilot_home:
77
+ p = Path(copilot_home).expanduser() / "session-state"
78
+ if p.exists():
79
+ return True
80
+ return False
81
+
82
+ def discover_sessions(self) -> list[Path]:
83
+ """Find events.jsonl files under each session-id directory."""
84
+ out: list[Path] = []
85
+ for root in self.roots:
86
+ root = Path(root).expanduser()
87
+ if not root.exists():
88
+ continue
89
+ # Walk <root>/<session-id>/events.jsonl
90
+ out.extend(sorted(root.glob("*/events.jsonl")))
91
+ # De-dupe while preserving order
92
+ seen: set[Path] = set()
93
+ unique: list[Path] = []
94
+ for p in out:
95
+ if p not in seen:
96
+ seen.add(p)
97
+ unique.append(p)
98
+ return unique
99
+
100
+ def derive_project_slug(self, path: Path) -> str:
101
+ """Use the session-id directory name as the project slug.
102
+
103
+ Layout: <root>/<session-id>/events.jsonl
104
+ The session-id directory is the immediate parent of events.jsonl.
105
+ """
106
+ for root in self.roots:
107
+ root = Path(root).expanduser()
108
+ try:
109
+ rel = path.relative_to(root)
110
+ if rel.parts:
111
+ return rel.parts[0]
112
+ except ValueError:
113
+ continue
114
+ # Fallback: use the parent directory name
115
+ return path.parent.name
@@ -0,0 +1,100 @@
1
+ """Cursor adapter.
2
+
3
+ Reads conversation history from Cursor IDE's local state.
4
+
5
+ Status: v0.2 initial implementation. Cursor stores chat history in an SQLite
6
+ database under ~/Library/Application Support/Cursor/User/workspaceStorage/
7
+ (macOS) or %APPDATA%\\Cursor\\User\\workspaceStorage\\ (Windows).
8
+
9
+ v0.2 scope:
10
+ - Detects Cursor install by checking the known paths
11
+ - Discovers workspace-level conversation stores
12
+ - Marks itself available if the paths exist
13
+ - Does NOT yet parse the SQLite records (v0.3 work once we have a real Cursor
14
+ install to test against)
15
+
16
+ The adapter registers cleanly and is listed as "available: yes" if Cursor is
17
+ installed, so users know the scaffold is present. Full record parsing is
18
+ tracked in the v0.3 milestone.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from sessionwiki.adapters import register
27
+ from sessionwiki.adapters.base import BaseAdapter
28
+
29
+
30
+ @register("cursor")
31
+ class CursorAdapter(BaseAdapter):
32
+ """Cursor IDE — reads chat history from ~/Library/Application Support/Cursor/"""
33
+
34
+ SUPPORTED_SCHEMA_VERSIONS = ["v1"] # to be pinned against a real Cursor install
35
+
36
+ # Cursor stores per-workspace data under these roots
37
+ DEFAULT_ROOTS = [
38
+ # macOS
39
+ Path.home() / "Library" / "Application Support" / "Cursor" / "User" / "workspaceStorage",
40
+ # Linux
41
+ Path.home() / ".config" / "Cursor" / "User" / "workspaceStorage",
42
+ # Windows (via %APPDATA%) — Python expands %APPDATA% only via os.environ
43
+ Path.home() / "AppData" / "Roaming" / "Cursor" / "User" / "workspaceStorage",
44
+ ]
45
+
46
+ # Known file patterns to match. Cursor stores conversation data in
47
+ # SQLite (state.vscdb) per-workspace; v0.3 will add the SQLite parser.
48
+ SESSION_FILE_PATTERNS = ["*.jsonl", "state.vscdb"]
49
+
50
+ def __init__(self, config: dict[str, Any] | None = None):
51
+ super().__init__(config)
52
+ ad_cfg = (config or {}).get("adapters", {}).get("cursor", {})
53
+ paths = ad_cfg.get("roots") or []
54
+ self.roots: list[Path] = (
55
+ [Path(p).expanduser() for p in paths] if paths else self.DEFAULT_ROOTS
56
+ )
57
+
58
+ @property
59
+ def session_store_path(self): # type: ignore[override]
60
+ return self.roots
61
+
62
+ # #496: is_available() inherited from BaseAdapter — temp
63
+ # instance reads self.session_store_path through the @property.
64
+
65
+ def discover_sessions(self) -> list[Path]:
66
+ """Find every conversation file under every configured root.
67
+
68
+ v0.2 returns .jsonl files (if any exist — some Cursor builds export
69
+ chat history as .jsonl). v0.3 will add SQLite state.vscdb parsing.
70
+ """
71
+ out: list[Path] = []
72
+ for root in self.roots:
73
+ root = Path(root).expanduser()
74
+ if not root.exists():
75
+ continue
76
+ for pattern in self.SESSION_FILE_PATTERNS:
77
+ out.extend(sorted(root.rglob(pattern)))
78
+ # De-dupe while preserving order
79
+ seen: set[Path] = set()
80
+ unique: list[Path] = []
81
+ for p in out:
82
+ if p not in seen:
83
+ seen.add(p)
84
+ unique.append(p)
85
+ return unique
86
+
87
+ def derive_project_slug(self, path: Path) -> str:
88
+ """Cursor workspace directories are hashed; fall back to the hash as the
89
+ slug. v0.3 will read workspace.json to get the friendly project name.
90
+ """
91
+ # Walk up to find the workspaceStorage/<hash>/ parent
92
+ for root in self.roots:
93
+ root = Path(root).expanduser()
94
+ try:
95
+ rel = path.relative_to(root)
96
+ if rel.parts:
97
+ return f"cursor-{rel.parts[0][:12]}"
98
+ except ValueError:
99
+ continue
100
+ return path.parent.name
@@ -0,0 +1,74 @@
1
+ """Gemini CLI adapter.
2
+
3
+ Reads conversation history from Google's Gemini CLI.
4
+
5
+ Status: v0.3 initial implementation. Gemini CLI stores sessions under
6
+ ~/.gemini/ (exact layout TBC against a real install). The adapter detects
7
+ the directory and discovers .jsonl / .json files inside.
8
+
9
+ Registers as "available" if any of the known Gemini CLI paths exist.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from sessionwiki.adapters import register
18
+ from sessionwiki.adapters.base import BaseAdapter
19
+
20
+
21
+ @register("gemini_cli")
22
+ class GeminiCliAdapter(BaseAdapter):
23
+ """Gemini CLI — reads ~/.gemini/ session history"""
24
+
25
+ SUPPORTED_SCHEMA_VERSIONS = ["v1"]
26
+
27
+ DEFAULT_ROOTS = [
28
+ # macOS / Linux / Windows (dot-dir works everywhere)
29
+ Path.home() / ".gemini",
30
+ # Linux (XDG)
31
+ Path.home() / ".config" / "gemini",
32
+ Path.home() / ".local" / "share" / "gemini",
33
+ # Windows (%APPDATA%)
34
+ Path.home() / "AppData" / "Roaming" / "gemini",
35
+ ]
36
+
37
+ def __init__(self, config: dict[str, Any] | None = None):
38
+ super().__init__(config)
39
+ ad_cfg = (config or {}).get("adapters", {}).get("gemini_cli", {})
40
+ paths = ad_cfg.get("roots") or []
41
+ self.roots: list[Path] = (
42
+ [Path(p).expanduser() for p in paths] if paths else self.DEFAULT_ROOTS
43
+ )
44
+
45
+ @property
46
+ def session_store_path(self): # type: ignore[override]
47
+ return self.roots
48
+
49
+ # #496: is_available() inherited from BaseAdapter — temp
50
+ # instance reads self.session_store_path through the @property.
51
+
52
+ def discover_sessions(self) -> list[Path]:
53
+ out: list[Path] = []
54
+ for root in self.roots:
55
+ root = Path(root).expanduser()
56
+ if not root.exists():
57
+ continue
58
+ # Look for both .jsonl and .json (Gemini CLI format TBC)
59
+ out.extend(sorted(root.rglob("*.jsonl")))
60
+ out.extend(sorted(root.rglob("chat-*.json")))
61
+ out.extend(sorted(root.rglob("session-*.json")))
62
+ return out
63
+
64
+ def derive_project_slug(self, path: Path) -> str:
65
+ # Gemini CLI doesn't have an obvious project encoding yet; use the
66
+ # parent directory name
67
+ for root in self.roots:
68
+ root = Path(root).expanduser()
69
+ try:
70
+ rel = path.relative_to(root)
71
+ return f"gemini-{rel.parts[0].lower()}" if rel.parts else "gemini-root"
72
+ except ValueError:
73
+ continue
74
+ return f"gemini-{path.parent.name.lower()}"
@@ -0,0 +1,121 @@
1
+ """Obsidian vault adapter.
2
+
3
+ Reads .md files from an Obsidian vault and treats each as a source document.
4
+ Unlike the Claude Code / Codex CLI adapters which parse .jsonl transcripts,
5
+ this adapter reads plain markdown that the user has already written.
6
+
7
+ It skips Obsidian's own internal files (`.obsidian/`, trash, templates) and
8
+ the `_templates/` convention seen in some vaults.
9
+
10
+ Config (in `examples/sessions_config.json`):
11
+
12
+ {
13
+ "adapters": {
14
+ "obsidian": {
15
+ "vault_paths": ["~/Documents/Obsidian Vault"],
16
+ "exclude_folders": [".obsidian", "Templates", "_templates", ".trash"],
17
+ "min_content_chars": 50
18
+ }
19
+ }
20
+ }
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from sessionwiki.adapters import register
29
+ from sessionwiki.adapters.base import BaseAdapter
30
+
31
+
32
+ @register("obsidian")
33
+ class ObsidianAdapter(BaseAdapter):
34
+ """Obsidian — reads .md files from an Obsidian vault"""
35
+
36
+ #: #326: Obsidian is user-authored content, not an AI session store.
37
+ #: Opt-in only so ``sessionwiki sync`` never silently ingests a user's
38
+ #: personal vault.
39
+ is_ai_session = False
40
+
41
+ SUPPORTED_SCHEMA_VERSIONS = ["1.x"]
42
+
43
+ DEFAULT_VAULT_PATHS = [
44
+ # macOS / Linux / Windows — common default vault locations
45
+ Path.home() / "Documents" / "Obsidian Vault",
46
+ Path.home() / "Obsidian",
47
+ # Windows (%APPDATA%) — some installs create vaults here
48
+ Path.home() / "AppData" / "Local" / "Obsidian" / "Vault",
49
+ ]
50
+
51
+ DEFAULT_EXCLUDE_FOLDERS = {
52
+ ".obsidian",
53
+ ".trash",
54
+ "Templates",
55
+ "_templates",
56
+ "templates",
57
+ ".git",
58
+ "node_modules",
59
+ }
60
+
61
+ def __init__(self, config: dict[str, Any] | None = None):
62
+ super().__init__(config)
63
+ ad_cfg = (config or {}).get("adapters", {}).get("obsidian", {})
64
+ paths = ad_cfg.get("vault_paths") or []
65
+ self.vault_paths: list[Path] = (
66
+ [Path(p).expanduser() for p in paths] if paths else self.DEFAULT_VAULT_PATHS
67
+ )
68
+ self.exclude_folders: set[str] = set(
69
+ ad_cfg.get("exclude_folders", self.DEFAULT_EXCLUDE_FOLDERS)
70
+ )
71
+ self.min_content_chars: int = int(ad_cfg.get("min_content_chars", 50))
72
+
73
+ # Override session_store_path so is_available() works.
74
+ @property
75
+ def session_store_path(self): # type: ignore[override]
76
+ return self.vault_paths
77
+
78
+ # #496: is_available() inherited from BaseAdapter — temp instance
79
+ # reads self.session_store_path (the @property above returning
80
+ # self.vault_paths). Was a duplicate scan over DEFAULT_VAULT_PATHS.
81
+
82
+ def discover_sessions(self) -> list[Path]:
83
+ """Walk every configured vault and return all .md files, excluding known
84
+ internal folders. Returns a sorted list."""
85
+ out: list[Path] = []
86
+ for vault in self.vault_paths:
87
+ vault = Path(vault).expanduser()
88
+ if not vault.exists():
89
+ continue
90
+ for md in vault.rglob("*.md"):
91
+ rel = md.relative_to(vault)
92
+ # Skip anything inside excluded folders
93
+ if any(part in self.exclude_folders for part in rel.parts):
94
+ continue
95
+ # Skip empty or near-empty files
96
+ try:
97
+ size = md.stat().st_size
98
+ except OSError:
99
+ continue
100
+ if size < self.min_content_chars:
101
+ continue
102
+ out.append(md)
103
+ return sorted(out)
104
+
105
+ def derive_project_slug(self, md_path: Path) -> str:
106
+ """Use the top-level folder under the vault as the project slug.
107
+ Files directly in the vault root get slug 'vault-root'."""
108
+ for vault in self.vault_paths:
109
+ vault = Path(vault).expanduser()
110
+ try:
111
+ rel = md_path.relative_to(vault)
112
+ if len(rel.parts) > 1:
113
+ # Use the first directory as the project slug, lowercased
114
+ return str(rel.parts[0]).lower().replace(" ", "-")
115
+ return "vault-root"
116
+ except ValueError:
117
+ continue
118
+ return md_path.parent.name
119
+
120
+ def is_subagent(self, md_path: Path) -> bool:
121
+ return False # Obsidian notes are never subagents
@@ -0,0 +1,169 @@
1
+ """OpenCode / OpenClaw adapter.
2
+
3
+ OpenCode V2 stores sessions in a live SQLite database. The sessionwiki
4
+ exporter (``sessionwiki opencode export``) writes a Claude-style JSONL corpus
5
+ under ``~/.llmwiki/opencode/<project-slug>/<session-id>.jsonl``; this
6
+ adapter reads that corpus. Legacy OpenCode/OpenClaw JSONL directories
7
+ are still supported for existing installations.
8
+
9
+ Session files are `*.jsonl` (one JSON object per line). Exported records
10
+ already use the Claude-style shape, so the default pass-through
11
+ ``normalize_records()`` is correct for them. Legacy records with a
12
+ top-level ``role`` / ``content`` are normalized on the fly.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from sessionwiki.adapters import register
21
+ from sessionwiki.adapters.base import BaseAdapter
22
+
23
+
24
+ @register("opencode")
25
+ class OpenCodeAdapter(BaseAdapter):
26
+ """OpenCode / OpenClaw — shared adapter (both use identical schema)."""
27
+
28
+ SUPPORTED_SCHEMA_VERSIONS = ["v1"]
29
+
30
+ DEFAULT_ROOTS = [
31
+ # Primary: sessionwiki's OpenCode V2 export corpus
32
+ Path.home() / ".llmwiki" / "opencode",
33
+ # Legacy OpenCode / OpenClaw JSONL stores
34
+ # macOS
35
+ Path.home() / "Library" / "Application Support" / "opencode" / "sessions",
36
+ Path.home() / "Library" / "Application Support" / "openclaw" / "sessions",
37
+ # Linux (XDG default)
38
+ Path.home() / ".config" / "opencode" / "sessions",
39
+ Path.home() / ".config" / "openclaw" / "sessions",
40
+ # Windows
41
+ Path.home() / "AppData" / "Roaming" / "opencode" / "sessions",
42
+ Path.home() / "AppData" / "Roaming" / "openclaw" / "sessions",
43
+ ]
44
+
45
+ def __init__(self, config: dict[str, Any] | None = None):
46
+ super().__init__(config)
47
+ ad_cfg = (config or {}).get("adapters", {}).get("opencode", {})
48
+ paths = ad_cfg.get("roots") or []
49
+ self.roots: list[Path] = (
50
+ [Path(p).expanduser() for p in paths] if paths else self.DEFAULT_ROOTS
51
+ )
52
+
53
+ @property
54
+ def session_store_path(self): # type: ignore[override]
55
+ return self.roots
56
+
57
+ @classmethod
58
+ def is_available(cls) -> bool:
59
+ """Available when at least one configured root contains a session."""
60
+ try:
61
+ instance = cls()
62
+ except Exception:
63
+ return False
64
+ roots = instance.session_store_path
65
+ if isinstance(roots, Path):
66
+ roots = [roots]
67
+ for root in roots:
68
+ path = Path(root).expanduser()
69
+ if path.exists() and any(path.rglob("*.jsonl")):
70
+ return True
71
+ return False
72
+
73
+ def discover_sessions(self) -> list[Path]:
74
+ """Return every .jsonl session file under every configured root."""
75
+ out: list[Path] = []
76
+ for root in self.roots:
77
+ root = Path(root).expanduser()
78
+ if not root.exists():
79
+ continue
80
+ out.extend(sorted(root.rglob("*.jsonl")))
81
+ return out
82
+
83
+ def derive_project_slug(self, jsonl_path: Path) -> str:
84
+ """OpenCode files are typically ``<project>-<session-id>.jsonl`` or
85
+ nested under ``<project>/<session-id>.jsonl``. Use parent dir if
86
+ nested, else split on first dash of filename."""
87
+ for root in self.roots:
88
+ root = Path(root).expanduser()
89
+ try:
90
+ rel = jsonl_path.relative_to(root)
91
+ # If nested: <project>/<session>.jsonl → project
92
+ if len(rel.parts) > 1:
93
+ return rel.parts[0]
94
+ # Flat: <project>-<session>.jsonl → left-of-first-dash
95
+ stem = rel.stem
96
+ if "-" in stem:
97
+ return stem.split("-", 1)[0]
98
+ return stem[:16]
99
+ except ValueError:
100
+ continue
101
+ return jsonl_path.parent.name
102
+
103
+ def is_subagent(self, jsonl_path: Path) -> bool:
104
+ """OpenCode marks subagent sessions with 'subagent' in the filename.
105
+
106
+ #arch-m1 (#614): match the segment, not the substring. The
107
+ previous `"subagent" in name` form would mis-classify a session
108
+ whose user-supplied slug happens to contain the literal text
109
+ "subagent" anywhere — same regression #406 fixed for the main
110
+ adapter contract.
111
+ """
112
+ # Hyphen-bounded match — accepts the subagent identifier whether
113
+ # it appears as a leading, trailing, or interior segment of a
114
+ # `-`-delimited filename.
115
+ import re
116
+ return bool(re.search(r"(?:^|[-/])subagent(?:[-.]|$)", jsonl_path.name))
117
+
118
+ def normalize_records(
119
+ self, records: list[dict[str, Any]]
120
+ ) -> list[dict[str, Any]]:
121
+ """Translate OpenCode/OpenClaw records into the shared Claude-style
122
+ shape that ``sessionwiki.convert.render_session_markdown`` expects.
123
+
124
+ OpenCode shape (approximate, based on public docs)::
125
+
126
+ {"role": "user", "content": "string", "timestamp": "..."}
127
+ {"role": "assistant", "content": "string or [{type, text|tool_use}]"}
128
+ {"role": "tool", "content": "tool result", "tool_call_id": "..."}
129
+
130
+ Claude Code shape::
131
+
132
+ {"type": "user", "message": {"role": "user", "content": "..."}}
133
+ {"type": "assistant", "message": {"role": "assistant", "content": [...]}}
134
+
135
+ Records already in Claude shape (have top-level ``type``) are
136
+ passed through unchanged.
137
+ """
138
+ out: list[dict[str, Any]] = []
139
+ for rec in records:
140
+ if not isinstance(rec, dict):
141
+ continue
142
+
143
+ # Pass-through if already Claude-shaped
144
+ if "type" in rec and "message" in rec:
145
+ out.append(rec)
146
+ continue
147
+
148
+ role = rec.get("role")
149
+ content = rec.get("content")
150
+ if role is None or content is None:
151
+ # Skip non-message records (metadata, session-init, etc.)
152
+ out.append(rec)
153
+ continue
154
+
155
+ # Map tool role → user (tool results are user-side in Claude shape)
156
+ claude_type = "user" if role in ("user", "tool", "system") else "assistant"
157
+
158
+ normalized = {
159
+ "type": claude_type,
160
+ "message": {"role": role, "content": content},
161
+ }
162
+ # Preserve timestamp if present — converter uses it for sorting
163
+ if "timestamp" in rec:
164
+ normalized["timestamp"] = rec["timestamp"]
165
+ if "sessionId" in rec:
166
+ normalized["sessionId"] = rec["sessionId"]
167
+
168
+ out.append(normalized)
169
+ return out
@@ -0,0 +1,58 @@
1
+ """Adapter status computation — pulled out of cli.py (#arch-h8 / #611).
2
+
3
+ Pre-#611 the ``configured``/``will_fire`` label computation lived as a
4
+ private ``_adapter_status`` helper inside ``cli.py``. The CLI's job is
5
+ to parse argv and print things — deciding whether an adapter is on /
6
+ off / auto belongs in the adapters package next to the adapters
7
+ themselves.
8
+
9
+ The function is re-exported from ``sessionwiki.cli`` so the existing
10
+ ``from sessionwiki.cli import _adapter_status`` import path keeps working
11
+ for any downstream caller that reached for it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+
19
+ def adapter_status(
20
+ name: str,
21
+ adapter_cls: Any,
22
+ config: dict,
23
+ ) -> tuple[str, str]:
24
+ """Return ``(configured, will_fire)`` labels for one adapter (G-01 · #287).
25
+
26
+ * ``configured``: ``explicit`` (user set ``enabled: true`` in the
27
+ config), ``off`` (user set ``enabled: false``), or ``auto``
28
+ (default — no explicit toggle).
29
+ * ``will_fire``: ``yes`` when the next ``sync`` will pick this
30
+ adapter up (available **and** not explicitly off), ``no``
31
+ otherwise.
32
+
33
+ The old labels — ``-`` / ``enabled`` / ``disabled`` — read as
34
+ "adapter can't see anything" even when the adapter was discovering
35
+ 471 files on the next line. The new labels say exactly what they
36
+ mean without the user cross-referencing ``sessions_config.json``.
37
+ """
38
+ adapter_cfg = config.get(name, {})
39
+ enabled_in_cfg = None
40
+ if isinstance(adapter_cfg, dict):
41
+ enabled_in_cfg = adapter_cfg.get("enabled", None)
42
+ if enabled_in_cfg is True:
43
+ configured = "explicit"
44
+ elif enabled_in_cfg is False:
45
+ configured = "off"
46
+ else:
47
+ configured = "auto"
48
+ available = adapter_cls.is_available()
49
+ # #326: non-AI adapters are opt-in only, so ``auto`` on an Obsidian /
50
+ # Jira / Meeting / PDF adapter means "available but won't fire".
51
+ is_ai = getattr(adapter_cls, "is_ai_session", True)
52
+ if configured == "off":
53
+ will_fire = "no"
54
+ elif configured == "explicit":
55
+ will_fire = "yes" if available else "no"
56
+ else: # auto
57
+ will_fire = "yes" if (available and is_ai) else "no"
58
+ return configured, will_fire
@@ -0,0 +1,45 @@
1
+ """ZCode session-store adapter.
2
+
3
+ ZCode sessions are exported by ``scripts/zcode_export.py`` into a flat,
4
+ Claude-style JSONL corpus:
5
+
6
+ ~/.llmwiki/zcode/<project-slug>/<session-id>.jsonl
7
+
8
+ The exporter already applies the ZCode-specific filters (subagents,
9
+ model-only messages, reasoning parts, tool collapsing), so this adapter
10
+ only needs discovery, project-slug derivation, and availability checks.
11
+ The default ``normalize_records`` pass-through is correct because the
12
+ exporter writes the shared Claude-style record shape.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from sessionwiki.adapters import register
20
+ from sessionwiki.adapters.base import BaseAdapter
21
+
22
+
23
+ @register("zcode")
24
+ class ZcodeAdapter(BaseAdapter):
25
+ """ZCode — reads ~/.llmwiki/zcode/<project>/*.jsonl"""
26
+
27
+ SUPPORTED_SCHEMA_VERSIONS = ["v1"]
28
+
29
+ session_store_path = Path.home() / ".llmwiki" / "zcode"
30
+
31
+ @classmethod
32
+ def is_available(cls) -> bool:
33
+ """Available when the export directory contains at least one session."""
34
+ try:
35
+ instance = cls()
36
+ except Exception:
37
+ return False
38
+ paths = instance.session_store_path
39
+ if isinstance(paths, Path):
40
+ paths = [paths]
41
+ for path in paths:
42
+ root = Path(path).expanduser()
43
+ if root.exists() and any(root.rglob("*.jsonl")):
44
+ return True
45
+ return False