pi-xmpp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,3 @@
1
+ # Agents
2
+
3
+ pi-xmpp does not ship any custom Pi agents. The extension provides the bridge runtime, tools, and slash commands for the default Pi agent to interact with XMPP.
package/BACKLOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Backlog
2
+
3
+ ## v0.2.0
4
+
5
+ - [ ] XMPP roster management (fetch and display contacts)
6
+ - [ ] Message delivery receipts (XEP-0184)
7
+ - [ ] Chat state notifications (XEP-0085)
8
+ - [ ] File transfer support (HTTP Upload / XEP-0363)
9
+ - [ ] End-to-end encryption (OMEMO / XEP-0384)
10
+ - [ ] Formatted messages (XEP-0071 XHTML-IM)
11
+
12
+ ## v0.3.0
13
+
14
+ - [ ] Multi-instance bus (like pi-telegram's threaded mode)
15
+ - [ ] Voice message support
16
+ - [ ] Message correction (XEP-0308)
17
+ - [ ] Last Message Correction (XEP-0308)
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - Initial release
6
+ - XMPP connection management (connect/disconnect/auto-reconnect)
7
+ - Direct message send/receive
8
+ - MUC/groupchat support (join/leave/participate)
9
+ - Presence management
10
+ - Authorization and auto-pairing
11
+ - Slash commands (/xmpp-connect, /xmpp-disconnect, /xmpp-status, /xmpp-join, /xmpp-leave, /xmpp-set-presence)
12
+ - Programmatic inbound/outbound handler API
13
+ - Companion extension API (status providers, commands, update handlers)
14
+ - Configuration via ~/.pi/agent/xmpp.json
15
+ - Auto-join rooms on connect
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Stan Kondrat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # pi-xmpp
2
+
3
+ XMPP runtime adapter for [Pi](https://github.com/earendil-works/pi).
4
+
5
+ Connect your Pi agent to Jabber/XMPP servers for instant messaging with AI assistance.
6
+
7
+ ## Features
8
+
9
+ - **XMPP Connection Management** — Connect, disconnect, and auto-reconnect to any XMPP server
10
+ - **Direct Messaging** — Receive and respond to one-on-one chat messages
11
+ - **MUC/Groupchat Support** — Join, leave, and participate in multi-user chat rooms
12
+ - **Presence Management** — Set your availability and status message
13
+ - **Authorization & Pairing** — Auto-pair on first message, restrict to specific JIDs
14
+ - **Companion Extension API** — Register inbound/outbound handlers, status providers, and slash commands
15
+ - **Auto-join Rooms** — Automatically join configured chat rooms on connect
16
+ - **Slash Commands** — Control the bridge via `/xmpp-connect`, `/xmpp-status`, `/xmpp-join`, and more
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install pi-xmpp
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ 1. **Configure your credentials** in `~/.pi/agent/xmpp.json`:
27
+
28
+ ```json
29
+ {
30
+ "jid": "your-username@your-server.org",
31
+ "password": "your-password",
32
+ "service": "xmpp://your-server.org"
33
+ }
34
+ ```
35
+
36
+ 2. **Start Pi** and the bridge auto-connects.
37
+
38
+ 3. **Or connect manually** from within Pi:
39
+
40
+ ```
41
+ /xmpp-connect --jid user@domain.tld --password secret --service xmpp://server.tld
42
+ ```
43
+
44
+ ## Slash Commands
45
+
46
+ | Command | Description |
47
+ |---------|-------------|
48
+ | `/xmpp-connect` | Connect to XMPP server (--jid, --password, --service) |
49
+ | `/xmpp-disconnect` | Disconnect from XMPP server |
50
+ | `/xmpp-status` | Show connection status and diagnostics |
51
+ | `/xmpp-join` | Join a MUC room (--room, --nick) |
52
+ | `/xmpp-leave` | Leave a MUC room (--room) |
53
+ | `/xmpp-set-presence` | Set presence (--show, --status) |
54
+
55
+ ## Configuration
56
+
57
+ Configuration is stored in `~/.pi/agent/xmpp.json`:
58
+
59
+ ```json
60
+ {
61
+ "jid": "user@domain.tld",
62
+ "password": "secret",
63
+ "service": "xmpp://server.tld",
64
+ "domain": "server.tld",
65
+ "resource": "pi-bridge",
66
+ "allowedJid": "trusted@domain.tld",
67
+ "autoReconnect": true,
68
+ "autoJoinRooms": ["room@conference.tld"]
69
+ }
70
+ ```
71
+
72
+ ## Extension API
73
+
74
+ ### Inbound Handlers
75
+
76
+ ```typescript
77
+ import { registerXmppInboundHandler } from "pi-xmpp/inbound";
78
+
79
+ registerXmppInboundHandler(async (input) => {
80
+ if (input.body.startsWith("!ping")) {
81
+ return { handled: true, prompt: "User requested ping" };
82
+ }
83
+ return { handled: false };
84
+ });
85
+ ```
86
+
87
+ ### Outbound Handlers
88
+
89
+ ```typescript
90
+ import { registerXmppOutboundHandler } from "pi-xmpp/outbound";
91
+
92
+ registerXmppOutboundHandler(async (input) => {
93
+ return { handled: false };
94
+ });
95
+ ```
96
+
97
+ ### Status Providers
98
+
99
+ ```typescript
100
+ import { registerXmppStatusLineProvider } from "pi-xmpp/status";
101
+
102
+ registerXmppStatusLineProvider((ctx) => ({
103
+ label: "My Custom Status",
104
+ value: "ok",
105
+ }));
106
+ ```
107
+
108
+ ## Architecture
109
+
110
+ ```
111
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
112
+ │ XMPP Server│◄───►│ xmpp.js │◄───►│ pi-xmpp │
113
+ │ │ │ Client │ │ Extension │
114
+ └─────────────┘ └──────────────┘ └──────┬──────┘
115
+
116
+ ┌──────▼──────┐
117
+ │ Pi Agent │
118
+ │ Runtime │
119
+ └─────────────┘
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public XMPP commands API
3
+ * Zones: package boundary, companion extension interop
4
+ * Exposes the stable XMPP slash-command registration surface
5
+ */
6
+
7
+ export {
8
+ registerXmppCommand,
9
+ type XmppExtensionCommandContext,
10
+ type XmppExtensionCommandRegistration,
11
+ } from "../lib/commands.ts";
package/api/inbound.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Public XMPP inbound API
3
+ * Zones: package boundary, companion extension interop
4
+ * Exposes the stable programmatic inbound handler surface
5
+ */
6
+
7
+ export {
8
+ registerXmppInboundHandler,
9
+ type XmppInboundHandlerFile,
10
+ type XmppInboundHandlerOutput,
11
+ type XmppInboundProgrammaticHandler,
12
+ type XmppInboundProgrammaticHandlerInput,
13
+ type XmppInboundProgrammaticHandlerResult,
14
+ } from "../lib/inbound.ts";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public XMPP outbound API
3
+ * Zones: package boundary, companion extension interop
4
+ * Exposes stable outbound handler and diagnostics surfaces
5
+ */
6
+
7
+ export {
8
+ recordXmppRuntimeEvent,
9
+ registerXmppOutboundHandler,
10
+ type XmppOutboundProgrammaticHandler,
11
+ } from "../lib/outbound.ts";
package/api/status.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public XMPP status API
3
+ * Zones: package boundary, companion extension interop
4
+ * Exposes compact status-line registration for companion extensions
5
+ */
6
+
7
+ export {
8
+ registerXmppStatusLineProvider,
9
+ type XmppStatusLineProvider,
10
+ type XmppStatusLineProviderContext,
11
+ type XmppStatusLineProviderResult,
12
+ } from "../lib/status.ts";
package/api/updates.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Public XMPP updates API
3
+ * Zones: package boundary, companion extension interop
4
+ * Exposes the stable stanza handler surface
5
+ */
6
+
7
+ import type { XmppStanza } from "../lib/xmpp-api.ts";
8
+
9
+ export type XmppUpdateHandlerVerdict =
10
+ | { handled: true }
11
+ | { handled: false; reason?: string };
12
+
13
+ export interface XmppUpdateHandler {
14
+ (stanza: XmppStanza): XmppUpdateHandlerVerdict | Promise<XmppUpdateHandlerVerdict>;
15
+ }
16
+
17
+ const UPDATE_HANDLER_REGISTRY_KEY = "__piXmppUpdateHandlers__";
18
+
19
+ /**
20
+ * Register a raw stanza update handler.
21
+ * Handlers receive every incoming stanza and can mark it as handled
22
+ * to prevent further processing by the default message pipeline.
23
+ */
24
+ export function registerXmppUpdateHandler(
25
+ handler: XmppUpdateHandler,
26
+ ): void {
27
+ const registry = getUpdateHandlerRegistry();
28
+ registry.push(handler);
29
+ }
30
+
31
+ function getUpdateHandlerRegistry(): XmppUpdateHandler[] {
32
+ const globals = globalThis as Record<string, unknown>;
33
+ if (!globals[UPDATE_HANDLER_REGISTRY_KEY]) {
34
+ globals[UPDATE_HANDLER_REGISTRY_KEY] = [];
35
+ }
36
+ return globals[UPDATE_HANDLER_REGISTRY_KEY] as XmppUpdateHandler[];
37
+ }
38
+
39
+ export function getXmppUpdateHandlers(): XmppUpdateHandler[] {
40
+ return getUpdateHandlerRegistry();
41
+ }
package/index.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * XMPP bridge extension entrypoint and orchestration layer
3
+ * Zones: xmpp, pi agent, orchestration
4
+ * Keeps the runtime wiring in one place while delegating reusable domain logic to /lib modules
5
+ */
6
+
7
+ import * as Pi from "./lib/pi.ts";
8
+ import * as Commands from "./lib/commands.ts";
9
+ import * as Config from "./lib/config.ts";
10
+ import * as Inbound from "./lib/inbound.ts";
11
+ import * as Lifecycle from "./lib/lifecycle.ts";
12
+ import * as Model from "./lib/model.ts";
13
+ import * as Outbound from "./lib/outbound.ts";
14
+ import * as Prompts from "./lib/prompts.ts";
15
+ import * as Queue from "./lib/queue.ts";
16
+ import * as Routing from "./lib/routing.ts";
17
+ import * as Runtime from "./lib/runtime.ts";
18
+ import * as Status from "./lib/status.ts";
19
+ import * as XmppApi from "./lib/xmpp-api.ts";
20
+ import { Type } from "@sinclair/typebox";
21
+ import * as Updates from "./api/updates.ts";
22
+
23
+ // --- Extension Runtime ---
24
+
25
+ export default function (pi: Pi.ExtensionAPI) {
26
+ const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
27
+ const { sendUserMessage } = piRuntime;
28
+
29
+ // --- XMPP Client ---
30
+ const xmppClient = XmppApi.createXmppClient();
31
+
32
+ // --- Instance identity ---
33
+ const xmppInstanceId = `${process.pid}:${Date.now()}`;
34
+
35
+ // --- Runtime ---
36
+ const bridgeRuntime = Runtime.createXmppBridgeRuntime();
37
+ const { abort, lifecycle, queue } = bridgeRuntime;
38
+
39
+ // --- Config ---
40
+ const runtimeEvents = Status.createXmppRuntimeEventRecorder();
41
+ const configStore = Config.createXmppConfigStore({ recordRuntimeEvent: runtimeEvents.record });
42
+ Outbound.bindXmppRuntimeEventRecorder(runtimeEvents.record);
43
+
44
+ const recordRuntimeEvent = function (
45
+ category: string,
46
+ error: unknown,
47
+ details?: Record<string, unknown>,
48
+ ) {
49
+ runtimeEvents.record(category, error ?? undefined, details);
50
+ };
51
+
52
+ // --- Context store ---
53
+ const sessionContextStore = Lifecycle.createXmppSessionContextStore<Pi.ExtensionContext>();
54
+
55
+ // --- Active turn store ---
56
+ const activeTurnRuntime = Queue.createXmppActiveTurnStore();
57
+
58
+ // --- Queue store ---
59
+ const xmppQueueStore = Queue.createXmppQueueStore<Pi.ExtensionContext>();
60
+
61
+ // --- Status helpers ---
62
+ const getAllowedJid = () => configStore.getAllowedJid();
63
+ const getConnectionStatus = () => xmppClient.status;
64
+ const getActiveTurnFrom = () => activeTurnRuntime.get()?.fromBare;
65
+
66
+ const sendMessageToActiveTurn = async function (body: string): Promise<void> {
67
+ sendUserMessage(body);
68
+ };
69
+
70
+ // --- Incoming stanza handler ---
71
+ const handleIncomingStanza = async function (stanza: XmppApi.XmppStanza): Promise<void> {
72
+ // Run through update handlers first
73
+ const updateHandlers = Updates.getXmppUpdateHandlers();
74
+ for (const handler of updateHandlers) {
75
+ try {
76
+ const verdict = await handler(stanza);
77
+ if (verdict.handled) return;
78
+ } catch {
79
+ // Continue to next handler
80
+ }
81
+ }
82
+
83
+ // Route the stanza
84
+ const route = Routing.routeStanza(stanza);
85
+ if (!route) return;
86
+
87
+ if (route.kind === "message") {
88
+ await handleIncomingMessage(route);
89
+ } else if (route.kind === "presence") {
90
+ await handleIncomingPresence(route);
91
+ }
92
+ };
93
+
94
+ // --- Incoming message handler ---
95
+ const handleIncomingMessage = async function (route: Routing.XmppMessageRoute): Promise<void> {
96
+ // Skip error messages
97
+ if (route.type === "error") return;
98
+
99
+ // Check authorization
100
+ const auth = Config.getXmppAuthorizationState(
101
+ route.fromBare,
102
+ configStore.getAllowedJid(),
103
+ );
104
+
105
+ if (auth.kind === "deny") return;
106
+
107
+ // Auto-pair if needed
108
+ if (auth.kind === "pair") {
109
+ configStore.setAllowedJid(route.fromBare);
110
+ await configStore.persist();
111
+ recordRuntimeEvent("pair", null, { jid: route.fromBare });
112
+ }
113
+
114
+ // Process through inbound handler pipeline
115
+ const config = configStore.get();
116
+ const inboundResult = await Inbound.processXmppInbound(route, config);
117
+
118
+ // Build turn context
119
+ const turn: Queue.XmppTurnContext = {
120
+ from: route.from,
121
+ fromBare: route.fromBare,
122
+ body: inboundResult.rawText,
123
+ type: route.type,
124
+ thread: route.thread,
125
+ subject: route.subject,
126
+ isGroup: route.isGroup,
127
+ roomJid: route.roomJid,
128
+ senderNick: route.senderNick,
129
+ timestamp: Date.now(),
130
+ };
131
+
132
+ // Build prompt
133
+ const prompt = Queue.buildXmppTurnPrompt(turn, {
134
+ includeThread: true,
135
+ extraContext: inboundResult.handlerOutputs.length > 0
136
+ ? `handler outputs: ${inboundResult.handlerOutputs.join("; ")}`
137
+ : undefined,
138
+ });
139
+
140
+ // Enqueue the message
141
+ const ctx = sessionContextStore.get();
142
+ if (ctx) {
143
+ const item: Queue.XmppQueueItem<Pi.ExtensionContext> = {
144
+ id: `xmpp-${queue.allocateItemOrder()}-${Date.now()}`,
145
+ order: queue.allocateItemOrder(),
146
+ turn,
147
+ prompt,
148
+ ctx,
149
+ status: "queued",
150
+ };
151
+
152
+ xmppQueueStore.enqueue(item);
153
+ dispatchNextQueuedTurn();
154
+ }
155
+ };
156
+
157
+ // --- Incoming presence handler ---
158
+ const handleIncomingPresence = async function (
159
+ route: Routing.XmppPresenceRoute,
160
+ ): Promise<void> {
161
+ recordRuntimeEvent("presence", null, {
162
+ from: route.from,
163
+ type: route.type,
164
+ show: route.show,
165
+ });
166
+ };
167
+
168
+ // --- Dispatch next queued turn ---
169
+ const dispatchNextQueuedTurn = function (): void {
170
+ if (bridgeRuntime.state.xmppTurnDispatchPending) return;
171
+ if (bridgeRuntime.state.compactionInProgress) return;
172
+ if (activeTurnRuntime.has()) return;
173
+
174
+ const next = xmppQueueStore.dequeue();
175
+ if (!next) return;
176
+
177
+ bridgeRuntime.state.xmppTurnDispatchPending = true;
178
+ activeTurnRuntime.set(next.turn);
179
+
180
+ const ctx = next.ctx;
181
+
182
+ // Set abort handler
183
+ const abortController = new AbortController();
184
+ abort.setHandler(() => {
185
+ abortController.abort();
186
+ activeTurnRuntime.clear();
187
+ bridgeRuntime.state.xmppTurnDispatchPending = false;
188
+ });
189
+
190
+ // Send the prompt to Pi
191
+ // Note: activeTurnRuntime is cleared in onAgentEnd after the agent finishes processing
192
+ try {
193
+ sendUserMessage(next.prompt);
194
+ } catch (error: unknown) {
195
+ recordRuntimeEvent("dispatch", error);
196
+ activeTurnRuntime.clear();
197
+ bridgeRuntime.state.xmppTurnDispatchPending = false;
198
+ abort.clearHandler();
199
+ }
200
+ };
201
+
202
+ // --- Register slash commands ---
203
+ const allCommands = Commands.createXmppSlashCommands({
204
+ client: xmppClient,
205
+ configStore,
206
+ runtime: bridgeRuntime,
207
+ getConnectionStatus,
208
+ sendMessageToActiveTurn,
209
+ updateStatus: () => {
210
+ // Status is pushed declaratively via getConnectionStatus/getAllowedJid;
211
+ // individual commands that change state can call this to trigger a status refresh.
212
+ },
213
+ getAllowedJid,
214
+ });
215
+
216
+ for (const cmd of allCommands) {
217
+ pi.registerCommand(cmd.name, {
218
+ description: cmd.description,
219
+ handler: async (_args: string, _ctx: Pi.ExtensionCommandContext) => {
220
+ await cmd.handler({ args: [], rawArgs: _args });
221
+ },
222
+ });
223
+ }
224
+
225
+ // --- Register the xmpp_send tool ---
226
+ pi.registerTool({
227
+ name: "xmpp_send",
228
+ label: "Send XMPP Message",
229
+ description:
230
+ "Send a message via XMPP. Provide `to` (JID), `body` (message text), and optionally `type` (chat or groupchat).",
231
+ parameters: Type.Object({
232
+ to: Type.String({ description: "Recipient JID" }),
233
+ body: Type.String({ description: "Message body" }),
234
+ type: Type.Optional(Type.String({ description: "Message type (default: chat)" })),
235
+ subject: Type.Optional(Type.String({ description: "Optional message subject" })),
236
+ }),
237
+ execute: async (_toolCallId: string, params: { to: string; body: string; type?: string; subject?: string }) => {
238
+ try {
239
+ await Outbound.sendXmppMessage(xmppClient, params.to, params.body, {
240
+ type: params.type,
241
+ subject: params.subject,
242
+ });
243
+ return {
244
+ content: [{ type: "text" as const, text: `Message sent to ${params.to}` }],
245
+ details: {},
246
+ };
247
+ } catch (error: unknown) {
248
+ return {
249
+ content: [
250
+ {
251
+ type: "text" as const,
252
+ text: `Failed to send message: ${error instanceof Error ? error.message : String(error)}`,
253
+ },
254
+ ],
255
+ isError: true,
256
+ details: {},
257
+ };
258
+ }
259
+ },
260
+ });
261
+
262
+ // --- Register the xmpp_help tool ---
263
+ Prompts.registerXmppHelpTool(pi);
264
+
265
+ // --- Lifecycle hooks ---
266
+ const beforeAgentStartHook = Prompts.createXmppBeforeAgentStartHook();
267
+
268
+ Lifecycle.registerXmppLifecycleHooks(pi, {
269
+ onSessionStart: async (_event, ctx) => {
270
+ sessionContextStore.set(ctx);
271
+
272
+ // Try to auto-connect if credentials are stored
273
+ const config = configStore.get();
274
+ if (config.jid && config.password && xmppClient.status === "offline") {
275
+ try {
276
+ await xmppClient.connect(config);
277
+
278
+ // Auto-join configured rooms
279
+ if (config.autoJoinRooms?.length) {
280
+ const nick = config.jid.split("@")[0];
281
+ for (const room of config.autoJoinRooms) {
282
+ xmppClient.joinRoom(room, nick);
283
+ }
284
+ }
285
+ } catch (error) {
286
+ recordRuntimeEvent("auto-connect", error);
287
+ }
288
+ }
289
+ },
290
+
291
+ onSessionShutdown: async () => {
292
+ sessionContextStore.clear();
293
+ },
294
+
295
+ onBeforeAgentStart: async (event) => {
296
+ return beforeAgentStartHook(event);
297
+ },
298
+
299
+ onAgentStart: async () => {
300
+ // Agent started processing
301
+ },
302
+
303
+ onAgentEnd: async () => {
304
+ // Agent finished processing; clear active turn and dispatch next queued
305
+ activeTurnRuntime.clear();
306
+ bridgeRuntime.state.xmppTurnDispatchPending = false;
307
+ abort.clearHandler();
308
+ dispatchNextQueuedTurn();
309
+ },
310
+ });
311
+
312
+ // --- Stanza listener ---
313
+ xmppClient.onStanza(handleIncomingStanza);
314
+
315
+ // --- Record extension start ---
316
+ recordRuntimeEvent("extension-start", null, {
317
+ instanceId: xmppInstanceId,
318
+ pid: process.pid,
319
+ });
320
+
321
+ // --- Handle uncaught XMPP errors ---
322
+ xmppClient.onError((error) => {
323
+ recordRuntimeEvent("xmpp-error", error);
324
+ });
325
+ }