@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.
- package/LICENSE +21 -0
- package/dist/channel/chunker.d.ts +69 -0
- package/dist/channel/chunker.d.ts.map +1 -0
- package/dist/channel/chunker.js +133 -0
- package/dist/channel/chunker.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +33 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +70 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/voice.d.ts +37 -0
- package/dist/channel/voice.d.ts.map +1 -0
- package/dist/channel/voice.js +88 -0
- package/dist/channel/voice.js.map +1 -0
- package/dist/channel.d.ts +154 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +468 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +215 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonrpc.d.ts +53 -0
- package/dist/jsonrpc.d.ts.map +1 -0
- package/dist/jsonrpc.js +167 -0
- package/dist/jsonrpc.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +55 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +21 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +136 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/permission.d.ts +21 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +27 -0
- package/dist/permission.js.map +1 -0
- package/dist/schema.d.ts +4295 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +84 -0
- package/dist/schema.js.map +1 -0
- package/dist/setup-wizard.d.ts +14 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +85 -0
- package/dist/setup-wizard.js.map +1 -0
- package/dist/sidecar.d.ts +137 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +421 -0
- package/dist/sidecar.js.map +1 -0
- package/package.json +89 -0
- package/src/channel/chunker.test.ts +135 -0
- package/src/channel/chunker.ts +147 -0
- package/src/channel/turn-runner.ts +97 -0
- package/src/channel/voice.test.ts +136 -0
- package/src/channel/voice.ts +118 -0
- package/src/channel.test.ts +364 -0
- package/src/channel.ts +607 -0
- package/src/index.ts +289 -0
- package/src/jsonrpc.test.ts +128 -0
- package/src/jsonrpc.ts +198 -0
- package/src/keys.test.ts +45 -0
- package/src/keys.ts +56 -0
- package/src/pair-flow.ts +161 -0
- package/src/permission.ts +36 -0
- package/src/schema.ts +98 -0
- package/src/setup-wizard.ts +98 -0
- package/src/sidecar.test.ts +276 -0
- package/src/sidecar.ts +511 -0
- 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":"AAAA,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,kBAAkB,EAAyB,MAAM,cAAc,CAAC;AAEzE;;;;;;GAMG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;AAiBhD,wDAAwD;AACxD,MAAM,UAAU,mBAAmB,CACjC,WAAwD;IAExD,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAC;IAC9B,OAAO,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,IAAI,IAAI,CAAC;AACnG,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,IAAe,EACf,UAA4B;IAE5B,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,YAAY,EAAE,CAAC;IAC7D,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,MAAM,IAAI,CAAC,KAAK,CACd,qGAAqG;YACnG,6EAA6E,CAChF,CAAC;QACF,OAAO,IAAI,CAAC;IACd,CAAC;IAED,2EAA2E;IAC3E,IAAI,OAAO,UAAU,CAAC,IAAI,KAAK,QAAQ,IAAI,UAAU,CAAC,IAAI,GAAG,eAAe,EAAE,CAAC;QAC7E,MAAM,IAAI,CAAC,KAAK,CACd,4BAA4B,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,qBAAqB,eAAe,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,KAAK,CACjI,CAAC;QACF,OAAO,IAAI,CAAC;IACd,CAAC;IAED,4EAA4E;IAC5E,4EAA4E;IAC5E,8CAA8C;IAC9C,MAAM,EAAE,GAAG,kBAAkB,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;IACvD,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC;QAChB,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,wDAAwD,CAAC,CAAC;QAC9E,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;IAEzD,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAS,EAAE,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9D,IAAI,KAAiB,CAAC;IACtB,IAAI,CAAC;QACH,KAAK,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC/B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,yCAAyC,EAAE;YAC7D,GAAG,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;SACtD,CAAC,CAAC;QACH,MAAM,IAAI,CAAC,KAAK,CAAC,sEAAsE,CAAC,CAAC;QACzF,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,KAAK,CAAC,UAAU,GAAG,eAAe,EAAE,CAAC;QACvC,MAAM,IAAI,CAAC,KAAK,CAAC,wCAAwC,CAAC,CAAC;QAC3D,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,UAAkB,CAAC;IACvB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,UAAU,CAAC,KAAK,EAAE;YACjD,QAAQ,EAAE,UAAU,CAAC,WAAW,IAAI,WAAW;SAChD,CAAC,CAAC;QACH,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;IAClC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,8BAA8B,EAAE;YAClD,GAAG,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;SACtD,CAAC,CAAC;QACH,MAAM,IAAI,CAAC,KAAK,CAAC,yBAAyB,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC9F,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,CAAC,KAAK,CAAC,uDAAuD,CAAC,CAAC;QAC1E,OAAO,IAAI,CAAC;IACd,CAAC;IAED,2EAA2E;IAC3E,mDAAmD;IACnD,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,UAAU,EAAE,CAAC,CAAC;IACzC,OAAO,UAAU,CAAC;AACpB,CAAC"}
|
|
@@ -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
|