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,77 @@
|
|
|
1
|
+
"""Claude Code session-store adapter.
|
|
2
|
+
|
|
3
|
+
Claude Code writes one .jsonl per session under:
|
|
4
|
+
~/.claude/projects/<project-dir-slug>/<session-uuid>.jsonl
|
|
5
|
+
|
|
6
|
+
Sub-agent runs live in:
|
|
7
|
+
~/.claude/projects/<project-dir-slug>/<session-uuid>/subagents/agent-*.jsonl
|
|
8
|
+
|
|
9
|
+
Project directory names encode the full absolute path with slashes replaced by
|
|
10
|
+
dashes, e.g. '-Users-USER-Desktop-2026-production-draft-ai-newsletter'.
|
|
11
|
+
We strip the common prefix to produce a friendly slug.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from sessionwiki.adapters import register
|
|
19
|
+
from sessionwiki.adapters.base import BaseAdapter
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register("claude_code")
|
|
23
|
+
class ClaudeCodeAdapter(BaseAdapter):
|
|
24
|
+
"""Claude Code — reads ~/.claude/projects/*/*.jsonl"""
|
|
25
|
+
|
|
26
|
+
# Cross-platform: dot-dir works on macOS, Linux, and Windows
|
|
27
|
+
session_store_path = Path.home() / ".claude" / "projects"
|
|
28
|
+
|
|
29
|
+
def derive_project_slug(self, jsonl_path: Path) -> str:
|
|
30
|
+
"""Strip the '-Users-...-production-draft-' prefix from the project dir name."""
|
|
31
|
+
store = Path(self.session_store_path).expanduser()
|
|
32
|
+
try:
|
|
33
|
+
rel = jsonl_path.relative_to(store)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return jsonl_path.parent.name
|
|
36
|
+
if not rel.parts:
|
|
37
|
+
return jsonl_path.parent.name
|
|
38
|
+
project_dir = rel.parts[0]
|
|
39
|
+
parts = project_dir.lstrip("-").split("-")
|
|
40
|
+
# Find a recognizable split point — anything after 'draft', 'production', or 'Desktop'
|
|
41
|
+
for marker in ("draft", "production", "Desktop"):
|
|
42
|
+
if marker in parts:
|
|
43
|
+
idx = len(parts) - 1 - parts[::-1].index(marker)
|
|
44
|
+
tail = parts[idx + 1 :]
|
|
45
|
+
if tail:
|
|
46
|
+
return "-".join(tail)
|
|
47
|
+
return "-".join(parts[-2:]) if len(parts) >= 2 else project_dir
|
|
48
|
+
|
|
49
|
+
def is_subagent(self, jsonl_path: Path) -> bool:
|
|
50
|
+
"""Detect Claude Code sub-agent runs by canonical path layout (#406).
|
|
51
|
+
|
|
52
|
+
Claude Code stores sub-agent runs at:
|
|
53
|
+
~/.claude/projects/<project>/<session-uuid>/subagents/agent-*.jsonl
|
|
54
|
+
|
|
55
|
+
Old heuristic was a substring check on ``"subagent" in path.parts``,
|
|
56
|
+
which mis-tagged any user project named e.g. ``subagent-runner`` —
|
|
57
|
+
every session in such a project was demoted to sub-agent on the
|
|
58
|
+
project page and excluded from session counts.
|
|
59
|
+
|
|
60
|
+
New rule: the file MUST live in a directory literally named
|
|
61
|
+
``subagents`` (plural) AND have a filename starting with
|
|
62
|
+
``agent-``. Both conditions match the canonical Claude layout
|
|
63
|
+
and exclude every false-positive case (project name "subagent",
|
|
64
|
+
"subagent-runner", "agent-subagent-helper", etc.).
|
|
65
|
+
"""
|
|
66
|
+
parts = jsonl_path.parts
|
|
67
|
+
if "subagents" not in parts:
|
|
68
|
+
return False
|
|
69
|
+
# The 'subagents' segment must be the immediate parent directory.
|
|
70
|
+
try:
|
|
71
|
+
parent_idx = parts.index("subagents")
|
|
72
|
+
except ValueError:
|
|
73
|
+
return False
|
|
74
|
+
if parent_idx >= len(parts) - 1:
|
|
75
|
+
return False
|
|
76
|
+
# And the filename must start with 'agent-' (the canonical pattern).
|
|
77
|
+
return parts[-1].startswith("agent-")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Codex CLI adapter (production — v0.5 fix #109).
|
|
2
|
+
|
|
3
|
+
Reads session transcripts from OpenAI's Codex CLI and normalizes the
|
|
4
|
+
Codex-native JSONL schema into the shared Claude-style format. v0.3 brings this adapter
|
|
5
|
+
from stub → production: it discovers session files, derives project slugs,
|
|
6
|
+
and declares its schema version. Record parsing goes through the shared
|
|
7
|
+
converter in sessionwiki.convert with graceful degradation for unknown record
|
|
8
|
+
types.
|
|
9
|
+
|
|
10
|
+
Codex CLI stores sessions under:
|
|
11
|
+
- ~/.codex/sessions/
|
|
12
|
+
- ~/.codex/projects/ (alternate layout)
|
|
13
|
+
|
|
14
|
+
Both are checked. Users can override via config.json.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from sessionwiki.adapters import register
|
|
23
|
+
from sessionwiki.adapters.base import BaseAdapter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@register("codex_cli")
|
|
27
|
+
class CodexCliAdapter(BaseAdapter):
|
|
28
|
+
"""Codex CLI — reads ~/.codex/sessions/**/*.jsonl (v0.3 production)"""
|
|
29
|
+
|
|
30
|
+
SUPPORTED_SCHEMA_VERSIONS = ["v0.x", "v1.0"]
|
|
31
|
+
|
|
32
|
+
DEFAULT_ROOTS = [
|
|
33
|
+
# Cross-platform: dot-dir works on macOS, Linux, and Windows
|
|
34
|
+
Path.home() / ".codex" / "sessions",
|
|
35
|
+
Path.home() / ".codex" / "projects",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
39
|
+
super().__init__(config)
|
|
40
|
+
ad_cfg = (config or {}).get("adapters", {}).get("codex_cli", {})
|
|
41
|
+
paths = ad_cfg.get("roots") or []
|
|
42
|
+
self.roots: list[Path] = (
|
|
43
|
+
[Path(p).expanduser() for p in paths] if paths else self.DEFAULT_ROOTS
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def session_store_path(self): # type: ignore[override]
|
|
48
|
+
return self.roots
|
|
49
|
+
|
|
50
|
+
# #496: is_available() inherited from BaseAdapter — it now creates
|
|
51
|
+
# a temp instance and reads self.session_store_path through the
|
|
52
|
+
# @property below. Was a duplicate scan over DEFAULT_ROOTS; same
|
|
53
|
+
# result, less code.
|
|
54
|
+
|
|
55
|
+
def discover_sessions(self) -> list[Path]:
|
|
56
|
+
out: list[Path] = []
|
|
57
|
+
for root in self.roots:
|
|
58
|
+
root = Path(root).expanduser()
|
|
59
|
+
if root.exists():
|
|
60
|
+
out.extend(sorted(root.rglob("*.jsonl")))
|
|
61
|
+
# Dedupe
|
|
62
|
+
seen: set[Path] = set()
|
|
63
|
+
unique: list[Path] = []
|
|
64
|
+
for p in out:
|
|
65
|
+
if p not in seen:
|
|
66
|
+
seen.add(p)
|
|
67
|
+
unique.append(p)
|
|
68
|
+
return unique
|
|
69
|
+
|
|
70
|
+
def derive_project_slug(self, path: Path) -> str:
|
|
71
|
+
"""Derive the project slug from the Codex session's recorded cwd.
|
|
72
|
+
|
|
73
|
+
Codex CLI 0.118.0 stores sessions under date-bucketed directories
|
|
74
|
+
(~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl), so the directory
|
|
75
|
+
path itself is NOT the project identifier. Instead we read the
|
|
76
|
+
session_meta record's `cwd` field and use its basename.
|
|
77
|
+
|
|
78
|
+
Fallback: if the session has no cwd, use the directory name
|
|
79
|
+
(which will be the DD date bucket — not great, but better than
|
|
80
|
+
crashing).
|
|
81
|
+
"""
|
|
82
|
+
import json
|
|
83
|
+
|
|
84
|
+
# Try to read cwd from the session_meta record
|
|
85
|
+
try:
|
|
86
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
try:
|
|
89
|
+
r = json.loads(line.strip())
|
|
90
|
+
except ValueError:
|
|
91
|
+
continue
|
|
92
|
+
if r.get("type") == "session_meta":
|
|
93
|
+
cwd = r.get("payload", {}).get("cwd", "")
|
|
94
|
+
if cwd:
|
|
95
|
+
# Use the basename of the working directory
|
|
96
|
+
return Path(cwd).name.lower().replace(" ", "-")
|
|
97
|
+
break # session_meta is always the first record
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# Fallback: use the parent directory name
|
|
102
|
+
return path.parent.name
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_records(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
106
|
+
"""Normalize Codex CLI records into the shared Claude-style format.
|
|
107
|
+
|
|
108
|
+
Maps:
|
|
109
|
+
- session_meta → sessionId, cwd, model metadata extraction
|
|
110
|
+
- response_item(role=user, type=message) → user record
|
|
111
|
+
- response_item(role=assistant, type=message) → assistant record
|
|
112
|
+
- response_item(type=web_search_call) → tool_use record
|
|
113
|
+
- event_msg(type=task_started) → turn boundary (triggers slug)
|
|
114
|
+
- Everything else → skipped gracefully
|
|
115
|
+
"""
|
|
116
|
+
out: list[dict[str, Any]] = []
|
|
117
|
+
|
|
118
|
+
# Extract session metadata first
|
|
119
|
+
session_id = ""
|
|
120
|
+
cwd = ""
|
|
121
|
+
model = ""
|
|
122
|
+
for r in records:
|
|
123
|
+
if r.get("type") == "session_meta":
|
|
124
|
+
p = r.get("payload", {})
|
|
125
|
+
session_id = p.get("id", "")
|
|
126
|
+
cwd = p.get("cwd", "")
|
|
127
|
+
|
|
128
|
+
for r in records:
|
|
129
|
+
rtype = r.get("type", "")
|
|
130
|
+
payload = r.get("payload", {})
|
|
131
|
+
ts = r.get("timestamp", "")
|
|
132
|
+
|
|
133
|
+
if rtype == "session_meta":
|
|
134
|
+
# Emit a synthetic init record with metadata
|
|
135
|
+
out.append({
|
|
136
|
+
"type": "init",
|
|
137
|
+
"sessionId": session_id,
|
|
138
|
+
"cwd": cwd,
|
|
139
|
+
"timestamp": ts,
|
|
140
|
+
})
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if rtype == "turn_context":
|
|
144
|
+
model = payload.get("model", model)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if rtype == "response_item":
|
|
148
|
+
role = payload.get("role", "")
|
|
149
|
+
item_type = payload.get("type", "")
|
|
150
|
+
content_blocks = payload.get("content", [])
|
|
151
|
+
|
|
152
|
+
if role == "user" and item_type == "message":
|
|
153
|
+
# Extract text from content blocks
|
|
154
|
+
text_parts = []
|
|
155
|
+
for block in (content_blocks if isinstance(content_blocks, list) else []):
|
|
156
|
+
if isinstance(block, dict) and block.get("type") == "input_text":
|
|
157
|
+
text_parts.append(block.get("text", ""))
|
|
158
|
+
elif isinstance(block, str):
|
|
159
|
+
text_parts.append(block)
|
|
160
|
+
text = "\n".join(text_parts).strip()
|
|
161
|
+
if text:
|
|
162
|
+
out.append({
|
|
163
|
+
"type": "user",
|
|
164
|
+
"message": {"role": "user", "content": text},
|
|
165
|
+
"timestamp": ts,
|
|
166
|
+
})
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if role == "assistant" and item_type == "message":
|
|
170
|
+
text_parts = []
|
|
171
|
+
for block in (content_blocks if isinstance(content_blocks, list) else []):
|
|
172
|
+
if isinstance(block, dict) and block.get("type") == "output_text":
|
|
173
|
+
text_parts.append({"type": "text", "text": block.get("text", "")})
|
|
174
|
+
elif isinstance(block, str):
|
|
175
|
+
text_parts.append({"type": "text", "text": block})
|
|
176
|
+
if text_parts:
|
|
177
|
+
out.append({
|
|
178
|
+
"type": "assistant",
|
|
179
|
+
"message": {
|
|
180
|
+
"role": "assistant",
|
|
181
|
+
"content": text_parts,
|
|
182
|
+
"model": model,
|
|
183
|
+
},
|
|
184
|
+
"timestamp": ts,
|
|
185
|
+
})
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
if role == "developer":
|
|
189
|
+
# System/developer prompt — skip (context, not conversation)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if item_type == "web_search_call":
|
|
193
|
+
out.append({
|
|
194
|
+
"type": "assistant",
|
|
195
|
+
"message": {
|
|
196
|
+
"role": "assistant",
|
|
197
|
+
"content": [{
|
|
198
|
+
"type": "tool_use",
|
|
199
|
+
"name": "WebSearch",
|
|
200
|
+
"input": {"query": payload.get("query", "")},
|
|
201
|
+
}],
|
|
202
|
+
},
|
|
203
|
+
"timestamp": ts,
|
|
204
|
+
})
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
if item_type == "reasoning":
|
|
208
|
+
# Thinking block — skip by default (matches Claude config)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
if rtype == "event_msg":
|
|
212
|
+
# Turn lifecycle events — mostly metadata, not conversation
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# Unknown record type — skip gracefully (never crash)
|
|
216
|
+
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
def is_subagent(self, path: Path) -> bool:
|
|
220
|
+
"""Codex CLI has no sub-agent concept (#406). Returns False
|
|
221
|
+
unconditionally. The previous substring heuristic produced
|
|
222
|
+
false positives on any project named e.g. ``subagent-runner``
|
|
223
|
+
or any path containing ``agent-`` (which is also a common
|
|
224
|
+
prefix in user code).
|
|
225
|
+
"""
|
|
226
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Contrib adapters — loaded on demand via ``discover_contrib()``."""
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""ChatGPT conversation-export adapter (v1.1 · #44).
|
|
2
|
+
|
|
3
|
+
ChatGPT doesn't write session files to disk like Claude Code or Codex.
|
|
4
|
+
Users must **export** their chat history from
|
|
5
|
+
https://chat.openai.com/settings → Data Controls → Export, then extract
|
|
6
|
+
the zip into a directory. This adapter reads `conversations.json` from
|
|
7
|
+
that export.
|
|
8
|
+
|
|
9
|
+
The export format is a JSON array of conversation objects::
|
|
10
|
+
|
|
11
|
+
[
|
|
12
|
+
{
|
|
13
|
+
"title": "Debugging my FastAPI route",
|
|
14
|
+
"create_time": 1730246400.0,
|
|
15
|
+
"update_time": 1730250000.0,
|
|
16
|
+
"mapping": {
|
|
17
|
+
"<uuid>": {
|
|
18
|
+
"id": "<uuid>",
|
|
19
|
+
"message": {
|
|
20
|
+
"author": {"role": "user"|"assistant"|"system"|"tool"},
|
|
21
|
+
"content": {"parts": ["..."]} | {"content_type": "code", ...},
|
|
22
|
+
"create_time": 1730246402.0
|
|
23
|
+
},
|
|
24
|
+
"parent": "<uuid>",
|
|
25
|
+
"children": ["<uuid>"]
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"current_node": "<uuid>"
|
|
29
|
+
},
|
|
30
|
+
...
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
Configuration (in ``sessions_config.json``)::
|
|
34
|
+
|
|
35
|
+
"chatgpt": {
|
|
36
|
+
"enabled": false,
|
|
37
|
+
"export_dirs": ["~/Downloads/chatgpt-export"],
|
|
38
|
+
"min_messages": 2
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Opt-in: disabled by default. Add ``"enabled": true`` and point
|
|
42
|
+
``export_dirs`` at the folder where you extracted the export zip.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Any, Iterator
|
|
51
|
+
|
|
52
|
+
from sessionwiki.adapters import register
|
|
53
|
+
from sessionwiki.adapters.base import BaseAdapter
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@register("chatgpt")
|
|
57
|
+
class ChatGPTAdapter(BaseAdapter):
|
|
58
|
+
"""ChatGPT — parses conversations.json from a user's export."""
|
|
59
|
+
|
|
60
|
+
SUPPORTED_SCHEMA_VERSIONS = ["v1"]
|
|
61
|
+
|
|
62
|
+
DEFAULT_EXPORT_DIRS = [
|
|
63
|
+
Path.home() / "Downloads" / "chatgpt-export",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
67
|
+
super().__init__(config)
|
|
68
|
+
ad_cfg = (config or {}).get("chatgpt", {})
|
|
69
|
+
self._enabled = ad_cfg.get("enabled", False)
|
|
70
|
+
self._min_messages = int(ad_cfg.get("min_messages", 2))
|
|
71
|
+
dirs = ad_cfg.get("export_dirs", [])
|
|
72
|
+
if dirs:
|
|
73
|
+
self._export_dirs = [Path(d).expanduser() for d in dirs]
|
|
74
|
+
else:
|
|
75
|
+
self._export_dirs = self.DEFAULT_EXPORT_DIRS
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def session_store_path(self): # type: ignore[override]
|
|
79
|
+
return self._export_dirs
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def is_available(cls) -> bool:
|
|
83
|
+
# Disabled by default — user must opt in via config
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def is_available_with_config(self) -> bool:
|
|
87
|
+
if not self._enabled:
|
|
88
|
+
return False
|
|
89
|
+
return any(
|
|
90
|
+
(d / "conversations.json").is_file() for d in self._export_dirs
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def discover_sessions(self) -> list[Path]:
|
|
94
|
+
"""Each conversations.json file is one 'session-store'."""
|
|
95
|
+
out: list[Path] = []
|
|
96
|
+
for d in self._export_dirs:
|
|
97
|
+
path = d / "conversations.json"
|
|
98
|
+
if path.is_file():
|
|
99
|
+
out.append(path)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─── Parsing ───────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _role(msg: dict[str, Any]) -> str:
|
|
107
|
+
"""Extract role from the message dict."""
|
|
108
|
+
try:
|
|
109
|
+
return msg["author"]["role"]
|
|
110
|
+
except (KeyError, TypeError):
|
|
111
|
+
return "unknown"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _content_parts(msg: dict[str, Any]) -> list[str]:
|
|
115
|
+
"""Extract text parts from a message. ChatGPT exports can be nested."""
|
|
116
|
+
content = msg.get("content", {})
|
|
117
|
+
if not isinstance(content, dict):
|
|
118
|
+
return []
|
|
119
|
+
parts = content.get("parts", [])
|
|
120
|
+
if not isinstance(parts, list):
|
|
121
|
+
return []
|
|
122
|
+
# Each part can be a string OR a dict with "text" or "image_url"
|
|
123
|
+
out: list[str] = []
|
|
124
|
+
for part in parts:
|
|
125
|
+
if isinstance(part, str):
|
|
126
|
+
out.append(part)
|
|
127
|
+
elif isinstance(part, dict) and "text" in part:
|
|
128
|
+
out.append(str(part["text"]))
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _linearize(conversation: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
|
133
|
+
"""Follow the parent→children chain from `current_node` back to root,
|
|
134
|
+
then yield messages in forward order."""
|
|
135
|
+
mapping = conversation.get("mapping", {})
|
|
136
|
+
current = conversation.get("current_node")
|
|
137
|
+
if not current or not mapping:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Walk up from current_node to collect the linear path
|
|
141
|
+
chain: list[str] = []
|
|
142
|
+
while current:
|
|
143
|
+
chain.append(current)
|
|
144
|
+
node = mapping.get(current, {})
|
|
145
|
+
current = node.get("parent")
|
|
146
|
+
chain.reverse() # root → leaf
|
|
147
|
+
|
|
148
|
+
for node_id in chain:
|
|
149
|
+
node = mapping.get(node_id, {})
|
|
150
|
+
msg = node.get("message")
|
|
151
|
+
if not msg:
|
|
152
|
+
continue
|
|
153
|
+
yield msg
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_conversations_json(path: Path) -> list[dict[str, Any]]:
|
|
157
|
+
"""Read a conversations.json export and return a list of session dicts.
|
|
158
|
+
|
|
159
|
+
Each session dict has: ``title``, ``created``, ``updated``, ``messages``
|
|
160
|
+
(list of ``{role, text, created}``).
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
164
|
+
except (OSError, json.JSONDecodeError):
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
if not isinstance(raw, list):
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
sessions: list[dict[str, Any]] = []
|
|
171
|
+
for conv in raw:
|
|
172
|
+
if not isinstance(conv, dict):
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
title = str(conv.get("title", "Untitled ChatGPT conversation"))
|
|
176
|
+
created = conv.get("create_time")
|
|
177
|
+
updated = conv.get("update_time")
|
|
178
|
+
|
|
179
|
+
messages: list[dict[str, Any]] = []
|
|
180
|
+
for msg in _linearize(conv):
|
|
181
|
+
role = _role(msg)
|
|
182
|
+
parts = _content_parts(msg)
|
|
183
|
+
if not parts:
|
|
184
|
+
continue
|
|
185
|
+
msg_created = msg.get("create_time")
|
|
186
|
+
messages.append({
|
|
187
|
+
"role": role,
|
|
188
|
+
"text": "\n".join(parts),
|
|
189
|
+
"created": msg_created,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
sessions.append({
|
|
193
|
+
"title": title,
|
|
194
|
+
"created": created,
|
|
195
|
+
"updated": updated,
|
|
196
|
+
"messages": messages,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return sessions
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ─── Markdown rendering ───────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _fmt_ts(ts: Any) -> str:
|
|
206
|
+
"""Format a Unix timestamp as ISO date string; "" if unavailable."""
|
|
207
|
+
if ts is None:
|
|
208
|
+
return ""
|
|
209
|
+
try:
|
|
210
|
+
return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d")
|
|
211
|
+
except (ValueError, TypeError, OSError):
|
|
212
|
+
return ""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def render_conversation_markdown(session: dict[str, Any]) -> str:
|
|
216
|
+
"""Render a single ChatGPT conversation as frontmatter-tagged markdown."""
|
|
217
|
+
title = session["title"]
|
|
218
|
+
date_str = _fmt_ts(session.get("created")) or _fmt_ts(session.get("updated"))
|
|
219
|
+
msg_count = len(session["messages"])
|
|
220
|
+
|
|
221
|
+
# Collect unique roles
|
|
222
|
+
roles = sorted({m["role"] for m in session["messages"] if m["role"] != "unknown"})
|
|
223
|
+
|
|
224
|
+
fm = [
|
|
225
|
+
"---",
|
|
226
|
+
f'title: "{title}"',
|
|
227
|
+
"type: source",
|
|
228
|
+
"tags: [chatgpt, session-transcript]",
|
|
229
|
+
f"date: {date_str}",
|
|
230
|
+
"project: chatgpt",
|
|
231
|
+
f"source: chatgpt-export",
|
|
232
|
+
f"message_count: {msg_count}",
|
|
233
|
+
f"roles: [{', '.join(roles)}]",
|
|
234
|
+
"---",
|
|
235
|
+
"",
|
|
236
|
+
f"# {title}",
|
|
237
|
+
"",
|
|
238
|
+
f"**Messages:** {msg_count} · **Date:** {date_str or 'unknown'}",
|
|
239
|
+
"",
|
|
240
|
+
"## Conversation",
|
|
241
|
+
"",
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
for m in session["messages"]:
|
|
245
|
+
role = m["role"].capitalize()
|
|
246
|
+
fm.append(f"### {role}")
|
|
247
|
+
fm.append("")
|
|
248
|
+
# Indent text blocks for readability
|
|
249
|
+
fm.append(m["text"])
|
|
250
|
+
fm.append("")
|
|
251
|
+
|
|
252
|
+
return "\n".join(fm)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""VS Code Copilot Chat adapter.
|
|
2
|
+
|
|
3
|
+
Reads conversation history from the GitHub Copilot Chat extension's local
|
|
4
|
+
storage in VS Code, VS Code Insiders, and VSCodium.
|
|
5
|
+
|
|
6
|
+
Copilot Chat stores per-workspace conversation files under:
|
|
7
|
+
<editor-data>/User/workspaceStorage/<hash>/chatSessions/*.jsonl
|
|
8
|
+
<editor-data>/User/workspaceStorage/<hash>/chatSessions/*.json
|
|
9
|
+
|
|
10
|
+
where <editor-data> is platform- and editor-variant-specific:
|
|
11
|
+
macOS: ~/Library/Application Support/<editor>/
|
|
12
|
+
Linux: ~/.config/<editor>/
|
|
13
|
+
Windows: %APPDATA%/<editor>/
|
|
14
|
+
|
|
15
|
+
Supported editor variants: Code, Code - Insiders, VSCodium.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from sessionwiki.adapters import register
|
|
25
|
+
from sessionwiki.adapters.base import BaseAdapter
|
|
26
|
+
|
|
27
|
+
# Editor directory names for each VS Code variant.
|
|
28
|
+
_EDITOR_DIRS = ["Code", "Code - Insiders", "VSCodium"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_default_roots() -> list[Path]:
|
|
32
|
+
"""Build the full cross-platform DEFAULT_ROOTS list at import time."""
|
|
33
|
+
home = Path.home()
|
|
34
|
+
roots: list[Path] = []
|
|
35
|
+
for editor in _EDITOR_DIRS:
|
|
36
|
+
# macOS
|
|
37
|
+
roots.append(home / "Library" / "Application Support" / editor / "User" / "workspaceStorage")
|
|
38
|
+
# Linux
|
|
39
|
+
roots.append(home / ".config" / editor / "User" / "workspaceStorage")
|
|
40
|
+
# Windows
|
|
41
|
+
roots.append(home / "AppData" / "Roaming" / editor / "User" / "workspaceStorage")
|
|
42
|
+
return roots
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@register("copilot_chat", aliases=["copilot-chat"])
|
|
46
|
+
class CopilotChatAdapter(BaseAdapter):
|
|
47
|
+
"""GitHub Copilot Chat — reads VS Code workspaceStorage chatSessions.
|
|
48
|
+
|
|
49
|
+
#arch-l5 (#626): canonical name is ``copilot_chat`` (snake_case,
|
|
50
|
+
matching every other adapter). The kebab-case ``copilot-chat`` is
|
|
51
|
+
kept as a REGISTRY alias and still recognised in
|
|
52
|
+
``sessions_config.json`` so existing user configs don't break.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# #arch-m9 (#621): SUPPORTED_SCHEMA_VERSIONS = ["v1"] is the
|
|
56
|
+
# BaseAdapter default — declaration removed here.
|
|
57
|
+
|
|
58
|
+
DEFAULT_ROOTS: list[Path] = _build_default_roots()
|
|
59
|
+
|
|
60
|
+
# File patterns inside chatSessions/ subdirectories.
|
|
61
|
+
SESSION_FILE_PATTERNS = ["*.jsonl", "*.json"]
|
|
62
|
+
|
|
63
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
64
|
+
super().__init__(config)
|
|
65
|
+
adapters_cfg = (config or {}).get("adapters", {}) or {}
|
|
66
|
+
# #626: read snake_case config first; fall back to legacy
|
|
67
|
+
# kebab-case so existing sessions_config.json keeps working.
|
|
68
|
+
ad_cfg = adapters_cfg.get("copilot_chat") or adapters_cfg.get("copilot-chat") or {}
|
|
69
|
+
paths = ad_cfg.get("roots") or []
|
|
70
|
+
self.roots: list[Path] = (
|
|
71
|
+
[Path(p).expanduser() for p in paths] if paths else self.DEFAULT_ROOTS
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def session_store_path(self): # type: ignore[override]
|
|
76
|
+
return self.roots
|
|
77
|
+
|
|
78
|
+
# #496: is_available() inherited from BaseAdapter — temp
|
|
79
|
+
# instance reads self.session_store_path through the @property.
|
|
80
|
+
|
|
81
|
+
def discover_sessions(self) -> list[Path]:
|
|
82
|
+
"""Find chatSessions/*.jsonl and *.json under each workspace hash dir."""
|
|
83
|
+
out: list[Path] = []
|
|
84
|
+
for root in self.roots:
|
|
85
|
+
root = Path(root).expanduser()
|
|
86
|
+
if not root.exists():
|
|
87
|
+
continue
|
|
88
|
+
# Walk <root>/<hash>/chatSessions/<file>
|
|
89
|
+
for pattern in self.SESSION_FILE_PATTERNS:
|
|
90
|
+
glob_pattern = os.path.join("*", "chatSessions", pattern)
|
|
91
|
+
out.extend(sorted(root.glob(glob_pattern)))
|
|
92
|
+
# De-dupe while preserving order
|
|
93
|
+
seen: set[Path] = set()
|
|
94
|
+
unique: list[Path] = []
|
|
95
|
+
for p in out:
|
|
96
|
+
if p not in seen:
|
|
97
|
+
seen.add(p)
|
|
98
|
+
unique.append(p)
|
|
99
|
+
return unique
|
|
100
|
+
|
|
101
|
+
def derive_project_slug(self, path: Path) -> str:
|
|
102
|
+
"""Derive a slug from the workspace hash directory.
|
|
103
|
+
|
|
104
|
+
The chatSessions/ files live at:
|
|
105
|
+
<root>/<workspace-hash>/chatSessions/<file>
|
|
106
|
+
We use the workspace hash (parent of chatSessions/) as the slug,
|
|
107
|
+
truncated to 12 chars and prefixed with 'copilot-'.
|
|
108
|
+
"""
|
|
109
|
+
# Try to find the workspace hash dir — should be the parent of chatSessions/
|
|
110
|
+
for root in self.roots:
|
|
111
|
+
root = Path(root).expanduser()
|
|
112
|
+
try:
|
|
113
|
+
rel = path.relative_to(root)
|
|
114
|
+
if rel.parts:
|
|
115
|
+
return f"copilot-{rel.parts[0][:12]}"
|
|
116
|
+
except ValueError:
|
|
117
|
+
continue
|
|
118
|
+
# Fallback: walk up from the file to find the chatSessions parent
|
|
119
|
+
if path.parent.name == "chatSessions":
|
|
120
|
+
return f"copilot-{path.parent.parent.name[:12]}"
|
|
121
|
+
return f"copilot-{path.parent.name[:12]}"
|