@moxxy/channel-kit 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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/dist/frame-pump.d.ts +77 -0
  3. package/dist/frame-pump.d.ts.map +1 -0
  4. package/dist/frame-pump.js +118 -0
  5. package/dist/frame-pump.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +19 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/ingest/dedupe.d.ts +31 -0
  11. package/dist/ingest/dedupe.d.ts.map +1 -0
  12. package/dist/ingest/dedupe.js +66 -0
  13. package/dist/ingest/dedupe.js.map +1 -0
  14. package/dist/ingest/http-server.d.ts +76 -0
  15. package/dist/ingest/http-server.d.ts.map +1 -0
  16. package/dist/ingest/http-server.js +101 -0
  17. package/dist/ingest/http-server.js.map +1 -0
  18. package/dist/pairing/host-code.d.ts +94 -0
  19. package/dist/pairing/host-code.d.ts.map +1 -0
  20. package/dist/pairing/host-code.js +107 -0
  21. package/dist/pairing/host-code.js.map +1 -0
  22. package/dist/pairing/tofu.d.ts +33 -0
  23. package/dist/pairing/tofu.d.ts.map +1 -0
  24. package/dist/pairing/tofu.js +41 -0
  25. package/dist/pairing/tofu.js.map +1 -0
  26. package/dist/permission.d.ts +28 -0
  27. package/dist/permission.d.ts.map +1 -0
  28. package/dist/permission.js +17 -0
  29. package/dist/permission.js.map +1 -0
  30. package/dist/plain-turn-renderer.d.ts +17 -0
  31. package/dist/plain-turn-renderer.d.ts.map +1 -0
  32. package/dist/plain-turn-renderer.js +28 -0
  33. package/dist/plain-turn-renderer.js.map +1 -0
  34. package/dist/secrets.d.ts +18 -0
  35. package/dist/secrets.d.ts.map +1 -0
  36. package/dist/secrets.js +16 -0
  37. package/dist/secrets.js.map +1 -0
  38. package/dist/turn.d.ts +96 -0
  39. package/dist/turn.d.ts.map +1 -0
  40. package/dist/turn.js +106 -0
  41. package/dist/turn.js.map +1 -0
  42. package/dist/voice-reply.d.ts +139 -0
  43. package/dist/voice-reply.d.ts.map +1 -0
  44. package/dist/voice-reply.js +333 -0
  45. package/dist/voice-reply.js.map +1 -0
  46. package/package.json +54 -0
  47. package/src/frame-pump.test.ts +209 -0
  48. package/src/frame-pump.ts +154 -0
  49. package/src/index.ts +66 -0
  50. package/src/ingest/dedupe.test.ts +58 -0
  51. package/src/ingest/dedupe.ts +66 -0
  52. package/src/ingest/http-server.test.ts +120 -0
  53. package/src/ingest/http-server.ts +173 -0
  54. package/src/pairing/host-code.test.ts +121 -0
  55. package/src/pairing/host-code.ts +159 -0
  56. package/src/pairing/tofu.test.ts +62 -0
  57. package/src/pairing/tofu.ts +60 -0
  58. package/src/permission.test.ts +69 -0
  59. package/src/permission.ts +46 -0
  60. package/src/plain-turn-renderer.ts +31 -0
  61. package/src/secrets.test.ts +59 -0
  62. package/src/secrets.ts +26 -0
  63. package/src/turn.test.ts +165 -0
  64. package/src/turn.ts +162 -0
  65. package/src/voice-reply.test.ts +316 -0
  66. package/src/voice-reply.ts +449 -0
@@ -0,0 +1,31 @@
1
+ import type { MoxxyEvent } from '@moxxy/sdk';
2
+
3
+ /**
4
+ * Accumulate streamed assistant text from the event log into a single growing
5
+ * snapshot — the minimal plain-text turn renderer for channels without a rich
6
+ * frame format (Slack v1 and future thin adapters). `assistant_chunk` deltas
7
+ * render live; the final `assistant_message` content (which supersedes the
8
+ * streamed deltas for the same turn) wins so the last edit always carries the
9
+ * complete reply.
10
+ */
11
+ export class PlainTurnRenderer {
12
+ private streamed = '';
13
+ private finalText: string | null = null;
14
+
15
+ /** Returns true when the event changed the snapshot (schedule an edit). */
16
+ accept(event: MoxxyEvent): boolean {
17
+ if (event.type === 'assistant_chunk') {
18
+ this.streamed += event.delta;
19
+ return true;
20
+ }
21
+ if (event.type === 'assistant_message') {
22
+ this.finalText = event.content;
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ snapshot(): string {
29
+ return (this.finalText ?? this.streamed).trim();
30
+ }
31
+ }
@@ -0,0 +1,59 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { resolveSecret, type SecretReader } from './secrets.js';
3
+
4
+ const ENV = 'MOXXY_CHANNEL_KIT_TEST_SECRET';
5
+
6
+ function vaultWith(values: Record<string, string | null>): SecretReader {
7
+ return {
8
+ async get(name) {
9
+ return values[name] ?? null;
10
+ },
11
+ };
12
+ }
13
+
14
+ describe('resolveSecret', () => {
15
+ afterEach(() => {
16
+ delete process.env[ENV];
17
+ });
18
+
19
+ it('prefers the env override', async () => {
20
+ process.env[ENV] = 'from-env';
21
+ const got = await resolveSecret(vaultWith({ key: 'from-vault' }), {
22
+ envVar: ENV,
23
+ vaultKey: 'key',
24
+ });
25
+ expect(got).toBe('from-env');
26
+ });
27
+
28
+ it('trims the env value', async () => {
29
+ process.env[ENV] = ' padded ';
30
+ expect(await resolveSecret(vaultWith({}), { envVar: ENV, vaultKey: 'key' })).toBe('padded');
31
+ });
32
+
33
+ it('falls through to the vault when the env var is unset or whitespace', async () => {
34
+ expect(
35
+ await resolveSecret(vaultWith({ key: 'from-vault' }), { envVar: ENV, vaultKey: 'key' }),
36
+ ).toBe('from-vault');
37
+
38
+ process.env[ENV] = ' ';
39
+ expect(
40
+ await resolveSecret(vaultWith({ key: 'from-vault' }), { envVar: ENV, vaultKey: 'key' }),
41
+ ).toBe('from-vault');
42
+ });
43
+
44
+ it('trims the vault value and treats empty as unset', async () => {
45
+ expect(await resolveSecret(vaultWith({ key: ' v ' }), { vaultKey: 'key' })).toBe('v');
46
+ expect(await resolveSecret(vaultWith({ key: ' ' }), { vaultKey: 'key' })).toBeNull();
47
+ });
48
+
49
+ it('returns null when neither source has a value', async () => {
50
+ expect(await resolveSecret(vaultWith({}), { envVar: ENV, vaultKey: 'key' })).toBeNull();
51
+ });
52
+
53
+ it('skips the env entirely when no envVar is given', async () => {
54
+ process.env[ENV] = 'should-not-be-read';
55
+ expect(await resolveSecret(vaultWith({ key: 'from-vault' }), { vaultKey: 'key' })).toBe(
56
+ 'from-vault',
57
+ );
58
+ });
59
+ });
package/src/secrets.ts ADDED
@@ -0,0 +1,26 @@
1
+ /** The read slice of the vault a channel secret lookup needs. */
2
+ export interface SecretReader {
3
+ get(name: string): Promise<string | null>;
4
+ }
5
+
6
+ export interface SecretSpec {
7
+ /** Env override — beats the vault, matching every channel's precedence. */
8
+ readonly envVar?: string;
9
+ /** Vault key the channel stores the secret under. */
10
+ readonly vaultKey: string;
11
+ }
12
+
13
+ /**
14
+ * Resolve a channel secret: env override first, then the vault. Values are
15
+ * trimmed and empty strings are treated as unset (an env var set to whitespace
16
+ * falls through to the vault rather than masking it). Returns null when
17
+ * neither source has a value.
18
+ */
19
+ export async function resolveSecret(vault: SecretReader, spec: SecretSpec): Promise<string | null> {
20
+ if (spec.envVar) {
21
+ const fromEnv = process.env[spec.envVar]?.trim();
22
+ if (fromEnv) return fromEnv;
23
+ }
24
+ const stored = (await vault.get(spec.vaultKey))?.trim();
25
+ return stored || null;
26
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asTurnId } from '@moxxy/sdk';
3
+ import type { MoxxyEvent } from '@moxxy/sdk';
4
+ import { TurnCoordinator, driveTurn, subscribeTurn } from './turn.js';
5
+
6
+ class FakeLog {
7
+ private readonly subs = new Set<(e: MoxxyEvent) => void>();
8
+ subscribe(fn: (e: MoxxyEvent) => void): () => void {
9
+ this.subs.add(fn);
10
+ return () => this.subs.delete(fn);
11
+ }
12
+ emit(e: MoxxyEvent): void {
13
+ for (const s of this.subs) s(e);
14
+ }
15
+ }
16
+
17
+ function chunk(turnId: string, delta: string): MoxxyEvent {
18
+ return {
19
+ id: `e_${Math.random()}`,
20
+ seq: 0,
21
+ ts: 0,
22
+ sessionId: 's1',
23
+ turnId,
24
+ source: 'model',
25
+ type: 'assistant_chunk',
26
+ delta,
27
+ } as MoxxyEvent;
28
+ }
29
+
30
+ function message(turnId: string, content: string): MoxxyEvent {
31
+ return {
32
+ id: `e_${Math.random()}`,
33
+ seq: 0,
34
+ ts: 0,
35
+ sessionId: 's1',
36
+ turnId,
37
+ source: 'model',
38
+ type: 'assistant_message',
39
+ content,
40
+ stopReason: 'end_turn',
41
+ } as MoxxyEvent;
42
+ }
43
+
44
+ describe('subscribeTurn', () => {
45
+ it('delivers only the matching turnId and unsubscribes cleanly', () => {
46
+ const log = new FakeLog();
47
+ const seen: string[] = [];
48
+ const unsubscribe = subscribeTurn({ log }, asTurnId('own'), (e) => {
49
+ seen.push(e.type === 'assistant_chunk' ? e.delta : '');
50
+ });
51
+
52
+ log.emit(chunk('foreign', 'NOPE'));
53
+ log.emit(chunk('own', 'a'));
54
+ log.emit(chunk('own', 'b'));
55
+ unsubscribe();
56
+ log.emit(chunk('own', 'after-unsub'));
57
+
58
+ expect(seen).toEqual(['a', 'b']);
59
+ });
60
+ });
61
+
62
+ describe('driveTurn', () => {
63
+ it('forwards turnId, model and signal to runTurn and drains the iterator', async () => {
64
+ const log = new FakeLog();
65
+ let receivedOpts: Record<string, unknown> | undefined;
66
+ let drained = 0;
67
+ const session = {
68
+ log,
69
+ runTurn(_prompt: string, opts: Record<string, unknown>) {
70
+ receivedOpts = opts;
71
+ return (async function* () {
72
+ drained += 1;
73
+ yield message('t1', 'done');
74
+ })();
75
+ },
76
+ };
77
+ const controller = new AbortController();
78
+ await driveTurn(session, {
79
+ turnId: asTurnId('t1'),
80
+ prompt: 'hi',
81
+ model: 'model-x',
82
+ signal: controller.signal,
83
+ });
84
+ expect(drained).toBe(1);
85
+ expect(receivedOpts).toMatchObject({ turnId: 't1', model: 'model-x' });
86
+ expect(receivedOpts?.['signal']).toBe(controller.signal);
87
+ });
88
+
89
+ it('omits model when not provided', async () => {
90
+ let receivedOpts: Record<string, unknown> | undefined;
91
+ const session = {
92
+ log: new FakeLog(),
93
+ runTurn(_prompt: string, opts: Record<string, unknown>) {
94
+ receivedOpts = opts;
95
+ return (async function* () {
96
+ // empty turn
97
+ })();
98
+ },
99
+ };
100
+ await driveTurn(session, {
101
+ turnId: asTurnId('t2'),
102
+ prompt: 'hi',
103
+ signal: new AbortController().signal,
104
+ });
105
+ expect(receivedOpts && 'model' in receivedOpts).toBe(false);
106
+ });
107
+ });
108
+
109
+ describe('TurnCoordinator', () => {
110
+ it('grants a single lease at a time (single-flight)', () => {
111
+ const turns = new TurnCoordinator();
112
+ const lease = turns.begin(asTurnId('t1'));
113
+ expect(lease).not.toBeNull();
114
+ expect(turns.busy).toBe(true);
115
+ expect(turns.begin(asTurnId('t2'))).toBeNull();
116
+
117
+ lease?.end();
118
+ expect(turns.busy).toBe(false);
119
+ expect(turns.begin(asTurnId('t3'))).not.toBeNull();
120
+ });
121
+
122
+ it('exposes the in-flight controller and aborts it on demand', () => {
123
+ const turns = new TurnCoordinator();
124
+ expect(turns.controller).toBeNull();
125
+ turns.abort('noop'); // idle abort is a no-op
126
+
127
+ const lease = turns.begin(asTurnId('t1'));
128
+ expect(turns.controller).toBe(lease?.controller);
129
+ turns.abort('shutdown');
130
+ expect(lease?.controller.signal.aborted).toBe(true);
131
+
132
+ lease?.end();
133
+ expect(turns.controller).toBeNull();
134
+ });
135
+
136
+ it('remembers own turnIds beyond the lease (bounded)', () => {
137
+ const turns = new TurnCoordinator({ maxOwnTurnIds: 2 });
138
+ turns.begin(asTurnId('t1'))?.end();
139
+ turns.begin(asTurnId('t2'))?.end();
140
+ expect(turns.isOwn('t1')).toBe(true);
141
+ expect(turns.isOwn('t2')).toBe(true);
142
+ turns.begin(asTurnId('t3'))?.end();
143
+ // Oldest evicted once the cap is exceeded.
144
+ expect(turns.isOwn('t1')).toBe(false);
145
+ expect(turns.isOwn('t3')).toBe(true);
146
+ });
147
+
148
+ it('mirrorText passes only foreign assistant prose while idle', () => {
149
+ const turns = new TurnCoordinator();
150
+ const lease = turns.begin(asTurnId('own'));
151
+
152
+ // Own turn: never mirrored, even after the lease ends (invariant #8 —
153
+ // late/replayed events must not be re-mirrored as foreign).
154
+ expect(turns.mirrorText(message('own', 'mine'))).toBeNull();
155
+ // Busy: a foreign turn is not mirrored while we render our own.
156
+ expect(turns.mirrorText(message('foreign', 'other'))).toBeNull();
157
+
158
+ lease?.end();
159
+ expect(turns.mirrorText(message('own', 'mine'))).toBeNull();
160
+ expect(turns.mirrorText(message('foreign', 'other'))).toBe('other');
161
+ // Non-message events and empty content are ignored.
162
+ expect(turns.mirrorText(chunk('foreign', 'delta'))).toBeNull();
163
+ expect(turns.mirrorText(message('foreign', ' '))).toBeNull();
164
+ });
165
+ });
package/src/turn.ts ADDED
@@ -0,0 +1,162 @@
1
+ import type { MoxxyEvent, RunTurnOptions, TurnId } from '@moxxy/sdk';
2
+
3
+ /**
4
+ * Turn machinery shared by messaging channels that multiplex onto ONE shared
5
+ * Session (AGENTS.md invariant #8: filter event-log subscribers by turnId —
6
+ * `session.log` fans out to every listener, so concurrent turns on the same
7
+ * Session cross-contaminate without it).
8
+ *
9
+ * Pieces are deliberately composable rather than one monolithic runner so each
10
+ * channel keeps its exact flush / typing / unwind ordering:
11
+ * - {@link TurnCoordinator} — single-flight `busy` guard, per-turn
12
+ * AbortController, bounded own-turn-id tracking, foreign-turn mirror gate.
13
+ * - {@link subscribeTurn} — turnId-filtered `session.log.subscribe`.
14
+ * - {@link driveTurn} — drain `session.runTurn` for a pre-minted turnId.
15
+ */
16
+
17
+ /** The slice of a session a channel turn needs: the live event log. */
18
+ export interface TurnEventSource {
19
+ readonly log: {
20
+ subscribe(fn: (event: MoxxyEvent) => void | Promise<void>): () => void;
21
+ };
22
+ }
23
+
24
+ /** The slice of a session `driveTurn` needs. `ClientSession` satisfies it. */
25
+ export interface TurnSession extends TurnEventSource {
26
+ runTurn(prompt: string, opts?: RunTurnOptions): AsyncIterable<MoxxyEvent>;
27
+ }
28
+
29
+ /**
30
+ * Subscribe to ONLY the given turn's events. Returns the unsubscribe function.
31
+ * The caller controls unsubscribe placement (channels keep their final flush
32
+ * inside the subscription window so trailing events still feed the renderer).
33
+ */
34
+ export function subscribeTurn(
35
+ source: TurnEventSource,
36
+ turnId: TurnId,
37
+ onEvent: (event: MoxxyEvent) => void,
38
+ ): () => void {
39
+ return source.log.subscribe((event) => {
40
+ if (event.turnId !== turnId) return;
41
+ onEvent(event);
42
+ });
43
+ }
44
+
45
+ export interface DriveTurnOptions {
46
+ readonly turnId: TurnId;
47
+ readonly prompt: string;
48
+ readonly model?: string | undefined;
49
+ readonly signal: AbortSignal;
50
+ }
51
+
52
+ /**
53
+ * Run one turn to completion, draining the `runTurn` iterator. Rendering is
54
+ * expected to happen via a {@link subscribeTurn} subscription, not the yielded
55
+ * events (which are identical); errors propagate to the caller.
56
+ */
57
+ export async function driveTurn(session: TurnSession, opts: DriveTurnOptions): Promise<void> {
58
+ for await (const _event of session.runTurn(opts.prompt, {
59
+ turnId: opts.turnId,
60
+ ...(opts.model ? { model: opts.model } : {}),
61
+ signal: opts.signal,
62
+ })) {
63
+ void _event;
64
+ }
65
+ }
66
+
67
+ /** A granted turn slot. Call `end()` in a `finally` to release single-flight. */
68
+ export interface TurnLease {
69
+ readonly turnId: TurnId;
70
+ /** Per-turn controller so a /cancel or channel stop aborts ONLY this turn
71
+ * without poisoning the session-level signal other channels share. */
72
+ readonly controller: AbortController;
73
+ end(): void;
74
+ }
75
+
76
+ export interface TurnCoordinatorOptions {
77
+ /**
78
+ * Bound on remembered own-turn ids so a long-lived channel can't leak; a
79
+ * handful of recent ids is enough to dedup late/replayed events. Default 64.
80
+ */
81
+ readonly maxOwnTurnIds?: number;
82
+ }
83
+
84
+ /**
85
+ * Single-flight turn state for a channel: one turn at a time, a per-turn
86
+ * AbortController, and a bounded set of turnIds THIS channel initiated.
87
+ *
88
+ * The own-turn-id set is what {@link mirrorText} filters on (invariant #8)
89
+ * rather than the coarse `busy` flag alone — so an `assistant_message`
90
+ * dispatched for our own turn AFTER `busy` flips false (async event ordering /
91
+ * RemoteSession replay) isn't re-mirrored as foreign.
92
+ */
93
+ export class TurnCoordinator {
94
+ private busyFlag = false;
95
+ private active: AbortController | null = null;
96
+ private readonly ownTurnIds = new Set<string>();
97
+ private readonly maxOwnTurnIds: number;
98
+
99
+ constructor(opts: TurnCoordinatorOptions = {}) {
100
+ this.maxOwnTurnIds = opts.maxOwnTurnIds ?? 64;
101
+ }
102
+
103
+ get busy(): boolean {
104
+ return this.busyFlag;
105
+ }
106
+
107
+ /** The in-flight turn's controller (null when idle) — for /cancel handlers. */
108
+ get controller(): AbortController | null {
109
+ return this.active;
110
+ }
111
+
112
+ /**
113
+ * Atomically claim the single turn slot. Synchronous on purpose: set `busy`
114
+ * BEFORE any await so a concurrently dispatched second turn can't slip past
115
+ * the guard. Returns null when a turn is already running (the channel replies
116
+ * "still working"); otherwise a lease whose `end()` releases the slot.
117
+ */
118
+ begin(turnId: TurnId): TurnLease | null {
119
+ if (this.busyFlag) return null;
120
+ this.busyFlag = true;
121
+ const controller = new AbortController();
122
+ this.active = controller;
123
+ this.ownTurnIds.add(turnId);
124
+ if (this.ownTurnIds.size > this.maxOwnTurnIds) {
125
+ const oldest = this.ownTurnIds.values().next().value;
126
+ if (oldest !== undefined) this.ownTurnIds.delete(oldest);
127
+ }
128
+ return {
129
+ turnId,
130
+ controller,
131
+ end: () => {
132
+ this.busyFlag = false;
133
+ if (this.active === controller) this.active = null;
134
+ },
135
+ };
136
+ }
137
+
138
+ /** Abort the in-flight turn (stop / cancel paths). No-op when idle. */
139
+ abort(reason: string): void {
140
+ if (this.active && !this.active.signal.aborted) this.active.abort(reason);
141
+ }
142
+
143
+ isOwn(turnId: string): boolean {
144
+ return this.ownTurnIds.has(turnId);
145
+ }
146
+
147
+ /**
148
+ * Foreign-turn mirror gate: returns the assistant prose to mirror for a turn
149
+ * this channel did NOT initiate (e.g. a co-attached surface ran one), or null
150
+ * when the event must not be mirrored — not an `assistant_message`, one of
151
+ * our own turnIds (invariant #8), a turn of ours currently rendering via the
152
+ * frame pump (`busy`), or empty content. The channel adds its own
153
+ * "have I served a target yet" check and does the send.
154
+ */
155
+ mirrorText(event: MoxxyEvent): string | null {
156
+ if (event.type !== 'assistant_message') return null;
157
+ if (this.ownTurnIds.has(event.turnId)) return null;
158
+ if (this.busyFlag) return null;
159
+ const text = event.content.trim();
160
+ return text ? text : null;
161
+ }
162
+ }