@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.
- package/README.md +32 -11
- package/dist/cache-strategy.d.ts +9 -0
- package/dist/cache-strategy.d.ts.map +1 -1
- package/dist/channel-auth.d.ts +43 -5
- package/dist/channel-auth.d.ts.map +1 -1
- package/dist/channel-auth.js +112 -17
- package/dist/channel-auth.js.map +1 -1
- package/dist/compactor-helpers.d.ts.map +1 -1
- package/dist/compactor-helpers.js +5 -0
- package/dist/compactor-helpers.js.map +1 -1
- package/dist/compactor.d.ts +10 -0
- package/dist/compactor.d.ts.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/mode-helpers.d.ts +9 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +19 -1
- package/dist/mode-helpers.js.map +1 -1
- package/dist/permission.d.ts +13 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/provider.d.ts +10 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/session-like.d.ts +13 -0
- package/dist/session-like.d.ts.map +1 -1
- package/dist/view-renderer.d.ts +15 -0
- package/dist/view-renderer.d.ts.map +1 -1
- package/dist/view-renderer.js +24 -0
- package/dist/view-renderer.js.map +1 -1
- package/package.json +1 -1
- package/src/cache-strategy.ts +9 -0
- package/src/channel-auth.test.ts +122 -0
- package/src/channel-auth.ts +129 -16
- package/src/compactor-helpers.ts +5 -0
- package/src/compactor.ts +10 -0
- package/src/index.ts +6 -7
- package/src/loop-helpers.test.ts +101 -0
- package/src/mode-helpers.ts +28 -1
- package/src/permission.ts +13 -0
- package/src/provider.ts +10 -0
- package/src/session-like.ts +14 -0
- package/src/view-renderer.ts +22 -0
- package/dist/voice.d.ts +0 -36
- package/dist/voice.d.ts.map +0 -1
- package/dist/voice.js +0 -82
- package/dist/voice.js.map +0 -1
- package/src/voice.ts +0 -103
package/src/channel-auth.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (existing)
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/compactor-helpers.ts
CHANGED
|
@@ -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';
|
package/src/loop-helpers.test.ts
CHANGED
|
@@ -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 {
|
package/src/mode-helpers.ts
CHANGED
|
@@ -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>;
|
package/src/session-like.ts
CHANGED
|
@@ -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.
|
package/src/view-renderer.ts
CHANGED
|
@@ -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
|
package/dist/voice.d.ts.map
DELETED
|
@@ -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"}
|