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
package/src/settings.py CHANGED
@@ -16,16 +16,13 @@ from package_paths import is_package_dir
16
16
  class Settings:
17
17
  """Load on each operation so MCP clients can change preferences without restart."""
18
18
 
19
- PROVIDERS = ("exa", "parallel", "tavily", "jina", "deepseek")
19
+ PROVIDERS = ("exa", "parallel", "tavily", "jina", "deepseek", "perplexity")
20
+ FETCH_PROVIDERS = ("exa", "parallel", "tavily", "jina", "deepseek", "browser")
20
21
  TOOL_SCOPE_PRESETS = ("all", "quick", "deep")
21
22
  # Daily context, retrieval, knowledge storage and provider-managed research.
22
23
  # Persistent inventories and diagnostics remain in the explicit full catalog.
23
24
  QUICK_TOOLS = (
24
- "sync_package", "index_status", "search_bookmarks", "get_context", "plan_research",
25
- "search_web", "fetch_web", "search_archive", "wiki_write", "wiki_get", "wiki_list", "wiki_search",
26
- "research_services", "research_service_prepare", "research_service_start", "research_service_status",
27
- "research_service_result", "research_service_cancel", "research_service_attach",
28
- "get_settings", "update_settings")
25
+ "locate", "read", "walk", "search", "fetch", "ingest", "save", "spawn", "job", "health")
29
26
  OPENAI_RESEARCH_MODELS = ("gpt-5.6-sol", "o3-deep-research", "o4-mini-deep-research")
30
27
 
31
28
  def __init__(self, path=None):
@@ -83,8 +80,12 @@ class Settings:
83
80
  "openai": {"model": "gpt-5.6-sol", "max_tool_calls": 24,
84
81
  "allow_untested_model": False},
85
82
  "parallel": {"processor": "pro"}},
86
- "wiki": {"directory": str(cls.data_directory() / "wiki"), "after_research": "suggest",
87
- "max_source_age_days": 30},
83
+ "custom_agentic": {"enabled": False, "base_url": "", "model": "",
84
+ "api_mode": "chat_completions", "search_mode": "prompt"},
85
+ "browser": {"enabled": False, "driver": "auto", "cdp_url": "http://127.0.0.1:9222",
86
+ "headless": True, "timeout_seconds": 20, "max_concurrency": 2},
87
+ "wiki": {"directory": str(cls.data_directory() / "wiki"), "after_research": "auto",
88
+ "max_source_age_days": 30, "aliases": []},
88
89
  "wiki_preflight": True,
89
90
  "freshness_days": 30,
90
91
  "plan": {"max_characters": 8000, "min_relevance": 0.05, "homepage_limit": 2},
@@ -112,7 +113,7 @@ class Settings:
112
113
  defaults = cls.defaults()
113
114
  if set(value) - set(defaults):
114
115
  raise ValueError("Unknown settings fields; credentials belong in the environment or private credential file")
115
- for section in ("search", "fetch", "server", "archive", "research", "readiness", "professional_research", "wiki", "plan", "output"):
116
+ for section in ("search", "fetch", "server", "archive", "research", "readiness", "professional_research", "custom_agentic", "browser", "wiki", "plan", "output"):
116
117
  if not isinstance(value.get(section), dict) or set(value[section]) != set(defaults[section]):
117
118
  raise ValueError("Invalid settings fields in " + section)
118
119
  for label, number, minimum, maximum in (
@@ -163,14 +164,20 @@ class Settings:
163
164
  raise ValueError("search.strategy must be hedged, staggered, sequential or wait_all")
164
165
  if value["fetch"]["strategy"] not in ("sequential", "staggered", "hedged"):
165
166
  raise ValueError("fetch.strategy must be sequential, staggered or hedged")
166
- for section, field, minimum in (("search", "providers", 1), ("fetch", "providers", 1),
167
- ("search", "fallback_providers", 0)):
167
+ for section, field, allowed, minimum in (("search", "providers", cls.PROVIDERS, 1),
168
+ ("fetch", "providers", cls.FETCH_PROVIDERS, 1),
169
+ ("search", "fallback_providers", cls.PROVIDERS, 0)):
168
170
  providers = value[section][field]
169
- if (not isinstance(providers, list) or not minimum <= len(providers) <= len(cls.PROVIDERS)
170
- or any(p not in cls.PROVIDERS for p in providers) or len(set(providers)) != len(providers)):
171
+ if (not isinstance(providers, list) or not minimum <= len(providers) <= len(allowed)
172
+ or any(p not in allowed for p in providers) or len(set(providers)) != len(providers)):
171
173
  raise ValueError(section + "." + field + " must select supported providers without duplicates")
172
- if value["fetch"]["provider"] not in cls.PROVIDERS:
174
+ if value["fetch"]["provider"] not in cls.FETCH_PROVIDERS:
173
175
  raise ValueError("fetch.provider must select a supported provider")
176
+ for field in ("provider", "providers"):
177
+ configured = value["fetch"][field]
178
+ names = [configured] if field == "provider" else configured
179
+ if any(name not in cls.FETCH_PROVIDERS for name in names):
180
+ raise ValueError("fetch settings may select only providers that support page extraction")
174
181
  if type(value["archive"]["enabled"]) is not bool:
175
182
  raise ValueError("archive.enabled must be a boolean")
176
183
  directory = value["archive"]["directory"]
@@ -213,12 +220,50 @@ class Settings:
213
220
  processor = services["parallel"]["processor"]
214
221
  if not isinstance(processor, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", processor):
215
222
  raise ValueError("professional_research.parallel.processor must be a processor name")
223
+ custom = value["custom_agentic"]
224
+ if type(custom["enabled"]) is not bool:
225
+ raise ValueError("custom_agentic.enabled must be a boolean")
226
+ if custom["api_mode"] not in ("chat_completions", "responses"):
227
+ raise ValueError("custom_agentic.api_mode must be chat_completions or responses")
228
+ if custom["search_mode"] not in ("prompt", "native"):
229
+ raise ValueError("custom_agentic.search_mode must be prompt or native")
230
+ if custom["search_mode"] == "native" and custom["api_mode"] != "responses":
231
+ raise ValueError("custom_agentic native web search requires the Responses API")
232
+ if not isinstance(custom["base_url"], str) or len(custom["base_url"]) > 1024:
233
+ raise ValueError("custom_agentic.base_url must be a string up to 1024 characters")
234
+ if not isinstance(custom["model"], str) or len(custom["model"]) > 192:
235
+ raise ValueError("custom_agentic.model must be a string up to 192 characters")
236
+ if custom["enabled"]:
237
+ from grok_search import normalize_compatible_base_url
238
+ normalize_compatible_base_url(custom["base_url"])
239
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/+-]{0,191}", custom["model"]):
240
+ raise ValueError("custom_agentic.model must be a valid model identifier")
241
+ browser = value["browser"]
242
+ if type(browser["enabled"]) is not bool:
243
+ raise ValueError("browser.enabled must be a boolean")
244
+ if browser["driver"] not in ("auto", "playwright", "cdp", "mock"):
245
+ raise ValueError("browser.driver must be auto, playwright, cdp or mock")
246
+ if not isinstance(browser["cdp_url"], str) or len(browser["cdp_url"]) > 1024:
247
+ raise ValueError("browser.cdp_url must be a string up to 1024 characters")
248
+ if type(browser["headless"]) is not bool:
249
+ raise ValueError("browser.headless must be a boolean")
250
+ for label, number, minimum, maximum in (
251
+ ("browser.timeout_seconds", browser["timeout_seconds"], 1, 60),
252
+ ("browser.max_concurrency", browser["max_concurrency"], 1, 8)):
253
+ if type(number) is not int or not minimum <= number <= maximum:
254
+ raise ValueError("%s must be an integer between %s and %s" % (label, minimum, maximum))
216
255
  wiki_directory = value["wiki"]["directory"]
217
256
  if not isinstance(wiki_directory, str) or not wiki_directory.strip() or "\x00" in wiki_directory:
218
257
  raise ValueError("wiki.directory must be a nonempty path")
219
258
  value["wiki"]["directory"] = str(cls.external_path(wiki_directory, "Wiki directory"))
220
259
  if value["wiki"]["after_research"] not in ("suggest", "auto", "off"):
221
260
  raise ValueError("wiki.after_research must be suggest, auto or off")
261
+ aliases = value["wiki"].get("aliases", [])
262
+ if not isinstance(aliases, list):
263
+ raise ValueError("wiki.aliases must be a list of alias groups")
264
+ for group in aliases:
265
+ if not isinstance(group, (list, tuple)) or not all(isinstance(t, str) for t in group):
266
+ raise ValueError("each alias group in wiki.aliases must be a list of strings")
222
267
  output = value["output"]
223
268
  if output["mode"] not in ("central", "beside_input"):
224
269
  raise ValueError("output.mode must be central or beside_input")
@@ -233,9 +278,9 @@ class Settings:
233
278
 
234
279
  @staticmethod
235
280
  def fetch_call_count(provider, urls):
236
- # Reader has one HTTP request per URL; the other providers batch URLs
281
+ # Reader and Browser have one request/render per URL; the other providers batch URLs
237
282
  # in one MCP tool call. Reserve this before any network work begins.
238
- return len(urls) if provider == "jina" else 1
283
+ return len(urls) if provider in ("jina", "browser") else 1
239
284
 
240
285
  @staticmethod
241
286
  def _unique_object(pairs):
@@ -36,6 +36,53 @@ def _safe_name(name):
36
36
  return parts
37
37
 
38
38
 
39
+ def _looks_like_section(obj):
40
+ return isinstance(obj, dict) and (
41
+ obj.get("format") == "bookmark-canvas-section"
42
+ or (obj.get("sectionType") in ("permanent", "temporary")
43
+ and ("items" in obj or "tree" in obj)))
44
+
45
+
46
+ def _unwrap_export(obj, depth=0):
47
+ """Mirror the canvas app: accept {data|tree|bookmarkTree|bookmarks|items} wrappers."""
48
+ if depth > 5 or not isinstance(obj, dict) or _looks_like_section(obj):
49
+ return obj
50
+ for key in ("data", "tree", "bookmarkTree", "bookmarks", "items"):
51
+ value = obj.get(key)
52
+ if isinstance(value, (dict, list)):
53
+ return _unwrap_export(value, depth + 1)
54
+ return obj
55
+
56
+
57
+ def _wrap_items(items, name):
58
+ return {"format": "bookmark-canvas-section", "schemaVersion": 2,
59
+ "sectionType": "temporary", "title": Path(name).stem,
60
+ "label": "导入", "tempKind": "special", "source": "import-json",
61
+ "items": items}
62
+
63
+
64
+ def _normalize_section_bytes(raw, name):
65
+ try:
66
+ obj = json.loads(raw.decode("utf-8"))
67
+ except (UnicodeDecodeError, ValueError):
68
+ return raw
69
+ if _looks_like_section(obj) and obj.get("format") == "bookmark-canvas-section":
70
+ return raw # already a section: keep bytes identical for idempotent re-import
71
+ if _looks_like_section(obj):
72
+ obj = dict(obj, format="bookmark-canvas-section")
73
+ return json.dumps(obj, ensure_ascii=False).encode("utf-8")
74
+ candidate = _unwrap_export(obj)
75
+ if _looks_like_section(candidate):
76
+ if "format" not in candidate:
77
+ candidate = dict(candidate, format="bookmark-canvas-section")
78
+ return json.dumps(candidate, ensure_ascii=False).encode("utf-8")
79
+ if isinstance(candidate, list):
80
+ return json.dumps(_wrap_items(candidate, name), ensure_ascii=False).encode("utf-8")
81
+ if isinstance(candidate, dict) and isinstance(candidate.get("children"), list):
82
+ return json.dumps(_wrap_items([candidate], name), ensure_ascii=False).encode("utf-8")
83
+ return raw
84
+
85
+
39
86
  def _section_name(name, raw, section_paths):
40
87
  obj = json_object(raw)
41
88
  prefix = "永久栏目" if obj.get("sectionType") == "permanent" else "临时栏目"
@@ -175,6 +222,36 @@ def _zip(path, section_paths):
175
222
  raise ValueError("Cannot read ZIP export: " + str(exc)) from exc
176
223
 
177
224
 
225
+ def scan_stats(path):
226
+ path = Path(path).expanduser().resolve()
227
+ if path.is_file():
228
+ if path.suffix.lower() in (".zip", ".json"):
229
+ st = path.stat()
230
+ return {path.name: [st.st_mtime_ns, st.st_size]}
231
+ raise ValueError("Input file must be ZIP or section JSON: " + str(path))
232
+ if not path.is_dir():
233
+ raise ValueError("Input directory does not exist: " + str(path))
234
+
235
+ stats = {}
236
+ for canvas in path.glob("*.canvas"):
237
+ if not canvas.is_symlink() and canvas.is_file():
238
+ st = canvas.stat()
239
+ stats[canvas.relative_to(path).as_posix()] = [st.st_mtime_ns, st.st_size]
240
+ for loose in path.glob("*.json"):
241
+ if not loose.is_symlink() and loose.is_file():
242
+ st = loose.stat()
243
+ stats[loose.relative_to(path).as_posix()] = [st.st_mtime_ns, st.st_size]
244
+ for directory in PROTOCOL_DIRS:
245
+ branch = path / directory
246
+ if branch.is_symlink() or not branch.is_dir():
247
+ continue
248
+ for entry in branch.rglob("*"):
249
+ if not entry.is_symlink() and entry.is_file() and entry.suffix == ".json":
250
+ st = entry.stat()
251
+ stats[entry.relative_to(path).as_posix()] = [st.st_mtime_ns, st.st_size]
252
+ return stats
253
+
254
+
178
255
  def read_input(path, section_paths=None):
179
256
  path = Path(path).expanduser().resolve()
180
257
  section_paths = section_paths or {}
@@ -186,17 +263,46 @@ def read_input(path, section_paths=None):
186
263
  files, single_section = _zip(path, section_paths)
187
264
  elif path.is_file() and path.suffix.lower() == ".json":
188
265
  kind, single_section = "section", True
189
- raw = _read_file(path, path.parent)
266
+ raw = _normalize_section_bytes(_read_file(path, path.parent), path.name)
190
267
  files = {_section_name(path.name, raw, section_paths): raw}
268
+ elif path.is_file() and path.suffix.lower() in (".html", ".htm"):
269
+ from netscape import encode as _encode_html_section, section_from_html
270
+ kind, single_section = "section", True
271
+ text = _read_file(path, path.parent).decode("utf-8", "replace")
272
+ raw = _encode_html_section(section_from_html(text, title=path.stem))
273
+ files = {_section_name(path.stem + ".json", raw, section_paths): raw}
191
274
  else:
192
- raise ValueError("Input must be an existing package directory, ZIP, or section JSON: " + str(path))
275
+ raise ValueError("Input must be an existing package directory, ZIP, section JSON, or bookmark HTML: " + str(path))
193
276
  if not files:
194
277
  raise ValueError("No protocol JSON or .canvas files in export; the saved index was retained")
195
278
  if sum(name.endswith(".canvas") for name in files) > 1:
196
279
  raise ValueError("An export must have at most one .canvas entry")
197
280
  file_hashes = hashes(files)
281
+ file_stats = {}
282
+ if kind == "directory":
283
+ for name in files:
284
+ file_entry = path / name
285
+ try:
286
+ st = file_entry.stat()
287
+ file_stats[name] = [st.st_mtime_ns, st.st_size]
288
+ except OSError:
289
+ pass
290
+ for loose in path.glob("*.json"):
291
+ if not loose.is_symlink() and loose.is_file():
292
+ try:
293
+ st = loose.stat()
294
+ file_stats[loose.relative_to(path).as_posix()] = [st.st_mtime_ns, st.st_size]
295
+ except OSError:
296
+ pass
297
+ elif kind in ("zip", "section"):
298
+ try:
299
+ st = path.stat()
300
+ file_stats[path.name] = [st.st_mtime_ns, st.st_size]
301
+ except OSError:
302
+ pass
198
303
  return {"path": str(path), "kind": kind, "single_section": single_section,
199
- "files": files, "hashes": file_hashes, "fingerprint": fingerprint(file_hashes)}
304
+ "files": files, "hashes": file_hashes, "stats": file_stats,
305
+ "fingerprint": fingerprint(file_hashes)}
200
306
 
201
307
 
202
308
  def git_directory(path):
@@ -14,7 +14,7 @@ from datetime import datetime, timezone
14
14
  from pathlib import Path
15
15
 
16
16
  from bookmark_index import _dump, _inside, _string
17
- from source_inputs import fingerprint, git_busy, git_directory, hashes, read_input
17
+ from source_inputs import fingerprint, git_busy, git_directory, hashes, read_input, scan_stats
18
18
 
19
19
 
20
20
  SCHEMA = (
@@ -35,10 +35,11 @@ class SourceManager:
35
35
  self.index, self.connection = index, index.connection
36
36
  self.clock, self.debounce, self.deletion_grace = clock, debounce, deletion_grace
37
37
  self.storage = index.db_path.with_name(index.db_path.name + ".sources") if index.db_path else None
38
- with index.transaction():
39
- for statement in SCHEMA:
40
- self.connection.execute(statement)
41
- self._recover_retired_snapshots()
38
+ if not index.readonly:
39
+ with index.transaction():
40
+ for statement in SCHEMA:
41
+ self.connection.execute(statement)
42
+ self._recover_retired_snapshots()
42
43
 
43
44
  def _recover_retired_snapshots(self):
44
45
  """Recover a removal interrupted before commit, using surviving DB references."""
@@ -307,7 +308,7 @@ class SourceManager:
307
308
  self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)", (source_id, prepared["path"]))
308
309
  record = {"mode": mode, "completeness": completeness, "input_path": prepared["path"],
309
310
  "input_kind": prepared["kind"], "version_id": version_id, "snapshot_path": str(snapshot_path),
310
- "observed_hashes": prepared["hashes"], "checked_at": self._now(),
311
+ "observed_hashes": prepared["hashes"], "observed_stats": prepared.get("stats"), "checked_at": self._now(),
311
312
  "state": "snapshot" if mode == "snapshot" else "current", "error": None,
312
313
  "pending_files": [], "pending_fingerprint": None, "pending_since": None,
313
314
  "retained_missing_files": summary["retained_missing_files"], "legacy": False}
@@ -344,6 +345,39 @@ class SourceManager:
344
345
  "SELECT COUNT(*) FROM %s WHERE source_id=?" % table, (source_id,)).fetchone()[0]
345
346
  for table in ("files", "sections", "items", "nodes", "edges", "memberships")}
346
347
 
348
+ def _reference_impact(self, source_id):
349
+ """Find durable research scopes and Wiki revisions that name a source."""
350
+ from settings import Settings
351
+ settings = Settings().load()
352
+ research_root = Path(settings["output"]["directory"])
353
+ wiki_root = Path(settings["wiki"]["directory"])
354
+ research_ids, pages = set(), set()
355
+ if research_root.is_dir() and not research_root.is_symlink():
356
+ for state_path in research_root.glob("*/state.json"):
357
+ if state_path.is_symlink():
358
+ continue
359
+ try:
360
+ state = json.loads(state_path.read_text(encoding="utf-8"))
361
+ except (OSError, ValueError):
362
+ continue
363
+ if source_id in state.get("source_scope", {}).get("source_ids", []):
364
+ research_ids.add(state.get("research_id") or state_path.parent.name)
365
+ revisions = wiki_root / "revisions"
366
+ if revisions.is_dir() and not revisions.is_symlink():
367
+ for revision_path in revisions.glob("*/*.json"):
368
+ if revision_path.is_symlink():
369
+ continue
370
+ try:
371
+ record = json.loads(revision_path.read_text(encoding="utf-8"))
372
+ except (OSError, ValueError):
373
+ continue
374
+ if any(ref.get("source_id") == source_id
375
+ for evidence in record.get("evidence", [])
376
+ for citation in evidence.get("citations", [])
377
+ for ref in citation.get("bookmark_refs", []) if isinstance(ref, dict)):
378
+ pages.add(record.get("page_id") or revision_path.parent.name)
379
+ return {"affected_pages": sorted(pages), "affected_research_ids": sorted(research_ids)}
380
+
347
381
  def _snapshot_directory(self, source_id):
348
382
  if self.storage is None:
349
383
  return None
@@ -400,9 +434,11 @@ class SourceManager:
400
434
  snapshot = self._snapshot_directory(source_id)
401
435
  aliases = [row[0] for row in self.connection.execute(
402
436
  "SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
437
+ impact = self._reference_impact(source_id)
403
438
  if not confirm:
404
439
  return {"source_id": source_id, "status": "confirmation_required", "would_remove": counts,
405
440
  "aliases": aliases, "snapshot_directory": str(snapshot) if snapshot else None,
441
+ **impact,
406
442
  "impact": "Research tasks that froze this source keep their saved inventory but stop resolving "
407
443
  "its items; Wiki pages citing those claims require review. Re-run with confirm=true "
408
444
  "(CLI: --confirm) to delete."}
@@ -411,7 +447,7 @@ class SourceManager:
411
447
  self.connection.execute("DELETE FROM %s WHERE source_id=?" % table, (source_id,))
412
448
  self.connection.execute("DELETE FROM sources WHERE source_id=?", (source_id,))
413
449
  return {"source_id": source_id, "status": "removed", "removed": counts,
414
- "aliases": aliases, **cleanup}
450
+ "aliases": aliases, **impact, **cleanup}
415
451
 
416
452
  def merge(self, source_id, into_source_id, confirm=False):
417
453
  """Drop a duplicate source whose content version equals the target's.
@@ -432,9 +468,11 @@ class SourceManager:
432
468
  "instead of merging")
433
469
  aliases = [row[0] for row in self.connection.execute(
434
470
  "SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
471
+ impact = self._reference_impact(source_id)
435
472
  if not confirm:
436
473
  return {"source_id": source_id, "into_source_id": into_source_id, "status": "confirmation_required",
437
474
  "version_id": record["version_id"], "moved_aliases": aliases,
475
+ **impact,
438
476
  "impact": "The duplicate is deleted and its paths re-point at into_source_id; saved research "
439
477
  "inventories that froze the duplicate stop resolving. Re-run with confirm=true "
440
478
  "(CLI: --confirm) to merge."}
@@ -442,13 +480,18 @@ class SourceManager:
442
480
  self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)", (into_source_id, alias))
443
481
  self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)",
444
482
  (into_source_id, target.get("input_path")))
483
+ self.connection.execute(
484
+ "INSERT INTO source_redirects(old_source_id,new_source_id,created_at,reason) VALUES(?,?,?,?) "
485
+ "ON CONFLICT(old_source_id) DO UPDATE SET new_source_id=excluded.new_source_id, "
486
+ "created_at=excluded.created_at, reason=excluded.reason",
487
+ (source_id, into_source_id, self._now(), "merged_duplicate"))
445
488
  for table in ("items", "sections", "files", "nodes", "edges", "memberships",
446
489
  "source_snapshots", "source_aliases", "source_records"):
447
490
  self.connection.execute("DELETE FROM %s WHERE source_id=?" % table, (source_id,))
448
491
  self.connection.execute("DELETE FROM sources WHERE source_id=?", (source_id,))
449
492
  return {"source_id": source_id, "into_source_id": into_source_id, "status": "merged",
450
493
  "version_id": record.get("version_id"), "moved_aliases": aliases,
451
- **cleanup}
494
+ **impact, **cleanup}
452
495
 
453
496
  def _observation(self, source_id, record, state, **fields):
454
497
  record.update(state=state, checked_at=self._now(), **fields)
@@ -458,7 +501,7 @@ class SourceManager:
458
501
  "pending_files": record.get("pending_files", []), "error": record.get("error"),
459
502
  "version_id": record.get("version_id"), "checked_at": record["checked_at"]}
460
503
 
461
- def refresh(self, source_id, force=True):
504
+ def refresh(self, source_id, force=True, verify_hashes=False):
462
505
  record = self._record(source_id)
463
506
  if record["mode"] == "snapshot":
464
507
  return {"source_id": source_id, "mode": "snapshot", "state": "snapshot", "performed": False,
@@ -467,17 +510,33 @@ class SourceManager:
467
510
  with self.index.transaction():
468
511
  record = self._record(source_id)
469
512
  if record["mode"] == "snapshot":
470
- return self.refresh(source_id, force)
513
+ return self.refresh(source_id, force, verify_hashes=verify_hashes)
471
514
  if git_busy(record["input_path"]):
472
515
  return self._observation(source_id, record, "pending", error="Git is updating the source",
473
516
  pending_fingerprint=None, pending_since=None)
517
+
518
+ # Pre-screen via mtime and size if not forcing full verification and not awaiting debounce
519
+ if not verify_hashes and not record.get("pending_files") and record.get("observed_stats") and record.get("observed_hashes"):
520
+ try:
521
+ current_stats = scan_stats(record["input_path"])
522
+ if current_stats == record["observed_stats"]:
523
+ return self._observation(source_id, record, "current",
524
+ observed_hashes=record["observed_hashes"],
525
+ observed_stats=record["observed_stats"],
526
+ pending_files=[],
527
+ pending_fingerprint=None, pending_since=None, error=None)
528
+ except (OSError, ValueError):
529
+ pass
530
+
474
531
  prepared = read_input(record["input_path"])
475
532
  before = record["observed_hashes"] or {row[0]: row[1] for row in self.connection.execute(
476
533
  "SELECT file_path,sha256 FROM files WHERE source_id=?", (source_id,))}
477
534
  after = prepared["hashes"]
478
535
  pending = sorted(name for name in before.keys() | after.keys() if before.get(name) != after.get(name))
479
536
  if not pending:
480
- return self._observation(source_id, record, "current", observed_hashes=after, pending_files=[],
537
+ return self._observation(source_id, record, "current", observed_hashes=after,
538
+ observed_stats=prepared.get("stats"),
539
+ pending_files=[],
481
540
  pending_fingerprint=None, pending_since=None, error=None)
482
541
  deletion = bool(before.keys() - after.keys()) and record["completeness"] == "complete"
483
542
  delay = self.deletion_grace if deletion else self.debounce
@@ -507,7 +566,7 @@ class SourceManager:
507
566
  def source_status(self, source_id):
508
567
  """Read lifecycle state without recounting items or loading section bodies."""
509
568
  record = self._record(source_id)
510
- visible = {key: value for key, value in record.items() if key not in ("observed_hashes", "pending_fingerprint", "pending_since")}
569
+ visible = {key: value for key, value in record.items() if key not in ("observed_hashes", "observed_stats", "pending_fingerprint", "pending_since")}
511
570
  checked = record.get("checked_at")
512
571
  if record["mode"] == "live" and record["state"] == "current" and (not checked or
513
572
  self.clock() - datetime.fromisoformat(checked).timestamp() > 10):
@@ -5,6 +5,8 @@ from contextlib import closing
5
5
  from datetime import datetime, timezone
6
6
  from pathlib import Path
7
7
 
8
+ import storage
9
+
8
10
 
9
11
  class SourceVersions:
10
12
  def __init__(self, path):
@@ -18,9 +20,15 @@ class SourceVersions:
18
20
  except (ValueError, TypeError, AttributeError):
19
21
  return None
20
22
 
21
- def _connect(self):
23
+ def _connect(self, readonly=False):
22
24
  if self.path.is_symlink():
23
25
  raise ValueError("Source version index must not be a symlink")
26
+ if readonly:
27
+ if not self.path.is_file():
28
+ raise sqlite3.OperationalError("Source version index is not built yet: %s" % self.path)
29
+ connection = storage.connect(self.path, readonly=True)
30
+ connection.row_factory = sqlite3.Row
31
+ return storage.mark_query_only(connection)
24
32
  self.path.parent.mkdir(parents=True, exist_ok=True)
25
33
  connection = sqlite3.connect(str(self.path), timeout=10)
26
34
  connection.row_factory = sqlite3.Row
@@ -69,11 +77,21 @@ class SourceVersions:
69
77
  "DO UPDATE SET attempted_at=excluded.attempted_at,status=excluded.status",
70
78
  (SourceArchive._key(url), datetime.now(timezone.utc).isoformat(), status))
71
79
 
80
+ def _open_read(self):
81
+ """Reads keep working when the observations directory is read-only."""
82
+ try:
83
+ connection = self._connect()
84
+ except (sqlite3.DatabaseError, OSError) as error:
85
+ if not storage.is_readonly_failure(error) or not self.path.is_file():
86
+ raise
87
+ return self._connect(readonly=True)
88
+ return connection
89
+
72
90
  def inspect(self, url, sha256, acquired_at, published_at, scope_id=None, source_id=None):
73
91
  from archive import SourceArchive
74
92
  if not self.path.is_file():
75
93
  return {"available": False, "latest": None, "newer": None, "last_attempt": None}
76
- with closing(self._connect()) as connection:
94
+ with closing(self._open_read()) as connection:
77
95
  key = SourceArchive._key(url)
78
96
  baseline = connection.execute("SELECT seq FROM snapshots WHERE scope_id=? AND source_id=? AND sha256=?",
79
97
  (scope_id, source_id, sha256)).fetchone()
@@ -9,13 +9,14 @@ from source_manager import SourceManager
9
9
 
10
10
 
11
11
  class SourceWatcher:
12
- def __init__(self, db_path, interval=1.0, debounce=2.0, deletion_grace=5.0, on_change=None):
12
+ def __init__(self, db_path, interval=1.0, debounce=2.0, deletion_grace=5.0, on_change=None, verify_hashes=False):
13
13
  for value in (interval, debounce, deletion_grace):
14
14
  if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
15
15
  raise ValueError("Watcher intervals must be finite positive seconds")
16
16
  self.db_path = Path(db_path)
17
17
  self.interval, self.debounce, self.deletion_grace = interval, debounce, deletion_grace
18
18
  self.on_change = on_change
19
+ self.verify_hashes = bool(verify_hashes)
19
20
  self.stop_event, self.ready = threading.Event(), threading.Event()
20
21
  self.thread = None
21
22
  self.last_error = None
@@ -35,6 +36,7 @@ class SourceWatcher:
35
36
  return {"running": bool(self.thread and self.thread.is_alive() and not self.stop_event.is_set()),
36
37
  "mechanism": "poll", "interval_seconds": self.interval,
37
38
  "debounce_seconds": self.debounce, "deletion_grace_seconds": self.deletion_grace,
39
+ "verify_hashes": self.verify_hashes,
38
40
  "error": self.last_error, "lifetime": "host_process"}
39
41
 
40
42
  def run(self):
@@ -50,7 +52,7 @@ class SourceWatcher:
50
52
  for source_id in manager.live_sources():
51
53
  if self.stop_event.is_set():
52
54
  break
53
- result = manager.refresh(source_id, force=False)
55
+ result = manager.refresh(source_id, force=False, verify_hashes=self.verify_hashes)
54
56
  signature = (result["state"], result.get("version_id"),
55
57
  tuple(result.get("pending_files", [])), result.get("error"))
56
58
  if self.on_change is not None and signature != previous.get(source_id):
package/src/storage.py ADDED
@@ -0,0 +1,60 @@
1
+ """One open policy for every local SQLite store.
2
+
3
+ Read paths must keep working when the data directory is read-only: a shared
4
+ index, a read-only mount, or a host sandbox that only allows the workspace.
5
+ Writers keep WAL and schema initialization; readers fall back to ``mode=ro``
6
+ instead of failing or silently creating sidecar files next to the index.
7
+ """
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+ _READONLY_HINTS = (
12
+ "readonly",
13
+ "read-only",
14
+ "unable to open database",
15
+ "operation not permitted",
16
+ "permission denied",
17
+ "no authorization to access",
18
+ )
19
+
20
+
21
+ def is_readonly_failure(error):
22
+ """True when the filesystem refused a write, rather than the query being wrong."""
23
+ if isinstance(error, (PermissionError, NotADirectoryError)):
24
+ return True
25
+ message = str(error).casefold()
26
+ return any(hint in message for hint in _READONLY_HINTS)
27
+
28
+
29
+ def connect(path, readonly=False, timeout=10.0):
30
+ """Open ``path`` read-only (URI, ``mode=ro``) or read-write."""
31
+ path = Path(path)
32
+ if not readonly:
33
+ return sqlite3.connect(str(path), timeout=timeout)
34
+ try:
35
+ connection = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=timeout)
36
+ connection.execute("SELECT count(*) FROM sqlite_master").fetchone() # open now, not on first use
37
+ return connection
38
+ except sqlite3.OperationalError as error:
39
+ # A WAL database with no ``-shm`` beside it cannot be opened read-only on
40
+ # a read-only directory. The sidecars are absent exactly when no writer is
41
+ # active, so reading the main file as immutable is safe here.
42
+ if "unable to open database file" not in str(error).casefold():
43
+ raise
44
+ return sqlite3.connect(path.as_uri() + "?mode=ro&immutable=1", uri=True, timeout=timeout)
45
+
46
+
47
+ def mark_query_only(connection):
48
+ """Tell shared helpers that this connection must not attempt self-healing writes."""
49
+ try:
50
+ connection.execute("PRAGMA query_only=ON")
51
+ except sqlite3.DatabaseError:
52
+ pass
53
+ return connection
54
+
55
+
56
+ def is_query_only(connection):
57
+ try:
58
+ return bool(connection.execute("PRAGMA query_only").fetchone()[0])
59
+ except (sqlite3.DatabaseError, TypeError):
60
+ return False
@@ -0,0 +1,45 @@
1
+ """Cheap URL classification used by fetch routing and duplicate reports."""
2
+ from urllib.parse import urlsplit
3
+
4
+ # Base domains whose page is a search session, not a document: fetching the page
5
+ # only returns the same result list a fresh search would produce.
6
+ SEARCH_DOMAINS = (
7
+ "bing.com", "google.com", "google.com.hk", "duckduckgo.com",
8
+ "search.yahoo.com", "search.brave.com", "baidu.com", "yandex.com",
9
+ "yandex.ru", "sogou.com", "so.com", "startpage.com", "ecosia.org",
10
+ "search.naver.com",
11
+ )
12
+
13
+
14
+ def host_of(url):
15
+ try:
16
+ return (urlsplit(url).hostname or "").casefold()
17
+ except (ValueError, TypeError):
18
+ return ""
19
+
20
+
21
+ def _matches(host, domain):
22
+ return host == domain or host.endswith("." + domain)
23
+
24
+
25
+ def is_search_result_url(url):
26
+ """True for search-engine result pages (query pages), not documents."""
27
+ if not isinstance(url, str) or not url:
28
+ return False
29
+ host = host_of(url)
30
+ if not host or not any(_matches(host, domain) for domain in SEARCH_DOMAINS):
31
+ return False
32
+ parts = urlsplit(url)
33
+ path = (parts.path or "").casefold()
34
+ query = parts.query or ""
35
+ if _matches(host, "google.com") or _matches(host, "google.com.hk"):
36
+ return path in ("/search", "/url", "/webhp") or "q=" in query
37
+ if _matches(host, "bing.com"):
38
+ return path.startswith("/search") or "q=" in query
39
+ if _matches(host, "baidu.com"):
40
+ return path.startswith("/s") or "wd=" in query
41
+ if _matches(host, "duckduckgo.com"):
42
+ return True
43
+ if _matches(host, "yandex.com") or _matches(host, "yandex.ru"):
44
+ return "text=" in query or "q=" in query
45
+ return path in ("/search", "/s", "/web") or "q=" in query