@lincoln504/pi-research 1.7.0 → 1.7.2
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/agent-skill/pi-research/SKILL.md +1 -1
- package/agent-skill/pi-research/references/configuration.md +1 -1
- package/dist/cli.mjs +48 -2
- package/dist/prompts/research-depth-guidance.ts +159 -0
- package/dist/prompts/research-tool-usage.md +2 -12
- package/dist/thread-worker.mjs +53 -4
- package/docs/CHANGELOG.md +14 -0
- package/docs/CONFIGURATION.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +14 -10
- package/src/infrastructure/browser/thread-worker.mjs +53 -4
- package/src/prompts/research-depth-guidance.ts +159 -0
- package/src/prompts/research-tool-usage.md +2 -12
- package/src/tools/research-tool-definition.ts +10 -2
- package/src/web-research/scraper-types.ts +7 -0
- package/src/web-research/scraper-utils.ts +73 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pi-research
|
|
3
3
|
description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
|
|
4
4
|
license: MIT
|
|
5
|
-
metadata: { "author": "Lincoln Deen", "version": "1.7.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.7.2", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ over `config.env` for the CLI only. Precedence:
|
|
|
27
27
|
| Variable | Default | Description |
|
|
28
28
|
|----------|---------|-------------|
|
|
29
29
|
| `PI_RESEARCH_MODEL` | _(required)_ | The `provider/model-id` all research runs on. |
|
|
30
|
-
| `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` | `1` | Default depth (0–3) when `--depth` omitted. `0` is quick (single-pass, no researcher team) and requires `PI_RESEARCH_QUICK_RESEARCH` on the pi extension. |
|
|
30
|
+
| `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` | `1` | Default depth (0–3) when `--depth` omitted. `0` is quick (single-pass, no researcher team) and requires `PI_RESEARCH_QUICK_RESEARCH` on the pi extension. The agent-facing `research` tool's depth guidance renders relative to this setting — when quick is the configured default, depth 0 is the ordinary choice and 1+ is the escalation; keeping the default (1) renders byte-identically. |
|
|
31
31
|
| `PI_RESEARCH_QUICK_MAX_QUERIES` | `5` | Max queries per `search` call in quick (depth 0) research (1–10). Applies to the CLI and SDK `--depth 0` runs too. |
|
|
32
32
|
| `PI_RESEARCH_MAX_RESEARCHERS` | `3` | Parallel researchers (1–5). |
|
|
33
33
|
| `PI_RESEARCH_TIMEOUT_MS` | `300000` | Per-researcher timeout (180000–1800000). |
|
package/dist/cli.mjs
CHANGED
|
@@ -18704,6 +18704,7 @@ var BOT_PATTERNS = [
|
|
|
18704
18704
|
];
|
|
18705
18705
|
var IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
|
|
18706
18706
|
var MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
|
|
18707
|
+
var EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
|
|
18707
18708
|
|
|
18708
18709
|
// src/web-research/types.ts
|
|
18709
18710
|
var FETCH_LAYER_TIMEOUT = 1e4;
|
|
@@ -18924,6 +18925,51 @@ function isMappedLoopback(ip) {
|
|
|
18924
18925
|
const mapped = extractMappedIpv4(ip.toLowerCase().replace(/^\[|\]$/g, ""));
|
|
18925
18926
|
return mapped !== null && /^127\./.test(mapped);
|
|
18926
18927
|
}
|
|
18928
|
+
function stripEmptyFragmentLinks(markdown) {
|
|
18929
|
+
return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, "");
|
|
18930
|
+
}
|
|
18931
|
+
var FRONT_MATTER_SLOTS = [
|
|
18932
|
+
["title", "meta-og:title", "meta-citation_title"],
|
|
18933
|
+
["meta-description", "description", "meta-og:description", "meta-twitter:description"],
|
|
18934
|
+
["meta-author", "meta-article:author", "meta-citation_author", "meta-dc.creator", "meta-dcterms.creator"],
|
|
18935
|
+
[
|
|
18936
|
+
"meta-article:published_time",
|
|
18937
|
+
"meta-citation_publication_date",
|
|
18938
|
+
"meta-citation_date",
|
|
18939
|
+
"meta-dcterms.date",
|
|
18940
|
+
"meta-dc.date",
|
|
18941
|
+
"meta-date",
|
|
18942
|
+
"meta-dcterms.created"
|
|
18943
|
+
],
|
|
18944
|
+
["meta-article:modified_time", "meta-dcterms.modified"]
|
|
18945
|
+
];
|
|
18946
|
+
var MAX_FRONT_MATTER_CHARS = 2e4;
|
|
18947
|
+
var FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
|
|
18948
|
+
function trimFrontMatter(markdown) {
|
|
18949
|
+
if (!markdown.startsWith("---\n")) return markdown;
|
|
18950
|
+
const close = markdown.indexOf("\n---\n", 3);
|
|
18951
|
+
if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
|
|
18952
|
+
const entries = [];
|
|
18953
|
+
for (const line of markdown.slice(4, close).split("\n")) {
|
|
18954
|
+
const m = FRONT_MATTER_KEY.exec(line);
|
|
18955
|
+
if (m) entries.push({ key: m[1].toLowerCase(), lines: [line] });
|
|
18956
|
+
else if (entries.length) entries[entries.length - 1].lines.push(line);
|
|
18957
|
+
}
|
|
18958
|
+
const kept = [];
|
|
18959
|
+
for (const slot of FRONT_MATTER_SLOTS) {
|
|
18960
|
+
const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== void 0);
|
|
18961
|
+
if (entry) kept.push(...entry.lines);
|
|
18962
|
+
}
|
|
18963
|
+
const rest = markdown.slice(close + 5).replace(/^\n+/, "");
|
|
18964
|
+
return kept.length ? `---
|
|
18965
|
+
${kept.join("\n")}
|
|
18966
|
+
---
|
|
18967
|
+
|
|
18968
|
+
${rest}` : rest;
|
|
18969
|
+
}
|
|
18970
|
+
function cleanConvertedMarkdown(markdown) {
|
|
18971
|
+
return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown))).replace(/\n{3,}/g, "\n\n").trim();
|
|
18972
|
+
}
|
|
18927
18973
|
function stripImageLinks(markdown) {
|
|
18928
18974
|
return markdown.replace(MARKDOWN_IMAGE_PATTERN, "").replace(IMAGE_LINK_PATTERN, "$1").replace(/\n{3,}/g, "\n\n").trim();
|
|
18929
18975
|
}
|
|
@@ -18937,7 +18983,7 @@ function createNativeMarkdownConverter(nativeModule) {
|
|
|
18937
18983
|
codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
|
|
18938
18984
|
wrap: false
|
|
18939
18985
|
});
|
|
18940
|
-
return
|
|
18986
|
+
return cleanConvertedMarkdown(result.content ?? "");
|
|
18941
18987
|
};
|
|
18942
18988
|
}
|
|
18943
18989
|
function createJsMarkdownConverter() {
|
|
@@ -18948,7 +18994,7 @@ function createJsMarkdownConverter() {
|
|
|
18948
18994
|
});
|
|
18949
18995
|
return async (html) => {
|
|
18950
18996
|
const markdown = converter.translate(html);
|
|
18951
|
-
return
|
|
18997
|
+
return cleanConvertedMarkdown(markdown);
|
|
18952
18998
|
};
|
|
18953
18999
|
}
|
|
18954
19000
|
function validateContent(html, markdown, url) {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the DEPTH PARAMETER guidance for the agent-facing `research` tool,
|
|
3
|
+
* relative to the user's configured `DEFAULT_RESEARCH_DEPTH`.
|
|
4
|
+
*
|
|
5
|
+
* Background (PR #16, discussion point 2): the tool-usage prompt says
|
|
6
|
+
* "Always specify a depth" and mapped ~95% of queries to `depth: 1`, so a
|
|
7
|
+
* user-configured default (e.g. quick/0) was effectively never honored for
|
|
8
|
+
* agent-initiated research — even with quick set as the default the agent
|
|
9
|
+
* still sent `depth: 1`. This builder renders the guidance relative to the
|
|
10
|
+
* configured default: when quick is the configured default, depth 0 becomes
|
|
11
|
+
* the ordinary choice and depth 1+ reads as the escalation. A user who keeps
|
|
12
|
+
* the default (1) sees byte-identical guidance — quality-first behavior is
|
|
13
|
+
* unchanged.
|
|
14
|
+
*
|
|
15
|
+
* The anchor is the *effective* default — the depth `execute()` actually
|
|
16
|
+
* resolves an omitted depth to:
|
|
17
|
+
*
|
|
18
|
+
* effectiveDefault = quickEnabled ? defaultDepth : Math.max(1, defaultDepth)
|
|
19
|
+
*
|
|
20
|
+
* Depth 0 is only reachable when quick mode is enabled; otherwise a
|
|
21
|
+
* configured 0 clamps to 1 at runtime, so the guidance must not advertise it.
|
|
22
|
+
*
|
|
23
|
+
* This is a pure function of the two session-static snapshots the tool schema
|
|
24
|
+
* was built from (see quickResearchEnabledAtRegistration /
|
|
25
|
+
* defaultDepthAtRegistration in index.ts) so the rendered guidance can never
|
|
26
|
+
* advertise a depth the registered schema rejects.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The depth `execute()` applies when the agent omits depth — `Math.max(depthMin,
|
|
31
|
+
* configured)` where `depthMin = quickEnabled ? 0 : 1` (see
|
|
32
|
+
* research-tool-definition.ts). This is "the ordinary choice" every rendered
|
|
33
|
+
* surface steers to, and the value the tool schema's `default` annotation must
|
|
34
|
+
* match.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveEffectiveDefault(quickEnabled: boolean, defaultDepth: number): number {
|
|
37
|
+
return quickEnabled ? defaultDepth : Math.max(1, defaultDepth);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the DEPTH PARAMETER guidance block (everything between the
|
|
42
|
+
* "Always specify a depth." lead-in and the "How depth works internally:"
|
|
43
|
+
* section). The returned string has no leading or trailing newlines; the
|
|
44
|
+
* caller's template supplies the surrounding blank lines.
|
|
45
|
+
*
|
|
46
|
+
* @param quickEnabled Whether quick mode (depth 0) is enabled — the same
|
|
47
|
+
* session-static snapshot the tool schema was built from.
|
|
48
|
+
* @param defaultDepth The user-configured `DEFAULT_RESEARCH_DEPTH` (0–3).
|
|
49
|
+
*/
|
|
50
|
+
export function buildResearchDepthGuidance(quickEnabled: boolean, defaultDepth: number): string {
|
|
51
|
+
const effectiveDefault = resolveEffectiveDefault(quickEnabled, defaultDepth);
|
|
52
|
+
|
|
53
|
+
if (!quickEnabled) {
|
|
54
|
+
return wordBlock(effectiveDefault) + '\n\n' + nothingBlock(effectiveDefault);
|
|
55
|
+
}
|
|
56
|
+
return quickDoc(effectiveDefault) + '\n' + wordBlock(effectiveDefault) + '\n\n' + nothingBlock(effectiveDefault);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The "user says a depth word" mapping. Byte-identical for effectiveDefault in
|
|
61
|
+
* {1,2,3}; only the "quick" line moves — to 0 — when the configured default IS
|
|
62
|
+
* 0. "normal" stays 1: the words form a shallow→max scale
|
|
63
|
+
* (quick < normal < deep < ultra = 0 < 1 < 2 < 3), so "normal" is always the
|
|
64
|
+
* standard orchestrated run.
|
|
65
|
+
*/
|
|
66
|
+
function wordBlock(effectiveDefault: number): string {
|
|
67
|
+
const quickTarget = effectiveDefault === 0 ? 0 : 1;
|
|
68
|
+
return [
|
|
69
|
+
'**User says a depth word (highest priority):**',
|
|
70
|
+
'- "quick" / "brief" / "simple" → `depth: ' + quickTarget + '`',
|
|
71
|
+
'- "normal" / "moderate" / "standard" → `depth: 1`',
|
|
72
|
+
'- "deep" / "thorough" / "in-depth" → `depth: 2` (never depth 3)',
|
|
73
|
+
'- "ultra" / "exhaustive" / "comprehensive" / "deep-dive" / "maximum" → `depth: 3`',
|
|
74
|
+
].join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The "user says nothing about depth — judge complexity:" section. Positions
|
|
79
|
+
* the effective default as the ordinary choice. effectiveDefault === 1
|
|
80
|
+
* reproduces the prior static text byte-for-byte.
|
|
81
|
+
*/
|
|
82
|
+
function nothingBlock(effectiveDefault: number): string {
|
|
83
|
+
const header = '**User says nothing about depth — judge complexity:**';
|
|
84
|
+
const never = '- `depth: 3` — **ABSOLUTELY NEVER without explicit user request.** The user MUST use trigger words like "ultra", "exhaustive", "comprehensive deep-dive", or "maximum research".';
|
|
85
|
+
switch (effectiveDefault) {
|
|
86
|
+
case 0:
|
|
87
|
+
return [
|
|
88
|
+
header,
|
|
89
|
+
'- `depth: 0` — the ordinary choice: a single verifiable fact, a URL, a price, a version number, or a yes/no with a source. One search-and-read pass, no researcher team.',
|
|
90
|
+
'- `depth: 1` — Coordinated, thorough research: simple facts with sources, lookups, overviews, background research. Escalate here when the question needs more than a single verifiable fact.',
|
|
91
|
+
'- `depth: 2` — Complex multi-faceted topics: policy analysis, tech evaluations, academic-style research.',
|
|
92
|
+
never + ' If the user does not explicitly request this level, use depth 0, 1, or 2.',
|
|
93
|
+
].join('\n');
|
|
94
|
+
case 2:
|
|
95
|
+
return [
|
|
96
|
+
header,
|
|
97
|
+
'- `depth: 1` — Simple facts, lookups, news, definitions, "what is X", overviews, background research.',
|
|
98
|
+
'- `depth: 2` — the ordinary choice (your configured default): complex multi-faceted topics, policy analysis, tech evaluations, academic-style research.',
|
|
99
|
+
never,
|
|
100
|
+
].join('\n');
|
|
101
|
+
case 3:
|
|
102
|
+
return [
|
|
103
|
+
header,
|
|
104
|
+
'- `depth: 1` — Simple facts, lookups, news, definitions, "what is X", overviews, background research.',
|
|
105
|
+
'- `depth: 2` — Complex multi-faceted topics: policy analysis, tech evaluations, academic-style research.',
|
|
106
|
+
'- `depth: 3` — the ordinary choice (your configured default): comprehensive, multi-round, exhaustive research.',
|
|
107
|
+
].join('\n');
|
|
108
|
+
case 1:
|
|
109
|
+
default:
|
|
110
|
+
return [
|
|
111
|
+
header,
|
|
112
|
+
'- `depth: 1` — Simple facts, lookups, news, definitions, "what is X", overviews, background research. This covers ~95%+ of queries.',
|
|
113
|
+
'- `depth: 2` — Complex multi-faceted topics: policy analysis, tech evaluations, academic-style research.',
|
|
114
|
+
never + ' If the user does not explicitly request this level, use depth 1 or depth 2.',
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The quick-mode intro, rendered only when quick mode is enabled. The first
|
|
121
|
+
* paragraph (what depth 0 is) is constant; the second names the ordinary
|
|
122
|
+
* choice, relative to the effective default.
|
|
123
|
+
*/
|
|
124
|
+
function quickDoc(effectiveDefault: number): string {
|
|
125
|
+
const first =
|
|
126
|
+
'**Depth 0 (quick) is available on this host.** Use `depth: 0` for a single verifiable fact, a URL, a price, a version number, or a yes/no with a source — or when the user says "just check", "one-liner", or "don\'t overdo it". It runs one search-and-read pass with no researcher team and answers concisely.';
|
|
127
|
+
let second: string;
|
|
128
|
+
switch (effectiveDefault) {
|
|
129
|
+
case 0:
|
|
130
|
+
second = '`depth: 0` is the ordinary choice; `depth: 1` is the escalation for coordinated research, `depth: 2` for complex or multi-faceted topics, `depth: 3` only on explicit request.';
|
|
131
|
+
break;
|
|
132
|
+
case 2:
|
|
133
|
+
second = '`depth: 2` is the ordinary choice (your configured default); `depth: 1` for simple lookups, `depth: 3` only on explicit request.';
|
|
134
|
+
break;
|
|
135
|
+
case 3:
|
|
136
|
+
second = '`depth: 3` is the ordinary choice (your configured default); `depth: 1` for simple lookups, `depth: 2` for complex topics.';
|
|
137
|
+
break;
|
|
138
|
+
case 1:
|
|
139
|
+
default:
|
|
140
|
+
second = '`depth: 1` remains the normal choice. `depth: 2` is the escalation for complex or multi-faceted questions; `depth: 3` only on explicit request.';
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
return first + '\n\n' + second;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The closing escalation nudge — the tail of the "Do NOT escalate depth just
|
|
148
|
+
* because a topic is broad" line in the MULTIPLE RESEARCH CALLS section,
|
|
149
|
+
* rendered relative to the effective default the same way the DEPTH PARAMETER
|
|
150
|
+
* block is. effectiveDefault === 1 reproduces the prior static text
|
|
151
|
+
* byte-for-byte, so a user who keeps the default (1) — quick on or off — sees
|
|
152
|
+
* an unchanged prompt.
|
|
153
|
+
*/
|
|
154
|
+
export function buildEscalationNudge(quickEnabled: boolean, defaultDepth: number): string {
|
|
155
|
+
const effectiveDefault = resolveEffectiveDefault(quickEnabled, defaultDepth);
|
|
156
|
+
return effectiveDefault === 1
|
|
157
|
+
? 'depth 1 handles most cases well, and the higher depths have their own internal decomposition.'
|
|
158
|
+
: 'your configured default handles most cases well, and the higher depths have their own internal decomposition.';
|
|
159
|
+
}
|
|
@@ -49,17 +49,7 @@ The `research` tool retrieves from the internet. Do NOT use it when the answer c
|
|
|
49
49
|
|
|
50
50
|
**Always specify a depth.** Judge it from the user's language and task complexity.
|
|
51
51
|
|
|
52
|
-
{{
|
|
53
|
-
**User says a depth word (highest priority):**
|
|
54
|
-
- "quick" / "brief" / "simple" → `depth: 1`
|
|
55
|
-
- "normal" / "moderate" / "standard" → `depth: 1`
|
|
56
|
-
- "deep" / "thorough" / "in-depth" → `depth: 2` (never depth 3)
|
|
57
|
-
- "ultra" / "exhaustive" / "comprehensive" / "deep-dive" / "maximum" → `depth: 3`
|
|
58
|
-
|
|
59
|
-
**User says nothing about depth — judge complexity:**
|
|
60
|
-
- `depth: 1` — Simple facts, lookups, news, definitions, "what is X", overviews, background research. This covers ~95%+ of queries.
|
|
61
|
-
- `depth: 2` — Complex multi-faceted topics: policy analysis, tech evaluations, academic-style research.
|
|
62
|
-
- `depth: 3` — **ABSOLUTELY NEVER without explicit user request.** The user MUST use trigger words like "ultra", "exhaustive", "comprehensive deep-dive", or "maximum research". If the user does not explicitly request this level, use depth 1 or depth 2.
|
|
52
|
+
{{DEPTH_GUIDANCE}}
|
|
63
53
|
|
|
64
54
|
**How depth works internally:**
|
|
65
55
|
- `depth: 1-3` — AI-orchestrated: coordinator plans a team, researchers execute, a research lead decides whether to continue deeper and writes the final report. Team size and number of rounds scale with complexity. The coordinator and the lead dynamically determine how many researchers are needed each round — it's not a fixed number.
|
|
@@ -92,4 +82,4 @@ The coordinator will plan as many researchers as needed (up to the max). You do
|
|
|
92
82
|
- **Group related topics**: If topics share ANY scope or could inform each other, combine them into a single `research` call. The internal coordinator will decompose them into parallel researcher agents. Do NOT split related sub-topics into separate calls.
|
|
93
83
|
- Example: `research("Python async performance and Rust async performance comparison")` — NOT separate calls for Python and Rust, since the comparison IS the research goal.
|
|
94
84
|
|
|
95
|
-
**Do NOT escalate depth just because a topic is broad** —
|
|
85
|
+
**Do NOT escalate depth just because a topic is broad** — {{ESCALATION_NUDGE}}
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -149,7 +149,7 @@ var init_user_agent = __esm({
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
// src/web-research/scraper-types.ts
|
|
152
|
-
var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN;
|
|
152
|
+
var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN, EMPTY_FRAGMENT_LINK_PATTERN;
|
|
153
153
|
var init_scraper_types = __esm({
|
|
154
154
|
"src/web-research/scraper-types.ts"() {
|
|
155
155
|
"use strict";
|
|
@@ -215,6 +215,7 @@ var init_scraper_types = __esm({
|
|
|
215
215
|
];
|
|
216
216
|
IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
|
|
217
217
|
MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
|
|
218
|
+
EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
|
|
218
219
|
}
|
|
219
220
|
});
|
|
220
221
|
|
|
@@ -1559,6 +1560,7 @@ var init_browser_error_utils = __esm({
|
|
|
1559
1560
|
// src/web-research/scraper-utils.ts
|
|
1560
1561
|
var scraper_utils_exports = {};
|
|
1561
1562
|
__export(scraper_utils_exports, {
|
|
1563
|
+
cleanConvertedMarkdown: () => cleanConvertedMarkdown,
|
|
1562
1564
|
createJsMarkdownConverter: () => createJsMarkdownConverter,
|
|
1563
1565
|
createNativeMarkdownConverter: () => createNativeMarkdownConverter,
|
|
1564
1566
|
disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
|
|
@@ -1569,7 +1571,9 @@ __export(scraper_utils_exports, {
|
|
|
1569
1571
|
isBenignScrapeFailure: () => isBenignScrapeFailure,
|
|
1570
1572
|
isSsrfBlockError: () => isSsrfBlockError,
|
|
1571
1573
|
ssrfSafeLookup: () => ssrfSafeLookup,
|
|
1574
|
+
stripEmptyFragmentLinks: () => stripEmptyFragmentLinks,
|
|
1572
1575
|
stripImageLinks: () => stripImageLinks,
|
|
1576
|
+
trimFrontMatter: () => trimFrontMatter,
|
|
1573
1577
|
validateContent: () => validateContent,
|
|
1574
1578
|
validateUrlForSSRF: () => validateUrlForSSRF,
|
|
1575
1579
|
validateUrlForSSRFSync: () => validateUrlForSSRFSync
|
|
@@ -1785,6 +1789,34 @@ function isMappedLoopback(ip) {
|
|
|
1785
1789
|
const mapped = extractMappedIpv4(ip.toLowerCase().replace(/^\[|\]$/g, ""));
|
|
1786
1790
|
return mapped !== null && /^127\./.test(mapped);
|
|
1787
1791
|
}
|
|
1792
|
+
function stripEmptyFragmentLinks(markdown) {
|
|
1793
|
+
return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, "");
|
|
1794
|
+
}
|
|
1795
|
+
function trimFrontMatter(markdown) {
|
|
1796
|
+
if (!markdown.startsWith("---\n")) return markdown;
|
|
1797
|
+
const close = markdown.indexOf("\n---\n", 3);
|
|
1798
|
+
if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
|
|
1799
|
+
const entries = [];
|
|
1800
|
+
for (const line of markdown.slice(4, close).split("\n")) {
|
|
1801
|
+
const m = FRONT_MATTER_KEY.exec(line);
|
|
1802
|
+
if (m) entries.push({ key: m[1].toLowerCase(), lines: [line] });
|
|
1803
|
+
else if (entries.length) entries[entries.length - 1].lines.push(line);
|
|
1804
|
+
}
|
|
1805
|
+
const kept = [];
|
|
1806
|
+
for (const slot of FRONT_MATTER_SLOTS) {
|
|
1807
|
+
const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== void 0);
|
|
1808
|
+
if (entry) kept.push(...entry.lines);
|
|
1809
|
+
}
|
|
1810
|
+
const rest = markdown.slice(close + 5).replace(/^\n+/, "");
|
|
1811
|
+
return kept.length ? `---
|
|
1812
|
+
${kept.join("\n")}
|
|
1813
|
+
---
|
|
1814
|
+
|
|
1815
|
+
${rest}` : rest;
|
|
1816
|
+
}
|
|
1817
|
+
function cleanConvertedMarkdown(markdown) {
|
|
1818
|
+
return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown))).replace(/\n{3,}/g, "\n\n").trim();
|
|
1819
|
+
}
|
|
1788
1820
|
function stripImageLinks(markdown) {
|
|
1789
1821
|
return markdown.replace(MARKDOWN_IMAGE_PATTERN, "").replace(IMAGE_LINK_PATTERN, "$1").replace(/\n{3,}/g, "\n\n").trim();
|
|
1790
1822
|
}
|
|
@@ -1798,7 +1830,7 @@ function createNativeMarkdownConverter(nativeModule) {
|
|
|
1798
1830
|
codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
|
|
1799
1831
|
wrap: false
|
|
1800
1832
|
});
|
|
1801
|
-
return
|
|
1833
|
+
return cleanConvertedMarkdown(result.content ?? "");
|
|
1802
1834
|
};
|
|
1803
1835
|
}
|
|
1804
1836
|
function createJsMarkdownConverter() {
|
|
@@ -1809,7 +1841,7 @@ function createJsMarkdownConverter() {
|
|
|
1809
1841
|
});
|
|
1810
1842
|
return async (html) => {
|
|
1811
1843
|
const markdown = converter.translate(html);
|
|
1812
|
-
return
|
|
1844
|
+
return cleanConvertedMarkdown(markdown);
|
|
1813
1845
|
};
|
|
1814
1846
|
}
|
|
1815
1847
|
function validateContent(html, markdown, url) {
|
|
@@ -1848,7 +1880,7 @@ function validateContent(html, markdown, url) {
|
|
|
1848
1880
|
throw error;
|
|
1849
1881
|
}
|
|
1850
1882
|
}
|
|
1851
|
-
var cachedSsrfFetcher;
|
|
1883
|
+
var cachedSsrfFetcher, FRONT_MATTER_SLOTS, MAX_FRONT_MATTER_CHARS, FRONT_MATTER_KEY;
|
|
1852
1884
|
var init_scraper_utils = __esm({
|
|
1853
1885
|
"src/web-research/scraper-utils.ts"() {
|
|
1854
1886
|
"use strict";
|
|
@@ -1858,6 +1890,23 @@ var init_scraper_utils = __esm({
|
|
|
1858
1890
|
init_scraper_types();
|
|
1859
1891
|
init_browser_error_utils();
|
|
1860
1892
|
cachedSsrfFetcher = void 0;
|
|
1893
|
+
FRONT_MATTER_SLOTS = [
|
|
1894
|
+
["title", "meta-og:title", "meta-citation_title"],
|
|
1895
|
+
["meta-description", "description", "meta-og:description", "meta-twitter:description"],
|
|
1896
|
+
["meta-author", "meta-article:author", "meta-citation_author", "meta-dc.creator", "meta-dcterms.creator"],
|
|
1897
|
+
[
|
|
1898
|
+
"meta-article:published_time",
|
|
1899
|
+
"meta-citation_publication_date",
|
|
1900
|
+
"meta-citation_date",
|
|
1901
|
+
"meta-dcterms.date",
|
|
1902
|
+
"meta-dc.date",
|
|
1903
|
+
"meta-date",
|
|
1904
|
+
"meta-dcterms.created"
|
|
1905
|
+
],
|
|
1906
|
+
["meta-article:modified_time", "meta-dcterms.modified"]
|
|
1907
|
+
];
|
|
1908
|
+
MAX_FRONT_MATTER_CHARS = 2e4;
|
|
1909
|
+
FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
|
|
1861
1910
|
}
|
|
1862
1911
|
});
|
|
1863
1912
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.7.2] - 2026-09-27
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Scraped pages keep only the useful part of the converter's front matter.** The native converter turns every `<meta>` tag into a front-matter line: 54 lines on a GitHub page (analytics and error-reporting URLs, verification hashes, nonces, app ids), and on arXiv the abstract three times over. Nothing parses these lines; researchers read them and the knowledge store indexes them. `trimFrontMatter` (`src/web-research/scraper-utils.ts`) now keeps the title, description, author and publication/modification dates (first key present per slot: plain, Open Graph, citation or Dublin Core names) and drops the rest; a page with none of them loses the block. GitHub pages shrink by about 11%, arXiv abstract pages by about a third. Side effect: an empty page shell whose front matter alone used to pass the stub check is now treated as a stub, so the browser fallback gets a chance.
|
|
13
|
+
- **Scraped pages no longer carry empty same-page links.** Documentation generators such as Quarto and nbdev put an empty anchor before every code line (`<a href="#cb2-1"></a>`) and next to headings; the native HTML→Markdown converter turned each into `[](#cb2-1)`, so every code block in a research scrape (and in the knowledge store) started each line with one. Both converters now drop links that have no text and point to the same page (`cleanConvertedMarkdown` in `src/web-research/scraper-utils.ts`, run after the existing image-link stripping so heading anchors that wrap an icon — GitHub's `[](#title)` — go too). Links with text, and empty links to other pages, are kept. On an nbdev documentation page (fastcore.fast.ai/docments.html) this removes 583 such links, 16% of the scraped text.
|
|
14
|
+
|
|
15
|
+
## [1.7.1] - 2026-09-27
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **The remaining static `depth: 1` references in the agent-facing surface now follow the configured default too.** Two spots still said `1` where the runtime applies the configured default: the tool schema's model-facing `default` annotation on the `depth` parameter (hardcoded `default: 1`, so a user who configured default 2 saw `"default": 1` in the schema while the prompt said depth 2 is the ordinary choice), and the closing "Do NOT escalate depth just because a topic is broad" line ("depth 1 handles most cases well"). Both are now rendered against the same effective default the DEPTH PARAMETER block uses — `Math.max(depthMin, configured)` where `depthMin = quickEnabled ? 0 : 1` — via `resolveEffectiveDefault` / `buildEscalationNudge` in `src/prompts/research-depth-guidance.ts`. A user whose effective default is 1 (the default install, quick on or off) sees byte-identical prompt and schema; the tests pin that.
|
|
20
|
+
- **The agent-facing `research` tool-usage prompt now renders its depth guidance relative to the user's configured `DEFAULT_RESEARCH_DEPTH`.** The DEPTH PARAMETER block was static and always steered to `depth: 1` — the "Always specify a depth" prompt mapped ~95% of queries there — so a user-configured default (e.g. quick/0) was effectively never honored for agent-initiated research: even with quick set as the default the agent still sent `depth: 1` (only `/research` and the CLI honored it). The block is now rendered by `buildResearchDepthGuidance` (new `src/prompts/research-depth-guidance.ts`) against the same session-static snapshots the tool schema is built from, so it can never advertise a depth the registered schema rejects. When quick is the configured default, depth 0 becomes the ordinary choice and depth 1+ reads as the escalation — a real cost lever. A user who keeps the default (1) sees **byte-identical** guidance, for both the quick-on and quick-off renderings (the green-light condition on PR #16); `test/unit/prompts/research-depth-guidance.test.ts` pins that byte-identity and the `effectiveDefault` the guidance anchors on (`quickEnabled ? defaultDepth : max(1, defaultDepth)`, the depth `execute()` applies to an omitted depth).
|
|
21
|
+
|
|
8
22
|
## [1.7.0] - 2026-09-22
|
|
9
23
|
|
|
10
24
|
### Added
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -73,7 +73,7 @@ Research
|
|
|
73
73
|
|----------|---------|-------|-------------|
|
|
74
74
|
| `PI_RESEARCH_TIMEOUT_MS` (TUI) | `300000` | 180000–1800000 | Per-researcher timeout (3–30 min). |
|
|
75
75
|
| `PI_RESEARCH_MAX_RESEARCHERS` (TUI) | `3` | 1–5 | Parallel researchers. |
|
|
76
|
-
| `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 0–3 | Depth for `/research` and the CLI when `--depth` is omitted (0=quick single-pass — requires `PI_RESEARCH_QUICK_RESEARCH` —, 1=normal, 2=deep, 3=ultra). |
|
|
76
|
+
| `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 0–3 | Depth for `/research` and the CLI when `--depth` is omitted (0=quick single-pass — requires `PI_RESEARCH_QUICK_RESEARCH` —, 1=normal, 2=deep, 3=ultra). The agent-facing `research` tool-usage prompt renders its depth guidance relative to this setting too: when quick is the configured default, depth 0 is the ordinary choice; a user who keeps the default (1) gets byte-identical guidance. |
|
|
77
77
|
| `PI_RESEARCH_QUICK_RESEARCH` (TUI) `[project]` | `false` | `true` · `false` | Opt-in gate for depth-0 (quick) research. When `false` (default), the agent-facing `research` tool keeps its upstream `minimum: 1` schema — agents can neither see nor send depth 0 — and a `DEFAULT_RESEARCH_DEPTH` of 0 resolves to 1. Toggling applies on the next session. |
|
|
78
78
|
| `PI_RESEARCH_QUICK_MAX_QUERIES` | `5` | 1–10 | Max queries per `search` call in quick (depth 0) research. Applies to every quick run — the pi extension tool, the CLI, and the SDK's `--depth 0` — since they share the same researcher session factory. Deliberately below deep research's 30-query cap; the ceiling of 10 keeps quick mode from ever exceeding the old 5–10 prompt guidance. |
|
|
79
79
|
| `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). When prompt caching is known to be active for the resolved research model (Anthropic API models, or a provider route explicitly configured for Anthropic-style cache control), the effective limit is this value plus one — the cached prompt prefix makes the extra batch cheap. |
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { getConfig, validateConfig } from './config.ts';
|
|
|
20
20
|
import { metrics } from './utils/metrics.ts';
|
|
21
21
|
import { handleResearchConfigCommand } from './research-config.ts';
|
|
22
22
|
import { loadPrompt } from './core/llm/prompts.ts';
|
|
23
|
+
import { buildResearchDepthGuidance, buildEscalationNudge } from './prompts/research-depth-guidance.ts';
|
|
23
24
|
import { clearAllSessionState, addSteeringMessage, getSteeringMessages, normalizeSessionId, getActiveSessionCount, popQueuedMessages, requeuePoppedMessage, getAllTrackedSessions, getPiActiveSessionOrder, getPiActivePanels } from './orchestration/session-state.ts';
|
|
24
25
|
import { initGlobalTuiController, disposeGlobalTuiController } from './tui/tui-controller.ts';
|
|
25
26
|
import { registerCoreServices, initializeCoreServices, disposeCoreServices } from './core/service-initialization.ts';
|
|
@@ -332,6 +333,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
332
333
|
// live config here would tell the agent depth 0 exists while the tool still
|
|
333
334
|
// rejects it with invalid_parameters until restart.
|
|
334
335
|
const quickResearchEnabledAtRegistration = getConfig(process.cwd(), 'pi').QUICK_RESEARCH === true;
|
|
336
|
+
// Session-static snapshot of the user's configured DEFAULT_RESEARCH_DEPTH, taken
|
|
337
|
+
// at the same moment as quickResearchEnabledAtRegistration so the depth guidance
|
|
338
|
+
// (rendered relative to it) agrees with the schema the session was built with.
|
|
339
|
+
const defaultDepthAtRegistration = getConfig(process.cwd(), 'pi').DEFAULT_RESEARCH_DEPTH;
|
|
335
340
|
|
|
336
341
|
// Create and register the health check tool
|
|
337
342
|
const healthTool: ToolDefinition = createHealthTool();
|
|
@@ -612,17 +617,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
612
617
|
(!event.systemPromptOptions || event.systemPromptOptions.selectedTools?.includes(researchKnowledgeSearchTool.name));
|
|
613
618
|
|
|
614
619
|
if (isResearchToolAvailable || isKnowledgeSearchAvailable) {
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
? '**Depth 0 (quick) is available on this host.** Use `depth: 0` for a single verifiable fact, a URL, a price, a version number, or a yes/no with a source — or when the user says "just check", "one-liner", or "don\'t overdo it". It runs one search-and-read pass with no researcher team and answers concisely.\n\n`depth: 1` remains the normal choice. `depth: 2` is the escalation for complex or multi-faceted questions; `depth: 3` only on explicit request.\n'
|
|
623
|
-
: '';
|
|
620
|
+
// The DEPTH PARAMETER guidance is rendered relative to the user's configured
|
|
621
|
+
// DEFAULT_RESEARCH_DEPTH (buildResearchDepthGuidance), gated on the SAME
|
|
622
|
+
// session-static snapshots the tool schema was built from (see
|
|
623
|
+
// quickResearchEnabledAtRegistration / defaultDepthAtRegistration) — not live
|
|
624
|
+
// config — so the prompt can never advertise a depth the registered schema
|
|
625
|
+
// rejects. A mid-session config toggle therefore fully applies on the next
|
|
626
|
+
// session, both schema and prompt together.
|
|
624
627
|
let researchPrompt = loadPrompt('research-tool-usage')
|
|
625
|
-
.replace(
|
|
628
|
+
.replace('{{DEPTH_GUIDANCE}}', buildResearchDepthGuidance(quickResearchEnabledAtRegistration, defaultDepthAtRegistration))
|
|
629
|
+
.replace('{{ESCALATION_NUDGE}}', buildEscalationNudge(quickResearchEnabledAtRegistration, defaultDepthAtRegistration))
|
|
626
630
|
.replace('{{max_team_size_l1}}', MAX_TEAM_SIZE_LEVEL_1.toString())
|
|
627
631
|
.replace('{{max_team_size_l2}}', MAX_TEAM_SIZE_LEVEL_2.toString())
|
|
628
632
|
.replace('{{max_team_size_l3}}', MAX_TEAM_SIZE_LEVEL_3.toString());
|
|
@@ -149,7 +149,7 @@ var init_user_agent = __esm({
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
// src/web-research/scraper-types.ts
|
|
152
|
-
var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN;
|
|
152
|
+
var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN, EMPTY_FRAGMENT_LINK_PATTERN;
|
|
153
153
|
var init_scraper_types = __esm({
|
|
154
154
|
"src/web-research/scraper-types.ts"() {
|
|
155
155
|
"use strict";
|
|
@@ -215,6 +215,7 @@ var init_scraper_types = __esm({
|
|
|
215
215
|
];
|
|
216
216
|
IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
|
|
217
217
|
MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
|
|
218
|
+
EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
|
|
218
219
|
}
|
|
219
220
|
});
|
|
220
221
|
|
|
@@ -1559,6 +1560,7 @@ var init_browser_error_utils = __esm({
|
|
|
1559
1560
|
// src/web-research/scraper-utils.ts
|
|
1560
1561
|
var scraper_utils_exports = {};
|
|
1561
1562
|
__export(scraper_utils_exports, {
|
|
1563
|
+
cleanConvertedMarkdown: () => cleanConvertedMarkdown,
|
|
1562
1564
|
createJsMarkdownConverter: () => createJsMarkdownConverter,
|
|
1563
1565
|
createNativeMarkdownConverter: () => createNativeMarkdownConverter,
|
|
1564
1566
|
disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
|
|
@@ -1569,7 +1571,9 @@ __export(scraper_utils_exports, {
|
|
|
1569
1571
|
isBenignScrapeFailure: () => isBenignScrapeFailure,
|
|
1570
1572
|
isSsrfBlockError: () => isSsrfBlockError,
|
|
1571
1573
|
ssrfSafeLookup: () => ssrfSafeLookup,
|
|
1574
|
+
stripEmptyFragmentLinks: () => stripEmptyFragmentLinks,
|
|
1572
1575
|
stripImageLinks: () => stripImageLinks,
|
|
1576
|
+
trimFrontMatter: () => trimFrontMatter,
|
|
1573
1577
|
validateContent: () => validateContent,
|
|
1574
1578
|
validateUrlForSSRF: () => validateUrlForSSRF,
|
|
1575
1579
|
validateUrlForSSRFSync: () => validateUrlForSSRFSync
|
|
@@ -1785,6 +1789,34 @@ function isMappedLoopback(ip) {
|
|
|
1785
1789
|
const mapped = extractMappedIpv4(ip.toLowerCase().replace(/^\[|\]$/g, ""));
|
|
1786
1790
|
return mapped !== null && /^127\./.test(mapped);
|
|
1787
1791
|
}
|
|
1792
|
+
function stripEmptyFragmentLinks(markdown) {
|
|
1793
|
+
return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, "");
|
|
1794
|
+
}
|
|
1795
|
+
function trimFrontMatter(markdown) {
|
|
1796
|
+
if (!markdown.startsWith("---\n")) return markdown;
|
|
1797
|
+
const close = markdown.indexOf("\n---\n", 3);
|
|
1798
|
+
if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
|
|
1799
|
+
const entries = [];
|
|
1800
|
+
for (const line of markdown.slice(4, close).split("\n")) {
|
|
1801
|
+
const m = FRONT_MATTER_KEY.exec(line);
|
|
1802
|
+
if (m) entries.push({ key: m[1].toLowerCase(), lines: [line] });
|
|
1803
|
+
else if (entries.length) entries[entries.length - 1].lines.push(line);
|
|
1804
|
+
}
|
|
1805
|
+
const kept = [];
|
|
1806
|
+
for (const slot of FRONT_MATTER_SLOTS) {
|
|
1807
|
+
const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== void 0);
|
|
1808
|
+
if (entry) kept.push(...entry.lines);
|
|
1809
|
+
}
|
|
1810
|
+
const rest = markdown.slice(close + 5).replace(/^\n+/, "");
|
|
1811
|
+
return kept.length ? `---
|
|
1812
|
+
${kept.join("\n")}
|
|
1813
|
+
---
|
|
1814
|
+
|
|
1815
|
+
${rest}` : rest;
|
|
1816
|
+
}
|
|
1817
|
+
function cleanConvertedMarkdown(markdown) {
|
|
1818
|
+
return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown))).replace(/\n{3,}/g, "\n\n").trim();
|
|
1819
|
+
}
|
|
1788
1820
|
function stripImageLinks(markdown) {
|
|
1789
1821
|
return markdown.replace(MARKDOWN_IMAGE_PATTERN, "").replace(IMAGE_LINK_PATTERN, "$1").replace(/\n{3,}/g, "\n\n").trim();
|
|
1790
1822
|
}
|
|
@@ -1798,7 +1830,7 @@ function createNativeMarkdownConverter(nativeModule) {
|
|
|
1798
1830
|
codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
|
|
1799
1831
|
wrap: false
|
|
1800
1832
|
});
|
|
1801
|
-
return
|
|
1833
|
+
return cleanConvertedMarkdown(result.content ?? "");
|
|
1802
1834
|
};
|
|
1803
1835
|
}
|
|
1804
1836
|
function createJsMarkdownConverter() {
|
|
@@ -1809,7 +1841,7 @@ function createJsMarkdownConverter() {
|
|
|
1809
1841
|
});
|
|
1810
1842
|
return async (html) => {
|
|
1811
1843
|
const markdown = converter.translate(html);
|
|
1812
|
-
return
|
|
1844
|
+
return cleanConvertedMarkdown(markdown);
|
|
1813
1845
|
};
|
|
1814
1846
|
}
|
|
1815
1847
|
function validateContent(html, markdown, url) {
|
|
@@ -1848,7 +1880,7 @@ function validateContent(html, markdown, url) {
|
|
|
1848
1880
|
throw error;
|
|
1849
1881
|
}
|
|
1850
1882
|
}
|
|
1851
|
-
var cachedSsrfFetcher;
|
|
1883
|
+
var cachedSsrfFetcher, FRONT_MATTER_SLOTS, MAX_FRONT_MATTER_CHARS, FRONT_MATTER_KEY;
|
|
1852
1884
|
var init_scraper_utils = __esm({
|
|
1853
1885
|
"src/web-research/scraper-utils.ts"() {
|
|
1854
1886
|
"use strict";
|
|
@@ -1858,6 +1890,23 @@ var init_scraper_utils = __esm({
|
|
|
1858
1890
|
init_scraper_types();
|
|
1859
1891
|
init_browser_error_utils();
|
|
1860
1892
|
cachedSsrfFetcher = void 0;
|
|
1893
|
+
FRONT_MATTER_SLOTS = [
|
|
1894
|
+
["title", "meta-og:title", "meta-citation_title"],
|
|
1895
|
+
["meta-description", "description", "meta-og:description", "meta-twitter:description"],
|
|
1896
|
+
["meta-author", "meta-article:author", "meta-citation_author", "meta-dc.creator", "meta-dcterms.creator"],
|
|
1897
|
+
[
|
|
1898
|
+
"meta-article:published_time",
|
|
1899
|
+
"meta-citation_publication_date",
|
|
1900
|
+
"meta-citation_date",
|
|
1901
|
+
"meta-dcterms.date",
|
|
1902
|
+
"meta-dc.date",
|
|
1903
|
+
"meta-date",
|
|
1904
|
+
"meta-dcterms.created"
|
|
1905
|
+
],
|
|
1906
|
+
["meta-article:modified_time", "meta-dcterms.modified"]
|
|
1907
|
+
];
|
|
1908
|
+
MAX_FRONT_MATTER_CHARS = 2e4;
|
|
1909
|
+
FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
|
|
1861
1910
|
}
|
|
1862
1911
|
});
|
|
1863
1912
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the DEPTH PARAMETER guidance for the agent-facing `research` tool,
|
|
3
|
+
* relative to the user's configured `DEFAULT_RESEARCH_DEPTH`.
|
|
4
|
+
*
|
|
5
|
+
* Background (PR #16, discussion point 2): the tool-usage prompt says
|
|
6
|
+
* "Always specify a depth" and mapped ~95% of queries to `depth: 1`, so a
|
|
7
|
+
* user-configured default (e.g. quick/0) was effectively never honored for
|
|
8
|
+
* agent-initiated research — even with quick set as the default the agent
|
|
9
|
+
* still sent `depth: 1`. This builder renders the guidance relative to the
|
|
10
|
+
* configured default: when quick is the configured default, depth 0 becomes
|
|
11
|
+
* the ordinary choice and depth 1+ reads as the escalation. A user who keeps
|
|
12
|
+
* the default (1) sees byte-identical guidance — quality-first behavior is
|
|
13
|
+
* unchanged.
|
|
14
|
+
*
|
|
15
|
+
* The anchor is the *effective* default — the depth `execute()` actually
|
|
16
|
+
* resolves an omitted depth to:
|
|
17
|
+
*
|
|
18
|
+
* effectiveDefault = quickEnabled ? defaultDepth : Math.max(1, defaultDepth)
|
|
19
|
+
*
|
|
20
|
+
* Depth 0 is only reachable when quick mode is enabled; otherwise a
|
|
21
|
+
* configured 0 clamps to 1 at runtime, so the guidance must not advertise it.
|
|
22
|
+
*
|
|
23
|
+
* This is a pure function of the two session-static snapshots the tool schema
|
|
24
|
+
* was built from (see quickResearchEnabledAtRegistration /
|
|
25
|
+
* defaultDepthAtRegistration in index.ts) so the rendered guidance can never
|
|
26
|
+
* advertise a depth the registered schema rejects.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The depth `execute()` applies when the agent omits depth — `Math.max(depthMin,
|
|
31
|
+
* configured)` where `depthMin = quickEnabled ? 0 : 1` (see
|
|
32
|
+
* research-tool-definition.ts). This is "the ordinary choice" every rendered
|
|
33
|
+
* surface steers to, and the value the tool schema's `default` annotation must
|
|
34
|
+
* match.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveEffectiveDefault(quickEnabled: boolean, defaultDepth: number): number {
|
|
37
|
+
return quickEnabled ? defaultDepth : Math.max(1, defaultDepth);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the DEPTH PARAMETER guidance block (everything between the
|
|
42
|
+
* "Always specify a depth." lead-in and the "How depth works internally:"
|
|
43
|
+
* section). The returned string has no leading or trailing newlines; the
|
|
44
|
+
* caller's template supplies the surrounding blank lines.
|
|
45
|
+
*
|
|
46
|
+
* @param quickEnabled Whether quick mode (depth 0) is enabled — the same
|
|
47
|
+
* session-static snapshot the tool schema was built from.
|
|
48
|
+
* @param defaultDepth The user-configured `DEFAULT_RESEARCH_DEPTH` (0–3).
|
|
49
|
+
*/
|
|
50
|
+
export function buildResearchDepthGuidance(quickEnabled: boolean, defaultDepth: number): string {
|
|
51
|
+
const effectiveDefault = resolveEffectiveDefault(quickEnabled, defaultDepth);
|
|
52
|
+
|
|
53
|
+
if (!quickEnabled) {
|
|
54
|
+
return wordBlock(effectiveDefault) + '\n\n' + nothingBlock(effectiveDefault);
|
|
55
|
+
}
|
|
56
|
+
return quickDoc(effectiveDefault) + '\n' + wordBlock(effectiveDefault) + '\n\n' + nothingBlock(effectiveDefault);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The "user says a depth word" mapping. Byte-identical for effectiveDefault in
|
|
61
|
+
* {1,2,3}; only the "quick" line moves — to 0 — when the configured default IS
|
|
62
|
+
* 0. "normal" stays 1: the words form a shallow→max scale
|
|
63
|
+
* (quick < normal < deep < ultra = 0 < 1 < 2 < 3), so "normal" is always the
|
|
64
|
+
* standard orchestrated run.
|
|
65
|
+
*/
|
|
66
|
+
function wordBlock(effectiveDefault: number): string {
|
|
67
|
+
const quickTarget = effectiveDefault === 0 ? 0 : 1;
|
|
68
|
+
return [
|
|
69
|
+
'**User says a depth word (highest priority):**',
|
|
70
|
+
'- "quick" / "brief" / "simple" → `depth: ' + quickTarget + '`',
|
|
71
|
+
'- "normal" / "moderate" / "standard" → `depth: 1`',
|
|
72
|
+
'- "deep" / "thorough" / "in-depth" → `depth: 2` (never depth 3)',
|
|
73
|
+
'- "ultra" / "exhaustive" / "comprehensive" / "deep-dive" / "maximum" → `depth: 3`',
|
|
74
|
+
].join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The "user says nothing about depth — judge complexity:" section. Positions
|
|
79
|
+
* the effective default as the ordinary choice. effectiveDefault === 1
|
|
80
|
+
* reproduces the prior static text byte-for-byte.
|
|
81
|
+
*/
|
|
82
|
+
function nothingBlock(effectiveDefault: number): string {
|
|
83
|
+
const header = '**User says nothing about depth — judge complexity:**';
|
|
84
|
+
const never = '- `depth: 3` — **ABSOLUTELY NEVER without explicit user request.** The user MUST use trigger words like "ultra", "exhaustive", "comprehensive deep-dive", or "maximum research".';
|
|
85
|
+
switch (effectiveDefault) {
|
|
86
|
+
case 0:
|
|
87
|
+
return [
|
|
88
|
+
header,
|
|
89
|
+
'- `depth: 0` — the ordinary choice: a single verifiable fact, a URL, a price, a version number, or a yes/no with a source. One search-and-read pass, no researcher team.',
|
|
90
|
+
'- `depth: 1` — Coordinated, thorough research: simple facts with sources, lookups, overviews, background research. Escalate here when the question needs more than a single verifiable fact.',
|
|
91
|
+
'- `depth: 2` — Complex multi-faceted topics: policy analysis, tech evaluations, academic-style research.',
|
|
92
|
+
never + ' If the user does not explicitly request this level, use depth 0, 1, or 2.',
|
|
93
|
+
].join('\n');
|
|
94
|
+
case 2:
|
|
95
|
+
return [
|
|
96
|
+
header,
|
|
97
|
+
'- `depth: 1` — Simple facts, lookups, news, definitions, "what is X", overviews, background research.',
|
|
98
|
+
'- `depth: 2` — the ordinary choice (your configured default): complex multi-faceted topics, policy analysis, tech evaluations, academic-style research.',
|
|
99
|
+
never,
|
|
100
|
+
].join('\n');
|
|
101
|
+
case 3:
|
|
102
|
+
return [
|
|
103
|
+
header,
|
|
104
|
+
'- `depth: 1` — Simple facts, lookups, news, definitions, "what is X", overviews, background research.',
|
|
105
|
+
'- `depth: 2` — Complex multi-faceted topics: policy analysis, tech evaluations, academic-style research.',
|
|
106
|
+
'- `depth: 3` — the ordinary choice (your configured default): comprehensive, multi-round, exhaustive research.',
|
|
107
|
+
].join('\n');
|
|
108
|
+
case 1:
|
|
109
|
+
default:
|
|
110
|
+
return [
|
|
111
|
+
header,
|
|
112
|
+
'- `depth: 1` — Simple facts, lookups, news, definitions, "what is X", overviews, background research. This covers ~95%+ of queries.',
|
|
113
|
+
'- `depth: 2` — Complex multi-faceted topics: policy analysis, tech evaluations, academic-style research.',
|
|
114
|
+
never + ' If the user does not explicitly request this level, use depth 1 or depth 2.',
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The quick-mode intro, rendered only when quick mode is enabled. The first
|
|
121
|
+
* paragraph (what depth 0 is) is constant; the second names the ordinary
|
|
122
|
+
* choice, relative to the effective default.
|
|
123
|
+
*/
|
|
124
|
+
function quickDoc(effectiveDefault: number): string {
|
|
125
|
+
const first =
|
|
126
|
+
'**Depth 0 (quick) is available on this host.** Use `depth: 0` for a single verifiable fact, a URL, a price, a version number, or a yes/no with a source — or when the user says "just check", "one-liner", or "don\'t overdo it". It runs one search-and-read pass with no researcher team and answers concisely.';
|
|
127
|
+
let second: string;
|
|
128
|
+
switch (effectiveDefault) {
|
|
129
|
+
case 0:
|
|
130
|
+
second = '`depth: 0` is the ordinary choice; `depth: 1` is the escalation for coordinated research, `depth: 2` for complex or multi-faceted topics, `depth: 3` only on explicit request.';
|
|
131
|
+
break;
|
|
132
|
+
case 2:
|
|
133
|
+
second = '`depth: 2` is the ordinary choice (your configured default); `depth: 1` for simple lookups, `depth: 3` only on explicit request.';
|
|
134
|
+
break;
|
|
135
|
+
case 3:
|
|
136
|
+
second = '`depth: 3` is the ordinary choice (your configured default); `depth: 1` for simple lookups, `depth: 2` for complex topics.';
|
|
137
|
+
break;
|
|
138
|
+
case 1:
|
|
139
|
+
default:
|
|
140
|
+
second = '`depth: 1` remains the normal choice. `depth: 2` is the escalation for complex or multi-faceted questions; `depth: 3` only on explicit request.';
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
return first + '\n\n' + second;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The closing escalation nudge — the tail of the "Do NOT escalate depth just
|
|
148
|
+
* because a topic is broad" line in the MULTIPLE RESEARCH CALLS section,
|
|
149
|
+
* rendered relative to the effective default the same way the DEPTH PARAMETER
|
|
150
|
+
* block is. effectiveDefault === 1 reproduces the prior static text
|
|
151
|
+
* byte-for-byte, so a user who keeps the default (1) — quick on or off — sees
|
|
152
|
+
* an unchanged prompt.
|
|
153
|
+
*/
|
|
154
|
+
export function buildEscalationNudge(quickEnabled: boolean, defaultDepth: number): string {
|
|
155
|
+
const effectiveDefault = resolveEffectiveDefault(quickEnabled, defaultDepth);
|
|
156
|
+
return effectiveDefault === 1
|
|
157
|
+
? 'depth 1 handles most cases well, and the higher depths have their own internal decomposition.'
|
|
158
|
+
: 'your configured default handles most cases well, and the higher depths have their own internal decomposition.';
|
|
159
|
+
}
|
|
@@ -49,17 +49,7 @@ The `research` tool retrieves from the internet. Do NOT use it when the answer c
|
|
|
49
49
|
|
|
50
50
|
**Always specify a depth.** Judge it from the user's language and task complexity.
|
|
51
51
|
|
|
52
|
-
{{
|
|
53
|
-
**User says a depth word (highest priority):**
|
|
54
|
-
- "quick" / "brief" / "simple" → `depth: 1`
|
|
55
|
-
- "normal" / "moderate" / "standard" → `depth: 1`
|
|
56
|
-
- "deep" / "thorough" / "in-depth" → `depth: 2` (never depth 3)
|
|
57
|
-
- "ultra" / "exhaustive" / "comprehensive" / "deep-dive" / "maximum" → `depth: 3`
|
|
58
|
-
|
|
59
|
-
**User says nothing about depth — judge complexity:**
|
|
60
|
-
- `depth: 1` — Simple facts, lookups, news, definitions, "what is X", overviews, background research. This covers ~95%+ of queries.
|
|
61
|
-
- `depth: 2` — Complex multi-faceted topics: policy analysis, tech evaluations, academic-style research.
|
|
62
|
-
- `depth: 3` — **ABSOLUTELY NEVER without explicit user request.** The user MUST use trigger words like "ultra", "exhaustive", "comprehensive deep-dive", or "maximum research". If the user does not explicitly request this level, use depth 1 or depth 2.
|
|
52
|
+
{{DEPTH_GUIDANCE}}
|
|
63
53
|
|
|
64
54
|
**How depth works internally:**
|
|
65
55
|
- `depth: 1-3` — AI-orchestrated: coordinator plans a team, researchers execute, a research lead decides whether to continue deeper and writes the final report. Team size and number of rounds scale with complexity. The coordinator and the lead dynamically determine how many researchers are needed each round — it's not a fixed number.
|
|
@@ -92,4 +82,4 @@ The coordinator will plan as many researchers as needed (up to the max). You do
|
|
|
92
82
|
- **Group related topics**: If topics share ANY scope or could inform each other, combine them into a single `research` call. The internal coordinator will decompose them into parallel researcher agents. Do NOT split related sub-topics into separate calls.
|
|
93
83
|
- Example: `research("Python async performance and Rust async performance comparison")` — NOT separate calls for Python and Rust, since the comparison IS the research goal.
|
|
94
84
|
|
|
95
|
-
**Do NOT escalate depth just because a topic is broad** —
|
|
85
|
+
**Do NOT escalate depth just because a topic is broad** — {{ESCALATION_NUDGE}}
|
|
@@ -93,8 +93,16 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
93
93
|
// a mid-session QUICK_RESEARCH toggle takes effect on the next session. When
|
|
94
94
|
// disabled (the default) the schema is byte-identical to the upstream
|
|
95
95
|
// minimum: 1 surface — agents can neither see nor send depth 0.
|
|
96
|
-
const
|
|
96
|
+
const toolConfig = getConfig(undefined, iface);
|
|
97
|
+
const quickEnabled = toolConfig.QUICK_RESEARCH === true;
|
|
97
98
|
const depthMin = quickEnabled ? 0 : 1;
|
|
99
|
+
// The depth execute() applies to an omitted depth: Math.max(depthMin,
|
|
100
|
+
// configured DEFAULT_RESEARCH_DEPTH) — the same value the agent-facing DEPTH
|
|
101
|
+
// guidance renders relative to (buildResearchDepthGuidance). The annotation
|
|
102
|
+
// must match it, or a user who configured e.g. default 2 sees `default: 1`
|
|
103
|
+
// in the model-facing schema while the prompt says "depth 2 is the ordinary
|
|
104
|
+
// choice (your configured default)".
|
|
105
|
+
const effectiveDefault = Math.max(depthMin, toolConfig.DEFAULT_RESEARCH_DEPTH);
|
|
98
106
|
|
|
99
107
|
const parameters = Type.Object({
|
|
100
108
|
query: Type.String({
|
|
@@ -110,7 +118,7 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
110
118
|
'2: Deep (multi-round, exhaustive).',
|
|
111
119
|
'3: Ultra (maximum depth, extreme rigor).',
|
|
112
120
|
].join('\n'),
|
|
113
|
-
default:
|
|
121
|
+
default: effectiveDefault,
|
|
114
122
|
})),
|
|
115
123
|
model: Type.Optional(Type.String({
|
|
116
124
|
description: 'Optional model ID override for coordination.',
|
|
@@ -54,6 +54,13 @@ export const BOT_PATTERNS: ReadonlyArray<[string, string]> = [
|
|
|
54
54
|
|
|
55
55
|
export const IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
|
|
56
56
|
export const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
|
|
57
|
+
/**
|
|
58
|
+
* `[](#fragment)` — a link with no text to a spot on the same page (optional
|
|
59
|
+
* title). Documentation generators put one before every code line (Quarto,
|
|
60
|
+
* nbdev: `<a href="#cb2-1" aria-hidden="true"></a>`) and next to headings; the
|
|
61
|
+
* converters turn each into `[](#cb2-1)`, which carries no text.
|
|
62
|
+
*/
|
|
63
|
+
export const EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
|
|
57
64
|
|
|
58
65
|
export type { ScrapeLayerResult } from './types.ts';
|
|
59
66
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
FILTERED_TAGS,
|
|
16
16
|
IMAGE_LINK_PATTERN,
|
|
17
17
|
MARKDOWN_IMAGE_PATTERN,
|
|
18
|
+
EMPTY_FRAGMENT_LINK_PATTERN,
|
|
18
19
|
BOT_PATTERNS,
|
|
19
20
|
INTERNAL_NETWORK_PATTERNS,
|
|
20
21
|
type NativeHtmlToMarkdownModule,
|
|
@@ -485,6 +486,76 @@ function isMappedLoopback(ip: string): boolean {
|
|
|
485
486
|
return mapped !== null && /^127\./.test(mapped);
|
|
486
487
|
}
|
|
487
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Drop empty same-page links (`[](#cb2-1)`): per-line code anchors and heading
|
|
491
|
+
* anchors that documentation generators emit. They carry no text, but repeat on
|
|
492
|
+
* every code line and every heading, so they cost tokens and add noise to the
|
|
493
|
+
* knowledge store index. Links with text, and empty links to other pages, stay.
|
|
494
|
+
*/
|
|
495
|
+
export function stripEmptyFragmentLinks(markdown: string): string {
|
|
496
|
+
return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, '');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Front-matter keys kept by trimFrontMatter, one slot per piece of information;
|
|
501
|
+
* the first key present in a slot wins (the native converter prefixes <meta>
|
|
502
|
+
* names with `meta-`, keeping Open Graph / Dublin Core / citation prefixes).
|
|
503
|
+
*/
|
|
504
|
+
const FRONT_MATTER_SLOTS: ReadonlyArray<readonly string[]> = [
|
|
505
|
+
['title', 'meta-og:title', 'meta-citation_title'],
|
|
506
|
+
['meta-description', 'description', 'meta-og:description', 'meta-twitter:description'],
|
|
507
|
+
['meta-author', 'meta-article:author', 'meta-citation_author', 'meta-dc.creator', 'meta-dcterms.creator'],
|
|
508
|
+
['meta-article:published_time', 'meta-citation_publication_date', 'meta-citation_date', 'meta-dcterms.date',
|
|
509
|
+
'meta-dc.date', 'meta-date', 'meta-dcterms.created'],
|
|
510
|
+
['meta-article:modified_time', 'meta-dcterms.modified'],
|
|
511
|
+
];
|
|
512
|
+
/** Front matter longer than this is not the converter's (e.g. a Markdown file's own); leave it. */
|
|
513
|
+
const MAX_FRONT_MATTER_CHARS = 20_000;
|
|
514
|
+
/** A front-matter entry starts with `key:`; other lines continue the previous value. */
|
|
515
|
+
const FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Trim the front matter the native converter builds from <meta> tags to the
|
|
519
|
+
* lines a reader can use: title, description, author, publication and
|
|
520
|
+
* modification date. The rest (analytics and error-reporting URLs, verification
|
|
521
|
+
* hashes, nonces, app ids, Open Graph / Twitter duplicates of the title and
|
|
522
|
+
* description, viewport, robots…) is noise: 54 lines on a GitHub page. Nothing
|
|
523
|
+
* in pi-research parses these lines. The block is dropped when none of the kept
|
|
524
|
+
* keys is present.
|
|
525
|
+
*/
|
|
526
|
+
export function trimFrontMatter(markdown: string): string {
|
|
527
|
+
if (!markdown.startsWith('---\n')) return markdown;
|
|
528
|
+
const close = markdown.indexOf('\n---\n', 3);
|
|
529
|
+
if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
|
|
530
|
+
|
|
531
|
+
const entries: Array<{ key: string; lines: string[] }> = [];
|
|
532
|
+
for (const line of markdown.slice(4, close).split('\n')) {
|
|
533
|
+
const m = FRONT_MATTER_KEY.exec(line);
|
|
534
|
+
if (m) entries.push({ key: m[1]!.toLowerCase(), lines: [line] });
|
|
535
|
+
else if (entries.length) entries[entries.length - 1]!.lines.push(line);
|
|
536
|
+
}
|
|
537
|
+
const kept: string[] = [];
|
|
538
|
+
for (const slot of FRONT_MATTER_SLOTS) {
|
|
539
|
+
const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== undefined);
|
|
540
|
+
if (entry) kept.push(...entry.lines);
|
|
541
|
+
}
|
|
542
|
+
const rest = markdown.slice(close + 5).replace(/^\n+/, '');
|
|
543
|
+
return kept.length ? `---\n${kept.join('\n')}\n---\n\n${rest}` : rest;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Converter post-processing, shared by the native and the JS converter. The
|
|
548
|
+
* front matter is trimmed first. Images go before empty links: a heading anchor
|
|
549
|
+
* often wraps an icon (GitHub: `[](#title)`) and only becomes an
|
|
550
|
+
* empty link once the image is gone. Removing a link that stood on its own line
|
|
551
|
+
* leaves extra blank lines, so they are collapsed again.
|
|
552
|
+
*/
|
|
553
|
+
export function cleanConvertedMarkdown(markdown: string): string {
|
|
554
|
+
return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown)))
|
|
555
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
556
|
+
.trim();
|
|
557
|
+
}
|
|
558
|
+
|
|
488
559
|
/**
|
|
489
560
|
* Strip image links from markdown
|
|
490
561
|
*/
|
|
@@ -511,7 +582,7 @@ export function createNativeMarkdownConverter(
|
|
|
511
582
|
codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
|
|
512
583
|
wrap: false,
|
|
513
584
|
});
|
|
514
|
-
return
|
|
585
|
+
return cleanConvertedMarkdown(result.content ?? '');
|
|
515
586
|
};
|
|
516
587
|
}
|
|
517
588
|
|
|
@@ -527,7 +598,7 @@ export function createJsMarkdownConverter(): (html: string) => Promise<string> {
|
|
|
527
598
|
|
|
528
599
|
return async (html: string): Promise<string> => {
|
|
529
600
|
const markdown = converter.translate(html);
|
|
530
|
-
return
|
|
601
|
+
return cleanConvertedMarkdown(markdown);
|
|
531
602
|
};
|
|
532
603
|
}
|
|
533
604
|
|