bookmark-research 0.6.0 → 0.7.0

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 (69) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.codex-plugin/plugin.json +4 -1
  3. package/README.md +15 -9
  4. package/README.zh.md +16 -9
  5. package/config/providers.json +26 -0
  6. package/config/settings.example.json +36 -0
  7. package/docs/bookmark-research-architecture.md +2 -2
  8. package/docs/canvas-tree-reading-acceptance.md +98 -0
  9. package/docs/configuration.en.md +70 -0
  10. package/docs/configuration.md +70 -0
  11. package/docs/details.en.md +10 -6
  12. package/docs/details.md +3 -3
  13. package/docs/harness-compatibility.md +1 -1
  14. package/docs/installation.en.md +4 -2
  15. package/docs/installation.md +4 -2
  16. package/docs/instructions.en.md +1 -0
  17. package/docs/instructions.md +1 -0
  18. package/docs/user-guide.en.md +1 -1
  19. package/docs/user-guide.md +1 -1
  20. package/hosts/dsh/plugin.js +3 -0
  21. package/package.json +1 -1
  22. package/scripts/bench_sync.py +191 -0
  23. package/scripts/bench_tools_list.py +129 -0
  24. package/scripts/evaluate_ladder.py +149 -0
  25. package/scripts/export_bundle.py +2 -0
  26. package/scripts/host_install.py +4 -1
  27. package/scripts/install.py +120 -28
  28. package/skills/bookmark-research/SKILL.md +28 -9
  29. package/skills/bookmark-research/references/package-semantics.md +56 -6
  30. package/skills/bookmark-research/references/research-workflow.md +5 -2
  31. package/skills/bookmark-research/references/settings-and-archive.md +5 -3
  32. package/skills/bookmark-research/references/wiki.md +3 -1
  33. package/skills/bookmark-research/references/zh/package-semantics.md +56 -6
  34. package/skills/bookmark-research/references/zh/research-workflow.md +5 -2
  35. package/skills/bookmark-research/references/zh/settings-and-archive.md +5 -3
  36. package/skills/bookmark-research/references/zh/skill-guide.md +20 -8
  37. package/skills/bookmark-research/references/zh/wiki.md +3 -1
  38. package/src/archive.py +100 -0
  39. package/src/bookmark_index.py +808 -41
  40. package/src/browser_client.py +342 -0
  41. package/src/cli.py +298 -14
  42. package/src/credentials.py +2 -1
  43. package/src/fetch_cache.py +60 -0
  44. package/src/grok_search.py +272 -0
  45. package/src/jobs.py +349 -0
  46. package/src/junk.py +33 -0
  47. package/src/locate.py +862 -0
  48. package/src/mcp_dispatch.py +22 -6
  49. package/src/mcp_server.py +573 -23
  50. package/src/netscape.py +124 -0
  51. package/src/onboarding.py +59 -9
  52. package/src/perplexity.py +78 -0
  53. package/src/raw_library.py +183 -45
  54. package/src/readiness.py +40 -5
  55. package/src/ref_codec.py +70 -0
  56. package/src/research.py +125 -12
  57. package/src/research_plan.py +11 -1
  58. package/src/routing.py +5 -2
  59. package/src/search_results.py +80 -2
  60. package/src/settings.py +61 -16
  61. package/src/source_inputs.py +109 -3
  62. package/src/source_manager.py +71 -12
  63. package/src/source_versions.py +20 -2
  64. package/src/source_watcher.py +4 -2
  65. package/src/storage.py +60 -0
  66. package/src/url_classify.py +45 -0
  67. package/src/web_search.py +106 -10
  68. package/src/wiki.py +269 -18
  69. package/src/wiki_search.py +119 -27
@@ -0,0 +1,124 @@
1
+ """Read the canvas card's HTML export (Netscape bookmark format).
2
+
3
+ The canvas right-click export writes ``<!DOCTYPE NETSCAPE-Bookmark-file-1>`` with
4
+ ``<DT><H3>`` folders and ``<DT><A HREF>`` bookmarks, so the same reader also
5
+ accepts a Chrome bookmark export. Anything else falls back to plain ``<a href>``
6
+ links, mirroring the canvas app's own import.
7
+ """
8
+ import json
9
+ from html.parser import HTMLParser
10
+
11
+ MAX_ITEMS = 20000
12
+
13
+
14
+ class _NetscapeParser(HTMLParser):
15
+ def __init__(self):
16
+ super().__init__(convert_charrefs=True)
17
+ self.stack = [[]]
18
+ self.pending_folder = None
19
+ self.mode = None
20
+ self.buffer = []
21
+ self.href = ""
22
+ self.pending_title = ""
23
+ self.count = 0
24
+
25
+ def handle_starttag(self, tag, attrs):
26
+ tag = tag.lower()
27
+ if tag == "dl":
28
+ if self.pending_folder is not None:
29
+ self.stack.append(self.pending_folder["children"])
30
+ self.pending_folder = None
31
+ else:
32
+ self.stack.append(self.stack[-1])
33
+ elif tag == "h3":
34
+ self.mode, self.buffer = "h3", []
35
+ elif tag == "a":
36
+ self.mode, self.buffer = "a", []
37
+ values = dict(attrs)
38
+ self.href = (values.get("href") or "").strip()
39
+ elif tag == "title" and not self.pending_title:
40
+ self.mode, self.buffer = "title", []
41
+
42
+ def handle_endtag(self, tag):
43
+ tag = tag.lower()
44
+ if tag == "dl":
45
+ if len(self.stack) > 1:
46
+ self.stack.pop()
47
+ elif tag == "h3" and self.mode == "h3":
48
+ title = "".join(self.buffer).strip()
49
+ folder = {"title": title or "Folder", "url": "", "type": "folder", "children": []}
50
+ self.stack[-1].append(folder)
51
+ self.pending_folder = folder
52
+ self.mode, self.buffer = None, []
53
+ elif tag == "a" and self.mode == "a":
54
+ title = "".join(self.buffer).strip()
55
+ if self.href:
56
+ self.stack[-1].append({"title": title or self.href, "url": self.href,
57
+ "type": "bookmark", "children": []})
58
+ self.count += 1
59
+ if self.count > MAX_ITEMS:
60
+ raise ValueError("HTML export has too many bookmarks")
61
+ self.mode, self.buffer, self.href = None, [], ""
62
+ elif tag == "title" and self.mode == "title":
63
+ self.pending_title = "".join(self.buffer).strip()
64
+ self.mode, self.buffer = None, []
65
+
66
+ def handle_data(self, data):
67
+ if self.mode is not None:
68
+ self.buffer.append(data)
69
+
70
+
71
+ def section_from_html(text, title=""):
72
+ """Parse a Netscape bookmark export into a temporary section payload."""
73
+ parser = _NetscapeParser()
74
+ parser.feed(text)
75
+ parser.close()
76
+ items = parser.stack[0]
77
+ if not items:
78
+ # Some exports are plain HTML; keep every anchor as a flat bookmark.
79
+ fallback = _links_only(text)
80
+ items = fallback
81
+ if not items:
82
+ raise ValueError("No bookmark links found in this HTML export")
83
+ return {"format": "bookmark-canvas-section", "schemaVersion": 2,
84
+ "sectionType": "temporary", "title": title or "Imported bookmarks",
85
+ "label": "导入", "tempKind": "special", "source": "import-html",
86
+ "items": items}
87
+
88
+
89
+ class _LinkParser(HTMLParser):
90
+ def __init__(self):
91
+ super().__init__(convert_charrefs=True)
92
+ self.items = []
93
+ self.href = None
94
+ self.buffer = []
95
+
96
+ def handle_starttag(self, tag, attrs):
97
+ if tag.lower() == "a":
98
+ values = dict(attrs)
99
+ href = (values.get("href") or "").strip()
100
+ if href.startswith(("http://", "https://")):
101
+ self.href, self.buffer = href, []
102
+
103
+ def handle_endtag(self, tag):
104
+ if tag.lower() == "a" and self.href:
105
+ title = "".join(self.buffer).strip() or self.href
106
+ self.items.append({"title": title, "url": self.href, "type": "bookmark",
107
+ "children": []})
108
+ self.href, self.buffer = None, []
109
+
110
+ def handle_data(self, data):
111
+ if self.href is not None:
112
+ self.buffer.append(data)
113
+
114
+
115
+ def _links_only(text):
116
+ parser = _LinkParser()
117
+ parser.feed(text)
118
+ parser.close()
119
+ return parser.items
120
+
121
+
122
+ def encode(section):
123
+ return json.dumps(section, ensure_ascii=False, allow_nan=False,
124
+ separators=(",", ":")).encode("utf-8")
package/src/onboarding.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from contextlib import contextmanager
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import shlex
7
8
  import shutil
8
9
  import sys
@@ -351,13 +352,13 @@ class Onboarding:
351
352
  hints = {"exa": ("Exa search and reading", "Exa 搜索与阅读"), "parallel": ("Parallel search and extract", "Parallel 搜索与提取"),
352
353
  "tavily": ("Tavily search and extract", "Tavily 搜索与提取"),
353
354
  "jina": ("Jina Reader; search needs a key", "Jina Reader;搜索需要密钥"),
354
- "deepseek": ("DeepSeek Search / Fetch; needs a key", "DeepSeek 搜索/读取;需要密钥")}
355
- # One ordered list answers the former search, fallback-search and reader
356
- # questions: selected providers run concurrently and also read pages,
357
- # unselected providers stay as the fallback search tier.
355
+ "deepseek": ("DeepSeek Search / Fetch; needs a key", "DeepSeek 搜索/读取;需要密钥"),
356
+ "perplexity": ("Perplexity structured Search; needs a key; page reading uses another provider", "Perplexity 结构化搜索;需要密钥;网页读取需使用其他服务")}
357
+ # The selected sources are the concurrent primary tier; the remaining
358
+ # providers form the fallback tier. Search-only providers are labeled.
358
359
  options = [Option(name, name, name, *hints.get(name, ("", None))) for name in Settings.PROVIDERS]
359
- primary = console.multiselect("Search and page-reading services (concurrent, in this order)",
360
- "搜索与正文读取服务(并发执行,按此顺序)", options, current["search"]["providers"])
360
+ primary = console.multiselect("Search sources (concurrent, in this order)",
361
+ "搜索来源(并发执行,按此顺序)", options, current["search"]["providers"])
361
362
  providers = list(primary)
362
363
  fallbacks = [name for name in Settings.PROVIDERS if name not in providers]
363
364
  storage = self._storage(current)
@@ -382,12 +383,59 @@ class Onboarding:
382
383
  elif service == "parallel":
383
384
  console.say("Parallel processor remains " + config["parallel"]["processor"] + "; change it with config set if needed.",
384
385
  "Parallel processor 沿用 " + config["parallel"]["processor"] + ";可通过 config set 修改。")
386
+ custom = current["custom_agentic"]
387
+ custom_choice = console.select("Custom OpenAI-compatible model endpoint (optional)",
388
+ "自定义 OpenAI 兼容模型端点(可选)", [
389
+ Option("disabled", "Disabled", "关闭", "keep custom model search unavailable", "不启用自定义模型搜索"),
390
+ Option("enabled", "Configure endpoint", "配置端点",
391
+ "separate answer channel; not merged into URL search results",
392
+ "独立回答通道;不并入 URL 搜索结果")],
393
+ "enabled" if custom["enabled"] else "disabled")
394
+ if custom_choice == "enabled":
395
+ from grok_search import normalize_compatible_base_url
396
+
397
+ def check_base_url(value):
398
+ try:
399
+ normalize_compatible_base_url(value)
400
+ except ValueError as error:
401
+ return (str(error), "请输入有效的 HTTP(S) Base URL,不要包含密钥、查询参数或片段。")
402
+ return None
403
+
404
+ base_url = normalize_compatible_base_url(console.input(
405
+ "OpenAI-compatible Base URL (for example https://gateway.example/v1)",
406
+ "OpenAI 兼容 Base URL(例如 https://gateway.example/v1)",
407
+ custom["base_url"], check_base_url))
408
+
409
+ def check_model(value):
410
+ return None if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/+-]{0,191}", value) else (
411
+ "Use a model ID with letters, numbers, '.', '_', ':', '/', '+' or '-'.",
412
+ "模型 ID 仅可包含字母、数字及 . _ : / + -。")
413
+
414
+ model = console.input("Model ID", "模型 ID", custom["model"], check_model)
415
+ api_mode = console.select("API protocol", "API 协议", [
416
+ Option("chat_completions", "Chat Completions", "Chat Completions"),
417
+ Option("responses", "Responses", "Responses")], custom["api_mode"])
418
+ search_options = [Option("prompt", "Ask the upstream model to use its own web search",
419
+ "提示上游模型使用其自身联网搜索",
420
+ "the endpoint must provide this capability",
421
+ "端点必须自己具备联网能力")]
422
+ if api_mode == "responses":
423
+ search_options.append(Option("native", "Request the Responses web_search tool",
424
+ "请求 Responses web_search 工具",
425
+ "the upstream must implement this tool",
426
+ "上游必须支持此工具"))
427
+ search_mode = console.select("Search capability", "搜索能力", search_options,
428
+ custom["search_mode"] if any(row.value == custom["search_mode"] for row in search_options) else "prompt")
429
+ custom_agentic = {"enabled": True, "base_url": base_url, "model": model,
430
+ "api_mode": api_mode, "search_mode": search_mode}
431
+ else:
432
+ custom_agentic = {"enabled": False}
385
433
  # Only changed values are returned, so an unchanged answer keeps the saved
386
434
  # Wiki follow-up, readiness policy and archive toggle exactly as they were.
387
435
  result = {"research": {"depth": depth, "response_language": language},
388
436
  "search": {"providers": providers, "fallback_providers": fallbacks},
389
437
  "fetch": {"provider": providers[0], "providers": providers},
390
- "professional_research": services}
438
+ "professional_research": services, "custom_agentic": custom_agentic}
391
439
  result.update(storage)
392
440
  return result
393
441
 
@@ -432,14 +480,16 @@ class Onboarding:
432
480
  console.note("API keys", "API 密钥", [
433
481
  ("Retrieval adapters are included; anonymous search/reading works where the provider permits it.",
434
482
  "检索适配器已包含在插件中;服务方允许时可使用匿名搜索和阅读。"),
483
+ ("Select keys for services enabled above; use Space to select, Enter to continue. You can skip this step.",
484
+ "选择上面启用的服务所需的密钥;空格勾选、回车继续,也可以跳过。"),
435
485
  ("Host MCP OAuth login stays in the host. An OpenAI API key is separate from a ChatGPT/Codex login.",
436
486
  "宿主 MCP 的 OAuth 登录由宿主管理;OpenAI API Key 与 ChatGPT/Codex 登录分开。"),
437
487
  ("Keys are hidden and saved in a local, unencrypted file readable only by your user: " + str(self.credentials.path),
438
488
  "密钥隐藏输入,并保存为仅当前用户可读的本地明文文件:" + str(self.credentials.path)),
439
489
  ("Environment variables override saved keys. Select nothing to configure credentials later.",
440
490
  "环境变量优先于保存的密钥;不选择即可稍后配置。"),
441
- ("Guidance: exa.ai/docs/reference/exa-mcp · docs.parallel.ai · docs.tavily.com · jina.ai/reader · platform.openai.com/api-keys",
442
- "说明:exa.ai/docs/reference/exa-mcp · docs.parallel.ai · docs.tavily.com · jina.ai/reader · platform.openai.com/api-keys")])
491
+ ("Key names and storage: https://github.com/Browser-bookmark-hub/Bookmark-Research/blob/main/docs/configuration.en.md",
492
+ "Key 名称与保存位置说明:https://github.com/Browser-bookmark-hub/Bookmark-Research/blob/main/docs/configuration.md")])
443
493
  status = {"environment": ("set in environment (overrides saved key)", "环境变量已设置(优先于保存的密钥)"),
444
494
  "private_file": ("configured, not yet verified", "已配置,尚未验证"), None: ("missing", "未配置")}
445
495
  options = [Option(row["name"], row["name"].split("_")[0].lower(), None, *status[row["source"] if row["configured"] else None], hint_always=True)
@@ -0,0 +1,78 @@
1
+ """Perplexity Search API client; returns structured sources, not a model answer."""
2
+
3
+ import http.client
4
+ import json
5
+ import threading
6
+ import urllib.error
7
+ import urllib.request
8
+
9
+ from release import user_agent
10
+ from remote_mcp import McpError, McpHttpClient
11
+ from service_http import ResearchHttp, _NoRedirect
12
+
13
+
14
+ class PerplexityClient:
15
+ SEARCH_URL = "https://api.perplexity.ai/search"
16
+
17
+ def __init__(self, headers=None, timeout=30, search_url=None):
18
+ self.headers = dict(headers or {})
19
+ self.timeout = timeout
20
+ self.search_url = search_url or self.SEARCH_URL
21
+ self._lock = threading.Lock()
22
+ self._requests = 0
23
+
24
+ def snapshot_usage(self):
25
+ with self._lock:
26
+ return {"http_requests": self._requests, "tool_calls": self._requests}
27
+
28
+ def _request(self, query, limit):
29
+ payload = json.dumps({"query": query, "max_results": max(1, min(int(limit), 20))},
30
+ ensure_ascii=False, allow_nan=False).encode("utf-8")
31
+ request = urllib.request.Request(self.search_url, data=payload, method="POST", headers={
32
+ "Accept": "application/json", "Content-Type": "application/json",
33
+ "User-Agent": user_agent(), **self.headers})
34
+ with self._lock:
35
+ self._requests += 1
36
+ try:
37
+ with urllib.request.build_opener(_NoRedirect()).open(request, timeout=self.timeout) as response:
38
+ body = response.read(McpHttpClient.MAX_BYTES + 1)
39
+ if len(body) > McpHttpClient.MAX_BYTES:
40
+ raise McpError("Perplexity response exceeds size limit", "response_too_large")
41
+ value = json.loads(body, object_pairs_hook=ResearchHttp._unique_object,
42
+ parse_constant=ResearchHttp._invalid_constant,
43
+ parse_float=ResearchHttp._finite_float)
44
+ if not isinstance(value, dict) or not isinstance(value.get("results"), list):
45
+ raise McpError("Perplexity returned invalid search results", "result_format")
46
+ rows = []
47
+ for item in value["results"]:
48
+ if not isinstance(item, dict) or not isinstance(item.get("url"), str):
49
+ continue
50
+ snippet = item.get("snippet")
51
+ rows.append({"url": item["url"], "title": item.get("title") or item["url"],
52
+ "snippet": snippet if isinstance(snippet, str) else "",
53
+ "date": item.get("date"), "last_updated": item.get("last_updated")})
54
+ return {"structuredContent": {"results": rows}}
55
+ except urllib.error.HTTPError as error:
56
+ status = error.code
57
+ kind = {400: "invalid_request", 401: "authentication_required", 403: "access_denied",
58
+ 408: "timeout", 429: "rate_limited"}.get(status, "http_error")
59
+ retry = McpHttpClient._retry_after((error.headers or {}).get("Retry-After"))
60
+ try:
61
+ error.close()
62
+ except OSError:
63
+ pass
64
+ raise McpError("Perplexity Search HTTP " + str(status), kind,
65
+ status == 429 or status >= 500, http_status=status, retry_after=retry) from None
66
+ except TimeoutError:
67
+ raise McpError("Perplexity Search timed out", "timeout", True) from None
68
+ except (urllib.error.URLError, OSError, http.client.HTTPException):
69
+ raise McpError("Perplexity Search connection failed", "connection_error", True) from None
70
+ except (ValueError, UnicodeError):
71
+ raise McpError("Perplexity returned invalid JSON", "result_format") from None
72
+
73
+ def call(self, purpose, query=None, urls=None, limit=5):
74
+ if purpose != "search":
75
+ raise McpError("Perplexity Search does not provide URL extraction", "capability_unavailable")
76
+ if not self.headers.get("Authorization"):
77
+ raise McpError("Perplexity Search requires PERPLEXITY_API_KEY", "authentication_required")
78
+ return self._request(query, limit)
@@ -22,9 +22,12 @@ import re
22
22
  import sqlite3
23
23
  from pathlib import Path
24
24
 
25
+ import storage
25
26
  from settings import Settings
26
27
 
27
28
  SCHEMA_VERSION = 1
29
+ _EMPTY_REPORT = {"missing_tasks": 0, "scanned_units": 0, "removed_units": 0,
30
+ "new_documents": 0, "errors": []}
28
31
  MAX_BODY_BYTES = 50 * 1024 * 1024
29
32
  MAX_TERMS = 16
30
33
  TRIGRAM_MINIMUM = 3
@@ -80,7 +83,19 @@ class RawLibrary:
80
83
 
81
84
  # Storage -----------------------------------------------------------------
82
85
 
83
- def _connect(self):
86
+ def _connect(self, readonly=False):
87
+ if readonly:
88
+ if not self.db_path.is_file():
89
+ raise sqlite3.OperationalError("Raw library index is not built yet: %s" % self.db_path)
90
+ connection = storage.connect(self.db_path, readonly=True, timeout=30)
91
+ connection.row_factory = sqlite3.Row
92
+ try:
93
+ stored = dict(connection.execute("SELECT key, value FROM meta").fetchall())
94
+ except sqlite3.DatabaseError:
95
+ stored = {}
96
+ if stored.get("mode") in ("trigram", "substring"):
97
+ self.mode = stored["mode"]
98
+ return storage.mark_query_only(connection)
84
99
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
85
100
  for attempt in (0, 1):
86
101
  connection = sqlite3.connect(str(self.db_path), timeout=30, isolation_level=None)
@@ -88,9 +103,9 @@ class RawLibrary:
88
103
  connection.execute("PRAGMA journal_mode=WAL")
89
104
  self._schema(connection)
90
105
  return connection
91
- except sqlite3.DatabaseError:
106
+ except sqlite3.DatabaseError as error:
92
107
  connection.close()
93
- if attempt:
108
+ if attempt or storage.is_readonly_failure(error):
94
109
  raise
95
110
  # A derived cache: a damaged file is discarded and rebuilt.
96
111
  for suffix in ("", "-wal", "-shm"):
@@ -98,6 +113,16 @@ class RawLibrary:
98
113
  if candidate.exists():
99
114
  candidate.unlink()
100
115
 
116
+ def _open_for_search(self):
117
+ """Prefer the writable index (it self-heals); a read-only one stays searchable."""
118
+ try:
119
+ connection = self._connect()
120
+ except (sqlite3.DatabaseError, OSError) as error:
121
+ if not storage.is_readonly_failure(error) or not self.db_path.is_file():
122
+ raise
123
+ return self._connect(readonly=True), False
124
+ return connection, True
125
+
101
126
  def _schema(self, connection):
102
127
  connection.execute("CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)")
103
128
  stored = dict(connection.execute("SELECT key, value FROM meta").fetchall())
@@ -343,15 +368,17 @@ class RawLibrary:
343
368
  text = re.sub(r"\s+", " ", text).strip()
344
369
  return ("…" if start > 0 else "") + text + ("…" if start + width < len(body) else "")
345
370
 
346
- def search(self, query, limit=10, offset=0, url=None, occurrence_limit=50, occurrence_offset=0):
371
+ def search(self, query, limit=10, offset=0, url=None, occurrence_limit=50, occurrence_offset=0, expand_versions=False):
347
372
  """Literal search over saved page text; every term must occur (quoted query = one phrase).
348
373
 
349
- Identical bodies are merged by SHA-256, so one result can have many
350
- occurrences. Occurrences are paged with ``occurrence_limit`` /
351
- ``occurrence_offset``: every result reports the true ``occurrence_count``,
352
- whether the page is complete, and the offset that continues it.
374
+ By default, results are grouped by URL, reporting version counts,
375
+ time spans, and all captured versions. When expand_versions=True,
376
+ bodies are merged by SHA-256. Occurrences are paged with
377
+ occurrence_limit / occurrence_offset.
353
378
  """
354
379
  terms = self._terms(query)
380
+ if type(expand_versions) is not bool:
381
+ raise ValueError("expand_versions must be a boolean")
355
382
  for value, label, minimum, maximum in ((limit, "limit", 1, 50), (offset, "offset", 0, 10000000),
356
383
  (occurrence_limit, "occurrence_limit", 1, 1000),
357
384
  (occurrence_offset, "occurrence_offset", 0, 10000000)):
@@ -359,9 +386,20 @@ class RawLibrary:
359
386
  raise ValueError("%s must be between %s and %s" % (label, minimum, maximum))
360
387
  if url is not None and (not isinstance(url, str) or not url or len(url) > 2048 or "\x00" in url):
361
388
  raise ValueError("url filter must be 1 to 2048 characters")
362
- connection = self._connect()
389
+ connection, writable = self._open_for_search()
363
390
  try:
364
- report = self._refresh(connection)
391
+ if writable:
392
+ try:
393
+ report = self._refresh(connection)
394
+ except sqlite3.DatabaseError as error:
395
+ if not storage.is_readonly_failure(error):
396
+ raise
397
+ # Querying a shared index on a read-only mount: nothing was written.
398
+ connection.close()
399
+ connection, writable = self._connect(readonly=True), False
400
+ report = _EMPTY_REPORT
401
+ else:
402
+ report = _EMPTY_REPORT
365
403
  indexed = self._statistics(connection, report)
366
404
  fts = self.mode == "trigram" and all(len(term) >= TRIGRAM_MINIMUM for term in terms)
367
405
  clauses, parameters = [], []
@@ -376,49 +414,149 @@ class RawLibrary:
376
414
  clauses.append("d.sha256 IN (SELECT sha256 FROM occurrences WHERE instr(lower(url), lower(?)) > 0)")
377
415
  parameters.append(url)
378
416
  base = ("FROM page_text JOIN documents d ON d.id = page_text.rowid WHERE " + " AND ".join(clauses))
379
- total = connection.execute("SELECT COUNT(*) " + base, parameters).fetchone()[0]
380
417
  order = "bm25(page_text), d.id DESC" if fts else "d.id DESC"
381
- rows = connection.execute("SELECT d.sha256, d.characters, page_text.body " + base +
382
- " ORDER BY " + order + " LIMIT ? OFFSET ?",
383
- parameters + [limit, offset]).fetchall()
384
- results = []
385
- for sha256, characters, body in rows:
386
- count = connection.execute("SELECT COUNT(*) FROM occurrences WHERE sha256 = ?",
387
- (sha256,)).fetchone()[0]
388
- occurrences = []
389
- for row in connection.execute(
390
- "SELECT kind, capture_id, research_id, source_id, path, url, title, retrieved_at "
391
- "FROM occurrences WHERE sha256 = ? "
392
- "ORDER BY (retrieved_at IS NULL), retrieved_at DESC, unit, position "
393
- "LIMIT ? OFFSET ?", (sha256, occurrence_limit, occurrence_offset)):
394
- kind, capture_id, research_id, source_id, path, found_url, title, retrieved_at = row
395
- entry = {"kind": kind}
396
- if kind == "archive":
397
- entry["capture_id"] = capture_id
398
- else:
399
- entry.update(research_id=research_id, source_id=source_id)
400
- entry.update(path=path, url=found_url, title=title, retrieved_at=retrieved_at)
401
- occurrences.append(entry)
402
- shown = occurrence_offset + len(occurrences)
403
- results.append({"sha256": sha256, "characters": characters,
404
- "url": next((entry["url"] for entry in occurrences if entry["url"]), None),
405
- "title": next((entry["title"] for entry in occurrences if entry["title"]), None),
406
- "snippet": self._snippet(body, terms),
407
- "occurrence_count": count,
408
- "occurrence_offset": occurrence_offset,
409
- "occurrences_truncated": shown < count,
410
- "occurrences_next_offset": shown if shown < count else None,
411
- "occurrences": occurrences})
418
+
419
+ if expand_versions:
420
+ total = connection.execute("SELECT COUNT(*) " + base, parameters).fetchone()[0]
421
+ rows = connection.execute("SELECT d.sha256, d.characters, page_text.body " + base +
422
+ " ORDER BY " + order + " LIMIT ? OFFSET ?",
423
+ parameters + [limit, offset]).fetchall()
424
+ results = []
425
+ for sha256, characters, body in rows:
426
+ count = connection.execute("SELECT COUNT(*) FROM occurrences WHERE sha256 = ?",
427
+ (sha256,)).fetchone()[0]
428
+ occurrences = []
429
+ for row in connection.execute(
430
+ "SELECT kind, capture_id, research_id, source_id, path, url, title, retrieved_at "
431
+ "FROM occurrences WHERE sha256 = ? "
432
+ "ORDER BY (retrieved_at IS NULL), retrieved_at DESC, unit, position "
433
+ "LIMIT ? OFFSET ?", (sha256, occurrence_limit, occurrence_offset)):
434
+ kind, capture_id, research_id, source_id, path, found_url, title, retrieved_at = row
435
+ entry = {"kind": kind}
436
+ if kind == "archive":
437
+ entry["capture_id"] = capture_id
438
+ else:
439
+ entry.update(research_id=research_id, source_id=source_id)
440
+ entry.update(path=path, url=found_url, title=title, retrieved_at=retrieved_at)
441
+ occurrences.append(entry)
442
+ shown = occurrence_offset + len(occurrences)
443
+ results.append({"sha256": sha256, "characters": characters,
444
+ "url": next((entry["url"] for entry in occurrences if entry["url"]), None),
445
+ "title": next((entry["title"] for entry in occurrences if entry["title"]), None),
446
+ "snippet": self._snippet(body, terms),
447
+ "occurrence_count": count,
448
+ "occurrence_offset": occurrence_offset,
449
+ "occurrences_truncated": shown < count,
450
+ "occurrences_next_offset": shown if shown < count else None,
451
+ "occurrences": occurrences})
452
+ else:
453
+ doc_rows = connection.execute("SELECT d.sha256, d.characters " + base +
454
+ " ORDER BY " + order, parameters).fetchall()
455
+ shas = [row[0] for row in doc_rows]
456
+ sha_chars = {row[0]: row[1] for row in doc_rows}
457
+
458
+ occ_map = {}
459
+ if shas:
460
+ for i in range(0, len(shas), 900):
461
+ chunk = shas[i : i + 900]
462
+ placeholders = ",".join("?" for _ in chunk)
463
+ q = (
464
+ "SELECT sha256, kind, capture_id, research_id, source_id, path, url, title, retrieved_at "
465
+ "FROM occurrences WHERE sha256 IN (" + placeholders + ") "
466
+ "ORDER BY (retrieved_at IS NULL), retrieved_at DESC, unit, position"
467
+ )
468
+ for r in connection.execute(q, chunk):
469
+ occ_map.setdefault(r[0], []).append(r[1:])
470
+
471
+ groups = {}
472
+ for sha256 in shas:
473
+ characters = sha_chars[sha256]
474
+ occ_rows = occ_map.get(sha256, [])
475
+ found_url = next((r[5] for r in occ_rows if r[5]), None)
476
+ grp_key = found_url if found_url else sha256
477
+ if grp_key not in groups:
478
+ groups[grp_key] = {
479
+ "url": found_url,
480
+ "versions": [],
481
+ "all_occurrences": [],
482
+ }
483
+ v_title = next((r[6] for r in occ_rows if r[6]), None)
484
+ v_retrieved = next((r[7] for r in occ_rows if r[7]), None)
485
+ groups[grp_key]["versions"].append({
486
+ "sha256": sha256,
487
+ "characters": characters,
488
+ "title": v_title,
489
+ "retrieved_at": v_retrieved,
490
+ "occurrence_count": len(occ_rows),
491
+ })
492
+ for r in occ_rows:
493
+ k, cap_id, res_id, src_id, p, u, t, ret_at = r
494
+ entry = {"kind": k}
495
+ if k == "archive":
496
+ entry["capture_id"] = cap_id
497
+ else:
498
+ entry.update(research_id=res_id, source_id=src_id)
499
+ entry.update(path=p, url=u, title=t, retrieved_at=ret_at)
500
+ groups[grp_key]["all_occurrences"].append(entry)
501
+
502
+ total = len(groups)
503
+ paged_keys = list(groups.keys())[offset : offset + limit]
504
+
505
+ needed_shas = {v["sha256"] for k in paged_keys for v in groups[k]["versions"]}
506
+ body_map = {}
507
+ if needed_shas:
508
+ placeholders = ",".join("?" for _ in needed_shas)
509
+ bq = (
510
+ "SELECT d.sha256, page_text.body FROM page_text "
511
+ "JOIN documents d ON d.id = page_text.rowid "
512
+ "WHERE d.sha256 IN (" + placeholders + ")"
513
+ )
514
+ for s_hash, body in connection.execute(bq, list(needed_shas)).fetchall():
515
+ body_map[s_hash] = self._snippet(body, terms)
516
+
517
+ results = []
518
+ for key in paged_keys:
519
+ grp = groups[key]
520
+ versions = grp["versions"]
521
+ for v in versions:
522
+ v["snippet"] = body_map.get(v["sha256"], "")
523
+ versions.sort(key=lambda v: (v["retrieved_at"] is not None, v["retrieved_at"] or ""), reverse=True)
524
+ all_occs = grp["all_occurrences"]
525
+ all_occs.sort(key=lambda o: (o.get("retrieved_at") is not None, o.get("retrieved_at") or ""), reverse=True)
526
+ total_occ_count = len(all_occs)
527
+ paged_occs = all_occs[occurrence_offset : occurrence_offset + occurrence_limit]
528
+ shown = occurrence_offset + len(paged_occs)
529
+ dates = [v["retrieved_at"] for v in versions if v["retrieved_at"]]
530
+ earliest = min(dates) if dates else None
531
+ latest = max(dates) if dates else None
532
+ top_version = versions[0]
533
+ results.append({
534
+ "url": grp["url"],
535
+ "title": top_version["title"],
536
+ "sha256": top_version["sha256"],
537
+ "characters": top_version["characters"],
538
+ "snippet": top_version["snippet"],
539
+ "version_count": len(versions),
540
+ "time_span": {"earliest": earliest, "latest": latest},
541
+ "versions": versions,
542
+ "occurrence_count": total_occ_count,
543
+ "occurrence_offset": occurrence_offset,
544
+ "occurrences_truncated": shown < total_occ_count,
545
+ "occurrences_next_offset": shown if shown < total_occ_count else None,
546
+ "occurrences": paged_occs,
547
+ })
412
548
  finally:
413
549
  connection.close()
414
550
  next_offset = offset + len(results)
415
551
  return {"query": query, "total": total, "offset": offset,
416
552
  "next_offset": next_offset if next_offset < total else None,
417
553
  "occurrence_limit": occurrence_limit, "occurrence_offset": occurrence_offset,
554
+ "expand_versions": expand_versions, "grouped_by": "hash" if expand_versions else "url",
418
555
  "match": "fts_trigram" if fts else "substring", "results": results, "indexed": indexed,
419
- "note": "Literal matching over saved page text (knowledge archive and research evidence); "
420
- "not web search and not semantic retrieval. Identical bodies are merged by SHA-256 and "
421
- "each result pages its occurrences with occurrence_offset/occurrence_limit."}
556
+ "index_refresh": "performed" if writable else "skipped_read_only",
557
+ "note": ("Literal matching over saved page text (knowledge archive and research evidence); "
558
+ "not web search and not semantic retrieval. " +
559
+ ("Results are grouped by URL with version counts and time spans. Use expand_versions=true to view distinct content hashes." if not expand_versions else "Identical bodies are merged by SHA-256 and each result pages its occurrences with occurrence_offset/occurrence_limit."))}
422
560
 
423
561
  def search_terms(self, terms, limit=5):
424
562
  """Search several literal terms and merge them into one preflight list.