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/cli.py CHANGED
@@ -122,8 +122,8 @@ def _parser():
122
122
  configure.add_argument("--search-fallback-provider", action="append", choices=Settings.PROVIDERS)
123
123
  configure.add_argument("--search-strategy", choices=("hedged", "staggered", "sequential", "wait_all"))
124
124
  configure.add_argument("--fetch-strategy", choices=("sequential", "staggered", "hedged"))
125
- configure.add_argument("--fetch-provider", choices=Settings.PROVIDERS)
126
- configure.add_argument("--fetch-fallback-provider", action="append", choices=Settings.PROVIDERS)
125
+ configure.add_argument("--fetch-provider", choices=Settings.FETCH_PROVIDERS)
126
+ configure.add_argument("--fetch-fallback-provider", action="append", choices=Settings.FETCH_PROVIDERS)
127
127
  configure.add_argument("--depth", choices=("auto", "quick", "agentic", "deep"),
128
128
  help="Default research depth; auto picks one from each request")
129
129
  configure.add_argument("--max-characters", type=int)
@@ -159,6 +159,8 @@ def _parser():
159
159
  watch.add_argument("--interval", type=float, default=1.0)
160
160
  watch.add_argument("--debounce", type=float, default=2.0)
161
161
  watch.add_argument("--deletion-grace", type=float, default=5.0)
162
+ watch.add_argument("--verify-hashes", action="store_true",
163
+ help="Always read and verify full byte hashes instead of pre-screening by mtime and size")
162
164
  search = commands.add_parser("search", help="Literal structured bookmark search")
163
165
  search.add_argument("source")
164
166
  search.add_argument("--compact", action="store_true", help="Return shared rows with ordered page references; preserve extra fields")
@@ -171,9 +173,41 @@ def _parser():
171
173
  search.add_argument("--item-type", action="append", choices=("bookmark", "folder"), default=[],
172
174
  help="Restrict to bookmark and/or folder rows; default bookmark, use folder to enumerate folders")
173
175
  search.add_argument("--count-only", action="store_true", help="Return counts without result rows")
176
+ search.add_argument("--score", action="store_true", help="Rank results by weighted score hints (title prefix, title contains, tag, note, path, url + temporal bonus)")
174
177
  search.add_argument("--limit", type=int, default=20)
175
178
  search.add_argument("--offset", type=int, default=0)
176
179
  search.add_argument("--no-refresh", action="store_true", help="Read the last synchronized index without refreshing")
180
+ lookup_url = commands.add_parser("lookup-url", help="Reverse index: find all instances of a bookmark URL across sections and sources")
181
+ lookup_url.add_argument("url")
182
+ lookup_url.add_argument("--source-id")
183
+ lookup_url.add_argument("--limit", type=int, default=100)
184
+ lookup_url.add_argument("--offset", type=int, default=0)
185
+ locate = commands.add_parser("locate", help="Locate Canvas bookmarks by text/URL or list a folder's direct children")
186
+ locate.add_argument("--text")
187
+ locate.add_argument("--url")
188
+ locate.add_argument("--in", dest="in_sources", action="append", choices=("canvas", "wiki", "archive"),
189
+ help="Search one saved scope: Canvas bookmarks, Wiki revisions, or archive captures")
190
+ locate.add_argument("--source-id")
191
+ locate.add_argument("--section")
192
+ locate.add_argument("--folder-id", help="Search within a folder, or with no text/URL list its direct children")
193
+ locate.add_argument("--tag", dest="tags", action="append")
194
+ locate.add_argument("--tag-color", dest="tag_colors", action="append")
195
+ note_filter = locate.add_mutually_exclusive_group()
196
+ note_filter.add_argument("--has-note", dest="has_note", action="store_true")
197
+ note_filter.add_argument("--without-note", dest="has_note", action="store_false")
198
+ locate.set_defaults(has_note=None)
199
+ tags_filter = locate.add_mutually_exclusive_group()
200
+ tags_filter.add_argument("--has-tags", dest="has_tags", action="store_true")
201
+ tags_filter.add_argument("--without-tags", dest="has_tags", action="store_false")
202
+ locate.set_defaults(has_tags=None)
203
+ locate.add_argument("--metadata-match", choices=("all", "any"), default="all")
204
+ locate.add_argument("--limit", type=int, default=8)
205
+ locate.add_argument("--offset", type=int, default=0)
206
+ locate.add_argument("--handle", help="Continue a previous locate response")
207
+ refresh = locate.add_mutually_exclusive_group()
208
+ refresh.add_argument("--refresh", dest="refresh", action="store_true")
209
+ refresh.add_argument("--no-refresh", dest="refresh", action="store_false")
210
+ locate.set_defaults(refresh=False)
177
211
  context = commands.add_parser("context")
178
212
  context.add_argument("source")
179
213
  context.add_argument("--section")
@@ -183,6 +217,17 @@ def _parser():
183
217
  context.add_argument("--collection", choices=("sections", "items", "nodes", "groups", "edges", "related_nodes", "memberships"))
184
218
  context.add_argument("--offset", type=int, default=0)
185
219
  context.add_argument("--limit", type=int, default=200, help="Rows per context collection")
220
+ traverse = commands.add_parser("traverse", help="Multi-hop graph traversal along canvas edges and memberships")
221
+ traverse.add_argument("source")
222
+ traverse.add_argument("--section")
223
+ traverse.add_argument("--group")
224
+ traverse.add_argument("--item")
225
+ traverse.add_argument("--node")
226
+ traverse.add_argument("--depth", type=int, default=2)
227
+ traverse.add_argument("--direction", choices=("all", "out", "in"), default="all")
228
+ traverse.add_argument("--limit", type=int, default=100)
229
+ traverse.add_argument("--offset", type=int, default=0)
230
+ traverse.add_argument("--no-refresh", action="store_true")
186
231
  providers = commands.add_parser("providers")
187
232
  providers.add_argument("--probe", action="store_true", help="Connect to selected official MCP endpoints")
188
233
  providers.add_argument("--provider", action="append")
@@ -195,7 +240,7 @@ def _parser():
195
240
  web.add_argument("--timeout", type=int)
196
241
  fetch = commands.add_parser("fetch-web")
197
242
  fetch.add_argument("urls", nargs="+")
198
- fetch.add_argument("--provider", choices=Settings.PROVIDERS)
243
+ fetch.add_argument("--provider", choices=Settings.FETCH_PROVIDERS)
199
244
  fetch.add_argument("--timeout", type=int, help="Per provider HTTP request timeout; discovery and fallback can take longer overall")
200
245
  fetch.add_argument("--max-characters", type=int)
201
246
  fetch.add_argument("--max-total-characters", type=int, default=None)
@@ -215,6 +260,11 @@ def _parser():
215
260
  raw.add_argument("--occurrence-offset", type=int, default=0, help="Continue a body's occurrence list here")
216
261
  merge = commands.add_parser("merge-results", help="Fuse normalized batches from existing MCPs")
217
262
  merge.add_argument("input", help="JSON input file, or '-' for stdin")
263
+ merge.add_argument("--archive", action="store_true", help="Save merged extracts to knowledge archive")
264
+ ingest = commands.add_parser("ingest", help="Fuse a search payload from a JSON file (no inline MCP size limit)")
265
+ ingest.add_argument("input_path", nargs="?", help="JSON input file, or '-' for stdin")
266
+ ingest.add_argument("--input", dest="input_option", help="JSON input file, or '-' for stdin")
267
+ ingest.add_argument("--archive", action="store_true", help="Save merged extracts to knowledge archive")
218
268
  research = commands.add_parser("research", help="Durable host-led deep research, evidence and reports")
219
269
  research.add_argument("--directory", help="Research root outside source packages and plugin; default in the data directory")
220
270
  research_commands = research.add_subparsers(dest="research_command", required=True)
@@ -305,7 +355,7 @@ def _parser():
305
355
  refresh = wiki_commands.add_parser("refresh", help="Capture cited webpages for review without rewriting the Wiki")
306
356
  refresh.add_argument("page_id")
307
357
  refresh.add_argument("--expected-revision", type=int)
308
- refresh.add_argument("--provider", choices=Settings.PROVIDERS)
358
+ refresh.add_argument("--provider", choices=Settings.FETCH_PROVIDERS)
309
359
  refresh.add_argument("--max-characters", type=int)
310
360
  refresh.add_argument("--offset", type=int, default=0)
311
361
  refresh.add_argument("--limit", type=int, default=8)
@@ -317,17 +367,125 @@ def _parser():
317
367
  view.add_argument("--source-url", help="Filter direct citations and linked dependents")
318
368
  view.add_argument("--offset", type=int, default=0)
319
369
  view.add_argument("--limit", type=int, default=20)
370
+ dupes = commands.add_parser("dupes", help="Report bookmarks whose URL appears more than once")
371
+ dupes.add_argument("--source-id")
372
+ dupes.add_argument("--min-count", type=int, default=2)
373
+ dupes.add_argument("--limit", type=int, default=50)
374
+ dupes.add_argument("--offset", type=int, default=0)
375
+ dupes.add_argument("--include-search", action="store_true",
376
+ help="Include search-engine result pages (excluded by default)")
377
+ job = commands.add_parser("job", help="Submit and observe durable local jobs")
378
+ job_commands = job.add_subparsers(dest="job_command", required=True)
379
+ submit = job_commands.add_parser("submit")
380
+ submit.add_argument("--kind", choices=("fetch", "ingest", "research"), required=True)
381
+ submit.add_argument("--input", required=True, help="Payload JSON file, or - for stdin")
382
+ submit.add_argument("--idempotency-key")
383
+ submit.add_argument("--priority", type=int, default=0)
384
+ listing = job_commands.add_parser("list")
385
+ listing.add_argument("--state", choices=("queued", "running", "succeeded", "failed", "cancelled"))
386
+ listing.add_argument("--kind", choices=("fetch", "ingest", "research"))
387
+ listing.add_argument("--limit", type=int, default=20)
388
+ listing.add_argument("--offset", type=int, default=0)
389
+ status = job_commands.add_parser("status")
390
+ status.add_argument("job_id")
391
+ result = job_commands.add_parser("result")
392
+ result.add_argument("job_id")
393
+ cancel = job_commands.add_parser("cancel")
394
+ cancel.add_argument("job_id")
395
+ worker = job_commands.add_parser("worker", help="Run the singleton job worker")
396
+ worker.add_argument("--once", action="store_true", help="Claim and run at most one job")
397
+ worker.add_argument("--max-jobs", type=int, default=None)
398
+ worker.add_argument("--concurrency", type=int, default=2)
399
+ worker.add_argument("--poll-seconds", type=float, default=0.5)
400
+ worker.add_argument("--lease-seconds", type=float, default=120.0)
320
401
  commands.add_parser("serve", help="Run the stdio MCP tool server")
402
+ bench = commands.add_parser("bench", help="Prepare reproducible host comparison artifacts")
403
+ bench_commands = bench.add_subparsers(dest="bench_command", required=True)
404
+ ladder = bench_commands.add_parser(
405
+ "ladder", help="Create a 15-question host capture template; host traces are recorded separately")
406
+ ladder.add_argument("--preset", choices=("legacy", "quick"), default="quick",
407
+ help="Record which advertised tool surface the host run must use")
408
+ ladder.add_argument("--file", default=".bench/ladder.json", help="Template output path")
409
+ ladder.add_argument("--force", action="store_true", help="Replace an existing ladder file")
410
+ def add_output_options(command_parser):
411
+ command_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON (the default format)")
412
+ command_parser.add_argument("--fields", help="Project comma-separated dotted fields from object results")
413
+ command_parser.add_argument("--out", help="Write the complete result to this file")
414
+ command_parser.add_argument("--quiet", action="store_true", help="With --out, print only a compact write receipt")
415
+ for action in command_parser._actions:
416
+ if isinstance(action, argparse._SubParsersAction):
417
+ for child in action.choices.values():
418
+ add_output_options(child)
419
+ for command_parser in commands.choices.values():
420
+ add_output_options(command_parser)
321
421
  return parser
322
422
 
323
423
 
424
+ def _project_fields(value, fields):
425
+ if not fields:
426
+ return value
427
+ selected = {}
428
+ for path in (part.strip() for part in fields.split(",")):
429
+ if not path:
430
+ continue
431
+ current = value
432
+ found = True
433
+ for segment in path.split("."):
434
+ if isinstance(current, dict) and segment in current:
435
+ current = current[segment]
436
+ else:
437
+ found = False
438
+ break
439
+ if found:
440
+ selected[path] = current
441
+ return selected
442
+
443
+
444
+ def _emit_result(result, args):
445
+ projected = _project_fields(result, args.fields)
446
+ rendered = json.dumps(projected, ensure_ascii=False, indent=None if args.json else 2,
447
+ separators=(",", ":") if args.json else None) + "\n"
448
+ if args.out:
449
+ destination = Path(args.out).expanduser()
450
+ destination.parent.mkdir(parents=True, exist_ok=True)
451
+ destination.write_text(rendered, encoding="utf-8")
452
+ if args.quiet:
453
+ print(json.dumps({"written": str(destination), "characters": len(rendered.rstrip("\n"))},
454
+ ensure_ascii=False, separators=(",", ":") if args.json else None))
455
+ return
456
+ print(rendered, end="")
457
+
458
+
324
459
  def main(argv=None):
325
460
  from textio import utf8_stdio
326
461
  utf8_stdio()
327
462
  args = _parser().parse_args(argv)
328
463
  try:
329
464
  settings = Settings(args.config)
330
- if args.command == "serve":
465
+ if args.command == "bench":
466
+ destination = Path(args.file).expanduser()
467
+ if destination.exists() and not args.force:
468
+ raise ValueError("Ladder file already exists; choose --file or pass --force: " + str(destination))
469
+ scripts_directory = Path(__file__).resolve().parent.parent / "scripts"
470
+ sys.path.insert(0, str(scripts_directory))
471
+ from evaluate_ladder import template as ladder_template
472
+ from bench_tools_list import LEGACY_TOOLS
473
+ from mcp_server import _legacy_compatibility_expired, release_version
474
+ if args.preset == "legacy" and _legacy_compatibility_expired(release_version()):
475
+ raise ValueError("The legacy ladder preset ended at 0.8.0")
476
+ surface = "legacy-25" if args.preset == "legacy" else "quick-10"
477
+ artifact = ladder_template()
478
+ artifact["baseline_id"] = "2026-11-" + args.preset
479
+ artifact["tool_surface"] = surface
480
+ artifact["required_tool_scope"] = (list(LEGACY_TOOLS) if args.preset == "legacy"
481
+ else list(Settings.QUICK_TOOLS))
482
+ destination.parent.mkdir(parents=True, exist_ok=True)
483
+ destination.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
484
+ result = {"template": str(destination), "questions": 15, "tool_surface": surface,
485
+ "runs_required": 3, "host_traces_recorded": False}
486
+ _emit_result(result, args)
487
+ return 0
488
+ elif args.command == "serve":
331
489
  from mcp_server import serve
332
490
  # Concurrency is read once at startup; an unreadable settings file
333
491
  # falls back to the conservative defaults instead of failing to serve.
@@ -343,7 +501,7 @@ def main(argv=None):
343
501
  if args.command == "watch":
344
502
  from source_watcher import SourceWatcher
345
503
  watcher = SourceWatcher(_database(args.db), interval=args.interval, debounce=args.debounce,
346
- deletion_grace=args.deletion_grace,
504
+ deletion_grace=args.deletion_grace, verify_hashes=args.verify_hashes,
347
505
  on_change=lambda value: print(json.dumps(value, ensure_ascii=False), flush=True))
348
506
  try:
349
507
  watcher.run()
@@ -457,6 +615,48 @@ def main(argv=None):
457
615
  result = store.search(args.query, args.offset, args.limit)
458
616
  else:
459
617
  result = store.lint(args.page_id)
618
+ elif args.command == "job":
619
+ from jobs import JobStore, JobWorker, default_handlers
620
+ store = JobStore()
621
+ try:
622
+ if args.job_command == "submit":
623
+ raw = sys.stdin.read() if args.input == "-" else Path(args.input).expanduser().read_text(encoding="utf-8")
624
+ payload = json.loads(raw)
625
+ job = store.submit(args.kind, payload, idempotency_key=args.idempotency_key,
626
+ priority=args.priority)
627
+ _emit_result({"job": job}, args)
628
+ elif args.job_command == "list":
629
+ _emit_result({"jobs": store.list(state=args.state, kind=args.kind,
630
+ limit=args.limit, offset=args.offset)}, args)
631
+ elif args.job_command == "status":
632
+ job = store.get(args.job_id)
633
+ if job is None:
634
+ raise ValueError("Unknown job: " + args.job_id)
635
+ _emit_result({"job": job}, args)
636
+ elif args.job_command == "result":
637
+ job = store.get(args.job_id)
638
+ if job is None:
639
+ raise ValueError("Unknown job: " + args.job_id)
640
+ if job["result"] is None:
641
+ _emit_result({"job_id": job["job_id"], "state": job["state"],
642
+ "note": "no result yet"}, args)
643
+ else:
644
+ _emit_result(job["result"], args)
645
+ elif args.job_command == "cancel":
646
+ _emit_result({"job": store.cancel(args.job_id)}, args)
647
+ elif args.job_command == "worker":
648
+ worker = JobWorker(store, handlers=default_handlers(),
649
+ concurrency=args.concurrency,
650
+ lease_seconds=args.lease_seconds,
651
+ poll_seconds=args.poll_seconds)
652
+ if args.once:
653
+ _emit_result({"processed": worker.run_once()}, args)
654
+ else:
655
+ worker.serve(max_jobs=args.max_jobs)
656
+ _emit_result({"worker": "stopped"}, args)
657
+ finally:
658
+ store.close()
659
+ return 0
460
660
  elif args.command == "config":
461
661
  if args.config_command == "show":
462
662
  result = settings.describe()
@@ -523,7 +723,12 @@ def main(argv=None):
523
723
  else:
524
724
  if args.offset:
525
725
  raise ValueError("offset requires capture_id so continued reads use the same saved evidence")
526
- result = engine.fetch(args.urls, args.provider, archive=args.archive, max_characters=args.max_characters)
726
+ if args.raw:
727
+ result = engine._fetch(args.urls, args.provider, archive=args.archive,
728
+ max_characters=args.max_characters)
729
+ else:
730
+ result = engine.fetch(args.urls, args.provider, archive=args.archive,
731
+ max_characters=args.max_characters)
527
732
  if not args.raw:
528
733
  result = SourceArchive.compact_fetch(
529
734
  result, max_total_characters=total, fair_share_min=fair)
@@ -540,24 +745,96 @@ def main(argv=None):
540
745
  result = RawLibrary(settings=settings).search(args.query, args.limit, args.offset, args.url,
541
746
  args.occurrence_limit, args.occurrence_offset)
542
747
  elif args.command == "merge-results":
543
- result = fuse_results(_read_json(args.input))
748
+ result = fuse_results(_read_json(args.input), archive=args.archive, settings=settings)
749
+ elif args.command == "ingest":
750
+ # The MCP primitive deliberately caps inline payloads. A file
751
+ # keeps larger host search batches out of the model context while
752
+ # preserving the same deterministic fusion implementation.
753
+ if bool(args.input_path) == bool(args.input_option):
754
+ raise ValueError("provide exactly one ingest input path, positionally or with --input")
755
+ result = fuse_results(_read_json(args.input_option or args.input_path),
756
+ archive=args.archive, settings=settings)
544
757
  else:
545
758
  from source_manager import SourceManager
546
- with BookmarkIndex(str(_database(args.db))) as index:
759
+ locate_readonly = not args.refresh if args.command == "locate" else True
760
+ if args.command == "locate" and args.handle:
761
+ from locate import continuation_refresh_requested
762
+ locate_readonly = not continuation_refresh_requested(args.handle, _database(args.db))
763
+ readonly = (args.command in ("status", "history", "lookup-url")
764
+ or (args.command == "locate" and locate_readonly)
765
+ or (args.command == "search" and args.no_refresh)
766
+ or (args.command in ("traverse", "context") and args.no_refresh)
767
+ or args.command == "dupes")
768
+ with BookmarkIndex(str(_database(args.db)), readonly=readonly) as index:
547
769
  sources = SourceManager(index)
548
- if args.command == "sync":
770
+ if args.command == "dupes":
771
+ source_ids = [args.source_id] if args.source_id else sources.live_sources()
772
+ if len(source_ids) != 1:
773
+ raise ValueError("source_required: dupes requires --source-id when the index has zero or multiple live sources")
774
+ result = index.duplicate_urls(source_ids[0], min_count=args.min_count,
775
+ limit=args.limit, offset=args.offset,
776
+ include_search=args.include_search)
777
+ elif args.command == "sync":
549
778
  result = sources.sync(args.package, args.source_id, mode=args.mode, completeness=args.completeness)
550
779
  elif args.command == "status":
551
780
  result = sources.status(args.source)
552
781
  elif args.command == "history":
553
782
  result = sources.history(args.source, limit=args.limit, offset=args.offset)
783
+ elif args.command == "lookup-url":
784
+ with index.read_snapshot():
785
+ result = index.lookup_url(args.url, source_id=args.source_id, limit=args.limit, offset=args.offset)
786
+ elif args.command == "locate":
787
+ from locate import locate as locate_core
788
+ from locate import continuation_scope
789
+ locate_scope = args.in_sources or ["canvas"]
790
+ if args.handle:
791
+ locate_scope = continuation_scope(args.handle, _database(args.db))
792
+ wiki = None
793
+ raw = None
794
+ if locate_scope == ["wiki"]:
795
+ from research import ResearchSessions
796
+ from wiki import WikiStore
797
+ sessions = ResearchSessions(settings=settings, db_path=_database(args.db))
798
+ wiki = WikiStore(directory=WikiStore.selected_directory(settings), settings=settings,
799
+ research_sessions=sessions)
800
+ elif locate_scope == ["archive"]:
801
+ from raw_library import RawLibrary
802
+ raw = RawLibrary(settings=settings)
803
+ elif args.handle:
804
+ # The continuation handle is opaque; provide both
805
+ # adapters so the core can restore its original scope.
806
+ from research import ResearchSessions
807
+ from raw_library import RawLibrary
808
+ from wiki import WikiStore
809
+ sessions = ResearchSessions(settings=settings, db_path=_database(args.db))
810
+ wiki = WikiStore(directory=WikiStore.selected_directory(settings), settings=settings,
811
+ research_sessions=sessions)
812
+ raw = RawLibrary(settings=settings)
813
+ result = locate_core(index, sources, {
814
+ "text": args.text, "url": args.url,
815
+ "in": locate_scope,
816
+ "source_id": args.source_id, "section": args.section,
817
+ "folder_id": args.folder_id, "tags": args.tags,
818
+ "tag_colors": args.tag_colors, "has_note": args.has_note,
819
+ "has_tags": args.has_tags, "metadata_match": args.metadata_match,
820
+ "limit": args.limit, "offset": args.offset,
821
+ "handle": args.handle, "refresh": args.refresh,
822
+ }, wiki=wiki, raw=raw)
554
823
  elif args.command == "source":
555
824
  if args.source_command == "remove":
556
825
  result = sources.remove(args.source_id, confirm=args.confirm)
557
826
  else:
558
827
  result = sources.merge(args.source_id, args.into_source_id, confirm=args.confirm)
559
828
  else:
560
- refresh = None if args.no_refresh else sources.refresh(args.source)
829
+ if args.no_refresh:
830
+ refresh = None
831
+ elif index.readonly:
832
+ # The index degraded to read-only (shared file, read-only
833
+ # mount, sandboxed host): answer from the saved snapshot
834
+ # instead of failing on the refresh write.
835
+ refresh = {"state": "skipped", "reason": "read-only index"}
836
+ else:
837
+ refresh = sources.refresh(args.source)
561
838
  if refresh and refresh["state"] in ("error", "unavailable"):
562
839
  raise ValueError(refresh["error"] + "; use --no-refresh to read the saved index")
563
840
  with index.read_snapshot():
@@ -566,7 +843,12 @@ def main(argv=None):
566
843
  group_id=args.group, folder_id=args.folder, tags=args.tag,
567
844
  limit=args.limit, offset=args.offset, tag_colors=args.tag_color,
568
845
  item_types=args.item_type or None, count_only=args.count_only,
569
- compact=args.compact)
846
+ compact=args.compact, score=args.score)
847
+ elif args.command == "traverse":
848
+ result = index.traverse(args.source, section=args.section, group_id=args.group,
849
+ item_id=args.item, node_id=args.node,
850
+ max_depth=args.depth, direction=args.direction,
851
+ limit=args.limit, offset=args.offset)
570
852
  else:
571
853
  from context_output import page_context
572
854
  result = index.context(args.source, section=args.section, group_id=args.group,
@@ -575,7 +857,7 @@ def main(argv=None):
575
857
  result["source"] = sources.source_status(args.source)
576
858
  result["refresh"] = refresh
577
859
  result["stored_snapshot_only"] = args.no_refresh
578
- print(json.dumps(result, ensure_ascii=False, indent=2))
860
+ _emit_result(result, args)
579
861
  if args.command == "providers" and args.probe and result["successful_provider_count"] == 0:
580
862
  return 1
581
863
  if args.command == "search-web" and result.get("successful_provider_count") == 0:
@@ -586,6 +868,8 @@ def main(argv=None):
586
868
  return 1
587
869
  if args.command == "wiki" and args.wiki_command == "refresh" and result.get("status") == "error":
588
870
  return 1
871
+ if isinstance(result, dict) and result.get("status") == "partial":
872
+ return 3
589
873
  return 0
590
874
  except KeyboardInterrupt:
591
875
  print(json.dumps({"cancelled": True, "error": "Setup cancelled; previously saved preferences and keys are retained."}))
@@ -598,7 +882,7 @@ def main(argv=None):
598
882
  message = ("Cannot write the data directory: %s. Set BOOKMARK_RESEARCH_DATA_DIR to a writable directory "
599
883
  "(and BOOKMARK_RESEARCH_CONFIG for settings), or fix that directory's permissions. "
600
884
  "Run doctor for a storage check." % message)
601
- print(json.dumps({"error": message, "type": type(error).__name__}, ensure_ascii=False))
885
+ print(json.dumps({"error": message, "type": type(error).__name__}, ensure_ascii=False), file=sys.stderr)
602
886
  return 1
603
887
 
604
888
 
@@ -10,7 +10,8 @@ from settings import Settings
10
10
 
11
11
 
12
12
  class Credentials:
13
- NAMES = ("EXA_API_KEY", "PARALLEL_API_KEY", "TAVILY_API_KEY", "JINA_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY")
13
+ NAMES = ("EXA_API_KEY", "PARALLEL_API_KEY", "TAVILY_API_KEY", "JINA_API_KEY", "DEEPSEEK_API_KEY",
14
+ "PERPLEXITY_API_KEY", "XAI_API_KEY", "CUSTOM_AGENTIC_API_KEY", "OPENAI_API_KEY")
14
15
 
15
16
  def __init__(self, settings=None):
16
17
  settings = settings if settings is not None else Settings()
@@ -0,0 +1,60 @@
1
+ """Small TTL cache for identical fetch batches.
2
+
3
+ Repeating the same research within the window should not pay the provider twice.
4
+ The cache stores the compact result the caller would have received, and callers
5
+ mark reused rows so provenance stays explicit.
6
+ """
7
+ import hashlib
8
+ import json
9
+ import sqlite3
10
+ import time
11
+ from pathlib import Path
12
+
13
+
14
+ class FetchCache:
15
+ def __init__(self, path=None):
16
+ from settings import Settings
17
+ self.path = Path(path or (Settings.data_directory() / "fetch-cache.sqlite3")).expanduser()
18
+ self.path.parent.mkdir(parents=True, exist_ok=True)
19
+ self.connection = sqlite3.connect(str(self.path), timeout=5.0)
20
+ self.connection.row_factory = sqlite3.Row
21
+ self.connection.execute("PRAGMA journal_mode=WAL")
22
+ self.connection.execute(
23
+ "CREATE TABLE IF NOT EXISTS fetch_cache ("
24
+ " cache_key TEXT PRIMARY KEY, urls TEXT NOT NULL, provider TEXT,"
25
+ " result TEXT NOT NULL, stored_at REAL NOT NULL, retrieved_at TEXT NOT NULL)")
26
+ self.connection.commit()
27
+
28
+ def close(self):
29
+ self.connection.close()
30
+
31
+ @staticmethod
32
+ def key(urls, provider, archive, max_characters):
33
+ payload = json.dumps({"urls": list(urls), "provider": provider,
34
+ "archive": bool(archive), "max_characters": max_characters},
35
+ ensure_ascii=False, sort_keys=True)
36
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
37
+
38
+ def get(self, cache_key, ttl_seconds):
39
+ if not ttl_seconds or ttl_seconds <= 0:
40
+ return None
41
+ row = self.connection.execute(
42
+ "SELECT result, stored_at FROM fetch_cache WHERE cache_key=?", (cache_key,)).fetchone()
43
+ if row is None:
44
+ return None
45
+ age = time.time() - row["stored_at"]
46
+ if age > ttl_seconds:
47
+ return None
48
+ try:
49
+ return json.loads(row["result"]), age
50
+ except ValueError:
51
+ return None
52
+
53
+ def put(self, cache_key, urls, provider, result):
54
+ self.connection.execute(
55
+ "INSERT OR REPLACE INTO fetch_cache (cache_key, urls, provider, result, stored_at, retrieved_at)"
56
+ " VALUES (?,?,?,?,?,?)",
57
+ (cache_key, json.dumps(list(urls), ensure_ascii=False), provider,
58
+ json.dumps(result, ensure_ascii=False, allow_nan=False),
59
+ time.time(), result.get("retrieved_at") or ""))
60
+ self.connection.commit()