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,126 @@
1
+ """Filesystem layout for a thread-archive instance.
2
+
3
+ A single archive lives under one *home* directory:
4
+
5
+ <home>/
6
+ truth/ # the durable JSONL truth (the backup)
7
+ manifest.json # shard depth + checkpoint watermark
8
+ threads/ # one file per thread: <id>.jsonl (metadata record + events)
9
+ kg_events.jsonl # append-only curatorial log (topics / links / citations)
10
+ thread_links.jsonl # cross-thread overlay (topic-graph edges, folded from kg_events)
11
+ topic_messages.jsonl # cross-thread overlay (topic evidence, folded from kg_events)
12
+ index.db # SQLite projection, rebuildable from truth/ via `archive reindex`
13
+ dumps/ # drop zone: account exports dropped here are auto-imported
14
+ config.json # operator choices (source opt-outs, setup state); absent = all defaults
15
+
16
+ `home` resolves from ``THREAD_ARCHIVE_HOME`` (env), else ``~/.thread/archive``
17
+ (the family's ``~/.thread/<product>/`` namespace; ``~/.thread_archive``
18
+ survives as a compat symlink on some boxes).
19
+ Truth and index paths can be overridden individually (e.g. for tests).
20
+
21
+ ``config.json`` is the durable form of the choices a user makes in the
22
+ ``thread_archive`` setup flow — which sources to ingest, what setup decided —
23
+ and every ingest path (the watcher daemon, lazy MCP catch-up, ``archive
24
+ watch``) consults it via :func:`source_enabled`. A missing or unreadable file
25
+ means "all defaults": every source enabled, exactly the pre-config behavior.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ ENV_HOME = "THREAD_ARCHIVE_HOME"
36
+ ENV_TRUTH = "THREAD_ARCHIVE_TRUTH_DIR"
37
+ ENV_INDEX = "THREAD_ARCHIVE_INDEX"
38
+
39
+ DEFAULT_HOME = Path.home() / ".thread" / "archive"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ArchivePaths:
44
+ """Resolved on-disk locations for one archive instance."""
45
+
46
+ home: Path
47
+ truth_dir: Path
48
+ index_path: Path
49
+
50
+ def ensure(self) -> "ArchivePaths":
51
+ """Create the home + truth directories if absent. Idempotent."""
52
+ self.home.mkdir(parents=True, exist_ok=True)
53
+ self.truth_dir.mkdir(parents=True, exist_ok=True)
54
+ return self
55
+
56
+ @property
57
+ def sqlalchemy_url(self) -> str:
58
+ return f"sqlite:///{self.index_path}"
59
+
60
+ @property
61
+ def dumps_dir(self) -> Path:
62
+ """Drop zone for downloaded account exports (auto-imported by the watcher)."""
63
+ return self.home / "dumps"
64
+
65
+
66
+ def resolve_paths(
67
+ home: str | os.PathLike[str] | None = None,
68
+ *,
69
+ truth_dir: str | os.PathLike[str] | None = None,
70
+ index_path: str | os.PathLike[str] | None = None,
71
+ ) -> ArchivePaths:
72
+ """Resolve archive paths from explicit args, then env, then defaults."""
73
+ base = Path(home) if home is not None else Path(os.environ.get(ENV_HOME, DEFAULT_HOME))
74
+ base = base.expanduser()
75
+
76
+ truth = (
77
+ Path(truth_dir).expanduser()
78
+ if truth_dir is not None
79
+ else Path(os.environ.get(ENV_TRUTH, base / "truth")).expanduser()
80
+ )
81
+ index = (
82
+ Path(index_path).expanduser()
83
+ if index_path is not None
84
+ else Path(os.environ.get(ENV_INDEX, base / "index.db")).expanduser()
85
+ )
86
+ return ArchivePaths(home=base, truth_dir=truth, index_path=index)
87
+
88
+
89
+ # ── config.json: durable operator choices ────────────────────────────────────
90
+
91
+ CONFIG_FILE = "config.json"
92
+
93
+
94
+ def config_path(home: str | os.PathLike[str] | None = None) -> Path:
95
+ return resolve_paths(home).home / CONFIG_FILE
96
+
97
+
98
+ def load_config(home: str | os.PathLike[str] | None = None) -> dict:
99
+ """The parsed config, or ``{}`` when absent/unreadable (all defaults).
100
+
101
+ Fail-soft on purpose: a corrupt config file must degrade to default
102
+ behavior (ingest everything), never take an ingest path down.
103
+ """
104
+ try:
105
+ data = json.loads(config_path(home).read_text(encoding="utf-8"))
106
+ except (OSError, json.JSONDecodeError):
107
+ return {}
108
+ return data if isinstance(data, dict) else {}
109
+
110
+
111
+ def save_config(cfg: dict, home: str | os.PathLike[str] | None = None) -> Path:
112
+ """Write the config atomically (tmp + rename). Returns the path."""
113
+ path = config_path(home)
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ tmp = path.with_name(path.name + ".tmp")
116
+ tmp.write_text(json.dumps(cfg, indent=2, sort_keys=True) + "\n", encoding="utf-8")
117
+ tmp.replace(path)
118
+ return path
119
+
120
+
121
+ def source_enabled(cfg: dict, source_name: str) -> bool:
122
+ """Whether a source watcher may ingest. Unlisted sources default to enabled."""
123
+ entry = cfg.get("sources", {}).get(source_name, {})
124
+ if not isinstance(entry, dict):
125
+ return True
126
+ return bool(entry.get("enabled", True))
@@ -0,0 +1,81 @@
1
+ """Incremental import: provider transcripts → events (over the JSONL truth seam).
2
+
3
+ Two shapes: single-file line-stream importers (Claude Code, Codex, Grok,
4
+ Antigravity, cloth) and SQLite-DB scanners (Cursor, OpenCode). All converge on the
5
+ same ``assemble_events`` build → dedup → write loop, and all are atomic per unit of
6
+ import. (cloth is Claude-Code-shaped, so its importer delegates to the claude_code
7
+ path under ``source="cloth"``.)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ._result import IncrementalImportResult
13
+ from .antigravity import import_antigravity_session_incremental
14
+ from .claude_code import import_session_incremental
15
+ from .claude_science import (
16
+ ClaudeScienceDbScanResult,
17
+ ClaudeScienceImportResult,
18
+ import_claude_science_db,
19
+ import_claude_science_frame,
20
+ )
21
+ from .cloth import import_cloth_session_incremental
22
+ from .codex import import_codex_session_incremental
23
+ from .cowork import import_cowork_session_incremental
24
+ from .cursor import (
25
+ CursorDbScanResult,
26
+ CursorImportResult,
27
+ import_cursor_db,
28
+ import_cursor_from_payload,
29
+ )
30
+ from .grok import import_grok_session_incremental
31
+ from .opencode import (
32
+ OpenCodeDbScanResult,
33
+ OpenCodeImportResult,
34
+ import_opencode_db,
35
+ import_opencode_from_payload,
36
+ )
37
+
38
+ # Provider name → single-file line-stream importer. (Cursor / OpenCode scan a DB
39
+ # of many sessions and are dispatched separately.)
40
+ LINE_STREAM_IMPORTERS = {
41
+ "claude-code": import_session_incremental,
42
+ "codex": import_codex_session_incremental,
43
+ "grok": import_grok_session_incremental,
44
+ "antigravity": import_antigravity_session_incremental,
45
+ "cloth": import_cloth_session_incremental,
46
+ }
47
+
48
+ DB_SCANNERS = {
49
+ "cursor": import_cursor_db,
50
+ "opencode": import_opencode_db,
51
+ }
52
+
53
+ # Claude Science scans a DB of many frames per org, dispatched per-org by the watcher
54
+ # (not a single fixed DB path like Cursor/OpenCode), so it's tracked separately.
55
+
56
+ PROVIDERS = list(LINE_STREAM_IMPORTERS) + list(DB_SCANNERS)
57
+
58
+ __all__ = [
59
+ "IncrementalImportResult",
60
+ "import_session_incremental",
61
+ "import_cloth_session_incremental",
62
+ "import_codex_session_incremental",
63
+ "import_cowork_session_incremental",
64
+ "import_claude_science_db",
65
+ "import_claude_science_frame",
66
+ "ClaudeScienceImportResult",
67
+ "ClaudeScienceDbScanResult",
68
+ "import_grok_session_incremental",
69
+ "import_antigravity_session_incremental",
70
+ "import_cursor_db",
71
+ "import_cursor_from_payload",
72
+ "import_opencode_db",
73
+ "import_opencode_from_payload",
74
+ "CursorImportResult",
75
+ "CursorDbScanResult",
76
+ "OpenCodeImportResult",
77
+ "OpenCodeDbScanResult",
78
+ "LINE_STREAM_IMPORTERS",
79
+ "DB_SCANNERS",
80
+ "PROVIDERS",
81
+ ]
@@ -0,0 +1,136 @@
1
+ """Continuation / fork detection for the CC incremental import.
2
+
3
+ Decides whether a fresh Claude Code JSONL is a *continuation* of an existing
4
+ thread (merge into it) or a new/forked session (mint its own). Ported from
5
+ canonical ``streaming/incremental_import/_continuation.py``; the Postgres
6
+ ``payload->>'content'`` becomes SQLite ``json_extract(payload, '$.content')``,
7
+ and the candidate lookup validates each same-first-message thread by the
8
+ prefix-consistency walk rather than disambiguating on a timestamp match.
9
+
10
+ Two detectors:
11
+
12
+ - ``detect_continuation_parent`` — the modern ``compact_boundary`` case: CC opens
13
+ the post-compaction file with a boundary line + a user message pointing at the
14
+ parent session's ``.jsonl`` path. Pull the parent UUID, resolve its thread.
15
+ - ``find_thread_by_first_message`` — older no-boundary compactions replay the whole
16
+ conversation with fresh UUIDs (a strict prefix-superset of the parent). A *fork*
17
+ shares a prefix then diverges; the prefix-consistency walk tells them apart.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from typing import TYPE_CHECKING, Optional
24
+
25
+ from sqlalchemy import func, select
26
+ from sqlalchemy.orm import Session
27
+
28
+ from .._store import Event
29
+ from ._state import lookup_parent_thread
30
+ from ._titles import _user_content_texts
31
+
32
+ if TYPE_CHECKING:
33
+ from thread_archive._thread_import.parsers.claude_code import ClaudeCodeParser
34
+
35
+ _PARENT_JSONL_RE = re.compile(r"/([0-9a-f-]{36})\.jsonl")
36
+
37
+
38
+ def detect_continuation_parent(
39
+ session: Session, lines: list[dict], source_id: str
40
+ ) -> Optional[int]:
41
+ """If this is a ``compact_boundary`` continuation, return the parent thread_id."""
42
+ has_boundary = any(line.get("subtype") == "compact_boundary" for line in lines[:3])
43
+ if not has_boundary:
44
+ return None
45
+
46
+ project_prefix = source_id.rsplit(":", 1)[0]
47
+ for line in lines[:5]:
48
+ if line.get("type") != "user":
49
+ continue
50
+ for txt in _user_content_texts(line.get("message", {}).get("content", "")):
51
+ match = _PARENT_JSONL_RE.search(txt)
52
+ if match:
53
+ parent_source_id = f"{project_prefix}:{match.group(1)}"
54
+ return lookup_parent_thread(session, parent_source_id)
55
+ return None
56
+
57
+
58
+ def find_thread_by_first_message(
59
+ session: Session, lines: list[dict], parser: "ClaudeCodeParser", builder
60
+ ) -> Optional[int]:
61
+ """Find an existing thread this session is a no-boundary *continuation* of.
62
+
63
+ Match the first user message's **content + timestamp** (content-only over-merges
64
+ distinct sessions that happen to share a first message), then require prefix-
65
+ consistency across the whole user-message overlap (a fork diverges → reject) and
66
+ that the incoming session is at least as long as the candidate (a strict prefix is
67
+ a not-yet-diverged fork → reject). Returns the thread to merge into, else None.
68
+
69
+ The first message's ``occurred_at`` is taken from a probe build of that message —
70
+ the deterministic parse of its source timestamp — so it matches the value the
71
+ original import stored through the same column processor."""
72
+ session_data = {
73
+ "provider": "claude-code",
74
+ "sessions": [{"session_id": "check", "project": "check", "lines": lines[:2000]}],
75
+ }
76
+ try:
77
+ messages = parser.parse_export(session_data)
78
+ except Exception:
79
+ return None
80
+
81
+ incoming_users = [
82
+ m.get("content_text", "")
83
+ for m in messages
84
+ if m.get("role") == "user" and m.get("content_text")
85
+ ]
86
+ if not incoming_users:
87
+ return None
88
+ first_content = incoming_users[0]
89
+
90
+ first_msg = next(
91
+ (m for m in messages if m.get("role") == "user" and m.get("content_text") == first_content),
92
+ None,
93
+ )
94
+ probe = builder.build_events(first_msg, "probe") if first_msg is not None else []
95
+ anchor = next((e for e in probe if e.event_type == "user_message_sent"), None)
96
+ if anchor is None:
97
+ return None
98
+
99
+ content_col = func.json_extract(Event.payload, "$.content")
100
+ candidate = session.execute(
101
+ select(Event.thread_id)
102
+ .where(
103
+ Event.event_type == "user_message_sent",
104
+ content_col == first_content,
105
+ Event.occurred_at == anchor.occurred_at,
106
+ )
107
+ .order_by(Event.id)
108
+ .limit(1)
109
+ ).scalar()
110
+ if candidate is None:
111
+ return None
112
+
113
+ parent_users = session.execute(
114
+ select(content_col)
115
+ .where(Event.thread_id == candidate, Event.event_type == "user_message_sent")
116
+ .order_by(Event.occurred_at, Event.id)
117
+ ).scalars().all()
118
+ # Prefix-consistency: any mismatch over the overlap → fork, not a continuation.
119
+ if any(inc != par for inc, par in zip(incoming_users, parent_users)):
120
+ return None
121
+ # A real no-boundary continuation replays the parent's whole history then adds
122
+ # more, so it's a superset; a strict prefix is a not-yet-diverged fork.
123
+ if len(incoming_users) < len(parent_users):
124
+ return None
125
+ return candidate
126
+
127
+
128
+ def resolve_continuation_thread(
129
+ session: Session, lines: list[dict], source_id: str, parser: "ClaudeCodeParser", builder
130
+ ) -> Optional[int]:
131
+ """The parent thread to merge this session into (boundary parent, else
132
+ first-message superset), or None when it's a genuinely new/forked session."""
133
+ parent = detect_continuation_parent(session, lines, source_id)
134
+ if parent is not None:
135
+ return parent
136
+ return find_thread_by_first_message(session, lines, parser, builder)
@@ -0,0 +1,103 @@
1
+ """The append-proving cursor for the JSONL line-stream importers.
2
+
3
+ A file watermark has to answer one question: *are the lines I already imported still
4
+ the first lines of this file?* Size alone cannot answer it. A rewritten line of the
5
+ same serialized length, a truncate-and-regrow between polls, or a malformed line
6
+ repaired in place all leave the size equal or larger while the content underneath the
7
+ cursor changed — and a line-count cursor then indexes into content that no longer
8
+ means what it meant, skipping the difference for good.
9
+
10
+ So the watermark carries a **digest of the exact bytes it was computed over**
11
+ (``ImportState.last_content_hash``, the sha256 of the whole file as of that import).
12
+ On the next poll we hash the current file's first ``last_file_size`` bytes: match ⇒
13
+ the file was only appended to, and the line cursor is still valid; mismatch (or a
14
+ file now shorter than the watermark) ⇒ the source was rewritten, so we rewind to line
15
+ 0 and re-import the whole thing. The ``dedup_key`` membership check collapses
16
+ everything already held, so a rewind costs work, never duplicates.
17
+
18
+ That one check subsumes several silent-loss modes at once: same-size rewrites, a
19
+ shrink, and — the sharp one — the *logical* line cursor drifting out of step with the
20
+ file. ``read_session_lines`` drops malformed lines, so the cursor counts parsed lines,
21
+ not physical ones; repairing a malformed line in the middle of a file shifts every
22
+ later line's index and the tail slice would silently skip a turn. A repair changes the
23
+ prefix bytes, so it rewinds instead.
24
+
25
+ A torn final line — a poll catching the writer mid-append — is *not* a rewrite: the
26
+ partial bytes are a prefix of the completed line, so the digest still matches and the
27
+ unparsed tail simply imports on the next poll.
28
+
29
+ Watermarks written before this hash existed carry ``last_content_hash = None``: they
30
+ can't be verified, so they keep the old size-only behavior for exactly one poll and
31
+ are backfilled with a digest as they go (a blanket rewind of every source would be a
32
+ correct but pointlessly expensive way to learn nothing).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import hashlib
38
+ import logging
39
+ from dataclasses import dataclass
40
+ from typing import Optional
41
+
42
+ from .._store import ImportState
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ def content_digest(data: bytes) -> str:
48
+ """The watermark's proof-of-content: sha256 of the bytes the cursor covers."""
49
+ return hashlib.sha256(data).hexdigest()
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SourceCursor:
54
+ """Where to resume in a re-read source file, and the digest to watermark it with.
55
+
56
+ ``unchanged`` = byte-identical to the last import (nothing to do). ``rewound`` =
57
+ the source was rewritten under us, so ``start_line`` is 0 and the whole file
58
+ re-imports.
59
+ """
60
+
61
+ start_line: int
62
+ content_hash: str
63
+ unchanged: bool = False
64
+ rewound: bool = False
65
+
66
+
67
+ def resolve_source_cursor(
68
+ state: Optional[ImportState], data: bytes, *, source: str, source_id: str
69
+ ) -> SourceCursor:
70
+ """Resolve where this poll should resume in ``data`` (see the module docstring)."""
71
+ size = len(data)
72
+ digest = content_digest(data)
73
+
74
+ if state is None:
75
+ return SourceCursor(start_line=0, content_hash=digest)
76
+
77
+ start_line = state.last_line_count
78
+ last_size = state.last_file_size
79
+
80
+ if not state.last_content_hash:
81
+ # Unverifiable pre-digest watermark: trust size for one more poll, and stamp a
82
+ # digest so the next one is provable.
83
+ if size == last_size:
84
+ return SourceCursor(start_line, digest, unchanged=True)
85
+ if size < last_size:
86
+ return _rewind(source, source_id, f"source shrank ({last_size} → {size} bytes)", digest)
87
+ return SourceCursor(start_line, digest)
88
+
89
+ if size == last_size and digest == state.last_content_hash:
90
+ return SourceCursor(start_line, digest, unchanged=True)
91
+ if size < last_size:
92
+ return _rewind(source, source_id, f"source shrank ({last_size} → {size} bytes)", digest)
93
+ if content_digest(data[:last_size]) != state.last_content_hash:
94
+ return _rewind(source, source_id, "content under the cursor changed", digest)
95
+ return SourceCursor(start_line, digest)
96
+
97
+
98
+ def _rewind(source: str, source_id: str, why: str, digest: str) -> SourceCursor:
99
+ logger.warning(
100
+ "%s %s: %s — rewinding cursor, re-importing from line 0 (dedup collapses what we hold)",
101
+ source, source_id, why,
102
+ )
103
+ return SourceCursor(start_line=0, content_hash=digest, rewound=True)
@@ -0,0 +1,244 @@
1
+ """The shared parse/build → dedup → write loop.
2
+
3
+ Every provider's import is the same loop: walk NormalizedMessages, mint a stream id
4
+ per user turn (the following assistant turns reuse it), build events via
5
+ ``DefaultEventBuilder`` with monotonic timestamp inheritance, and write. That loop
6
+ lives in one function, :func:`assemble_events`.
7
+
8
+ Idempotence is enforced on the ``dedup_key`` the builder computes (timestamp-free,
9
+ content-inclusive, scoped per thread). A re-import — even one that re-reads
10
+ already-imported lines after a watermark loss — adds nothing.
11
+
12
+ A turn straddles polls: the user line can land in one poll and its assistant reply in
13
+ the next. So both of the loop's carried anchors are seeded from what the thread
14
+ already holds — the stream id (:func:`_last_stream_id`) and, for the providers that
15
+ pass it, the timestamp (``base_prev_ts``). Without that seed the continuing assistant
16
+ turn would open its own stream and orphan itself from the user turn it answers.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import uuid
23
+ from datetime import datetime
24
+ from typing import Iterable, Optional
25
+
26
+ from sqlalchemy import func, select
27
+ from sqlalchemy.orm import Session
28
+
29
+ from thread_archive._thread_import import DefaultEventBuilder
30
+
31
+ from .._store import Event
32
+ from .._truth import write_events
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # SQLite's default host-parameter ceiling is 999; stay well under it per IN (...).
37
+ _KEY_CHUNK = 500
38
+
39
+
40
+ def _existing_dedup_keys(
41
+ session: Session, thread_id: int, keys: Optional[Iterable[str]] = None
42
+ ) -> set[str]:
43
+ """Which of ``keys`` the thread already holds — or *all* its keys when ``keys``
44
+ is None.
45
+
46
+ The import loop asks only about the keys it built this pass. Loading every key in
47
+ the thread instead makes each poll of a long session pay for the whole session's
48
+ history, which is the shape of quadratic work on a transcript that grows by a line
49
+ at a time.
50
+ """
51
+ stmt = select(Event.dedup_key).where(
52
+ Event.thread_id == thread_id, Event.dedup_key.is_not(None)
53
+ )
54
+ if keys is None:
55
+ return set(session.execute(stmt).scalars().all())
56
+
57
+ candidates = list(keys)
58
+ found: set[str] = set()
59
+ for i in range(0, len(candidates), _KEY_CHUNK):
60
+ chunk = candidates[i : i + _KEY_CHUNK]
61
+ found.update(session.execute(stmt.where(Event.dedup_key.in_(chunk))).scalars().all())
62
+ return found
63
+
64
+
65
+ def _last_stream_id(session: Session, thread_id: int) -> Optional[str]:
66
+ """The stream id of the thread's newest event — the open turn a continuing
67
+ assistant message belongs to. None on a thread with no events yet."""
68
+ return session.execute(
69
+ select(Event.stream_id)
70
+ .where(Event.thread_id == thread_id)
71
+ .order_by(Event.id.desc())
72
+ .limit(1)
73
+ ).scalars().first()
74
+
75
+
76
+ def _anchor_event(events: list, event_type: str):
77
+ """The content-bearing anchor event of a built message (the user_message_sent
78
+ of a user turn, the api_request_completed of an assistant turn), or None."""
79
+ for ev in events:
80
+ if ev.event_type == event_type:
81
+ return ev
82
+ return None
83
+
84
+
85
+ def _message_already_present(session: Session, thread_id: int, anchor) -> bool:
86
+ """True when an event with the same (type, content, occurred_at) already exists
87
+ in the thread. Used to suppress the replayed prefix when a CC continuation /
88
+ fork-resume merges into an existing thread (its fresh UUIDs defeat the dedup_key
89
+ check). The built ``anchor.occurred_at`` is the deterministic parse of the source
90
+ line's timestamp, so passing it as a bind param matches the value the original
91
+ import stored through the same column processor — robust across tz/format."""
92
+ if anchor is None:
93
+ return False
94
+ content = anchor.payload.get("content")
95
+ if content is None:
96
+ return False
97
+ return session.execute(
98
+ select(Event.id)
99
+ .where(
100
+ Event.thread_id == thread_id,
101
+ Event.event_type == anchor.event_type,
102
+ func.json_extract(Event.payload, "$.content") == content,
103
+ Event.occurred_at == anchor.occurred_at,
104
+ )
105
+ .limit(1)
106
+ ).first() is not None
107
+
108
+
109
+ def _to_event(thread_id: int, te) -> Event:
110
+ """Map a portable ThreadEvent onto an Event row (thread_id added at write time)."""
111
+ return Event(
112
+ thread_id=thread_id,
113
+ stream_id=te.stream_id,
114
+ api_call_id=te.api_call_id,
115
+ event_type=te.event_type,
116
+ payload=te.payload,
117
+ occurred_at=te.occurred_at,
118
+ correlation_id=te.correlation_id,
119
+ dedup_key=te.dedup_key,
120
+ )
121
+
122
+
123
+ def assemble_events(
124
+ session: Session,
125
+ thread_id: int,
126
+ messages: list,
127
+ builder: DefaultEventBuilder,
128
+ *,
129
+ base_prev_ts: Optional[datetime] = None,
130
+ cross_pass_dedup: bool = False,
131
+ ) -> tuple[int, Optional[str]]:
132
+ """Build events from NormalizedMessages and write the not-yet-seen ones.
133
+
134
+ ``base_prev_ts`` seeds the timestamp-inheritance anchor (grok/opencode seed it
135
+ from the newest persisted event so a later incremental pass doesn't sort to the
136
+ top of the thread). Returns ``(events_created, last_message_uuid)``.
137
+
138
+ ``cross_pass_dedup`` adds a message-level (content + timestamp) existence check
139
+ on top of the dedup_key membership check. It is enabled only when merging a CC
140
+ continuation / fork-resume into an existing thread, where the replayed prefix
141
+ carries fresh UUIDs (so its dedup_keys won't match) but identical content +
142
+ timestamps. A matched user turn is skipped along with its following assistant
143
+ turns (until the next genuinely-new user turn), mirroring canonical.
144
+ """
145
+ if not messages:
146
+ return 0, None
147
+
148
+ last_uuid: Optional[str] = None
149
+ prev_occurred_at: Optional[datetime] = base_prev_ts
150
+ # Seed from the thread's open turn so an assistant reply that arrives in a later
151
+ # poll than its user line joins that turn instead of stranding itself in a new one.
152
+ current_stream_id: Optional[str] = _last_stream_id(session, thread_id)
153
+
154
+ built: list[tuple[str, list]] = []
155
+ for msg in messages:
156
+ role = msg.get("role", "")
157
+ # The parser emits the provider id as `provider_message_id`; older CC line
158
+ # dicts use `uuid`. Track whichever is present.
159
+ message_id = msg.get("uuid") or msg.get("provider_message_id")
160
+ if message_id:
161
+ last_uuid = message_id
162
+
163
+ if role == "user":
164
+ current_stream_id = str(uuid.uuid4())
165
+ events = builder.build_events(msg, current_stream_id, prev_occurred_at=prev_occurred_at)
166
+ elif role == "assistant":
167
+ if not current_stream_id:
168
+ current_stream_id = str(uuid.uuid4())
169
+ events = builder.build_events(
170
+ msg, current_stream_id, str(uuid.uuid4()), prev_occurred_at=prev_occurred_at
171
+ )
172
+ else:
173
+ # 'system', or a role the builder doesn't specifically model (tool/function/
174
+ # developer/model/… from non-CC harnesses). Preserve the turn, don't drop it.
175
+ events = builder.build_events(
176
+ msg, current_stream_id or str(uuid.uuid4()), prev_occurred_at=prev_occurred_at
177
+ )
178
+
179
+ if events:
180
+ # Advance the anchor even for deduped messages so a later new message
181
+ # inherits a monotonic time (never a silent now()).
182
+ prev_occurred_at = events[-1].occurred_at
183
+ built.append((role, events))
184
+
185
+ seen = _existing_dedup_keys(
186
+ session, thread_id, {te.dedup_key for _, evs in built for te in evs if te.dedup_key}
187
+ )
188
+ batch: list[Event] = []
189
+ skip_until_next_user = False
190
+
191
+ for role, events in built:
192
+ # Cross-pass message-level dedup (continuation/fork merge only): skip a turn
193
+ # whose anchor (content + timestamp) is already in the thread.
194
+ if cross_pass_dedup:
195
+ if role == "user":
196
+ skip_until_next_user = _message_already_present(
197
+ session, thread_id, _anchor_event(events, "user_message_sent")
198
+ )
199
+ if skip_until_next_user:
200
+ continue
201
+ elif role == "assistant":
202
+ if skip_until_next_user:
203
+ continue
204
+ if _message_already_present(
205
+ session, thread_id, _anchor_event(events, "api_request_completed")
206
+ ):
207
+ continue
208
+
209
+ for te in events:
210
+ if te.dedup_key and te.dedup_key in seen:
211
+ continue
212
+ if te.dedup_key:
213
+ seen.add(te.dedup_key)
214
+ batch.append(_to_event(thread_id, te))
215
+
216
+ if batch:
217
+ write_events(session, batch)
218
+ # Index the new events into the FTS surface in the same transaction, so
219
+ # search stays current without a full rebuild and FTS commits atomically
220
+ # with the events + truth log.
221
+ from .._retrieval.fts import index_events
222
+
223
+ index_events(session, batch)
224
+ return len(batch), last_uuid
225
+
226
+
227
+ def import_lines(
228
+ session: Session,
229
+ thread_id: int,
230
+ lines: list[dict],
231
+ parser,
232
+ builder: DefaultEventBuilder,
233
+ *,
234
+ source: str = "claude-code",
235
+ source_id: str = "",
236
+ cross_pass_dedup: bool = False,
237
+ ) -> tuple[int, Optional[str]]:
238
+ """Claude Code: parse a line bundle via the thread_import parser, then assemble."""
239
+ session_data = {
240
+ "provider": "claude-code",
241
+ "sessions": [{"session_id": "incremental", "project": "incremental", "lines": lines}],
242
+ }
243
+ messages = parser.parse_export(session_data)
244
+ return assemble_events(session, thread_id, messages, builder, cross_pass_dedup=cross_pass_dedup)