@researai/deepscientist 1.5.8 → 1.5.11

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 (148) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +108 -95
  3. package/assets/branding/connector-qq.png +0 -0
  4. package/assets/branding/connector-rokid.png +0 -0
  5. package/assets/branding/connector-weixin.png +0 -0
  6. package/assets/branding/projects.png +0 -0
  7. package/bin/ds.js +172 -13
  8. package/docs/assets/branding/projects.png +0 -0
  9. package/docs/en/00_QUICK_START.md +308 -70
  10. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  11. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  12. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  13. package/docs/en/09_DOCTOR.md +41 -5
  14. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  15. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  16. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
  17. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  18. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +79 -0
  21. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  23. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  24. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  25. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  26. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  27. package/docs/zh/00_QUICK_START.md +315 -74
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +41 -5
  32. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  33. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  34. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  35. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  36. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  37. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  38. package/docs/zh/README.md +126 -0
  39. package/install.sh +0 -34
  40. package/package.json +3 -3
  41. package/pyproject.toml +2 -2
  42. package/src/deepscientist/__init__.py +1 -1
  43. package/src/deepscientist/annotations.py +343 -0
  44. package/src/deepscientist/artifact/arxiv.py +484 -37
  45. package/src/deepscientist/artifact/metrics.py +1 -3
  46. package/src/deepscientist/artifact/service.py +1347 -111
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/service.py +9 -0
  49. package/src/deepscientist/bridges/builtins.py +2 -0
  50. package/src/deepscientist/bridges/connectors.py +447 -0
  51. package/src/deepscientist/channels/__init__.py +2 -0
  52. package/src/deepscientist/channels/builtins.py +3 -1
  53. package/src/deepscientist/channels/qq.py +1 -1
  54. package/src/deepscientist/channels/qq_gateway.py +1 -1
  55. package/src/deepscientist/channels/relay.py +7 -1
  56. package/src/deepscientist/channels/weixin.py +59 -0
  57. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  58. package/src/deepscientist/config/models.py +22 -2
  59. package/src/deepscientist/config/service.py +431 -60
  60. package/src/deepscientist/connector/__init__.py +4 -0
  61. package/src/deepscientist/connector/connector_profiles.py +481 -0
  62. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  63. package/src/deepscientist/connector/qq_profiles.py +206 -0
  64. package/src/deepscientist/connector/weixin_support.py +663 -0
  65. package/src/deepscientist/connector_profiles.py +1 -374
  66. package/src/deepscientist/connector_runtime.py +2 -0
  67. package/src/deepscientist/daemon/api/handlers.py +295 -5
  68. package/src/deepscientist/daemon/api/router.py +16 -1
  69. package/src/deepscientist/daemon/app.py +1130 -61
  70. package/src/deepscientist/doctor.py +5 -2
  71. package/src/deepscientist/gitops/diff.py +120 -29
  72. package/src/deepscientist/lingzhu_support.py +1 -182
  73. package/src/deepscientist/mcp/server.py +14 -5
  74. package/src/deepscientist/prompts/builder.py +29 -1
  75. package/src/deepscientist/qq_profiles.py +1 -196
  76. package/src/deepscientist/quest/node_traces.py +152 -2
  77. package/src/deepscientist/quest/service.py +169 -43
  78. package/src/deepscientist/quest/stage_views.py +172 -9
  79. package/src/deepscientist/registries/baseline.py +56 -4
  80. package/src/deepscientist/runners/codex.py +55 -3
  81. package/src/deepscientist/weixin_support.py +1 -0
  82. package/src/prompts/connectors/lingzhu.md +3 -1
  83. package/src/prompts/connectors/weixin.md +230 -0
  84. package/src/prompts/system.md +9 -0
  85. package/src/skills/idea/SKILL.md +16 -0
  86. package/src/skills/idea/references/literature-survey-template.md +24 -0
  87. package/src/skills/idea/references/related-work-playbook.md +4 -0
  88. package/src/skills/idea/references/selection-gate.md +9 -0
  89. package/src/skills/write/SKILL.md +1 -1
  90. package/src/tui/package.json +1 -1
  91. package/src/ui/dist/assets/{AiManusChatView-m2FNtwbn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  92. package/src/ui/dist/assets/{AnalysisPlugin-BMTF8EGL.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  93. package/src/ui/dist/assets/{CliPlugin-BEOWgxCI.js → CliPlugin-DrV8je02.js} +164 -9
  94. package/src/ui/dist/assets/{CodeEditorPlugin-BCXvjqmb.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  95. package/src/ui/dist/assets/{CodeViewerPlugin-DaJcy3nD.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  96. package/src/ui/dist/assets/{DocViewerPlugin-ByfeIq4K.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  97. package/src/ui/dist/assets/{GitDiffViewerPlugin-Cksf3VZ-.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  98. package/src/ui/dist/assets/{ImageViewerPlugin-CFz-OsTS.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  99. package/src/ui/dist/assets/{LabCopilotPanel-CJ1cJzoX.js → LabCopilotPanel-1qSow1es.js} +11 -11
  100. package/src/ui/dist/assets/{LabPlugin-BF3dVJwa.js → LabPlugin-eQpPPCEp.js} +2 -1
  101. package/src/ui/dist/assets/{LatexPlugin-DDkwZ6Sj.js → LatexPlugin-BwRfi89Z.js} +7 -7
  102. package/src/ui/dist/assets/{MarkdownViewerPlugin-HAuvurcT.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  103. package/src/ui/dist/assets/{MarketplacePlugin-BtoTYy2C.js → MarketplacePlugin-C2y_556i.js} +3 -3
  104. package/src/ui/dist/assets/{NotebookEditor-CSJYx7b-.js → NotebookEditor-BRzJbGsn.js} +12 -12
  105. package/src/ui/dist/assets/{NotebookEditor-DQgRezm_.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  106. package/src/ui/dist/assets/{PdfLoader-DPa_-fv6.js → PdfLoader-DzRaTAlq.js} +14 -7
  107. package/src/ui/dist/assets/{PdfMarkdownPlugin-BZpXOEjm.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  108. package/src/ui/dist/assets/{PdfViewerPlugin-BT8a6wGR.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  109. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  110. package/src/ui/dist/assets/{SearchPlugin-D_blveZi.js → SearchPlugin-DHeIAMsx.js} +1 -1
  111. package/src/ui/dist/assets/{TextViewerPlugin-Btx0M3hX.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  112. package/src/ui/dist/assets/{VNCViewer-DImJO4rO.js → VNCViewer-CQsKVm3t.js} +10 -10
  113. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  114. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  115. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  116. package/src/ui/dist/assets/{code-BUfXGJSl.js → code-XfbSR8K2.js} +1 -1
  117. package/src/ui/dist/assets/{file-content-VqamwI3X.js → file-content-BjxNaIfy.js} +1 -1
  118. package/src/ui/dist/assets/{file-diff-panel-C_wOoS7a.js → file-diff-panel-D_lLVQk0.js} +1 -1
  119. package/src/ui/dist/assets/{file-socket-D2bTuMVP.js → file-socket-D9x_5vlY.js} +1 -1
  120. package/src/ui/dist/assets/{image-BZkGJ4mM.js → image-BhWT33W1.js} +1 -1
  121. package/src/ui/dist/assets/{index-DdRW6RMJ.js → index--c4iXtuy.js} +12 -12
  122. package/src/ui/dist/assets/{index-CxkvSeKw.js → index-BDxipwrC.js} +2 -2
  123. package/src/ui/dist/assets/{index-DjggJovS.js → index-DZTZ8mWP.js} +14934 -9613
  124. package/src/ui/dist/assets/{index-DXZ1daiJ.css → index-Dqj-Mjb4.css} +2 -13
  125. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  126. package/src/ui/dist/assets/{monaco-DHMc7kKM.js → monaco-K8izTGgo.js} +1 -1
  127. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  128. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  129. package/src/ui/dist/assets/{popover-B85oCgCS.js → popover-yFK1J4fL.js} +1 -1
  130. package/src/ui/dist/assets/{project-sync-DOMCcPac.js → project-sync-PENr2zcz.js} +1 -74
  131. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  132. package/src/ui/dist/assets/{sigma-BO2rQrl3.js → sigma-DEuYJqTl.js} +1 -1
  133. package/src/ui/dist/assets/{index-D9QIGcmc.js → square-check-big-omoSUmcd.js} +2 -13
  134. package/src/ui/dist/assets/{trash-BsVEH_dV.js → trash--F119N47.js} +1 -1
  135. package/src/ui/dist/assets/{useCliAccess-b8L6JuZm.js → useCliAccess-D31UR23I.js} +1 -1
  136. package/src/ui/dist/assets/{useFileDiffOverlay-BY7uA9hV.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  137. package/src/ui/dist/assets/{wrap-text-BwyVuUIK.js → wrap-text-CZ613PM5.js} +1 -1
  138. package/src/ui/dist/assets/{zoom-out-RDpLugQP.js → zoom-out-BgDLAv3z.js} +1 -1
  139. package/src/ui/dist/index.html +2 -2
  140. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  141. package/src/ui/dist/assets/AutoFigurePlugin-DxPdMUNb.js +0 -8149
  142. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  143. package/src/ui/dist/assets/Stepper-DH2k75Vo.js +0 -158
  144. package/src/ui/dist/assets/bibtex-B-Hqu0Sg.js +0 -189
  145. package/src/ui/dist/assets/file-utils--zJCPN1i.js +0 -109
  146. package/src/ui/dist/assets/message-square-FUIPIhU2.js +0 -16
  147. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  148. package/src/ui/dist/assets/tooltip-B1OspAkx.js +0 -108
package/pyproject.toml CHANGED
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepscientist"
7
- version = "1.5.8"
7
+ version = "1.5.11"
8
8
  description = "DeepScientist Core skeleton"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
- license = { text = "MIT" }
11
+ license = { text = "Apache-2.0" }
12
12
  authors = [
13
13
  { name = "OpenAI Codex" }
14
14
  ]
@@ -5,4 +5,4 @@ __all__ = ["__version__"]
5
5
  try:
6
6
  __version__ = _package_version("deepscientist")
7
7
  except PackageNotFoundError: # pragma: no cover - source checkout fallback
8
- __version__ = "1.5.8"
8
+ __version__ = "1.5.11"
@@ -0,0 +1,343 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from urllib.parse import unquote
8
+
9
+ from .quest import QuestService
10
+ from .shared import ensure_dir, generate_id, read_json, utc_now, write_json
11
+
12
+ _QUEST_FILE_PREFIX = "quest-file::"
13
+ _SCHEMA_VERSION = 1
14
+ _DEFAULT_AUTHOR_COLOR = "#F1E9D0"
15
+ _ALLOWED_KINDS = {"note", "question", "task"}
16
+
17
+
18
+ def _json_clone(value: Any) -> Any:
19
+ return json.loads(json.dumps(value))
20
+
21
+
22
+ class AnnotationService:
23
+ def __init__(self, home: Path) -> None:
24
+ self.home = home.resolve()
25
+ self._lock = threading.Lock()
26
+
27
+ @staticmethod
28
+ def _parse_quest_file_id(file_id: str) -> tuple[str, str, str]:
29
+ raw = str(file_id or "").strip()
30
+ if not raw.startswith(_QUEST_FILE_PREFIX):
31
+ raise ValueError("Only quest file ids are supported for annotations.")
32
+ payload = raw[len(_QUEST_FILE_PREFIX) :]
33
+ project_id, encoded_document_id, encoded_path = (payload.split("::", 2) + ["", ""])[:3]
34
+ project_id = str(project_id or "").strip()
35
+ if not project_id or not encoded_document_id:
36
+ raise ValueError("Invalid quest file id.")
37
+ document_id = unquote(encoded_document_id)
38
+ relative_path = unquote(encoded_path or encoded_document_id)
39
+ if not document_id:
40
+ raise ValueError("Invalid quest file id.")
41
+ return project_id, document_id, relative_path
42
+
43
+ def _quest_root(self, project_id: str) -> Path:
44
+ quest_root = self.home / "quests" / project_id
45
+ if not quest_root.exists():
46
+ raise FileNotFoundError(f"Unknown quest `{project_id}`.")
47
+ return quest_root
48
+
49
+ @staticmethod
50
+ def _manifest_path(quest_root: Path) -> Path:
51
+ return quest_root / ".ds" / "annotations" / "index.json"
52
+
53
+ def _load_manifest(self, quest_root: Path) -> dict[str, Any]:
54
+ payload = read_json(self._manifest_path(quest_root), default=None)
55
+ if not isinstance(payload, dict):
56
+ payload = {}
57
+ items = payload.get("items")
58
+ if not isinstance(items, list):
59
+ items = []
60
+ return {
61
+ "schema_version": _SCHEMA_VERSION,
62
+ "updated_at": str(payload.get("updated_at") or utc_now()),
63
+ "items": [dict(item) for item in items if isinstance(item, dict)],
64
+ }
65
+
66
+ def _save_manifest(self, quest_root: Path, payload: dict[str, Any]) -> dict[str, Any]:
67
+ normalized = {
68
+ "schema_version": _SCHEMA_VERSION,
69
+ "updated_at": utc_now(),
70
+ "items": [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)],
71
+ }
72
+ ensure_dir(self._manifest_path(quest_root).parent)
73
+ write_json(self._manifest_path(quest_root), normalized)
74
+ return normalized
75
+
76
+ @staticmethod
77
+ def _normalize_kind(value: object) -> str:
78
+ kind = str(value or "note").strip().lower()
79
+ return kind if kind in _ALLOWED_KINDS else "note"
80
+
81
+ @staticmethod
82
+ def _normalize_tags(value: object) -> list[str]:
83
+ if not isinstance(value, list):
84
+ return []
85
+ result: list[str] = []
86
+ seen: set[str] = set()
87
+ for raw in value:
88
+ tag = str(raw or "").strip()
89
+ if not tag or tag in seen:
90
+ continue
91
+ seen.add(tag)
92
+ result.append(tag)
93
+ return result
94
+
95
+ @classmethod
96
+ def _normalize_position(cls, value: object) -> dict[str, Any]:
97
+ payload = _json_clone(value if isinstance(value, dict) else {})
98
+ if not isinstance(payload, dict):
99
+ raise ValueError("`position` must be an object.")
100
+ page_number = payload.get("pageNumber")
101
+ if not isinstance(page_number, int) or page_number <= 0:
102
+ raise ValueError("`position.pageNumber` must be a positive integer.")
103
+ if not isinstance(payload.get("boundingRect"), dict):
104
+ raise ValueError("`position.boundingRect` is required.")
105
+ rects = payload.get("rects")
106
+ if not isinstance(rects, list) or not rects:
107
+ raise ValueError("`position.rects` must be a non-empty list.")
108
+ return payload
109
+
110
+ @classmethod
111
+ def _normalize_content(cls, value: object) -> dict[str, Any]:
112
+ payload = _json_clone(value if isinstance(value, dict) else {})
113
+ if not isinstance(payload, dict):
114
+ return {}
115
+ text = payload.get("text")
116
+ image = payload.get("image")
117
+ result: dict[str, Any] = {}
118
+ if isinstance(text, str):
119
+ result["text"] = text
120
+ if isinstance(image, str):
121
+ result["image"] = image
122
+ return result
123
+
124
+ @staticmethod
125
+ def _normalize_author(color: object) -> dict[str, str]:
126
+ author_color = str(color or _DEFAULT_AUTHOR_COLOR).strip() or _DEFAULT_AUTHOR_COLOR
127
+ return {
128
+ "id": "local-user",
129
+ "handle": "user",
130
+ "color": author_color,
131
+ }
132
+
133
+ @classmethod
134
+ def _normalize_item(cls, item: dict[str, Any]) -> dict[str, Any]:
135
+ author = item.get("author") if isinstance(item.get("author"), dict) else {}
136
+ color = str(item.get("color") or author.get("color") or _DEFAULT_AUTHOR_COLOR).strip() or _DEFAULT_AUTHOR_COLOR
137
+ normalized = {
138
+ "id": str(item.get("id") or "").strip(),
139
+ "file_id": str(item.get("file_id") or "").strip(),
140
+ "project_id": str(item.get("project_id") or "").strip(),
141
+ "position": cls._normalize_position(item.get("position") or {}),
142
+ "content": cls._normalize_content(item.get("content") or {}),
143
+ "comment": str(item.get("comment") or "").strip(),
144
+ "kind": cls._normalize_kind(item.get("kind")),
145
+ "color": color,
146
+ "tags": cls._normalize_tags(item.get("tags")),
147
+ "created_by": str(item.get("created_by") or "local-user").strip() or "local-user",
148
+ "author": {
149
+ "id": str(author.get("id") or "local-user").strip() or "local-user",
150
+ "handle": str(author.get("handle") or "user").strip() or "user",
151
+ "color": str(author.get("color") or color).strip() or color,
152
+ },
153
+ "created_at": str(item.get("created_at") or utc_now()).strip() or utc_now(),
154
+ "updated_at": str(item.get("updated_at") or utc_now()).strip() or utc_now(),
155
+ }
156
+ if not normalized["id"]:
157
+ raise ValueError("Annotation id is required.")
158
+ if not normalized["file_id"]:
159
+ raise ValueError("Annotation file id is required.")
160
+ if not normalized["project_id"]:
161
+ raise ValueError("Annotation project id is required.")
162
+ return normalized
163
+
164
+ @staticmethod
165
+ def _response_item(item: dict[str, Any]) -> dict[str, Any]:
166
+ return {
167
+ "id": item["id"],
168
+ "file_id": item["file_id"],
169
+ "project_id": item["project_id"],
170
+ "position": _json_clone(item["position"]),
171
+ "content": _json_clone(item["content"]),
172
+ "comment": item["comment"],
173
+ "kind": item["kind"],
174
+ "color": item["color"],
175
+ "tags": list(item["tags"]),
176
+ "created_by": item["created_by"],
177
+ "author": dict(item["author"]),
178
+ "created_at": item["created_at"],
179
+ "updated_at": item["updated_at"],
180
+ }
181
+
182
+ def _ensure_document_exists(self, project_id: str, document_id: str) -> None:
183
+ quest_service = QuestService(self.home)
184
+ quest_service.open_document(project_id, document_id)
185
+
186
+ def list_annotations(self, file_id: str) -> dict[str, Any]:
187
+ project_id, document_id, _relative_path = self._parse_quest_file_id(file_id)
188
+ self._ensure_document_exists(project_id, document_id)
189
+ manifest = self._load_manifest(self._quest_root(project_id))
190
+ items = [
191
+ self._response_item(self._normalize_item(item))
192
+ for item in manifest["items"]
193
+ if str(item.get("file_id") or "").strip() == file_id
194
+ ]
195
+ items.sort(key=lambda item: (item.get("created_at") or "", item.get("id") or ""), reverse=True)
196
+ return {"items": items, "total": len(items)}
197
+
198
+ def create_annotation(
199
+ self,
200
+ *,
201
+ file_id: str,
202
+ position: object,
203
+ content: object,
204
+ comment: object = "",
205
+ kind: object = "note",
206
+ color: object = None,
207
+ tags: object = None,
208
+ ) -> dict[str, Any]:
209
+ project_id, document_id, _relative_path = self._parse_quest_file_id(file_id)
210
+ self._ensure_document_exists(project_id, document_id)
211
+ quest_root = self._quest_root(project_id)
212
+ author = self._normalize_author(color)
213
+ now = utc_now()
214
+ item = self._normalize_item(
215
+ {
216
+ "id": generate_id("ann"),
217
+ "file_id": file_id,
218
+ "project_id": project_id,
219
+ "position": position,
220
+ "content": content,
221
+ "comment": str(comment or "").strip(),
222
+ "kind": kind,
223
+ "color": author["color"],
224
+ "tags": tags,
225
+ "created_by": author["id"],
226
+ "author": author,
227
+ "created_at": now,
228
+ "updated_at": now,
229
+ }
230
+ )
231
+ with self._lock:
232
+ manifest = self._load_manifest(quest_root)
233
+ manifest["items"] = [*manifest["items"], item]
234
+ self._save_manifest(quest_root, manifest)
235
+ return self._response_item(item)
236
+
237
+ def _find_annotation(self, annotation_id: str) -> tuple[Path, dict[str, Any], list[dict[str, Any]], int]:
238
+ normalized_id = str(annotation_id or "").strip()
239
+ if not normalized_id:
240
+ raise FileNotFoundError("Unknown annotation.")
241
+ quests_root = self.home / "quests"
242
+ for quest_root in sorted(quests_root.iterdir()) if quests_root.exists() else []:
243
+ if not quest_root.is_dir():
244
+ continue
245
+ manifest = self._load_manifest(quest_root)
246
+ items = manifest["items"]
247
+ for index, raw_item in enumerate(items):
248
+ if str(raw_item.get("id") or "").strip() != normalized_id:
249
+ continue
250
+ return quest_root, manifest, items, index
251
+ raise FileNotFoundError(f"Unknown annotation `{normalized_id}`.")
252
+
253
+ def get_annotation(self, annotation_id: str) -> dict[str, Any]:
254
+ _quest_root, _manifest, items, index = self._find_annotation(annotation_id)
255
+ return self._response_item(self._normalize_item(items[index]))
256
+
257
+ def update_annotation(
258
+ self,
259
+ annotation_id: str,
260
+ *,
261
+ comment: object | None = None,
262
+ kind: object | None = None,
263
+ position: object | None = None,
264
+ content: object | None = None,
265
+ color: object | None = None,
266
+ tags: object | None = None,
267
+ ) -> dict[str, Any]:
268
+ with self._lock:
269
+ quest_root, manifest, items, index = self._find_annotation(annotation_id)
270
+ current = self._normalize_item(items[index])
271
+ next_item = dict(current)
272
+ if comment is not None:
273
+ next_item["comment"] = str(comment or "").strip()
274
+ if kind is not None:
275
+ next_item["kind"] = self._normalize_kind(kind)
276
+ if position is not None:
277
+ next_item["position"] = self._normalize_position(position)
278
+ if content is not None:
279
+ next_item["content"] = self._normalize_content(content)
280
+ if color is not None:
281
+ resolved_color = str(color or current["color"]).strip() or current["color"]
282
+ next_item["color"] = resolved_color
283
+ next_item["author"] = {
284
+ **dict(current["author"]),
285
+ "color": resolved_color,
286
+ }
287
+ if tags is not None:
288
+ next_item["tags"] = self._normalize_tags(tags)
289
+ next_item["updated_at"] = utc_now()
290
+ normalized = self._normalize_item(next_item)
291
+ items[index] = normalized
292
+ manifest["items"] = items
293
+ self._save_manifest(quest_root, manifest)
294
+ return self._response_item(normalized)
295
+
296
+ def delete_annotation(self, annotation_id: str) -> dict[str, Any]:
297
+ with self._lock:
298
+ quest_root, manifest, items, index = self._find_annotation(annotation_id)
299
+ removed = self._normalize_item(items[index])
300
+ items.pop(index)
301
+ manifest["items"] = items
302
+ self._save_manifest(quest_root, manifest)
303
+ return {"ok": True, "id": removed["id"], "file_id": removed["file_id"], "project_id": removed["project_id"]}
304
+
305
+ def search_annotations(
306
+ self,
307
+ project_id: str,
308
+ *,
309
+ query: str | None = None,
310
+ color: str | None = None,
311
+ tag: str | None = None,
312
+ page: int | None = None,
313
+ limit: int = 100,
314
+ ) -> dict[str, Any]:
315
+ quest_root = self._quest_root(project_id)
316
+ manifest = self._load_manifest(quest_root)
317
+ normalized_query = str(query or "").strip().lower()
318
+ normalized_color = str(color or "").strip().lower()
319
+ normalized_tag = str(tag or "").strip().lower()
320
+ items: list[dict[str, Any]] = []
321
+ for raw_item in manifest["items"]:
322
+ item = self._normalize_item(raw_item)
323
+ if normalized_color and str(item.get("color") or "").strip().lower() != normalized_color:
324
+ continue
325
+ if normalized_tag and normalized_tag not in {tag_item.lower() for tag_item in item.get("tags") or []}:
326
+ continue
327
+ if page is not None:
328
+ item_page = item.get("position", {}).get("pageNumber")
329
+ if item_page != page:
330
+ continue
331
+ if normalized_query:
332
+ haystack = " ".join(
333
+ [
334
+ str(item.get("comment") or ""),
335
+ str(item.get("content", {}).get("text") or ""),
336
+ " ".join(item.get("tags") or []),
337
+ ]
338
+ ).lower()
339
+ if normalized_query not in haystack:
340
+ continue
341
+ items.append(self._response_item(item))
342
+ items.sort(key=lambda item: (item.get("created_at") or "", item.get("id") or ""), reverse=True)
343
+ return {"items": items[: max(1, limit)], "total": len(items)}