neeter 0.6.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/LICENSE +21 -0
- package/README.md +361 -0
- package/dist/react/AgentProvider.d.ts +15 -0
- package/dist/react/AgentProvider.js +30 -0
- package/dist/react/ApprovalButtons.d.ts +5 -0
- package/dist/react/ApprovalButtons.js +30 -0
- package/dist/react/ChatInput.d.ts +6 -0
- package/dist/react/ChatInput.js +41 -0
- package/dist/react/CollapsibleCard.d.ts +9 -0
- package/dist/react/CollapsibleCard.js +9 -0
- package/dist/react/MessageList.d.ts +3 -0
- package/dist/react/MessageList.js +32 -0
- package/dist/react/PendingPermissions.d.ts +3 -0
- package/dist/react/PendingPermissions.js +35 -0
- package/dist/react/StatusDot.d.ts +8 -0
- package/dist/react/StatusDot.js +15 -0
- package/dist/react/TextMessage.d.ts +5 -0
- package/dist/react/TextMessage.js +8 -0
- package/dist/react/ThinkingBlock.d.ts +5 -0
- package/dist/react/ThinkingBlock.js +38 -0
- package/dist/react/ThinkingIndicator.d.ts +3 -0
- package/dist/react/ThinkingIndicator.js +5 -0
- package/dist/react/ToolApprovalCard.d.ts +7 -0
- package/dist/react/ToolApprovalCard.js +11 -0
- package/dist/react/ToolCallCard.d.ts +5 -0
- package/dist/react/ToolCallCard.js +59 -0
- package/dist/react/UserQuestionCard.d.ts +6 -0
- package/dist/react/UserQuestionCard.js +120 -0
- package/dist/react/approval-matching.d.ts +13 -0
- package/dist/react/approval-matching.js +30 -0
- package/dist/react/cn.d.ts +2 -0
- package/dist/react/cn.js +5 -0
- package/dist/react/icons.d.ts +7 -0
- package/dist/react/icons.js +8 -0
- package/dist/react/index.d.ts +28 -0
- package/dist/react/index.js +28 -0
- package/dist/react/markdown-overrides.d.ts +2 -0
- package/dist/react/markdown-overrides.js +8 -0
- package/dist/react/registry.d.ts +4 -0
- package/dist/react/registry.js +10 -0
- package/dist/react/store.d.ts +34 -0
- package/dist/react/store.js +141 -0
- package/dist/react/use-agent.d.ts +12 -0
- package/dist/react/use-agent.js +119 -0
- package/dist/react/widgets/AskUserQuestionWidget.d.ts +1 -0
- package/dist/react/widgets/AskUserQuestionWidget.js +42 -0
- package/dist/react/widgets/BashWidget.d.ts +1 -0
- package/dist/react/widgets/BashWidget.js +33 -0
- package/dist/react/widgets/EditWidget.d.ts +1 -0
- package/dist/react/widgets/EditWidget.js +36 -0
- package/dist/react/widgets/GlobWidget.d.ts +1 -0
- package/dist/react/widgets/GlobWidget.js +31 -0
- package/dist/react/widgets/GrepWidget.d.ts +1 -0
- package/dist/react/widgets/GrepWidget.js +36 -0
- package/dist/react/widgets/NotebookEditWidget.d.ts +1 -0
- package/dist/react/widgets/NotebookEditWidget.js +47 -0
- package/dist/react/widgets/ReadWidget.d.ts +1 -0
- package/dist/react/widgets/ReadWidget.js +46 -0
- package/dist/react/widgets/TodoWriteWidget.d.ts +1 -0
- package/dist/react/widgets/TodoWriteWidget.js +40 -0
- package/dist/react/widgets/WebFetchWidget.d.ts +1 -0
- package/dist/react/widgets/WebFetchWidget.js +48 -0
- package/dist/react/widgets/WebSearchWidget.d.ts +1 -0
- package/dist/react/widgets/WebSearchWidget.js +85 -0
- package/dist/react/widgets/WriteWidget.d.ts +1 -0
- package/dist/react/widgets/WriteWidget.js +30 -0
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.js +5 -0
- package/dist/server/permission-gate.d.ts +12 -0
- package/dist/server/permission-gate.js +41 -0
- package/dist/server/push-channel.d.ts +8 -0
- package/dist/server/push-channel.js +40 -0
- package/dist/server/router.d.ts +8 -0
- package/dist/server/router.js +67 -0
- package/dist/server/session.d.ts +40 -0
- package/dist/server/session.js +117 -0
- package/dist/server/translator.d.ts +15 -0
- package/dist/server/translator.js +236 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.js +1 -0
- package/package.json +72 -0
- package/src/theme.css +170 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { registerWidget } from "../registry.js";
|
|
3
|
+
function basename(filePath) {
|
|
4
|
+
return filePath.split("/").pop() ?? filePath;
|
|
5
|
+
}
|
|
6
|
+
function WriteInputRenderer({ input }) {
|
|
7
|
+
const filePath = typeof input.file_path === "string" ? input.file_path : null;
|
|
8
|
+
const content = typeof input.content === "string" ? input.content : null;
|
|
9
|
+
if (!filePath)
|
|
10
|
+
return null;
|
|
11
|
+
return (_jsxs("div", { className: "mt-1.5 space-y-1", children: [_jsx("div", { className: "text-xs text-muted-foreground font-mono truncate", children: filePath }), content && (_jsx("pre", { className: "text-[11px] leading-snug text-muted-foreground bg-accent rounded px-2 py-1 overflow-x-auto whitespace-pre-wrap break-all max-h-[120px] overflow-y-auto", children: _jsx("code", { children: content }) }))] }));
|
|
12
|
+
}
|
|
13
|
+
function WriteWidget({ input, phase }) {
|
|
14
|
+
const filePath = typeof input.file_path === "string" ? input.file_path : null;
|
|
15
|
+
if (phase === "running" || phase === "pending") {
|
|
16
|
+
return (_jsx("div", { className: "flex items-center gap-2 text-xs text-muted-foreground py-1", children: _jsxs("span", { className: "animate-pulse", children: ["Writing ", filePath ? basename(filePath) : "file", "\u2026"] }) }));
|
|
17
|
+
}
|
|
18
|
+
const content = typeof input.content === "string" ? input.content : null;
|
|
19
|
+
return (_jsx("div", { className: "py-1 space-y-1.5", children: content && (_jsx("pre", { className: "text-[11px] leading-snug text-foreground bg-accent rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all max-h-[200px] overflow-y-auto", children: _jsx("code", { children: content }) })) }));
|
|
20
|
+
}
|
|
21
|
+
registerWidget({
|
|
22
|
+
toolName: "Write",
|
|
23
|
+
label: "Write",
|
|
24
|
+
richLabel: (_r, input) => {
|
|
25
|
+
const filePath = typeof input.file_path === "string" ? input.file_path : null;
|
|
26
|
+
return filePath ? basename(filePath) : null;
|
|
27
|
+
},
|
|
28
|
+
inputRenderer: WriteInputRenderer,
|
|
29
|
+
component: WriteWidget,
|
|
30
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { CustomEvent, SSEEvent } from "../types.js";
|
|
2
|
+
export { PermissionGate } from "./permission-gate.js";
|
|
3
|
+
export { PushChannel } from "./push-channel.js";
|
|
4
|
+
export { createAgentRouter } from "./router.js";
|
|
5
|
+
export { type Session, type SessionInit, SessionManager } from "./session.js";
|
|
6
|
+
export { MessageTranslator, sseEncode, streamSession, type TranslatorConfig, } from "./translator.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PermissionGate } from "./permission-gate.js";
|
|
2
|
+
export { PushChannel } from "./push-channel.js";
|
|
3
|
+
export { createAgentRouter } from "./router.js";
|
|
4
|
+
export { SessionManager } from "./session.js";
|
|
5
|
+
export { MessageTranslator, sseEncode, streamSession, } from "./translator.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PermissionRequest, PermissionResponse } from "../types.js";
|
|
2
|
+
type RequestListener = (request: PermissionRequest) => void;
|
|
3
|
+
export declare class PermissionGate {
|
|
4
|
+
private pending;
|
|
5
|
+
private listeners;
|
|
6
|
+
request(permissionRequest: PermissionRequest): Promise<PermissionResponse>;
|
|
7
|
+
respond(response: PermissionResponse): boolean;
|
|
8
|
+
onRequest(listener: RequestListener): () => void;
|
|
9
|
+
getPending(): PermissionRequest[];
|
|
10
|
+
cancelAll(message: string): void;
|
|
11
|
+
}
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class PermissionGate {
|
|
2
|
+
pending = new Map();
|
|
3
|
+
listeners = new Set();
|
|
4
|
+
request(permissionRequest) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
this.pending.set(permissionRequest.requestId, {
|
|
7
|
+
request: permissionRequest,
|
|
8
|
+
resolve,
|
|
9
|
+
});
|
|
10
|
+
for (const listener of this.listeners) {
|
|
11
|
+
listener(permissionRequest);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
respond(response) {
|
|
16
|
+
const entry = this.pending.get(response.requestId);
|
|
17
|
+
if (!entry)
|
|
18
|
+
return false;
|
|
19
|
+
this.pending.delete(response.requestId);
|
|
20
|
+
entry.resolve(response);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
onRequest(listener) {
|
|
24
|
+
this.listeners.add(listener);
|
|
25
|
+
return () => this.listeners.delete(listener);
|
|
26
|
+
}
|
|
27
|
+
getPending() {
|
|
28
|
+
return [...this.pending.values()].map((p) => p.request);
|
|
29
|
+
}
|
|
30
|
+
cancelAll(message) {
|
|
31
|
+
for (const [id, entry] of this.pending) {
|
|
32
|
+
if (entry.request.kind === "tool_approval") {
|
|
33
|
+
entry.resolve({ kind: "tool_approval", requestId: id, behavior: "deny", message });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
entry.resolve({ kind: "user_question", requestId: id, answers: {} });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
this.pending.clear();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export class PushChannel {
|
|
2
|
+
queue = [];
|
|
3
|
+
resolve = null;
|
|
4
|
+
done = false;
|
|
5
|
+
push(value) {
|
|
6
|
+
if (this.done)
|
|
7
|
+
return;
|
|
8
|
+
if (this.resolve) {
|
|
9
|
+
const r = this.resolve;
|
|
10
|
+
this.resolve = null;
|
|
11
|
+
r({ value, done: false });
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
this.queue.push(value);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
close() {
|
|
18
|
+
this.done = true;
|
|
19
|
+
if (this.resolve) {
|
|
20
|
+
const r = this.resolve;
|
|
21
|
+
this.resolve = null;
|
|
22
|
+
r({ value: undefined, done: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
[Symbol.asyncIterator]() {
|
|
26
|
+
return {
|
|
27
|
+
next: () => {
|
|
28
|
+
if (this.queue.length > 0) {
|
|
29
|
+
return Promise.resolve({ value: this.queue.shift(), done: false });
|
|
30
|
+
}
|
|
31
|
+
if (this.done) {
|
|
32
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
33
|
+
}
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
this.resolve = resolve;
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { SessionManager } from "./session.js";
|
|
3
|
+
import { type MessageTranslator } from "./translator.js";
|
|
4
|
+
export declare function createAgentRouter<TCtx>(config: {
|
|
5
|
+
sessions: SessionManager<TCtx>;
|
|
6
|
+
translator: MessageTranslator<TCtx>;
|
|
7
|
+
basePath?: string;
|
|
8
|
+
}): Hono;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { sseEncode, streamSession } from "./translator.js";
|
|
4
|
+
export function createAgentRouter(config) {
|
|
5
|
+
const { sessions, translator, basePath = "/api" } = config;
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
app.use(`${basePath}/*`, cors({ origin: "*" }));
|
|
8
|
+
app.post(`${basePath}/sessions`, (c) => {
|
|
9
|
+
const session = sessions.create();
|
|
10
|
+
return c.json({ sessionId: session.id });
|
|
11
|
+
});
|
|
12
|
+
app.post(`${basePath}/sessions/:id/messages`, async (c) => {
|
|
13
|
+
const session = sessions.get(c.req.param("id"));
|
|
14
|
+
if (!session)
|
|
15
|
+
return c.json({ error: "Session not found" }, 404);
|
|
16
|
+
const body = await c.req.json();
|
|
17
|
+
if (!body.text?.trim())
|
|
18
|
+
return c.json({ error: "Message text required" }, 400);
|
|
19
|
+
session.pushMessage(body.text.trim());
|
|
20
|
+
return c.json({ ok: true });
|
|
21
|
+
});
|
|
22
|
+
app.get(`${basePath}/sessions/:id/events`, (c) => {
|
|
23
|
+
const session = sessions.get(c.req.param("id"));
|
|
24
|
+
if (!session)
|
|
25
|
+
return c.json({ error: "Session not found" }, 404);
|
|
26
|
+
const stream = new ReadableStream({
|
|
27
|
+
async start(controller) {
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
try {
|
|
30
|
+
for await (const evt of streamSession(session, translator)) {
|
|
31
|
+
controller.enqueue(encoder.encode(sseEncode(evt)));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
36
|
+
controller.enqueue(encoder.encode(sseEncode({ event: "error", data: JSON.stringify({ message }) })));
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
controller.close();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return new Response(stream, {
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "text/event-stream",
|
|
46
|
+
"Cache-Control": "no-cache",
|
|
47
|
+
Connection: "keep-alive",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
app.post(`${basePath}/sessions/:id/permissions`, async (c) => {
|
|
52
|
+
const session = sessions.get(c.req.param("id"));
|
|
53
|
+
if (!session)
|
|
54
|
+
return c.json({ error: "Session not found" }, 404);
|
|
55
|
+
const body = await c.req.json();
|
|
56
|
+
if (!body.requestId || !body.kind) {
|
|
57
|
+
return c.json({ error: "Invalid permission response" }, 400);
|
|
58
|
+
}
|
|
59
|
+
const resolved = session.permissionGate.respond(body);
|
|
60
|
+
if (!resolved) {
|
|
61
|
+
return c.json({ error: "No pending request with this ID" }, 404);
|
|
62
|
+
}
|
|
63
|
+
session.lastActivityAt = Date.now();
|
|
64
|
+
return c.json({ ok: true });
|
|
65
|
+
});
|
|
66
|
+
return app;
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { PermissionGate } from "./permission-gate.js";
|
|
3
|
+
type SDKMessage = ReturnType<typeof query> extends AsyncGenerator<infer T> ? T : never;
|
|
4
|
+
export interface SessionInit<TCtx> {
|
|
5
|
+
context: TCtx;
|
|
6
|
+
model: string;
|
|
7
|
+
systemPrompt: string;
|
|
8
|
+
mcpServers?: Record<string, unknown>;
|
|
9
|
+
tools?: unknown[];
|
|
10
|
+
allowedTools?: string[];
|
|
11
|
+
maxTurns?: number;
|
|
12
|
+
permissionMode?: "default" | "acceptEdits" | "plan" | "bypassPermissions";
|
|
13
|
+
thinking?: {
|
|
14
|
+
type: "enabled";
|
|
15
|
+
budgetTokens: number;
|
|
16
|
+
} | {
|
|
17
|
+
type: "disabled";
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface Session<TCtx> {
|
|
21
|
+
id: string;
|
|
22
|
+
context: TCtx;
|
|
23
|
+
pushMessage(text: string): void;
|
|
24
|
+
messageIterator: AsyncIterable<SDKMessage>;
|
|
25
|
+
permissionGate: PermissionGate;
|
|
26
|
+
abort(): void;
|
|
27
|
+
createdAt: number;
|
|
28
|
+
lastActivityAt: number;
|
|
29
|
+
}
|
|
30
|
+
export declare class SessionManager<TCtx> {
|
|
31
|
+
private sessions;
|
|
32
|
+
private factory;
|
|
33
|
+
private idleTimeoutMs;
|
|
34
|
+
constructor(factory: () => SessionInit<TCtx>, idleTimeoutMs?: number);
|
|
35
|
+
create(): Session<TCtx>;
|
|
36
|
+
get(id: string): Session<TCtx> | undefined;
|
|
37
|
+
delete(id: string): void;
|
|
38
|
+
cleanup(): void;
|
|
39
|
+
}
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { PermissionGate } from "./permission-gate.js";
|
|
3
|
+
import { PushChannel } from "./push-channel.js";
|
|
4
|
+
function userMessage(content) {
|
|
5
|
+
return {
|
|
6
|
+
type: "user",
|
|
7
|
+
message: { role: "user", content },
|
|
8
|
+
parent_tool_use_id: null,
|
|
9
|
+
session_id: "",
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
13
|
+
export class SessionManager {
|
|
14
|
+
sessions = new Map();
|
|
15
|
+
factory;
|
|
16
|
+
idleTimeoutMs;
|
|
17
|
+
constructor(factory, idleTimeoutMs) {
|
|
18
|
+
this.factory = factory;
|
|
19
|
+
this.idleTimeoutMs = idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
20
|
+
}
|
|
21
|
+
create() {
|
|
22
|
+
const id = crypto.randomUUID();
|
|
23
|
+
const init = this.factory();
|
|
24
|
+
const channel = new PushChannel();
|
|
25
|
+
const abortController = new AbortController();
|
|
26
|
+
const permissionGate = new PermissionGate();
|
|
27
|
+
const permissionMode = init.permissionMode ?? "bypassPermissions";
|
|
28
|
+
const isBypass = permissionMode === "bypassPermissions";
|
|
29
|
+
const canUseTool = isBypass
|
|
30
|
+
? undefined
|
|
31
|
+
: async (toolName, input, options) => {
|
|
32
|
+
const requestId = crypto.randomUUID();
|
|
33
|
+
if (toolName === "AskUserQuestion") {
|
|
34
|
+
const questions = (input.questions ?? []);
|
|
35
|
+
const response = await permissionGate.request({
|
|
36
|
+
kind: "user_question",
|
|
37
|
+
requestId,
|
|
38
|
+
questions,
|
|
39
|
+
});
|
|
40
|
+
if (response.kind === "user_question") {
|
|
41
|
+
return {
|
|
42
|
+
behavior: "allow",
|
|
43
|
+
updatedInput: { ...input, answers: response.answers },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { behavior: "deny", message: "Cancelled" };
|
|
47
|
+
}
|
|
48
|
+
const response = await permissionGate.request({
|
|
49
|
+
kind: "tool_approval",
|
|
50
|
+
requestId,
|
|
51
|
+
toolName,
|
|
52
|
+
toolUseId: options.toolUseID,
|
|
53
|
+
input,
|
|
54
|
+
description: input.description,
|
|
55
|
+
});
|
|
56
|
+
if (response.kind === "tool_approval" && response.behavior === "allow") {
|
|
57
|
+
return { behavior: "allow", updatedInput: input };
|
|
58
|
+
}
|
|
59
|
+
const message = response.kind === "tool_approval" ? (response.message ?? "Denied by user") : "Denied";
|
|
60
|
+
return { behavior: "deny", message };
|
|
61
|
+
};
|
|
62
|
+
const messageIterator = query({
|
|
63
|
+
prompt: channel,
|
|
64
|
+
options: {
|
|
65
|
+
systemPrompt: init.systemPrompt,
|
|
66
|
+
model: init.model,
|
|
67
|
+
tools: init.tools ?? [],
|
|
68
|
+
mcpServers: init.mcpServers,
|
|
69
|
+
allowedTools: init.allowedTools,
|
|
70
|
+
maxTurns: init.maxTurns ?? 200,
|
|
71
|
+
permissionMode,
|
|
72
|
+
...(isBypass ? { allowDangerouslySkipPermissions: true } : {}),
|
|
73
|
+
includePartialMessages: true,
|
|
74
|
+
abortController,
|
|
75
|
+
...(canUseTool ? { canUseTool } : {}),
|
|
76
|
+
...(init.thinking ? { thinking: init.thinking } : {}),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const session = {
|
|
80
|
+
id,
|
|
81
|
+
context: init.context,
|
|
82
|
+
pushMessage: (text) => {
|
|
83
|
+
session.lastActivityAt = Date.now();
|
|
84
|
+
channel.push(userMessage(text));
|
|
85
|
+
},
|
|
86
|
+
messageIterator,
|
|
87
|
+
permissionGate,
|
|
88
|
+
abort: () => {
|
|
89
|
+
permissionGate.cancelAll("Session aborted");
|
|
90
|
+
abortController.abort();
|
|
91
|
+
},
|
|
92
|
+
createdAt: Date.now(),
|
|
93
|
+
lastActivityAt: Date.now(),
|
|
94
|
+
};
|
|
95
|
+
this.sessions.set(id, session);
|
|
96
|
+
return session;
|
|
97
|
+
}
|
|
98
|
+
get(id) {
|
|
99
|
+
return this.sessions.get(id);
|
|
100
|
+
}
|
|
101
|
+
delete(id) {
|
|
102
|
+
const session = this.sessions.get(id);
|
|
103
|
+
if (session) {
|
|
104
|
+
session.abort();
|
|
105
|
+
this.sessions.delete(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
cleanup() {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
for (const [id, session] of this.sessions) {
|
|
111
|
+
if (now - session.lastActivityAt > this.idleTimeoutMs) {
|
|
112
|
+
session.abort();
|
|
113
|
+
this.sessions.delete(id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CustomEvent, SSEEvent } from "../types.js";
|
|
2
|
+
import type { Session } from "./session.js";
|
|
3
|
+
export interface TranslatorConfig<TCtx> {
|
|
4
|
+
onToolResult?: (toolName: string, result: string, session: Session<TCtx>) => CustomEvent[];
|
|
5
|
+
}
|
|
6
|
+
export declare class MessageTranslator<TCtx> {
|
|
7
|
+
private config;
|
|
8
|
+
private toolNames;
|
|
9
|
+
private hadStreamThinking;
|
|
10
|
+
constructor(config?: TranslatorConfig<TCtx>);
|
|
11
|
+
translate(message: Record<string, unknown>, session: Session<TCtx>): SSEEvent[];
|
|
12
|
+
private lastToolId;
|
|
13
|
+
}
|
|
14
|
+
export declare function sseEncode(evt: SSEEvent): string;
|
|
15
|
+
export declare function streamSession<TCtx>(session: Session<TCtx>, translator: MessageTranslator<TCtx>): AsyncGenerator<SSEEvent>;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { PushChannel } from "./push-channel.js";
|
|
2
|
+
export class MessageTranslator {
|
|
3
|
+
config;
|
|
4
|
+
toolNames = new Map();
|
|
5
|
+
hadStreamThinking = false;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config ?? {};
|
|
8
|
+
}
|
|
9
|
+
translate(message, session) {
|
|
10
|
+
const events = [];
|
|
11
|
+
const type = message.type;
|
|
12
|
+
switch (type) {
|
|
13
|
+
case "stream_event": {
|
|
14
|
+
if (!("event" in message))
|
|
15
|
+
break;
|
|
16
|
+
const event = message.event;
|
|
17
|
+
switch (event.type) {
|
|
18
|
+
case "message_start": {
|
|
19
|
+
this.hadStreamThinking = false;
|
|
20
|
+
events.push({ event: "message_start", data: "{}" });
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
case "content_block_start": {
|
|
24
|
+
const block = event.content_block;
|
|
25
|
+
switch (block?.type) {
|
|
26
|
+
case "tool_use":
|
|
27
|
+
case "server_tool_use": {
|
|
28
|
+
const id = block.id;
|
|
29
|
+
const name = block.name;
|
|
30
|
+
this.toolNames.set(id, name);
|
|
31
|
+
events.push({
|
|
32
|
+
event: "tool_start",
|
|
33
|
+
data: JSON.stringify({ id, name }),
|
|
34
|
+
});
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case "thinking": {
|
|
38
|
+
events.push({ event: "thinking_start", data: "{}" });
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "web_search_tool_result": {
|
|
42
|
+
const toolUseId = block.tool_use_id;
|
|
43
|
+
const toolName = this.toolNames.get(toolUseId);
|
|
44
|
+
const result = JSON.stringify(block.content);
|
|
45
|
+
events.push({
|
|
46
|
+
event: "tool_result",
|
|
47
|
+
data: JSON.stringify({ toolUseId, result }),
|
|
48
|
+
});
|
|
49
|
+
if (this.config.onToolResult && toolName) {
|
|
50
|
+
for (const c of this.config.onToolResult(toolName, result, session)) {
|
|
51
|
+
events.push({ event: "custom", data: JSON.stringify(c) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "content_block_delta": {
|
|
60
|
+
const delta = event.delta;
|
|
61
|
+
if (delta.type === "thinking_delta" && typeof delta.thinking === "string") {
|
|
62
|
+
this.hadStreamThinking = true;
|
|
63
|
+
events.push({
|
|
64
|
+
event: "thinking_delta",
|
|
65
|
+
data: JSON.stringify({ text: delta.thinking }),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else if (delta.type === "text_delta" && typeof delta.text === "string") {
|
|
69
|
+
events.push({
|
|
70
|
+
event: "text_delta",
|
|
71
|
+
data: JSON.stringify({ text: delta.text }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else if (delta.type === "input_json_delta" &&
|
|
75
|
+
typeof delta.partial_json === "string") {
|
|
76
|
+
const id = this.lastToolId();
|
|
77
|
+
if (id) {
|
|
78
|
+
events.push({
|
|
79
|
+
event: "tool_input_delta",
|
|
80
|
+
data: JSON.stringify({ id, partialJson: delta.partial_json }),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "assistant": {
|
|
90
|
+
const msg = message.message;
|
|
91
|
+
if (msg?.content) {
|
|
92
|
+
for (const block of msg.content) {
|
|
93
|
+
switch (block.type) {
|
|
94
|
+
case "thinking": {
|
|
95
|
+
if (!this.hadStreamThinking && typeof block.thinking === "string") {
|
|
96
|
+
events.push({
|
|
97
|
+
event: "thinking_delta",
|
|
98
|
+
data: JSON.stringify({ text: block.thinking }),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "tool_use":
|
|
104
|
+
case "server_tool_use": {
|
|
105
|
+
const name = block.name;
|
|
106
|
+
const id = block.id;
|
|
107
|
+
this.toolNames.set(id, name);
|
|
108
|
+
events.push({
|
|
109
|
+
event: "tool_call",
|
|
110
|
+
data: JSON.stringify({ id, name, input: block.input }),
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "web_search_tool_result": {
|
|
115
|
+
const toolUseId = block.tool_use_id;
|
|
116
|
+
const toolName = this.toolNames.get(toolUseId);
|
|
117
|
+
const result = JSON.stringify(block.content);
|
|
118
|
+
events.push({
|
|
119
|
+
event: "tool_result",
|
|
120
|
+
data: JSON.stringify({ toolUseId, result }),
|
|
121
|
+
});
|
|
122
|
+
if (this.config.onToolResult && toolName) {
|
|
123
|
+
for (const c of this.config.onToolResult(toolName, result, session)) {
|
|
124
|
+
events.push({ event: "custom", data: JSON.stringify(c) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "user": {
|
|
135
|
+
const msg = message.message;
|
|
136
|
+
if (Array.isArray(msg?.content)) {
|
|
137
|
+
for (const block of msg.content) {
|
|
138
|
+
if (block.type === "tool_result") {
|
|
139
|
+
const text = extractToolResultText(block);
|
|
140
|
+
const toolName = this.toolNames.get(block.tool_use_id);
|
|
141
|
+
events.push({
|
|
142
|
+
event: "tool_result",
|
|
143
|
+
data: JSON.stringify({ toolUseId: block.tool_use_id, result: text }),
|
|
144
|
+
});
|
|
145
|
+
if (this.config.onToolResult && toolName) {
|
|
146
|
+
for (const c of this.config.onToolResult(toolName, text, session)) {
|
|
147
|
+
events.push({ event: "custom", data: JSON.stringify(c) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "tool_progress": {
|
|
156
|
+
events.push({
|
|
157
|
+
event: "tool_progress",
|
|
158
|
+
data: JSON.stringify({
|
|
159
|
+
toolName: message.tool_name,
|
|
160
|
+
elapsed: message.elapsed_time_seconds,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "result": {
|
|
166
|
+
if (message.subtype === "success") {
|
|
167
|
+
events.push({
|
|
168
|
+
event: "turn_complete",
|
|
169
|
+
data: JSON.stringify({
|
|
170
|
+
numTurns: message.num_turns ?? 0,
|
|
171
|
+
cost: message.total_cost_usd ?? 0,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
events.push({
|
|
177
|
+
event: "session_error",
|
|
178
|
+
data: JSON.stringify({ subtype: message.subtype }),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return events;
|
|
185
|
+
}
|
|
186
|
+
lastToolId() {
|
|
187
|
+
const entries = [...this.toolNames.entries()];
|
|
188
|
+
return entries.length > 0 ? entries[entries.length - 1][0] : undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export function sseEncode(evt) {
|
|
192
|
+
return `event: ${evt.event}\ndata: ${evt.data}\n\n`;
|
|
193
|
+
}
|
|
194
|
+
export async function* streamSession(session, translator) {
|
|
195
|
+
const output = new PushChannel();
|
|
196
|
+
const unsubscribe = session.permissionGate.onRequest((request) => {
|
|
197
|
+
output.push({ event: "permission_request", data: JSON.stringify(request) });
|
|
198
|
+
});
|
|
199
|
+
for (const pending of session.permissionGate.getPending()) {
|
|
200
|
+
yield { event: "permission_request", data: JSON.stringify(pending) };
|
|
201
|
+
}
|
|
202
|
+
const driveMessages = async () => {
|
|
203
|
+
try {
|
|
204
|
+
for await (const message of session.messageIterator) {
|
|
205
|
+
const events = translator.translate(message, session);
|
|
206
|
+
for (const evt of events) {
|
|
207
|
+
output.push(evt);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
unsubscribe();
|
|
213
|
+
output.close();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
driveMessages();
|
|
217
|
+
for await (const evt of output) {
|
|
218
|
+
yield evt;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function extractToolResultText(block) {
|
|
222
|
+
const content = block.content;
|
|
223
|
+
if (typeof content === "string")
|
|
224
|
+
return content;
|
|
225
|
+
if (Array.isArray(content)) {
|
|
226
|
+
const textParts = content
|
|
227
|
+
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
228
|
+
.map((p) => p.text);
|
|
229
|
+
if (textParts.length > 0)
|
|
230
|
+
return textParts.join("");
|
|
231
|
+
// Non-text content blocks (e.g. web_search_tool_result) — serialize so
|
|
232
|
+
// downstream consumers (widgets, onToolResult) can still parse the data.
|
|
233
|
+
return JSON.stringify(content);
|
|
234
|
+
}
|
|
235
|
+
return "";
|
|
236
|
+
}
|