@moxxy/sdk 0.14.4 → 0.14.5

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 (40) hide show
  1. package/README.md +2 -0
  2. package/dist/compactor-helpers.d.ts +59 -0
  3. package/dist/compactor-helpers.d.ts.map +1 -1
  4. package/dist/compactor-helpers.js +72 -1
  5. package/dist/compactor-helpers.js.map +1 -1
  6. package/dist/elision-helpers.d.ts.map +1 -1
  7. package/dist/elision-helpers.js +12 -5
  8. package/dist/elision-helpers.js.map +1 -1
  9. package/dist/elision-state.d.ts +4 -0
  10. package/dist/elision-state.d.ts.map +1 -1
  11. package/dist/elision-state.js +41 -0
  12. package/dist/elision-state.js.map +1 -1
  13. package/dist/index.d.ts +4 -5
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -5
  16. package/dist/index.js.map +1 -1
  17. package/dist/mode/project-messages.d.ts +9 -0
  18. package/dist/mode/project-messages.d.ts.map +1 -1
  19. package/dist/mode/project-messages.js +4 -1
  20. package/dist/mode/project-messages.js.map +1 -1
  21. package/dist/server.d.ts +21 -0
  22. package/dist/server.d.ts.map +1 -0
  23. package/dist/server.js +21 -0
  24. package/dist/server.js.map +1 -0
  25. package/dist/transcriber.d.ts +13 -0
  26. package/dist/transcriber.d.ts.map +1 -1
  27. package/dist/transcriber.js +13 -1
  28. package/dist/transcriber.js.map +1 -1
  29. package/package.json +9 -1
  30. package/src/compactor-helpers.test.ts +106 -0
  31. package/src/compactor-helpers.ts +125 -1
  32. package/src/elision-helpers.ts +14 -5
  33. package/src/elision-state.test.ts +78 -0
  34. package/src/elision-state.ts +42 -0
  35. package/src/index.ts +13 -19
  36. package/src/mode/project-messages.ts +13 -1
  37. package/src/package-root.test.ts +37 -0
  38. package/src/server.ts +34 -0
  39. package/src/transcriber.test.ts +12 -0
  40. package/src/transcriber.ts +14 -0
@@ -0,0 +1,21 @@
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
+ export { spawnCliTunnel, isCliTunnelAvailable } from './tunnel.js';
18
+ export { writeFileAtomic, writeFileAtomicSync, moxxyHome, moxxyPath, } from './fs-utils.js';
19
+ export { readRequestBody, bearerTokenMatches } from './http-utils.js';
20
+ export { resolveChannelToken, rotateChannelToken, bearerGuard, encodeWsBearerProtocol, tokenFromWsProtocolHeader, MOXXY_WS_SUBPROTOCOL, MOXXY_WS_BEARER_PROTOCOL_PREFIX, } from './channel-auth.js';
21
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,SAAS,EACT,SAAS,GACV,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,WAAW,EACX,sBAAsB,EACtB,yBAAyB,EACzB,oBAAoB,EACpB,+BAA+B,GAChC,MAAM,mBAAmB,CAAC"}
package/dist/server.js ADDED
@@ -0,0 +1,21 @@
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
+ export { spawnCliTunnel, isCliTunnelAvailable } from './tunnel.js';
18
+ export { writeFileAtomic, writeFileAtomicSync, moxxyHome, moxxyPath, } from './fs-utils.js';
19
+ export { readRequestBody, bearerTokenMatches } from './http-utils.js';
20
+ export { resolveChannelToken, rotateChannelToken, bearerGuard, encodeWsBearerProtocol, tokenFromWsProtocolHeader, MOXXY_WS_SUBPROTOCOL, MOXXY_WS_BEARER_PROTOCOL_PREFIX, } from './channel-auth.js';
21
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,SAAS,EACT,SAAS,GACV,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,WAAW,EACX,sBAAsB,EACtB,yBAAyB,EACzB,oBAAoB,EACpB,+BAA+B,GAChC,MAAM,mBAAmB,CAAC"}
@@ -17,6 +17,19 @@
17
17
  * - `ContentBlock` with `type: 'audio'` — provider-level wire format, only
18
18
  * used by models that advertise `supportsAudio: true`.
19
19
  */
20
+ /**
21
+ * Wire MIME tag for raw 16-bit little-endian PCM mono at 24 kHz — the lossless
22
+ * format the desktop/web mic capture path produces and that the Codex
23
+ * transcriber keys on to wrap the bytes in a WAV header before upload.
24
+ *
25
+ * This is a cross-package PROTOCOL value: client-platform-web stamps it onto
26
+ * the captured blob, plugin-cli forwards it as the attachment `mimeType`, and
27
+ * plugin-stt-whisper switches on it. It lived as an independently-redeclared
28
+ * literal in all three (silent transcription breakage if any drifted), so it is
29
+ * hoisted here to the SDK's single zero-dep typed surface as the source of
30
+ * truth. Consumers should import this rather than re-spell the literal.
31
+ */
32
+ export declare const MOXXY_PCM16_24KHZ_MIME = "audio/x-moxxy-pcm16-24khz";
20
33
  export interface TranscriptionSegment {
21
34
  /** Segment start, in seconds from the start of the clip. */
22
35
  readonly start: number;
@@ -1 +1 @@
1
- {"version":3,"file":"transcriber.d.ts","sourceRoot":"","sources":["../src/transcriber.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,+BAA+B;IAC/B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,oEAAoE;IACpE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,yCAAyC;IACzC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,gEAAgE;IAChE,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC,oBAAoB,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,iBAAiB;IAChC,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,UAAU,CACR,KAAK,EAAE,UAAU,GAAG,WAAW,EAC/B,IAAI,CAAC,EAAE,iBAAiB,GACvB,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,CAAC;CAC5D"}
1
+ {"version":3,"file":"transcriber.d.ts","sourceRoot":"","sources":["../src/transcriber.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,8BAA8B,CAAC;AAElE,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,+BAA+B;IAC/B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,oEAAoE;IACpE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,yCAAyC;IACzC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,gEAAgE;IAChE,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC,oBAAoB,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,iBAAiB;IAChC,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,UAAU,CACR,KAAK,EAAE,UAAU,GAAG,WAAW,EAC/B,IAAI,CAAC,EAAE,iBAAiB,GACvB,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,CAAC;CAC5D"}
@@ -17,5 +17,17 @@
17
17
  * - `ContentBlock` with `type: 'audio'` — provider-level wire format, only
18
18
  * used by models that advertise `supportsAudio: true`.
19
19
  */
20
- export {};
20
+ /**
21
+ * Wire MIME tag for raw 16-bit little-endian PCM mono at 24 kHz — the lossless
22
+ * format the desktop/web mic capture path produces and that the Codex
23
+ * transcriber keys on to wrap the bytes in a WAV header before upload.
24
+ *
25
+ * This is a cross-package PROTOCOL value: client-platform-web stamps it onto
26
+ * the captured blob, plugin-cli forwards it as the attachment `mimeType`, and
27
+ * plugin-stt-whisper switches on it. It lived as an independently-redeclared
28
+ * literal in all three (silent transcription breakage if any drifted), so it is
29
+ * hoisted here to the SDK's single zero-dep typed surface as the source of
30
+ * truth. Consumers should import this rather than re-spell the literal.
31
+ */
32
+ export const MOXXY_PCM16_24KHZ_MIME = 'audio/x-moxxy-pcm16-24khz';
21
33
  //# sourceMappingURL=transcriber.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"transcriber.js","sourceRoot":"","sources":["../src/transcriber.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"}
1
+ {"version":3,"file":"transcriber.js","sourceRoot":"","sources":["../src/transcriber.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,2BAA2B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxxy/sdk",
3
- "version": "0.14.4",
3
+ "version": "0.14.5",
4
4
  "description": "Typed public surface for the moxxy framework: event types, define* factories, lifecycle hook signatures.",
5
5
  "keywords": [
6
6
  "moxxy",
@@ -45,9 +45,17 @@
45
45
  "types": "./dist/index.d.ts",
46
46
  "import": "./dist/index.js"
47
47
  },
48
+ "./server": {
49
+ "types": "./dist/server.d.ts",
50
+ "import": "./dist/server.js"
51
+ },
48
52
  "./tool-display": {
49
53
  "types": "./dist/tool-display.d.ts",
50
54
  "import": "./dist/tool-display.js"
55
+ },
56
+ "./transcriber": {
57
+ "types": "./dist/transcriber.d.ts",
58
+ "import": "./dist/transcriber.js"
51
59
  }
52
60
  },
53
61
  "files": [
@@ -9,6 +9,7 @@ import {
9
9
  isContextOverflowError,
10
10
  runCompactionIfNeeded,
11
11
  runElisionIfNeeded,
12
+ runManualCompaction,
12
13
  type CompactorDef,
13
14
  type EmittedEvent,
14
15
  type EventLogReader,
@@ -298,6 +299,111 @@ describe('runCompactionIfNeeded', () => {
298
299
  });
299
300
  });
300
301
 
302
+ describe('runManualCompaction', () => {
303
+ // An appendable reader matching ManualCompactionInput.log — records the
304
+ // appended event so we can assert defensive identity-fill.
305
+ function appendableLog(seed: ReadonlyArray<MoxxyEvent>): EventLogReader & {
306
+ append(event: EmittedEvent): Promise<MoxxyEvent>;
307
+ appended: EmittedEvent[];
308
+ } {
309
+ const events = [...seed];
310
+ const appended: EmittedEvent[] = [];
311
+ return {
312
+ ...reader(events),
313
+ appended,
314
+ append: async (e: EmittedEvent) => {
315
+ appended.push(e);
316
+ const mat = { ...e, id: asEventId(`a${appended.length}`), seq: events.length, ts: events.length, sessionId: sid } as MoxxyEvent;
317
+ events.push(mat);
318
+ return mat;
319
+ },
320
+ };
321
+ }
322
+
323
+ const seed: MoxxyEvent[] = [
324
+ event(0, { type: 'user_prompt', turnId: tid, source: 'user', text: 'x'.repeat(400) }),
325
+ event(1, { type: 'assistant_message', turnId: tid, source: 'model', content: 'y'.repeat(400), stopReason: 'end_turn' }),
326
+ ];
327
+
328
+ it('returns the no-op result for an empty log', async () => {
329
+ const log = appendableLog([]);
330
+ const compactor: CompactorDef = {
331
+ name: 'never',
332
+ shouldCompact: () => false,
333
+ compact: async () => { throw new Error('should not run'); },
334
+ };
335
+ const res = await runManualCompaction({ compactor, log });
336
+ expect(res).toEqual({ compacted: false, tokensSaved: 0, eventsCompacted: 0 });
337
+ expect(log.appended).toHaveLength(0);
338
+ });
339
+
340
+ it('compacts unconditionally (no shouldCompact gate) and reports counts', async () => {
341
+ const log = appendableLog(seed);
342
+ const shouldCompact = vi.fn().mockReturnValue(false); // would block the auto path
343
+ const compactor: CompactorDef = {
344
+ name: 'fake',
345
+ shouldCompact,
346
+ compact: async () => ({
347
+ type: 'compaction',
348
+ compactor: 'fake',
349
+ replacedRange: [0, 1],
350
+ summary: 'compressed',
351
+ tokensSaved: 123,
352
+ }),
353
+ };
354
+ const res = await runManualCompaction({ compactor, log });
355
+ // shouldCompact is NEVER consulted — manual compaction always runs.
356
+ expect(shouldCompact).not.toHaveBeenCalled();
357
+ expect(res).toEqual({ compacted: true, tokensSaved: 123, eventsCompacted: 2 });
358
+ expect(log.appended).toHaveLength(1);
359
+ // Defensive identity fill from the log tail + source=compactor.
360
+ expect(log.appended[0]).toMatchObject({ type: 'compaction', source: 'compactor', sessionId: sid, turnId: tid });
361
+ });
362
+
363
+ it('counts only events inside the inclusive replacedRange, not the seq span', async () => {
364
+ // A range [0, 999] spanning a gap: only the two real events fall inside.
365
+ const log = appendableLog([
366
+ event(0, { type: 'user_prompt', turnId: tid, source: 'user', text: 'x'.repeat(400) }),
367
+ event(500, { type: 'assistant_message', turnId: tid, source: 'model', content: 'z'.repeat(400), stopReason: 'end_turn' }),
368
+ ]);
369
+ const compactor: CompactorDef = {
370
+ name: 'wide',
371
+ shouldCompact: () => false,
372
+ compact: async () => ({
373
+ type: 'compaction', compactor: 'wide', replacedRange: [0, 999], summary: 's', tokensSaved: 50,
374
+ }),
375
+ };
376
+ const res = await runManualCompaction({ compactor, log });
377
+ expect(res.eventsCompacted).toBe(2);
378
+ });
379
+
380
+ it('reports no-op (no append) when the summary is empty / tokensSaved <= 0', async () => {
381
+ const log = appendableLog(seed);
382
+ const compactor: CompactorDef = {
383
+ name: 'empty',
384
+ shouldCompact: () => true,
385
+ compact: async () => ({
386
+ type: 'compaction', compactor: 'empty', replacedRange: [0, 1], summary: ' ', tokensSaved: 0,
387
+ }),
388
+ };
389
+ const res = await runManualCompaction({ compactor, log });
390
+ expect(res.compacted).toBe(false);
391
+ expect(log.appended).toHaveLength(0);
392
+ });
393
+
394
+ it('caller-supplied sessionId/turnId override the log-tail fallback', async () => {
395
+ const log = appendableLog(seed);
396
+ const compactor: CompactorDef = {
397
+ name: 'fake', shouldCompact: () => false,
398
+ compact: async () => ({ type: 'compaction', compactor: 'fake', replacedRange: [0, 1], summary: 'ok', tokensSaved: 10 }),
399
+ };
400
+ await runManualCompaction({
401
+ compactor, log, sessionId: asSessionId('other'), turnId: asTurnId('explicit'),
402
+ });
403
+ expect(log.appended[0]).toMatchObject({ sessionId: 'other', turnId: 'explicit' });
404
+ });
405
+ });
406
+
301
407
  describe('isContextOverflowError', () => {
302
408
  it('matches common provider context-overflow phrasings', () => {
303
409
  for (const msg of [
@@ -7,9 +7,11 @@ import {
7
7
  toolResultStubbed,
8
8
  type ElisionState,
9
9
  } from './elision-state.js';
10
+ import type { CompactorDef, TokenBudget } from './compactor.js';
10
11
  import type { EmittedEvent, MoxxyEvent } from './events.js';
11
12
  import type { EventLogReader } from './log.js';
12
13
  import type { ModeContext } from './mode.js';
14
+ import type { LLMProvider } from './provider.js';
13
15
 
14
16
  /**
15
17
  * Cheap, no-network estimate of how many tokens the current event log
@@ -168,9 +170,15 @@ export async function runCompactionIfNeeded(
168
170
  const events = ctx.log.slice();
169
171
  if (events.length === 0) return false;
170
172
 
173
+ // Derive the elision state ONCE for this snapshot and thread it into the
174
+ // estimate — `computeElisionState` is memoized on the log version, but
175
+ // threading skips even the memo lookup and guarantees the estimate folds the
176
+ // exact same state the rest of this iteration uses.
177
+ const elisionState = computeElisionState(events);
178
+
171
179
  const budget = {
172
180
  contextWindow: resolved?.contextWindow ?? Number.MAX_SAFE_INTEGER,
173
- estimatedTokens: estimateContextTokens(ctx.log),
181
+ estimatedTokens: estimateContextTokens(ctx.log, elisionState),
174
182
  reserveForOutput: resolved?.reserveForOutput ?? 0,
175
183
  } as const;
176
184
 
@@ -233,6 +241,122 @@ export async function runCompactionIfNeeded(
233
241
  }
234
242
  }
235
243
 
244
+ /**
245
+ * Outcome of {@link runManualCompaction}. `compacted` is false (with the other
246
+ * counts at 0) whenever there was nothing to compact — empty log, no usable
247
+ * summary, or `tokensSaved <= 0` — so a caller can format "nothing to compact"
248
+ * vs "compacted N events" without re-deriving the gate.
249
+ */
250
+ export interface ManualCompactionResult {
251
+ /** Did a CompactionEvent get appended? */
252
+ readonly compacted: boolean;
253
+ /** Estimated tokens the summary saves vs the replaced range. */
254
+ readonly tokensSaved: number;
255
+ /** Count of events whose `seq` fell inside the (inclusive) replaced range. */
256
+ readonly eventsCompacted: number;
257
+ }
258
+
259
+ const NO_COMPACTION: ManualCompactionResult = {
260
+ compacted: false,
261
+ tokensSaved: 0,
262
+ eventsCompacted: 0,
263
+ };
264
+
265
+ /**
266
+ * Shape a manual `/compact` needs from the session, kept structural so callers
267
+ * (plugin-commands) stay free of a `@moxxy/core` dependency — the host always
268
+ * passes a real Session that satisfies it. Mirrors the fields
269
+ * {@link runCompactionIfNeeded} reads off `ModeContext`, but log-first because
270
+ * a manual compaction has no live turn/mode context.
271
+ */
272
+ export interface ManualCompactionInput {
273
+ readonly compactor: CompactorDef;
274
+ /** Authoring log the CompactionEvent is appended to. */
275
+ readonly log: EventLogReader & {
276
+ append(event: EmittedEvent): Promise<MoxxyEvent>;
277
+ };
278
+ /** Active provider/model so the default compactor writes a real summary. */
279
+ readonly provider?: LLMProvider;
280
+ readonly model?: string;
281
+ /** Active model's resolved context window (for `shouldCompact`). */
282
+ readonly contextWindow?: number;
283
+ readonly reserveForOutput?: number;
284
+ /** Cancellation signal; a fresh one is used when omitted. */
285
+ readonly signal?: AbortSignal;
286
+ /** Override the appended event's sessionId/turnId (else taken from the log tail). */
287
+ readonly sessionId?: string;
288
+ readonly turnId?: string;
289
+ }
290
+
291
+ /**
292
+ * Run ONE compaction now, unconditionally (the `/compact` command's force
293
+ * semantics — the threshold gate is skipped). The single shared implementation
294
+ * of the manual-compaction flow that `compactSession` in `@moxxy/plugin-commands`
295
+ * used to hand-roll: build the {@link TokenBudget} (with the same `estimate`
296
+ * + context-window fallback as the auto path), call `compactor.compact`, guard
297
+ * on an empty/zero-saving result, defensively fill the event's
298
+ * sessionId/turnId/source, append it, and report `{ compacted, tokensSaved,
299
+ * eventsCompacted }` so the caller only formats the message.
300
+ *
301
+ * Distinct from {@link runCompactionIfNeeded}, which is the per-iteration
302
+ * auto-compaction hook bound to a live `ModeContext` and gated by
303
+ * `shouldCompact` (unless forced). This one is log-first and always runs,
304
+ * matching how a user-invoked `/compact` works. Errors propagate to the caller
305
+ * (the command formats them); a no-op returns {@link NO_COMPACTION}.
306
+ */
307
+ export async function runManualCompaction(
308
+ input: ManualCompactionInput,
309
+ ): Promise<ManualCompactionResult> {
310
+ const { compactor, log } = input;
311
+ const events = log.slice();
312
+ if (events.length === 0) return NO_COMPACTION;
313
+
314
+ // Match the auto path's window fallback: an unresolved window degrades to
315
+ // MAX_SAFE_INTEGER (manual compaction ignores the threshold anyway).
316
+ const contextWindow =
317
+ input.contextWindow && input.contextWindow > 0
318
+ ? input.contextWindow
319
+ : Number.MAX_SAFE_INTEGER;
320
+ const budget: TokenBudget = {
321
+ contextWindow,
322
+ estimatedTokens: estimateContextTokens(log),
323
+ reserveForOutput: input.reserveForOutput ?? 0,
324
+ };
325
+
326
+ const result = await compactor.compact(events, {
327
+ log,
328
+ budget,
329
+ signal: input.signal ?? new AbortController().signal,
330
+ ...(input.provider ? { provider: input.provider } : {}),
331
+ ...(input.model !== undefined ? { model: input.model } : {}),
332
+ });
333
+ if (result.tokensSaved <= 0 || result.summary.trim().length === 0) {
334
+ return NO_COMPACTION;
335
+ }
336
+
337
+ // Defensive-fill identity fields a spec-compliant compactor may omit
338
+ // (`compact` declares `Omit<CompactionEvent, keyof EventBase>`). The
339
+ // compactor's own values win (result spreads last). Mirrors
340
+ // `runCompactionIfNeeded`.
341
+ const lastEvent = events[events.length - 1];
342
+ const emittable: EmittedEvent = {
343
+ sessionId: input.sessionId ?? lastEvent?.sessionId,
344
+ turnId: input.turnId ?? lastEvent?.turnId,
345
+ source: 'compactor',
346
+ ...result,
347
+ } as EmittedEvent;
348
+ await log.append(emittable);
349
+
350
+ // `replacedRange` is an INCLUSIVE [fromSeq, toSeq] of event `seq` VALUES, not
351
+ // array indices — and `seq === arrayIndex` is not guaranteed for
352
+ // mirrors/partial views. Count the events actually inside the range rather
353
+ // than differencing the seqs (which would overstate across any seq gap).
354
+ const [fromSeq, toSeq] = result.replacedRange;
355
+ const eventsCompacted = events.filter((e) => e.seq >= fromSeq && e.seq <= toSeq).length;
356
+
357
+ return { compacted: true, tokensSaved: result.tokensSaved, eventsCompacted };
358
+ }
359
+
236
360
  /**
237
361
  * Heuristic: does this provider error mean "the request was too big for the
238
362
  * model's context window"? Providers phrase it many ways (OpenAI "maximum
@@ -1,5 +1,5 @@
1
1
  import { estimateContextTokens, resolveModelContext } from './compactor-helpers.js';
2
- import { toolResultBytes } from './elision-state.js';
2
+ import { computeElisionState, toolResultBytes } from './elision-state.js';
3
3
  import type { EmittedEvent, MoxxyEvent } from './events.js';
4
4
  import type { ElisionSettings, ModeContext } from './mode.js';
5
5
 
@@ -67,13 +67,22 @@ export async function runElisionIfNeeded(ctx: ModeContext): Promise<void> {
67
67
  if (!resolved) return;
68
68
  const contextWindow = resolved.contextWindow;
69
69
 
70
- // Gate: below this fill the whole history fits comfortably — eliding would
71
- // only add missed-context risk for no token benefit.
72
- if (estimateContextTokens(ctx.log) < s.minContextRatioToElide * contextWindow) return;
73
-
74
70
  const events = ctx.log.slice();
75
71
  if (events.length === 0) return;
76
72
 
73
+ // Derive the elision state ONCE for this snapshot and thread it into the
74
+ // gate's estimate. The same `computeElisionState` fold otherwise runs again
75
+ // inside `estimateContextTokens` (and a third time in projection this
76
+ // iteration); threading shares the one fold. Order vs the slice is
77
+ // irrelevant — both read the same immutable snapshot.
78
+ const elisionState = computeElisionState(events);
79
+
80
+ // Gate: below this fill the whole history fits comfortably — eliding would
81
+ // only add missed-context risk for no token benefit.
82
+ if (estimateContextTokens(ctx.log, elisionState) < s.minContextRatioToElide * contextWindow) {
83
+ return;
84
+ }
85
+
77
86
  const priorHWM = events.reduce(
78
87
  (max, e) => (e.type === 'elision' ? Math.max(max, e.elidedThrough) : max),
79
88
  -1,
@@ -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,
@@ -403,6 +396,7 @@ export type {
403
396
  TranscriptionSegment,
404
397
  TranscribeOptions,
405
398
  } from './transcriber.js';
399
+ export { MOXXY_PCM16_24KHZ_MIME } from './transcriber.js';
406
400
 
407
401
  export type {
408
402
  Synthesizer,