@meetopenbot/pi 0.0.1

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 ADDED
@@ -0,0 +1,53 @@
1
+ # @meetopenbot/pi
2
+
3
+ OpenBot agent plugin powered by the [Pi coding agent SDK](https://pi.dev/docs/latest/sdk).
4
+
5
+ Pi provides a full agent runtime with built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`), session management, and model/provider support. This plugin wraps Pi as an OpenBot **agent runtime** — each `agent:invoke` runs a Pi turn and streams progress back via `agent:output`.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @meetopenbot/pi
11
+ ```
12
+
13
+ Add the plugin to your agent in `AGENT.md`:
14
+
15
+ ```yaml
16
+ plugins:
17
+ - id: '@meetopenbot/pi'
18
+ config:
19
+ provider: anthropic
20
+ model: claude-opus-4-5
21
+ thinkingLevel: medium
22
+ tools: read,bash,edit,write,grep,find,ls
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ | Option | Description |
28
+ |--------|-------------|
29
+ | `cwd` | Working directory for Pi tools and resource discovery. Defaults to the OpenBot channel `cwd`, then `process.cwd()`. |
30
+ | `agentDir` | Pi config directory (credentials, settings, sessions). Default: `~/.pi/agent`. |
31
+ | `provider` | Model provider (e.g. `anthropic`, `openai`). |
32
+ | `model` | Model id (e.g. `claude-opus-4-5`). |
33
+ | `thinkingLevel` | Extended thinking: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. |
34
+ | `tools` | Comma-separated built-in tools to enable. |
35
+ | `excludeTools` | Comma-separated tool names to disable. |
36
+ | `noTools` | `all` or `builtin` to disable tools. |
37
+ | `systemPrompt` | Override Pi's system prompt for this agent. |
38
+
39
+ ## API Keys
40
+
41
+ Pi resolves credentials via `AuthStorage` (see [Pi SDK docs](https://pi.dev/docs/latest/sdk)):
42
+
43
+ 1. Runtime overrides
44
+ 2. `~/.pi/agent/auth.json`
45
+ 3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
46
+
47
+ ## Sessions
48
+
49
+ Each OpenBot thread gets its own Pi session. The session file path is stored in thread state so conversations continue across turns.
50
+
51
+ ## License
52
+
53
+ MIT
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ const THINKING_LEVELS = new Set([
2
+ 'off',
3
+ 'minimal',
4
+ 'low',
5
+ 'medium',
6
+ 'high',
7
+ 'xhigh',
8
+ ]);
9
+ export const resolveConfig = (context, channelCwd) => {
10
+ const config = context.config;
11
+ const thinkingLevel = config.thinkingLevel && THINKING_LEVELS.has(config.thinkingLevel)
12
+ ? config.thinkingLevel
13
+ : undefined;
14
+ return {
15
+ cwd: channelCwd || config.cwd,
16
+ agentDir: config.agentDir,
17
+ provider: config.provider?.trim() || undefined,
18
+ model: config.model?.trim() || undefined,
19
+ thinkingLevel,
20
+ tools: config.tools?.trim() || undefined,
21
+ excludeTools: config.excludeTools?.trim() || undefined,
22
+ noTools: config.noTools,
23
+ systemPrompt: config.systemPrompt?.trim() || undefined,
24
+ };
25
+ };
26
+ export const parseToolList = (value) => {
27
+ if (!value)
28
+ return undefined;
29
+ const tools = value
30
+ .split(',')
31
+ .map((tool) => tool.trim())
32
+ .filter(Boolean);
33
+ return tools.length > 0 ? tools : undefined;
34
+ };
package/dist/format.js ADDED
@@ -0,0 +1,120 @@
1
+ const isAssistantMessage = (message) => message.role === 'assistant';
2
+ export const createStreamState = () => ({
3
+ assistantText: '',
4
+ lastYieldedText: '',
5
+ });
6
+ const strArg = (args, ...keys) => {
7
+ if (!args || typeof args !== 'object')
8
+ return undefined;
9
+ const record = args;
10
+ for (const key of keys) {
11
+ const value = record[key];
12
+ if (typeof value === 'string' && value.trim())
13
+ return value.trim();
14
+ if (typeof value === 'number' && Number.isFinite(value))
15
+ return String(value);
16
+ }
17
+ return undefined;
18
+ };
19
+ const truncate = (text, max = 80) => text.length > max ? `${text.slice(0, max - 3)}…` : text;
20
+ const quoteDetail = (detail) => detail.includes('`') ? `"${detail}"` : `\`${detail}\``;
21
+ const formatToolDetail = (toolName, args) => {
22
+ switch (toolName) {
23
+ case 'bash': {
24
+ const command = strArg(args, 'command');
25
+ return command ? truncate(command.replace(/\s+/g, ' ')) : undefined;
26
+ }
27
+ case 'read':
28
+ case 'edit':
29
+ case 'write': {
30
+ const path = strArg(args, 'path', 'file_path');
31
+ if (!path)
32
+ return undefined;
33
+ let detail = truncate(path, 120);
34
+ if (toolName === 'read') {
35
+ const offset = strArg(args, 'offset');
36
+ const limit = strArg(args, 'limit');
37
+ const parts = [offset && `offset=${offset}`, limit && `limit=${limit}`].filter(Boolean);
38
+ if (parts.length)
39
+ detail += ` (${parts.join(', ')})`;
40
+ }
41
+ return detail;
42
+ }
43
+ case 'grep': {
44
+ const pattern = strArg(args, 'pattern');
45
+ const path = strArg(args, 'path') ?? '.';
46
+ if (!pattern)
47
+ return undefined;
48
+ return `${truncate(pattern)} in ${truncate(path, 60)}`;
49
+ }
50
+ case 'find': {
51
+ const pattern = strArg(args, 'pattern');
52
+ const path = strArg(args, 'path') ?? '.';
53
+ if (!pattern)
54
+ return undefined;
55
+ return `${truncate(pattern)} in ${truncate(path, 60)}`;
56
+ }
57
+ case 'ls': {
58
+ const path = strArg(args, 'path') ?? '.';
59
+ return truncate(path, 120);
60
+ }
61
+ default:
62
+ return undefined;
63
+ }
64
+ };
65
+ const formatToolStart = (toolName, args) => {
66
+ const detail = formatToolDetail(toolName, args);
67
+ if (detail)
68
+ return `Running **${toolName}** (${quoteDetail(detail)})…`;
69
+ return `Running **${toolName}**…`;
70
+ };
71
+ /** Map Pi session events to user-visible output chunks. */
72
+ export const formatPiEvent = (event, state) => {
73
+ switch (event.type) {
74
+ case 'message_update': {
75
+ const assistantEvent = event.assistantMessageEvent;
76
+ if (assistantEvent.type !== 'text_delta')
77
+ return undefined;
78
+ state.assistantText += assistantEvent.delta;
79
+ if (state.assistantText === state.lastYieldedText)
80
+ return undefined;
81
+ state.lastYieldedText = state.assistantText;
82
+ return state.assistantText;
83
+ }
84
+ case 'tool_execution_start': {
85
+ state.statusLine = formatToolStart(event.toolName, event.args);
86
+ return state.statusLine;
87
+ }
88
+ case 'tool_execution_end': {
89
+ if (!event.isError)
90
+ return undefined;
91
+ state.statusLine = `Tool **${event.toolName}** failed.`;
92
+ return state.statusLine;
93
+ }
94
+ case 'auto_retry_start': {
95
+ state.statusLine = `Retrying (${event.attempt}/${event.maxAttempts})…`;
96
+ return state.statusLine;
97
+ }
98
+ case 'compaction_start': {
99
+ state.statusLine = 'Compacting conversation context…';
100
+ return state.statusLine;
101
+ }
102
+ case 'agent_end': {
103
+ const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
104
+ if (lastAssistant?.stopReason === 'error' && lastAssistant.errorMessage?.trim()) {
105
+ return `**Pi error:** ${lastAssistant.errorMessage.trim()}`;
106
+ }
107
+ if (state.assistantText.trim()) {
108
+ return state.assistantText;
109
+ }
110
+ return undefined;
111
+ }
112
+ default:
113
+ return undefined;
114
+ }
115
+ };
116
+ export const formatPiError = (error) => {
117
+ if (error instanceof Error)
118
+ return error.message;
119
+ return String(error);
120
+ };
package/dist/index.js ADDED
@@ -0,0 +1,95 @@
1
+ import { agentOutput, definePlugin, shouldHandleInvoke, } from '@meetopenbot/plugin-sdk';
2
+ import { resolveConfig } from './config.js';
3
+ import { formatPiError } from './format.js';
4
+ import { getOrCreatePiSession } from './session.js';
5
+ import { streamPiPrompt } from './stream.js';
6
+ export default definePlugin({
7
+ id: 'pi',
8
+ name: 'Pi',
9
+ description: 'Pi coding agent — read, edit, and run code in your workspace.',
10
+ configSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ cwd: {
14
+ type: 'string',
15
+ description: 'Working directory for Pi tools and resource discovery.',
16
+ },
17
+ agentDir: {
18
+ type: 'string',
19
+ description: 'Pi config directory (default: ~/.pi/agent).',
20
+ },
21
+ provider: {
22
+ type: 'string',
23
+ description: 'Model provider (e.g. anthropic, openai).',
24
+ },
25
+ model: {
26
+ type: 'string',
27
+ description: 'Model id (e.g. claude-opus-4-5).',
28
+ },
29
+ thinkingLevel: {
30
+ type: 'string',
31
+ description: 'Extended thinking level.',
32
+ enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'],
33
+ default: 'off',
34
+ },
35
+ tools: {
36
+ type: 'string',
37
+ description: 'Comma-separated built-in tools to enable (e.g. read,bash,edit,write,grep,find,ls).',
38
+ },
39
+ excludeTools: {
40
+ type: 'string',
41
+ description: 'Comma-separated tool names to disable.',
42
+ },
43
+ noTools: {
44
+ type: 'string',
45
+ description: 'Disable tools: "all" or "builtin".',
46
+ enum: ['all', 'builtin'],
47
+ },
48
+ systemPrompt: {
49
+ type: 'string',
50
+ description: 'Override Pi system prompt for this agent.',
51
+ },
52
+ },
53
+ },
54
+ factory: (context) => (builder) => {
55
+ builder.on('agent:invoke', async function* (event, handlerCtx) {
56
+ if (!shouldHandleInvoke(event, context.agentId))
57
+ return;
58
+ const threadId = event.meta?.threadId ?? handlerCtx.state.threadId;
59
+ const prompt = (event.data.content ?? '').trim();
60
+ if (!prompt) {
61
+ yield agentOutput({
62
+ agentId: context.agentId,
63
+ content: 'Send a message to run Pi in this workspace — for example, "List the files here" or "Fix the failing test in src/foo.test.ts".',
64
+ threadId,
65
+ });
66
+ return;
67
+ }
68
+ const channelCwd = handlerCtx.state.channelDetails?.cwd;
69
+ const config = resolveConfig(context, channelCwd);
70
+ try {
71
+ const { session } = await getOrCreatePiSession({
72
+ config,
73
+ state: handlerCtx.state,
74
+ storage: context.storage,
75
+ });
76
+ for await (const chunk of streamPiPrompt(session, prompt, {
77
+ streaming: session.isStreaming,
78
+ })) {
79
+ yield agentOutput({
80
+ agentId: context.agentId,
81
+ content: chunk,
82
+ threadId,
83
+ });
84
+ }
85
+ }
86
+ catch (error) {
87
+ yield agentOutput({
88
+ agentId: context.agentId,
89
+ content: `**Pi error:** ${formatPiError(error)}`,
90
+ threadId,
91
+ });
92
+ }
93
+ });
94
+ },
95
+ });
@@ -0,0 +1,68 @@
1
+ import { AuthStorage, createAgentSession, DefaultResourceLoader, getAgentDir, ModelRegistry, SessionManager, SettingsManager, } from '@earendil-works/pi-coding-agent';
2
+ import { parseToolList } from './config.js';
3
+ import { persistPiState, readPersistedState } from './state.js';
4
+ const sessionCache = new Map();
5
+ const buildSessionKey = (state) => state.threadId ? `${state.channelId}:${state.threadId}` : state.channelId;
6
+ const resolveModel = async (config, modelRegistry) => {
7
+ if (config.provider && config.model) {
8
+ return modelRegistry.find(config.provider, config.model) ?? undefined;
9
+ }
10
+ const available = await modelRegistry.getAvailable();
11
+ return available[0];
12
+ };
13
+ export const getOrCreatePiSession = async (args) => {
14
+ const { config, state, storage } = args;
15
+ const sessionKey = buildSessionKey(state);
16
+ const cached = sessionCache.get(sessionKey);
17
+ if (cached)
18
+ return cached;
19
+ const cwd = config.cwd || process.cwd();
20
+ const agentDir = config.agentDir || getAgentDir();
21
+ const persisted = readPersistedState(state);
22
+ const authStorage = AuthStorage.create(`${agentDir}/auth.json`);
23
+ const modelRegistry = ModelRegistry.create(authStorage, `${agentDir}/models.json`);
24
+ const model = await resolveModel(config, modelRegistry);
25
+ const settingsManager = SettingsManager.create(cwd, agentDir);
26
+ const loader = new DefaultResourceLoader({
27
+ cwd,
28
+ agentDir,
29
+ settingsManager,
30
+ ...(config.systemPrompt
31
+ ? { systemPromptOverride: () => config.systemPrompt }
32
+ : {}),
33
+ });
34
+ await loader.reload();
35
+ const sessionManager = persisted.sessionFile
36
+ ? SessionManager.open(persisted.sessionFile)
37
+ : SessionManager.create(cwd);
38
+ const { session, modelFallbackMessage } = await createAgentSession({
39
+ cwd,
40
+ agentDir,
41
+ authStorage,
42
+ modelRegistry,
43
+ model,
44
+ thinkingLevel: config.thinkingLevel,
45
+ tools: parseToolList(config.tools),
46
+ excludeTools: parseToolList(config.excludeTools),
47
+ noTools: config.noTools,
48
+ resourceLoader: loader,
49
+ sessionManager,
50
+ settingsManager,
51
+ });
52
+ if (modelFallbackMessage) {
53
+ console.warn(`[pi-plugin] ${modelFallbackMessage}`);
54
+ }
55
+ if (session.sessionFile && session.sessionFile !== persisted.sessionFile) {
56
+ await persistPiState(state, storage, { sessionFile: session.sessionFile });
57
+ }
58
+ const handle = { session, sessionKey };
59
+ sessionCache.set(sessionKey, handle);
60
+ return handle;
61
+ };
62
+ export const disposePiSession = (sessionKey) => {
63
+ const cached = sessionCache.get(sessionKey);
64
+ if (!cached)
65
+ return;
66
+ cached.session.dispose();
67
+ sessionCache.delete(sessionKey);
68
+ };
package/dist/state.js ADDED
@@ -0,0 +1,19 @@
1
+ const asRecord = (value) => value && typeof value === 'object' && !Array.isArray(value)
2
+ ? value
3
+ : {};
4
+ export const readPersistedState = (state) => {
5
+ const source = state.threadDetails?.state ?? state.channelDetails?.state;
6
+ const record = asRecord(source);
7
+ return typeof record.sessionFile === 'string' ? { sessionFile: record.sessionFile } : {};
8
+ };
9
+ export const persistPiState = async (state, storage, patch) => {
10
+ if (state.threadId) {
11
+ await storage.patchThreadState({
12
+ channelId: state.channelId,
13
+ threadId: state.threadId,
14
+ state: patch,
15
+ });
16
+ return;
17
+ }
18
+ await storage.patchChannelState({ channelId: state.channelId, state: patch });
19
+ };
package/dist/stream.js ADDED
@@ -0,0 +1,58 @@
1
+ import { createStreamState, formatPiEvent } from './format.js';
2
+ /** Bridge Pi session events into an async generator of output chunks. */
3
+ export async function* streamPiPrompt(session, prompt, options) {
4
+ const state = createStreamState();
5
+ const pending = [];
6
+ let wake;
7
+ let finished = false;
8
+ let error;
9
+ const wakeUp = () => {
10
+ wake?.();
11
+ wake = undefined;
12
+ };
13
+ const unsubscribe = session.subscribe((event) => {
14
+ const chunk = formatPiEvent(event, state);
15
+ if (chunk) {
16
+ pending.push(chunk);
17
+ wakeUp();
18
+ }
19
+ if (event.type === 'agent_end') {
20
+ finished = true;
21
+ wakeUp();
22
+ }
23
+ });
24
+ const run = (async () => {
25
+ try {
26
+ if (options?.streaming && session.isStreaming) {
27
+ await session.followUp(prompt);
28
+ }
29
+ else {
30
+ await session.prompt(prompt);
31
+ }
32
+ }
33
+ catch (err) {
34
+ error = err instanceof Error ? err : new Error(String(err));
35
+ }
36
+ finally {
37
+ finished = true;
38
+ wakeUp();
39
+ }
40
+ })();
41
+ try {
42
+ while (!finished || pending.length > 0) {
43
+ if (pending.length === 0) {
44
+ await new Promise((resolve) => {
45
+ wake = resolve;
46
+ });
47
+ continue;
48
+ }
49
+ yield pending.shift();
50
+ }
51
+ await run;
52
+ if (error)
53
+ throw error;
54
+ }
55
+ finally {
56
+ unsubscribe();
57
+ }
58
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@meetopenbot/pi",
3
+ "version": "0.0.1",
4
+ "description": "OpenBot agent plugin powered by the Pi coding agent SDK.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": "./dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "typecheck": "tsc -p tsconfig.json --noEmit"
19
+ },
20
+ "license": "MIT",
21
+ "dependencies": {
22
+ "@earendil-works/pi-ai": "^0.79.1",
23
+ "@earendil-works/pi-coding-agent": "^0.79.1",
24
+ "@meetopenbot/plugin-sdk": "^0.1.2"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^25.9.2",
28
+ "typescript": "^6.0.3"
29
+ }
30
+ }