bookmark-research 0.5.0-beta.1

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 (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. package/src/wiki.py +383 -0
@@ -0,0 +1,684 @@
1
+ """Thin provider-run references and evidence import; providers own execution.
2
+
3
+ No polling loop, worker pool or host-agent scheduler lives here. Each call either
4
+ prepares a concrete request, starts it once, observes its existing provider ID,
5
+ or imports a saved report. Lost create responses never trigger resubmission.
6
+ """
7
+
8
+ import copy
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import tempfile
14
+ import uuid
15
+ from pathlib import Path
16
+
17
+ from research import ResearchSessions
18
+ from service_http import ResearchHttp, ServiceError
19
+ from settings import Settings
20
+ from credentials import Credentials
21
+
22
+
23
+ class ResearchServices:
24
+ DOCS = {
25
+ "openai": "https://developers.openai.com/api/docs/guides/deep-research",
26
+ "parallel": "https://docs.parallel.ai/task-api/guides/execute-task-run",
27
+ }
28
+ ACTIVE = ("pending", "queued", "in_progress", "unknown_outcome")
29
+
30
+ def __init__(self, directory=None, settings=None, research_sessions=None, transport=None):
31
+ self.settings = settings if settings is not None else Settings()
32
+ self.credentials = Credentials(self.settings)
33
+ self.directory = Settings.external_path(
34
+ str(directory or (Settings.data_directory() / "service-runs")), "Research service directory")
35
+ self.sessions = research_sessions if research_sessions is not None else ResearchSessions(settings=self.settings)
36
+ self.transport = transport
37
+
38
+ @staticmethod
39
+ def _now():
40
+ return ResearchSessions._now()
41
+
42
+ @staticmethod
43
+ def _text(value, label, maximum=12000):
44
+ return ResearchSessions._text(value, label, maximum)
45
+
46
+ @staticmethod
47
+ def _identifier(value, label):
48
+ return ResearchSessions._identifier(value, label)
49
+
50
+ @staticmethod
51
+ def _json(value):
52
+ return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":"))
53
+
54
+ def _provider(self, value):
55
+ provider = self.settings.load()["professional_research"]["provider"] if value is None else value
56
+ if not isinstance(provider, str) or provider not in self.DOCS:
57
+ raise ValueError("Select openai or parallel for professional research")
58
+ return provider
59
+
60
+ def _path(self, external_id):
61
+ if not isinstance(external_id, str) or not re.fullmatch(r"er-[a-f0-9]{24}", external_id):
62
+ raise ValueError("Invalid external_id")
63
+ path = self.directory / external_id
64
+ if path.is_symlink() or path.resolve().parent != self.directory:
65
+ raise ValueError("Research service record must stay in its data directory")
66
+ return Settings.external_path(str(path), "Research service record")
67
+
68
+ @staticmethod
69
+ def _remote_id(provider, run_id):
70
+ pattern = r"resp_[A-Za-z0-9_-]{1,192}" if provider == "openai" else r"trun_[A-Za-z0-9_-]{1,192}"
71
+ if not isinstance(run_id, str) or not re.fullmatch(pattern, run_id):
72
+ raise ValueError("Invalid provider run ID")
73
+ return run_id
74
+
75
+ @staticmethod
76
+ def _external_id(research_id, operation_id):
77
+ return "er-" + hashlib.sha256((research_id + "\0" + operation_id).encode()).hexdigest()[:24]
78
+
79
+ @staticmethod
80
+ def _save(path, state):
81
+ state["updated_at"] = ResearchServices._now()
82
+ ResearchSessions._write(path / "state.json", state)
83
+
84
+ @staticmethod
85
+ def _write_bytes(path, value):
86
+ """Keep report line endings and UTF-8 bytes unchanged on every OS."""
87
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
88
+ temporary = None
89
+ try:
90
+ with tempfile.NamedTemporaryFile(mode="wb", dir=path.parent, prefix=".service-", delete=False) as stream:
91
+ temporary = Path(stream.name)
92
+ stream.write(value)
93
+ stream.flush()
94
+ os.fsync(stream.fileno())
95
+ temporary.replace(path)
96
+ finally:
97
+ if temporary is not None:
98
+ temporary.unlink(missing_ok=True)
99
+
100
+ @staticmethod
101
+ def _local_error(kind, message):
102
+ return {"error_kind": kind, "error": message}
103
+
104
+ def _run_data(self, provider, response):
105
+ if not isinstance(response, dict):
106
+ raise ValueError("Invalid research API response")
107
+ data = response.get("run", response) if provider == "parallel" else response
108
+ if not isinstance(data, dict):
109
+ raise ValueError("Invalid research API run response")
110
+ return data
111
+
112
+ def _capture_create_id(self, state, response):
113
+ """A valid create ID remains useful even if its report cannot be parsed."""
114
+ if state.get("provider_run_id"):
115
+ return
116
+ try:
117
+ data = self._run_data(state["provider"], response)
118
+ state["provider_run_id"] = self._remote_id(state["provider"], data.get("id" if state["provider"] == "openai" else "run_id"))
119
+ except ValueError:
120
+ pass
121
+
122
+ def _receipt(self, path, relative):
123
+ target = ResearchSessions._artifact(path, relative)
124
+ if target.stat().st_size > 2 * ResearchHttp.MAX_RESPONSE_BYTES:
125
+ raise ValueError("Saved research response exceeds the local size limit")
126
+ return json.loads(target.read_bytes(), object_pairs_hook=ResearchHttp._unique_object,
127
+ parse_constant=ResearchHttp._invalid_constant, parse_float=ResearchHttp._finite_float)
128
+
129
+ def _load(self, external_id):
130
+ path = self._path(external_id)
131
+ state_path = path / "state.json"
132
+ if state_path.is_symlink():
133
+ raise ValueError("Research service state must not be a symlink")
134
+ state = json.loads(state_path.read_text(encoding="utf-8"))
135
+ if state.get("schema_version") != 1 or state.get("external_id") != external_id:
136
+ raise ValueError("Inconsistent research service record")
137
+ receipt = path / "create-receipt.json"
138
+ if (state["status"] in ("pending", "unknown_outcome") and not state.get("attached_at")
139
+ and not state.get("create_response_applied", bool(state.get("provider_run_id")))):
140
+ if receipt.is_file():
141
+ matched = False
142
+ try:
143
+ value = self._receipt(path, "create-receipt.json")
144
+ if not isinstance(value, dict) or value.get("external_id") != external_id or value.get("digest") != state["digest"]:
145
+ raise ValueError("Create receipt does not match research request")
146
+ response = value["response"]
147
+ matched = True
148
+ self._apply_response(path, state, response, "create-receipt.json", value.get("received_at"))
149
+ except (ValueError, OSError, KeyError, TypeError):
150
+ if matched:
151
+ self._capture_create_id(state, response)
152
+ state["status"] = "unknown_outcome"
153
+ state["observation_error"] = self._local_error("create_receipt_error", "Saved create receipt could not be verified or interpreted; no request was repeated")
154
+ elif state["status"] == "pending":
155
+ state["status"] = "unknown_outcome"
156
+ state["observation_error"] = self._local_error("create_interrupted", "Create outcome is unknown; no provider request was repeated")
157
+ pending = state.get("pending_observation")
158
+ if pending:
159
+ try:
160
+ value = self._receipt(path, pending["response_file"])
161
+ if (not isinstance(value, dict) or value.get("external_id") != external_id
162
+ or value.get("provider_run_id") != state.get("provider_run_id") or value.get("action") != pending["action"]):
163
+ raise ValueError("Observation receipt does not match the existing run")
164
+ self._apply_response(path, state, value["response"], pending["response_file"], value.get("received_at"))
165
+ state.pop("pending_observation", None)
166
+ except (ValueError, OSError, KeyError, TypeError):
167
+ state["observation_error"] = self._local_error("observation_receipt_error", "An interrupted observation could not be recovered; the last provider state is retained")
168
+ return path, state
169
+
170
+ def _request(self, provider, method, endpoint, payload=None):
171
+ client = self.transport or ResearchHttp(self.settings.load()["timeout_seconds"], credentials=self.credentials)
172
+ return client.request(provider, method, endpoint, payload)
173
+
174
+ def _availability(self, provider, config):
175
+ key = ResearchHttp.PROVIDERS[provider]["key_env"]
176
+ missing = ([] if config["enabled"] else ["professional_research.enabled"])
177
+ missing += [] if self.credentials.get(key) else [key]
178
+ return {"ready_to_start": not missing, "missing": missing, "authentication_verified": False}
179
+
180
+ def describe(self, provider=None):
181
+ config = self.settings.load()["professional_research"]
182
+ providers = [self._provider(provider)] if provider is not None else list(self.DOCS)
183
+ return {"enabled": config["enabled"], "default_provider": config["provider"], "network_checked": False,
184
+ "providers": [{"provider": name, "docs": self.DOCS[name],
185
+ **self._availability(name, config),
186
+ "credential_env": ResearchHttp.PROVIDERS[name]["key_env"],
187
+ "credential_configured": bool(self.credentials.get(ResearchHttp.PROVIDERS[name]["key_env"])),
188
+ "authentication_verified": False, "defaults": config[name],
189
+ "cancel_supported": name == "openai",
190
+ "budget_controls": ["max_tool_calls"] if name == "openai" else ["processor"]}
191
+ for name in providers],
192
+ "execution": "Provider-managed runs; observe explicitly by run ID. No local worker or automatic retry.",
193
+ "workflow": ["research_start", "research_service_prepare", "research_service_start",
194
+ "research_service_status", "research_service_result", "research_service_import"],
195
+ "native_research": {"available": True, "entrypoint": "research_start",
196
+ "execution_owner": "host", "requires_service_credentials": False},
197
+ "cost_note": "Tool-count/processor controls are not a monetary cap. Host-native research tools remain usable independently."}
198
+
199
+ def prepare(self, research_id, input, provider=None, question_id=None, inventory_ids=None, options=None):
200
+ provider = self._provider(provider)
201
+ input = self._text(input, "Research service input", 50000)
202
+ _, state = self.sessions._load(research_id)
203
+ ResearchSessions._active(state)
204
+ if question_id is not None:
205
+ ResearchSessions._find(state["questions"], question_id, "question")
206
+ options = {} if options is None else copy.deepcopy(options)
207
+ if not isinstance(options, dict):
208
+ raise ValueError("Research service options must be an object")
209
+ allowed = {"model", "max_tool_calls", "store", "vector_store_ids"} if provider == "openai" else {"processor", "output_schema"}
210
+ if set(options) - allowed:
211
+ raise ValueError("Unsupported " + provider + " research options")
212
+ source_scope = copy.deepcopy(state.get("source_scope", {}))
213
+ selected = source_scope.get("selected_inventory_ids", [])
214
+ if inventory_ids is not None:
215
+ if (not isinstance(inventory_ids, list) or any(not isinstance(x, str) for x in inventory_ids)
216
+ or len(set(inventory_ids)) != len(inventory_ids) or set(inventory_ids) - set(selected)):
217
+ raise ValueError("inventory_ids must select distinct sources within the frozen research scope")
218
+ selected = inventory_ids[:]
219
+ sources, local_only = [], []
220
+ selected_set = set(selected)
221
+ # _load verified the frozen inventory. Read its complete entries here:
222
+ # the public inventory API intentionally limits ID filters and may
223
+ # replace oversized context with an artifact pointer in a single page.
224
+ for item in state.get("inventory", []):
225
+ if item["id"] not in selected_set:
226
+ continue
227
+ url = item.get("retrieval_url")
228
+ if isinstance(url, str) and url.startswith(("https://", "http://")):
229
+ sources.append({"source_id": item["id"], "url": url})
230
+ else:
231
+ local_only.append(item["id"])
232
+ shared = {"research_question": input, "sources": sources,
233
+ "local_only_source_ids": local_only,
234
+ "requirements": "Investigate the question using the supplied source scope. Identify unread or inaccessible sources and contradictions. Return a cited report; do not claim coverage for sources not actually reviewed."}
235
+ input_text = self._json(shared)
236
+ if len(input_text) > 1_000_000:
237
+ raise ValueError("Research input exceeds the API adapter size limit; explicitly split the investigation without shrinking its recorded scope")
238
+ config = self.settings.load()["professional_research"]
239
+ defaults = config[provider]
240
+ if provider == "openai":
241
+ model = options.get("model", defaults["model"])
242
+ maximum = options.get("max_tool_calls", defaults["max_tool_calls"])
243
+ if model not in ("o3-deep-research", "o4-mini-deep-research"):
244
+ raise ValueError("Use a documented OpenAI deep research model")
245
+ if type(maximum) is not int or not 1 <= maximum <= 1000:
246
+ raise ValueError("max_tool_calls must be between 1 and 1000")
247
+ store = options.get("store", False)
248
+ if type(store) is not bool:
249
+ raise ValueError("store must be boolean")
250
+ tools = [{"type": "web_search_preview"}]
251
+ if "vector_store_ids" in options:
252
+ stores = options["vector_store_ids"]
253
+ if (not isinstance(stores, list) or not 1 <= len(stores) <= 2
254
+ or any(not isinstance(x, str) or not re.fullmatch(r"vs_[A-Za-z0-9_-]+", x) for x in stores)):
255
+ raise ValueError("vector_store_ids must contain one or two remote OpenAI vector store IDs")
256
+ tools.append({"type": "file_search", "vector_store_ids": stores})
257
+ payload = {"model": model, "input": input_text, "background": True, "store": store,
258
+ "tools": tools, "max_tool_calls": maximum}
259
+ endpoint = "/v1/responses"
260
+ else:
261
+ processor = options.get("processor", defaults["processor"])
262
+ if not isinstance(processor, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", processor):
263
+ raise ValueError("Invalid Parallel processor")
264
+ schema = options.get("output_schema", "A Markdown research report with citations, contradictions, and an explicit source coverage/gap section.")
265
+ schema = self._text(schema, "Parallel output schema", 12000)
266
+ payload = {"processor": processor, "input": input_text, "task_spec": {"output_schema": schema}}
267
+ endpoint = "/v1/tasks/runs"
268
+ return {"research_id": research_id, "provider": provider, "question_id": question_id,
269
+ **self._availability(provider, config),
270
+ "next_action": "research_service_start" if self._availability(provider, config)["ready_to_start"]
271
+ else "Configure the listed missing settings/credential in the host environment before starting this service.",
272
+ "endpoint": endpoint, "payload": payload,
273
+ "source_scope": {"input_version": source_scope.get("input_version"),
274
+ "inventory_ids": selected, "shared_url_count": len(sources),
275
+ "local_only_inventory_ids": local_only,
276
+ "remaining_inventory_ids": [x for x in source_scope.get("selected_inventory_ids", []) if x not in selected_set]},
277
+ "network_performed": False,
278
+ "data_note": "Only the explicit input and source URLs are sent. Local paths, bookmark labels, notes and files are not uploaded. Imported reports do not count as reading their cited pages."}
279
+
280
+ def _publish(self, path, state, strict=False):
281
+ entry = {"kind": "external_run", "id": state["external_id"], "provider": state["provider"],
282
+ "run_id": state.get("provider_run_id") or state["external_id"],
283
+ "status": state["status"],
284
+ "text": "Provider-run observation; " + ("provider ID verified from a response or explicitly attached." if state.get("provider_run_id") else "create outcome has no known provider ID; no local worker is implied."),
285
+ "result": {"provider_status": state.get("provider_status"), "observed_at": state.get("observed_at"),
286
+ "source_scope": state.get("source_scope"), "report_path": state.get("report_path"),
287
+ "usage": state.get("usage"), "observation_error": state.get("observation_error")}}
288
+ if state.get("response_file"):
289
+ entry["artifact_path"] = str(path / state["response_file"])
290
+ try:
291
+ self.sessions.record(state["research_id"], entry)
292
+ state.pop("research_record_error", None)
293
+ except (ValueError, OSError, RuntimeError) as error:
294
+ if strict:
295
+ raise
296
+ state["research_record_error"] = "Provider observation saved, but research record could not be updated: " + type(error).__name__
297
+
298
+ @staticmethod
299
+ def _next_action(state):
300
+ external_id = state["external_id"]
301
+ if state["status"] in ResearchServices.ACTIVE:
302
+ if state.get("provider_run_id"):
303
+ return {"tool": "research_service_status", "arguments": {"external_id": external_id, "refresh": True},
304
+ "poll_after_seconds": 15}
305
+ return {"tool": "research_service_attach", "requires": ["known provider run_id"],
306
+ "reason": "The create outcome is unknown. Recover the existing provider ID; do not submit another run."}
307
+ if state["status"] == "completed":
308
+ return {"tool": "research_service_result",
309
+ "arguments": {"external_id": external_id, "refresh": not bool(state.get("report_path"))},
310
+ "then": "Read all report pages, import with research_service_import, and verify cited source pages."}
311
+ if state["status"] == "error":
312
+ return {"tool": "research_route", "arguments": {"depth": "deep",
313
+ "failed_routes": ["professional_service:" + state["provider"]]},
314
+ "research_id": state["research_id"],
315
+ "reason": "Confirmed failure: add current host observations and earlier failed_routes, then continue the same research. Preserve an explicit user provider choice instead of silently switching."}
316
+ return {"tool": "research_status", "arguments": {"research_id": state["research_id"]},
317
+ "reason": "Inspect the cancellation and remaining evidence gaps; cancellation does not trigger a replacement run."}
318
+
319
+ @staticmethod
320
+ def _summary(path, state):
321
+ fields = ("external_id", "research_id", "operation_id", "provider", "provider_run_id", "status",
322
+ "provider_status", "created_at", "updated_at", "observed_at", "source_scope", "report_path",
323
+ "report_sha256", "response_sha256", "request_sha256", "citations_file", "citations_sha256", "citation_count",
324
+ "usage", "observation_error", "research_record_error", "imported_evidence")
325
+ return {**{key: copy.deepcopy(state.get(key)) for key in fields}, "directory": str(path),
326
+ "next_action": ResearchServices._next_action(state),
327
+ "request_path": str(path / "request.json") if (path / "request.json").is_file() else None,
328
+ "observations": len(state.get("observations", [])),
329
+ "local_worker_running": False,
330
+ "note": "Status is the last observation. Refresh observes the same provider run; a timeout never recreates it."}
331
+
332
+ def _intent(self, input, provider, question_id, inventory_ids, options):
333
+ value = {"input": self._text(input, "Research service input", 50000), "provider": provider,
334
+ "question_id": question_id, "inventory_ids": inventory_ids, "options": {} if options is None else options}
335
+ if provider is not None and (not isinstance(provider, str) or provider not in self.DOCS):
336
+ raise ValueError("Select openai or parallel for professional research")
337
+ if not isinstance(value["options"], dict):
338
+ raise ValueError("Research service options must be an object")
339
+ if question_id is not None:
340
+ self._identifier(question_id, "question_id")
341
+ if inventory_ids is not None and (not isinstance(inventory_ids, list)
342
+ or any(not isinstance(item, str) for item in inventory_ids) or len(set(inventory_ids)) != len(inventory_ids)):
343
+ raise ValueError("inventory_ids must contain distinct source IDs")
344
+ try:
345
+ return json.loads(self._json(value))
346
+ except (ValueError, TypeError):
347
+ raise ValueError("Research service arguments must contain valid JSON") from None
348
+
349
+ def _replay_matches(self, path, state, intent, intent_digest):
350
+ if state.get("intent_digest") is not None:
351
+ return state["intent_digest"] == intent_digest
352
+ # Read old records against their frozen request, never today's defaults.
353
+ if not state.get("digest") or not (path / "request.json").is_file():
354
+ return False
355
+ request = self._receipt(path, "request.json")
356
+ if hashlib.sha256(self._json(request).encode()).hexdigest() != state["digest"]:
357
+ raise ValueError("Saved research request hash does not match")
358
+ if intent["provider"] not in (None, state["provider"]) or intent["question_id"] != request.get("question_id"):
359
+ return False
360
+ if intent["input"] != json.loads(request["payload"]["input"])["research_question"]:
361
+ return False
362
+ if intent["inventory_ids"] is not None and intent["inventory_ids"] != request["source_scope"]["inventory_ids"]:
363
+ return False
364
+ for key, value in intent["options"].items():
365
+ payload = request["payload"]
366
+ old = payload.get("task_spec", {}).get("output_schema") if key == "output_schema" else payload.get(key)
367
+ if key == "vector_store_ids":
368
+ old = next((tool.get(key) for tool in payload.get("tools", []) if tool.get("type") == "file_search"), None)
369
+ if old != value:
370
+ return False
371
+ return True
372
+
373
+ def start(self, research_id, operation_id, input, provider=None, question_id=None, inventory_ids=None, options=None):
374
+ operation_id = self._identifier(operation_id, "operation_id")
375
+ self.sessions._path(research_id)
376
+ intent = self._intent(input, provider, question_id, inventory_ids, options)
377
+ intent_digest = hashlib.sha256(self._json(intent).encode()).hexdigest()
378
+ external_id = self._external_id(research_id, operation_id)
379
+ path = self._path(external_id)
380
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
381
+ with Settings._update_lock(path / "state.json"):
382
+ if (path / "state.json").exists():
383
+ _, state = self._load(external_id)
384
+ if not self._replay_matches(path, state, intent, intent_digest):
385
+ raise ValueError("operation_id already refers to a different research service request")
386
+ self._publish(path, state)
387
+ self._save(path, state)
388
+ return {**self._summary(path, state), "replayed": True}
389
+ request = self.prepare(research_id, input, provider, question_id, inventory_ids, options)
390
+ provider = request["provider"]
391
+ digest = hashlib.sha256(self._json(request).encode()).hexdigest()
392
+ if not self.settings.load()["professional_research"]["enabled"]:
393
+ raise ValueError("Professional research is disabled; enable professional_research.enabled to start API runs")
394
+ if not self.credentials.get(ResearchHttp.PROVIDERS[provider]["key_env"]):
395
+ raise ServiceError("authentication_required", "Configure " + ResearchHttp.PROVIDERS[provider]["key_env"] + " with setup or in the host environment")
396
+ state = {"schema_version": 1, "external_id": external_id, "research_id": research_id,
397
+ "operation_id": operation_id, "provider": provider, "provider_run_id": None,
398
+ "question_id": question_id, "digest": digest, "intent_digest": intent_digest, "status": "pending",
399
+ "create_response_applied": False,
400
+ "source_scope": request["source_scope"], "created_at": self._now(), "observations": []}
401
+ ResearchSessions._write(path / "request.json", request)
402
+ state["request_sha256"] = hashlib.sha256((path / "request.json").read_bytes()).hexdigest()
403
+ self._save(path, state)
404
+ try:
405
+ self._publish(path, state, strict=True)
406
+ except (ValueError, OSError, RuntimeError):
407
+ state["status"] = "error"
408
+ state["observation_error"] = {"error_kind": "local_record_failed", "error": "Research record rejected before the API call"}
409
+ self._save(path, state)
410
+ raise
411
+ result = None
412
+ try:
413
+ result = self._request(provider, "POST", request["endpoint"], request["payload"])
414
+ ResearchSessions._write(path / "create-receipt.json",
415
+ {"external_id": external_id, "digest": digest, "received_at": self._now(), "response": result})
416
+ self._apply_response(path, state, result, "create-receipt.json")
417
+ except ServiceError as error:
418
+ state["status"] = "unknown_outcome" if error.uncertain else "error"
419
+ state["observation_error"] = error.details()
420
+ except (OSError, ValueError, TypeError):
421
+ # The provider may already have accepted this request.
422
+ state["status"] = "unknown_outcome"
423
+ if result is not None:
424
+ self._capture_create_id(state, result)
425
+ state["observation_error"] = {"error_kind": "local_result_error",
426
+ "error": "Create result could not be saved or interpreted"}
427
+ self._publish(path, state)
428
+ self._save(path, state)
429
+ return {**self._summary(path, state), "replayed": False}
430
+
431
+ def _apply_response(self, path, state, response, response_file, observed_at=None):
432
+ data = self._run_data(state["provider"], response)
433
+ run_id = data.get("run_id" if state["provider"] == "parallel" else "id")
434
+ run_id = self._remote_id(state["provider"], run_id)
435
+ if state.get("provider_run_id") not in (None, run_id):
436
+ raise ValueError("Response belongs to a different provider run")
437
+ remote_status = data.get("status")
438
+ if not isinstance(remote_status, str) or not remote_status or len(remote_status) > 100:
439
+ raise ValueError("Invalid research API run status")
440
+ states = {"queued": "queued", "running": "in_progress", "in_progress": "in_progress",
441
+ "action_required": "in_progress", "cancelling": "in_progress", "completed": "completed",
442
+ "failed": "error", "incomplete": "error", "cancelled": "cancelled"}
443
+ candidate = copy.deepcopy(state)
444
+ candidate["provider_run_id"] = run_id
445
+ candidate["create_response_applied"] = True
446
+ candidate["status"] = states.get(remote_status, "unknown_outcome")
447
+ candidate["provider_status"] = remote_status
448
+ candidate["observed_at"] = observed_at or self._now()
449
+ candidate["response_file"] = response_file
450
+ candidate["response_sha256"] = hashlib.sha256(ResearchSessions._artifact(path, response_file).read_bytes()).hexdigest()
451
+ candidate.pop("observation_error", None)
452
+ candidate["usage"] = copy.deepcopy(response.get("usage", data.get("usage")))
453
+ if candidate["usage"] is not None and not isinstance(candidate["usage"], dict):
454
+ raise ValueError("Invalid provider usage metadata")
455
+ self._json(candidate["usage"])
456
+ report, citations = self._report(state["provider"], response)
457
+ if report:
458
+ raw = report.encode("utf-8")
459
+ report_file = "reports/" + hashlib.sha256(raw).hexdigest() + ".md"
460
+ self._write_bytes(ResearchSessions._artifact(path, report_file), raw)
461
+ citation_bytes = (self._json(citations) + "\n").encode("utf-8")
462
+ citation_file = "reports/" + hashlib.sha256(citation_bytes).hexdigest() + "-citations.json"
463
+ self._write_bytes(ResearchSessions._artifact(path, citation_file), citation_bytes)
464
+ candidate["report_path"] = str(path / report_file)
465
+ candidate["report_sha256"] = hashlib.sha256(raw).hexdigest()
466
+ candidate["citations_file"] = str(path / citation_file)
467
+ candidate["citations_sha256"] = hashlib.sha256(citation_bytes).hexdigest()
468
+ candidate["citation_count"] = len(citations)
469
+ candidate.pop("citations", None)
470
+ candidate.setdefault("observations", []).append(
471
+ {"observed_at": candidate["observed_at"], "provider_status": remote_status, "response_file": response_file,
472
+ "response_sha256": candidate["response_sha256"]})
473
+ # A parser/storage failure must never leave a half-applied terminal
474
+ # state, a replacement run ID or mismatched report metadata behind.
475
+ state.clear()
476
+ state.update(candidate)
477
+
478
+ @staticmethod
479
+ def _report(provider, response):
480
+ texts, citations = [], []
481
+ def rows(value, label):
482
+ if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
483
+ raise ValueError("Invalid " + label + " in research API response")
484
+ return value
485
+ if provider == "openai":
486
+ for item in rows(response.get("output", []), "output"):
487
+ if item.get("type") != "message":
488
+ continue
489
+ for content in rows(item.get("content", []), "message content"):
490
+ if content.get("type") != "output_text":
491
+ continue
492
+ if not isinstance(content.get("text"), str):
493
+ raise ValueError("Invalid output text in research API response")
494
+ texts.append(content["text"])
495
+ for annotation in rows(content.get("annotations", []), "annotations"):
496
+ if annotation.get("type") == "url_citation":
497
+ citations.append(copy.deepcopy(annotation))
498
+ else:
499
+ output = response.get("output")
500
+ if "run" in response and not isinstance(output, dict):
501
+ raise ValueError("Parallel result must contain its run and output objects")
502
+ if output is not None:
503
+ if not isinstance(output, dict):
504
+ raise ValueError("Invalid Parallel result output")
505
+ content = output.get("content")
506
+ if isinstance(content, str):
507
+ texts.append(content)
508
+ elif isinstance(content, dict):
509
+ texts.append(json.dumps(content, ensure_ascii=False, allow_nan=False, indent=2))
510
+ else:
511
+ raise ValueError("Invalid Parallel result content")
512
+ for basis in rows(output.get("basis", []), "research basis"):
513
+ for citation in rows(basis.get("citations", []), "basis citations"):
514
+ citations.append(copy.deepcopy(citation))
515
+ return "\n\n".join(texts), citations
516
+
517
+ @staticmethod
518
+ def _read_saved(path, file, expected_hash):
519
+ target = Path(file)
520
+ if target.is_symlink() or path not in target.resolve().parents:
521
+ raise ValueError("Research artifact must stay within its run directory")
522
+ raw = target.read_bytes()
523
+ if hashlib.sha256(raw).hexdigest() != expected_hash:
524
+ raise ValueError("Saved research artifact hash does not match")
525
+ return raw
526
+
527
+ def _read_report(self, path, state):
528
+ if not state.get("report_path"):
529
+ return ""
530
+ return self._read_saved(path, state["report_path"], state["report_sha256"]).decode("utf-8")
531
+
532
+ def _citations(self, path, state):
533
+ if state.get("citations_file"):
534
+ citations = json.loads(self._read_saved(path, state["citations_file"], state["citations_sha256"]))
535
+ else:
536
+ citations = state.get("citations", [])
537
+ if not isinstance(citations, list):
538
+ raise ValueError("Invalid saved provider citations")
539
+ return citations
540
+
541
+ def _citation_preview(self, citations, byte_limit=100000):
542
+ selected, consumed = [], 0
543
+ for citation in citations[:100]:
544
+ size = len(self._json(citation).encode("utf-8"))
545
+ if consumed + size > byte_limit:
546
+ break
547
+ selected.append(citation)
548
+ consumed += size
549
+ return selected
550
+
551
+ def status(self, external_id, refresh=False):
552
+ path = self._path(external_id)
553
+ with Settings._update_lock(path / "state.json"):
554
+ _, state = self._load(external_id)
555
+ if refresh:
556
+ self._observe(path, state, "status")
557
+ self._publish(path, state)
558
+ self._save(path, state)
559
+ return self._summary(path, state)
560
+
561
+ def _observe(self, path, state, action):
562
+ run_id = state.get("provider_run_id")
563
+ if not run_id:
564
+ raise ValueError("No provider run ID is known; attach an independently verified ID instead of restarting")
565
+ run_id = self._remote_id(state["provider"], run_id)
566
+ prefix = "/v1/responses/" if state["provider"] == "openai" else "/v1/tasks/runs/"
567
+ endpoint, method, payload = prefix + run_id, "GET", None
568
+ if action == "cancel":
569
+ if state["provider"] != "openai":
570
+ raise ValueError("Parallel Task cancellation is not established by this adapter; remote state is unchanged")
571
+ endpoint, method, payload = endpoint + "/cancel", "POST", {}
572
+ elif action == "result" and state["provider"] == "parallel":
573
+ endpoint += "/result?timeout=1"
574
+ response_file = "observations/" + uuid.uuid4().hex + ".json"
575
+ state["pending_observation"] = {"action": action, "response_file": response_file, "started_at": self._now()}
576
+ self._save(path, state)
577
+ try:
578
+ result = self._request(state["provider"], method, endpoint, payload)
579
+ ResearchSessions._write(ResearchSessions._artifact(path, response_file), {
580
+ "external_id": state["external_id"], "provider_run_id": run_id, "action": action,
581
+ "received_at": self._now(), "response": result})
582
+ self._apply_response(path, state, result, response_file)
583
+ state.pop("pending_observation", None)
584
+ except ServiceError as error:
585
+ # This is an observation failure, not a provider terminal status.
586
+ state["observation_error"] = error.details()
587
+ state.setdefault("observations", []).append({"observed_at": self._now(), "action": action,
588
+ "observation_error": error.details()})
589
+ state.pop("pending_observation", None)
590
+ except (ValueError, OSError, TypeError):
591
+ state["observation_error"] = {"error_kind": "local_result_error",
592
+ "error": "Provider observation could not be saved or interpreted"}
593
+
594
+ def result(self, external_id, refresh=False, offset=0, limit=12000):
595
+ ResearchSessions._integer(offset, "offset", 0, 10000000)
596
+ ResearchSessions._integer(limit, "limit", 1, 50000)
597
+ path = self._path(external_id)
598
+ with Settings._update_lock(path / "state.json"):
599
+ _, state = self._load(external_id)
600
+ if refresh:
601
+ self._observe(path, state, "result")
602
+ self._publish(path, state)
603
+ self._save(path, state)
604
+ text = self._read_report(path, state)
605
+ citations = self._citations(path, state)
606
+ preview = self._citation_preview(citations)
607
+ return {**self._summary(path, state), "text": text[offset:offset + limit], "offset": offset,
608
+ "total_characters": len(text), "next_offset": offset + limit if offset + limit < len(text) else None,
609
+ "citations": preview, "citation_count": len(citations), "citations_truncated": len(preview) < len(citations),
610
+ "citations_verified": False}
611
+
612
+ def cancel(self, external_id):
613
+ path = self._path(external_id)
614
+ with Settings._update_lock(path / "state.json"):
615
+ _, state = self._load(external_id)
616
+ if state["status"] in self.ACTIVE:
617
+ self._observe(path, state, "cancel")
618
+ self._publish(path, state)
619
+ self._save(path, state)
620
+ return self._summary(path, state)
621
+
622
+ def attach(self, research_id, operation_id, provider, run_id, question_id=None):
623
+ provider = self._provider(provider)
624
+ operation_id = self._identifier(operation_id, "operation_id")
625
+ run_id = self._remote_id(provider, run_id)
626
+ self.sessions._path(research_id)
627
+ external_id = self._external_id(research_id, operation_id)
628
+ path = self._path(external_id)
629
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
630
+ with Settings._update_lock(path / "state.json"):
631
+ existed = (path / "state.json").exists()
632
+ if existed:
633
+ _, state = self._load(external_id)
634
+ if state["provider"] != provider or state.get("provider_run_id") not in (None, run_id):
635
+ raise ValueError("Existing operation refers to a different provider run")
636
+ if question_id is not None and state.get("question_id") not in (None, question_id):
637
+ raise ValueError("Existing operation belongs to another research question")
638
+ if question_id is not None and state.get("question_id") is None:
639
+ _, research = self.sessions._load(research_id)
640
+ ResearchSessions._find(research["questions"], question_id, "question")
641
+ state["question_id"] = question_id
642
+ state["provider_run_id"] = run_id
643
+ else:
644
+ _, research = self.sessions._load(research_id)
645
+ ResearchSessions._active(research)
646
+ if question_id is not None:
647
+ ResearchSessions._find(research["questions"], question_id, "question")
648
+ state = {"schema_version": 1, "external_id": external_id, "research_id": research_id,
649
+ "operation_id": operation_id, "provider": provider, "provider_run_id": run_id,
650
+ "question_id": question_id, "digest": None, "status": "unknown_outcome",
651
+ "created_at": self._now(), "source_scope": None, "observations": []}
652
+ state["attached_at"] = self._now()
653
+ self._publish(path, state, strict=not existed)
654
+ self._save(path, state)
655
+ return self._summary(path, state)
656
+
657
+ def import_result(self, external_id, operation_id, question_id=None):
658
+ operation_id = self._identifier(operation_id, "operation_id")
659
+ path = self._path(external_id)
660
+ with Settings._update_lock(path / "state.json"):
661
+ _, state = self._load(external_id)
662
+ if state["status"] != "completed" or not state.get("report_path"):
663
+ raise ValueError("A completed, saved provider report is required for import")
664
+ question_id = question_id if question_id is not None else state.get("question_id")
665
+ if question_id is None:
666
+ raise ValueError("question_id is required to import a report")
667
+ text = self._read_report(path, state)
668
+ citations = self._citations(path, state)
669
+ endpoint = "/v1/responses/" if state["provider"] == "openai" else "/v1/tasks/runs/"
670
+ url = ResearchHttp.PROVIDERS[state["provider"]]["origin"] + endpoint + self._remote_id(state["provider"], state["provider_run_id"])
671
+ provenance = {"kind": "external_report", "provider": state["provider"], "run_id": state["provider_run_id"],
672
+ "external_id": external_id, "artifact_hash": state["report_sha256"], "text_sha256": state["report_sha256"],
673
+ "citations_verified": False, "provider_citations": self._citation_preview(citations, byte_limit=16000),
674
+ "provider_citation_count": len(citations)}
675
+ if state.get("citations_file"):
676
+ provenance.update(provider_citations_file=state["citations_file"], provider_citations_sha256=state["citations_sha256"])
677
+ self._publish(path, state)
678
+ evidence = self.sessions.import_evidence(state["research_id"], operation_id, question_id, url, text, provenance,
679
+ inventory_ids=(state.get("source_scope") or {}).get("inventory_ids", []),
680
+ title=state["provider"] + " research report")
681
+ state["imported_evidence"] = evidence
682
+ self._save(path, state)
683
+ return {"external_id": external_id, "research_id": state["research_id"], "evidence": evidence,
684
+ "coverage_note": "Imported as an external report. Referenced original pages remain unreviewed until read and assessed independently."}