@herbertgao/pi-extensions 2026.8.13 → 2026.8.14
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 +8 -6
- package/node_modules/@czottmann/pi-automode/CHANGELOG.md +12 -0
- package/node_modules/@czottmann/pi-automode/README.md +1 -1
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +55 -4
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +7 -3
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +2 -0
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +134 -23
- package/node_modules/@czottmann/pi-automode/package.json +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +8 -1
- package/node_modules/@tifan/pi-preferred-thinking/README.md +1 -1
- package/node_modules/@tifan/pi-preferred-thinking/package.json +1 -1
- package/node_modules/@tifan/pi-preferred-thinking/src/index.ts +15 -2
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +20 -0
- package/node_modules/pi-mcp-adapter/README.md +3 -0
- package/node_modules/pi-mcp-adapter/cli.js +4 -4
- package/node_modules/pi-mcp-adapter/config.ts +1 -0
- package/node_modules/pi-mcp-adapter/dist/config.js +1 -0
- package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.d.ts +24 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js +336 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js.map +1 -0
- package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
- package/node_modules/pi-mcp-adapter/index.ts +90 -6
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +19 -0
- package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +0 -2
- package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +89 -0
- package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -1
- package/node_modules/pi-mcp-adapter/package.json +1 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +40 -10
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +1 -1
- package/node_modules/pi-mcp-adapter/types.ts +2 -0
- package/node_modules/pi-web-access/CHANGELOG.md +22 -0
- package/node_modules/pi-web-access/README.md +11 -8
- package/node_modules/pi-web-access/curator-page.ts +12 -3
- package/node_modules/pi-web-access/curator-server.ts +3 -1
- package/node_modules/pi-web-access/extract.ts +31 -1
- package/node_modules/pi-web-access/gemini-search.ts +10 -5
- package/node_modules/pi-web-access/index.ts +39 -39
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/xcrawl.ts +264 -0
- package/package.json +7 -10
- package/node_modules/@herbertgao/pi-stash/LICENSE +0 -22
- package/node_modules/@herbertgao/pi-stash/README.md +0 -34
- package/node_modules/@herbertgao/pi-stash/package.json +0 -51
- package/node_modules/@herbertgao/pi-stash/src/index.ts +0 -118
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { activityMonitor } from "./activity.ts";
|
|
3
|
+
import type { SearchOptions, SearchResponse } from "./perplexity.ts";
|
|
4
|
+
import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.ts";
|
|
5
|
+
import { getWebSearchConfigPath } from "./utils.ts";
|
|
6
|
+
|
|
7
|
+
const XCRAWL_API_URL = "https://run.xcrawl.com/v1/serp";
|
|
8
|
+
const CONFIG_PATH = getWebSearchConfigPath();
|
|
9
|
+
// The SERP API is usually fast (a few seconds, cached responses are quicker),
|
|
10
|
+
// but leave generous headroom before treating a slow job as a provider failure.
|
|
11
|
+
const SEARCH_TIMEOUT_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
interface WebSearchConfig {
|
|
14
|
+
xcrawlApiKey?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface XCrawlSerpResult {
|
|
18
|
+
title?: unknown;
|
|
19
|
+
link?: unknown;
|
|
20
|
+
snippet?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface XCrawlSerpResponse {
|
|
24
|
+
search_metadata?: unknown;
|
|
25
|
+
organic_results?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let cachedConfig: WebSearchConfig | null = null;
|
|
29
|
+
|
|
30
|
+
function loadConfig(): WebSearchConfig {
|
|
31
|
+
if (cachedConfig) return cachedConfig;
|
|
32
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
33
|
+
cachedConfig = {};
|
|
34
|
+
return cachedConfig;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
38
|
+
let parsed: unknown;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(raw);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
|
|
44
|
+
}
|
|
45
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
46
|
+
throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
|
|
47
|
+
}
|
|
48
|
+
cachedConfig = parsed as WebSearchConfig;
|
|
49
|
+
return cachedConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getApiKey(signal?: AbortSignal): Promise<string | null> {
|
|
53
|
+
return resolveCredential({
|
|
54
|
+
provider: "XCrawl",
|
|
55
|
+
configuredValue: loadConfig().xcrawlApiKey,
|
|
56
|
+
environmentValue: process.env.XCRAWL_API_KEY,
|
|
57
|
+
signal,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isXcrawlAvailable(): boolean {
|
|
62
|
+
return hasCredentialSource({ provider: "XCrawl", configuredValue: loadConfig().xcrawlApiKey, environmentValue: process.env.XCRAWL_API_KEY });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function errorMessage(err: unknown): string {
|
|
66
|
+
return err instanceof Error ? err.message : String(err);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function invalidResponse(message: string): Error {
|
|
70
|
+
return new Error(`XCrawl API returned invalid response: ${message}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeCount(value: number | undefined): number {
|
|
74
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 5;
|
|
75
|
+
return Math.max(1, Math.min(Math.floor(value), 20));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hostnameOf(url: string): string {
|
|
79
|
+
try {
|
|
80
|
+
return new URL(url).hostname.toLowerCase();
|
|
81
|
+
} catch {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// XCrawl's Google SERP results can carry links relative to the API origin
|
|
87
|
+
// (e.g. "/goto?url=..." redirect stubs) instead of the documented absolute
|
|
88
|
+
// URLs. Resolve those against the API origin so callers always receive a
|
|
89
|
+
// well-formed absolute URL; absolute http(s) links pass through unchanged.
|
|
90
|
+
function absolutizeLink(link: string): string {
|
|
91
|
+
if (/^https?:\/\//i.test(link)) return link;
|
|
92
|
+
try {
|
|
93
|
+
return new URL(link, XCRAWL_API_URL).href;
|
|
94
|
+
} catch {
|
|
95
|
+
return link;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Normalize a shared domainFilter entry the same way Valyu does before
|
|
100
|
+
// matching: trim, lowercase, strip URL/paths/ports, validate the shape.
|
|
101
|
+
function normalizeDomain(value: string): string | null {
|
|
102
|
+
let input = value.trim().toLowerCase();
|
|
103
|
+
if (!input) return null;
|
|
104
|
+
if (input.startsWith("-")) input = input.slice(1).trim();
|
|
105
|
+
if (!input) return null;
|
|
106
|
+
try {
|
|
107
|
+
const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
|
|
108
|
+
input = parsed.hostname;
|
|
109
|
+
} catch {
|
|
110
|
+
input = input.split("/")[0]?.split(":")[0] ?? "";
|
|
111
|
+
}
|
|
112
|
+
input = input.replace(/^\.+|\.+$/g, "");
|
|
113
|
+
return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// XCrawl's SERP API has no server-side domain filter, so apply the shared
|
|
117
|
+
// include/exclude domainFilter locally instead of returning off-domain results.
|
|
118
|
+
function applyDomainFilter(results: SearchResponse["results"], domainFilter: NonNullable<SearchOptions["domainFilter"]>): SearchResponse["results"] {
|
|
119
|
+
const includes: string[] = [];
|
|
120
|
+
const excludes: string[] = [];
|
|
121
|
+
for (const raw of domainFilter) {
|
|
122
|
+
const normalized = normalizeDomain(raw);
|
|
123
|
+
if (!normalized) continue;
|
|
124
|
+
if (raw.trim().startsWith("-")) excludes.push(normalized);
|
|
125
|
+
else includes.push(normalized);
|
|
126
|
+
}
|
|
127
|
+
if (!includes.length && !excludes.length) return results;
|
|
128
|
+
return results.filter((result) => {
|
|
129
|
+
const host = hostnameOf(result.url);
|
|
130
|
+
if (!host) return false;
|
|
131
|
+
const matches = (domain: string) => host === domain || host.endsWith(`.${domain}`);
|
|
132
|
+
if (excludes.some(matches)) return false;
|
|
133
|
+
if (includes.length && !includes.some(matches)) return false;
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseResponse(value: unknown): SearchResponse["results"] {
|
|
139
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
140
|
+
throw invalidResponse("expected an object envelope");
|
|
141
|
+
}
|
|
142
|
+
const envelope = value as XCrawlSerpResponse;
|
|
143
|
+
const metadata = envelope.search_metadata;
|
|
144
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
145
|
+
throw invalidResponse("expected search_metadata object");
|
|
146
|
+
}
|
|
147
|
+
const status = (metadata as Record<string, unknown>).status;
|
|
148
|
+
if (status !== undefined && status !== "completed") {
|
|
149
|
+
throw invalidResponse(`expected search_metadata.status \"completed\", got ${JSON.stringify(status)}`);
|
|
150
|
+
}
|
|
151
|
+
if (!Array.isArray(envelope.organic_results)) throw invalidResponse("expected organic_results array");
|
|
152
|
+
|
|
153
|
+
const results: SearchResponse["results"] = [];
|
|
154
|
+
for (const [index, value] of (envelope.organic_results as unknown[]).entries()) {
|
|
155
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
156
|
+
throw invalidResponse(`expected organic_results[${index}] object`);
|
|
157
|
+
}
|
|
158
|
+
const result = value as XCrawlSerpResult;
|
|
159
|
+
const { title, link, snippet } = result;
|
|
160
|
+
if (typeof link !== "string" || !link.trim()) throw invalidResponse(`expected organic_results[${index}].link to be a non-empty string`);
|
|
161
|
+
if (title !== null && title !== undefined && typeof title !== "string") {
|
|
162
|
+
throw invalidResponse(`expected organic_results[${index}].title to be a string or null`);
|
|
163
|
+
}
|
|
164
|
+
if (snippet !== undefined && snippet !== null && typeof snippet !== "string") {
|
|
165
|
+
throw invalidResponse(`expected organic_results[${index}].snippet to be a string or null`);
|
|
166
|
+
}
|
|
167
|
+
const resolved = absolutizeLink(link.trim());
|
|
168
|
+
results.push({
|
|
169
|
+
title: typeof title === "string" && title.trim().length > 0 ? title : resolved,
|
|
170
|
+
url: resolved,
|
|
171
|
+
snippet: typeof snippet === "string" ? snippet : "",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildAnswer(results: SearchResponse["results"]): string {
|
|
179
|
+
return results
|
|
180
|
+
.map((result) => result.snippet
|
|
181
|
+
? `${result.snippet}\nSource: ${result.title} (${result.url})`
|
|
182
|
+
: `Source: ${result.title} (${result.url})`)
|
|
183
|
+
.join("\n\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function searchWithXCrawl(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
|
|
187
|
+
const apiKey = await getApiKey(options.signal);
|
|
188
|
+
const numResults = normalizeCount(options.numResults);
|
|
189
|
+
if (!apiKey) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
"XCrawl search requires an API key. Set xcrawlApiKey in " + CONFIG_PATH +
|
|
192
|
+
" or export XCRAWL_API_KEY. Get one at https://dash.xcrawl.com/",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const body = { engine: "google_search", q: query };
|
|
196
|
+
const activityId = activityMonitor.logStart({ type: "api", query });
|
|
197
|
+
// Distinguishing caller cancellation from a provider-side timeout lets the
|
|
198
|
+
// timeout surface as a retriable failure instead of looking like an abort.
|
|
199
|
+
const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
|
|
200
|
+
let response: Response;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
response = await fetch(XCRAWL_API_URL, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: {
|
|
206
|
+
Authorization: `Bearer ${apiKey}`,
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify(body),
|
|
210
|
+
signal: options.signal
|
|
211
|
+
? AbortSignal.any([timeoutSignal, options.signal])
|
|
212
|
+
: timeoutSignal,
|
|
213
|
+
});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
const message = errorMessage(err);
|
|
216
|
+
if (options.signal?.aborted) {
|
|
217
|
+
activityMonitor.logComplete(activityId, 0);
|
|
218
|
+
throw new Error("Aborted");
|
|
219
|
+
}
|
|
220
|
+
// AbortSignal.timeout rejects with a TimeoutError; treat either that
|
|
221
|
+
// shape or our own fired timer as a retriable provider-side timeout so
|
|
222
|
+
// routing fallback still applies.
|
|
223
|
+
const providerTimeout = timeoutSignal.aborted || (err instanceof Error && err.name === "TimeoutError");
|
|
224
|
+
let outgoing: Error;
|
|
225
|
+
if (providerTimeout) {
|
|
226
|
+
outgoing = new Error(`XCrawl search request timed out after ${Math.round(SEARCH_TIMEOUT_MS / 1000)}s`);
|
|
227
|
+
} else {
|
|
228
|
+
const redactedMessage = redactCredential(message, apiKey);
|
|
229
|
+
outgoing = redactedMessage === message && err instanceof Error ? err : new Error(redactedMessage);
|
|
230
|
+
if (err instanceof Error && outgoing.name === "Error") outgoing.name = err.name;
|
|
231
|
+
}
|
|
232
|
+
activityMonitor.logError(activityId, redactCredential(errorMessage(outgoing), apiKey));
|
|
233
|
+
throw outgoing;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
238
|
+
const errorText = redactCredential(await response.text(), apiKey);
|
|
239
|
+
let detail = errorText.slice(0, 300);
|
|
240
|
+
try {
|
|
241
|
+
const parsed = JSON.parse(errorText) as { message?: unknown; error?: unknown };
|
|
242
|
+
if (typeof parsed.message === "string") detail = parsed.message.slice(0, 300);
|
|
243
|
+
else if (typeof parsed.error === "string") detail = parsed.error.slice(0, 300);
|
|
244
|
+
} catch {
|
|
245
|
+
// keep raw text slice
|
|
246
|
+
}
|
|
247
|
+
throw new Error(`XCrawl API error (${response.status}): ${detail}`);
|
|
248
|
+
}
|
|
249
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
250
|
+
|
|
251
|
+
let payload: unknown;
|
|
252
|
+
try {
|
|
253
|
+
payload = await response.json();
|
|
254
|
+
} catch (err) {
|
|
255
|
+
throw invalidResponse(`response body is not valid JSON: ${errorMessage(err)}`);
|
|
256
|
+
}
|
|
257
|
+
const results = parseResponse(payload);
|
|
258
|
+
const filtered = (options.domainFilter?.length ? applyDomainFilter(results, options.domainFilter) : results).slice(0, numResults);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
answer: buildAnswer(filtered),
|
|
262
|
+
results: filtered,
|
|
263
|
+
};
|
|
264
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@herbertgao/pi-extensions",
|
|
3
|
-
"version": "2026.8.
|
|
3
|
+
"version": "2026.8.14",
|
|
4
4
|
"description": "Aggregate installer for HerbertGao-maintained Pi extensions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"extensions",
|
|
@@ -37,15 +37,14 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@ast-grep/cli": "^0.45.0",
|
|
39
39
|
"@ast-grep/napi": "^0.45.0",
|
|
40
|
-
"@czottmann/pi-automode": "1.
|
|
40
|
+
"@czottmann/pi-automode": "1.14.0",
|
|
41
41
|
"@dietrichgebert/ponytail": "4.9.0",
|
|
42
42
|
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
43
43
|
"@earendil-works/pi-tui": "^0.84.2",
|
|
44
44
|
"@effect/platform-node": "4.0.0-beta.103",
|
|
45
45
|
"@effect/platform-node-shared": "4.0.0-beta.103",
|
|
46
|
-
"@herbertgao/pi-cc-extensions": "0.8.
|
|
47
|
-
"@herbertgao/pi-
|
|
48
|
-
"@herbertgao/pi-subagents": "0.17.0",
|
|
46
|
+
"@herbertgao/pi-cc-extensions": "0.8.60",
|
|
47
|
+
"@herbertgao/pi-subagents": "0.17.1",
|
|
49
48
|
"@herbertgao/resume-from": "0.2.0",
|
|
50
49
|
"@juicesharp/rpiv-ask-user-question": "2.7.1",
|
|
51
50
|
"@juicesharp/rpiv-config": "^2.7.1",
|
|
@@ -66,7 +65,7 @@
|
|
|
66
65
|
"@tifan/pi-handoff": "2.0.1",
|
|
67
66
|
"@tifan/pi-inline-skills": "1.0.5",
|
|
68
67
|
"@tifan/pi-mermaid-open": "0.2.0",
|
|
69
|
-
"@tifan/pi-preferred-thinking": "1.0.
|
|
68
|
+
"@tifan/pi-preferred-thinking": "1.0.1",
|
|
70
69
|
"@tifan/pi-recap": "0.4.5",
|
|
71
70
|
"@tifan/pi-rename": "0.5.1",
|
|
72
71
|
"@tifan/pi-titlebar-spinner": "0.1.3",
|
|
@@ -90,8 +89,8 @@
|
|
|
90
89
|
"p-limit": "^6.1.0",
|
|
91
90
|
"pi-footer": "0.5.1",
|
|
92
91
|
"pi-lens": "4.1.2",
|
|
93
|
-
"pi-mcp-adapter": "2.
|
|
94
|
-
"pi-web-access": "0.
|
|
92
|
+
"pi-mcp-adapter": "2.30.0",
|
|
93
|
+
"pi-web-access": "0.26.0",
|
|
95
94
|
"pidusage": "^4.0.1",
|
|
96
95
|
"promise.try": "^2.0.1",
|
|
97
96
|
"qrcode-terminal": "^0.12.0",
|
|
@@ -115,7 +114,6 @@
|
|
|
115
114
|
"@czottmann/pi-automode",
|
|
116
115
|
"@dietrichgebert/ponytail",
|
|
117
116
|
"@herbertgao/pi-cc-extensions",
|
|
118
|
-
"@herbertgao/pi-stash",
|
|
119
117
|
"@herbertgao/pi-subagents",
|
|
120
118
|
"@herbertgao/resume-from",
|
|
121
119
|
"@juicesharp/rpiv-ask-user-question",
|
|
@@ -152,7 +150,6 @@
|
|
|
152
150
|
"./node_modules/@tifan/pi-preferred-thinking/src/index.ts",
|
|
153
151
|
"./node_modules/@tifan/pi-recap/src/index.ts",
|
|
154
152
|
"./node_modules/@tifan/pi-rename/src/index.ts",
|
|
155
|
-
"./node_modules/@herbertgao/pi-stash/src/index.ts",
|
|
156
153
|
"./node_modules/@herbertgao/pi-subagents/src/index.ts",
|
|
157
154
|
"./node_modules/@tifan/pi-titlebar-spinner/src/index.ts",
|
|
158
155
|
"./node_modules/@juicesharp/rpiv-ask-user-question/index.ts",
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Tifan Dwi Avianto
|
|
4
|
-
Copyright (c) 2026 Herbert Gao
|
|
5
|
-
|
|
6
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
-
in the Software without restriction, including without limitation the rights
|
|
9
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
-
furnished to do so, subject to the following conditions:
|
|
12
|
-
|
|
13
|
-
The above copyright notice and this permission notice shall be included in
|
|
14
|
-
all copies or substantial portions of the Software.
|
|
15
|
-
|
|
16
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
22
|
-
THE SOFTWARE.
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# @herbertgao/pi-stash
|
|
2
|
-
|
|
3
|
-
> HerbertGao-maintained fork of [@tifan/pi-stash](https://github.com/tifandotme/pi-extensions/tree/master/packages/pi-stash), distributed under MIT with the original attribution preserved.
|
|
4
|
-
|
|
5
|
-
Stash one draft, send another message, and return to the draft while pi works.
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
pi install npm:@herbertgao/pi-stash
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Keybinding
|
|
14
|
-
|
|
15
|
-
pi-stash uses `Alt+S`, which does not conflict with Pi's built-in shortcuts.
|
|
16
|
-
|
|
17
|
-
### macOS
|
|
18
|
-
|
|
19
|
-
On macOS, press `Option+S` (`Option` is the `Alt` key). If that inserts `ß` instead of triggering pi-stash, configure the terminal to send `Option` as Meta/Escape:
|
|
20
|
-
|
|
21
|
-
- Terminal.app: **Settings → Profiles → Keyboard → Use Option as Meta key**.
|
|
22
|
-
- iTerm2: **Settings → Profiles → Keys → Left Option key** (or **Right Option key**) → **Esc+**.
|
|
23
|
-
|
|
24
|
-
## Usage
|
|
25
|
-
|
|
26
|
-
Press `Alt+S` with text in the editor to stash it. Pi clears the editor so you can send another message, then restores the draft as soon as that message is submitted.
|
|
27
|
-
|
|
28
|
-
Press `Alt+S` again while the editor is empty to restore the draft manually. If the editor contains new text, pi-stash keeps both drafts unchanged and refuses to overwrite either one.
|
|
29
|
-
|
|
30
|
-
The stash belongs to the current session and survives reloads and restarts. Slash commands and `!` shell commands do not consume it.
|
|
31
|
-
|
|
32
|
-
## License
|
|
33
|
-
|
|
34
|
-
[MIT](LICENSE)
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@herbertgao/pi-stash",
|
|
3
|
-
"version": "0.1.2",
|
|
4
|
-
"description": "Stash one pi draft and restore it after the next message.",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"pi",
|
|
7
|
-
"pi-extension",
|
|
8
|
-
"pi-package",
|
|
9
|
-
"prompt",
|
|
10
|
-
"stash"
|
|
11
|
-
],
|
|
12
|
-
"homepage": "https://github.com/HerbertGao/pi-extensions/tree/master/packages/pi-stash#readme",
|
|
13
|
-
"bugs": "https://github.com/HerbertGao/pi-extensions/issues",
|
|
14
|
-
"license": "MIT",
|
|
15
|
-
"author": "Herbert Gao",
|
|
16
|
-
"contributors": [
|
|
17
|
-
"Tifan Dwi Avianto <hi@tifan.me>"
|
|
18
|
-
],
|
|
19
|
-
"repository": {
|
|
20
|
-
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/HerbertGao/pi-extensions.git",
|
|
22
|
-
"directory": "packages/pi-stash"
|
|
23
|
-
},
|
|
24
|
-
"files": [
|
|
25
|
-
"src/**/*.ts",
|
|
26
|
-
"README.md",
|
|
27
|
-
"LICENSE"
|
|
28
|
-
],
|
|
29
|
-
"type": "module",
|
|
30
|
-
"publishConfig": {
|
|
31
|
-
"access": "public",
|
|
32
|
-
"provenance": true
|
|
33
|
-
},
|
|
34
|
-
"scripts": {
|
|
35
|
-
"test": "node --experimental-strip-types --test tests/**/*.test.ts"
|
|
36
|
-
},
|
|
37
|
-
"engines": {
|
|
38
|
-
"node": ">=20"
|
|
39
|
-
},
|
|
40
|
-
"pi": {
|
|
41
|
-
"extensions": [
|
|
42
|
-
"./src/index.ts"
|
|
43
|
-
]
|
|
44
|
-
},
|
|
45
|
-
"x-upstream": {
|
|
46
|
-
"package": "@tifan/pi-stash",
|
|
47
|
-
"version": "0.2.0",
|
|
48
|
-
"repository": "https://github.com/tifandotme/pi-extensions",
|
|
49
|
-
"commit": "b39d1e6359c78718cbf8fe5c74c82b89065e9a0b"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionContext,
|
|
4
|
-
InputSource,
|
|
5
|
-
SessionEntry,
|
|
6
|
-
} from "@earendil-works/pi-coding-agent"
|
|
7
|
-
|
|
8
|
-
export const STASH_ENTRY_TYPE = "pi-stash-state"
|
|
9
|
-
export const STASH_SHORTCUT = "alt+s"
|
|
10
|
-
|
|
11
|
-
interface StashState {
|
|
12
|
-
draft: string | null
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type ShortcutAction =
|
|
16
|
-
| { type: "none" }
|
|
17
|
-
| { type: "reject" }
|
|
18
|
-
| { type: "restore"; draft: string }
|
|
19
|
-
| { type: "stash"; draft: string }
|
|
20
|
-
|
|
21
|
-
export function getShortcutAction(
|
|
22
|
-
editorText: string,
|
|
23
|
-
pendingDraft: string | null,
|
|
24
|
-
): ShortcutAction {
|
|
25
|
-
if (pendingDraft !== null) {
|
|
26
|
-
return editorText.trim()
|
|
27
|
-
? { type: "reject" }
|
|
28
|
-
: { type: "restore", draft: pendingDraft }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return editorText.trim()
|
|
32
|
-
? { type: "stash", draft: editorText }
|
|
33
|
-
: { type: "none" }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function shouldAutoRestore(source: InputSource, text: string) {
|
|
37
|
-
return source === "interactive" && !text.trimStart().startsWith("/")
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function getPendingDraft(
|
|
41
|
-
entries: readonly SessionEntry[],
|
|
42
|
-
): string | null {
|
|
43
|
-
let draft: string | null = null
|
|
44
|
-
|
|
45
|
-
for (const entry of entries) {
|
|
46
|
-
if (entry.type !== "custom" || entry.customType !== STASH_ENTRY_TYPE) {
|
|
47
|
-
continue
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const data = entry.data
|
|
51
|
-
if (
|
|
52
|
-
typeof data === "object" &&
|
|
53
|
-
data !== null &&
|
|
54
|
-
"draft" in data &&
|
|
55
|
-
(typeof data.draft === "string" || data.draft === null)
|
|
56
|
-
) {
|
|
57
|
-
draft = data.draft
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return draft
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export default function stashExtension(pi: ExtensionAPI) {
|
|
65
|
-
let pendingDraft: string | null = null
|
|
66
|
-
|
|
67
|
-
function saveDraft(draft: string | null) {
|
|
68
|
-
pendingDraft = draft
|
|
69
|
-
pi.appendEntry<StashState>(STASH_ENTRY_TYPE, { draft })
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function restoreDraft(ctx: ExtensionContext) {
|
|
73
|
-
if (pendingDraft === null) return
|
|
74
|
-
|
|
75
|
-
const draft = pendingDraft
|
|
76
|
-
ctx.ui.setEditorText(draft)
|
|
77
|
-
saveDraft(null)
|
|
78
|
-
ctx.ui.notify("Draft restored", "info")
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
pi.on("session_start", (_event, ctx) => {
|
|
82
|
-
pendingDraft = getPendingDraft(ctx.sessionManager.getBranch())
|
|
83
|
-
if (
|
|
84
|
-
ctx.mode === "tui" &&
|
|
85
|
-
pendingDraft !== null &&
|
|
86
|
-
!ctx.ui.getEditorText().trim()
|
|
87
|
-
) {
|
|
88
|
-
restoreDraft(ctx)
|
|
89
|
-
}
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
pi.on("input", (event, ctx) => {
|
|
93
|
-
if (shouldAutoRestore(event.source, event.text)) restoreDraft(ctx)
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
pi.registerShortcut(STASH_SHORTCUT, {
|
|
97
|
-
description: "Stash or restore the current draft",
|
|
98
|
-
handler: (ctx) => {
|
|
99
|
-
const action = getShortcutAction(ctx.ui.getEditorText(), pendingDraft)
|
|
100
|
-
|
|
101
|
-
switch (action.type) {
|
|
102
|
-
case "stash":
|
|
103
|
-
saveDraft(action.draft)
|
|
104
|
-
ctx.ui.setEditorText("")
|
|
105
|
-
ctx.ui.notify("Draft stashed", "info")
|
|
106
|
-
break
|
|
107
|
-
case "restore":
|
|
108
|
-
restoreDraft(ctx)
|
|
109
|
-
break
|
|
110
|
-
case "reject":
|
|
111
|
-
ctx.ui.notify("A draft is already stashed", "warning")
|
|
112
|
-
break
|
|
113
|
-
case "none":
|
|
114
|
-
break
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
})
|
|
118
|
-
}
|