happy-coder 0.2.2 → 0.2.3-beta.1
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.cjs +2713 -1846
- package/dist/index.mjs +2511 -1644
- package/dist/lib.cjs +1 -1
- package/dist/lib.d.cts +8 -6
- package/dist/lib.d.mts +8 -6
- package/dist/lib.mjs +1 -1
- package/dist/types-BG9AgCI4.mjs +875 -0
- package/dist/types-BX4xv8Ty.mjs +881 -0
- package/dist/types-BeUppqJU.cjs +886 -0
- package/dist/types-C6Wx_bRW.cjs +886 -0
- package/dist/types-CKUdOV6c.mjs +875 -0
- package/dist/types-CNuBtNA5.cjs +884 -0
- package/dist/types-DXK5YldG.cjs +892 -0
- package/dist/types-ikrrEcJm.mjs +873 -0
- package/package.json +9 -3
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { appendFileSync } from 'fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { mkdir } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { io } from 'socket.io-client';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
12
|
+
import tweetnacl from 'tweetnacl';
|
|
13
|
+
import { Expo } from 'expo-server-sdk';
|
|
14
|
+
|
|
15
|
+
class Configuration {
|
|
16
|
+
serverUrl;
|
|
17
|
+
installationLocation;
|
|
18
|
+
isDaemonProcess;
|
|
19
|
+
// Directories and paths (from persistence)
|
|
20
|
+
happyDir;
|
|
21
|
+
logsDir;
|
|
22
|
+
daemonLogsDir;
|
|
23
|
+
settingsFile;
|
|
24
|
+
privateKeyFile;
|
|
25
|
+
daemonPidFile;
|
|
26
|
+
constructor(location, serverUrl) {
|
|
27
|
+
this.serverUrl = serverUrl || process.env.HANDY_SERVER_URL || "https://handy-api.korshakov.org";
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
this.isDaemonProcess = args.length >= 2 && args[0] === "daemon" && (args[1] === "start" || args[1] === "stop");
|
|
30
|
+
if (location === "local") {
|
|
31
|
+
this.happyDir = join(process.cwd(), ".happy");
|
|
32
|
+
this.installationLocation = "local";
|
|
33
|
+
} else if (location === "global") {
|
|
34
|
+
this.happyDir = join(homedir(), ".happy");
|
|
35
|
+
this.installationLocation = "global";
|
|
36
|
+
} else {
|
|
37
|
+
this.happyDir = join(location, ".happy");
|
|
38
|
+
this.installationLocation = "global";
|
|
39
|
+
}
|
|
40
|
+
this.logsDir = join(this.happyDir, "logs");
|
|
41
|
+
this.daemonLogsDir = join(this.happyDir, "logs-daemon");
|
|
42
|
+
this.settingsFile = join(this.happyDir, "settings.json");
|
|
43
|
+
this.privateKeyFile = join(this.happyDir, "access.key");
|
|
44
|
+
this.daemonPidFile = join(this.happyDir, "daemon.pid");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let configuration = void 0;
|
|
48
|
+
function initializeConfiguration(location, serverUrl) {
|
|
49
|
+
configuration = new Configuration(location, serverUrl);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createTimestampForFilename(date = /* @__PURE__ */ new Date()) {
|
|
53
|
+
return date.toLocaleString("sv-SE", {
|
|
54
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
55
|
+
year: "numeric",
|
|
56
|
+
month: "2-digit",
|
|
57
|
+
day: "2-digit",
|
|
58
|
+
hour: "2-digit",
|
|
59
|
+
minute: "2-digit",
|
|
60
|
+
second: "2-digit"
|
|
61
|
+
}).replace(/[: ]/g, "-").replace(/,/g, "");
|
|
62
|
+
}
|
|
63
|
+
function createTimestampForLogEntry(date = /* @__PURE__ */ new Date()) {
|
|
64
|
+
return date.toLocaleTimeString("en-US", {
|
|
65
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
66
|
+
hour12: false,
|
|
67
|
+
hour: "2-digit",
|
|
68
|
+
minute: "2-digit",
|
|
69
|
+
second: "2-digit",
|
|
70
|
+
fractionalSecondDigits: 3
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async function getSessionLogPath() {
|
|
74
|
+
if (!existsSync(configuration.logsDir)) {
|
|
75
|
+
await mkdir(configuration.logsDir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
const timestamp = createTimestampForFilename();
|
|
78
|
+
const filename = configuration.isDaemonProcess ? `${timestamp}-daemon.log` : `${timestamp}.log`;
|
|
79
|
+
return join(configuration.logsDir, filename);
|
|
80
|
+
}
|
|
81
|
+
class Logger {
|
|
82
|
+
constructor(logFilePathPromise = getSessionLogPath()) {
|
|
83
|
+
this.logFilePathPromise = logFilePathPromise;
|
|
84
|
+
}
|
|
85
|
+
// Use local timezone for simplicity of locating the logs,
|
|
86
|
+
// in practice you will not need absolute timestamps
|
|
87
|
+
localTimezoneTimestamp() {
|
|
88
|
+
return createTimestampForLogEntry();
|
|
89
|
+
}
|
|
90
|
+
debug(message, ...args) {
|
|
91
|
+
this.logToFile(`[${this.localTimezoneTimestamp()}]`, message, ...args);
|
|
92
|
+
}
|
|
93
|
+
debugLargeJson(message, object, maxStringLength = 100, maxArrayLength = 10) {
|
|
94
|
+
if (!process.env.DEBUG) {
|
|
95
|
+
this.debug(`In production, skipping message inspection`);
|
|
96
|
+
}
|
|
97
|
+
const truncateStrings = (obj) => {
|
|
98
|
+
if (typeof obj === "string") {
|
|
99
|
+
return obj.length > maxStringLength ? obj.substring(0, maxStringLength) + "... [truncated for logs]" : obj;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(obj)) {
|
|
102
|
+
const truncatedArray = obj.map((item) => truncateStrings(item)).slice(0, maxArrayLength);
|
|
103
|
+
if (obj.length > maxArrayLength) {
|
|
104
|
+
truncatedArray.push(`... [truncated array for logs up to ${maxArrayLength} items]`);
|
|
105
|
+
}
|
|
106
|
+
return truncatedArray;
|
|
107
|
+
}
|
|
108
|
+
if (obj && typeof obj === "object") {
|
|
109
|
+
const result = {};
|
|
110
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
111
|
+
if (key === "usage") {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
result[key] = truncateStrings(value);
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
return obj;
|
|
119
|
+
};
|
|
120
|
+
const truncatedObject = truncateStrings(object);
|
|
121
|
+
const json = JSON.stringify(truncatedObject, null, 2);
|
|
122
|
+
this.logToFile(`[${this.localTimezoneTimestamp()}]`, message, "\n", json);
|
|
123
|
+
}
|
|
124
|
+
info(message, ...args) {
|
|
125
|
+
this.logToConsole("info", "", message, ...args);
|
|
126
|
+
this.debug(message, args);
|
|
127
|
+
}
|
|
128
|
+
infoDeveloper(message, ...args) {
|
|
129
|
+
this.debug(message, ...args);
|
|
130
|
+
if (process.env.DEBUG) {
|
|
131
|
+
this.logToConsole("info", "[DEV]", message, ...args);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
daemonDebug(message, ...args) {
|
|
135
|
+
this.debug(`[DAEMON] ${message}`, ...args);
|
|
136
|
+
}
|
|
137
|
+
logToConsole(level, prefix, message, ...args) {
|
|
138
|
+
switch (level) {
|
|
139
|
+
case "debug": {
|
|
140
|
+
console.log(chalk.gray(prefix), message, ...args);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "error": {
|
|
144
|
+
console.error(chalk.red(prefix), message, ...args);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "info": {
|
|
148
|
+
console.log(chalk.blue(prefix), message, ...args);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "warn": {
|
|
152
|
+
console.log(chalk.yellow(prefix), message, ...args);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
default: {
|
|
156
|
+
this.debug("Unknown log level:", level);
|
|
157
|
+
console.log(chalk.blue(prefix), message, ...args);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
logToFile(prefix, message, ...args) {
|
|
163
|
+
const logLine = `${prefix} ${message} ${args.map(
|
|
164
|
+
(arg) => typeof arg === "string" ? arg : JSON.stringify(arg)
|
|
165
|
+
).join(" ")}
|
|
166
|
+
`;
|
|
167
|
+
this.logFilePathPromise.then((logFilePath) => {
|
|
168
|
+
try {
|
|
169
|
+
appendFileSync(logFilePath, logLine);
|
|
170
|
+
} catch (appendError) {
|
|
171
|
+
if (process.env.DEBUG) {
|
|
172
|
+
console.error("Failed to append to log file:", appendError);
|
|
173
|
+
throw appendError;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}).catch((error) => {
|
|
177
|
+
if (process.env.DEBUG) {
|
|
178
|
+
console.log("This message only visible in DEBUG mode, not in production");
|
|
179
|
+
console.error("Failed to resolve log file path:", error);
|
|
180
|
+
console.log(prefix, message, ...args);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
let logger;
|
|
186
|
+
function initLoggerWithGlobalConfiguration() {
|
|
187
|
+
logger = new Logger();
|
|
188
|
+
if (process.env.DEBUG) {
|
|
189
|
+
logger.logFilePathPromise.then((logPath) => {
|
|
190
|
+
logger.info(chalk.yellow("[DEBUG MODE] Debug logging enabled"));
|
|
191
|
+
logger.info(chalk.gray(`Log file: ${logPath}`));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const SessionMessageContentSchema = z.object({
|
|
197
|
+
c: z.string(),
|
|
198
|
+
// Base64 encoded encrypted content
|
|
199
|
+
t: z.literal("encrypted")
|
|
200
|
+
});
|
|
201
|
+
const UpdateBodySchema = z.object({
|
|
202
|
+
message: z.object({
|
|
203
|
+
id: z.string(),
|
|
204
|
+
seq: z.number(),
|
|
205
|
+
content: SessionMessageContentSchema
|
|
206
|
+
}),
|
|
207
|
+
sid: z.string(),
|
|
208
|
+
// Session ID
|
|
209
|
+
t: z.literal("new-message")
|
|
210
|
+
});
|
|
211
|
+
const UpdateSessionBodySchema = z.object({
|
|
212
|
+
t: z.literal("update-session"),
|
|
213
|
+
sid: z.string(),
|
|
214
|
+
metadata: z.object({
|
|
215
|
+
version: z.number(),
|
|
216
|
+
value: z.string()
|
|
217
|
+
}).nullish(),
|
|
218
|
+
agentState: z.object({
|
|
219
|
+
version: z.number(),
|
|
220
|
+
value: z.string()
|
|
221
|
+
}).nullish()
|
|
222
|
+
});
|
|
223
|
+
z.object({
|
|
224
|
+
id: z.string(),
|
|
225
|
+
seq: z.number(),
|
|
226
|
+
body: z.union([UpdateBodySchema, UpdateSessionBodySchema]),
|
|
227
|
+
createdAt: z.number()
|
|
228
|
+
});
|
|
229
|
+
z.object({
|
|
230
|
+
createdAt: z.number(),
|
|
231
|
+
id: z.string(),
|
|
232
|
+
seq: z.number(),
|
|
233
|
+
updatedAt: z.number(),
|
|
234
|
+
metadata: z.any(),
|
|
235
|
+
metadataVersion: z.number(),
|
|
236
|
+
agentState: z.any().nullable(),
|
|
237
|
+
agentStateVersion: z.number()
|
|
238
|
+
});
|
|
239
|
+
z.object({
|
|
240
|
+
content: SessionMessageContentSchema,
|
|
241
|
+
createdAt: z.number(),
|
|
242
|
+
id: z.string(),
|
|
243
|
+
seq: z.number(),
|
|
244
|
+
updatedAt: z.number()
|
|
245
|
+
});
|
|
246
|
+
const MessageMetaSchema = z.object({
|
|
247
|
+
sentFrom: z.string().optional(),
|
|
248
|
+
// Source identifier
|
|
249
|
+
permissionMode: z.string().optional()
|
|
250
|
+
// Permission mode for this message
|
|
251
|
+
});
|
|
252
|
+
z.object({
|
|
253
|
+
session: z.object({
|
|
254
|
+
id: z.string(),
|
|
255
|
+
tag: z.string(),
|
|
256
|
+
seq: z.number(),
|
|
257
|
+
createdAt: z.number(),
|
|
258
|
+
updatedAt: z.number(),
|
|
259
|
+
metadata: z.string(),
|
|
260
|
+
metadataVersion: z.number(),
|
|
261
|
+
agentState: z.string().nullable(),
|
|
262
|
+
agentStateVersion: z.number()
|
|
263
|
+
})
|
|
264
|
+
});
|
|
265
|
+
const UserMessageSchema = z.object({
|
|
266
|
+
role: z.literal("user"),
|
|
267
|
+
content: z.object({
|
|
268
|
+
type: z.literal("text"),
|
|
269
|
+
text: z.string()
|
|
270
|
+
}),
|
|
271
|
+
localKey: z.string().optional(),
|
|
272
|
+
// Mobile messages include this
|
|
273
|
+
meta: MessageMetaSchema.optional()
|
|
274
|
+
});
|
|
275
|
+
const AgentMessageSchema = z.object({
|
|
276
|
+
role: z.literal("agent"),
|
|
277
|
+
content: z.object({
|
|
278
|
+
type: z.literal("output"),
|
|
279
|
+
data: z.any()
|
|
280
|
+
}),
|
|
281
|
+
meta: MessageMetaSchema.optional()
|
|
282
|
+
});
|
|
283
|
+
z.union([UserMessageSchema, AgentMessageSchema]);
|
|
284
|
+
|
|
285
|
+
function encodeBase64(buffer, variant = "base64") {
|
|
286
|
+
if (variant === "base64url") {
|
|
287
|
+
return encodeBase64Url(buffer);
|
|
288
|
+
}
|
|
289
|
+
return Buffer.from(buffer).toString("base64");
|
|
290
|
+
}
|
|
291
|
+
function encodeBase64Url(buffer) {
|
|
292
|
+
return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
293
|
+
}
|
|
294
|
+
function decodeBase64(base64, variant = "base64") {
|
|
295
|
+
if (variant === "base64url") {
|
|
296
|
+
const base64Standard = base64.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - base64.length % 4) % 4);
|
|
297
|
+
return new Uint8Array(Buffer.from(base64Standard, "base64"));
|
|
298
|
+
}
|
|
299
|
+
return new Uint8Array(Buffer.from(base64, "base64"));
|
|
300
|
+
}
|
|
301
|
+
function getRandomBytes(size) {
|
|
302
|
+
return new Uint8Array(randomBytes(size));
|
|
303
|
+
}
|
|
304
|
+
function encrypt(data, secret) {
|
|
305
|
+
const nonce = getRandomBytes(tweetnacl.secretbox.nonceLength);
|
|
306
|
+
const encrypted = tweetnacl.secretbox(new TextEncoder().encode(JSON.stringify(data)), nonce, secret);
|
|
307
|
+
const result = new Uint8Array(nonce.length + encrypted.length);
|
|
308
|
+
result.set(nonce);
|
|
309
|
+
result.set(encrypted, nonce.length);
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
function decrypt(data, secret) {
|
|
313
|
+
const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
|
|
314
|
+
const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
|
|
315
|
+
const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
|
|
316
|
+
if (!decrypted) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function delay(ms) {
|
|
323
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
324
|
+
}
|
|
325
|
+
function exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount) {
|
|
326
|
+
let maxDelayRet = minDelay + (maxDelay - minDelay) / maxFailureCount * Math.max(currentFailureCount, maxFailureCount);
|
|
327
|
+
return Math.round(Math.random() * maxDelayRet);
|
|
328
|
+
}
|
|
329
|
+
function createBackoff(opts) {
|
|
330
|
+
return async (callback) => {
|
|
331
|
+
let currentFailureCount = 0;
|
|
332
|
+
const minDelay = 250;
|
|
333
|
+
const maxDelay = 1e3;
|
|
334
|
+
const maxFailureCount = 50;
|
|
335
|
+
while (true) {
|
|
336
|
+
try {
|
|
337
|
+
return await callback();
|
|
338
|
+
} catch (e) {
|
|
339
|
+
if (currentFailureCount < maxFailureCount) {
|
|
340
|
+
currentFailureCount++;
|
|
341
|
+
}
|
|
342
|
+
let waitForRequest = exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount);
|
|
343
|
+
await delay(waitForRequest);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
let backoff = createBackoff();
|
|
349
|
+
|
|
350
|
+
class ApiSessionClient extends EventEmitter {
|
|
351
|
+
token;
|
|
352
|
+
secret;
|
|
353
|
+
sessionId;
|
|
354
|
+
metadata;
|
|
355
|
+
metadataVersion;
|
|
356
|
+
agentState;
|
|
357
|
+
agentStateVersion;
|
|
358
|
+
socket;
|
|
359
|
+
pendingMessages = [];
|
|
360
|
+
pendingMessageCallback = null;
|
|
361
|
+
rpcHandlers = /* @__PURE__ */ new Map();
|
|
362
|
+
constructor(token, secret, session) {
|
|
363
|
+
super();
|
|
364
|
+
this.token = token;
|
|
365
|
+
this.secret = secret;
|
|
366
|
+
this.sessionId = session.id;
|
|
367
|
+
this.metadata = session.metadata;
|
|
368
|
+
this.metadataVersion = session.metadataVersion;
|
|
369
|
+
this.agentState = session.agentState;
|
|
370
|
+
this.agentStateVersion = session.agentStateVersion;
|
|
371
|
+
this.socket = io(configuration.serverUrl, {
|
|
372
|
+
auth: {
|
|
373
|
+
token: this.token,
|
|
374
|
+
clientType: "session-scoped",
|
|
375
|
+
sessionId: this.sessionId
|
|
376
|
+
},
|
|
377
|
+
path: "/v1/updates",
|
|
378
|
+
reconnection: true,
|
|
379
|
+
reconnectionAttempts: Infinity,
|
|
380
|
+
reconnectionDelay: 1e3,
|
|
381
|
+
reconnectionDelayMax: 5e3,
|
|
382
|
+
transports: ["websocket"],
|
|
383
|
+
withCredentials: true,
|
|
384
|
+
autoConnect: false
|
|
385
|
+
});
|
|
386
|
+
this.socket.on("connect", () => {
|
|
387
|
+
logger.debug("Socket connected successfully");
|
|
388
|
+
this.reregisterHandlers();
|
|
389
|
+
});
|
|
390
|
+
this.socket.on("rpc-request", async (data, callback) => {
|
|
391
|
+
try {
|
|
392
|
+
const method = data.method;
|
|
393
|
+
const handler = this.rpcHandlers.get(method);
|
|
394
|
+
if (!handler) {
|
|
395
|
+
logger.debug("[SOCKET] [RPC] [ERROR] method not found", { method });
|
|
396
|
+
const errorResponse = { error: "Method not found" };
|
|
397
|
+
const encryptedError = encodeBase64(encrypt(errorResponse, this.secret));
|
|
398
|
+
callback(encryptedError);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const decryptedParams = decrypt(decodeBase64(data.params), this.secret);
|
|
402
|
+
const result = await handler(decryptedParams);
|
|
403
|
+
const encryptedResponse = encodeBase64(encrypt(result, this.secret));
|
|
404
|
+
callback(encryptedResponse);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
logger.debug("[SOCKET] [RPC] [ERROR] Error handling RPC request", { error });
|
|
407
|
+
const errorResponse = { error: error instanceof Error ? error.message : "Unknown error" };
|
|
408
|
+
const encryptedError = encodeBase64(encrypt(errorResponse, this.secret));
|
|
409
|
+
callback(encryptedError);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
this.socket.on("disconnect", (reason) => {
|
|
413
|
+
logger.debug("[API] Socket disconnected:", reason);
|
|
414
|
+
});
|
|
415
|
+
this.socket.on("connect_error", (error) => {
|
|
416
|
+
logger.debug("[API] Socket connection error:", error);
|
|
417
|
+
});
|
|
418
|
+
this.socket.on("update", (data) => {
|
|
419
|
+
try {
|
|
420
|
+
logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", data);
|
|
421
|
+
if (!data.body) {
|
|
422
|
+
logger.debug("[SOCKET] [UPDATE] [ERROR] No body in update!");
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (data.body.t === "new-message" && data.body.message.content.t === "encrypted") {
|
|
426
|
+
const body = decrypt(decodeBase64(data.body.message.content.c), this.secret);
|
|
427
|
+
logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", body);
|
|
428
|
+
const userResult = UserMessageSchema.safeParse(body);
|
|
429
|
+
if (userResult.success) {
|
|
430
|
+
if (this.pendingMessageCallback) {
|
|
431
|
+
this.pendingMessageCallback(userResult.data);
|
|
432
|
+
} else {
|
|
433
|
+
this.pendingMessages.push(userResult.data);
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
this.emit("message", body);
|
|
437
|
+
}
|
|
438
|
+
} else if (data.body.t === "update-session") {
|
|
439
|
+
if (data.body.metadata && data.body.metadata.version > this.metadataVersion) {
|
|
440
|
+
this.metadata = decrypt(decodeBase64(data.body.metadata.value), this.secret);
|
|
441
|
+
this.metadataVersion = data.body.metadata.version;
|
|
442
|
+
}
|
|
443
|
+
if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) {
|
|
444
|
+
this.agentState = data.body.agentState.value ? decrypt(decodeBase64(data.body.agentState.value), this.secret) : null;
|
|
445
|
+
this.agentStateVersion = data.body.agentState.version;
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
this.emit("message", data.body);
|
|
449
|
+
}
|
|
450
|
+
} catch (error) {
|
|
451
|
+
logger.debug("[SOCKET] [UPDATE] [ERROR] Error handling update", { error });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
this.socket.on("error", (error) => {
|
|
455
|
+
logger.debug("[API] Socket error:", error);
|
|
456
|
+
});
|
|
457
|
+
this.socket.connect();
|
|
458
|
+
}
|
|
459
|
+
onUserMessage(callback) {
|
|
460
|
+
this.pendingMessageCallback = callback;
|
|
461
|
+
while (this.pendingMessages.length > 0) {
|
|
462
|
+
callback(this.pendingMessages.shift());
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Send message to session
|
|
467
|
+
* @param body - Message body (can be MessageContent or raw content for agent messages)
|
|
468
|
+
*/
|
|
469
|
+
sendClaudeSessionMessage(body) {
|
|
470
|
+
let content;
|
|
471
|
+
if (body.type === "user" && typeof body.message.content === "string" && body.isSidechain !== true && body.isMeta !== true) {
|
|
472
|
+
content = {
|
|
473
|
+
role: "user",
|
|
474
|
+
content: {
|
|
475
|
+
type: "text",
|
|
476
|
+
text: body.message.content
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
} else {
|
|
480
|
+
content = {
|
|
481
|
+
role: "agent",
|
|
482
|
+
content: {
|
|
483
|
+
type: "output",
|
|
484
|
+
data: body
|
|
485
|
+
// This wraps the entire Claude message
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
logger.debugLargeJson("[SOCKET] Sending message through socket:", content);
|
|
490
|
+
const encrypted = encodeBase64(encrypt(content, this.secret));
|
|
491
|
+
this.socket.emit("message", {
|
|
492
|
+
sid: this.sessionId,
|
|
493
|
+
message: encrypted
|
|
494
|
+
});
|
|
495
|
+
if (body.type === "assistant" && body.message.usage) {
|
|
496
|
+
try {
|
|
497
|
+
this.sendUsageData(body.message.usage);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
logger.debug("[SOCKET] Failed to send usage data:", error);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (body.type === "summary" && "summary" in body && "leafUuid" in body) {
|
|
503
|
+
this.updateMetadata((metadata) => ({
|
|
504
|
+
...metadata,
|
|
505
|
+
summary: {
|
|
506
|
+
text: body.summary,
|
|
507
|
+
updatedAt: Date.now()
|
|
508
|
+
}
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
sendSessionEvent(event, id) {
|
|
513
|
+
let content = {
|
|
514
|
+
role: "agent",
|
|
515
|
+
content: {
|
|
516
|
+
id: id ?? randomUUID(),
|
|
517
|
+
type: "event",
|
|
518
|
+
data: event
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
const encrypted = encodeBase64(encrypt(content, this.secret));
|
|
522
|
+
this.socket.emit("message", {
|
|
523
|
+
sid: this.sessionId,
|
|
524
|
+
message: encrypted
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Send a ping message to keep the connection alive
|
|
529
|
+
*/
|
|
530
|
+
keepAlive(thinking, mode) {
|
|
531
|
+
this.socket.volatile.emit("session-alive", {
|
|
532
|
+
sid: this.sessionId,
|
|
533
|
+
time: Date.now(),
|
|
534
|
+
thinking,
|
|
535
|
+
mode
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Send session death message
|
|
540
|
+
*/
|
|
541
|
+
sendSessionDeath() {
|
|
542
|
+
this.socket.emit("session-end", { sid: this.sessionId, time: Date.now() });
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Send usage data to the server
|
|
546
|
+
*/
|
|
547
|
+
sendUsageData(usage) {
|
|
548
|
+
const totalTokens = usage.input_tokens + usage.output_tokens + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
|
|
549
|
+
const usageReport = {
|
|
550
|
+
key: "claude-session",
|
|
551
|
+
sessionId: this.sessionId,
|
|
552
|
+
tokens: {
|
|
553
|
+
total: totalTokens,
|
|
554
|
+
input: usage.input_tokens,
|
|
555
|
+
output: usage.output_tokens,
|
|
556
|
+
cache_creation: usage.cache_creation_input_tokens || 0,
|
|
557
|
+
cache_read: usage.cache_read_input_tokens || 0
|
|
558
|
+
},
|
|
559
|
+
cost: {
|
|
560
|
+
// TODO: Calculate actual costs based on pricing
|
|
561
|
+
// For now, using placeholder values
|
|
562
|
+
total: 0,
|
|
563
|
+
input: 0,
|
|
564
|
+
output: 0
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
logger.debugLargeJson("[SOCKET] Sending usage data:", usageReport);
|
|
568
|
+
this.socket.emit("usage-report", usageReport);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Update session metadata
|
|
572
|
+
* @param handler - Handler function that returns the updated metadata
|
|
573
|
+
*/
|
|
574
|
+
updateMetadata(handler) {
|
|
575
|
+
backoff(async () => {
|
|
576
|
+
let updated = handler(this.metadata);
|
|
577
|
+
const answer = await this.socket.emitWithAck("update-metadata", { sid: this.sessionId, expectedVersion: this.metadataVersion, metadata: encodeBase64(encrypt(updated, this.secret)) });
|
|
578
|
+
if (answer.result === "success") {
|
|
579
|
+
this.metadata = decrypt(decodeBase64(answer.metadata), this.secret);
|
|
580
|
+
this.metadataVersion = answer.version;
|
|
581
|
+
} else if (answer.result === "version-mismatch") {
|
|
582
|
+
if (answer.version > this.metadataVersion) {
|
|
583
|
+
this.metadataVersion = answer.version;
|
|
584
|
+
this.metadata = decrypt(decodeBase64(answer.metadata), this.secret);
|
|
585
|
+
}
|
|
586
|
+
throw new Error("Metadata version mismatch");
|
|
587
|
+
} else if (answer.result === "error") ;
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Update session agent state
|
|
592
|
+
* @param handler - Handler function that returns the updated agent state
|
|
593
|
+
*/
|
|
594
|
+
updateAgentState(handler) {
|
|
595
|
+
logger.debugLargeJson("Updating agent state", this.agentState);
|
|
596
|
+
backoff(async () => {
|
|
597
|
+
let updated = handler(this.agentState || {});
|
|
598
|
+
const answer = await this.socket.emitWithAck("update-state", { sid: this.sessionId, expectedVersion: this.agentStateVersion, agentState: updated ? encodeBase64(encrypt(updated, this.secret)) : null });
|
|
599
|
+
if (answer.result === "success") {
|
|
600
|
+
this.agentState = answer.agentState ? decrypt(decodeBase64(answer.agentState), this.secret) : null;
|
|
601
|
+
this.agentStateVersion = answer.version;
|
|
602
|
+
logger.debug("Agent state updated", this.agentState);
|
|
603
|
+
} else if (answer.result === "version-mismatch") {
|
|
604
|
+
if (answer.version > this.agentStateVersion) {
|
|
605
|
+
this.agentStateVersion = answer.version;
|
|
606
|
+
this.agentState = answer.agentState ? decrypt(decodeBase64(answer.agentState), this.secret) : null;
|
|
607
|
+
}
|
|
608
|
+
throw new Error("Agent state version mismatch");
|
|
609
|
+
} else if (answer.result === "error") {
|
|
610
|
+
console.error("Agent state update error", answer);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Set a custom RPC handler for a specific method with encrypted arguments and responses
|
|
616
|
+
* @param method - The method name to handle
|
|
617
|
+
* @param handler - The handler function to call when the method is invoked
|
|
618
|
+
*/
|
|
619
|
+
setHandler(method, handler) {
|
|
620
|
+
const prefixedMethod = `${this.sessionId}:${method}`;
|
|
621
|
+
this.rpcHandlers.set(prefixedMethod, handler);
|
|
622
|
+
this.socket.emit("rpc-register", { method: prefixedMethod });
|
|
623
|
+
logger.debug("Registered RPC handler", { method, prefixedMethod });
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Re-register all RPC handlers after reconnection
|
|
627
|
+
*/
|
|
628
|
+
reregisterHandlers() {
|
|
629
|
+
logger.debug("Re-registering RPC handlers after reconnection", {
|
|
630
|
+
totalMethods: this.rpcHandlers.size
|
|
631
|
+
});
|
|
632
|
+
for (const [prefixedMethod] of this.rpcHandlers) {
|
|
633
|
+
this.socket.emit("rpc-register", { method: prefixedMethod });
|
|
634
|
+
logger.debug("Re-registered method", { prefixedMethod });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Wait for socket buffer to flush
|
|
639
|
+
*/
|
|
640
|
+
async flush() {
|
|
641
|
+
if (!this.socket.connected) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
return new Promise((resolve) => {
|
|
645
|
+
this.socket.emit("ping", () => {
|
|
646
|
+
resolve();
|
|
647
|
+
});
|
|
648
|
+
setTimeout(() => {
|
|
649
|
+
resolve();
|
|
650
|
+
}, 1e4);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
async close() {
|
|
654
|
+
this.socket.close();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
class PushNotificationClient {
|
|
659
|
+
token;
|
|
660
|
+
baseUrl;
|
|
661
|
+
expo;
|
|
662
|
+
constructor(token, baseUrl = "https://handy-api.korshakov.org") {
|
|
663
|
+
this.token = token;
|
|
664
|
+
this.baseUrl = baseUrl;
|
|
665
|
+
this.expo = new Expo();
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Fetch all push tokens for the authenticated user
|
|
669
|
+
*/
|
|
670
|
+
async fetchPushTokens() {
|
|
671
|
+
try {
|
|
672
|
+
const response = await axios.get(
|
|
673
|
+
`${this.baseUrl}/v1/push-tokens`,
|
|
674
|
+
{
|
|
675
|
+
headers: {
|
|
676
|
+
"Authorization": `Bearer ${this.token}`,
|
|
677
|
+
"Content-Type": "application/json"
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
logger.debug(`Fetched ${response.data.tokens.length} push tokens`);
|
|
682
|
+
return response.data.tokens;
|
|
683
|
+
} catch (error) {
|
|
684
|
+
logger.debug("[PUSH] [ERROR] Failed to fetch push tokens:", error);
|
|
685
|
+
throw new Error(`Failed to fetch push tokens: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Send push notification via Expo Push API with retry
|
|
690
|
+
* @param messages - Array of push messages to send
|
|
691
|
+
*/
|
|
692
|
+
async sendPushNotifications(messages) {
|
|
693
|
+
logger.debug(`Sending ${messages.length} push notifications`);
|
|
694
|
+
const validMessages = messages.filter((message) => {
|
|
695
|
+
if (Array.isArray(message.to)) {
|
|
696
|
+
return message.to.every((token) => Expo.isExpoPushToken(token));
|
|
697
|
+
}
|
|
698
|
+
return Expo.isExpoPushToken(message.to);
|
|
699
|
+
});
|
|
700
|
+
if (validMessages.length === 0) {
|
|
701
|
+
logger.debug("No valid Expo push tokens found");
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const chunks = this.expo.chunkPushNotifications(validMessages);
|
|
705
|
+
for (const chunk of chunks) {
|
|
706
|
+
const startTime = Date.now();
|
|
707
|
+
const timeout = 3e5;
|
|
708
|
+
let attempt = 0;
|
|
709
|
+
while (true) {
|
|
710
|
+
try {
|
|
711
|
+
const ticketChunk = await this.expo.sendPushNotificationsAsync(chunk);
|
|
712
|
+
const errors = ticketChunk.filter((ticket) => ticket.status === "error");
|
|
713
|
+
if (errors.length > 0) {
|
|
714
|
+
logger.debug("[PUSH] Some notifications failed:", errors);
|
|
715
|
+
}
|
|
716
|
+
if (errors.length === ticketChunk.length) {
|
|
717
|
+
throw new Error("All push notifications in chunk failed");
|
|
718
|
+
}
|
|
719
|
+
break;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
const elapsed = Date.now() - startTime;
|
|
722
|
+
if (elapsed >= timeout) {
|
|
723
|
+
logger.debug("[PUSH] Timeout reached after 5 minutes, giving up on chunk");
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
attempt++;
|
|
727
|
+
const delay = Math.min(1e3 * Math.pow(2, attempt), 3e4);
|
|
728
|
+
const remainingTime = timeout - elapsed;
|
|
729
|
+
const waitTime = Math.min(delay, remainingTime);
|
|
730
|
+
if (waitTime > 0) {
|
|
731
|
+
logger.debug(`[PUSH] Retrying in ${waitTime}ms (attempt ${attempt})`);
|
|
732
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
logger.debug(`Push notifications sent successfully`);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Send a push notification to all registered devices for the user
|
|
741
|
+
* @param title - Notification title
|
|
742
|
+
* @param body - Notification body
|
|
743
|
+
* @param data - Additional data to send with the notification
|
|
744
|
+
*/
|
|
745
|
+
async sendToAllDevices(title, body, data) {
|
|
746
|
+
const tokens = await this.fetchPushTokens();
|
|
747
|
+
if (tokens.length === 0) {
|
|
748
|
+
logger.debug("No push tokens found for user");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const messages = tokens.map((token) => ({
|
|
752
|
+
to: token.token,
|
|
753
|
+
title,
|
|
754
|
+
body,
|
|
755
|
+
data,
|
|
756
|
+
sound: "default",
|
|
757
|
+
priority: "high"
|
|
758
|
+
}));
|
|
759
|
+
await this.sendPushNotifications(messages);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
class ApiClient {
|
|
764
|
+
token;
|
|
765
|
+
secret;
|
|
766
|
+
pushClient;
|
|
767
|
+
constructor(token, secret) {
|
|
768
|
+
this.token = token;
|
|
769
|
+
this.secret = secret;
|
|
770
|
+
this.pushClient = new PushNotificationClient(token);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Create a new session or load existing one with the given tag
|
|
774
|
+
*/
|
|
775
|
+
async getOrCreateSession(opts) {
|
|
776
|
+
try {
|
|
777
|
+
const response = await axios.post(
|
|
778
|
+
`${configuration.serverUrl}/v1/sessions`,
|
|
779
|
+
{
|
|
780
|
+
tag: opts.tag,
|
|
781
|
+
metadata: encodeBase64(encrypt(opts.metadata, this.secret)),
|
|
782
|
+
agentState: opts.state ? encodeBase64(encrypt(opts.state, this.secret)) : null
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
headers: {
|
|
786
|
+
"Authorization": `Bearer ${this.token}`,
|
|
787
|
+
"Content-Type": "application/json"
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
);
|
|
791
|
+
logger.debug(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
|
|
792
|
+
let raw = response.data.session;
|
|
793
|
+
let session = {
|
|
794
|
+
id: raw.id,
|
|
795
|
+
createdAt: raw.createdAt,
|
|
796
|
+
updatedAt: raw.updatedAt,
|
|
797
|
+
seq: raw.seq,
|
|
798
|
+
metadata: decrypt(decodeBase64(raw.metadata), this.secret),
|
|
799
|
+
metadataVersion: raw.metadataVersion,
|
|
800
|
+
agentState: raw.agentState ? decrypt(decodeBase64(raw.agentState), this.secret) : null,
|
|
801
|
+
agentStateVersion: raw.agentStateVersion
|
|
802
|
+
};
|
|
803
|
+
return session;
|
|
804
|
+
} catch (error) {
|
|
805
|
+
logger.debug("[API] [ERROR] Failed to get or create session:", error);
|
|
806
|
+
throw new Error(`Failed to get or create session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Start realtime session client
|
|
811
|
+
* @param id - Session ID
|
|
812
|
+
* @returns Session client
|
|
813
|
+
*/
|
|
814
|
+
session(session) {
|
|
815
|
+
return new ApiSessionClient(this.token, this.secret, session);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Get push notification client
|
|
819
|
+
* @returns Push notification client
|
|
820
|
+
*/
|
|
821
|
+
push() {
|
|
822
|
+
return this.pushClient;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const UsageSchema = z.object({
|
|
827
|
+
input_tokens: z.number().int().nonnegative(),
|
|
828
|
+
cache_creation_input_tokens: z.number().int().nonnegative().optional(),
|
|
829
|
+
cache_read_input_tokens: z.number().int().nonnegative().optional(),
|
|
830
|
+
output_tokens: z.number().int().nonnegative(),
|
|
831
|
+
service_tier: z.string().optional()
|
|
832
|
+
}).passthrough();
|
|
833
|
+
const RawJSONLinesSchema = z.discriminatedUnion("type", [
|
|
834
|
+
// User message - validates uuid and message.content
|
|
835
|
+
z.object({
|
|
836
|
+
type: z.literal("user"),
|
|
837
|
+
isSidechain: z.boolean().optional(),
|
|
838
|
+
isMeta: z.boolean().optional(),
|
|
839
|
+
uuid: z.string(),
|
|
840
|
+
// Used in getMessageKey()
|
|
841
|
+
message: z.object({
|
|
842
|
+
content: z.union([z.string(), z.any()])
|
|
843
|
+
// Used in sessionScanner.ts
|
|
844
|
+
}).passthrough()
|
|
845
|
+
}).passthrough(),
|
|
846
|
+
// Assistant message - validates message object with usage and content
|
|
847
|
+
z.object({
|
|
848
|
+
type: z.literal("assistant"),
|
|
849
|
+
message: z.object({
|
|
850
|
+
// Entire message used in getMessageKey()
|
|
851
|
+
usage: UsageSchema.optional(),
|
|
852
|
+
// Used in apiSession.ts
|
|
853
|
+
content: z.any()
|
|
854
|
+
// Used in tests
|
|
855
|
+
}).passthrough()
|
|
856
|
+
}).passthrough(),
|
|
857
|
+
// Summary message - validates summary and leafUuid
|
|
858
|
+
z.object({
|
|
859
|
+
type: z.literal("summary"),
|
|
860
|
+
summary: z.string(),
|
|
861
|
+
// Used in apiSession.ts
|
|
862
|
+
leafUuid: z.string()
|
|
863
|
+
// Used in getMessageKey()
|
|
864
|
+
}).passthrough(),
|
|
865
|
+
// System message - validates uuid
|
|
866
|
+
z.object({
|
|
867
|
+
type: z.literal("system"),
|
|
868
|
+
uuid: z.string()
|
|
869
|
+
// Used in getMessageKey()
|
|
870
|
+
}).passthrough()
|
|
871
|
+
]);
|
|
872
|
+
|
|
873
|
+
export { ApiClient as A, RawJSONLinesSchema as R, ApiSessionClient as a, initializeConfiguration as b, configuration as c, delay as d, backoff as e, encodeBase64 as f, encodeBase64Url as g, decodeBase64 as h, initLoggerWithGlobalConfiguration as i, logger as l };
|