@moxxy/plugin-channel-signal 0.27.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel/chunker.d.ts +69 -0
  3. package/dist/channel/chunker.d.ts.map +1 -0
  4. package/dist/channel/chunker.js +133 -0
  5. package/dist/channel/chunker.js.map +1 -0
  6. package/dist/channel/turn-runner.d.ts +33 -0
  7. package/dist/channel/turn-runner.d.ts.map +1 -0
  8. package/dist/channel/turn-runner.js +70 -0
  9. package/dist/channel/turn-runner.js.map +1 -0
  10. package/dist/channel/voice.d.ts +37 -0
  11. package/dist/channel/voice.d.ts.map +1 -0
  12. package/dist/channel/voice.js +88 -0
  13. package/dist/channel/voice.js.map +1 -0
  14. package/dist/channel.d.ts +154 -0
  15. package/dist/channel.d.ts.map +1 -0
  16. package/dist/channel.js +468 -0
  17. package/dist/channel.js.map +1 -0
  18. package/dist/index.d.ts +28 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +215 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/jsonrpc.d.ts +53 -0
  23. package/dist/jsonrpc.d.ts.map +1 -0
  24. package/dist/jsonrpc.js +167 -0
  25. package/dist/jsonrpc.js.map +1 -0
  26. package/dist/keys.d.ts +37 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +55 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/pair-flow.d.ts +21 -0
  31. package/dist/pair-flow.d.ts.map +1 -0
  32. package/dist/pair-flow.js +136 -0
  33. package/dist/pair-flow.js.map +1 -0
  34. package/dist/permission.d.ts +21 -0
  35. package/dist/permission.d.ts.map +1 -0
  36. package/dist/permission.js +27 -0
  37. package/dist/permission.js.map +1 -0
  38. package/dist/schema.d.ts +4295 -0
  39. package/dist/schema.d.ts.map +1 -0
  40. package/dist/schema.js +84 -0
  41. package/dist/schema.js.map +1 -0
  42. package/dist/setup-wizard.d.ts +14 -0
  43. package/dist/setup-wizard.d.ts.map +1 -0
  44. package/dist/setup-wizard.js +85 -0
  45. package/dist/setup-wizard.js.map +1 -0
  46. package/dist/sidecar.d.ts +137 -0
  47. package/dist/sidecar.d.ts.map +1 -0
  48. package/dist/sidecar.js +421 -0
  49. package/dist/sidecar.js.map +1 -0
  50. package/package.json +89 -0
  51. package/src/channel/chunker.test.ts +135 -0
  52. package/src/channel/chunker.ts +147 -0
  53. package/src/channel/turn-runner.ts +97 -0
  54. package/src/channel/voice.test.ts +136 -0
  55. package/src/channel/voice.ts +118 -0
  56. package/src/channel.test.ts +364 -0
  57. package/src/channel.ts +607 -0
  58. package/src/index.ts +289 -0
  59. package/src/jsonrpc.test.ts +128 -0
  60. package/src/jsonrpc.ts +198 -0
  61. package/src/keys.test.ts +45 -0
  62. package/src/keys.ts +56 -0
  63. package/src/pair-flow.ts +161 -0
  64. package/src/permission.ts +36 -0
  65. package/src/schema.ts +98 -0
  66. package/src/setup-wizard.ts +98 -0
  67. package/src/sidecar.test.ts +276 -0
  68. package/src/sidecar.ts +511 -0
  69. package/src/subcommands.test.ts +206 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxxy (moxxy.ai)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Buffered chunked sends — Signal's streaming strategy.
3
+ *
4
+ * WHY NOT FramePump edits: signal-cli's `send` does support editing a previous
5
+ * message (`editTimestamp`), but every Signal edit is a full end-to-end
6
+ * re-delivery of the whole message body to every device in the conversation.
7
+ * Driving it at a streaming cadence (an edit per ~1s for a long turn) floods
8
+ * the recipient's devices with dozens of E2E deliveries per reply, official
9
+ * clients keep a bounded edit history per message, and burst sends are exactly
10
+ * what Signal's server-side spam heuristics rate-limit (challenge/backoff).
11
+ * So instead of "send once then edit" we buffer the streamed text and send
12
+ * coherent chunks: nothing goes out until a paragraph-aligned chunk is ready,
13
+ * and the remainder goes out once, at turn end. Liveness comes from the typing
14
+ * indicator (`sendTyping`), not message churn.
15
+ */
16
+ /** Buffered text below this length is held for the final flush. */
17
+ export declare const SIGNAL_CHUNK_SOFT_LIMIT = 1500;
18
+ /**
19
+ * Never send a single message longer than this. Signal itself allows long
20
+ * bodies, but clients render extremely long messages poorly and signal-cli
21
+ * sends the whole body per message; 2000 mirrors common client truncation.
22
+ */
23
+ export declare const SIGNAL_CHUNK_HARD_LIMIT = 2000;
24
+ export interface ChunkLimits {
25
+ readonly softLimit?: number;
26
+ readonly hardLimit?: number;
27
+ }
28
+ /**
29
+ * If `text` has grown past the soft limit, split off one send-ready chunk at
30
+ * the best boundary (paragraph → line → word) at or below the hard limit.
31
+ * Returns null while the text should keep buffering.
32
+ */
33
+ export declare function takeChunk(text: string, limits?: ChunkLimits): {
34
+ chunk: string;
35
+ rest: string;
36
+ } | null;
37
+ /** Split a final remainder into hard-limit-sized pieces at the best boundaries. */
38
+ export declare function splitForSignal(text: string, limits?: ChunkLimits): string[];
39
+ export interface ChunkedSenderOptions {
40
+ /** Deliver one message. Errors should be handled by the caller-supplied fn
41
+ * (log + swallow) — a failed chunk must never abort the turn. */
42
+ readonly send: (text: string) => Promise<void>;
43
+ readonly limits?: ChunkLimits;
44
+ }
45
+ /**
46
+ * Drives {@link takeChunk} over a GROWING renderer snapshot: `offer()` on every
47
+ * change (sends any ready chunk), `finalize()` at turn end (sends the
48
+ * remainder, or `emptyText` when the whole turn produced nothing). Sends are
49
+ * serialized on an internal queue so chunks can never interleave out of order.
50
+ */
51
+ export declare class ChunkedSender {
52
+ private readonly opts;
53
+ /** The exact snapshot prefix already delivered (chunk boundaries included). */
54
+ private sentPrefix;
55
+ private sentAnything;
56
+ private queue;
57
+ constructor(opts: ChunkedSenderOptions);
58
+ /** Called with the current full snapshot whenever it changes. */
59
+ offer(snapshot: string): void;
60
+ /**
61
+ * Turn end: deliver whatever the final snapshot still owes. When the final
62
+ * text no longer extends what we already sent (a divergent final frame —
63
+ * rare), send the full final text so the user always receives the
64
+ * authoritative reply, accepting the duplication.
65
+ */
66
+ finalize(snapshot: string, emptyText?: string): Promise<void>;
67
+ private deliver;
68
+ }
69
+ //# sourceMappingURL=chunker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chunker.d.ts","sourceRoot":"","sources":["../../src/channel/chunker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,mEAAmE;AACnE,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAC7C;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EACZ,MAAM,GAAE,WAAgB,GACvB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAiBxC;AAED,mFAAmF;AACnF,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,GAAE,WAAgB,GAAG,MAAM,EAAE,CAc/E;AAED,MAAM,WAAW,oBAAoB;IACnC;sEACkE;IAClE,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED;;;;;GAKG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAuB;IAC5C,+EAA+E;IAC/E,OAAO,CAAC,UAAU,CAAM;IACxB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,KAAK,CAAoC;gBAErC,IAAI,EAAE,oBAAoB;IAItC,iEAAiE;IACjE,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAiB7B;;;;;OAKG;IACG,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAkBrD,OAAO;CAMtB"}
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Buffered chunked sends — Signal's streaming strategy.
3
+ *
4
+ * WHY NOT FramePump edits: signal-cli's `send` does support editing a previous
5
+ * message (`editTimestamp`), but every Signal edit is a full end-to-end
6
+ * re-delivery of the whole message body to every device in the conversation.
7
+ * Driving it at a streaming cadence (an edit per ~1s for a long turn) floods
8
+ * the recipient's devices with dozens of E2E deliveries per reply, official
9
+ * clients keep a bounded edit history per message, and burst sends are exactly
10
+ * what Signal's server-side spam heuristics rate-limit (challenge/backoff).
11
+ * So instead of "send once then edit" we buffer the streamed text and send
12
+ * coherent chunks: nothing goes out until a paragraph-aligned chunk is ready,
13
+ * and the remainder goes out once, at turn end. Liveness comes from the typing
14
+ * indicator (`sendTyping`), not message churn.
15
+ */
16
+ /** Buffered text below this length is held for the final flush. */
17
+ export const SIGNAL_CHUNK_SOFT_LIMIT = 1_500;
18
+ /**
19
+ * Never send a single message longer than this. Signal itself allows long
20
+ * bodies, but clients render extremely long messages poorly and signal-cli
21
+ * sends the whole body per message; 2000 mirrors common client truncation.
22
+ */
23
+ export const SIGNAL_CHUNK_HARD_LIMIT = 2_000;
24
+ /**
25
+ * If `text` has grown past the soft limit, split off one send-ready chunk at
26
+ * the best boundary (paragraph → line → word) at or below the hard limit.
27
+ * Returns null while the text should keep buffering.
28
+ */
29
+ export function takeChunk(text, limits = {}) {
30
+ const soft = limits.softLimit ?? SIGNAL_CHUNK_SOFT_LIMIT;
31
+ const hard = limits.hardLimit ?? SIGNAL_CHUNK_HARD_LIMIT;
32
+ if (text.length <= soft)
33
+ return null;
34
+ const window = text.slice(0, Math.min(text.length, hard));
35
+ // Prefer a paragraph break; a chunk that ends mid-sentence reads badly as a
36
+ // standalone Signal message. Require the boundary to keep a meaningful chunk
37
+ // (≥ half the soft limit) so a leading blank line can't produce confetti.
38
+ const minCut = Math.floor(soft / 2);
39
+ for (const boundary of ['\n\n', '\n', ' ']) {
40
+ const at = window.lastIndexOf(boundary);
41
+ if (at >= minCut) {
42
+ return { chunk: window.slice(0, at).trimEnd(), rest: text.slice(at + boundary.length) };
43
+ }
44
+ }
45
+ if (text.length <= hard)
46
+ return null; // no boundary yet — wait for more text
47
+ return { chunk: window, rest: text.slice(window.length) }; // pathological: hard cut
48
+ }
49
+ /** Split a final remainder into hard-limit-sized pieces at the best boundaries. */
50
+ export function splitForSignal(text, limits = {}) {
51
+ const hard = limits.hardLimit ?? SIGNAL_CHUNK_HARD_LIMIT;
52
+ const parts = [];
53
+ let rest = text;
54
+ while (rest.length > hard) {
55
+ // Reuse takeChunk with soft==hard-ish so it only splits when forced.
56
+ const taken = takeChunk(rest, { softLimit: Math.floor(hard / 2), hardLimit: hard });
57
+ if (!taken)
58
+ break;
59
+ if (taken.chunk)
60
+ parts.push(taken.chunk);
61
+ rest = taken.rest;
62
+ }
63
+ const tail = rest.trim();
64
+ if (tail)
65
+ parts.push(tail);
66
+ return parts;
67
+ }
68
+ /**
69
+ * Drives {@link takeChunk} over a GROWING renderer snapshot: `offer()` on every
70
+ * change (sends any ready chunk), `finalize()` at turn end (sends the
71
+ * remainder, or `emptyText` when the whole turn produced nothing). Sends are
72
+ * serialized on an internal queue so chunks can never interleave out of order.
73
+ */
74
+ export class ChunkedSender {
75
+ opts;
76
+ /** The exact snapshot prefix already delivered (chunk boundaries included). */
77
+ sentPrefix = '';
78
+ sentAnything = false;
79
+ queue = Promise.resolve();
80
+ constructor(opts) {
81
+ this.opts = opts;
82
+ }
83
+ /** Called with the current full snapshot whenever it changes. */
84
+ offer(snapshot) {
85
+ this.queue = this.queue.then(async () => {
86
+ // Streamed text only ever grows; if the snapshot diverged from what we
87
+ // already delivered (the final assistant_message rewrote history) hold
88
+ // everything for finalize(), which handles divergence explicitly.
89
+ if (!snapshot.startsWith(this.sentPrefix))
90
+ return;
91
+ let consumed = this.sentPrefix.length;
92
+ let taken = takeChunk(snapshot.slice(consumed), this.opts.limits);
93
+ while (taken) {
94
+ await this.deliver(taken.chunk);
95
+ consumed = snapshot.length - taken.rest.length;
96
+ this.sentPrefix = snapshot.slice(0, consumed);
97
+ taken = takeChunk(taken.rest, this.opts.limits);
98
+ }
99
+ });
100
+ }
101
+ /**
102
+ * Turn end: deliver whatever the final snapshot still owes. When the final
103
+ * text no longer extends what we already sent (a divergent final frame —
104
+ * rare), send the full final text so the user always receives the
105
+ * authoritative reply, accepting the duplication.
106
+ */
107
+ async finalize(snapshot, emptyText) {
108
+ this.queue = this.queue.then(async () => {
109
+ const finalText = snapshot.trim();
110
+ if (!finalText) {
111
+ if (!this.sentAnything && emptyText)
112
+ await this.deliver(emptyText);
113
+ return;
114
+ }
115
+ const remainder = snapshot.startsWith(this.sentPrefix)
116
+ ? snapshot.slice(this.sentPrefix.length)
117
+ : snapshot; // diverged: resend the authoritative text in full
118
+ for (const part of splitForSignal(remainder, this.opts.limits)) {
119
+ await this.deliver(part);
120
+ }
121
+ this.sentPrefix = snapshot;
122
+ });
123
+ await this.queue;
124
+ }
125
+ async deliver(text) {
126
+ const trimmed = text.trim();
127
+ if (!trimmed)
128
+ return;
129
+ this.sentAnything = true;
130
+ await this.opts.send(trimmed);
131
+ }
132
+ }
133
+ //# sourceMappingURL=chunker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chunker.js","sourceRoot":"","sources":["../../src/channel/chunker.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ import type { newTurnId } from '@moxxy/core';
2
+ import type { ClientSession as Session } from '@moxxy/sdk';
3
+ import { type ChunkLimits } from './chunker.js';
4
+ export interface TurnRunnerLogger {
5
+ warn?(msg: string, meta?: Record<string, unknown>): void;
6
+ }
7
+ export interface RunSignalTurnDeps {
8
+ readonly session: Session;
9
+ /** Deliver one outbound message to the turn's reply target. Must swallow its
10
+ * own transport errors (log + resolve) — a failed send never aborts the turn. */
11
+ readonly send: (text: string) => Promise<void>;
12
+ /** Start/refresh the typing indicator (best-effort; errors swallowed). */
13
+ readonly sendTyping?: (stop: boolean) => Promise<void>;
14
+ readonly chunkLimits?: ChunkLimits;
15
+ readonly logger?: TurnRunnerLogger;
16
+ }
17
+ export interface RunSignalTurnOptions {
18
+ readonly text: string;
19
+ readonly model?: string;
20
+ readonly controller: AbortController;
21
+ /** Pre-minted turn id; the channel records it as an own-turn id (invariant #8). */
22
+ readonly turnId: ReturnType<typeof newTurnId>;
23
+ }
24
+ /**
25
+ * Drive a single Signal turn end-to-end: subscribe to THIS turn's events
26
+ * (filtered by turnId — `session.log` fans out to every listener, AGENTS.md
27
+ * invariant #8), stream the rendered snapshot through the buffered
28
+ * {@link ChunkedSender} (see chunker.ts for why Signal gets chunked sends
29
+ * instead of FramePump edits), keep a typing indicator alive for liveness, run
30
+ * the turn, flush the final remainder, and unwind in `finally`.
31
+ */
32
+ export declare function runSignalTurn(deps: RunSignalTurnDeps, opts: RunSignalTurnOptions): Promise<void>;
33
+ //# sourceMappingURL=turn-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-runner.d.ts","sourceRoot":"","sources":["../../src/channel/turn-runner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,KAAK,EAAE,aAAa,IAAI,OAAO,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAE/D,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC1D;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;sFACkF;IAClF,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,0EAA0E;IAC1E,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,gBAAgB,CAAC;CACpC;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAC;IACrC,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;CAC/C;AAKD;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,iBAAiB,EACvB,IAAI,EAAE,oBAAoB,GACzB,OAAO,CAAC,IAAI,CAAC,CAsDf"}
@@ -0,0 +1,70 @@
1
+ import { PlainTurnRenderer, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
2
+ import { ChunkedSender } from './chunker.js';
3
+ /** Signal shows a typing indicator for ~15s per sendTyping; refresh under that. */
4
+ const TYPING_REFRESH_MS = 10_000;
5
+ /**
6
+ * Drive a single Signal turn end-to-end: subscribe to THIS turn's events
7
+ * (filtered by turnId — `session.log` fans out to every listener, AGENTS.md
8
+ * invariant #8), stream the rendered snapshot through the buffered
9
+ * {@link ChunkedSender} (see chunker.ts for why Signal gets chunked sends
10
+ * instead of FramePump edits), keep a typing indicator alive for liveness, run
11
+ * the turn, flush the final remainder, and unwind in `finally`.
12
+ */
13
+ export async function runSignalTurn(deps, opts) {
14
+ const { session, send, sendTyping, logger } = deps;
15
+ const { text, model, controller, turnId } = opts;
16
+ const renderer = new PlainTurnRenderer();
17
+ const sender = new ChunkedSender({
18
+ send: async (t) => {
19
+ try {
20
+ await send(t);
21
+ }
22
+ catch (err) {
23
+ logger?.warn?.('signal: send failed', { err: err instanceof Error ? err.message : String(err) });
24
+ }
25
+ },
26
+ ...(deps.chunkLimits ? { limits: deps.chunkLimits } : {}),
27
+ });
28
+ // Liveness: refresh the typing indicator while the turn runs (each
29
+ // sendTyping shows for ~15s). Best-effort — a failure only loses the
30
+ // indicator, never the turn.
31
+ const typing = (stop) => {
32
+ if (!sendTyping)
33
+ return;
34
+ void sendTyping(stop).catch(() => undefined);
35
+ };
36
+ typing(false);
37
+ const typingTimer = setInterval(() => typing(false), TYPING_REFRESH_MS);
38
+ typingTimer.unref?.();
39
+ const unsubscribe = subscribeTurn(session, turnId, (event) => {
40
+ if (renderer.accept(event))
41
+ sender.offer(renderer.snapshot());
42
+ });
43
+ try {
44
+ await driveTurn(session, {
45
+ turnId,
46
+ prompt: text,
47
+ ...(model ? { model } : {}),
48
+ signal: controller.signal,
49
+ });
50
+ await sender.finalize(renderer.snapshot(), '(no output)');
51
+ }
52
+ catch (err) {
53
+ logger?.warn?.('signal: turn failed', {
54
+ err: err instanceof Error ? err.message : String(err),
55
+ });
56
+ // Surface the failure to the sender rather than going silent.
57
+ try {
58
+ await send(`Turn failed: ${err instanceof Error ? err.message : String(err)}`);
59
+ }
60
+ catch {
61
+ /* best-effort */
62
+ }
63
+ }
64
+ finally {
65
+ clearInterval(typingTimer);
66
+ typing(true);
67
+ unsubscribe();
68
+ }
69
+ }
70
+ //# sourceMappingURL=turn-runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-runner.js","sourceRoot":"","sources":["../../src/channel/turn-runner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ import type { ClientSession as Session } from '@moxxy/sdk';
2
+ import { type SignalAttachment } from '../schema.js';
3
+ /**
4
+ * Voice-note handling for the Signal channel. signal-cli writes received
5
+ * attachments to `<dataDir>/attachments/<id>` (it does NOT inline them in the
6
+ * receive notification), so the flow is: pick the first audio attachment,
7
+ * size-cap it, read the file, transcribe via the session's active Transcriber,
8
+ * and hand the transcript back for a normal text turn.
9
+ */
10
+ /**
11
+ * Hard cap on an audio file we will buffer into memory before transcribing —
12
+ * matches the Telegram channel's ceiling so an authorized-but-compromised
13
+ * sender can't make the runner buffer an arbitrarily large blob.
14
+ */
15
+ export declare const MAX_AUDIO_BYTES: number;
16
+ export interface VoiceLogger {
17
+ warn?(msg: string, meta?: Record<string, unknown>): void;
18
+ }
19
+ export interface VoiceDeps {
20
+ readonly session: Session;
21
+ /** signal-cli's attachments dir (injectable for tests). */
22
+ readonly attachmentsDir: string;
23
+ /** Reply to the sender (guidance / error strings). */
24
+ readonly reply: (text: string) => Promise<void>;
25
+ /** Injectable file reader (tests). Defaults to fs.readFile. */
26
+ readonly readFile?: (filePath: string) => Promise<Uint8Array>;
27
+ readonly logger?: VoiceLogger;
28
+ }
29
+ /** The first audio attachment in a message, or null. */
30
+ export declare function pickAudioAttachment(attachments: ReadonlyArray<SignalAttachment> | undefined): SignalAttachment | null;
31
+ /**
32
+ * Transcribe a received audio attachment. Returns the transcript, or null when
33
+ * the message was fully handled with a reply (no transcriber configured,
34
+ * oversized, unreadable, transcription failed).
35
+ */
36
+ export declare function transcribeVoiceAttachment(deps: VoiceDeps, attachment: SignalAttachment): Promise<string | null>;
37
+ //# sourceMappingURL=voice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../../src/channel/voice.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,IAAI,OAAO,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEzE;;;;;;GAMG;AAEH;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAmB,CAAC;AAEhD,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC1D;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,2DAA2D;IAC3D,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,sDAAsD;IACtD,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAC9D,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED,wDAAwD;AACxD,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,aAAa,CAAC,gBAAgB,CAAC,GAAG,SAAS,GACvD,gBAAgB,GAAG,IAAI,CAGzB;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,SAAS,EACf,UAAU,EAAE,gBAAgB,GAC3B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAkExB"}
@@ -0,0 +1,88 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { attachmentIdSchema } from '../schema.js';
4
+ /**
5
+ * Voice-note handling for the Signal channel. signal-cli writes received
6
+ * attachments to `<dataDir>/attachments/<id>` (it does NOT inline them in the
7
+ * receive notification), so the flow is: pick the first audio attachment,
8
+ * size-cap it, read the file, transcribe via the session's active Transcriber,
9
+ * and hand the transcript back for a normal text turn.
10
+ */
11
+ /**
12
+ * Hard cap on an audio file we will buffer into memory before transcribing —
13
+ * matches the Telegram channel's ceiling so an authorized-but-compromised
14
+ * sender can't make the runner buffer an arbitrarily large blob.
15
+ */
16
+ export const MAX_AUDIO_BYTES = 20 * 1024 * 1024;
17
+ /** The first audio attachment in a message, or null. */
18
+ export function pickAudioAttachment(attachments) {
19
+ if (!attachments)
20
+ return null;
21
+ return attachments.find((a) => (a.contentType ?? '').toLowerCase().startsWith('audio/')) ?? null;
22
+ }
23
+ /**
24
+ * Transcribe a received audio attachment. Returns the transcript, or null when
25
+ * the message was fully handled with a reply (no transcriber configured,
26
+ * oversized, unreadable, transcription failed).
27
+ */
28
+ export async function transcribeVoiceAttachment(deps, attachment) {
29
+ const transcriber = deps.session.transcribers.tryGetActive();
30
+ if (!transcriber) {
31
+ await deps.reply('Heard a voice note, but no speech-to-text backend is configured. Install @moxxy/plugin-stt-whisper ' +
32
+ 'and run `moxxy login openai` (or set OPENAI_API_KEY) to enable voice input.');
33
+ return null;
34
+ }
35
+ // Reject oversized audio up-front from the declared size, before any read.
36
+ if (typeof attachment.size === 'number' && attachment.size > MAX_AUDIO_BYTES) {
37
+ await deps.reply(`That audio is too large (${Math.round(attachment.size / (1024 * 1024))}MB). The limit is ${MAX_AUDIO_BYTES / (1024 * 1024)}MB.`);
38
+ return null;
39
+ }
40
+ // The id becomes a filename under signal-cli's attachments dir; re-validate
41
+ // its charset here (defense in depth on top of the zod schema) so a crafted
42
+ // id can never traverse out of the directory.
43
+ const id = attachmentIdSchema.safeParse(attachment.id);
44
+ if (!id.success) {
45
+ deps.logger?.warn?.('signal: dropping voice note with invalid attachment id');
46
+ return null;
47
+ }
48
+ const filePath = path.join(deps.attachmentsDir, id.data);
49
+ const read = deps.readFile ?? ((p) => fs.readFile(p));
50
+ let bytes;
51
+ try {
52
+ bytes = await read(filePath);
53
+ }
54
+ catch (err) {
55
+ deps.logger?.warn?.('signal: could not read voice attachment', {
56
+ err: err instanceof Error ? err.message : String(err),
57
+ });
58
+ await deps.reply('Could not read that voice note from the signal-cli attachment store.');
59
+ return null;
60
+ }
61
+ if (bytes.byteLength > MAX_AUDIO_BYTES) {
62
+ await deps.reply('That audio is too large to transcribe.');
63
+ return null;
64
+ }
65
+ let transcript;
66
+ try {
67
+ const result = await transcriber.transcribe(bytes, {
68
+ mimeType: attachment.contentType ?? 'audio/aac',
69
+ });
70
+ transcript = result.text.trim();
71
+ }
72
+ catch (err) {
73
+ deps.logger?.warn?.('signal: transcription failed', {
74
+ err: err instanceof Error ? err.message : String(err),
75
+ });
76
+ await deps.reply(`Transcription failed: ${err instanceof Error ? err.message : String(err)}`);
77
+ return null;
78
+ }
79
+ if (!transcript) {
80
+ await deps.reply('Could not transcribe the voice note (got empty text).');
81
+ return null;
82
+ }
83
+ // Echo what we heard so the user can spot misrecognitions before the agent
84
+ // acts on it (same UX as the Telegram voice path).
85
+ await deps.reply(`heard: ${transcript}`);
86
+ return transcript;
87
+ }
88
+ //# sourceMappingURL=voice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.js","sourceRoot":"","sources":["../../src/channel/voice.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,154 @@
1
+ import type { ClientSession as Session } from '@moxxy/sdk';
2
+ import type { Channel, ChannelHandle, ChannelStartOptsBase, PermissionResolver } from '@moxxy/sdk';
3
+ import type { VaultStore } from '@moxxy/plugin-vault';
4
+ import { type LinkProcessHandle, type SpawnFn } from './sidecar.js';
5
+ export interface SignalChannelLogger {
6
+ debug?(msg: string, meta?: Record<string, unknown>): void;
7
+ info?(msg: string, meta?: Record<string, unknown>): void;
8
+ warn?(msg: string, meta?: Record<string, unknown>): void;
9
+ error?(msg: string, meta?: Record<string, unknown>): void;
10
+ }
11
+ /** Where a reply goes: the owner's Note-to-Self thread, or a direct recipient. */
12
+ export type SendTarget = {
13
+ readonly noteToSelf: true;
14
+ } | {
15
+ readonly recipient: string;
16
+ };
17
+ /** The RPC slice the channel drives (SignalRpcClient satisfies it; tests fake it). */
18
+ export interface SignalRpcLike {
19
+ request(method: string, params?: Record<string, unknown>): Promise<unknown>;
20
+ onNotification(method: string, listener: (params: unknown) => void): () => void;
21
+ onClose(listener: (reason: string) => void): () => void;
22
+ close(): void;
23
+ }
24
+ /** The sidecar slice the channel manages (SignalSidecar satisfies it). */
25
+ export interface SignalSidecarLike {
26
+ start(): Promise<SignalRpcLike>;
27
+ stop(): Promise<void>;
28
+ onExit(listener: (code: number | null) => void): () => void;
29
+ }
30
+ export interface SignalStartOpts extends ChannelStartOptsBase {
31
+ readonly session: Session;
32
+ /**
33
+ * If true (and the account isn't linked yet), open a linking window on
34
+ * startup: spawn `signal-cli link`, publish the `sgnl://linkdevice…` URI as
35
+ * this channel's connect value (rendered as a QR by the pair flow / desktop
36
+ * Channels panel), and boot the daemon once the phone completes the link.
37
+ */
38
+ readonly pair?: boolean;
39
+ /**
40
+ * Running on a dedicated runner under a GUI control surface (the desktop
41
+ * Channels panel). Equivalent to `pair` for the unlinked case, so the desktop
42
+ * links with the identical mechanism instead of getting a throw.
43
+ */
44
+ readonly dedicated?: boolean;
45
+ /** Override the autonomous tool allow-list at start. */
46
+ readonly allowedTools?: ReadonlyArray<string>;
47
+ }
48
+ export interface SignalChannelOptions {
49
+ readonly vault: VaultStore;
50
+ /** Account override (E.164); beats env + vault. */
51
+ readonly account?: string;
52
+ /** signal-cli binary path/name override. Default: resolve from PATH. */
53
+ readonly binary?: string;
54
+ /** Tools the model may call autonomously. `['*']` allows every registered tool. */
55
+ readonly allowedTools?: ReadonlyArray<string>;
56
+ /** Extra allowed senders (E.164/uuid), merged with the vault allow-list. */
57
+ readonly allowedSenders?: ReadonlyArray<string>;
58
+ readonly logger?: SignalChannelLogger;
59
+ readonly sidecarFactory?: (opts: {
60
+ account: string;
61
+ binary: string;
62
+ }) => SignalSidecarLike;
63
+ readonly linkFactory?: (opts: {
64
+ deviceName: string;
65
+ binary: string;
66
+ }) => LinkProcessHandle;
67
+ readonly listAccountsFn?: (opts: {
68
+ binary: string;
69
+ }) => Promise<string[]>;
70
+ readonly findBinaryFn?: () => string | null;
71
+ readonly attachmentsDir?: string;
72
+ readonly spawnFn?: SpawnFn;
73
+ }
74
+ export declare class SignalChannel implements Channel<SignalStartOpts> {
75
+ readonly name = "signal";
76
+ /**
77
+ * Installed on the session by the CLI dispatcher. Replaced in `start()` once
78
+ * the live tool registry is available for `['*']` expansion; until then it
79
+ * denies everything (safe default before start).
80
+ */
81
+ permissionResolver: PermissionResolver;
82
+ private readonly opts;
83
+ private session;
84
+ private model;
85
+ private account;
86
+ private allowedSenders;
87
+ private sidecar;
88
+ private rpc;
89
+ private link;
90
+ private handle;
91
+ private runningSettled;
92
+ private resolveRunning;
93
+ private rejectRunning;
94
+ private stopping;
95
+ private readonly turns;
96
+ private lastTarget;
97
+ private logUnsub;
98
+ /**
99
+ * Timestamps of messages WE sent through the daemon (signal-cli's `send`
100
+ * result). A linked device sees the account's sends come back as
101
+ * `syncMessage.sentMessage` envelopes — including its own — so any sync'd
102
+ * sent-message whose timestamp is in this set is an echo of ourselves and
103
+ * must be dropped (loop protection).
104
+ */
105
+ private readonly sentTimestamps;
106
+ private linkUri;
107
+ private linked;
108
+ private readonly connectListeners;
109
+ private readonly linkedListeners;
110
+ private lastDropWarnAt;
111
+ constructor(opts: SignalChannelOptions);
112
+ /** The `sgnl://linkdevice…` URI while a linking window is open; null otherwise. */
113
+ get requestUrl(): string | null;
114
+ /** Whether the account is linked (the "connect the other side" step). */
115
+ get connected(): boolean;
116
+ /** Fires when linking completes (the pair flow prints success off this). */
117
+ onLinked(listener: (account: string) => void): () => void;
118
+ start(startOpts: SignalStartOpts): Promise<ChannelHandle>;
119
+ private openLinkWindow;
120
+ private bootDaemon;
121
+ /** Every inbound notification funnels through here — zod-validate FIRST. */
122
+ private handleReceive;
123
+ /** A message from ANOTHER account — gate on the sender allow-list. */
124
+ private handleDataMessage;
125
+ /**
126
+ * A sync'd copy of a message the ACCOUNT OWNER sent from another device.
127
+ * Two jobs: (1) drop echoes of OUR OWN sends (linked devices receive the
128
+ * account's sends back — loop protection), (2) accept the owner's
129
+ * Note-to-Self prompts, which are allowed by default after linking.
130
+ */
131
+ private handleSyncSent;
132
+ /** Voice → transcript, busy gate, then one coordinated turn. */
133
+ private processMessage;
134
+ /** Send one message and record its timestamp for the sync-echo drop. */
135
+ private sendText;
136
+ private sendTyping;
137
+ /**
138
+ * Post the assistant's prose for a turn this channel did not initiate.
139
+ * Skipped for our own turnIds (robust to async ordering / replay, invariant
140
+ * #8) and while a turn of ours is streaming via the chunked sender.
141
+ */
142
+ private mirrorForeignTurn;
143
+ /**
144
+ * Run a handler detached from the socket read loop (an envelope handler that
145
+ * awaited a whole turn would park notification delivery for its duration).
146
+ * Errors are logged here — nothing upstream awaits this promise.
147
+ */
148
+ private dispatchInBackground;
149
+ private warnDrop;
150
+ private notifyConnectChange;
151
+ private settleRunning;
152
+ private fatal;
153
+ }
154
+ //# sourceMappingURL=channel.d.ts.map