@lincoln504/pi-research 1.6.11 → 1.6.12

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/README.md CHANGED
@@ -22,6 +22,8 @@
22
22
  <a href="docs/DOCUMENTATION-ES.md">ES</a>
23
23
  ·
24
24
  <a href="docs/DOCUMENTATION-ZH.md">中文研究</a>
25
+ ·
26
+ <a href="docs/DOCUMENTATION-JA.md">日本語研究</a>
25
27
  </p>
26
28
 
27
29
  ---
@@ -86,6 +88,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
86
88
 
87
89
  - Node.js >= 22.19.0
88
90
  - An LLM with a 100k+ context window (an API key or a local model)
91
+ - On OpenRouter, `inclusionai/ling-3.0-flash` is a proven low-cost workhorse for driving runs: $0.021 input / $0.063 output per Mtok with a 262k context window — the cheapest capable model on the platform at time of writing — with reliable strict-JSON planning output and fast synthesis (verified over extended production use). The `:free` variants (`ling-3.0-flash-fin:free`) work for light usage but are subject to free-tier rate limits. Note: `ling-3.0-tiny` currently has no serving endpoints on OpenRouter and cannot be called.
89
92
  - Internet access on a residential IP, since search, scraping, and YouTube transcripts get bot-blocked from datacenter/VPS/cloud IPs
90
93
  - pi. The pi extension uses the host's copy while the standalone CLI and agent skill install it as a dependency.
91
94
  - Local knowledge-store embeddings need `@huggingface/transformers`, which is an **optional dependency**: if its native image chain (sharp) cannot install on your machine, npm skips it with a warning, the install still succeeds, and everything except local embeddings works. The store disables embeddings gracefully with a message telling you how to restore them.
@@ -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.6.11", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.6.12", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/dist/cli.mjs CHANGED
@@ -21804,6 +21804,21 @@ var Filters = {
21804
21804
  ANSWERS_WITH_BODY: "!9_bDE(fI5"
21805
21805
  };
21806
21806
 
21807
+ // src/utils/plain-url.ts
21808
+ var MIN_FENCE = 3;
21809
+ function plainUrl(url) {
21810
+ const safe = url.replace(/`/g, "%60");
21811
+ return `\`${safe}\``;
21812
+ }
21813
+ function fenceBlock(content) {
21814
+ const runs = content.match(/`+/g) ?? [];
21815
+ const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
21816
+ const fence = "`".repeat(Math.max(MIN_FENCE, longest + 1));
21817
+ return `${fence}text
21818
+ ${content}
21819
+ ${fence}`;
21820
+ }
21821
+
21807
21822
  // src/stackexchange/output/table.ts
21808
21823
  function formatQuestionsTable(questions) {
21809
21824
  if (questions.length === 0) {
@@ -21829,13 +21844,15 @@ function formatQuestionsTable(questions) {
21829
21844
  output += `- **Author:** ${q.owner.display_name} (rep: ${q.owner.reputation})
21830
21845
  `;
21831
21846
  }
21832
- output += `- **Link:** ${q.link}
21847
+ output += `- **Link:** ${plainUrl(q.link)}
21833
21848
  `;
21834
21849
  output += `- **Created:** ${new Date(q.creation_date * 1e3).toLocaleString()}
21835
21850
  `;
21836
21851
  if (q.body) {
21837
21852
  const body = q.body.length > 500 ? `${q.body.substring(0, 500)}...` : q.body;
21838
- output += `- **Body:** ${body}
21853
+ output += `- **Body:**
21854
+
21855
+ ${fenceBlock(body)}
21839
21856
  `;
21840
21857
  }
21841
21858
  output += "\n---\n\n";
@@ -21870,7 +21887,7 @@ function formatAnswersTable(answers) {
21870
21887
  output += `
21871
21888
  ### Answer Body
21872
21889
 
21873
- ${body}
21890
+ ${fenceBlock(body)}
21874
21891
  `;
21875
21892
  }
21876
21893
  output += "\n---\n\n";
@@ -21902,10 +21919,10 @@ function formatUsersTable(users) {
21902
21919
  `;
21903
21920
  }
21904
21921
  if (u.website_url) {
21905
- output += `- **Website:** ${u.website_url}
21922
+ output += `- **Website:** ${plainUrl(u.website_url)}
21906
21923
  `;
21907
21924
  }
21908
- output += `- **Profile:** ${u.link}
21925
+ output += `- **Profile:** ${plainUrl(u.link)}
21909
21926
  `;
21910
21927
  output += "\n---\n\n";
21911
21928
  }
@@ -21936,7 +21953,7 @@ function formatCompactQuestions(questions) {
21936
21953
  let index = 1;
21937
21954
  for (const q of questions) {
21938
21955
  const accepted = q.accepted_answer_id ? "[accepted]" : "";
21939
- output += `${index}. [${q.title}](${q.link}) ${accepted} (score: ${q.score}, answers: ${q.answer_count})
21956
+ output += `${index}. ${q.title} ${accepted} (score: ${q.score}, answers: ${q.answer_count}) ${plainUrl(q.link)}
21940
21957
  `;
21941
21958
  index++;
21942
21959
  }
@@ -21952,7 +21969,7 @@ function formatQuestionsCompact(questions) {
21952
21969
  let index = 1;
21953
21970
  for (const q of questions) {
21954
21971
  const accepted = q.accepted_answer_id ? "[accepted]" : " ";
21955
- lines.push(`${index}. ${accepted} [${q.title}](${q.link}) (${q.score} pts, ${q.answer_count} ans, ${q.view_count} views)`);
21972
+ lines.push(`${index}. ${accepted} ${q.title} (${q.score} pts, ${q.answer_count} ans, ${q.view_count} views) ${plainUrl(q.link)}`);
21956
21973
  index++;
21957
21974
  }
21958
21975
  return lines.join("\n");
@@ -22324,7 +22341,8 @@ function createStackexchangeTool(options) {
22324
22341
  "Anonymous access: 300 requests/day. Set STACKEXCHANGE_API_KEY env var for 10,000/day.",
22325
22342
  "Use tags to filter by specific topics.",
22326
22343
  "Use maxPages parameter to control pagination for search results (default: 5 pages).",
22327
- `CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls(options.config)} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`
22344
+ `CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls(options.config)} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
22345
+ "URLs in results are wrapped in backticks on purpose: they are data, not clickable links. Never reformat them as markdown links \u2014 a click in the terminal opens a real browser tab."
22328
22346
  ],
22329
22347
  parameters: StackExchangeParamsSchema,
22330
22348
  executionMode: "parallel",
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ 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
+ ## [Unreleased]
9
+
10
+ ### Fixed
11
+
12
+ - **Stack Exchange tool output can no longer open a real browser tab when clicked.** The tool's formatters emitted markdown links (`[title](link)`) and bare URLs, and question/answer bodies carried third-party markup — any markdown-rendering surface (assistant replies, research reports, the terminal UI, which turns markdown links AND bare URLs into OSC 8 terminal hyperlinks and opens them in the system browser on mouse press+release) turned these into click-to-open-tab targets in the middle of scrolling tool output. New `src/utils/plain-url.ts` fixes this at the source, cross-platform and renderer-independent: `plainUrl()` wraps every emitted URL in backticks (code spans tokenize before autolink/link parsing in every CommonMark renderer, so a code-spanned URL can never become a link but stays byte-exact and copyable for the model), and `fenceBlock()` wraps third-party body content in a fenced code block whose length grows past any backtick run in the content (embedded links, headings and tables render inert; content containing its own ``` fences cannot close the fence early). All stackexchange formatters (questions, answers, users, sites, compact variants, and the `get`+compact path) now emit code-spanned URLs and own-line-fenced bodies — fence placement matters: an opening fence inline after `- **Body:**` is NOT recognized as a code block by CommonMark and was verified (against pi's bundled `marked`) to still render embedded links clickable, so the fence starts on its own line. The tool's prompt guidelines now also instruct the model never to re-emit result URLs as markdown links, covering the assistant-echo path. Pinned by `test/unit/stackexchange/output/no-clickable-links.test.ts`, which scans each formatter's clickable surface (post code-span/fence stripping) for link syntax and — when `marked` is resolvable — renders the actual output and asserts zero anchor elements.
13
+
14
+ - **CI: declare `vite` as an explicit devDependency.** CI installs with `--legacy-peer-deps`, which skips peer dependency resolution — `vite` was only reachable as a transitive peer and the install could not resolve it. Now declared directly.
15
+
8
16
  ## [1.6.11] - 2026-09-04
9
17
 
10
18
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.6.11",
3
+ "version": "1.6.12",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Question, Answer, User, Site } from '../types.ts';
6
+ import { plainUrl } from '../../utils/plain-url.ts';
6
7
 
7
8
  export function formatQuestionsCompact(questions: Question[]): string {
8
9
  if (questions.length === 0) {
@@ -13,7 +14,7 @@ export function formatQuestionsCompact(questions: Question[]): string {
13
14
  let index = 1;
14
15
  for (const q of questions) {
15
16
  const accepted = q.accepted_answer_id ? '[accepted]' : ' ';
16
- lines.push(`${index}. ${accepted} [${q.title}](${q.link}) (${q.score} pts, ${q.answer_count} ans, ${q.view_count} views)`);
17
+ lines.push(`${index}. ${accepted} ${q.title} (${q.score} pts, ${q.answer_count} ans, ${q.view_count} views) ${plainUrl(q.link)}`);
17
18
  index++;
18
19
  }
19
20
  return lines.join('\n');
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Question, Answer, User, Site } from '../types.ts';
6
+ import { plainUrl, fenceBlock } from '../../utils/plain-url.ts';
6
7
 
7
8
  export function formatQuestionsTable(questions: Question[]): string {
8
9
  if (questions.length === 0) {
@@ -21,7 +22,10 @@ export function formatQuestionsTable(questions: Question[]): string {
21
22
  if (q.owner) {
22
23
  output += `- **Author:** ${q.owner.display_name} (rep: ${q.owner.reputation})\n`;
23
24
  }
24
- output += `- **Link:** ${q.link}\n`;
25
+ // plainUrl: URLs in tool output are data, not interactive links — see
26
+ // plain-url.ts. Bare URLs would be GFM-autolinked into clickable OSC 8
27
+ // hyperlinks by markdown renderers (accidental browser-tab opens).
28
+ output += `- **Link:** ${plainUrl(q.link)}\n`;
25
29
  output += `- **Created:** ${new Date(q.creation_date * 1000).toLocaleString()}\n`;
26
30
 
27
31
  if (q.body) {
@@ -29,7 +33,13 @@ export function formatQuestionsTable(questions: Question[]): string {
29
33
  // previous `length < 1000` guard omitted the Body line for the longest
30
34
  // questions (the ones most in need of truncation).
31
35
  const body = q.body.length > 500 ? `${q.body.substring(0, 500)}...` : q.body;
32
- output += `- **Body:** ${body}\n`;
36
+ // Third-party body text must never be rendered as markdown — it can
37
+ // carry arbitrary links (markdown injection → clickable browser tabs).
38
+ // The fence MUST start on its own line: CommonMark only recognizes a
39
+ // fenced code block when the opening fence begins the line; inline
40
+ // after "**Body:**" it degrades to paragraph text and marked renders
41
+ // embedded links clickable (verified against pi's bundled marked).
42
+ output += `- **Body:**\n\n${fenceBlock(body)}\n`;
33
43
  }
34
44
 
35
45
  output += '\n---\n\n';
@@ -62,7 +72,8 @@ export function formatAnswersTable(answers: Answer[]): string {
62
72
  // answers (same class as the question-body fix above; answers get a more
63
73
  // generous bound since the body is the whole point of an answer).
64
74
  const body = a.body.length > 2000 ? `${a.body.substring(0, 2000)}...` : a.body;
65
- output += `\n### Answer Body\n\n${body}\n`;
75
+ // Same third-party-content rule as the question body above.
76
+ output += `\n### Answer Body\n\n${fenceBlock(body)}\n`;
66
77
  }
67
78
 
68
79
  output += '\n---\n\n';
@@ -89,9 +100,9 @@ export function formatUsersTable(users: User[]): string {
89
100
  output += `- **Location:** ${u.location}\n`;
90
101
  }
91
102
  if (u.website_url) {
92
- output += `- **Website:** ${u.website_url}\n`;
103
+ output += `- **Website:** ${plainUrl(u.website_url)}\n`;
93
104
  }
94
- output += `- **Profile:** ${u.link}\n`;
105
+ output += `- **Profile:** ${plainUrl(u.link)}\n`;
95
106
  output += '\n---\n\n';
96
107
  }
97
108
 
@@ -125,7 +136,7 @@ export function formatCompactQuestions(questions: Question[]): string {
125
136
  let index = 1;
126
137
  for (const q of questions) {
127
138
  const accepted = q.accepted_answer_id ? '[accepted]' : '';
128
- output += `${index}. [${q.title}](${q.link}) ${accepted} (score: ${q.score}, answers: ${q.answer_count})\n`;
139
+ output += `${index}. ${q.title} ${accepted} (score: ${q.score}, answers: ${q.answer_count}) ${plainUrl(q.link)}\n`;
129
140
  index++;
130
141
  }
131
142
  return output;
@@ -70,6 +70,7 @@ export function createStackexchangeTool(options: {
70
70
  'Use tags to filter by specific topics.',
71
71
  'Use maxPages parameter to control pagination for search results (default: 5 pages).',
72
72
  `CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls(options.config)} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
73
+ 'URLs in results are wrapped in backticks on purpose: they are data, not clickable links. Never reformat them as markdown links — a click in the terminal opens a real browser tab.',
73
74
  ],
74
75
  parameters: StackExchangeParamsSchema,
75
76
  executionMode: 'parallel',
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Neutralize external URLs in tool output so terminal-UI markdown renderers
3
+ * cannot turn them into clickable hyperlinks.
4
+ *
5
+ * Why this exists: pi's TUI (and most markdown-rendering terminals) convert
6
+ * markdown links AND bare URLs (GFM autolink) into OSC 8 terminal hyperlinks,
7
+ * and a mouse press+release on such a span opens the URL in the user's real
8
+ * browser. Tool results are agent-facing data, not interactive UI — a URL
9
+ * appearing there should never be one accidental click away from opening a
10
+ * tab, and third-party content (question bodies, API fields) must not be able
11
+ * to inject clickable links into the user's terminal at all.
12
+ *
13
+ * Mechanism: wrapping a URL in backticks makes it a markdown code span. Code
14
+ * spans are tokenized before autolink in every CommonMark/GFM tokenizer
15
+ * (verified against the `marked` version pi ships), so the URL renders as
16
+ * literal text — copyable by humans, trivially extractable by the model, and
17
+ * impossible to click.
18
+ */
19
+
20
+ /** Longest run of backticks that can safely fence arbitrary content. */
21
+ const MIN_FENCE = 3;
22
+
23
+ /**
24
+ * Return `url` as a markdown code span: renders as plain text everywhere,
25
+ * never becomes a clickable hyperlink, and stays byte-exact for the model.
26
+ */
27
+ export function plainUrl(url: string): string {
28
+ const safe = url.replace(/`/g, '%60');
29
+ return `\`${safe}\``;
30
+ }
31
+
32
+ /**
33
+ * Wrap arbitrary third-party text (question bodies, API-provided markup) in a
34
+ * fenced code block so no markdown inside it — links, headings, tables — is
35
+ * ever rendered or autolinked. The fence length grows past any backtick run
36
+ * found in the content, so content containing ``` fences cannot close it
37
+ * early.
38
+ */
39
+ export function fenceBlock(content: string): string {
40
+ const runs = content.match(/`+/g) ?? [];
41
+ const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
42
+ const fence = '`'.repeat(Math.max(MIN_FENCE, longest + 1));
43
+ return `${fence}text\n${content}\n${fence}`;
44
+ }