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,328 @@
|
|
|
1
|
+
"""Backfill NULL dedup_keys by recomputing each from the event's OWN persisted state.
|
|
2
|
+
|
|
3
|
+
Re-parsing the source (backfill_reconcile) only reaches threads whose source file
|
|
4
|
+
still exists — a small slice. Most NULL-key events are on threads whose transcript
|
|
5
|
+
was rotated away, or on live-captured threads that never had a provider source at
|
|
6
|
+
all. This backfill needs no source: ``dedup_key`` is a pure function of
|
|
7
|
+
``(provider_message_id, event_type, payload)`` — all recoverable from the row itself.
|
|
8
|
+
|
|
9
|
+
``provider_message_id`` is the one field not stored on every event: the builder
|
|
10
|
+
stamps every event of a turn with the turn's id but only writes it into the anchor
|
|
11
|
+
event's payload (``api_request_started`` / ``user_message_sent`` →
|
|
12
|
+
``provider_data.provider_message_id``). We regroup a thread's events by
|
|
13
|
+
``(stream_id, api_call_id)`` — exactly the events the builder built together — and
|
|
14
|
+
lift the id from the group's anchor, applying it to the group's NULL-key rows. A
|
|
15
|
+
group with no stored id falls back to the content-hash anchor, which is precisely
|
|
16
|
+
what the builder does for id-less providers, so the key still matches a re-import.
|
|
17
|
+
|
|
18
|
+
Safety: a recomputed key is deterministic and content-inclusive, so it can only ever
|
|
19
|
+
equal another row's key when the two rows are genuine duplicates — never a harmful
|
|
20
|
+
false collision. By default a recomputed key that would land on a *different*
|
|
21
|
+
existing key is skipped (left NULL), never written. ``--collapse`` acts on that same
|
|
22
|
+
invariant instead of skipping: the two rows ARE the same turn twice (e.g. a re-import
|
|
23
|
+
after a lost watermark, keyed, beside its pre-dedup-era NULL-key original), so the
|
|
24
|
+
pair is collapsed — the cited copy (else the lower id) survives and takes the key,
|
|
25
|
+
the other row is deleted along with its FTS/vector shadow rows. A pair where BOTH
|
|
26
|
+
copies carry citations is skipped with a warning, never guessed at.
|
|
27
|
+
|
|
28
|
+
This script writes the **index only** — event truth lines have no update-in-place,
|
|
29
|
+
so after an ``--apply`` run the truth still carries the old NULL-key (and any
|
|
30
|
+
collapsed-away duplicate) lines, and a reindex would revert everything. Finish the
|
|
31
|
+
operation with ``rebuild_truth_from_store()`` (under the exclusive reindex lock) so
|
|
32
|
+
the truth re-emits keyed, single-copy lines. Dry-run by default; ``--apply`` writes
|
|
33
|
+
per-thread with a row-level backup (every id set + its new key, every collapsed pair)
|
|
34
|
+
so the pass is reversible.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
from collections import defaultdict
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any, Optional
|
|
45
|
+
|
|
46
|
+
from sqlalchemy import func, select, update
|
|
47
|
+
from sqlalchemy import text as sa_text
|
|
48
|
+
|
|
49
|
+
from thread_archive._thread_import.event_builder import compute_content_hash, compute_dedup_key
|
|
50
|
+
|
|
51
|
+
from .._store import Event, ImportState, get_session
|
|
52
|
+
from .backfill_reconcile import _norm_key, _pmid
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# Types whose provider_message_id is the turn's id — recoverable from the group's
|
|
57
|
+
# anchor event, so a recompute reproduces the builder's key (validated ~99.8% against
|
|
58
|
+
# already-keyed rows). Types NOT here (queue_operation, progress, file_snapshot,
|
|
59
|
+
# context_summary, model_change, ide_context, hook_context, and the live-capture
|
|
60
|
+
# *_delta stream) carry a *synthetic* id (``queue-remove-…``, ``file-snapshot-N``) that
|
|
61
|
+
# is not stored on the row, so a recompute would guess wrong — those are left NULL.
|
|
62
|
+
_RECOMPUTE_SAFE = frozenset({
|
|
63
|
+
"api_request_started", "api_request_completed", "stream_completed",
|
|
64
|
+
"text_complete", "tool_use_complete", "thinking_complete",
|
|
65
|
+
"user_message_sent", "tool_execution_completed", "tool_execution_error",
|
|
66
|
+
"tool_use_started",
|
|
67
|
+
})
|
|
68
|
+
# Anchor events store the turn's provider_message_id in their payload.
|
|
69
|
+
_ANCHOR_TYPES = frozenset({"api_request_started", "user_message_sent"})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def plan_thread(
|
|
73
|
+
session, thread_id: int, *, collapse: bool = False,
|
|
74
|
+
) -> tuple[list[tuple[int, str]], list[tuple[int, int, str]], list[str], dict]:
|
|
75
|
+
"""Return (backfills, collapses, warnings, stats) for one thread — pure planning.
|
|
76
|
+
|
|
77
|
+
Only whitelisted turn events in a group that contains its anchor are keyed, using
|
|
78
|
+
the anchor's provider_message_id. This is the exact condition under which a
|
|
79
|
+
recompute reproduces the builder's key; anything else is left NULL.
|
|
80
|
+
|
|
81
|
+
With ``collapse``, a recomputed key that collides with another row's key marks
|
|
82
|
+
the pair as one turn stored twice: ``collapses`` carries
|
|
83
|
+
``(survivor_id, doomed_id, key)`` — the cited copy (else the lower id) survives;
|
|
84
|
+
``key`` is the value to write onto a surviving NULL-key row ('' when the
|
|
85
|
+
survivor already carries it)."""
|
|
86
|
+
events = list(
|
|
87
|
+
session.execute(
|
|
88
|
+
select(Event).where(Event.thread_id == thread_id).order_by(Event.id)
|
|
89
|
+
).scalars().all()
|
|
90
|
+
)
|
|
91
|
+
stats: dict[str, int] = defaultdict(int)
|
|
92
|
+
warnings: list[str] = []
|
|
93
|
+
backfills: list[tuple[int, str]] = []
|
|
94
|
+
collapses: list[tuple[int, int, str]] = []
|
|
95
|
+
|
|
96
|
+
# Keys already present in the thread (normalized to the current un-prefixed
|
|
97
|
+
# format) mapped to their row ids, so a recomputed collision can name its twin.
|
|
98
|
+
key_owner: dict[str, int] = {
|
|
99
|
+
_norm_key(e.dedup_key, thread_id): e.id for e in events if e.dedup_key
|
|
100
|
+
}
|
|
101
|
+
cited: set[int] = set(
|
|
102
|
+
session.execute(
|
|
103
|
+
sa_text("SELECT event_id FROM topic_messages WHERE thread_id = :t"),
|
|
104
|
+
{"t": thread_id},
|
|
105
|
+
).scalars().all()
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
groups: dict[tuple, list[Event]] = defaultdict(list)
|
|
109
|
+
for e in events:
|
|
110
|
+
groups[(e.stream_id, e.api_call_id)].append(e)
|
|
111
|
+
|
|
112
|
+
# For collapse: keyed rows by type, and which keyed rows are already claimed
|
|
113
|
+
# as someone's twin — a keyed row can be at most one duplicate's other half.
|
|
114
|
+
keyed_by_type: dict[str, list[Event]] = defaultdict(list)
|
|
115
|
+
if collapse:
|
|
116
|
+
for e in events:
|
|
117
|
+
if e.dedup_key:
|
|
118
|
+
keyed_by_type[e.event_type].append(e)
|
|
119
|
+
claimed: set[int] = set()
|
|
120
|
+
|
|
121
|
+
def _twin_of(e: Event) -> Optional[Event]:
|
|
122
|
+
"""The keyed row that is provably the same turn stored twice, matched
|
|
123
|
+
without the (unrecoverable) provider id: same type, same block
|
|
124
|
+
(``tool=<id>`` / ``blk=<n>``), same content hash, same occurred_at —
|
|
125
|
+
every component parsed from the candidate's own key. Ambiguity → None."""
|
|
126
|
+
p = e.payload or {}
|
|
127
|
+
if p.get("tool_call_id"):
|
|
128
|
+
block = f"tool={p['tool_call_id']}"
|
|
129
|
+
elif p.get("block_index") is not None:
|
|
130
|
+
block = f"blk={p['block_index']}"
|
|
131
|
+
else:
|
|
132
|
+
block = ""
|
|
133
|
+
want_hash = compute_content_hash(p)
|
|
134
|
+
matches = []
|
|
135
|
+
for cand in keyed_by_type.get(e.event_type, []):
|
|
136
|
+
if cand.id in claimed or cand.occurred_at != e.occurred_at:
|
|
137
|
+
continue
|
|
138
|
+
parts = _norm_key(cand.dedup_key, thread_id).rsplit(":", 2)
|
|
139
|
+
if len(parts) == 3 and parts[1] == block and parts[2] == want_hash:
|
|
140
|
+
matches.append(cand)
|
|
141
|
+
return matches[0] if len(matches) == 1 else None
|
|
142
|
+
|
|
143
|
+
for evs in groups.values():
|
|
144
|
+
nulls = [e for e in evs if e.dedup_key is None and e.event_type in _RECOMPUTE_SAFE]
|
|
145
|
+
if not nulls:
|
|
146
|
+
continue
|
|
147
|
+
if not any(e.event_type in _ANCHOR_TYPES for e in evs):
|
|
148
|
+
# Can't source the turn id — the key is unrecomputable. In collapse
|
|
149
|
+
# mode the duplicate can still be identified from its twin's own key.
|
|
150
|
+
if collapse:
|
|
151
|
+
for e in nulls:
|
|
152
|
+
twin = _twin_of(e)
|
|
153
|
+
if twin is None:
|
|
154
|
+
stats["no_anchor"] += 1
|
|
155
|
+
continue
|
|
156
|
+
if e.id in cited and twin.id in cited:
|
|
157
|
+
stats["collapse_skipped_both_cited"] += 1
|
|
158
|
+
continue
|
|
159
|
+
if e.id in cited or (twin.id not in cited and e.id < twin.id):
|
|
160
|
+
collapses.append((e.id, twin.id, _norm_key(twin.dedup_key, thread_id)))
|
|
161
|
+
else:
|
|
162
|
+
collapses.append((twin.id, e.id, ""))
|
|
163
|
+
claimed.add(twin.id)
|
|
164
|
+
stats["collapse"] += 1
|
|
165
|
+
else:
|
|
166
|
+
stats["no_anchor"] += len(nulls) # can't source the turn id — leave NULL
|
|
167
|
+
continue
|
|
168
|
+
anchor_pmids = {
|
|
169
|
+
p for p in (_pmid(e.payload or {}) for e in evs if e.event_type in _ANCHOR_TYPES)
|
|
170
|
+
if p is not None
|
|
171
|
+
}
|
|
172
|
+
if len(anchor_pmids) > 1:
|
|
173
|
+
warnings.append(f"group has {len(anchor_pmids)} anchor provider_message_ids")
|
|
174
|
+
continue
|
|
175
|
+
pmid = next(iter(anchor_pmids)) if anchor_pmids else ""
|
|
176
|
+
for e in nulls:
|
|
177
|
+
key = compute_dedup_key(pmid, e.event_type, e.payload or {})
|
|
178
|
+
other = key_owner.get(key)
|
|
179
|
+
if other is not None:
|
|
180
|
+
if not collapse:
|
|
181
|
+
# would duplicate an existing/other row's key — genuine dup
|
|
182
|
+
# content; leave NULL rather than write an ambiguous key.
|
|
183
|
+
stats["collision"] += 1
|
|
184
|
+
continue
|
|
185
|
+
if other in claimed:
|
|
186
|
+
warnings.append(f"twin {other} already claimed — skipping {e.id}")
|
|
187
|
+
stats["collision"] += 1
|
|
188
|
+
continue
|
|
189
|
+
if e.id in cited and other in cited:
|
|
190
|
+
warnings.append(f"both copies of key {key!r} are cited ({e.id}, {other})")
|
|
191
|
+
stats["collapse_skipped_both_cited"] += 1
|
|
192
|
+
continue
|
|
193
|
+
# Survivor: the cited copy if exactly one is, else the lower id.
|
|
194
|
+
if e.id in cited or (other not in cited and e.id < other):
|
|
195
|
+
survivor, doomed, fill = e.id, other, key
|
|
196
|
+
else:
|
|
197
|
+
survivor, doomed, fill = other, e.id, ""
|
|
198
|
+
collapses.append((survivor, doomed, fill))
|
|
199
|
+
claimed.add(doomed)
|
|
200
|
+
key_owner[key] = survivor
|
|
201
|
+
stats["collapse"] += 1
|
|
202
|
+
continue
|
|
203
|
+
key_owner[key] = e.id
|
|
204
|
+
backfills.append((e.id, key))
|
|
205
|
+
stats["backfill"] += 1
|
|
206
|
+
return backfills, collapses, warnings, stats
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _threads_with_null_keys(session, limit: Optional[int]) -> list[int]:
|
|
210
|
+
"""Import-derived threads (present in import_state) that have NULL-key events.
|
|
211
|
+
|
|
212
|
+
Scoped to import-derived threads on purpose: dedup_key exists for *import*
|
|
213
|
+
idempotence, and only these threads are ever re-imported. Live-captured threads
|
|
214
|
+
(the original app's live event stream — no import_state, no source) are never
|
|
215
|
+
re-imported, so their NULL keys are harmless; recompute skips them (their event
|
|
216
|
+
structure also differs from the importer's and isn't validated here)."""
|
|
217
|
+
imported = select(ImportState.thread_id).where(ImportState.thread_id.is_not(None))
|
|
218
|
+
q = (
|
|
219
|
+
select(Event.thread_id)
|
|
220
|
+
.where(Event.dedup_key.is_(None), Event.thread_id.in_(imported))
|
|
221
|
+
.group_by(Event.thread_id)
|
|
222
|
+
.order_by(func.count().desc())
|
|
223
|
+
)
|
|
224
|
+
if limit is not None:
|
|
225
|
+
q = q.limit(limit)
|
|
226
|
+
return list(session.execute(q).scalars().all())
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _delete_events(session, ids: list[int]) -> None:
|
|
230
|
+
"""Delete event rows plus their FTS / vector shadow rows, one statement per
|
|
231
|
+
table (``event_search``'s event_id is UNINDEXED — per-id deletes would each
|
|
232
|
+
scan the whole FTS table)."""
|
|
233
|
+
marks = ",".join(str(int(i)) for i in ids)
|
|
234
|
+
session.execute(sa_text(f"DELETE FROM events WHERE id IN ({marks})")) # noqa: S608 — ints only
|
|
235
|
+
session.execute(sa_text(f"DELETE FROM events_fts WHERE event_id IN ({marks})")) # noqa: S608
|
|
236
|
+
if session.execute(sa_text(
|
|
237
|
+
"SELECT 1 FROM sqlite_master WHERE name = 'event_search'"
|
|
238
|
+
)).scalar():
|
|
239
|
+
session.execute(sa_text(f"DELETE FROM event_search WHERE event_id IN ({marks})")) # noqa: S608
|
|
240
|
+
if session.execute(sa_text(
|
|
241
|
+
"SELECT 1 FROM sqlite_master WHERE name = 'event_vectors'"
|
|
242
|
+
)).scalar():
|
|
243
|
+
session.execute(sa_text(f"DELETE FROM event_vectors WHERE event_id IN ({marks})")) # noqa: S608
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def run(
|
|
247
|
+
*, apply: bool = False, limit: Optional[int] = None,
|
|
248
|
+
backup_path: Optional[Path] = None, collapse: bool = False,
|
|
249
|
+
) -> dict:
|
|
250
|
+
totals: dict[str, Any] = defaultdict(int)
|
|
251
|
+
backup = open(backup_path, "a", encoding="utf-8") if (apply and backup_path) else None
|
|
252
|
+
try:
|
|
253
|
+
with get_session() as s:
|
|
254
|
+
thread_ids = _threads_with_null_keys(s, limit)
|
|
255
|
+
totals["threads"] = len(thread_ids)
|
|
256
|
+
for tid in thread_ids:
|
|
257
|
+
with get_session() as s:
|
|
258
|
+
try:
|
|
259
|
+
backfills, collapses, warnings, stats = plan_thread(s, tid, collapse=collapse)
|
|
260
|
+
except Exception as e: # noqa: BLE001
|
|
261
|
+
logger.warning("plan failed for thread %s: %s", tid, e)
|
|
262
|
+
totals["plan_errors"] += 1
|
|
263
|
+
continue
|
|
264
|
+
totals["collisions"] += stats.get("collision", 0)
|
|
265
|
+
totals["collapse_skipped_both_cited"] += stats.get("collapse_skipped_both_cited", 0)
|
|
266
|
+
if warnings:
|
|
267
|
+
totals["threads_with_warnings"] += 1
|
|
268
|
+
totals["warnings"] += len(warnings)
|
|
269
|
+
if not backfills and not collapses:
|
|
270
|
+
continue
|
|
271
|
+
totals["threads_changed"] += 1
|
|
272
|
+
totals["backfills"] += len(backfills)
|
|
273
|
+
totals["collapses"] += len(collapses)
|
|
274
|
+
if apply:
|
|
275
|
+
if backup:
|
|
276
|
+
backup.write(json.dumps(
|
|
277
|
+
{"thread_id": tid, "backfills": backfills, "collapses": collapses}
|
|
278
|
+
) + "\n")
|
|
279
|
+
backup.flush()
|
|
280
|
+
# Deletes first: a planned backfill that targeted a doomed row
|
|
281
|
+
# becomes a harmless 0-row update, and the survivor's key write
|
|
282
|
+
# can never trip the unique index against its dead twin.
|
|
283
|
+
doomed = [d for _, d, _ in collapses]
|
|
284
|
+
if doomed:
|
|
285
|
+
_delete_events(s, doomed)
|
|
286
|
+
for survivor, _, fill in collapses:
|
|
287
|
+
if fill:
|
|
288
|
+
s.execute(update(Event).where(Event.id == survivor).values(dedup_key=fill))
|
|
289
|
+
for eid, key in backfills:
|
|
290
|
+
s.execute(update(Event).where(Event.id == eid).values(dedup_key=key))
|
|
291
|
+
s.commit()
|
|
292
|
+
finally:
|
|
293
|
+
if backup:
|
|
294
|
+
backup.close()
|
|
295
|
+
return dict(totals)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main(argv: Optional[list[str]] = None) -> None:
|
|
299
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
300
|
+
ap.add_argument("--apply", action="store_true", help="write (default: dry-run)")
|
|
301
|
+
ap.add_argument("--limit", type=int, default=None, help="cap threads (largest-NULL-count first)")
|
|
302
|
+
ap.add_argument("--backup", type=Path, default=None, help="row-level backup JSONL (apply)")
|
|
303
|
+
ap.add_argument(
|
|
304
|
+
"--collapse", action="store_true",
|
|
305
|
+
help="collapse a key collision as one turn stored twice (delete the un-cited/"
|
|
306
|
+
"newer copy) instead of leaving the NULL key; re-emit truth afterwards",
|
|
307
|
+
)
|
|
308
|
+
ap.add_argument("-v", "--verbose", action="store_true")
|
|
309
|
+
args = ap.parse_args(argv)
|
|
310
|
+
logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING)
|
|
311
|
+
|
|
312
|
+
totals = run(apply=args.apply, limit=args.limit, backup_path=args.backup, collapse=args.collapse)
|
|
313
|
+
mode = "APPLIED" if args.apply else "DRY-RUN"
|
|
314
|
+
print(f"[{mode}] backfill-recompute (dedup_key from persisted payload)")
|
|
315
|
+
print(f" threads (w/ NULL keys): {totals.get('threads', 0):,}")
|
|
316
|
+
print(f" threads changed: {totals.get('threads_changed', 0):,}")
|
|
317
|
+
print(f" dedup_key backfills: {totals.get('backfills', 0):,}")
|
|
318
|
+
if args.collapse:
|
|
319
|
+
print(f" duplicate pairs collapsed: {totals.get('collapses', 0):,}")
|
|
320
|
+
print(f" collapses skipped (both cited): {totals.get('collapse_skipped_both_cited', 0):,}")
|
|
321
|
+
else:
|
|
322
|
+
print(f" collisions (left NULL): {totals.get('collisions', 0):,}")
|
|
323
|
+
print(f" threads w/ warnings: {totals.get('threads_with_warnings', 0):,} (warnings={totals.get('warnings', 0):,})")
|
|
324
|
+
print(f" plan errors: {totals.get('plan_errors', 0):,}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Reconcile a thread's persisted events against a fresh re-parse of its source.
|
|
2
|
+
|
|
3
|
+
Two jobs, one pass, safe on the live memory-of-record:
|
|
4
|
+
|
|
5
|
+
1. **dedup_key backfill.** Pre-``dedup_key``-era events carry NULL keys, so a full
|
|
6
|
+
re-import can't tell they already exist and duplicates them. Re-parsing the
|
|
7
|
+
source with the *current* builder yields each event's correct ``dedup_key``
|
|
8
|
+
(right ``provider_message_id`` + current payload); we copy it onto the matched
|
|
9
|
+
persisted row. Because the key is what today's builder produces, a future
|
|
10
|
+
re-import now matches and stays idempotent.
|
|
11
|
+
|
|
12
|
+
2. **Dropped-content backfill.** A fresh event that matches no persisted event is
|
|
13
|
+
content the old importer dropped (the builder/importer fixes now preserve it);
|
|
14
|
+
we insert it, borrowing its turn's ``stream_id`` so it lands in place.
|
|
15
|
+
|
|
16
|
+
Matching is anchored on ``(event_type, occurred_at, block)`` — all derived from the
|
|
17
|
+
source, *not* from payload formatting — so a payload that merely evolved between
|
|
18
|
+
builder versions is treated as a MATCH (key-fill), never a spurious INSERT. That is
|
|
19
|
+
the core safety property: it cannot manufacture duplicates from payload drift.
|
|
20
|
+
|
|
21
|
+
Any thread that does not align cleanly (mismatched core-type counts, an ambiguous
|
|
22
|
+
match, a key that would collide with a *different* row) is FLAGGED and SKIPPED — never
|
|
23
|
+
guessed at. Dry-run by default; ``--apply`` writes per-thread with a row-level backup
|
|
24
|
+
JSONL (every dedup_key set + every event id inserted) so the pass is reversible.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import uuid as _uuid
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
from datetime import timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Iterator, Optional
|
|
37
|
+
|
|
38
|
+
from sqlalchemy import select, update
|
|
39
|
+
|
|
40
|
+
from thread_archive._thread_import import DefaultEventBuilder
|
|
41
|
+
from thread_archive._thread_import.event_builder import compute_content_hash
|
|
42
|
+
from thread_archive._thread_import.parsers.claude_code import ClaudeCodeParser
|
|
43
|
+
|
|
44
|
+
from .._importers._read import read_session_lines
|
|
45
|
+
from .._store import Event, ImportState, get_session
|
|
46
|
+
from .._watcher.sources import ClaudeCodeWatcher, cloth_watcher
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── anchors ──────────────────────────────────────────────────────────────────
|
|
52
|
+
def _ts_key(dt: Any) -> str:
|
|
53
|
+
"""Canonical timestamp string, tz-normalized so a fresh (aware-UTC) build and a
|
|
54
|
+
persisted (DB round-tripped) value compare equal."""
|
|
55
|
+
if dt is None:
|
|
56
|
+
return ""
|
|
57
|
+
if isinstance(dt, str):
|
|
58
|
+
return dt
|
|
59
|
+
if getattr(dt, "tzinfo", None) is not None:
|
|
60
|
+
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
61
|
+
return dt.isoformat(timespec="microseconds")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _block(payload: dict) -> str:
|
|
65
|
+
"""The block discriminator compute_dedup_key uses (tool id or block index)."""
|
|
66
|
+
if payload.get("tool_call_id"):
|
|
67
|
+
return f"tool={payload['tool_call_id']}"
|
|
68
|
+
if payload.get("block_index") is not None:
|
|
69
|
+
return f"blk={payload['block_index']}"
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _struct_anchor(event_type: str, payload: dict, occurred_at: Any) -> tuple:
|
|
74
|
+
"""Source-stable identity: (type, timestamp, block). Robust to payload drift."""
|
|
75
|
+
return (event_type, _ts_key(occurred_at), _block(payload))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _content_anchor(event_type: str, payload: dict, occurred_at: Any) -> tuple:
|
|
79
|
+
"""Exact identity: structural anchor + content hash."""
|
|
80
|
+
return _struct_anchor(event_type, payload, occurred_at) + (compute_content_hash(payload),)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _pmid(payload: dict) -> Optional[str]:
|
|
84
|
+
"""The provider_message_id an event carries, if any (anchor events store it under
|
|
85
|
+
provider_data). Used to stop two same-content turns from false-matching."""
|
|
86
|
+
pd = payload.get("provider_data")
|
|
87
|
+
return pd.get("provider_message_id") if isinstance(pd, dict) else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _norm_key(key: Optional[str], thread_id: int) -> Optional[str]:
|
|
91
|
+
"""Normalize a stored dedup_key to the current (un-prefixed) format for comparison.
|
|
92
|
+
|
|
93
|
+
A retired code path namespaced keys as ``{thread_id}:{compute_dedup_key(...)}``;
|
|
94
|
+
the current importer writes the un-prefixed form (dedup is already thread-scoped by
|
|
95
|
+
the WHERE clause). Stripping the ``{thread_id}:`` prefix lets an old-format keyed
|
|
96
|
+
row be recognized as the same event a fresh (un-prefixed) build produces — so a
|
|
97
|
+
benign format difference isn't mistaken for real drift. NULL-key rows are filled
|
|
98
|
+
with the un-prefixed form, matching what a future re-import computes."""
|
|
99
|
+
if not key:
|
|
100
|
+
return key
|
|
101
|
+
prefix = f"{thread_id}:"
|
|
102
|
+
return key[len(prefix):] if key.startswith(prefix) else key
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ThreadPlan:
|
|
106
|
+
__slots__ = ("thread_id", "backfills", "warnings", "stats")
|
|
107
|
+
|
|
108
|
+
def __init__(self, thread_id: int) -> None:
|
|
109
|
+
self.thread_id = thread_id
|
|
110
|
+
self.backfills: list[tuple[int, str]] = [] # (event_id, dedup_key)
|
|
111
|
+
self.warnings: list[str] = []
|
|
112
|
+
self.stats: dict[str, int] = defaultdict(int)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def safe(self) -> bool:
|
|
116
|
+
return not self.warnings
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fresh_events(source: str, lines: list[dict]) -> list:
|
|
120
|
+
"""Re-parse a thread's source lines into freshly-built events (with dedup_keys),
|
|
121
|
+
in build order. CC + cloth share the Claude Code parser."""
|
|
122
|
+
if source in ("claude-code", "cloth"):
|
|
123
|
+
parser = ClaudeCodeParser()
|
|
124
|
+
session_data = {
|
|
125
|
+
"provider": "claude-code",
|
|
126
|
+
"sessions": [{"session_id": "reconcile", "project": "reconcile", "lines": lines}],
|
|
127
|
+
}
|
|
128
|
+
messages = parser.parse_export(session_data)
|
|
129
|
+
else:
|
|
130
|
+
raise ValueError(f"no re-parse adapter for source {source!r}")
|
|
131
|
+
|
|
132
|
+
builder = DefaultEventBuilder()
|
|
133
|
+
out: list = []
|
|
134
|
+
prev = None
|
|
135
|
+
for msg in messages:
|
|
136
|
+
role = msg.get("role", "")
|
|
137
|
+
api_call_id = str(_uuid.uuid4()) if role == "assistant" else None
|
|
138
|
+
stream_id = str(_uuid.uuid4())
|
|
139
|
+
evs = builder.build_events(msg, stream_id, api_call_id, prev_occurred_at=prev)
|
|
140
|
+
if evs:
|
|
141
|
+
prev = evs[-1].occurred_at
|
|
142
|
+
out.extend(evs)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def plan_thread(session, thread_id: int, source: str, lines: list[dict]) -> ThreadPlan:
|
|
147
|
+
"""Plan the dedup_key backfill for one thread. Pure planning — no writes.
|
|
148
|
+
|
|
149
|
+
Match each freshly-built event to a persisted row by EXACT content anchor
|
|
150
|
+
(type, timestamp, block, content_hash) with provider_message_id agreement, and
|
|
151
|
+
copy the fresh (correct) dedup_key onto NULL-key rows. Exact content match ⇒ the
|
|
152
|
+
key is what a re-import produces and can only ever collide with a genuine
|
|
153
|
+
duplicate, so this cannot manufacture a harmful key. Fresh events that match
|
|
154
|
+
nothing are candidate *dropped content* (a separate insert phase) — counted, not
|
|
155
|
+
written. Any real anomaly flags the thread unsafe and it is skipped.
|
|
156
|
+
"""
|
|
157
|
+
plan = ThreadPlan(thread_id)
|
|
158
|
+
fresh = _fresh_events(source, lines)
|
|
159
|
+
persisted = list(
|
|
160
|
+
session.execute(
|
|
161
|
+
select(Event).where(Event.thread_id == thread_id).order_by(Event.id)
|
|
162
|
+
).scalars().all()
|
|
163
|
+
)
|
|
164
|
+
plan.stats["fresh"] = len(fresh)
|
|
165
|
+
plan.stats["persisted"] = len(persisted)
|
|
166
|
+
|
|
167
|
+
# by_key uses the normalized (un-prefixed) form so an old-format keyed row is
|
|
168
|
+
# recognized by a fresh (un-prefixed) key.
|
|
169
|
+
by_key = {_norm_key(e.dedup_key, thread_id): e for e in persisted if e.dedup_key}
|
|
170
|
+
content_index: dict[tuple, list] = defaultdict(list)
|
|
171
|
+
for e in persisted:
|
|
172
|
+
content_index[_content_anchor(e.event_type, e.payload or {}, e.occurred_at)].append(e)
|
|
173
|
+
|
|
174
|
+
used: set[int] = set()
|
|
175
|
+
planned_keys: set[str] = set(by_key)
|
|
176
|
+
for f in fresh:
|
|
177
|
+
if f.dedup_key and f.dedup_key in by_key:
|
|
178
|
+
used.add(by_key[f.dedup_key].id)
|
|
179
|
+
plan.stats["already"] += 1
|
|
180
|
+
continue
|
|
181
|
+
match = None
|
|
182
|
+
for e in content_index.get(_content_anchor(f.event_type, f.payload, f.occurred_at), []):
|
|
183
|
+
if e.id in used:
|
|
184
|
+
continue
|
|
185
|
+
fp, ep = _pmid(f.payload), _pmid(e.payload or {})
|
|
186
|
+
if fp is not None and ep is not None and fp != ep:
|
|
187
|
+
continue # same content, different turn — not this event
|
|
188
|
+
match = e
|
|
189
|
+
break
|
|
190
|
+
if match is None:
|
|
191
|
+
plan.stats["unmatched_fresh"] += 1 # candidate dropped content (deferred)
|
|
192
|
+
continue
|
|
193
|
+
used.add(match.id)
|
|
194
|
+
if match.dedup_key is None:
|
|
195
|
+
if f.dedup_key in planned_keys:
|
|
196
|
+
# would give two different rows the same key — never happens for a
|
|
197
|
+
# genuine event pair; flag rather than write.
|
|
198
|
+
plan.warnings.append(f"key collision {f.dedup_key[:28]}")
|
|
199
|
+
continue
|
|
200
|
+
planned_keys.add(f.dedup_key)
|
|
201
|
+
plan.backfills.append((match.id, f.dedup_key))
|
|
202
|
+
plan.stats["backfill"] += 1
|
|
203
|
+
elif _norm_key(match.dedup_key, thread_id) == f.dedup_key:
|
|
204
|
+
plan.stats["already"] += 1 # already keyed (either format)
|
|
205
|
+
else:
|
|
206
|
+
# exact content + pmid agree yet stored key genuinely differs → real drift.
|
|
207
|
+
plan.warnings.append(f"key drift on {match.event_type} id={match.id}")
|
|
208
|
+
return plan
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _iter_pairs() -> Iterator[tuple[str, Path, str]]:
|
|
212
|
+
for watcher, name in ((ClaudeCodeWatcher(), "claude-code"), (cloth_watcher(), "cloth")):
|
|
213
|
+
if not watcher.is_available():
|
|
214
|
+
continue
|
|
215
|
+
for path, source_id in watcher._iter_files():
|
|
216
|
+
yield name, path, source_id
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def run(*, apply: bool = False, limit: Optional[int] = None, backup_path: Optional[Path] = None) -> dict:
|
|
220
|
+
"""Backfill dedup_keys onto NULL-key rows. Dry-run by default; ``apply`` writes
|
|
221
|
+
per-thread (unsafe threads always skipped), appending a row-level backup."""
|
|
222
|
+
totals: dict[str, Any] = defaultdict(int)
|
|
223
|
+
examined = 0
|
|
224
|
+
backup = open(backup_path, "a", encoding="utf-8") if (apply and backup_path) else None
|
|
225
|
+
try:
|
|
226
|
+
for name, path, source_id in _iter_pairs():
|
|
227
|
+
if limit is not None and examined >= limit:
|
|
228
|
+
break
|
|
229
|
+
with get_session() as s:
|
|
230
|
+
state = s.execute(
|
|
231
|
+
select(ImportState).where(
|
|
232
|
+
ImportState.source == name, ImportState.source_id == source_id
|
|
233
|
+
)
|
|
234
|
+
).scalar_one_or_none()
|
|
235
|
+
if state is None or not state.thread_id:
|
|
236
|
+
continue
|
|
237
|
+
examined += 1
|
|
238
|
+
totals["threads"] += 1
|
|
239
|
+
try:
|
|
240
|
+
lines = read_session_lines(path)
|
|
241
|
+
plan = plan_thread(s, state.thread_id, name, lines)
|
|
242
|
+
except Exception as e: # noqa: BLE001
|
|
243
|
+
logger.warning("plan failed for %s (%s): %s", source_id, path, e)
|
|
244
|
+
totals["plan_errors"] += 1
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
totals["unmatched_fresh"] += plan.stats.get("unmatched_fresh", 0)
|
|
248
|
+
if not plan.safe:
|
|
249
|
+
totals["unsafe_threads"] += 1
|
|
250
|
+
totals["warnings"] += len(plan.warnings)
|
|
251
|
+
for w in plan.warnings[:3]:
|
|
252
|
+
logger.info(" [skip t%s] %s", plan.thread_id, w)
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
totals["backfills"] += len(plan.backfills)
|
|
256
|
+
if plan.backfills:
|
|
257
|
+
totals["threads_changed"] += 1
|
|
258
|
+
|
|
259
|
+
if apply and plan.backfills:
|
|
260
|
+
if backup:
|
|
261
|
+
backup.write(json.dumps({
|
|
262
|
+
"thread_id": plan.thread_id,
|
|
263
|
+
"backfill_ids": [eid for eid, _ in plan.backfills],
|
|
264
|
+
}) + "\n")
|
|
265
|
+
backup.flush()
|
|
266
|
+
for eid, key in plan.backfills:
|
|
267
|
+
s.execute(update(Event).where(Event.id == eid).values(dedup_key=key))
|
|
268
|
+
s.commit()
|
|
269
|
+
finally:
|
|
270
|
+
if backup:
|
|
271
|
+
backup.close()
|
|
272
|
+
return dict(totals)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main(argv: Optional[list[str]] = None) -> None:
|
|
276
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
277
|
+
ap.add_argument("--apply", action="store_true", help="write (default: dry-run)")
|
|
278
|
+
ap.add_argument("--limit", type=int, default=None)
|
|
279
|
+
ap.add_argument("--backup", type=Path, default=None, help="row-level backup JSONL (apply)")
|
|
280
|
+
ap.add_argument("-v", "--verbose", action="store_true")
|
|
281
|
+
args = ap.parse_args(argv)
|
|
282
|
+
logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING)
|
|
283
|
+
|
|
284
|
+
totals = run(apply=args.apply, limit=args.limit, backup_path=args.backup)
|
|
285
|
+
mode = "APPLIED" if args.apply else "DRY-RUN"
|
|
286
|
+
print(f"[{mode}] backfill-reconcile (dedup_key backfill)")
|
|
287
|
+
print(f" threads examined: {totals.get('threads', 0):,}")
|
|
288
|
+
print(f" threads changed: {totals.get('threads_changed', 0):,}")
|
|
289
|
+
print(f" dedup_key backfills:{totals.get('backfills', 0):,}")
|
|
290
|
+
print(f" unmatched fresh: {totals.get('unmatched_fresh', 0):,} (candidate dropped content, deferred to insert phase)")
|
|
291
|
+
print(f" UNSAFE (skipped): {totals.get('unsafe_threads', 0):,} (warnings={totals.get('warnings', 0):,})")
|
|
292
|
+
print(f" plan errors: {totals.get('plan_errors', 0):,}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
main()
|