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