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