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,120 @@
1
+ """Strip the legacy ``{thread_id}:`` prefix from dedup_keys.
2
+
3
+ A retired code path wrote ``dedup_key`` as ``{thread_id}:{compute_dedup_key(...)}``;
4
+ the current importer writes the bare ``compute_dedup_key(...)`` form (dedup is already
5
+ thread-scoped by the ``WHERE thread_id = ...`` clause, so the prefix is redundant, and
6
+ there is no global unique index that would need it). The two formats coexist, so a
7
+ re-import — which computes the bare form — won't recognize a prefixed row and would
8
+ duplicate it. This one-shot pass aligns the prefixed rows to the current bare format.
9
+
10
+ Safe: it only removes a leading ``{thread_id}:`` that is literally present (a bare
11
+ key's anchor is a uuid / ``c=<hash>`` / ``queue-…`` / ``file-snapshot-N`` / ``tool=…`` —
12
+ never the thread's own integer id, so a bare key can't be mistaken for a prefixed one).
13
+ A stripped key can only equal another row's key when they are the same event identity.
14
+ Dry-run by default; ``--apply`` writes per-thread with a row-level backup (id + original
15
+ key) so it is reversible.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import logging
23
+ from collections import defaultdict
24
+ from pathlib import Path
25
+ from typing import Any, Optional
26
+
27
+ from sqlalchemy import Text, func, select, update
28
+
29
+ from .._store import Event, get_session
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _PREFIXED = Event.dedup_key.like(func.cast(Event.thread_id, Text).concat(":%"))
34
+
35
+
36
+ def _prefixed_thread_ids(session, limit: Optional[int]) -> list[int]:
37
+ """Threads holding at least one ``{thread_id}:``-prefixed dedup_key."""
38
+ q = select(Event.thread_id).where(Event.dedup_key.is_not(None), _PREFIXED).group_by(Event.thread_id)
39
+ if limit is not None:
40
+ q = q.limit(limit)
41
+ return list(session.execute(q).scalars().all())
42
+
43
+
44
+ def plan_thread(session, thread_id: int) -> tuple[list[tuple[int, str, str]], dict]:
45
+ """Return (updates, stats): (event_id, original_key, bare_key) for each prefixed row."""
46
+ prefix = f"{thread_id}:"
47
+ rows = list(
48
+ session.execute(
49
+ select(Event.id, Event.dedup_key).where(
50
+ Event.thread_id == thread_id, Event.dedup_key.is_not(None)
51
+ )
52
+ ).all()
53
+ )
54
+ existing_bare = {k for _i, k in rows if not k.startswith(prefix)}
55
+ stats: dict[str, int] = defaultdict(int)
56
+ updates: list[tuple[int, str, str]] = []
57
+ for eid, key in rows:
58
+ if not key.startswith(prefix):
59
+ continue
60
+ bare = key[len(prefix):]
61
+ if bare in existing_bare:
62
+ # same identity already present bare — a genuine duplicate row; de-prefixing
63
+ # is still correct (both collapse to one identity).
64
+ stats["collapses_onto_existing"] += 1
65
+ updates.append((eid, key, bare))
66
+ stats["stripped"] += 1
67
+ return updates, stats
68
+
69
+
70
+ def run(*, apply: bool = False, limit: Optional[int] = None, backup_path: Optional[Path] = None) -> dict:
71
+ totals: dict[str, Any] = defaultdict(int)
72
+ backup = open(backup_path, "a", encoding="utf-8") if (apply and backup_path) else None
73
+ try:
74
+ with get_session() as s:
75
+ thread_ids = _prefixed_thread_ids(s, limit)
76
+ totals["threads"] = len(thread_ids)
77
+ for tid in thread_ids:
78
+ with get_session() as s:
79
+ updates, stats = plan_thread(s, tid)
80
+ totals["stripped"] += len(updates)
81
+ totals["collapses"] += stats.get("collapses_onto_existing", 0)
82
+ if not updates:
83
+ continue
84
+ totals["threads_changed"] += 1
85
+ if apply:
86
+ if backup:
87
+ backup.write(json.dumps({
88
+ "thread_id": tid,
89
+ "rows": [{"id": eid, "old": old} for eid, old, _bare in updates],
90
+ }) + "\n")
91
+ backup.flush()
92
+ for eid, _old, bare in updates:
93
+ s.execute(update(Event).where(Event.id == eid).values(dedup_key=bare))
94
+ s.commit()
95
+ finally:
96
+ if backup:
97
+ backup.close()
98
+ return dict(totals)
99
+
100
+
101
+ def main(argv: Optional[list[str]] = None) -> None:
102
+ ap = argparse.ArgumentParser(description=__doc__)
103
+ ap.add_argument("--apply", action="store_true", help="write (default: dry-run)")
104
+ ap.add_argument("--limit", type=int, default=None, help="cap threads")
105
+ ap.add_argument("--backup", type=Path, default=None, help="row-level backup JSONL (apply)")
106
+ ap.add_argument("-v", "--verbose", action="store_true")
107
+ args = ap.parse_args(argv)
108
+ logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING)
109
+
110
+ totals = run(apply=args.apply, limit=args.limit, backup_path=args.backup)
111
+ mode = "APPLIED" if args.apply else "DRY-RUN"
112
+ print(f"[{mode}] denamespace-dedup-keys")
113
+ print(f" threads with prefixed keys: {totals.get('threads', 0):,}")
114
+ print(f" threads changed: {totals.get('threads_changed', 0):,}")
115
+ print(f" keys de-prefixed: {totals.get('stripped', 0):,}")
116
+ print(f" collapse-onto-existing-bare: {totals.get('collapses', 0):,}")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -0,0 +1,190 @@
1
+ """Backfill import-dropped content the builder now preserves.
2
+
3
+ The ``DefaultEventBuilder`` used to silently drop three kinds of content it didn't
4
+ model — IDE context blocks (``<ide_selection>`` / ``<ide_opened_file>``), unmodeled
5
+ assistant block types (``server_tool_use``, ``web_search_tool_result``, images, …),
6
+ and messages whose role isn't user/assistant/system. The builder fix preserves them
7
+ going forward as ``ide_context`` / ``content_block`` / ``message`` events. This
8
+ one-shot backfill recovers the same events for **already-imported** CC-shaped threads
9
+ (``claude-code`` + ``cloth``) by re-parsing their still-on-disk source transcripts.
10
+
11
+ Why this is safe on the live memory-of-record — even though a full re-import is not:
12
+ it only ever inserts events whose ``event_type`` is in :data:`RECOVERABLE_TYPES`.
13
+ Those types were **never written before the fix**, so a fresh parse's ``dedup_key``
14
+ for them cannot collide with any existing row. Existing events are never rewritten,
15
+ so the bulk-seed dedup-key mismatch that makes a blanket re-import double events (the
16
+ hazard ``adopt_if_unwatermarked`` guards) simply doesn't apply here. Re-running is
17
+ idempotent: an already-recovered event's key is now present, so it's skipped.
18
+
19
+ Recovered events reuse the **existing** turn's ``stream_id`` / ``api_call_id`` (looked
20
+ up by the source message id recovered from each event's dedup-key anchor) so they land
21
+ in the right turn; a fully-dropped turn (e.g. an ide-only turn, or an unknown-role
22
+ turn) has no existing event to borrow from and gets a fresh stream.
23
+
24
+ Scope: CC-shaped sources only — that's where ``ide_context`` originates and the bulk of
25
+ the archive lives. Other providers (codex/grok/cursor/opencode/…) build events through
26
+ their own paths; their unmodeled-block/role incidence is low and is a separate follow-up.
27
+
28
+ Dry-run by default (reports what it *would* recover). Pass ``--apply`` to write.
29
+ Threads are re-parsed and committed one at a time, so the run is resumable and keeps
30
+ its lock windows short against the concurrently-writing watcher — but for a clean apply,
31
+ pausing the archive watcher first is recommended.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import logging
38
+ import uuid as _uuid
39
+ from pathlib import Path
40
+ from typing import Iterator, Optional
41
+
42
+ from sqlalchemy import select
43
+
44
+ from thread_archive._thread_import import DefaultEventBuilder
45
+ from thread_archive._thread_import.parsers.claude_code import ClaudeCodeParser
46
+
47
+ from .._importers._events import _to_event
48
+ from .._importers._read import read_session_lines
49
+ from .._retrieval.fts import index_events
50
+ from .._store import Event, ImportState, get_session
51
+ from .._truth import write_events
52
+ from .._watcher.sources import ClaudeCodeWatcher, cloth_watcher
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ # Event types the fix newly preserves — the only rows this backfill ever inserts.
57
+ RECOVERABLE_TYPES = frozenset({"ide_context", "content_block", "message"})
58
+
59
+
60
+ def _existing(session, thread_id: int) -> tuple[set[str], dict[str, tuple[str, Optional[str]]]]:
61
+ """Return ``(dedup_keys, anchor→(stream_id, api_call_id))`` for a thread's events.
62
+
63
+ The anchor is the source message id (dedup-key prefix); it lets a recovered event
64
+ borrow the ``stream_id`` / ``api_call_id`` of the turn it belongs to."""
65
+ rows = session.execute(select(Event).where(Event.thread_id == thread_id)).scalars().all()
66
+ keys = {e.dedup_key for e in rows if e.dedup_key}
67
+ anchor: dict[str, tuple[str, Optional[str]]] = {}
68
+ for e in rows:
69
+ if e.dedup_key:
70
+ anchor.setdefault(e.dedup_key.split(":", 1)[0], (e.stream_id, e.api_call_id))
71
+ return keys, anchor
72
+
73
+
74
+ def plan_thread(session, thread_id: int, lines: list[dict]) -> list[Event]:
75
+ """Re-parse a thread's source lines and return the recoverable Event rows not yet
76
+ present. Pure planning — writes nothing."""
77
+ parser = ClaudeCodeParser()
78
+ builder = DefaultEventBuilder()
79
+ session_data = {
80
+ "provider": "claude-code",
81
+ "sessions": [{"session_id": "recover", "project": "recover", "lines": lines}],
82
+ }
83
+ messages = parser.parse_export(session_data)
84
+ existing_keys, anchor = _existing(session, thread_id)
85
+
86
+ out: list[Event] = []
87
+ prev = None
88
+ for msg in messages:
89
+ pmid = msg.get("provider_message_id") or msg.get("uuid") or ""
90
+ stream_id, api_call_id = anchor.get(pmid, (str(_uuid.uuid4()), None))
91
+ events = builder.build_events(msg, stream_id, api_call_id, prev_occurred_at=prev)
92
+ if events:
93
+ prev = events[-1].occurred_at
94
+ for e in events:
95
+ if (
96
+ e.event_type in RECOVERABLE_TYPES
97
+ and e.dedup_key
98
+ and e.dedup_key not in existing_keys
99
+ ):
100
+ out.append(_to_event(thread_id, e))
101
+ existing_keys.add(e.dedup_key) # guard against in-file duplicates
102
+ return out
103
+
104
+
105
+ def _iter_pairs() -> Iterator[tuple[str, Path, str]]:
106
+ """Yield ``(source, path, source_id)`` for every on-disk CC-shaped transcript."""
107
+ for watcher, name in ((ClaudeCodeWatcher(), "claude-code"), (cloth_watcher(), "cloth")):
108
+ if not watcher.is_available():
109
+ continue
110
+ for path, source_id in watcher._iter_files():
111
+ yield name, path, source_id
112
+
113
+
114
+ def run(*, apply: bool = False, limit: Optional[int] = None) -> dict:
115
+ """Walk on-disk CC-shaped transcripts, backfilling recoverable events onto their
116
+ already-imported threads. Returns a summary dict."""
117
+ totals = {
118
+ "files_seen": 0,
119
+ "threads_mapped": 0,
120
+ "threads_recovered": 0,
121
+ "events_recovered": 0,
122
+ "by_type": {},
123
+ "read_errors": 0,
124
+ "write_errors": 0,
125
+ }
126
+ examined = 0
127
+ for name, path, source_id in _iter_pairs():
128
+ totals["files_seen"] += 1
129
+ if limit is not None and examined >= limit:
130
+ break
131
+ with get_session() as s:
132
+ state = s.execute(
133
+ select(ImportState).where(
134
+ ImportState.source == name, ImportState.source_id == source_id
135
+ )
136
+ ).scalar_one_or_none()
137
+ if state is None or not state.thread_id:
138
+ continue # never imported (or merged elsewhere) — nothing to backfill
139
+ totals["threads_mapped"] += 1
140
+ examined += 1
141
+ try:
142
+ lines = read_session_lines(path)
143
+ except Exception as e: # noqa: BLE001
144
+ logger.warning("read failed for %s (%s): %s", source_id, path, e)
145
+ totals["read_errors"] += 1
146
+ continue
147
+
148
+ rows = plan_thread(s, state.thread_id, lines)
149
+ if not rows:
150
+ continue
151
+ totals["threads_recovered"] += 1
152
+ totals["events_recovered"] += len(rows)
153
+ for r in rows:
154
+ totals["by_type"][r.event_type] = totals["by_type"].get(r.event_type, 0) + 1
155
+
156
+ if apply:
157
+ try:
158
+ write_events(s, rows)
159
+ index_events(s, rows)
160
+ s.commit()
161
+ except Exception as e: # noqa: BLE001 — one bad thread must not abort the run
162
+ logger.warning("write failed for thread %s (%s): %s", state.thread_id, source_id, e)
163
+ totals["write_errors"] += 1
164
+ s.rollback()
165
+ return totals
166
+
167
+
168
+ def main(argv: Optional[list[str]] = None) -> None:
169
+ ap = argparse.ArgumentParser(description=__doc__)
170
+ ap.add_argument("--apply", action="store_true", help="write recovered events (default: dry-run)")
171
+ ap.add_argument("--limit", type=int, default=None, help="cap threads examined (sampling)")
172
+ ap.add_argument("-v", "--verbose", action="store_true")
173
+ args = ap.parse_args(argv)
174
+ logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING)
175
+
176
+ totals = run(apply=args.apply, limit=args.limit)
177
+ mode = "APPLIED" if args.apply else "DRY-RUN"
178
+ print(f"[{mode}] recover-dropped-events")
179
+ print(f" files seen: {totals['files_seen']}")
180
+ print(f" threads mapped: {totals['threads_mapped']}")
181
+ print(f" threads w/ recovery: {totals['threads_recovered']}")
182
+ print(f" events recovered: {totals['events_recovered']}")
183
+ for t, n in sorted(totals["by_type"].items()):
184
+ print(f" {t:<14} {n}")
185
+ if totals["read_errors"] or totals["write_errors"]:
186
+ print(f" read errors: {totals['read_errors']} write errors: {totals['write_errors']}")
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
@@ -0,0 +1,118 @@
1
+ """One-off repair for three grok threads (ids 3716011 / 3716012 / 3716013,
2
+ sessions of 2026-06-29) whose live tail-import baked in chunking artifacts: a
3
+ ``tool_result`` processed in a later poll than its ``tool_calls`` line lost the
4
+ id->name map and recorded ``tool_name: "unknown"``, and several thinking/text
5
+ blocks were anchored at a stale previous-turn timestamp. The monorepo pg store
6
+ tailed the same sessions on its own schedule and froze *different* artifacts,
7
+ so the ingest-cutover soak's byte-level content check flags these threads as
8
+ divergent on every rotation pass — blocking the week-of-aligned-runs cut bar.
9
+
10
+ The fix aligns both stores to a canonical deterministic parse: this repo's own
11
+ importer run over the *complete* ``chat_history.jsonl`` files (no chunk
12
+ boundaries). This script applies the standalone half of the patch plan (21
13
+ events); its sibling in the monorepo
14
+ (``archive/src/archive/scripts/repair_grok_tool_names.py``) applies the pg
15
+ half. The plan (``repair_grok_tool_names_plan_20260704.json`` beside this
16
+ script) carries old + new values; old payloads are asserted before writing and
17
+ the changed rows are dumped to a backup file first. Truth-file history is
18
+ inherent: the corrected event lines append via the normal ``append_event_row``
19
+ seam and reindex is last-wins by id, so the pre-repair lines remain in the
20
+ per-thread JSONL as history. FTS rows for the patched events are re-indexed
21
+ (tool_name is an FTS column); vectors are left alone (embedded text is
22
+ unchanged).
23
+
24
+ Preview (read-only): .venv/bin/python src/thread_archive/_scripts/repair_grok_tool_names.py
25
+ Apply: .venv/bin/python src/thread_archive/_scripts/repair_grok_tool_names.py --apply
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import sys
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ from sqlalchemy import delete
36
+ from sqlalchemy import text as sa_text
37
+
38
+ from thread_archive._api import open_archive
39
+ from thread_archive._retrieval.fts import index_events
40
+ from thread_archive._store import use_session
41
+ from thread_archive._store.models import Event, EventFts
42
+ from thread_archive._truth.jsonl_log import append_event_row
43
+
44
+ HERE = Path(__file__).parent
45
+ PLAN_PATH = HERE / "repair_grok_tool_names_plan_20260704.json"
46
+ BACKUP_PATH = HERE / f"repair_grok_tool_names_backup_{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}.json"
47
+
48
+
49
+ def canonical_json(payload) -> str:
50
+ obj = json.loads(payload) if isinstance(payload, (str, bytes)) else payload
51
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
52
+
53
+
54
+ def main() -> int:
55
+ apply = "--apply" in sys.argv
56
+ patches = json.loads(PLAN_PATH.read_text())["sa"]
57
+ print(f"{len(patches)} standalone patches loaded from {PLAN_PATH.name}")
58
+
59
+ open_archive(None)
60
+ with use_session() as session:
61
+ events: dict[int, Event] = {}
62
+ for p in patches:
63
+ ev = session.get(Event, p["event_id"])
64
+ if ev is None:
65
+ print(f"ABORT: event {p['event_id']} not found")
66
+ return 1
67
+ if canonical_json(ev.payload) != p["old_payload"]:
68
+ print(f"ABORT: event {p['event_id']} payload no longer matches plan")
69
+ return 1
70
+ if ev.event_type != p["old_event_type"]:
71
+ print(f"ABORT: event {p['event_id']} type {ev.event_type} != plan")
72
+ return 1
73
+ events[p["event_id"]] = ev
74
+
75
+ for p in patches:
76
+ changes = []
77
+ if p["old_event_type"] != p["new_event_type"]:
78
+ changes.append(f"type {p['old_event_type']} -> {p['new_event_type']}")
79
+ if p["old_occurred_at"] != p["new_occurred_at"]:
80
+ changes.append(f"ts {p['old_occurred_at']} -> {p['new_occurred_at']}")
81
+ if p["old_payload"] != p["new_payload"]:
82
+ changes.append("payload")
83
+ print(f" {p['event_id']}: {', '.join(changes)}")
84
+
85
+ if not apply:
86
+ print("\npreview only — rerun with --apply to write")
87
+ return 0
88
+
89
+ BACKUP_PATH.write_text(json.dumps(
90
+ [{"id": ev.id, "thread_id": ev.thread_id, "event_type": ev.event_type,
91
+ "occurred_at": str(ev.occurred_at), "payload": ev.payload,
92
+ "dedup_key": ev.dedup_key}
93
+ for ev in events.values()], indent=1, default=str))
94
+ print(f"\nbackup written: {BACKUP_PATH}")
95
+
96
+ for p in patches:
97
+ ev = events[p["event_id"]]
98
+ ev.event_type = p["new_event_type"]
99
+ ev.occurred_at = datetime.fromisoformat(p["new_occurred_at"])
100
+ ev.payload = json.loads(p["new_payload"])
101
+ if p.get("new_dedup_key"):
102
+ ev.dedup_key = p["new_dedup_key"]
103
+ append_event_row(session, ev)
104
+
105
+ ids = list(events)
106
+ session.execute(delete(EventFts).where(EventFts.event_id.in_(ids)))
107
+ for eid in ids:
108
+ session.execute(
109
+ sa_text("DELETE FROM event_search WHERE event_id = :id"), {"id": eid})
110
+ session.flush()
111
+ indexed = index_events(session, list(events.values()))
112
+ session.commit()
113
+ print(f"applied {len(patches)} standalone event repairs; re-indexed {indexed} FTS rows")
114
+ return 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ sys.exit(main())