@moxxy/sdk 0.14.4 → 0.15.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 (45) hide show
  1. package/README.md +2 -0
  2. package/dist/channel.d.ts +32 -0
  3. package/dist/channel.d.ts.map +1 -1
  4. package/dist/channel.js +28 -1
  5. package/dist/channel.js.map +1 -1
  6. package/dist/compactor-helpers.d.ts +59 -0
  7. package/dist/compactor-helpers.d.ts.map +1 -1
  8. package/dist/compactor-helpers.js +72 -1
  9. package/dist/compactor-helpers.js.map +1 -1
  10. package/dist/elision-helpers.d.ts.map +1 -1
  11. package/dist/elision-helpers.js +12 -5
  12. package/dist/elision-helpers.js.map +1 -1
  13. package/dist/elision-state.d.ts +4 -0
  14. package/dist/elision-state.d.ts.map +1 -1
  15. package/dist/elision-state.js +41 -0
  16. package/dist/elision-state.js.map +1 -1
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/mode/project-messages.d.ts +9 -0
  22. package/dist/mode/project-messages.d.ts.map +1 -1
  23. package/dist/mode/project-messages.js +4 -1
  24. package/dist/mode/project-messages.js.map +1 -1
  25. package/dist/server.d.ts +21 -0
  26. package/dist/server.d.ts.map +1 -0
  27. package/dist/server.js +21 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/transcriber.d.ts +13 -0
  30. package/dist/transcriber.d.ts.map +1 -1
  31. package/dist/transcriber.js +13 -1
  32. package/dist/transcriber.js.map +1 -1
  33. package/package.json +9 -1
  34. package/src/channel.ts +37 -0
  35. package/src/compactor-helpers.test.ts +106 -0
  36. package/src/compactor-helpers.ts +125 -1
  37. package/src/elision-helpers.ts +14 -5
  38. package/src/elision-state.test.ts +78 -0
  39. package/src/elision-state.ts +42 -0
  40. package/src/index.ts +15 -19
  41. package/src/mode/project-messages.ts +13 -1
  42. package/src/package-root.test.ts +37 -0
  43. package/src/server.ts +34 -0
  44. package/src/transcriber.test.ts +12 -0
  45. package/src/transcriber.ts +14 -0
@@ -255,3 +255,81 @@ describe('computeElisionState (golden: fused == 4-pass reference)', () => {
255
255
  assertSameState(state, computeElisionStateOld(events));
256
256
  });
257
257
  });
258
+
259
+ // ─────────────────────────────────────────────────────────────────────────────
260
+ // Memo correctness (complexity-hotspots-7 / u122-2): the single-slot memo keyed
261
+ // on the input array's IDENTITY must (a) return the cached state for the same
262
+ // immutable snapshot (a cache HIT — the identical reference), and (b) RECOMPUTE
263
+ // — never serve a stale state — for any different array, including a new event
264
+ // appended past the HWM or a re-config that reuses the same ids/seqs.
265
+ // ─────────────────────────────────────────────────────────────────────────────
266
+ describe('computeElisionState (memo correctness)', () => {
267
+ const baseLog = (): MoxxyEvent[] => [
268
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'the task' }),
269
+ event(1, { type: 'tool_call_requested', turnId: t1, source: 'model', callId: asToolCallId('ra'), name: 'recall', input: { callId: 'x' } }),
270
+ event(2, { type: 'tool_result', turnId: t1, source: 'tool', callId: asToolCallId('ra'), ok: true, output: 'A'.repeat(300) }),
271
+ event(3, {
272
+ type: 'elision', turnId: t2, source: 'system', elidedThrough: 2, stubbedRanges: [[0, 2]],
273
+ elideConversational: true, conversationalRecallThreshold: 4, maxRecallBytes: 1000, neverElideTools: [], tokensSaved: 100,
274
+ }),
275
+ event(4, { type: 'user_prompt', turnId: t2, source: 'user', text: 'recent' }),
276
+ ];
277
+
278
+ it('returns the cached state for the same snapshot, equal to a fresh fold', () => {
279
+ const events = baseLog();
280
+ const a = computeElisionState(events);
281
+ // Same array reference → memo HIT → the identical cached reference.
282
+ expect(computeElisionState(events)).toBe(a);
283
+ // …and it equals an uncached fresh fold of the same snapshot.
284
+ assertSameState(a, computeElisionStateOld(events));
285
+ });
286
+
287
+ it('invalidates (recomputes) when a new event is appended past the HWM', () => {
288
+ const events = baseLog();
289
+ const before = computeElisionState(events);
290
+ expect(before.hwm).toBe(2);
291
+ // A NEW snapshot array with one more tail event must NOT serve `before`.
292
+ const grown = [
293
+ ...events,
294
+ event(5, { type: 'tool_call_requested', turnId: t2, source: 'model', callId: asToolCallId('rb'), name: 'recall', input: { callId: 'y' } }),
295
+ ];
296
+ const after = computeElisionState(grown);
297
+ expect(after).not.toBe(before); // recomputed, not the cached ref
298
+ // `rb` only exists in the grown log → its presence proves invalidation.
299
+ expect(after.recallResultCallIds.has('rb')).toBe(true);
300
+ expect(before.recallResultCallIds.has('rb')).toBe(false);
301
+ assertSameState(after, computeElisionStateOld(grown));
302
+ // A new ElisionEvent that advances the HWM also invalidates.
303
+ const reElided = [
304
+ ...grown,
305
+ event(6, { type: 'tool_result', turnId: t2, source: 'tool', callId: asToolCallId('rb'), ok: true, output: 'B'.repeat(50) }),
306
+ event(7, {
307
+ type: 'elision', turnId: t2, source: 'system', elidedThrough: 6, stubbedRanges: [[3, 6]],
308
+ elideConversational: true, conversationalRecallThreshold: 4, maxRecallBytes: 1000, neverElideTools: [], tokensSaved: 200,
309
+ }),
310
+ ];
311
+ const advanced = computeElisionState(reElided);
312
+ expect(advanced.hwm).toBe(6);
313
+ assertSameState(advanced, computeElisionStateOld(reElided));
314
+ });
315
+
316
+ it('never serves a stale state for a re-config that reuses the same ids/seqs', () => {
317
+ // Two logically-distinct logs with byte-identical ids/seqs but different
318
+ // elision config — the exact case a content hash of id+seq would collide on
319
+ // and serve stale. Distinct array instances → distinct memo keys → correct.
320
+ const mk = (threshold: number): MoxxyEvent[] => [
321
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'the task' }),
322
+ event(1, { type: 'tool_call_requested', turnId: t1, source: 'model', callId: asToolCallId('s1'), name: 'recall', input: { seq: 0 } }),
323
+ event(2, { type: 'tool_call_requested', turnId: t1, source: 'model', callId: asToolCallId('s2'), name: 'recall', input: { seq: 0 } }),
324
+ event(3, {
325
+ type: 'elision', turnId: t2, source: 'system', elidedThrough: 2, stubbedRanges: [[0, 2]],
326
+ elideConversational: true, conversationalRecallThreshold: threshold, maxRecallBytes: 1000, neverElideTools: [], tokensSaved: 10,
327
+ }),
328
+ event(4, { type: 'user_prompt', turnId: t2, source: 'user', text: 'next' }),
329
+ ];
330
+ const on = computeElisionState(mk(3)); // threshold 3 > 2 seq-recalls → ON
331
+ const off = computeElisionState(mk(2)); // threshold 2 == 2 → OFF
332
+ expect(on.effectiveElideConversational).toBe(true);
333
+ expect(off.effectiveElideConversational).toBe(false);
334
+ });
335
+ });
@@ -70,12 +70,54 @@ const EMPTY_STATE: ElisionState = {
70
70
  firstUserPromptSeq: -1,
71
71
  };
72
72
 
73
+ /**
74
+ * Single-slot memo of the most recent {@link computeElisionState} result,
75
+ * keyed on the IDENTITY of the events array it was folded from.
76
+ *
77
+ * The event log is append-only and every held event is immutable (invariant
78
+ * #6: events at/below the HWM never change), so a given snapshot array is a
79
+ * stable, never-mutated value: the same array reference always denotes the
80
+ * same content and therefore the same state. A new turn produces a NEW snapshot
81
+ * array (the live `log.slice()` returns a fresh array each call), so the memo
82
+ * self-invalidates the moment the log changes — there is no way for it to serve
83
+ * a state that is stale for the array it is keyed on.
84
+ *
85
+ * Keying on identity (not a content hash of `id`/`seq`/payload) is the only
86
+ * SOUND single-slot choice for a pure fold over an arbitrary array: two
87
+ * logically-different logs can share ids/seqs (e.g. a test that rebuilds the
88
+ * same prefix with different payload, or a re-config), and a content hash that
89
+ * missed a payload field would serve a stale state — a correctness bug. Array
90
+ * identity can never collide across distinct values.
91
+ *
92
+ * The win: callers that re-ask over the SAME snapshot within an iteration
93
+ * (e.g. the elision/compaction gates and the estimate, when they thread the
94
+ * one `log.slice()` array — see `estimateContextTokens`) fold it only once;
95
+ * `WeakMap` would also help a held snapshot survive GC pressure, but a single
96
+ * slot keeps it allocation-free and matches the "one live snapshot at a time"
97
+ * access pattern. Threading a precomputed state is the explicit zero-cost fast
98
+ * path; this memo covers callers that re-pass the same array but can't thread.
99
+ */
100
+ let memoEvents: ReadonlyArray<MoxxyEvent> | null = null;
101
+ let memoState: ElisionState | null = null;
102
+
73
103
  /**
74
104
  * Derive elision state purely from the log: the active high-water mark + flags
75
105
  * (from the latest ElisionEvent), the callId→tool map, recall bookkeeping, the
76
106
  * adaptive conversational auto-disable, and which pinned recalls exceed the cap.
107
+ *
108
+ * Memoized on the input array's identity (see above) so repeated calls over the
109
+ * same immutable snapshot fold it only once. The returned state is identical
110
+ * (and `===` on a cache hit) to a fresh fold.
77
111
  */
78
112
  export function computeElisionState(events: ReadonlyArray<MoxxyEvent>): ElisionState {
113
+ if (memoEvents === events && memoState !== null) return memoState;
114
+ const state = computeElisionStateUncached(events);
115
+ memoEvents = events;
116
+ memoState = state;
117
+ return state;
118
+ }
119
+
120
+ function computeElisionStateUncached(events: ReadonlyArray<MoxxyEvent>): ElisionState {
79
121
  let hwm = -1;
80
122
  let elideConversational = false;
81
123
  let conversationalRecallThreshold = Number.POSITIVE_INFINITY;
package/src/index.ts CHANGED
@@ -181,15 +181,12 @@ export type {
181
181
  SpawnCliTunnelOptions,
182
182
  CliTunnelHandle,
183
183
  } from './tunnel.js';
184
- export { spawnCliTunnel, isCliTunnelAvailable } from './tunnel.js';
184
+ // Node-runtime helpers (spawnCliTunnel/isCliTunnelAvailable, writeFileAtomic*,
185
+ // moxxyHome/moxxyPath, readRequestBody/bearerTokenMatches, channel-auth) are
186
+ // exported from the './server' subpath, NOT the main barrel — they statically
187
+ // reach node:* builtins and would break a browser/RN bundle. See ./server.ts.
185
188
  export { isRetryableError, toFriendlyError, zodToJsonSchema, estimateTextTokens, type StopReason } from './provider-utils.js';
186
- export {
187
- writeFileAtomic,
188
- writeFileAtomicSync,
189
- moxxyHome,
190
- moxxyPath,
191
- type WriteFileAtomicOptions,
192
- } from './fs-utils.js';
189
+ export type { WriteFileAtomicOptions } from './fs-utils.js';
193
190
  export { createMutex, type Mutex } from './mutex.js';
194
191
  export {
195
192
  createJsonFileStore,
@@ -198,17 +195,9 @@ export {
198
195
  } from './json-file-store.js';
199
196
  export { assertNever } from './assert.js';
200
197
  export { compareSemver, parseSemverCore } from './semver.js';
201
- export { readRequestBody, bearerTokenMatches } from './http-utils.js';
202
- export {
203
- resolveChannelToken,
204
- rotateChannelToken,
205
- bearerGuard,
206
- encodeWsBearerProtocol,
207
- tokenFromWsProtocolHeader,
208
- MOXXY_WS_SUBPROTOCOL,
209
- MOXXY_WS_BEARER_PROTOCOL_PREFIX,
210
- type ChannelTokenOptions,
211
- } from './channel-auth.js';
198
+ // readRequestBody/bearerTokenMatches (http-utils) and the channel-auth value
199
+ // helpers live on the './server' subpath — they reach node:http/crypto/fs/path.
200
+ export type { ChannelTokenOptions } from './channel-auth.js';
212
201
  export {
213
202
  autoAllowResolver,
214
203
  denyByDefaultResolver,
@@ -254,7 +243,11 @@ export type { TokenBudget, CompactContext, CompactorDef } from './compactor.js';
254
243
  export {
255
244
  estimateContextTokens,
256
245
  runCompactionIfNeeded,
246
+ runManualCompaction,
247
+ resolveModelContext,
257
248
  isContextOverflowError,
249
+ type ManualCompactionInput,
250
+ type ManualCompactionResult,
258
251
  } from './compactor-helpers.js';
259
252
  export {
260
253
  runElisionIfNeeded,
@@ -357,9 +350,11 @@ export type {
357
350
  ResolvedPluginManifest,
358
351
  } from './plugin.js';
359
352
 
353
+ export { startChannelWith } from './channel.js';
360
354
  export type {
361
355
  Channel,
362
356
  ChannelHandle,
357
+ ChannelStartArgs,
363
358
  ChannelStartOptsBase,
364
359
  ChannelFactoryDeps,
365
360
  ChannelDef,
@@ -403,6 +398,7 @@ export type {
403
398
  TranscriptionSegment,
404
399
  TranscribeOptions,
405
400
  } from './transcriber.js';
401
+ export { MOXXY_PCM16_24KHZ_MIME } from './transcriber.js';
406
402
 
407
403
  export type {
408
404
  Synthesizer,
@@ -61,6 +61,15 @@ export interface ProjectMessagesOptions {
61
61
  readonly systemPrompt?: string;
62
62
  /** Optional trailing user message — useful for plan-execute's "Focus on this step now: X". */
63
63
  readonly trailingUserText?: string;
64
+ /**
65
+ * Optional precomputed elision state for THIS exact log snapshot. When a
66
+ * caller already derived it within the same loop iteration (e.g. the
67
+ * compaction/elision gates), threading it here skips a redundant
68
+ * `computeElisionState` fold. MUST be the state of the same log — a stale one
69
+ * would mis-render stubs — so it is purely an opt-in fast path; omitting it
70
+ * recomputes (memoized on the log version), byte-identically.
71
+ */
72
+ readonly precomputedElisionState?: ElisionState;
64
73
  }
65
74
 
66
75
  interface CompactionRange {
@@ -260,7 +269,10 @@ export function projectMessages(
260
269
  const compactions = activeCompactionRanges(allEvents);
261
270
  const compactionFor = makeCompactionLookup(compactions);
262
271
  const emittedCompactions = new Set<CompactionRange>();
263
- const el = computeElisionState(allEvents);
272
+ // Reuse a threaded state when the caller already derived it for this exact
273
+ // snapshot; otherwise `computeElisionState` is memoized on the log version so
274
+ // the in-iteration projection still folds only once.
275
+ const el = opts.precomputedElisionState ?? computeElisionState(allEvents);
264
276
 
265
277
  const messages: ProviderMessage[] = [];
266
278
  // The stable prefix is every message produced from events at/below the
@@ -37,4 +37,41 @@ describe('@moxxy/sdk package root', () => {
37
37
 
38
38
  expect(check.issues[0]?.requirement.name).toBe('auth:provider:openai-codex');
39
39
  });
40
+
41
+ // The main barrel is the browser/RN-safe surface: a value import of any of
42
+ // these would drag a node:* builtin into a Metro/browser bundle. They live on
43
+ // the './server' subpath instead. This pins the split so a future accidental
44
+ // re-export on the main barrel is caught (see .dependency-cruiser.cjs
45
+ // `no-node-builtins-in-renderer`).
46
+ const NODE_ONLY_VALUE_EXPORTS = [
47
+ 'spawnCliTunnel',
48
+ 'isCliTunnelAvailable',
49
+ 'writeFileAtomic',
50
+ 'writeFileAtomicSync',
51
+ 'moxxyHome',
52
+ 'moxxyPath',
53
+ 'readRequestBody',
54
+ 'bearerTokenMatches',
55
+ 'resolveChannelToken',
56
+ 'rotateChannelToken',
57
+ 'bearerGuard',
58
+ 'encodeWsBearerProtocol',
59
+ 'tokenFromWsProtocolHeader',
60
+ 'MOXXY_WS_SUBPROTOCOL',
61
+ 'MOXXY_WS_BEARER_PROTOCOL_PREFIX',
62
+ ] as const;
63
+
64
+ it('does NOT expose Node-runtime values on the browser/RN-safe main barrel', async () => {
65
+ const sdk: Record<string, unknown> = await import('@moxxy/sdk');
66
+ for (const name of NODE_ONLY_VALUE_EXPORTS) {
67
+ expect(sdk[name], `${name} must not be on the @moxxy/sdk main barrel`).toBeUndefined();
68
+ }
69
+ });
70
+
71
+ it('exposes the Node-runtime helpers on the @moxxy/sdk/server subpath', async () => {
72
+ const server: Record<string, unknown> = await import('@moxxy/sdk/server');
73
+ for (const name of NODE_ONLY_VALUE_EXPORTS) {
74
+ expect(server[name], `${name} must be exported from @moxxy/sdk/server`).toBeDefined();
75
+ }
76
+ });
40
77
  });
package/src/server.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Node-runtime-only surface of @moxxy/sdk.
3
+ *
4
+ * Importing `@moxxy/sdk/server` pulls in the value helpers that statically
5
+ * depend on Node builtins (`node:child_process`, `node:fs`, `node:os`,
6
+ * `node:http`, `node:crypto`, `node:path`). The MAIN barrel (`@moxxy/sdk`) is
7
+ * deliberately kept free of these so a browser/React-Native bundle can value-
8
+ * import from it (and from `@moxxy/sdk/tool-display`) without dragging a Node
9
+ * builtin into the bundle — Metro cannot polyfill `node:child_process`.
10
+ *
11
+ * Node-side consumers (cli, runner, desktop-host, channel/oauth/webhooks
12
+ * plugins, …) import these helpers from here. The corresponding *type* exports
13
+ * (e.g. `TunnelHandle`, `WriteFileAtomicOptions`, `ChannelTokenOptions`) remain
14
+ * on the main barrel because types are erased at build time and never reach a
15
+ * bundle.
16
+ */
17
+
18
+ export { spawnCliTunnel, isCliTunnelAvailable } from './tunnel.js';
19
+ export {
20
+ writeFileAtomic,
21
+ writeFileAtomicSync,
22
+ moxxyHome,
23
+ moxxyPath,
24
+ } from './fs-utils.js';
25
+ export { readRequestBody, bearerTokenMatches } from './http-utils.js';
26
+ export {
27
+ resolveChannelToken,
28
+ rotateChannelToken,
29
+ bearerGuard,
30
+ encodeWsBearerProtocol,
31
+ tokenFromWsProtocolHeader,
32
+ MOXXY_WS_SUBPROTOCOL,
33
+ MOXXY_WS_BEARER_PROTOCOL_PREFIX,
34
+ } from './channel-auth.js';
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MOXXY_PCM16_24KHZ_MIME } from './transcriber.js';
3
+
4
+ describe('MOXXY_PCM16_24KHZ_MIME', () => {
5
+ it('locks the cross-package wire value', () => {
6
+ // This is a PROTOCOL constant: the desktop/web capture path stamps it onto
7
+ // the audio blob and the Codex transcriber keys on it to wrap the raw PCM
8
+ // samples in a WAV header. Drift in the literal silently breaks
9
+ // transcription with no compile-time signal — so pin the exact bytes here.
10
+ expect(MOXXY_PCM16_24KHZ_MIME).toBe('audio/x-moxxy-pcm16-24khz');
11
+ });
12
+ });
@@ -18,6 +18,20 @@
18
18
  * used by models that advertise `supportsAudio: true`.
19
19
  */
20
20
 
21
+ /**
22
+ * Wire MIME tag for raw 16-bit little-endian PCM mono at 24 kHz — the lossless
23
+ * format the desktop/web mic capture path produces and that the Codex
24
+ * transcriber keys on to wrap the bytes in a WAV header before upload.
25
+ *
26
+ * This is a cross-package PROTOCOL value: client-platform-web stamps it onto
27
+ * the captured blob, plugin-cli forwards it as the attachment `mimeType`, and
28
+ * plugin-stt-whisper switches on it. It lived as an independently-redeclared
29
+ * literal in all three (silent transcription breakage if any drifted), so it is
30
+ * hoisted here to the SDK's single zero-dep typed surface as the source of
31
+ * truth. Consumers should import this rather than re-spell the literal.
32
+ */
33
+ export const MOXXY_PCM16_24KHZ_MIME = 'audio/x-moxxy-pcm16-24khz';
34
+
21
35
  export interface TranscriptionSegment {
22
36
  /** Segment start, in seconds from the start of the clip. */
23
37
  readonly start: number;