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/wiki_search.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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"],
|
|
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
|
-
|
|
154
|
-
|
|
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],
|