@diegopetrucci/pi-extensions 0.1.22 → 0.1.25
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 +6 -1
- package/extensions/dirty-repo-guard/README.md +47 -0
- package/extensions/dirty-repo-guard/index.ts +56 -0
- package/extensions/dirty-repo-guard/package.json +27 -0
- package/extensions/inline-bash/README.md +54 -0
- package/extensions/inline-bash/index.ts +107 -0
- package/extensions/inline-bash/package.json +27 -0
- package/extensions/librarian/index.ts +30 -8
- package/extensions/librarian/package.json +1 -1
- package/extensions/todo/README.md +43 -0
- package/extensions/todo/index.ts +301 -0
- package/extensions/todo/package.json +30 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@ A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions
|
|
|
5
5
|
- [`confirm-destructive`](./extensions/confirm-destructive): Confirms before destructive session actions like clear, switch, and fork.
|
|
6
6
|
- [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
|
|
7
7
|
- [`context-inspector`](./extensions/context-inspector): Adds `/context`, a local self-contained HTML dashboard that breaks down where the current session context is going, with category overview, top offenders, and drilldown search.
|
|
8
|
+
- [`dirty-repo-guard`](./extensions/dirty-repo-guard): Prompts before new sessions, session switches, or forks when the current git repo has uncommitted changes.
|
|
9
|
+
- [`inline-bash`](./extensions/inline-bash): Expands `!{command}` snippets in user prompts by running them through bash before the prompt reaches the agent.
|
|
8
10
|
- [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache enabled by default under the OS user cache directory, toggleable with `/librarian-cache`, with cached repos expiring after 30 days of non-use.
|
|
9
11
|
- [`minimal-footer`](./extensions/minimal-footer): Replaces pi's built-in footer with a minimal configurable two-line layout: branch/repo on the first line, context/model on the second, optional `DUMB ZONE`, plus OpenAI Codex 5-hour and 7-day usage when available.
|
|
10
12
|
- [`notify`](./extensions/notify): Sends configurable terminal, desktop, bell, and sound notifications when pi finishes and is ready for input.
|
|
@@ -12,6 +14,9 @@ A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions
|
|
|
12
14
|
- [`oracle`](./extensions/oracle): Adds an Amp-style read-only oracle tool that auto-selects the strongest reasoning model on the current provider/subscription, covers pi’s built-in providers with hardcoded rankings, sets reasoning to xhigh by default, and shows live status while running.
|
|
13
15
|
- [`permission-gate`](./extensions/permission-gate): Prompts for confirmation before dangerous bash commands like `rm -rf`, `sudo`, and `chmod 777`.
|
|
14
16
|
- [`quiet-tools`](./extensions/quiet-tools): Renders collapsed built-in tool rows as a one-line invocation plus an expand hint without changing model-visible tool results; toggle temporarily with `/quiet-tools`.
|
|
17
|
+
- [`todo`](./extensions/todo): Adds a branch-aware `todo` tool for the agent and a `/todos` viewer for users.
|
|
18
|
+
|
|
19
|
+
> Security note: the full collection includes `inline-bash`, which executes `!{...}` snippets from prompt text through your local shell before the agent sees them. Treat pasted prompts as shell code; `permission-gate` does not intercept these user-prompt expansions.
|
|
15
20
|
|
|
16
21
|
(For the full list of pi extensions I use, [check out my dotfiles](https://github.com/diegopetrucci/dot/blob/main/.pi/agent/settings.json).)
|
|
17
22
|
|
|
@@ -26,7 +31,7 @@ pi install npm:@diegopetrucci/pi-extensions
|
|
|
26
31
|
Or pin the GitHub package to this release:
|
|
27
32
|
|
|
28
33
|
```bash
|
|
29
|
-
pi install git:github.com/diegopetrucci/pi-extensions@v0.1.
|
|
34
|
+
pi install git:github.com/diegopetrucci/pi-extensions@v0.1.25
|
|
30
35
|
```
|
|
31
36
|
|
|
32
37
|
Or a specific extension:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# dirty-repo-guard
|
|
2
|
+
|
|
3
|
+
A small pi extension that prompts before session changes when the current git repo has uncommitted changes.
|
|
4
|
+
|
|
5
|
+
This is copied from the original `dirty-repo-guard.ts` example in [`earendil-works/pi-mono`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/examples/extensions/dirty-repo-guard.ts) and kept basically the same.
|
|
6
|
+
|
|
7
|
+
## What it checks
|
|
8
|
+
|
|
9
|
+
Before creating a new session, switching sessions, or forking, the extension runs:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
git status --porcelain
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
If the command reports changed files, pi asks whether to proceed anyway. If pi is running without an interactive UI, matching actions are cancelled by default.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
### Standalone npm package
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:@diegopetrucci/pi-dirty-repo-guard
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Collection package
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install npm:@diegopetrucci/pi-extensions
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### GitHub package
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install git:github.com/diegopetrucci/pi-extensions
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then reload pi:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
/reload
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Notes
|
|
44
|
+
|
|
45
|
+
- Hooks `session_before_switch` and `session_before_fork`.
|
|
46
|
+
- Allows session changes outside git repos.
|
|
47
|
+
- Cancels the action when the user declines.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dirty Repo Guard Extension
|
|
3
|
+
*
|
|
4
|
+
* Prevents session changes when there are uncommitted git changes.
|
|
5
|
+
* Useful to ensure work is committed before switching context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
async function checkDirtyRepo(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
ctx: ExtensionContext,
|
|
13
|
+
action: string,
|
|
14
|
+
): Promise<{ cancel: boolean } | undefined> {
|
|
15
|
+
// Check for uncommitted changes
|
|
16
|
+
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
17
|
+
|
|
18
|
+
if (code !== 0) {
|
|
19
|
+
// Not a git repo, allow the action
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hasChanges = stdout.trim().length > 0;
|
|
24
|
+
if (!hasChanges) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!ctx.hasUI) {
|
|
29
|
+
// In non-interactive mode, block by default
|
|
30
|
+
return { cancel: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Count changed files
|
|
34
|
+
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
|
35
|
+
|
|
36
|
+
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
|
37
|
+
"Yes, proceed anyway",
|
|
38
|
+
"No, let me commit first",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
if (choice !== "Yes, proceed anyway") {
|
|
42
|
+
ctx.ui.notify("Commit your changes first", "warning");
|
|
43
|
+
return { cancel: true };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function (pi: ExtensionAPI) {
|
|
48
|
+
pi.on("session_before_switch", async (event, ctx) => {
|
|
49
|
+
const action = event.reason === "new" ? "new session" : "switch session";
|
|
50
|
+
return checkDirtyRepo(pi, ctx, action);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
pi.on("session_before_fork", async (_event, ctx) => {
|
|
54
|
+
return checkDirtyRepo(pi, ctx, "fork");
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diegopetrucci/pi-dirty-repo-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A pi extension that prompts before session changes when the current git repo has uncommitted changes.",
|
|
5
|
+
"keywords": ["pi-package", "pi", "git", "safety"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/diegopetrucci/pi-extensions.git",
|
|
10
|
+
"directory": "extensions/dirty-repo-guard"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.ts",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"index.ts"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# inline-bash
|
|
2
|
+
|
|
3
|
+
A pi extension that expands inline bash commands in user prompts before they are sent to the agent.
|
|
4
|
+
|
|
5
|
+
This started from the original `inline-bash.ts` example in [`earendil-works/pi-mono`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/examples/extensions/inline-bash.ts), with small packaging and safety tweaks.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Write inline commands with `!{...}`:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
What's in !{pwd}?
|
|
13
|
+
The current branch is !{git branch --show-current} and status: !{git status --short}
|
|
14
|
+
My node version is !{node --version}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The extension runs each command and replaces the `!{command}` pattern with trimmed stdout or stderr. Each expansion is capped at 50,000 characters.
|
|
18
|
+
|
|
19
|
+
Whole-line `!command` syntax is left alone so pi's built-in shell-command behavior still works.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
### Standalone npm package
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install npm:@diegopetrucci/pi-inline-bash
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Collection package
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi install npm:@diegopetrucci/pi-extensions
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### GitHub package
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi install git:github.com/diegopetrucci/pi-extensions
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then reload pi:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
/reload
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Notes
|
|
48
|
+
|
|
49
|
+
- Hooks the `input` event.
|
|
50
|
+
- Inline commands run through `bash -c` with a 30-second timeout.
|
|
51
|
+
- Extension-injected follow-up messages are ignored to avoid implicit shell execution from other extensions.
|
|
52
|
+
- In interactive mode, pi shows a notification summarizing the expanded commands.
|
|
53
|
+
- Treat prompt text containing `!{...}` as shell code; only use this extension where prompt authors are trusted.
|
|
54
|
+
- `permission-gate` does not intercept these user-prompt expansions because they run before agent tool calls.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline Bash Extension - expands inline bash commands in user prompts.
|
|
3
|
+
*
|
|
4
|
+
* Start pi with this extension:
|
|
5
|
+
* pi -e ./examples/extensions/inline-bash.ts
|
|
6
|
+
*
|
|
7
|
+
* Then type prompts with inline bash:
|
|
8
|
+
* What's in !{pwd}?
|
|
9
|
+
* The current branch is !{git branch --show-current} and status: !{git status --short}
|
|
10
|
+
* My node version is !{node --version}
|
|
11
|
+
*
|
|
12
|
+
* The !{command} patterns are executed and replaced with their trimmed output
|
|
13
|
+
* before the prompt is sent to the agent. Very large expansions are truncated.
|
|
14
|
+
*
|
|
15
|
+
* Note: Regular !command syntax (whole-line bash) is preserved and works as before.
|
|
16
|
+
*/
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
export default function (pi: ExtensionAPI) {
|
|
20
|
+
const PATTERN = /!\{([^}]+)\}/g;
|
|
21
|
+
const TIMEOUT_MS = 30000;
|
|
22
|
+
const MAX_OUTPUT_CHARS = 50000;
|
|
23
|
+
|
|
24
|
+
const trimAndLimit = (output: string) => {
|
|
25
|
+
const trimmed = output.trim();
|
|
26
|
+
if (trimmed.length <= MAX_OUTPUT_CHARS) return trimmed;
|
|
27
|
+
return `${trimmed.slice(0, MAX_OUTPUT_CHARS)}\n[inline-bash output truncated after ${MAX_OUTPUT_CHARS} characters]`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
pi.on("input", async (event, ctx) => {
|
|
31
|
+
const text = event.text;
|
|
32
|
+
|
|
33
|
+
// Only expand prompts supplied by the user/client. Extension-injected follow-ups
|
|
34
|
+
// should not get an implicit path to local shell execution.
|
|
35
|
+
if (event.source === "extension") {
|
|
36
|
+
return { action: "continue" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Don't process if it's a whole-line bash command (starts with !)
|
|
40
|
+
// This preserves the existing !command behavior
|
|
41
|
+
if (text.trimStart().startsWith("!") && !text.trimStart().startsWith("!{")) {
|
|
42
|
+
return { action: "continue" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check if there are any inline bash patterns
|
|
46
|
+
if (!PATTERN.test(text)) {
|
|
47
|
+
return { action: "continue" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reset regex state after test()
|
|
51
|
+
PATTERN.lastIndex = 0;
|
|
52
|
+
|
|
53
|
+
let result = text;
|
|
54
|
+
const expansions: Array<{ command: string; output: string; error?: string }> = [];
|
|
55
|
+
|
|
56
|
+
// Find all matches first (to avoid issues with replacing while iterating)
|
|
57
|
+
const matches: Array<{ full: string; command: string }> = [];
|
|
58
|
+
let match = PATTERN.exec(text);
|
|
59
|
+
while (match) {
|
|
60
|
+
matches.push({ full: match[0], command: match[1] });
|
|
61
|
+
match = PATTERN.exec(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Execute each command and collect results
|
|
65
|
+
for (const { full, command } of matches) {
|
|
66
|
+
try {
|
|
67
|
+
const bashResult = await pi.exec("bash", ["-c", command], {
|
|
68
|
+
timeout: TIMEOUT_MS,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const output = bashResult.stdout || bashResult.stderr || "";
|
|
72
|
+
const trimmed = trimAndLimit(output);
|
|
73
|
+
|
|
74
|
+
if (bashResult.code !== 0 && bashResult.stderr) {
|
|
75
|
+
expansions.push({
|
|
76
|
+
command,
|
|
77
|
+
output: trimmed,
|
|
78
|
+
error: `exit code ${bashResult.code}`,
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
expansions.push({ command, output: trimmed });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
result = result.replace(full, trimmed);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
87
|
+
expansions.push({ command, output: "", error: errorMsg });
|
|
88
|
+
result = result.replace(full, `[error: ${errorMsg}]`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Show what was expanded (if UI available)
|
|
93
|
+
if (ctx.hasUI && expansions.length > 0) {
|
|
94
|
+
const summary = expansions
|
|
95
|
+
.map((e) => {
|
|
96
|
+
const status = e.error ? ` (${e.error})` : "";
|
|
97
|
+
const preview = e.output.length > 50 ? `${e.output.slice(0, 50)}...` : e.output;
|
|
98
|
+
return `!{${e.command}}${status} -> "${preview}"`;
|
|
99
|
+
})
|
|
100
|
+
.join("\n");
|
|
101
|
+
|
|
102
|
+
ctx.ui.notify(`Expanded ${expansions.length} inline command(s):\n${summary}`, "info");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { action: "transform", text: result, images: event.images };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diegopetrucci/pi-inline-bash",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A pi extension that expands inline bash commands in user prompts.",
|
|
5
|
+
"keywords": ["pi-package", "pi", "bash", "input", "prompt"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/diegopetrucci/pi-extensions.git",
|
|
10
|
+
"directory": "extensions/inline-bash"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.ts",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"index.ts"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -260,8 +260,13 @@ function getCacheConfigPath(): string {
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
function parseCacheMode(value: unknown): CacheMode | undefined {
|
|
263
|
-
if (
|
|
264
|
-
|
|
263
|
+
if (typeof value === "string") {
|
|
264
|
+
const normalized = value.trim().toLowerCase();
|
|
265
|
+
if (normalized === "enabled" || normalized === "on" || normalized === "true") return "enabled";
|
|
266
|
+
if (normalized === "disabled" || normalized === "off" || normalized === "false") return "disabled";
|
|
267
|
+
}
|
|
268
|
+
if (value === true) return "enabled";
|
|
269
|
+
if (value === false) return "disabled";
|
|
265
270
|
return undefined;
|
|
266
271
|
}
|
|
267
272
|
|
|
@@ -280,8 +285,8 @@ async function readCachePreference(): Promise<CacheMode> {
|
|
|
280
285
|
parseCacheMode(parsed.cache?.enabled) ??
|
|
281
286
|
DEFAULT_CACHE_MODE
|
|
282
287
|
);
|
|
283
|
-
} catch {
|
|
284
|
-
return DEFAULT_CACHE_MODE;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled";
|
|
285
290
|
}
|
|
286
291
|
}
|
|
287
292
|
|
|
@@ -324,7 +329,12 @@ function resolveToolPath(cwd: string, rawPath: string): string {
|
|
|
324
329
|
}
|
|
325
330
|
|
|
326
331
|
function getBlockedBashReason(command: string, options: { workspace: string; cacheRoot: string; cacheEnabled: boolean }): string | undefined {
|
|
327
|
-
|
|
332
|
+
if (!options.cacheEnabled && command.includes(options.cacheRoot)) {
|
|
333
|
+
return "Local repo checkout cache is disabled for this Librarian call.";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let scan = command.split(options.workspace).join("<WORKSPACE>");
|
|
337
|
+
if (options.cacheEnabled) scan = scan.split(options.cacheRoot).join("<CACHE>");
|
|
328
338
|
if (/(^|[\n;&|()])\s*\//.test(scan)) return "Librarian bash blocks absolute-path executables.";
|
|
329
339
|
|
|
330
340
|
const destructiveLocal = /(^|[\n;&|()])\s*(?:sudo|su|rm|rmdir|mv|cp|chmod|chown|dd|truncate|killall|pkill|launchctl|osascript|pbcopy|pbpaste|eval|exec|xargs)(?=$|[\s;&|()])/;
|
|
@@ -580,9 +590,21 @@ export default function librarianExtension(pi: ExtensionAPI) {
|
|
|
580
590
|
await fs.mkdir(path.join(workspace, "repos"), { recursive: true });
|
|
581
591
|
|
|
582
592
|
const cacheRoot = getUserCacheReposRoot();
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
593
|
+
let cacheDecision = resolveCacheDecision(cachePreference);
|
|
594
|
+
let cleanup: { deleted: number; errors: string[] } = { deleted: 0, errors: [] };
|
|
595
|
+
if (cacheDecision.enabled) {
|
|
596
|
+
try {
|
|
597
|
+
await fs.mkdir(cacheRoot, { recursive: true });
|
|
598
|
+
cleanup = await cleanupExpiredCache(cacheRoot);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
601
|
+
cacheDecision = {
|
|
602
|
+
enabled: false,
|
|
603
|
+
reason: `cache setup failed (${message}); using GitHub API/temp files only`,
|
|
604
|
+
};
|
|
605
|
+
cleanup = { deleted: 0, errors: [`cache setup: ${message}`] };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
586
608
|
|
|
587
609
|
const details: LibrarianDetails = {
|
|
588
610
|
status: "running",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-librarian",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A pi GitHub research scout with a toggleable local repo checkout cache under the user's OS cache directory.",
|
|
5
5
|
"keywords": ["pi-package", "pi", "github", "research", "subagent", "cache"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# todo
|
|
2
|
+
|
|
3
|
+
A pi extension that adds a branch-aware todo list managed by the agent.
|
|
4
|
+
|
|
5
|
+
This started from the original `todo.ts` example in [`earendil-works/pi-mono`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/examples/extensions/todo.ts), with small packaging and snapshot-safety tweaks.
|
|
6
|
+
|
|
7
|
+
## What it adds
|
|
8
|
+
|
|
9
|
+
- a `todo` tool for the agent to list, add, toggle, and clear todos
|
|
10
|
+
- a `/todos` command for users to view todos on the current branch
|
|
11
|
+
- todo state stored as snapshots in tool result details, so session branches reconstruct the right todo list for that point in history
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
### Standalone npm package
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@diegopetrucci/pi-todo
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Collection package
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:@diegopetrucci/pi-extensions
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### GitHub package
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install git:github.com/diegopetrucci/pi-extensions
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then reload pi:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
/reload
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
- Hooks `session_start` and `session_tree` to reconstruct branch-local todo state.
|
|
42
|
+
- The `todo` tool supports `list`, `add`, `toggle`, and `clear` actions.
|
|
43
|
+
- The `/todos` command opens an interactive viewer and requires UI mode.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Extension - Demonstrates state management via session entries
|
|
3
|
+
*
|
|
4
|
+
* This extension:
|
|
5
|
+
* - Registers a `todo` tool for the LLM to manage todos
|
|
6
|
+
* - Registers a `/todos` command for users to view the list
|
|
7
|
+
*
|
|
8
|
+
* State is stored in tool result details (not external files), which allows
|
|
9
|
+
* proper branching - when you branch, the todo state is automatically
|
|
10
|
+
* correct for that point in history.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
|
|
18
|
+
interface Todo {
|
|
19
|
+
id: number;
|
|
20
|
+
text: string;
|
|
21
|
+
done: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TodoDetails {
|
|
25
|
+
action: "list" | "add" | "toggle" | "clear";
|
|
26
|
+
todos: Todo[];
|
|
27
|
+
nextId: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cloneTodos = (items: Todo[]): Todo[] => items.map((todo) => ({ ...todo }));
|
|
32
|
+
|
|
33
|
+
const TodoParams = Type.Object({
|
|
34
|
+
action: StringEnum(["list", "add", "toggle", "clear"] as const),
|
|
35
|
+
text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
|
|
36
|
+
id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* UI component for the /todos command
|
|
41
|
+
*/
|
|
42
|
+
class TodoListComponent {
|
|
43
|
+
private todos: Todo[];
|
|
44
|
+
private theme: Theme;
|
|
45
|
+
private onClose: () => void;
|
|
46
|
+
private cachedWidth?: number;
|
|
47
|
+
private cachedLines?: string[];
|
|
48
|
+
|
|
49
|
+
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
|
|
50
|
+
this.todos = todos;
|
|
51
|
+
this.theme = theme;
|
|
52
|
+
this.onClose = onClose;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
handleInput(data: string): void {
|
|
56
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
57
|
+
this.onClose();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
render(width: number): string[] {
|
|
62
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
63
|
+
return this.cachedLines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
const th = this.theme;
|
|
68
|
+
|
|
69
|
+
lines.push("");
|
|
70
|
+
const title = th.fg("accent", " Todos ");
|
|
71
|
+
const headerLine =
|
|
72
|
+
th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
|
|
73
|
+
lines.push(truncateToWidth(headerLine, width));
|
|
74
|
+
lines.push("");
|
|
75
|
+
|
|
76
|
+
if (this.todos.length === 0) {
|
|
77
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
|
|
78
|
+
} else {
|
|
79
|
+
const done = this.todos.filter((t) => t.done).length;
|
|
80
|
+
const total = this.todos.length;
|
|
81
|
+
lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
|
|
82
|
+
lines.push("");
|
|
83
|
+
|
|
84
|
+
for (const todo of this.todos) {
|
|
85
|
+
const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
|
|
86
|
+
const id = th.fg("accent", `#${todo.id}`);
|
|
87
|
+
const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
|
|
88
|
+
lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
|
94
|
+
lines.push("");
|
|
95
|
+
|
|
96
|
+
this.cachedWidth = width;
|
|
97
|
+
this.cachedLines = lines;
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
invalidate(): void {
|
|
102
|
+
this.cachedWidth = undefined;
|
|
103
|
+
this.cachedLines = undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default function (pi: ExtensionAPI) {
|
|
108
|
+
// In-memory state (reconstructed from session on load)
|
|
109
|
+
let todos: Todo[] = [];
|
|
110
|
+
let nextId = 1;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reconstruct state from session entries.
|
|
114
|
+
* Scans tool results for this tool and applies them in order.
|
|
115
|
+
*/
|
|
116
|
+
const reconstructState = (ctx: ExtensionContext) => {
|
|
117
|
+
todos = [];
|
|
118
|
+
nextId = 1;
|
|
119
|
+
|
|
120
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
121
|
+
if (entry.type !== "message") continue;
|
|
122
|
+
const msg = entry.message;
|
|
123
|
+
if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
|
|
124
|
+
|
|
125
|
+
const details = msg.details as TodoDetails | undefined;
|
|
126
|
+
if (details) {
|
|
127
|
+
todos = cloneTodos(details.todos);
|
|
128
|
+
nextId = details.nextId;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Reconstruct state on session events
|
|
134
|
+
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
135
|
+
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
136
|
+
|
|
137
|
+
// Register the todo tool for the LLM
|
|
138
|
+
pi.registerTool({
|
|
139
|
+
name: "todo",
|
|
140
|
+
label: "Todo",
|
|
141
|
+
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
|
|
142
|
+
parameters: TodoParams,
|
|
143
|
+
|
|
144
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
145
|
+
switch (params.action) {
|
|
146
|
+
case "list":
|
|
147
|
+
return {
|
|
148
|
+
content: [
|
|
149
|
+
{
|
|
150
|
+
type: "text",
|
|
151
|
+
text: todos.length
|
|
152
|
+
? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
|
|
153
|
+
: "No todos",
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
details: { action: "list", todos: cloneTodos(todos), nextId } as TodoDetails,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
case "add": {
|
|
160
|
+
if (!params.text) {
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text", text: "Error: text required for add" }],
|
|
163
|
+
details: { action: "add", todos: cloneTodos(todos), nextId, error: "text required" } as TodoDetails,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const newTodo: Todo = { id: nextId++, text: params.text, done: false };
|
|
167
|
+
todos = [...todos, newTodo];
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
|
|
170
|
+
details: { action: "add", todos: cloneTodos(todos), nextId } as TodoDetails,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case "toggle": {
|
|
175
|
+
if (params.id === undefined) {
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: "text", text: "Error: id required for toggle" }],
|
|
178
|
+
details: { action: "toggle", todos: cloneTodos(todos), nextId, error: "id required" } as TodoDetails,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const targetId = params.id;
|
|
182
|
+
const todo = todos.find((t) => t.id === targetId);
|
|
183
|
+
if (!todo) {
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: "text", text: `Todo #${targetId} not found` }],
|
|
186
|
+
details: {
|
|
187
|
+
action: "toggle",
|
|
188
|
+
todos: cloneTodos(todos),
|
|
189
|
+
nextId,
|
|
190
|
+
error: `#${targetId} not found`,
|
|
191
|
+
} as TodoDetails,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
todos = todos.map((t) => (t.id === targetId ? { ...t, done: !t.done } : t));
|
|
195
|
+
const updated = todos.find((t) => t.id === targetId)!;
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: "text", text: `Todo #${updated.id} ${updated.done ? "completed" : "uncompleted"}` }],
|
|
198
|
+
details: { action: "toggle", todos: cloneTodos(todos), nextId } as TodoDetails,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "clear": {
|
|
203
|
+
const count = todos.length;
|
|
204
|
+
todos = [];
|
|
205
|
+
nextId = 1;
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: `Cleared ${count} todos` }],
|
|
208
|
+
details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
default:
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
|
|
215
|
+
details: {
|
|
216
|
+
action: "list",
|
|
217
|
+
todos: cloneTodos(todos),
|
|
218
|
+
nextId,
|
|
219
|
+
error: `unknown action: ${params.action}`,
|
|
220
|
+
} as TodoDetails,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
renderCall(args, theme, _context) {
|
|
226
|
+
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
|
227
|
+
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
|
228
|
+
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
|
229
|
+
return new Text(text, 0, 0);
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
renderResult(result, { expanded }, theme, _context) {
|
|
233
|
+
const details = result.details as TodoDetails | undefined;
|
|
234
|
+
if (!details) {
|
|
235
|
+
const text = result.content[0];
|
|
236
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (details.error) {
|
|
240
|
+
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const todoList = details.todos;
|
|
244
|
+
|
|
245
|
+
switch (details.action) {
|
|
246
|
+
case "list": {
|
|
247
|
+
if (todoList.length === 0) {
|
|
248
|
+
return new Text(theme.fg("dim", "No todos"), 0, 0);
|
|
249
|
+
}
|
|
250
|
+
let listText = theme.fg("muted", `${todoList.length} todo(s):`);
|
|
251
|
+
const display = expanded ? todoList : todoList.slice(0, 5);
|
|
252
|
+
for (const t of display) {
|
|
253
|
+
const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
|
|
254
|
+
const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
255
|
+
listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
256
|
+
}
|
|
257
|
+
if (!expanded && todoList.length > 5) {
|
|
258
|
+
listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
|
|
259
|
+
}
|
|
260
|
+
return new Text(listText, 0, 0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case "add": {
|
|
264
|
+
const added = todoList[todoList.length - 1];
|
|
265
|
+
return new Text(
|
|
266
|
+
theme.fg("success", "✓ Added ") +
|
|
267
|
+
theme.fg("accent", `#${added.id}`) +
|
|
268
|
+
" " +
|
|
269
|
+
theme.fg("muted", added.text),
|
|
270
|
+
0,
|
|
271
|
+
0,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case "toggle": {
|
|
276
|
+
const text = result.content[0];
|
|
277
|
+
const msg = text?.type === "text" ? text.text : "";
|
|
278
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case "clear":
|
|
282
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Register the /todos command for users
|
|
288
|
+
pi.registerCommand("todos", {
|
|
289
|
+
description: "Show all todos on the current branch",
|
|
290
|
+
handler: async (_args, ctx) => {
|
|
291
|
+
if (!ctx.hasUI) {
|
|
292
|
+
ctx.ui.notify("/todos requires interactive mode", "error");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
297
|
+
return new TodoListComponent(todos, theme, () => done());
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diegopetrucci/pi-todo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A pi extension that adds a branch-aware todo tool and /todos viewer.",
|
|
5
|
+
"keywords": ["pi-package", "pi", "todo", "tool", "session"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/diegopetrucci/pi-extensions.git",
|
|
10
|
+
"directory": "extensions/todo"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.ts",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"index.ts"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-ai": "*",
|
|
26
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
27
|
+
"@earendil-works/pi-tui": "*",
|
|
28
|
+
"typebox": "*"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "A collection of pi extensions, including a GitHub librarian with toggleable local repo checkout caching, a minimal custom footer, an Amp-style oracle, a 200k context cap for auto-compaction, a local HTML context inspector, OpenAI Codex Fast mode controls, quiet one-line collapsed invocation previews, a permission gate for dangerous bash commands, confirm-before-destructive session actions, and terminal notifications when pi is ready for input.",
|
|
3
|
+
"version": "0.1.25",
|
|
4
|
+
"description": "A collection of pi extensions, including a GitHub librarian with toggleable local repo checkout caching, a minimal custom footer, an Amp-style oracle, a 200k context cap for auto-compaction, a local HTML context inspector, a dirty repository guard for session changes, inline bash prompt expansion, a branch-aware todo tool, OpenAI Codex Fast mode controls, quiet one-line collapsed invocation previews, a permission gate for dangerous bash commands, confirm-before-destructive session actions, and terminal notifications when pi is ready for input.",
|
|
5
5
|
"keywords": ["pi-package", "pi", "terminal", "agent"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -32,12 +32,15 @@
|
|
|
32
32
|
"./extensions/oracle/index.ts",
|
|
33
33
|
"./extensions/context-cap/index.ts",
|
|
34
34
|
"./extensions/context-inspector/index.ts",
|
|
35
|
+
"./extensions/dirty-repo-guard/index.ts",
|
|
36
|
+
"./extensions/inline-bash/index.ts",
|
|
35
37
|
"./extensions/librarian/index.ts",
|
|
36
38
|
"./extensions/quiet-tools/index.ts",
|
|
37
39
|
"./extensions/permission-gate/index.ts",
|
|
38
40
|
"./extensions/confirm-destructive/index.ts",
|
|
39
41
|
"./extensions/notify/index.ts",
|
|
40
|
-
"./extensions/openai-fast/index.ts"
|
|
42
|
+
"./extensions/openai-fast/index.ts",
|
|
43
|
+
"./extensions/todo/index.ts"
|
|
41
44
|
],
|
|
42
45
|
"image": "https://raw.githubusercontent.com/diegopetrucci/pi-extensions/main/assets/oracle-preview.svg"
|
|
43
46
|
}
|