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/lib/routing.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * XMPP message routing helpers
3
+ * Zones: xmpp routing, stanza dispatch
4
+ * Owns stanza type detection, sender extraction, and routing logic for dispatching
5
+ * incoming stanzas to the correct handler pipeline
6
+ */
7
+
8
+ import type { XmppStanza } from "./xmpp-api.ts";
9
+
10
+ export const XMPP_PREFIX = "[xmpp";
11
+
12
+ export interface XmppMessageRoute {
13
+ kind: "message";
14
+ from: string;
15
+ fromBare: string;
16
+ body: string;
17
+ type: string;
18
+ thread?: string;
19
+ subject?: string;
20
+ isGroup: boolean;
21
+ roomJid?: string;
22
+ senderNick?: string;
23
+ }
24
+
25
+ export interface XmppPresenceRoute {
26
+ kind: "presence";
27
+ from: string;
28
+ type?: string;
29
+ show?: string;
30
+ status?: string;
31
+ }
32
+
33
+ export interface XmppErrorRoute {
34
+ kind: "error";
35
+ from?: string;
36
+ body?: string;
37
+ }
38
+
39
+ export type XmppRoute = XmppMessageRoute | XmppPresenceRoute | XmppErrorRoute;
40
+
41
+ /**
42
+ * Get the bare JID (without resource) from a full JID string
43
+ */
44
+ export function getBareJid(jid: string): string {
45
+ const idx = jid.indexOf("/");
46
+ return idx >= 0 ? jid.slice(0, idx) : jid;
47
+ }
48
+
49
+ /**
50
+ * Extract nickname from a MUC occupant JID
51
+ */
52
+ export function extractMucNick(fullJid: string): string | undefined {
53
+ const idx = fullJid.indexOf("/");
54
+ return idx >= 0 ? fullJid.slice(idx + 1) : undefined;
55
+ }
56
+
57
+ /**
58
+ * Determine if a bare JID looks like a MUC room
59
+ */
60
+ export function isRoomJid(bareJid: string): boolean {
61
+ // MUC rooms typically have a conference subdomain or contain special chars
62
+ return bareJid.includes("conference") || bareJid.includes("chat.");
63
+ }
64
+
65
+ /**
66
+ * Build the XMPP prefix for Pi agent context
67
+ */
68
+ export function buildXmppPrefix(route: XmppMessageRoute): string {
69
+ const parts = [`${XMPP_PREFIX}|from:${route.from}`];
70
+ if (route.isGroup && route.roomJid) {
71
+ parts.push(`room:${route.roomJid}`);
72
+ if (route.senderNick) parts.push(`nick:${route.senderNick}`);
73
+ }
74
+ return parts.join("|");
75
+ }
76
+
77
+ /**
78
+ * Route an incoming stanza
79
+ */
80
+ export function routeStanza(stanza: XmppStanza): XmppRoute | undefined {
81
+ if (stanza.name === "message") {
82
+ return routeMessage(stanza);
83
+ }
84
+ if (stanza.name === "presence") {
85
+ return routePresence(stanza);
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function routeMessage(stanza: XmppStanza): XmppMessageRoute | undefined {
91
+ const from = stanza.attrs.from;
92
+ const type = stanza.attrs.type ?? "normal";
93
+ if (!from) return undefined;
94
+
95
+ // Skip messages from self
96
+ const fromBare = getBareJid(from);
97
+
98
+ // Find body
99
+ const bodyChild = stanza.children.find(
100
+ (c) =>
101
+ typeof c === "object" && c !== null && (c as XmppStanza).name === "body",
102
+ ) as XmppStanza | undefined;
103
+
104
+ if (!bodyChild) return undefined;
105
+
106
+ const bodyText = bodyChild.children.find((c) => typeof c === "string");
107
+ if (typeof bodyText !== "string") return undefined;
108
+
109
+ // Find subject and thread
110
+ const subjectChild = stanza.children.find(
111
+ (c) =>
112
+ typeof c === "object" &&
113
+ c !== null &&
114
+ (c as XmppStanza).name === "subject",
115
+ ) as XmppStanza | undefined;
116
+
117
+ const threadChild = stanza.children.find(
118
+ (c) =>
119
+ typeof c === "object" &&
120
+ c !== null &&
121
+ (c as XmppStanza).name === "thread",
122
+ ) as XmppStanza | undefined;
123
+
124
+ const subject = subjectChild
125
+ ? (subjectChild.children.find((c) => typeof c === "string") as
126
+ | string
127
+ | undefined)
128
+ : undefined;
129
+
130
+ const thread = threadChild
131
+ ? (threadChild.children.find((c) => typeof c === "string") as
132
+ | string
133
+ | undefined)
134
+ : undefined;
135
+
136
+ const isGroup = type === "groupchat";
137
+ let roomJid: string | undefined;
138
+ let senderNick: string | undefined;
139
+
140
+ if (isGroup) {
141
+ roomJid = fromBare;
142
+ senderNick = extractMucNick(from);
143
+ }
144
+
145
+ return {
146
+ kind: "message",
147
+ from,
148
+ fromBare,
149
+ body: bodyText,
150
+ type,
151
+ thread,
152
+ subject,
153
+ isGroup,
154
+ roomJid,
155
+ senderNick,
156
+ };
157
+ }
158
+
159
+ function routePresence(
160
+ stanza: XmppStanza,
161
+ ): XmppPresenceRoute | undefined {
162
+ const from = stanza.attrs.from;
163
+ if (!from) return undefined;
164
+
165
+ const showChild = stanza.children.find(
166
+ (c) =>
167
+ typeof c === "object" && c !== null && (c as XmppStanza).name === "show",
168
+ ) as XmppStanza | undefined;
169
+
170
+ const statusChild = stanza.children.find(
171
+ (c) =>
172
+ typeof c === "object" &&
173
+ c !== null &&
174
+ (c as XmppStanza).name === "status",
175
+ ) as XmppStanza | undefined;
176
+
177
+ const show = showChild
178
+ ? (showChild.children.find((c) => typeof c === "string") as
179
+ | string
180
+ | undefined)
181
+ : undefined;
182
+
183
+ const status = statusChild
184
+ ? (statusChild.children.find((c) => typeof c === "string") as
185
+ | string
186
+ | undefined)
187
+ : undefined;
188
+
189
+ return {
190
+ kind: "presence",
191
+ from,
192
+ type: stanza.attrs.type,
193
+ show,
194
+ status,
195
+ };
196
+ }
package/lib/runtime.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * XMPP bridge runtime-state helpers
3
+ * Zones: pi agent runtime state, xmpp session, shared coordination
4
+ * Owns small session-local runtime primitives shared by orchestration
5
+ */
6
+
7
+ const XMPP_TYPING_LABEL_INTERVAL_MS = 2500;
8
+
9
+ export interface XmppRuntimeQueueCounters {
10
+ nextQueuedXmppItemOrder: number;
11
+ nextQueuedXmppControlOrder: number;
12
+ }
13
+
14
+ export interface XmppRuntimeLifecycleFlags {
15
+ activeXmppToolExecutions: number;
16
+ xmppTurnDispatchPending: boolean;
17
+ compactionInProgress: boolean;
18
+ }
19
+
20
+ export interface XmppBridgeRuntimeState
21
+ extends XmppRuntimeQueueCounters, XmppRuntimeLifecycleFlags {
22
+ abortHandler?: () => void;
23
+ chatStateInterval?: ReturnType<typeof setInterval>;
24
+ }
25
+
26
+ export interface XmppRuntimeQueuePort {
27
+ allocateItemOrder: () => number;
28
+ allocateControlOrder: () => number;
29
+ }
30
+
31
+ export interface XmppRuntimeLifecyclePort {
32
+ getActiveToolExecutions: () => number;
33
+ setActiveToolExecutions: (count: number) => void;
34
+ resetActiveToolExecutions: () => void;
35
+ hasDispatchPending: () => boolean;
36
+ setDispatchPending: (pending: boolean) => void;
37
+ clearDispatchPending: () => void;
38
+ isCompactionInProgress: () => boolean;
39
+ setCompactionInProgress: (inProgress: boolean) => void;
40
+ }
41
+
42
+ export interface XmppRuntimeAbortPort {
43
+ hasHandler: () => boolean;
44
+ setHandler: (abortHandler: () => void) => void;
45
+ clearHandler: () => void;
46
+ getHandler: () => (() => void) | undefined;
47
+ abortTurn: () => boolean;
48
+ }
49
+
50
+ export interface XmppBridgeRuntime {
51
+ state: XmppBridgeRuntimeState;
52
+ queue: XmppRuntimeQueuePort;
53
+ lifecycle: XmppRuntimeLifecyclePort;
54
+ abort: XmppRuntimeAbortPort;
55
+ }
56
+
57
+ export function createXmppBridgeRuntimeState(): XmppBridgeRuntimeState {
58
+ return {
59
+ nextQueuedXmppItemOrder: 0,
60
+ nextQueuedXmppControlOrder: 0,
61
+ activeXmppToolExecutions: 0,
62
+ xmppTurnDispatchPending: false,
63
+ compactionInProgress: false,
64
+ };
65
+ }
66
+
67
+ export function createXmppBridgeRuntime(
68
+ state = createXmppBridgeRuntimeState(),
69
+ ): XmppBridgeRuntime {
70
+ return {
71
+ state,
72
+ queue: {
73
+ allocateItemOrder: () => state.nextQueuedXmppItemOrder++,
74
+ allocateControlOrder: () => state.nextQueuedXmppControlOrder++,
75
+ },
76
+ lifecycle: {
77
+ getActiveToolExecutions: () => state.activeXmppToolExecutions,
78
+ setActiveToolExecutions: (count) => {
79
+ state.activeXmppToolExecutions = count;
80
+ },
81
+ resetActiveToolExecutions: () => {
82
+ state.activeXmppToolExecutions = 0;
83
+ },
84
+ hasDispatchPending: () => state.xmppTurnDispatchPending,
85
+ setDispatchPending: (pending) => {
86
+ state.xmppTurnDispatchPending = pending;
87
+ },
88
+ clearDispatchPending: () => {
89
+ state.xmppTurnDispatchPending = false;
90
+ },
91
+ isCompactionInProgress: () => state.compactionInProgress,
92
+ setCompactionInProgress: (inProgress) => {
93
+ state.compactionInProgress = inProgress;
94
+ },
95
+ },
96
+ abort: {
97
+ hasHandler: () => typeof state.abortHandler === "function",
98
+ setHandler: (abortHandler) => {
99
+ state.abortHandler = abortHandler;
100
+ },
101
+ clearHandler: () => {
102
+ state.abortHandler = undefined;
103
+ },
104
+ getHandler: () => state.abortHandler,
105
+ abortTurn: () => {
106
+ if (typeof state.abortHandler === "function") {
107
+ state.abortHandler();
108
+ state.abortHandler = undefined;
109
+ return true;
110
+ }
111
+ return false;
112
+ },
113
+ },
114
+ };
115
+ }
116
+
117
+ export function createXmppChatStateSender(
118
+ sendChatState: (to: string, state: string) => void,
119
+ getActiveTurnTarget: () => string | undefined,
120
+ ) {
121
+ let interval: ReturnType<typeof setInterval> | undefined;
122
+
123
+ return {
124
+ start: (jid: string) => {
125
+ sendChatState(jid, "active");
126
+ interval = setInterval(() => {
127
+ const target = getActiveTurnTarget();
128
+ if (target) sendChatState(target, "active");
129
+ }, XMPP_TYPING_LABEL_INTERVAL_MS);
130
+ },
131
+ stop: () => {
132
+ const target = getActiveTurnTarget();
133
+ if (target) sendChatState(target, "inactive");
134
+ if (interval) {
135
+ clearInterval(interval);
136
+ interval = undefined;
137
+ }
138
+ },
139
+ pause: () => {
140
+ const target = getActiveTurnTarget();
141
+ if (target) sendChatState(target, "paused");
142
+ if (interval) {
143
+ clearInterval(interval);
144
+ interval = undefined;
145
+ }
146
+ },
147
+ };
148
+ }
package/lib/status.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * XMPP bridge status and diagnostics
3
+ * Zones: xmpp status, diagnostics
4
+ * Owns runtime event recording, status-line providers, and status rendering
5
+ */
6
+
7
+ import type { XmppConfig } from "./config.ts";
8
+ import type { XmppConnectionStatus } from "./xmpp-api.ts";
9
+
10
+ const STATUS_LINE_PROVIDER_KEY = "__piXmppStatusLineProviders__";
11
+
12
+ export interface XmppStatusLineProviderContext {
13
+ config: XmppConfig;
14
+ connectionStatus: XmppConnectionStatus;
15
+ connectedJid?: string;
16
+ allowedJid?: string;
17
+ activeTurnFrom?: string;
18
+ queuedCount: number;
19
+ }
20
+
21
+ export interface XmppStatusLineProviderResult {
22
+ label: string;
23
+ value: string;
24
+ priority?: number;
25
+ }
26
+
27
+ export interface XmppStatusLineProvider {
28
+ (
29
+ ctx: XmppStatusLineProviderContext,
30
+ ):
31
+ | XmppStatusLineProviderResult
32
+ | Promise<XmppStatusLineProviderResult>;
33
+ }
34
+
35
+ /**
36
+ * Register a status line provider for extension diagnostics.
37
+ * Companion extensions can add custom status lines to `/xmpp-status`.
38
+ */
39
+ export function registerXmppStatusLineProvider(
40
+ provider: XmppStatusLineProvider,
41
+ ): void {
42
+ const registry = getStatusLineRegistry();
43
+ registry.push(provider);
44
+ }
45
+
46
+ function getStatusLineRegistry(): XmppStatusLineProvider[] {
47
+ const globals = globalThis as Record<string, unknown>;
48
+ if (!globals[STATUS_LINE_PROVIDER_KEY]) {
49
+ globals[STATUS_LINE_PROVIDER_KEY] = [];
50
+ }
51
+ return globals[STATUS_LINE_PROVIDER_KEY] as XmppStatusLineProvider[];
52
+ }
53
+
54
+ export interface XmppRuntimeEventEntry {
55
+ timestamp: number;
56
+ category: string;
57
+ error?: string;
58
+ details?: Record<string, unknown>;
59
+ }
60
+
61
+ export function createXmppRuntimeEventRecorder(
62
+ options?: { maxEntries?: number },
63
+ ) {
64
+ const maxEntries = options?.maxEntries ?? 100;
65
+ const events: XmppRuntimeEventEntry[] = [];
66
+
67
+ return {
68
+ record: (
69
+ category: string,
70
+ error: unknown,
71
+ details?: Record<string, unknown>,
72
+ ) => {
73
+ let errorStr: string | undefined;
74
+ if (error instanceof Error) {
75
+ errorStr = error.message;
76
+ } else if (error != null) {
77
+ errorStr = String(error);
78
+ }
79
+ events.push({
80
+ timestamp: Date.now(),
81
+ category,
82
+ error: errorStr,
83
+ details,
84
+ });
85
+ if (events.length > maxEntries) {
86
+ events.splice(0, events.length - maxEntries);
87
+ }
88
+ },
89
+ getEvents: () => [...events],
90
+ clear: () => {
91
+ events.length = 0;
92
+ },
93
+ };
94
+ }
95
+
96
+ export function formatXmppStatusSummary(deps: {
97
+ config: XmppConfig;
98
+ connectionStatus: XmppConnectionStatus;
99
+ connectedJid?: string;
100
+ allowedJid?: string;
101
+ activeTurnFrom?: string;
102
+ queuedCount: number;
103
+ runtimeEvents: XmppRuntimeEventEntry[];
104
+ recentErrors?: string[];
105
+ }): string {
106
+ const lines: string[] = [
107
+ "## XMPP Bridge Status",
108
+ "",
109
+ `**Connection:** ${deps.connectionStatus}`,
110
+ `**JID:** ${deps.connectedJid ?? "not connected"}`,
111
+ `**Configured JID:** ${deps.config.jid ?? "not configured"}`,
112
+ `**Allowed JID:** ${deps.allowedJid ?? "not paired"}`,
113
+ `**Active turn:** ${deps.activeTurnFrom ?? "none"}`,
114
+ `**Queued messages:** ${deps.queuedCount}`,
115
+ `**Service:** ${deps.config.service ?? "auto"}`,
116
+ `**Domain:** ${deps.config.domain ?? "auto"}`,
117
+ ];
118
+
119
+ if (deps.config.autoJoinRooms?.length) {
120
+ lines.push(`**Auto-join rooms:** ${deps.config.autoJoinRooms.join(", ")}`);
121
+ }
122
+
123
+ if (deps.recentErrors?.length) {
124
+ lines.push("");
125
+ lines.push("### Recent Errors");
126
+ for (const err of deps.recentErrors) {
127
+ lines.push(`- ${err}`);
128
+ }
129
+ }
130
+
131
+ if (deps.runtimeEvents.length > 0) {
132
+ lines.push("");
133
+ lines.push("### Runtime Events");
134
+ const recent = deps.runtimeEvents.slice(-10);
135
+ for (const event of recent) {
136
+ const time = new Date(event.timestamp).toISOString();
137
+ lines.push(
138
+ `- [${time}] ${event.category}: ${event.error ?? "ok"}`,
139
+ );
140
+ }
141
+ }
142
+
143
+ return lines.join("\n");
144
+ }