@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/NOTICE +6 -0
- package/README.md +277 -0
- package/extensions/bark/client.ts +79 -0
- package/extensions/bark/config.ts +246 -0
- package/extensions/bark/crypto.ts +31 -0
- package/extensions/bark/index.ts +317 -0
- package/extensions/bark/recap.ts +53 -0
- package/extensions/bark/redact.ts +21 -0
- package/extensions/bark/tui/model-selector.ts +102 -0
- package/extensions/bark/tui/settings.ts +213 -0
- package/extensions/bark/tui/setup.ts +375 -0
- package/extensions/bark/tui/theme.ts +49 -0
- package/extensions/bark/types.ts +68 -0
- package/extensions/image-placeholders/README.md +22 -0
- package/extensions/image-placeholders/index.ts +312 -0
- package/package.json +64 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-09-02
|
|
4
|
+
|
|
5
|
+
Initial public release of pi-toolkit.
|
|
6
|
+
|
|
7
|
+
### Included
|
|
8
|
+
|
|
9
|
+
- End-to-end encrypted Bark notifications using AES-128-CBC with PKCS#7 padding.
|
|
10
|
+
- Automatic task-completion notifications with LLM-generated recaps and local fallback summaries.
|
|
11
|
+
- One-shot continuous ringing for important tasks through `/toolkit:bark-urgent`.
|
|
12
|
+
- Interactive Bark setup, encryption rotation, event settings, recap-model selection, and device-side verification.
|
|
13
|
+
- Global private credentials with project-level non-secret preferences.
|
|
14
|
+
- Defense-in-depth redaction before recap, encryption, and Bark transport.
|
|
15
|
+
- Image paste placeholders with vision-model submission, history restoration, and temporary-file cleanup.
|
|
16
|
+
- Local, GitHub, and npm installation support.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Neuron Mr White and pi-toolkit contributors
|
|
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/NOTICE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pi-toolkit includes an independently refactored Bark notification extension derived from ideas and MIT-licensed code in Neuron-Mr-White/unipi.
|
|
2
|
+
|
|
3
|
+
Upstream project: https://github.com/Neuron-Mr-White/unipi
|
|
4
|
+
Bark project and push protocol: https://github.com/finb/bark
|
|
5
|
+
|
|
6
|
+
The image placeholder extension was migrated from the user's local Pi extension into this package.
|
package/README.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# pi-toolkit
|
|
2
|
+
|
|
3
|
+
A focused extension pack for [Pi](https://github.com/earendil-works/pi) that adds encrypted Bark notifications and compact image-paste placeholders.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
### Bark notifications
|
|
8
|
+
|
|
9
|
+
- End-to-end encrypted delivery by default
|
|
10
|
+
- Automatic notification when a Pi task fully settles
|
|
11
|
+
- One-shot 30-second ringing for important tasks
|
|
12
|
+
- LLM-generated completion recaps with a local fallback
|
|
13
|
+
- Global credentials with project-level, non-secret preferences
|
|
14
|
+
- Official `api.day.app` and self-hosted Bark Server support
|
|
15
|
+
- Manual `toolkit_notify` tool for agent-initiated notifications
|
|
16
|
+
- Defense-in-depth secret redaction
|
|
17
|
+
|
|
18
|
+
### Image paste placeholders
|
|
19
|
+
|
|
20
|
+
- Replaces clipboard image paths with readable `[Image N]` markers
|
|
21
|
+
- Sends the original images to vision-capable models
|
|
22
|
+
- Restores image associations from session history
|
|
23
|
+
- Cleans up unused temporary clipboard files
|
|
24
|
+
- Wraps existing custom editors instead of replacing them
|
|
25
|
+
|
|
26
|
+
pi-toolkit is independent of UniPi. It does not depend on `@pi-unipi/*`, register `/unipi:*` commands, or use `~/.unipi`.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Node.js 22.19 or later
|
|
31
|
+
- `@earendil-works/pi-coding-agent` 0.84 or later
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
### npm
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi install npm:@aerok/pi-toolkit
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### GitHub
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pi install git:github.com/wayne1943x/pi-toolkit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Local checkout
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/wayne1943x/pi-toolkit.git
|
|
51
|
+
cd pi-toolkit
|
|
52
|
+
npm install
|
|
53
|
+
pi install .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Restart Pi after installation, or run `/reload` in the current session.
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
1. Run:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
/toolkit:bark-setup
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
2. Paste the test URL shown in the Bark app. The setup wizard extracts the device key.
|
|
67
|
+
3. Configure Bark app encryption with the values generated by the wizard:
|
|
68
|
+
|
|
69
|
+
| Bark setting | Value |
|
|
70
|
+
|---|---|
|
|
71
|
+
| Algorithm | `AES128` |
|
|
72
|
+
| Mode | `CBC` |
|
|
73
|
+
| Padding | `pkcs7` |
|
|
74
|
+
| Key | Generated 16-character value; press `K` to copy |
|
|
75
|
+
| IV | Generated 16-character value; press `I` to copy |
|
|
76
|
+
|
|
77
|
+
4. Send the encrypted test notification.
|
|
78
|
+
5. Confirm activation only if the device displays the readable test sentence. Do not confirm if Bark displays `Decryption Failed`.
|
|
79
|
+
|
|
80
|
+
The wizard does not save or activate new encryption credentials until this device-side check succeeds.
|
|
81
|
+
|
|
82
|
+
## Bark commands
|
|
83
|
+
|
|
84
|
+
| Command | Description |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `/toolkit:bark-setup` | Configure the Bark server, device key, delivery defaults, and encryption |
|
|
87
|
+
| `/toolkit:bark-test` | Send a test using the active encryption mode |
|
|
88
|
+
| `/toolkit:bark-encryption-rotate` | Generate and verify a new encryption Key and IV |
|
|
89
|
+
| `/toolkit:bark-urgent` | Toggle continuous ringing for the next completed task only |
|
|
90
|
+
| `/toolkit:bark-notify-settings` | Configure notification events, encryption mode, and recap behavior |
|
|
91
|
+
| `/toolkit:bark-recap-model` | Select the model used to generate completion recaps |
|
|
92
|
+
|
|
93
|
+
All Bark commands use the `toolkit:bark-*` namespace so other pi-toolkit extensions can add their own command groups later.
|
|
94
|
+
|
|
95
|
+
## Default completion notification
|
|
96
|
+
|
|
97
|
+
When `agent_settled` fires, pi-toolkit sends:
|
|
98
|
+
|
|
99
|
+
| Field | Default |
|
|
100
|
+
|---|---|
|
|
101
|
+
| Title | `Pi · <session name>`, or `Pi Toolkit — Task Complete` when unnamed |
|
|
102
|
+
| Body | One-sentence LLM recap, with a local fallback |
|
|
103
|
+
| Group | `Pi` |
|
|
104
|
+
| Sound | `anticipate` |
|
|
105
|
+
| Level | `active` |
|
|
106
|
+
| Encryption | Enabled after encrypted setup is completed |
|
|
107
|
+
|
|
108
|
+
`agent_settled` runs after Pi has finished retries, continuations, and compaction, making it the most reliable standalone task-completion event.
|
|
109
|
+
|
|
110
|
+
## Important tasks
|
|
111
|
+
|
|
112
|
+
Before submitting an important task, run:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
/toolkit:bark-urgent
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The footer displays:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
☎ next task: 30s ring
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The next `agent_settled` notification includes Bark `call=1`, which repeats the selected ringtone for approximately 30 seconds. The state is then cleared, even if delivery fails, so an unrelated later task cannot ring unexpectedly.
|
|
125
|
+
|
|
126
|
+
Run the command again before task completion to cancel it. Urgent state is session-only and does not register or override Pi's `/fast` command.
|
|
127
|
+
|
|
128
|
+
## Encrypted delivery
|
|
129
|
+
|
|
130
|
+
pi-toolkit implements Bark's documented encryption format:
|
|
131
|
+
|
|
132
|
+
- AES-128
|
|
133
|
+
- CBC mode
|
|
134
|
+
- PKCS#7 padding
|
|
135
|
+
- 16-byte Key
|
|
136
|
+
- 16-byte fixed IV
|
|
137
|
+
- Base64-encoded ciphertext
|
|
138
|
+
|
|
139
|
+
The title, body, group, sound, icon, level, and call fields are included in the encrypted JSON payload. Delivery fields required by Bark are also sent outside the ciphertext when necessary for routing and processing.
|
|
140
|
+
|
|
141
|
+
Bark Server and Apple Push Notification service can observe delivery metadata such as the device key, ciphertext, interruption level, and whether continuous ringing was requested. They cannot read the encrypted title or body.
|
|
142
|
+
|
|
143
|
+
Use the following command to rotate both the Key and IV:
|
|
144
|
+
|
|
145
|
+
```text
|
|
146
|
+
/toolkit:bark-encryption-rotate
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The old configuration remains active until the new encrypted test is readable on the device and explicitly confirmed.
|
|
150
|
+
|
|
151
|
+
### Plaintext mode
|
|
152
|
+
|
|
153
|
+
Encrypted delivery can be disabled globally from:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
/toolkit:bark-notify-settings
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Disabling encryption requires a second confirmation because Bark Server and Apple Push Notification service will be able to read notification content. Projects cannot override encryption mode or downgrade themselves to plaintext.
|
|
160
|
+
|
|
161
|
+
## Automatic events
|
|
162
|
+
|
|
163
|
+
| Event | Default | Description |
|
|
164
|
+
|---|---:|---|
|
|
165
|
+
| `agent_settled` | On | The task is fully complete |
|
|
166
|
+
| `agent_end` | Off | Every low-level agent response; may be noisy |
|
|
167
|
+
| `session_shutdown` | Off | The Pi session is closing |
|
|
168
|
+
| `ask_user_prompt` | Off | An ask-user extension is waiting for input |
|
|
169
|
+
| `permission_request` | Off | A permission extension is waiting for approval |
|
|
170
|
+
|
|
171
|
+
Configure these events with `/toolkit:bark-notify-settings`.
|
|
172
|
+
|
|
173
|
+
## LLM recap
|
|
174
|
+
|
|
175
|
+
Completion notifications use Pi's provider-neutral model adapter to summarize the final assistant response into one sentence.
|
|
176
|
+
|
|
177
|
+
- Enabled by default
|
|
178
|
+
- Uses the current session model by default
|
|
179
|
+
- A fixed model can be selected with `/toolkit:bark-recap-model`
|
|
180
|
+
- Input is limited to 2,000 characters
|
|
181
|
+
- Output is limited to 100 tokens
|
|
182
|
+
- Requests time out after 10 seconds
|
|
183
|
+
- Model, authentication, network, and empty-output failures fall back to a local sanitized summary
|
|
184
|
+
|
|
185
|
+
Sensitive values are redacted before text reaches the recap model. Model output is sanitized again before encryption and at the Bark transport boundary.
|
|
186
|
+
|
|
187
|
+
## Agent notification tool
|
|
188
|
+
|
|
189
|
+
Agents can send an explicit Bark notification through the same global configuration:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
toolkit_notify({
|
|
193
|
+
title: "Build Complete",
|
|
194
|
+
message: "All checks passed.",
|
|
195
|
+
priority: "normal"
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Priority mapping:
|
|
200
|
+
|
|
201
|
+
| Toolkit priority | Bark level |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `low` | `passive` |
|
|
204
|
+
| `normal` | `active` |
|
|
205
|
+
| `high` | `timeSensitive` |
|
|
206
|
+
|
|
207
|
+
`high` deliberately does not map to `critical`, avoiding an unexpected bypass of silent or focus modes.
|
|
208
|
+
|
|
209
|
+
## Configuration
|
|
210
|
+
|
|
211
|
+
### Global configuration
|
|
212
|
+
|
|
213
|
+
```text
|
|
214
|
+
~/.pi/agent/pi-toolkit/bark.json
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
This file contains the Bark server URL, device key, encryption Key and IV, defaults, and global event settings. It is written atomically with file mode `0600`; its parent directory uses mode `0700`.
|
|
218
|
+
|
|
219
|
+
### Project configuration
|
|
220
|
+
|
|
221
|
+
```text
|
|
222
|
+
<project>/.pi/pi-toolkit/bark.json
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Project configuration may override non-secret preferences such as enabled state, group, sound, icon, level, events, and recap settings. It cannot contain or override the server URL, device key, encryption mode, encryption Key, or IV.
|
|
226
|
+
|
|
227
|
+
## Image paste placeholders
|
|
228
|
+
|
|
229
|
+
Paste an image into the Pi TUI using Pi's standard shortcut:
|
|
230
|
+
|
|
231
|
+
- macOS and Linux: `Ctrl+V`
|
|
232
|
+
- Windows and WSL: `Alt+V`
|
|
233
|
+
|
|
234
|
+
Instead of showing a long temporary path, the editor displays:
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
[Image 1]
|
|
238
|
+
[Image 2]
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The marker remains in the prompt so it can be referenced naturally, while the corresponding image is submitted as image content.
|
|
242
|
+
|
|
243
|
+
Additional behavior:
|
|
244
|
+
|
|
245
|
+
- Numbering restarts from `[Image 1]` after each successful submission.
|
|
246
|
+
- PNG, JPEG, WebP, and GIF are supported.
|
|
247
|
+
- Session restore and input history reconnect markers to their original images.
|
|
248
|
+
- Removing an unsubmitted marker cleans up its temporary file.
|
|
249
|
+
- Submission is blocked and editor text is restored if the active model does not support image input.
|
|
250
|
+
- Existing custom editor components remain composable.
|
|
251
|
+
|
|
252
|
+
Do not install a second copy at `~/.pi/agent/extensions/image-placeholders`; duplicate installations would wrap the editor twice.
|
|
253
|
+
|
|
254
|
+
## Security model
|
|
255
|
+
|
|
256
|
+
- Notification text is sanitized before recap, before encryption, and at the Bark transport boundary.
|
|
257
|
+
- Redaction covers common passwords, API keys, tokens, authorization headers, private keys, Bark URLs, cloud-token formats, the configured Bark device key, and the configured encryption Key and IV.
|
|
258
|
+
- Matching values are replaced with `[REDACTED]`.
|
|
259
|
+
- Encryption credentials are displayed only in an interactive setup overlay and are not added to the Pi conversation.
|
|
260
|
+
- Copying a Key or IV places a secret on the system clipboard; overwrite it after configuring the Bark app.
|
|
261
|
+
- The Bark device key must still be sent to the configured Bark Server as a routing credential.
|
|
262
|
+
- Redaction is defense in depth, not a guarantee that arbitrary unlabeled secrets can always be recognized. Avoid including credentials in prompts and final responses.
|
|
263
|
+
- Pi extensions execute local code with the user's permissions. Review packages before installation.
|
|
264
|
+
|
|
265
|
+
## Development
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
npm install
|
|
269
|
+
npm run check
|
|
270
|
+
npm pack --dry-run
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The test suite covers configuration migration, encrypted payload compatibility, secret redaction, setup confirmation, one-shot urgent notifications, Bark API behavior, and image placeholder lifecycle handling.
|
|
274
|
+
|
|
275
|
+
## License
|
|
276
|
+
|
|
277
|
+
MIT. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { BarkEncryptionConfig, BarkGlobalConfig, BarkNotification } from "./types.js";
|
|
2
|
+
import { encryptPayload } from "./crypto.js";
|
|
3
|
+
import { redactSensitiveText } from "./redact.js";
|
|
4
|
+
|
|
5
|
+
interface BarkApiResponse {
|
|
6
|
+
code?: number;
|
|
7
|
+
message?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type BarkClientConfig = Pick<BarkGlobalConfig, "serverUrl" | "deviceKey"> & {
|
|
11
|
+
encryption?: BarkEncryptionConfig;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface BarkClient {
|
|
15
|
+
send(config: BarkClientConfig, notification: BarkNotification): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a Bark client. The fetch adapter is injectable so the external HTTP
|
|
20
|
+
* seam is exercised through the same small interface in production and tests.
|
|
21
|
+
*/
|
|
22
|
+
export function createBarkClient(fetchAdapter: typeof fetch = globalThis.fetch): BarkClient {
|
|
23
|
+
return {
|
|
24
|
+
async send(config, notification): Promise<void> {
|
|
25
|
+
if (!config.deviceKey) throw new Error("Bark device key is required");
|
|
26
|
+
const baseUrl = config.serverUrl.replace(/\/+$/, "");
|
|
27
|
+
const endpoint = baseUrl.endsWith("/push") ? baseUrl : `${baseUrl}/push`;
|
|
28
|
+
const redact = (value: string): string => redactSensitiveText(value, [
|
|
29
|
+
config.deviceKey,
|
|
30
|
+
config.encryption?.key,
|
|
31
|
+
config.encryption?.iv,
|
|
32
|
+
]);
|
|
33
|
+
const payload = {
|
|
34
|
+
title: redact(notification.title),
|
|
35
|
+
body: redact(notification.body),
|
|
36
|
+
...(notification.group ? { group: redact(notification.group) } : {}),
|
|
37
|
+
...(notification.sound ? { sound: notification.sound } : {}),
|
|
38
|
+
...(notification.icon ? { icon: redact(notification.icon) } : {}),
|
|
39
|
+
};
|
|
40
|
+
const delivery = {
|
|
41
|
+
...(notification.level ? { level: notification.level } : {}),
|
|
42
|
+
...(notification.call ? { call: "1" } : {}),
|
|
43
|
+
};
|
|
44
|
+
const body = config.encryption?.mode === "encrypted"
|
|
45
|
+
? {
|
|
46
|
+
device_key: config.deviceKey,
|
|
47
|
+
// Bark replaces outer userInfo with this decrypted map before later processors run.
|
|
48
|
+
ciphertext: encryptPayload({ ...payload, ...delivery }, {
|
|
49
|
+
key: config.encryption.key ?? "",
|
|
50
|
+
iv: config.encryption.iv ?? "",
|
|
51
|
+
}),
|
|
52
|
+
...delivery,
|
|
53
|
+
}
|
|
54
|
+
: { device_key: config.deviceKey, ...payload, ...delivery };
|
|
55
|
+
|
|
56
|
+
const response = await fetchAdapter(endpoint, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
59
|
+
body: JSON.stringify(body),
|
|
60
|
+
});
|
|
61
|
+
const responseText = await response.text().catch(() => "");
|
|
62
|
+
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new Error(`Bark API error ${response.status}: ${responseText || "<no body>"}`);
|
|
65
|
+
}
|
|
66
|
+
if (!responseText) return;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const result = JSON.parse(responseText) as BarkApiResponse | null;
|
|
70
|
+
if (result && typeof result.code === "number" && result.code !== 200) {
|
|
71
|
+
throw new Error(`Bark API error ${result.code}: ${result.message || "unknown error"}`);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof SyntaxError) return;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
BarkEventKey,
|
|
14
|
+
BarkGlobalConfig,
|
|
15
|
+
BarkLevel,
|
|
16
|
+
BarkProjectConfig,
|
|
17
|
+
ResolvedBarkConfig,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
|
|
20
|
+
const LEVELS = new Set<BarkLevel>(["critical", "active", "timeSensitive", "passive"]);
|
|
21
|
+
const EVENT_KEYS: BarkEventKey[] = [
|
|
22
|
+
"agent_settled",
|
|
23
|
+
"agent_end",
|
|
24
|
+
"session_shutdown",
|
|
25
|
+
"ask_user_prompt",
|
|
26
|
+
"permission_request",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_GLOBAL_CONFIG: BarkGlobalConfig = {
|
|
30
|
+
version: 2,
|
|
31
|
+
enabled: false,
|
|
32
|
+
serverUrl: "https://api.day.app",
|
|
33
|
+
group: "Pi",
|
|
34
|
+
sound: "anticipate",
|
|
35
|
+
level: "active",
|
|
36
|
+
encryption: {
|
|
37
|
+
mode: "encrypted",
|
|
38
|
+
},
|
|
39
|
+
events: {
|
|
40
|
+
agent_settled: true,
|
|
41
|
+
agent_end: false,
|
|
42
|
+
session_shutdown: false,
|
|
43
|
+
ask_user_prompt: false,
|
|
44
|
+
permission_request: false,
|
|
45
|
+
},
|
|
46
|
+
recap: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function cloneDefaults(): BarkGlobalConfig {
|
|
52
|
+
return {
|
|
53
|
+
...DEFAULT_GLOBAL_CONFIG,
|
|
54
|
+
encryption: { ...DEFAULT_GLOBAL_CONFIG.encryption },
|
|
55
|
+
events: { ...DEFAULT_GLOBAL_CONFIG.events },
|
|
56
|
+
recap: { ...DEFAULT_GLOBAL_CONFIG.recap },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toolkitConfigRoot(): string {
|
|
61
|
+
if (process.env.PI_TOOLKIT_CONFIG_DIR) return process.env.PI_TOOLKIT_CONFIG_DIR;
|
|
62
|
+
const home = process.env.HOME || homedir();
|
|
63
|
+
return join(home, ".pi", "agent", "pi-toolkit");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function globalConfigPath(): string {
|
|
67
|
+
return join(toolkitConfigRoot(), "bark.json");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function projectConfigPath(cwd: string): string {
|
|
71
|
+
return join(cwd, ".pi", "pi-toolkit", "bark.json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readJson(path: string): unknown {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function optionalString(value: unknown): string | undefined {
|
|
87
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function optionalNullableString(value: unknown): string | null | undefined {
|
|
91
|
+
if (value === null) return null;
|
|
92
|
+
return optionalString(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseEvents(value: unknown): Partial<Record<BarkEventKey, boolean>> {
|
|
96
|
+
if (!isRecord(value)) return {};
|
|
97
|
+
const result: Partial<Record<BarkEventKey, boolean>> = {};
|
|
98
|
+
for (const key of EVENT_KEYS) {
|
|
99
|
+
if (typeof value[key] === "boolean") result[key] = value[key];
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseGlobal(value: unknown): BarkGlobalConfig {
|
|
105
|
+
const config = cloneDefaults();
|
|
106
|
+
if (!isRecord(value)) return config;
|
|
107
|
+
|
|
108
|
+
if (typeof value.enabled === "boolean") config.enabled = value.enabled;
|
|
109
|
+
config.serverUrl = optionalString(value.serverUrl) ?? config.serverUrl;
|
|
110
|
+
if ("deviceKey" in value) config.deviceKey = optionalString(value.deviceKey);
|
|
111
|
+
if ("group" in value) config.group = optionalString(value.group);
|
|
112
|
+
if ("sound" in value) config.sound = optionalString(value.sound);
|
|
113
|
+
if ("icon" in value) config.icon = optionalString(value.icon);
|
|
114
|
+
if (typeof value.level === "string" && LEVELS.has(value.level as BarkLevel)) {
|
|
115
|
+
config.level = value.level as BarkLevel;
|
|
116
|
+
}
|
|
117
|
+
if (value.version === 1 && !isRecord(value.encryption)) {
|
|
118
|
+
// Existing installs stay plaintext until the user completes encrypted setup.
|
|
119
|
+
config.encryption = { mode: "plaintext" };
|
|
120
|
+
} else if (isRecord(value.encryption)) {
|
|
121
|
+
config.encryption = {
|
|
122
|
+
mode: value.encryption.mode === "plaintext" ? "plaintext" : "encrypted",
|
|
123
|
+
key: optionalString(value.encryption.key),
|
|
124
|
+
iv: optionalString(value.encryption.iv),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
config.events = { ...config.events, ...parseEvents(value.events) };
|
|
128
|
+
if (isRecord(value.recap)) {
|
|
129
|
+
if (typeof value.recap.enabled === "boolean") config.recap.enabled = value.recap.enabled;
|
|
130
|
+
if ("model" in value.recap) config.recap.model = optionalString(value.recap.model);
|
|
131
|
+
}
|
|
132
|
+
return config;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseProject(value: unknown): BarkProjectConfig | undefined {
|
|
136
|
+
if (!isRecord(value)) return undefined;
|
|
137
|
+
const config: BarkProjectConfig = { version: 1 };
|
|
138
|
+
if (typeof value.enabled === "boolean") config.enabled = value.enabled;
|
|
139
|
+
config.group = optionalNullableString(value.group);
|
|
140
|
+
config.sound = optionalNullableString(value.sound);
|
|
141
|
+
config.icon = optionalNullableString(value.icon);
|
|
142
|
+
if (typeof value.level === "string" && LEVELS.has(value.level as BarkLevel)) {
|
|
143
|
+
config.level = value.level as BarkLevel;
|
|
144
|
+
}
|
|
145
|
+
const events = parseEvents(value.events);
|
|
146
|
+
if (Object.keys(events).length > 0) config.events = events;
|
|
147
|
+
if (isRecord(value.recap)) {
|
|
148
|
+
const recap: BarkProjectConfig["recap"] = {};
|
|
149
|
+
if (typeof value.recap.enabled === "boolean") recap.enabled = value.recap.enabled;
|
|
150
|
+
const model = optionalString(value.recap.model);
|
|
151
|
+
if (model) recap.model = model;
|
|
152
|
+
if (Object.keys(recap).length > 0) config.recap = recap;
|
|
153
|
+
}
|
|
154
|
+
return config;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function loadGlobalConfig(): BarkGlobalConfig {
|
|
158
|
+
return parseGlobal(readJson(globalConfigPath()));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function loadProjectConfig(cwd: string): BarkProjectConfig | undefined {
|
|
162
|
+
const path = projectConfigPath(cwd);
|
|
163
|
+
if (!existsSync(path)) return undefined;
|
|
164
|
+
return parseProject(readJson(path));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function applyNullable(base: string | undefined, override: string | null | undefined): string | undefined {
|
|
168
|
+
if (override === undefined) return base;
|
|
169
|
+
return override === null ? undefined : override;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function loadResolvedConfig(cwd: string): ResolvedBarkConfig {
|
|
173
|
+
const global = loadGlobalConfig();
|
|
174
|
+
const project = loadProjectConfig(cwd);
|
|
175
|
+
if (!project) return { ...global, projectOverride: false };
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...global,
|
|
179
|
+
enabled: project.enabled ?? global.enabled,
|
|
180
|
+
group: applyNullable(global.group, project.group),
|
|
181
|
+
sound: applyNullable(global.sound, project.sound),
|
|
182
|
+
icon: applyNullable(global.icon, project.icon),
|
|
183
|
+
level: project.level ?? global.level,
|
|
184
|
+
events: { ...global.events, ...project.events },
|
|
185
|
+
recap: { ...global.recap, ...project.recap },
|
|
186
|
+
projectOverride: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function writePrivateJson(path: string, value: unknown): void {
|
|
191
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
192
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
193
|
+
try {
|
|
194
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
195
|
+
renameSync(temp, path);
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(temp, { force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function saveGlobalConfig(config: BarkGlobalConfig): void {
|
|
202
|
+
const normalized = parseGlobal(config);
|
|
203
|
+
normalized.version = 2;
|
|
204
|
+
writePrivateJson(globalConfigPath(), normalized);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function saveProjectConfig(cwd: string, config: BarkProjectConfig): void {
|
|
208
|
+
const normalized = parseProject(config) ?? { version: 1 as const };
|
|
209
|
+
normalized.version = 1;
|
|
210
|
+
writePrivateJson(projectConfigPath(cwd), normalized);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function isValidEncryptionValue(value: string | undefined): boolean {
|
|
214
|
+
return typeof value === "string" && Buffer.byteLength(value, "utf8") === 16;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function validateResolvedConfig(config: ResolvedBarkConfig): string[] {
|
|
218
|
+
const errors: string[] = [];
|
|
219
|
+
if (!config.serverUrl) errors.push("Bark server URL is required");
|
|
220
|
+
if (!config.deviceKey) errors.push("Bark device key is required");
|
|
221
|
+
try {
|
|
222
|
+
const url = new URL(config.serverUrl);
|
|
223
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
224
|
+
errors.push("Bark server URL must use http or https");
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
errors.push("Bark server URL is invalid");
|
|
228
|
+
}
|
|
229
|
+
if (config.encryption.mode === "encrypted") {
|
|
230
|
+
if (!isValidEncryptionValue(config.encryption.key)) errors.push("Bark encryption key must be exactly 16 bytes");
|
|
231
|
+
if (!isValidEncryptionValue(config.encryption.iv)) errors.push("Bark encryption IV must be exactly 16 bytes");
|
|
232
|
+
}
|
|
233
|
+
return errors;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function normalizeBarkDeviceKey(value: string): string {
|
|
237
|
+
const trimmed = value.trim();
|
|
238
|
+
if (!/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
239
|
+
try {
|
|
240
|
+
const url = new URL(trimmed);
|
|
241
|
+
const firstSegment = url.pathname.split("/").filter(Boolean)[0];
|
|
242
|
+
return firstSegment && firstSegment !== "push" ? firstSegment : trimmed;
|
|
243
|
+
} catch {
|
|
244
|
+
return trimmed;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createCipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { isValidEncryptionValue } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export interface BarkEncryptionCredentials {
|
|
6
|
+
key: string;
|
|
7
|
+
iv: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function generateEncryptionCredentials(): BarkEncryptionCredentials {
|
|
11
|
+
return {
|
|
12
|
+
key: randomBytes(12).toString("base64url"),
|
|
13
|
+
iv: randomBytes(12).toString("base64url"),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function encryptText(plaintext: string, credentials: BarkEncryptionCredentials): string {
|
|
18
|
+
if (!isValidEncryptionValue(credentials.key) || !isValidEncryptionValue(credentials.iv)) {
|
|
19
|
+
throw new Error("Bark AES-128-CBC key and IV must each be exactly 16 bytes");
|
|
20
|
+
}
|
|
21
|
+
const cipher = createCipheriv(
|
|
22
|
+
"aes-128-cbc",
|
|
23
|
+
Buffer.from(credentials.key, "utf8"),
|
|
24
|
+
Buffer.from(credentials.iv, "utf8"),
|
|
25
|
+
);
|
|
26
|
+
return Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]).toString("base64");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function encryptPayload(payload: Record<string, string>, credentials: BarkEncryptionCredentials): string {
|
|
30
|
+
return encryptText(JSON.stringify(payload), credentials);
|
|
31
|
+
}
|