pi-code 0.1.0 → 0.2.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/README.md +24 -7
- package/extensions/claude-rules.ts +7 -3
- package/extensions/context-imports.ts +56 -29
- package/extensions/git-checkpoint.ts +16 -16
- package/extensions/hooks.ts +2 -1
- package/extensions/mcp.ts +24 -10
- package/extensions/memory.ts +15 -4
- package/extensions/notify.ts +6 -4
- package/extensions/output-styles.ts +1 -1
- package/extensions/plan-mode/index.ts +83 -58
- package/extensions/plan-mode/utils.ts +76 -16
- package/extensions/project-trust.ts +65 -0
- package/extensions/question.ts +63 -49
- package/extensions/subagent/agents.ts +12 -11
- package/extensions/subagent/index.ts +609 -465
- package/extensions/todo.ts +48 -31
- package/extensions/web.ts +49 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# pi-code
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-code)
|
|
4
|
+
[](https://www.npmjs.com/package/pi-code)
|
|
5
|
+
[](https://github.com/ilovepixelart/pi-code/blob/main/LICENSE)
|
|
6
|
+
\
|
|
7
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
8
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
9
|
+
\
|
|
10
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
11
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
12
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
13
|
+
|
|
3
14
|
Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, and subagents.
|
|
4
15
|
|
|
5
16
|

|
|
@@ -7,12 +18,18 @@ Claude Code experience for the [pi](https://pi.dev) coding agent, in one package
|
|
|
7
18
|
## Install
|
|
8
19
|
|
|
9
20
|
```bash
|
|
10
|
-
pi install pi-code
|
|
21
|
+
pi install npm:pi-code # from npm
|
|
22
|
+
pi install -l npm:pi-code # project-local instead, writes .pi/settings.json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Other sources:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
11
28
|
pi install git:github.com/ilovepixelart/pi-code
|
|
12
|
-
pi install
|
|
29
|
+
pi install ./pi-code # local checkout, then /reload after edits
|
|
13
30
|
```
|
|
14
31
|
|
|
15
|
-
One `pi install
|
|
32
|
+
One `pi install` and everything below loads on the next start. `pi list` shows what is installed, `pi config` toggles individual resources, and `pi update pi-code` upgrades it. Each feature is an extension under [`extensions/`](extensions).
|
|
16
33
|
|
|
17
34
|
## What it does
|
|
18
35
|
|
|
@@ -24,8 +41,8 @@ One `pi install`, everything below loads. Each feature is an extension under [`e
|
|
|
24
41
|
| Hooks | `.claude/settings.json` hooks on pi lifecycle events | `hooks.ts` |
|
|
25
42
|
| Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
|
|
26
43
|
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips | `context-imports.ts` |
|
|
27
|
-
| MCP servers |
|
|
28
|
-
| Subagents / Task | `~/.claude/agents`
|
|
44
|
+
| MCP servers | `~/.claude.json`, `~/.pi/agent/mcp.json`, `.mcp.json`, `.pi/mcp.json` (later wins); stdio, HTTP, SSE | `mcp.ts` |
|
|
45
|
+
| Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
|
|
29
46
|
| Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
|
|
30
47
|
| Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
|
|
31
48
|
| Checkpoints / rewind | shadow-repo snapshots, hard-reset restore | `git-checkpoint.ts` |
|
|
@@ -37,7 +54,7 @@ One `pi install`, everything below loads. Each feature is an extension under [`e
|
|
|
37
54
|
|
|
38
55
|
`CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
|
|
39
56
|
|
|
40
|
-
Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
|
|
57
|
+
Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
|
|
41
58
|
|
|
42
59
|
## Development
|
|
43
60
|
|
|
@@ -48,4 +65,4 @@ scripts/e2e.sh # drives the real pi TUI via tmux (needs a working model
|
|
|
48
65
|
scripts/record-demos.sh # re-records demos/*.tape with vhs at low thinking
|
|
49
66
|
```
|
|
50
67
|
|
|
51
|
-
Extensions live in `extensions/`, tests in `tests/`. Install
|
|
68
|
+
Extensions live in `extensions/`, tests in `tests/`. Install a local checkout with `pi install ./pi-code`, then `/reload` after edits.
|
|
@@ -42,10 +42,14 @@ function parsePaths(frontmatter: string): string[] {
|
|
|
42
42
|
const inline = lines[index].replace(/^\s*paths\s*:/, '').trim()
|
|
43
43
|
if (inline) return splitInline(inline)
|
|
44
44
|
const items: string[] = []
|
|
45
|
+
// Matched with string ops rather than a regex: the equivalent pattern needs two
|
|
46
|
+
// adjacent whitespace quantifiers, which backtracks super-linearly on long lines.
|
|
45
47
|
for (let i = index + 1; i < lines.length; i++) {
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
48
|
-
|
|
48
|
+
const entry = lines[i].trimStart()
|
|
49
|
+
if (!entry.startsWith('-')) break
|
|
50
|
+
const value = entry.slice(1).trim()
|
|
51
|
+
if (!value) break
|
|
52
|
+
items.push(unquote(value))
|
|
49
53
|
}
|
|
50
54
|
return items
|
|
51
55
|
}
|
|
@@ -54,6 +54,41 @@ export interface ImportedFile {
|
|
|
54
54
|
body: string
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The `@path` targets of a context file, in document order, skipping fenced code blocks. */
|
|
58
|
+
function importTargets(content: string): string[] {
|
|
59
|
+
const targets: string[] = []
|
|
60
|
+
let inFence = false
|
|
61
|
+
for (const line of content.split('\n')) {
|
|
62
|
+
if (line.trimStart().startsWith('```')) {
|
|
63
|
+
inFence = !inFence
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (inFence) continue
|
|
67
|
+
for (const match of line.matchAll(/(^|\s)@(\S+)/g)) targets.push(match[2])
|
|
68
|
+
}
|
|
69
|
+
return targets
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, or unreadable. */
|
|
73
|
+
function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>): { real: string; body: string } | null {
|
|
74
|
+
const resolved = path.resolve(fromDir, expandHome(target, home))
|
|
75
|
+
let real: string
|
|
76
|
+
try {
|
|
77
|
+
real = fs.realpathSync(resolved)
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
if (seen.has(real)) return null
|
|
82
|
+
seen.add(real)
|
|
83
|
+
if (!isUnder(real, allowedRoots)) return null
|
|
84
|
+
try {
|
|
85
|
+
// real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
|
|
86
|
+
return { real, body: fs.readFileSync(real, 'utf-8') }
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
/**
|
|
58
93
|
* Collect the contents of every file transitively imported via `@path`, in
|
|
59
94
|
* discovery order. Imports are resolved through symlinks and kept within
|
|
@@ -62,38 +97,29 @@ export interface ImportedFile {
|
|
|
62
97
|
export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, depth = 0): ImportedFile[] {
|
|
63
98
|
if (depth >= MAX_IMPORT_DEPTH) return []
|
|
64
99
|
const out: ImportedFile[] = []
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
continue
|
|
70
|
-
}
|
|
71
|
-
if (inFence) continue
|
|
72
|
-
for (const match of line.matchAll(/(^|\s)@(\S+)/g)) {
|
|
73
|
-
const resolved = path.resolve(fromDir, expandHome(match[2], home))
|
|
74
|
-
let real: string
|
|
75
|
-
try {
|
|
76
|
-
real = fs.realpathSync(resolved)
|
|
77
|
-
} catch {
|
|
78
|
-
continue
|
|
79
|
-
}
|
|
80
|
-
if (seen.has(real)) continue
|
|
81
|
-
seen.add(real)
|
|
82
|
-
if (!isUnder(real, allowedRoots)) continue
|
|
83
|
-
let body: string
|
|
84
|
-
try {
|
|
85
|
-
// real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
|
|
86
|
-
body = fs.readFileSync(real, 'utf-8')
|
|
87
|
-
} catch {
|
|
88
|
-
continue
|
|
89
|
-
}
|
|
90
|
-
out.push({ path: real, body: body.trim() })
|
|
91
|
-
out.push(...collectImports(body, path.dirname(real), home, allowedRoots, seen, depth + 1))
|
|
92
|
-
}
|
|
100
|
+
for (const target of importTargets(content)) {
|
|
101
|
+
const file = readImport(target, fromDir, home, allowedRoots, seen)
|
|
102
|
+
if (!file) continue
|
|
103
|
+
out.push({ path: file.real, body: file.body.trim() }, ...collectImports(file.body, path.dirname(file.real), home, allowedRoots, seen, depth + 1))
|
|
93
104
|
}
|
|
94
105
|
return out
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Roots an importing file may pull from.
|
|
110
|
+
*
|
|
111
|
+
* A context file under the user's own config may reach the whole config; a project
|
|
112
|
+
* file may not. `~/.claude` holds `.credentials.json`, global settings and every
|
|
113
|
+
* project's transcripts, so granting those roots to a cloned repo's `CLAUDE.md`
|
|
114
|
+
* would let it read them into the system prompt.
|
|
115
|
+
*/
|
|
116
|
+
export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
|
|
117
|
+
const userRoots = realRoots([path.join(home, '.claude'), path.join(home, '.pi')])
|
|
118
|
+
const [real] = realRoots([importer])
|
|
119
|
+
const fromUserConfig = real !== undefined && isUnder(real, userRoots)
|
|
120
|
+
return fromUserConfig ? realRoots([cwd, ...userRoots]) : realRoots([cwd])
|
|
121
|
+
}
|
|
122
|
+
|
|
97
123
|
export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
98
124
|
pi.on('before_agent_start', async (event) => {
|
|
99
125
|
const contextFiles: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
|
|
@@ -101,13 +127,14 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
101
127
|
|
|
102
128
|
const home = os.homedir()
|
|
103
129
|
const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
|
|
104
|
-
const allowedRoots = realRoots([cwd, path.join(home, '.claude'), path.join(home, '.pi')])
|
|
105
130
|
// Seed with the loaded context file paths so pi's own files are never re-imported.
|
|
106
131
|
const seen = realRoots(contextFiles.map((file) => file.path))
|
|
107
132
|
const seenSet = new Set(seen)
|
|
108
133
|
|
|
109
134
|
const imported: ImportedFile[] = []
|
|
110
135
|
for (const file of contextFiles) {
|
|
136
|
+
// Roots are scoped per importing file: a project file never reaches user config.
|
|
137
|
+
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
111
138
|
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet))
|
|
112
139
|
}
|
|
113
140
|
if (imported.length === 0) return
|
|
@@ -65,7 +65,21 @@ function checkpointLabel(checkpoint: Checkpoint, index: number): string {
|
|
|
65
65
|
return `${index + 1}. ${time} ${checkpoint.prompt || '(empty prompt)'}${marker}`
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
async function restoreConversation(ctx: ExtensionCommandContext, entryId: string): Promise<boolean> {
|
|
69
|
+
try {
|
|
70
|
+
// navigateTree's published type omits editorText, but it is present at runtime (docs/extensions.md)
|
|
71
|
+
const result = (await ctx.navigateTree(entryId, { summarize: false })) as { cancelled: boolean; editorText?: string }
|
|
72
|
+
if (result.cancelled) return false
|
|
73
|
+
if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
|
|
74
|
+
return true
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
77
|
+
ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
69
83
|
const checkpoints = new Map<string, Checkpoint>()
|
|
70
84
|
let pending: { ref: string; createdAt: string } | undefined
|
|
71
85
|
let shadowDir: string | undefined
|
|
@@ -117,20 +131,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
117
131
|
return true
|
|
118
132
|
}
|
|
119
133
|
|
|
120
|
-
async function restoreConversation(ctx: ExtensionCommandContext, entryId: string): Promise<boolean> {
|
|
121
|
-
try {
|
|
122
|
-
// navigateTree's published type omits editorText, but it is present at runtime (docs/extensions.md)
|
|
123
|
-
const result = (await ctx.navigateTree(entryId, { summarize: false })) as { cancelled: boolean; editorText?: string }
|
|
124
|
-
if (result.cancelled) return false
|
|
125
|
-
if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
|
|
126
|
-
return true
|
|
127
|
-
} catch (error) {
|
|
128
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
129
|
-
ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
|
|
130
|
-
return false
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
134
|
async function runRestoreMode(ctx: ExtensionCommandContext, checkpoint: Checkpoint): Promise<void> {
|
|
135
135
|
const mode = await ctx.ui.select('Restore mode:', [...RESTORE_MODES])
|
|
136
136
|
if (!mode) return
|
|
@@ -178,7 +178,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
178
|
ctx.ui.notify('No checkpoints recorded yet', 'info')
|
|
179
179
|
return
|
|
180
180
|
}
|
|
181
|
-
const labels = ordered.map(checkpointLabel)
|
|
181
|
+
const labels = ordered.map((checkpoint, index) => checkpointLabel(checkpoint, index))
|
|
182
182
|
const choice = await ctx.ui.select('Rewind to checkpoint:', labels)
|
|
183
183
|
if (!choice) return
|
|
184
184
|
const checkpoint = ordered[labels.indexOf(choice)]
|
package/extensions/hooks.ts
CHANGED
|
@@ -112,7 +112,8 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
|
|
|
112
112
|
|
|
113
113
|
export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
|
|
114
114
|
new Promise((resolve) => {
|
|
115
|
-
|
|
115
|
+
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
116
|
+
const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
116
117
|
let stdout = ''
|
|
117
118
|
let stderr = ''
|
|
118
119
|
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
|
package/extensions/mcp.ts
CHANGED
|
@@ -18,16 +18,22 @@ import * as os from 'node:os'
|
|
|
18
18
|
import * as path from 'node:path'
|
|
19
19
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
20
20
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
|
|
22
|
+
// the old spec exist, so this stays as a fallback for the migration period.
|
|
23
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' // NOSONAR
|
|
24
|
+
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
23
25
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
24
26
|
import { Type } from 'typebox'
|
|
25
27
|
|
|
26
28
|
const CONNECT_TIMEOUT_MS = 10_000
|
|
27
29
|
const CALL_TIMEOUT_MS = 120_000
|
|
28
30
|
const MAX_INLINE_RESULT = 50_000
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
+
// Tool names an MCP server must never take over. formatToolName always emits
|
|
32
|
+
// `<server>_<tool>`, so only names containing an underscore are actually reachable:
|
|
33
|
+
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
34
|
+
// These are pi-code's own tools, and mcp.ts registers before the extensions owning
|
|
35
|
+
// them, so without this guard a server named `web` would replace the SSRF-checked fetch.
|
|
36
|
+
const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete'])
|
|
31
37
|
|
|
32
38
|
export interface StdioServerConfig {
|
|
33
39
|
command: string
|
|
@@ -82,7 +88,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
export function formatToolName(server: string, tool: string): string {
|
|
85
|
-
return `${server}_${tool}`.
|
|
91
|
+
return `${server}_${tool}`.replaceAll('-', '_')
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
export function normalizeSchema(schema: unknown): object {
|
|
@@ -143,7 +149,10 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): P
|
|
|
143
149
|
async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
144
150
|
const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
145
151
|
if (isStdio(config)) {
|
|
146
|
-
|
|
152
|
+
// Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
|
|
153
|
+
// process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
|
|
154
|
+
// for being launched. A server that needs a variable names it in its own env block.
|
|
155
|
+
const env: Record<string, string> = { ...getDefaultEnvironment() }
|
|
147
156
|
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = interpolateEnv(value)
|
|
148
157
|
const transport = new StdioClientTransport({
|
|
149
158
|
command: config.command,
|
|
@@ -167,7 +176,7 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
167
176
|
} catch (error) {
|
|
168
177
|
if (String(error).includes('Unauthorized')) throw error
|
|
169
178
|
const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
170
|
-
const transport = new SSEClientTransport(url, { requestInit: { headers } })
|
|
179
|
+
const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: deliberate legacy fallback
|
|
171
180
|
await withTimeout(fallback.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
|
|
172
181
|
return fallback
|
|
173
182
|
}
|
|
@@ -229,11 +238,16 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
229
238
|
}
|
|
230
239
|
}
|
|
231
240
|
|
|
232
|
-
|
|
233
|
-
await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
|
|
234
|
-
|
|
241
|
+
let userConnected = false
|
|
235
242
|
let projectConnected = false
|
|
243
|
+
|
|
236
244
|
pi.on('session_start', async (_event, ctx) => {
|
|
245
|
+
// Connecting spawns processes and opens sockets, so it belongs here rather than in
|
|
246
|
+
// the factory: pi runs the factory for invocations that never start a session.
|
|
247
|
+
if (!userConnected) {
|
|
248
|
+
userConnected = true
|
|
249
|
+
await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
|
|
250
|
+
}
|
|
237
251
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
|
|
238
252
|
if (!projectConnected && ctx.isProjectTrusted?.()) {
|
|
239
253
|
projectConnected = true
|
package/extensions/memory.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as fs from 'node:fs'
|
|
|
11
11
|
import * as os from 'node:os'
|
|
12
12
|
import * as path from 'node:path'
|
|
13
13
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
14
|
-
import type
|
|
14
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type ExtensionAPI, formatSize, truncateHead } from '@earendil-works/pi-coding-agent'
|
|
15
15
|
import { Type } from 'typebox'
|
|
16
16
|
|
|
17
17
|
const INDEX_FILE = 'MEMORY.md'
|
|
@@ -28,8 +28,8 @@ export function slugifyName(name: string): string {
|
|
|
28
28
|
return (
|
|
29
29
|
name
|
|
30
30
|
.toLowerCase()
|
|
31
|
-
.
|
|
32
|
-
.
|
|
31
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
32
|
+
.replaceAll(/^-|-$/g, '')
|
|
33
33
|
.slice(0, 64) || 'memory'
|
|
34
34
|
)
|
|
35
35
|
}
|
|
@@ -63,6 +63,17 @@ function readIndex(dir: string): string {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Keep a memory inside pi's context budget. truncateHead keeps whole lines, so a
|
|
68
|
+
* single oversized line yields nothing; fall back to a hard slice in that case.
|
|
69
|
+
*/
|
|
70
|
+
function capForContext(body: string): string {
|
|
71
|
+
const cut = truncateHead(body, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES })
|
|
72
|
+
if (!cut.truncated) return body
|
|
73
|
+
const kept = cut.content || body.slice(0, DEFAULT_MAX_BYTES)
|
|
74
|
+
return `${kept}\n\n[truncated: ${formatSize(cut.totalBytes)} total]`
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
67
78
|
let dir = memoryDir(process.cwd())
|
|
68
79
|
|
|
@@ -105,7 +116,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
105
116
|
if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
|
|
106
117
|
try {
|
|
107
118
|
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
108
|
-
return { content: [{ type: 'text' as const, text: body }], details: {} }
|
|
119
|
+
return { content: [{ type: 'text' as const, text: capForContext(body) }], details: {} }
|
|
109
120
|
} catch {
|
|
110
121
|
return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
|
|
111
122
|
}
|
package/extensions/notify.ts
CHANGED
|
@@ -30,9 +30,11 @@ function notifyOSC99(title: string, body: string): void {
|
|
|
30
30
|
|
|
31
31
|
function notifyWindows(title: string, body: string): void {
|
|
32
32
|
const { execFile } = require('node:child_process')
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
33
|
+
// Resolve powershell from a fixed system path rather than through PATH, and let the callback
|
|
34
|
+
// capture a spawn failure instead of an unhandled 'error' event crashing the host.
|
|
35
|
+
const root = process.env.SystemRoot ?? String.raw`C:\Windows`
|
|
36
|
+
const powershell = String.raw`${root}\System32\WindowsPowerShell\v1.0\powershell.exe`
|
|
37
|
+
execFile(powershell, ['-NoProfile', '-Command', windowsToastScript(title, body)], () => {})
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
function notify(title: string, body: string): void {
|
|
@@ -45,7 +47,7 @@ function notify(title: string, body: string): void {
|
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
export default function (pi: ExtensionAPI) {
|
|
50
|
+
export default function notifyExtension(pi: ExtensionAPI) {
|
|
49
51
|
pi.on('agent_end', async () => {
|
|
50
52
|
notify('Pi', 'Ready for input')
|
|
51
53
|
})
|
|
@@ -26,7 +26,7 @@ export interface OutputStyle {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function field(frontmatter: string, key: string): string {
|
|
29
|
-
const match = new RegExp(
|
|
29
|
+
const match = new RegExp(String.raw`^\s*${key}\s*:\s*(.+)$`, 'm').exec(frontmatter)
|
|
30
30
|
return match ? match[1].trim().replace(/^["']|["']$/g, '') : ''
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { AgentMessage } from '@earendil-works/pi-agent-core'
|
|
16
16
|
import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
|
|
17
|
-
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
18
18
|
import { Key } from '@earendil-works/pi-tui'
|
|
19
19
|
import { Type } from 'typebox'
|
|
20
20
|
import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
|
|
@@ -35,6 +35,23 @@ function getTextContent(message: AssistantMessage): string {
|
|
|
35
35
|
.join('\n')
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Last element matching the predicate (the lib target predates Array.prototype.findLast)
|
|
39
|
+
function findLast<T>(items: T[], match: (item: T) => boolean): T | undefined {
|
|
40
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
41
|
+
if (match(items[i])) return items[i]
|
|
42
|
+
}
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Index of the last plan-mode-execute entry, or -1 when the current run never started one
|
|
47
|
+
function findLastExecuteIndex(entries: SessionEntry[]): number {
|
|
48
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
49
|
+
const entry = entries[i] as { customType?: string }
|
|
50
|
+
if (entry.customType === 'plan-mode-execute') return i
|
|
51
|
+
}
|
|
52
|
+
return -1
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
export default function planModeExtension(pi: ExtensionAPI): void {
|
|
39
56
|
let planModeEnabled = false
|
|
40
57
|
let executionMode = false
|
|
@@ -108,6 +125,62 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
108
125
|
})
|
|
109
126
|
}
|
|
110
127
|
|
|
128
|
+
// Announce completion and reset once every step is done
|
|
129
|
+
function finalizeCompletedExecution(ctx: ExtensionContext): void {
|
|
130
|
+
if (!todoItems.every((t) => t.completed)) return
|
|
131
|
+
const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
|
|
132
|
+
pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
|
|
133
|
+
executionMode = false
|
|
134
|
+
todoItems = []
|
|
135
|
+
restoreTools()
|
|
136
|
+
updateStatus(ctx)
|
|
137
|
+
persistState() // Save cleared state so resume doesn't restore old execution mode
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fall back to extracting a plan from the last assistant message's prose
|
|
141
|
+
function deriveTodosFromProse(messages: AgentMessage[]): void {
|
|
142
|
+
const lastAssistant = [...messages].reverse().find(isAssistantMessage)
|
|
143
|
+
if (!lastAssistant) return
|
|
144
|
+
const extracted = extractTodoItems(getTextContent(lastAssistant))
|
|
145
|
+
if (extracted.length > 0) {
|
|
146
|
+
todoItems = extracted
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Ask the user how to proceed after a plan is ready
|
|
151
|
+
async function promptPlanNextAction(ctx: ExtensionContext): Promise<void> {
|
|
152
|
+
const choice = await ctx.ui.select('Plan mode - what next?', [todoItems.length > 0 ? 'Execute the plan (track progress)' : 'Execute the plan', 'Stay in plan mode', 'Refine the plan'])
|
|
153
|
+
|
|
154
|
+
if (choice?.startsWith('Execute')) {
|
|
155
|
+
planModeEnabled = false
|
|
156
|
+
executionMode = todoItems.length > 0
|
|
157
|
+
planFromTool = false
|
|
158
|
+
restoreTools()
|
|
159
|
+
updateStatus(ctx)
|
|
160
|
+
|
|
161
|
+
const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
|
|
162
|
+
pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
|
|
163
|
+
} else if (choice === 'Refine the plan') {
|
|
164
|
+
const refinement = await ctx.ui.editor('Refine the plan:', '')
|
|
165
|
+
if (refinement?.trim()) {
|
|
166
|
+
pi.sendUserMessage(refinement.trim())
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Rebuild completion state from assistant messages after the last execute marker
|
|
172
|
+
function rescanCompletion(entries: SessionEntry[]): void {
|
|
173
|
+
const executeIndex = findLastExecuteIndex(entries)
|
|
174
|
+
const messages: AssistantMessage[] = []
|
|
175
|
+
for (let i = executeIndex + 1; i < entries.length; i++) {
|
|
176
|
+
const entry = entries[i]
|
|
177
|
+
if (entry.type === 'message' && 'message' in entry && isAssistantMessage(entry.message as AgentMessage)) {
|
|
178
|
+
messages.push(entry.message as AssistantMessage)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
markCompletedSteps(messages.map(getTextContent).join('\n'), todoItems)
|
|
182
|
+
}
|
|
183
|
+
|
|
111
184
|
pi.registerCommand('plan', {
|
|
112
185
|
description: 'Toggle plan mode (read-only exploration)',
|
|
113
186
|
handler: async (_args, ctx) => togglePlanMode(ctx),
|
|
@@ -193,7 +266,8 @@ You are in plan mode - a read-only exploration mode for safe code analysis.
|
|
|
193
266
|
Restrictions:
|
|
194
267
|
- You can only use: read, bash, grep, find, ls, question
|
|
195
268
|
- You CANNOT use: edit, write (file modifications are disabled)
|
|
196
|
-
- Bash is
|
|
269
|
+
- Bash is limited to an allowlist of read-only commands, checked per subcommand. Treat it
|
|
270
|
+
as a reminder of intent, not a sandbox: do not look for ways around it
|
|
197
271
|
|
|
198
272
|
Ask clarifying questions using the question tool.
|
|
199
273
|
|
|
@@ -244,15 +318,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
244
318
|
pi.on('agent_end', async (event, ctx) => {
|
|
245
319
|
// Check if execution is complete
|
|
246
320
|
if (executionMode && todoItems.length > 0) {
|
|
247
|
-
|
|
248
|
-
const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
|
|
249
|
-
pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
|
|
250
|
-
executionMode = false
|
|
251
|
-
todoItems = []
|
|
252
|
-
restoreTools()
|
|
253
|
-
updateStatus(ctx)
|
|
254
|
-
persistState() // Save cleared state so resume doesn't restore old execution mode
|
|
255
|
-
}
|
|
321
|
+
finalizeCompletedExecution(ctx)
|
|
256
322
|
return
|
|
257
323
|
}
|
|
258
324
|
|
|
@@ -260,13 +326,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
260
326
|
|
|
261
327
|
// Prefer an explicitly submitted plan; fall back to extracting from prose
|
|
262
328
|
if (!planFromTool) {
|
|
263
|
-
|
|
264
|
-
if (lastAssistant) {
|
|
265
|
-
const extracted = extractTodoItems(getTextContent(lastAssistant))
|
|
266
|
-
if (extracted.length > 0) {
|
|
267
|
-
todoItems = extracted
|
|
268
|
-
}
|
|
269
|
-
}
|
|
329
|
+
deriveTodosFromProse(event.messages)
|
|
270
330
|
}
|
|
271
331
|
|
|
272
332
|
// Show plan steps and prompt for next action
|
|
@@ -282,23 +342,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
282
342
|
)
|
|
283
343
|
}
|
|
284
344
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (choice?.startsWith('Execute')) {
|
|
288
|
-
planModeEnabled = false
|
|
289
|
-
executionMode = todoItems.length > 0
|
|
290
|
-
planFromTool = false
|
|
291
|
-
restoreTools()
|
|
292
|
-
updateStatus(ctx)
|
|
293
|
-
|
|
294
|
-
const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
|
|
295
|
-
pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
|
|
296
|
-
} else if (choice === 'Refine the plan') {
|
|
297
|
-
const refinement = await ctx.ui.editor('Refine the plan:', '')
|
|
298
|
-
if (refinement?.trim()) {
|
|
299
|
-
pi.sendUserMessage(refinement.trim())
|
|
300
|
-
}
|
|
301
|
-
}
|
|
345
|
+
await promptPlanNextAction(ctx)
|
|
302
346
|
})
|
|
303
347
|
|
|
304
348
|
// Restore state on session start/resume
|
|
@@ -310,7 +354,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
310
354
|
const entries = ctx.sessionManager.getEntries()
|
|
311
355
|
|
|
312
356
|
// Restore persisted state
|
|
313
|
-
const planModeEntry = entries
|
|
357
|
+
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
|
|
314
358
|
|
|
315
359
|
if (planModeEntry?.data) {
|
|
316
360
|
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled
|
|
@@ -318,30 +362,11 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
318
362
|
executionMode = planModeEntry.data.executing ?? executionMode
|
|
319
363
|
}
|
|
320
364
|
|
|
321
|
-
// On resume: re-scan messages to rebuild
|
|
322
|
-
//
|
|
365
|
+
// On resume: re-scan messages after the last "plan-mode-execute" to rebuild
|
|
366
|
+
// completion state without picking up [DONE:n] from previous plans
|
|
323
367
|
const isResume = planModeEntry !== undefined
|
|
324
368
|
if (isResume && executionMode && todoItems.length > 0) {
|
|
325
|
-
|
|
326
|
-
let executeIndex = -1
|
|
327
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
328
|
-
const entry = entries[i] as { type: string; customType?: string }
|
|
329
|
-
if (entry.customType === 'plan-mode-execute') {
|
|
330
|
-
executeIndex = i
|
|
331
|
-
break
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Only scan messages after the execute marker
|
|
336
|
-
const messages: AssistantMessage[] = []
|
|
337
|
-
for (let i = executeIndex + 1; i < entries.length; i++) {
|
|
338
|
-
const entry = entries[i]
|
|
339
|
-
if (entry.type === 'message' && 'message' in entry && isAssistantMessage(entry.message as AgentMessage)) {
|
|
340
|
-
messages.push(entry.message as AssistantMessage)
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
const allText = messages.map(getTextContent).join('\n')
|
|
344
|
-
markCompletedSteps(allText, todoItems)
|
|
369
|
+
rescanCompletion(entries)
|
|
345
370
|
}
|
|
346
371
|
|
|
347
372
|
if (planModeEnabled) {
|