@chatpanel/events 0.53.0 → 0.61.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/cowriter-router.js +77 -0
- package/cowriter.js +190 -0
- package/index.js +37 -0
- package/knowledge.js +16 -7
- package/model-picker.js +186 -0
- package/note-actions.js +252 -0
- package/note-graph.js +158 -0
- package/note-links.js +68 -0
- package/note-mentions.js +117 -0
- package/note-plan.js +113 -0
- package/note-research.js +135 -0
- package/package.json +22 -2
- package/web-search.js +160 -0
package/note-plan.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Planning a goal into a note: the decomposition, the document it becomes, and who wrote
|
|
2
|
+
// which part of it.
|
|
3
|
+
//
|
|
4
|
+
// "Plan this in a new note" is not one model call. A goal is decomposed into sub-tasks, each
|
|
5
|
+
// sub-task is assigned a ROLE — research (needs facts, options, current information) or write
|
|
6
|
+
// (drafting, structure, synthesis) — and each is then run by whichever member of the team
|
|
7
|
+
// suits it. The note is rebuilt after every step, so the user watches a checklist fill in
|
|
8
|
+
// rather than a spinner.
|
|
9
|
+
//
|
|
10
|
+
// What has to be shared is the SHAPE: the decomposition prompt and how its answer is read,
|
|
11
|
+
// the document the tasks render into, and the authorship ledger that matches that document
|
|
12
|
+
// character for character. What stays in the client is the orchestration — running the calls,
|
|
13
|
+
// choosing the models, and painting the editor.
|
|
14
|
+
//
|
|
15
|
+
// THE LEDGER IS BUILT FROM THE SAME PARTS AS THE BODY, which is the whole reason `planParts`
|
|
16
|
+
// exists rather than a template string. A plan note is written entirely by agents; attributing
|
|
17
|
+
// it to "You" would be a lie the History tab then repeats forever. Building the text and the
|
|
18
|
+
// run-list from one list of `{ author, text }` parts means the ledger cannot drift from the
|
|
19
|
+
// document — it sums to its length by construction, which is the invariant every attribution
|
|
20
|
+
// test asserts.
|
|
21
|
+
|
|
22
|
+
import { mergeRuns } from './attribution.js';
|
|
23
|
+
|
|
24
|
+
/** Roles a sub-task can be assigned. Anything a model invents is coerced to `write`. */
|
|
25
|
+
export const PLAN_ROLES = Object.freeze(['research', 'write']);
|
|
26
|
+
|
|
27
|
+
/** The authors a plan note's ledger can name — the team, not the user. */
|
|
28
|
+
export const PLAN_AUTHORS = Object.freeze({ planner: 'Planner', research: 'Researcher', write: 'Writer' });
|
|
29
|
+
|
|
30
|
+
export const PLAN_DECOMPOSE_SYSTEM = 'You are a planning orchestrator. Break the goal into 3–6 concrete sub-tasks. For each, pick a role: "research" (needs facts, options, prices, or current info — it will web + history search) or "write" (drafting, structure, synthesis). Return ONLY compact JSON: {"tasks":[{"title":"short title","role":"research|write","prompt":"a focused instruction for this sub-task"}]}';
|
|
31
|
+
export const PLAN_DECOMPOSE_MAX_TOKENS = 600;
|
|
32
|
+
export const PLAN_DECOMPOSE_TEMPERATURE = 0.2;
|
|
33
|
+
|
|
34
|
+
/** The instruction for ONE drafted section of a plan. */
|
|
35
|
+
export function planSectionSystem(goal, task) {
|
|
36
|
+
return `You are drafting ONE section of a plan for the goal "${goal}". Write the section titled "${task?.title || ''}". Instruction: ${task?.prompt || task?.title || ''}. Be concrete and actionable — bullets, - [ ] tasks, or a small table for options. Output ONLY the section's markdown content (no heading, no preamble).`;
|
|
37
|
+
}
|
|
38
|
+
export const PLAN_SECTION_MAX_TOKENS = 700;
|
|
39
|
+
export const PLAN_SECTION_TEMPERATURE = 0.5;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read the decomposer's answer into tasks.
|
|
43
|
+
*
|
|
44
|
+
* Generous on purpose: this is a model returning JSON through whatever wrapper its provider
|
|
45
|
+
* felt like adding, and a plan that fails because the answer arrived in a fenced block is a
|
|
46
|
+
* plan that failed for no reason. The object is found inside the text, an unknown role
|
|
47
|
+
* becomes `write`, and a completely unreadable answer falls back to ONE task carrying the
|
|
48
|
+
* original goal — which still produces a useful note rather than an error.
|
|
49
|
+
*/
|
|
50
|
+
export function parsePlanTasks(raw, goal = '', max = 6) {
|
|
51
|
+
let tasks = [];
|
|
52
|
+
try {
|
|
53
|
+
const json = JSON.parse((String(raw || '').match(/\{[\s\S]*\}/) || ['{}'])[0]);
|
|
54
|
+
tasks = (Array.isArray(json.tasks) ? json.tasks : []).slice(0, max).map((t) => makeTask(t));
|
|
55
|
+
} catch { /* fall through to the single-task plan */ }
|
|
56
|
+
tasks = tasks.filter((t) => t.title || t.prompt);
|
|
57
|
+
if (!tasks.length && String(goal).trim()) tasks = [makeTask({ title: planTitleFor(goal), role: 'write', prompt: goal })];
|
|
58
|
+
return tasks;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const makeTask = (t) => ({
|
|
62
|
+
title: String(t?.title || 'Task').slice(0, 80),
|
|
63
|
+
role: t?.role === 'research' ? 'research' : 'write',
|
|
64
|
+
prompt: String(t?.prompt || t?.title || ''),
|
|
65
|
+
done: false,
|
|
66
|
+
working: false,
|
|
67
|
+
output: '',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** A goal reduced to something that reads as a title — markdown stripped, one line, capped. */
|
|
71
|
+
export function planTitleFor(goal, max = 60) {
|
|
72
|
+
return String(goal || '').replace(/[#*_`>~[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The plan note, as `{ author, text }` parts.
|
|
77
|
+
*
|
|
78
|
+
* A checklist at the top that fills in as the work lands — so the note is a progress report
|
|
79
|
+
* at a glance — then one section per sub-task. A task with no output yet says which member is
|
|
80
|
+
* working on it rather than showing an empty heading, because a heading with nothing under it
|
|
81
|
+
* reads as a section that came back empty.
|
|
82
|
+
*/
|
|
83
|
+
export function planParts(goal, tasks) {
|
|
84
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
85
|
+
const done = list.filter((t) => t.done).length;
|
|
86
|
+
const checklist = list
|
|
87
|
+
.map((t, i) => `- [${t.done ? 'x' : ' '}] ${i + 1}. ${t.title}${t.working ? ' — _working…_' : ''}`)
|
|
88
|
+
.join('\n');
|
|
89
|
+
const parts = [{
|
|
90
|
+
author: PLAN_AUTHORS.planner,
|
|
91
|
+
text: `# ${goal}\n\n**Plan** — ${done}/${list.length} sub-tasks done\n\n${checklist}\n\n---\n\n`,
|
|
92
|
+
}];
|
|
93
|
+
list.forEach((t, i) => {
|
|
94
|
+
const who = t.role === 'research' ? PLAN_AUTHORS.research : PLAN_AUTHORS.write;
|
|
95
|
+
parts.push({ author: PLAN_AUTHORS.planner, text: `## ${i + 1}. ${t.title}\n\n` });
|
|
96
|
+
parts.push({
|
|
97
|
+
author: t.output ? who : PLAN_AUTHORS.planner,
|
|
98
|
+
text: t.output || (t.working ? `_⏳ ${who} working…_` : '_pending_'),
|
|
99
|
+
});
|
|
100
|
+
parts.push({ author: PLAN_AUTHORS.planner, text: i < list.length - 1 ? '\n\n' : '\n' });
|
|
101
|
+
});
|
|
102
|
+
return parts;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The plan note's markdown. */
|
|
106
|
+
export function planBody(goal, tasks) {
|
|
107
|
+
return planParts(goal, tasks).map((p) => p.text).join('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The authorship run-list for `planBody(goal, tasks)` — equal in length by construction. */
|
|
111
|
+
export function planAttribution(goal, tasks, at = Date.now()) {
|
|
112
|
+
return mergeRuns(planParts(goal, tasks).map((p) => ({ len: p.text.length, author: p.author, at })));
|
|
113
|
+
}
|
package/note-research.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// What a note is ABOUT, and whether a search result is actually related to it.
|
|
2
|
+
//
|
|
3
|
+
// A research pane beside a note answers two questions, and both are ranking problems rather
|
|
4
|
+
// than retrieval ones. The retrieval is already shared (`sources-retrieval.js`, `rrf.js`) and
|
|
5
|
+
// the corpus differs per client; what has to be identical is the JUDGEMENT — what words the
|
|
6
|
+
// query is built from, and which results are close enough to show.
|
|
7
|
+
//
|
|
8
|
+
// THE FAILURE THIS EXISTS TO PREVENT IS A PANE FULL OF PLAUSIBLE, UNRELATED THINGS. Any
|
|
9
|
+
// ranker returns its top N for any query, so a note containing "can you check the plan today"
|
|
10
|
+
// retrieves the user's whole history ranked by nothing. Every result looks like a result. The
|
|
11
|
+
// fix is two-sided: build the query from the note's content-bearing words, then require a real
|
|
12
|
+
// overlap before showing anything. **Empty is better than irrelevant** — an empty pane is read
|
|
13
|
+
// as "nothing yet", a full one as "these are related", and only one of those can be wrong.
|
|
14
|
+
//
|
|
15
|
+
// Extracted from the extension's `notes-util.js`, where it was already pure.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Words that carry no topic, dropped from every query and every relevance test.
|
|
19
|
+
*
|
|
20
|
+
* Beyond ordinary stop-words this drops NOTE-META and AGENT noise — claude, codex, agent,
|
|
21
|
+
* research, question, answer, summarize, https, www — because a note that says "ask Claude to
|
|
22
|
+
* research this" would otherwise be judged to be ABOUT Claude and research, and would match
|
|
23
|
+
* every other note in which the user typed the same sentence.
|
|
24
|
+
*/
|
|
25
|
+
const STOP = new Set(('the a an and or but for to of in on at by with from as is are was were be been being this that these those it its i you your my me we our they them he she his her can could would should will shall may might do does did done get got make made just like about into over under out up down off not no yes plan planning day today check please help note notes write writing claude code codex anthropic agent agents assistant research researcher question questions answer answers answered reply inline summary summarize source sources cite citation https http www com net org html url link links thing things using use used need needs want wants below above here there').split(/\s+/));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The content-bearing terms of a query — lowercased words of 4+ characters that are not
|
|
29
|
+
* stop-words. Used to judge relevance, so it is about what the text IS, not how it is phrased.
|
|
30
|
+
*/
|
|
31
|
+
export function salientTerms(q) {
|
|
32
|
+
const out = new Set();
|
|
33
|
+
for (const w of String(q || '').toLowerCase().match(/[a-z0-9][a-z0-9'-]{3,}/g) || []) {
|
|
34
|
+
if (!STOP.has(w)) out.add(w);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A note's TOPIC terms, most-repeated first — what a good search query is built from.
|
|
41
|
+
*
|
|
42
|
+
* Ranked by FREQUENCY rather than position, because the words at the top of a note are
|
|
43
|
+
* usually its scaffolding ("Notes from the meeting about…") while the words it keeps
|
|
44
|
+
* returning to are its subject. Ties break toward the longer, more specific term.
|
|
45
|
+
*/
|
|
46
|
+
export function topicTerms(text, n = 8) {
|
|
47
|
+
const freq = new Map();
|
|
48
|
+
for (const w of String(text || '').toLowerCase().match(/[a-z][a-z'-]{3,}/g) || []) {
|
|
49
|
+
if (STOP.has(w)) continue;
|
|
50
|
+
freq.set(w, (freq.get(w) || 0) + 1);
|
|
51
|
+
}
|
|
52
|
+
return [...freq.entries()]
|
|
53
|
+
.sort((a, b) => b[1] - a[1] || b[0].length - a[0].length)
|
|
54
|
+
.slice(0, n)
|
|
55
|
+
.map(([w]) => w);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How related a result is to the query — 0 means "do not show this".
|
|
60
|
+
*
|
|
61
|
+
* A specific word (6+ characters) is worth twice a short one, and a WORKSPACE hit has to earn
|
|
62
|
+
* its place: two matching terms, or one specific one. A single generic word in common is how
|
|
63
|
+
* an unrelated note from March ends up in the pane.
|
|
64
|
+
*
|
|
65
|
+
* `web: true` relaxes that last rule, deliberately. A web result was fetched for a query the
|
|
66
|
+
* user explicitly asked for, and re-gating it on snippet overlap drops valid hits whose
|
|
67
|
+
* snippet happens to paraphrase — which is most of them.
|
|
68
|
+
*/
|
|
69
|
+
export function researchRelevance(card, salient, { web = false } = {}) {
|
|
70
|
+
if (!salient || !salient.size) return 0;
|
|
71
|
+
const hay = `${card?.title || ''} ${card?.snippet || ''}`.toLowerCase();
|
|
72
|
+
let hits = 0;
|
|
73
|
+
let specific = 0;
|
|
74
|
+
let score = 0;
|
|
75
|
+
for (const t of salient) {
|
|
76
|
+
if (!hay.includes(t)) continue;
|
|
77
|
+
hits += 1;
|
|
78
|
+
score += t.length >= 6 ? 2 : 1;
|
|
79
|
+
if (t.length >= 6) specific += 1;
|
|
80
|
+
}
|
|
81
|
+
if (!hits) return 0;
|
|
82
|
+
if (web || hits >= 2 || specific >= 1) return score;
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The web query for a note: its title plus its topic terms, capped at a sane length. */
|
|
87
|
+
export function webQuery(title, terms) {
|
|
88
|
+
return [String(title || ''), [...(terms || [])].slice(0, 8).join(' ')]
|
|
89
|
+
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
|
|
90
|
+
.slice(0, 120);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One line of a source, flattened — enough to recognise it, not enough to read instead. */
|
|
94
|
+
export function researchSnippet(text = '', max = 160) {
|
|
95
|
+
return String(text).replace(/\s+/g, ' ').trim().slice(0, max);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Rank and gate a set of candidate cards against the query.
|
|
100
|
+
*
|
|
101
|
+
* `{ kind, title, snippet, url, key }` in, the same objects out, ordered by relevance with
|
|
102
|
+
* the irrelevant dropped and `dismissed` keys removed. Web cards keep their retrieval order
|
|
103
|
+
* (the engine already ranked them and the user asked for them); workspace cards are re-ranked
|
|
104
|
+
* on overlap, which is the gate described above.
|
|
105
|
+
*/
|
|
106
|
+
export function rankResearchCards(cards, query, { dismissed = null, web = false } = {}) {
|
|
107
|
+
const salient = salientTerms(query);
|
|
108
|
+
const skip = dismissed instanceof Set ? dismissed : new Set(dismissed || []);
|
|
109
|
+
const kept = (Array.isArray(cards) ? cards : []).filter((c) => c && !skip.has(c.key ?? c.url));
|
|
110
|
+
if (web) return kept;
|
|
111
|
+
return kept
|
|
112
|
+
.map((c) => ({ c, s: researchRelevance(c, salient) }))
|
|
113
|
+
.filter((x) => x.s > 0)
|
|
114
|
+
.sort((a, b) => b.s - a.s)
|
|
115
|
+
.map((x) => x.c);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Merge the two lanes into one shelf.
|
|
120
|
+
*
|
|
121
|
+
* Web first when the user explicitly asked for a web search — that is what they pressed —
|
|
122
|
+
* then the grounded workspace hits, deduped by key across both.
|
|
123
|
+
*/
|
|
124
|
+
export function mergeResearchLanes(webCards, localCards, limit = 12) {
|
|
125
|
+
const out = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
for (const c of [...(webCards || []), ...(localCards || [])]) {
|
|
128
|
+
const k = c?.key ?? c?.url;
|
|
129
|
+
if (!k || seen.has(k)) continue;
|
|
130
|
+
seen.add(k);
|
|
131
|
+
out.push(c);
|
|
132
|
+
if (out.length >= limit) break;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The canonical ChatPanel event-log and capability contracts
|
|
3
|
+
"version": "0.61.0",
|
|
4
|
+
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"./backup-envelope.js": "./backup-envelope.js",
|
|
12
12
|
"./capability.js": "./capability.js",
|
|
13
13
|
"./citations.js": "./citations.js",
|
|
14
|
+
"./cowriter-router.js": "./cowriter-router.js",
|
|
15
|
+
"./cowriter.js": "./cowriter.js",
|
|
14
16
|
"./curate.js": "./curate.js",
|
|
15
17
|
"./distance.js": "./distance.js",
|
|
16
18
|
"./entitlement.js": "./entitlement.js",
|
|
@@ -33,6 +35,13 @@
|
|
|
33
35
|
"./meeting-analyzers.js": "./meeting-analyzers.js",
|
|
34
36
|
"./meeting-text.js": "./meeting-text.js",
|
|
35
37
|
"./memory.js": "./memory.js",
|
|
38
|
+
"./model-picker.js": "./model-picker.js",
|
|
39
|
+
"./note-actions.js": "./note-actions.js",
|
|
40
|
+
"./note-graph.js": "./note-graph.js",
|
|
41
|
+
"./note-links.js": "./note-links.js",
|
|
42
|
+
"./note-mentions.js": "./note-mentions.js",
|
|
43
|
+
"./note-plan.js": "./note-plan.js",
|
|
44
|
+
"./note-research.js": "./note-research.js",
|
|
36
45
|
"./observability.js": "./observability.js",
|
|
37
46
|
"./omni.js": "./omni.js",
|
|
38
47
|
"./order.js": "./order.js",
|
|
@@ -74,6 +83,7 @@
|
|
|
74
83
|
"./view.js": "./view.js",
|
|
75
84
|
"./voice-intents.js": "./voice-intents.js",
|
|
76
85
|
"./weather.js": "./weather.js",
|
|
86
|
+
"./web-search.js": "./web-search.js",
|
|
77
87
|
"./widget.js": "./widget.js"
|
|
78
88
|
},
|
|
79
89
|
"files": [
|
|
@@ -83,6 +93,8 @@
|
|
|
83
93
|
"backup-envelope.js",
|
|
84
94
|
"capability.js",
|
|
85
95
|
"citations.js",
|
|
96
|
+
"cowriter-router.js",
|
|
97
|
+
"cowriter.js",
|
|
86
98
|
"curate.js",
|
|
87
99
|
"distance.js",
|
|
88
100
|
"entitlement.js",
|
|
@@ -106,6 +118,13 @@
|
|
|
106
118
|
"meeting-analyzers.js",
|
|
107
119
|
"meeting-text.js",
|
|
108
120
|
"memory.js",
|
|
121
|
+
"model-picker.js",
|
|
122
|
+
"note-actions.js",
|
|
123
|
+
"note-graph.js",
|
|
124
|
+
"note-links.js",
|
|
125
|
+
"note-mentions.js",
|
|
126
|
+
"note-plan.js",
|
|
127
|
+
"note-research.js",
|
|
109
128
|
"observability.js",
|
|
110
129
|
"omni.js",
|
|
111
130
|
"order.js",
|
|
@@ -147,6 +166,7 @@
|
|
|
147
166
|
"view.js",
|
|
148
167
|
"voice-intents.js",
|
|
149
168
|
"weather.js",
|
|
169
|
+
"web-search.js",
|
|
150
170
|
"widget.js"
|
|
151
171
|
],
|
|
152
172
|
"scripts": {
|
package/web-search.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Reading a search engine's results page: the rules, without the DOM.
|
|
2
|
+
//
|
|
3
|
+
// Scraping a SERP is mostly judgement, and the judgement is identical everywhere: which
|
|
4
|
+
// engines to ask, how to build their URL safely from a template the user can edit, how to
|
|
5
|
+
// unwrap the redirector every engine wraps its links in, and which of the hundred anchors on
|
|
6
|
+
// the page are actually RESULTS rather than the engine's own navigation, promos and
|
|
7
|
+
// newsletter sign-ups.
|
|
8
|
+
//
|
|
9
|
+
// None of that needs a document. What does need one is turning HTML into a list of anchors,
|
|
10
|
+
// and that is the single thing each host injects: the extension has `DOMParser`, the desktop's
|
|
11
|
+
// main process has neither and uses a small extractor, and a gateway that grew one could pass
|
|
12
|
+
// its own. So there is ONE scraper with three front doors, rather than three scrapers.
|
|
13
|
+
//
|
|
14
|
+
// THE DANGEROUS PART IS THE URL, NOT THE HTML. A search template is user input that becomes a
|
|
15
|
+
// fetch, so `buildSearchUrl` refuses anything that is not https and anything without a query
|
|
16
|
+
// placeholder — and the caller is required to pass the host guard it already owns
|
|
17
|
+
// (`assertFetchable`), because deciding whether an address is safe to fetch is not a question
|
|
18
|
+
// this module is allowed to answer on its own.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The engines, in the order they are tried.
|
|
22
|
+
*
|
|
23
|
+
* `enabled` is what a fresh install starts with. Mojeek is absent rather than disabled:
|
|
24
|
+
* tested, it returns nothing at all, and an engine that never answers is not a fallback — it
|
|
25
|
+
* is latency plus a misleading "no results".
|
|
26
|
+
*/
|
|
27
|
+
export const SEARCH_ENGINES = Object.freeze([
|
|
28
|
+
Object.freeze({ id: 'startpage', name: 'Startpage', url: 'https://www.startpage.com/sp/search?query=%s', enabled: true }),
|
|
29
|
+
Object.freeze({ id: 'duckduckgo', name: 'DuckDuckGo', url: 'https://html.duckduckgo.com/html/?q=%s', enabled: true }),
|
|
30
|
+
Object.freeze({ id: 'google', name: 'Google', url: 'https://www.google.com/search?q=%s', enabled: false }),
|
|
31
|
+
Object.freeze({ id: 'bing', name: 'Bing', url: 'https://www.bing.com/search?q=%s', enabled: false }),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** How many results to take from one engine before moving on. */
|
|
35
|
+
export const RESULTS_PER_ENGINE = 5;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Turn a template into a URL, refusing the ones that are not safe to fetch.
|
|
39
|
+
*
|
|
40
|
+
* `assertFetchable` is INJECTED rather than implemented here: the SSRF/private-host guard is
|
|
41
|
+
* one shared primitive that the client, the gateway and the bridge all call, and a second
|
|
42
|
+
* opinion about what counts as a private address is how the two drift apart. Omitting it is
|
|
43
|
+
* allowed only where the caller has already checked — it throws loudly rather than silently
|
|
44
|
+
* skipping, so "I forgot" and "I checked elsewhere" cannot look the same.
|
|
45
|
+
*/
|
|
46
|
+
export function buildSearchUrl(template, query, { assertFetchable } = {}) {
|
|
47
|
+
const t = String(template || '').trim();
|
|
48
|
+
if (!/^https:\/\//i.test(t)) throw new Error('Search engine URL must start with https://');
|
|
49
|
+
if (!t.includes('%s') && !t.includes('{q}')) {
|
|
50
|
+
throw new Error('Search engine URL must contain a %s (or {q}) query placeholder');
|
|
51
|
+
}
|
|
52
|
+
const url = t.replace(/%s|\{q\}/g, encodeURIComponent(String(query || '').trim()));
|
|
53
|
+
if (typeof assertFetchable === 'function') assertFetchable(url);
|
|
54
|
+
return url;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Unwrap the redirector an engine wraps its results in, so what is fetched and CITED is the
|
|
59
|
+
* real destination rather than the engine's tracking URL.
|
|
60
|
+
*/
|
|
61
|
+
export function unwrapRedirect(href) {
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(href, 'https://duckduckgo.com');
|
|
64
|
+
const uddg = u.searchParams.get('uddg'); // DuckDuckGo /l/?uddg=<encoded>
|
|
65
|
+
if (uddg) return decodeURIComponent(uddg);
|
|
66
|
+
const other = u.searchParams.get('url') || u.searchParams.get('u');
|
|
67
|
+
if (other && /^https?:/i.test(other)) return other;
|
|
68
|
+
return u.href;
|
|
69
|
+
} catch {
|
|
70
|
+
return String(href || '');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Hosts that appear on a SERP and are never results: the engine's own family, and the promos
|
|
76
|
+
* they carry (Startpage's StartMail, Mojeek's newsletter).
|
|
77
|
+
*
|
|
78
|
+
* Deliberately NOT a broad denylist — real results legitimately point at YouTube, Yahoo and
|
|
79
|
+
* Google properties, and filtering those would be filtering the web.
|
|
80
|
+
*/
|
|
81
|
+
const JUNK_HOSTS = /(^|\.)(startpage\.com|startmail\.com|startpage\.dev|mojeek\.com|buttondown\.(com|email)|ecosia\.org|duckduckgo\.com|search\.brave\.com|qwant\.com)$/i;
|
|
82
|
+
|
|
83
|
+
/** Is this a link off the engine's own page, or part of the engine itself? */
|
|
84
|
+
export function isResultHost(hostname, engineHost = '') {
|
|
85
|
+
const host = String(hostname || '').toLowerCase();
|
|
86
|
+
if (!host) return false;
|
|
87
|
+
if (JUNK_HOSTS.test(host)) return false;
|
|
88
|
+
const engineDomain = String(engineHost || '').split('.').slice(-2).join('.').toLowerCase();
|
|
89
|
+
if (engineDomain && (host === engineDomain || host.endsWith(`.${engineDomain}`))) return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Turn a page's anchors into results.
|
|
95
|
+
*
|
|
96
|
+
* `anchors` is what the host's extractor produced: `{ href, text, snippet?, chrome? }`, where
|
|
97
|
+
* `chrome` marks an anchor that sat inside a nav, footer or promo block. Everything after this
|
|
98
|
+
* point is identical in every client, which is the reason the module exists.
|
|
99
|
+
*
|
|
100
|
+
* Deduped on origin+path rather than the full URL, so the same page offered twice with
|
|
101
|
+
* different tracking parameters counts once.
|
|
102
|
+
*/
|
|
103
|
+
export function pickResults(anchors, { engineHost = '', limit = RESULTS_PER_ENGINE, fallback = false } = {}) {
|
|
104
|
+
const out = [];
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
for (const a of anchors || []) {
|
|
107
|
+
const href = unwrapRedirect(a?.href || '');
|
|
108
|
+
let u;
|
|
109
|
+
try { u = new URL(href); } catch { continue; }
|
|
110
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') continue;
|
|
111
|
+
if (!isResultHost(u.hostname, engineHost)) continue;
|
|
112
|
+
// The generic sweep sees the WHOLE page, so it is filtered harder: an anchor inside the
|
|
113
|
+
// page's own chrome is navigation, not a result.
|
|
114
|
+
if (fallback && a.chrome) continue;
|
|
115
|
+
const title = String(a?.text || '').replace(/\s+/g, ' ').trim();
|
|
116
|
+
// A link whose visible text is a word or two is an icon, a "next", or a breadcrumb.
|
|
117
|
+
if (title.length < 6) continue;
|
|
118
|
+
const key = u.origin + u.pathname;
|
|
119
|
+
if (seen.has(key)) continue;
|
|
120
|
+
seen.add(key);
|
|
121
|
+
out.push({ url: href, title, snippet: String(a?.snippet || '').replace(/\s+/g, ' ').trim() });
|
|
122
|
+
if (out.length >= limit) break;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Merge what several engines returned into one list.
|
|
129
|
+
*
|
|
130
|
+
* Engine ORDER is preserved rather than interleaved: the first engine is the one the user put
|
|
131
|
+
* first, and a result it ranked third is more likely to be wanted than the second engine's
|
|
132
|
+
* first. Deduped across engines on origin+path, so two engines agreeing shows once.
|
|
133
|
+
*/
|
|
134
|
+
export function mergeEngineResults(perEngine, limit = 8) {
|
|
135
|
+
const out = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (const results of perEngine || []) {
|
|
138
|
+
for (const r of results || []) {
|
|
139
|
+
let key;
|
|
140
|
+
try { const u = new URL(r.url); key = u.origin + u.pathname; } catch { key = r.url; }
|
|
141
|
+
if (seen.has(key)) continue;
|
|
142
|
+
seen.add(key);
|
|
143
|
+
out.push(r);
|
|
144
|
+
if (out.length >= limit) return out;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The engines to try, in order: the enabled ones first, then the rest.
|
|
152
|
+
*
|
|
153
|
+
* A search that found nothing should escalate to the engines the user configured but left off
|
|
154
|
+
* rather than report failure — being wrong about which engine works today is much more likely
|
|
155
|
+
* than the web having no answer.
|
|
156
|
+
*/
|
|
157
|
+
export function engineOrder(engines = SEARCH_ENGINES) {
|
|
158
|
+
const list = (Array.isArray(engines) ? engines : []).filter((e) => e && e.url && !e.retired);
|
|
159
|
+
return [...list.filter((e) => e.enabled !== false), ...list.filter((e) => e.enabled === false)];
|
|
160
|
+
}
|