pi-amplike 0.1.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/LICENSE +21 -0
- package/README.md +112 -0
- package/extensions/handoff.ts +151 -0
- package/extensions/session-query.ts +180 -0
- package/package.json +52 -0
- package/skills/session-query/SKILL.md +32 -0
- package/skills/visit-webpage/SKILL.md +62 -0
- package/skills/visit-webpage/visit.py +165 -0
- package/skills/web-search/SKILL.md +60 -0
- package/skills/web-search/search.py +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pasky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# pi-amplike
|
|
2
|
+
|
|
3
|
+
[Pi](https://github.com/badlogic/pi-mono) skills and extensions that give Pi similar capabilities to [Amp Code](https://ampcode.com/) out of the box.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
### Session Management
|
|
8
|
+
- **`/handoff <goal>`** - Create a new focused session based on the current one with context compacted based on a given goal
|
|
9
|
+
- **`session_query`** tool - The agent in the handed off session automatically gets the ability to query the parent session for context, decisions, or code changes
|
|
10
|
+
- Use `/resume` to switch between and navigate handed-off sessions
|
|
11
|
+
|
|
12
|
+
### Web Access
|
|
13
|
+
- **web-search** - Search the web via Jina Search API
|
|
14
|
+
- **visit-webpage** - Extract webpage content as markdown (using Jina API), or download images
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
### Option A: Install from npm (recommended)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
mkdir -p ~/.pi/packages
|
|
22
|
+
cd ~/.pi/packages
|
|
23
|
+
npm install pi-amplike
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This creates `~/.pi/packages/node_modules/pi-amplike`. Pi will pick it up as a package automatically.
|
|
27
|
+
|
|
28
|
+
### Option B: Install from git
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/pasky/pi-amplike ~/.pi/packages/pi-amplike
|
|
32
|
+
cd ~/.pi/packages/pi-amplike
|
|
33
|
+
npm install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
Get a Jina API key for web skills (optional, works with rate limits without it):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
export JINA_API_KEY="your-key" # Add to ~/.profile or ~/.zprofile
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Get an API key at [jina.ai](https://jina.ai/). Even if you charge only the minimum credit, it's going to last approximately forever.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
### Session Handoff
|
|
49
|
+
|
|
50
|
+
When your conversation gets long or you want to branch off to a focused task:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
/handoff now implement this for teams as well
|
|
54
|
+
/handoff execute phase one of the plan
|
|
55
|
+
/handoff check other places that need this fix
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This creates a new session with:
|
|
59
|
+
- Summarized context from the current conversation
|
|
60
|
+
- List of relevant files
|
|
61
|
+
- Clear task description based on your goal
|
|
62
|
+
- Reference to parent session (for later querying)
|
|
63
|
+
|
|
64
|
+
### Session Navigation
|
|
65
|
+
|
|
66
|
+
Use Pi's built-in `/resume` command to switch between sessions, including handed-off sessions. The handoff creates sessions with descriptive names that make them easy to find.
|
|
67
|
+
|
|
68
|
+
### Querying Past Sessions
|
|
69
|
+
|
|
70
|
+
The `session_query` tool lets the model look up information from previous sessions. It's automatically used when a handoff includes parent session reference, but can also be invoked directly:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
session_query("/path/to/session.jsonl", "What files were modified?")
|
|
74
|
+
session_query("/path/to/session.jsonl", "What approach was chosen?")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Web Search
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
~/.pi/packages/pi-amplike/skills/web-search/search.py "python async tutorial"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Visit Webpage
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
~/.pi/packages/pi-amplike/skills/visit-webpage/visit.py https://docs.example.com/api
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Components
|
|
90
|
+
|
|
91
|
+
| Component | Type | Description |
|
|
92
|
+
|-----------|------|-------------|
|
|
93
|
+
| [handoff](extensions/handoff.ts) | Extension | `/handoff` command for context transfer |
|
|
94
|
+
| [session-query](extensions/session-query.ts) | Extension | `session_query` tool for the model |
|
|
95
|
+
| [session-query](skills/session-query/) | Skill | Instructions for using the session_query tool |
|
|
96
|
+
| [web-search](skills/web-search/) | Skill | Web search via Jina API |
|
|
97
|
+
| [visit-webpage](skills/visit-webpage/) | Skill | Webpage content extraction |
|
|
98
|
+
|
|
99
|
+
## Why "AmpCode-like"?
|
|
100
|
+
|
|
101
|
+
Amp Code has excellent session management built-in - you can branch conversations, reference parent context, and navigate session history. This package brings similar workflows to Pi:
|
|
102
|
+
|
|
103
|
+
- **Context handoff** → Amp's conversation branching
|
|
104
|
+
- **Session querying** → Amp's ability to reference parent context
|
|
105
|
+
|
|
106
|
+
## Web Skills Origin
|
|
107
|
+
|
|
108
|
+
The web-search and visit-webpage skills were extracted from [pasky/muaddib](https://github.com/pasky/muaddib). The original implementations have additional features (rate limiting, multiple backends, async execution) that aren't needed for Pi's skill system.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff extension - transfer context to a new focused session
|
|
3
|
+
*
|
|
4
|
+
* Instead of compacting (which is lossy), handoff extracts what matters
|
|
5
|
+
* for your next task and creates a new session with a generated prompt.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* /handoff now implement this for teams as well
|
|
9
|
+
* /handoff execute phase one of the plan
|
|
10
|
+
* /handoff check other places that need this fix
|
|
11
|
+
*
|
|
12
|
+
* The generated prompt appears as a draft in the editor for review/editing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { complete, type Message } from "@mariozechner/pi-ai";
|
|
16
|
+
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
|
|
20
|
+
|
|
21
|
+
1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
|
|
22
|
+
2. Lists any relevant files that were discussed or modified
|
|
23
|
+
3. Clearly states the next task based on the user's goal
|
|
24
|
+
4. Is self-contained - the new thread should be able to proceed without the old conversation
|
|
25
|
+
|
|
26
|
+
Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
|
|
27
|
+
|
|
28
|
+
Example output format:
|
|
29
|
+
## Context
|
|
30
|
+
We've been working on X. Key decisions:
|
|
31
|
+
- Decision 1
|
|
32
|
+
- Decision 2
|
|
33
|
+
|
|
34
|
+
Files involved:
|
|
35
|
+
- path/to/file1.ts
|
|
36
|
+
- path/to/file2.ts
|
|
37
|
+
|
|
38
|
+
## Task
|
|
39
|
+
[Clear description of what to do next based on user's goal]`;
|
|
40
|
+
|
|
41
|
+
export default function (pi: ExtensionAPI) {
|
|
42
|
+
pi.registerCommand("handoff", {
|
|
43
|
+
description: "Transfer context to a new focused session",
|
|
44
|
+
handler: async (args, ctx) => {
|
|
45
|
+
if (!ctx.hasUI) {
|
|
46
|
+
ctx.ui.notify("handoff requires interactive mode", "error");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!ctx.model) {
|
|
51
|
+
ctx.ui.notify("No model selected", "error");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const goal = args.trim();
|
|
56
|
+
if (!goal) {
|
|
57
|
+
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Gather conversation context from current branch
|
|
62
|
+
const branch = ctx.sessionManager.getBranch();
|
|
63
|
+
const messages = branch
|
|
64
|
+
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
65
|
+
.map((entry) => entry.message);
|
|
66
|
+
|
|
67
|
+
if (messages.length === 0) {
|
|
68
|
+
ctx.ui.notify("No conversation to hand off", "error");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Convert to LLM format and serialize
|
|
73
|
+
const llmMessages = convertToLlm(messages);
|
|
74
|
+
const conversationText = serializeConversation(llmMessages);
|
|
75
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
76
|
+
|
|
77
|
+
// Generate the handoff prompt with loader UI
|
|
78
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
79
|
+
const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
|
|
80
|
+
loader.onAbort = () => done(null);
|
|
81
|
+
|
|
82
|
+
const doGenerate = async () => {
|
|
83
|
+
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
|
84
|
+
|
|
85
|
+
const userMessage: Message = {
|
|
86
|
+
role: "user",
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
89
|
+
type: "text",
|
|
90
|
+
text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const response = await complete(
|
|
97
|
+
ctx.model!,
|
|
98
|
+
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
|
99
|
+
{ apiKey, signal: loader.signal },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (response.stopReason === "aborted") {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return response.content
|
|
107
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
108
|
+
.map((c) => c.text)
|
|
109
|
+
.join("\n");
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
doGenerate()
|
|
113
|
+
.then(done)
|
|
114
|
+
.catch((err) => {
|
|
115
|
+
console.error("Handoff generation failed:", err);
|
|
116
|
+
done(null);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return loader;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (result === null) {
|
|
123
|
+
ctx.ui.notify("Cancelled", "info");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Create new session with parent tracking
|
|
128
|
+
const newSessionResult = await ctx.newSession({
|
|
129
|
+
parentSession: currentSessionFile,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (newSessionResult.cancelled) {
|
|
133
|
+
ctx.ui.notify("New session cancelled", "info");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Build the final prompt with user's goal first for easy identification
|
|
138
|
+
// Format: goal (first line for session preview) → skill → parent ref → context
|
|
139
|
+
let finalPrompt = result;
|
|
140
|
+
if (currentSessionFile) {
|
|
141
|
+
finalPrompt = `${goal}\n\n/skill:session-query\n\n**Parent session:** \`${currentSessionFile}\`\n\n${result}`;
|
|
142
|
+
} else {
|
|
143
|
+
// Even without parent session, put goal first
|
|
144
|
+
finalPrompt = `${goal}\n\n${result}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Immediately submit the handoff prompt to start the agent
|
|
148
|
+
pi.sendUserMessage(finalPrompt);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Query Extension - Query previous pi sessions
|
|
3
|
+
*
|
|
4
|
+
* Provides a tool the model can use to query past sessions for context,
|
|
5
|
+
* decisions, code changes, or other information.
|
|
6
|
+
*
|
|
7
|
+
* Works with handoff: when a handoff prompt includes "Parent session: <path>",
|
|
8
|
+
* the model can use this tool to look up details from that session.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { complete, type Message } from "@mariozechner/pi-ai";
|
|
12
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
SessionManager,
|
|
15
|
+
convertToLlm,
|
|
16
|
+
getMarkdownTheme,
|
|
17
|
+
serializeConversation,
|
|
18
|
+
type SessionEntry,
|
|
19
|
+
} from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
|
21
|
+
import { Type } from "@sinclair/typebox";
|
|
22
|
+
|
|
23
|
+
const QUERY_SYSTEM_PROMPT = `You are a session context assistant. Given the conversation history from a pi coding session and a question, provide a concise answer based on the session contents.
|
|
24
|
+
|
|
25
|
+
Focus on:
|
|
26
|
+
- Specific facts, decisions, and outcomes
|
|
27
|
+
- File paths and code changes mentioned
|
|
28
|
+
- Key context the user is asking about
|
|
29
|
+
|
|
30
|
+
Be concise and direct. If the information isn't in the session, say so.`;
|
|
31
|
+
|
|
32
|
+
export default function (pi: ExtensionAPI) {
|
|
33
|
+
pi.registerTool({
|
|
34
|
+
name: "session_query",
|
|
35
|
+
label: (params) => `Session Query: ${params.question}`,
|
|
36
|
+
description:
|
|
37
|
+
"Query a previous pi session file for context, decisions, or information. Use when you need to look up what happened in a parent session or any other session.",
|
|
38
|
+
renderResult: (result, _options, theme) => {
|
|
39
|
+
const container = new Container();
|
|
40
|
+
|
|
41
|
+
if (result.content && result.content[0]?.text) {
|
|
42
|
+
const text = result.content[0].text;
|
|
43
|
+
// Parse: **Query:** question\n\n---\n\nanswer
|
|
44
|
+
const match = text.match(/\*\*Query:\*\* (.+?)\n\n---\n\n([\s\S]+)/);
|
|
45
|
+
|
|
46
|
+
if (match) {
|
|
47
|
+
const [, query, answer] = match;
|
|
48
|
+
container.addChild(new Text(theme.bold("Query: ") + theme.fg("accent", query), 0, 0));
|
|
49
|
+
container.addChild(new Spacer(1));
|
|
50
|
+
// Render the answer as markdown
|
|
51
|
+
container.addChild(new Markdown(answer.trim(), 0, 0, getMarkdownTheme(), {
|
|
52
|
+
color: (text: string) => theme.fg("toolOutput", text),
|
|
53
|
+
}));
|
|
54
|
+
} else {
|
|
55
|
+
// Fallback for other formats (errors, etc)
|
|
56
|
+
container.addChild(new Text(theme.fg("toolOutput", text), 0, 0));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return container;
|
|
61
|
+
},
|
|
62
|
+
parameters: Type.Object({
|
|
63
|
+
sessionPath: Type.String({
|
|
64
|
+
description: "Full path to the session file (e.g., /home/user/.pi/agent/sessions/.../session.jsonl)",
|
|
65
|
+
}),
|
|
66
|
+
question: Type.String({
|
|
67
|
+
description: "What you want to know about that session (e.g., 'What files were modified?' or 'What approach was chosen?')",
|
|
68
|
+
}),
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
|
72
|
+
const { sessionPath, question } = params;
|
|
73
|
+
|
|
74
|
+
// Helper for error returns
|
|
75
|
+
const errorResult = (text: string) => ({
|
|
76
|
+
content: [{ type: "text" as const, text }],
|
|
77
|
+
details: { error: true },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Validate session path
|
|
81
|
+
if (!sessionPath.endsWith(".jsonl")) {
|
|
82
|
+
return errorResult(`Error: Invalid session path. Expected a .jsonl file, got: ${sessionPath}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check if file exists
|
|
86
|
+
try {
|
|
87
|
+
const fs = await import("node:fs");
|
|
88
|
+
if (!fs.existsSync(sessionPath)) {
|
|
89
|
+
return errorResult(`Error: Session file not found: ${sessionPath}`);
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return errorResult(`Error checking session file: ${err}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
onUpdate?.({
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text",
|
|
99
|
+
text: `Query: ${question}`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
details: { status: "loading", question },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Load the session
|
|
106
|
+
let sessionManager: SessionManager;
|
|
107
|
+
try {
|
|
108
|
+
sessionManager = SessionManager.open(sessionPath);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return errorResult(`Error loading session: ${err}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Get conversation from the session
|
|
114
|
+
const branch = sessionManager.getBranch();
|
|
115
|
+
const messages = branch
|
|
116
|
+
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
117
|
+
.map((entry) => entry.message);
|
|
118
|
+
|
|
119
|
+
if (messages.length === 0) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text" as const, text: "Session is empty - no messages found." }],
|
|
122
|
+
details: { empty: true },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Serialize the conversation
|
|
127
|
+
const llmMessages = convertToLlm(messages);
|
|
128
|
+
const conversationText = serializeConversation(llmMessages);
|
|
129
|
+
|
|
130
|
+
// Use LLM to answer the question
|
|
131
|
+
if (!ctx.model) {
|
|
132
|
+
return errorResult("Error: No model available to analyze the session.");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
|
|
137
|
+
|
|
138
|
+
const userMessage: Message = {
|
|
139
|
+
role: "user",
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `## Session Conversation\n\n${conversationText}\n\n## Question\n\n${question}`,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
timestamp: Date.now(),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const response = await complete(
|
|
150
|
+
ctx.model,
|
|
151
|
+
{ systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] },
|
|
152
|
+
{ apiKey, signal },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (response.stopReason === "aborted") {
|
|
156
|
+
return {
|
|
157
|
+
content: [{ type: "text" as const, text: "Query was cancelled." }],
|
|
158
|
+
details: { cancelled: true },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const answer = response.content
|
|
163
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
164
|
+
.map((c) => c.text)
|
|
165
|
+
.join("\n");
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text" as const, text: `**Query:** ${question}\n\n---\n\n${answer}` }],
|
|
169
|
+
details: {
|
|
170
|
+
sessionPath,
|
|
171
|
+
question,
|
|
172
|
+
messageCount: messages.length,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return errorResult(`Error querying session: ${err}`);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-amplike",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi skills and extensions that provide Amp Code-like workflows (handoff, session query, web search, visit webpage).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/pasky/pi-amplike",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/pasky/pi-amplike.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/pasky/pi-amplike/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pi",
|
|
16
|
+
"pi-coding-agent",
|
|
17
|
+
"agent",
|
|
18
|
+
"skills",
|
|
19
|
+
"extensions",
|
|
20
|
+
"amp",
|
|
21
|
+
"session",
|
|
22
|
+
"handoff",
|
|
23
|
+
"web-search"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"extensions/",
|
|
27
|
+
"skills/",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"skills": [
|
|
33
|
+
"./skills/visit-webpage",
|
|
34
|
+
"./skills/web-search",
|
|
35
|
+
"./skills/session-query"
|
|
36
|
+
],
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./extensions"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^25.1.0",
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@mariozechner/pi-coding-agent": "^0.50.5",
|
|
50
|
+
"@mariozechner/pi-tui": "^0.50.5"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: session-query
|
|
3
|
+
description: Query previous pi sessions to retrieve context, decisions, code changes, or other information. Use when you need to look up what happened in a parent session or any other session file.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Session Query
|
|
8
|
+
|
|
9
|
+
Query pi session files to retrieve context from past conversations.
|
|
10
|
+
|
|
11
|
+
This skill is automatically invoked in handed-off sessions when you need to look up details from the parent session.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Use the `session_query` tool:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
session_query(sessionPath, question)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- `sessionPath`: Full path to the session file (provided in the "Parent session:" line)
|
|
22
|
+
- `question`: Specific question about that session (e.g., "What files were modified?" or "What approach was chosen?")
|
|
23
|
+
|
|
24
|
+
## Examples
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
session_query("/path/to/session.jsonl", "What files were modified?")
|
|
28
|
+
session_query("/path/to/session.jsonl", "What approach was chosen for authentication?")
|
|
29
|
+
session_query("/path/to/session.jsonl", "Summarize the key decisions made")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The tool loads the session and uses an LLM to answer your question based on its contents. Ask specific questions for best results.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: visit-webpage
|
|
3
|
+
description: Visit a webpage and extract its content as markdown, or fetch images. Use for reading articles, documentation, or any web page content. Handles both HTML pages (via Jina Reader) and image URLs (downloads and saves locally).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Visit Webpage
|
|
7
|
+
|
|
8
|
+
Fetch and extract readable content from web pages as markdown, or download images. Handles JavaScript-rendered content via Jina Reader service.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
Optionally get a Jina API key for higher rate limits:
|
|
13
|
+
1. Create an account at https://jina.ai/
|
|
14
|
+
2. Get your API key from the dashboard
|
|
15
|
+
3. Add to your shell profile (`~/.profile` or `~/.zprofile` for zsh):
|
|
16
|
+
```bash
|
|
17
|
+
export JINA_API_KEY="your-api-key-here"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Without an API key, the service works with rate limits.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
{baseDir}/visit.py <url>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Examples
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Read an article (returns markdown)
|
|
32
|
+
{baseDir}/visit.py https://example.com/article
|
|
33
|
+
|
|
34
|
+
# Fetch documentation
|
|
35
|
+
{baseDir}/visit.py https://docs.python.org/3/library/asyncio.html
|
|
36
|
+
|
|
37
|
+
# Download an image (auto-detected by content-type)
|
|
38
|
+
{baseDir}/visit.py https://example.com/image.png
|
|
39
|
+
# Then use read tool to view: read /tmp/visit-image-xxx.png
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Output
|
|
43
|
+
|
|
44
|
+
For **HTML pages**: Returns markdown content to stdout.
|
|
45
|
+
|
|
46
|
+
For **images**: Downloads the image to a temp file and prints the path. Use the `read` tool to view it. Supports PNG, JPEG, GIF, and WebP formats.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- Extracts main content from HTML pages
|
|
51
|
+
- Converts HTML to clean markdown
|
|
52
|
+
- Handles JavaScript-rendered pages via Jina Reader
|
|
53
|
+
- Auto-detects and downloads images to temp files
|
|
54
|
+
- Retries on rate limiting (HTTP 451)
|
|
55
|
+
- 5MB max image size limit
|
|
56
|
+
|
|
57
|
+
## When to Use
|
|
58
|
+
|
|
59
|
+
- Reading articles, blog posts, or documentation
|
|
60
|
+
- Extracting content from search results
|
|
61
|
+
- Downloading images from URLs (then use `read` to view)
|
|
62
|
+
- Following links found during web search
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Visit webpage and extract content as markdown, or download images."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
import urllib.request
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
11
|
+
|
|
12
|
+
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
|
|
13
|
+
MAX_CONTENT_LENGTH = 100000 # 100KB text limit
|
|
14
|
+
TIMEOUT = 60
|
|
15
|
+
RETRY_DELAYS = [0, 30, 90]
|
|
16
|
+
|
|
17
|
+
IMAGE_EXTENSIONS = {
|
|
18
|
+
"image/png": ".png",
|
|
19
|
+
"image/jpeg": ".jpg",
|
|
20
|
+
"image/gif": ".gif",
|
|
21
|
+
"image/webp": ".webp",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_headers(include_jina_auth: bool = False) -> dict[str, str]:
|
|
26
|
+
"""Build request headers."""
|
|
27
|
+
headers = {"User-Agent": "pi-skill/1.0"}
|
|
28
|
+
api_key = os.environ.get("JINA_API_KEY")
|
|
29
|
+
if include_jina_auth and api_key:
|
|
30
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
31
|
+
return headers
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_content_type(url: str) -> str | None:
|
|
35
|
+
"""Check URL content type via HEAD request. Returns content-type or None on error."""
|
|
36
|
+
req = urllib.request.Request(url, method="HEAD", headers=get_headers())
|
|
37
|
+
try:
|
|
38
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as response:
|
|
39
|
+
return response.headers.get("Content-Type", "").lower().split(";")[0]
|
|
40
|
+
except (HTTPError, URLError):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def download_image(url: str) -> str:
|
|
45
|
+
"""Download image and save to temp file. Returns file path."""
|
|
46
|
+
req = urllib.request.Request(url, headers=get_headers())
|
|
47
|
+
|
|
48
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as response:
|
|
49
|
+
content_type = response.headers.get("Content-Type", "").lower().split(";")[0]
|
|
50
|
+
|
|
51
|
+
if content_type not in IMAGE_EXTENSIONS:
|
|
52
|
+
raise ValueError(f"Unsupported image type: {content_type}")
|
|
53
|
+
|
|
54
|
+
content_length = response.headers.get("Content-Length")
|
|
55
|
+
if content_length and int(content_length) > MAX_IMAGE_SIZE:
|
|
56
|
+
raise ValueError(f"Image too large: {content_length} bytes (max {MAX_IMAGE_SIZE})")
|
|
57
|
+
|
|
58
|
+
data = response.read()
|
|
59
|
+
if len(data) > MAX_IMAGE_SIZE:
|
|
60
|
+
raise ValueError(f"Image too large: {len(data)} bytes (max {MAX_IMAGE_SIZE})")
|
|
61
|
+
|
|
62
|
+
ext = IMAGE_EXTENSIONS[content_type]
|
|
63
|
+
|
|
64
|
+
# Create temp file with appropriate extension
|
|
65
|
+
fd, filepath = tempfile.mkstemp(suffix=ext, prefix="visit-image-")
|
|
66
|
+
os.write(fd, data)
|
|
67
|
+
os.close(fd)
|
|
68
|
+
|
|
69
|
+
return filepath
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fetch_webpage(url: str) -> str:
|
|
73
|
+
"""Fetch webpage content via Jina Reader with retries."""
|
|
74
|
+
jina_url = f"https://r.jina.ai/{url}"
|
|
75
|
+
headers = get_headers(include_jina_auth=True)
|
|
76
|
+
|
|
77
|
+
last_error = None
|
|
78
|
+
for i, delay in enumerate(RETRY_DELAYS):
|
|
79
|
+
if delay > 0:
|
|
80
|
+
print(f"Waiting {delay}s before retry {i+1}/{len(RETRY_DELAYS)}...", file=sys.stderr)
|
|
81
|
+
time.sleep(delay)
|
|
82
|
+
|
|
83
|
+
req = urllib.request.Request(jina_url, headers=headers)
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as response:
|
|
86
|
+
content = response.read().decode("utf-8", errors="replace")
|
|
87
|
+
|
|
88
|
+
# Clean up multiple line breaks
|
|
89
|
+
content = re.sub(r"\n{3,}", "\n\n", content)
|
|
90
|
+
|
|
91
|
+
# Truncate if too long
|
|
92
|
+
if len(content) > MAX_CONTENT_LENGTH:
|
|
93
|
+
content = content[:MAX_CONTENT_LENGTH] + "\n\n..._Content truncated_..."
|
|
94
|
+
|
|
95
|
+
return content
|
|
96
|
+
|
|
97
|
+
except HTTPError as e:
|
|
98
|
+
last_error = e
|
|
99
|
+
if e.code in (451, 500, 502, 503, 504) and i < len(RETRY_DELAYS) - 1:
|
|
100
|
+
print(f"HTTP {e.code}, will retry...", file=sys.stderr)
|
|
101
|
+
continue
|
|
102
|
+
raise
|
|
103
|
+
except URLError as e:
|
|
104
|
+
last_error = e
|
|
105
|
+
if i < len(RETRY_DELAYS) - 1:
|
|
106
|
+
print(f"Network error: {e.reason}, will retry...", file=sys.stderr)
|
|
107
|
+
continue
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
raise last_error or RuntimeError("Failed after retries")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main():
|
|
114
|
+
if len(sys.argv) < 2:
|
|
115
|
+
print("Usage: visit.py <url>")
|
|
116
|
+
print()
|
|
117
|
+
print("Fetches a webpage and extracts its content as markdown,")
|
|
118
|
+
print("or downloads images to a temp file.")
|
|
119
|
+
print()
|
|
120
|
+
print("Environment:")
|
|
121
|
+
print(" JINA_API_KEY Optional. Your Jina API key for higher rate limits.")
|
|
122
|
+
print()
|
|
123
|
+
print("Examples:")
|
|
124
|
+
print(" visit.py https://example.com/article")
|
|
125
|
+
print(" visit.py https://example.com/image.png")
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
|
|
128
|
+
url = sys.argv[1]
|
|
129
|
+
|
|
130
|
+
# Validate URL
|
|
131
|
+
if not url.startswith(("http://", "https://")):
|
|
132
|
+
print("Error: URL must start with http:// or https://", file=sys.stderr)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
# Check content type first
|
|
137
|
+
content_type = check_content_type(url)
|
|
138
|
+
|
|
139
|
+
if content_type and content_type.startswith("image/"):
|
|
140
|
+
# Handle image - download to temp and print path (like browser-screenshot.js)
|
|
141
|
+
filepath = download_image(url)
|
|
142
|
+
print(filepath)
|
|
143
|
+
else:
|
|
144
|
+
# Handle webpage
|
|
145
|
+
content = fetch_webpage(url)
|
|
146
|
+
print(f"## Content from {url}")
|
|
147
|
+
print()
|
|
148
|
+
print(content)
|
|
149
|
+
|
|
150
|
+
except HTTPError as e:
|
|
151
|
+
print(f"Error: HTTP {e.code} - {e.reason}", file=sys.stderr)
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
except URLError as e:
|
|
154
|
+
print(f"Error: {e.reason}", file=sys.stderr)
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
except ValueError as e:
|
|
157
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-search
|
|
3
|
+
description: Web search using Jina Search API. Returns search results with titles, URLs, and descriptions. Use for finding documentation, facts, current information, or any web content. Lightweight, no browser required.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Web Search
|
|
7
|
+
|
|
8
|
+
Perform web searches using the Jina Search API. Returns formatted search results with titles, URLs, and descriptions.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
Optionally get a Jina API key for higher rate limits:
|
|
13
|
+
1. Create an account at https://jina.ai/
|
|
14
|
+
2. Get your API key from the dashboard
|
|
15
|
+
3. Add to your shell profile (`~/.profile` or `~/.zprofile` for zsh):
|
|
16
|
+
```bash
|
|
17
|
+
export JINA_API_KEY="your-api-key-here"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Without an API key, the service works with rate limits.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
{baseDir}/search.py "your search query"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Examples
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Basic search
|
|
32
|
+
{baseDir}/search.py "python async await tutorial"
|
|
33
|
+
|
|
34
|
+
# Search for recent news
|
|
35
|
+
{baseDir}/search.py "latest AI developments 2024"
|
|
36
|
+
|
|
37
|
+
# Find documentation
|
|
38
|
+
{baseDir}/search.py "nodejs fs promises API"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Output Format
|
|
42
|
+
|
|
43
|
+
Returns markdown-formatted search results:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
## Search Results
|
|
47
|
+
|
|
48
|
+
[Title of first result](https://example.com/page1)
|
|
49
|
+
Description or snippet from the search result...
|
|
50
|
+
|
|
51
|
+
[Title of second result](https://example.com/page2)
|
|
52
|
+
Description or snippet from the search result...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## When to Use
|
|
56
|
+
|
|
57
|
+
- Searching for documentation or API references
|
|
58
|
+
- Looking up facts or current information
|
|
59
|
+
- Finding relevant web pages for research
|
|
60
|
+
- Any task requiring web search without interactive browsing
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Web search using Jina Search API."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
|
|
10
|
+
TIMEOUT = 30
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
if len(sys.argv) < 2:
|
|
15
|
+
print("Usage: search.py <query>")
|
|
16
|
+
print()
|
|
17
|
+
print("Searches the web using Jina Search API.")
|
|
18
|
+
print()
|
|
19
|
+
print("Environment:")
|
|
20
|
+
print(" JINA_API_KEY Optional. Your Jina API key for higher rate limits.")
|
|
21
|
+
print()
|
|
22
|
+
print("Examples:")
|
|
23
|
+
print(' search.py "python async await"')
|
|
24
|
+
print(' search.py "rust ownership tutorial"')
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
query = " ".join(sys.argv[1:])
|
|
28
|
+
encoded_query = urllib.parse.quote(query)
|
|
29
|
+
url = f"https://s.jina.ai/?q={encoded_query}"
|
|
30
|
+
|
|
31
|
+
# Build headers
|
|
32
|
+
headers = {
|
|
33
|
+
"User-Agent": "pi-skill/1.0",
|
|
34
|
+
"X-Respond-With": "no-content",
|
|
35
|
+
"Accept": "text/plain",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
api_key = os.environ.get("JINA_API_KEY")
|
|
39
|
+
if api_key:
|
|
40
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
41
|
+
|
|
42
|
+
req = urllib.request.Request(url, headers=headers)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as response:
|
|
46
|
+
content = response.read().decode("utf-8", errors="replace").strip()
|
|
47
|
+
|
|
48
|
+
if not content:
|
|
49
|
+
print("No search results found. Try a different query.")
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
|
|
52
|
+
print("## Search Results")
|
|
53
|
+
print()
|
|
54
|
+
print(content)
|
|
55
|
+
|
|
56
|
+
except HTTPError as e:
|
|
57
|
+
print(f"Error: HTTP {e.code} - {e.reason}", file=sys.stderr)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
except URLError as e:
|
|
60
|
+
print(f"Error: {e.reason}", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|