@moxxy/sdk 0.7.0 → 0.8.1

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 (48) hide show
  1. package/README.md +32 -11
  2. package/dist/cache-strategy.d.ts +9 -0
  3. package/dist/cache-strategy.d.ts.map +1 -1
  4. package/dist/channel-auth.d.ts +43 -5
  5. package/dist/channel-auth.d.ts.map +1 -1
  6. package/dist/channel-auth.js +112 -17
  7. package/dist/channel-auth.js.map +1 -1
  8. package/dist/compactor-helpers.d.ts.map +1 -1
  9. package/dist/compactor-helpers.js +5 -0
  10. package/dist/compactor-helpers.js.map +1 -1
  11. package/dist/compactor.d.ts +10 -0
  12. package/dist/compactor.d.ts.map +1 -1
  13. package/dist/index.d.ts +2 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/mode-helpers.d.ts +9 -0
  18. package/dist/mode-helpers.d.ts.map +1 -1
  19. package/dist/mode-helpers.js +19 -1
  20. package/dist/mode-helpers.js.map +1 -1
  21. package/dist/permission.d.ts +13 -0
  22. package/dist/permission.d.ts.map +1 -1
  23. package/dist/provider.d.ts +10 -0
  24. package/dist/provider.d.ts.map +1 -1
  25. package/dist/session-like.d.ts +13 -0
  26. package/dist/session-like.d.ts.map +1 -1
  27. package/dist/view-renderer.d.ts +15 -0
  28. package/dist/view-renderer.d.ts.map +1 -1
  29. package/dist/view-renderer.js +24 -0
  30. package/dist/view-renderer.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/cache-strategy.ts +9 -0
  33. package/src/channel-auth.test.ts +122 -0
  34. package/src/channel-auth.ts +129 -16
  35. package/src/compactor-helpers.ts +5 -0
  36. package/src/compactor.ts +10 -0
  37. package/src/index.ts +6 -7
  38. package/src/loop-helpers.test.ts +101 -0
  39. package/src/mode-helpers.ts +28 -1
  40. package/src/permission.ts +13 -0
  41. package/src/provider.ts +10 -0
  42. package/src/session-like.ts +14 -0
  43. package/src/view-renderer.ts +22 -0
  44. package/dist/voice.d.ts +0 -36
  45. package/dist/voice.d.ts.map +0 -1
  46. package/dist/voice.js +0 -82
  47. package/dist/voice.js.map +0 -1
  48. package/src/voice.ts +0 -103
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Standard auth toolkit for channels — so a new channel gets consistent
3
- * connection auth without re-rolling token handling. Two pieces:
3
+ * connection auth without re-rolling token handling. The pieces:
4
4
  *
5
5
  * - {@link resolveChannelToken} — the standard `channels.<name>` token
6
6
  * resolution (env → config → a generated, persisted secret). Never empty.
7
+ * - {@link rotateChannelToken} — replace the persisted secret with a fresh
8
+ * one (pairing-token rotation; old tokens stop authenticating).
7
9
  * - {@link bearerGuard} — a pre-connection handler: a constant-time bearer
8
10
  * check a channel runs at its accept point (a WebSocket handshake, an HTTP
9
11
  * request) before admitting a client.
12
+ * - {@link encodeWsBearerProtocol} / {@link tokenFromWsProtocolHeader} — the
13
+ * `Sec-WebSocket-Protocol` token convention for WebSocket clients that
14
+ * cannot set an `Authorization` header (browser / React Native), so the
15
+ * secret stays out of the URL (query strings leak via logs and QR/stdout).
10
16
  *
11
17
  * Channels that expose a network surface (mobile WS bridge, HTTP, web) should
12
18
  * resolve their token with the former and gate connections with the latter,
@@ -16,9 +22,9 @@
16
22
 
17
23
  import { randomBytes } from 'node:crypto';
18
24
  import fs from 'node:fs';
19
- import os from 'node:os';
20
25
  import path from 'node:path';
21
26
 
27
+ import { moxxyHome } from './fs-utils.js';
22
28
  import { bearerTokenMatches } from './http-utils.js';
23
29
 
24
30
  export interface ChannelTokenOptions {
@@ -26,35 +32,107 @@ export interface ChannelTokenOptions {
26
32
  readonly configured?: string;
27
33
  /** Env var checked first, e.g. `MOXXY_MOBILE_TOKEN`. */
28
34
  readonly envVar?: string;
29
- /** File (under the moxxy home dir) to persist a generated token, e.g. `mobile-token`. */
35
+ /** File to persist a generated token, e.g. `mobile-token`. */
30
36
  readonly fileName: string;
37
+ /** Directory the token file lives under. Defaults to the moxxy home dir;
38
+ * the desktop bridge passes its Electron `userData` dir here instead. */
39
+ readonly dir?: string;
40
+ /** Receives the stale-token warning (default: `console.warn`). */
41
+ readonly warn?: (msg: string) => void;
31
42
  }
32
43
 
33
- function moxxyHome(): string {
34
- return process.env.MOXXY_HOME?.trim() || path.join(os.homedir(), '.moxxy');
44
+ /** Persisted tokens older than this trigger a rotation warning (soft only
45
+ * silently breaking an existing pairing is worse than an old secret). */
46
+ const STALE_TOKEN_MS = 90 * 24 * 60 * 60 * 1000;
47
+
48
+ interface PersistedToken {
49
+ readonly token: string;
50
+ /** Epoch ms the token was created, when known (null for ageless reads). */
51
+ readonly createdAtMs: number | null;
52
+ }
53
+
54
+ function tokenFilePath(opts: Pick<ChannelTokenOptions, 'dir' | 'fileName'>): string {
55
+ return path.join(opts.dir ?? moxxyHome(), opts.fileName);
56
+ }
57
+
58
+ /** Read a persisted token. New files are JSON `{ token, createdAt }`; legacy
59
+ * files are the bare token (age falls back to the file mtime, best-effort). */
60
+ function readTokenFile(file: string): PersistedToken | null {
61
+ let raw: string;
62
+ try {
63
+ raw = fs.readFileSync(file, 'utf8').trim();
64
+ } catch {
65
+ return null; // not yet created
66
+ }
67
+ if (!raw) return null;
68
+ if (raw.startsWith('{')) {
69
+ try {
70
+ const parsed = JSON.parse(raw) as { token?: unknown; createdAt?: unknown };
71
+ if (typeof parsed.token !== 'string' || !parsed.token) return null;
72
+ const created = typeof parsed.createdAt === 'string' ? Date.parse(parsed.createdAt) : NaN;
73
+ return { token: parsed.token, createdAtMs: Number.isFinite(created) ? created : null };
74
+ } catch {
75
+ return null; // corrupt — regenerate
76
+ }
77
+ }
78
+ let createdAtMs: number | null = null;
79
+ try {
80
+ createdAtMs = fs.statSync(file).mtimeMs;
81
+ } catch {
82
+ // age unknown
83
+ }
84
+ return { token: raw, createdAtMs };
85
+ }
86
+
87
+ function writeTokenFile(file: string, token: string): void {
88
+ fs.mkdirSync(path.dirname(file), { recursive: true });
89
+ fs.writeFileSync(file, `${JSON.stringify({ token, createdAt: new Date().toISOString() })}\n`, {
90
+ mode: 0o600,
91
+ });
35
92
  }
36
93
 
37
94
  /**
38
95
  * Resolve a channel's auth token. Precedence: `envVar` → `configured` → a
39
- * 256-bit secret generated once and persisted (0600) under the moxxy home, so
40
- * the same pairing secret survives restarts. Never returns an empty string —
41
- * a network-reachable channel must always be authenticated.
96
+ * 256-bit secret generated once and persisted (0600, JSON with a `createdAt`)
97
+ * under `dir` (default: the moxxy home). Never returns an empty string — a
98
+ * network-reachable channel must always be authenticated.
99
+ *
100
+ * A persisted token older than ~90 days logs a rotation hint (via `warn`);
101
+ * there is no hard expiry, because silently breaking a pairing is worse.
42
102
  */
43
103
  export function resolveChannelToken(opts: ChannelTokenOptions): string {
44
104
  const fromEnv = (opts.envVar ? process.env[opts.envVar] : undefined)?.trim();
45
105
  if (fromEnv) return fromEnv;
46
106
  if (opts.configured && opts.configured.trim()) return opts.configured.trim();
47
107
 
48
- const file = path.join(moxxyHome(), opts.fileName);
49
- try {
50
- const existing = fs.readFileSync(file, 'utf8').trim();
51
- if (existing) return existing;
52
- } catch {
53
- // not yet created
108
+ const file = tokenFilePath(opts);
109
+ const existing = readTokenFile(file);
110
+ if (existing) {
111
+ if (existing.createdAtMs !== null && Date.now() - existing.createdAtMs > STALE_TOKEN_MS) {
112
+ const days = Math.floor((Date.now() - existing.createdAtMs) / (24 * 60 * 60 * 1000));
113
+ (opts.warn ?? console.warn)(
114
+ `[moxxy] channel token at ${file} is ${days} days old — consider rotating it ` +
115
+ `(rotateChannelToken / delete the file to regenerate; clients must re-pair).`,
116
+ );
117
+ }
118
+ return existing.token;
54
119
  }
120
+ return rotateChannelToken(opts);
121
+ }
122
+
123
+ /**
124
+ * Rotate the PERSISTED channel token: generate a fresh 256-bit secret, rewrite
125
+ * the token file (old token stops authenticating new connections), and return
126
+ * it. Callers that hold a live server must also re-key it and drop existing
127
+ * connections (the WS bridge exposes `rotateAuthToken` for exactly this).
128
+ *
129
+ * Note: only the file-persisted token rotates — a token supplied via `envVar`
130
+ * or channel config takes precedence at resolve time and must be rotated at
131
+ * its source.
132
+ */
133
+ export function rotateChannelToken(opts: Pick<ChannelTokenOptions, 'dir' | 'fileName'>): string {
55
134
  const token = randomBytes(32).toString('hex');
56
- fs.mkdirSync(path.dirname(file), { recursive: true });
57
- fs.writeFileSync(file, token, { mode: 0o600 });
135
+ writeTokenFile(tokenFilePath(opts), token);
58
136
  return token;
59
137
  }
60
138
 
@@ -66,3 +144,38 @@ export function resolveChannelToken(opts: ChannelTokenOptions): string {
66
144
  export function bearerGuard(expected: string): (presented: string | undefined | null) => boolean {
67
145
  return (presented) => (expected ? bearerTokenMatches(presented, expected) : false);
68
146
  }
147
+
148
+ /** The moxxy WS application subprotocol a client requests (and the server
149
+ * selects) when it also smuggles its bearer token as a protocol entry. */
150
+ export const MOXXY_WS_SUBPROTOCOL = 'moxxy.v1';
151
+
152
+ /** Prefix of the `Sec-WebSocket-Protocol` entry that carries the bearer token:
153
+ * `moxxy.bearer.<rfc3986-strict-percent-encoded token>`. Percent-encoding
154
+ * keeps every output character a valid HTTP token char. */
155
+ export const MOXXY_WS_BEARER_PROTOCOL_PREFIX = 'moxxy.bearer.';
156
+
157
+ /** Encode a bearer token as the WS subprotocol entry a header-less WebSocket
158
+ * client (browser / React Native) sends alongside {@link MOXXY_WS_SUBPROTOCOL}. */
159
+ export function encodeWsBearerProtocol(token: string): string {
160
+ const strict = encodeURIComponent(token).replace(
161
+ /[!'()*]/g,
162
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
163
+ );
164
+ return `${MOXXY_WS_BEARER_PROTOCOL_PREFIX}${strict}`;
165
+ }
166
+
167
+ /** Extract the bearer token from a `Sec-WebSocket-Protocol` request header
168
+ * (comma-separated offer list), or null when no bearer entry is present. */
169
+ export function tokenFromWsProtocolHeader(header: string | undefined): string | null {
170
+ if (!header) return null;
171
+ for (const entry of header.split(',')) {
172
+ const candidate = entry.trim();
173
+ if (!candidate.startsWith(MOXXY_WS_BEARER_PROTOCOL_PREFIX)) continue;
174
+ try {
175
+ return decodeURIComponent(candidate.slice(MOXXY_WS_BEARER_PROTOCOL_PREFIX.length));
176
+ } catch {
177
+ return null; // malformed escape — treat as unauthenticated
178
+ }
179
+ }
180
+ return null;
181
+ }
@@ -182,6 +182,11 @@ export async function runCompactionIfNeeded(
182
182
  log: ctx.log,
183
183
  budget,
184
184
  signal: ctx.signal,
185
+ // Hand the compactor the session's provider/model so the default
186
+ // summarize compactor can write a real summary (it truncates honestly
187
+ // when no provider is reachable).
188
+ provider: ctx.provider,
189
+ model: ctx.model,
185
190
  });
186
191
  if (result.tokensSaved <= 0 || result.summary.trim().length === 0) return false;
187
192
  // `compactor.compact` declares `Omit<CompactionEvent, keyof EventBase>`,
package/src/compactor.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CompactionEvent, MoxxyEvent } from './events.js';
2
2
  import type { EventLogReader } from './log.js';
3
+ import type { LLMProvider } from './provider.js';
3
4
 
4
5
  export interface TokenBudget {
5
6
  readonly contextWindow: number;
@@ -11,6 +12,15 @@ export interface CompactContext {
11
12
  readonly log: EventLogReader;
12
13
  readonly budget: TokenBudget;
13
14
  readonly signal: AbortSignal;
15
+ /**
16
+ * The session's active provider + model, when the dispatcher has them
17
+ * (`runCompactionIfNeeded` always passes both). Lets a compactor produce a
18
+ * REAL model-written summary instead of a lossy truncation. Optional so
19
+ * hand-rolled callers and tests can still invoke `compact` without one —
20
+ * compactors must degrade gracefully when absent.
21
+ */
22
+ readonly provider?: LLMProvider;
23
+ readonly model?: string;
14
24
  }
15
25
 
16
26
  export interface CompactorDef {
package/src/index.ts CHANGED
@@ -143,7 +143,7 @@ export type {
143
143
  ViewTagSpec,
144
144
  ViewRendererDef,
145
145
  } from './view-renderer.js';
146
- export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS } from './view-renderer.js';
146
+ export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS, isSafeViewUrl } from './view-renderer.js';
147
147
  export type { TunnelProviderDef, TunnelHandle, TunnelOpenOptions } from './tunnel.js';
148
148
  export { isRetryableError, toFriendlyError, zodToJsonSchema, estimateTextTokens, type StopReason } from './provider-utils.js';
149
149
  export { writeFileAtomic, moxxyHome, moxxyPath, type WriteFileAtomicOptions } from './fs-utils.js';
@@ -151,7 +151,12 @@ export { createMutex, type Mutex } from './mutex.js';
151
151
  export { readRequestBody, bearerTokenMatches } from './http-utils.js';
152
152
  export {
153
153
  resolveChannelToken,
154
+ rotateChannelToken,
154
155
  bearerGuard,
156
+ encodeWsBearerProtocol,
157
+ tokenFromWsProtocolHeader,
158
+ MOXXY_WS_SUBPROTOCOL,
159
+ MOXXY_WS_BEARER_PROTOCOL_PREFIX,
155
160
  type ChannelTokenOptions,
156
161
  } from './channel-auth.js';
157
162
  export {
@@ -376,10 +381,4 @@ export {
376
381
  type InstallTarget,
377
382
  } from './install-hints.js';
378
383
 
379
- export {
380
- checkTranscriberReady,
381
- pickFirstAvailableTranscriber,
382
- resolveTranscriber,
383
- } from './voice.js';
384
-
385
384
  export { z } from 'zod';
@@ -50,6 +50,107 @@ describe('projectMessagesFromLog', () => {
50
50
  content: [{ type: 'text', text: 'current prompt' }],
51
51
  });
52
52
  });
53
+
54
+ it('omits the empty text block from a tool-only assistant turn (keeps tool_use + results)', () => {
55
+ // Historical wedged-log shape: end_turn with tool calls and no prose
56
+ // produced an assistant_message with empty content. Providers (Anthropic)
57
+ // reject empty text blocks on the NEXT request — the projection must drop
58
+ // the block so even existing logs become valid again.
59
+ const log = reader([
60
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'run the tool' }),
61
+ event(1, {
62
+ type: 'tool_call_requested',
63
+ turnId: t1,
64
+ source: 'model',
65
+ callId: 'call-1',
66
+ name: 'do_thing',
67
+ input: { a: 1 },
68
+ }),
69
+ event(2, {
70
+ type: 'assistant_message',
71
+ turnId: t1,
72
+ source: 'model',
73
+ content: '',
74
+ stopReason: 'end_turn',
75
+ }),
76
+ event(3, {
77
+ type: 'tool_result',
78
+ turnId: t1,
79
+ source: 'tool',
80
+ callId: 'call-1',
81
+ ok: true,
82
+ output: 'done',
83
+ }),
84
+ ]);
85
+
86
+ const messages = projectMessagesFromLog({ log });
87
+
88
+ expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool_result']);
89
+ expect(messages[1]!.content).toEqual([
90
+ { type: 'tool_use', id: 'call-1', name: 'do_thing', input: { a: 1 } },
91
+ ]);
92
+ // No empty text block anywhere in the projected conversation.
93
+ for (const m of messages) {
94
+ for (const block of m.content) {
95
+ if (block.type === 'text') expect(block.text.trim().length).toBeGreaterThan(0);
96
+ }
97
+ }
98
+ });
99
+
100
+ it('skips a whitespace-only assistant message even without tool calls', () => {
101
+ const log = reader([
102
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'hi' }),
103
+ event(1, {
104
+ type: 'assistant_message',
105
+ turnId: t1,
106
+ source: 'model',
107
+ content: ' \n',
108
+ stopReason: 'end_turn',
109
+ }),
110
+ event(2, { type: 'user_prompt', turnId: t2, source: 'user', text: 'still there?' }),
111
+ ]);
112
+
113
+ const messages = projectMessagesFromLog({ log });
114
+
115
+ expect(messages.map((m) => m.role)).toEqual(['user', 'user']);
116
+ });
117
+
118
+ it('still projects non-empty assistant messages alongside tool calls', () => {
119
+ const log = reader([
120
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'run it' }),
121
+ event(1, {
122
+ type: 'tool_call_requested',
123
+ turnId: t1,
124
+ source: 'model',
125
+ callId: 'call-2',
126
+ name: 'do_thing',
127
+ input: {},
128
+ }),
129
+ event(2, {
130
+ type: 'assistant_message',
131
+ turnId: t1,
132
+ source: 'model',
133
+ content: 'running it now',
134
+ stopReason: 'end_turn',
135
+ }),
136
+ event(3, {
137
+ type: 'tool_result',
138
+ turnId: t1,
139
+ source: 'tool',
140
+ callId: 'call-2',
141
+ ok: true,
142
+ output: 'ok',
143
+ }),
144
+ ]);
145
+
146
+ const messages = projectMessagesFromLog({ log });
147
+
148
+ expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'assistant', 'tool_result']);
149
+ expect(messages[2]).toMatchObject({
150
+ role: 'assistant',
151
+ content: [{ type: 'text', text: 'running it now' }],
152
+ });
153
+ });
53
154
  });
54
155
 
55
156
  function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
@@ -296,6 +296,16 @@ export function projectMessages(
296
296
  recordStable(e.seq);
297
297
  break;
298
298
  }
299
+ // A tool-only turn can log an assistant_message with empty content
300
+ // (end_turn + tool calls, no prose). Projecting it as an empty text
301
+ // block makes some providers (Anthropic) reject the NEXT request and
302
+ // permanently wedges the session. Skip the block — the turn's
303
+ // tool_use blocks are projected from tool_call_requested events —
304
+ // which also un-wedges historical logs that already contain one.
305
+ if (e.content.trim().length === 0) {
306
+ recordStable(e.seq);
307
+ break;
308
+ }
299
309
  messages.push({ role: 'assistant', content: [{ type: 'text', text: e.content }] });
300
310
  recordStable(e.seq);
301
311
  break;
@@ -373,6 +383,15 @@ export async function collectProviderStream(
373
383
  * strategy then falls back to its tools/system/tail breakpoints only.
374
384
  */
375
385
  stablePrefixIndex?: number;
386
+ /**
387
+ * Number of trailing messages in `messages` that are volatile — injected
388
+ * for this call only (e.g. goal mode's `trailingUserText` nudge) and
389
+ * absent from the append-only log, so they won't recur at the same
390
+ * position next call. Forwarded to the cache strategy as
391
+ * `volatileTailMessageCount` so it keeps its rolling tail breakpoint
392
+ * before them instead of paying a guaranteed-wasted cache write.
393
+ */
394
+ volatileTailCount?: number;
376
395
  } = {},
377
396
  ): Promise<StreamResult> {
378
397
  // Lazy tool gating (opt-in): send only always-on + loaded tool schemas, and
@@ -400,12 +419,20 @@ export async function collectProviderStream(
400
419
  ...(opts.stablePrefixIndex != null && opts.stablePrefixIndex >= 0
401
420
  ? { stablePrefixMessageIndex: opts.stablePrefixIndex }
402
421
  : {}),
422
+ ...(opts.volatileTailCount != null && opts.volatileTailCount > 0
423
+ ? { volatileTailMessageCount: opts.volatileTailCount }
424
+ : {}),
403
425
  })
404
426
  : undefined;
405
427
 
428
+ // NOTE: `system` is deliberately NOT prefilled with ctx.systemPrompt — the
429
+ // composed system prompt already rides as the leading system-role message
430
+ // (see projectMessages), and providers deliver `req.system` IN ADDITION to
431
+ // message-derived system text. Prefilling it would duplicate the prompt.
432
+ // It stays as the side channel `onBeforeProviderCall` hooks use to inject
433
+ // per-request system text (e.g. the memory consolidation nudge).
406
434
  const req = {
407
435
  model: ctx.model,
408
- system: ctx.systemPrompt,
409
436
  messages: effectiveMessages,
410
437
  ...(toolList ? { tools: toolList } : {}),
411
438
  ...(cacheHints && cacheHints.length > 0 ? { cacheHints } : {}),
package/src/permission.ts CHANGED
@@ -34,4 +34,17 @@ export interface PermissionContext {
34
34
  export interface PermissionResolver {
35
35
  readonly name: string;
36
36
  check(call: PendingToolCall, ctx: PermissionContext): Promise<PermissionDecision>;
37
+ /**
38
+ * Optional prompt-free policy probe. Returns the decision the persistent
39
+ * policy layer would make for this call (user deny/allow rules from
40
+ * `~/.moxxy/permissions.json`, then the tool's own declared rule), or
41
+ * `null` when no rule matches — WITHOUT falling through to the
42
+ * interactive prompt path that `check` takes.
43
+ *
44
+ * Core's session resolver (the policy wrapper) implements this; bare
45
+ * resolvers may omit it. Auto-approving modes (e.g. goal mode) consult it
46
+ * before allowing, so a configured deny rule still denies in unattended
47
+ * runs while nothing can ever block on a prompt.
48
+ */
49
+ policyCheck?(call: PendingToolCall, ctx: PermissionContext): Promise<PermissionDecision | null>;
37
50
  }
package/src/provider.ts CHANGED
@@ -38,6 +38,16 @@ export interface CacheHint {
38
38
 
39
39
  export interface ProviderRequest {
40
40
  readonly model: string;
41
+ /**
42
+ * Extra system text delivered IN ADDITION to any `role: 'system'`
43
+ * messages in `messages`. The loop helpers project the composed system
44
+ * prompt as the leading system message (so cache hints can target it)
45
+ * and leave this unset; `onBeforeProviderCall` hooks use it as the
46
+ * side channel for per-request system injections (e.g. plugin-memory's
47
+ * consolidation nudge), and direct callers (e.g. skill synthesis) may
48
+ * set it instead of crafting a system message. Providers MUST deliver
49
+ * it — appended after the message-derived system text — never drop it.
50
+ */
41
51
  readonly system?: string;
42
52
  readonly messages: ReadonlyArray<ProviderMessage>;
43
53
  readonly tools?: ReadonlyArray<ToolDef>;
@@ -235,6 +235,20 @@ export interface SessionLike {
235
235
  getInfo(): SessionInfo;
236
236
  close(reason?: string): Promise<void>;
237
237
 
238
+ /**
239
+ * Authoritative session reset (`/new`): wipe the conversation history at
240
+ * its source so it cannot resurrect. On an in-process `Session` this clears
241
+ * the real `EventLog` (and, via the log's clear listeners, truncates the
242
+ * persistence sidecar so `--resume` sees an empty session). On a
243
+ * `RemoteSession` it invokes the runner's `session.reset` RPC — the runner
244
+ * aborts in-flight turns, clears ITS log, and broadcasts a reset
245
+ * notification so every attached mirror clears in lockstep. Optional
246
+ * capability per the seam convention: callers MUST guard and fall back to
247
+ * `log.clear()` when absent, and MUST surface a rejection instead of
248
+ * claiming the history was cleared.
249
+ */
250
+ reset?(): Promise<void>;
251
+
238
252
  /**
239
253
  * Live runtime capabilities present only on an in-process Session; a
240
254
  * `RemoteSession` thin client leaves them undefined, so callers MUST guard.
@@ -93,6 +93,28 @@ export interface ViewRendererDef {
93
93
  validate(doc: ViewDoc): ReadonlyArray<ViewParseError>;
94
94
  }
95
95
 
96
+ /**
97
+ * URL-scheme allow-list for agent-authored `href`/`src` values — the single
98
+ * canonical check, enforced at BOTH walls: parse/validate time
99
+ * (core's `parseView`/`validateDoc`) and render time (the web frontend
100
+ * re-checks before emitting an anchor/img; it keeps a verbatim copy in
101
+ * `plugin-channel-web/src/frontend/url-safety.ts` because the browser bundle
102
+ * cannot import the sdk root — keep the two in lockstep).
103
+ *
104
+ * Decision (audit A44): allowed schemes are `https:`, `http:`, `mailto:`,
105
+ * `tel:`, plus relative/fragment URLs; `data:` only as an `img src` and only
106
+ * `data:image/*`. Everything else — notably `javascript:`, `vbscript:`,
107
+ * `data:text/*` — is rejected. Views are designed to be shared with third
108
+ * parties, so a `javascript:` link is click-XSS on a shared surface.
109
+ */
110
+ export function isSafeViewUrl(url: string, attr: string): boolean {
111
+ const u = url.trim().toLowerCase();
112
+ if (u.startsWith('javascript:') || u.startsWith('vbscript:')) return false;
113
+ if (u.startsWith('data:')) return attr === 'src' && u.startsWith('data:image/');
114
+ if (/^[a-z][a-z0-9+.-]*:/.test(u)) return /^(https?:|mailto:|tel:)/.test(u);
115
+ return true; // relative / fragment
116
+ }
117
+
96
118
  const TONE = ['default', 'muted', 'success', 'warn', 'danger'] as const;
97
119
  const GAP = ['none', 'sm', 'md', 'lg'] as const;
98
120
 
package/dist/voice.d.ts DELETED
@@ -1,36 +0,0 @@
1
- /**
2
- * Voice / transcription helpers shared across surfaces.
3
- *
4
- * The TUI's voice-input infrastructure used to inline the same logic
5
- * with a `Codex`-specific name baked in. Pulled out here as
6
- * *agnostic* helpers that take a transcriber name as input, so the
7
- * desktop, TUI, and any future channel can mirror the same flow:
8
- *
9
- * - Is the session ready to transcribe? Check via the requirements
10
- * API for a named transcriber. (`checkTranscriberReady`)
11
- * - Activate any registered transcriber lazily. Returns the active
12
- * transcriber instance. (`resolveTranscriber`)
13
- * - "Just give me whatever works" — try a list of candidates in
14
- * order, or fall back to the first registered one. (`pickFirstAvailableTranscriber`)
15
- */
16
- import type { ClientSession } from './client-session.js';
17
- import type { MoxxyRequirement, RequirementCheck } from './requirements.js';
18
- import type { Transcriber } from './transcriber.js';
19
- /** Probe whether a *named* transcriber is ready: registered, with any
20
- * declared upstream requirements satisfied. The optional `requires`
21
- * list lets channels gate on additional provider / auth runtimes
22
- * (the Codex transcriber e.g. depends on the `openai-codex` provider
23
- * + its OAuth runtime). */
24
- export declare function checkTranscriberReady(session: ClientSession, transcriberName: string, requires?: ReadonlyArray<MoxxyRequirement>): RequirementCheck;
25
- /** Activate a transcriber by name, lazily. Returns the active instance
26
- * ready to `.transcribe(...)`. Throws if no such transcriber is
27
- * registered, or a *different* one is already active. */
28
- export declare function resolveTranscriber(session: ClientSession, transcriberName: string): Transcriber;
29
- /** "Just pick a transcriber that works."
30
- *
31
- * Tries each name in `candidates` in order — first one that can be
32
- * activated wins. Returns null if none can be activated, so callers
33
- * can degrade gracefully (hide their mic button, show a "no voice
34
- * configured" tip, …) instead of throwing. */
35
- export declare function pickFirstAvailableTranscriber(session: ClientSession, candidates: ReadonlyArray<string>): Transcriber | null;
36
- //# sourceMappingURL=voice.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../src/voice.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD;;;;4BAI4B;AAC5B,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,aAAa,EACtB,eAAe,EAAE,MAAM,EACvB,QAAQ,GAAE,aAAa,CAAC,gBAAgB,CAAM,GAC7C,gBAAgB,CAqBlB;AAED;;0DAE0D;AAC1D,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,aAAa,EACtB,eAAe,EAAE,MAAM,GACtB,WAAW,CAcb;AAED;;;;;+CAK+C;AAC/C,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,aAAa,EACtB,UAAU,EAAE,aAAa,CAAC,MAAM,CAAC,GAChC,WAAW,GAAG,IAAI,CAepB"}
package/dist/voice.js DELETED
@@ -1,82 +0,0 @@
1
- /**
2
- * Voice / transcription helpers shared across surfaces.
3
- *
4
- * The TUI's voice-input infrastructure used to inline the same logic
5
- * with a `Codex`-specific name baked in. Pulled out here as
6
- * *agnostic* helpers that take a transcriber name as input, so the
7
- * desktop, TUI, and any future channel can mirror the same flow:
8
- *
9
- * - Is the session ready to transcribe? Check via the requirements
10
- * API for a named transcriber. (`checkTranscriberReady`)
11
- * - Activate any registered transcriber lazily. Returns the active
12
- * transcriber instance. (`resolveTranscriber`)
13
- * - "Just give me whatever works" — try a list of candidates in
14
- * order, or fall back to the first registered one. (`pickFirstAvailableTranscriber`)
15
- */
16
- /** Probe whether a *named* transcriber is ready: registered, with any
17
- * declared upstream requirements satisfied. The optional `requires`
18
- * list lets channels gate on additional provider / auth runtimes
19
- * (the Codex transcriber e.g. depends on the `openai-codex` provider
20
- * + its OAuth runtime). */
21
- export function checkTranscriberReady(session, transcriberName, requires = []) {
22
- const baseline = [
23
- { kind: 'transcriber', name: transcriberName },
24
- ...requires,
25
- ];
26
- const check = session.requirements.check(baseline);
27
- const activeName = session.transcribers.getActiveName();
28
- if (!activeName || activeName === transcriberName)
29
- return check;
30
- const conflict = {
31
- requirement: {
32
- kind: 'transcriber',
33
- name: transcriberName,
34
- state: 'active',
35
- hint: `Switch active transcriber to ${transcriberName}.`,
36
- },
37
- code: 'inactive',
38
- message: `Required active transcriber ${transcriberName}; active is ${activeName}`,
39
- hint: `Switch active transcriber to ${transcriberName}.`,
40
- };
41
- return { ready: false, issues: [conflict, ...check.issues] };
42
- }
43
- /** Activate a transcriber by name, lazily. Returns the active instance
44
- * ready to `.transcribe(...)`. Throws if no such transcriber is
45
- * registered, or a *different* one is already active. */
46
- export function resolveTranscriber(session, transcriberName) {
47
- const activeName = session.transcribers.getActiveName();
48
- if (activeName && activeName !== transcriberName) {
49
- throw new Error(`Another transcriber is already active: ${activeName}.`);
50
- }
51
- if (activeName === transcriberName)
52
- return session.transcribers.getActive();
53
- if (session.transcribers.has(transcriberName)) {
54
- return session.transcribers.setActive(transcriberName);
55
- }
56
- throw new Error(`No transcriber registered as ${transcriberName}. Configure one via your moxxy plugins.`);
57
- }
58
- /** "Just pick a transcriber that works."
59
- *
60
- * Tries each name in `candidates` in order — first one that can be
61
- * activated wins. Returns null if none can be activated, so callers
62
- * can degrade gracefully (hide their mic button, show a "no voice
63
- * configured" tip, …) instead of throwing. */
64
- export function pickFirstAvailableTranscriber(session, candidates) {
65
- // If something's already active, just hand that back — never fight
66
- // a user-chosen activation.
67
- const existing = session.transcribers.tryGetActive();
68
- if (existing)
69
- return existing;
70
- for (const name of candidates) {
71
- try {
72
- return resolveTranscriber(session, name);
73
- }
74
- catch {
75
- // Wrong-active errors don't apply here (we just returned early),
76
- // so any throw is "this candidate isn't registered" — keep
77
- // trying.
78
- }
79
- }
80
- return null;
81
- }
82
- //# sourceMappingURL=voice.js.map
package/dist/voice.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"voice.js","sourceRoot":"","sources":["../src/voice.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAUH;;;;4BAI4B;AAC5B,MAAM,UAAU,qBAAqB,CACnC,OAAsB,EACtB,eAAuB,EACvB,WAA4C,EAAE;IAE9C,MAAM,QAAQ,GAAoC;QAChD,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,eAAe,EAAE;QAC9C,GAAG,QAAQ;KACZ,CAAC;IACF,MAAM,KAAK,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IACnD,MAAM,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,EAAE,CAAC;IACxD,IAAI,CAAC,UAAU,IAAI,UAAU,KAAK,eAAe;QAAE,OAAO,KAAK,CAAC;IAEhE,MAAM,QAAQ,GAAqB;QACjC,WAAW,EAAE;YACX,IAAI,EAAE,aAAa;YACnB,IAAI,EAAE,eAAe;YACrB,KAAK,EAAE,QAAQ;YACf,IAAI,EAAE,gCAAgC,eAAe,GAAG;SACzD;QACD,IAAI,EAAE,UAAU;QAChB,OAAO,EAAE,+BAA+B,eAAe,eAAe,UAAU,EAAE;QAClF,IAAI,EAAE,gCAAgC,eAAe,GAAG;KACzD,CAAC;IACF,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,QAAQ,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;AAC/D,CAAC;AAED;;0DAE0D;AAC1D,MAAM,UAAU,kBAAkB,CAChC,OAAsB,EACtB,eAAuB;IAEvB,MAAM,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,EAAE,CAAC;IACxD,IAAI,UAAU,IAAI,UAAU,KAAK,eAAe,EAAE,CAAC;QACjD,MAAM,IAAI,KAAK,CACb,0CAA0C,UAAU,GAAG,CACxD,CAAC;IACJ,CAAC;IACD,IAAI,UAAU,KAAK,eAAe;QAAE,OAAO,OAAO,CAAC,YAAY,CAAC,SAAS,EAAE,CAAC;IAC5E,IAAI,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,eAAe,CAAC,EAAE,CAAC;QAC9C,OAAO,OAAO,CAAC,YAAY,CAAC,SAAS,CAAC,eAAe,CAAC,CAAC;IACzD,CAAC;IACD,MAAM,IAAI,KAAK,CACb,gCAAgC,eAAe,yCAAyC,CACzF,CAAC;AACJ,CAAC;AAED;;;;;+CAK+C;AAC/C,MAAM,UAAU,6BAA6B,CAC3C,OAAsB,EACtB,UAAiC;IAEjC,mEAAmE;IACnE,4BAA4B;IAC5B,MAAM,QAAQ,GAAG,OAAO,CAAC,YAAY,CAAC,YAAY,EAAE,CAAC;IACrD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC9B,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;QAC9B,IAAI,CAAC;YACH,OAAO,kBAAkB,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QAC3C,CAAC;QAAC,MAAM,CAAC;YACP,iEAAiE;YACjE,2DAA2D;YAC3D,UAAU;QACZ,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}