@moxxy/sdk 0.14.2 → 0.14.4

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 (44) hide show
  1. package/dist/compactor-helpers.d.ts.map +1 -1
  2. package/dist/compactor-helpers.js +9 -5
  3. package/dist/compactor-helpers.js.map +1 -1
  4. package/dist/mode/collect-stream.d.ts +68 -0
  5. package/dist/mode/collect-stream.d.ts.map +1 -0
  6. package/dist/mode/collect-stream.js +181 -0
  7. package/dist/mode/collect-stream.js.map +1 -0
  8. package/dist/mode/project-messages.d.ts +84 -0
  9. package/dist/mode/project-messages.d.ts.map +1 -0
  10. package/dist/mode/project-messages.js +392 -0
  11. package/dist/mode/project-messages.js.map +1 -0
  12. package/dist/mode/single-shot.d.ts +17 -0
  13. package/dist/mode/single-shot.d.ts.map +1 -0
  14. package/dist/mode/single-shot.js +53 -0
  15. package/dist/mode/single-shot.js.map +1 -0
  16. package/dist/mode/stable-hash.d.ts +7 -0
  17. package/dist/mode/stable-hash.d.ts.map +1 -0
  18. package/dist/mode/stable-hash.js +20 -0
  19. package/dist/mode/stable-hash.js.map +1 -0
  20. package/dist/mode/stuck-loop.d.ts +39 -0
  21. package/dist/mode/stuck-loop.d.ts.map +1 -0
  22. package/dist/mode/stuck-loop.js +51 -0
  23. package/dist/mode/stuck-loop.js.map +1 -0
  24. package/dist/mode-helpers.d.ts +15 -175
  25. package/dist/mode-helpers.d.ts.map +1 -1
  26. package/dist/mode-helpers.js +14 -666
  27. package/dist/mode-helpers.js.map +1 -1
  28. package/dist/tunnel.d.ts.map +1 -1
  29. package/dist/tunnel.js +11 -1
  30. package/dist/tunnel.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/compactor-helpers.test.ts +21 -0
  33. package/src/compactor-helpers.ts +10 -5
  34. package/src/mode/collect-stream.ts +247 -0
  35. package/src/mode/project-messages.test.ts +121 -0
  36. package/src/mode/project-messages.ts +461 -0
  37. package/src/mode/single-shot.ts +63 -0
  38. package/src/mode/stable-hash.ts +20 -0
  39. package/src/mode/stuck-loop.ts +89 -0
  40. package/src/mode-helpers.ts +32 -850
  41. package/src/mode.test.ts +20 -0
  42. package/src/token-accounting.test.ts +90 -0
  43. package/src/tunnel.test.ts +21 -0
  44. package/src/tunnel.ts +10 -1
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { migrateModeName } from './mode.js';
3
+
4
+ describe('migrateModeName', () => {
5
+ it('maps every legacy mode name to its current target', () => {
6
+ expect(migrateModeName('tool-use')).toBe('default');
7
+ expect(migrateModeName('deep-research')).toBe('research');
8
+ expect(migrateModeName('plan-execute')).toBe('default');
9
+ expect(migrateModeName('bmad')).toBe('default');
10
+ expect(migrateModeName('developer')).toBe('default');
11
+ });
12
+
13
+ it('passes a current/unknown name through unchanged (identity)', () => {
14
+ expect(migrateModeName('default')).toBe('default');
15
+ expect(migrateModeName('goal')).toBe('goal');
16
+ expect(migrateModeName('research')).toBe('research');
17
+ expect(migrateModeName('some-future-mode')).toBe('some-future-mode');
18
+ expect(migrateModeName('')).toBe('');
19
+ });
20
+ });
@@ -4,6 +4,7 @@ import {
4
4
  asEventId,
5
5
  asSessionId,
6
6
  asTurnId,
7
+ summarizeSessionTokensFromEvents,
7
8
  summarizeTokensByModel,
8
9
  type ModelUsageTotals,
9
10
  type MoxxyEvent,
@@ -78,6 +79,95 @@ describe('summarizeTokensByModel', () => {
78
79
  });
79
80
  });
80
81
 
82
+ describe('summarizeSessionTokensFromEvents — cost fold', () => {
83
+ it('counts only responses that reported usage', () => {
84
+ const s = summarizeSessionTokensFromEvents([
85
+ resp(0, {}), // no token fields → not counted
86
+ resp(1, { inputTokens: 100, outputTokens: 10 }),
87
+ resp(2, { cacheReadTokens: 50 }),
88
+ ]);
89
+ expect(s.calls).toBe(2);
90
+ expect(s.totalInput).toBe(100);
91
+ expect(s.totalCacheRead).toBe(50);
92
+ expect(s.totalOutput).toBe(10);
93
+ expect(s.totalPrompt).toBe(150);
94
+ });
95
+
96
+ it('applies the read 0.1x / write 1.25x billing multipliers', () => {
97
+ const s = summarizeSessionTokensFromEvents([
98
+ resp(0, { inputTokens: 100, cacheReadTokens: 1000, cacheCreationTokens: 200 }),
99
+ ]);
100
+ // totalPrompt = 100 + 1000 + 200 = 1300 (== uncachedInputEq)
101
+ expect(s.totalPrompt).toBe(1300);
102
+ expect(s.uncachedInputEq).toBe(1300);
103
+ // billedInputEq = 100 + 1000*0.1 + 200*1.25 = 100 + 100 + 250 = 450
104
+ expect(s.billedInputEq).toBeCloseTo(450, 6);
105
+ expect(s.cacheHitRate).toBeCloseTo(1000 / 1300, 6);
106
+ expect(s.savedRatio).toBeCloseTo(1 - 450 / 1300, 6);
107
+ });
108
+
109
+ it('guards divide-by-zero when no prompt tokens were reported', () => {
110
+ const s = summarizeSessionTokensFromEvents([resp(0, { outputTokens: 5 })]);
111
+ expect(s.totalPrompt).toBe(0);
112
+ expect(s.cacheHitRate).toBe(0);
113
+ expect(s.savedRatio).toBe(0);
114
+ // No writes → cache considered effective (deliberately-off cache never alarms).
115
+ expect(s.cacheEffective).toBe(true);
116
+ });
117
+
118
+ it('reports cacheEffective=true for a healthy cache (high read ratio)', () => {
119
+ const events = Array.from({ length: 6 }, (_, i) =>
120
+ resp(i, { inputTokens: 10, cacheReadTokens: 1000, cacheCreationTokens: 100 }),
121
+ );
122
+ const s = summarizeSessionTokensFromEvents(events);
123
+ expect(s.calls).toBe(6);
124
+ expect(s.cacheHitRate).toBeGreaterThan(0.05);
125
+ expect(s.savedRatio).toBeGreaterThan(0);
126
+ expect(s.cacheEffective).toBe(true);
127
+ });
128
+
129
+ it('trips cacheEffective=false on broken cache: >=5 calls, writes>0, near-zero reads', () => {
130
+ // 5 calls each writing cache but never reading it → cacheHitRate 0.
131
+ const events = Array.from({ length: 5 }, (_, i) =>
132
+ resp(i, { inputTokens: 100, cacheCreationTokens: 500, cacheReadTokens: 0 }),
133
+ );
134
+ const s = summarizeSessionTokensFromEvents(events);
135
+ expect(s.calls).toBe(5);
136
+ expect(s.totalCacheCreation).toBeGreaterThan(0);
137
+ expect(s.cacheHitRate).toBe(0);
138
+ expect(s.cacheEffective).toBe(false);
139
+ });
140
+
141
+ it('exempts the broken-cache trip below 5 calls (boundary at calls===4 vs 5)', () => {
142
+ const make = (n: number) =>
143
+ summarizeSessionTokensFromEvents(
144
+ Array.from({ length: n }, (_, i) =>
145
+ resp(i, { inputTokens: 100, cacheCreationTokens: 500, cacheReadTokens: 0 }),
146
+ ),
147
+ );
148
+ expect(make(4).cacheEffective).toBe(true); // < 5 → exempt
149
+ expect(make(5).cacheEffective).toBe(false); // >= 5 → trips
150
+ });
151
+
152
+ it('exempts when there are no cache writes even with zero reads over many calls', () => {
153
+ const events = Array.from({ length: 8 }, (_, i) =>
154
+ resp(i, { inputTokens: 100, cacheCreationTokens: 0, cacheReadTokens: 0 }),
155
+ );
156
+ expect(summarizeSessionTokensFromEvents(events).cacheEffective).toBe(true);
157
+ });
158
+
159
+ it('does not trip when cacheHitRate sits exactly at the 0.05 threshold', () => {
160
+ // hitRate === 0.05 is NOT < 0.05, so the cache stays effective.
161
+ // read/(input+read+write) = 125 / 2500 = 0.05.
162
+ const events = Array.from({ length: 5 }, (_, i) =>
163
+ resp(i, { inputTokens: 1875, cacheReadTokens: 125, cacheCreationTokens: 500 }),
164
+ );
165
+ const s = summarizeSessionTokensFromEvents(events);
166
+ expect(s.cacheHitRate).toBeCloseTo(0.05, 6);
167
+ expect(s.cacheEffective).toBe(true);
168
+ });
169
+ });
170
+
81
171
  describe('addModelTotals', () => {
82
172
  it('adds field-wise', () => {
83
173
  const a: ModelUsageTotals = { calls: 1, inputTokens: 10, outputTokens: 2, cacheReadTokens: 3, cacheCreationTokens: 4 };
@@ -29,6 +29,27 @@ describe('spawnCliTunnel', () => {
29
29
  await handle.close();
30
30
  });
31
31
 
32
+ it('u125-1: resolves a URL split across two stdout chunks', async () => {
33
+ // Write the URL in two separate process.stdout.write calls, split
34
+ // mid-host, with a tick gap so they surface as distinct 'data' events.
35
+ // Matching each chunk in isolation would miss it; the rolling buffer must
36
+ // stitch them.
37
+ const handle = await spawnCliTunnel({
38
+ cmd: process.execPath,
39
+ args: [
40
+ '-e',
41
+ 'process.stdout.write("ready https://abc-1");' +
42
+ 'setTimeout(()=>{process.stdout.write("23.example.test\\n");},50);' +
43
+ 'setInterval(()=>{}, 1000);',
44
+ ],
45
+ urlRegex: URL_RE,
46
+ name: 'splittunnel',
47
+ timeoutMs: 5_000,
48
+ });
49
+ expect(handle.url).toBe('https://abc-123.example.test');
50
+ await handle.close();
51
+ });
52
+
32
53
  it('rejects when the child exits before emitting a URL', async () => {
33
54
  await expect(
34
55
  spawnCliTunnel({
package/src/tunnel.ts CHANGED
@@ -158,11 +158,20 @@ export function spawnCliTunnel(opts: SpawnCliTunnelOptions): Promise<CliTunnelHa
158
158
  }, timeoutMs);
159
159
  timer.unref?.();
160
160
 
161
+ // 'data' events are not line-buffered: the assigned URL can straddle two
162
+ // chunk boundaries (large URL, fragmented flush). Match against a rolling
163
+ // accumulation rather than each lone chunk, capped so a chatty tunnel that
164
+ // never prints a URL can't grow this unboundedly while we wait.
165
+ const MAX_BUF = 8192;
166
+ let acc = '';
161
167
  const onData = (buf: Buffer): void => {
162
168
  if (settled) return; // drain quietly once resolved so the pipe never fills
163
- const url = urlRegex.exec(buf.toString('utf8'))?.[0] ?? null;
169
+ acc += buf.toString('utf8');
170
+ if (acc.length > MAX_BUF) acc = acc.slice(acc.length - MAX_BUF);
171
+ const url = urlRegex.exec(acc)?.[0] ?? null;
164
172
  if (!url) return;
165
173
  settled = true;
174
+ acc = '';
166
175
  clearTimeout(timer);
167
176
  resolve({ url, pid: child.pid ?? -1, close: untrack });
168
177
  };