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