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/locate.py ADDED
@@ -0,0 +1,862 @@
1
+ """Shared Canvas locate primitive.
2
+
3
+ The MCP transport and the command-line entry point should use this module so
4
+ query normalization, metadata inference, ranking and evidence explanations do
5
+ not diverge between hosts.
6
+ """
7
+
8
+ import base64
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import os
13
+ import re
14
+ import tempfile
15
+ import zlib
16
+ from pathlib import Path
17
+
18
+ from ref_codec import register as register_ref
19
+
20
+
21
+ # Keep this list focused on conversational glue. Note and Tag are metadata
22
+ # predicates, not free-text terms, so they are handled by ``locate_metadata``.
23
+ LOCATE_STOPWORDS = frozenset({
24
+ "关于", "定位", "一个", "就是", "然后", "主要", "这种", "帮", "书签", "东西", "好像",
25
+ "还有", "带", "有", "这个", "那个", "请", "帮忙", "者", "的", "和", "或", "与", "及",
26
+ "note", "notes", "tag", "tags", "标签",
27
+ })
28
+
29
+ # The public locate contract is intentionally kept in one place for CLI/MCP
30
+ # adapters and parameter-matrix tests.
31
+ LOCATE_ARGUMENTS = (
32
+ "text", "url", "in", "source_id", "section", "folder_id", "tags",
33
+ "tag_colors", "has_note", "has_tags", "metadata_match", "limit", "offset", "handle", "refresh",
34
+ "package_path",
35
+ )
36
+ LOCATE_FIELD_WEIGHTS = {"title": 140, "url": 120, "tags": 110, "note": 90, "path": 60}
37
+ LOCATE_DIRECT_FIELDS = frozenset(("title", "url", "note", "tags"))
38
+
39
+ _LOCATE_HANDLE_PREFIX = "lh1."
40
+ _LOCATE_HANDLE_MAX_LENGTH = 16384
41
+ _LOCATE_HANDLE_MAX_PAYLOAD = 65536
42
+ _LOCATE_HANDLE_SIGNATURE_BYTES = 16
43
+
44
+
45
+ def locate_ref(source_id, section_id, item_id, version_id=None):
46
+ """Return a collision-checked short Canvas ref."""
47
+ return register_ref("b", (source_id, version_id or "", section_id, item_id))
48
+
49
+
50
+ def locate_why(item, terms):
51
+ """Explain literal field hits using values from the indexed item.
52
+
53
+ Metadata predicates are deliberately excluded here. A ``has_note`` or
54
+ ``has_tags`` filter is reported by the response's ``filters`` object; it is
55
+ not evidence that the query term matched the note or tags field.
56
+ """
57
+ tags = item.get("tags") or []
58
+ if isinstance(tags, (list, tuple)):
59
+ tags = "\n".join(str(value) for value in tags)
60
+ values = {
61
+ "title": item.get("title") or "",
62
+ "note": item.get("note") or "",
63
+ "tags": tags,
64
+ "path": item.get("folder_path") or "",
65
+ "url": item.get("url") or "",
66
+ }
67
+ fields, matched_terms = [], []
68
+ for term in terms:
69
+ term = str(term)
70
+ needle = term.casefold()
71
+ if not needle:
72
+ continue
73
+ if term.isascii():
74
+ pattern = r"(?<![0-9A-Za-z])" + re.escape(term) + r"(?![0-9A-Za-z])"
75
+ term_fields = [field for field, value in values.items()
76
+ if re.search(pattern, str(value), re.I)]
77
+ else:
78
+ term_fields = [field for field, value in values.items()
79
+ if needle in str(value).casefold()]
80
+ if term_fields:
81
+ matched_terms.append(term)
82
+ for field in term_fields:
83
+ if field not in fields:
84
+ fields.append(field)
85
+ weight = max((LOCATE_FIELD_WEIGHTS[field] for field in fields), default=0)
86
+ return {
87
+ "terms": matched_terms,
88
+ "fields": fields,
89
+ "field_weights": {field: LOCATE_FIELD_WEIGHTS[field] for field in fields},
90
+ "weight": weight,
91
+ "match_mode": "literal_boundary" if any(str(term).isascii() for term in terms)
92
+ else "literal_substring",
93
+ }
94
+
95
+
96
+ def locate_query(text):
97
+ """Keep common Chinese digit-plus-D notation searchable as 3D/2D."""
98
+ digits = {"一": "1", "二": "2", "三": "3", "四": "4", "五": "5",
99
+ "六": "6", "七": "7", "八": "8", "九": "9", "十": "10"}
100
+ return re.sub(r"([一二三四五六七八九十])\s*[dD](?=\b|[^0-9A-Za-z])",
101
+ lambda match: digits[match.group(1)] + "D", text)
102
+
103
+
104
+ def locate_metadata(text):
105
+ """Extract only unambiguous note/tag language as structured filters."""
106
+ value = text.casefold()
107
+ has_note = bool(re.search(r"(?:有|带|含|包含|还有)\s*(?:些?\s*)?notes?(?![0-9a-z])", value))
108
+ has_tags = bool(re.search(r"(?:有|带|含|包含|还有)\s*(?:些?\s*)?(?:tags?|标签)(?![0-9a-z])", value))
109
+ return has_note, has_tags
110
+
111
+
112
+ def locate_terms(terms):
113
+ """Remove conversational terms while preserving index-provided ordering."""
114
+ return [term for term in terms if str(term).casefold() not in LOCATE_STOPWORDS]
115
+
116
+
117
+ def locate_match_mode(terms):
118
+ """Return the literal matching mode implied by normalized search terms."""
119
+ return "literal_boundary" if any(str(term).isascii() for term in terms) else "literal_substring"
120
+
121
+
122
+ def _handle_request(arguments):
123
+ """Return the query arguments that define a continuation stream."""
124
+ return {key: value for key, value in arguments.items() if key not in ("handle", "offset")}
125
+
126
+
127
+ def _handle_key_path(database_path=None):
128
+ """Return a stable per-database key path shared by CLI and MCP processes."""
129
+ if database_path is not None and str(database_path) != ":memory:":
130
+ database = Path(database_path).expanduser().resolve()
131
+ directory = database.parent
132
+ identity = str(database)
133
+ else:
134
+ selected = os.environ.get("BOOKMARK_RESEARCH_DATA_DIR")
135
+ if selected:
136
+ directory = Path(selected).expanduser().resolve()
137
+ else:
138
+ xdg_home = os.environ.get("XDG_DATA_HOME")
139
+ base = (Path(xdg_home).expanduser()
140
+ if xdg_home and Path(xdg_home).is_absolute()
141
+ else Path.home() / ".local/share")
142
+ directory = (base / "bookmark-research").resolve()
143
+ directory = directory / "continuations"
144
+ identity = str(directory / "index.sqlite3")
145
+ digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:20]
146
+ return directory / (".bookmark-research-locate-" + digest + ".key")
147
+
148
+
149
+ def _fallback_handle_key_path(primary_path):
150
+ """Return a per-user temp path for hosts that cannot write beside the index."""
151
+ owner = str(os.getuid()) if hasattr(os, "getuid") else (
152
+ os.environ.get("USERNAME") or os.environ.get("USER") or "default")
153
+ owner_digest = hashlib.sha256(owner.encode("utf-8")).hexdigest()[:12]
154
+ return (Path(tempfile.gettempdir()) / ("bookmark-research-" + owner_digest)
155
+ / "continuations" / primary_path.name)
156
+
157
+
158
+ def _load_or_create_handle_key(path):
159
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
160
+ if path.is_symlink():
161
+ raise ValueError("locate continuation key must not be a symbolic link")
162
+ if not path.exists():
163
+ descriptor, temporary = tempfile.mkstemp(prefix=".locate-key-", dir=str(path.parent))
164
+ try:
165
+ key = os.urandom(32)
166
+ with os.fdopen(descriptor, "wb") as stream:
167
+ stream.write(key)
168
+ stream.flush()
169
+ os.fsync(stream.fileno())
170
+ try:
171
+ # Exclusive hard-link creation prevents concurrent callers
172
+ # from replacing the key and invalidating each other's handles.
173
+ os.link(temporary, str(path))
174
+ except FileExistsError:
175
+ pass
176
+ finally:
177
+ try:
178
+ os.unlink(temporary)
179
+ except FileNotFoundError:
180
+ pass
181
+ if path.is_symlink():
182
+ raise ValueError("locate continuation key must not be a symbolic link")
183
+ try:
184
+ os.chmod(path, 0o600)
185
+ except OSError:
186
+ pass
187
+ key = path.read_bytes()
188
+ if len(key) != 32:
189
+ raise ValueError("locate continuation signing key is invalid")
190
+ return key
191
+
192
+
193
+ def _handle_key(database_path=None):
194
+ """Load or atomically create a signing key, with a writable temp fallback."""
195
+ primary = _handle_key_path(database_path)
196
+ fallback = _fallback_handle_key_path(primary)
197
+ try:
198
+ return _load_or_create_handle_key(primary)
199
+ except OSError:
200
+ if fallback == primary:
201
+ raise
202
+ return _load_or_create_handle_key(fallback)
203
+
204
+
205
+ def _b64encode(value):
206
+ return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
207
+
208
+
209
+ def _b64decode(value):
210
+ if not isinstance(value, str) or re.fullmatch(r"[A-Za-z0-9_-]+", value) is None:
211
+ raise ValueError("invalid locate continuation encoding")
212
+ decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
213
+ if _b64encode(decoded) != value:
214
+ raise ValueError("non-canonical locate continuation encoding")
215
+ return decoded
216
+
217
+
218
+ def _remember_handle(arguments, database_path=None):
219
+ """Encode query and filters into a signed, cross-process continuation handle."""
220
+ body = {"v": 1, "request": _handle_request(arguments)}
221
+ payload = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
222
+ if len(payload) > _LOCATE_HANDLE_MAX_PAYLOAD:
223
+ raise ValueError("locate continuation arguments exceed the handle payload limit")
224
+ encoded = _b64encode(zlib.compress(payload, level=9))
225
+ signed = (_LOCATE_HANDLE_PREFIX + encoded).encode("ascii")
226
+ signature = hmac.new(_handle_key(database_path), signed, hashlib.sha256).digest()[:_LOCATE_HANDLE_SIGNATURE_BYTES]
227
+ handle = _LOCATE_HANDLE_PREFIX + encoded + "." + _b64encode(signature)
228
+ if len(handle) > _LOCATE_HANDLE_MAX_LENGTH:
229
+ raise ValueError("locate continuation handle exceeds the maximum length")
230
+ return handle
231
+
232
+
233
+ def _resolve_handle(handle, database_path=None):
234
+ """Verify and decode a stateless continuation handle."""
235
+ if not isinstance(handle, str) or len(handle) > _LOCATE_HANDLE_MAX_LENGTH:
236
+ return None
237
+ try:
238
+ prefix, encoded, supplied_signature = handle.split(".", 2)
239
+ if prefix != "lh1" or not encoded or not supplied_signature:
240
+ return None
241
+ signed = (prefix + "." + encoded).encode("ascii")
242
+ expected_signature = hmac.new(
243
+ _handle_key(database_path), signed, hashlib.sha256).digest()[:_LOCATE_HANDLE_SIGNATURE_BYTES]
244
+ if not hmac.compare_digest(expected_signature, _b64decode(supplied_signature)):
245
+ return None
246
+ decoder = zlib.decompressobj()
247
+ payload = decoder.decompress(_b64decode(encoded), _LOCATE_HANDLE_MAX_PAYLOAD + 1)
248
+ if (len(payload) > _LOCATE_HANDLE_MAX_PAYLOAD or decoder.unconsumed_tail
249
+ or decoder.unused_data or not decoder.eof):
250
+ return None
251
+ body = json.loads(payload.decode("utf-8"))
252
+ if not isinstance(body, dict) or body.get("v") != 1 or not isinstance(body.get("request"), dict):
253
+ return None
254
+ return body["request"]
255
+ except (OSError, ValueError, TypeError, UnicodeError, zlib.error, json.JSONDecodeError):
256
+ return None
257
+
258
+
259
+ def continuation_scope(handle, database_path=None):
260
+ """Return a continuation's original scope for transport adapters."""
261
+ saved = _resolve_handle(handle, database_path) if handle else None
262
+ request = saved or {}
263
+ scope = request.get("in", ["canvas"])
264
+ return scope if isinstance(scope, list) else ["canvas"]
265
+
266
+
267
+ def continuation_refresh_requested(handle, database_path=None):
268
+ """Return whether a continuation's original query requires writable refresh."""
269
+ saved = _resolve_handle(handle, database_path) if handle else None
270
+ return bool(saved and saved.get("refresh"))
271
+
272
+
273
+ def _query_terms(text):
274
+ """Get conservative display terms for non-Canvas literal evidence."""
275
+ return [part for part in re.findall(r"[A-Za-z0-9][A-Za-z0-9_.-]*|[\u4e00-\u9fff]", text)
276
+ if part.casefold() not in LOCATE_STOPWORDS]
277
+
278
+
279
+ def _why_for_result(row, terms, fields):
280
+ values = {field: str(row.get(field) or "") for field in fields}
281
+ matched, matched_fields = [], []
282
+ for term in terms:
283
+ found = [field for field, value in values.items() if term.casefold() in value.casefold()]
284
+ if found:
285
+ matched.append(term)
286
+ for field in found:
287
+ if field not in matched_fields:
288
+ matched_fields.append(field)
289
+ return {"terms": matched, "fields": matched_fields, "match_mode": "literal_substring"}
290
+
291
+
292
+ def _locate_external(scope, text, url, offset, limit, wiki=None, raw=None):
293
+ if scope == "wiki":
294
+ if url is not None:
295
+ raise ValueError("locate wiki scope accepts text, not url")
296
+ if wiki is None:
297
+ raise ValueError("wiki locate adapter is unavailable")
298
+ payload = wiki.search(text, offset=offset, limit=limit)
299
+ terms = _query_terms(text)
300
+ hits = []
301
+ for row in payload.get("results", []):
302
+ page_id = row.get("page_id")
303
+ revision = row.get("revision")
304
+ if not page_id:
305
+ continue
306
+ evidence = {"ref": register_ref("w", (page_id, revision)),
307
+ "source_kind": "wiki", "source_id": page_id,
308
+ "wiki_revision": revision, "evidence_kind": "wiki_revision",
309
+ "title": row.get("title") or "", "snippet": row.get("snippet") or "",
310
+ "why": _why_for_result(row, terms, ("title", "snippet"))}
311
+ validation = row.get("validation") or {}
312
+ if validation:
313
+ evidence["validation"] = validation
314
+ if validation.get("status"):
315
+ evidence["content_state"] = ("usable" if validation["status"] == "ok"
316
+ else validation["status"])
317
+ hits.append(evidence)
318
+ total = payload.get("total", len(hits))
319
+ next_offset = payload.get("next_offset")
320
+ return hits, total, next_offset, terms
321
+ if raw is None:
322
+ raise ValueError("archive locate adapter is unavailable")
323
+ query = text or url
324
+ payload = raw.search(query, limit=limit, offset=offset, url=url)
325
+ terms = _query_terms(query)
326
+ hits = []
327
+ for row in payload.get("results", []):
328
+ occurrences = row.get("occurrences") or []
329
+ capture_id = next((item.get("capture_id") for item in occurrences if item.get("capture_id")), None)
330
+ archive_key = capture_id or row.get("sha256") or row.get("url")
331
+ if not archive_key:
332
+ continue
333
+ evidence = {"ref": register_ref("a", (capture_id or "", row.get("sha256") or "",
334
+ row.get("url") or "")), "source_kind": "archive",
335
+ "source_id": row.get("url") or row.get("sha256"),
336
+ "evidence_kind": "archive_capture", "title": row.get("title") or "",
337
+ "url": row.get("url") or "", "snippet": row.get("snippet") or "",
338
+ "sha256": row.get("sha256"),
339
+ "capture_id": capture_id,
340
+ "content_state": "usable" if row.get("snippet") else "content_insufficient",
341
+ "why": _why_for_result(row, terms, ("title", "snippet", "url"))}
342
+ hits.append({key: value for key, value in evidence.items() if value is not None})
343
+ total = payload.get("total", len(hits))
344
+ next_offset = payload.get("next_offset")
345
+ return hits, total, next_offset, terms
346
+
347
+
348
+ def locate(index, source_manager, arguments, wiki=None, raw=None):
349
+ """Run the P0 Canvas locate operation against an indexed source.
350
+
351
+ ``source_manager`` is passed in by the host so this module remains
352
+ transport-independent. Continuation handles contain a signed, compressed
353
+ copy of the original query and filters, so page traversal works across CLI
354
+ invocations and MCP restarts.
355
+ """
356
+ supplied = dict(arguments)
357
+ handle = supplied.get("handle")
358
+ offset = supplied.get("offset", 0)
359
+ if type(offset) is not int or offset < 0:
360
+ raise ValueError("offset must be a non-negative integer")
361
+ if handle is not None:
362
+ database_path = getattr(index, "db_path", None)
363
+ saved = _resolve_handle(handle, database_path)
364
+ if saved is None:
365
+ raise ValueError("unknown locate continuation handle")
366
+ # A handle owns the original query and filters. Only offset may vary
367
+ # on a continuation request, preventing accidental cross-query reads.
368
+ supplied = dict(saved, offset=offset, handle=handle)
369
+ request = _handle_request(supplied)
370
+ database_path = getattr(index, "db_path", None)
371
+ text = request.get("text")
372
+ url = request.get("url")
373
+ requested = request.get("in", ["canvas"])
374
+ if not isinstance(requested, list) or not requested or any(scope not in ("canvas", "wiki", "archive") for scope in requested):
375
+ raise ValueError('locate in must contain canvas, wiki or archive')
376
+ if len(requested) != 1:
377
+ raise ValueError("locate currently requires exactly one scope in")
378
+ source_id = request.get("source_id")
379
+ section = request.get("section")
380
+ folder_id = request.get("folder_id")
381
+ tags = request.get("tags")
382
+ tag_colors = request.get("tag_colors")
383
+ has_note = request.get("has_note")
384
+ has_tags = request.get("has_tags")
385
+ folder_listing = text is None and url is None and folder_id is not None
386
+ # A metadata filter is a query in its own right: "show me the bookmarks with
387
+ # a note" must not require inventing a search term. Only a request with no
388
+ # text, URL, folder and no metadata filter is ambiguous.
389
+ metadata_only = (text is None and url is None and folder_id is None
390
+ and any(value is not None for value in (tags, tag_colors, has_note, has_tags)))
391
+ if not folder_listing and not metadata_only and (text is None) == (url is None):
392
+ raise ValueError("locate requires exactly one of text or url, folder_id, or a metadata filter (tags/has_note/has_tags)")
393
+ metadata_match = request.get("metadata_match", "all")
394
+ limit = request.get("limit", 8)
395
+ refresh = request.get("refresh", False)
396
+ if requested[0] != "canvas":
397
+ if folder_listing:
398
+ raise ValueError("folder_id-only locate is supported for canvas scope")
399
+ unsupported = ("source_id", "section", "folder_id", "tags", "tag_colors",
400
+ "has_note", "has_tags", "metadata_match")
401
+ if any(request.get(key) is not None and not (key == "metadata_match" and request.get(key) == "all")
402
+ for key in unsupported):
403
+ raise ValueError("metadata filters are only supported for canvas locate")
404
+ if refresh:
405
+ raise ValueError("refresh is only supported for canvas locate")
406
+ hits, total_hits, next_offset, terms = _locate_external(
407
+ requested[0], text, url, offset, limit, wiki=wiki, raw=raw)
408
+ result = {"hits": hits, "total": total_hits, "in": requested,
409
+ "match_mode": "literal_substring", "source_ids": [],
410
+ "candidates": [], "filters": {}}
411
+ if text is not None:
412
+ result["query"] = text
413
+ else:
414
+ result["url"] = url
415
+ if next_offset is not None and next_offset < total_hits:
416
+ continuation = _remember_handle(request, database_path)
417
+ result["more"] = {"handle": continuation, "offset": next_offset, "total": total_hits}
418
+ result["next"] = {"tool": "locate", "handle": continuation,
419
+ "offset": next_offset, "reason": "continue_results"}
420
+ return result
421
+ if source_id is None:
422
+ source_ids = source_manager.live_sources()
423
+ if len(source_ids) != 1:
424
+ raise ValueError("source_required: locate requires source_id when the index has zero or multiple live sources")
425
+ else:
426
+ source_ids = [index.resolve_source_id(source_id)]
427
+ source_ids = sorted(dict.fromkeys(source_ids))
428
+ statuses = {}
429
+ for identifier in source_ids:
430
+ update = source_manager.refresh(identifier) if refresh else None
431
+ if update and update.get("state") in ("error", "unavailable"):
432
+ raise ValueError(update.get("error", "source is unavailable") + "; use refresh=false to read the saved index")
433
+ statuses[identifier] = source_manager.source_status(identifier)
434
+
435
+ hits, seen, candidates = [], set(), []
436
+ folder_candidates = []
437
+ folder_rows, hint_rows = {}, {}
438
+ terms_by_source = {}
439
+ text_match_mode = "literal_substring"
440
+ query = locate_query(text) if text is not None else None
441
+ inferred_note, inferred_tags = locate_metadata(query) if query is not None else (False, False)
442
+ if has_note is None and inferred_note:
443
+ has_note = True
444
+ if has_tags is None and inferred_tags:
445
+ has_tags = True
446
+ filters = {"section": section, "folder_id": folder_id, "tags": tags,
447
+ "tag_colors": tag_colors, "has_note": has_note, "has_tags": has_tags,
448
+ "metadata_match": metadata_match}
449
+ has_filters = any(value is not None for key, value in filters.items()
450
+ if key != "metadata_match") or metadata_match != "all"
451
+ metadata_requested = any(value is not None for key, value in filters.items()
452
+ if key in ("tags", "tag_colors", "has_note", "has_tags"))
453
+ with index.read_snapshot():
454
+ if folder_listing:
455
+ identifier = source_ids[0]
456
+ found = index.folder_children(
457
+ identifier, folder_id, section=section, tags=tags, tag_colors=tag_colors,
458
+ has_note=has_note, has_tags=has_tags, metadata_match=metadata_match,
459
+ limit=limit, offset=offset)
460
+ version = statuses[identifier].get("version_id")
461
+ parent = found["folder"]
462
+ parent_ref = locate_ref(identifier, parent["section_id"], folder_id, version)
463
+ folder = {"ref": parent_ref, "folder_id": folder_id,
464
+ "section_id": parent["section_id"], "title": parent.get("title") or "",
465
+ "path": list(parent.get("path") or []) +
466
+ ([parent["title"]] if parent.get("title") else [])}
467
+ if version is not None:
468
+ folder["version_id"] = version
469
+ page_hits = []
470
+ for child in found["results"]:
471
+ hit = {"ref": locate_ref(identifier, child["section_id"], child["item_id"], version),
472
+ "kind": child["item_type"], "source_kind": "canvas", "source_id": identifier,
473
+ "evidence_kind": "folder_child", "section_id": child["section_id"],
474
+ "item_id": child["item_id"], "parent_id": child.get("parent_id"),
475
+ "title": child.get("title") or "",
476
+ "path": list(child.get("path") or []) +
477
+ ([child["title"]] if child.get("title") else []),
478
+ "position": child.get("position")}
479
+ if child.get("url"):
480
+ hit["url"] = child["url"]
481
+ if child.get("note"):
482
+ hit["note"] = child["note"]
483
+ if child.get("note_color"):
484
+ hit["note_color"] = child["note_color"]
485
+ if child.get("tags"):
486
+ hit["tags"] = child["tags"]
487
+ if version is not None:
488
+ hit["version_id"] = version
489
+ page_hits.append(hit)
490
+ total_hits = found["total"]
491
+ coverage = {"matched": total_hits, "shown": len(page_hits),
492
+ "hidden": max(0, total_hits - len(page_hits))}
493
+ result = {"hits": page_hits, "total": total_hits, "in": ["canvas"],
494
+ "match_mode": "folder_children", "source_ids": source_ids,
495
+ "folder": folder, "coverage": coverage,
496
+ "filters": {key: value for key, value in filters.items()
497
+ if value is not None and not (key == "metadata_match" and value == "all")}}
498
+ next_offset = offset + len(page_hits)
499
+ next_message = ("%d direct children; read a child ref for its full context" % total_hits
500
+ if total_hits else "No direct children match this folder scope")
501
+ if next_offset < total_hits:
502
+ continuation = _remember_handle(request, database_path)
503
+ result["more"] = {"handle": continuation, "offset": next_offset, "total": total_hits}
504
+ result["next"] = {"tool": "locate", "handle": continuation,
505
+ "offset": next_offset, "reason": "continue_results",
506
+ "message": next_message}
507
+ else:
508
+ result["next"] = next_message
509
+ return result
510
+ if metadata_only:
511
+ # No term, no URL, no folder: the filters are the query. Ranking is
512
+ # recency so a "what did I note recently" question gets a useful page.
513
+ identifier = source_ids[0]
514
+ found = index.search(identifier, targets=[], section=section, folder_id=folder_id,
515
+ tags=tags, tag_colors=tag_colors, limit=limit, offset=offset,
516
+ has_note=has_note, has_tags=has_tags,
517
+ metadata_match=metadata_match, sort_by="added_at_desc")
518
+ version = statuses[identifier].get("version_id")
519
+ page_hits = []
520
+ for item in found["results"]:
521
+ hit = {"ref": locate_ref(identifier, item["section_id"], item["item_id"], version),
522
+ "source_kind": "canvas", "kind": item["item_type"],
523
+ "source_id": identifier, "evidence_kind": "bookmark_instance",
524
+ "section_id": item["section_id"], "item_id": item["item_id"],
525
+ "parent_id": item.get("parent_id"),
526
+ "title": item.get("title") or "",
527
+ "path": list(item.get("path") or []) + ([item["title"]] if item.get("title") else []),
528
+ "position": item.get("position"),
529
+ "why": {"terms": [], "fields": [], "field_weights": {}, "weight": 0,
530
+ "match_mode": "metadata"}}
531
+ if item.get("url"):
532
+ hit["url"] = item["url"]
533
+ if item.get("note"):
534
+ hit["note"] = item["note"]
535
+ if item.get("note_color"):
536
+ hit["note_color"] = item["note_color"]
537
+ if item.get("tags"):
538
+ hit["tags"] = item["tags"]
539
+ if version is not None:
540
+ hit["version_id"] = version
541
+ page_hits.append(hit)
542
+ total_hits = found["total"]
543
+ shown = offset + len(page_hits)
544
+ result = {"hits": page_hits, "total": total_hits, "in": ["canvas"],
545
+ "match_mode": "metadata", "source_ids": source_ids,
546
+ "filters": {key: value for key, value in filters.items()
547
+ if value is not None and not (key == "metadata_match" and value == "all")},
548
+ "coverage": {"matched": total_hits, "shown": len(page_hits),
549
+ "hidden": max(0, total_hits - shown)}}
550
+ next_message = ("%d bookmarks match the metadata filter; read a hit ref for its card"
551
+ % total_hits if total_hits else "No bookmark matches these metadata filters")
552
+ if shown < total_hits:
553
+ continuation = _remember_handle(request, database_path)
554
+ result["more"] = {"handle": continuation, "offset": shown, "total": total_hits}
555
+ result["next"] = {"tool": "locate", "handle": continuation,
556
+ "offset": shown, "reason": "continue_results",
557
+ "message": next_message}
558
+ else:
559
+ result["next"] = next_message
560
+ return result
561
+ if url is not None:
562
+ for identifier in source_ids:
563
+ if has_filters:
564
+ found = index.search(identifier, targets=[url], section=section, folder_id=folder_id,
565
+ tags=tags, tag_colors=tag_colors, limit=1000, offset=0,
566
+ score=True, has_note=has_note, has_tags=has_tags,
567
+ metadata_match=metadata_match)
568
+ if found["total"] > len(found["results"]):
569
+ raise ValueError("locate URL result exceeds the bounded page; narrow the URL or use the P0b continuation handle")
570
+ instances = [item for item in found["results"] if item.get("url") == url]
571
+ else:
572
+ found = index.lookup_url(url, source_id=identifier, limit=1000, offset=0)
573
+ if found["total_instances"] > len(found["instances"]):
574
+ raise ValueError("locate URL result exceeds the bounded page; narrow the URL or use the P0b continuation handle")
575
+ instances = found["instances"]
576
+ for item in instances:
577
+ key = (identifier, item["section_id"], item["item_id"])
578
+ if key in seen:
579
+ continue
580
+ seen.add(key)
581
+ version = statuses[identifier].get("version_id")
582
+ hit = {"ref": locate_ref(*key, version), "source_kind": "canvas",
583
+ "kind": "bookmark",
584
+ "source_id": identifier, "evidence_kind": "bookmark_instance",
585
+ "section_id": item["section_id"], "item_id": item["item_id"],
586
+ "title": item.get("title") or "", "url": item.get("url") or "",
587
+ "why": {"terms": [url], "fields": ["url"],
588
+ "field_weights": {"url": LOCATE_FIELD_WEIGHTS["url"]},
589
+ "weight": LOCATE_FIELD_WEIGHTS["url"], "match_mode": "exact"}}
590
+ if version is not None:
591
+ hit["version_id"] = version
592
+ hits.append(hit)
593
+ else:
594
+ for identifier in source_ids:
595
+ scope = index.plan_scope(identifier, query, max_cards=3, max_urls=8)
596
+ terms = locate_terms(scope.get("terms", []))
597
+ terms_by_source[identifier] = terms
598
+ if locate_match_mode(terms) == "literal_boundary":
599
+ text_match_mode = "literal_boundary"
600
+ candidates.append({
601
+ "source_id": identifier,
602
+ "terms": terms,
603
+ "cards": [{"section_id": card.get("section_id"),
604
+ "matched_terms": locate_terms(card.get("matched_terms", []))}
605
+ for card in scope.get("cards", [])[:3]],
606
+ "urls": [{"url": row.get("url"),
607
+ "matched_terms": locate_terms(row.get("matched_terms", []))}
608
+ for row in scope.get("urls", [])[:3]],
609
+ })
610
+ term_cards = scope.get("term_cards", {})
611
+ card_ranks = {card.get("section_id"): rank for rank, card in enumerate(scope.get("cards", []))}
612
+ for card in scope.get("cards", []):
613
+ result_section = section or card["section_id"]
614
+ targets = locate_terms(card.get("matched_terms", [])) or terms
615
+ found = index.search(identifier, targets=targets, section=result_section,
616
+ folder_id=folder_id, tags=tags, tag_colors=tag_colors,
617
+ limit=1000, offset=0, score=True, has_note=has_note,
618
+ has_tags=has_tags, metadata_match=metadata_match)
619
+ if found["total"] > len(found["results"]):
620
+ raise ValueError("locate text result exceeds the bounded page; narrow the query or use the P0b continuation handle")
621
+ for item in found["results"]:
622
+ key = (identifier, item["section_id"], item["item_id"])
623
+ if key in seen:
624
+ continue
625
+ why = locate_why(item, terms)
626
+ why["match_mode"] = text_match_mode
627
+ if not why["terms"] and not metadata_requested:
628
+ continue
629
+ seen.add(key)
630
+ rarity = sum(1.0 / max(term_cards.get(term, 1), 1)
631
+ for term in why["terms"])
632
+ version = statuses[identifier].get("version_id")
633
+ hit = {"ref": locate_ref(*key, version), "source_kind": "canvas",
634
+ "kind": "bookmark",
635
+ "source_id": identifier, "evidence_kind": "bookmark_instance",
636
+ "section_id": item["section_id"], "item_id": item["item_id"],
637
+ "title": item.get("title") or "", "url": item.get("url") or "",
638
+ "why": why}
639
+ hit["_locate_weight"] = why["weight"]
640
+ hit["_locate_rarity"] = rarity
641
+ hit["_locate_card_rank"] = card_ranks.get(item["section_id"], len(card_ranks))
642
+ hit["_locate_parent_id"] = item.get("parent_id")
643
+ if version is not None:
644
+ hit["version_id"] = version
645
+ hits.append(hit)
646
+ if text is not None and folder_id is None:
647
+ # "Where is the 2D / 3D folder?" is answered by the folder itself.
648
+ # Bookmark hits stay first (existing ranking contract); the folder is
649
+ # reported alongside them so the caller does not have to guess that
650
+ # folder_id can list its children.
651
+ for identifier in source_ids:
652
+ folder_terms = terms_by_source.get(identifier) or []
653
+ if not folder_terms:
654
+ continue
655
+ found_folders = index.search(identifier, targets=folder_terms, section=section,
656
+ item_types=["folder"], limit=100, offset=0)
657
+ for folder in found_folders["results"]:
658
+ why = locate_why(folder, folder_terms)
659
+ if len(why["terms"]) != len(folder_terms):
660
+ continue
661
+ version = statuses[identifier].get("version_id")
662
+ folder_candidates.append({
663
+ "ref": locate_ref(identifier, folder["section_id"], folder["item_id"], version),
664
+ "folder_id": folder["item_id"], "source_id": identifier,
665
+ "section_id": folder["section_id"],
666
+ "title": folder.get("title") or "",
667
+ "path": list(folder.get("path") or []) +
668
+ ([folder["title"]] if folder.get("title") else [])})
669
+ if url is None:
670
+ for hit in hits:
671
+ if set(hit["why"]["fields"]) != {"path"}:
672
+ continue
673
+ scope_key = (hit["source_id"], hit["section_id"])
674
+ if scope_key in folder_rows:
675
+ continue
676
+ found_folders = index.search(
677
+ hit["source_id"], section=hit["section_id"], folder_id=folder_id,
678
+ item_types=["folder"], limit=1000, offset=0)
679
+ if found_folders["total"] > len(found_folders["results"]):
680
+ raise ValueError("locate folder inventory exceeds the bounded page; narrow the section")
681
+ folder_rows[scope_key] = {row["item_id"]: row for row in found_folders["results"]}
682
+ if not hits:
683
+ for identifier in source_ids:
684
+ for term in terms_by_source.get(identifier, []):
685
+ found_folders = index.search(
686
+ identifier, targets=[term], section=section, folder_id=folder_id,
687
+ item_types=["folder"], limit=1000, offset=0)
688
+ if found_folders["total"] > len(found_folders["results"]):
689
+ continue
690
+ for folder in found_folders["results"]:
691
+ if not locate_why(folder, [term])["terms"]:
692
+ continue
693
+ hint_key = (term, identifier, folder["section_id"], folder["item_id"])
694
+ hint_rows[hint_key] = {
695
+ "folder_id": folder["item_id"], "ref": locate_ref(
696
+ identifier, folder["section_id"], folder["item_id"],
697
+ statuses[identifier].get("version_id")),
698
+ "title": folder.get("title") or "",
699
+ "path": list(folder.get("path") or []) +
700
+ ([folder["title"]] if folder.get("title") else []),
701
+ }
702
+ hits.sort(key=lambda hit: (-hit.pop("_locate_weight", 0),
703
+ -hit.pop("_locate_rarity", 0.0),
704
+ hit.pop("_locate_card_rank", 0), hit["source_id"],
705
+ hit["section_id"], hit["item_id"]))
706
+ direct_hits, inherited_hits, unclassified_hits = [], [], []
707
+ for hit in hits:
708
+ fields = set(hit["why"]["fields"])
709
+ if fields.intersection(LOCATE_DIRECT_FIELDS):
710
+ direct_hits.append(hit)
711
+ elif fields == {"path"}:
712
+ inherited_hits.append(hit)
713
+ else:
714
+ unclassified_hits.append(hit)
715
+ direct_hits.extend(unclassified_hits)
716
+
717
+ folder_groups = {}
718
+ unresolved_inherited = []
719
+ for hit in inherited_hits:
720
+ folders = folder_rows[(hit["source_id"], hit["section_id"])]
721
+ path_terms = hit["why"]["terms"]
722
+ current = folders.get(hit.get("_locate_parent_id"))
723
+ nearest = None
724
+ while current is not None:
725
+ folder_why = locate_why(current, path_terms)
726
+ if len(folder_why["terms"]) == len(path_terms):
727
+ nearest = current
728
+ break
729
+ current = folders.get(current.get("parent_id"))
730
+ if nearest is None and folder_id is not None:
731
+ nearest = folders.get(folder_id)
732
+ if nearest is None:
733
+ unresolved_inherited.append(hit)
734
+ continue
735
+ key = (hit["source_id"], hit["section_id"], nearest["item_id"])
736
+ group = folder_groups.setdefault(key, {"folder": nearest, "members": [], "terms": []})
737
+ group["members"].append(hit)
738
+ group["terms"] = list(dict.fromkeys(group["terms"] + path_terms))
739
+
740
+ folder_hits = []
741
+ for (identifier, section_id, folder_id), group in folder_groups.items():
742
+ folder = group["folder"]
743
+ version = statuses[identifier].get("version_id")
744
+ sample = []
745
+ for member in group["members"][:3]:
746
+ member.pop("_locate_parent_id", None)
747
+ sample.append({key: member[key] for key in
748
+ ("ref", "source_id", "section_id", "item_id", "title", "url", "why")})
749
+ folder_hit = {
750
+ "kind": "folder", "ref": locate_ref(identifier, section_id, folder_id, version),
751
+ "source_kind": "canvas", "source_id": identifier,
752
+ "evidence_kind": "inherited_matches", "section_id": section_id,
753
+ "folder_id": folder_id, "item_id": folder_id,
754
+ "title": folder.get("title") or "",
755
+ "path": list(folder.get("path") or []) + ([folder["title"]] if folder.get("title") else []),
756
+ "count": len(group["members"]), "sample": sample,
757
+ "why": {"terms": group["terms"], "fields": ["path"],
758
+ "field_weights": {"path": LOCATE_FIELD_WEIGHTS["path"]},
759
+ "weight": LOCATE_FIELD_WEIGHTS["path"], "match_mode": text_match_mode},
760
+ }
761
+ if version is not None:
762
+ folder_hit["version_id"] = version
763
+ folder_hits.append(folder_hit)
764
+ folder_hits.sort(key=lambda hit: (hit["count"], hit["source_id"], hit["section_id"], hit["folder_id"]))
765
+
766
+ for hit in direct_hits + unresolved_inherited:
767
+ hit.pop("_locate_parent_id", None)
768
+ ranked_hits = direct_hits + folder_hits + unresolved_inherited[:2]
769
+ total_hits = len(ranked_hits)
770
+ page_hits = ranked_hits[offset:offset + limit]
771
+ direct_match_count = sum(bool(LOCATE_DIRECT_FIELDS.intersection(hit["why"]["fields"]))
772
+ for hit in direct_hits)
773
+ inherited_count = len(inherited_hits)
774
+ shown_direct = sum(hit.get("kind") != "folder" for hit in page_hits)
775
+ shown_samples = sum(len(hit.get("sample", [])) for hit in page_hits if hit.get("kind") == "folder")
776
+ shown = shown_direct + shown_samples
777
+ coverage = {
778
+ "matched": len(direct_hits) + inherited_count,
779
+ "shown": shown,
780
+ "hidden": max(0, len(direct_hits) + inherited_count - shown),
781
+ "folders": len(folder_hits),
782
+ "direct": {"matched": direct_match_count, "shown": sum(
783
+ bool(LOCATE_DIRECT_FIELDS.intersection(hit["why"]["fields"])) for hit in page_hits)},
784
+ "inherited": {"matched": inherited_count, "folders": len(folder_hits), "sampled": shown_samples},
785
+ }
786
+
787
+ if not direct_match_count:
788
+ for folder in folder_hits:
789
+ for term in folder["why"]["terms"]:
790
+ key = (term, folder["source_id"], folder["section_id"], folder["folder_id"])
791
+ hint_rows[key] = {"folder_id": folder["folder_id"], "ref": folder["ref"],
792
+ "title": folder["title"], "path": folder["path"]}
793
+ by_term = {}
794
+ for (term, _, _, _), folder in hint_rows.items():
795
+ by_term.setdefault(term, []).append(folder)
796
+ hints = [{"term": term, "folders": folders} for term, folders in by_term.items()]
797
+ query_terms = list(dict.fromkeys(term for terms in terms_by_source.values() for term in terms))
798
+ matched_terms = {term for hit in direct_hits for term in hit["why"]["terms"]}
799
+ matched_terms.update(term for hit in folder_hits for term in hit["why"]["terms"])
800
+ missing_terms = [term for term in query_terms if term not in matched_terms]
801
+ if total_hits and not missing_terms:
802
+ hints = []
803
+ result = {"hits": page_hits, "total": total_hits,
804
+ "in": ["canvas"], "match_mode": "exact" if url is not None else text_match_mode,
805
+ "source_ids": source_ids, "candidates": candidates if total_hits == 0 else [],
806
+ "filters": {key: value for key, value in filters.items()
807
+ if value is not None and not (key == "metadata_match" and value == "all")}}
808
+ result["coverage"] = coverage
809
+ if hints:
810
+ result["hints"] = hints
811
+ next_offset = offset + len(page_hits)
812
+ if (total_hits == 1 and has_note is True and page_hits
813
+ and page_hits[0].get("kind") != "folder"
814
+ and "title" in page_hits[0]["why"]["fields"]):
815
+ next_message = "1 hit with title+note; stop if it matches"
816
+ elif direct_match_count and inherited_count:
817
+ top_score = max(hit["why"]["weight"] for hit in direct_hits
818
+ if LOCATE_DIRECT_FIELDS.intersection(hit["why"]["fields"]))
819
+ next_message = "%d direct hits, top score %d vs %d; read the first direct hit and stop if it matches" % (
820
+ direct_match_count, top_score, LOCATE_FIELD_WEIGHTS["path"])
821
+ elif direct_match_count:
822
+ next_message = "%d direct hits; read the first direct hit and stop if it matches" % direct_match_count
823
+ elif folder_hits and missing_terms and hints:
824
+ hint = hints[0]
825
+ next_message = "No full match for %r; %r appears in %d folders; try folder_id" % (
826
+ text, hint["term"], len(hint["folders"]))
827
+ elif folder_hits:
828
+ next_message = "All matches are inherited; use folder_id to narrow a folder"
829
+ elif hints:
830
+ hint = hints[0]
831
+ next_message = "No match for %r; %r appears in %d folders; try folder_id" % (
832
+ text, hint["term"], len(hint["folders"]))
833
+ elif unresolved_inherited:
834
+ next_message = "Inherited matches need a folder scope; inspect the reported sample and narrow by folder_id"
835
+ elif url is not None:
836
+ next_message = "No exact URL matches; verify the URL string"
837
+ else:
838
+ next_message = "No direct or inherited matches; refine the text query"
839
+ if next_offset < total_hits:
840
+ continuation = _remember_handle(request, database_path)
841
+ result["more"] = {"handle": continuation, "offset": next_offset, "total": total_hits}
842
+ result["next"] = {"tool": "locate", "handle": continuation,
843
+ "offset": next_offset, "reason": "continue_results",
844
+ "message": next_message}
845
+ else:
846
+ result["next"] = next_message
847
+ if folder_candidates:
848
+ result["folders"] = folder_candidates[:5]
849
+ message = ("Folder match: %s; pass its folder_id to list direct children."
850
+ % folder_candidates[0]["title"])
851
+ if isinstance(result.get("next"), dict):
852
+ result["next"].setdefault("message", "")
853
+ result["next"]["message"] += " " + message
854
+ else:
855
+ current = str(result.get("next") or "").strip()
856
+ result["next"] = (current + " " + message).strip() if current else message
857
+ if text is not None:
858
+ result["query"] = text
859
+ else:
860
+ result["url"] = url
861
+ return result
862
+