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,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]}"