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