otterly 0.1.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.
@@ -0,0 +1,155 @@
1
+ // WebSocket handler for /ws — multi-turn sessions.
2
+ // Each connection gets its own otterly Session.
3
+ // Includes heartbeats: ping/pong with 30s interval, 10s timeout.
4
+ import crypto from "crypto";
5
+ import { ClaudeEngine } from "../engine.js";
6
+ import { apiSessions } from "./session-store.js";
7
+ const HEARTBEAT_INTERVAL_MS = 30_000;
8
+ const HEARTBEAT_TIMEOUT_MS = 10_000;
9
+ /**
10
+ * Attach WebSocket handling to a WebSocketServer for the API server.
11
+ */
12
+ export function attachWsHandler(wss, ctx) {
13
+ // Heartbeat sweep: ping all connections every 30s
14
+ const heartbeatInterval = setInterval(() => {
15
+ for (const ws of wss.clients) {
16
+ const extWs = ws;
17
+ const state = extWs._otterlyState;
18
+ if (state && !state.isAlive) {
19
+ // No pong received since last ping — terminate
20
+ ws.terminate();
21
+ continue;
22
+ }
23
+ if (state) {
24
+ state.isAlive = false;
25
+ }
26
+ ws.ping();
27
+ }
28
+ }, HEARTBEAT_INTERVAL_MS);
29
+ heartbeatInterval.unref();
30
+ wss.on("close", () => {
31
+ clearInterval(heartbeatInterval);
32
+ });
33
+ wss.on("connection", (ws) => {
34
+ const connId = crypto.randomUUID().slice(0, 8);
35
+ const state = {
36
+ session: null,
37
+ engine: new ClaudeEngine({ cwd: ctx.workingDir, permissionMode: "bypassPermissions" }),
38
+ isAlive: true,
39
+ };
40
+ // Attach state to ws for heartbeat access
41
+ ws._otterlyState = state;
42
+ apiSessions.create(connId, { state });
43
+ function send(obj) {
44
+ if (ws.readyState === 1) {
45
+ ws.send(JSON.stringify(obj));
46
+ }
47
+ }
48
+ // Pong handler: mark connection as alive
49
+ ws.on("pong", () => {
50
+ state.isAlive = true;
51
+ });
52
+ ws.on("message", async (raw) => {
53
+ state.isAlive = true; // any message counts as alive
54
+ let msg;
55
+ try {
56
+ msg = JSON.parse(raw.toString());
57
+ }
58
+ catch {
59
+ send({ kind: "error", code: "INVALID_JSON", message: "Invalid JSON" });
60
+ return;
61
+ }
62
+ if (msg.type === "chat") {
63
+ await handleChat(msg, state, send, ctx);
64
+ }
65
+ else if (msg.type === "cancel") {
66
+ if (state.session) {
67
+ state.session.close();
68
+ state.session = null;
69
+ }
70
+ send({ kind: "status", status: "ready" });
71
+ }
72
+ else if (msg.type === "resume") {
73
+ // Close existing session and create new one with resume
74
+ if (state.session) {
75
+ state.session.close();
76
+ }
77
+ const opts = {
78
+ cwd: ctx.workingDir,
79
+ permissionMode: "bypassPermissions",
80
+ };
81
+ if (msg.sessionId) {
82
+ opts.resume = msg.sessionId;
83
+ }
84
+ state.session = state.engine.session(opts);
85
+ send({ kind: "status", status: "ready" });
86
+ }
87
+ else if (msg.type === "new_session") {
88
+ if (state.session) {
89
+ state.session.close();
90
+ state.session = null;
91
+ }
92
+ send({ kind: "status", status: "ready" });
93
+ }
94
+ });
95
+ ws.on("close", () => {
96
+ if (state.session) {
97
+ state.session.close();
98
+ state.session = null;
99
+ }
100
+ apiSessions.delete(connId);
101
+ });
102
+ });
103
+ }
104
+ async function handleChat(msg, state, send, ctx) {
105
+ const text = msg.text || "";
106
+ if (!text) {
107
+ send({ kind: "error", code: "EMPTY_MESSAGE", message: "text is required" });
108
+ return;
109
+ }
110
+ // Create session if not exists
111
+ if (!state.session) {
112
+ const opts = {
113
+ cwd: msg.options?.cwd || ctx.workingDir,
114
+ permissionMode: msg.options?.permissionMode || "bypassPermissions",
115
+ };
116
+ if (msg.options?.systemPrompt)
117
+ opts.systemPrompt = msg.options.systemPrompt;
118
+ if (msg.options?.model)
119
+ opts.model = msg.options.model;
120
+ state.session = state.engine.session(opts);
121
+ }
122
+ send({ kind: "status", status: "thinking" });
123
+ try {
124
+ for await (const event of state.session.sendStream(text)) {
125
+ const payload = eventToWsPayload(event);
126
+ if (payload)
127
+ send(payload);
128
+ }
129
+ }
130
+ catch (err) {
131
+ const e = err instanceof Error ? err : new Error(String(err));
132
+ send({ kind: "error", code: "UNKNOWN", message: e.message });
133
+ }
134
+ send({ kind: "status", status: "ready" });
135
+ }
136
+ function eventToWsPayload(event) {
137
+ switch (event.type) {
138
+ case "system":
139
+ return { kind: "session_init", sessionId: event.sessionId, model: event.model };
140
+ case "text_delta":
141
+ return { kind: "text_delta", delta: event.delta };
142
+ case "text":
143
+ return { kind: "text", text: event.text };
144
+ case "tool_use":
145
+ return { kind: "tool_use", id: event.id, tool: event.tool, input: event.input, description: event.description };
146
+ case "tool_result":
147
+ return { kind: "tool_result", toolUseId: event.toolUseId, tool: event.tool, output: event.output, isError: event.isError };
148
+ case "result":
149
+ return { kind: "result", text: event.text, cost: event.cost, duration: event.duration, sessionId: event.sessionId, usage: event.usage };
150
+ case "error":
151
+ return { kind: "error", code: "EXECUTION_ERROR", message: event.error.message };
152
+ default:
153
+ return null;
154
+ }
155
+ }
@@ -0,0 +1,43 @@
1
+ import type { AgentEvent, AgentResult, EngineOptions } from "./types.js";
2
+ type QueryFn = (args: {
3
+ prompt: unknown;
4
+ options: Record<string, unknown>;
5
+ }) => AsyncIterable<Record<string, unknown>>;
6
+ /**
7
+ * Multi-turn session that keeps conversation context alive across send() calls.
8
+ *
9
+ * Uses the SDK's streaming input mode: an async generator yields user messages
10
+ * on demand, and the SDK processes them within one long-lived query() call.
11
+ *
12
+ * Internally, the SDK iteration runs in the background. Events are queued and
13
+ * pulled by sendStream(). After a result event, sendStream() returns but the
14
+ * background loop continues — allowing the SDK to call prompt.next() and await
15
+ * the next user message.
16
+ */
17
+ export declare class Session {
18
+ private options;
19
+ private queryFn;
20
+ private messageResolve;
21
+ private abortController;
22
+ private ctx;
23
+ private _sessionId;
24
+ private closed;
25
+ private started;
26
+ private eventQueue;
27
+ private eventWaiter;
28
+ private backgroundDone;
29
+ private backgroundError;
30
+ constructor(queryFn: QueryFn, options: EngineOptions);
31
+ /** Session ID from the SDK. Available after first send(). */
32
+ get id(): string | null;
33
+ /** Send a message and collect the full result. */
34
+ send(prompt: string): Promise<AgentResult>;
35
+ /** Send a message and stream events. */
36
+ sendStream(prompt: string): AsyncGenerator<AgentEvent>;
37
+ /** End the session and clean up. */
38
+ close(): void;
39
+ private pushEvent;
40
+ private pullEvent;
41
+ private startBackground;
42
+ }
43
+ export {};
@@ -0,0 +1,255 @@
1
+ import { normalizeEvents, createEventContext } from "./events.js";
2
+ import { classifyError, AgentError } from "./errors.js";
3
+ import { wrapPermissionHandler } from "./permissions.js";
4
+ /**
5
+ * Multi-turn session that keeps conversation context alive across send() calls.
6
+ *
7
+ * Uses the SDK's streaming input mode: an async generator yields user messages
8
+ * on demand, and the SDK processes them within one long-lived query() call.
9
+ *
10
+ * Internally, the SDK iteration runs in the background. Events are queued and
11
+ * pulled by sendStream(). After a result event, sendStream() returns but the
12
+ * background loop continues — allowing the SDK to call prompt.next() and await
13
+ * the next user message.
14
+ */
15
+ export class Session {
16
+ options;
17
+ queryFn;
18
+ messageResolve = null;
19
+ abortController;
20
+ ctx;
21
+ _sessionId = null;
22
+ closed = false;
23
+ started = false;
24
+ // Event queue: background iteration pushes events, sendStream() pulls them
25
+ eventQueue = [];
26
+ eventWaiter = null;
27
+ backgroundDone = false;
28
+ backgroundError = null;
29
+ constructor(queryFn, options) {
30
+ this.queryFn = queryFn;
31
+ this.options = options;
32
+ this.abortController = new AbortController();
33
+ this.ctx = createEventContext();
34
+ if (options.signal) {
35
+ options.signal.addEventListener("abort", () => this.abortController.abort());
36
+ }
37
+ }
38
+ /** Session ID from the SDK. Available after first send(). */
39
+ get id() {
40
+ return this._sessionId;
41
+ }
42
+ /** Send a message and collect the full result. */
43
+ async send(prompt) {
44
+ const tools = [];
45
+ let resultText = "";
46
+ let cost = 0;
47
+ let duration = 0;
48
+ let sessionId = "";
49
+ let usage = { input_tokens: 0, output_tokens: 0 };
50
+ const pendingToolUses = new Map();
51
+ for await (const event of this.sendStream(prompt)) {
52
+ switch (event.type) {
53
+ case "text":
54
+ resultText = event.text;
55
+ break;
56
+ case "tool_use":
57
+ pendingToolUses.set(event.id, { tool: event.tool, input: event.input });
58
+ break;
59
+ case "tool_result": {
60
+ const pending = pendingToolUses.get(event.toolUseId);
61
+ tools.push({
62
+ tool: pending?.tool || event.tool,
63
+ input: pending?.input || {},
64
+ output: event.output,
65
+ isError: event.isError,
66
+ });
67
+ pendingToolUses.delete(event.toolUseId);
68
+ break;
69
+ }
70
+ case "result":
71
+ resultText = event.text || resultText;
72
+ cost = event.cost;
73
+ duration = event.duration;
74
+ sessionId = event.sessionId;
75
+ usage = event.usage;
76
+ break;
77
+ case "error":
78
+ throw event.error instanceof AgentError
79
+ ? event.error
80
+ : classifyError(event.error);
81
+ }
82
+ }
83
+ return { text: resultText, cost, duration, sessionId, usage, tools };
84
+ }
85
+ /** Send a message and stream events. */
86
+ async *sendStream(prompt) {
87
+ if (this.closed) {
88
+ throw new AgentError("ABORTED", "Session has been closed.");
89
+ }
90
+ if (!this.started) {
91
+ // First call: start the query with the prompt baked into the message stream
92
+ this.started = true;
93
+ this.startBackground(prompt);
94
+ }
95
+ else {
96
+ // Subsequent calls: feed the message into the running generator
97
+ if (!this.messageResolve) {
98
+ // Wait a tick — the background loop may be about to set messageResolve
99
+ await new Promise((r) => setTimeout(r, 0));
100
+ }
101
+ if (!this.messageResolve) {
102
+ throw new AgentError("UNKNOWN", "Session is busy processing. Wait for the previous send() to complete.");
103
+ }
104
+ this.messageResolve({
105
+ type: "user",
106
+ message: { role: "user", content: prompt },
107
+ });
108
+ this.messageResolve = null;
109
+ }
110
+ // Pull events from the queue until we get a result or error
111
+ while (true) {
112
+ const event = await this.pullEvent();
113
+ if (event === null)
114
+ break;
115
+ if (event.type === "system") {
116
+ this._sessionId = event.sessionId;
117
+ }
118
+ yield event;
119
+ if (event.type === "result" || event.type === "error") {
120
+ return;
121
+ }
122
+ }
123
+ }
124
+ /** End the session and clean up. */
125
+ close() {
126
+ if (this.closed)
127
+ return;
128
+ this.closed = true;
129
+ if (this.messageResolve) {
130
+ this.messageResolve(null);
131
+ this.messageResolve = null;
132
+ }
133
+ this.abortController.abort();
134
+ this.backgroundDone = true;
135
+ // Unblock any pending pullEvent()
136
+ if (this.eventWaiter) {
137
+ this.eventWaiter(null);
138
+ this.eventWaiter = null;
139
+ }
140
+ }
141
+ pushEvent(event) {
142
+ if (this.eventWaiter) {
143
+ const waiter = this.eventWaiter;
144
+ this.eventWaiter = null;
145
+ waiter(event);
146
+ }
147
+ else if (event !== null) {
148
+ this.eventQueue.push(event);
149
+ }
150
+ }
151
+ pullEvent() {
152
+ if (this.eventQueue.length > 0) {
153
+ return Promise.resolve(this.eventQueue.shift());
154
+ }
155
+ if (this.backgroundDone) {
156
+ if (this.backgroundError) {
157
+ return Promise.reject(classifyError(this.backgroundError));
158
+ }
159
+ return Promise.resolve(null);
160
+ }
161
+ return new Promise((resolve) => {
162
+ this.eventWaiter = resolve;
163
+ });
164
+ }
165
+ startBackground(firstPrompt) {
166
+ const self = this;
167
+ async function* messageStream() {
168
+ yield {
169
+ type: "user",
170
+ message: { role: "user", content: firstPrompt },
171
+ };
172
+ while (true) {
173
+ const msg = await new Promise((resolve) => {
174
+ self.messageResolve = resolve;
175
+ });
176
+ if (msg === null)
177
+ return;
178
+ yield msg;
179
+ }
180
+ }
181
+ const queryOptions = {
182
+ abortController: this.abortController,
183
+ cwd: this.options.cwd || process.cwd(),
184
+ permissionMode: this.options.permissionMode || "bypassPermissions",
185
+ includePartialMessages: true,
186
+ };
187
+ if (this.options.model)
188
+ queryOptions.model = this.options.model;
189
+ if (this.options.maxTurns)
190
+ queryOptions.maxTurns = this.options.maxTurns;
191
+ if (this.options.allowedTools)
192
+ queryOptions.allowedTools = this.options.allowedTools;
193
+ if (this.options.disallowedTools)
194
+ queryOptions.disallowedTools = this.options.disallowedTools;
195
+ if (this.options.mcpServers)
196
+ queryOptions.mcpServers = this.options.mcpServers;
197
+ if (this.options.effort)
198
+ queryOptions.effort = this.options.effort;
199
+ if (this.options.resume)
200
+ queryOptions.resume = this.options.resume;
201
+ if (this.options.systemPrompt)
202
+ queryOptions.systemPrompt = this.options.systemPrompt;
203
+ if (this.options.onPermission) {
204
+ queryOptions.permissionMode = "default";
205
+ queryOptions.canUseTool = wrapPermissionHandler(this.options.onPermission);
206
+ }
207
+ // Run the iteration in the background — push events to the queue
208
+ (async () => {
209
+ let queryInstance;
210
+ try {
211
+ queryInstance = this.queryFn({
212
+ prompt: messageStream(),
213
+ options: queryOptions,
214
+ });
215
+ }
216
+ catch (err) {
217
+ this.backgroundError = err instanceof Error ? err : new Error(String(err));
218
+ this.pushEvent({
219
+ type: "error",
220
+ error: classifyError(this.backgroundError),
221
+ });
222
+ this.backgroundDone = true;
223
+ this.pushEvent(null);
224
+ return;
225
+ }
226
+ try {
227
+ for await (const raw of queryInstance) {
228
+ if (this.abortController.signal.aborted)
229
+ break;
230
+ const events = normalizeEvents(raw, this.ctx);
231
+ for (const event of events) {
232
+ this.pushEvent(event);
233
+ }
234
+ }
235
+ }
236
+ catch (err) {
237
+ if (err instanceof Error &&
238
+ (err.name === "AbortError" || this.abortController.signal.aborted)) {
239
+ // Cancelled — don't push error
240
+ }
241
+ else {
242
+ this.backgroundError = err instanceof Error ? err : new Error(String(err));
243
+ this.pushEvent({
244
+ type: "error",
245
+ error: classifyError(this.backgroundError),
246
+ });
247
+ }
248
+ }
249
+ finally {
250
+ this.backgroundDone = true;
251
+ this.pushEvent(null);
252
+ }
253
+ })();
254
+ }
255
+ }
@@ -0,0 +1,100 @@
1
+ export type PermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan";
2
+ export interface EngineOptions {
3
+ /** Working directory for file operations. Defaults to process.cwd() */
4
+ cwd?: string;
5
+ /** Model to use (e.g. "claude-sonnet-4-20250514") */
6
+ model?: string;
7
+ /** Permission mode. Defaults to "bypassPermissions" (autopilot) */
8
+ permissionMode?: PermissionMode;
9
+ /** Custom system prompt */
10
+ systemPrompt?: string;
11
+ /** Max agent turns before stopping */
12
+ maxTurns?: number;
13
+ /** Tool whitelist — only these tools can be used */
14
+ allowedTools?: string[];
15
+ /** Tool blacklist — these tools are blocked */
16
+ disallowedTools?: string[];
17
+ /** MCP server configurations */
18
+ mcpServers?: Record<string, unknown>;
19
+ /** AbortSignal to cancel the operation */
20
+ signal?: AbortSignal;
21
+ /** Custom permission handler. When set, permissionMode should be "default" to enable prompting */
22
+ onPermission?: PermissionHandler;
23
+ /** Session ID to resume a previous conversation */
24
+ resume?: string;
25
+ /** Reasoning effort level */
26
+ effort?: "low" | "medium" | "high";
27
+ }
28
+ export interface ToolRequest {
29
+ tool: string;
30
+ input: Record<string, unknown>;
31
+ reason?: string;
32
+ }
33
+ export interface PermissionDecision {
34
+ allow: boolean;
35
+ updatedInput?: Record<string, unknown>;
36
+ message?: string;
37
+ }
38
+ export type PermissionHandler = (request: ToolRequest) => PermissionDecision | Promise<PermissionDecision>;
39
+ export interface TextEvent {
40
+ type: "text";
41
+ text: string;
42
+ }
43
+ export interface TextDeltaEvent {
44
+ type: "text_delta";
45
+ delta: string;
46
+ }
47
+ export interface ToolUseEvent {
48
+ type: "tool_use";
49
+ id: string;
50
+ tool: string;
51
+ input: Record<string, unknown>;
52
+ description: string;
53
+ }
54
+ export interface ToolResultEvent {
55
+ type: "tool_result";
56
+ toolUseId: string;
57
+ tool: string;
58
+ output: string;
59
+ isError: boolean;
60
+ }
61
+ export interface SystemEvent {
62
+ type: "system";
63
+ sessionId: string;
64
+ model: string;
65
+ cwd: string;
66
+ tools: string[];
67
+ }
68
+ export interface ErrorEvent {
69
+ type: "error";
70
+ error: Error;
71
+ }
72
+ export interface ResultEvent {
73
+ type: "result";
74
+ text: string;
75
+ cost: number;
76
+ duration: number;
77
+ sessionId: string;
78
+ usage: {
79
+ input_tokens: number;
80
+ output_tokens: number;
81
+ };
82
+ }
83
+ export type AgentEvent = TextEvent | TextDeltaEvent | ToolUseEvent | ToolResultEvent | SystemEvent | ErrorEvent | ResultEvent;
84
+ export interface ToolExecution {
85
+ tool: string;
86
+ input: Record<string, unknown>;
87
+ output: string;
88
+ isError: boolean;
89
+ }
90
+ export interface AgentResult {
91
+ text: string;
92
+ cost: number;
93
+ duration: number;
94
+ sessionId: string;
95
+ usage: {
96
+ input_tokens: number;
97
+ output_tokens: number;
98
+ };
99
+ tools: ToolExecution[];
100
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // ── Configuration ──
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "otterly",
3
+ "version": "0.1.0",
4
+ "description": "Ollama-simple SDK for Claude Code. Drop a coding agent into your app in one line.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "otterly": "./dist/cli.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ },
16
+ "./server": {
17
+ "import": "./dist/server/index.js",
18
+ "types": "./dist/server/index.d.ts"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist/",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "dependencies": {
33
+ "ws": "^8.18.0"
34
+ },
35
+ "peerDependencies": {
36
+ "@anthropic-ai/claude-code": ">=1.0.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@anthropic-ai/claude-code": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@anthropic-ai/claude-code": "^1.0.128",
45
+ "@types/node": "^25.6.2",
46
+ "@types/ws": "^8.5.0",
47
+ "typescript": "^5.5.0",
48
+ "vitest": "^3.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=18"
52
+ },
53
+ "keywords": [
54
+ "claude",
55
+ "claude-code",
56
+ "claude-api",
57
+ "anthropic",
58
+ "coding-agent",
59
+ "ai",
60
+ "sdk",
61
+ "local",
62
+ "agent",
63
+ "otterly",
64
+ "openai-compatible",
65
+ "inference-server"
66
+ ],
67
+ "author": "Harsh Joshi",
68
+ "license": "MIT",
69
+ "repository": {
70
+ "type": "git",
71
+ "url": "https://github.com/josharsh/otterly.git"
72
+ }
73
+ }