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.
- package/.agents/plugins/marketplace.json +1 -1
- package/.codex-plugin/plugin.json +4 -1
- package/README.md +15 -9
- package/README.zh.md +16 -9
- package/config/providers.json +26 -0
- package/config/settings.example.json +36 -0
- package/docs/bookmark-research-architecture.md +2 -2
- package/docs/canvas-tree-reading-acceptance.md +98 -0
- package/docs/configuration.en.md +70 -0
- package/docs/configuration.md +70 -0
- package/docs/details.en.md +10 -6
- package/docs/details.md +3 -3
- package/docs/harness-compatibility.md +1 -1
- package/docs/installation.en.md +4 -2
- package/docs/installation.md +4 -2
- package/docs/instructions.en.md +1 -0
- package/docs/instructions.md +1 -0
- package/docs/user-guide.en.md +1 -1
- package/docs/user-guide.md +1 -1
- package/hosts/dsh/plugin.js +3 -0
- package/package.json +1 -1
- package/scripts/bench_sync.py +191 -0
- package/scripts/bench_tools_list.py +129 -0
- package/scripts/evaluate_ladder.py +149 -0
- package/scripts/export_bundle.py +2 -0
- package/scripts/host_install.py +4 -1
- package/scripts/install.py +120 -28
- package/skills/bookmark-research/SKILL.md +28 -9
- package/skills/bookmark-research/references/package-semantics.md +56 -6
- package/skills/bookmark-research/references/research-workflow.md +5 -2
- package/skills/bookmark-research/references/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/wiki.md +3 -1
- package/skills/bookmark-research/references/zh/package-semantics.md +56 -6
- package/skills/bookmark-research/references/zh/research-workflow.md +5 -2
- package/skills/bookmark-research/references/zh/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/zh/skill-guide.md +20 -8
- package/skills/bookmark-research/references/zh/wiki.md +3 -1
- package/src/archive.py +100 -0
- package/src/bookmark_index.py +808 -41
- package/src/browser_client.py +342 -0
- package/src/cli.py +298 -14
- package/src/credentials.py +2 -1
- package/src/fetch_cache.py +60 -0
- package/src/grok_search.py +272 -0
- package/src/jobs.py +349 -0
- package/src/junk.py +33 -0
- package/src/locate.py +862 -0
- package/src/mcp_dispatch.py +22 -6
- package/src/mcp_server.py +573 -23
- package/src/netscape.py +124 -0
- package/src/onboarding.py +59 -9
- package/src/perplexity.py +78 -0
- package/src/raw_library.py +183 -45
- package/src/readiness.py +40 -5
- package/src/ref_codec.py +70 -0
- package/src/research.py +125 -12
- package/src/research_plan.py +11 -1
- package/src/routing.py +5 -2
- package/src/search_results.py +80 -2
- package/src/settings.py +61 -16
- package/src/source_inputs.py +109 -3
- package/src/source_manager.py +71 -12
- package/src/source_versions.py +20 -2
- package/src/source_watcher.py +4 -2
- package/src/storage.py +60 -0
- package/src/url_classify.py +45 -0
- package/src/web_search.py +106 -10
- package/src/wiki.py +269 -18
- 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.
|
|
126
|
-
configure.add_argument("--fetch-fallback-provider", action="append", choices=Settings.
|
|
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.
|
|
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.
|
|
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 == "
|
|
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
|
-
|
|
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
|
-
|
|
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 == "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/credentials.py
CHANGED
|
@@ -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",
|
|
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()
|