thread-archive 0.0.2__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 (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,538 @@
1
+ """OpenCode (`opencode.db`) DB scan + per-session import + assembler.
2
+
3
+ One SQLite DB holds every session (session / message / part tables). We open it
4
+ read-only, read everything into memory, and dispatch each session to the
5
+ per-session importer (incremental skip via the import_state cursor on
6
+ ``time_updated``; a streaming assistant message with no ``time.completed`` stops
7
+ the settled prefix so a half-finished turn isn't imported). Pure ``_opencode_*`` /
8
+ ``_build_opencode_messages`` helpers copied verbatim; orchestration rewired.
9
+
10
+ Capture-everything invariant: no provider record is silently dropped on import.
11
+ Everything the DB holds reaches an event, leaning on the shared builder's two
12
+ verbatim-preservation paths — an unmodeled role becomes a ``message`` event
13
+ (``content_blocks`` kept), and an unmodeled assistant block becomes a
14
+ ``content_block`` event (raw under ``data``). So non-user/assistant messages,
15
+ non-text user parts, unknown assistant part types, and even JSON that failed to
16
+ parse are all preserved rather than skipped.
17
+
18
+ Settled-prefix exception (the abandoned-turn hole): the ``time.completed`` gate
19
+ holds back a *live* streaming turn so its churning partial content isn't stacked
20
+ every poll (its dedup_key changes as it streams). But a turn that crashed and
21
+ will never get ``completed`` written would strand itself AND every message after
22
+ it forever. So the gate is staleness-aware: on a session untouched for longer
23
+ than ``_OPENCODE_ABANDONED_MS`` the turn is treated as abandoned — its
24
+ already-present content is imported once (tagged ``stop_reason="incomplete"``)
25
+ and the walk continues so the tail survives. A fresh session still breaks (no
26
+ churn); a stale session is re-imported at most once (the ``time_updated`` cursor
27
+ skips it thereafter), so the partial is captured exactly once.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import logging
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Any, Optional
38
+
39
+ from thread_archive._thread_import import DefaultEventBuilder
40
+
41
+ from .._store import ImportState, get_session
42
+ from ._events import assemble_events
43
+ from ._state import create_thread, get_import_state, get_thread_by_source, upsert_import_state
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ @dataclass
49
+ class OpenCodeImportResult:
50
+ events_created: int
51
+ thread_id: int
52
+ is_new_thread: bool
53
+
54
+
55
+ @dataclass
56
+ class OpenCodeDbScanResult:
57
+ sessions_processed: int
58
+ sessions_imported: int
59
+ events_created: int
60
+ sessions_failed: int = 0
61
+ errors: list[str] = field(default_factory=list)
62
+
63
+
64
+ def import_opencode_db(db_path) -> OpenCodeDbScanResult:
65
+ """Open an OpenCode ``opencode.db`` and import every session in it."""
66
+ import sqlite3
67
+
68
+ db_path = Path(db_path)
69
+ sessions: dict[str, dict[str, Any]] = {}
70
+ messages_by_session: dict[str, list[tuple[str, dict[str, Any]]]] = {}
71
+ parts_by_message: dict[str, list[dict[str, Any]]] = {}
72
+
73
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
74
+ try:
75
+ conn.execute("PRAGMA busy_timeout=5000")
76
+ have = {
77
+ row[0]
78
+ for row in conn.execute(
79
+ "SELECT name FROM sqlite_master WHERE type='table' "
80
+ "AND name IN ('session','message','part')"
81
+ )
82
+ }
83
+ if not {"session", "message", "part"} <= have:
84
+ return OpenCodeDbScanResult(0, 0, 0)
85
+
86
+ for sid, project_id, parent_id, title, directory, t_created, t_updated, agent in conn.execute(
87
+ "SELECT id, project_id, parent_id, title, directory, time_created, "
88
+ "time_updated, agent FROM session"
89
+ ):
90
+ sessions[sid] = {
91
+ "id": sid,
92
+ "project_id": project_id,
93
+ "parent_id": parent_id,
94
+ "title": title,
95
+ "directory": directory,
96
+ "time_created": t_created,
97
+ "time_updated": t_updated,
98
+ "agent": agent,
99
+ }
100
+
101
+ for msg_id, session_id, data in conn.execute(
102
+ "SELECT id, session_id, data FROM message ORDER BY time_created, id"
103
+ ):
104
+ try:
105
+ parsed = json.loads(data)
106
+ except (json.JSONDecodeError, TypeError) as e:
107
+ # Corrupt message row: don't drop it silently. Carry a stub with the
108
+ # raw text + error so `_build_opencode_messages` can preserve it as a
109
+ # visible parse_error record (mirrors the parse_error convention).
110
+ parsed = {"_opencode_parse_error": str(e), "_raw_data": _opencode_raw_text(data)}
111
+ messages_by_session.setdefault(session_id, []).append((msg_id, parsed))
112
+
113
+ for message_id, data in conn.execute(
114
+ "SELECT message_id, data FROM part ORDER BY time_created, id"
115
+ ):
116
+ try:
117
+ parsed = json.loads(data)
118
+ except (json.JSONDecodeError, TypeError) as e:
119
+ # Corrupt part row: keep it as a parse_error part. It rides the same
120
+ # preservation paths as any other part (assistant → content_block,
121
+ # user/other → a raw block) so the corruption stays visible.
122
+ parsed = {
123
+ "type": "parse_error",
124
+ "raw_text": _opencode_raw_text(data),
125
+ "error": str(e),
126
+ "_opencode_parse_error": True,
127
+ }
128
+ parts_by_message.setdefault(message_id, []).append(parsed)
129
+ finally:
130
+ conn.close()
131
+
132
+ summary = OpenCodeDbScanResult(0, 0, 0)
133
+ for session_id, session_data in sessions.items():
134
+ summary.sessions_processed += 1
135
+ try:
136
+ result = import_opencode_from_payload(
137
+ session_id=session_id,
138
+ session_data=session_data,
139
+ messages=messages_by_session.get(session_id, []),
140
+ parts_by_message=parts_by_message,
141
+ )
142
+ if result.events_created > 0:
143
+ summary.sessions_imported += 1
144
+ summary.events_created += result.events_created
145
+ except Exception as e: # noqa: BLE001 — one bad session must not stop the scan
146
+ # Counted and carried out to the watcher, not just logged: a scan that
147
+ # reports only "nothing new" makes a failed session indistinguishable from
148
+ # an unchanged one, and `archive status` stays green while content is
149
+ # missing. The session's watermark never advanced, so it retries.
150
+ logger.exception("import_opencode_db: session %s failed; skipping", session_id[:12])
151
+ summary.sessions_failed += 1
152
+ summary.errors.append(f"session {session_id[:12]}: {e}")
153
+ return summary
154
+
155
+
156
+ def import_opencode_from_payload(
157
+ *,
158
+ session_id: str,
159
+ session_data: dict[str, Any],
160
+ messages: list[tuple[str, dict[str, Any]]],
161
+ parts_by_message: dict[str, list[dict[str, Any]]],
162
+ session=None,
163
+ ) -> OpenCodeImportResult:
164
+ """Import one OpenCode session (atomic per-session transaction when no session)."""
165
+ if session is not None:
166
+ return _run_opencode(session, session_id, session_data, messages, parts_by_message)
167
+ with get_session() as s:
168
+ result = _run_opencode(s, session_id, session_data, messages, parts_by_message)
169
+ s.commit()
170
+ return result
171
+
172
+
173
+ def _run_opencode(session, session_id, session_data, messages, parts_by_message) -> OpenCodeImportResult:
174
+ source_id = session_id
175
+ import_state = get_import_state(session, "opencode", source_id)
176
+
177
+ if _opencode_session_unchanged(import_state, session_data):
178
+ return OpenCodeImportResult(0, (import_state.thread_id or 0) if import_state else 0, False)
179
+
180
+ norm = _build_opencode_messages(messages, parts_by_message, session_data=session_data)
181
+ if not norm:
182
+ return OpenCodeImportResult(0, 0, False)
183
+
184
+ thread_id, is_new_thread = _opencode_resolve_thread(session, import_state, session_id, source_id, session_data)
185
+
186
+ start_index = import_state.last_line_count if import_state else 0
187
+ new_messages = norm[start_index:]
188
+ if not new_messages:
189
+ return OpenCodeImportResult(0, thread_id, is_new_thread)
190
+
191
+ base_ts = _parse_opencode_timestamp(session_data.get("time_created")) or datetime.now(timezone.utc)
192
+ normalized = [_opencode_to_normalized(m) for m in new_messages]
193
+ # cross_pass_dedup: see cursor.py — a full re-scan must not re-stack duplicate
194
+ # events against NULL-dedup_key backfill rows (the Postgres-seeded March 2026
195
+ # backfill). The message-level content+timestamp check skips already-present
196
+ # turns regardless of dedup_key.
197
+ events_created, _ = assemble_events(
198
+ session, thread_id, normalized, DefaultEventBuilder(),
199
+ base_prev_ts=base_ts, cross_pass_dedup=True,
200
+ )
201
+ upsert_import_state(
202
+ session,
203
+ source="opencode",
204
+ source_id=source_id,
205
+ thread_id=thread_id,
206
+ last_line_count=len(norm),
207
+ last_file_size=0,
208
+ last_message_uuid=norm[-1].get("id") if norm else None,
209
+ )
210
+ return OpenCodeImportResult(events_created, thread_id, is_new_thread)
211
+
212
+
213
+ def _opencode_session_unchanged(import_state: Optional[ImportState], session_data: dict[str, Any]) -> bool:
214
+ if not (import_state and import_state.last_import_at):
215
+ return False
216
+ time_updated_ms = session_data.get("time_updated") or 0
217
+ last_import_ms = import_state.last_import_at.timestamp() * 1000
218
+ return time_updated_ms <= last_import_ms
219
+
220
+
221
+ def _opencode_resolve_thread(session, import_state, session_id, source_id, session_data) -> tuple[int, bool]:
222
+ if import_state and import_state.thread_id:
223
+ return import_state.thread_id, False
224
+ existing = get_thread_by_source(session, "opencode", source_id)
225
+ if existing and existing.id is not None:
226
+ return existing.id, False
227
+
228
+ title = session_data.get("title") or "OpenCode Session"
229
+ if len(title) > 100:
230
+ title = title[:97] + "..."
231
+ source_metadata = {
232
+ k: v
233
+ for k, v in {
234
+ "provider": "opencode",
235
+ "opencode_session_id": session_id,
236
+ "directory": session_data.get("directory"),
237
+ "agent": session_data.get("agent"),
238
+ "parent_id": session_data.get("parent_id"),
239
+ }.items()
240
+ if v is not None
241
+ }
242
+ thread_id = create_thread(
243
+ session,
244
+ source="opencode",
245
+ source_id=source_id,
246
+ title=title,
247
+ source_metadata=source_metadata,
248
+ )
249
+ return thread_id, True
250
+
251
+
252
+ # A session untouched for this long still carrying an unsettled assistant turn is
253
+ # treated as abandoned/crashed rather than live-streaming: its partial content is
254
+ # imported once (tagged incomplete) instead of stranding it + everything after it.
255
+ # Conservatively large so a genuinely live turn is never misread as abandoned.
256
+ _OPENCODE_ABANDONED_MS = 24 * 60 * 60 * 1000
257
+
258
+
259
+ def _build_opencode_messages(
260
+ messages: list[tuple[str, dict[str, Any]]],
261
+ parts_by_message: dict[str, list[dict[str, Any]]],
262
+ session_data: Optional[dict[str, Any]] = None,
263
+ ) -> list[dict[str, Any]]:
264
+ """Assemble messages + parts into the settled-prefix normalized shape."""
265
+ norm: list[dict[str, Any]] = []
266
+ now_ms = datetime.now(timezone.utc).timestamp() * 1000
267
+ session_updated_ms = (session_data or {}).get("time_updated")
268
+
269
+ for msg_id, data in messages:
270
+ if not isinstance(data, dict):
271
+ continue
272
+
273
+ # A message row whose JSON failed to parse was stubbed at read time —
274
+ # preserve it as a visible parse_error record instead of dropping it.
275
+ if data.get("_opencode_parse_error") is not None:
276
+ norm.append({
277
+ "id": msg_id,
278
+ "role": "parse_error",
279
+ "error": data.get("_opencode_parse_error"),
280
+ "raw": data.get("_raw_data"),
281
+ })
282
+ continue
283
+
284
+ role = data.get("role")
285
+ mtime = data.get("time") or {}
286
+ parts = parts_by_message.get(msg_id, [])
287
+
288
+ if role == "user":
289
+ text = "\n".join(
290
+ p.get("text", "") for p in parts if p.get("type") == "text" and p.get("text")
291
+ )
292
+ norm.append({"id": msg_id, "role": "user", "created_at": mtime.get("created"), "content": text})
293
+ # Non-text user parts (files/attachments/images) must not be discarded. Keep
294
+ # them as a sibling turn under an unmodeled role so the builder's generic
295
+ # `message` path preserves each raw part verbatim in content_blocks.
296
+ other_parts = [p for p in parts if isinstance(p, dict) and p.get("type") != "text"]
297
+ if other_parts:
298
+ norm.append({
299
+ "id": f"{msg_id}#parts",
300
+ "role": "user_attachment",
301
+ "created_at": mtime.get("created"),
302
+ "parts": other_parts,
303
+ })
304
+ continue
305
+
306
+ if role != "assistant":
307
+ # Any other role (opencode is normally user/assistant only; this is the
308
+ # forward-compat catch). Preserve it verbatim rather than dropping — text
309
+ # as content, every part as a raw block.
310
+ text = "\n".join(
311
+ p.get("text", "") for p in parts if p.get("type") == "text" and p.get("text")
312
+ )
313
+ norm.append({
314
+ "id": msg_id,
315
+ "role": role or "unknown",
316
+ "created_at": mtime.get("created"),
317
+ "content": text,
318
+ "parts": [p for p in parts if isinstance(p, dict)],
319
+ })
320
+ continue
321
+
322
+ # assistant — hold back until the turn has settled (the settled prefix).
323
+ if mtime.get("completed") is None:
324
+ # A live in-progress turn settles on a later poll, so break and wait —
325
+ # importing its churning partial now would stack a fresh event every poll
326
+ # (dedup_key tracks content). But an abandoned/crashed turn never gets
327
+ # `completed`; breaking there strands it + everything after it forever.
328
+ # If the session has gone stale, treat the turn as abandoned: keep its
329
+ # already-present content (tagged incomplete) and walk on. The stale
330
+ # session is re-imported at most once (time_updated cursor), so this
331
+ # partial lands exactly once — no churn.
332
+ if _opencode_session_stale(session_updated_ms, now_ms):
333
+ norm.append({
334
+ "id": msg_id,
335
+ "role": "assistant",
336
+ "started_at": mtime.get("created"),
337
+ "completed_at": None,
338
+ "model": _opencode_model(data),
339
+ "segments": _opencode_assistant_segments(parts),
340
+ "incomplete": True,
341
+ })
342
+ continue
343
+ break
344
+
345
+ norm.append({
346
+ "id": msg_id,
347
+ "role": "assistant",
348
+ "started_at": mtime.get("created"),
349
+ "completed_at": mtime.get("completed"),
350
+ "model": _opencode_model(data),
351
+ "segments": _opencode_assistant_segments(parts),
352
+ })
353
+
354
+ return norm
355
+
356
+
357
+ def _opencode_session_stale(session_updated_ms: Any, now_ms: float) -> bool:
358
+ """True when a session hasn't been touched for ``_OPENCODE_ABANDONED_MS`` — a
359
+ still-unsettled turn on such a session is abandoned, not live. A missing
360
+ ``time_updated`` is treated as *not* stale (conservative: keep holding rather
361
+ than risk churning a live turn we can't date)."""
362
+ if not isinstance(session_updated_ms, (int, float)):
363
+ return False
364
+ return (now_ms - session_updated_ms) > _OPENCODE_ABANDONED_MS
365
+
366
+
367
+ def _opencode_raw_text(data: Any) -> str:
368
+ """The raw stored blob for a corrupt row, as text and never truncated."""
369
+ return data if isinstance(data, str) else repr(data)
370
+
371
+
372
+ def _opencode_text_segment(kind: str, p: dict[str, Any]) -> Optional[dict[str, Any]]:
373
+ txt = p.get("text", "")
374
+ if not txt:
375
+ return None
376
+ ptime = p.get("time") or {}
377
+ return {"kind": kind, "text": txt, "ts": ptime.get("start")}
378
+
379
+
380
+ def _opencode_tool_segment(p: dict[str, Any]) -> dict[str, Any]:
381
+ state = p.get("state") or {}
382
+ stime = state.get("time") or {}
383
+ return {
384
+ "kind": "tool",
385
+ "call_id": p.get("callID") or "",
386
+ "name": p.get("tool") or "unknown",
387
+ "input": state.get("input") or {},
388
+ "output": _opencode_stringify(state.get("output")),
389
+ "is_error": state.get("status") == "error",
390
+ "ts": stime.get("start"),
391
+ "end_ts": stime.get("end"),
392
+ }
393
+
394
+
395
+ def _opencode_assistant_segments(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
396
+ segments: list[dict[str, Any]] = []
397
+ for p in parts:
398
+ ptype = p.get("type")
399
+ if ptype == "reasoning":
400
+ seg = _opencode_text_segment("thinking", p)
401
+ elif ptype == "text":
402
+ seg = _opencode_text_segment("text", p)
403
+ elif ptype == "tool":
404
+ seg = _opencode_tool_segment(p)
405
+ else:
406
+ # An assistant part type we don't model (file, step-start/finish,
407
+ # snapshot, a parse_error stub, …). Keep it as a raw segment carrying the
408
+ # part + its type; `_opencode_to_normalized` turns it into an unmodeled
409
+ # content block, which the builder preserves as a `content_block` event.
410
+ ptime = p.get("time") or {}
411
+ seg = {"kind": "raw", "part_type": ptype, "raw": p, "ts": ptime.get("start")}
412
+ if seg is not None:
413
+ segments.append(seg)
414
+ return segments
415
+
416
+
417
+ def _opencode_model(data: dict[str, Any]) -> str:
418
+ model_id = data.get("modelID")
419
+ provider = data.get("providerID")
420
+ if model_id and provider:
421
+ return f"{provider}/{model_id}"
422
+ return model_id or "opencode"
423
+
424
+
425
+ def _opencode_stringify(output: Any) -> str:
426
+ if isinstance(output, str):
427
+ return output
428
+ if output is None:
429
+ return ""
430
+ return json.dumps(output, default=str)
431
+
432
+
433
+ def _opencode_iso(ms: Any) -> Optional[str]:
434
+ dt = _parse_opencode_timestamp(ms)
435
+ return dt.isoformat() if dt else None
436
+
437
+
438
+ def _opencode_to_normalized(msg: dict[str, Any]) -> dict[str, Any]:
439
+ """Map one settled OpenCode message into the canonical NormalizedMessage shape."""
440
+ role = msg.get("role", "")
441
+ if role == "user":
442
+ return {
443
+ "role": "user",
444
+ "created_at": _opencode_iso(msg.get("created_at")),
445
+ "content_text": msg.get("content", ""),
446
+ "content_blocks": [],
447
+ "provider_message_id": msg.get("id", ""),
448
+ "provider_data": {"provider": "opencode", "role": "user"},
449
+ }
450
+
451
+ if role == "parse_error":
452
+ # A corrupt message row — surface it as a system parse_error, mirroring the
453
+ # shared parse_error convention. The builder's system path routes an
454
+ # unrecognized first block to a context_summary event, so the raw text +
455
+ # error survive (raw_text in provider_data, error in the content).
456
+ raw = msg.get("raw")
457
+ err = msg.get("error")
458
+ return {
459
+ "role": "system",
460
+ "created_at": None,
461
+ "content_text": f"[OpenCode message parse error]: {err}",
462
+ "content_blocks": [{"type": "parse_error", "raw_text": raw, "error": err, "seq": 0}],
463
+ "provider_message_id": msg.get("id", ""),
464
+ "provider_data": {
465
+ "provider": "opencode", "parse_error": True, "raw_text": raw, "error": err,
466
+ },
467
+ }
468
+
469
+ if role != "assistant":
470
+ # An unmodeled role (opencode's non-text user parts land here as
471
+ # `user_attachment`; any other message role too). The builder's generic
472
+ # `message` path keeps content_blocks verbatim; we also stash the raw parts in
473
+ # provider_data so nothing is lost even on the lossier system path.
474
+ blocks = [p for p in msg.get("parts", []) if isinstance(p, dict)]
475
+ return {
476
+ "role": role or "unknown",
477
+ "created_at": _opencode_iso(msg.get("created_at")),
478
+ "content_text": msg.get("content", ""),
479
+ "content_blocks": blocks,
480
+ "provider_message_id": msg.get("id", ""),
481
+ "provider_data": {"provider": "opencode", "role": role, "opencode_parts": blocks},
482
+ }
483
+
484
+ blocks: list[dict[str, Any]] = []
485
+ for seg in msg.get("segments", []):
486
+ kind = seg.get("kind")
487
+ ts = _opencode_iso(seg.get("ts"))
488
+ if kind == "thinking":
489
+ blocks.append({"type": "thinking", "text": seg.get("text", ""), "start_timestamp": ts})
490
+ elif kind == "text":
491
+ blocks.append({"type": "text", "text": seg.get("text", ""), "start_timestamp": ts})
492
+ elif kind == "tool":
493
+ call_id = seg.get("call_id") or ""
494
+ name = seg.get("name") or "unknown"
495
+ blocks.append({
496
+ "type": "tool_use", "id": call_id, "name": name,
497
+ "input": seg.get("input") or {}, "start_timestamp": ts,
498
+ })
499
+ blocks.append({
500
+ "type": "tool_result", "tool_use_id": call_id, "name": name,
501
+ "content": seg.get("output", ""), "is_error": bool(seg.get("is_error")),
502
+ "start_timestamp": _opencode_iso(seg.get("end_ts")) or ts,
503
+ })
504
+ elif kind == "raw":
505
+ # An unmodeled assistant part — emit an unmodeled content block so the
506
+ # builder preserves it verbatim as a `content_block` event (raw under
507
+ # `data`). The part type stays queryable as the block type.
508
+ blocks.append({
509
+ "type": seg.get("part_type") or "opencode_part",
510
+ "opencode_part_type": seg.get("part_type"),
511
+ "opencode_part": seg.get("raw"),
512
+ "start_timestamp": ts,
513
+ })
514
+ provider_data = {"provider": "opencode", "model": msg.get("model") or "opencode"}
515
+ if msg.get("incomplete"):
516
+ # Abandoned/unsettled turn preserved via the staleness gate — tag it so it's
517
+ # distinguishable from a normally-completed turn. stop_reason isn't part of
518
+ # the dedup content, so a later settled version is still a distinct event.
519
+ provider_data["stop_reason"] = "incomplete"
520
+ return {
521
+ "role": "assistant",
522
+ "created_at": _opencode_iso(msg.get("started_at")),
523
+ "content_text": "",
524
+ "content_blocks": blocks,
525
+ "provider_message_id": msg.get("id", ""),
526
+ "provider_data": provider_data,
527
+ }
528
+
529
+
530
+ def _parse_opencode_timestamp(ts: Any) -> Optional[datetime]:
531
+ """OpenCode timestamps are integer milliseconds since epoch."""
532
+ if not isinstance(ts, (int, float)):
533
+ return None
534
+ try:
535
+ seconds = ts / 1000 if ts > 1e12 else ts
536
+ return datetime.fromtimestamp(seconds, tz=timezone.utc)
537
+ except (ValueError, OSError):
538
+ return None
@@ -0,0 +1,67 @@
1
+ """Optional knowledge layer: the event-sourced topic graph over ``thread_links``.
2
+
3
+ Two halves over a pure-graph spine (networkx, no Neo4j, no graph server):
4
+
5
+ * **read / analytics** (:mod:`.graph`) — the in-process topic graph: PageRank,
6
+ communities (Leiden when the ``[graph]`` extra is present, networkx Louvain as a
7
+ fail-soft fallback), bridges, peers.
8
+ * **write / curation** (:mod:`.write`, :mod:`.materialize`) — the librarian's hands:
9
+ create topics, link threads, cite evidence. Every mutation is an append-only
10
+ ``KgEvent`` folded into the projection, so the operation history survives.
11
+
12
+ The graph is empty (all queries return empty) until topics + links exist — the core
13
+ archive works without this layer.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .graph import (
19
+ get_bridge_topics,
20
+ get_community_peers,
21
+ get_community_topic_ids,
22
+ get_status,
23
+ get_topic_graph_meta,
24
+ get_topic_graph_metadata,
25
+ get_unconnected_topics,
26
+ reset_cache,
27
+ )
28
+ from .materialize import apply_event
29
+ from .write import (
30
+ add_topic_evidence,
31
+ archive_topic,
32
+ archive_topic_evidence,
33
+ create_topic,
34
+ link_threads,
35
+ merge_topics,
36
+ rename_topic,
37
+ review_queue,
38
+ thread_user_messages,
39
+ topic_search,
40
+ unlink_threads,
41
+ )
42
+
43
+ __all__ = [
44
+ # read / analytics
45
+ "get_topic_graph_metadata",
46
+ "get_topic_graph_meta",
47
+ "get_community_topic_ids",
48
+ "get_community_peers",
49
+ "get_bridge_topics",
50
+ "get_unconnected_topics",
51
+ "get_status",
52
+ "reset_cache",
53
+ # write / curation
54
+ "apply_event",
55
+ "create_topic",
56
+ "rename_topic",
57
+ "archive_topic",
58
+ "merge_topics",
59
+ "link_threads",
60
+ "unlink_threads",
61
+ "add_topic_evidence",
62
+ "archive_topic_evidence",
63
+ # curation-read
64
+ "review_queue",
65
+ "topic_search",
66
+ "thread_user_messages",
67
+ ]