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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- 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
|
+
]
|