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
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
14
14
  from pathlib import Path, PurePosixPath
15
15
  from urllib.parse import urlsplit
16
16
 
17
+ import storage
17
18
  from package_paths import (
18
19
  PROTOCOL_DIRS,
19
20
  dir_matches_section_type,
@@ -65,6 +66,7 @@ def url_kind(url):
65
66
  # into bigrams because a phrase the user typed rarely appears verbatim.
66
67
  PLAN_RUN = re.compile(r"[0-9A-Za-z][0-9A-Za-z+._-]*|[㐀-䶿一-鿿]+")
67
68
  QUESTION_URL = re.compile(r"https?://[^\s<>()\"']+")
69
+ PLAN_CJK_SPLITTERS = re.compile(r"(?:有哪些|有什么|是什么|怎么|如何|为什么|怎样|[有的和在与或等及从到把被让我你他它这那])")
68
70
  PLAN_MAX_TERMS = 12
69
71
 
70
72
 
@@ -192,6 +194,10 @@ CREATE TABLE IF NOT EXISTS sources (
192
194
  source_id TEXT PRIMARY KEY, package_path TEXT NOT NULL,
193
195
  created_at TEXT NOT NULL, last_sync TEXT NOT NULL
194
196
  );
197
+ CREATE TABLE IF NOT EXISTS source_redirects (
198
+ old_source_id TEXT PRIMARY KEY, new_source_id TEXT NOT NULL,
199
+ created_at TEXT NOT NULL, reason TEXT NOT NULL
200
+ );
195
201
  CREATE TABLE IF NOT EXISTS files (
196
202
  source_id TEXT NOT NULL, file_path TEXT NOT NULL, kind TEXT NOT NULL,
197
203
  sha256 TEXT NOT NULL, raw_json TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
@@ -222,6 +228,7 @@ CREATE TABLE IF NOT EXISTS items (
222
228
  );
223
229
  CREATE INDEX IF NOT EXISTS items_parent ON items(source_id,section_id,parent_id);
224
230
  CREATE INDEX IF NOT EXISTS items_url ON items(source_id,url);
231
+ CREATE INDEX IF NOT EXISTS items_url_lookup ON items(url);
225
232
  CREATE TABLE IF NOT EXISTS item_tags (
226
233
  item_pk INTEGER NOT NULL REFERENCES items(pk) ON DELETE CASCADE,
227
234
  position INTEGER NOT NULL, color TEXT NOT NULL, text TEXT NOT NULL,
@@ -268,38 +275,68 @@ PRAGMA user_version=2;
268
275
 
269
276
 
270
277
  class BookmarkIndex:
271
- def __init__(self, db_path):
278
+ def __init__(self, db_path, readonly=False):
272
279
  self.db_path = None if str(db_path) == ":memory:" else Path(db_path).expanduser().resolve()
280
+ self.connection = None
281
+ self.readonly = False
273
282
  if self.db_path is not None:
274
283
  # Refuse before connecting, so even an accidentally chosen package
275
284
  # path does not acquire an empty database, journal or WAL file.
276
285
  for parent in self.db_path.parents:
277
286
  if is_package_dir(parent):
278
287
  raise ValueError("Database must be outside imported package directories")
288
+ modes = [bool(readonly), not bool(readonly)] if self.db_path is not None else [False]
289
+ first_error = None
290
+ for mode in modes:
291
+ try:
292
+ self._initialize(mode)
293
+ break
294
+ except (OSError, sqlite3.DatabaseError) as error:
295
+ self.close()
296
+ if first_error is None:
297
+ first_error = error
298
+ if not mode and not self._read_only_open_failure(error):
299
+ raise
300
+ except Exception:
301
+ self.close()
302
+ raise
303
+ else:
304
+ raise first_error
305
+
306
+ @staticmethod
307
+ def _read_only_open_failure(error):
308
+ return storage.is_readonly_failure(error)
309
+
310
+ def _initialize(self, readonly):
311
+ if self.db_path is None:
312
+ connection = sqlite3.connect(":memory:")
313
+ elif readonly:
314
+ connection = storage.connect(self.db_path, readonly=True)
315
+ else:
279
316
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
280
- self.connection = sqlite3.connect(str(self.db_path) if self.db_path else ":memory:")
317
+ connection = sqlite3.connect(str(self.db_path))
318
+ self.connection = connection
319
+ self.readonly = readonly
281
320
  self.connection.row_factory = sqlite3.Row
282
321
  self.connection.execute("PRAGMA foreign_keys=ON")
283
322
  self.connection.execute("PRAGMA busy_timeout=5000")
284
323
  version = self.connection.execute("PRAGMA user_version").fetchone()[0]
285
324
  if version not in (0, 1, 2):
286
- self.close()
287
325
  raise ValueError("Unsupported bookmark index schema version: %s" % version)
288
- if version == 1:
289
- # Add the added-at columns and fill them from stored raw_json before
290
- # the schema script runs: CREATE TABLE IF NOT EXISTS cannot add a
291
- # column, and the script would advance the version past this repair.
292
- try:
326
+ if readonly:
327
+ if version != 2:
328
+ raise sqlite3.OperationalError("Read-only bookmark index requires schema version 2")
329
+ row = self.connection.execute(
330
+ "SELECT sql FROM sqlite_master WHERE name='items_fts'").fetchone()
331
+ self._fts_mode = "trigram" if row is not None and "trigram" in (row["sql"] or "") else "plain"
332
+ else:
333
+ if version == 1:
334
+ # Add the added-at columns and fill them from stored raw_json before
335
+ # the schema script runs: CREATE TABLE IF NOT EXISTS cannot add a
336
+ # column, and the script would advance the version past this repair.
293
337
  self._upgrade_items_added_at()
294
- except Exception:
295
- self.close()
296
- raise
297
- try:
298
338
  self.connection.executescript(SCHEMA)
299
- except Exception:
300
- self.close()
301
- raise
302
- self._fts_mode = self._prepare_search_index()
339
+ self._fts_mode = self._prepare_search_index()
303
340
  self._window_functions = self._supports_window_functions()
304
341
 
305
342
  def _upgrade_items_added_at(self):
@@ -366,7 +403,9 @@ class BookmarkIndex:
366
403
  return True
367
404
 
368
405
  def close(self):
369
- self.connection.close()
406
+ if self.connection is not None:
407
+ self.connection.close()
408
+ self.connection = None
370
409
 
371
410
  def __enter__(self):
372
411
  return self
@@ -374,8 +413,21 @@ class BookmarkIndex:
374
413
  def __exit__(self, *_):
375
414
  self.close()
376
415
 
416
+ def resolve_source_id(self, source_id):
417
+ """Resolve retired source IDs to their surviving merge target."""
418
+ current, seen = source_id, set()
419
+ while current not in seen:
420
+ seen.add(current)
421
+ row = self.connection.execute(
422
+ "SELECT new_source_id FROM source_redirects WHERE old_source_id=?", (current,)).fetchone()
423
+ if row is None:
424
+ return current
425
+ current = row[0]
426
+ raise ValueError("Source redirect cycle: " + str(source_id))
427
+
377
428
  def _source(self, source_id):
378
- row = self.connection.execute("SELECT * FROM sources WHERE source_id=?", (source_id,)).fetchone()
429
+ resolved = self.resolve_source_id(source_id)
430
+ row = self.connection.execute("SELECT * FROM sources WHERE source_id=?", (resolved,)).fetchone()
379
431
  if row is None:
380
432
  raise ValueError("Unknown source_id: " + str(source_id))
381
433
  return dict(row)
@@ -443,8 +495,13 @@ class BookmarkIndex:
443
495
  else:
444
496
  if anchor:
445
497
  raise ValueError("Only permanent sections can be copy anchors")
446
- section_id = obj.get("id")
447
498
  label = obj.get("label", "unknown")
499
+ section_id = obj.get("id")
500
+ if not section_id:
501
+ # The canvas right-click export omits ids; derive a stable one so
502
+ # re-importing the same file keeps refs and duplicate groups.
503
+ digest = hashlib.sha256(("temporary|" + str(label) + "|" + _string(obj.get("title", ""), "section title") + "|" + file_path).encode("utf-8")).hexdigest()[:12]
504
+ section_id = "temp-section-export-" + digest
448
505
  _string(section_id, "section id", True)
449
506
  _string(label, "section label", True)
450
507
  return section_id, {
@@ -491,7 +548,14 @@ class BookmarkIndex:
491
548
  for position, node in enumerate(nodes):
492
549
  if not isinstance(node, dict):
493
550
  raise ValueError("Items must be JSON objects")
494
- item_id = _string(node.get("id"), "item id", True)
551
+ if node.get("id") is None:
552
+ # Exported items carry no id; derive one from position and
553
+ # content so re-imports stay stable and duplicates still group.
554
+ seed = "|".join([section_id, parent_id or "", str(position),
555
+ str(node.get("url") or ""), str(node.get("title") or "")])
556
+ item_id = ("tempId_" if not permanent else "persistId_") + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
557
+ else:
558
+ item_id = _string(node.get("id"), "item id", True)
495
559
  if item_id in seen:
496
560
  raise ValueError("Duplicate item id in section: " + item_id)
497
561
  seen.add(item_id)
@@ -500,8 +564,9 @@ class BookmarkIndex:
500
564
  if not permanent and node.get("sectionId", section_id) != section_id:
501
565
  raise ValueError("Temporary item sectionId mismatch: " + item_id)
502
566
  title = _string(node.get("title", ""), "item title")
503
- url = _string(node.get("url", ""), "item URL")
504
- item_type = ("bookmark" if url else "folder") if permanent else node.get("type")
567
+ url = _string(node.get("url") or "", "item URL")
568
+ item_type = (("bookmark" if url else "folder") if permanent
569
+ else (node.get("type") or ("bookmark" if url else "folder")))
505
570
  if item_type not in ("bookmark", "folder") or (item_type == "bookmark" and not url):
506
571
  raise ValueError("Invalid bookmark/folder type: " + item_id)
507
572
  children = node.get("children", [])
@@ -531,8 +596,12 @@ class BookmarkIndex:
531
596
  "tag_text": "\n".join(tag["text"] for tag in normalized),
532
597
  "path_json": _dump(list(titles)), "folder_path": " / ".join(titles),
533
598
  "ancestor_ids_json": _dump(list(ancestor_ids)),
534
- # Full child arrays also survive in files/sections.raw_json.
535
- "raw_json": _dump({key: value for key, value in node.items() if key != "children"}),
599
+ # Keep tree structure in parent_id/path/position and in the
600
+ # source-file snapshot; duplicating descendants here makes
601
+ # every ancestor appear changed when one child is edited.
602
+ "raw_json": _dump({key: value for key, value in node.items()
603
+ if key != "children"
604
+ and not (item_type == "folder" and key == "url" and value == "")}),
536
605
  "metadata_json": _dump(item_meta if permanent else {key: node[key] for key in ("tags", "note", "noteColor") if key in node}),
537
606
  "added_at": added[0], "added_at_source": added[1], "added_at_confidence": added[2],
538
607
  }))
@@ -597,6 +666,8 @@ class BookmarkIndex:
597
666
  @contextmanager
598
667
  def transaction(self):
599
668
  """Serialize writers; allow an importer to publish metadata atomically."""
669
+ if self.readonly:
670
+ raise sqlite3.OperationalError("attempt to write a readonly bookmark index")
600
671
  nested = self.connection.in_transaction
601
672
  if nested:
602
673
  self.connection.execute("SAVEPOINT bookmark_sync")
@@ -850,7 +921,8 @@ class BookmarkIndex:
850
921
  return rows[0]
851
922
 
852
923
  def _scope(self, source_id, section=None, group_id=None, folder_id=None, tags=None, tag_colors=None,
853
- item_types=None):
924
+ item_types=None, has_note=None, has_tags=None, metadata_match="all"):
925
+ source_id = self.resolve_source_id(source_id)
854
926
  self._source(source_id)
855
927
  selected_types = list(item_types) if item_types else ["bookmark"]
856
928
  clauses = ["i.source_id=?", "i.item_type IN (%s)" % ",".join("?" for _ in selected_types)]
@@ -892,15 +964,189 @@ class BookmarkIndex:
892
964
  _string(color, "tag color", True)
893
965
  clauses.append("EXISTS (SELECT 1 FROM item_tags t WHERE t.item_pk=i.pk AND t.color=?)")
894
966
  args.append(color)
967
+ metadata_clauses = []
968
+ if has_note is not None:
969
+ metadata_clauses.append("trim(i.note)<>''" if has_note else "trim(i.note)=''")
970
+ if has_tags is not None:
971
+ metadata_clauses.append("EXISTS (SELECT 1 FROM item_tags t WHERE t.item_pk=i.pk)"
972
+ if has_tags else
973
+ "NOT EXISTS (SELECT 1 FROM item_tags t WHERE t.item_pk=i.pk)")
974
+ if metadata_clauses:
975
+ clauses.append("(" + (" OR " if metadata_match == "any" else " AND ").join(metadata_clauses) + ")")
895
976
  return clauses, args
896
977
 
978
+ def source_summary(self, source_id):
979
+ """Recognise a freshly imported package: counts, sections, noise, duplicates."""
980
+ source_id = self.resolve_source_id(source_id)
981
+ from url_classify import is_search_result_url
982
+ counts = {}
983
+ for row in self.connection.execute(
984
+ "SELECT item_type, COUNT(*) AS c FROM items WHERE source_id=? GROUP BY item_type",
985
+ (source_id,)):
986
+ counts[row["item_type"]] = row["c"]
987
+ notes = self.connection.execute(
988
+ "SELECT COUNT(*) FROM items WHERE source_id=? AND note!=''", (source_id,)).fetchone()[0]
989
+ notes_bookmarks = self.connection.execute(
990
+ "SELECT COUNT(*) FROM items WHERE source_id=? AND note!='' AND item_type='bookmark'",
991
+ (source_id,)).fetchone()[0]
992
+ tagged = self.connection.execute(
993
+ "SELECT COUNT(*) FROM items WHERE source_id=? AND tag_text!=''", (source_id,)).fetchone()[0]
994
+ section_counts = {row["section_id"]: row["c"] for row in self.connection.execute(
995
+ "SELECT section_id, COUNT(*) AS c FROM items WHERE source_id=? GROUP BY section_id",
996
+ (source_id,))}
997
+ sections = []
998
+ for row in self.connection.execute(
999
+ "SELECT section_id, label, title, section_type, file_path, raw_json FROM sections"
1000
+ " WHERE source_id=? ORDER BY section_id", (source_id,)):
1001
+ try:
1002
+ raw = json.loads(row["raw_json"]) if row["raw_json"] else {}
1003
+ except ValueError:
1004
+ raw = {}
1005
+ sections.append({**dict(row), "items": section_counts.get(row["section_id"], 0),
1006
+ "source": raw.get("source") or "",
1007
+ "temp_kind": raw.get("tempKind") or ""})
1008
+ sections[-1].pop("raw_json", None)
1009
+ urls = [row["url"] for row in self.connection.execute(
1010
+ "SELECT DISTINCT url FROM items WHERE source_id=? AND url!=''", (source_id,))]
1011
+ search_pages = sum(1 for url in urls if is_search_result_url(url))
1012
+ from junk import is_junk
1013
+ junk_items = 0
1014
+ for row in self.connection.execute(
1015
+ "SELECT title, url FROM items WHERE source_id=? AND item_type='bookmark'",
1016
+ (source_id,)):
1017
+ if is_junk(row["title"], row["url"]):
1018
+ junk_items += 1
1019
+ duplicates = self.duplicate_urls(source_id, include_search=False, limit=1)
1020
+ return {"source_id": source_id,
1021
+ "counts": {"items": sum(counts.values()), "bookmarks": counts.get("bookmark", 0),
1022
+ "folders": counts.get("folder", 0)},
1023
+ "notes": notes, "notes_bookmarks": notes_bookmarks,
1024
+ "notes_folders": notes - notes_bookmarks, "tagged": tagged, "unique_urls": len(urls),
1025
+ "search_pages": search_pages, "junk_items": junk_items,
1026
+ "duplicate_url_groups": duplicates["total_groups"],
1027
+ "sections": sections}
1028
+
1029
+ def duplicate_urls(self, source_id, min_count=2, limit=50, offset=0, include_search=False):
1030
+ """Group bookmarks that point at the same URL and suggest which copy to keep.
1031
+
1032
+ The suggestion prefers an instance with a note, then the earliest added
1033
+ copy, then the shallowest folder path. It is a proposal, never an edit.
1034
+ """
1035
+ source_id = self.resolve_source_id(source_id)
1036
+ if isinstance(min_count, bool) or not isinstance(min_count, int) or min_count < 2:
1037
+ raise ValueError("min_count must be an integer of at least 2")
1038
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500:
1039
+ raise ValueError("limit must be an integer from 1 to 500")
1040
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
1041
+ raise ValueError("offset must be a non-negative integer")
1042
+ if type(include_search) is not bool:
1043
+ raise ValueError("include_search must be a boolean")
1044
+ from junk import is_junk
1045
+ from url_classify import is_search_result_url
1046
+ base = ("FROM items WHERE source_id=? AND item_type='bookmark'"
1047
+ " AND url IS NOT NULL AND url!=''")
1048
+ rows = self.connection.execute(
1049
+ "SELECT url, COUNT(*) AS copies, MIN(added_at) AS first_added " + base +
1050
+ " GROUP BY url HAVING copies>=? ORDER BY copies DESC, first_added, url",
1051
+ (source_id, min_count)).fetchall()
1052
+ groups = [dict(row) for row in rows]
1053
+ # Placeholder rows (divider searches) are not real duplicates; drop them
1054
+ # before counting groups so the report stays honest.
1055
+ junk_seen = 0
1056
+ kept_groups = []
1057
+ for group in groups:
1058
+ if is_junk("", group["url"]):
1059
+ junk_seen += group["copies"]
1060
+ continue
1061
+ kept_groups.append(group)
1062
+ groups = kept_groups
1063
+ if not include_search:
1064
+ groups = [group for group in groups if not is_search_result_url(group["url"])]
1065
+ urls = [group["url"] for group in groups]
1066
+ by_url = {}
1067
+ if urls:
1068
+ placeholders = ",".join("?" for _ in urls)
1069
+ instance_rows = self.connection.execute(
1070
+ "SELECT section_id, item_id, url, title, note, note_color, folder_path, added_at, position "
1071
+ + base + " AND url IN (" + placeholders + ") ORDER BY url, added_at, folder_path, item_id",
1072
+ [source_id, *urls]).fetchall()
1073
+ for row in instance_rows:
1074
+ by_url.setdefault(row["url"], []).append(dict(row))
1075
+ kept = []
1076
+ for group in groups:
1077
+ copies = []
1078
+ for instance in by_url.get(group["url"], []):
1079
+ if is_junk(instance.get("title"), instance.get("url")):
1080
+ junk_seen += 1
1081
+ continue
1082
+ instance["depth"] = (instance.get("folder_path") or "").count("/") + 1
1083
+ copies.append(instance)
1084
+ if len(copies) < min_count:
1085
+ continue
1086
+ keep = sorted(copies, key=lambda item: (
1087
+ 0 if item.get("note") else 1, item.get("added_at") or "9999",
1088
+ item["depth"], item["section_id"], item["item_id"]))[0]
1089
+ group["copies"] = len(copies)
1090
+ group["keep"] = {key: keep[key] for key in ("section_id", "item_id", "title",
1091
+ "folder_path", "note", "added_at")}
1092
+ group["instances"] = copies
1093
+ kept.append(group)
1094
+ total_groups = len(kept)
1095
+ page = kept[offset:offset + limit]
1096
+ shown = offset + len(page)
1097
+ return {"source_id": source_id, "total_groups": total_groups, "groups": page,
1098
+ "limit": limit, "offset": offset,
1099
+ "next_offset": shown if shown < total_groups else None,
1100
+ "include_search": include_search, "junk_instances_excluded": junk_seen}
1101
+
1102
+ def folder_children(self, source_id, folder_id, section=None, tags=None, tag_colors=None,
1103
+ has_note=None, has_tags=None, metadata_match="all", limit=8, offset=0):
1104
+ """List one folder's direct bookmark/folder children with stable pagination."""
1105
+ source_id = self.resolve_source_id(source_id)
1106
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
1107
+ raise ValueError("limit must be an integer from 1 to 1000")
1108
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
1109
+ raise ValueError("offset must be a non-negative integer")
1110
+ self._source(source_id)
1111
+ rows = self.connection.execute(
1112
+ "SELECT * FROM items WHERE source_id=? AND item_type='folder' AND item_id=?",
1113
+ (source_id, folder_id)).fetchall()
1114
+ if section is not None:
1115
+ selected_sections = {row["canonical_id"] or row["section_id"]
1116
+ for row in self._sections(source_id, section)}
1117
+ rows = [row for row in rows if row["section_id"] in selected_sections]
1118
+ unique = {(row["section_id"], row["item_id"]): row for row in rows}
1119
+ if not unique:
1120
+ raise ValueError("Unknown folder ID: " + str(folder_id))
1121
+ if len(unique) > 1:
1122
+ raise ValueError("Ambiguous folder ID; pass section to select one: " + str(folder_id))
1123
+ folder = next(iter(unique.values()))
1124
+ clauses, parameters = self._scope(
1125
+ source_id, section=folder["section_id"], tags=tags, tag_colors=tag_colors,
1126
+ item_types=["bookmark", "folder"], has_note=has_note, has_tags=has_tags,
1127
+ metadata_match=metadata_match)
1128
+ clauses.append("i.parent_id=?")
1129
+ parameters.append(folder_id)
1130
+ where = " AND ".join(clauses)
1131
+ total = self.connection.execute("SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
1132
+ children = self.connection.execute(
1133
+ "SELECT i.* FROM items i WHERE " + where +
1134
+ " ORDER BY i.position,i.preorder,i.item_id LIMIT ? OFFSET ?",
1135
+ parameters + [limit, offset]).fetchall()
1136
+ anchors = self._section_anchors(source_id)
1137
+ return {"source_id": source_id, "folder": self._item_result(folder, anchors),
1138
+ "total": total, "limit": limit, "offset": offset,
1139
+ "results": [self._item_result(row, anchors) for row in children]}
1140
+
897
1141
  @staticmethod
898
1142
  def plan_terms(question):
899
1143
  """Question text into literal search terms; no semantics, no guessing.
900
1144
 
901
1145
  ASCII runs of two or more characters are kept whole. A CJK run of four
902
- or fewer characters is kept whole as well; a longer run contributes its
903
- bigrams instead, so 本地部署 still reaches a card that only says 部署.
1146
+ or fewer characters is kept whole as well, with boundary-aligned bigrams
1147
+ when exactly four characters. A longer run contributes boundary-aligned
1148
+ bigrams without cross-word fragments, so 本地部署 still reaches a card
1149
+ that only says 部署.
904
1150
  A single ASCII character is dropped, because it would match almost
905
1151
  everything; a single CJK character is kept, because it is often the
906
1152
  whole entity. The order is the order of appearance and the list is
@@ -918,14 +1164,26 @@ class BookmarkIndex:
918
1164
  if run[0].isascii():
919
1165
  candidates = [run] if len(run) >= 2 else []
920
1166
  else:
921
- candidates = [run] if len(run) <= 4 else []
922
- candidates.extend(run[index:index + 2] for index in range(len(run) - 1))
1167
+ candidates = []
1168
+ sub_segments = [s for s in PLAN_CJK_SPLITTERS.split(run) if s]
1169
+ for segment in sub_segments:
1170
+ if len(segment) <= 4:
1171
+ candidates.append(segment)
1172
+ if len(segment) == 4:
1173
+ candidates.extend([segment[:2], segment[2:]])
1174
+ else:
1175
+ if len(segment) % 2 == 0:
1176
+ candidates.extend(segment[i:i + 2] for i in range(0, len(segment) - 1, 2))
1177
+ else:
1178
+ for i in range(0, len(segment) - 1, 2):
1179
+ candidates.append(segment[i:i + 2])
1180
+ candidates.append(segment[-2:])
923
1181
  for candidate in candidates:
924
1182
  if candidate not in terms:
925
1183
  terms.append(candidate)
926
1184
  return terms[:PLAN_MAX_TERMS]
927
1185
 
928
- def plan_scope(self, source_id, question, max_cards=3, max_urls=8, homepage_limit=0):
1186
+ def plan_scope(self, source_id, question, max_cards=3, max_urls=8, homepage_limit=0, field_score=False):
929
1187
  """Rank the cards a question touches and the bookmarks inside them.
930
1188
 
931
1189
  Local only: literal matching over indexed metadata, no fetch and no
@@ -942,6 +1200,7 @@ class BookmarkIndex:
942
1200
  cards, deduplicated by URL, with the terms each URL's own rows matched
943
1201
  and the source-wide instance count, which is not the selected scope.
944
1202
  """
1203
+ source_id = self.resolve_source_id(source_id)
945
1204
  source = self._source(source_id)
946
1205
  package = self._input_path(source_id, source["package_path"])
947
1206
  # Partial imports keep cards whose file disappeared. They stay indexed,
@@ -956,6 +1215,8 @@ class BookmarkIndex:
956
1215
  raise ValueError("max_urls must be an integer from 1 to 8")
957
1216
  if type(homepage_limit) is not int or not 0 <= homepage_limit <= 8:
958
1217
  raise ValueError("homepage_limit must be an integer from 0 to 8")
1218
+ if type(field_score) is not bool:
1219
+ raise ValueError("field_score must be a boolean")
959
1220
  terms = self.plan_terms(question)
960
1221
  result = {"source_id": source_id, "terms": terms, "term_cards": {}, "cards": [], "urls": [],
961
1222
  "truncated": False, "errors": []}
@@ -1029,8 +1290,20 @@ class BookmarkIndex:
1029
1290
  # ascending tie-breaks, the second orders by score, hits and freshness,
1030
1291
  # and equal rows keep the order the first pass gave them.
1031
1292
  ordered = sorted(hits, key=lambda value: (sections[value]["file_path"], value))
1032
- ordered.sort(key=lambda value: (scores[value] * density[value], hits[value],
1033
- (newest.get(value) or (None, None))[0] or ""), reverse=True)
1293
+ if field_score:
1294
+ card_field_scores = {}
1295
+ for sid in scores:
1296
+ sec_items = [dict(r) for r in self.connection.execute(
1297
+ "SELECT * FROM items i WHERE i.source_id=? AND i.section_id=? AND i.item_type='bookmark' AND (" + where + ")",
1298
+ (source_id, sid, *parameters)).fetchall()]
1299
+ total_f = sum(BookmarkIndex.score_item(r, terms)[0] for r in sec_items)
1300
+ card_field_scores[sid] = total_f / (bookmarks.get(sid) or 1)
1301
+ ordered.sort(key=lambda value: (card_field_scores.get(value, 0.0) * density[value],
1302
+ scores[value] * density[value], hits[value],
1303
+ (newest.get(value) or (None, None))[0] or ""), reverse=True)
1304
+ else:
1305
+ ordered.sort(key=lambda value: (scores[value] * density[value], hits[value],
1306
+ (newest.get(value) or (None, None))[0] or ""), reverse=True)
1034
1307
  trees, ranked = set(), []
1035
1308
  for section_id in ordered:
1036
1309
  row = sections[section_id]
@@ -1152,8 +1425,10 @@ class BookmarkIndex:
1152
1425
  def _item_result(self, row, anchors=None, section_cache=None):
1153
1426
  keys = row.keys()
1154
1427
  result = {key: row[key] for key in ("source_id", "section_id", "item_id", "parent_id", "item_type", "title",
1155
- "url", "note", "note_color", "position")
1428
+ "note", "note_color", "position")
1156
1429
  + (("added_at", "added_at_source", "added_at_confidence") if "added_at" in keys else ())}
1430
+ if row["item_type"] != "folder" or row["url"]:
1431
+ result["url"] = row["url"]
1157
1432
  result["added_at_precision"] = ADDED_AT_SOURCES.get(result.get("added_at_source"), (None,))[0]
1158
1433
  result.update(source=row["source_id"], section=row["section_id"], item=row["item_id"],
1159
1434
  path=json.loads(row["path_json"]), folder_path=row["folder_path"], tags=json.loads(row["tags_json"]),
@@ -1177,15 +1452,93 @@ class BookmarkIndex:
1177
1452
  result["section_label"], result["file_path"] = section[0], section[1]
1178
1453
  return result
1179
1454
 
1455
+ @staticmethod
1456
+ def score_item(row, targets=None):
1457
+ """Compute non-binding weighted scoring hints for a candidate item.
1458
+
1459
+ Scoring tiers:
1460
+ - title_prefix: 140
1461
+ - title_contains: 135
1462
+ - url: 120
1463
+ - tag: 110
1464
+ - note: 90
1465
+ - folder_path: inherited evidence only; it is not an item score
1466
+ Time confidence tier bonus:
1467
+ - high: +10
1468
+ - medium: +5
1469
+ - low: +2
1470
+ """
1471
+ targets = targets or []
1472
+ keys = set(row.keys()) if hasattr(row, "keys") else set()
1473
+ title = (row["title"] if "title" in keys and row["title"] else "").casefold()
1474
+ note = (row["note"] if "note" in keys and row["note"] else "").casefold()
1475
+ folder_path = (row["folder_path"] if "folder_path" in keys and row["folder_path"] else "").casefold()
1476
+ url = (row["url"] if "url" in keys and row["url"] else "").casefold()
1477
+ tag_text = (row["tag_text"] if "tag_text" in keys and row["tag_text"] else "").casefold()
1478
+
1479
+ field_hits = []
1480
+ score = 0
1481
+ for target in targets:
1482
+ t = (target or "").casefold().strip()
1483
+ if not t:
1484
+ continue
1485
+ if title.startswith(t):
1486
+ if "title_prefix" not in field_hits:
1487
+ field_hits.append("title_prefix")
1488
+ score += 140
1489
+ elif t in title:
1490
+ if "title_contains" not in field_hits:
1491
+ field_hits.append("title_contains")
1492
+ score += 135
1493
+ if t in tag_text:
1494
+ if "tag" not in field_hits:
1495
+ field_hits.append("tag")
1496
+ score += 110
1497
+ if t in note:
1498
+ if "note" not in field_hits:
1499
+ field_hits.append("note")
1500
+ score += 90
1501
+ if t in folder_path and "folder_path" not in field_hits:
1502
+ field_hits.append("folder_path")
1503
+ if t in url:
1504
+ if "url" not in field_hits:
1505
+ field_hits.append("url")
1506
+ score += 120
1507
+
1508
+ conf = row["added_at_confidence"] if "added_at_confidence" in keys else None
1509
+ if conf == "high":
1510
+ score += 10
1511
+ field_hits.append("time_high")
1512
+ elif conf == "medium":
1513
+ score += 5
1514
+ field_hits.append("time_medium")
1515
+ elif conf == "low":
1516
+ score += 2
1517
+ field_hits.append("time_low")
1518
+
1519
+ return score, field_hits
1520
+
1180
1521
  def search(self, source_id, targets=None, section=None, group_id=None, folder_id=None, tags=None,
1181
- limit=20, offset=0, tag_colors=None, item_types=None, count_only=False, compact=False):
1522
+ limit=20, offset=0, tag_colors=None, item_types=None, count_only=False, compact=False,
1523
+ score=False, has_note=None, has_tags=None, metadata_match="all", sort_by="default"):
1182
1524
  """Literal structured search. ``limit=0`` or ``count_only`` returns counts only."""
1525
+ source_id = self.resolve_source_id(source_id)
1183
1526
  if isinstance(limit, bool) or not isinstance(limit, int) or not 0 <= limit <= 1000:
1184
1527
  raise ValueError("limit must be an integer from 0 to 1000; 0 returns counts only")
1185
1528
  if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
1186
1529
  raise ValueError("offset must be a non-negative integer")
1187
1530
  if type(compact) is not bool:
1188
1531
  raise ValueError("compact must be a boolean")
1532
+ if type(score) is not bool:
1533
+ raise ValueError("score must be a boolean")
1534
+ if has_note is not None and type(has_note) is not bool:
1535
+ raise ValueError("has_note must be a boolean or null")
1536
+ if has_tags is not None and type(has_tags) is not bool:
1537
+ raise ValueError("has_tags must be a boolean or null")
1538
+ if metadata_match not in ("all", "any"):
1539
+ raise ValueError("metadata_match must be all or any")
1540
+ if sort_by not in ("default", "added_at_desc", "added_at_asc"):
1541
+ raise ValueError("sort_by must be default, added_at_desc or added_at_asc")
1189
1542
  if isinstance(targets, str) or isinstance(tags, str) or isinstance(tag_colors, str):
1190
1543
  raise ValueError("targets, tags and tag_colors must be lists of strings")
1191
1544
  targets = list(dict.fromkeys(targets or []))
@@ -1198,7 +1551,8 @@ class BookmarkIndex:
1198
1551
  or any(value not in ("bookmark", "folder") for value in item_types)
1199
1552
  or len(set(item_types)) != len(item_types)):
1200
1553
  raise ValueError("item_types must select bookmark and/or folder without duplicates")
1201
- clauses, args = self._scope(source_id, section, group_id, folder_id, tags, tag_colors, item_types)
1554
+ clauses, args = self._scope(source_id, section, group_id, folder_id, tags, tag_colors, item_types,
1555
+ has_note, has_tags, metadata_match)
1202
1556
  page_size = 0 if count_only else limit
1203
1557
  anchors_by_section = self._section_anchors(source_id)
1204
1558
  section_cache = {}
@@ -1212,34 +1566,94 @@ class BookmarkIndex:
1212
1566
  where = " AND ".join(predicates)
1213
1567
  rows, total = [], None
1214
1568
  if page and page_size:
1215
- if self._window_functions:
1569
+ if score:
1570
+ total = self.connection.execute(
1571
+ "SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
1572
+ candidate_limit = max(1000, offset + page_size * 5)
1573
+ if total > candidate_limit:
1574
+ all_rows = self.connection.execute(
1575
+ "SELECT i.* FROM items i WHERE " + where +
1576
+ " ORDER BY (i.added_at IS NULL), i.added_at DESC, i.section_id, i.preorder LIMIT ?",
1577
+ parameters + [candidate_limit]).fetchall()
1578
+ else:
1579
+ all_rows = self.connection.execute(
1580
+ "SELECT i.* FROM items i WHERE " + where, parameters).fetchall()
1581
+ scored_rows = []
1582
+ for r in all_rows:
1583
+ s_val, s_hits = BookmarkIndex.score_item(r, chosen)
1584
+ scored_rows.append((r, s_val, s_hits))
1585
+ scored_rows.sort(key=lambda t: (t[0]["section_id"], t[0]["preorder"], t[0]["item_id"]))
1586
+ if sort_by == "default":
1587
+ scored_rows.sort(key=lambda t: t[0]["added_at"] or "", reverse=True)
1588
+ scored_rows.sort(key=lambda t: 0 if t[0]["added_at"] is not None else 1)
1589
+ scored_rows.sort(key=lambda t: t[1], reverse=True)
1590
+ else:
1591
+ scored_rows.sort(key=lambda t: t[1], reverse=True)
1592
+ scored_rows.sort(key=lambda t: t[0]["added_at"] or "",
1593
+ reverse=sort_by == "added_at_desc")
1594
+ scored_rows.sort(key=lambda t: 0 if t[0]["added_at"] is not None else 1)
1595
+
1596
+ page_slice = scored_rows[offset : offset + page_size]
1597
+ rows_result = []
1598
+ for r, s_val, s_hits in page_slice:
1599
+ item = self._item_result(r, anchors_by_section, section_cache)
1600
+ item["score"] = s_val
1601
+ item["score_hint"] = True
1602
+ item["field_hits"] = s_hits
1603
+ rows_result.append(item)
1604
+ elif self._window_functions:
1216
1605
  # One statement returns the page and its pre-LIMIT count, so a
1217
1606
  # batch of targets does not pay a second scan per target.
1607
+ order = ("(i.added_at IS NULL),i.added_at " + ("DESC" if sort_by == "added_at_desc" else "ASC")
1608
+ if sort_by != "default" else "i.section_id,i.preorder,i.item_id")
1218
1609
  rows = self.connection.execute(
1219
1610
  "SELECT i.*, count(*) OVER () AS computed_total FROM items i WHERE " + where +
1220
- " ORDER BY i.section_id,i.preorder,i.item_id LIMIT ? OFFSET ?",
1611
+ " ORDER BY " + order + (",i.section_id,i.preorder,i.item_id" if sort_by != "default" else "") +
1612
+ " LIMIT ? OFFSET ?",
1221
1613
  parameters + [page_size, offset]).fetchall()
1222
1614
  total = rows[0]["computed_total"] if rows else self.connection.execute(
1223
1615
  "SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
1616
+ rows_result = [self._item_result(row, anchors_by_section, section_cache) for row in rows]
1224
1617
  else:
1225
1618
  total = self.connection.execute("SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
1619
+ order = ("(i.added_at IS NULL),i.added_at " + ("DESC" if sort_by == "added_at_desc" else "ASC")
1620
+ if sort_by != "default" else "i.section_id,i.preorder,i.item_id")
1226
1621
  rows = self.connection.execute(
1227
- "SELECT i.* FROM items i WHERE " + where + " ORDER BY i.section_id,i.preorder,i.item_id LIMIT ? OFFSET ?",
1622
+ "SELECT i.* FROM items i WHERE " + where + " ORDER BY " + order +
1623
+ (",i.section_id,i.preorder,i.item_id" if sort_by != "default" else "") + " LIMIT ? OFFSET ?",
1228
1624
  parameters + [page_size, offset]).fetchall()
1625
+ rows_result = [self._item_result(row, anchors_by_section, section_cache) for row in rows]
1229
1626
  else:
1230
1627
  total = self.connection.execute("SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
1628
+ rows_result = []
1231
1629
  unique = self.connection.execute(
1232
1630
  "SELECT count(DISTINCT i.url) FROM items i WHERE " + where + " AND i.url<>''", parameters).fetchone()[0]
1233
1631
  return {"total": total, "unique_urls": unique, "limit": limit, "offset": offset,
1234
- "results": [self._item_result(row, anchors_by_section, section_cache) for row in rows]}
1632
+ "results": rows_result}
1235
1633
 
1236
1634
  result = query(targets)
1635
+ if score:
1636
+ result["score_mode"] = "hint"
1637
+ result["score_candidate_pool"] = {
1638
+ "truncated": result["total"] > max(1000, offset + page_size * 5),
1639
+ "selection": "newest_added_at_first",
1640
+ "candidate_limit": max(1000, offset + page_size * 5),
1641
+ }
1642
+ result["score_note"] = (
1643
+ "Item scores are non-binding ranking hints based on field weights "
1644
+ "(title_prefix: 140, title_contains: 135, url: 120, tag: 110, note: 90) "
1645
+ "plus time confidence bonus (high: +10, medium: +5, low: +2). folder_path is inherited evidence "
1646
+ "for folder aggregation and does not increase an item's score. Filter matching remains literal substring. "
1647
+ "When score_candidate_pool.truncated is true, only the newest-added candidates were ranked."
1648
+ )
1237
1649
  section_rows = self._sections(source_id, section) if section is not None else []
1238
1650
  anchors = {row["section_id"]: row["canonical_id"] for row in section_rows if row["is_anchor"]}
1239
1651
  result.update(source_id=source_id,
1240
1652
  query_scope={"section": section, "group_id": group_id, "folder_id": folder_id,
1241
1653
  "tags": list(tags or []), "tag_colors": list(tag_colors or []),
1242
- "item_types": list(item_types) if item_types else ["bookmark"]},
1654
+ "item_types": list(item_types) if item_types else ["bookmark"],
1655
+ "has_note": has_note, "has_tags": has_tags,
1656
+ "metadata_match": metadata_match, "sort_by": sort_by},
1243
1657
  match_mode="literal_substring", match_index="fts_trigram+like" if self._fts_mode == "trigram" else "like",
1244
1658
  searched_fields=["title", "url", "note", "tags.text", "folder_path"])
1245
1659
  result["counts"] = {"instances": result["total"], "unique_urls": result["unique_urls"],
@@ -1306,6 +1720,7 @@ class BookmarkIndex:
1306
1720
  return matches
1307
1721
 
1308
1722
  def context(self, source_id, section=None, group_id=None, item_id=None):
1723
+ source_id = self.resolve_source_id(source_id)
1309
1724
  source = self._source(source_id)
1310
1725
  sections = self._sections(source_id, section)
1311
1726
  nodes = [dict(row) for row in self.connection.execute("SELECT * FROM nodes WHERE source_id=? ORDER BY canvas_path,position", (source_id,))]
@@ -1405,6 +1820,352 @@ class BookmarkIndex:
1405
1820
  "instances": instances, "unique_urls": len(urls),
1406
1821
  "note": "Section totals, not item or folder subtree totals. Sum of counted_sections only; copy anchors share another card's tree and are excluded."}
1407
1822
 
1823
+ def lookup_url(self, url, source_id=None, limit=100, offset=0):
1824
+ """Find all instances of a bookmark URL across sections and sources.
1825
+
1826
+ Returns matching item instances with source, section, folder path,
1827
+ chronological added_at, and associated copy anchor cards.
1828
+ """
1829
+ if not isinstance(url, str) or not url.strip():
1830
+ raise ValueError("url must be a non-empty string")
1831
+ url = url.strip()
1832
+ if source_id is not None:
1833
+ _string(source_id, "source_id", True)
1834
+ source_id = self.resolve_source_id(source_id)
1835
+ self._source(source_id)
1836
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
1837
+ raise ValueError("limit must be an integer between 1 and 1000")
1838
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
1839
+ raise ValueError("offset must be a non-negative integer")
1840
+
1841
+ clauses = ["i.item_type = 'bookmark'", "i.url = ?"]
1842
+ params = [url]
1843
+ if source_id is not None:
1844
+ clauses.append("i.source_id = ?")
1845
+ params.append(source_id)
1846
+ where = " AND ".join(clauses)
1847
+
1848
+ total = self.connection.execute(f"SELECT count(*) FROM items i WHERE {where}", params).fetchone()[0]
1849
+ distinct_sources = [r[0] for r in self.connection.execute(
1850
+ f"SELECT DISTINCT i.source_id FROM items i WHERE {where} ORDER BY i.source_id", params).fetchall()]
1851
+
1852
+ first_seen_row = self.connection.execute(
1853
+ f"SELECT min(i.added_at) FROM items i WHERE {where} AND i.added_at IS NOT NULL", params).fetchone()
1854
+ first_seen = first_seen_row[0] if first_seen_row and first_seen_row[0] else None
1855
+
1856
+ if source_id is not None:
1857
+ anchors_by_section = self._section_anchors(source_id)
1858
+ else:
1859
+ anchors_by_section = {}
1860
+ for row in self.connection.execute(
1861
+ "SELECT source_id, section_id, canonical_id FROM sections WHERE is_anchor=1 AND canonical_id IS NOT NULL AND canonical_id != section_id"):
1862
+ anchors_by_section.setdefault((row["source_id"], row["canonical_id"]), []).append(row["section_id"])
1863
+
1864
+ order_by = "ORDER BY (i.added_at IS NULL), i.added_at ASC, i.source_id ASC, i.section_id ASC, i.preorder ASC, i.item_id ASC"
1865
+ query_sql = f"""
1866
+ SELECT i.source_id, i.section_id, i.item_id, i.parent_id, i.title, i.url,
1867
+ i.note, i.folder_path, i.path_json, i.added_at, i.added_at_source,
1868
+ i.added_at_confidence, s.label AS section_label, s.title AS section_title,
1869
+ s.file_path, s.is_anchor, s.canonical_id
1870
+ FROM items i
1871
+ LEFT JOIN sections s ON i.source_id = s.source_id AND i.section_id = s.section_id
1872
+ WHERE {where}
1873
+ {order_by}
1874
+ LIMIT ? OFFSET ?
1875
+ """
1876
+ rows = self.connection.execute(query_sql, params + [limit, offset]).fetchall()
1877
+
1878
+ instances = []
1879
+ for row in rows:
1880
+ sid = row["source_id"]
1881
+ sec_id = row["section_id"]
1882
+ if source_id is not None:
1883
+ anchors = anchors_by_section.get(sec_id, [])
1884
+ else:
1885
+ anchors = anchors_by_section.get((sid, sec_id), [])
1886
+ try:
1887
+ path = json.loads(row["path_json"]) if row["path_json"] else []
1888
+ except (ValueError, TypeError):
1889
+ path = []
1890
+ instances.append({
1891
+ "source_id": sid,
1892
+ "section_id": sec_id,
1893
+ "section_label": row["section_label"] or "",
1894
+ "section_title": row["section_title"] or "",
1895
+ "file_path": row["file_path"] or "",
1896
+ "item_id": row["item_id"],
1897
+ "parent_id": row["parent_id"],
1898
+ "title": row["title"],
1899
+ "url": row["url"],
1900
+ "folder_path": row["folder_path"],
1901
+ "path": path,
1902
+ "added_at": row["added_at"],
1903
+ "added_at_source": row["added_at_source"],
1904
+ "added_at_confidence": row["added_at_confidence"],
1905
+ "shown_in_anchors": sorted(anchors),
1906
+ })
1907
+
1908
+ next_offset = (offset + len(instances)) if (offset + len(instances)) < total else None
1909
+ return {
1910
+ "url": url,
1911
+ "source_filter": source_id,
1912
+ "total_instances": total,
1913
+ "unique_sources": len(distinct_sources),
1914
+ "sources": distinct_sources,
1915
+ "first_seen": first_seen,
1916
+ "instances": instances,
1917
+ "limit": limit,
1918
+ "offset": offset,
1919
+ "next_offset": next_offset,
1920
+ }
1921
+
1922
+ def traverse(self, source_id, section=None, group_id=None, item_id=None, node_id=None,
1923
+ max_depth=2, direction="all", limit=100, offset=0):
1924
+ """Multi-hop graph traversal starting from nodes, sections, groups, or items.
1925
+
1926
+ Traverses along canvas edges and group memberships up to max_depth hops.
1927
+ Returns the impact surface: affected sections, groups, text cards,
1928
+ and traversed paths with deterministic sorting and pagination.
1929
+ """
1930
+ source_id = self.resolve_source_id(source_id)
1931
+ source = self._source(source_id)
1932
+ if isinstance(max_depth, bool) or not isinstance(max_depth, int) or not 1 <= max_depth <= 10:
1933
+ raise ValueError("max_depth must be an integer between 1 and 10")
1934
+ if direction not in ("all", "out", "in"):
1935
+ raise ValueError("direction must be 'all', 'out', or 'in'")
1936
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
1937
+ raise ValueError("limit must be an integer between 1 and 1000")
1938
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
1939
+ raise ValueError("offset must be a non-negative integer")
1940
+
1941
+ all_nodes = [dict(row) for row in self.connection.execute(
1942
+ "SELECT * FROM nodes WHERE source_id=? ORDER BY canvas_path,position", (source_id,))]
1943
+ all_edges = [dict(row) for row in self.connection.execute(
1944
+ "SELECT * FROM edges WHERE source_id=? ORDER BY canvas_path,edge_id", (source_id,))]
1945
+ all_memberships = [dict(row) for row in self.connection.execute(
1946
+ "SELECT * FROM memberships WHERE source_id=? ORDER BY canvas_path,group_id,node_id", (source_id,))]
1947
+ sections_map = {row["section_id"]: dict(row) for row in self._sections(source_id)}
1948
+
1949
+ for edge in all_edges:
1950
+ edge["direction"] = ("both" if edge["from_end"] == edge["to_end"] == "arrow" else
1951
+ "forward" if edge["to_end"] == "arrow" else
1952
+ "reverse" if edge["from_end"] == "arrow" else "none")
1953
+ try:
1954
+ edge["raw_json"] = json.loads(edge["raw_json"])
1955
+ except (ValueError, TypeError):
1956
+ edge["raw_json"] = {}
1957
+ edge["added_at"] = _added_at_from_edge_id(edge["edge_id"])
1958
+
1959
+ for node in all_nodes:
1960
+ try:
1961
+ node["raw_json"] = json.loads(node["raw_json"])
1962
+ except (ValueError, TypeError):
1963
+ node["raw_json"] = {}
1964
+
1965
+ node_map = {(n["canvas_path"], n["node_id"]): n for n in all_nodes}
1966
+ start_nodes = []
1967
+ start_scope = {}
1968
+
1969
+ if item_id is not None:
1970
+ start_scope["item_id"] = item_id
1971
+ item_rows = self.connection.execute(
1972
+ "SELECT section_id FROM items WHERE source_id=? AND item_id=?", (source_id, item_id)).fetchall()
1973
+ if not item_rows:
1974
+ raise ValueError("Unknown item in requested scope: " + str(item_id))
1975
+ item_sections = {r[0] for r in item_rows}
1976
+ start_nodes = [n for n in all_nodes if n["section_id"] in item_sections]
1977
+ elif group_id is not None:
1978
+ start_scope["group_id"] = group_id
1979
+ group = self._group(source_id, group_id)
1980
+ start_nodes = [n for n in all_nodes if n["canvas_path"] == group["canvas_path"] and n["node_id"] == group["node_id"]]
1981
+ elif node_id is not None:
1982
+ start_scope["node_id"] = node_id
1983
+ start_nodes = [n for n in all_nodes if n["node_id"] == node_id]
1984
+ elif section is not None:
1985
+ start_scope["section"] = section
1986
+ matched_sections = self._sections(source_id, section)
1987
+ if not matched_sections:
1988
+ raise ValueError("Unknown section: " + str(section))
1989
+ sec_ids = {r["section_id"] for r in matched_sections}
1990
+ start_nodes = [n for n in all_nodes if n["section_id"] in sec_ids]
1991
+ # Match get_context: include enclosing group containers in start nodes (depth 0)
1992
+ group_ids = {m["group_id"] for m in all_memberships if any(
1993
+ n["canvas_path"] == m["canvas_path"] and n["node_id"] == m["node_id"] for n in start_nodes)}
1994
+ for n in all_nodes:
1995
+ if n["node_type"] == "group" and n["node_id"] in group_ids and n not in start_nodes:
1996
+ start_nodes.append(n)
1997
+ else:
1998
+ raise ValueError("Specify at least one of section, group_id, item_id, or node_id")
1999
+
2000
+ if not start_nodes:
2001
+ raise ValueError("No matching canvas nodes found for the specified start target")
2002
+
2003
+ adj = {}
2004
+ for edge in all_edges:
2005
+ cp = edge["canvas_path"]
2006
+ u = (cp, edge["from_node"])
2007
+ v = (cp, edge["to_node"])
2008
+ edir = edge["direction"]
2009
+ adj.setdefault(u, []).append(("edge", v, edge, "out", edir))
2010
+ adj.setdefault(v, []).append(("edge", u, edge, "in", edir))
2011
+
2012
+ for m in all_memberships:
2013
+ cp = m["canvas_path"]
2014
+ grp = (cp, m["group_id"])
2015
+ mbr = (cp, m["node_id"])
2016
+ adj.setdefault(grp, []).append(("membership", mbr, m, "contains", None))
2017
+ adj.setdefault(mbr, []).append(("membership", grp, m, "member_of", None))
2018
+
2019
+ start_keys = {(n["canvas_path"], n["node_id"]) for n in start_nodes}
2020
+ visited = {key: {"depth": 0, "node": node_map[key], "path": []} for key in start_keys if key in node_map}
2021
+ queue = [(key, 0, []) for key in visited]
2022
+
2023
+ traversed_edges = {}
2024
+ traversed_memberships = set()
2025
+
2026
+ while queue:
2027
+ curr_key, curr_d, curr_path = queue.pop(0)
2028
+ if curr_d >= max_depth:
2029
+ continue
2030
+
2031
+ for hop_type, neighbor_key, obj, rel_or_dir, edge_dir in adj.get(curr_key, []):
2032
+ if neighbor_key not in node_map:
2033
+ continue
2034
+
2035
+ if hop_type == "edge":
2036
+ if direction == "out":
2037
+ if rel_or_dir == "out" and edge_dir not in ("forward", "both", "none"):
2038
+ continue
2039
+ if rel_or_dir == "in" and edge_dir not in ("reverse", "both", "none"):
2040
+ continue
2041
+ elif direction == "in":
2042
+ if rel_or_dir == "out" and edge_dir not in ("reverse", "both", "none"):
2043
+ continue
2044
+ if rel_or_dir == "in" and edge_dir not in ("forward", "both", "none"):
2045
+ continue
2046
+
2047
+ traversed_edges[obj["edge_id"]] = obj
2048
+ step = {
2049
+ "hop": curr_d + 1,
2050
+ "type": "edge",
2051
+ "edge_id": obj["edge_id"],
2052
+ "label": obj.get("label", ""),
2053
+ "direction": obj["direction"],
2054
+ "from_node": obj["from_node"],
2055
+ "to_node": obj["to_node"],
2056
+ }
2057
+ else:
2058
+ traversed_memberships.add((obj["canvas_path"], obj["group_id"], obj["node_id"]))
2059
+ step = {
2060
+ "hop": curr_d + 1,
2061
+ "type": "membership",
2062
+ "relation": rel_or_dir,
2063
+ "group_id": obj["group_id"],
2064
+ "node_id": obj["node_id"],
2065
+ }
2066
+
2067
+ if neighbor_key not in visited:
2068
+ new_path = curr_path + [step]
2069
+ visited[neighbor_key] = {
2070
+ "depth": curr_d + 1,
2071
+ "node": node_map[neighbor_key],
2072
+ "path": new_path,
2073
+ }
2074
+ queue.append((neighbor_key, curr_d + 1, new_path))
2075
+
2076
+ seen_affected_sections = {}
2077
+ affected_groups = []
2078
+ affected_text_cards = []
2079
+ all_visited_nodes = []
2080
+
2081
+ max_depth_reached = max(v["depth"] for v in visited.values()) if visited else 0
2082
+
2083
+ sorted_nodes = sorted(
2084
+ visited.values(),
2085
+ key=lambda item: (item["depth"], item["node"]["canvas_path"], item["node"]["position"], item["node"]["node_id"])
2086
+ )
2087
+
2088
+ for entry in sorted_nodes:
2089
+ d = entry["depth"]
2090
+ n = entry["node"]
2091
+ node_repr = {
2092
+ "depth": d,
2093
+ "canvas_path": n["canvas_path"],
2094
+ "node_id": n["node_id"],
2095
+ "node_type": n["node_type"],
2096
+ "label": n["label"],
2097
+ "text": n["text"],
2098
+ "section_id": n["section_id"],
2099
+ "path": entry["path"],
2100
+ }
2101
+ if n["section_id"] and n["section_id"] in sections_map:
2102
+ s = sections_map[n["section_id"]]
2103
+ target_sec_id = s.get("canonical_id") or s["section_id"]
2104
+ b_count = self.connection.execute(
2105
+ "SELECT count(*) FROM items WHERE source_id=? AND section_id=? AND item_type='bookmark'",
2106
+ (source_id, target_sec_id)).fetchone()[0]
2107
+ node_repr["section"] = {
2108
+ "section_id": s["section_id"],
2109
+ "label": s["label"],
2110
+ "title": s["title"],
2111
+ "file_path": s["file_path"],
2112
+ "is_anchor": bool(s["is_anchor"]),
2113
+ "canonical_id": s["canonical_id"],
2114
+ "bookmark_count": b_count,
2115
+ }
2116
+ if d > 0:
2117
+ sec_id = s["section_id"]
2118
+ if sec_id not in seen_affected_sections:
2119
+ seen_affected_sections[sec_id] = dict(depth=d, **node_repr["section"])
2120
+ elif d < seen_affected_sections[sec_id]["depth"]:
2121
+ seen_affected_sections[sec_id]["depth"] = d
2122
+
2123
+ if d > 0:
2124
+ if n["node_type"] == "group":
2125
+ affected_groups.append({"depth": d, "canvas_path": n["canvas_path"], "node_id": n["node_id"], "label": n["label"]})
2126
+ elif n["node_type"] == "text":
2127
+ affected_text_cards.append({"depth": d, "canvas_path": n["canvas_path"], "node_id": n["node_id"], "text": n["text"]})
2128
+
2129
+ all_visited_nodes.append(node_repr)
2130
+
2131
+ affected_sections = list(seen_affected_sections.values())
2132
+
2133
+ reached_only = [n for n in all_visited_nodes if n["depth"] > 0]
2134
+ total_reached = len(reached_only)
2135
+ paged_nodes = reached_only[offset : offset + limit]
2136
+ next_offset = (offset + len(paged_nodes)) if (offset + len(paged_nodes)) < total_reached else None
2137
+
2138
+ return {
2139
+ "source_id": source_id,
2140
+ "start_scope": start_scope,
2141
+ "start_nodes": [{"node_id": n["node_id"], "canvas_path": n["canvas_path"], "node_type": n["node_type"], "label": n["label"], "section_id": n["section_id"]} for n in start_nodes],
2142
+ "max_depth": max_depth,
2143
+ "direction": direction,
2144
+ "depth_reached": max_depth_reached,
2145
+ "counts": {
2146
+ "start_nodes": len(start_nodes),
2147
+ "total_visited": len(visited),
2148
+ "reached_nodes": total_reached,
2149
+ "affected_sections": len(affected_sections),
2150
+ "affected_groups": len(affected_groups),
2151
+ "affected_text_cards": len(affected_text_cards),
2152
+ "traversed_edges": len(traversed_edges),
2153
+ "traversed_memberships": len(traversed_memberships),
2154
+ },
2155
+ "affected_sections": affected_sections,
2156
+ "affected_groups": affected_groups,
2157
+ "affected_text_cards": affected_text_cards,
2158
+ "traversed_edges": sorted(traversed_edges.values(), key=lambda e: (e["canvas_path"], e["edge_id"])),
2159
+ "traversed_memberships": [{"canvas_path": cp, "group_id": gid, "node_id": nid} for cp, gid, nid in sorted(traversed_memberships)],
2160
+ "nodes": paged_nodes,
2161
+ "pagination": {
2162
+ "limit": limit,
2163
+ "offset": offset,
2164
+ "total": total_reached,
2165
+ "next_offset": next_offset,
2166
+ }
2167
+ }
2168
+
1408
2169
  def inventory(self, source_ids):
1409
2170
  """Freeze the complete indexed input, independently of query previews.
1410
2171
 
@@ -1415,7 +2176,7 @@ class BookmarkIndex:
1415
2176
  """
1416
2177
  if not isinstance(source_ids, list) or not source_ids:
1417
2178
  raise ValueError("source_ids must contain indexed package IDs")
1418
- identifiers = sorted(set(_string(value, "source_id", True) for value in source_ids))
2179
+ identifiers = sorted(set(self.resolve_source_id(_string(value, "source_id", True)) for value in source_ids))
1419
2180
  from archive import SourceArchive
1420
2181
  result = {"schema_version": 1, "index_state": "last_synchronized",
1421
2182
  "source_ids": identifiers, "sources": [], "files": [],
@@ -1498,6 +2259,7 @@ class BookmarkIndex:
1498
2259
  self.connection.execute("SAVEPOINT input_version_snapshot")
1499
2260
  try:
1500
2261
  for source_id in sorted(set(source_ids)):
2262
+ source_id = self.resolve_source_id(source_id)
1501
2263
  self._source(source_id)
1502
2264
  records.extend(dict(row) for row in self.connection.execute(
1503
2265
  "SELECT source_id,file_path,kind,sha256 FROM files WHERE source_id=? ORDER BY file_path", (source_id,)))
@@ -1510,6 +2272,8 @@ class BookmarkIndex:
1510
2272
  ids = [row[0] for row in self.connection.execute("SELECT source_id FROM sources ORDER BY source_id")]
1511
2273
  return {"database": str(self.db_path) if self.db_path else ":memory:", "schema_version": 1,
1512
2274
  "sources": [self.status(identifier) for identifier in ids]}
2275
+ requested_source_id = source_id
2276
+ source_id = self.resolve_source_id(source_id)
1513
2277
  result = self._source(source_id)
1514
2278
  counts = dict(self.connection.execute("""SELECT count(*) AS items,
1515
2279
  coalesce(sum(item_type='bookmark'),0) AS bookmarks,
@@ -1524,6 +2288,9 @@ class BookmarkIndex:
1524
2288
  counts[table] = self.connection.execute("SELECT count(*) FROM " + table + " WHERE source_id=?", (source_id,)).fetchone()[0]
1525
2289
  result["counts"] = counts
1526
2290
  result.update(bookmarks=counts["bookmarks"], unique_urls=counts["unique_urls"])
2291
+ if requested_source_id != source_id:
2292
+ result["resolved_via_redirect"] = True
2293
+ result["requested_source_id"] = requested_source_id
1527
2294
  package = Path(result["package_path"])
1528
2295
  result["package_exists"] = package.is_dir()
1529
2296
  result["retained_missing_files"] = [row[0] for row in self.connection.execute("SELECT file_path FROM files WHERE source_id=? ORDER BY file_path", (source_id,)) if not (package / row[0]).is_file()]