@onmars/lunar-agent-claude 0.7.0 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onmars/lunar-agent-claude",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -12,7 +12,7 @@
12
12
  "LICENSE"
13
13
  ],
14
14
  "dependencies": {
15
- "@onmars/lunar-core": "^0.7.0"
15
+ "@onmars/lunar-core": "^0.9.0"
16
16
  },
17
17
  "description": "Claude CLI agent adapter for Lunar",
18
18
  "author": "onMars Tech",
@@ -0,0 +1,291 @@
1
+ /**
2
+ * # ClaudeCompactionBridge — Functional Specification
3
+ *
4
+ * Phase 1.5 bridge between Anthropic's Compaction API (and Claude Code
5
+ * PreCompact hook) and the agnostic FlushHook that lives in
6
+ * @onmars/lunar-core.
7
+ *
8
+ * ## onStreamEvent(event)
9
+ * Pattern-matches Anthropic streaming events. When a
10
+ * `content_block_start` event with `content_block.type === 'compaction'`
11
+ * is seen, emits `{ kind: 'compaction-incoming', source:
12
+ * 'anthropic-stream', model? }` on the wired FlushHook. Other event
13
+ * types (including `message_start`, `content_block_delta`, unrelated
14
+ * blocks) are ignored. `message_start` events are sniffed for the
15
+ * active model so subsequent compaction signals can be annotated.
16
+ *
17
+ * ## handlePreCompactHook(payload)
18
+ * Public entry point for Claude Code's PreCompact hook. Emits
19
+ * `{ kind: 'compaction-incoming', source: 'claude-code-hook', model? }`
20
+ * and returns exit code 0 — the native compaction must never be
21
+ * blocked. Errors from the FlushHook are swallowed (logged) and still
22
+ * resolve to 0.
23
+ *
24
+ * ## shutdown()
25
+ * Makes the bridge inert. Subsequent onStreamEvent/handlePreCompactHook
26
+ * calls are no-ops.
27
+ *
28
+ * ## Defensive: missing FlushHook
29
+ * When constructed with `undefined`, every method is a silent no-op.
30
+ */
31
+ import { describe, expect, test } from 'bun:test'
32
+ import type { CurationEngine, CurationResult, PressureSignal } from '@onmars/lunar-core'
33
+ import { FlushHook } from '@onmars/lunar-core'
34
+ import {
35
+ type AnthropicStreamEvent,
36
+ ClaudeCompactionBridge,
37
+ type PreCompactPayload,
38
+ } from '../lib/compaction-bridge'
39
+
40
+ // ─── helpers ────────────────────────────────────────────────
41
+
42
+ /** Minimal CurationEngine stub that records signals it receives. */
43
+ function makeEngineStub(result?: CurationResult) {
44
+ const signals: PressureSignal[] = []
45
+ const stub = {
46
+ curate: async (ctx: { signal: PressureSignal }) => {
47
+ signals.push(ctx.signal)
48
+ return (
49
+ result ?? {
50
+ strategy: 'delegate' as const,
51
+ savedFacts: [],
52
+ skipped: [],
53
+ instructionToInject: '[SYSTEM FLUSH] test',
54
+ }
55
+ )
56
+ },
57
+ signals,
58
+ } as unknown as CurationEngine & { signals: PressureSignal[] }
59
+ return stub
60
+ }
61
+
62
+ function makeFlushHook(): {
63
+ hook: FlushHook
64
+ signals: PressureSignal[]
65
+ engine: CurationEngine & { signals: PressureSignal[] }
66
+ } {
67
+ const engine = makeEngineStub()
68
+ const hook = new FlushHook({ curationEngine: engine, cooldownMs: 0, threshold: 0.7 })
69
+ // Buffer a message so the signal does not get skipped as 'empty-buffer'.
70
+ hook.pushMessage({ role: 'user', content: 'hello' })
71
+ return { hook, signals: engine.signals, engine }
72
+ }
73
+
74
+ /** Wait a tick for fire-and-forget onSignal to flush through. */
75
+ async function flushMicrotasks(ms = 10): Promise<void> {
76
+ await new Promise<void>((r) => setTimeout(r, ms))
77
+ }
78
+
79
+ // ─── tests ──────────────────────────────────────────────────
80
+
81
+ describe('ClaudeCompactionBridge — onStreamEvent', () => {
82
+ test('emits compaction-incoming when content_block_start.type is compaction', async () => {
83
+ const { hook, signals } = makeFlushHook()
84
+ const bridge = new ClaudeCompactionBridge(hook)
85
+
86
+ // Observe the model from a prior message_start so the signal can
87
+ // annotate it.
88
+ bridge.onStreamEvent({
89
+ type: 'message_start',
90
+ message: { model: 'claude-opus-4-7' },
91
+ } satisfies AnthropicStreamEvent)
92
+
93
+ bridge.onStreamEvent({
94
+ type: 'content_block_start',
95
+ content_block: { type: 'compaction' },
96
+ } satisfies AnthropicStreamEvent)
97
+
98
+ await flushMicrotasks()
99
+
100
+ expect(signals).toHaveLength(1)
101
+ const [signal] = signals
102
+ expect(signal.kind).toBe('compaction-incoming')
103
+ if (signal.kind === 'compaction-incoming') {
104
+ expect(signal.source).toBe('anthropic-stream')
105
+ expect(signal.model).toBe('claude-opus-4-7')
106
+ }
107
+ })
108
+
109
+ test('emits signal with undefined model when no message_start was seen', async () => {
110
+ const { hook, signals } = makeFlushHook()
111
+ const bridge = new ClaudeCompactionBridge(hook)
112
+
113
+ bridge.onStreamEvent({
114
+ type: 'content_block_start',
115
+ content_block: { type: 'compaction' },
116
+ })
117
+
118
+ await flushMicrotasks()
119
+
120
+ expect(signals).toHaveLength(1)
121
+ const [signal] = signals
122
+ if (signal.kind === 'compaction-incoming') {
123
+ expect(signal.model).toBeUndefined()
124
+ }
125
+ })
126
+
127
+ test('ignores unrelated content_block_start types', async () => {
128
+ const { hook, signals } = makeFlushHook()
129
+ const bridge = new ClaudeCompactionBridge(hook)
130
+
131
+ bridge.onStreamEvent({
132
+ type: 'content_block_start',
133
+ content_block: { type: 'text' },
134
+ })
135
+ bridge.onStreamEvent({
136
+ type: 'content_block_start',
137
+ content_block: { type: 'tool_use' },
138
+ })
139
+
140
+ await flushMicrotasks()
141
+
142
+ expect(signals).toHaveLength(0)
143
+ })
144
+
145
+ test('ignores unrelated event types', async () => {
146
+ const { hook, signals } = makeFlushHook()
147
+ const bridge = new ClaudeCompactionBridge(hook)
148
+
149
+ bridge.onStreamEvent({ type: 'message_delta' })
150
+ bridge.onStreamEvent({ type: 'message_stop' })
151
+ bridge.onStreamEvent({ type: 'content_block_delta', content_block: { type: 'compaction' } })
152
+ bridge.onStreamEvent({ type: 'ping' })
153
+
154
+ await flushMicrotasks()
155
+
156
+ expect(signals).toHaveLength(0)
157
+ })
158
+
159
+ test('ignores malformed events gracefully', async () => {
160
+ const { hook, signals } = makeFlushHook()
161
+ const bridge = new ClaudeCompactionBridge(hook)
162
+
163
+ // None of these should throw; none should emit.
164
+ bridge.onStreamEvent({ type: 'content_block_start' })
165
+ bridge.onStreamEvent({ type: 'content_block_start', content_block: { type: '' } })
166
+ bridge.onStreamEvent({ type: '' })
167
+
168
+ await flushMicrotasks()
169
+
170
+ expect(signals).toHaveLength(0)
171
+ })
172
+
173
+ test('tracks model across multiple message_start events', async () => {
174
+ const { hook, signals } = makeFlushHook()
175
+ const bridge = new ClaudeCompactionBridge(hook)
176
+
177
+ bridge.onStreamEvent({ type: 'message_start', message: { model: 'claude-sonnet-4-6' } })
178
+ bridge.onStreamEvent({ type: 'message_start', message: { model: 'claude-opus-4-7' } })
179
+
180
+ bridge.onStreamEvent({
181
+ type: 'content_block_start',
182
+ content_block: { type: 'compaction' },
183
+ })
184
+
185
+ await flushMicrotasks()
186
+
187
+ expect(signals).toHaveLength(1)
188
+ const [signal] = signals
189
+ if (signal.kind === 'compaction-incoming') {
190
+ expect(signal.model).toBe('claude-opus-4-7')
191
+ }
192
+ })
193
+ })
194
+
195
+ describe('ClaudeCompactionBridge — handlePreCompactHook', () => {
196
+ test('emits compaction-incoming with claude-code-hook source and returns 0', async () => {
197
+ const { hook, signals } = makeFlushHook()
198
+ const bridge = new ClaudeCompactionBridge(hook)
199
+
200
+ const code = await bridge.handlePreCompactHook({
201
+ model: 'claude-opus-4-7',
202
+ session_id: 'sess-123',
203
+ } satisfies PreCompactPayload)
204
+
205
+ expect(code).toBe(0)
206
+ expect(signals).toHaveLength(1)
207
+ const [signal] = signals
208
+ expect(signal.kind).toBe('compaction-incoming')
209
+ if (signal.kind === 'compaction-incoming') {
210
+ expect(signal.source).toBe('claude-code-hook')
211
+ expect(signal.model).toBe('claude-opus-4-7')
212
+ }
213
+ })
214
+
215
+ test('accepts payload without model', async () => {
216
+ const { hook, signals } = makeFlushHook()
217
+ const bridge = new ClaudeCompactionBridge(hook)
218
+
219
+ const code = await bridge.handlePreCompactHook({})
220
+ expect(code).toBe(0)
221
+ expect(signals).toHaveLength(1)
222
+ const [signal] = signals
223
+ if (signal.kind === 'compaction-incoming') {
224
+ expect(signal.model).toBeUndefined()
225
+ }
226
+ })
227
+
228
+ test('returns 0 even when the underlying flush throws', async () => {
229
+ const { hook } = makeFlushHook()
230
+ // Monkey-patch onSignal to throw — handlePreCompactHook must still
231
+ // return 0 so it never blocks the native compaction.
232
+ const original = hook.onSignal.bind(hook)
233
+ hook.onSignal = async () => {
234
+ throw new Error('boom')
235
+ }
236
+ const bridge = new ClaudeCompactionBridge(hook)
237
+
238
+ const code = await bridge.handlePreCompactHook({ model: 'claude-opus-4-7' })
239
+ expect(code).toBe(0)
240
+
241
+ // Restore to avoid cross-test contamination.
242
+ hook.onSignal = original
243
+ })
244
+ })
245
+
246
+ describe('ClaudeCompactionBridge — shutdown', () => {
247
+ test('onStreamEvent is a no-op after shutdown', async () => {
248
+ const { hook, signals } = makeFlushHook()
249
+ const bridge = new ClaudeCompactionBridge(hook)
250
+
251
+ bridge.shutdown()
252
+ expect(bridge.isShutdown).toBe(true)
253
+
254
+ bridge.onStreamEvent({
255
+ type: 'content_block_start',
256
+ content_block: { type: 'compaction' },
257
+ })
258
+
259
+ await flushMicrotasks()
260
+ expect(signals).toHaveLength(0)
261
+ })
262
+
263
+ test('handlePreCompactHook returns 0 and emits nothing after shutdown', async () => {
264
+ const { hook, signals } = makeFlushHook()
265
+ const bridge = new ClaudeCompactionBridge(hook)
266
+ bridge.shutdown()
267
+
268
+ const code = await bridge.handlePreCompactHook({ model: 'x' })
269
+ expect(code).toBe(0)
270
+ expect(signals).toHaveLength(0)
271
+ })
272
+ })
273
+
274
+ describe('ClaudeCompactionBridge — defensive (no FlushHook)', () => {
275
+ test('onStreamEvent is a no-op when constructed without a FlushHook', () => {
276
+ const bridge = new ClaudeCompactionBridge(undefined)
277
+
278
+ expect(() =>
279
+ bridge.onStreamEvent({
280
+ type: 'content_block_start',
281
+ content_block: { type: 'compaction' },
282
+ }),
283
+ ).not.toThrow()
284
+ })
285
+
286
+ test('handlePreCompactHook returns 0 when constructed without a FlushHook', async () => {
287
+ const bridge = new ClaudeCompactionBridge(undefined)
288
+ const code = await bridge.handlePreCompactHook({ model: 'claude-opus-4-7' })
289
+ expect(code).toBe(0)
290
+ })
291
+ })
@@ -0,0 +1,211 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ClaudeTokenCounter } from '../lib/token-counter-claude'
3
+
4
+ // ─── mock fetch helper ─────────────────────────────────────
5
+
6
+ interface FakeCall {
7
+ url: string
8
+ init: RequestInit
9
+ }
10
+
11
+ function makeFetch(responses: Array<{ status?: number; body: unknown }>) {
12
+ const calls: FakeCall[] = []
13
+ let idx = 0
14
+ const fetchImpl = (async (input: unknown, init?: RequestInit) => {
15
+ calls.push({ url: String(input), init: init ?? {} })
16
+ const resp = responses[Math.min(idx, responses.length - 1)]
17
+ idx++
18
+ return new Response(JSON.stringify(resp.body), {
19
+ status: resp.status ?? 200,
20
+ headers: { 'content-type': 'application/json' },
21
+ })
22
+ }) as unknown as typeof fetch
23
+ return { fetchImpl, calls }
24
+ }
25
+
26
+ // ─── tests ─────────────────────────────────────────────────
27
+
28
+ describe('ClaudeTokenCounter', () => {
29
+ test('count returns input_tokens from API response', async () => {
30
+ const { fetchImpl, calls } = makeFetch([{ body: { input_tokens: 12345 } }])
31
+
32
+ const counter = new ClaudeTokenCounter({
33
+ apiKey: 'sk-test',
34
+ model: 'claude-opus-4-7',
35
+ fetchImpl,
36
+ })
37
+
38
+ const n = await counter.count([{ role: 'user', content: 'hello world' }])
39
+ expect(n).toBe(12345)
40
+ expect(calls).toHaveLength(1)
41
+
42
+ // Request shape
43
+ const init = calls[0].init
44
+ expect(init.method).toBe('POST')
45
+ const headers = init.headers as Record<string, string>
46
+ expect(headers['x-api-key']).toBe('sk-test')
47
+ expect(headers['anthropic-version']).toBe('2023-06-01')
48
+ expect(headers['content-type']).toBe('application/json')
49
+
50
+ const body = JSON.parse(init.body as string)
51
+ expect(body.model).toBe('claude-opus-4-7')
52
+ expect(body.messages).toHaveLength(1)
53
+ })
54
+
55
+ test('cache hit avoids second fetch for identical messages', async () => {
56
+ const { fetchImpl, calls } = makeFetch([{ body: { input_tokens: 100 } }])
57
+
58
+ const counter = new ClaudeTokenCounter({
59
+ apiKey: 'sk-test',
60
+ model: 'claude-sonnet-4-6',
61
+ fetchImpl,
62
+ })
63
+
64
+ const msgs = [{ role: 'user', content: 'hi' }]
65
+ const a = await counter.count(msgs)
66
+ const b = await counter.count(msgs)
67
+
68
+ expect(a).toBe(100)
69
+ expect(b).toBe(100)
70
+ expect(calls).toHaveLength(1)
71
+ expect(counter.cacheSize()).toBe(1)
72
+ })
73
+
74
+ test('different messages trigger new API call', async () => {
75
+ const { fetchImpl, calls } = makeFetch([
76
+ { body: { input_tokens: 10 } },
77
+ { body: { input_tokens: 20 } },
78
+ ])
79
+
80
+ const counter = new ClaudeTokenCounter({
81
+ apiKey: 'sk-test',
82
+ model: 'claude-haiku-4-5',
83
+ fetchImpl,
84
+ })
85
+
86
+ expect(await counter.count([{ role: 'user', content: 'first' }])).toBe(10)
87
+ expect(await counter.count([{ role: 'user', content: 'second' }])).toBe(20)
88
+ expect(calls).toHaveLength(2)
89
+ })
90
+
91
+ test('LRU evicts oldest after 50 unique entries', async () => {
92
+ // Each call returns a fresh token count. We just care about cache bookkeeping.
93
+ const { fetchImpl } = makeFetch([{ body: { input_tokens: 1 } }])
94
+
95
+ const counter = new ClaudeTokenCounter({
96
+ apiKey: 'sk-test',
97
+ model: 'claude-opus-4-7',
98
+ fetchImpl,
99
+ })
100
+
101
+ for (let i = 0; i < 60; i++) {
102
+ await counter.count([{ role: 'user', content: `msg-${i}` }])
103
+ }
104
+ expect(counter.cacheSize()).toBe(50)
105
+ })
106
+
107
+ test('windowSize returns 200K for standard models', () => {
108
+ const a = new ClaudeTokenCounter({ apiKey: 'x', model: 'claude-opus-4-7' })
109
+ expect(a.windowSize()).toBe(200_000)
110
+
111
+ const b = new ClaudeTokenCounter({ apiKey: 'x', model: 'claude-sonnet-4-6' })
112
+ expect(b.windowSize()).toBe(200_000)
113
+
114
+ const c = new ClaudeTokenCounter({ apiKey: 'x', model: 'claude-haiku-4-5' })
115
+ expect(c.windowSize()).toBe(200_000)
116
+ })
117
+
118
+ test('windowSize returns 1M when model ends with [1m]', () => {
119
+ const counter = new ClaudeTokenCounter({
120
+ apiKey: 'x',
121
+ model: 'claude-opus-4-7[1m]',
122
+ })
123
+ expect(counter.windowSize()).toBe(1_000_000)
124
+ })
125
+
126
+ test('aliases resolve: opus → claude-opus-4-7', () => {
127
+ const counter = new ClaudeTokenCounter({ apiKey: 'x', model: 'opus' })
128
+ expect(counter.windowSize()).toBe(200_000)
129
+ })
130
+
131
+ test('aliases + [1m]: sonnet[1m] → 1M window', () => {
132
+ const counter = new ClaudeTokenCounter({ apiKey: 'x', model: 'sonnet[1m]' })
133
+ expect(counter.windowSize()).toBe(1_000_000)
134
+ })
135
+
136
+ test('disabled mode (no apiKey) returns 0 without calling fetch', async () => {
137
+ let callCount = 0
138
+ const fetchImpl = (async () => {
139
+ callCount++
140
+ return new Response('{}', { status: 200 })
141
+ }) as unknown as typeof fetch
142
+
143
+ const counter = new ClaudeTokenCounter({
144
+ model: 'claude-opus-4-7',
145
+ fetchImpl,
146
+ })
147
+ expect(counter.isDisabled()).toBe(true)
148
+
149
+ const n = await counter.count([{ role: 'user', content: 'hi' }])
150
+ expect(n).toBe(0)
151
+ expect(callCount).toBe(0)
152
+ expect(counter.windowSize()).toBe(200_000)
153
+ })
154
+
155
+ test('empty messages returns 0 without fetching', async () => {
156
+ let callCount = 0
157
+ const fetchImpl = (async () => {
158
+ callCount++
159
+ return new Response(JSON.stringify({ input_tokens: 999 }), { status: 200 })
160
+ }) as unknown as typeof fetch
161
+
162
+ const counter = new ClaudeTokenCounter({
163
+ apiKey: 'sk-test',
164
+ model: 'claude-opus-4-7',
165
+ fetchImpl,
166
+ })
167
+
168
+ expect(await counter.count([])).toBe(0)
169
+ expect(callCount).toBe(0)
170
+ })
171
+
172
+ test('non-OK response returns 0 without crashing', async () => {
173
+ const { fetchImpl } = makeFetch([{ status: 500, body: { error: 'server down' } }])
174
+ const counter = new ClaudeTokenCounter({
175
+ apiKey: 'sk-test',
176
+ model: 'claude-opus-4-7',
177
+ fetchImpl,
178
+ })
179
+ const n = await counter.count([{ role: 'user', content: 'hi' }])
180
+ expect(n).toBe(0)
181
+ })
182
+
183
+ test('fetch throw is swallowed into 0', async () => {
184
+ const fetchImpl = (async () => {
185
+ throw new Error('network failure')
186
+ }) as unknown as typeof fetch
187
+
188
+ const counter = new ClaudeTokenCounter({
189
+ apiKey: 'sk-test',
190
+ model: 'claude-opus-4-7',
191
+ fetchImpl,
192
+ })
193
+ const n = await counter.count([{ role: 'user', content: 'hi' }])
194
+ expect(n).toBe(0)
195
+ })
196
+
197
+ test('context1m flag adds anthropic-beta header', async () => {
198
+ const { fetchImpl, calls } = makeFetch([{ body: { input_tokens: 7 } }])
199
+ const counter = new ClaudeTokenCounter({
200
+ apiKey: 'sk-test',
201
+ model: 'claude-opus-4-7[1m]',
202
+ fetchImpl,
203
+ })
204
+ await counter.count([{ role: 'user', content: 'hi' }])
205
+ const headers = calls[0].init.headers as Record<string, string>
206
+ expect(headers['anthropic-beta']).toBe('context-1m-2025-08-07')
207
+ // Model sent to API is the alias-resolved, suffix-stripped version
208
+ const body = JSON.parse(calls[0].init.body as string)
209
+ expect(body.model).toBe('claude-opus-4-7')
210
+ })
211
+ })
package/src/adapter.ts CHANGED
@@ -2,6 +2,7 @@ import type { Agent, AgentEvent, AgentInput, AgentUsage } from '@onmars/lunar-co
2
2
  import { log } from '@onmars/lunar-core'
3
3
  import type { SecurityConfig } from '@onmars/lunar-core/lib/config-loader'
4
4
  import { buildSafeEnv } from '@onmars/lunar-core/lib/security'
5
+ import type { ClaudeCompactionBridge } from './lib/compaction-bridge'
5
6
 
6
7
  export interface ClaudeAgentOptions {
7
8
  /** Agent ID for routing — defaults to 'claude' */
@@ -100,11 +101,29 @@ export class ClaudeAgent implements Agent {
100
101
 
101
102
  private activeProcess: ReturnType<typeof Bun.spawn> | null = null
102
103
 
104
+ /**
105
+ * Optional Claude-specific compaction bridge. When set, every parsed
106
+ * CLI stream event is forwarded so the bridge can emit
107
+ * `compaction-incoming` pressure signals. The bridge remains fully
108
+ * opt-in — if unset (or the feature is disabled upstream), the adapter
109
+ * behaves exactly as before.
110
+ */
111
+ private compactionBridge: ClaudeCompactionBridge | undefined
112
+
103
113
  constructor(private options: ClaudeAgentOptions) {
104
114
  this.id = options.id ?? 'claude'
105
115
  this.name = options.name ?? 'Claude Code (CLI)'
106
116
  }
107
117
 
118
+ /**
119
+ * Inject a compaction bridge after construction. The bridge is
120
+ * optional and installed post-hoc from `start.ts` because the
121
+ * FlushHook it wraps is built later in the boot sequence.
122
+ */
123
+ setCompactionBridge(bridge: ClaudeCompactionBridge | undefined): void {
124
+ this.compactionBridge = bridge
125
+ }
126
+
108
127
  async init(): Promise<void> {
109
128
  const binary = this.options.binaryPath ?? 'claude'
110
129
 
@@ -389,6 +408,26 @@ export class ClaudeAgent implements Agent {
389
408
 
390
409
  try {
391
410
  const msg: ClaudeJsonMessage = JSON.parse(trimmed)
411
+
412
+ // Forward raw stream event to the compaction bridge (if any)
413
+ // BEFORE we transform into AgentEvents. The bridge does its
414
+ // own structural matching and ignores unrelated messages.
415
+ if (this.compactionBridge) {
416
+ try {
417
+ this.compactionBridge.onStreamEvent(
418
+ msg as unknown as import('./lib/compaction-bridge').AnthropicStreamEvent,
419
+ )
420
+ } catch (bridgeErr) {
421
+ // Bridge failures must never break the stream loop.
422
+ log.debug(
423
+ {
424
+ err: bridgeErr instanceof Error ? bridgeErr.message : String(bridgeErr),
425
+ },
426
+ 'Compaction bridge onStreamEvent threw (ignored)',
427
+ )
428
+ }
429
+ }
430
+
392
431
  for (const evt of this.processMessage(msg)) {
393
432
  if (evt.type === 'text') hasAssistantText = true
394
433
  yield evt
package/src/index.ts CHANGED
@@ -1,2 +1,9 @@
1
1
  export type { ClaudeAgentOptions } from './adapter'
2
2
  export { CLAUDE_MODEL_ALIASES, ClaudeAgent, resolveModelAlias } from './adapter'
3
+ export type {
4
+ AnthropicStreamEvent,
5
+ PreCompactPayload,
6
+ } from './lib/compaction-bridge'
7
+ export { ClaudeCompactionBridge } from './lib/compaction-bridge'
8
+ export type { ClaudeTokenCounterOptions } from './lib/token-counter-claude'
9
+ export { ClaudeTokenCounter } from './lib/token-counter-claude'
@@ -0,0 +1,182 @@
1
+ /**
2
+ * ClaudeCompactionBridge — Claude-specific translator between Anthropic's
3
+ * Compaction API (and Claude Code's PreCompact hook) and the agnostic
4
+ * `FlushHook` that lives in @onmars/lunar-core.
5
+ *
6
+ * Phase 1.5 bridge. Responsibilities:
7
+ *
8
+ * • onStreamEvent(event) — inspect a Claude stream event. If we
9
+ * detect a `content_block_start` of
10
+ * type `compaction` (Anthropic's
11
+ * Compaction API, beta header
12
+ * `compact-2026-01-12` with
13
+ * `pause_after_compaction: true`), emit
14
+ * a `compaction-incoming` PressureSignal
15
+ * with source = 'anthropic-stream'.
16
+ *
17
+ * • handlePreCompactHook(p) — public entry point for Claude Code's
18
+ * PreCompact hook. Claude Code can be
19
+ * configured (via settings.json's
20
+ * hooks.PreCompact) to shell out to a
21
+ * local endpoint that ultimately calls
22
+ * this method. Emits the same signal
23
+ * with source = 'claude-code-hook'.
24
+ * Returns exit code 0 so the native
25
+ * compact proceeds unblocked.
26
+ *
27
+ * • shutdown() — mark the bridge inert. After shutdown,
28
+ * further events/hooks are ignored.
29
+ *
30
+ * Design constraints:
31
+ *
32
+ * • Zero runtime dependency on @anthropic-ai/sdk. We accept a minimal
33
+ * structural `AnthropicStreamEvent` shape so integrators are free to
34
+ * feed events from any source (SDK, CLI stream-json, mock tests).
35
+ * • No @onmars/lunar-core types leak Anthropic-specific fields upward.
36
+ * This file is the only place that knows about Anthropic's
37
+ * compaction block shape.
38
+ * • Opt-in. If no FlushHook is provided, every method is a no-op.
39
+ * Callers should simply skip constructing the bridge when the
40
+ * feature is disabled.
41
+ */
42
+
43
+ import type { FlushHook } from '@onmars/lunar-core'
44
+ import { log } from '@onmars/lunar-core'
45
+
46
+ // ════════════════════════════════════════════════════════════
47
+ // Public types — structural, no SDK import required
48
+ // ════════════════════════════════════════════════════════════
49
+
50
+ /**
51
+ * Minimal structural shape of an Anthropic streaming event we need to
52
+ * recognise compaction. Matches the public Messages API streaming
53
+ * contract (see Anthropic docs). Only the fields we actually inspect
54
+ * are declared; integrators may feed richer events and we'll ignore
55
+ * the rest.
56
+ *
57
+ * Compaction detection uses the `content_block_start` event whose
58
+ * `content_block.type === 'compaction'` (Anthropic Compaction API, beta
59
+ * header `compact-2026-01-12`).
60
+ */
61
+ export interface AnthropicStreamEvent {
62
+ type: string
63
+ /** Present on content_block_start / content_block_delta events. */
64
+ content_block?: {
65
+ type: string
66
+ [key: string]: unknown
67
+ }
68
+ /** Present on message_start — we use it to annotate the signal. */
69
+ message?: {
70
+ model?: string
71
+ [key: string]: unknown
72
+ }
73
+ [key: string]: unknown
74
+ }
75
+
76
+ /**
77
+ * Payload shape for Claude Code's PreCompact hook. The upstream CLI
78
+ * currently writes a JSON object on stdin to hook commands; we accept
79
+ * whatever it provides and only read what we need.
80
+ */
81
+ export interface PreCompactPayload {
82
+ /** The model about to compact, if reported. */
83
+ model?: string
84
+ /** Optional session identifier for log context. */
85
+ session_id?: string
86
+ /** Allow arbitrary additional fields without type loss. */
87
+ [key: string]: unknown
88
+ }
89
+
90
+ // ════════════════════════════════════════════════════════════
91
+ // Implementation
92
+ // ════════════════════════════════════════════════════════════
93
+
94
+ export class ClaudeCompactionBridge {
95
+ private shutdownRequested = false
96
+
97
+ /** Last model seen on a message_start — lets us tag stream signals. */
98
+ private lastKnownModel: string | undefined
99
+
100
+ constructor(private readonly flushHook: FlushHook | undefined) {}
101
+
102
+ /**
103
+ * Feed a single Anthropic stream event to the bridge. The method is
104
+ * intentionally synchronous and fire-and-forget on the FlushHook side:
105
+ * we trigger the signal but do not await the flush (callers must not
106
+ * block the streaming loop).
107
+ */
108
+ onStreamEvent(event: AnthropicStreamEvent): void {
109
+ if (this.shutdownRequested) return
110
+ if (!this.flushHook) return
111
+ if (!event || typeof event !== 'object') return
112
+
113
+ // Track model from message_start so we can annotate downstream
114
+ // signals with the active model id.
115
+ if (event.type === 'message_start' && event.message?.model) {
116
+ this.lastKnownModel = event.message.model
117
+ return
118
+ }
119
+
120
+ if (event.type !== 'content_block_start') return
121
+ if (!event.content_block || event.content_block.type !== 'compaction') return
122
+
123
+ const model = this.lastKnownModel
124
+ log.info(
125
+ { source: 'anthropic-stream', model },
126
+ 'Claude Compaction bridge: compaction block detected in stream',
127
+ )
128
+
129
+ // Fire and forget — the FlushHook is already async-safe internally
130
+ // and will coalesce concurrent signals.
131
+ void this.flushHook.onSignal({
132
+ kind: 'compaction-incoming',
133
+ source: 'anthropic-stream',
134
+ model,
135
+ })
136
+ }
137
+
138
+ /**
139
+ * Public entry point for Claude Code's PreCompact hook. Intended to
140
+ * be invoked by whatever process bridges the CLI hook payload into
141
+ * the Lunar runtime (future: a lightweight HTTP endpoint). Always
142
+ * returns 0 so the native compaction proceeds unblocked — we are a
143
+ * last-chance persister, not a gatekeeper.
144
+ */
145
+ async handlePreCompactHook(payload: PreCompactPayload): Promise<number> {
146
+ if (this.shutdownRequested) return 0
147
+ if (!this.flushHook) return 0
148
+
149
+ const model = typeof payload?.model === 'string' ? payload.model : undefined
150
+ log.info(
151
+ { source: 'claude-code-hook', model, sessionId: payload?.session_id },
152
+ 'Claude Compaction bridge: PreCompact hook received',
153
+ )
154
+
155
+ try {
156
+ await this.flushHook.onSignal({
157
+ kind: 'compaction-incoming',
158
+ source: 'claude-code-hook',
159
+ model,
160
+ })
161
+ } catch (err) {
162
+ // Never block the native compaction on our side — log and swallow.
163
+ log.warn(
164
+ { err: err instanceof Error ? err.message : String(err) },
165
+ 'Claude Compaction bridge: PreCompact flush failed (continuing)',
166
+ )
167
+ }
168
+
169
+ return 0
170
+ }
171
+
172
+ /** Mark the bridge inert. Subsequent events/hooks are no-ops. */
173
+ shutdown(): void {
174
+ this.shutdownRequested = true
175
+ this.lastKnownModel = undefined
176
+ }
177
+
178
+ /** Observability helper — tests and logs only. */
179
+ get isShutdown(): boolean {
180
+ return this.shutdownRequested
181
+ }
182
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * ClaudeTokenCounter — concrete TokenCounter implementation backed by
3
+ * Anthropic's Token Count API (https://docs.anthropic.com/en/api/messages-count-tokens).
4
+ *
5
+ * This lives in agent-claude to keep the core package provider-agnostic.
6
+ * A tiny in-memory LRU cache (50 entries) keeps the same message bundle
7
+ * from re-hitting the API when the Router checks the ratio repeatedly.
8
+ *
9
+ * If the constructor doesn't receive an API key, the counter operates in
10
+ * disabled mode: `count()` returns 0 and the FlushHook will simply never
11
+ * see a token-threshold signal above zero.
12
+ */
13
+
14
+ import { createHash } from 'node:crypto'
15
+ import { log } from '@onmars/lunar-core'
16
+ import type { CountableMessage, TokenCounter } from '@onmars/lunar-core/lib/token-counter'
17
+ import { resolveModelAlias } from '../adapter'
18
+
19
+ // ════════════════════════════════════════════════════════════
20
+ // Model → context window mapping (static; single source of truth here)
21
+ // ════════════════════════════════════════════════════════════
22
+
23
+ const MODEL_WINDOW: Readonly<Record<string, number>> = Object.freeze({
24
+ 'claude-opus-4-7': 200_000,
25
+ 'claude-opus-4-6': 200_000,
26
+ 'claude-sonnet-4-6': 200_000,
27
+ 'claude-sonnet-4-5': 200_000,
28
+ 'claude-haiku-4-5': 200_000,
29
+ })
30
+
31
+ const ONE_MILLION = 1_000_000
32
+ const DEFAULT_WINDOW = 200_000
33
+
34
+ // ════════════════════════════════════════════════════════════
35
+ // Tiny LRU (insertion-ordered Map trick — no dep)
36
+ // ════════════════════════════════════════════════════════════
37
+
38
+ class TinyLRU<V> {
39
+ private readonly max: number
40
+ private readonly store = new Map<string, V>()
41
+
42
+ constructor(max: number) {
43
+ this.max = max
44
+ }
45
+
46
+ get(key: string): V | undefined {
47
+ const v = this.store.get(key)
48
+ if (v !== undefined) {
49
+ // refresh recency
50
+ this.store.delete(key)
51
+ this.store.set(key, v)
52
+ }
53
+ return v
54
+ }
55
+
56
+ set(key: string, value: V): void {
57
+ if (this.store.has(key)) {
58
+ this.store.delete(key)
59
+ } else if (this.store.size >= this.max) {
60
+ // evict oldest
61
+ const oldest = this.store.keys().next().value as string | undefined
62
+ if (oldest !== undefined) this.store.delete(oldest)
63
+ }
64
+ this.store.set(key, value)
65
+ }
66
+
67
+ get size(): number {
68
+ return this.store.size
69
+ }
70
+ }
71
+
72
+ // ════════════════════════════════════════════════════════════
73
+ // Public options + types
74
+ // ════════════════════════════════════════════════════════════
75
+
76
+ export interface ClaudeTokenCounterOptions {
77
+ /** Anthropic API key. When absent, the counter runs in disabled mode. */
78
+ apiKey?: string
79
+ /** Model ID (aliases resolved; accepts "[1m]" suffix for 1M context). */
80
+ model: string
81
+ /** Override the endpoint (tests). */
82
+ endpoint?: string
83
+ /** Override fetch (tests). */
84
+ fetchImpl?: typeof fetch
85
+ /** Anthropic API version header. Defaults to '2023-06-01'. */
86
+ apiVersion?: string
87
+ }
88
+
89
+ interface CountTokensResponse {
90
+ input_tokens: number
91
+ }
92
+
93
+ // ════════════════════════════════════════════════════════════
94
+ // Implementation
95
+ // ════════════════════════════════════════════════════════════
96
+
97
+ export class ClaudeTokenCounter implements TokenCounter {
98
+ private readonly apiKey?: string
99
+ private readonly model: string
100
+ private readonly effectiveModel: string
101
+ private readonly endpoint: string
102
+ private readonly fetchImpl: typeof fetch
103
+ private readonly apiVersion: string
104
+ private readonly context1m: boolean
105
+ private readonly cache = new TinyLRU<number>(50)
106
+ private readonly disabled: boolean
107
+
108
+ constructor(options: ClaudeTokenCounterOptions) {
109
+ this.apiKey = options.apiKey
110
+ this.model = options.model
111
+ this.context1m = options.model.endsWith('[1m]')
112
+ const baseModel = this.context1m ? options.model.slice(0, -'[1m]'.length) : options.model
113
+ this.effectiveModel = resolveModelAlias(baseModel)
114
+ this.endpoint = options.endpoint ?? 'https://api.anthropic.com/v1/messages/count_tokens'
115
+ this.fetchImpl = options.fetchImpl ?? fetch
116
+ this.apiVersion = options.apiVersion ?? '2023-06-01'
117
+ this.disabled = !this.apiKey
118
+
119
+ if (this.disabled) {
120
+ log.warn(
121
+ { model: this.effectiveModel },
122
+ 'ClaudeTokenCounter: no ANTHROPIC_API_KEY — token-threshold signals disabled',
123
+ )
124
+ }
125
+ }
126
+
127
+ /** Whether this counter is disabled (no API key). */
128
+ isDisabled(): boolean {
129
+ return this.disabled
130
+ }
131
+
132
+ async count(messages: CountableMessage[]): Promise<number> {
133
+ if (this.disabled || messages.length === 0) return 0
134
+
135
+ const key = this.cacheKey(messages)
136
+ const cached = this.cache.get(key)
137
+ if (cached !== undefined) return cached
138
+
139
+ try {
140
+ const headers: Record<string, string> = {
141
+ 'content-type': 'application/json',
142
+ 'x-api-key': this.apiKey!,
143
+ 'anthropic-version': this.apiVersion,
144
+ }
145
+ if (this.context1m) {
146
+ headers['anthropic-beta'] = 'context-1m-2025-08-07'
147
+ }
148
+
149
+ const body = JSON.stringify({
150
+ model: this.effectiveModel,
151
+ messages: normaliseForApi(messages),
152
+ })
153
+
154
+ const res = await this.fetchImpl(this.endpoint, {
155
+ method: 'POST',
156
+ headers,
157
+ body,
158
+ })
159
+
160
+ if (!res.ok) {
161
+ log.warn(
162
+ { status: res.status, model: this.effectiveModel },
163
+ 'ClaudeTokenCounter: count_tokens API returned non-OK — treating as 0',
164
+ )
165
+ return 0
166
+ }
167
+
168
+ const data = (await res.json()) as CountTokensResponse
169
+ const tokens = typeof data?.input_tokens === 'number' ? data.input_tokens : 0
170
+ this.cache.set(key, tokens)
171
+ return tokens
172
+ } catch (err) {
173
+ log.warn(
174
+ { err: err instanceof Error ? err.message : String(err) },
175
+ 'ClaudeTokenCounter: count_tokens call failed — treating as 0',
176
+ )
177
+ return 0
178
+ }
179
+ }
180
+
181
+ windowSize(): number {
182
+ const base = MODEL_WINDOW[this.effectiveModel] ?? DEFAULT_WINDOW
183
+ return this.context1m ? ONE_MILLION : base
184
+ }
185
+
186
+ /** Exposed for tests. */
187
+ cacheSize(): number {
188
+ return this.cache.size
189
+ }
190
+
191
+ private cacheKey(messages: CountableMessage[]): string {
192
+ const serialised = JSON.stringify(messages)
193
+ return createHash('sha1')
194
+ .update(
195
+ this.effectiveModel + '\u0000' + (this.context1m ? '1m' : 'std') + '\u0000' + serialised,
196
+ )
197
+ .digest('hex')
198
+ }
199
+ }
200
+
201
+ // ════════════════════════════════════════════════════════════
202
+ // Helpers
203
+ // ════════════════════════════════════════════════════════════
204
+
205
+ function normaliseForApi(messages: CountableMessage[]): Array<{ role: string; content: unknown }> {
206
+ // Anthropic accepts string or block-array content; we just pass through.
207
+ // If content is null/undefined, stringify to empty.
208
+ return messages.map((m) => ({
209
+ role: m.role,
210
+ content: m.content == null ? '' : m.content,
211
+ }))
212
+ }