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
@@ -4,6 +4,8 @@ import sqlite3
4
4
  from contextlib import contextmanager
5
5
  from pathlib import Path
6
6
 
7
+ import storage
8
+
7
9
 
8
10
  class WikiSearchIndex:
9
11
  # Explicit query aliases, not an assertion of semantic equivalence of pages.
@@ -19,8 +21,15 @@ class WikiSearchIndex:
19
21
  return re.sub(r"\s+", "", text.casefold())
20
22
 
21
23
  @classmethod
22
- def terms(cls, query):
23
- return [list(next((group for group in cls.ALIASES if term in group), (term,)))
24
+ def terms(cls, query, extra_aliases=None):
25
+ alias_groups = list(cls.ALIASES)
26
+ if extra_aliases:
27
+ for group in extra_aliases:
28
+ if isinstance(group, (list, tuple)):
29
+ cleaned = tuple(dict.fromkeys(str(term).strip() for term in group if str(term).strip()))
30
+ if cleaned and cleaned not in alias_groups:
31
+ alias_groups.append(cleaned)
32
+ return [list(next((group for group in alias_groups if term in group), (term,)))
24
33
  for term in dict.fromkeys(query.casefold().split())]
25
34
 
26
35
  @classmethod
@@ -28,47 +37,72 @@ class WikiSearchIndex:
28
37
  value = cls.normalize(text)
29
38
  return {"1" + char for char in value} | {"2" + value[i:i + 2] for i in range(len(value) - 1)}
30
39
 
40
+ def _prepare(self, connection):
41
+ connection.execute("PRAGMA foreign_keys=ON")
42
+ connection.execute("CREATE TABLE IF NOT EXISTS documents (page_id TEXT PRIMARY KEY, sha256 TEXT NOT NULL, "
43
+ "title TEXT NOT NULL, body TEXT NOT NULL, aliases TEXT NOT NULL, headings TEXT NOT NULL DEFAULT '')")
44
+ cols = {col[1] for col in connection.execute("PRAGMA table_info(documents)").fetchall()}
45
+ if "headings" not in cols:
46
+ connection.execute("ALTER TABLE documents ADD COLUMN headings TEXT NOT NULL DEFAULT ''")
47
+ connection.execute("CREATE TABLE IF NOT EXISTS postings (gram TEXT NOT NULL, page_id TEXT NOT NULL "
48
+ "REFERENCES documents(page_id) ON DELETE CASCADE, PRIMARY KEY(gram,page_id)) WITHOUT ROWID")
49
+ connection.execute("CREATE INDEX IF NOT EXISTS postings_page ON postings(page_id)")
50
+ connection.execute("CREATE TABLE IF NOT EXISTS dependencies (url TEXT NOT NULL, page_id TEXT NOT NULL "
51
+ "REFERENCES documents(page_id) ON DELETE CASCADE, PRIMARY KEY(url,page_id)) WITHOUT ROWID")
52
+ connection.execute("CREATE TABLE IF NOT EXISTS links (target TEXT NOT NULL, page_id TEXT NOT NULL "
53
+ "REFERENCES documents(page_id) ON DELETE CASCADE, PRIMARY KEY(target,page_id)) WITHOUT ROWID")
54
+ connection.execute("CREATE INDEX IF NOT EXISTS dependencies_page ON dependencies(page_id)")
55
+ connection.execute("CREATE INDEX IF NOT EXISTS links_page ON links(page_id)")
56
+
31
57
  @contextmanager
32
- def connection(self):
33
- self.path.parent.mkdir(parents=True, exist_ok=True)
58
+ def connection(self, readonly=False):
59
+ """Writable connections self-heal; a read-only wiki index stays queryable."""
34
60
  if self.path.is_symlink():
35
61
  raise ValueError("Wiki search index must not be a symlink")
36
- connection = sqlite3.connect(str(self.path), timeout=10)
62
+ connection = None
63
+ if not readonly:
64
+ try:
65
+ self.path.parent.mkdir(parents=True, exist_ok=True)
66
+ connection = sqlite3.connect(str(self.path), timeout=10)
67
+ connection.row_factory = sqlite3.Row
68
+ self._prepare(connection)
69
+ with connection:
70
+ if connection.execute("PRAGMA user_version").fetchone()[0] == 0:
71
+ connection.execute("DELETE FROM documents")
72
+ connection.execute("PRAGMA user_version=1")
73
+ yield connection
74
+ return
75
+ except sqlite3.DatabaseError as error:
76
+ if connection is not None:
77
+ connection.close()
78
+ connection = None
79
+ if not storage.is_readonly_failure(error) or not self.path.is_file():
80
+ raise
81
+ if not self.path.is_file():
82
+ raise sqlite3.OperationalError("Wiki search index is not built yet: %s" % self.path)
83
+ connection = storage.connect(self.path, readonly=True)
37
84
  connection.row_factory = sqlite3.Row
38
85
  try:
39
86
  connection.execute("PRAGMA foreign_keys=ON")
40
- connection.execute("CREATE TABLE IF NOT EXISTS documents (page_id TEXT PRIMARY KEY, sha256 TEXT NOT NULL, "
41
- "title TEXT NOT NULL, body TEXT NOT NULL, aliases TEXT NOT NULL)")
42
- connection.execute("CREATE TABLE IF NOT EXISTS postings (gram TEXT NOT NULL, page_id TEXT NOT NULL "
43
- "REFERENCES documents(page_id) ON DELETE CASCADE, PRIMARY KEY(gram,page_id)) WITHOUT ROWID")
44
- connection.execute("CREATE INDEX IF NOT EXISTS postings_page ON postings(page_id)")
45
- connection.execute("CREATE TABLE IF NOT EXISTS dependencies (url TEXT NOT NULL, page_id TEXT NOT NULL "
46
- "REFERENCES documents(page_id) ON DELETE CASCADE, PRIMARY KEY(url,page_id)) WITHOUT ROWID")
47
- connection.execute("CREATE TABLE IF NOT EXISTS links (target TEXT NOT NULL, page_id TEXT NOT NULL "
48
- "REFERENCES documents(page_id) ON DELETE CASCADE, PRIMARY KEY(target,page_id)) WITHOUT ROWID")
49
- connection.execute("CREATE INDEX IF NOT EXISTS dependencies_page ON dependencies(page_id)")
50
- connection.execute("CREATE INDEX IF NOT EXISTS links_page ON links(page_id)")
51
- with connection:
52
- if connection.execute("PRAGMA user_version").fetchone()[0] == 0:
53
- connection.execute("DELETE FROM documents")
54
- connection.execute("PRAGMA user_version=1")
55
- yield connection
87
+ yield storage.mark_query_only(connection)
56
88
  finally:
57
89
  connection.close()
58
90
 
59
91
  @staticmethod
60
92
  def row(page_id, pointer, record):
61
93
  page = record["page"]
94
+ headings = "\n".join(section["heading"] for section in page.get("sections", []))
62
95
  body = "\n\n".join(section["heading"] + "\n" + section["text"] for section in page["sections"])
63
96
  aliases = "\n".join(page.get("aliases", []))
64
- return {"page_id": page_id, "sha256": pointer["sha256"], "title": page["title"], "body": body, "aliases": aliases}
97
+ return {"page_id": page_id, "sha256": pointer["sha256"], "title": page["title"],
98
+ "body": body, "aliases": aliases, "headings": headings}
65
99
 
66
100
  def _put(self, connection, page_id, pointer, record):
67
101
  from archive import SourceArchive
68
102
  row = self.row(page_id, pointer, record)
69
103
  connection.execute("DELETE FROM documents WHERE page_id=?", (page_id,))
70
- connection.execute("INSERT INTO documents VALUES (?,?,?,?,?)",
71
- tuple(row[key] for key in ("page_id", "sha256", "title", "body", "aliases")))
104
+ connection.execute("INSERT INTO documents (page_id, sha256, title, body, aliases, headings) VALUES (?,?,?,?,?,?)",
105
+ tuple(row[key] for key in ("page_id", "sha256", "title", "body", "aliases", "headings")))
72
106
  connection.executemany("INSERT INTO postings VALUES (?,?)",
73
107
  ((gram, page_id) for gram in self.grams(row["title"] + "\n" + row["body"] + "\n" + row["aliases"])))
74
108
  urls = {SourceArchive._key(citation["url"]) for evidence in record["evidence"]
@@ -83,6 +117,18 @@ class WikiSearchIndex:
83
117
  self._put(connection, page_id, pointer, record)
84
118
 
85
119
  def _sync(self, connection, index, load_revision):
120
+ if storage.is_query_only(connection):
121
+ # A read-only wiki index cannot be rebuilt; answer from what is stored.
122
+ return []
123
+ try:
124
+ return self._sync_writes(connection, index, load_revision)
125
+ except sqlite3.DatabaseError as error:
126
+ if not storage.is_readonly_failure(error):
127
+ raise
128
+ storage.mark_query_only(connection)
129
+ return []
130
+
131
+ def _sync_writes(self, connection, index, load_revision):
86
132
  invalid = []
87
133
  existing = dict(connection.execute("SELECT page_id,sha256 FROM documents"))
88
134
  for page_id in existing.keys() - index["pages"].keys():
@@ -113,6 +159,48 @@ class WikiSearchIndex:
113
159
  pending.append(row[0])
114
160
  return {"direct_page_ids": sorted(direct), "page_ids": sorted(affected), "unreadable_page_ids": invalid}
115
161
 
162
+ def detect_duplicates(self, index, load_revision, page_id, title, urls):
163
+ with self.connection() as connection:
164
+ self._sync(connection, index, load_revision)
165
+ rows = connection.execute("SELECT page_id, title FROM documents WHERE page_id != ?", (page_id,)).fetchall()
166
+ url_matches = {}
167
+ for url in (urls or ()):
168
+ for row in connection.execute("SELECT page_id FROM dependencies WHERE url=? AND page_id != ?", (url, page_id)):
169
+ url_matches.setdefault(row[0], set()).add(url)
170
+ norm_new = self.normalize(title) if title else ""
171
+ tokens_new = set(re.findall(r"\w+", title.lower())) if title else set()
172
+ duplicates = []
173
+ for row in rows:
174
+ other_id, other_title = row["page_id"], row["title"]
175
+ reasons = []
176
+ matched_urls = sorted(url_matches.get(other_id, set()))
177
+ if matched_urls:
178
+ reasons.append("overlapping_urls")
179
+ norm_other = self.normalize(other_title) if other_title else ""
180
+ tokens_other = set(re.findall(r"\w+", other_title.lower())) if other_title else set()
181
+ similar_title = False
182
+ if norm_new and norm_other:
183
+ if norm_new == norm_other:
184
+ similar_title = True
185
+ elif (norm_new in norm_other or norm_other in norm_new) and min(len(norm_new), len(norm_other)) >= 4:
186
+ similar_title = True
187
+ elif tokens_new and tokens_other:
188
+ union = tokens_new | tokens_other
189
+ if union and (len(tokens_new & tokens_other) / len(union)) >= 0.7:
190
+ similar_title = True
191
+ if similar_title:
192
+ reasons.append("similar_title")
193
+ if reasons:
194
+ duplicates.append({
195
+ "page_id": other_id,
196
+ "title": other_title,
197
+ "reasons": reasons,
198
+ "overlapping_urls": matched_urls,
199
+ "candidate_merge_target": other_id,
200
+ "suggestion": f"Consider reviewing or merging with existing Wiki page '{other_id}' ({other_title})."
201
+ })
202
+ return duplicates
203
+
116
204
  def candidates(self, index, load_revision, groups):
117
205
  with self.connection() as connection:
118
206
  invalid = self._sync(connection, index, load_revision)
@@ -140,7 +228,7 @@ class WikiSearchIndex:
140
228
  return rows, invalid
141
229
 
142
230
  @classmethod
143
- def match(cls, row, query, groups):
231
+ def match(cls, row, query, groups, doc_freq=None):
144
232
  text = (row["title"] + "\n" + row["body"] + "\n" + row["aliases"]).casefold()
145
233
  squashed = cls.normalize(text)
146
234
  terms = list(dict.fromkeys(query.casefold().split()))
@@ -150,8 +238,12 @@ class WikiSearchIndex:
150
238
  for variants in groups]
151
239
  if not all(chosen):
152
240
  return None
153
- rank = sum(min(text.count(term), 20) + (5 if term in row["title"].casefold() else 0)
154
- for term in chosen) + (10 if literal or spacing else 0)
241
+ headings = row.get("headings", "").casefold()
242
+ rank = sum(
243
+ (min(text.count(term), 20) + (15 if term in row["title"].casefold() else 0) + (8 if term in headings else 0))
244
+ * (float(max(1, len(term))) / max(1, doc_freq.get(term, 1)) if doc_freq else 1.0)
245
+ for term in chosen
246
+ ) + (10 if literal or spacing else 0)
155
247
  positions = [row["body"].casefold().find(term) for term in chosen if term in row["body"].casefold()]
156
248
  begin = max(0, min(positions, default=0) - 60)
157
249
  return {"rank": rank, "snippet": row["body"][begin:begin + 500],