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