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,193 @@
1
+ """Shared incremental-import scaffold for the line-stream providers (codex, grok,
2
+ antigravity).
3
+
4
+ All three read one JSONL transcript and differ only in provider-specific steps
5
+ supplied as callbacks. The orchestration — the append-proving cursor (:mod:`._cursor`),
6
+ thread resolve/create, empty-thread cleanup, watermark persist — is identical and
7
+ lives here. Runs as one atomic transaction (events + watermark commit together)
8
+ when no session is passed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Callable, Optional
16
+
17
+ from .._store import get_session
18
+ from ._cursor import resolve_source_cursor
19
+ from ._read import parse_session_lines, read_source_bytes
20
+ from ._result import IncrementalImportResult
21
+ from ._state import (
22
+ adopt_if_unwatermarked,
23
+ create_thread,
24
+ discard_new_thread,
25
+ get_import_state,
26
+ get_thread_by_source,
27
+ upsert_import_state,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _run(
34
+ session,
35
+ *,
36
+ source: str,
37
+ source_id: str,
38
+ session_path: Path,
39
+ all_lines: list[dict],
40
+ source_bytes: bytes,
41
+ prepare,
42
+ has_importable_content,
43
+ make_title,
44
+ import_lines,
45
+ make_source_metadata,
46
+ ) -> IncrementalImportResult:
47
+ import_state = get_import_state(session, source, source_id)
48
+ cursor = resolve_source_cursor(import_state, source_bytes, source=source, source_id=source_id)
49
+ current_file_size = len(source_bytes)
50
+
51
+ if cursor.unchanged and import_state:
52
+ # Byte-identical to the last import; stamp the digest if the watermark predates it.
53
+ import_state.last_content_hash = cursor.content_hash
54
+ return IncrementalImportResult(
55
+ 0, 0, import_state.thread_id or 0, False, import_state.last_message_uuid
56
+ )
57
+
58
+ total_lines = len(all_lines)
59
+ start_line = cursor.start_line
60
+ if start_line >= total_lines:
61
+ # Changed bytes, no new parsed lines (a torn tail the writer hasn't finished).
62
+ # Carry size + digest forward so the completed line reads as an append next
63
+ # poll; the line cursor tracks what parsed, so that line lands then.
64
+ if import_state:
65
+ import_state.last_line_count = total_lines
66
+ import_state.last_file_size = current_file_size
67
+ import_state.last_content_hash = cursor.content_hash
68
+ return IncrementalImportResult(
69
+ 0,
70
+ 0,
71
+ (import_state.thread_id or 0) if import_state else 0,
72
+ False,
73
+ import_state.last_message_uuid if import_state else None,
74
+ )
75
+
76
+ new_lines = all_lines[start_line:]
77
+ ctx = prepare(all_lines, session_path) if prepare is not None else None
78
+
79
+ thread_id: Optional[int] = (
80
+ import_state.thread_id if (import_state and import_state.thread_id) else None
81
+ )
82
+ if thread_id is None:
83
+ existing = get_thread_by_source(session, source, source_id)
84
+ thread_id = existing.id if existing else None
85
+
86
+ if import_state is None and adopt_if_unwatermarked(
87
+ session, source=source, source_id=source_id,
88
+ thread_id=thread_id, total_lines=total_lines, file_size=current_file_size,
89
+ content_hash=cursor.content_hash,
90
+ ):
91
+ return IncrementalImportResult(0, 0, thread_id or 0, False, None)
92
+
93
+ is_new_thread = False
94
+ if thread_id is None:
95
+ if not has_importable_content(new_lines):
96
+ # Nothing worth a thread — record the watermark so the watcher doesn't
97
+ # re-scan, and stop.
98
+ upsert_import_state(
99
+ session,
100
+ source=source,
101
+ source_id=source_id,
102
+ thread_id=None,
103
+ last_line_count=total_lines,
104
+ last_file_size=current_file_size,
105
+ last_content_hash=cursor.content_hash,
106
+ last_message_uuid=None,
107
+ )
108
+ return IncrementalImportResult(len(new_lines), 0, 0, False)
109
+ thread_id = create_thread(
110
+ session,
111
+ source=source,
112
+ source_id=source_id,
113
+ title=make_title(all_lines, ctx),
114
+ source_metadata=make_source_metadata(ctx) if make_source_metadata else None,
115
+ )
116
+ is_new_thread = True
117
+
118
+ events_created, last_uuid = import_lines(session, thread_id, all_lines, new_lines, ctx)
119
+
120
+ if is_new_thread and events_created == 0:
121
+ # Row AND staged truth record — no ghost threads/<id>.jsonl on commit.
122
+ discard_new_thread(session, thread_id)
123
+ thread_id = 0
124
+ is_new_thread = False
125
+
126
+ upsert_import_state(
127
+ session,
128
+ source=source,
129
+ source_id=source_id,
130
+ thread_id=thread_id or None,
131
+ last_line_count=total_lines,
132
+ last_file_size=current_file_size,
133
+ last_content_hash=cursor.content_hash,
134
+ last_message_uuid=last_uuid,
135
+ )
136
+
137
+ return IncrementalImportResult(
138
+ lines_processed=len(new_lines),
139
+ events_created=events_created,
140
+ thread_id=thread_id or 0,
141
+ is_new_thread=is_new_thread,
142
+ last_message_uuid=last_uuid,
143
+ )
144
+
145
+
146
+ def import_line_stream_session(
147
+ *,
148
+ source: str,
149
+ source_id: str,
150
+ session_path,
151
+ session=None,
152
+ not_found_msg: str,
153
+ has_importable_content: Callable[[list[dict]], bool],
154
+ make_title: Callable[[list[dict], object], str],
155
+ import_lines: Callable[..., tuple[int, Optional[str]]],
156
+ prepare: Optional[Callable[[list[dict], Path], object]] = None,
157
+ make_source_metadata: Optional[Callable[[object], Optional[dict]]] = None,
158
+ ) -> IncrementalImportResult:
159
+ """Import a single-JSONL line-stream provider transcript incrementally.
160
+
161
+ Callbacks:
162
+ - ``prepare(all_lines, path) -> ctx`` — read provider metadata once (or None).
163
+ - ``has_importable_content(new_lines) -> bool``
164
+ - ``make_title(all_lines, ctx) -> str``
165
+ - ``make_source_metadata(ctx) -> dict | None`` — optional.
166
+ - ``import_lines(session, thread_id, all_lines, new_lines, ctx) -> (events, last_uuid)``.
167
+ """
168
+ session_path = Path(session_path)
169
+ if not session_path.exists():
170
+ raise FileNotFoundError(not_found_msg)
171
+
172
+ source_bytes = read_source_bytes(session_path)
173
+ all_lines = parse_session_lines(source_bytes, session_path.name)
174
+
175
+ kwargs = dict(
176
+ source=source,
177
+ source_id=source_id,
178
+ session_path=session_path,
179
+ all_lines=all_lines,
180
+ source_bytes=source_bytes,
181
+ prepare=prepare,
182
+ has_importable_content=has_importable_content,
183
+ make_title=make_title,
184
+ import_lines=import_lines,
185
+ make_source_metadata=make_source_metadata,
186
+ )
187
+
188
+ if session is not None:
189
+ return _run(session, **kwargs)
190
+ with get_session() as s:
191
+ result = _run(s, **kwargs)
192
+ s.commit()
193
+ return result
@@ -0,0 +1,82 @@
1
+ """Read + sanitize a provider JSONL transcript.
2
+
3
+ ``errors="replace"`` so a half-written multibyte tail (a poll catching a file
4
+ mid-write) decodes to U+FFFD instead of raising; the per-line ``JSONDecodeError``
5
+ guard means one bad line is skipped, never the whole file. Each parsed line is
6
+ scrubbed of null bytes + lone UTF-16 surrogates at the ingest boundary — source
7
+ JSONL is written by external (JS/TS) processes that can slice strings on UTF-16
8
+ boundaries and leave an orphaned surrogate, which then fails ``json.dumps`` on the
9
+ write path.
10
+
11
+ The importers read the file's **bytes** once (:func:`read_source_bytes`) and parse
12
+ from that buffer (:func:`parse_session_lines`), because the same buffer is what
13
+ :mod:`._cursor` digests to prove the source was appended to rather than rewritten.
14
+ Reading twice would race a live writer: the bytes the cursor verified must be the
15
+ bytes the lines were parsed from.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import json
22
+ import logging
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _scrub_str(s: str) -> str:
30
+ if "\x00" in s:
31
+ s = s.replace("\x00", "")
32
+ try:
33
+ s.encode("utf-8")
34
+ except UnicodeEncodeError:
35
+ # Lone surrogate(s): replace them rather than fail the whole import.
36
+ s = s.encode("utf-8", "replace").decode("utf-8")
37
+ return s
38
+
39
+
40
+ def sanitize_payload(obj: Any) -> Any:
41
+ """Recursively scrub strings in a parsed JSON value (null bytes / lone surrogates)."""
42
+ if isinstance(obj, str):
43
+ return _scrub_str(obj)
44
+ if isinstance(obj, dict):
45
+ return {k: sanitize_payload(v) for k, v in obj.items()}
46
+ if isinstance(obj, list):
47
+ return [sanitize_payload(v) for v in obj]
48
+ return obj
49
+
50
+
51
+ def read_source_bytes(session_path: Path) -> bytes:
52
+ """The transcript's raw bytes — the one read the importers do per poll."""
53
+ return Path(session_path).read_bytes()
54
+
55
+
56
+ def parse_session_lines(data: bytes, name: str = "<transcript>") -> list[dict]:
57
+ """Parse every line of a JSONL transcript's bytes; skip (log) bad lines.
58
+
59
+ ``io.StringIO(..., newline=None)`` gives the same universal-newline line split
60
+ as ``open()`` in text mode: only ``\\n`` (post-translation) ends a line, so a
61
+ raw U+2028 inside a JSON string doesn't tear a valid line in half the way
62
+ ``str.splitlines()`` would.
63
+ """
64
+ lines: list[dict] = []
65
+ parse_errors = 0
66
+ text = data.decode("utf-8", errors="replace")
67
+ for line_num, line in enumerate(io.StringIO(text, newline=None), 1):
68
+ if line.strip():
69
+ try:
70
+ lines.append(sanitize_payload(json.loads(line)))
71
+ except json.JSONDecodeError as e:
72
+ parse_errors += 1
73
+ logger.warning("%s:%d — JSON parse error: %s", name, line_num, e)
74
+ if parse_errors:
75
+ logger.warning("%s: %d lines skipped due to parse errors", name, parse_errors)
76
+ return lines
77
+
78
+
79
+ def read_session_lines(session_path: Path) -> list[dict]:
80
+ """Read + parse every line of a JSONL transcript; skip (log) bad lines."""
81
+ session_path = Path(session_path)
82
+ return parse_session_lines(read_source_bytes(session_path), session_path.name)
@@ -0,0 +1,17 @@
1
+ """Result contract for an incremental import."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class IncrementalImportResult:
11
+ """Outcome of one incremental-import call."""
12
+
13
+ lines_processed: int
14
+ events_created: int
15
+ thread_id: int
16
+ is_new_thread: bool
17
+ last_message_uuid: Optional[str] = None
@@ -0,0 +1,113 @@
1
+ """Claude Code hook-context sidecar import.
2
+
3
+ Claude Code writes a sibling ``<session>.context.jsonl`` of hook-context lines
4
+ (``{"hook", "context", "ts", "prompt", "metadata"}``) — content that never reaches
5
+ the session JSONL. Each non-empty ``context`` line becomes a ``hook_context`` event
6
+ on the same thread, cursored independently via a derived ``{source_id}:context``
7
+ import-state row so re-imports only carry the grown tail. Ported from canonical
8
+ ``streaming/incremental_import/_events.py:_import_sidecar_lines``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from sqlalchemy.orm import Session
20
+
21
+ from thread_archive._thread_import.event_builder import compute_dedup_key
22
+
23
+ from .._store import Event
24
+ from .._truth import write_events
25
+ from ._events import _existing_dedup_keys
26
+ from ._read import read_session_lines
27
+ from ._state import get_import_state, upsert_import_state
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def read_sidecar_lines(session_path) -> Optional[list[dict]]:
33
+ """The sibling ``<session>.context.jsonl`` lines, or None when absent."""
34
+ sidecar = Path(session_path).with_suffix(".context.jsonl")
35
+ if not sidecar.exists():
36
+ return None
37
+ return read_session_lines(sidecar)
38
+
39
+
40
+ def import_sidecar_lines(
41
+ session: Session, thread_id: int, source_id: str, sidecar_lines: list[dict]
42
+ ) -> int:
43
+ """Import grown hook-context sidecar lines as ``hook_context`` events. Returns
44
+ the number written (idempotent: dedup_key membership + a line-count cursor)."""
45
+ sidecar_source_id = f"{source_id}:context"
46
+ state = get_import_state(session, "claude-code", sidecar_source_id)
47
+ start_line = state.last_line_count if state else 0
48
+ total = len(sidecar_lines)
49
+ new_lines = sidecar_lines[start_line:]
50
+ if not new_lines:
51
+ return 0
52
+
53
+ seen = _existing_dedup_keys(session, thread_id)
54
+ batch: list[Event] = []
55
+ for entry in new_lines:
56
+ if not isinstance(entry, dict):
57
+ continue
58
+ context = entry.get("context", "")
59
+ if not context:
60
+ continue
61
+ hook_name = entry.get("hook", "unknown")
62
+ ts_str = entry.get("ts")
63
+ prompt = entry.get("prompt")
64
+
65
+ occurred_at: Optional[datetime] = None
66
+ if ts_str:
67
+ try:
68
+ occurred_at = datetime.fromisoformat(ts_str)
69
+ except (ValueError, TypeError):
70
+ pass
71
+
72
+ payload: dict = {"hook_name": hook_name, "context": context}
73
+ if prompt:
74
+ payload["prompt"] = prompt
75
+ if entry.get("metadata"):
76
+ payload["metadata"] = entry["metadata"]
77
+ if occurred_at is None:
78
+ # Same convention as the event builder: a fabricated timestamp is
79
+ # flagged, never silent — and timezone-aware like every other write.
80
+ payload["timestamp_inferred"] = True
81
+ payload["timestamp_source"] = "fabricated_no_source"
82
+
83
+ # Deterministic identity over the line's stable parts so a re-import collapses.
84
+ content_anchor = f"{ts_str or ''}:{hook_name}:{context}"
85
+ dedup_key = compute_dedup_key(content_anchor, "hook_context", payload)
86
+ if dedup_key in seen:
87
+ continue
88
+ seen.add(dedup_key)
89
+ batch.append(Event(
90
+ thread_id=thread_id,
91
+ stream_id=str(uuid.uuid4()),
92
+ event_type="hook_context",
93
+ payload=payload,
94
+ occurred_at=occurred_at or datetime.now(timezone.utc),
95
+ dedup_key=dedup_key,
96
+ ))
97
+
98
+ if batch:
99
+ write_events(session, batch)
100
+ from .._retrieval.fts import index_events
101
+
102
+ index_events(session, batch)
103
+
104
+ upsert_import_state(
105
+ session,
106
+ source="claude-code",
107
+ source_id=sidecar_source_id,
108
+ thread_id=thread_id,
109
+ last_line_count=total,
110
+ last_file_size=0,
111
+ last_message_uuid=None,
112
+ )
113
+ return len(batch)
@@ -0,0 +1,240 @@
1
+ """Thread resolution + import-state watermarks — the SQLite slice that incremental
2
+ import needs.
3
+
4
+ A thread's identity is its ``(source, source_id)`` and its ``name`` is the
5
+ deterministic ``"{source}:{source_id}"`` (also the unique key), since we always
6
+ resolve by source before creating.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+ from typing import Optional
13
+
14
+ from sqlalchemy import delete, func, select
15
+ from sqlalchemy.orm import Session
16
+
17
+ from .._store import Event, ImportState, Thread
18
+
19
+ # Placeholder model values that aren't a real model — skipped when denormalizing a
20
+ # thread's models list (mirrors canonical NON_MODEL_VALUES).
21
+ NON_MODEL_VALUES = ("", "unknown", "<synthetic>")
22
+
23
+
24
+ def get_thread_by_source(session: Session, source: str, source_id: str) -> Optional[Thread]:
25
+ return session.execute(
26
+ select(Thread).where(Thread.source == source, Thread.source_id == source_id)
27
+ ).scalars().first()
28
+
29
+
30
+ def lookup_parent_thread(
31
+ session: Session, parent_source_id: str, *, source: str = "claude-code"
32
+ ) -> Optional[int]:
33
+ """Resolve a parent session's thread_id by ``(source, parent_source_id)`` —
34
+ the import-state watermark first (authoritative even before the thread row is
35
+ visible), then the threads table. None when unknown."""
36
+ state = get_import_state(session, source, parent_source_id)
37
+ if state and state.thread_id:
38
+ return state.thread_id
39
+ thread = get_thread_by_source(session, source, parent_source_id)
40
+ return thread.id if thread else None
41
+
42
+
43
+ def adopt_if_unwatermarked(
44
+ session: Session,
45
+ *,
46
+ source: str,
47
+ source_id: str,
48
+ thread_id: Optional[int],
49
+ total_lines: int,
50
+ file_size: int,
51
+ content_hash: Optional[str] = None,
52
+ ) -> bool:
53
+ """Guard against re-importing a thread whose ingest cursor was lost.
54
+
55
+ The last-resort path for a thread that exists with no ``import_state`` at all —
56
+ e.g. a bulk-seeded archive whose events were loaded outside the incremental
57
+ importers. (Reindex carries the watermarks over — from the previous index and the
58
+ ``import_state.jsonl`` checkpoint snapshot — so a rebuild alone no longer lands
59
+ here.) Re-importing such a file from line 0 would re-insert events the truth
60
+ already holds: their stored ``dedup_key`` need not match a fresh import's, so the
61
+ dedup check wouldn't catch them and every event would double. When the thread
62
+ already has events, we instead stamp the watermark at the file's current EOF and
63
+ skip — the existing events stand, and only genuinely-new appended lines import on
64
+ later polls. The cost is that source lines never imported before adoption are
65
+ skipped for good, which is why the watermarks are kept durable and this path is
66
+ kept rare. Returns True when it adopted (caller must not import)."""
67
+ if thread_id is None:
68
+ return False
69
+ has_events = session.execute(
70
+ select(Event.id).where(Event.thread_id == thread_id).limit(1)
71
+ ).first() is not None
72
+ if not has_events:
73
+ return False
74
+ upsert_import_state(
75
+ session,
76
+ source=source,
77
+ source_id=source_id,
78
+ thread_id=thread_id,
79
+ last_line_count=total_lines,
80
+ last_file_size=file_size,
81
+ last_content_hash=content_hash,
82
+ last_message_uuid=None,
83
+ )
84
+ return True
85
+
86
+
87
+ def create_thread(
88
+ session: Session,
89
+ *,
90
+ source: str,
91
+ source_id: str,
92
+ title: Optional[str] = None,
93
+ thread_type: str = "conversation",
94
+ description: Optional[str] = None,
95
+ source_metadata: Optional[dict] = None,
96
+ ) -> int:
97
+ """Create a thread for ``(source, source_id)`` and return its id (flushed)."""
98
+ thread = Thread(
99
+ name=f"{source}:{source_id}",
100
+ title=title,
101
+ thread_type=thread_type,
102
+ description=description,
103
+ source=source,
104
+ source_id=source_id,
105
+ source_metadata=source_metadata,
106
+ )
107
+ session.add(thread)
108
+ session.flush()
109
+ # Stage the thread's metadata record so its truth file opens with it (written
110
+ # to threads/<id>.jsonl on commit, before the events that follow).
111
+ from .._truth.jsonl_log import record_thread
112
+
113
+ record_thread(session, thread)
114
+ return thread.id
115
+
116
+
117
+ def discard_new_thread(session: Session, thread_id: int) -> None:
118
+ """Remove a just-created thread that imported nothing — row AND staged truth.
119
+
120
+ The complement of :func:`create_thread` for the empty-import cleanup path.
121
+ Deleting only the row is not enough: ``create_thread`` staged the thread's
122
+ metadata record for its truth file, and a commit would still write that
123
+ ``threads/<id>.jsonl`` ghost — which ``verify`` counts as drift and the next
124
+ reindex resurrects as an empty thread. Only for threads created in THIS
125
+ session's transaction (nothing else may reference them yet)."""
126
+ session.execute(delete(Thread).where(Thread.id == thread_id))
127
+ from .._truth.jsonl_log import unstage_thread
128
+
129
+ unstage_thread(session, thread_id)
130
+
131
+
132
+ def _restage_thread(session: Session, thread: Thread) -> None:
133
+ """Bump ``updated_at`` and re-stage the thread's metadata record to truth.
134
+
135
+ A metadata change after creation (title resync, description/models backfill)
136
+ must reach the JSONL truth or a ``reindex`` would lose it. ``record_thread``
137
+ appends a fresh ``{"type": "thread", ...}`` record (latest wins on reindex),
138
+ and the bumped ``updated_at`` also lets the checkpoint backstop pick it up."""
139
+ thread.updated_at = datetime.now(timezone.utc)
140
+ session.flush()
141
+ from .._truth.jsonl_log import record_thread
142
+
143
+ record_thread(session, thread)
144
+
145
+
146
+ def update_thread_title(session: Session, thread_id: int, title: str) -> bool:
147
+ """Set a thread's title and re-stage it to truth. No-op if unchanged/missing."""
148
+ thread = session.get(Thread, thread_id)
149
+ if not thread or thread.title == title:
150
+ return False
151
+ thread.title = title
152
+ _restage_thread(session, thread)
153
+ return True
154
+
155
+
156
+ def update_thread_description(session: Session, thread_id: int, description: str) -> bool:
157
+ """Set a thread's one-line description and re-stage it to truth."""
158
+ thread = session.get(Thread, thread_id)
159
+ if not thread:
160
+ return False
161
+ thread.description = description
162
+ _restage_thread(session, thread)
163
+ return True
164
+
165
+
166
+ def set_thread_models_from_events(session: Session, thread_id: int) -> list[str]:
167
+ """Denormalize a thread's models into ``source_metadata['models']`` from its
168
+ ``api_request_completed`` events (distinct, first-appearance order), so new
169
+ imports land in the sidebar's model filter. Falls back to the operative
170
+ ``source_metadata['model']`` when no model appears in the events. Pure
171
+ re-derivation (overwrites any prior value); re-stages truth only on change.
172
+
173
+ SQLite analogue of canonical ``set_thread_models_from_events`` — ``json_extract``
174
+ replaces the Postgres ``payload['model'].astext``."""
175
+ thread = session.get(Thread, thread_id)
176
+ if not thread:
177
+ return []
178
+ model_col = func.json_extract(Event.payload, "$.model")
179
+ rows = session.execute(
180
+ select(model_col)
181
+ .where(Event.thread_id == thread_id)
182
+ .where(Event.event_type == "api_request_completed")
183
+ .where(model_col.is_not(None))
184
+ .where(model_col.notin_(NON_MODEL_VALUES))
185
+ .order_by(Event.id.asc())
186
+ ).scalars().all()
187
+ models: list[str] = []
188
+ for m in rows:
189
+ if m and m not in models:
190
+ models.append(m)
191
+ if not models:
192
+ operative = (thread.source_metadata or {}).get("model")
193
+ if operative:
194
+ models = [operative]
195
+ if not models:
196
+ return []
197
+ if (thread.source_metadata or {}).get("models") == models:
198
+ return models
199
+ # Reassign a fresh dict so SQLAlchemy flags the JSON column dirty.
200
+ meta = dict(thread.source_metadata or {})
201
+ meta["models"] = models
202
+ thread.source_metadata = meta
203
+ _restage_thread(session, thread)
204
+ return models
205
+
206
+
207
+ def get_import_state(session: Session, source: str, source_id: str) -> Optional[ImportState]:
208
+ return session.execute(
209
+ select(ImportState).where(ImportState.source == source, ImportState.source_id == source_id)
210
+ ).scalars().first()
211
+
212
+
213
+ def upsert_import_state(
214
+ session: Session,
215
+ *,
216
+ source: str,
217
+ source_id: str,
218
+ thread_id: Optional[int],
219
+ last_line_count: int,
220
+ last_file_size: int,
221
+ last_message_uuid: Optional[str],
222
+ last_content_hash: Optional[str] = None,
223
+ ) -> ImportState:
224
+ """Insert or update the ``(source, source_id)`` watermark (no commit).
225
+
226
+ ``last_content_hash`` is the digest of the source bytes this watermark was
227
+ computed over — the file sources pass it so the next poll can prove the cursor
228
+ still points into the same content; the DB-backed sources leave it None.
229
+ """
230
+ state = get_import_state(session, source, source_id)
231
+ if state is None:
232
+ state = ImportState(source=source, source_id=source_id)
233
+ session.add(state)
234
+ state.thread_id = thread_id
235
+ state.last_line_count = last_line_count
236
+ state.last_file_size = last_file_size
237
+ state.last_content_hash = last_content_hash
238
+ state.last_message_uuid = last_message_uuid
239
+ state.last_import_at = datetime.now(timezone.utc)
240
+ return state