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,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
|