@strand-js/core 0.1.0 → 0.1.2

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/dist/index.mjs ADDED
@@ -0,0 +1,477 @@
1
+ // src/context-window.ts
2
+ function estimateTokens(msg) {
3
+ return Math.max(1, Math.ceil(msg.content.length / 4));
4
+ }
5
+ function applyContextWindow(messages, config) {
6
+ if (!messages.length) return [];
7
+ if (!config.maxTokens || config.strategy === "none") return messages;
8
+ if (config.strategy === "truncate-oldest") {
9
+ return truncateOldest(messages, config.maxTokens);
10
+ }
11
+ if (config.strategy === "sliding-window") {
12
+ return slidingWindow(messages, config.maxTokens);
13
+ }
14
+ return messages;
15
+ }
16
+ function truncateOldest(messages, maxTokens) {
17
+ let total = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
18
+ let start = 0;
19
+ while (total > maxTokens && start < messages.length - 1) {
20
+ total -= estimateTokens(messages[start]);
21
+ start++;
22
+ }
23
+ return messages.slice(start);
24
+ }
25
+ function slidingWindow(messages, maxTokens) {
26
+ const result = [];
27
+ let total = 0;
28
+ for (let i = messages.length - 1; i >= 0; i--) {
29
+ const tokens = estimateTokens(messages[i]);
30
+ if (total + tokens > maxTokens && result.length > 0) break;
31
+ result.unshift(messages[i]);
32
+ total += tokens;
33
+ }
34
+ return result;
35
+ }
36
+
37
+ // src/retry.ts
38
+ var StrandError = class extends Error {
39
+ constructor(status, message, code) {
40
+ super(message);
41
+ this.name = "StrandError";
42
+ this.status = status;
43
+ this.code = code;
44
+ }
45
+ };
46
+ function isRetryable(error, retryOn) {
47
+ if (!(error instanceof StrandError)) return false;
48
+ return retryOn.includes(error.code);
49
+ }
50
+ function getDelay(backoff, attempt) {
51
+ const base = 1e3;
52
+ if (backoff === "none") return 0;
53
+ if (backoff === "linear") return base;
54
+ return base * Math.pow(2, attempt);
55
+ }
56
+ function sleep(ms) {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+ async function withRetry(fn, config) {
60
+ const maxAttempts = config.maxAttempts ?? 3;
61
+ const backoff = config.backoff ?? "exponential";
62
+ const retryOn = config.retryOn ?? ["rate_limit", "server_error"];
63
+ let lastError;
64
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
65
+ try {
66
+ return await fn();
67
+ } catch (error) {
68
+ lastError = error;
69
+ if (!isRetryable(error, retryOn)) throw error;
70
+ if (attempt === maxAttempts - 1) throw error;
71
+ const delay = getDelay(backoff, attempt);
72
+ if (delay > 0) await sleep(delay);
73
+ }
74
+ }
75
+ throw lastError;
76
+ }
77
+
78
+ // src/stream.ts
79
+ var KNOWN_EVENT_TYPES = /* @__PURE__ */ new Set([
80
+ "strand:start",
81
+ "strand:text-delta",
82
+ "strand:tool-start",
83
+ "strand:tool-input-delta",
84
+ "strand:tool-input-done",
85
+ "strand:tool-result",
86
+ "strand:tool-error",
87
+ "strand:done",
88
+ "strand:error"
89
+ ]);
90
+ function parseSSEBuffer(buffer) {
91
+ const messages = buffer.split("\n\n");
92
+ const rest = messages.pop() ?? "";
93
+ const events = [];
94
+ for (const message of messages) {
95
+ if (!message.trim()) continue;
96
+ let event = "";
97
+ let data = "";
98
+ for (const line of message.split("\n")) {
99
+ if (line.startsWith("event: ")) event = line.slice(7).trim();
100
+ else if (line.startsWith("data: ")) data = line.slice(6);
101
+ }
102
+ if (event && data) events.push({ event, data });
103
+ }
104
+ return { events, rest };
105
+ }
106
+ async function* parseSSEStream(body, signal) {
107
+ const decoder = new TextDecoder();
108
+ const reader = body.getReader();
109
+ let buffer = "";
110
+ try {
111
+ while (true) {
112
+ if (signal?.aborted) break;
113
+ const { done, value } = await reader.read();
114
+ if (done) break;
115
+ buffer += decoder.decode(value, { stream: true });
116
+ const { events: events2, rest } = parseSSEBuffer(buffer);
117
+ buffer = rest;
118
+ for (const { event, data } of events2) {
119
+ if (!KNOWN_EVENT_TYPES.has(event)) continue;
120
+ try {
121
+ const parsed = JSON.parse(data);
122
+ yield { type: event, ...parsed };
123
+ } catch {
124
+ }
125
+ }
126
+ }
127
+ buffer += decoder.decode();
128
+ const { events } = parseSSEBuffer(buffer + "\n\n");
129
+ for (const { event, data } of events) {
130
+ if (!KNOWN_EVENT_TYPES.has(event)) continue;
131
+ try {
132
+ const parsed = JSON.parse(data);
133
+ yield { type: event, ...parsed };
134
+ } catch {
135
+ }
136
+ }
137
+ } finally {
138
+ reader.releaseLock();
139
+ }
140
+ }
141
+
142
+ // src/client.ts
143
+ var StrandClientImpl = class {
144
+ constructor(config) {
145
+ this.config = {
146
+ baseUrl: config.baseUrl.replace(/\/$/, ""),
147
+ retry: {
148
+ maxAttempts: config.retry?.maxAttempts ?? 3,
149
+ backoff: config.retry?.backoff ?? "exponential",
150
+ retryOn: config.retry?.retryOn ?? ["rate_limit", "server_error"]
151
+ },
152
+ contextWindow: {
153
+ strategy: config.contextWindow?.strategy ?? "truncate-oldest",
154
+ maxTokens: config.contextWindow?.maxTokens ?? 1e5
155
+ }
156
+ };
157
+ }
158
+ async *send(messages, options = {}) {
159
+ const { signal, context } = options;
160
+ const windowedMessages = applyContextWindow(messages, this.config.contextWindow);
161
+ const body = JSON.stringify({
162
+ messages: windowedMessages.map((m) => ({ role: m.role, content: m.content })),
163
+ ...context ? { context } : {}
164
+ });
165
+ const response = await withRetry(
166
+ () => fetch(this.config.baseUrl, {
167
+ method: "POST",
168
+ headers: {
169
+ "Content-Type": "application/json",
170
+ Accept: "text/event-stream"
171
+ },
172
+ body,
173
+ signal
174
+ }),
175
+ this.config.retry
176
+ );
177
+ if (!response.ok) {
178
+ const text = await response.text().catch(() => response.statusText);
179
+ const code = response.status === 429 ? "rate_limit" : "server_error";
180
+ throw new StrandError(response.status, text, code);
181
+ }
182
+ if (!response.body) {
183
+ throw new StrandError(0, "Response body is null", "server_error");
184
+ }
185
+ yield* parseSSEStream(response.body, signal);
186
+ }
187
+ };
188
+ function createStrandClient(config) {
189
+ if (!config.baseUrl) throw new Error("[strand] createStrandClient: baseUrl is required");
190
+ return new StrandClientImpl(config);
191
+ }
192
+
193
+ // src/tool.ts
194
+ function tool(definition) {
195
+ if (!definition.name) throw new Error("[strand] tool: name is required");
196
+ if (!definition.description) throw new Error("[strand] tool: description is required");
197
+ if (!definition.parameters) throw new Error("[strand] tool: parameters schema is required");
198
+ return definition;
199
+ }
200
+
201
+ // src/session.ts
202
+ var SessionStateMachine = class {
203
+ constructor(sessionId) {
204
+ this._listeners = /* @__PURE__ */ new Set();
205
+ this._resetTimer = null;
206
+ this._session = makeInitialSession(sessionId ?? generateId());
207
+ }
208
+ get session() {
209
+ return this._session;
210
+ }
211
+ subscribe(listener) {
212
+ this._listeners.add(listener);
213
+ return () => this._listeners.delete(listener);
214
+ }
215
+ emit() {
216
+ this._listeners.forEach((l) => l(this._session));
217
+ }
218
+ transition(status, error) {
219
+ if (this._resetTimer !== null) {
220
+ clearTimeout(this._resetTimer);
221
+ this._resetTimer = null;
222
+ }
223
+ this._session = { ...this._session, status, error: error ?? null };
224
+ this.emit();
225
+ if (status === "done" || status === "error") {
226
+ this._resetTimer = setTimeout(() => {
227
+ this._session = { ...this._session, status: "idle" };
228
+ this._resetTimer = null;
229
+ this.emit();
230
+ }, 0);
231
+ }
232
+ }
233
+ addUserMessage(content) {
234
+ const msg = {
235
+ id: generateId(),
236
+ role: "user",
237
+ content,
238
+ createdAt: /* @__PURE__ */ new Date()
239
+ };
240
+ this._session = { ...this._session, messages: [...this._session.messages, msg] };
241
+ this.emit();
242
+ }
243
+ appendTextDelta(delta) {
244
+ const messages = [...this._session.messages];
245
+ const last = messages.at(-1);
246
+ if (last?.role === "assistant") {
247
+ messages[messages.length - 1] = { ...last, content: last.content + delta };
248
+ } else {
249
+ messages.push({
250
+ id: generateId(),
251
+ role: "assistant",
252
+ content: delta,
253
+ createdAt: /* @__PURE__ */ new Date()
254
+ });
255
+ }
256
+ this._session = { ...this._session, messages };
257
+ this.emit();
258
+ }
259
+ beginToolCall(toolCallId, toolName) {
260
+ const messages = [...this._session.messages];
261
+ const last = messages.at(-1);
262
+ const toolCall = {
263
+ id: toolCallId,
264
+ name: toolName,
265
+ input: {},
266
+ output: null,
267
+ status: "pending",
268
+ error: null
269
+ };
270
+ if (last?.role === "assistant") {
271
+ messages[messages.length - 1] = {
272
+ ...last,
273
+ toolCalls: [...last.toolCalls ?? [], toolCall]
274
+ };
275
+ } else {
276
+ messages.push({
277
+ id: generateId(),
278
+ role: "assistant",
279
+ content: "",
280
+ toolCalls: [toolCall],
281
+ createdAt: /* @__PURE__ */ new Date()
282
+ });
283
+ }
284
+ this._session = { ...this._session, messages };
285
+ this.emit();
286
+ }
287
+ updateToolCall(toolCallId, update) {
288
+ const messages = this._session.messages.map((msg) => {
289
+ if (!msg.toolCalls) return msg;
290
+ const toolCalls = msg.toolCalls.map(
291
+ (tc) => tc.id === toolCallId ? { ...tc, ...update } : tc
292
+ );
293
+ return { ...msg, toolCalls };
294
+ });
295
+ this._session = { ...this._session, messages };
296
+ this.emit();
297
+ }
298
+ updateTokenUsage(usage) {
299
+ this._session = { ...this._session, tokenUsage: usage };
300
+ this.emit();
301
+ }
302
+ clear() {
303
+ if (this._resetTimer !== null) {
304
+ clearTimeout(this._resetTimer);
305
+ this._resetTimer = null;
306
+ }
307
+ this._session = makeInitialSession(this._session.id);
308
+ this.emit();
309
+ }
310
+ };
311
+ function makeInitialSession(id) {
312
+ return {
313
+ id,
314
+ messages: [],
315
+ status: "idle",
316
+ tokenUsage: { input: 0, output: 0, total: 0 },
317
+ error: null
318
+ };
319
+ }
320
+ function generateId() {
321
+ return crypto.randomUUID();
322
+ }
323
+
324
+ // src/tool-store.ts
325
+ function idleState() {
326
+ return { id: null, toolName: null, status: "idle", input: null, output: null, error: null };
327
+ }
328
+ var ToolCallStore = class {
329
+ constructor() {
330
+ // toolCallId → toolName for reverse lookups
331
+ this._idToName = /* @__PURE__ */ new Map();
332
+ // toolName → current state (latest invocation wins)
333
+ this._states = /* @__PURE__ */ new Map();
334
+ this._listeners = /* @__PURE__ */ new Map();
335
+ }
336
+ getState(toolName) {
337
+ return this._states.get(toolName) ?? idleState();
338
+ }
339
+ subscribe(toolName, listener) {
340
+ if (!this._listeners.has(toolName)) this._listeners.set(toolName, /* @__PURE__ */ new Set());
341
+ this._listeners.get(toolName).add(listener);
342
+ return () => this._listeners.get(toolName)?.delete(listener);
343
+ }
344
+ onToolStart(toolCallId, toolName) {
345
+ this._idToName.set(toolCallId, toolName);
346
+ this._set(toolName, { id: toolCallId, toolName, status: "pending", input: null, output: null, error: null });
347
+ }
348
+ onToolInputDone(toolCallId, input) {
349
+ const toolName = this._idToName.get(toolCallId);
350
+ if (!toolName) return;
351
+ const current = this._states.get(toolName);
352
+ if (current?.id !== toolCallId) return;
353
+ this._set(toolName, { ...current, status: "running", input });
354
+ }
355
+ onToolResult(toolCallId, result) {
356
+ const toolName = this._idToName.get(toolCallId);
357
+ if (!toolName) return;
358
+ const current = this._states.get(toolName);
359
+ if (current?.id !== toolCallId) return;
360
+ this._set(toolName, { ...current, status: "done", output: result });
361
+ }
362
+ onToolError(toolCallId, error) {
363
+ const toolName = this._idToName.get(toolCallId);
364
+ if (!toolName) return;
365
+ const current = this._states.get(toolName);
366
+ if (current?.id !== toolCallId) return;
367
+ this._set(toolName, { ...current, status: "failed", error });
368
+ }
369
+ resetAll() {
370
+ this._idToName.clear();
371
+ for (const toolName of this._states.keys()) {
372
+ this._set(toolName, idleState());
373
+ }
374
+ }
375
+ _set(toolName, state) {
376
+ this._states.set(toolName, state);
377
+ this._listeners.get(toolName)?.forEach((l) => l(state));
378
+ }
379
+ };
380
+
381
+ // src/wire-processor.ts
382
+ function processWireEvent(event, session, toolStore) {
383
+ switch (event.type) {
384
+ case "strand:start":
385
+ session.transition("submitting");
386
+ break;
387
+ case "strand:text-delta":
388
+ if (session.session.status !== "streaming") session.transition("streaming");
389
+ session.appendTextDelta(event.delta);
390
+ break;
391
+ case "strand:tool-start":
392
+ session.beginToolCall(event.toolCallId, event.toolName);
393
+ toolStore.onToolStart(event.toolCallId, event.toolName);
394
+ break;
395
+ case "strand:tool-input-delta":
396
+ break;
397
+ case "strand:tool-input-done":
398
+ session.updateToolCall(event.toolCallId, { input: event.input, status: "running" });
399
+ toolStore.onToolInputDone(event.toolCallId, event.input);
400
+ break;
401
+ case "strand:tool-result":
402
+ session.updateToolCall(event.toolCallId, { output: event.result, status: "done" });
403
+ toolStore.onToolResult(event.toolCallId, event.result);
404
+ break;
405
+ case "strand:tool-error": {
406
+ const error = new Error(event.error);
407
+ session.updateToolCall(event.toolCallId, { error, status: "failed" });
408
+ toolStore.onToolError(event.toolCallId, error);
409
+ break;
410
+ }
411
+ case "strand:done":
412
+ session.updateTokenUsage(event.usage);
413
+ session.transition("done");
414
+ toolStore.resetAll();
415
+ break;
416
+ case "strand:error": {
417
+ const error = new StrandError(0, event.message, event.code);
418
+ session.transition("error", error);
419
+ break;
420
+ }
421
+ }
422
+ }
423
+
424
+ // src/schema.ts
425
+ import { zodToJsonSchema } from "zod-to-json-schema";
426
+ function toolToJsonSchema(tool2) {
427
+ const schema = zodToJsonSchema(tool2.parameters, { target: "openApi3" });
428
+ const inner = schema;
429
+ delete inner["$schema"];
430
+ return inner;
431
+ }
432
+
433
+ // src/validate.ts
434
+ function validateMessages(messages, config = {}) {
435
+ if (!Array.isArray(messages)) {
436
+ return { ok: false, status: 400, error: "messages must be an array" };
437
+ }
438
+ const maxMessages = config.maxMessages ?? 100;
439
+ if (messages.length > maxMessages) {
440
+ return { ok: false, status: 400, error: `Too many messages \u2014 max ${maxMessages}` };
441
+ }
442
+ const maxLen = config.maxMessageLength ?? 5e4;
443
+ for (const msg of messages) {
444
+ if (typeof msg !== "object" || msg === null) {
445
+ return { ok: false, status: 400, error: "Each message must be an object" };
446
+ }
447
+ const { role, content } = msg;
448
+ if (!["user", "assistant"].includes(role)) {
449
+ return { ok: false, status: 400, error: `Invalid message role: "${role}". Must be "user" or "assistant"` };
450
+ }
451
+ if (typeof content !== "string") {
452
+ return { ok: false, status: 400, error: "Message content must be a string" };
453
+ }
454
+ if (content.length > maxLen) {
455
+ return {
456
+ ok: false,
457
+ status: 400,
458
+ error: `Message content exceeds max length of ${maxLen} characters`
459
+ };
460
+ }
461
+ }
462
+ return { ok: true };
463
+ }
464
+ export {
465
+ SessionStateMachine,
466
+ StrandError,
467
+ ToolCallStore,
468
+ applyContextWindow,
469
+ createStrandClient,
470
+ generateId,
471
+ parseSSEStream,
472
+ processWireEvent,
473
+ tool,
474
+ toolToJsonSchema,
475
+ validateMessages,
476
+ withRetry
477
+ };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@strand-js/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
+ "license": "MIT",
4
5
  "description": "Provider-agnostic AI state management — core types and client",
5
6
  "main": "./dist/index.js",
6
7
  "module": "./dist/index.mjs",
@@ -15,6 +16,13 @@
15
16
  "files": [
16
17
  "dist"
17
18
  ],
19
+ "scripts": {
20
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
21
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "vitest run",
24
+ "lint": "eslint src"
25
+ },
18
26
  "dependencies": {
19
27
  "zod": "^3.23.8",
20
28
  "zod-to-json-schema": "^3.23.5"
@@ -26,12 +34,5 @@
26
34
  },
27
35
  "publishConfig": {
28
36
  "access": "public"
29
- },
30
- "scripts": {
31
- "build": "tsup src/index.ts --format esm,cjs --dts --clean",
32
- "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
33
- "typecheck": "tsc --noEmit",
34
- "test": "vitest run",
35
- "lint": "eslint src"
36
37
  }
37
- }
38
+ }