@aliou/pi-linkup 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/README.md +11 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/ci.yml +32 -0
- package/.github/workflows/publish.yml +151 -0
- package/.husky/pre-commit +3 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +7 -0
- package/README.md +203 -0
- package/biome.json +30 -0
- package/package.json +24 -3
- package/shell.nix +10 -0
- package/skills/linkup/SKILL.md +99 -0
- package/src/client.ts +83 -0
- package/src/commands/balance.ts +23 -0
- package/src/index.ts +33 -0
- package/src/tools/web-answer.ts +143 -0
- package/src/tools/web-fetch.ts +119 -0
- package/src/tools/web-search.ts +134 -0
- package/src/types.ts +34 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changesets
|
|
2
|
+
|
|
3
|
+
This folder is used by [Changesets](https://github.com/changesets/changesets) to manage versioning and changelogs.
|
|
4
|
+
|
|
5
|
+
## Adding a changeset
|
|
6
|
+
|
|
7
|
+
Run `pnpm changeset` to create a new changeset when you make changes that should be released.
|
|
8
|
+
|
|
9
|
+
## Releasing
|
|
10
|
+
|
|
11
|
+
When changesets are merged to main, a PR will be created to version and release the package.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
|
|
3
|
+
"changelog": "@changesets/cli/changelog",
|
|
4
|
+
"commit": false,
|
|
5
|
+
"fixed": [],
|
|
6
|
+
"linked": [],
|
|
7
|
+
"access": "public",
|
|
8
|
+
"baseBranch": "main",
|
|
9
|
+
"updateInternalDependencies": "patch",
|
|
10
|
+
"ignore": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
check:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: pnpm/action-setup@v4
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: "22"
|
|
23
|
+
cache: "pnpm"
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: pnpm install --frozen-lockfile
|
|
27
|
+
|
|
28
|
+
- name: Lint
|
|
29
|
+
run: pnpm lint
|
|
30
|
+
|
|
31
|
+
- name: Typecheck
|
|
32
|
+
run: pnpm typecheck
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
skip-checks:
|
|
10
|
+
description: "Skip lint and typecheck"
|
|
11
|
+
type: boolean
|
|
12
|
+
default: false
|
|
13
|
+
|
|
14
|
+
concurrency:
|
|
15
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
16
|
+
cancel-in-progress: true
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
check:
|
|
20
|
+
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip-checks) }}
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: pnpm/action-setup@v4
|
|
26
|
+
|
|
27
|
+
- uses: actions/setup-node@v4
|
|
28
|
+
with:
|
|
29
|
+
node-version: "22"
|
|
30
|
+
cache: "pnpm"
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: pnpm install --frozen-lockfile
|
|
34
|
+
|
|
35
|
+
- name: Lint
|
|
36
|
+
run: pnpm lint
|
|
37
|
+
|
|
38
|
+
- name: Typecheck
|
|
39
|
+
run: pnpm typecheck
|
|
40
|
+
|
|
41
|
+
publish:
|
|
42
|
+
name: Publish
|
|
43
|
+
needs: check
|
|
44
|
+
if: ${{ always() && (needs.check.result == 'success' || needs.check.result == 'skipped') }}
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
permissions:
|
|
47
|
+
contents: write
|
|
48
|
+
packages: write
|
|
49
|
+
pull-requests: write
|
|
50
|
+
id-token: write
|
|
51
|
+
|
|
52
|
+
steps:
|
|
53
|
+
- name: Checkout
|
|
54
|
+
uses: actions/checkout@v4
|
|
55
|
+
with:
|
|
56
|
+
fetch-depth: 0
|
|
57
|
+
|
|
58
|
+
- name: Setup pnpm
|
|
59
|
+
uses: pnpm/action-setup@v4
|
|
60
|
+
|
|
61
|
+
- name: Setup Node.js
|
|
62
|
+
uses: actions/setup-node@v4
|
|
63
|
+
with:
|
|
64
|
+
node-version: "22"
|
|
65
|
+
registry-url: "https://registry.npmjs.org"
|
|
66
|
+
scope: "@aliou"
|
|
67
|
+
cache: "pnpm"
|
|
68
|
+
|
|
69
|
+
- name: Upgrade npm for OIDC support
|
|
70
|
+
run: npm install -g npm@latest
|
|
71
|
+
|
|
72
|
+
- name: Install dependencies
|
|
73
|
+
run: pnpm install --frozen-lockfile
|
|
74
|
+
|
|
75
|
+
- name: Get release info
|
|
76
|
+
id: release-info
|
|
77
|
+
run: |
|
|
78
|
+
pnpm changeset status --output=release.json 2>/dev/null || echo '{"releases":[]}' > release.json
|
|
79
|
+
node <<NODE
|
|
80
|
+
const fs = require('fs');
|
|
81
|
+
const release = JSON.parse(fs.readFileSync('release.json', 'utf8'));
|
|
82
|
+
const releases = release.releases?.filter(r => r.type !== 'none') || [];
|
|
83
|
+
|
|
84
|
+
let title = 'Version Packages';
|
|
85
|
+
let commit = 'Version Packages';
|
|
86
|
+
if (releases.length === 1) {
|
|
87
|
+
const { name, newVersion } = releases[0];
|
|
88
|
+
title = 'Updating ' + name + ' to version ' + newVersion;
|
|
89
|
+
commit = name + '@' + newVersion;
|
|
90
|
+
} else if (releases.length > 1) {
|
|
91
|
+
const summary = releases.map(r => r.name + '@' + r.newVersion).join(', ');
|
|
92
|
+
title = 'Updating ' + summary;
|
|
93
|
+
commit = summary;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'title=' + title + '\n');
|
|
97
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'commit=' + commit + '\n');
|
|
98
|
+
NODE
|
|
99
|
+
rm -f release.json
|
|
100
|
+
continue-on-error: true
|
|
101
|
+
|
|
102
|
+
- name: Create Release PR or Publish
|
|
103
|
+
id: changesets
|
|
104
|
+
uses: changesets/action@v1
|
|
105
|
+
with:
|
|
106
|
+
version: pnpm changeset version
|
|
107
|
+
publish: pnpm changeset publish
|
|
108
|
+
title: ${{ steps.release-info.outputs.title || 'Version Packages' }}
|
|
109
|
+
commit: ${{ steps.release-info.outputs.commit || 'Version Packages' }}
|
|
110
|
+
env:
|
|
111
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
112
|
+
NPM_CONFIG_PROVENANCE: true
|
|
113
|
+
|
|
114
|
+
- name: Create GitHub releases
|
|
115
|
+
if: steps.changesets.outputs.published == 'true'
|
|
116
|
+
env:
|
|
117
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
118
|
+
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
|
|
119
|
+
run: |
|
|
120
|
+
node <<'NODE'
|
|
121
|
+
const { execSync } = require("node:child_process");
|
|
122
|
+
|
|
123
|
+
const published = JSON.parse(process.env.PUBLISHED_PACKAGES || "[]");
|
|
124
|
+
|
|
125
|
+
for (const pkg of published) {
|
|
126
|
+
const shortName = pkg.name.replace(/^@[^/]+\//, "");
|
|
127
|
+
const tag = `${shortName}@${pkg.version}`;
|
|
128
|
+
|
|
129
|
+
const existing = execSync(`git tag --list ${tag}`, { encoding: "utf8" }).trim();
|
|
130
|
+
if (!existing) {
|
|
131
|
+
execSync(`git tag ${tag}`);
|
|
132
|
+
execSync(`git push origin ${tag}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let hasRelease = false;
|
|
136
|
+
try {
|
|
137
|
+
const output = execSync(`gh release view ${tag} --json tagName --jq .tagName`, {
|
|
138
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
139
|
+
}).toString().trim();
|
|
140
|
+
hasRelease = output.length > 0;
|
|
141
|
+
} catch {
|
|
142
|
+
hasRelease = false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!hasRelease) {
|
|
146
|
+
execSync(`gh release create ${tag} --title ${tag} --notes "Release ${tag}"`, {
|
|
147
|
+
stdio: "inherit",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
NODE
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# pi-linkup
|
|
2
|
+
|
|
3
|
+
Public Pi extension providing web search, answer, and fetch tools via the Linkup API. People could be using this, so consider backwards compatibility when making changes.
|
|
4
|
+
|
|
5
|
+
Pi is pre-1.0.0, so breaking changes can happen between Pi versions. This extension must stay up to date with Pi or things will break.
|
|
6
|
+
|
|
7
|
+
## Stack
|
|
8
|
+
|
|
9
|
+
- TypeScript (strict mode)
|
|
10
|
+
- pnpm 10.26.1
|
|
11
|
+
- Biome for linting/formatting
|
|
12
|
+
- Changesets for versioning
|
|
13
|
+
|
|
14
|
+
## Scripts
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm typecheck # Type check
|
|
18
|
+
pnpm lint # Lint (runs on pre-commit)
|
|
19
|
+
pnpm format # Format
|
|
20
|
+
pnpm changeset # Create changeset for versioning
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Structure
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
src/
|
|
27
|
+
index.ts # Extension entry, registers tools and commands
|
|
28
|
+
client.ts # Linkup API client
|
|
29
|
+
types.ts # Shared types
|
|
30
|
+
tools/ # Tool implementations
|
|
31
|
+
commands/ # Command implementations
|
|
32
|
+
skills/
|
|
33
|
+
linkup/SKILL.md # Skill docs for agents using this extension
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Conventions
|
|
37
|
+
|
|
38
|
+
- New tools: follow patterns in `src/tools/`
|
|
39
|
+
- API keys come from environment (`LINKUP_API_KEY`)
|
|
40
|
+
- Update `skills/linkup/SKILL.md` when tool behavior changes
|
|
41
|
+
|
|
42
|
+
## Versioning
|
|
43
|
+
|
|
44
|
+
Uses changesets. Run `pnpm changeset` before committing user-facing changes.
|
|
45
|
+
|
|
46
|
+
- `patch`: bug fixes
|
|
47
|
+
- `minor`: new features/tools
|
|
48
|
+
- `major`: breaking changes
|
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# pi-linkup
|
|
2
|
+
|
|
3
|
+
Web search and content fetching extension for [Pi](https://buildwithpi.ai/) using the [Linkup API](https://linkup.so).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- `linkup_web_search` - Search the web, get relevant sources with content
|
|
8
|
+
- `linkup_web_answer` - Get synthesized answers with citations
|
|
9
|
+
- `linkup_web_fetch` - Extract clean markdown from URLs
|
|
10
|
+
- `/linkup:balance` - Check API credit balance
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
### Get API Key
|
|
15
|
+
|
|
16
|
+
Sign up at [app.linkup.so](https://app.linkup.so) to get an API key.
|
|
17
|
+
|
|
18
|
+
### Set Environment Variable
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export LINKUP_API_KEY="your-api-key-here"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Add to shell profile for persistence:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
echo 'export LINKUP_API_KEY="your-api-key-here"' >> ~/.zshrc
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Install Extension
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# From npm
|
|
34
|
+
pi install npm:@aliou/pi-linkup
|
|
35
|
+
|
|
36
|
+
# From git
|
|
37
|
+
pi install git:github.com/aliou/pi-linkup
|
|
38
|
+
|
|
39
|
+
# Local development
|
|
40
|
+
pi -e ./src/index.ts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### linkup_web_search
|
|
46
|
+
|
|
47
|
+
Search the web and get a list of sources with content snippets.
|
|
48
|
+
|
|
49
|
+
**Parameters:**
|
|
50
|
+
- `query` (string, required) - The search query
|
|
51
|
+
- `deep` (boolean, optional) - Use deep search mode for comprehensive results. Default: false
|
|
52
|
+
|
|
53
|
+
**Example prompts:**
|
|
54
|
+
```
|
|
55
|
+
Search for "TypeScript 5.0 new features"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Use linkup_web_search with deep mode to research WebAssembly WASI
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The agent will use `linkup_web_search` to find relevant sources. Results are shown in compact view by default. Press `Ctrl+O` to expand and see all sources with full content.
|
|
63
|
+
|
|
64
|
+
### linkup_web_answer
|
|
65
|
+
|
|
66
|
+
Get a synthesized answer with sources.
|
|
67
|
+
|
|
68
|
+
**Parameters:**
|
|
69
|
+
- `query` (string, required) - The question to answer
|
|
70
|
+
- `deep` (boolean, optional) - Use deep search mode. Default: false
|
|
71
|
+
|
|
72
|
+
**Example prompts:**
|
|
73
|
+
```
|
|
74
|
+
What is the latest stable Node.js version?
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
Use linkup_web_answer to find Microsoft's 2024 revenue
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The agent will use `linkup_web_answer` for concise answers. Press `Ctrl+O` to expand and see the full answer with all sources.
|
|
82
|
+
|
|
83
|
+
### linkup_web_fetch
|
|
84
|
+
|
|
85
|
+
Fetch content from a specific URL as markdown.
|
|
86
|
+
|
|
87
|
+
**Parameters:**
|
|
88
|
+
- `url` (string, required) - The URL to fetch
|
|
89
|
+
- `renderJs` (boolean, optional) - Render JavaScript. Default: true
|
|
90
|
+
|
|
91
|
+
**Example prompts:**
|
|
92
|
+
```
|
|
93
|
+
Fetch the content from https://docs.linkup.so
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
Use linkup_web_fetch without JavaScript rendering for https://example.com/docs
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The agent will use `linkup_web_fetch` to extract clean markdown. Press `Ctrl+O` to expand and see more content.
|
|
101
|
+
|
|
102
|
+
### Check Balance
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
/linkup:balance
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Shows remaining API credits.
|
|
109
|
+
|
|
110
|
+
## Skill
|
|
111
|
+
|
|
112
|
+
Includes agentskills.io compliant skill with detailed usage guide:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
/skill:linkup
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Provides:
|
|
119
|
+
- Tool selection guidance
|
|
120
|
+
- Query formulation tips
|
|
121
|
+
- When to use deep vs standard search
|
|
122
|
+
- Prompting best practices
|
|
123
|
+
- Example workflows
|
|
124
|
+
|
|
125
|
+
## Tool Selection Guide
|
|
126
|
+
|
|
127
|
+
**Use linkup_web_search when:**
|
|
128
|
+
- Finding information across multiple sources
|
|
129
|
+
- Research and discovery
|
|
130
|
+
- Need to see different perspectives
|
|
131
|
+
|
|
132
|
+
**Use linkup_web_answer when:**
|
|
133
|
+
- Need a direct answer to a specific question
|
|
134
|
+
- Want a quick summary from multiple sources
|
|
135
|
+
- Time-sensitive queries
|
|
136
|
+
|
|
137
|
+
**Use linkup_web_fetch when:**
|
|
138
|
+
- Reading documentation from a known URL
|
|
139
|
+
- Following up on search results
|
|
140
|
+
- Extracting content from specific articles
|
|
141
|
+
|
|
142
|
+
## Best Practices
|
|
143
|
+
|
|
144
|
+
1. Be specific with queries: "Microsoft 2024 Q4 revenue" beats "Microsoft revenue"
|
|
145
|
+
2. Use deep mode strategically: Deep searches are thorough but slower
|
|
146
|
+
3. Choose the right tool: search for discovery, answer for facts, fetch for known URLs
|
|
147
|
+
4. Monitor usage: Check `/linkup:balance` to track credit consumption
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
### Setup
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
git clone https://github.com/aliou/pi-linkup.git
|
|
155
|
+
cd pi-linkup
|
|
156
|
+
|
|
157
|
+
# Install dependencies (sets up pre-commit hooks)
|
|
158
|
+
pnpm install
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Pre-commit hooks run on every commit:
|
|
162
|
+
- TypeScript type checking
|
|
163
|
+
- Biome linting
|
|
164
|
+
- Biome formatting with auto-fix
|
|
165
|
+
|
|
166
|
+
### Commands
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Type check
|
|
170
|
+
pnpm run typecheck
|
|
171
|
+
|
|
172
|
+
# Lint
|
|
173
|
+
pnpm run lint
|
|
174
|
+
|
|
175
|
+
# Format
|
|
176
|
+
pnpm run format
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Test Locally
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
pi -e ./src/index.ts
|
|
183
|
+
|
|
184
|
+
# Then in Pi
|
|
185
|
+
/skill:linkup
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Requirements
|
|
189
|
+
|
|
190
|
+
- Pi coding agent v0.50.0+
|
|
191
|
+
- LINKUP_API_KEY environment variable
|
|
192
|
+
|
|
193
|
+
## Links
|
|
194
|
+
|
|
195
|
+
- [Linkup Documentation](https://docs.linkup.so)
|
|
196
|
+
- [Linkup API Reference](https://docs.linkup.so/pages/documentation/api-reference)
|
|
197
|
+
- [Get API Key](https://app.linkup.so)
|
|
198
|
+
- [Pi Documentation](https://buildwithpi.ai/)
|
|
199
|
+
- [Agent Skills Spec](https://agentskills.io/specification)
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT
|
package/biome.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": true
|
|
7
|
+
},
|
|
8
|
+
"files": {
|
|
9
|
+
"includes": ["**/*.ts", "**/*.json"],
|
|
10
|
+
"ignoreUnknown": true
|
|
11
|
+
},
|
|
12
|
+
"assist": {
|
|
13
|
+
"actions": {
|
|
14
|
+
"source": {
|
|
15
|
+
"organizeImports": "on"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"linter": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"rules": {
|
|
22
|
+
"recommended": true
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"formatter": {
|
|
26
|
+
"enabled": true,
|
|
27
|
+
"indentStyle": "space",
|
|
28
|
+
"indentWidth": 2
|
|
29
|
+
}
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-linkup",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/aliou/pi-linkup"
|
|
7
|
+
},
|
|
5
8
|
"keywords": [
|
|
6
9
|
"pi-package"
|
|
7
10
|
],
|
|
@@ -12,5 +15,23 @@
|
|
|
12
15
|
"skills": [
|
|
13
16
|
"./skills"
|
|
14
17
|
]
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@biomejs/biome": "^2.3.13",
|
|
21
|
+
"@changesets/cli": "^2.27.11",
|
|
22
|
+
"@mariozechner/pi-coding-agent": "0.51.0",
|
|
23
|
+
"@mariozechner/pi-tui": "0.51.0",
|
|
24
|
+
"@sinclair/typebox": "^0.34.48",
|
|
25
|
+
"@types/node": "^25.0.10",
|
|
26
|
+
"husky": "^9.1.7",
|
|
27
|
+
"typescript": "^5.9.3"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"lint": "biome check",
|
|
32
|
+
"format": "biome check --write",
|
|
33
|
+
"changeset": "changeset",
|
|
34
|
+
"version": "changeset version",
|
|
35
|
+
"release": "pnpm changeset publish"
|
|
15
36
|
}
|
|
16
|
-
}
|
|
37
|
+
}
|
package/shell.nix
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: linkup
|
|
3
|
+
description: "Web search and content fetching using Linkup extension. Use when needing to search the web, get answers to questions with sources, or fetch content from specific URLs. Provides three tools: linkup_web_search (discovery), linkup_web_answer (direct answers), linkup_web_fetch (URL content extraction)."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Linkup Extension
|
|
7
|
+
|
|
8
|
+
Web search and content fetching tools powered by Linkup API.
|
|
9
|
+
|
|
10
|
+
## Tools
|
|
11
|
+
|
|
12
|
+
### linkup_web_search
|
|
13
|
+
|
|
14
|
+
Search the web and get sources with content snippets.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
linkup_web_search(query: string, deep?: boolean)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- `query`: Be specific and detailed. Include context like dates, locations, company names.
|
|
21
|
+
- `deep`: Use for complex research requiring multiple searches. Default: false (faster).
|
|
22
|
+
|
|
23
|
+
**Use when:** Discovering information across multiple sources, researching topics, comparing perspectives.
|
|
24
|
+
|
|
25
|
+
### linkup_web_answer
|
|
26
|
+
|
|
27
|
+
Get a synthesized answer with source citations.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
linkup_web_answer(query: string, deep?: boolean)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Use when:** Need a direct answer to a specific question, quick facts with citations.
|
|
34
|
+
|
|
35
|
+
### linkup_web_fetch
|
|
36
|
+
|
|
37
|
+
Fetch content from a URL as clean markdown.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
linkup_web_fetch(url: string, renderJs?: boolean)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `url`: The URL to fetch.
|
|
44
|
+
- `renderJs`: Set false for static pages (faster). Default: true.
|
|
45
|
+
|
|
46
|
+
**Use when:** Reading documentation, following up on search results, extracting content from known URLs.
|
|
47
|
+
|
|
48
|
+
## Tool Selection
|
|
49
|
+
|
|
50
|
+
| Need | Tool |
|
|
51
|
+
|------|------|
|
|
52
|
+
| Find information across sources | `linkup_web_search` |
|
|
53
|
+
| Get a direct answer with sources | `linkup_web_answer` |
|
|
54
|
+
| Read content from a known URL | `linkup_web_fetch` |
|
|
55
|
+
|
|
56
|
+
## Query Formulation
|
|
57
|
+
|
|
58
|
+
**Good queries are specific:**
|
|
59
|
+
|
|
60
|
+
| Bad | Good |
|
|
61
|
+
|-----|------|
|
|
62
|
+
| "Microsoft revenue" | "Microsoft fiscal year 2024 total revenue" |
|
|
63
|
+
| "React hooks" | "React useEffect cleanup function best practices" |
|
|
64
|
+
| "AI news" | "OpenAI announcements January 2026" |
|
|
65
|
+
|
|
66
|
+
**Add context:**
|
|
67
|
+
- Time: "2025", "last quarter", "since version 5.0"
|
|
68
|
+
- Location: "French company Total", "US market"
|
|
69
|
+
- Specifics: company names, version numbers, exact terms
|
|
70
|
+
|
|
71
|
+
## When to Use Deep Mode
|
|
72
|
+
|
|
73
|
+
**Standard (default):** Simple questions, quick lookups, known topics.
|
|
74
|
+
|
|
75
|
+
**Deep:** Complex research, multi-step queries, comprehensive coverage needed.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
// Standard - one search is enough
|
|
79
|
+
linkup_web_search("Node.js 22 release date")
|
|
80
|
+
|
|
81
|
+
// Deep - needs multiple searches
|
|
82
|
+
linkup_web_search("comparison of Rust web frameworks performance benchmarks 2025", deep: true)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Common Patterns
|
|
86
|
+
|
|
87
|
+
### Research workflow
|
|
88
|
+
1. `linkup_web_search` to discover sources
|
|
89
|
+
2. `linkup_web_fetch` on promising URLs for full content
|
|
90
|
+
|
|
91
|
+
### Quick facts
|
|
92
|
+
1. `linkup_web_answer` for direct answer with citations
|
|
93
|
+
|
|
94
|
+
### Documentation reading
|
|
95
|
+
1. `linkup_web_fetch` on known documentation URL
|
|
96
|
+
|
|
97
|
+
## Commands
|
|
98
|
+
|
|
99
|
+
- `/linkup:balance` - Check remaining API credits
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LinkupBalanceResponse,
|
|
3
|
+
LinkupErrorResponse,
|
|
4
|
+
LinkupFetchResponse,
|
|
5
|
+
LinkupSearchResponse,
|
|
6
|
+
LinkupSourcedAnswerResponse,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
const BASE_URL = "https://api.linkup.so/v1";
|
|
10
|
+
|
|
11
|
+
export class LinkupClient {
|
|
12
|
+
private apiKey: string;
|
|
13
|
+
|
|
14
|
+
constructor(apiKey: string) {
|
|
15
|
+
this.apiKey = apiKey;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private async request<T>(
|
|
19
|
+
endpoint: string,
|
|
20
|
+
options: RequestInit = {},
|
|
21
|
+
): Promise<T> {
|
|
22
|
+
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
|
23
|
+
...options,
|
|
24
|
+
headers: {
|
|
25
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
...options.headers,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const error = (await response.json()) as LinkupErrorResponse;
|
|
33
|
+
throw new Error(
|
|
34
|
+
error.error?.message ||
|
|
35
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return response.json();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async search(params: {
|
|
43
|
+
query: string;
|
|
44
|
+
depth: "standard" | "deep";
|
|
45
|
+
outputType: "searchResults" | "sourcedAnswer";
|
|
46
|
+
}): Promise<LinkupSearchResponse | LinkupSourcedAnswerResponse> {
|
|
47
|
+
return this.request("/search", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
q: params.query,
|
|
51
|
+
depth: params.depth,
|
|
52
|
+
outputType: params.outputType,
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async fetch(params: {
|
|
58
|
+
url: string;
|
|
59
|
+
renderJs?: boolean;
|
|
60
|
+
}): Promise<LinkupFetchResponse> {
|
|
61
|
+
return this.request("/fetch", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
url: params.url,
|
|
65
|
+
renderJs: params.renderJs ?? true,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getBalance(): Promise<LinkupBalanceResponse> {
|
|
71
|
+
return this.request("/credits/balance", {
|
|
72
|
+
method: "GET",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getClient(): LinkupClient {
|
|
78
|
+
const apiKey = process.env.LINKUP_API_KEY;
|
|
79
|
+
if (!apiKey) {
|
|
80
|
+
throw new Error("LINKUP_API_KEY environment variable is not set");
|
|
81
|
+
}
|
|
82
|
+
return new LinkupClient(apiKey);
|
|
83
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { getClient } from "../client";
|
|
3
|
+
|
|
4
|
+
export function registerBalanceCommand(pi: ExtensionAPI) {
|
|
5
|
+
pi.registerCommand("linkup:balance", {
|
|
6
|
+
description: "Display remaining Linkup API credits",
|
|
7
|
+
async handler(_args, ctx) {
|
|
8
|
+
const client = getClient();
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const response = await client.getBalance();
|
|
12
|
+
ctx.ui.notify(
|
|
13
|
+
`Linkup Balance: ${response.balance.toFixed(2)} credits`,
|
|
14
|
+
"info",
|
|
15
|
+
);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const message =
|
|
18
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
19
|
+
ctx.ui.notify(`Error: ${message}`, "error");
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { registerBalanceCommand } from "./commands/balance";
|
|
3
|
+
import { registerWebAnswerTool } from "./tools/web-answer";
|
|
4
|
+
import { registerWebFetchTool } from "./tools/web-fetch";
|
|
5
|
+
import { registerWebSearchTool } from "./tools/web-search";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
const hasApiKey = !!process.env.LINKUP_API_KEY;
|
|
9
|
+
|
|
10
|
+
if (!hasApiKey) {
|
|
11
|
+
console.warn(
|
|
12
|
+
"[linkup] Warning: LINKUP_API_KEY not set. Linkup extension will not load.",
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
pi.on("session_start", (_event, ctx) => {
|
|
16
|
+
if (ctx.hasUI) {
|
|
17
|
+
ctx.ui.notify(
|
|
18
|
+
"LINKUP_API_KEY not set. Linkup extension disabled.",
|
|
19
|
+
"warning",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Register tools
|
|
27
|
+
registerWebSearchTool(pi);
|
|
28
|
+
registerWebAnswerTool(pi);
|
|
29
|
+
registerWebFetchTool(pi);
|
|
30
|
+
|
|
31
|
+
// Register commands
|
|
32
|
+
registerBalanceCommand(pi);
|
|
33
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { getClient } from "../client";
|
|
5
|
+
import type { LinkupSource, LinkupSourcedAnswerResponse } from "../types";
|
|
6
|
+
|
|
7
|
+
interface WebAnswerDetails {
|
|
8
|
+
answer?: string;
|
|
9
|
+
sources?: LinkupSource[];
|
|
10
|
+
query?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerWebAnswerTool(pi: ExtensionAPI) {
|
|
16
|
+
pi.registerTool({
|
|
17
|
+
name: "linkup_web_answer",
|
|
18
|
+
label: "Linkup Web Answer",
|
|
19
|
+
description:
|
|
20
|
+
"Get a synthesized answer to a question using Linkup API. Returns a direct answer with sources. Use when you need a concise answer to a specific question.",
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
query: Type.String({
|
|
23
|
+
description:
|
|
24
|
+
"The question to answer. Be specific and detailed for best results.",
|
|
25
|
+
}),
|
|
26
|
+
deep: Type.Optional(
|
|
27
|
+
Type.Boolean({
|
|
28
|
+
description:
|
|
29
|
+
"Use deep search for more comprehensive answer (slower). Default: false (standard search).",
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
|
|
35
|
+
const client = getClient();
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
onUpdate?.({
|
|
39
|
+
content: [
|
|
40
|
+
{
|
|
41
|
+
type: "text",
|
|
42
|
+
text: `Searching for answer${params.deep ? " (deep mode)" : ""}...`,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
details: {},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const response = (await client.search({
|
|
49
|
+
query: params.query,
|
|
50
|
+
depth: params.deep ? "deep" : "standard",
|
|
51
|
+
outputType: "sourcedAnswer",
|
|
52
|
+
})) as LinkupSourcedAnswerResponse;
|
|
53
|
+
|
|
54
|
+
let content = `${response.answer}\n\n`;
|
|
55
|
+
content += `Sources:\n`;
|
|
56
|
+
for (const source of response.sources) {
|
|
57
|
+
content += `- ${source.name}: ${source.url}\n`;
|
|
58
|
+
if (source.snippet) {
|
|
59
|
+
content += ` ${source.snippet}\n`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: content }],
|
|
65
|
+
details: {
|
|
66
|
+
answer: response.answer,
|
|
67
|
+
sources: response.sources,
|
|
68
|
+
query: params.query,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const message =
|
|
73
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
76
|
+
details: { error: message, isError: true },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
renderCall(args, theme) {
|
|
82
|
+
let text = theme.fg("toolTitle", theme.bold("Linkup: WebAnswer "));
|
|
83
|
+
text += theme.fg("accent", `"${args.query}"`);
|
|
84
|
+
if (args.deep) {
|
|
85
|
+
text += theme.fg("dim", " (deep)");
|
|
86
|
+
}
|
|
87
|
+
return new Text(text, 0, 0);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
91
|
+
if (isPartial) {
|
|
92
|
+
const text =
|
|
93
|
+
result.content?.[0]?.type === "text"
|
|
94
|
+
? result.content[0].text
|
|
95
|
+
: "Searching...";
|
|
96
|
+
return new Text(theme.fg("dim", text), 0, 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const details = result.details as WebAnswerDetails;
|
|
100
|
+
|
|
101
|
+
if (details?.isError) {
|
|
102
|
+
const errorMsg =
|
|
103
|
+
result.content?.[0]?.type === "text"
|
|
104
|
+
? result.content[0].text
|
|
105
|
+
: "Error occurred";
|
|
106
|
+
return new Text(theme.fg("error", errorMsg), 0, 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const answer = details?.answer || "";
|
|
110
|
+
const sources = details?.sources || [];
|
|
111
|
+
|
|
112
|
+
let text = theme.fg("success", "✓ Answer received");
|
|
113
|
+
|
|
114
|
+
if (!expanded) {
|
|
115
|
+
const preview = answer.slice(0, 100);
|
|
116
|
+
text += `\n ${theme.fg("muted", preview)}`;
|
|
117
|
+
if (answer.length > 100) {
|
|
118
|
+
text += theme.fg("dim", "...");
|
|
119
|
+
}
|
|
120
|
+
text += `\n ${theme.fg("dim", `${sources.length} source(s)`)}`;
|
|
121
|
+
text += theme.fg("muted", ` [Ctrl+O to expand]`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (expanded) {
|
|
125
|
+
text += `\n\n${theme.fg("accent", "Answer:")}`;
|
|
126
|
+
text += `\n${answer}`;
|
|
127
|
+
|
|
128
|
+
if (sources.length > 0) {
|
|
129
|
+
text += `\n\n${theme.fg("accent", "Sources:")}`;
|
|
130
|
+
for (const source of sources) {
|
|
131
|
+
text += `\n• ${theme.bold(source.name)}`;
|
|
132
|
+
text += `\n ${theme.fg("dim", source.url)}`;
|
|
133
|
+
if (source.snippet) {
|
|
134
|
+
text += `\n ${theme.fg("muted", source.snippet)}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return new Text(text, 0, 0);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { getClient } from "../client";
|
|
5
|
+
|
|
6
|
+
interface WebFetchDetails {
|
|
7
|
+
url?: string;
|
|
8
|
+
markdown?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
isError?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerWebFetchTool(pi: ExtensionAPI) {
|
|
14
|
+
pi.registerTool({
|
|
15
|
+
name: "linkup_web_fetch",
|
|
16
|
+
label: "Linkup Web Fetch",
|
|
17
|
+
description:
|
|
18
|
+
"Fetch and extract content from a specific URL using Linkup API. Returns clean markdown content. Use for reading documentation, articles, or any specific webpage.",
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
url: Type.String({
|
|
21
|
+
description: "The URL to fetch content from.",
|
|
22
|
+
}),
|
|
23
|
+
renderJs: Type.Optional(
|
|
24
|
+
Type.Boolean({
|
|
25
|
+
description:
|
|
26
|
+
"Whether to render JavaScript on the page. Default: true. Set to false for faster fetching of static pages.",
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
|
|
32
|
+
const client = getClient();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
onUpdate?.({
|
|
36
|
+
content: [
|
|
37
|
+
{
|
|
38
|
+
type: "text",
|
|
39
|
+
text: `Fetching ${params.url}...`,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
details: {},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const response = await client.fetch({
|
|
46
|
+
url: params.url,
|
|
47
|
+
renderJs: params.renderJs,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text: response.markdown }],
|
|
52
|
+
details: { url: params.url, markdown: response.markdown },
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const message =
|
|
56
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
59
|
+
details: { error: message, url: params.url, isError: true },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
renderCall(args, theme) {
|
|
65
|
+
let text = theme.fg("toolTitle", theme.bold("Linkup: WebFetch "));
|
|
66
|
+
text += theme.fg("accent", args.url);
|
|
67
|
+
if (args.renderJs === false) {
|
|
68
|
+
text += theme.fg("dim", " (no JS)");
|
|
69
|
+
}
|
|
70
|
+
return new Text(text, 0, 0);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
74
|
+
if (isPartial) {
|
|
75
|
+
const text =
|
|
76
|
+
result.content?.[0]?.type === "text"
|
|
77
|
+
? result.content[0].text
|
|
78
|
+
: "Fetching...";
|
|
79
|
+
return new Text(theme.fg("dim", text), 0, 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const details = result.details as WebFetchDetails;
|
|
83
|
+
|
|
84
|
+
if (details?.isError) {
|
|
85
|
+
const errorMsg =
|
|
86
|
+
result.content?.[0]?.type === "text"
|
|
87
|
+
? result.content[0].text
|
|
88
|
+
: "Error occurred";
|
|
89
|
+
return new Text(theme.fg("error", errorMsg), 0, 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const markdown = details?.markdown || "";
|
|
93
|
+
const url = details?.url || "";
|
|
94
|
+
|
|
95
|
+
let text = theme.fg("success", "✓ Fetched");
|
|
96
|
+
text += ` ${theme.fg("dim", url)}`;
|
|
97
|
+
|
|
98
|
+
if (!expanded) {
|
|
99
|
+
const preview = markdown.slice(0, 100).replace(/\n/g, " ");
|
|
100
|
+
text += `\n ${theme.fg("muted", preview)}`;
|
|
101
|
+
if (markdown.length > 100) {
|
|
102
|
+
text += theme.fg("dim", "...");
|
|
103
|
+
}
|
|
104
|
+
text += theme.fg("muted", ` [Ctrl+O to expand]`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (expanded) {
|
|
108
|
+
const lines = markdown.split("\n");
|
|
109
|
+
const previewLines = lines.slice(0, 50);
|
|
110
|
+
text += `\n\n${previewLines.join("\n")}`;
|
|
111
|
+
if (lines.length > 50) {
|
|
112
|
+
text += `\n${theme.fg("dim", `\n[${lines.length - 50} more lines...]`)}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return new Text(text, 0, 0);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { getClient } from "../client";
|
|
5
|
+
import type { LinkupSearchResponse, LinkupSearchResult } from "../types";
|
|
6
|
+
|
|
7
|
+
interface WebSearchDetails {
|
|
8
|
+
results?: LinkupSearchResult[];
|
|
9
|
+
query?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
isError?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerWebSearchTool(pi: ExtensionAPI) {
|
|
15
|
+
pi.registerTool({
|
|
16
|
+
name: "linkup_web_search",
|
|
17
|
+
label: "Linkup Web Search",
|
|
18
|
+
description:
|
|
19
|
+
"Search the web using Linkup API. Returns a list of relevant sources with content snippets. Use for finding information, documentation, articles, or any web content.",
|
|
20
|
+
parameters: Type.Object({
|
|
21
|
+
query: Type.String({
|
|
22
|
+
description:
|
|
23
|
+
"The search query. Be specific and detailed for best results.",
|
|
24
|
+
}),
|
|
25
|
+
deep: Type.Optional(
|
|
26
|
+
Type.Boolean({
|
|
27
|
+
description:
|
|
28
|
+
"Use deep search for comprehensive results (slower). Default: false (standard search).",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
|
|
34
|
+
const client = getClient();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
onUpdate?.({
|
|
38
|
+
content: [
|
|
39
|
+
{
|
|
40
|
+
type: "text",
|
|
41
|
+
text: `Searching${params.deep ? " (deep mode)" : ""}...`,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
details: {},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const response = (await client.search({
|
|
48
|
+
query: params.query,
|
|
49
|
+
depth: params.deep ? "deep" : "standard",
|
|
50
|
+
outputType: "searchResults",
|
|
51
|
+
})) as LinkupSearchResponse;
|
|
52
|
+
|
|
53
|
+
let content = `Found ${response.results.length} result(s):\n\n`;
|
|
54
|
+
for (const result of response.results) {
|
|
55
|
+
content += `## ${result.name}\n`;
|
|
56
|
+
content += `URL: ${result.url}\n`;
|
|
57
|
+
if (result.content) {
|
|
58
|
+
content += `\n${result.content}\n`;
|
|
59
|
+
}
|
|
60
|
+
content += "\n---\n\n";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: content }],
|
|
65
|
+
details: { results: response.results, query: params.query },
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const message =
|
|
69
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
72
|
+
details: { error: message, isError: true },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
renderCall(args, theme) {
|
|
78
|
+
let text = theme.fg("toolTitle", theme.bold("Linkup: WebSearch "));
|
|
79
|
+
text += theme.fg("accent", `"${args.query}"`);
|
|
80
|
+
if (args.deep) {
|
|
81
|
+
text += theme.fg("dim", " (deep)");
|
|
82
|
+
}
|
|
83
|
+
return new Text(text, 0, 0);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
87
|
+
if (isPartial) {
|
|
88
|
+
const text =
|
|
89
|
+
result.content?.[0]?.type === "text"
|
|
90
|
+
? result.content[0].text
|
|
91
|
+
: "Searching...";
|
|
92
|
+
return new Text(theme.fg("dim", text), 0, 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const details = result.details as WebSearchDetails;
|
|
96
|
+
|
|
97
|
+
if (details?.isError) {
|
|
98
|
+
const errorMsg =
|
|
99
|
+
result.content?.[0]?.type === "text"
|
|
100
|
+
? result.content[0].text
|
|
101
|
+
: "Error occurred";
|
|
102
|
+
return new Text(theme.fg("error", errorMsg), 0, 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const results = details?.results || [];
|
|
106
|
+
let text = theme.fg("success", `✓ Found ${results.length} result(s)`);
|
|
107
|
+
|
|
108
|
+
if (!expanded && results.length > 0) {
|
|
109
|
+
const first = results[0];
|
|
110
|
+
text += `\n ${theme.fg("dim", `${first.name}`)}`;
|
|
111
|
+
if (results.length > 1) {
|
|
112
|
+
text += theme.fg("dim", ` (${results.length - 1} more)`);
|
|
113
|
+
}
|
|
114
|
+
text += theme.fg("muted", ` [Ctrl+O to expand]`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (expanded) {
|
|
118
|
+
for (const r of results) {
|
|
119
|
+
text += `\n\n${theme.fg("accent", theme.bold(r.name))}`;
|
|
120
|
+
text += `\n${theme.fg("dim", r.url)}`;
|
|
121
|
+
if (r.content) {
|
|
122
|
+
const preview = r.content.slice(0, 200);
|
|
123
|
+
text += `\n${theme.fg("muted", preview)}`;
|
|
124
|
+
if (r.content.length > 200) {
|
|
125
|
+
text += theme.fg("dim", "...");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return new Text(text, 0, 0);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface LinkupSearchResult {
|
|
2
|
+
name: string;
|
|
3
|
+
url: string;
|
|
4
|
+
content?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface LinkupSearchResponse {
|
|
8
|
+
results: LinkupSearchResult[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LinkupSource {
|
|
12
|
+
name: string;
|
|
13
|
+
url: string;
|
|
14
|
+
snippet?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LinkupSourcedAnswerResponse {
|
|
18
|
+
answer: string;
|
|
19
|
+
sources: LinkupSource[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LinkupFetchResponse {
|
|
23
|
+
markdown: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LinkupBalanceResponse {
|
|
27
|
+
balance: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LinkupErrorResponse {
|
|
31
|
+
error?: {
|
|
32
|
+
message?: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"noEmit": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules"]
|
|
15
|
+
}
|