@kuralle-syrinx/server-workers 2.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.
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite-shm +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite-wal +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite-wal +0 -0
- package/LICENSE +22 -0
- package/package.json +42 -0
- package/src/alarm-scheduler.test.ts +44 -0
- package/src/alarm-scheduler.ts +67 -0
- package/src/durable-session-store.test.ts +60 -0
- package/src/durable-session-store.ts +148 -0
- package/src/kuralle-realtime-agent.ts +211 -0
- package/src/live-realtime-session.test.ts +89 -0
- package/src/live-realtime-session.ts +94 -0
- package/src/live-session.test.ts +89 -0
- package/src/live-session.ts +85 -0
- package/src/r2-recorder.test.ts +100 -0
- package/src/r2-recorder.ts +218 -0
- package/src/test-storage.ts +80 -0
- package/src/worker-realtime.ts +91 -0
- package/src/worker-runtime.test.ts +265 -0
- package/src/worker.ts +125 -0
- package/tsconfig.json +22 -0
- package/wrangler.jsonc +33 -0
- package/wrangler.realtime-gemini.jsonc +28 -0
- package/wrangler.realtime.jsonc +27 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/server-workers",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/worker.ts",
|
|
8
|
+
"types": "./src/worker.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@ai-sdk/openai": "^3.0.67",
|
|
11
|
+
"@kuralle-agents/core": "^0.8.5",
|
|
12
|
+
"@kuralle-agents/rag": "^0.8.5",
|
|
13
|
+
"@kuralle-agents/skills": "^0.8.5",
|
|
14
|
+
"@kuralle-agents/vectorize-store": "^0.8.5",
|
|
15
|
+
"ai": "^6.0.0",
|
|
16
|
+
"zod": "^4.1.8",
|
|
17
|
+
"@kuralle-syrinx/core": "2.1.0",
|
|
18
|
+
"@kuralle-syrinx/aisdk": "2.1.0",
|
|
19
|
+
"@kuralle-syrinx/server-websocket": "2.1.0",
|
|
20
|
+
"@kuralle-syrinx/ws": "2.1.0",
|
|
21
|
+
"@kuralle-syrinx/realtime": "2.1.0",
|
|
22
|
+
"@kuralle-syrinx/kuralle": "2.1.0",
|
|
23
|
+
"@kuralle-syrinx/cartesia": "2.1.0",
|
|
24
|
+
"@kuralle-syrinx/deepgram": "2.1.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@cloudflare/workers-types": "^4.20260601.0",
|
|
28
|
+
"esbuild": "^0.25.0",
|
|
29
|
+
"miniflare": "^4.20260601.0",
|
|
30
|
+
"typescript": "^5.7.0",
|
|
31
|
+
"vitest": "^2.1.0",
|
|
32
|
+
"wrangler": "^4.19.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:live": "SYRINX_LIVE_WORKER_TEST=1 vitest run",
|
|
38
|
+
"verify:edge-bundle": "../../scripts/verify-edge-bundle.sh",
|
|
39
|
+
"wrangler:dry-run": "wrangler deploy --dry-run",
|
|
40
|
+
"deploy:realtime": "wrangler deploy -c wrangler.realtime.jsonc"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
5
|
+
import { MemoryDurableStorage } from "./test-storage.js";
|
|
6
|
+
|
|
7
|
+
describe("DurableObjectAlarmScheduler", () => {
|
|
8
|
+
it("persists deadlines, fires due callbacks, and rearms the nearest remaining task", async () => {
|
|
9
|
+
const storage = new MemoryDurableStorage();
|
|
10
|
+
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
11
|
+
const fired: string[] = [];
|
|
12
|
+
|
|
13
|
+
scheduler.schedule("late", 1000, () => {
|
|
14
|
+
fired.push("late");
|
|
15
|
+
});
|
|
16
|
+
scheduler.schedule("soon", 50, () => {
|
|
17
|
+
fired.push("soon");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(storage.tasks.has("soon")).toBe(true);
|
|
21
|
+
expect(storage.alarm).toBeLessThanOrEqual(Date.now() + 1000);
|
|
22
|
+
|
|
23
|
+
await scheduler.runDue(Date.now() + 100);
|
|
24
|
+
|
|
25
|
+
expect(fired).toEqual(["soon"]);
|
|
26
|
+
expect(storage.tasks.has("soon")).toBe(false);
|
|
27
|
+
expect(storage.tasks.has("late")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("cancels persisted tasks before the alarm fires", async () => {
|
|
31
|
+
const storage = new MemoryDurableStorage();
|
|
32
|
+
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
33
|
+
let fired = false;
|
|
34
|
+
|
|
35
|
+
scheduler.schedule("task", 1, () => {
|
|
36
|
+
fired = true;
|
|
37
|
+
});
|
|
38
|
+
scheduler.cancel("task");
|
|
39
|
+
await scheduler.runDue(Date.now() + 10);
|
|
40
|
+
|
|
41
|
+
expect(fired).toBe(false);
|
|
42
|
+
expect(storage.tasks.size).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { ScheduledCallback, Scheduler } from "@kuralle-syrinx/core";
|
|
4
|
+
|
|
5
|
+
type SqlCursor<T> = Iterable<T>;
|
|
6
|
+
|
|
7
|
+
interface SqlStorage {
|
|
8
|
+
exec(query: string, ...bindings: unknown[]): SqlCursor<Record<string, unknown>>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DurableSchedulerStorage {
|
|
12
|
+
readonly sql: SqlStorage;
|
|
13
|
+
setAlarm(scheduledTime: number | Date): Promise<void>;
|
|
14
|
+
deleteAlarm(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DurableObjectAlarmScheduler implements Scheduler {
|
|
18
|
+
private readonly callbacks = new Map<string, ScheduledCallback>();
|
|
19
|
+
|
|
20
|
+
constructor(private readonly storage: DurableSchedulerStorage) {
|
|
21
|
+
this.storage.sql.exec(
|
|
22
|
+
"CREATE TABLE IF NOT EXISTS scheduled_tasks (key TEXT PRIMARY KEY, deadline_ms INTEGER NOT NULL)",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
schedule(key: string, delayMs: number, cb: ScheduledCallback): void {
|
|
27
|
+
const deadlineMs = Date.now() + Math.max(0, delayMs);
|
|
28
|
+
this.callbacks.set(key, cb);
|
|
29
|
+
this.storage.sql.exec(
|
|
30
|
+
"INSERT OR REPLACE INTO scheduled_tasks (key, deadline_ms) VALUES (?, ?)",
|
|
31
|
+
key,
|
|
32
|
+
deadlineMs,
|
|
33
|
+
);
|
|
34
|
+
void this.armNext();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
cancel(key: string): void {
|
|
38
|
+
this.callbacks.delete(key);
|
|
39
|
+
this.storage.sql.exec("DELETE FROM scheduled_tasks WHERE key = ?", key);
|
|
40
|
+
void this.armNext();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async runDue(nowMs = Date.now()): Promise<void> {
|
|
44
|
+
const due = [...this.storage.sql.exec(
|
|
45
|
+
"SELECT key FROM scheduled_tasks WHERE deadline_ms <= ? ORDER BY deadline_ms ASC",
|
|
46
|
+
nowMs,
|
|
47
|
+
)] as Array<{ key: string }>;
|
|
48
|
+
for (const row of due) {
|
|
49
|
+
this.storage.sql.exec("DELETE FROM scheduled_tasks WHERE key = ?", row.key);
|
|
50
|
+
const cb = this.callbacks.get(row.key);
|
|
51
|
+
this.callbacks.delete(row.key);
|
|
52
|
+
if (cb) await cb();
|
|
53
|
+
}
|
|
54
|
+
await this.armNext();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private async armNext(): Promise<void> {
|
|
58
|
+
const [next] = [...this.storage.sql.exec(
|
|
59
|
+
"SELECT deadline_ms FROM scheduled_tasks ORDER BY deadline_ms ASC LIMIT 1",
|
|
60
|
+
)] as Array<{ deadline_ms: number }>;
|
|
61
|
+
if (!next) {
|
|
62
|
+
await this.storage.deleteAlarm();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await this.storage.setAlarm(next.deadline_ms);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { ManagedSession } from "@kuralle-syrinx/server-websocket/session-store";
|
|
5
|
+
import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
6
|
+
import { DurableObjectSessionStore } from "./durable-session-store.js";
|
|
7
|
+
import { MemoryDurableStorage } from "./test-storage.js";
|
|
8
|
+
|
|
9
|
+
describe("DurableObjectSessionStore", () => {
|
|
10
|
+
it("persists turn metadata and restores it when the live session is recreated", async () => {
|
|
11
|
+
const storage = new MemoryDurableStorage();
|
|
12
|
+
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
13
|
+
const store = new DurableObjectSessionStore(storage, scheduler);
|
|
14
|
+
|
|
15
|
+
const first = await store.lease("s1", async () => managed("s1", "turn-a"));
|
|
16
|
+
store.update("s1", (stored) => {
|
|
17
|
+
stored.currentContextId = "turn-b";
|
|
18
|
+
stored.inputSequence.lastSequence = 7;
|
|
19
|
+
});
|
|
20
|
+
first.managed.connectionCount = 0;
|
|
21
|
+
await store.release("s1", 1000);
|
|
22
|
+
|
|
23
|
+
const recreatedStore = new DurableObjectSessionStore(storage, scheduler);
|
|
24
|
+
const second = await recreatedStore.lease("s1", async () => managed("s1", "fresh"));
|
|
25
|
+
|
|
26
|
+
expect(second.resumed).toBe(true);
|
|
27
|
+
expect(second.managed.currentContextId).toBe("turn-b");
|
|
28
|
+
expect(second.managed.inputSequence.lastSequence).toBe(7);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("deletes retained sessions when the scheduled release fires", async () => {
|
|
32
|
+
const storage = new MemoryDurableStorage();
|
|
33
|
+
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
34
|
+
const store = new DurableObjectSessionStore(storage, scheduler);
|
|
35
|
+
const leased = await store.lease("s2", async () => managed("s2", "turn-a"));
|
|
36
|
+
leased.managed.connectionCount = 0;
|
|
37
|
+
|
|
38
|
+
await store.release("s2", 5);
|
|
39
|
+
await scheduler.runDue(Date.now() + 10);
|
|
40
|
+
|
|
41
|
+
expect(await store.get("s2")).toBeNull();
|
|
42
|
+
expect(storage.sessions.has("s2")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function managed(id: string, currentContextId: string): ManagedSession {
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
session: {
|
|
50
|
+
start: async () => undefined,
|
|
51
|
+
close: async () => undefined,
|
|
52
|
+
} as ManagedSession["session"],
|
|
53
|
+
currentContextId,
|
|
54
|
+
contextSampleRates: new Map(),
|
|
55
|
+
inputSequence: { lastSequence: null },
|
|
56
|
+
turnMetricsTurns: new Map(),
|
|
57
|
+
closeTimer: null,
|
|
58
|
+
connectionCount: 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ManagedSession,
|
|
5
|
+
ManagedSessionLease,
|
|
6
|
+
SessionStore,
|
|
7
|
+
} from "@kuralle-syrinx/server-websocket/session-store";
|
|
8
|
+
import type { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
9
|
+
|
|
10
|
+
type SqlCursor<T> = Iterable<T>;
|
|
11
|
+
|
|
12
|
+
interface SqlStorage {
|
|
13
|
+
exec(query: string, ...bindings: unknown[]): SqlCursor<Record<string, unknown>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DurableSessionStorage {
|
|
17
|
+
readonly sql: SqlStorage;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SessionRow {
|
|
21
|
+
id: string;
|
|
22
|
+
current_context_id: string;
|
|
23
|
+
last_sequence: number | null;
|
|
24
|
+
retained_until_ms: number;
|
|
25
|
+
connection_count: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DurableObjectSessionStore implements SessionStore {
|
|
29
|
+
private readonly sessions = new Map<string, ManagedSession>();
|
|
30
|
+
private readonly pendingLeases = new Map<string, Promise<ManagedSession>>();
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly storage: DurableSessionStorage,
|
|
34
|
+
private readonly scheduler: DurableObjectAlarmScheduler,
|
|
35
|
+
) {
|
|
36
|
+
this.storage.sql.exec(
|
|
37
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
current_context_id TEXT NOT NULL,
|
|
40
|
+
last_sequence INTEGER,
|
|
41
|
+
retained_until_ms INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
connection_count INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
updated_at_ms INTEGER NOT NULL
|
|
44
|
+
)`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async lease(sessionId: string, create: () => Promise<ManagedSession>): Promise<ManagedSessionLease> {
|
|
49
|
+
const existing = this.sessions.get(sessionId);
|
|
50
|
+
if (existing) {
|
|
51
|
+
this.attachConnection(existing);
|
|
52
|
+
return { managed: existing, resumed: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let pending = this.pendingLeases.get(sessionId);
|
|
56
|
+
if (!pending) {
|
|
57
|
+
pending = create().then((managed) => {
|
|
58
|
+
const row = this.row(sessionId);
|
|
59
|
+
if (row) {
|
|
60
|
+
managed.currentContextId = row.current_context_id;
|
|
61
|
+
managed.inputSequence.lastSequence = row.last_sequence;
|
|
62
|
+
}
|
|
63
|
+
this.sessions.set(sessionId, managed);
|
|
64
|
+
this.persist(managed, 0);
|
|
65
|
+
return managed;
|
|
66
|
+
}).finally(() => {
|
|
67
|
+
this.pendingLeases.delete(sessionId);
|
|
68
|
+
});
|
|
69
|
+
this.pendingLeases.set(sessionId, pending);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const row = this.row(sessionId);
|
|
73
|
+
const managed = await pending;
|
|
74
|
+
this.attachConnection(managed);
|
|
75
|
+
return { managed, resumed: Boolean(row && row.retained_until_ms > Date.now()) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async release(sessionId: string, retainMs: number): Promise<void> {
|
|
79
|
+
const managed = this.sessions.get(sessionId);
|
|
80
|
+
if (!managed) return;
|
|
81
|
+
if (managed.connectionCount > 0) return;
|
|
82
|
+
|
|
83
|
+
if (retainMs <= 0) {
|
|
84
|
+
this.sessions.delete(sessionId);
|
|
85
|
+
this.storage.sql.exec("DELETE FROM sessions WHERE id = ?", sessionId);
|
|
86
|
+
await managed.session.close().catch(() => undefined);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const retainedUntilMs = Date.now() + retainMs;
|
|
91
|
+
this.persist(managed, retainedUntilMs);
|
|
92
|
+
this.scheduler.schedule(`session.retain:${sessionId}`, retainMs, async () => {
|
|
93
|
+
const current = this.sessions.get(sessionId);
|
|
94
|
+
if (!current || current.connectionCount > 0) return;
|
|
95
|
+
this.sessions.delete(sessionId);
|
|
96
|
+
this.storage.sql.exec("DELETE FROM sessions WHERE id = ?", sessionId);
|
|
97
|
+
await current.session.close().catch(() => undefined);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
update(sessionId: string, mutate: (managed: ManagedSession) => void): void {
|
|
102
|
+
const managed = this.sessions.get(sessionId);
|
|
103
|
+
if (!managed) return;
|
|
104
|
+
mutate(managed);
|
|
105
|
+
this.persist(managed, 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async get(sessionId: string): Promise<ManagedSession | null> {
|
|
109
|
+
return this.sessions.get(sessionId) ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async listAll(): Promise<readonly ManagedSession[]> {
|
|
113
|
+
return [...this.sessions.values()];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async clear(): Promise<void> {
|
|
117
|
+
for (const managed of this.sessions.values()) {
|
|
118
|
+
await managed.session.close().catch(() => undefined);
|
|
119
|
+
}
|
|
120
|
+
this.sessions.clear();
|
|
121
|
+
this.storage.sql.exec("DELETE FROM sessions");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private attachConnection(managed: ManagedSession): void {
|
|
125
|
+
this.scheduler.cancel(`session.retain:${managed.id}`);
|
|
126
|
+
managed.connectionCount += 1;
|
|
127
|
+
this.persist(managed, 0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private persist(managed: ManagedSession, retainedUntilMs: number): void {
|
|
131
|
+
this.storage.sql.exec(
|
|
132
|
+
`INSERT OR REPLACE INTO sessions
|
|
133
|
+
(id, current_context_id, last_sequence, retained_until_ms, connection_count, updated_at_ms)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
135
|
+
managed.id,
|
|
136
|
+
managed.currentContextId,
|
|
137
|
+
managed.inputSequence.lastSequence,
|
|
138
|
+
retainedUntilMs,
|
|
139
|
+
managed.connectionCount,
|
|
140
|
+
Date.now(),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private row(sessionId: string): SessionRow | null {
|
|
145
|
+
const [row] = [...this.storage.sql.exec("SELECT * FROM sessions WHERE id = ?", sessionId)] as unknown as SessionRow[];
|
|
146
|
+
return row ?? null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
4
|
+
import {
|
|
5
|
+
defineAgent,
|
|
6
|
+
defineFlow,
|
|
7
|
+
defineTool,
|
|
8
|
+
buildToolSet,
|
|
9
|
+
createRuntime,
|
|
10
|
+
MemoryStore,
|
|
11
|
+
reply,
|
|
12
|
+
type Runtime,
|
|
13
|
+
} from "@kuralle-agents/core";
|
|
14
|
+
import {
|
|
15
|
+
AiSdkEmbedder,
|
|
16
|
+
VectorRetriever,
|
|
17
|
+
type VectorStoreCore,
|
|
18
|
+
} from "@kuralle-agents/rag";
|
|
19
|
+
import { defineSkill } from "@kuralle-agents/skills";
|
|
20
|
+
import { CloudflareVectorizeStore, type VectorizeBinding } from "@kuralle-agents/vectorize-store";
|
|
21
|
+
import type { VectorizeIndex } from "@cloudflare/workers-types";
|
|
22
|
+
import { fromKuralleRuntime, type KuralleRuntimeLike } from "@kuralle-syrinx/kuralle";
|
|
23
|
+
import type { Reasoner } from "@kuralle-syrinx/core";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_MODEL = "gpt-4.1-mini";
|
|
27
|
+
const INDEX_NAME = "kuralle-university-kb";
|
|
28
|
+
|
|
29
|
+
const SCHOLARSHIP_SKILL = defineSkill({
|
|
30
|
+
name: "scholarship-guidance",
|
|
31
|
+
description: "Guide students through scholarship eligibility and application steps",
|
|
32
|
+
body: `# Scholarship Guidance
|
|
33
|
+
|
|
34
|
+
1. Ask whether the student is seeking merit-based aid, need-based aid, or both.
|
|
35
|
+
2. For merit: cite the Dean's Merit Scholarship (GPA ≥ 3.5).
|
|
36
|
+
3. For need: cite the Need-Based Grant (FAFSA required).
|
|
37
|
+
4. Always mention the application deadline of February 15.`,
|
|
38
|
+
allowedTools: [],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let bookingCounter = 0;
|
|
42
|
+
|
|
43
|
+
const createBookingTool = defineTool({
|
|
44
|
+
name: "create_booking",
|
|
45
|
+
description: "Finalize the advisor appointment booking. Only call after the user has explicitly confirmed the details.",
|
|
46
|
+
input: z.object({}),
|
|
47
|
+
execute: async () => {
|
|
48
|
+
bookingCounter += 1;
|
|
49
|
+
return { bookingRef: `ADV-${bookingCounter}` };
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const recordBookingDetails = defineTool({
|
|
54
|
+
name: "record_booking_details",
|
|
55
|
+
description: "Record the student's name, program, and preferred date once all three are known.",
|
|
56
|
+
input: z.object({ name: z.string(), program: z.string(), preferredDate: z.string() }),
|
|
57
|
+
execute: async (details) => details,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const requestTranscriptTool = defineTool({
|
|
61
|
+
name: "request_transcript",
|
|
62
|
+
description: "Submit an official transcript request for the given studentId.",
|
|
63
|
+
input: z.object({ studentId: z.string() }),
|
|
64
|
+
execute: async ({ studentId }) => ({ requestRef: `TR-${studentId}` }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const replyBooked = reply({
|
|
68
|
+
id: "reply-booked",
|
|
69
|
+
grounding: { knowledge: { autoRetrieve: false } },
|
|
70
|
+
instructions: ({ state }) =>
|
|
71
|
+
`The advisor appointment is booked. Confirm it to the user and include this exact reference verbatim: ${String(state["bookingRef"] ?? "")}.`,
|
|
72
|
+
next: () => ({ end: "booked" }),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const confirmBooking = reply({
|
|
76
|
+
id: "confirm-booking",
|
|
77
|
+
grounding: { knowledge: { autoRetrieve: false } },
|
|
78
|
+
instructions: ({ state }) =>
|
|
79
|
+
`Summarize the advisor appointment — name: ${String(state["name"] ?? "")}, program: ${String(state["program"] ?? "")}, date: ${String(state["preferredDate"] ?? "")} — and ask the user to confirm. ONLY when the user clearly confirms, call create_booking to finalize. If they want changes, ask for the corrected detail.`,
|
|
80
|
+
tools: buildToolSet({ create_booking: createBookingTool }),
|
|
81
|
+
next: (turn) => {
|
|
82
|
+
const r = turn.toolResults.find((t) => t.name === "create_booking");
|
|
83
|
+
if (r?.result && typeof r.result === "object") return { goto: replyBooked, data: r.result as Record<string, unknown> };
|
|
84
|
+
return "stay";
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const collectBooking = reply({
|
|
89
|
+
id: "collect-booking",
|
|
90
|
+
grounding: { knowledge: { autoRetrieve: false } },
|
|
91
|
+
instructions:
|
|
92
|
+
"Help the user book an advisor appointment. If you don't yet have all three of name, program, and preferred date, ask for the missing ones. Once you have all three, call record_booking_details.",
|
|
93
|
+
tools: buildToolSet({ record_booking_details: recordBookingDetails }),
|
|
94
|
+
next: (turn) => {
|
|
95
|
+
const r = turn.toolResults.find((t) => t.name === "record_booking_details");
|
|
96
|
+
if (r?.result && typeof r.result === "object") return { goto: confirmBooking, data: r.result as Record<string, unknown> };
|
|
97
|
+
return "stay";
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const bookingFlow = defineFlow({
|
|
102
|
+
name: "book-advisor-appointment",
|
|
103
|
+
description: "Book an appointment with an academic advisor",
|
|
104
|
+
start: collectBooking,
|
|
105
|
+
nodes: [collectBooking, confirmBooking, replyBooked],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const replyTranscript = reply({
|
|
109
|
+
id: "reply-transcript",
|
|
110
|
+
grounding: { knowledge: { autoRetrieve: false } },
|
|
111
|
+
instructions: ({ state }) =>
|
|
112
|
+
`Confirm the transcript request was submitted. Include this exact request reference verbatim: ${String(state["requestRef"] ?? "")}.`,
|
|
113
|
+
next: () => ({ end: "requested" }),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const collectTranscript = reply({
|
|
117
|
+
id: "collect-transcript",
|
|
118
|
+
grounding: { knowledge: { autoRetrieve: false } },
|
|
119
|
+
instructions:
|
|
120
|
+
"Help the user request an official transcript. If you don't have their student ID, ask for it. Once you have it, call request_transcript with that studentId.",
|
|
121
|
+
tools: buildToolSet({ request_transcript: requestTranscriptTool }),
|
|
122
|
+
next: (turn) => {
|
|
123
|
+
const r = turn.toolResults.find((t) => t.name === "request_transcript");
|
|
124
|
+
if (r?.result && typeof r.result === "object") return { goto: replyTranscript, data: r.result as Record<string, unknown> };
|
|
125
|
+
return "stay";
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const transcriptFlow = defineFlow({
|
|
130
|
+
name: "request-transcript",
|
|
131
|
+
description: "Request an official academic transcript",
|
|
132
|
+
start: collectTranscript,
|
|
133
|
+
nodes: [collectTranscript, replyTranscript],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export interface KuralleRealtimeEnv {
|
|
137
|
+
readonly OPENAI_API_KEY?: string;
|
|
138
|
+
readonly OPENAI_MODEL?: string;
|
|
139
|
+
readonly VECTORIZE: VectorizeIndex;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface CreateRealtimeKuralleReasonerOptions {
|
|
143
|
+
readonly sessionId: string;
|
|
144
|
+
readonly userId?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let runtimePromise: Promise<Runtime> | undefined;
|
|
148
|
+
|
|
149
|
+
function vectorStore(env: KuralleRealtimeEnv): VectorStoreCore {
|
|
150
|
+
return new CloudflareVectorizeStore({ binding: env.VECTORIZE as unknown as VectorizeBinding });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function createUniversityRuntime(env: KuralleRealtimeEnv): Promise<Runtime> {
|
|
154
|
+
const apiKey = requireKey(env.OPENAI_API_KEY, "OPENAI_API_KEY");
|
|
155
|
+
const openai = createOpenAI({ apiKey });
|
|
156
|
+
const model = openai(env.OPENAI_MODEL?.trim() || DEFAULT_MODEL);
|
|
157
|
+
const embedder = new AiSdkEmbedder({ model: openai.embedding("text-embedding-3-small") });
|
|
158
|
+
const retriever = new VectorRetriever({
|
|
159
|
+
vectorStore: vectorStore(env),
|
|
160
|
+
embedder,
|
|
161
|
+
indexName: INDEX_NAME,
|
|
162
|
+
topK: 3,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const agent = defineAgent({
|
|
166
|
+
id: "university",
|
|
167
|
+
model,
|
|
168
|
+
instructions:
|
|
169
|
+
"You are a friendly university support assistant. Answer admissions, program, tuition, and scholarship questions clearly and concisely for voice.",
|
|
170
|
+
knowledge: { autoRetrieve: true },
|
|
171
|
+
skills: [SCHOLARSHIP_SKILL],
|
|
172
|
+
flows: [bookingFlow, transcriptFlow],
|
|
173
|
+
tools: {
|
|
174
|
+
create_booking: createBookingTool,
|
|
175
|
+
request_transcript: requestTranscriptTool,
|
|
176
|
+
},
|
|
177
|
+
memory: { workingMemory: { autoLoad: [{ scope: "user", key: "USER" }] } },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return createRuntime({
|
|
181
|
+
agents: [agent],
|
|
182
|
+
defaultAgentId: "university",
|
|
183
|
+
sessionStore: new MemoryStore(),
|
|
184
|
+
knowledge: { retriever, embedder },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getRuntime(env: KuralleRealtimeEnv): Promise<Runtime> {
|
|
189
|
+
return (runtimePromise ??= createUniversityRuntime(env));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function createRealtimeKuralleReasoner(
|
|
193
|
+
env: KuralleRealtimeEnv,
|
|
194
|
+
opts: CreateRealtimeKuralleReasonerOptions,
|
|
195
|
+
): Promise<Reasoner> {
|
|
196
|
+
const runtime = await getRuntime(env);
|
|
197
|
+
return fromKuralleRuntime(runtime as unknown as KuralleRuntimeLike, {
|
|
198
|
+
sessionId: opts.sessionId,
|
|
199
|
+
userId: opts.userId ?? "voice",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export type KuralleCascadeEnv = KuralleRealtimeEnv;
|
|
204
|
+
export type CreateCascadeKuralleReasonerOptions = CreateRealtimeKuralleReasonerOptions;
|
|
205
|
+
export const createCascadeKuralleReasoner = createRealtimeKuralleReasoner;
|
|
206
|
+
|
|
207
|
+
function requireKey(value: string | undefined, name: string): string {
|
|
208
|
+
const trimmed = value?.trim();
|
|
209
|
+
if (!trimmed) throw new Error(`${name} is required to start a realtime voice session`);
|
|
210
|
+
return trimmed;
|
|
211
|
+
}
|