auggy 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. package/src/types.ts +1009 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Shared Telegram bot API client.
3
+ *
4
+ * Used by both `notify`'s telegram adapter and `telegramTransport`. NOT a
5
+ * cross-augment dependency — peer library import only. Mirrors the shared-
6
+ * utility pattern of `src/http.ts` and `src/engines/_shared/cost.ts`.
7
+ *
8
+ * Future bot-API surface (file uploads, editMessageText for streaming-edit
9
+ * replies, reactions, inline keyboards) MUST land here, not be duplicated
10
+ * across notify and telegramTransport.
11
+ */
12
+
13
+ import type { HttpClient } from "./http";
14
+ import { createHttpClient } from "./http";
15
+
16
+ export interface SendMessageOptions {
17
+ parseMode?: "Markdown" | "HTML" | "MarkdownV2";
18
+ replyToMessageId?: number;
19
+ disableNotification?: boolean;
20
+ }
21
+
22
+ export interface SendMessageResult {
23
+ messageId: number;
24
+ chatId: number | string;
25
+ }
26
+
27
+ export interface GetUpdatesOptions {
28
+ offset?: number;
29
+ timeoutSec?: number;
30
+ allowedUpdates?: string[];
31
+ }
32
+
33
+ export interface SetWebhookOptions {
34
+ allowedUpdates?: string[];
35
+ dropPendingUpdates?: boolean;
36
+ }
37
+
38
+ export interface TelegramUpdate {
39
+ update_id: number;
40
+ message?: TelegramMessage;
41
+ }
42
+
43
+ export interface TelegramMessage {
44
+ message_id: number;
45
+ from?: { id: number; is_bot: boolean; first_name?: string; username?: string };
46
+ chat: { id: number; type: string };
47
+ date: number;
48
+ text?: string;
49
+ }
50
+
51
+ export interface TelegramChat {
52
+ id: number;
53
+ type: string;
54
+ title?: string;
55
+ first_name?: string;
56
+ username?: string;
57
+ }
58
+
59
+ export interface TelegramBotClient {
60
+ sendMessage(
61
+ chatId: number | string,
62
+ text: string,
63
+ opts?: SendMessageOptions,
64
+ ): Promise<SendMessageResult>;
65
+ getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]>;
66
+ setWebhook(url: string, secretToken: string, opts?: SetWebhookOptions): Promise<void>;
67
+ deleteWebhook(): Promise<void>;
68
+ getChat(chatId: number | string): Promise<TelegramChat>;
69
+ }
70
+
71
+ export interface CreateTelegramBotClientOptions {
72
+ botToken: string;
73
+ client?: Pick<HttpClient, "post">;
74
+ baseUrl?: string;
75
+ }
76
+
77
+ interface BotApiResponse<T> {
78
+ ok: boolean;
79
+ result?: T;
80
+ description?: string;
81
+ error_code?: number;
82
+ }
83
+
84
+ export function createTelegramBotClient(opts: CreateTelegramBotClientOptions): TelegramBotClient {
85
+ const baseUrl = opts.baseUrl ?? "https://api.telegram.org";
86
+ const url = (method: string) => `${baseUrl}/bot${opts.botToken}/${method}`;
87
+ const http =
88
+ opts.client ?? createHttpClient({ timeoutMs: 60_000, userAgent: "auggy-telegram/0.1" });
89
+
90
+ async function call<T>(method: string, body: Record<string, unknown>): Promise<T> {
91
+ const res = await http.post(url(method), {
92
+ headers: { "content-type": "application/json" },
93
+ body: JSON.stringify(body),
94
+ });
95
+ let parsed: BotApiResponse<T>;
96
+ try {
97
+ parsed = JSON.parse(res.body) as BotApiResponse<T>;
98
+ } catch {
99
+ throw new Error(`Telegram bot API ${method}: non-JSON response (${res.status})`);
100
+ }
101
+ if (!parsed.ok) {
102
+ throw new Error(
103
+ `Telegram bot API ${method}: ${parsed.description ?? "unknown error"} (${res.status})`,
104
+ );
105
+ }
106
+ return parsed.result as T;
107
+ }
108
+
109
+ return {
110
+ async sendMessage(chatId, text, sendOpts) {
111
+ const body: Record<string, unknown> = { chat_id: chatId, text };
112
+ if (sendOpts?.parseMode) body.parse_mode = sendOpts.parseMode;
113
+ if (sendOpts?.replyToMessageId != null) body.reply_to_message_id = sendOpts.replyToMessageId;
114
+ if (sendOpts?.disableNotification) body.disable_notification = true;
115
+ const result = await call<{ message_id: number; chat: { id: number | string } }>(
116
+ "sendMessage",
117
+ body,
118
+ );
119
+ return { messageId: result.message_id, chatId: result.chat.id };
120
+ },
121
+
122
+ async getUpdates(getOpts) {
123
+ const body: Record<string, unknown> = {};
124
+ if (getOpts.offset != null) body.offset = getOpts.offset;
125
+ if (getOpts.timeoutSec != null) body.timeout = getOpts.timeoutSec;
126
+ if (getOpts.allowedUpdates) body.allowed_updates = getOpts.allowedUpdates;
127
+ return await call<TelegramUpdate[]>("getUpdates", body);
128
+ },
129
+
130
+ async setWebhook(webhookUrl, secretToken, webhookOpts) {
131
+ const body: Record<string, unknown> = { url: webhookUrl, secret_token: secretToken };
132
+ if (webhookOpts?.allowedUpdates) body.allowed_updates = webhookOpts.allowedUpdates;
133
+ if (webhookOpts?.dropPendingUpdates) body.drop_pending_updates = true;
134
+ await call<true>("setWebhook", body);
135
+ },
136
+
137
+ async deleteWebhook() {
138
+ await call<true>("deleteWebhook", {});
139
+ },
140
+
141
+ async getChat(chatId) {
142
+ return await call<TelegramChat>("getChat", { chat_id: chatId });
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,14 @@
1
+ export interface Tokenizer {
2
+ count(text: string): number;
3
+ }
4
+
5
+ export function createTokenizer(): Tokenizer {
6
+ return {
7
+ count(text: string): number {
8
+ if (text.length === 0) return 0;
9
+ // ~4 chars per token approximation. Accurate to ±15% for English.
10
+ // Replace with tiktoken for production accuracy.
11
+ return Math.ceil(text.length / 4);
12
+ },
13
+ };
14
+ }
@@ -0,0 +1,253 @@
1
+ import type { KernelEvent, TaskState } from "../types";
2
+
3
+ // === AG-UI event shapes (subset we emit in v1) ===
4
+ // These match the AG-UI spec at https://docs.ag-ui.com/concepts/events.md
5
+
6
+ export interface AGUIBaseEvent {
7
+ type: string;
8
+ timestamp?: number;
9
+ }
10
+
11
+ export interface AGUIRunStarted extends AGUIBaseEvent {
12
+ type: "RUN_STARTED";
13
+ threadId: string;
14
+ runId: string;
15
+ parentRunId?: string;
16
+ }
17
+
18
+ export interface AGUIRunFinished extends AGUIBaseEvent {
19
+ type: "RUN_FINISHED";
20
+ threadId: string;
21
+ runId: string;
22
+ /**
23
+ * Optional structured result. v1 carries the terminal status discriminator
24
+ * so consumers can distinguish "agent finished" from "agent waiting for
25
+ * user input." Old AG-UI clients that ignore `result` keep working.
26
+ */
27
+ result?: {
28
+ status: TaskState;
29
+ message?: string;
30
+ };
31
+ }
32
+
33
+ export interface AGUIRunError extends AGUIBaseEvent {
34
+ type: "RUN_ERROR";
35
+ message: string;
36
+ code?: string;
37
+ }
38
+
39
+ export interface AGUITextMessageStart extends AGUIBaseEvent {
40
+ type: "TEXT_MESSAGE_START";
41
+ messageId: string;
42
+ role: "assistant" | "user" | "system" | "tool" | "developer";
43
+ }
44
+
45
+ export interface AGUITextMessageContent extends AGUIBaseEvent {
46
+ type: "TEXT_MESSAGE_CONTENT";
47
+ messageId: string;
48
+ delta: string;
49
+ }
50
+
51
+ export interface AGUITextMessageEnd extends AGUIBaseEvent {
52
+ type: "TEXT_MESSAGE_END";
53
+ messageId: string;
54
+ }
55
+
56
+ export interface AGUIToolCallStart extends AGUIBaseEvent {
57
+ type: "TOOL_CALL_START";
58
+ toolCallId: string;
59
+ toolCallName: string;
60
+ parentMessageId?: string;
61
+ }
62
+
63
+ export interface AGUIToolCallArgs extends AGUIBaseEvent {
64
+ type: "TOOL_CALL_ARGS";
65
+ toolCallId: string;
66
+ delta: string;
67
+ }
68
+
69
+ export interface AGUIToolCallEnd extends AGUIBaseEvent {
70
+ type: "TOOL_CALL_END";
71
+ toolCallId: string;
72
+ }
73
+
74
+ export interface AGUIToolCallResult extends AGUIBaseEvent {
75
+ type: "TOOL_CALL_RESULT";
76
+ messageId: string;
77
+ toolCallId: string;
78
+ content: string;
79
+ role?: "tool";
80
+ }
81
+
82
+ export type AGUIEvent =
83
+ | AGUIRunStarted
84
+ | AGUIRunFinished
85
+ | AGUIRunError
86
+ | AGUITextMessageStart
87
+ | AGUITextMessageContent
88
+ | AGUITextMessageEnd
89
+ | AGUIToolCallStart
90
+ | AGUIToolCallArgs
91
+ | AGUIToolCallEnd
92
+ | AGUIToolCallResult;
93
+
94
+ // === Constructor helpers ===
95
+
96
+ export function runStarted(opts: { threadId: string; runId: string }): AGUIRunStarted {
97
+ return { type: "RUN_STARTED", ...opts };
98
+ }
99
+
100
+ export function runFinished(opts: {
101
+ threadId: string;
102
+ runId: string;
103
+ status?: TaskState;
104
+ message?: string;
105
+ }): AGUIRunFinished {
106
+ const base: AGUIRunFinished = {
107
+ type: "RUN_FINISHED",
108
+ threadId: opts.threadId,
109
+ runId: opts.runId,
110
+ };
111
+ if (opts.status !== undefined) {
112
+ base.result = opts.message
113
+ ? { status: opts.status, message: opts.message }
114
+ : { status: opts.status };
115
+ }
116
+ return base;
117
+ }
118
+
119
+ export function runError(opts: { message: string; code?: string }): AGUIRunError {
120
+ return { type: "RUN_ERROR", ...opts };
121
+ }
122
+
123
+ export function textMessageStart(opts: {
124
+ messageId: string;
125
+ role: AGUITextMessageStart["role"];
126
+ }): AGUITextMessageStart {
127
+ return { type: "TEXT_MESSAGE_START", ...opts };
128
+ }
129
+
130
+ export function textMessageContent(opts: {
131
+ messageId: string;
132
+ delta: string;
133
+ }): AGUITextMessageContent {
134
+ return { type: "TEXT_MESSAGE_CONTENT", ...opts };
135
+ }
136
+
137
+ export function textMessageEnd(opts: { messageId: string }): AGUITextMessageEnd {
138
+ return { type: "TEXT_MESSAGE_END", ...opts };
139
+ }
140
+
141
+ export function toolCallStart(opts: {
142
+ toolCallId: string;
143
+ toolCallName: string;
144
+ parentMessageId?: string;
145
+ }): AGUIToolCallStart {
146
+ return { type: "TOOL_CALL_START", ...opts };
147
+ }
148
+
149
+ export function toolCallArgs(opts: { toolCallId: string; delta: string }): AGUIToolCallArgs {
150
+ return { type: "TOOL_CALL_ARGS", ...opts };
151
+ }
152
+
153
+ export function toolCallEnd(opts: { toolCallId: string }): AGUIToolCallEnd {
154
+ return { type: "TOOL_CALL_END", ...opts };
155
+ }
156
+
157
+ export function toolCallResult(opts: {
158
+ messageId: string;
159
+ toolCallId: string;
160
+ content: string;
161
+ }): AGUIToolCallResult {
162
+ return { type: "TOOL_CALL_RESULT", role: "tool", ...opts };
163
+ }
164
+
165
+ // === Kernel → AG-UI translation ===
166
+
167
+ /**
168
+ * Translate a single kernel event into zero or more AG-UI events.
169
+ * Some kernel events map 1:1, others expand (e.g. text_message emits
170
+ * a START / CONTENT / END triple since v1 doesn't stream tokens).
171
+ *
172
+ * Note: run_finished and run_error emit events with empty threadId —
173
+ * the transport patches it before serialization, since threadId is
174
+ * known at the transport layer.
175
+ */
176
+ export function translateKernelEvent(event: KernelEvent): AGUIEvent[] {
177
+ switch (event.kind) {
178
+ case "run_started":
179
+ return [runStarted({ threadId: event.threadId, runId: event.turnId })];
180
+
181
+ case "tool_call_started":
182
+ return [
183
+ toolCallStart({
184
+ toolCallId: event.toolCallId,
185
+ toolCallName: event.toolName,
186
+ }),
187
+ ];
188
+
189
+ case "tool_call_args":
190
+ return [
191
+ toolCallArgs({
192
+ toolCallId: event.toolCallId,
193
+ delta: JSON.stringify(event.args),
194
+ }),
195
+ ];
196
+
197
+ case "tool_call_result":
198
+ return [
199
+ toolCallEnd({ toolCallId: event.toolCallId }),
200
+ toolCallResult({
201
+ messageId: `${event.toolCallId}-result`,
202
+ toolCallId: event.toolCallId,
203
+ content: event.output,
204
+ }),
205
+ ];
206
+
207
+ case "text_message":
208
+ return [
209
+ textMessageStart({ messageId: event.messageId, role: event.role }),
210
+ textMessageContent({ messageId: event.messageId, delta: event.text }),
211
+ textMessageEnd({ messageId: event.messageId }),
212
+ ];
213
+
214
+ // Streaming text lifecycle: emitted by engines that support onDelta.
215
+ // Each maps 1:1 to its AG-UI counterpart.
216
+ case "text_message_start":
217
+ return [textMessageStart({ messageId: event.messageId, role: event.role })];
218
+
219
+ case "text_message_delta":
220
+ return [textMessageContent({ messageId: event.messageId, delta: event.delta })];
221
+
222
+ case "text_message_end":
223
+ return [textMessageEnd({ messageId: event.messageId })];
224
+
225
+ case "run_finished":
226
+ return [
227
+ runFinished({
228
+ threadId: "",
229
+ runId: event.turnId,
230
+ status: event.status,
231
+ ...(event.message !== undefined && { message: event.message }),
232
+ }),
233
+ ];
234
+
235
+ case "run_error":
236
+ // Only emit RUN_ERROR here. The turn loop always emits a separate
237
+ // run_finished kernel event after run_error (see turn-loop.ts), which
238
+ // produces the terminal RUN_FINISHED. Emitting one here would cause
239
+ // clients to see two terminal events for a single failing turn.
240
+ return [runError({ message: event.message, code: event.source })];
241
+ }
242
+ }
243
+
244
+ // === SSE serialization ===
245
+
246
+ /**
247
+ * Format an AG-UI event as a single Server-Sent Events data frame.
248
+ * Each event is a JSON object prefixed with `data: ` and followed
249
+ * by two newlines to mark the end of the frame.
250
+ */
251
+ export function serializeSSE(event: AGUIEvent): string {
252
+ return `data: ${JSON.stringify(event)}\n\n`;
253
+ }
@@ -0,0 +1,82 @@
1
+ export interface VisitorTokenPayload {
2
+ visitorId: string;
3
+ agentId: string;
4
+ issuedAt: number;
5
+ expiresAt: number;
6
+ }
7
+
8
+ const PURPOSE = "auggy-visitor-signing";
9
+ const encoder = new TextEncoder();
10
+
11
+ export async function deriveSigningKey(bearerToken: string): Promise<CryptoKey> {
12
+ const keyMaterial = await crypto.subtle.importKey(
13
+ "raw",
14
+ encoder.encode(bearerToken),
15
+ { name: "HMAC", hash: "SHA-256" },
16
+ false,
17
+ ["sign"],
18
+ );
19
+ const derived = await crypto.subtle.sign("HMAC", keyMaterial, encoder.encode(PURPOSE));
20
+ return crypto.subtle.importKey("raw", derived, { name: "HMAC", hash: "SHA-256" }, true, [
21
+ "sign",
22
+ "verify",
23
+ ]);
24
+ }
25
+
26
+ export async function createVisitorToken(
27
+ key: CryptoKey,
28
+ agentId: string,
29
+ ttlSeconds: number,
30
+ /**
31
+ * Optional pre-existing visitorId. When omitted (the common case for first
32
+ * verification or anonymous-recognized issuance), a fresh `vis_<uuid>` is
33
+ * minted. When provided (re-verification of an already-known email), the
34
+ * existing identifier is preserved so peer-scoped state in layered-memory
35
+ * remains continuous across re-verifications.
36
+ */
37
+ existingVisitorId?: string,
38
+ ): Promise<{ token: string; payload: VisitorTokenPayload }> {
39
+ const payload: VisitorTokenPayload = {
40
+ visitorId: existingVisitorId ?? `vis_${crypto.randomUUID()}`,
41
+ agentId,
42
+ issuedAt: Date.now(),
43
+ expiresAt: Date.now() + ttlSeconds * 1000,
44
+ };
45
+ const payloadB64 = btoa(JSON.stringify(payload));
46
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payloadB64));
47
+ const sigB64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
48
+ return { token: `${payloadB64}.${sigB64}`, payload };
49
+ }
50
+
51
+ export async function verifyVisitorToken(
52
+ key: CryptoKey,
53
+ token: string,
54
+ ): Promise<VisitorTokenPayload | null> {
55
+ const parts = token.split(".");
56
+ if (parts.length !== 2) return null;
57
+ const [payloadB64, sigB64] = parts as [string, string];
58
+
59
+ let sigBytes: Uint8Array;
60
+ try {
61
+ sigBytes = Uint8Array.from(atob(sigB64), (c) => c.charCodeAt(0));
62
+ } catch {
63
+ return null;
64
+ }
65
+
66
+ const sigBuffer = sigBytes.buffer.slice(
67
+ sigBytes.byteOffset,
68
+ sigBytes.byteOffset + sigBytes.byteLength,
69
+ ) as ArrayBuffer;
70
+ const valid = await crypto.subtle.verify("HMAC", key, sigBuffer, encoder.encode(payloadB64));
71
+ if (!valid) return null;
72
+
73
+ let payload: VisitorTokenPayload;
74
+ try {
75
+ payload = JSON.parse(atob(payloadB64)) as VisitorTokenPayload;
76
+ } catch {
77
+ return null;
78
+ }
79
+
80
+ if (payload.expiresAt < Date.now()) return null;
81
+ return payload;
82
+ }