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,184 @@
1
+ """Searchable-content extraction: an event's payload → FTS shadow rows.
2
+
3
+ Reads the payload dict keys directly — the importer's ``DefaultEventBuilder``
4
+ produces exactly these payload shapes, so there's no dependency on a dataclass
5
+ event-type hierarchy.
6
+
7
+ Each extractor returns ``(content, content_type, tool_name)`` tuples; an event
8
+ with no searchable content returns ``[]``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Optional
15
+
16
+ # Event types that carry searchable content (must match the dispatch below).
17
+ #
18
+ # NB: ``api_request_completed`` is deliberately *not* indexed. thread-archive never
19
+ # live-streams — every assistant turn is *imported* via DefaultEventBuilder, which
20
+ # emits the granular text_complete / thinking_complete events alongside an
21
+ # api_request_completed summary that duplicates them. Indexing both double-counts
22
+ # the same text, so we index the granular events and drop the summary. (A
23
+ # live-streaming path would need the summary, since its stream emits text_delta
24
+ # rather than text_complete — but that path doesn't apply here.)
25
+ INDEXABLE_EVENT_TYPES = [
26
+ "user_message_sent",
27
+ "text_complete",
28
+ "thinking_complete",
29
+ "tool_use_started",
30
+ "tool_use_complete",
31
+ "tool_execution_completed",
32
+ "context_summary",
33
+ "tool_execution_error",
34
+ "thread_message_sent",
35
+ "ide_context",
36
+ "content_block",
37
+ "message",
38
+ ]
39
+
40
+
41
+ def _to_str(value) -> str:
42
+ return value if isinstance(value, str) else json.dumps(value, default=str)
43
+
44
+
45
+ def _block_search_text(block) -> str:
46
+ """Best-effort human-readable text from an arbitrary content block, skipping
47
+ binary/base64 payloads (the ``source`` blob on image/document blocks). Used to
48
+ make preserved-but-unmodeled blocks (``content_block``) searchable without
49
+ indexing megabytes of base64."""
50
+ if isinstance(block, str):
51
+ return block
52
+ if not isinstance(block, dict):
53
+ return _to_str(block)
54
+ parts: list[str] = []
55
+ for key, value in block.items():
56
+ if key in ("type", "source"): # ``source`` carries base64 image/doc data
57
+ continue
58
+ if isinstance(value, str):
59
+ if len(value) > 1000 and " " not in value[:100]:
60
+ continue # looks like an opaque/base64 blob
61
+ parts.append(value)
62
+ elif isinstance(value, (dict, list)):
63
+ parts.append(_to_str(value))
64
+ return " ".join(parts).strip()
65
+
66
+
67
+ def _strip_frontmatter(text: str) -> str:
68
+ """Strip YAML frontmatter (---\\n...\\n---) from markdown content."""
69
+ if text.startswith("---"):
70
+ end = text.find("---", 3)
71
+ if end != -1:
72
+ return text[end + 3:].lstrip()
73
+ return text
74
+
75
+
76
+ def _fts_tool_use(payload: dict) -> list[tuple[str, str, Optional[str]]]:
77
+ tool_name = payload.get("tool_name") or ""
78
+ tool_input = payload.get("input")
79
+ if tool_name.startswith("mcp__") and isinstance(tool_input, dict) and "command" in tool_input:
80
+ # Heredoc bodies span many lines; index the first line, truncated.
81
+ tool_name = tool_input["command"].split("\n", 1)[0][:200]
82
+
83
+ # Prioritize the real content keys over stringified metadata.
84
+ content_parts = [payload.get("tool_name") or ""]
85
+ if isinstance(tool_input, dict):
86
+ for key in ("content", "text", "body", "message", "query", "prompt", "description", "entry"):
87
+ val = tool_input.get(key)
88
+ if val and isinstance(val, str):
89
+ content_parts.append(_strip_frontmatter(val))
90
+ break
91
+ else:
92
+ content_parts.append(" ".join(f"{k}={v}" for k, v in tool_input.items()))
93
+ elif tool_input is not None:
94
+ content_parts.append(str(tool_input))
95
+
96
+ content = " ".join(content_parts).strip()
97
+ return [(content[:2000], "tool", tool_name or None)] if content else []
98
+
99
+
100
+ def _fts_tool_completed(payload: dict) -> list[tuple[str, str, Optional[str]]]:
101
+ output = payload.get("output")
102
+ tool_name = payload.get("tool_name")
103
+ if payload.get("is_error"):
104
+ error_text = output or payload.get("error", "")
105
+ return [(_to_str(error_text), "tool_error", tool_name)] if error_text else []
106
+ if output:
107
+ return [(_to_str(output), "tool_result", tool_name)]
108
+ return []
109
+
110
+
111
+ def extract_fts_content(event_type: str, payload: dict) -> list[tuple[str, str, Optional[str]]]:
112
+ """Extract searchable ``(content, content_type, tool_name)`` tuples from an event."""
113
+ if not payload:
114
+ return []
115
+
116
+ if event_type == "user_message_sent":
117
+ content = payload.get("content", "")
118
+ if not content:
119
+ return []
120
+ if content.lstrip().startswith("This session is being continued from a previous conversation"):
121
+ return [(content, "continuation_summary", None)]
122
+ return [(content, "user", None)]
123
+
124
+ if event_type == "api_request_completed":
125
+ results: list[tuple[str, str, Optional[str]]] = []
126
+ for block in payload.get("content_blocks", []) or []:
127
+ bt = block.get("type")
128
+ if bt == "thinking" and block.get("thinking"):
129
+ results.append((block["thinking"], "thinking", None))
130
+ elif bt == "text" and block.get("text"):
131
+ results.append((block["text"], "text", None))
132
+ return results
133
+
134
+ if event_type == "text_complete":
135
+ text = payload.get("text", "")
136
+ return [(text, "text", None)] if text else []
137
+
138
+ if event_type == "thinking_complete":
139
+ text = payload.get("text", "")
140
+ return [(text, "thinking", None)] if text else []
141
+
142
+ if event_type in ("tool_use_started", "tool_use_complete"):
143
+ return _fts_tool_use(payload)
144
+
145
+ if event_type == "tool_execution_completed":
146
+ return _fts_tool_completed(payload)
147
+
148
+ if event_type == "tool_execution_error":
149
+ error = payload.get("error", "")
150
+ return [(_to_str(error), "tool_error", payload.get("tool_name"))] if error else []
151
+
152
+ if event_type == "context_summary":
153
+ content = payload.get("content", "")
154
+ return [(content[:2000], "context_summary", None)] if content else []
155
+
156
+ if event_type == "thread_message_sent":
157
+ content = payload.get("content", "")
158
+ return [(content, "user", None)] if content else []
159
+
160
+ if event_type == "ide_context":
161
+ # Index the opened-file path / selection body so "what was I looking at"
162
+ # is searchable. file_path (when present) leads so a path query matches.
163
+ content = payload.get("content", "")
164
+ file_path = payload.get("file_path")
165
+ text = f"{file_path}\n{content}" if file_path else content
166
+ return [(text[:2000], "ide_context", None)] if text.strip() else []
167
+
168
+ if event_type == "content_block":
169
+ # An unmodeled block preserved verbatim; index its human-readable text.
170
+ text = _block_search_text(payload.get("data"))
171
+ content_type = payload.get("block_type") or "content_block"
172
+ return [(text[:2000], content_type, None)] if text.strip() else []
173
+
174
+ if event_type == "message":
175
+ # A preserved non-standard-role turn. Prefer its text; fall back to blocks.
176
+ content = payload.get("content", "")
177
+ content_type = payload.get("role") or "message"
178
+ if content.strip():
179
+ return [(content, content_type, None)]
180
+ blocks = payload.get("content_blocks") or []
181
+ text = " ".join(t for t in (_block_search_text(b) for b in blocks) if t)
182
+ return [(text[:2000], content_type, None)] if text.strip() else []
183
+
184
+ return []
@@ -0,0 +1,186 @@
1
+ """In-process nomic embeddings (sentence-transformers) — the `[embeddings]` extra.
2
+
3
+ The in-process embedding server: nomic model, ``search_query:`` /
4
+ ``search_document:`` prefixes, 768-dim ``list[float]`` out, ``None`` on failure.
5
+ Heavy (torch); gated to the extra. The base install is lexical-only:
6
+ ``is_available()`` is False and the vector arm degrades out cleanly.
7
+
8
+ Self-consistency is the contract that matters — vectors indexed with this provider
9
+ must be *queried* with it (llama.cpp-nomic ≠ torch-nomic are different spaces). The
10
+ ``space_key`` tags a vector set by model so a cached set is only reused in-space.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import logging
17
+ import os
18
+ import sys
19
+ import threading
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Cap input length before embedding. On the in-process torch path a batch of many
24
+ # 6000-char docs (≈1500 tokens each) spikes MPS memory — it hangs and crawls
25
+ # (~9 docs/s). 2048 chars keeps the gist of all but the longest code/tool dumps
26
+ # (mean doc is ~300 chars) and runs ~7× faster with no hang.
27
+ EMBEDDING_CHAR_CAP = 2048
28
+ _MODEL_NAME = os.environ.get("THREAD_ARCHIVE_EMBED_MODEL", "nomic-ai/nomic-embed-text-v1.5")
29
+
30
+ _model = None
31
+ _load_failed = False
32
+ # Serializes _load() so a background warm (mcp.server) and a concurrent first query can't
33
+ # both construct the heavy model at once (a transient double-load / memory spike).
34
+ _load_lock = threading.Lock()
35
+
36
+
37
+ def model_name() -> str:
38
+ return _MODEL_NAME
39
+
40
+
41
+ def space_key() -> str:
42
+ """Identity of the embedding space — vectors are only comparable within one."""
43
+ return "local:" + _MODEL_NAME
44
+
45
+
46
+ def _device() -> str:
47
+ """``$THREAD_ARCHIVE_EMBED_DEVICE`` if set, else the best accelerator (Apple
48
+ ``mps`` / CUDA), falling back to ``cpu``. On Apple silicon the GPU is ~20× the
49
+ CPU — the difference between an hour and a day for the corpus embed."""
50
+ dev = os.environ.get("THREAD_ARCHIVE_EMBED_DEVICE")
51
+ if dev:
52
+ return dev
53
+ try:
54
+ import torch
55
+
56
+ if torch.backends.mps.is_available():
57
+ return "mps"
58
+ if torch.cuda.is_available():
59
+ return "cuda"
60
+ except Exception:
61
+ pass
62
+ return "cpu"
63
+
64
+
65
+ def is_available() -> bool:
66
+ """True when sentence-transformers is importable (the extra is installed). Cheap —
67
+ does not load the model; a load failure degrades at call time."""
68
+ try:
69
+ if importlib.util.find_spec("sentence_transformers") is None:
70
+ return False
71
+ except ImportError:
72
+ return False
73
+ return not _load_failed
74
+
75
+
76
+ def _hub_cache_dir() -> str:
77
+ """The HF hub cache directory, resolved the way huggingface_hub does — but as a pure
78
+ filesystem path, with no `huggingface_hub` import. Called *before* we set the offline
79
+ env, so it must not pull in the hub (which freezes ``HF_HUB_OFFLINE`` at import)."""
80
+ if os.environ.get("HUGGINGFACE_HUB_CACHE"):
81
+ return os.environ["HUGGINGFACE_HUB_CACHE"]
82
+ if os.environ.get("HF_HOME"):
83
+ return os.path.join(os.environ["HF_HOME"], "hub")
84
+ return os.path.expanduser("~/.cache/huggingface/hub")
85
+
86
+
87
+ def _model_cached() -> bool:
88
+ """True when ``_MODEL_NAME`` is already in the HF hub cache (a non-empty ``snapshots/``).
89
+ Pure filesystem probe — no hub import — so it's safe to consult before going offline."""
90
+ folder = "models--" + _MODEL_NAME.replace("/", "--")
91
+ snaps = os.path.join(_hub_cache_dir(), folder, "snapshots")
92
+ try:
93
+ return os.path.isdir(snaps) and any(os.scandir(snaps))
94
+ except OSError:
95
+ return False
96
+
97
+
98
+ def _pin_offline_if_cached() -> None:
99
+ """Pin the model load to the local cache once it's downloaded, so the hot path makes
100
+ NO Hub request: no revision re-resolve, no ``trust_remote_code`` re-fetch, no
101
+ unauthenticated-HF-Hub warning, and it still works with the network down. The model is
102
+ pinned (one fixed ``_MODEL_NAME``) — a Hub round-trip on every search buys nothing.
103
+
104
+ Skipped when the model isn't cached yet (so a first install still downloads it) or when
105
+ ``THREAD_ARCHIVE_EMBED_ONLINE=1`` forces an online load (first download / deliberate
106
+ refresh). ``huggingface_hub`` freezes ``HF_HUB_OFFLINE`` into a module constant at
107
+ import, so set the env *before* it's imported and, if it already is, flip the live
108
+ constant too."""
109
+ if os.environ.get("THREAD_ARCHIVE_EMBED_ONLINE") == "1" or not _model_cached():
110
+ return
111
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
112
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
113
+ const = sys.modules.get("huggingface_hub.constants")
114
+ if const is not None:
115
+ const.HF_HUB_OFFLINE = True
116
+
117
+
118
+ def _load():
119
+ """Lazily construct the cached SentenceTransformer (nomic needs trust_remote_code).
120
+ Double-checked under ``_load_lock`` so a background warm and a first query race to a
121
+ single construction, not two concurrent heavy loads."""
122
+ global _model, _load_failed
123
+ if _model is not None:
124
+ return _model
125
+ if _load_failed:
126
+ return None
127
+ with _load_lock:
128
+ if _model is not None:
129
+ return _model
130
+ if _load_failed:
131
+ return None
132
+ try:
133
+ _pin_offline_if_cached()
134
+ from sentence_transformers import SentenceTransformer
135
+
136
+ device = _device()
137
+ _model = SentenceTransformer(_MODEL_NAME, trust_remote_code=True, device=device)
138
+ dim = getattr(_model, "get_embedding_dimension", _model.get_sentence_embedding_dimension)()
139
+ logger.info("embed: loaded %s (dim=%s, device=%s)", _MODEL_NAME, dim, device)
140
+ return _model
141
+ except Exception as e: # noqa: BLE001
142
+ _load_failed = True
143
+ logger.warning("embed: model load failed (%s) — vector arm degrades", e)
144
+ return None
145
+
146
+
147
+ def warm() -> bool:
148
+ """Eagerly load the embedding model so it isn't cold-loaded inside the first query.
149
+ Fail-soft and idempotent: returns False when the ``[embeddings]`` extra is absent or the
150
+ load fails (search then stays lexical, exactly as it does without warming)."""
151
+ if not is_available():
152
+ return False
153
+ return _load() is not None
154
+
155
+
156
+ def _cap(text: str) -> str:
157
+ return text[:EMBEDDING_CHAR_CAP]
158
+
159
+
160
+ def _encode(prefixed: list[str]):
161
+ model = _load()
162
+ if model is None:
163
+ return None
164
+ try:
165
+ # Un-normalized to match the contract — the vector store normalizes on write.
166
+ vecs = model.encode(prefixed, normalize_embeddings=False, convert_to_numpy=True)
167
+ return [v.astype("float32").tolist() for v in vecs]
168
+ except Exception as e: # noqa: BLE001
169
+ logger.warning("embed: encode failed (%s)", e)
170
+ return None
171
+
172
+
173
+ def embed_query(text: str):
174
+ """Embed a search query (nomic ``search_query:`` prefix). None on any failure."""
175
+ if not text or not text.strip():
176
+ return None
177
+ vecs = _encode([f"search_query: {_cap(text)}"])
178
+ return vecs[0] if vecs else None
179
+
180
+
181
+ def embed_documents(texts: list[str]):
182
+ """Embed indexed content (nomic ``search_document:`` prefix), in input order. None
183
+ on any failure (the caller writes nothing — never a partial/wrong-space batch)."""
184
+ if not texts:
185
+ return None
186
+ return _encode([f"search_document: {_cap(t)}" for t in texts])
@@ -0,0 +1,149 @@
1
+ """Render search hits for the CLI / MCP.
2
+
3
+ Default render is a compact text list, but it carries the **match-quality signal**:
4
+ a top-line ``quality=`` verdict (strong / partial / weak / semantic) plus a ``K/N``
5
+ per hit — how many of the N query terms actually landed — so a reader can tell a
6
+ solid keyword hit from a nearest-neighbour guess before trusting it. ``output``
7
+ switches to ``count`` (a per-thread tally over the whole match pool) or ``linkable``
8
+ (JSON of event/thread ids for batch linking)."""
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from collections import Counter
15
+
16
+ from sqlalchemy import text as sa_text
17
+
18
+ from .._store import use_session
19
+ from . import rank as _rank
20
+
21
+ # output='count' wants a true tally, so the pipeline over-fetches to this cap; a
22
+ # pool that reaches it was truncated and the tally renders as a floor ("N+").
23
+ COUNT_FETCH_CAP = 1000
24
+
25
+
26
+ def _hit_text(h: dict) -> str:
27
+ return h.get("full_content") or h.get("snippet") or ""
28
+
29
+
30
+ def _term_hit_count(content: str, terms: list[str]) -> int:
31
+ """How many of ``terms`` literally appear in ``content``. Terms ≥4 chars match
32
+ by substring; shorter terms must hit a word boundary (so 'go' doesn't match
33
+ 'good'). Each term counts at most once."""
34
+ if not terms or not content:
35
+ return 0
36
+ c = content.lower()
37
+ n = 0
38
+ for t in terms:
39
+ if len(t) >= 4:
40
+ if t in c:
41
+ n += 1
42
+ elif re.search(r"\b" + re.escape(t) + r"\b", c):
43
+ n += 1
44
+ return n
45
+
46
+
47
+ def _search_quality(top_hit_count: int, n_terms: int, did_rerank: bool):
48
+ """Verdict for the top hit → ``(quality, note)`` or None. Rerank wins (the order
49
+ is by-meaning, not keyword overlap); else zero overlap is ``weak``, ≥⌈2/3·N⌉
50
+ terms is ``strong``, in-between is ``partial``."""
51
+ if n_terms <= 0:
52
+ return None
53
+ if did_rerank:
54
+ return ("semantic", "ranked by meaning, not keyword overlap — confirm the top hit "
55
+ "actually answers the query before trusting it")
56
+ if top_hit_count == 0:
57
+ return ("weak", "no query term appears in the top hit — these are nearest-neighbour "
58
+ "guesses and the log may simply not contain this. Rephrase the concept "
59
+ "or switch data store; piling on more synonyms won't help")
60
+ strong_at = max(1, -(-2 * n_terms // 3)) # ceil(2/3 · n_terms)
61
+ if top_hit_count >= strong_at:
62
+ return ("strong", None)
63
+ return ("partial", "only some query terms matched the top hit — scan before trusting")
64
+
65
+
66
+ def _query_terms(query: str) -> list[str]:
67
+ """Ranking terms minus the pipe-OR token (which isn't a content term)."""
68
+ return [t for t in _rank.search_terms(query) if t and t != "|"]
69
+
70
+
71
+ def _format_count(hits: list[dict], query: str) -> str:
72
+ thread_counts = Counter(h["thread_id"] for h in hits)
73
+ with use_session() as s:
74
+ corpus = s.execute(
75
+ sa_text("SELECT count(*), count(DISTINCT thread_id) FROM event_search")
76
+ ).one()
77
+ capped = len(hits) >= COUNT_FETCH_CAP
78
+ total = f"{len(hits)}+ (tally capped)" if capped else str(len(hits))
79
+ lines = [
80
+ f"Total: {total} results across {len(thread_counts)} threads",
81
+ f" (corpus: {corpus[0]:,} indexed events, {corpus[1]:,} threads)",
82
+ ]
83
+ for tid, count in thread_counts.most_common():
84
+ title = next((h.get("thread_title") for h in hits if h["thread_id"] == tid), None)
85
+ lines.append(f" [{tid}] {title or '(untitled)'}: {count}")
86
+ return "\n".join(lines)
87
+
88
+
89
+ def _format_linkable(hits: list[dict]) -> str:
90
+ out = []
91
+ for h in hits:
92
+ entry = {
93
+ "event_id": h["event_id"],
94
+ "thread_id": h["thread_id"],
95
+ "preview": (h.get("snippet") or "")[:80],
96
+ }
97
+ if "context_events" in h:
98
+ entry["context_events"] = h["context_events"]
99
+ out.append(entry)
100
+ return json.dumps(out, indent=2)
101
+
102
+
103
+ def format_results(hits: list[dict], query: str, *, output: str | None = None) -> str:
104
+ if output == "count":
105
+ return _format_count(hits, query)
106
+ if output == "linkable":
107
+ return _format_linkable(hits)
108
+ if not hits:
109
+ return f'No results for "{query}".'
110
+
111
+ terms = _query_terms(query)
112
+ n_terms = len(terms)
113
+ did_rerank = bool(hits[0].get("_did_rerank"))
114
+ verdict = _search_quality(_term_hit_count(_hit_text(hits[0]), terms), n_terms, did_rerank) if n_terms else None
115
+
116
+ header = f'{len(hits)} result(s) for "{query}"'
117
+ if verdict:
118
+ header += f" · quality={verdict[0]}"
119
+ lines = [header]
120
+ if verdict and verdict[1]:
121
+ lines.append(f" note: {verdict[1]}")
122
+ lines.append("")
123
+
124
+ for h in hits:
125
+ title = h.get("thread_title") or f"thread {h['thread_id']}"
126
+ ct = h.get("content_type") or h["event_type"]
127
+ head = f"[{h['thread_id']}/{h['event_id']}] {title} · {ct}"
128
+ if n_terms:
129
+ k = _term_hit_count(_hit_text(h), terms)
130
+ head += f" · {k}/{n_terms}"
131
+ if k == 0:
132
+ head += " (semantic)"
133
+ lines.append(head)
134
+
135
+ context = h.get("context")
136
+ if context: # context_lines: a numbered multi-line block replaces the snippet
137
+ lines.extend(f" {ln}" for ln in context.split("\n"))
138
+ else:
139
+ snippet = " ".join((h.get("snippet") or "").split())
140
+ if snippet:
141
+ lines.append(f" {snippet}")
142
+
143
+ ctx_events = h.get("context_events") or {}
144
+ for direction in ("before", "after"):
145
+ for ev in ctx_events.get(direction, []):
146
+ body = " ".join((ev.get("content") or "")[:200].split())
147
+ lines.append(f" ({direction} · {ev.get('content_type')}) {body}")
148
+
149
+ return "\n".join(lines)