Skip to content

Commit e765c84

Browse files
committed
Add initial implementation: server, client, and CLI
1 parent 70471d1 commit e765c84

39 files changed

Lines changed: 6010 additions & 0 deletions

bin/mdredd.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env node
2+
import { fileURLToPath } from 'node:url';
3+
import { dirname, resolve } from 'node:path';
4+
import { existsSync } from 'node:fs';
5+
6+
const here = dirname(fileURLToPath(import.meta.url));
7+
const built = resolve(here, '..', 'dist', 'server', 'index.js');
8+
9+
if (!existsSync(built)) {
10+
console.error(
11+
'mdredd: build artifacts missing. Run `npm run build` in the mdredd repo before launching.',
12+
);
13+
process.exit(1);
14+
}
15+
16+
await import(built);

src/server/claudeStream.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { EventEmitter } from 'node:events';
2+
import type { Readable } from 'node:stream';
3+
import { createInterface } from 'node:readline';
4+
import type { NormalizedEvent } from '@shared/schemas/events.js';
5+
import { log } from './log.js';
6+
7+
/**
8+
* Parser for `claude -p --output-format stream-json --include-partial-messages` output.
9+
*
10+
* Real-claude events we normalize:
11+
* {type: "stream_event", event: {type: "message_start", message: {role: "assistant", ...}}}
12+
* {type: "stream_event", event: {type: "content_block_start", content_block: {type: "text"|"thinking"|"tool_use", ...}}}
13+
* {type: "stream_event", event: {type: "content_block_delta", delta: {type: "text_delta"|"thinking_delta"|"input_json_delta", ...}}}
14+
* {type: "stream_event", event: {type: "content_block_stop"}}
15+
* {type: "stream_event", event: {type: "message_delta", delta: {stop_reason, ...}}}
16+
* {type: "stream_event", event: {type: "message_stop"}}
17+
* {type: "user", message: {content: [{type: "tool_result", tool_use_id, content, is_error}]}}
18+
* {type: "assistant", message: {...}} — aggregated partial/final assistant message
19+
* {type: "result", subtype: "success", ...} — authoritative end-of-run payload
20+
* {type: "system", subtype: "init", ...}
21+
*
22+
* Turn counting (plan § Safety cap): increment on message_stop when the current message
23+
* role === "assistant" and stop_reason !== "tool_use". Partial/delta events never count.
24+
*/
25+
26+
export interface ClaudeStreamParserEvents {
27+
normalized: (e: NormalizedEvent) => void;
28+
turn: (turnCount: number) => void;
29+
parseError: (raw: string, error: Error) => void;
30+
novelType: (type: string) => void;
31+
systemInit: (payload: Record<string, unknown>) => void;
32+
result: (payload: Record<string, unknown>) => void;
33+
rawStreamLine: (line: string) => void;
34+
}
35+
36+
type Listener<E extends keyof ClaudeStreamParserEvents> = ClaudeStreamParserEvents[E];
37+
38+
export class ClaudeStreamParser extends EventEmitter {
39+
private turnCount = 0;
40+
private currentMessageRole: string | null = null;
41+
private currentStopReason: string | null = null;
42+
private currentToolName: string | null = null;
43+
private currentToolInputBuffer = '';
44+
private currentContentBlockKind: 'text' | 'thinking' | 'tool_use' | null = null;
45+
private seenNovelTypes = new Set<string>();
46+
47+
override on<E extends keyof ClaudeStreamParserEvents>(event: E, listener: Listener<E>): this {
48+
return super.on(event, listener as (...args: unknown[]) => void);
49+
}
50+
51+
override emit<E extends keyof ClaudeStreamParserEvents>(
52+
event: E,
53+
...args: Parameters<Listener<E>>
54+
): boolean {
55+
return super.emit(event, ...(args as unknown[]));
56+
}
57+
58+
getTurnCount(): number {
59+
return this.turnCount;
60+
}
61+
62+
consume(stream: Readable): Promise<void> {
63+
return new Promise((resolve, reject) => {
64+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
65+
rl.on('line', (line) => this.parseLine(line));
66+
rl.on('close', () => resolve());
67+
rl.on('error', (err) => reject(err));
68+
});
69+
}
70+
71+
parseLine(line: string): void {
72+
const trimmed = line.trim();
73+
if (!trimmed) return;
74+
this.emit('rawStreamLine', trimmed);
75+
let parsed: unknown;
76+
try {
77+
parsed = JSON.parse(trimmed);
78+
} catch (err) {
79+
this.emit('parseError', trimmed, err as Error);
80+
return;
81+
}
82+
if (!parsed || typeof parsed !== 'object') {
83+
this.emit('parseError', trimmed, new Error('top-level value is not an object'));
84+
return;
85+
}
86+
this.handleRaw(parsed as Record<string, unknown>);
87+
}
88+
89+
private handleRaw(raw: Record<string, unknown>): void {
90+
const type = raw.type;
91+
switch (type) {
92+
case 'system':
93+
this.handleSystem(raw);
94+
return;
95+
case 'stream_event':
96+
this.handleStreamEvent(raw);
97+
return;
98+
case 'assistant':
99+
case 'user':
100+
this.handleAggregateMessage(raw);
101+
return;
102+
case 'result':
103+
this.emit('result', raw);
104+
return;
105+
case 'rate_limit_event':
106+
return;
107+
default:
108+
if (typeof type === 'string') {
109+
this.noteNovel(type);
110+
}
111+
}
112+
}
113+
114+
private handleSystem(raw: Record<string, unknown>): void {
115+
if (raw.subtype === 'init') {
116+
this.emit('systemInit', raw);
117+
}
118+
// Other system subtypes (status, permission_denied at top level, etc.) fall through.
119+
if (raw.subtype === 'permission_denied') {
120+
this.emitNormalized({
121+
t: 'permissionDenied',
122+
tool: String(raw.tool_name ?? 'unknown'),
123+
path: String(raw.path ?? ''),
124+
ts: Date.now(),
125+
});
126+
}
127+
}
128+
129+
private handleStreamEvent(raw: Record<string, unknown>): void {
130+
const ev = raw.event as Record<string, unknown> | undefined;
131+
if (!ev || typeof ev !== 'object') return;
132+
const evType = ev.type;
133+
switch (evType) {
134+
case 'message_start': {
135+
const message = ev.message as Record<string, unknown> | undefined;
136+
this.currentMessageRole = (message?.role as string | undefined) ?? null;
137+
this.currentStopReason = null;
138+
return;
139+
}
140+
case 'content_block_start': {
141+
const block = ev.content_block as Record<string, unknown> | undefined;
142+
const kind = (block?.type as string | undefined) ?? null;
143+
if (kind === 'text' || kind === 'thinking' || kind === 'tool_use') {
144+
this.currentContentBlockKind = kind;
145+
} else {
146+
this.currentContentBlockKind = null;
147+
}
148+
if (kind === 'tool_use') {
149+
this.currentToolName = (block?.name as string | undefined) ?? 'unknown';
150+
this.currentToolInputBuffer = '';
151+
}
152+
return;
153+
}
154+
case 'content_block_delta': {
155+
const delta = ev.delta as Record<string, unknown> | undefined;
156+
const dtype = delta?.type;
157+
if (dtype === 'text_delta') {
158+
const text = (delta?.text as string | undefined) ?? '';
159+
if (text) this.emitNormalized({ t: 'partial', chunk: text, kind: 'text', ts: Date.now() });
160+
} else if (dtype === 'thinking_delta') {
161+
const text = (delta?.thinking as string | undefined) ?? '';
162+
if (text)
163+
this.emitNormalized({ t: 'partial', chunk: text, kind: 'thinking', ts: Date.now() });
164+
} else if (dtype === 'input_json_delta') {
165+
const partial = (delta?.partial_json as string | undefined) ?? '';
166+
this.currentToolInputBuffer += partial;
167+
}
168+
return;
169+
}
170+
case 'content_block_stop': {
171+
if (this.currentContentBlockKind === 'tool_use' && this.currentToolName) {
172+
const argsSummary = truncate(this.currentToolInputBuffer, 160);
173+
this.emitNormalized({
174+
t: 'toolUse',
175+
tool: this.currentToolName,
176+
argsSummary,
177+
ts: Date.now(),
178+
});
179+
}
180+
this.currentContentBlockKind = null;
181+
return;
182+
}
183+
case 'message_delta': {
184+
const delta = ev.delta as Record<string, unknown> | undefined;
185+
const reason = delta?.stop_reason;
186+
if (typeof reason === 'string') this.currentStopReason = reason;
187+
return;
188+
}
189+
case 'message_stop': {
190+
if (
191+
this.currentMessageRole === 'assistant' &&
192+
this.currentStopReason !== 'tool_use'
193+
) {
194+
this.turnCount += 1;
195+
this.emit('turn', this.turnCount);
196+
this.emitNormalized({ t: 'turn', turn: this.turnCount, ts: Date.now() });
197+
}
198+
this.currentMessageRole = null;
199+
this.currentStopReason = null;
200+
return;
201+
}
202+
case 'permission_denied': {
203+
this.emitNormalized({
204+
t: 'permissionDenied',
205+
tool: String((ev.tool_name as string | undefined) ?? 'unknown'),
206+
path: String((ev.path as string | undefined) ?? ''),
207+
ts: Date.now(),
208+
});
209+
return;
210+
}
211+
default: {
212+
if (typeof evType === 'string') this.noteNovel(`stream_event.${evType}`);
213+
}
214+
}
215+
}
216+
217+
private handleAggregateMessage(raw: Record<string, unknown>): void {
218+
// Aggregated assistant/user messages. We emit one `message` normalized event per
219+
// top-level message emission so the transcript has durable records even if the
220+
// partial-delta stream is imperfect. The live UI renders from the partials.
221+
const role = raw.type === 'assistant' ? 'assistant' : 'user';
222+
const message = raw.message as Record<string, unknown> | undefined;
223+
const content = message?.content;
224+
if (role === 'user' && Array.isArray(content)) {
225+
for (const item of content as unknown[]) {
226+
if (!item || typeof item !== 'object') continue;
227+
const obj = item as Record<string, unknown>;
228+
if (obj.type === 'tool_result') {
229+
const tool = this.currentToolName ?? 'unknown';
230+
const rawResult = obj.content;
231+
const resultSummary = truncate(stringifyResult(rawResult), 200);
232+
this.emitNormalized({
233+
t: 'toolResult',
234+
tool,
235+
resultSummary,
236+
isError: Boolean(obj.is_error),
237+
ts: Date.now(),
238+
});
239+
}
240+
}
241+
return;
242+
}
243+
this.emitNormalized({ t: 'message', role, content, ts: Date.now() });
244+
}
245+
246+
private emitNormalized(e: NormalizedEvent): void {
247+
this.emit('normalized', e);
248+
}
249+
250+
private noteNovel(type: string): void {
251+
if (this.seenNovelTypes.has(type)) return;
252+
this.seenNovelTypes.add(type);
253+
log.warn('claude-stream: novel event type', { type });
254+
this.emit('novelType', type);
255+
}
256+
}
257+
258+
function stringifyResult(v: unknown): string {
259+
if (v === null || v === undefined) return '';
260+
if (typeof v === 'string') return v;
261+
try {
262+
return JSON.stringify(v);
263+
} catch {
264+
return String(v);
265+
}
266+
}
267+
268+
function truncate(s: string, n: number): string {
269+
if (s.length <= n) return s;
270+
return s.slice(0, n - 1) + '…';
271+
}

0 commit comments

Comments
 (0)