pi-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Claude Hooks Extension
3
+ *
4
+ * Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
5
+ * a project's existing hooks work under pi:
6
+ * - PreToolUse -> pi `tool_call` (can block the tool)
7
+ * - PostToolUse -> pi `tool_execution_end` (fire-and-forget)
8
+ * - SessionStart-> pi `session_start` (fire-and-forget)
9
+ *
10
+ * Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
11
+ * hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
12
+ * `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
13
+ * `{"decision": "block"}`).
14
+ *
15
+ * Config is merged from ~/.claude/settings.json (always) plus the project's
16
+ * .claude/settings.json and settings.local.json (only when the project is
17
+ * trusted, since hooks execute arbitrary shell). Claude tool matchers are
18
+ * PascalCase (`Bash`); pi tool names are lowercase (`bash`), so matchers are
19
+ * applied case-insensitively.
20
+ *
21
+ * Docs: https://code.claude.com/docs/en/hooks.md
22
+ */
23
+
24
+ import { spawn } from 'node:child_process'
25
+ import * as fs from 'node:fs'
26
+ import * as os from 'node:os'
27
+ import * as path from 'node:path'
28
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
29
+
30
+ const DEFAULT_TIMEOUT_S = 60
31
+
32
+ interface HookCommand {
33
+ type?: string
34
+ command: string
35
+ timeout?: number
36
+ }
37
+ interface HookMatcher {
38
+ matcher?: string
39
+ hooks: HookCommand[]
40
+ }
41
+ export type HooksConfig = Record<string, HookMatcher[]>
42
+
43
+ export interface HookDecision {
44
+ block: boolean
45
+ reason?: string
46
+ }
47
+ export interface HookRunResult {
48
+ code: number
49
+ stdout: string
50
+ stderr: string
51
+ }
52
+ export type HookRunner = (command: string, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
53
+
54
+ /** Settings files to read, newest-winning. Project files load only when trusted. */
55
+ export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
56
+ const files = [path.join(home, '.claude', 'settings.json')]
57
+ if (trusted) files.push(path.join(cwd, '.claude', 'settings.json'), path.join(cwd, '.claude', 'settings.local.json'))
58
+ return files
59
+ }
60
+
61
+ export function loadHooks(files: string[]): HooksConfig {
62
+ const config: HooksConfig = {}
63
+ for (const file of files) {
64
+ let parsed: { hooks?: HooksConfig }
65
+ try {
66
+ parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
67
+ } catch {
68
+ continue
69
+ }
70
+ for (const [event, matchers] of Object.entries(parsed.hooks ?? {})) {
71
+ if (Array.isArray(matchers)) config[event] = [...(config[event] ?? []), ...matchers]
72
+ }
73
+ }
74
+ return config
75
+ }
76
+
77
+ function matcherApplies(matcher: string | undefined, name: string): boolean {
78
+ if (!matcher || matcher === '*') return true
79
+ try {
80
+ return new RegExp(`^(?:${matcher})$`, 'i').test(name)
81
+ } catch {
82
+ return matcher.toLowerCase() === name.toLowerCase()
83
+ }
84
+ }
85
+
86
+ /** Command specs whose matcher applies to the given tool/source name. */
87
+ export function matchingCommands(matchers: HookMatcher[] | undefined, name: string): HookCommand[] {
88
+ const result: HookCommand[] = []
89
+ for (const entry of matchers ?? []) {
90
+ if (matcherApplies(entry.matcher, name)) result.push(...(entry.hooks ?? []))
91
+ }
92
+ return result
93
+ }
94
+
95
+ function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string } | undefined {
96
+ try {
97
+ return JSON.parse(text)
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ /** Map a hook's exit code / output to a block-or-allow decision. */
104
+ export function interpretHookResult(code: number, stdout: string, stderr: string): HookDecision {
105
+ if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
106
+ const parsed = tryParseJson(stdout)
107
+ const specific = parsed?.hookSpecificOutput
108
+ if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
109
+ if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
110
+ return { block: false }
111
+ }
112
+
113
+ export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
114
+ new Promise((resolve) => {
115
+ const child = spawn('sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
116
+ let stdout = ''
117
+ let stderr = ''
118
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
119
+ child.stdout?.on('data', (chunk) => {
120
+ stdout += chunk
121
+ })
122
+ child.stderr?.on('data', (chunk) => {
123
+ stderr += chunk
124
+ })
125
+ child.on('close', (code) => {
126
+ clearTimeout(timer)
127
+ resolve({ code: code ?? 0, stdout, stderr })
128
+ })
129
+ child.on('error', () => {
130
+ clearTimeout(timer)
131
+ resolve({ code: 0, stdout, stderr })
132
+ })
133
+ // A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
134
+ // so ignore EPIPE on this write rather than crashing the host process.
135
+ child.stdin?.on('error', () => {})
136
+ child.stdin?.end(JSON.stringify(payload))
137
+ })
138
+
139
+ function timeoutMs(command: HookCommand): number {
140
+ return (command.timeout ?? DEFAULT_TIMEOUT_S) * 1000
141
+ }
142
+
143
+ /** Run PreToolUse hooks for a tool; the first blocking verdict wins. */
144
+ export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner): Promise<HookDecision> {
145
+ for (const command of matchingCommands(config.PreToolUse, toolName)) {
146
+ const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: toolInput }, timeoutMs(command))
147
+ const decision = interpretHookResult(result.code, result.stdout, result.stderr)
148
+ if (decision.block) return decision
149
+ }
150
+ return { block: false }
151
+ }
152
+
153
+ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<void> {
154
+ await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
155
+ }
156
+
157
+ export default function hooksExtension(pi: ExtensionAPI) {
158
+ let config: HooksConfig = {}
159
+
160
+ pi.on('session_start', async (event, ctx) => {
161
+ const trusted = ctx.isProjectTrusted?.() ?? false
162
+ config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
163
+ // Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
164
+ // "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
165
+ if (event.reason === 'reload' || event.reason === 'fork') return
166
+ await runNotifyHooks(matchingCommands(config.SessionStart, event.reason), { hook_event_name: 'SessionStart', source: event.reason }, runHookCommand)
167
+ })
168
+
169
+ pi.on('tool_call', async (event) => {
170
+ const decision = await runPreToolUse(config, event.toolName, event.input, runHookCommand)
171
+ return decision.block ? { block: true, reason: decision.reason } : undefined
172
+ })
173
+
174
+ pi.on('tool_execution_end', async (event) => {
175
+ if (event.isError) return
176
+ await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName }, runHookCommand)
177
+ })
178
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * MCP Adapter Extension
3
+ *
4
+ * Connects MCP (Model Context Protocol) servers from mcp.json and registers
5
+ * their tools in pi as `<server>_<tool>`. Async factory connects eagerly at
6
+ * startup (per-server timeout, failures skip with a notice); stdio and HTTP
7
+ * (streamable with SSE fallback) transports; /mcp shows status.
8
+ *
9
+ * Reads Claude Code's MCP config too. Merge order (later wins): ~/.claude.json,
10
+ * ~/.pi/agent/mcp.json, .mcp.json, then .pi/mcp.json. User config connects at
11
+ * startup; project config (.mcp.json / .pi/mcp.json) can run arbitrary commands,
12
+ * so it connects only once the project is trusted.
13
+ * Values support ${VAR} environment interpolation.
14
+ */
15
+
16
+ import * as fs from 'node:fs'
17
+ import * as os from 'node:os'
18
+ import * as path from 'node:path'
19
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
20
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
21
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
22
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
23
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
24
+ import { Type } from 'typebox'
25
+
26
+ const CONNECT_TIMEOUT_MS = 10_000
27
+ const CALL_TIMEOUT_MS = 120_000
28
+ const MAX_INLINE_RESULT = 50_000
29
+ // pi's built-in tool names must never be shadowed by an MCP tool
30
+ const RESERVED_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'mcp'])
31
+
32
+ export interface StdioServerConfig {
33
+ command: string
34
+ args?: string[]
35
+ env?: Record<string, string>
36
+ cwd?: string
37
+ }
38
+
39
+ export interface HttpServerConfig {
40
+ url: string
41
+ headers?: Record<string, string>
42
+ bearerToken?: string
43
+ bearerTokenEnv?: string
44
+ }
45
+
46
+ export type ServerConfig = StdioServerConfig | HttpServerConfig
47
+
48
+ export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env): string {
49
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => env[name] ?? '')
50
+ }
51
+
52
+ /** User-scoped MCP config (the user's own; safe to load without project trust). */
53
+ export function userConfigPaths(home: string): string[] {
54
+ return [path.join(home, '.claude.json'), path.join(home, '.pi', 'agent', 'mcp.json')]
55
+ }
56
+
57
+ /** Project-scoped MCP config. Loaded only for trusted projects: a server's `command` runs on connect. */
58
+ export function projectConfigPaths(cwd: string): string[] {
59
+ return [path.join(cwd, '.mcp.json'), path.join(cwd, '.pi', 'mcp.json')]
60
+ }
61
+
62
+ /** All config files, later winning: user first, then project. */
63
+ export function configPaths(cwd: string, home: string): string[] {
64
+ return [...userConfigPaths(home), ...projectConfigPaths(cwd)]
65
+ }
66
+
67
+ export function loadConfig(cwd: string): Record<string, ServerConfig> {
68
+ return loadConfigFrom(configPaths(cwd, os.homedir()))
69
+ }
70
+
71
+ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
72
+ const servers: Record<string, ServerConfig> = {}
73
+ for (const file of files) {
74
+ try {
75
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
76
+ Object.assign(servers, parsed.mcpServers ?? {})
77
+ } catch {
78
+ // missing or invalid file: skip silently, /mcp reports what loaded
79
+ }
80
+ }
81
+ return servers
82
+ }
83
+
84
+ export function formatToolName(server: string, tool: string): string {
85
+ return `${server}_${tool}`.replace(/-/g, '_')
86
+ }
87
+
88
+ export function normalizeSchema(schema: unknown): object {
89
+ const base = (schema as Record<string, unknown>) ?? {}
90
+ const { $schema: _dropSchema, additionalProperties: _dropAdditional, ...rest } = base
91
+ if (!rest.type) return { type: 'object', properties: {} }
92
+ return rest
93
+ }
94
+
95
+ interface McpContentBlock {
96
+ type: string
97
+ text?: string
98
+ data?: string
99
+ mimeType?: string
100
+ resource?: { uri?: string; text?: string }
101
+ }
102
+
103
+ export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
104
+
105
+ export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
106
+ if (!content || content.length === 0) {
107
+ return [{ type: 'text', text: structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)' }]
108
+ }
109
+ return content.map((block) => {
110
+ if (block.type === 'text') {
111
+ const text = block.text ?? ''
112
+ return text.length > MAX_INLINE_RESULT ? { type: 'text', text: `${text.slice(0, MAX_INLINE_RESULT)}\n[truncated ${text.length - MAX_INLINE_RESULT} chars]` } : { type: 'text', text }
113
+ }
114
+ if (block.type === 'image' && block.data) {
115
+ return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
116
+ }
117
+ if (block.type === 'resource' && block.resource) {
118
+ return { type: 'text', text: `[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}` }
119
+ }
120
+ return { type: 'text', text: JSON.stringify(block) }
121
+ })
122
+ }
123
+
124
+ function isStdio(config: ServerConfig): config is StdioServerConfig {
125
+ return 'command' in config
126
+ }
127
+
128
+ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
129
+ let timer: ReturnType<typeof setTimeout> | undefined
130
+ const timeout = new Promise<never>((_, reject) => {
131
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
132
+ })
133
+ // If the timeout wins, `promise` stays pending; swallow any late rejection so it can never
134
+ // surface as an unhandled rejection that crashes the host.
135
+ promise.catch(() => {})
136
+ try {
137
+ return await Promise.race([promise, timeout])
138
+ } finally {
139
+ clearTimeout(timer)
140
+ }
141
+ }
142
+
143
+ async function connect(name: string, config: ServerConfig): Promise<Client> {
144
+ const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
145
+ if (isStdio(config)) {
146
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) }
147
+ for (const [key, value] of Object.entries(config.env ?? {})) env[key] = interpolateEnv(value)
148
+ const transport = new StdioClientTransport({
149
+ command: config.command,
150
+ args: config.args ?? [],
151
+ env,
152
+ cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
153
+ stderr: 'ignore',
154
+ })
155
+ await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
156
+ return client
157
+ }
158
+ const headers: Record<string, string> = {}
159
+ for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
160
+ const token = config.bearerToken ?? (config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined)
161
+ if (token) headers.Authorization = `Bearer ${token}`
162
+ const url = new URL(interpolateEnv(config.url))
163
+ try {
164
+ const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } })
165
+ await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
166
+ return client
167
+ } catch (error) {
168
+ if (String(error).includes('Unauthorized')) throw error
169
+ const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
170
+ const transport = new SSEClientTransport(url, { requestInit: { headers } })
171
+ await withTimeout(fallback.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
172
+ return fallback
173
+ }
174
+ }
175
+
176
+ async function listAllTools(client: Client): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
177
+ const tools: Array<{ name: string; description?: string; inputSchema?: unknown }> = []
178
+ let cursor: string | undefined
179
+ do {
180
+ const page = await client.listTools({ cursor })
181
+ tools.push(...page.tools)
182
+ cursor = page.nextCursor
183
+ } while (cursor)
184
+ return tools
185
+ }
186
+
187
+ export default async function mcpExtension(pi: ExtensionAPI) {
188
+ const clients = new Map<string, Client>()
189
+ const status = new Map<string, { state: string; tools: number }>()
190
+ const registered = new Set<string>()
191
+
192
+ async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
193
+ for (const [name, config] of Object.entries(servers)) {
194
+ try {
195
+ const client = await connect(name, config)
196
+ clients.set(name, client)
197
+ const tools = await withTimeout(listAllTools(client), CONNECT_TIMEOUT_MS, `list tools ${name}`)
198
+ let count = 0
199
+ for (const tool of tools) {
200
+ const toolName = formatToolName(name, tool.name)
201
+ if (RESERVED_NAMES.has(toolName) || registered.has(toolName)) {
202
+ console.warn(`pi-code-mcp: skipping colliding tool name ${toolName}`)
203
+ continue
204
+ }
205
+ registered.add(toolName)
206
+ count++
207
+ pi.registerTool({
208
+ name: toolName,
209
+ label: `${name}: ${tool.name}`,
210
+ description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
211
+ parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
212
+ async execute(_id, params) {
213
+ const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }), CALL_TIMEOUT_MS, toolName)
214
+ const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
215
+ const details: { error?: string } = {}
216
+ if (result.isError) {
217
+ details.error = 'tool_error'
218
+ const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
219
+ content.push({ type: 'text', text: `Tool reported an error. Expected input schema: ${hint}` })
220
+ }
221
+ return { content, details }
222
+ },
223
+ })
224
+ }
225
+ status.set(name, { state: 'connected', tools: count })
226
+ } catch (error) {
227
+ status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
228
+ }
229
+ }
230
+ }
231
+
232
+ // User config is the user's own, so connect it eagerly.
233
+ await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
234
+
235
+ let projectConnected = false
236
+ pi.on('session_start', async (_event, ctx) => {
237
+ // A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
238
+ if (!projectConnected && ctx.isProjectTrusted?.()) {
239
+ projectConnected = true
240
+ await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
241
+ }
242
+
243
+ const connected = [...status.values()].filter((s) => s.state === 'connected')
244
+ const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
245
+ if (connected.length > 0 || failed.length > 0) {
246
+ const total = connected.reduce((sum, s) => sum + s.tools, 0)
247
+ const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
248
+ ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
249
+ }
250
+ })
251
+
252
+ pi.on('session_shutdown', async () => {
253
+ // Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
254
+ await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
255
+ })
256
+
257
+ pi.registerCommand('mcp', {
258
+ description: 'Show MCP server status and tools',
259
+ handler: async (_args, ctx) => {
260
+ if (status.size === 0) {
261
+ ctx.ui.notify('No MCP servers configured. Add them to .mcp.json, .pi/mcp.json, ~/.claude.json, or ~/.pi/agent/mcp.json', 'info')
262
+ return
263
+ }
264
+ const lines = [...status.entries()].map(([name, s]) => `${name}: ${s.state} (${s.tools} tools)`)
265
+ ctx.ui.notify(lines.join('\n'), 'info')
266
+ },
267
+ })
268
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Memory Extension
3
+ *
4
+ * Claude Code style persistent memory, per project. Memories live as markdown
5
+ * files under ~/.pi/agent/memory/<project-slug>/ with a MEMORY.md index whose
6
+ * content is injected into the system prompt each session. The agent manages
7
+ * memories through the memory tool (save / read / delete / list).
8
+ */
9
+
10
+ import * as fs from 'node:fs'
11
+ import * as os from 'node:os'
12
+ import * as path from 'node:path'
13
+ import { StringEnum } from '@earendil-works/pi-ai'
14
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
15
+ import { Type } from 'typebox'
16
+
17
+ const INDEX_FILE = 'MEMORY.md'
18
+
19
+ export function projectSlug(cwd: string): string {
20
+ return cwd.replace(/[/\\]/g, '-').replace(/^-+/, '-')
21
+ }
22
+
23
+ export function memoryDir(cwd: string): string {
24
+ return path.join(os.homedir(), '.pi', 'agent', 'memory', projectSlug(cwd))
25
+ }
26
+
27
+ export function slugifyName(name: string): string {
28
+ return (
29
+ name
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, '-')
32
+ .replace(/^-+|-+$/g, '')
33
+ .slice(0, 64) || 'memory'
34
+ )
35
+ }
36
+
37
+ /** Add or replace this memory's line in the index, keyed by its markdown link target. */
38
+ export function upsertIndexLine(index: string, name: string, description: string): string {
39
+ const line = `- [${name}](${name}.md): ${description}`
40
+ const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.includes(`](${name}.md)`))
41
+ if (lines.length === 0 || !lines[0].startsWith('#')) lines.unshift('# Memory index')
42
+ lines.push(line)
43
+ return `${lines.join('\n')}\n`
44
+ }
45
+
46
+ export function removeIndexLine(index: string, name: string): string {
47
+ const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.includes(`](${name}.md)`))
48
+ return lines.length > 0 ? `${lines.join('\n')}\n` : ''
49
+ }
50
+
51
+ const MemoryParams = Type.Object({
52
+ action: StringEnum(['save', 'read', 'delete', 'list'] as const, { description: 'What to do' }),
53
+ name: Type.Optional(Type.String({ description: 'Short kebab-case memory name (save/read/delete)' })),
54
+ description: Type.Optional(Type.String({ description: 'One-line summary shown in the always-loaded index (save)' })),
55
+ content: Type.Optional(Type.String({ description: 'Full memory content in markdown (save)' })),
56
+ })
57
+
58
+ function readIndex(dir: string): string {
59
+ try {
60
+ return fs.readFileSync(path.join(dir, INDEX_FILE), 'utf-8')
61
+ } catch {
62
+ return ''
63
+ }
64
+ }
65
+
66
+ export default function memoryExtension(pi: ExtensionAPI) {
67
+ let dir = memoryDir(process.cwd())
68
+
69
+ pi.on('session_start', async (_event, ctx) => {
70
+ dir = memoryDir(ctx.cwd)
71
+ const count = readIndex(dir)
72
+ .split('\n')
73
+ .filter((l) => l.startsWith('- ')).length
74
+ if (count > 0) ctx.ui.notify(`Memory: ${count} memories loaded`, 'info')
75
+ })
76
+
77
+ pi.on('before_agent_start', async (event) => {
78
+ const index = readIndex(dir)
79
+ if (!index.trim()) return
80
+ return {
81
+ systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${index}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
82
+ }
83
+ })
84
+
85
+ pi.registerTool({
86
+ name: 'memory',
87
+ label: 'Memory',
88
+ description: 'Persistent memory across sessions. Save durable facts, user preferences, corrections, and project decisions that are not derivable from the code. Actions: save (name + description + content), read (name), delete (name), list.',
89
+ parameters: MemoryParams,
90
+ async execute(_id, params) {
91
+ const name = params.name ? slugifyName(params.name) : undefined
92
+ const indexPath = path.join(dir, INDEX_FILE)
93
+
94
+ if (params.action === 'save') {
95
+ if (!name || !params.description || !params.content) {
96
+ return { content: [{ type: 'text' as const, text: 'save requires name, description, and content.' }], details: {} }
97
+ }
98
+ fs.mkdirSync(dir, { recursive: true })
99
+ fs.writeFileSync(path.join(dir, `${name}.md`), params.content)
100
+ fs.writeFileSync(indexPath, upsertIndexLine(readIndex(dir), name, params.description))
101
+ return { content: [{ type: 'text' as const, text: `Saved memory ${name}.` }], details: {} }
102
+ }
103
+
104
+ if (params.action === 'read') {
105
+ if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
106
+ try {
107
+ const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
108
+ return { content: [{ type: 'text' as const, text: body }], details: {} }
109
+ } catch {
110
+ return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
111
+ }
112
+ }
113
+
114
+ if (params.action === 'delete') {
115
+ if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
116
+ fs.rmSync(path.join(dir, `${name}.md`), { force: true })
117
+ const remaining = removeIndexLine(readIndex(dir), name)
118
+ if (remaining) fs.writeFileSync(indexPath, remaining)
119
+ else fs.rmSync(indexPath, { force: true })
120
+ return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
121
+ }
122
+
123
+ const index = readIndex(dir)
124
+ return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
125
+ },
126
+ })
127
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pi Notify Extension
3
+ *
4
+ * Sends a native terminal notification when Pi agent is done and waiting for input.
5
+ * Supports multiple terminal protocols:
6
+ * - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
7
+ * - OSC 99: Kitty
8
+ * - Windows toast: Windows Terminal (WSL)
9
+ */
10
+
11
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
12
+
13
+ function windowsToastScript(title: string, body: string): string {
14
+ const type = 'Windows.UI.Notifications'
15
+ const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`
16
+ const template = `[${type}.ToastTemplateType]::ToastText01`
17
+ const toast = `[${type}.ToastNotification]::new($xml)`
18
+ return [`${mgr} > $null`, `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`, `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`, `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`].join('; ')
19
+ }
20
+
21
+ function notifyOSC777(title: string, body: string): void {
22
+ process.stdout.write(`\x1b]777;notify;${title};${body}\x07`)
23
+ }
24
+
25
+ function notifyOSC99(title: string, body: string): void {
26
+ // Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
27
+ process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`)
28
+ process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`)
29
+ }
30
+
31
+ function notifyWindows(title: string, body: string): void {
32
+ const { execFile } = require('node:child_process')
33
+ // The callback captures a spawn failure (e.g. powershell.exe missing) instead of
34
+ // letting an unhandled 'error' event crash the host process.
35
+ execFile('powershell.exe', ['-NoProfile', '-Command', windowsToastScript(title, body)], () => {})
36
+ }
37
+
38
+ function notify(title: string, body: string): void {
39
+ if (process.env.WT_SESSION) {
40
+ notifyWindows(title, body)
41
+ } else if (process.env.KITTY_WINDOW_ID) {
42
+ notifyOSC99(title, body)
43
+ } else {
44
+ notifyOSC777(title, body)
45
+ }
46
+ }
47
+
48
+ export default function (pi: ExtensionAPI) {
49
+ pi.on('agent_end', async () => {
50
+ notify('Pi', 'Ready for input')
51
+ })
52
+ }