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,228 @@
1
+ """Result ranking — the weighted lexical scorer + the cross-encoder gate.
2
+
3
+ The production ranker. The federation produces a pool; this turns it into an order:
4
+
5
+ 1. :func:`dedup_results` collapses byte-identical hits (same logical message
6
+ re-emitted under two event_ids) before ranking.
7
+ 2. :func:`rank_search_results` scores each hit by term **density** (normalized by
8
+ content length), **phrase proximity**, **recency**, **content-type** weight,
9
+ and **cross-backend fusion** (the ``_rrf`` agreement score the vector arm
10
+ contributes) — the same five knobs, at the same shipped weights, as prod.
11
+ 3. :func:`should_rerank` gates the latency-bearing cross-encoder head re-rank
12
+ (:mod:`.rerank`) to *conceptual* multi-term queries — the vocab-mismatch ones
13
+ where the bi-encoder ranks the target mid-list. Keyword shapes (OR / quoted /
14
+ identifier-dominated / single-term) the lexical arm already nails are skipped.
15
+
16
+ The weights are the production values, with their evidence: recency 1.0 (the first
17
+ call made on production click data — the corpus skews to OLD threads, so a recency
18
+ boost buries what users actually read), fusion 50.0 (the MRR optimum once the vector
19
+ arm joined), content-type from ``_CONTENT_TYPE_WEIGHT`` (user > text > tool_result …).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import re
26
+ from datetime import datetime
27
+
28
+ # Per-content-type relevance multiplier — user messages are the most intentional,
29
+ # tool/thinking the noisiest. Thread-meta docs (title/summary) rank like the
30
+ # intentional kinds they distill: a title is aboutness itself, a summary is the
31
+ # thread's own digest.
32
+ _CONTENT_TYPE_WEIGHT = {
33
+ "title": 1.5,
34
+ "user": 1.5,
35
+ "summary": 1.2,
36
+ "text": 1.2,
37
+ "tool_result": 0.8,
38
+ "tool": 0.5,
39
+ "thinking": 0.3,
40
+ "continuation_summary": 0.1,
41
+ }
42
+
43
+ # Cross-backend RRF fusion weight — the MRR optimum once the vector arm joined the
44
+ # federation (the fusion-sweep: 50.0 Pareto-dominates 0.0 on R@1/10/20 and MRR).
45
+ # Lexical scoring is ~0 for a semantic-only hit, so without this term a
46
+ # vocab-mismatch hit the vector arm surfaced would sink regardless of its rank.
47
+ _SEARCH_FUSION_WEIGHT = 50.0
48
+
49
+ # Recency weight — dropped 10.0→1.0 on production click data (the first decision
50
+ # made on real searches-that-led-to-reads). The corpus skews to OLD threads; a
51
+ # recency boost buries what users actually read. 1.0 keeps a mild recent tiebreaker.
52
+ _SEARCH_RECENCY_WEIGHT = 1.0
53
+
54
+ # Cross-encoder re-rank pool — how many ranked candidates to feed the reranker
55
+ # before cutting to ``limit``. Wide enough to cover recall@20, small enough to keep
56
+ # the in-process re-rank stage quick.
57
+ RERANK_POOL = 24
58
+
59
+ # Function words carry no relevance signal, so they're dropped from the *ranking*
60
+ # term set (density, phrase, the K/N match-quality verdict) — otherwise "how did we
61
+ # fix the auth bug" scores hits on "how/did/we/the" and the quality header inflates.
62
+ # The FTS MATCH itself is untouched (bm25 already discounts common terms); this only
63
+ # shapes what the scorer and the trust signal count. An all-stopword query keeps its
64
+ # terms — better a weak signal than none.
65
+ _STOPWORDS = frozenset(
66
+ "a an and are as at be but by did do does for from had has have how i in is it "
67
+ "its me my of on or our so that the their then there these this to was we "
68
+ "were what when where which who why will with you your".split()
69
+ )
70
+
71
+
72
+ def recency_score(occurred_at, now: datetime | None = None) -> int:
73
+ """0–20 exponential-decay recency score (half-life ~3 days). Datetime or ISO
74
+ string in, int out."""
75
+ dt = _parse_naive_dt(occurred_at)
76
+ if dt is None:
77
+ return 0
78
+ if now is None:
79
+ now = datetime.now()
80
+ age_hours = max(0, (now - dt).total_seconds() / 3600)
81
+ return max(1, int(20 * math.exp(-age_hours / 72)))
82
+
83
+
84
+ def _parse_naive_dt(val) -> datetime | None:
85
+ if not val:
86
+ return None
87
+ try:
88
+ dt = val if isinstance(val, datetime) else datetime.fromisoformat(str(val).replace("Z", "+00:00"))
89
+ if dt.tzinfo:
90
+ dt = dt.replace(tzinfo=None)
91
+ return dt
92
+ except Exception:
93
+ return None
94
+
95
+
96
+ def _drop_stopwords(terms: list[str]) -> list[str]:
97
+ """Filter function words out of a ranking term set; keep the set intact when
98
+ filtering would empty it (an all-stopword query still needs *some* signal).
99
+ Multi-word phrase terms (from quoted spans) always survive."""
100
+ kept = [t for t in terms if " " in t or t not in _STOPWORDS]
101
+ return kept or terms
102
+
103
+
104
+ def search_terms(query: str) -> list[str]:
105
+ """Lowercased ranking terms for ``query`` — the term set the density/phrase
106
+ scorer matches against. Identifier-style words (``help_think``) are preserved
107
+ intact; quoted spans become one phrase term each; AND/OR/NOT/pipe and
108
+ function words (:data:`_STOPWORDS`) are dropped."""
109
+ if not query or not query.strip():
110
+ return []
111
+ if re.search(r'\b(AND|OR|NOT)\b|".*?"|\*$', query):
112
+ phrases = [m.strip().lower() for m in re.findall(r'"([^"]+)"', query) if m.strip()]
113
+ outside = re.sub(r'"[^"]*"', " ", query)
114
+ words = [w.lower() for w in outside.split() if w not in ("AND", "OR", "NOT", "|")]
115
+ terms = phrases + words
116
+ if not terms:
117
+ terms = [t.lower() for t in query.split() if t not in ("AND", "OR", "NOT")]
118
+ return _drop_stopwords(terms)
119
+ q = re.sub(r"(?<=\w)-(?=\w)", " ", query)
120
+ q = re.sub(r"[:\^()\[\]{}]", " ", q)
121
+ q = re.sub(r"\s+", " ", q).strip()
122
+ return _drop_stopwords([t.lower() for t in q.split()])
123
+
124
+
125
+ _IDENTIFIER_RE = re.compile(r"[_]|::|(?<=\w)\.(?=\w)")
126
+
127
+
128
+ def should_rerank(query: str, terms: list[str]) -> bool:
129
+ """Gate the cross-encoder re-rank to *conceptual* multi-term queries. Skip the
130
+ keyword shapes the lexical arm already nails: pipe-OR, quoted exact phrases,
131
+ identifier-dominated queries, and single-term queries. A query that merely
132
+ *mentions* an identifier inside a conceptual question ("why thread_search
133
+ misses old threads") still reranks — only when identifier terms make up half
134
+ or more of the terms is the lexical arm trusted outright. A false positive
135
+ only costs latency (the re-rank is fail-soft), never results."""
136
+ q = (query or "").strip()
137
+ if "|" in q or '"' in q:
138
+ return False
139
+ if len(terms) < 2:
140
+ return False
141
+ code_terms = sum(1 for t in terms if _IDENTIFIER_RE.search(t))
142
+ return code_terms * 2 < len(terms)
143
+
144
+
145
+ def match_window(content: str, terms: list[str], chars: int) -> str:
146
+ """The ~``chars``-wide slice of ``content`` centred on the earliest term match —
147
+ what a cross-encoder should score. Feeding it the doc *head* mis-scores any hit
148
+ whose relevant text sits mid-message; centring keeps the match (plus a third of
149
+ the window as lead-in) inside the scored span. Head of the doc when nothing
150
+ matches (conceptual queries may share no literal term with the target)."""
151
+ if not content or len(content) <= chars:
152
+ return content
153
+ low = content.lower()
154
+ pos = min((p for p in (low.find(t) for t in terms) if p >= 0), default=-1)
155
+ if pos <= chars // 3: # no match, or match already inside a head window
156
+ return content[:chars]
157
+ start = min(pos - chars // 3, len(content) - chars)
158
+ return content[start:start + chars]
159
+
160
+
161
+ def dedup_results(results: list[dict]) -> list[dict]:
162
+ """Collapse byte-identical hits before ranking. The same logical message can
163
+ land as two events (streaming re-emits a text block), which the
164
+ (event_id, content_type) federation dedup misses. Key on (thread_id, content)
165
+ so identical short lines in *different* threads still both surface; keep first."""
166
+ if len(results) <= 1:
167
+ return results
168
+ seen: set[tuple] = set()
169
+ out: list[dict] = []
170
+ for r in results:
171
+ key = (r.get("thread_id"), (r.get("full_content") or r.get("snippet") or "").strip())
172
+ if key in seen:
173
+ continue
174
+ seen.add(key)
175
+ out.append(r)
176
+ return out
177
+
178
+
179
+ def rank_search_results(
180
+ results: list[dict],
181
+ terms: list[str],
182
+ limit: int,
183
+ *,
184
+ recency_weight: float = _SEARCH_RECENCY_WEIGHT,
185
+ density_weight: float = 100.0,
186
+ phrase_weight: float = 50.0,
187
+ fusion_weight: float = _SEARCH_FUSION_WEIGHT,
188
+ content_type_weights: dict[str, float] | None = None,
189
+ now: datetime | None = None,
190
+ ) -> list[dict]:
191
+ """Re-rank ``results`` by term density, phrase proximity, recency, content-type,
192
+ and cross-backend fusion (``_rrf``). The production scorer — see the module
193
+ docstring for the weight evidence. Returns the top ``limit``."""
194
+ now = now or datetime.now()
195
+ ct_weights = content_type_weights if content_type_weights is not None else _CONTENT_TYPE_WEIGHT
196
+ term_patterns = {t: re.compile(r"\b" + re.escape(t) + r"\b") for t in terms if len(t) < 4}
197
+
198
+ def combined_score(result: dict) -> float:
199
+ content = (result.get("full_content", "") or "").lower()
200
+ content_len = max(len(content), 1)
201
+ term_count = sum(
202
+ 1 for t in terms
203
+ if (len(t) >= 4 and t in content) or (len(t) < 4 and bool(term_patterns[t].search(content)))
204
+ )
205
+ density = term_count / max(1, content_len / 500)
206
+
207
+ phrase_bonus = 0.0
208
+ if len(terms) >= 2:
209
+ full_phrase = " ".join(terms)
210
+ if full_phrase in content:
211
+ phrase_bonus = 3.0
212
+ else:
213
+ positions = [content.find(t) for t in terms if content.find(t) >= 0]
214
+ if len(positions) == len(terms):
215
+ span = max(positions) - min(positions)
216
+ if span < 100:
217
+ phrase_bonus = 2.0
218
+ elif span < 300:
219
+ phrase_bonus = 1.0
220
+
221
+ recency = recency_score(result.get("occurred_at", ""), now)
222
+ ct_weight = ct_weights.get(result.get("content_type", "text"), 1.0)
223
+ rrf = result.get("_rrf", 0.0) or 0.0
224
+ return (density * density_weight + phrase_bonus * phrase_weight
225
+ + recency * recency_weight + rrf * fusion_weight) * ct_weight
226
+
227
+ ranked = sorted(enumerate(results), key=lambda x: (-combined_score(x[1]), x[0]))
228
+ return [r for _, r in ranked[:limit]]