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,151 @@
1
+ """The librarian MCP server — the curatorial *write* surface, kept separate.
2
+
3
+ A second, deliberately distinct MCP server (``thread-archive-librarian``) carrying the
4
+ knowledge-layer write tools + the curation-read tools the librarian needs to decide
5
+ what to write. The read-only ``thread-archive`` server (``thread_search`` /
6
+ ``thread_read``) stays read-only: mixing write tools into it would hand every
7
+ read-only client (e.g. a reader bot) curation power. This mirrors the read/write split
8
+ the upstream operator surface enforces.
9
+
10
+ Library-native, like the read server: each tool opens the archive and dispatches
11
+ straight to :mod:`thread_archive._knowledge.write` in-process — no HTTP, no route layer.
12
+ The archive home comes from ``$THREAD_ARCHIVE_HOME`` (else ``~/.thread/archive``).
13
+ Run with::
14
+
15
+ python -m thread_archive._mcp.librarian
16
+
17
+ Every write is event-sourced (an append-only ``KgEvent``) and idempotent at the
18
+ projection layer, so a re-run never double-links or double-cites.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from typing import Optional
25
+
26
+ from mcp.server.fastmcp import FastMCP
27
+
28
+ from .. import _api as api
29
+ from .._knowledge import write as _write
30
+
31
+ mcp = FastMCP("thread-archive-librarian")
32
+
33
+
34
+ def _dump(value) -> str:
35
+ return json.dumps(value, ensure_ascii=False, default=str)
36
+
37
+
38
+ # ── curation-read ────────────────────────────────────────────────────────────
39
+ @mcp.tool()
40
+ def review_queue(limit: int = 20, exclude_source_id: Optional[str] = None) -> str:
41
+ """Unreviewed conversation threads (the librarian backlog), newest first.
42
+
43
+ Event-bearing conversations the librarian hasn't curated yet — a thread leaves the
44
+ queue once it gains its first topic citation/link. Pass your own session's
45
+ ``source_id`` as ``exclude_source_id`` to drop your still-growing transcript from the
46
+ queue. In a parallel backfill (when ``$THREAD_ARCHIVE_LIBRARIAN_WORKER`` is set) this
47
+ transparently claims its batch under a lease, so concurrent workers don't overlap —
48
+ you call it the same way regardless. Returns a JSON list of ``{id, title, source_id}``."""
49
+ api.open_archive()
50
+ return _dump(_write.review_queue(limit=limit, exclude_source_id=exclude_source_id))
51
+
52
+
53
+ @mcp.tool()
54
+ def topic_search(query: str, limit: int = 10) -> str:
55
+ """Find existing live topics by title substring — search before creating duplicates.
56
+ Returns a JSON list of ``{topic_id, title}``."""
57
+ api.open_archive()
58
+ return _dump(_write.topic_search(query, limit=limit))
59
+
60
+
61
+ @mcp.tool()
62
+ def thread_user_messages(thread_id: int, limit: Optional[int] = None) -> str:
63
+ """A thread's user messages as ``[{event_id, text}]`` — the cheap, high-signal read
64
+ to cite from (cite the ``event_id`` values)."""
65
+ api.open_archive()
66
+ return _dump(_write.thread_user_messages(thread_id, limit=limit))
67
+
68
+
69
+ # ── topics ───────────────────────────────────────────────────────────────────
70
+ @mcp.tool()
71
+ def topic_create(title: str, description: Optional[str] = None) -> str:
72
+ """Create a topic. Errors if one with that title exists — ``topic_search`` first and
73
+ link to the existing topic rather than duplicating."""
74
+ api.open_archive()
75
+ try:
76
+ return _dump(_write.create_topic(title, description))
77
+ except ValueError as e:
78
+ return f"Error: {e}"
79
+
80
+
81
+ @mcp.tool()
82
+ def topic_rename(topic_id: int, title: str, description: Optional[str] = None) -> str:
83
+ """Rename (and optionally re-describe) a topic."""
84
+ api.open_archive()
85
+ try:
86
+ return _dump(_write.rename_topic(topic_id, title, description=description))
87
+ except ValueError as e:
88
+ return f"Error: {e}"
89
+
90
+
91
+ @mcp.tool()
92
+ def topic_archive(topic_id: int) -> str:
93
+ """Archive a topic — it drops out of the live graph (the thread stays reachable)."""
94
+ api.open_archive()
95
+ try:
96
+ return _dump(_write.archive_topic(topic_id))
97
+ except ValueError as e:
98
+ return f"Error: {e}"
99
+
100
+
101
+ @mcp.tool()
102
+ def topic_merge(from_id: int, into_id: int) -> str:
103
+ """Merge ``from_id`` into ``into_id``: repoint its links + citations, archive it."""
104
+ api.open_archive()
105
+ try:
106
+ return _dump(_write.merge_topics(from_id, into_id))
107
+ except ValueError as e:
108
+ return f"Error: {e}"
109
+
110
+
111
+ # ── links + citations ─────────────────────────────────────────────────────────
112
+ @mcp.tool()
113
+ def topic_link(
114
+ source_id: int, target_id: int, link_type: str = "related",
115
+ strength: float = 1.0, evidence: Optional[str] = None,
116
+ ) -> str:
117
+ """Link two threads/topics (idempotent on source+target+link_type). ``link_type`` is
118
+ e.g. related / implements / example-of / contrast / supersedes / works_on."""
119
+ api.open_archive()
120
+ return _dump(_write.link_threads(
121
+ source_id, target_id, link_type, strength=strength, evidence=evidence))
122
+
123
+
124
+ @mcp.tool()
125
+ def topic_unlink(source_id: int, target_id: int, link_type: str = "related") -> str:
126
+ """Remove a link (a tombstone event — recorded, not erased from history)."""
127
+ api.open_archive()
128
+ return _dump(_write.unlink_threads(source_id, target_id, link_type))
129
+
130
+
131
+ @mcp.tool()
132
+ def topic_cite(topic_id: int, event_id: int, thread_id: int, quote: str) -> str:
133
+ """Cite a conversation message (``event_id`` in ``thread_id``) as evidence for a
134
+ topic. Idempotent on (topic_id, event_id)."""
135
+ api.open_archive()
136
+ return _dump(_write.add_topic_evidence(topic_id, event_id, thread_id, quote))
137
+
138
+
139
+ @mcp.tool()
140
+ def topic_uncite(topic_id: int, event_id: int) -> str:
141
+ """Archive a citation (tombstone — sets archived_at, keeps the row + history)."""
142
+ api.open_archive()
143
+ return _dump(_write.archive_topic_evidence(topic_id, event_id))
144
+
145
+
146
+ def main() -> None:
147
+ mcp.run()
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,307 @@
1
+ """Library-native MCP server.
2
+
3
+ Exposes ``thread_search`` + ``thread_read`` as MCP tools that call the
4
+ :mod:`thread_archive._api` library functions directly — no web framework, no HTTP,
5
+ no route layer. The server is library-native: it dispatches straight to the API
6
+ functions in-process.
7
+
8
+ The *tools* are read-only, but the server process also cohosts lazy catch-up
9
+ ingest (see :func:`_maybe_catch_up` and :mod:`.._watcher.lazy`): a throttled
10
+ background pass at startup and around tool calls keeps the archive current
11
+ with no daemon installed, and degrades to a no-op flock probe when the
12
+ always-on watcher owns ingest. ``THREAD_ARCHIVE_MCP_INGEST=0`` disables it.
13
+
14
+ The archive home comes from ``$THREAD_ARCHIVE_HOME`` (set by the MCP client
15
+ config), else ``~/.thread/archive``. Run with::
16
+
17
+ python -m thread_archive._mcp.server
18
+
19
+ ``import mcp`` below is the external MCP SDK (top-level absolute import); this
20
+ package is ``thread_archive._mcp`` and never shadows it.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+ import threading
28
+ import time
29
+ from typing import Optional
30
+
31
+ from mcp.server.fastmcp import FastMCP
32
+
33
+ from .. import _api as api
34
+ from .._retrieval import format_results, warm_models
35
+ from .._retrieval.format import _query_terms, _term_hit_count
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ mcp = FastMCP("thread-archive")
40
+
41
+ # ── lazy catch-up ingest ──────────────────────────────────────────────────────
42
+ # The zero-daemon freshness path: the server runs a background catch-up pass at
43
+ # startup and (throttled) around tool calls, so a bare `claude mcp add …
44
+ # archive-mcp` searches a current archive without any LaunchAgent installed.
45
+ # Cross-process safety lives in the pass itself (see _watcher.lazy): the
46
+ # ingest-owner flock makes every pass a no-op probe while the always-on watcher
47
+ # daemon — or another server's pass — owns ingest. THREAD_ARCHIVE_MCP_INGEST=0
48
+ # turns the whole behaviour off.
49
+ _INGEST_MIN_INTERVAL = 300.0 # seconds between catch-up attempts in this process
50
+ _ingest_last = 0.0 # monotonic time of the last attempt (0 = never)
51
+ _ingest_running = threading.Lock() # one in-flight catch-up per process
52
+
53
+
54
+ def _maybe_catch_up() -> None:
55
+ """Kick a background catch-up pass, throttled. Never blocks the caller and
56
+ never raises — retrieval must work identically with ingest disabled, owned
57
+ by another process, or broken."""
58
+ global _ingest_last
59
+ if os.environ.get("THREAD_ARCHIVE_MCP_INGEST", "1").strip().lower() in (
60
+ "0", "false", "no", "off",
61
+ ):
62
+ return
63
+ now = time.monotonic()
64
+ if _ingest_last and now - _ingest_last < _INGEST_MIN_INTERVAL:
65
+ return
66
+ if not _ingest_running.acquire(blocking=False):
67
+ return # a catch-up is already in flight in this process
68
+ _ingest_last = now
69
+
70
+ def _run() -> None:
71
+ try:
72
+ from .._watcher import catch_up_once
73
+
74
+ catch_up_once()
75
+ except Exception: # noqa: BLE001 — advisory; retrieval must not care
76
+ logger.exception("lazy catch-up ingest failed")
77
+ finally:
78
+ _ingest_running.release()
79
+
80
+ threading.Thread(target=_run, name="archive-lazy-ingest", daemon=True).start()
81
+
82
+ # The agent-facing default search scope: USER messages plus the thread-meta docs
83
+ # (title + stored summary) — the intentional signals of what a thread was about.
84
+ # Assistant text, tool calls/results, and thinking are opt-in (pass an explicit
85
+ # content_type), and content_type='all' clears the filter to search everything.
86
+ # Mirrors the archive backend's thread_search default.
87
+ DEFAULT_SEARCH_CONTENT_TYPES = ("user", "title", "summary")
88
+
89
+ # Where the default scope widens to when it comes up dry: assistant text is where
90
+ # conclusions/decisions live (see thread_read's mode docs), so a query the asking
91
+ # side can't answer gets one automatic retry against it. One retry, only on the
92
+ # default scope, only when no query term landed — an explicit content_type is a
93
+ # deliberate choice and is never second-guessed.
94
+ WIDENED_SEARCH_CONTENT_TYPES = DEFAULT_SEARCH_CONTENT_TYPES + ("text",)
95
+
96
+
97
+ def _default_scope_is_weak(hits: list[dict], query: str) -> bool:
98
+ """True when a default-scope result set warrants the one-shot widen to
99
+ assistant text: no hits at all, or no query term appears in the top hit
100
+ (nearest-neighbour guesses)."""
101
+ terms = _query_terms(query)
102
+ if not terms:
103
+ return False
104
+ if not hits:
105
+ return True
106
+ return _term_hit_count(hits[0].get("full_content") or hits[0].get("snippet") or "", terms) == 0
107
+
108
+
109
+ @mcp.tool()
110
+ def thread_search(
111
+ query: str,
112
+ limit: int = 10,
113
+ thread_id: Optional[int] = None,
114
+ content_type: Optional[str] = None,
115
+ exclude_content_type: Optional[str] = None,
116
+ since: Optional[str] = None,
117
+ until: Optional[str] = None,
118
+ tool_name: Optional[str] = None,
119
+ source: Optional[str] = None,
120
+ startswith: Optional[str] = None,
121
+ sort: Optional[str] = None,
122
+ output: Optional[str] = None,
123
+ context_lines: int = 2,
124
+ context_events: Optional[str] = None,
125
+ rerank: Optional[bool] = None,
126
+ ) -> str:
127
+ """Search the local conversation archive (federated: lexical FTS5 + optional
128
+ semantic vectors → fusion → rank → optional cross-encoder re-rank).
129
+
130
+ Read the match signal before trusting a result: the header carries
131
+ ``quality=strong|partial|weak|semantic`` for the top hit, and each hit shows
132
+ ``K/N`` (how many query terms landed) — a weak / 0-of-N result means these are
133
+ nearest-neighbour guesses and the log likely lacks it, so rephrase or switch
134
+ store rather than piling on synonyms.
135
+
136
+ By default USER messages, thread titles, and stored thread summaries are
137
+ searched — the strongest signals of what a thread was about. When that scope
138
+ comes up dry (no query term in the top hit), the search retries once with
139
+ assistant text included and says so in the output. Assistant text,
140
+ tool calls/results, and thinking are otherwise opt-in: pass
141
+ ``content_type='all'`` to search everything, or a specific ``content_type``
142
+ (text/thinking/tool/tool_result/...) to target one.
143
+
144
+ Query grammar: natural language, "quoted phrases", boolean AND/OR/NOT,
145
+ pipe-OR (a|b), and code identifiers (get_session, a.b.c). Filter by
146
+ ``thread_id``, ``content_type`` (default user+title+summary; 'all' searches
147
+ everything),
148
+ ``exclude_content_type`` (comma-separated types to drop), ``tool_name``,
149
+ ``source`` (comma-separated providers, e.g. 'claude-code,cursor'), and a
150
+ ``since``/``until`` window (ISO timestamp or '7d').
151
+
152
+ ``startswith`` does a structural prefix scan (content LIKE 'prefix%'; query text
153
+ unused). ``sort='oldest'`` returns matches chronologically (find when something
154
+ was first discussed) instead of the default recency-biased ranking.
155
+ ``context_lines`` (default 2; set 0 for the raw FTS snippet) replaces each
156
+ snippet with a numbered ±N-line window around the match; ``context_events``
157
+ ('N' / 'before:after' /
158
+ 'before:after:types', e.g. '2' or '0:1:user') appends the neighbouring events.
159
+ ``output='count'`` returns a per-thread tally (no snippets); ``output='linkable'``
160
+ returns JSON of event/thread ids. ``rerank`` forces the cross-encoder head
161
+ re-rank on/off (else auto-gated to conceptual queries when the ``[embeddings]``
162
+ extra is installed).
163
+ """
164
+ _maybe_catch_up()
165
+ # Default scope is user messages only; an explicit type targets it, and
166
+ # content_type='all' clears the filter to search everything (see the constant).
167
+ if content_type == "all":
168
+ content_types = None
169
+ elif content_type:
170
+ content_types = [content_type]
171
+ else:
172
+ content_types = list(DEFAULT_SEARCH_CONTENT_TYPES)
173
+ exclude = [c.strip() for c in exclude_content_type.split(",") if c.strip()] if exclude_content_type else None
174
+ sources = [s.strip() for s in source.split(",") if s.strip()] if source else None
175
+
176
+ def _run(cts):
177
+ return api.search(
178
+ query,
179
+ limit=limit,
180
+ thread_id=thread_id,
181
+ content_types=cts,
182
+ exclude_content_types=exclude,
183
+ since=since,
184
+ until=until,
185
+ tool_name=tool_name,
186
+ source=sources,
187
+ startswith=startswith,
188
+ sort=sort,
189
+ output=output,
190
+ context_lines=context_lines,
191
+ context_events=context_events,
192
+ rerank=rerank,
193
+ )
194
+
195
+ hits = _run(content_types)
196
+
197
+ # One-shot scope widen: a default-scope search whose top hit contains no query
198
+ # term (or that found nothing) retries once with assistant text included —
199
+ # conclusions live there, and internalizing the retry saves the agent a
200
+ # round-trip the quality note would otherwise ask of it. Ranked/plain output
201
+ # only: structural shapes (browse/startswith/oldest/count/linkable) have no
202
+ # match signal to judge weakness by.
203
+ widened = False
204
+ ranked_shape = bool((query or "").strip()) and startswith is None and sort is None and output is None
205
+ if content_type is None and ranked_shape and _default_scope_is_weak(hits, query):
206
+ wide_hits = _run(list(WIDENED_SEARCH_CONTENT_TYPES))
207
+ if wide_hits and not _default_scope_is_weak(wide_hits, query):
208
+ hits, widened = wide_hits, True
209
+
210
+ rendered = format_results(hits, query, output=output)
211
+ if widened:
212
+ rendered = ("note: no keyword match in the default scope (user/title/summary) — "
213
+ "results below include assistant text\n" + rendered)
214
+ return rendered
215
+
216
+
217
+ @mcp.tool()
218
+ def thread_read(
219
+ thread_id: int | str,
220
+ limit: int = 200,
221
+ offset: int = 0,
222
+ summary: bool | str = False,
223
+ mode: Optional[str] = None,
224
+ user_only: Optional[bool] = None,
225
+ tool_results: bool = False,
226
+ max_chars: int = 0,
227
+ after_event: Optional[int] = None,
228
+ ) -> str:
229
+ """Read a thread's conversation, reconstructed from the event log.
230
+
231
+ ``thread_id`` accepts either the archive's own integer thread id OR a provider
232
+ **session uuid** (the id a tool like claude-code / cursor / codex knows the
233
+ conversation by — its ``source_id``). The uuid is resolved to the thread
234
+ automatically (newest match wins), so you can pass a session uuid straight
235
+ through without looking the integer id up first.
236
+
237
+ ``mode`` picks the view: 'user' (default) = only the USER messages — the real
238
+ signal of what a thread was about and what was wanted, far cheaper than the
239
+ transcript (for research / 'what was this thread about' that IS what you want);
240
+ 'chat' = the readable conversation — user turns + the assistant's reasoning/text
241
+ with tool calls stripped out (use when you need what was *decided/concluded/built*,
242
+ which lives in assistant text); 'full' = the whole transcript including every
243
+ tool call (bulky, mostly tool noise — only when you need what the assistant *did*).
244
+ Tool *output* is off by default; set ``tool_results=true`` (only meaningful with
245
+ 'full', where the calls are shown) to fold each tool's result under its call.
246
+
247
+ The read is size-budgeted (~48k chars), so it never silently overflows the MCP
248
+ output cap: a thread bigger than one chunk ends in a CHUNKED footer naming the
249
+ exact offset to read next (that's pagination, not lost data — page with
250
+ ``offset``, or resume from an event with ``after_event``). ``summary`` picks a
251
+ summary view instead of the transcript: ``true``/``'toc'`` = a compact per-message
252
+ TOC; ``'short'`` = the thread's stored short summary (a few sentences);
253
+ ``'indexed'`` = the stored indexed summary (structured, with event anchors) —
254
+ the stored kinds exist only where the summarizer has covered the thread.
255
+ ``user_only`` is a back-compat alias for ``mode`` (true→user, false→full);
256
+ prefer ``mode``, which wins if both are set.
257
+
258
+ Args:
259
+ thread_id: Integer thread id, or a provider session uuid (source_id) which
260
+ is resolved to the thread automatically.
261
+ limit: Max turns per chunk (safety cap; the char budget usually bites first).
262
+ Default: 200.
263
+ offset: Skip first N turns. Use the offset from a CHUNKED footer to read the
264
+ next chunk. Negative counts from end: -20 = last 20 turns. Default: 0.
265
+ summary: Summary view instead of full content — true/'toc' for a compact
266
+ TOC with previews, 'short' or 'indexed' for the stored thread summary.
267
+ mode: View — 'user' (default), 'chat', or 'full'. Default: user.
268
+ user_only: Back-compat alias for mode (true→user, false→full). Prefer mode.
269
+ tool_results: Include tool output under each call (default off; needs 'full').
270
+ max_chars: Per-chunk character budget; the read stops at a clean turn
271
+ boundary once hit and the footer points at the next offset. Default: ~48k.
272
+ after_event: Resume reading from the turn AFTER this event id (overrides
273
+ offset). Robust way to continue from where a previous read stopped.
274
+ """
275
+ _maybe_catch_up()
276
+ return api.read_thread(
277
+ thread_id,
278
+ limit=limit,
279
+ offset=offset,
280
+ summary=summary,
281
+ mode=mode,
282
+ user_only=user_only,
283
+ tool_results=tool_results,
284
+ max_chars=max_chars,
285
+ after_event=after_event,
286
+ )
287
+
288
+
289
+ def main() -> None:
290
+ # Warm the embedding + cross-encoder models on a background daemon thread. The cold load
291
+ # is tens of seconds; when it lands inside the first conceptual search it can exceed the
292
+ # client's MCP request timeout (cloth defaults to 60s), which surfaces to the model as a
293
+ # failed tool call. Warming at startup moves that cost off the request path — the models
294
+ # are (usually) resident by the time the first query arrives, and _load()'s lock makes an
295
+ # early query that races the warm wait on one load rather than kick off a second. Daemon
296
+ # so it never holds up interpreter exit; warm_models is fail-soft (a missing extra / load
297
+ # failure just restores the old lazy behaviour).
298
+ threading.Thread(target=warm_models, name="archive-warm-models", daemon=True).start()
299
+ # Startup catch-up: whatever landed in the local stores since the last
300
+ # ingest (by any process) is searchable by the time the first query
301
+ # arrives — or shortly after; the pass is additive, never blocking.
302
+ _maybe_catch_up()
303
+ mcp.run()
304
+
305
+
306
+ if __name__ == "__main__":
307
+ main()