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/readiness.py CHANGED
@@ -64,8 +64,10 @@ class Readiness:
64
64
  def _signature(self, row, salt):
65
65
  # Only a salted signature is persisted; no keys, headers, or signatures
66
66
  # are included in CLI/MCP results. Rotation invalidates cached checks.
67
+ cred_env = row.get("credential_env")
68
+ cred_val = self.credentials.get(cred_env) if cred_env and cred_env in self.credentials.NAMES else None
67
69
  content = {"row": row, "settings": self.settings.load(),
68
- "credential": self.credentials.get(row["credential_env"]),
70
+ "credential": cred_val,
69
71
  "registry": self.web.registry, "version": self.VERSION}
70
72
  return hmac.new(salt.encode(), json.dumps(content, sort_keys=True).encode(), hashlib.sha256).hexdigest()
71
73
 
@@ -84,12 +86,19 @@ class Readiness:
84
86
  preferences["search"]["providers"] + preferences["search"]["fallback_providers"] +
85
87
  Settings.fetch_providers(preferences)))
86
88
  described = self.web.describe(names)["providers"]
89
+
90
+ def get_cred_env(p):
91
+ key = p.upper() + "_API_KEY"
92
+ return key if key in self.credentials.NAMES else None
93
+
87
94
  return [{"id": "retrieval:" + row["provider"], "kind": "retrieval", "provider": row["provider"],
88
- "credential_env": row["provider"].upper() + "_API_KEY",
95
+ "credential_env": get_cred_env(row["provider"]),
89
96
  "credential_configured": row["authentication_state"]["credential_configured"],
90
97
  "authentication_mode": row["authentication_state"]["mode"],
91
98
  "authentication_verified": False, "execution_verified": False,
92
- "status": "unchecked", "docs": row["docs"], "operations": row["operations"],
99
+ "status": ("disabled" if row["provider"] == "browser" and not preferences.get("browser", {}).get("enabled")
100
+ else "unchecked"),
101
+ "docs": row["docs"], "operations": row["operations"],
93
102
  "installation": "included_adapter", "network_checked": False,
94
103
  "next_step": "Run readiness --refresh for connection checks; --test-retrieval also uses retrieval quota."}
95
104
  for row in described]
@@ -101,12 +110,25 @@ class Readiness:
101
110
  key = ResearchHttp.PROVIDERS[name]["key_env"]
102
111
  enabled = config["enabled"] and (config["provider"] in (None, name) or service == name)
103
112
  configured = bool(self.credentials.get(key))
113
+ if not enabled and not configured:
114
+ diagnosis = "disabled_and_unconfigured"
115
+ next_step = "Run setup to enable professional_research and configure " + key + "."
116
+ elif not enabled and configured:
117
+ diagnosis = "disabled_with_credential_present"
118
+ next_step = "Credential " + key + " is present. Enable via settings.professional_research.enabled=true."
119
+ elif enabled and not configured:
120
+ diagnosis = "enabled_missing_credential"
121
+ next_step = "Service is enabled in settings, but missing API key. Configure " + key + " in environment or private credential file."
122
+ else:
123
+ diagnosis = "ready_for_check"
124
+ next_step = "Ready for deep research. Run readiness --refresh to verify connectivity."
104
125
  rows.append({"id": "service:" + name, "kind": "professional_research", "provider": name,
105
126
  "credential_env": key, "credential_configured": configured,
106
127
  "authentication_verified": False, "execution_verified": False,
107
128
  "network_checked": False, "enabled": enabled,
108
129
  "status": "unchecked" if configured and enabled else "disabled" if not enabled else "missing_credentials",
109
- "next_step": "Run setup to enable this optional service and configure " + key + ".",
130
+ "diagnosis": diagnosis,
131
+ "next_step": next_step,
110
132
  "docs": "https://developers.openai.com/api/docs/guides/deep-research" if name == "openai"
111
133
  else "https://docs.parallel.ai/task-api/guides/execute-task-run"})
112
134
  return rows
@@ -116,6 +138,9 @@ class Readiness:
116
138
  if test_retrieval:
117
139
  operations = copy.deepcopy(row["operations"])
118
140
  for purpose in ("search", "fetch"):
141
+ if not self.web.registry["providers"][name].get(purpose + "_tools"):
142
+ operations[purpose]["status"] = "unsupported"
143
+ continue
119
144
  if operations[purpose]["missing"]:
120
145
  operations[purpose]["status"] = "missing_credentials"
121
146
  continue
@@ -140,8 +165,18 @@ class Readiness:
140
165
  "capabilities": probe["capabilities"],
141
166
  "next_step": "Search/fetch can be attempted. Catalog discovery does not verify retrieval authorization or quota."}
142
167
  if probe["status"] == "not_probed":
168
+ missing = sorted({key for operation in row.get("operations", {}).values()
169
+ for key in operation.get("missing", [])})
170
+ supported = [purpose for purpose in ("search", "fetch")
171
+ if self.web.registry["providers"][name].get(purpose + "_tools")]
172
+ guidance = []
173
+ if missing:
174
+ guidance.append("Configure " + ", ".join(missing) + ".")
175
+ if supported:
176
+ guidance.append("Supported operations: " + ", ".join(supported) + ".")
177
+ guidance.append("Use --test-retrieval for an actual check; this may use provider quota.")
143
178
  return {"status": "http_unchecked", "network_checked": False,
144
- "next_step": "Reader can be attempted anonymously; Search needs JINA_API_KEY. Use --test-retrieval for an actual check."}
179
+ "next_step": " ".join(guidance)}
145
180
  return {"status": "failed", "network_checked": True,
146
181
  "error_kind": probe.get("error_kind", "check_failed"),
147
182
  "next_step": "Check credentials and connectivity, then refresh; another configured provider may remain available."}
@@ -0,0 +1,70 @@
1
+ """Process-local short reference registry.
2
+
3
+ Short refs reduce repeated addressing cost while the registry keeps the
4
+ authoritative source/version/object identity available to read and walk.
5
+ References are opaque handles; callers must resolve them through this module.
6
+ """
7
+
8
+ import hashlib
9
+ import threading
10
+ from collections import OrderedDict
11
+
12
+
13
+ _MAX_REFS = 4096
14
+ _LOCK = threading.RLock()
15
+ _BY_REF = OrderedDict()
16
+ _BY_IDENTITY = {}
17
+
18
+
19
+ def _scope(kind, identity):
20
+ digest = hashlib.sha256((kind + "\0" + "\0".join(str(v) for v in identity)).encode("utf-8")).hexdigest()
21
+ return digest[:4]
22
+
23
+
24
+ def register(kind, identity):
25
+ """Register an identity and return a collision-checked short ref."""
26
+ if not isinstance(kind, str) or len(kind) != 1 or kind not in ("b", "w", "a"):
27
+ raise ValueError("ref kind must be b, w or a")
28
+ identity = tuple(str(value) for value in identity)
29
+ key = (kind, identity)
30
+ with _LOCK:
31
+ existing = _BY_IDENTITY.get(key)
32
+ if existing is not None:
33
+ _BY_REF.move_to_end(existing)
34
+ return existing
35
+ scope = _scope(kind, identity)
36
+ sequence = 1
37
+ while True:
38
+ ref = f"{kind}-{scope}-{sequence}"
39
+ previous = _BY_REF.get(ref)
40
+ if previous is None or previous == key:
41
+ break
42
+ sequence += 1
43
+ _BY_REF[ref] = key
44
+ _BY_IDENTITY[key] = ref
45
+ _BY_REF.move_to_end(ref)
46
+ while len(_BY_REF) > _MAX_REFS:
47
+ old_ref, old_key = _BY_REF.popitem(last=False)
48
+ if _BY_IDENTITY.get(old_key) == old_ref:
49
+ del _BY_IDENTITY[old_key]
50
+ return ref
51
+
52
+
53
+ def resolve(ref):
54
+ """Resolve a short ref to its kind and bound identity, or return None."""
55
+ if not isinstance(ref, str):
56
+ return None
57
+ with _LOCK:
58
+ key = _BY_REF.get(ref)
59
+ if key is None:
60
+ return None
61
+ _BY_REF.move_to_end(ref)
62
+ kind, identity = key
63
+ return {"kind": kind, "identity": list(identity)}
64
+
65
+
66
+ def clear():
67
+ """Clear the process-local registry; intended for isolated tests."""
68
+ with _LOCK:
69
+ _BY_REF.clear()
70
+ _BY_IDENTITY.clear()
package/src/research.py CHANGED
@@ -89,6 +89,18 @@ class ResearchSessions:
89
89
  raise ValueError("Invalid research_id")
90
90
  return research_id
91
91
 
92
+ @staticmethod
93
+ def _slug(value, fallback=None):
94
+ raw = (value or "").strip().lower()
95
+ slug = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
96
+ slug = re.sub(r"-+", "-", slug)[:80]
97
+ if slug and re.fullmatch(r"[a-z0-9][a-z0-9-]{0,79}", slug):
98
+ return slug
99
+ if fallback is not None:
100
+ return ResearchSessions._slug(fallback)
101
+ from wiki import WikiStore
102
+ return WikiStore._slug(value)
103
+
92
104
  @staticmethod
93
105
  def _registry():
94
106
  """Return the location registry; an unreadable registry means legacy lookup only."""
@@ -526,7 +538,8 @@ class ResearchSessions:
526
538
  self._integer(limit, key, 0, self.LIMITS[key])
527
539
  preferences = self.settings.load()
528
540
  names = self._providers(providers if providers is not None else preferences["search"]["providers"])
529
- fetch_names = names[:] if providers is not None else Settings.fetch_providers(preferences)
541
+ fetch_names = ([name for name in names if name in Settings.FETCH_PROVIDERS]
542
+ or Settings.fetch_providers(preferences)) if providers is not None else Settings.fetch_providers(preferences)
530
543
  search_fallbacks = [] if providers is not None else [name for name in preferences["search"]["fallback_providers"]
531
544
  if name not in names]
532
545
  if not isinstance(scope, str) or len(scope) > 12000 or "\x00" in scope:
@@ -703,11 +716,22 @@ class ResearchSessions:
703
716
  with BookmarkIndex(self.db_path) as index:
704
717
  manager = SourceManager(index)
705
718
  with index.read_snapshot():
719
+ resolved_ids = {sid: index.resolve_source_id(sid) for sid in ids}
720
+ resolved_refs = ([{**ref, "source_id": index.resolve_source_id(ref["source_id"])}
721
+ for ref in bookmark_refs] if bookmark_refs else None)
706
722
  snapshot = {"version": index.input_version(ids),
707
- "states": {sid: manager.source_status(sid)["state"] for sid in ids}}
723
+ "states": {sid: manager.source_status(resolved_ids[sid])["state"] for sid in ids},
724
+ "resolved_ids": resolved_ids}
708
725
  if bookmark_refs:
709
726
  snapshot["inventory"] = index.inventory(ids) if snapshot["version"] != frozen else None
710
727
  cache[key] = snapshot
728
+ else:
729
+ with BookmarkIndex(self.db_path) as index:
730
+ resolved_refs = ([{**ref, "source_id": index.resolve_source_id(ref["source_id"])}
731
+ for ref in bookmark_refs] if bookmark_refs else None)
732
+ redirected = {old: new for old, new in snapshot.get("resolved_ids", {}).items() if old != new}
733
+ if redirected:
734
+ result["resolved_via_redirect"] = redirected
711
735
  current, states = snapshot["version"], snapshot["states"]
712
736
  changed = current != frozen
713
737
  result.update(current_input_version=current, state="changed" if changed else "unchanged",
@@ -722,8 +746,18 @@ class ResearchSessions:
722
746
  if hashlib.sha256(raw).hexdigest() != state.get("inventory_manifest_sha256"):
723
747
  raise ValueError("Frozen input inventory hash changed")
724
748
  cache[frozen_key] = json.loads(raw)
725
- relevant = scoped_input(cache[frozen_key], bookmark_refs) != scoped_input(
726
- snapshot["inventory"], bookmark_refs)
749
+ frozen_inventory = cache[frozen_key]
750
+ if redirected:
751
+ def remap(value):
752
+ if isinstance(value, dict):
753
+ return {key: (redirected.get(child, child) if key == "source_id" else remap(child))
754
+ for key, child in value.items()}
755
+ if isinstance(value, list):
756
+ return [remap(child) for child in value]
757
+ return value
758
+ frozen_inventory = remap(frozen_inventory)
759
+ relevant = scoped_input(frozen_inventory, resolved_refs) != scoped_input(
760
+ snapshot["inventory"], resolved_refs)
727
761
  result.update(state="changed" if relevant else "unchanged", requires_review=relevant,
728
762
  package_changed=True, comparison_scope="cited_bookmarks_and_context")
729
763
  except (OSError, ValueError, KeyError, TypeError, sqlite3.Error) as exc:
@@ -1154,7 +1188,7 @@ class ResearchSessions:
1154
1188
  {"fetch_calls": Settings.fetch_call_count(names[0], fresh) if fresh else 0}, execute)
1155
1189
 
1156
1190
  def import_evidence(self, research_id, operation_id, question_id, url, text, provenance,
1157
- inventory_ids=None, title=""):
1191
+ inventory_ids=None, title="", bookmark_refs=None):
1158
1192
  """Save host-read page text or a secondary report without fetching it.
1159
1193
 
1160
1194
  A report can cite original input IDs, but those references never count
@@ -1188,14 +1222,16 @@ class ResearchSessions:
1188
1222
  if "run_id" in provenance:
1189
1223
  self._text(provenance["run_id"], "External run id", 2000)
1190
1224
  supplied_ids = None
1225
+ is_internal_wiki = state.get("topic", "").startswith("Wiki: ") or state.get("brief", "").startswith("Wiki: ")
1191
1226
  if inventory_ids is not None:
1192
1227
  if not isinstance(inventory_ids, list):
1193
1228
  raise ValueError("inventory_ids must be a list")
1194
1229
  supplied_ids = list(dict.fromkeys(self._identifier(value, "Inventory id") for value in inventory_ids))
1195
- for identifier in supplied_ids:
1196
- self._find(state.get("inventory", []), identifier, "inventory item")
1230
+ if state.get("inventory") or not is_internal_wiki:
1231
+ for identifier in supplied_ids:
1232
+ self._find(state.get("inventory", []), identifier, "inventory item")
1197
1233
  linked_ids, _ = self._source_links(state, url)
1198
- if kind != "external_report" and supplied_ids is not None and set(supplied_ids) - set(linked_ids):
1234
+ if kind != "external_report" and supplied_ids is not None and (state.get("inventory") or not is_internal_wiki) and set(supplied_ids) - set(linked_ids):
1199
1235
  raise ValueError("Imported page URL does not match the selected original inventory URL")
1200
1236
  digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
1201
1237
  if "text_sha256" in provenance and provenance["text_sha256"] != digest:
@@ -1228,7 +1264,7 @@ class ResearchSessions:
1228
1264
  "provenance": provenance, "review": {"verdict": "unreviewed", "text": None},
1229
1265
  "inventory_ids": [] if kind == "external_report" else identifiers,
1230
1266
  "referenced_inventory_ids": (supplied_ids or []) if kind == "external_report" else [],
1231
- "bookmark_refs": [] if kind == "external_report" else refs}
1267
+ "bookmark_refs": bookmark_refs if bookmark_refs is not None else ([] if kind == "external_report" else refs)}
1232
1268
  # The commit assigns the source id and writes its manifest together.
1233
1269
  # The request snapshot cannot predict ids allocated by other writers.
1234
1270
  return {"status": "ok", "sources": [source], "next_action":
@@ -1730,6 +1766,8 @@ class ResearchSessions:
1730
1766
  policy = "suggest"
1731
1767
  if policy == "off":
1732
1768
  return {"policy": "off"}
1769
+ if state.get("topic", "").startswith("Wiki: ") or state.get("brief", "").startswith("Wiki: "):
1770
+ return {"policy": policy, "skip_reason": "internal_wiki_session", "candidates": [], "draft": None}
1733
1771
  eligible = [claim["id"] for claim in state["claims"] if claim.get("status", "active") == "active"
1734
1772
  and all(self._find(state["sources"], citation["source_id"], "source").get("review", {}).get("verdict")
1735
1773
  == "accepted" for citation in claim["citations"])]
@@ -1746,7 +1784,75 @@ class ResearchSessions:
1746
1784
  ranked = sorted(candidates.values(), key=lambda row: (-len(row["matched_terms"]), row["page_id"]))[:5]
1747
1785
  except Exception: # A Wiki problem must never undo a saved report.
1748
1786
  ranked = []
1787
+ draft_page = self._wiki_page(drafts, state)
1788
+ written_page = None
1789
+ skip_reason = None
1749
1790
  if policy == "auto":
1791
+ if not drafts:
1792
+ skip_reason = "insufficient_evidence" if not state.get("claims") else "no_accepted_claims"
1793
+ elif state.get("status") == "active":
1794
+ skip_reason = "session_active"
1795
+ elif state.get("dry_run"):
1796
+ skip_reason = "dry_run"
1797
+ else:
1798
+ try:
1799
+ from wiki import WikiStore
1800
+ store = WikiStore(settings=self.settings, research_sessions=self)
1801
+ auto_page = copy.deepcopy(draft_page["page"])
1802
+ auto_page["review"] = {
1803
+ "method": "model",
1804
+ "reviewer": "auto_research",
1805
+ "note": "Auto-synthesized reviewed claims from research session %s" % state["research_id"]
1806
+ }
1807
+ if ranked:
1808
+ candidate_page_id = ranked[0]["page_id"]
1809
+ else:
1810
+ title = state.get("title") or state.get("topic") or state.get("brief") or state["research_id"]
1811
+ candidate_page_id = self._slug(title, fallback=state["research_id"])
1812
+ expected_rev = 0
1813
+ existing = None
1814
+ try:
1815
+ existing = store.get(candidate_page_id)
1816
+ except Exception:
1817
+ existing = None
1818
+ if existing and existing.get("page"):
1819
+ expected_rev = existing.get("revision", 0)
1820
+ merged_sections = copy.deepcopy(existing["page"].get("sections", []))
1821
+ existing_headings = {s.get("heading") for s in merged_sections}
1822
+ for s in auto_page.get("sections", []):
1823
+ if s.get("heading") not in existing_headings and len(merged_sections) < WikiStore.MAX_SECTIONS:
1824
+ merged_sections.append(s)
1825
+ existing_headings.add(s.get("heading"))
1826
+ auto_page["sections"] = merged_sections
1827
+ existing_links = existing["page"].get("links", [])
1828
+ merged_links = copy.deepcopy(existing_links)
1829
+ existing_link_targets = {l.get("page_id") for l in merged_links}
1830
+ for l in auto_page.get("links", []):
1831
+ if l.get("page_id") not in existing_link_targets and len(merged_links) < 32:
1832
+ merged_links.append(l)
1833
+ existing_link_targets.add(l.get("page_id"))
1834
+ auto_page["links"] = merged_links
1835
+ if existing["page"].get("aliases"):
1836
+ auto_page["aliases"] = existing["page"]["aliases"]
1837
+ write_result = store.write(
1838
+ candidate_page_id,
1839
+ auto_page,
1840
+ change_note="Auto-saved research %s" % state["research_id"],
1841
+ expected_revision=expected_rev
1842
+ )
1843
+ written_page = {
1844
+ "status": "written",
1845
+ "page_id": write_result["page_id"],
1846
+ "revision": write_result["revision"],
1847
+ "markdown_file": write_result.get("markdown_file")
1848
+ }
1849
+ except Exception as exc:
1850
+ conflict = "revision changed" in str(exc).lower()
1851
+ written_page = {
1852
+ "status": "conflict_aborted" if conflict else "failed",
1853
+ "error": str(exc),
1854
+ "skip_reason": "revision_conflict" if conflict else "write_failed"
1855
+ }
1750
1856
  instruction = ("Without asking the user, write or update Wiki pages with the reviewed claims: update a matching "
1751
1857
  "candidate page (read it with wiki_get and pass its expected_revision) or create a new page from "
1752
1858
  "eligible_claims, citing only those claims and marking inference statements as inference. "
@@ -1755,9 +1861,16 @@ class ResearchSessions:
1755
1861
  instruction = ("Present the candidate Wiki pages to update, or a proposed new page built from eligible_claims "
1756
1862
  "(group them by question_id into draft sections; each claim carries exact first quotes and an "
1757
1863
  "inference mark), to the user. Honor existing authorization; otherwise obtain confirmation before writing.")
1758
- return {"policy": policy, "candidates": ranked, "eligible_claim_ids": eligible, "eligible_claims": drafts,
1759
- "draft": self._wiki_page(drafts, state), "instruction": instruction,
1760
- "work": {"tool": "wiki_work", "arguments": {"research_id": state["research_id"]}}}
1864
+ follow_up = {"policy": policy, "candidates": ranked, "eligible_claim_ids": eligible, "eligible_claims": drafts,
1865
+ "draft": draft_page, "instruction": instruction,
1866
+ "work": {"tool": "wiki_work", "arguments": {"research_id": state["research_id"]}}}
1867
+ if written_page:
1868
+ follow_up["written_page"] = written_page
1869
+ follow_up.update(written_page)
1870
+ elif skip_reason:
1871
+ follow_up["status"] = "skipped"
1872
+ follow_up["skip_reason"] = skip_reason
1873
+ return follow_up
1761
1874
 
1762
1875
  @staticmethod
1763
1876
  def _question_gap(question, state):
@@ -87,7 +87,17 @@ class ResearchPlan:
87
87
  if not terms:
88
88
  return [], None
89
89
  try:
90
- return self._wiki().search_terms(terms, limit=limit), None
90
+ results = self._wiki().search_terms(terms, limit=limit)
91
+ if hasattr(self._wiki(), "_log_metric"):
92
+ pids = [row.get("page_id") for row in results if row.get("page_id")]
93
+ self._wiki()._log_metric(
94
+ "plan_research",
95
+ page_id=pids[0] if len(pids) == 1 else None,
96
+ page_ids=pids if len(pids) > 1 else None,
97
+ hit_count=len(results),
98
+ reused=bool(results)
99
+ )
100
+ return results, None
91
101
  except Exception as exc: # local optional check; never hide the bookmark scope
92
102
  return [], "Wiki preflight unavailable: " + str(exc)
93
103
 
package/src/routing.py CHANGED
@@ -201,10 +201,12 @@ class ResearchRouting:
201
201
  if host_search else "always"),
202
202
  "deadline_seconds": settings["search"]["deadline_seconds"],
203
203
  "first_usable_result_wins": True,
204
+ "backflow_tool": "merge_search_results",
204
205
  "note": ("Host-native search is the default discovery channel. Use search_web when it is "
205
206
  "unavailable, returns nothing or insufficient results, or the user asks for provider "
206
207
  "diversity. For latency-sensitive work, issue both together and take the first "
207
- "usable result; do not wait for a slow channel."
208
+ "usable result; do not wait for a slow channel. Host search results can be merged and "
209
+ "archived via merge_search_results."
208
210
  if host_search else
209
211
  "The host has no observed native search; search_web runs its configured providers "
210
212
  "in parallel with fallbacks for unresolved queries.")}
@@ -213,7 +215,8 @@ class ResearchRouting:
213
215
  return {"depth": selected_depth, "host": host, "task_shape": task_shape, "selected": selected,
214
216
  "candidates": candidates, "missing_or_unconfirmed": missing,
215
217
  "host_search": {"available": host_search is not None, "tool": host_search,
216
- "role": "discovery_only; fetch_web remains the evidence read"},
218
+ "role": "discovery_only; fetch_web remains the evidence read",
219
+ "backflow_tool": "merge_search_results"},
217
220
  "discovery": discovery,
218
221
  "next_action": next_action, "workflow": workflow, "research_process": process,
219
222
  "fallback": {"execution_owner": "host", "failed_routes": failed,
@@ -7,6 +7,7 @@ Only snippets are retained; full source text belongs in a later fetch step.
7
7
 
8
8
  from collections import OrderedDict
9
9
  from urllib.parse import urlsplit, urlunsplit
10
+ import hashlib
10
11
  import ipaddress
11
12
  import re
12
13
 
@@ -71,13 +72,27 @@ def _mode(providers, query_count):
71
72
  return "single_provider_multi_query" if query_count > 1 else "single_provider_single_query"
72
73
 
73
74
 
74
- def fuse_results(payload):
75
+ def fuse_results(payload, archive=False, archive_dir=None, settings=None):
75
76
  """Return independently ranked targets, provenance, failures and coverage.
76
77
 
77
78
  Malformed top-level configuration raises ValueError. Invalid batches/results
78
79
  are reported and do not discard valid siblings. Provider counts include
79
80
  failed providers; successful-provider coverage is reported separately.
81
+ Deduplicates URLs and content SHA-256 hashes, with optional archive persistence.
80
82
  """
83
+ if isinstance(payload, dict) and "batches" not in payload and isinstance(payload.get("streams"), list):
84
+ default_target = payload.get("target") or payload.get("query") or "default"
85
+ payload = dict(payload)
86
+ payload["batches"] = []
87
+ for s in payload["streams"]:
88
+ if isinstance(s, dict):
89
+ payload["batches"].append({
90
+ "target": s.get("target", default_target),
91
+ "provider": s.get("provider", "host"),
92
+ "query": s.get("query", payload.get("query", default_target)),
93
+ "status": s.get("status", "ok"),
94
+ "results": s.get("results") if "results" in s else s.get("items", [])
95
+ })
81
96
  if not isinstance(payload, dict) or not isinstance(payload.get("batches"), list):
82
97
  raise ValueError("payload must be an object with a batches array")
83
98
  limit = payload.get("limit_per_target", 10)
@@ -88,6 +103,7 @@ def fuse_results(payload):
88
103
  raise ValueError("targets must be an array of nonempty strings")
89
104
  buckets = OrderedDict((target.strip(), _bucket()) for target in requested)
90
105
  errors, invalid_results = [], []
106
+ total_submitted_results = 0
91
107
 
92
108
  def error(batch_index, message, target=None, provider=None, query=None, kind="invalid_batch"):
93
109
  errors.append({"batch_index": batch_index, "target": target,
@@ -130,6 +146,7 @@ def fuse_results(payload):
130
146
  continue
131
147
  stream["ok"] = True
132
148
  for position, item in enumerate(results, 1):
149
+ total_submitted_results += 1
133
150
  try:
134
151
  if not isinstance(item, dict):
135
152
  raise ValueError("result must be an object")
@@ -146,6 +163,9 @@ def fuse_results(payload):
146
163
  snippet = item.get("text", "")
147
164
  if not isinstance(snippet, str):
148
165
  raise ValueError("text or snippet must be a string")
166
+ content = item.get("content")
167
+ if content is not None and not isinstance(content, str):
168
+ raise ValueError("content must be a string")
149
169
  except ValueError as exc:
150
170
  invalid_results.append({"batch_index": batch_index, "result_position": position,
151
171
  "target": target, "provider": provider, "query": query,
@@ -153,25 +173,36 @@ def fuse_results(payload):
153
173
  "reason": str(exc)})
154
174
  bucket["invalid_count"] += 1
155
175
  continue
176
+
177
+ text_body = content if content is not None else snippet
178
+ digest = hashlib.sha256(text_body.encode("utf-8")).hexdigest() if text_body else None
179
+
156
180
  snippet, truncated = snippet[:MAX_SNIPPET_CHARS], len(snippet) > MAX_SNIPPET_CHARS
157
181
  existing = stream["urls"].get(canonical)
158
182
  if existing is None:
159
183
  stream["urls"][canonical] = {
160
184
  "rank": rank, "raw_urls": {raw_url}, "title": title,
161
185
  "snippet": snippet, "snippet_truncated": truncated,
186
+ "content": content, "sha256": digest,
162
187
  }
163
188
  else:
164
189
  existing["rank"] = min(existing["rank"], rank)
165
190
  existing["raw_urls"].add(raw_url)
166
191
  if title:
167
192
  existing["title"] = min(t for t in (existing["title"], title) if t)
193
+ if content and (not existing.get("content") or len(content) > len(existing["content"])):
194
+ existing["content"] = content
195
+ existing["sha256"] = digest
168
196
  old = (len(existing["snippet"]), existing["snippet"], existing["snippet_truncated"])
169
197
  new = (len(snippet), snippet, truncated)
170
198
  if new > old:
171
199
  existing["snippet"], existing["snippet_truncated"] = snippet, truncated
200
+ if not existing.get("content"):
201
+ existing["sha256"] = digest
172
202
 
173
203
  targets, all_providers, all_successful_providers = [], set(), set()
174
204
  query_count = 0
205
+ all_ranked_by_target = {}
175
206
  for target, bucket in buckets.items():
176
207
  streams = bucket["streams"]
177
208
  by_url, by_provider = {}, {}
@@ -188,17 +219,21 @@ def fuse_results(payload):
188
219
  "provider": provider, "query": query, "rank": hit["rank"],
189
220
  "url": raw_urls[0], "raw_urls": raw_urls, "title": hit["title"],
190
221
  "snippet": hit["snippet"], "snippet_truncated": hit["snippet_truncated"],
222
+ "content": hit.get("content"), "sha256": hit.get("sha256"),
191
223
  })
192
224
  ranked = []
193
225
  for canonical, sources in by_url.items():
194
226
  sources.sort(key=lambda source: (source["rank"], source["provider"], source["query"]))
195
227
  provider_count = len({source["provider"] for source in sources})
228
+ preferred_sha = next((s["sha256"] for s in sources if s.get("sha256")), None)
196
229
  ranked.append({
197
230
  "url": sources[0]["url"], "canonical_url": canonical,
198
231
  "raw_urls": sorted({url for source in sources for url in source["raw_urls"]}),
199
232
  "title": next((source["title"] for source in sources if source["title"]), ""),
233
+ "sha256": preferred_sha,
200
234
  "rrf_score": sum(1.0 / (RRF_K + source["rank"]) for source in sources),
201
235
  "provider_count": provider_count,
236
+ "providers": sorted({source["provider"] for source in sources}),
202
237
  "corroborated": provider_count > 1,
203
238
  "evidence_count": len(sources), "sources": sources,
204
239
  })
@@ -217,6 +252,7 @@ def fuse_results(payload):
217
252
  successful_providers = sorted(p for p, coverage in by_provider.items() if coverage["successful_queries"])
218
253
  for coverage in by_provider.values():
219
254
  coverage["unique_urls"] = len(coverage["unique_urls"])
255
+ all_ranked_by_target[target] = ranked
220
256
  targets.append({
221
257
  "target": target, "status": status, "providers": sorted(by_provider),
222
258
  "provider_count": len(by_provider), "query_count": len(streams),
@@ -232,6 +268,44 @@ def fuse_results(payload):
232
268
  all_providers.update(by_provider)
233
269
  all_successful_providers.update(successful_providers)
234
270
  query_count += len(streams)
271
+
272
+ all_canonical_urls = set()
273
+ sha_to_urls = {}
274
+ for target_items in all_ranked_by_target.values():
275
+ for item in target_items:
276
+ all_canonical_urls.add(item["canonical_url"])
277
+ h = item.get("sha256")
278
+ if h:
279
+ sha_to_urls.setdefault(h, set()).add(item["canonical_url"])
280
+
281
+ content_duplicates = [
282
+ {"sha256": h, "canonical_urls": sorted(urls)}
283
+ for h, urls in sha_to_urls.items() if len(urls) > 1
284
+ ]
285
+ total_ranked = sum(len(items) for items in all_ranked_by_target.values())
286
+ deduplication = {
287
+ "submitted_results": total_submitted_results,
288
+ "unique_canonical_urls": len(all_canonical_urls),
289
+ "unique_content_hashes": len(sha_to_urls),
290
+ "duplicate_urls_merged": max(0, total_submitted_results - total_ranked),
291
+ "cross_target_shared_urls": max(0, total_ranked - len(all_canonical_urls)),
292
+ "content_duplicates": content_duplicates,
293
+ }
294
+
295
+ archive_report = None
296
+ if archive or payload.get("archive") is True:
297
+ from archive import SourceArchive
298
+ from settings import Settings
299
+ target_dir = archive_dir
300
+ if not target_dir:
301
+ if settings:
302
+ target_dir = settings.load()["archive"]["directory"]
303
+ else:
304
+ target_dir = str(Settings.defaults()["archive"]["directory"])
305
+ store = SourceArchive(target_dir)
306
+ all_ranked_hits = [hit for target_items in all_ranked_by_target.values() for hit in target_items]
307
+ archive_report = store.save_search_batch(payload, all_ranked_hits)
308
+
235
309
  return {
236
310
  "provider_count": len(all_providers), "providers": sorted(all_providers),
237
311
  "successful_provider_count": len(all_successful_providers),
@@ -243,5 +317,9 @@ def fuse_results(payload):
243
317
  "independent_fact_confirmation": False},
244
318
  "missing_targets": [item["target"] for item in targets if item["status"] == "missing"],
245
319
  "uncovered_targets": [item["target"] for item in targets if not item["results"]],
246
- "targets": targets, "errors": errors, "invalid_results": invalid_results,
320
+ "targets": targets,
321
+ "errors": errors, "invalid_results": invalid_results,
322
+ "deduplication": deduplication,
323
+ "archive": archive_report if archive_report is not None else {"status": "disabled"},
324
+ "backflow_contract": "normalized_host_search_v1",
247
325
  }