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
|
@@ -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
|