pi-tandem 0.2.0 → 0.3.1

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
@@ -18,6 +18,17 @@ Modern coding agents lean toward autonomy: multi-file changes in one go, subagen
18
18
  - **`subagent` skill** to run a task in a fresh agent session on request, with full visibility and control, instead of harness-managed subagents
19
19
  - **Tool-specific instructions** (`gh`, `aws`, `jira`, ...), added to the prompt only if the tool is actually installed
20
20
 
21
+ ## Recommended tools
22
+
23
+ The plugin notices which CLI tools are actually installed on your machine, and for each one it finds, adds a short note telling the model that the tool is available and recommended to use. Installing these gets you significantly more out of this package:
24
+
25
+ - `gh` - GitHub CLI: repos, issues, PRs, releases, workflows
26
+ - `pandoc` - read docx/odt/rtf/html (and fetched web pages) as text instead of raw markup
27
+ - `pdftotext` (poppler) - extract text from PDFs
28
+ - a Chromium-based browser (Brave, Chromium, Chrome, Edge) - headless-render fallback for JS-heavy or bot-blocked pages
29
+ - `osascript` (macOS only) - drive the user's real browser sessions via AppleScript
30
+ - `jira`, `aws`, `saml2aws` - if your workflow includes them
31
+
21
32
  ## Attribution
22
33
 
23
34
  The "lazy senior" part of the coding section in the prompt is adapted from [ponytail](https://github.com/DietrichGebert/ponytail) by DietrichGebert (MIT).
@@ -1,8 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import injectProjectContext from "./pi-tandem";
3
- import registerWebTools from "./web-tools";
4
3
 
5
4
  export default function (pi: ExtensionAPI) {
6
5
  injectProjectContext(pi);
7
- registerWebTools(pi);
8
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tandem",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/skhoroshavin/tandem-skills.git"
@@ -9,6 +9,7 @@
9
9
  "keywords": [
10
10
  "pi-package"
11
11
  ],
12
+ "type": "module",
12
13
  "license": "MIT",
13
14
  "files": [
14
15
  "extensions/",
@@ -17,5 +18,10 @@
17
18
  "prompt.md",
18
19
  "LICENSE",
19
20
  "README.md"
20
- ]
21
+ ],
22
+ "devDependencies": {
23
+ "@earendil-works/pi-coding-agent": "^0.84.4",
24
+ "@types/node": "^26.4.1",
25
+ "typescript": "^7.0.2"
26
+ }
21
27
  }
package/prompt.md CHANGED
@@ -43,6 +43,17 @@ A number of CLI tools are installed on this laptop and fully authenticated, you'
43
43
  <!--cli:saml2aws-->
44
44
  - AWS credentials are short-lived: on an expired or invalid token error, ask the user to run saml2aws login --idp-account <profile> --skip-prompt themselves, giving them the full command to copy-paste
45
45
  <!--/cli-->
46
+ <!--cli:pdftotext-->
47
+ - Use `pdftotext` to extract text from PDFs: `pdftotext input.pdf output.txt`, or `pdftotext input.pdf -` to print to stdout
48
+ <!--/cli-->
49
+ <!--cli:pandoc-->
50
+ - Use `pandoc` to read documents other than pdf as markdown or plain text: `pandoc input.docx -o output.md` (also works for odt, rtf, html, ...)
51
+ - Unless your task requires seeing actual tags, always convert HTML fetched from the web to plain text or markdown, instead of reading it raw, for example when using `curl`: `curl -s <url> | pandoc -f html -t plain`.
52
+ <!--/cli-->
53
+ <!--cli:browser-->
54
+ - Use a real headless browser when you need to read a JS-rendered or bot-blocked page: `<browser-binary> --headless --disable-gpu --user-data-dir=$(mktemp -d) --dump-dom --virtual-time-budget=5000 <url>`
55
+ - When in doubt, always try `curl` first before resorting to a headless browser
56
+ <!--/cli-->
46
57
  <!--cli:osascript-->
47
58
  - Use `osascript` with `execute <tab> javascript "<js>"` to read pages and interact using the user's
48
59
  real logged-in sessions (analyzing dashboards, checking Google Calendar and Mail, etc)
@@ -18,18 +18,41 @@ export function isOnPath(binary) {
18
18
  return false;
19
19
  }
20
20
 
21
+ // Chromium-family binary for the headless-render fallback; PATH first, then app bundles
22
+ function findBrowserBinary() {
23
+ for (const name of ["brave", "brave-browser", "chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]) {
24
+ if (isOnPath(name)) return name;
25
+ }
26
+ if (process.platform === "darwin") {
27
+ for (const app of ["Brave Browser", "Chromium", "Google Chrome", "Microsoft Edge"]) {
28
+ const bin = `/Applications/${app}.app/Contents/MacOS/${app}`;
29
+ try {
30
+ if (statSync(bin).isFile()) return bin;
31
+ } catch {}
32
+ }
33
+ }
34
+ }
35
+
21
36
  // drop <!--cli:tool--> blocks for absent tools, and the whole section when none remain
37
+ function toolAvailable(tool, browser) {
38
+ if (tool === "browser") return browser !== undefined;
39
+ if (tool === "osascript") return process.platform === "darwin" && isOnPath(tool);
40
+ return isOnPath(tool);
41
+ }
42
+
22
43
  export function filterCliTools(raw) {
23
- let any = false;
24
- const filtered = raw.replace(
25
- /<!--cli:([a-z0-9]+)-->\n([\s\S]*?)<!--\/cli-->\n?/g,
26
- (_marker, tool, body) => {
27
- const available =
28
- tool === "osascript" ? process.platform === "darwin" && isOnPath(tool) : isOnPath(tool);
29
- if (!available) return "";
30
- any = true;
31
- return body;
32
- },
33
- ).replace(/\n{3,}/g, "\n\n");
34
- return any ? filtered : filtered.replace(/\n## CLI tools[\s\S]*?(?=\n## )/, "");
44
+ let hasTools = false;
45
+ const browser = findBrowserBinary();
46
+ const filtered = raw
47
+ .replace(
48
+ /<!--cli:([a-z0-9]+)-->\r?\n?([\s\S]*?)<!--\/cli-->\r?\n?/g,
49
+ (_marker, tool, body) => {
50
+ if (!toolAvailable(tool, browser)) return "";
51
+ hasTools = true;
52
+ return body;
53
+ },
54
+ )
55
+ .replace(/<browser-binary>/g, () => (browser ? JSON.stringify(browser) : "<browser-binary>"))
56
+ .replace(/\n{3,}/g, "\n\n");
57
+ return hasTools ? filtered : filtered.replace(/\n## CLI tools[\s\S]*?(?=\n## )/, "");
35
58
  }
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: code-review
3
+ description: Use when asked to do a code review.
4
+ ---
5
+
6
+ Read the full diff and everything it touches before judging; the worst review mistakes come from reviewing the diff only. The review is read-only: never modify tracked files, the index, HEAD, or branch state; running builds and tests to verify the change is fine.
7
+
8
+ Review as a lazy senior engineer: besides bugs, flag NIH syndrome, overengineering, unnecessary dependencies, obvious copy paste and other similar code bloats. Give accompanying tests the same, if not higher, level of attention: unreadable, overcomplicated tests explain nothing, and a test that would also pass without the change proves nothing.
9
+
10
+ Report rules:
11
+
12
+ - Actionable findings only, ordered by severity: ones requiring changes, or at least explicit decisions; "checked and fine" does not qualify.
13
+ - Each finding: what is wrong and why it matters in 1-3 lines, then the smallest fix as a concrete diff, or set of diffs; if not diffable, describe generally and ask for a decision.
14
+ - No praise, no diff summary, no restating the request, no advice beyond findings.
15
+ - If nothing actionable: say exactly that, plus one line on what was checked.
@@ -1,246 +0,0 @@
1
- import { mkdtemp, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { formatSize, type TruncationResult, truncateHead } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "typebox";
7
-
8
- // web_search (Brave Search API) and web_fetch (generic HTTP reader) for pi.
9
-
10
- const MIN_SEARCH_RESULTS = 1;
11
- const MAX_SEARCH_RESULTS = 10;
12
- const DEFAULT_SEARCH_RESULTS = 5;
13
- const BRAVE_API_KEY_ENV_VAR = "BRAVE_SEARCH_API_KEY";
14
-
15
- interface SearchResult {
16
- title: string;
17
- url: string;
18
- snippet: string;
19
- }
20
-
21
- function refusePrivateHost(hostname: string): never {
22
- throw new Error(`Refusing to fetch private/loopback address: ${hostname}`);
23
- }
24
-
25
- // Rejects anything that is not a public http(s) endpoint: no non-HTTP schemes,
26
- // and hostnames that point at loopback, RFC1918, link-local (incl. cloud
27
- // metadata) or IPv6 local addresses. Literal hostnames only - no DNS lookup.
28
- function assertPublicHttpUrl(raw: string): void {
29
- let url: URL;
30
- try {
31
- url = new URL(raw);
32
- } catch {
33
- throw new Error(`Invalid URL: ${raw}`);
34
- }
35
- if (!/^https?:$/.test(url.protocol)) {
36
- throw new Error(`Unsupported URL protocol: ${url.protocol}. Only http and https are supported.`);
37
- }
38
-
39
- const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
40
- if (host.includes(":")) {
41
- // IPv6 literal (DNS names never contain ":"); ::ffff:* are IPv4-mapped
42
- // forms whose dotted target is hidden by URL canonicalization.
43
- if (
44
- host === "::" ||
45
- host === "::1" ||
46
- host.startsWith("fe80:") ||
47
- host.startsWith("fc") ||
48
- host.startsWith("fd") ||
49
- host.startsWith("::ffff:")
50
- ) {
51
- refusePrivateHost(url.hostname);
52
- }
53
- } else {
54
- const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
55
- if (v4) {
56
- const [a, b] = [Number(v4[1]), Number(v4[2])];
57
- const reserved =
58
- a === 0 || // unspecified
59
- a === 10 || // RFC1918
60
- a === 127 || // loopback
61
- (a === 169 && b === 254) || // link-local, incl. cloud metadata
62
- (a === 172 && b >= 16 && b <= 31) || // RFC1918
63
- (a === 192 && b === 168); // RFC1918
64
- if (reserved) refusePrivateHost(url.hostname);
65
- }
66
- }
67
- }
68
-
69
- const OMITTED_ELEMENTS_REGEX = /<(script|style|noscript)\b[\s\S]*?<\/\1>/gi;
70
- const BLOCK_BREAK_REGEX =
71
- /<\/(p|div|h[1-6]|li|tr|blockquote|pre|section|article|header|footer|nav|details|summary)>|<br\s*\/?>/gi;
72
- const ANY_TAG_REGEX = /<[^>]+>/g;
73
- const TITLE_TAG_REGEX = /<title[^>]*>([\s\S]*?)<\/title>/i;
74
-
75
- function htmlToText(html: string): string {
76
- let text = html.replace(OMITTED_ELEMENTS_REGEX, "").replace(BLOCK_BREAK_REGEX, "\n");
77
- text = text.replace(ANY_TAG_REGEX, " ");
78
- text = text
79
- .replace(/&amp;/g, "&")
80
- .replace(/&lt;/g, "<")
81
- .replace(/&gt;/g, ">")
82
- .replace(/&quot;/g, '"')
83
- .replace(/&#39;/g, "'")
84
- .replace(/&nbsp;/g, " ")
85
- .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
86
- return text.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
87
- }
88
-
89
- function pageTitle(html: string): string | undefined {
90
- return html.match(TITLE_TAG_REGEX)?.[1].replace(ANY_TAG_REGEX, "").trim() || undefined;
91
- }
92
-
93
- async function braveSearch(
94
- query: string,
95
- maxResults: number,
96
- apiKey: string,
97
- signal: AbortSignal | undefined,
98
- ): Promise<SearchResult[]> {
99
- const url = new URL("https://api.search.brave.com/res/v1/web/search");
100
- url.searchParams.set("q", query);
101
- url.searchParams.set("count", String(maxResults));
102
- const res = await fetch(url.toString(), {
103
- headers: {
104
- Accept: "application/json",
105
- "Accept-Encoding": "gzip",
106
- "X-Subscription-Token": apiKey,
107
- },
108
- signal,
109
- });
110
- if (!res.ok) {
111
- throw new Error(`Brave API error (${res.status}): ${await res.text()}`);
112
- }
113
- const raw = (await res.json()) as { web?: { results?: Array<{ title?: string; url?: string; description?: string }> } };
114
- return (raw.web?.results ?? []).map((r) => ({ title: r.title ?? "", url: r.url ?? "", snippet: r.description ?? "" }));
115
- }
116
-
117
- async function readPage(url: string, raw: boolean, signal: AbortSignal | undefined): Promise<{ text: string; title?: string; contentType?: string }> {
118
- const res = await fetch(url, {
119
- signal,
120
- redirect: "follow",
121
- headers: {
122
- "User-Agent": "Mozilla/5.0 (compatible; tandem-pi/1.0)",
123
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5",
124
- },
125
- });
126
- if (!res.ok) {
127
- throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
128
- }
129
- const contentType = res.headers.get("content-type") ?? "";
130
- if (/^(image|video|audio)\//.test(contentType)) {
131
- throw new Error(`Unsupported content type: ${contentType}. web_fetch supports text pages only.`);
132
- }
133
- const rawBody = await res.text();
134
- const pageContentType = contentType || undefined;
135
- if (!raw && /text\/html/.test(contentType)) {
136
- return { text: htmlToText(rawBody), title: pageTitle(rawBody), contentType: pageContentType };
137
- }
138
- return { text: rawBody, contentType: pageContentType };
139
- }
140
-
141
- async function writeTempCopy(content: string): Promise<string> {
142
- const dir = await mkdtemp(join(tmpdir(), "tandem-fetch-"));
143
- const file = join(dir, "content.txt");
144
- await writeFile(file, content, "utf8");
145
- return file;
146
- }
147
-
148
- function formatTruncationNote(truncation: TruncationResult, tempFile: string): string {
149
- const omittedLines = truncation.totalLines - truncation.outputLines;
150
- const omittedBytes = truncation.totalBytes - truncation.outputBytes;
151
- return (
152
- `\n\n[Content truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines` +
153
- ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).` +
154
- ` ${omittedLines} lines (${formatSize(omittedBytes)}) omitted.` +
155
- ` Full content saved to: ${tempFile}]`
156
- );
157
- }
158
-
159
- function formatSearchResults(query: string, results: SearchResult[]): string {
160
- const header = `**Search results for "${query}":**`;
161
- const list = results
162
- .map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`)
163
- .join("\n\n");
164
- return `${header}\n\n${list}`;
165
- }
166
-
167
- function formatPageHeader(url: string, title: string | undefined, contentType: string): string {
168
- const lines = [`**Fetched:** ${url}`];
169
- if (title) lines.push(`**Title:** ${title}`);
170
- if (contentType) lines.push(`**Content-Type:** ${contentType}`);
171
- return `${lines.join("\n")}\n\n`;
172
- }
173
-
174
- export default function (pi: ExtensionAPI) {
175
- pi.registerTool({
176
- name: "web_search",
177
- label: "Web Search",
178
- description:
179
- "Search the web for information. Returns a list of results with titles, URLs, and snippets. Use when you need current information not in your training data.",
180
- parameters: Type.Object({
181
- query: Type.String({
182
- description: "The search query. Be specific and use natural language.",
183
- }),
184
- max_results: Type.Optional(
185
- Type.Number({
186
- description: `Maximum number of results to return (${MIN_SEARCH_RESULTS}-${MAX_SEARCH_RESULTS}). Default: ${DEFAULT_SEARCH_RESULTS}.`,
187
- default: DEFAULT_SEARCH_RESULTS,
188
- minimum: MIN_SEARCH_RESULTS,
189
- maximum: MAX_SEARCH_RESULTS,
190
- }),
191
- ),
192
- }),
193
- async execute(_toolCallId, params, signal, onUpdate) {
194
- const apiKey = process.env[BRAVE_API_KEY_ENV_VAR]?.trim();
195
- if (!apiKey) {
196
- throw new Error(`${BRAVE_API_KEY_ENV_VAR} is not set. Export it to enable web_search.`);
197
- }
198
- onUpdate?.({
199
- content: [{ type: "text", text: `Searching Brave for: "${params.query}"...` }],
200
- details: undefined,
201
- });
202
- const maxResults = Math.min(
203
- Math.max(params.max_results ?? DEFAULT_SEARCH_RESULTS, MIN_SEARCH_RESULTS),
204
- MAX_SEARCH_RESULTS,
205
- );
206
- const results = await braveSearch(params.query, maxResults, apiKey, signal);
207
- if (results.length === 0) {
208
- return { content: [{ type: "text", text: `No results found for "${params.query}".` }], details: undefined };
209
- }
210
- return { content: [{ type: "text", text: formatSearchResults(params.query, results) }], details: undefined };
211
- },
212
- });
213
-
214
- pi.registerTool({
215
- name: "web_fetch",
216
- label: "Web Fetch",
217
- description:
218
- "Fetch the content of a specific URL. Returns text content for HTML pages (tags stripped), raw text for plain text or JSON. Supports http and https only. Content is truncated to avoid overwhelming the context window.",
219
- parameters: Type.Object({
220
- url: Type.String({
221
- description: "The URL to fetch. Must be http or https.",
222
- }),
223
- raw: Type.Optional(
224
- Type.Boolean({
225
- description: "If true, return the raw HTML instead of extracted text. Default: false.",
226
- default: false,
227
- }),
228
- ),
229
- }),
230
- async execute(_toolCallId, params, signal, onUpdate) {
231
- const { url, raw = false } = params;
232
- assertPublicHttpUrl(url);
233
- onUpdate?.({ content: [{ type: "text", text: `Fetching: ${url}...` }], details: undefined });
234
- const { text: pageText, title, contentType } = await readPage(url, raw, signal);
235
- const truncation = truncateHead(pageText);
236
- let output = truncation.content;
237
- if (truncation.truncated) {
238
- output += formatTruncationNote(truncation, await writeTempCopy(pageText));
239
- }
240
- return {
241
- content: [{ type: "text", text: formatPageHeader(url, title, contentType ?? "") + output }],
242
- details: undefined,
243
- };
244
- },
245
- });
246
- }