@mitsein-ai/cli 0.2.2 → 0.3.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/README.md +75 -41
- package/dist/index.js +138 -140
- package/package.json +1 -1
- package/src/commands/agent.ts +90 -108
- package/src/commands/api-auto.ts +1 -1
- package/src/commands/dev.ts +1 -1
- package/src/commands/files.ts +1 -1
- package/src/commands/project.ts +6 -6
- package/src/commands/thread.ts +66 -143
- package/src/core/chat-output.ts +93 -0
- package/src/core/chat-run.ts +156 -0
- package/src/core/chat-stream.ts +188 -0
- package/src/core/client.ts +9 -2
- package/src/core/output.ts +14 -1
- package/src/index.ts +1 -3
- package/src/commands/messages.ts +0 -61
- package/src/core/sse.ts +0 -135
- package/src/core/waiters-output.ts +0 -95
- package/src/core/waiters.ts +0 -169
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ChatEvent } from './chat-stream.js';
|
|
2
|
+
import { isTTY } from './output.js';
|
|
3
|
+
|
|
4
|
+
function formatLocalTime(tsSec: number): string {
|
|
5
|
+
const d = new Date(tsSec * 1000);
|
|
6
|
+
const h = String(d.getHours()).padStart(2, '0');
|
|
7
|
+
const m = String(d.getMinutes()).padStart(2, '0');
|
|
8
|
+
const s = String(d.getSeconds()).padStart(2, '0');
|
|
9
|
+
return `${h}:${m}:${s}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function truncate(s: string, max: number): string {
|
|
13
|
+
return s.length > max ? `${s.slice(0, max)}...` : s;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stringify(v: unknown, limit: number): string {
|
|
17
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
18
|
+
return truncate(s ?? '', limit);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Human-friendly single-line render of one chat SSE event. */
|
|
22
|
+
export function printChatEventHuman(event: ChatEvent): void {
|
|
23
|
+
const ts = formatLocalTime(event.timestamp);
|
|
24
|
+
const d = event.data;
|
|
25
|
+
const dim = '\x1b[2m';
|
|
26
|
+
const reset = '\x1b[0m';
|
|
27
|
+
const bold = '\x1b[1m';
|
|
28
|
+
|
|
29
|
+
switch (event.type) {
|
|
30
|
+
case 'text': {
|
|
31
|
+
const content = typeof d.content === 'string' ? d.content : '';
|
|
32
|
+
process.stdout.write(`${dim}[${ts}]${reset} ${bold}assistant:${reset} ${content}\n`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
case 'tool_call': {
|
|
36
|
+
const name = typeof d.name === 'string' ? d.name : '?';
|
|
37
|
+
const args = stringify(d.args ?? d.arguments ?? '', 80);
|
|
38
|
+
process.stdout.write(`${dim}[${ts}]${reset} \x1b[33mtool_call:${reset} ${name}(${args})\n`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
case 'tool_result': {
|
|
42
|
+
const name = typeof d.name === 'string' ? d.name : '?';
|
|
43
|
+
const output = stringify(d.content ?? d.output ?? '', 100);
|
|
44
|
+
const status = typeof d.status === 'string' ? ` [${d.status}]` : '';
|
|
45
|
+
process.stdout.write(`${dim}[${ts}]${reset} \x1b[32mtool_result:${reset} ${name}${status} ${output}\n`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
case 'done': {
|
|
49
|
+
const reason = typeof d.reason === 'string' ? d.reason : 'completed';
|
|
50
|
+
process.stdout.write(`${dim}[${ts}]${reset} \x1b[34mdone:${reset} ${reason}\n`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
case 'error': {
|
|
54
|
+
const msg = typeof d.message === 'string' ? d.message : stringify(d, 120);
|
|
55
|
+
process.stdout.write(`${dim}[${ts}]${reset} ${bold}\x1b[31merror:${reset} ${msg}\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
default: {
|
|
59
|
+
const snippet = stringify(d, 120);
|
|
60
|
+
process.stdout.write(`${dim}[${ts}] ${event.type}: ${snippet}${reset}\n`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Completion summary printed after a streaming run (TTY only, non-JSON). */
|
|
66
|
+
export function printRunSummary(result: Record<string, unknown>): void {
|
|
67
|
+
const status = String(result.status ?? '');
|
|
68
|
+
const durationMs = Number(result.duration_ms ?? 0) / 1000;
|
|
69
|
+
const events = result.events_count;
|
|
70
|
+
|
|
71
|
+
if (status === 'completed') {
|
|
72
|
+
process.stdout.write(
|
|
73
|
+
`\n\x1b[1m\x1b[32m✓\x1b[0m run completed in ${durationMs.toFixed(1)}s (${events} events)\n`,
|
|
74
|
+
);
|
|
75
|
+
} else if (status === 'error') {
|
|
76
|
+
const err = String(result.error ?? '');
|
|
77
|
+
process.stdout.write(
|
|
78
|
+
`\n\x1b[1m\x1b[31m✗\x1b[0m run failed in ${durationMs.toFixed(1)}s: ${err}\n`,
|
|
79
|
+
);
|
|
80
|
+
} else if (status === 'cancelled') {
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
`\n\x1b[33m!\x1b[0m run cancelled after ${durationMs.toFixed(1)}s (${events} events)\n`,
|
|
83
|
+
);
|
|
84
|
+
} else if (status === 'disconnected') {
|
|
85
|
+
process.stdout.write(
|
|
86
|
+
`\n\x1b[2mdisconnected after ${durationMs.toFixed(1)}s (${events} events)\x1b[0m\n`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function shouldPrintSummary(streamOutput: boolean, jsonMode: boolean): boolean {
|
|
92
|
+
return streamOutput && !jsonMode && isTTY();
|
|
93
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { chatStream, type ChatEvent, type ContentBlockInput } from './chat-stream.js';
|
|
2
|
+
import { printChatEventHuman, printRunSummary, shouldPrintSummary } from './chat-output.js';
|
|
3
|
+
import { CliError, ExitCode } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export interface RunChatOptions {
|
|
6
|
+
endpoint: string;
|
|
7
|
+
token: string;
|
|
8
|
+
threadId: string;
|
|
9
|
+
content?: ContentBlockInput[];
|
|
10
|
+
lastEventId?: string | null;
|
|
11
|
+
model?: string | null;
|
|
12
|
+
timeoutSec?: number | null;
|
|
13
|
+
debug?: boolean;
|
|
14
|
+
streamOutput: boolean;
|
|
15
|
+
jsonMode: boolean;
|
|
16
|
+
eventFilter?: Set<string> | null;
|
|
17
|
+
/** Register SIGINT handler that aborts the stream. */
|
|
18
|
+
cancelOnInterrupt?: boolean;
|
|
19
|
+
/** Test hook: inject events instead of hitting HTTP. */
|
|
20
|
+
_eventSource?: AsyncIterable<ChatEvent> | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RunChatResult {
|
|
24
|
+
status: string;
|
|
25
|
+
thread_id: string;
|
|
26
|
+
duration_ms: number;
|
|
27
|
+
events_count: number;
|
|
28
|
+
last_event_id: string | null;
|
|
29
|
+
assistant_message?: string;
|
|
30
|
+
tool_calls?: Record<string, unknown>[];
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function emitJsonLine(ev: ChatEvent): void {
|
|
35
|
+
const line = { ts: ev.timestamp, event_id: ev.id, ...ev.data };
|
|
36
|
+
process.stdout.write(`${JSON.stringify(line)}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function emitEvent(
|
|
40
|
+
ev: ChatEvent,
|
|
41
|
+
streamOutput: boolean,
|
|
42
|
+
jsonMode: boolean,
|
|
43
|
+
filter: Set<string> | null | undefined,
|
|
44
|
+
): void {
|
|
45
|
+
if (!streamOutput) return;
|
|
46
|
+
if (filter && !filter.has(ev.type)) return;
|
|
47
|
+
if (jsonMode) {
|
|
48
|
+
emitJsonLine(ev);
|
|
49
|
+
} else {
|
|
50
|
+
printChatEventHuman(ev);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Drive a `POST /api/next/chat` SSE stream, accumulating state into a
|
|
56
|
+
* structured result. Mode matrix:
|
|
57
|
+
* - `streamOutput=true, content=[...]` → live streaming of a fresh run
|
|
58
|
+
* - `streamOutput=false, content=[...]` → quiet wait for completion
|
|
59
|
+
* - `content=undefined` → reconnect to an in-progress run (tail/wait)
|
|
60
|
+
*/
|
|
61
|
+
export async function runChat(opts: RunChatOptions): Promise<RunChatResult> {
|
|
62
|
+
const started = Date.now();
|
|
63
|
+
let eventsCount = 0;
|
|
64
|
+
let lastEventId: string | null = null;
|
|
65
|
+
const assistantParts: string[] = [];
|
|
66
|
+
const toolCalls: Record<string, unknown>[] = [];
|
|
67
|
+
let finalStatus = 'unknown';
|
|
68
|
+
let errorMessage: string | null = null;
|
|
69
|
+
|
|
70
|
+
const useBuiltin = opts._eventSource == null;
|
|
71
|
+
const ac = useBuiltin ? new AbortController() : null;
|
|
72
|
+
const onSigint = (): void => {
|
|
73
|
+
ac?.abort();
|
|
74
|
+
};
|
|
75
|
+
if (useBuiltin && opts.cancelOnInterrupt) {
|
|
76
|
+
process.once('SIGINT', onSigint);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const stream: AsyncIterable<ChatEvent> =
|
|
80
|
+
opts._eventSource ??
|
|
81
|
+
chatStream({
|
|
82
|
+
endpoint: opts.endpoint,
|
|
83
|
+
token: opts.token,
|
|
84
|
+
threadId: opts.threadId,
|
|
85
|
+
content: opts.content,
|
|
86
|
+
lastEventId: opts.lastEventId ?? null,
|
|
87
|
+
model: opts.model ?? null,
|
|
88
|
+
timeoutSec: opts.timeoutSec ?? null,
|
|
89
|
+
debug: opts.debug,
|
|
90
|
+
signal: ac?.signal,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
for await (const ev of stream) {
|
|
95
|
+
eventsCount += 1;
|
|
96
|
+
if (ev.id) lastEventId = ev.id;
|
|
97
|
+
emitEvent(ev, opts.streamOutput, opts.jsonMode, opts.eventFilter);
|
|
98
|
+
|
|
99
|
+
const d = ev.data;
|
|
100
|
+
if (ev.type === 'text') {
|
|
101
|
+
const c = typeof d.content === 'string' ? d.content : '';
|
|
102
|
+
if (c) assistantParts.push(c);
|
|
103
|
+
} else if (ev.type === 'tool_call' || ev.type === 'tool_result') {
|
|
104
|
+
toolCalls.push(d);
|
|
105
|
+
} else if (ev.type === 'done') {
|
|
106
|
+
finalStatus = typeof d.reason === 'string' ? (d.reason as string) : 'completed';
|
|
107
|
+
break;
|
|
108
|
+
} else if (ev.type === 'error') {
|
|
109
|
+
finalStatus = 'error';
|
|
110
|
+
errorMessage = typeof d.message === 'string' ? (d.message as string) : JSON.stringify(d);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (finalStatus === 'unknown') finalStatus = 'disconnected';
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e !== null && typeof e === 'object' && (e as Error).name === 'AbortError') {
|
|
116
|
+
finalStatus = 'cancelled';
|
|
117
|
+
if (!opts.jsonMode) {
|
|
118
|
+
process.stderr.write('\n\x1b[2mDisconnected (run continues in background)\x1b[0m\n');
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
if (useBuiltin && opts.cancelOnInterrupt) {
|
|
125
|
+
process.off('SIGINT', onSigint);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const result: RunChatResult = {
|
|
130
|
+
status: finalStatus,
|
|
131
|
+
thread_id: opts.threadId,
|
|
132
|
+
duration_ms: Date.now() - started,
|
|
133
|
+
events_count: eventsCount,
|
|
134
|
+
last_event_id: lastEventId,
|
|
135
|
+
};
|
|
136
|
+
if (assistantParts.length > 0) result.assistant_message = assistantParts.join('');
|
|
137
|
+
if (toolCalls.length > 0) result.tool_calls = toolCalls;
|
|
138
|
+
if (errorMessage) result.error = errorMessage;
|
|
139
|
+
|
|
140
|
+
if (shouldPrintSummary(opts.streamOutput, opts.jsonMode)) {
|
|
141
|
+
printRunSummary(result as unknown as Record<string, unknown>);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (finalStatus === 'error') {
|
|
145
|
+
throw new CliError(
|
|
146
|
+
`Run failed: ${errorMessage ?? 'unknown error'}`,
|
|
147
|
+
ExitCode.BUSINESS_ERROR,
|
|
148
|
+
result,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (finalStatus === 'timeout') {
|
|
152
|
+
throw new CliError('Run timeout', ExitCode.TIMEOUT, result);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { CliError, ExitCode, HttpError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/** One event from the `/api/next/chat` SSE stream. */
|
|
4
|
+
export interface ChatEvent {
|
|
5
|
+
/** SSE `id:` line (stable, monotonic `<ts>-<seq>`). `null` if absent. */
|
|
6
|
+
id: string | null;
|
|
7
|
+
/** Event discriminator from `data.type` (text/tool_call/tool_result/done/error/...). */
|
|
8
|
+
type: string;
|
|
9
|
+
/** Parsed JSON payload. */
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
/** Unix seconds, client-side wall clock at parse time. */
|
|
12
|
+
timestamp: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TextContentInput {
|
|
16
|
+
type: 'text';
|
|
17
|
+
text: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ImageContentInput {
|
|
21
|
+
type: 'image_url';
|
|
22
|
+
image_url: { url: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ContentBlockInput = TextContentInput | ImageContentInput;
|
|
26
|
+
|
|
27
|
+
export interface ChatStreamOptions {
|
|
28
|
+
endpoint: string;
|
|
29
|
+
token: string;
|
|
30
|
+
threadId: string;
|
|
31
|
+
/** Send mode: new prompt. Reconnect mode: omit. */
|
|
32
|
+
content?: ContentBlockInput[];
|
|
33
|
+
/** Reconnect resume point. Overridden to `null` in send mode. */
|
|
34
|
+
lastEventId?: string | null;
|
|
35
|
+
model?: string | null;
|
|
36
|
+
/** Wall-clock timeout in seconds. `0` / `null` / `undefined` = no limit. */
|
|
37
|
+
timeoutSec?: number | null;
|
|
38
|
+
debug?: boolean;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
/** Test hook: inject a pre-read body instead of calling fetch. */
|
|
41
|
+
_bodyOverride?: ReadableStream<Uint8Array> | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeBase(endpoint: string): string {
|
|
45
|
+
return endpoint.replace(/\/$/, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function logDebug(debug: boolean | undefined, msg: string): void {
|
|
49
|
+
if (debug) {
|
|
50
|
+
process.stderr.write(`[debug] ${msg}\n`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a single SSE frame (text between two `\n\n` separators).
|
|
56
|
+
* Returns `null` for heartbeats (`:comment`) and empty frames.
|
|
57
|
+
*/
|
|
58
|
+
export function parseChatFrame(block: string): ChatEvent | null {
|
|
59
|
+
const lines = block.split('\n');
|
|
60
|
+
let eventId: string | null = null;
|
|
61
|
+
const dataLines: string[] = [];
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
if (line === '' || line.startsWith(':')) continue;
|
|
64
|
+
if (line.startsWith('id:')) {
|
|
65
|
+
eventId = line.slice(3).trimStart();
|
|
66
|
+
} else if (line.startsWith('data:')) {
|
|
67
|
+
dataLines.push(line.slice(5).trimStart());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (dataLines.length === 0) return null;
|
|
71
|
+
|
|
72
|
+
const rawData = dataLines.join('\n');
|
|
73
|
+
let parsed: Record<string, unknown>;
|
|
74
|
+
try {
|
|
75
|
+
const obj = JSON.parse(rawData) as unknown;
|
|
76
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
77
|
+
parsed = { raw: rawData };
|
|
78
|
+
} else {
|
|
79
|
+
parsed = obj as Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
parsed = { raw: rawData };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const type = typeof parsed.type === 'string' ? (parsed.type as string) : 'unknown';
|
|
86
|
+
return {
|
|
87
|
+
id: eventId,
|
|
88
|
+
type,
|
|
89
|
+
data: parsed,
|
|
90
|
+
timestamp: Date.now() / 1000,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function openChatStream(opts: ChatStreamOptions): Promise<ReadableStream<Uint8Array>> {
|
|
95
|
+
if (opts._bodyOverride) return opts._bodyOverride;
|
|
96
|
+
|
|
97
|
+
const url = `${normalizeBase(opts.endpoint)}/api/next/chat`;
|
|
98
|
+
const body: Record<string, unknown> = {
|
|
99
|
+
thread_id: opts.threadId,
|
|
100
|
+
};
|
|
101
|
+
if (opts.content !== undefined) body.content = opts.content;
|
|
102
|
+
if (opts.model != null) body.model = opts.model;
|
|
103
|
+
if (opts.lastEventId != null) body.last_event_id = opts.lastEventId;
|
|
104
|
+
|
|
105
|
+
logDebug(opts.debug, `POST ${url}`);
|
|
106
|
+
logDebug(opts.debug, `body: ${JSON.stringify(body)}`);
|
|
107
|
+
|
|
108
|
+
const res = await fetch(url, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
Authorization: `Bearer ${opts.token}`,
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
Accept: 'text/event-stream',
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify(body),
|
|
116
|
+
signal: opts.signal,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
logDebug(opts.debug, `→ ${res.status} ${res.headers.get('content-type') ?? ''}`);
|
|
120
|
+
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
let detail: unknown;
|
|
123
|
+
let message = `HTTP ${res.status}`;
|
|
124
|
+
try {
|
|
125
|
+
const j = (await res.json()) as Record<string, unknown>;
|
|
126
|
+
detail = j.detail ?? j.error;
|
|
127
|
+
if (typeof j.message === 'string') message = j.message;
|
|
128
|
+
} catch {
|
|
129
|
+
/* ignore */
|
|
130
|
+
}
|
|
131
|
+
throw new HttpError(res.status, message, detail);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!res.body) {
|
|
135
|
+
throw new CliError('Chat response has no body', ExitCode.HTTP_ERROR);
|
|
136
|
+
}
|
|
137
|
+
return res.body;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Stream agent events from `POST /api/next/chat`.
|
|
142
|
+
*
|
|
143
|
+
* **Send mode**: pass `content`; starts a new agent run from the user prompt.
|
|
144
|
+
* **Reconnect mode**: omit `content`, optionally pass `lastEventId`; resumes
|
|
145
|
+
* an in-progress run's event stream.
|
|
146
|
+
*
|
|
147
|
+
* Yields every event until the server closes the stream (normally right after
|
|
148
|
+
* a `done` event). Raises `CliError(TIMEOUT)` if `timeoutSec` elapses before
|
|
149
|
+
* the stream ends. Aborts cleanly on `signal` (Ctrl-C); the caller gets the
|
|
150
|
+
* native `AbortError` and should map it to exit code 130.
|
|
151
|
+
*/
|
|
152
|
+
export async function* chatStream(
|
|
153
|
+
opts: ChatStreamOptions
|
|
154
|
+
): AsyncGenerator<ChatEvent, void, void> {
|
|
155
|
+
const started = Date.now();
|
|
156
|
+
const timeoutMs =
|
|
157
|
+
opts.timeoutSec == null || opts.timeoutSec === 0 ? null : opts.timeoutSec * 1000;
|
|
158
|
+
|
|
159
|
+
const body = await openChatStream(opts);
|
|
160
|
+
const reader = body.getReader();
|
|
161
|
+
const decoder = new TextDecoder();
|
|
162
|
+
let buffer = '';
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
for (;;) {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) return;
|
|
168
|
+
buffer += decoder.decode(value, { stream: true });
|
|
169
|
+
let sep: number;
|
|
170
|
+
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
|
171
|
+
const block = buffer.slice(0, sep);
|
|
172
|
+
buffer = buffer.slice(sep + 2);
|
|
173
|
+
const ev = parseChatFrame(block);
|
|
174
|
+
if (ev === null) continue;
|
|
175
|
+
yield ev;
|
|
176
|
+
if (timeoutMs !== null && Date.now() - started > timeoutMs) {
|
|
177
|
+
throw new CliError('Chat stream timeout', ExitCode.TIMEOUT);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
try {
|
|
183
|
+
reader.releaseLock();
|
|
184
|
+
} catch {
|
|
185
|
+
/* ignore */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/core/client.ts
CHANGED
|
@@ -71,8 +71,15 @@ export class ApiClient {
|
|
|
71
71
|
'Content-Type': 'application/json',
|
|
72
72
|
},
|
|
73
73
|
retry: 0,
|
|
74
|
-
onRequest({ request }) {
|
|
75
|
-
|
|
74
|
+
onRequest({ request, options }) {
|
|
75
|
+
const method = (options.method ?? 'GET').toString().toUpperCase();
|
|
76
|
+
const url =
|
|
77
|
+
typeof request === 'string'
|
|
78
|
+
? request
|
|
79
|
+
: request instanceof URL
|
|
80
|
+
? request.toString()
|
|
81
|
+
: ((request as Request)?.url ?? '');
|
|
82
|
+
logDebug(dbg, `${method} ${url}`);
|
|
76
83
|
},
|
|
77
84
|
onResponse({ response }) {
|
|
78
85
|
logDebug(
|
package/src/core/output.ts
CHANGED
|
@@ -52,8 +52,21 @@ function replacer(_key: string, value: unknown): unknown {
|
|
|
52
52
|
return value;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function formatScalar(value: unknown): string {
|
|
56
|
+
if (value === null) return 'null';
|
|
57
|
+
if (value === undefined) return '';
|
|
58
|
+
if (typeof value === 'object') {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.stringify(value, replacer);
|
|
61
|
+
} catch {
|
|
62
|
+
return String(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return String(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
function printDictHuman(d: Record<string, unknown>): void {
|
|
56
69
|
for (const [key, value] of Object.entries(d)) {
|
|
57
|
-
process.stdout.write(`${key}: ${
|
|
70
|
+
process.stdout.write(`${key}: ${formatScalar(value)}\n`);
|
|
58
71
|
}
|
|
59
72
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { registerApi } from './commands/api-auto.js';
|
|
|
7
7
|
import { registerAuth } from './commands/auth.js';
|
|
8
8
|
import { registerDev } from './commands/dev.js';
|
|
9
9
|
import { registerFiles } from './commands/files.js';
|
|
10
|
-
import { registerMessages } from './commands/messages.js';
|
|
11
10
|
import { registerProject } from './commands/project.js';
|
|
12
11
|
import { registerThread } from './commands/thread.js';
|
|
13
12
|
import { runVersion } from './commands/version.js';
|
|
@@ -28,7 +27,7 @@ program
|
|
|
28
27
|
.description('Mitsein CLI — internal dev tool for agent verification and workflow automation.')
|
|
29
28
|
.option('--endpoint <url>', 'API endpoint URL')
|
|
30
29
|
.option('--token <token>', 'Bearer token')
|
|
31
|
-
.option('--profile <name>', 'Profile name', '
|
|
30
|
+
.option('--profile <name>', 'Profile name', 'default')
|
|
32
31
|
.option('--real', 'Use real account for dev token', false)
|
|
33
32
|
.option('--json', 'Output structured JSON', false)
|
|
34
33
|
.option('--debug', 'Print HTTP request/response details', false)
|
|
@@ -49,7 +48,6 @@ program
|
|
|
49
48
|
registerDev(program);
|
|
50
49
|
registerApi(program);
|
|
51
50
|
registerThread(program);
|
|
52
|
-
registerMessages(program);
|
|
53
51
|
registerAgent(program);
|
|
54
52
|
registerProject(program);
|
|
55
53
|
registerFiles(program);
|
package/src/commands/messages.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import type { Command } from 'commander';
|
|
2
|
-
import { ApiClient } from '../core/client.js';
|
|
3
|
-
import { emit, setJsonMode } from '../core/output.js';
|
|
4
|
-
import { handleErrors } from '../core/errors.js';
|
|
5
|
-
import { httpTimeoutSec, readGlobals } from './command-opts.js';
|
|
6
|
-
|
|
7
|
-
export function registerMessages(program: Command): void {
|
|
8
|
-
const messages = program.command('messages').description('Message list and send (no agent)');
|
|
9
|
-
|
|
10
|
-
messages
|
|
11
|
-
.command('list <thread_id>')
|
|
12
|
-
.description('List messages for a thread')
|
|
13
|
-
.option('--offset <n>', 'Pagination offset', '0')
|
|
14
|
-
.option('--limit <n>', 'Max results', '20')
|
|
15
|
-
.action(
|
|
16
|
-
handleErrors(async function messagesListAction(this: Command, threadId: string) {
|
|
17
|
-
const g = readGlobals(this);
|
|
18
|
-
const opts = this.opts() as { offset?: string; limit?: string };
|
|
19
|
-
setJsonMode(Boolean(g.json));
|
|
20
|
-
const client = ApiClient.fromOptions({
|
|
21
|
-
token: g.token,
|
|
22
|
-
endpoint: g.endpoint,
|
|
23
|
-
profile: g.profile ?? 'e2e',
|
|
24
|
-
real: g.real,
|
|
25
|
-
timeoutSec: httpTimeoutSec(g),
|
|
26
|
-
debug: g.debug,
|
|
27
|
-
});
|
|
28
|
-
const offset = Number.parseInt(String(opts.offset ?? '0'), 10);
|
|
29
|
-
const limit = Number.parseInt(String(opts.limit ?? '20'), 10);
|
|
30
|
-
const result = await client.post('/api/message/list', {
|
|
31
|
-
thread_id: threadId,
|
|
32
|
-
limit: Number.isFinite(limit) ? limit : 20,
|
|
33
|
-
offset: Number.isFinite(offset) ? offset : 0,
|
|
34
|
-
});
|
|
35
|
-
emit(result);
|
|
36
|
-
})
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
messages
|
|
40
|
-
.command('send <thread_id> <content>')
|
|
41
|
-
.description('Add a user message (does not start the agent)')
|
|
42
|
-
.action(
|
|
43
|
-
handleErrors(async function messagesSendAction(this: Command, threadId: string, content: string) {
|
|
44
|
-
const g = readGlobals(this);
|
|
45
|
-
setJsonMode(Boolean(g.json));
|
|
46
|
-
const client = ApiClient.fromOptions({
|
|
47
|
-
token: g.token,
|
|
48
|
-
endpoint: g.endpoint,
|
|
49
|
-
profile: g.profile ?? 'e2e',
|
|
50
|
-
real: g.real,
|
|
51
|
-
timeoutSec: httpTimeoutSec(g),
|
|
52
|
-
debug: g.debug,
|
|
53
|
-
});
|
|
54
|
-
const result = await client.post('/api/message/add', {
|
|
55
|
-
thread_id: threadId,
|
|
56
|
-
content,
|
|
57
|
-
});
|
|
58
|
-
emit(result);
|
|
59
|
-
})
|
|
60
|
-
);
|
|
61
|
-
}
|
package/src/core/sse.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import { CliError, ExitCode } from './errors.js';
|
|
2
|
-
|
|
3
|
-
export interface SseEvent {
|
|
4
|
-
event_type: string;
|
|
5
|
-
data: Record<string, unknown> | string;
|
|
6
|
-
raw: string;
|
|
7
|
-
timestamp: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function normalizeBase(endpoint: string): string {
|
|
11
|
-
return endpoint.replace(/\/$/, '');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Parse one SSE block (`\n\n` delimited). Returns `null` for heartbeats / empty. */
|
|
15
|
-
export function parseSseEvent(text: string, debug = false): SseEvent | null {
|
|
16
|
-
const lines = text.trim().split('\n');
|
|
17
|
-
const dataLines: string[] = [];
|
|
18
|
-
|
|
19
|
-
for (const line of lines) {
|
|
20
|
-
if (line.startsWith(':')) {
|
|
21
|
-
if (debug) {
|
|
22
|
-
process.stderr.write(`[debug] SSE comment: ${line}\n`);
|
|
23
|
-
}
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
if (line.startsWith('data: ')) {
|
|
27
|
-
dataLines.push(line.slice(6));
|
|
28
|
-
} else if (line.startsWith('data:')) {
|
|
29
|
-
dataLines.push(line.slice(5));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (dataLines.length === 0) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const rawData = dataLines.join('\n');
|
|
38
|
-
const timestamp = Date.now() / 1000;
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
const parsed = JSON.parse(rawData) as unknown;
|
|
42
|
-
const event_type =
|
|
43
|
-
typeof parsed === 'object' &&
|
|
44
|
-
parsed !== null &&
|
|
45
|
-
'type' in parsed &&
|
|
46
|
-
typeof (parsed as { type: unknown }).type === 'string'
|
|
47
|
-
? (parsed as { type: string }).type
|
|
48
|
-
: 'message';
|
|
49
|
-
return {
|
|
50
|
-
event_type,
|
|
51
|
-
data: parsed as Record<string, unknown>,
|
|
52
|
-
raw: rawData,
|
|
53
|
-
timestamp,
|
|
54
|
-
};
|
|
55
|
-
} catch {
|
|
56
|
-
return {
|
|
57
|
-
event_type: 'unknown',
|
|
58
|
-
data: rawData,
|
|
59
|
-
raw: rawData,
|
|
60
|
-
timestamp,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface StreamSseOptions {
|
|
66
|
-
endpoint: string;
|
|
67
|
-
path: string;
|
|
68
|
-
token: string;
|
|
69
|
-
timeoutSec?: number | null;
|
|
70
|
-
debug?: boolean;
|
|
71
|
-
signal?: AbortSignal | undefined;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** SSE client using `fetch` + `ReadableStream`. */
|
|
75
|
-
export async function* streamSse(options: StreamSseOptions): AsyncGenerator<SseEvent> {
|
|
76
|
-
const url = `${normalizeBase(options.endpoint)}${options.path.startsWith('/') ? options.path : `/${options.path}`}`;
|
|
77
|
-
if (options.debug) {
|
|
78
|
-
process.stderr.write(`[debug] SSE connecting to ${url}\n`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const start = Date.now();
|
|
82
|
-
const timeoutMs =
|
|
83
|
-
options.timeoutSec === undefined || options.timeoutSec === null || options.timeoutSec === 0
|
|
84
|
-
? null
|
|
85
|
-
: options.timeoutSec * 1000;
|
|
86
|
-
|
|
87
|
-
const res = await fetch(url, {
|
|
88
|
-
headers: {
|
|
89
|
-
Authorization: `Bearer ${options.token}`,
|
|
90
|
-
Accept: 'text/event-stream',
|
|
91
|
-
},
|
|
92
|
-
signal: options.signal,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
if (!res.ok) {
|
|
96
|
-
throw new CliError(`SSE connection failed: HTTP ${res.status}`, ExitCode.HTTP_ERROR);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (options.debug) {
|
|
100
|
-
process.stderr.write(`[debug] SSE connected (${res.status})\n`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const body = res.body;
|
|
104
|
-
if (!body) {
|
|
105
|
-
throw new CliError('SSE response has no body', ExitCode.HTTP_ERROR);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const reader = body.getReader();
|
|
109
|
-
const decoder = new TextDecoder();
|
|
110
|
-
let buffer = '';
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
for (;;) {
|
|
114
|
-
const { done, value } = await reader.read();
|
|
115
|
-
if (done) {
|
|
116
|
-
break;
|
|
117
|
-
}
|
|
118
|
-
buffer += decoder.decode(value, { stream: true });
|
|
119
|
-
let sep: number;
|
|
120
|
-
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
|
121
|
-
const block = buffer.slice(0, sep);
|
|
122
|
-
buffer = buffer.slice(sep + 2);
|
|
123
|
-
const ev = parseSseEvent(block, options.debug ?? false);
|
|
124
|
-
if (ev !== null) {
|
|
125
|
-
yield ev;
|
|
126
|
-
if (timeoutMs !== null && Date.now() - start > timeoutMs) {
|
|
127
|
-
throw new CliError('Stream timeout', ExitCode.TIMEOUT);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
} finally {
|
|
133
|
-
reader.releaseLock();
|
|
134
|
-
}
|
|
135
|
-
}
|