@parall/parel-channel 1.37.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,30 @@
1
+ /**
2
+ * Prompt framing for external IM channel messages (channel_message
3
+ * dispatches). Mirrors agent-core's event-format.ts channel branch, adapted
4
+ * to the parel runtime's tool surface:
5
+ *
6
+ * - The audience is OUTSIDE Parall — no prll:// refs, no internal cards or
7
+ * attachment links in the reply (dead links there).
8
+ * - The reply goes out through the provider clip's `send_message` command.
9
+ * A parel agent invokes clips from its sandbox via the parall CLI, so the
10
+ * hint spells out the concrete `parall clip invoke …` command (agent-core
11
+ * leaves the invocation mechanism to the bridge's own clip tooling).
12
+ * - Provider metadata is best-effort (the connection lookup can fail) —
13
+ * never instruct the agent to invoke a made-up clip alias.
14
+ * - When the inbound external message id is known, offer the in-thread reply
15
+ * form ({"message_id": …}) so threaded conversations aren't nudged toward
16
+ * new top-level messages.
17
+ */
18
+ export interface ChannelPromptArgs {
19
+ /** Provider name (= clip alias, e.g. "feishu"); undefined when unresolved. */
20
+ provider?: string;
21
+ /** Conversation shape as recorded on the ChannelConversation (dm/group). */
22
+ conversationType?: string;
23
+ externalConversationId?: string;
24
+ externalMessageId?: string;
25
+ senderName: string;
26
+ /** The inbound message text (raw, un-framed). */
27
+ text: string;
28
+ }
29
+ export declare function buildChannelPrompt(args: ChannelPromptArgs): string;
30
+ //# sourceMappingURL=channel-prompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AASH,MAAM,WAAW,iBAAiB;IAChC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAkBlE"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Prompt framing for external IM channel messages (channel_message
3
+ * dispatches). Mirrors agent-core's event-format.ts channel branch, adapted
4
+ * to the parel runtime's tool surface:
5
+ *
6
+ * - The audience is OUTSIDE Parall — no prll:// refs, no internal cards or
7
+ * attachment links in the reply (dead links there).
8
+ * - The reply goes out through the provider clip's `send_message` command.
9
+ * A parel agent invokes clips from its sandbox via the parall CLI, so the
10
+ * hint spells out the concrete `parall clip invoke …` command (agent-core
11
+ * leaves the invocation mechanism to the bridge's own clip tooling).
12
+ * - Provider metadata is best-effort (the connection lookup can fail) —
13
+ * never instruct the agent to invoke a made-up clip alias.
14
+ * - When the inbound external message id is known, offer the in-thread reply
15
+ * form ({"message_id": …}) so threaded conversations aren't nudged toward
16
+ * new top-level messages.
17
+ */
18
+ function sanitizeMeta(value) {
19
+ return value
20
+ .replace(/[\r\n]+/g, ' ')
21
+ .replace(/[[\]|]/g, ' ')
22
+ .trim();
23
+ }
24
+ export function buildChannelPrompt(args) {
25
+ const providerLabel = sanitizeMeta(args.provider ?? 'external IM');
26
+ const convLabel = args.externalConversationId
27
+ ? `${sanitizeMeta(args.externalConversationId)} (${sanitizeMeta(args.conversationType ?? 'conversation')})`
28
+ : sanitizeMeta(args.conversationType ?? 'conversation');
29
+ const lines = [
30
+ '[Event: channel.message]',
31
+ `[Channel: ${providerLabel} | conversation: ${convLabel}]`,
32
+ `[From: ${sanitizeMeta(args.senderName)} (external user, not a Parall member)]`,
33
+ ];
34
+ if (args.externalMessageId) {
35
+ lines.push(`[External message ID: ${sanitizeMeta(args.externalMessageId)}]`);
36
+ }
37
+ lines.push(`[Audience: this conversation lives on ${providerLabel}, OUTSIDE Parall. Readers cannot open prll:// links, Parall cards, or internal attachments — never include them in replies. Write plain conversational text.]`);
38
+ lines.push('', args.text);
39
+ return lines.join('\n') + buildChannelReplyHint(args);
40
+ }
41
+ function buildChannelReplyHint(args) {
42
+ const target = JSON.stringify({
43
+ chat_id: args.externalConversationId || '<conversation id>',
44
+ text: '...',
45
+ });
46
+ const threadAlt = args.externalMessageId
47
+ ? ` To reply in-thread to this specific message, use '${JSON.stringify({ message_id: args.externalMessageId, text: '...' })}' instead.`
48
+ : '';
49
+ if (args.provider) {
50
+ const alias = args.provider;
51
+ return `\n<system-reminder>To reply, invoke the \`${alias}\` clip's \`send_message\` command from your sandbox: \`parall clip invoke ${alias} send_message '${target}'\` — your plain text output is NOT delivered to the external conversation.${threadAlt} The same clip's \`call\` command reaches the wider ${alias} API when needed.</system-reminder>`;
52
+ }
53
+ return `\n<system-reminder>To reply, invoke your channel provider clip's \`send_message\` command from your sandbox: \`parall clip invoke <alias> send_message '${target}'\` (find the alias with \`parall clip list\`) — your plain text output is NOT delivered to the external conversation.${threadAlt} The same clip's \`call\` command reaches the wider external platform API when needed.</system-reminder>`;
54
+ }
@@ -0,0 +1,22 @@
1
+ import type { ConnectorContext, ConnectRequest } from '@parel/plugin-sdk';
2
+ /**
3
+ * Open the Parall gateway WebSocket as this agent. Auth is the agent's agk_: we
4
+ * fetch a short-lived WS ticket and hand parel the ws:// url to connect to. The
5
+ * parel runtime owns the socket lifecycle from there.
6
+ *
7
+ * Contract (confirmed against parel #102/#103): one long-lived managed_ws
8
+ * connection per declaration; the host rewrites ws(s):// to http(s):// for the
9
+ * Workers fetch dial, owns keepalive/liveness, and re-invokes connect() on
10
+ * every (re)connect — so the short-lived ticket is minted fresh each time.
11
+ */
12
+ export declare function connectParall(ctx: ConnectorContext): Promise<ConnectRequest>;
13
+ export declare function parallApiUrl(ctx: ConnectorContext): string;
14
+ export declare function parallOrgId(ctx: ConnectorContext): string;
15
+ /**
16
+ * The parall agent's user id (usr_), needed for the path-scoped session/step
17
+ * reporting endpoints. Empty on configs deployed before progress reporting
18
+ * shipped — reporting degrades to off until the agent is redeployed.
19
+ */
20
+ export declare function parallAgentId(ctx: ConnectorContext): string;
21
+ export declare function requireAgk(ctx: ConnectorContext): string;
22
+ //# sourceMappingURL=connect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAE1E;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC,CAyBlF;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE3D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAMxD"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Open the Parall gateway WebSocket as this agent. Auth is the agent's agk_: we
3
+ * fetch a short-lived WS ticket and hand parel the ws:// url to connect to. The
4
+ * parel runtime owns the socket lifecycle from there.
5
+ *
6
+ * Contract (confirmed against parel #102/#103): one long-lived managed_ws
7
+ * connection per declaration; the host rewrites ws(s):// to http(s):// for the
8
+ * Workers fetch dial, owns keepalive/liveness, and re-invokes connect() on
9
+ * every (re)connect — so the short-lived ticket is minted fresh each time.
10
+ */
11
+ export async function connectParall(ctx) {
12
+ const apiUrl = parallApiUrl(ctx);
13
+ const agk = requireAgk(ctx);
14
+ // connect() runs on every (re)connect — fail fast rather than hang the host's
15
+ // reconnect loop on a wedged ticket endpoint.
16
+ const res = await fetch(`${apiUrl}/api/v1/ws/ticket`, {
17
+ method: 'POST',
18
+ headers: { Authorization: `Bearer ${agk}` },
19
+ signal: AbortSignal.timeout(10_000),
20
+ });
21
+ if (!res.ok) {
22
+ throw new Error(`parel-channel: ws ticket failed (${res.status})`);
23
+ }
24
+ const body = (await res.json().catch(() => null));
25
+ const ticket = body?.ticket;
26
+ const wsUrl = body?.ws_url;
27
+ if (!ticket) {
28
+ throw new Error('parel-channel: ws ticket response missing ticket');
29
+ }
30
+ // Prefer the server-advertised gateway URL — the API and WS hosts differ in
31
+ // some deployments (local dev ports, split domains). Fall back to deriving
32
+ // from the API origin (prod-style single domain routing /ws).
33
+ const base = wsUrl || `${apiUrl.replace(/^http/, 'ws')}/ws`;
34
+ const sep = base.includes('?') ? '&' : '?';
35
+ return { url: `${base}${sep}ticket=${encodeURIComponent(ticket)}` };
36
+ }
37
+ export function parallApiUrl(ctx) {
38
+ return String(ctx.config.parallApiUrl ?? '').replace(/\/+$/, '');
39
+ }
40
+ export function parallOrgId(ctx) {
41
+ return String(ctx.config.parallOrgId ?? '');
42
+ }
43
+ /**
44
+ * The parall agent's user id (usr_), needed for the path-scoped session/step
45
+ * reporting endpoints. Empty on configs deployed before progress reporting
46
+ * shipped — reporting degrades to off until the agent is redeployed.
47
+ */
48
+ export function parallAgentId(ctx) {
49
+ return String(ctx.config.parallAgentId ?? '');
50
+ }
51
+ export function requireAgk(ctx) {
52
+ const agk = ctx.secrets.parallApiKey;
53
+ if (!agk) {
54
+ throw new Error('parel-channel: parallApiKey secret is required');
55
+ }
56
+ return agk;
57
+ }
@@ -0,0 +1,24 @@
1
+ import type { ChannelDelivery, ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
2
+ /**
3
+ * Post the agent's reply back to the Parall chat. The chat id rides the
4
+ * replyRoute.data set in inbound.ts.
5
+ *
6
+ * Contract (confirmed against parel #104): provider_http reply routes are
7
+ * auto-delivered at turn end via this hook; `payload` is the plain reply text
8
+ * string and `replyRoute` is the one captured from the originating envelope.
9
+ *
10
+ * The reply POST is awaited (not returned as a host fetch effect) so the
11
+ * session link resolution and failure handling stay in one place; a
12
+ * retryable failure falls back to the host effect (at-least-once, idempotent
13
+ * by delivery id + message idempotency_key). The turn's PRIMARY done signal
14
+ * lives in onAgentEvent (turn_completed); the idle here is a deliberate
15
+ * redundant close for bindings without `observe: [turn]` — see the inline
16
+ * comment at the bottom.
17
+ *
18
+ * External IM turns (channel_message dispatches) route here too but deliver
19
+ * NOTHING: their replyRoute carries a conversationId and no chatId, and the
20
+ * agent replies through the provider clip itself — this hook only runs the
21
+ * same redundant session idle for them.
22
+ */
23
+ export declare function buildParallDelivery(delivery: ChannelDelivery, ctx: ConnectorContext): Promise<ConnectorEffect[]>;
24
+ //# sourceMappingURL=delivery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery.d.ts","sourceRoot":"","sources":["../src/delivery.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAU5F;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CA2F5B"}
@@ -0,0 +1,120 @@
1
+ import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
2
+ import { ensureSession, invalidateSession, patchSession, REQUEST_TIMEOUT_MS, turnEnvelopeKey, } from './session.js';
3
+ /**
4
+ * Post the agent's reply back to the Parall chat. The chat id rides the
5
+ * replyRoute.data set in inbound.ts.
6
+ *
7
+ * Contract (confirmed against parel #104): provider_http reply routes are
8
+ * auto-delivered at turn end via this hook; `payload` is the plain reply text
9
+ * string and `replyRoute` is the one captured from the originating envelope.
10
+ *
11
+ * The reply POST is awaited (not returned as a host fetch effect) so the
12
+ * session link resolution and failure handling stay in one place; a
13
+ * retryable failure falls back to the host effect (at-least-once, idempotent
14
+ * by delivery id + message idempotency_key). The turn's PRIMARY done signal
15
+ * lives in onAgentEvent (turn_completed); the idle here is a deliberate
16
+ * redundant close for bindings without `observe: [turn]` — see the inline
17
+ * comment at the bottom.
18
+ *
19
+ * External IM turns (channel_message dispatches) route here too but deliver
20
+ * NOTHING: their replyRoute carries a conversationId and no chatId, and the
21
+ * agent replies through the provider clip itself — this hook only runs the
22
+ * same redundant session idle for them.
23
+ */
24
+ export async function buildParallDelivery(delivery, ctx) {
25
+ const apiUrl = parallApiUrl(ctx);
26
+ const agk = requireAgk(ctx);
27
+ const orgId = parallOrgId(ctx);
28
+ const route = (delivery.replyRoute?.data ?? {});
29
+ const chatId = route.chatId;
30
+ if (!chatId) {
31
+ // External IM turn (channel_message): the reply already went out through
32
+ // the provider clip, agent-invoked — the turn-end text is deliberately
33
+ // NOT delivered anywhere (same contract as agent-core: plain output is
34
+ // not the reply). Only run the redundant idle so a binding without
35
+ // `observe: [turn]` still closes the session.
36
+ if (route.conversationId) {
37
+ const sessionId = await ensureSession(ctx, route.conversationId, { timeoutMs: 3_000 });
38
+ if (sessionId) {
39
+ await idleUnlessSuperseded(ctx, route.conversationId, route.envelopeId, sessionId);
40
+ }
41
+ }
42
+ return [];
43
+ }
44
+ const payload = delivery.payload;
45
+ const text = typeof payload === 'string' ? payload : (payload?.text ?? '');
46
+ if (!text)
47
+ return [];
48
+ // Short timeout: this sits in front of the user-visible reply POST. Cache
49
+ // hit (every message after a chat's first) is a store read; on a slow miss
50
+ // we degrade to a reply without the session link rather than delay it.
51
+ const sessionId = await ensureSession(ctx, chatId, { timeoutMs: 3_000 });
52
+ const request = {
53
+ url: `${apiUrl}/api/v1/orgs/${orgId}/chats/${chatId}/messages`,
54
+ method: 'POST',
55
+ headers: {
56
+ Authorization: `Bearer ${agk}`,
57
+ 'Content-Type': 'application/json',
58
+ },
59
+ // message_type must be explicit: the server persists it verbatim, and
60
+ // a blank type skips text normalization (prll:// refs, mentions) and
61
+ // renders as an unknown-type fallback on clients. agent_session_id links
62
+ // the reply bubble to its session panel (server validates ownership).
63
+ // idempotency_key dedupes the ambiguous-failure case where the direct
64
+ // POST committed but its response was lost and the host retry re-sends —
65
+ // same body on both paths, so the server collapses them to one message.
66
+ body: JSON.stringify({
67
+ message_type: 'text',
68
+ content: { text },
69
+ idempotency_key: `parel-reply:${delivery.id}`,
70
+ // A reply to a thread message stays in that thread.
71
+ ...(route.threadRootId ? { thread_root_id: route.threadRootId } : {}),
72
+ ...(sessionId ? { agent_session_id: sessionId } : {}),
73
+ }),
74
+ };
75
+ try {
76
+ const res = await fetch(request.url, {
77
+ method: request.method,
78
+ headers: request.headers,
79
+ body: request.body,
80
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
81
+ });
82
+ if (!res.ok) {
83
+ // 4xx (bar 429) is permanent — retrying an unprocessable reply burns
84
+ // requests forever. Drop it (logged); turn_completed still closes the
85
+ // turn's indicator.
86
+ const permanent = res.status >= 400 && res.status < 500 && res.status !== 429;
87
+ if (!permanent)
88
+ throw new Error(`reply post failed (${res.status})`);
89
+ console.error(`[parel-channel] reply rejected (${res.status}) — dropping`);
90
+ }
91
+ }
92
+ catch (err) {
93
+ console.error('[parel-channel] direct reply failed; falling back to host retry', err);
94
+ return [{ type: 'fetch', request, idempotencyKey: `reply:${delivery.id}` }];
95
+ }
96
+ // Redundant done signal, kept deliberately: turn_completed (onAgentEvent)
97
+ // is the primary close for every turn — including no-reply turns this hook
98
+ // never sees — but a binding deployed without `observe: [turn]` that still
99
+ // resolves this connector (npm ^1 fallback path; the artifact path freezes
100
+ // connector+config atomically) would otherwise never idle. Both signals
101
+ // run the same turnEnvelope staleness guard and idle is idempotent, so the
102
+ // overlap is harmless.
103
+ if (sessionId) {
104
+ await idleUnlessSuperseded(ctx, chatId, route.envelopeId, sessionId);
105
+ }
106
+ return [];
107
+ }
108
+ /**
109
+ * Idle the subject's session unless a newer envelope owns the turn (the same
110
+ * turnEnvelope staleness guard onAgentEvent runs). Fail-open when the marker
111
+ * is unavailable — a stuck ring is worse than a blink.
112
+ */
113
+ async function idleUnlessSuperseded(ctx, subject, envelopeId, sessionId) {
114
+ const current = await ctx.store?.get(turnEnvelopeKey(subject)).catch(() => null);
115
+ if (!(typeof current === 'string' && current) || !envelopeId || current === envelopeId) {
116
+ const result = await patchSession(ctx, sessionId, { status: 'idle' });
117
+ if (result === 'stale')
118
+ await invalidateSession(ctx, subject);
119
+ }
120
+ }
@@ -0,0 +1,28 @@
1
+ import type { AgentEvent, AgentEventEffect, ConnectorContext, ConnectorEffect, WebSocketFrame } from '@parel/plugin-sdk';
2
+ export declare function handleParallFrame(frame: WebSocketFrame, ctx: ConnectorContext): Promise<ConnectorEffect[]>;
3
+ /**
4
+ * Sweep pending dispatches on (re)connect — the live WS push has no replay.
5
+ * Paginates with the server cursor so a long outage's backlog is not silently
6
+ * truncated (bounded to keep a pathological backlog from wedging onOpen; the
7
+ * remainder is picked up on the next reconnect sweep).
8
+ */
9
+ export declare function catchUpParall(ctx: ConnectorContext): Promise<ConnectorEffect[]>;
10
+ /**
11
+ * Agent execution events (parel E1+E2, plugin-sdk 0.9.0, opt-in via the
12
+ * channel declaration's `observe: [turn, steps]`).
13
+ *
14
+ * E1 (turn lifecycle): turn_completed fires unconditionally on every cleanly
15
+ * finished turn — INCLUDING no-reply turns (hadOutput: false), the reliable
16
+ * "processing ended" signal that used to need a 30min turnwatch timer.
17
+ * turn_failed closes the turn the same way.
18
+ *
19
+ * E2 (step trace): model_reasoning / tool_call / tool_result become parall
20
+ * AgentSteps — the session panel gets the same live execution feed CC agents
21
+ * have (reasoning is aggregated host-side; tool output is a bounded preview).
22
+ *
23
+ * Events are best-effort (display feed, never data-correctness): a missed
24
+ * done event just means the ring clears on the next turn's trigger change; a
25
+ * missed trace step is a gap in the panel, nothing more.
26
+ */
27
+ export declare function handleParallAgentEvent(event: AgentEvent, ctx: ConnectorContext): Promise<AgentEventEffect[]>;
28
+ //# sourceMappingURL=inbound.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../src/inbound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACf,MAAM,mBAAmB,CAAC;AA4C3B,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAiB5B;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAiFrF;AA+XD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA2C7B"}