@aerok/pi-toolkit 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.
@@ -0,0 +1,68 @@
1
+ export type BarkLevel = "critical" | "active" | "timeSensitive" | "passive";
2
+ export type NotifyPriority = "low" | "normal" | "high";
3
+ export type EncryptionMode = "encrypted" | "plaintext";
4
+
5
+ export interface BarkEncryptionConfig {
6
+ mode: EncryptionMode;
7
+ key?: string;
8
+ iv?: string;
9
+ }
10
+
11
+ export type BarkEventKey =
12
+ | "agent_settled"
13
+ | "agent_end"
14
+ | "session_shutdown"
15
+ | "ask_user_prompt"
16
+ | "permission_request";
17
+
18
+ export interface RecapConfig {
19
+ enabled: boolean;
20
+ /** provider/model-id; empty means the current session model. */
21
+ model?: string;
22
+ }
23
+
24
+ export interface BarkGlobalConfig {
25
+ version: 2;
26
+ enabled: boolean;
27
+ serverUrl: string;
28
+ deviceKey?: string;
29
+ group?: string;
30
+ sound?: string;
31
+ icon?: string;
32
+ level: BarkLevel;
33
+ encryption: BarkEncryptionConfig;
34
+ events: Record<BarkEventKey, boolean>;
35
+ recap: RecapConfig;
36
+ }
37
+
38
+ /** Project files never contain server URLs or device keys. */
39
+ export interface BarkProjectConfig {
40
+ version: 1;
41
+ enabled?: boolean;
42
+ group?: string | null;
43
+ sound?: string | null;
44
+ icon?: string | null;
45
+ level?: BarkLevel;
46
+ events?: Partial<Record<BarkEventKey, boolean>>;
47
+ recap?: Partial<RecapConfig>;
48
+ }
49
+
50
+ export interface ResolvedBarkConfig extends BarkGlobalConfig {
51
+ projectOverride: boolean;
52
+ }
53
+
54
+ export interface BarkNotification {
55
+ title: string;
56
+ body: string;
57
+ level?: BarkLevel;
58
+ group?: string;
59
+ sound?: string;
60
+ icon?: string;
61
+ call?: boolean;
62
+ }
63
+
64
+ export interface CachedModel {
65
+ provider: string;
66
+ id: string;
67
+ name?: string;
68
+ }
@@ -0,0 +1,22 @@
1
+ # 图片粘贴占位符扩展
2
+
3
+ 这是 `pi-toolkit` 的独立图片扩展。在 Pi TUI 中粘贴剪贴板图片时,将默认临时文件长路径显示为紧凑占位符:
4
+
5
+ ```text
6
+ [Image 1]
7
+ [Image 2]
8
+ ```
9
+
10
+ 提交消息时,扩展会把占位符对应的图片作为真实图片附件发送给模型;占位符文字保留,便于引用具体图片。
11
+
12
+ ## 行为
13
+
14
+ - 图片编号按单次提问独立计算,成功提交后重新从 `[Image 1]` 开始。
15
+ - 使用当前主题强调色和粗体高亮有效占位符。
16
+ - 兼容已有会话中的旧格式 `[图片N]`。
17
+ - 删除未提交占位符后清理对应临时文件。
18
+ - 从输入历史重新提交时重新附加原图片。
19
+ - 当前模型不支持图片输入时阻止发送并恢复原输入。
20
+ - 包装当前主编辑器,可与其他自定义编辑器扩展组合。
21
+
22
+ 由 `pi-toolkit` package manifest 自动加载,不要同时保留全局 `~/.pi/agent/extensions/image-placeholders` 副本。
@@ -0,0 +1,312 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { basename, extname } from "node:path";
3
+
4
+ import {
5
+ CustomEditor,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { ImageContent } from "@earendil-works/pi-ai";
10
+
11
+ // Accept the previous Chinese markers when restoring existing session history.
12
+ const MARKER_PATTERN = /\[(?:Image\s+|图片)(\d+)\]/gi;
13
+ const CLIPBOARD_FILE_PATTERN = /^pi-clipboard-[0-9a-f-]+\.(?:png|jpe?g|webp|gif)$/i;
14
+ const SHARED_STATE_KEY = Symbol.for("pi.image-placeholders.state.v2");
15
+ const WRAPPED_FACTORY_KEY = Symbol.for("pi.image-placeholders.wrapped-factory.v2");
16
+ const PATCHED_EDITOR_KEY = Symbol.for("pi.image-placeholders.patched-editor.v2");
17
+
18
+ interface StoredImage {
19
+ id: number;
20
+ path?: string;
21
+ data?: string;
22
+ mimeType: string;
23
+ submitted: boolean;
24
+ }
25
+
26
+ interface PlaceholderState {
27
+ attachments: Map<number, StoredImage>;
28
+ history: Map<string, Map<number, StoredImage>>;
29
+ nextId: number;
30
+ }
31
+
32
+ type GlobalSymbolRegistry = typeof globalThis & {
33
+ [key: symbol]: unknown;
34
+ };
35
+
36
+ function getSharedStates(): Map<string, PlaceholderState> {
37
+ const globalState = globalThis as GlobalSymbolRegistry;
38
+ const existing = globalState[SHARED_STATE_KEY];
39
+ if (existing instanceof Map) {
40
+ return existing as Map<string, PlaceholderState>;
41
+ }
42
+
43
+ const states = new Map<string, PlaceholderState>();
44
+ globalState[SHARED_STATE_KEY] = states;
45
+ return states;
46
+ }
47
+
48
+ function sessionKey(ctx: ExtensionContext): string {
49
+ return ctx.sessionManager.getSessionFile() ?? `ephemeral:${ctx.cwd}`;
50
+ }
51
+
52
+ function markerFor(id: number): string {
53
+ return `[Image ${id}]`;
54
+ }
55
+
56
+ function extractMarkerIds(text: string): number[] {
57
+ const ids: number[] = [];
58
+ const seen = new Set<number>();
59
+
60
+ for (const match of text.matchAll(MARKER_PATTERN)) {
61
+ const id = Number(match[1]);
62
+ if (Number.isSafeInteger(id) && id > 0 && !seen.has(id)) {
63
+ seen.add(id);
64
+ ids.push(id);
65
+ }
66
+ }
67
+
68
+ return ids;
69
+ }
70
+
71
+ function mimeTypeForPath(path: string): string | undefined {
72
+ switch (extname(path).toLowerCase()) {
73
+ case ".png":
74
+ return "image/png";
75
+ case ".jpg":
76
+ case ".jpeg":
77
+ return "image/jpeg";
78
+ case ".webp":
79
+ return "image/webp";
80
+ case ".gif":
81
+ return "image/gif";
82
+ default:
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ function isPiClipboardImagePath(text: string): boolean {
88
+ return CLIPBOARD_FILE_PATTERN.test(basename(text)) && mimeTypeForPath(text) !== undefined;
89
+ }
90
+
91
+ async function removeTempFile(path: string | undefined): Promise<void> {
92
+ if (!path) return;
93
+ await rm(path, { force: true }).catch(() => undefined);
94
+ }
95
+
96
+ function restoreSubmittedImages(ctx: ExtensionContext, state: PlaceholderState): void {
97
+ const history = new Map<string, Map<number, StoredImage>>();
98
+
99
+ for (const entry of ctx.sessionManager.getBranch()) {
100
+ if (entry.type !== "message" || entry.message.role !== "user") continue;
101
+
102
+ const content = entry.message.content;
103
+ if (!Array.isArray(content)) continue;
104
+
105
+ const text = content
106
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
107
+ .map((block) => block.text)
108
+ .join("\n");
109
+ const markerIds = extractMarkerIds(text);
110
+ const images = content.filter((block): block is ImageContent => block.type === "image");
111
+ const promptImages = new Map<number, StoredImage>();
112
+
113
+ for (let index = 0; index < Math.min(markerIds.length, images.length); index++) {
114
+ const id = markerIds[index]!;
115
+ const image = images[index]!;
116
+ promptImages.set(id, {
117
+ id,
118
+ data: image.data,
119
+ mimeType: image.mimeType,
120
+ submitted: true,
121
+ });
122
+ }
123
+
124
+ if (promptImages.size > 0) history.set(text, promptImages);
125
+ }
126
+
127
+ state.history = history;
128
+ state.nextId = state.attachments.size > 0
129
+ ? Math.max(...state.attachments.keys()) + 1
130
+ : 1;
131
+ }
132
+
133
+ function restoreEditorAfterFailure(ctx: ExtensionContext, text: string, message: string): void {
134
+ ctx.ui.setEditorText(text);
135
+ ctx.ui.notify(message, "error");
136
+ }
137
+
138
+ function replaceClipboardPath(text: string, state: PlaceholderState): string {
139
+ if (!isPiClipboardImagePath(text)) return text;
140
+
141
+ const id = state.nextId++;
142
+ state.attachments.set(id, {
143
+ id,
144
+ path: text,
145
+ mimeType: mimeTypeForPath(text)!,
146
+ submitted: false,
147
+ });
148
+ return markerFor(id);
149
+ }
150
+
151
+ export default function imagePlaceholders(pi: ExtensionAPI): void {
152
+ let state: PlaceholderState | undefined;
153
+ let activeSessionKey: string | undefined;
154
+ let installTimer: ReturnType<typeof setTimeout> | undefined;
155
+ let terminalInputUnsubscribe: (() => void) | undefined;
156
+
157
+ const installEditorWrapper = (ctx: ExtensionContext): void => {
158
+ if (!state || ctx.mode !== "tui") return;
159
+
160
+ const previousFactory = ctx.ui.getEditorComponent();
161
+ if (previousFactory && Reflect.get(previousFactory, WRAPPED_FACTORY_KEY) === true) return;
162
+
163
+ const wrappedFactory: NonNullable<ReturnType<typeof ctx.ui.getEditorComponent>> = (
164
+ tui,
165
+ theme,
166
+ keybindings,
167
+ ) => {
168
+ const editor = previousFactory?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
169
+ if (Reflect.get(editor, PATCHED_EDITOR_KEY) === true) return editor;
170
+
171
+ const originalInsert = Reflect.get(editor, "insertTextAtCursor");
172
+ if (typeof originalInsert === "function") {
173
+ Reflect.set(editor, "insertTextAtCursor", (text: string) => {
174
+ originalInsert.call(editor, state ? replaceClipboardPath(text, state) : text);
175
+ });
176
+ }
177
+
178
+ const originalRender = Reflect.get(editor, "render");
179
+ if (typeof originalRender === "function") {
180
+ Reflect.set(editor, "render", (width: number): string[] => {
181
+ const lines = originalRender.call(editor, width) as string[];
182
+ if (!state) return lines;
183
+
184
+ const getEditorText = Reflect.get(editor, "getExpandedText") ?? Reflect.get(editor, "getText");
185
+ const editorText = typeof getEditorText === "function" ? getEditorText.call(editor) as string : "";
186
+ const activeImages = state.attachments.size > 0
187
+ ? state.attachments
188
+ : state.history.get(editorText);
189
+
190
+ return lines.map((line) =>
191
+ line.replace(MARKER_PATTERN, (marker, rawId: string) => {
192
+ const id = Number(rawId);
193
+ if (!activeImages?.has(id)) return marker;
194
+ return ctx.ui.theme.fg("accent", ctx.ui.theme.bold(marker));
195
+ }),
196
+ );
197
+ });
198
+ }
199
+ Reflect.set(editor, PATCHED_EDITOR_KEY, true);
200
+ return editor;
201
+ };
202
+
203
+ Reflect.set(wrappedFactory, WRAPPED_FACTORY_KEY, true);
204
+ ctx.ui.setEditorComponent(wrappedFactory);
205
+ };
206
+
207
+ pi.on("session_start", (_event, ctx) => {
208
+ activeSessionKey = sessionKey(ctx);
209
+ const sharedStates = getSharedStates();
210
+ state = sharedStates.get(activeSessionKey) ?? {
211
+ attachments: new Map<number, StoredImage>(),
212
+ history: new Map<string, Map<number, StoredImage>>(),
213
+ nextId: 1,
214
+ };
215
+ sharedStates.set(activeSessionKey, state);
216
+ restoreSubmittedImages(ctx, state);
217
+
218
+ if (ctx.mode !== "tui") return;
219
+
220
+ // Other extensions (notably pi-powerline-footer) install their editor during
221
+ // session_start. Defer composition so we wrap the final editor instead of
222
+ // replacing it, and repair the wrapper if a later extension replaces it.
223
+ installTimer = setTimeout(() => installEditorWrapper(ctx), 50);
224
+ terminalInputUnsubscribe = ctx.ui.onTerminalInput(() => {
225
+ installEditorWrapper(ctx);
226
+ return undefined;
227
+ });
228
+ });
229
+
230
+ pi.on("session_tree", (_event, ctx) => {
231
+ if (state) restoreSubmittedImages(ctx, state);
232
+ });
233
+
234
+ pi.on("input", async (event, ctx) => {
235
+ if (event.source !== "interactive" || !state) {
236
+ return { action: "continue" };
237
+ }
238
+
239
+ const markerIds = extractMarkerIds(event.text);
240
+ const referencedIds = new Set(markerIds);
241
+
242
+ for (const [id, image] of state.attachments) {
243
+ if (!referencedIds.has(id)) {
244
+ await removeTempFile(image.path);
245
+ state.attachments.delete(id);
246
+ }
247
+ }
248
+
249
+ const sourceImages = state.attachments.size > 0
250
+ ? state.attachments
251
+ : state.history.get(event.text);
252
+ const referencedImages = markerIds
253
+ .map((id) => sourceImages?.get(id))
254
+ .filter((image): image is StoredImage => image !== undefined);
255
+ if (referencedImages.length === 0) {
256
+ state.nextId = 1;
257
+ return { action: "continue" };
258
+ }
259
+
260
+ if (ctx.model && !ctx.model.input.includes("image")) {
261
+ restoreEditorAfterFailure(ctx, event.text, `当前模型 ${ctx.model.id} 不支持图片输入`);
262
+ return { action: "handled" };
263
+ }
264
+
265
+ const images: ImageContent[] = [];
266
+ const archivedImages = new Map<number, StoredImage>();
267
+ for (const image of referencedImages) {
268
+ try {
269
+ if (!image.data) {
270
+ if (!image.path) throw new Error("临时图片路径不存在");
271
+ image.data = (await readFile(image.path)).toString("base64");
272
+ await removeTempFile(image.path);
273
+ image.path = undefined;
274
+ }
275
+ image.submitted = true;
276
+ images.push({ type: "image", data: image.data, mimeType: image.mimeType });
277
+ archivedImages.set(image.id, { ...image, path: undefined, submitted: true });
278
+ } catch (error) {
279
+ const reason = error instanceof Error ? error.message : String(error);
280
+ restoreEditorAfterFailure(ctx, event.text, `${markerFor(image.id)} 读取失败:${reason}`);
281
+ return { action: "handled" };
282
+ }
283
+ }
284
+
285
+ state.history.set(event.text, archivedImages);
286
+ state.attachments.clear();
287
+ state.nextId = 1;
288
+
289
+ return {
290
+ action: "transform",
291
+ text: event.text,
292
+ // Keep placeholder images first so they can be reconstructed from session history.
293
+ images: [...images, ...(event.images ?? [])],
294
+ };
295
+ });
296
+
297
+ pi.on("session_shutdown", async (event) => {
298
+ if (installTimer) clearTimeout(installTimer);
299
+ installTimer = undefined;
300
+ terminalInputUnsubscribe?.();
301
+ terminalInputUnsubscribe = undefined;
302
+
303
+ if (!state || !activeSessionKey || event.reason === "reload") return;
304
+
305
+ await Promise.all(
306
+ [...state.attachments.values()].map((image) => removeTempFile(image.path)),
307
+ );
308
+ getSharedStates().delete(activeSessionKey);
309
+ state = undefined;
310
+ activeSessionKey = undefined;
311
+ });
312
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@aerok/pi-toolkit",
3
+ "version": "0.1.0",
4
+ "description": "Encrypted Bark notifications and image paste placeholders for Pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "wayne1943x",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/wayne1943x/pi-toolkit.git"
11
+ },
12
+ "homepage": "https://github.com/wayne1943x/pi-toolkit#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/wayne1943x/pi-toolkit/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "pi-coding-agent",
20
+ "toolkit",
21
+ "bark",
22
+ "notifications",
23
+ "encryption",
24
+ "ios",
25
+ "image-paste"
26
+ ],
27
+ "scripts": {
28
+ "test": "tsx --test tests/**/*.test.ts",
29
+ "typecheck": "tsc --noEmit",
30
+ "check": "npm run typecheck && npm test"
31
+ },
32
+ "files": [
33
+ "extensions/**/*.ts",
34
+ "extensions/**/*.md",
35
+ "README.md",
36
+ "CHANGELOG.md",
37
+ "LICENSE",
38
+ "NOTICE"
39
+ ],
40
+ "pi": {
41
+ "extensions": [
42
+ "./extensions/bark/index.ts",
43
+ "./extensions/image-placeholders/index.ts"
44
+ ]
45
+ },
46
+ "engines": {
47
+ "node": ">=22.19.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-ai": "^0.84.0",
51
+ "@earendil-works/pi-coding-agent": "^0.84.0",
52
+ "@earendil-works/pi-tui": "^0.84.0",
53
+ "typebox": "^1.1.38"
54
+ },
55
+ "devDependencies": {
56
+ "@earendil-works/pi-ai": "^0.84.0",
57
+ "@earendil-works/pi-coding-agent": "^0.84.0",
58
+ "@earendil-works/pi-tui": "^0.84.0",
59
+ "@types/node": "^25.0.0",
60
+ "tsx": "^4.21.0",
61
+ "typebox": "^1.1.38",
62
+ "typescript": "^6.0.0"
63
+ }
64
+ }