@heysummon/consumer-sdk 0.1.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/cli.d.ts +14 -0
- package/dist/cli.js +589 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +37 -0
- package/dist/client.js +80 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto.d.ts +41 -0
- package/dist/crypto.js +122 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/poller.d.ts +28 -0
- package/dist/poller.js +57 -0
- package/dist/poller.js.map +1 -0
- package/dist/provider-store.d.ts +25 -0
- package/dist/provider-store.js +73 -0
- package/dist/provider-store.js.map +1 -0
- package/dist/request-tracker.d.ts +22 -0
- package/dist/request-tracker.js +66 -0
- package/dist/request-tracker.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +39 -0
- package/src/cli.ts +683 -0
- package/src/client.ts +117 -0
- package/src/crypto.ts +205 -0
- package/src/index.ts +23 -0
- package/src/poller.ts +72 -0
- package/src/provider-store.ts +88 -0
- package/src/request-tracker.ts +72 -0
- package/src/types.ts +92 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SubmitRequestOptions,
|
|
3
|
+
SubmitRequestResult,
|
|
4
|
+
PendingEvent,
|
|
5
|
+
Message,
|
|
6
|
+
WhoamiResult,
|
|
7
|
+
HeySummonClientOptions,
|
|
8
|
+
RequestStatusResponse,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
/** HTTP error with status code for callers to inspect */
|
|
12
|
+
export class HeySummonHttpError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
public readonly status: number,
|
|
15
|
+
public readonly statusText: string,
|
|
16
|
+
public readonly body: string
|
|
17
|
+
) {
|
|
18
|
+
super(`HTTP ${status}: ${statusText} — ${body}`);
|
|
19
|
+
this.name = "HeySummonHttpError";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get isAuthError(): boolean {
|
|
23
|
+
return this.status === 401 || this.status === 403 || this.status === 404;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Typed HTTP client for the HeySummon consumer API.
|
|
29
|
+
* Each method is a thin wrapper around fetch that includes the x-api-key header.
|
|
30
|
+
*/
|
|
31
|
+
export class HeySummonClient {
|
|
32
|
+
private readonly baseUrl: string;
|
|
33
|
+
private readonly apiKey: string;
|
|
34
|
+
|
|
35
|
+
constructor(opts: HeySummonClientOptions) {
|
|
36
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, ""); // trim trailing slash
|
|
37
|
+
this.apiKey = opts.apiKey;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async request<T>(
|
|
41
|
+
method: string,
|
|
42
|
+
path: string,
|
|
43
|
+
body?: unknown
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
46
|
+
method,
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"x-api-key": this.apiKey,
|
|
50
|
+
},
|
|
51
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const text = await res.text().catch(() => "(no body)");
|
|
56
|
+
throw new HeySummonHttpError(res.status, res.statusText, text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return res.json() as Promise<T>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Identify which provider this API key is linked to */
|
|
63
|
+
async whoami(): Promise<WhoamiResult> {
|
|
64
|
+
return this.request<WhoamiResult>("GET", "/api/v1/whoami");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Submit a help request */
|
|
68
|
+
async submitRequest(opts: SubmitRequestOptions): Promise<SubmitRequestResult> {
|
|
69
|
+
return this.request<SubmitRequestResult>("POST", "/api/v1/help", {
|
|
70
|
+
apiKey: this.apiKey,
|
|
71
|
+
question: opts.question,
|
|
72
|
+
messages: opts.messages,
|
|
73
|
+
signPublicKey: opts.signPublicKey,
|
|
74
|
+
encryptPublicKey: opts.encryptPublicKey,
|
|
75
|
+
providerName: opts.providerName,
|
|
76
|
+
requiresApproval: opts.requiresApproval,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Poll for pending events (writes lastPollAt heartbeat on the server) */
|
|
81
|
+
async getPendingEvents(): Promise<{ events: PendingEvent[] }> {
|
|
82
|
+
return this.request<{ events: PendingEvent[] }>("GET", "/api/v1/events/pending");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Acknowledge a specific event */
|
|
86
|
+
async ackEvent(requestId: string): Promise<void> {
|
|
87
|
+
await this.request<unknown>("POST", `/api/v1/events/ack/${requestId}`, {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fetch the full message history for a request */
|
|
91
|
+
async getMessages(requestId: string): Promise<{ messages: Message[] }> {
|
|
92
|
+
return this.request<{ messages: Message[] }>(
|
|
93
|
+
"GET",
|
|
94
|
+
`/api/v1/messages/${requestId}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Get the current status of a help request */
|
|
99
|
+
async getRequestStatus(
|
|
100
|
+
requestId: string
|
|
101
|
+
): Promise<RequestStatusResponse> {
|
|
102
|
+
return this.request<RequestStatusResponse>(
|
|
103
|
+
"GET",
|
|
104
|
+
`/api/v1/help/${requestId}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Look up a request by its ref code */
|
|
109
|
+
async getRequestByRef(
|
|
110
|
+
refCode: string
|
|
111
|
+
): Promise<RequestStatusResponse> {
|
|
112
|
+
return this.request<RequestStatusResponse>(
|
|
113
|
+
"GET",
|
|
114
|
+
`/api/v1/requests/by-ref/${refCode}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface KeyPair {
|
|
6
|
+
signPublicKey: string;
|
|
7
|
+
encryptPublicKey: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate ephemeral Ed25519 + X25519 key pairs in memory.
|
|
12
|
+
* Returns hex-encoded DER public keys for the HeySummon API.
|
|
13
|
+
* Used by Claude Code (no file I/O needed).
|
|
14
|
+
*/
|
|
15
|
+
export function generateEphemeralKeys(): KeyPair {
|
|
16
|
+
const signKeys = crypto.generateKeyPairSync("ed25519");
|
|
17
|
+
const encKeys = crypto.generateKeyPairSync("x25519");
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
signPublicKey: signKeys.publicKey
|
|
21
|
+
.export({ type: "spki", format: "der" })
|
|
22
|
+
.toString("hex"),
|
|
23
|
+
encryptPublicKey: encKeys.publicKey
|
|
24
|
+
.export({ type: "spki", format: "der" })
|
|
25
|
+
.toString("hex"),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate persistent Ed25519 + X25519 key pairs, writing PEM files to dir.
|
|
31
|
+
* Returns hex-encoded DER public keys for the HeySummon API.
|
|
32
|
+
* Used by OpenClaw (keys survive restarts).
|
|
33
|
+
*/
|
|
34
|
+
export function generatePersistentKeys(dir: string): KeyPair {
|
|
35
|
+
if (!fs.existsSync(dir)) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ed = crypto.generateKeyPairSync("ed25519", {
|
|
40
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
41
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
42
|
+
});
|
|
43
|
+
fs.writeFileSync(path.join(dir, "sign_public.pem"), ed.publicKey);
|
|
44
|
+
fs.writeFileSync(path.join(dir, "sign_private.pem"), ed.privateKey);
|
|
45
|
+
|
|
46
|
+
const x = crypto.generateKeyPairSync("x25519", {
|
|
47
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
48
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
49
|
+
});
|
|
50
|
+
fs.writeFileSync(path.join(dir, "encrypt_public.pem"), x.publicKey);
|
|
51
|
+
fs.writeFileSync(path.join(dir, "encrypt_private.pem"), x.privateKey);
|
|
52
|
+
|
|
53
|
+
// Convert PEM to hex DER for the API
|
|
54
|
+
return {
|
|
55
|
+
signPublicKey: crypto
|
|
56
|
+
.createPublicKey(ed.publicKey)
|
|
57
|
+
.export({ type: "spki", format: "der" })
|
|
58
|
+
.toString("hex"),
|
|
59
|
+
encryptPublicKey: crypto
|
|
60
|
+
.createPublicKey(x.publicKey)
|
|
61
|
+
.export({ type: "spki", format: "der" })
|
|
62
|
+
.toString("hex"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load existing public keys from PEM files without regenerating.
|
|
68
|
+
* Returns hex-encoded DER public keys for the HeySummon API.
|
|
69
|
+
*/
|
|
70
|
+
export function loadPublicKeys(dir: string): KeyPair {
|
|
71
|
+
const signPem = fs.readFileSync(path.join(dir, "sign_public.pem"), "utf8");
|
|
72
|
+
const encPem = fs.readFileSync(
|
|
73
|
+
path.join(dir, "encrypt_public.pem"),
|
|
74
|
+
"utf8"
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
signPublicKey: crypto
|
|
79
|
+
.createPublicKey(signPem)
|
|
80
|
+
.export({ type: "spki", format: "der" })
|
|
81
|
+
.toString("hex"),
|
|
82
|
+
encryptPublicKey: crypto
|
|
83
|
+
.createPublicKey(encPem)
|
|
84
|
+
.export({ type: "spki", format: "der" })
|
|
85
|
+
.toString("hex"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Encrypt a plaintext message using X25519 DH + AES-256-GCM + Ed25519 signing.
|
|
91
|
+
*/
|
|
92
|
+
export function encrypt(
|
|
93
|
+
plaintext: string,
|
|
94
|
+
recipientX25519PubPath: string,
|
|
95
|
+
ownSignPrivPath: string,
|
|
96
|
+
ownEncPrivPath: string,
|
|
97
|
+
messageId?: string
|
|
98
|
+
): {
|
|
99
|
+
ciphertext: string;
|
|
100
|
+
iv: string;
|
|
101
|
+
authTag: string;
|
|
102
|
+
signature: string;
|
|
103
|
+
messageId: string;
|
|
104
|
+
} {
|
|
105
|
+
const recipientPub = crypto.createPublicKey(
|
|
106
|
+
fs.readFileSync(recipientX25519PubPath)
|
|
107
|
+
);
|
|
108
|
+
const ownEncPriv = crypto.createPrivateKey(fs.readFileSync(ownEncPrivPath));
|
|
109
|
+
const signPriv = crypto.createPrivateKey(fs.readFileSync(ownSignPrivPath));
|
|
110
|
+
|
|
111
|
+
const sharedSecret = crypto.diffieHellman({
|
|
112
|
+
privateKey: ownEncPriv,
|
|
113
|
+
publicKey: recipientPub,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const msgId = messageId || crypto.randomUUID();
|
|
117
|
+
|
|
118
|
+
const messageKey = crypto.hkdfSync(
|
|
119
|
+
"sha256",
|
|
120
|
+
sharedSecret,
|
|
121
|
+
msgId,
|
|
122
|
+
"heysummon-msg",
|
|
123
|
+
32
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const iv = crypto.randomBytes(12);
|
|
127
|
+
const cipher = crypto.createCipheriv(
|
|
128
|
+
"aes-256-gcm",
|
|
129
|
+
Buffer.from(messageKey),
|
|
130
|
+
iv
|
|
131
|
+
);
|
|
132
|
+
const encrypted = Buffer.concat([
|
|
133
|
+
cipher.update(plaintext, "utf8"),
|
|
134
|
+
cipher.final(),
|
|
135
|
+
]);
|
|
136
|
+
const authTag = cipher.getAuthTag();
|
|
137
|
+
|
|
138
|
+
const signature = crypto.sign(null, encrypted, signPriv);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
ciphertext: encrypted.toString("base64"),
|
|
142
|
+
iv: iv.toString("base64"),
|
|
143
|
+
authTag: authTag.toString("base64"),
|
|
144
|
+
signature: signature.toString("base64"),
|
|
145
|
+
messageId: msgId,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decrypt an encrypted message, verifying the Ed25519 signature first.
|
|
151
|
+
*/
|
|
152
|
+
export function decrypt(
|
|
153
|
+
payload: {
|
|
154
|
+
ciphertext: string;
|
|
155
|
+
iv: string;
|
|
156
|
+
authTag: string;
|
|
157
|
+
signature: string;
|
|
158
|
+
messageId: string;
|
|
159
|
+
},
|
|
160
|
+
senderX25519PubPath: string,
|
|
161
|
+
senderSignPubPath: string,
|
|
162
|
+
ownEncPrivPath: string
|
|
163
|
+
): string {
|
|
164
|
+
const senderPub = crypto.createPublicKey(
|
|
165
|
+
fs.readFileSync(senderX25519PubPath)
|
|
166
|
+
);
|
|
167
|
+
const senderSignPub = crypto.createPublicKey(
|
|
168
|
+
fs.readFileSync(senderSignPubPath)
|
|
169
|
+
);
|
|
170
|
+
const ownPriv = crypto.createPrivateKey(fs.readFileSync(ownEncPrivPath));
|
|
171
|
+
|
|
172
|
+
const ciphertextBuf = Buffer.from(payload.ciphertext, "base64");
|
|
173
|
+
const valid = crypto.verify(
|
|
174
|
+
null,
|
|
175
|
+
ciphertextBuf,
|
|
176
|
+
senderSignPub,
|
|
177
|
+
Buffer.from(payload.signature, "base64")
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (!valid) {
|
|
181
|
+
throw new Error("Signature verification failed");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const sharedSecret = crypto.diffieHellman({
|
|
185
|
+
privateKey: ownPriv,
|
|
186
|
+
publicKey: senderPub,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const messageKey = crypto.hkdfSync(
|
|
190
|
+
"sha256",
|
|
191
|
+
sharedSecret,
|
|
192
|
+
payload.messageId,
|
|
193
|
+
"heysummon-msg",
|
|
194
|
+
32
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const decipher = crypto.createDecipheriv(
|
|
198
|
+
"aes-256-gcm",
|
|
199
|
+
Buffer.from(messageKey),
|
|
200
|
+
Buffer.from(payload.iv, "base64")
|
|
201
|
+
);
|
|
202
|
+
decipher.setAuthTag(Buffer.from(payload.authTag, "base64"));
|
|
203
|
+
|
|
204
|
+
return Buffer.concat([decipher.update(ciphertextBuf), decipher.final()]).toString("utf8");
|
|
205
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { HeySummonClient, HeySummonHttpError } from "./client.js";
|
|
2
|
+
export { PollingWatcher } from "./poller.js";
|
|
3
|
+
export { ProviderStore } from "./provider-store.js";
|
|
4
|
+
export { RequestTracker } from "./request-tracker.js";
|
|
5
|
+
export {
|
|
6
|
+
generateEphemeralKeys,
|
|
7
|
+
generatePersistentKeys,
|
|
8
|
+
loadPublicKeys,
|
|
9
|
+
encrypt,
|
|
10
|
+
decrypt,
|
|
11
|
+
} from "./crypto.js";
|
|
12
|
+
export type {
|
|
13
|
+
Provider,
|
|
14
|
+
SubmitRequestOptions,
|
|
15
|
+
SubmitRequestResult,
|
|
16
|
+
PendingEvent,
|
|
17
|
+
Message,
|
|
18
|
+
RequestStatusResponse,
|
|
19
|
+
WhoamiResult,
|
|
20
|
+
HeySummonClientOptions,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
export type { PollingWatcherOptions } from "./poller.js";
|
|
23
|
+
export type { KeyPair } from "./crypto.js";
|
package/src/poller.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { HeySummonClient } from "./client.js";
|
|
2
|
+
import type { PendingEvent } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface PollingWatcherOptions {
|
|
5
|
+
client: HeySummonClient;
|
|
6
|
+
/** Interval between polls in ms (default: 5000) */
|
|
7
|
+
pollIntervalMs?: number;
|
|
8
|
+
/** Called for each new event received */
|
|
9
|
+
onEvent: (event: PendingEvent) => Promise<void>;
|
|
10
|
+
/** Called on network/poll errors — must not throw */
|
|
11
|
+
onError?: (err: Error) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Polls /api/v1/events/pending on an interval and fires onEvent for each result.
|
|
16
|
+
* Replaces the 286-line platform-watcher.sh with a testable TypeScript class.
|
|
17
|
+
*
|
|
18
|
+
* Deduplication: tracks seen requestIds in memory to avoid double-processing.
|
|
19
|
+
*/
|
|
20
|
+
export class PollingWatcher {
|
|
21
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
22
|
+
private seen = new Set<string>();
|
|
23
|
+
|
|
24
|
+
constructor(private readonly opts: PollingWatcherOptions) {}
|
|
25
|
+
|
|
26
|
+
start(): void {
|
|
27
|
+
if (this.timer !== null) return; // already running
|
|
28
|
+
|
|
29
|
+
const interval = this.opts.pollIntervalMs ?? 5_000;
|
|
30
|
+
|
|
31
|
+
this.timer = setInterval(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const { events } = await this.opts.client.getPendingEvents();
|
|
34
|
+
|
|
35
|
+
for (const event of events) {
|
|
36
|
+
const eventKey = `${event.requestId}:${event.type}:${event.latestMessageAt ?? ""}`;
|
|
37
|
+
if (this.seen.has(eventKey)) continue;
|
|
38
|
+
this.seen.add(eventKey);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await this.opts.onEvent(event);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Ack the event (best-effort, non-blocking)
|
|
47
|
+
if (event.requestId) {
|
|
48
|
+
this.opts.client.ackEvent(event.requestId).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
53
|
+
}
|
|
54
|
+
}, interval);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stop(): void {
|
|
58
|
+
if (this.timer !== null) {
|
|
59
|
+
clearInterval(this.timer);
|
|
60
|
+
this.timer = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isRunning(): boolean {
|
|
65
|
+
return this.timer !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reset the deduplication set (useful in tests) */
|
|
69
|
+
resetSeen(): void {
|
|
70
|
+
this.seen.clear();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import type { Provider } from "./types.js";
|
|
4
|
+
|
|
5
|
+
interface ProvidersFile {
|
|
6
|
+
providers: Provider[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Typed replacement for the inline `node -e` JSON manipulation in the bash scripts.
|
|
11
|
+
* Reads/writes providers.json with deduplication by API key and case-insensitive name.
|
|
12
|
+
*/
|
|
13
|
+
export class ProviderStore {
|
|
14
|
+
private readonly filePath: string;
|
|
15
|
+
|
|
16
|
+
constructor(filePath: string) {
|
|
17
|
+
this.filePath = filePath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
load(): Provider[] {
|
|
21
|
+
if (!existsSync(this.filePath)) return [];
|
|
22
|
+
try {
|
|
23
|
+
const data = JSON.parse(readFileSync(this.filePath, "utf8")) as ProvidersFile;
|
|
24
|
+
return Array.isArray(data.providers) ? data.providers : [];
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
save(providers: Provider[]): void {
|
|
31
|
+
const dir = dirname(this.filePath);
|
|
32
|
+
if (!existsSync(dir)) {
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
writeFileSync(this.filePath, JSON.stringify({ providers }, null, 2));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Add or update a provider entry.
|
|
40
|
+
* Deduplicates by API key and case-insensitive name (same logic as the bash script).
|
|
41
|
+
*/
|
|
42
|
+
add(entry: Omit<Provider, "addedAt" | "nameLower"> & { addedAt?: string }): Provider {
|
|
43
|
+
const providers = this.load();
|
|
44
|
+
const nameLower = entry.name.toLowerCase();
|
|
45
|
+
|
|
46
|
+
// Remove existing entries with same key or name (case-insensitive)
|
|
47
|
+
const filtered = providers.filter(
|
|
48
|
+
(p) => p.apiKey !== entry.apiKey && p.nameLower !== nameLower
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const newEntry: Provider = {
|
|
52
|
+
name: entry.name,
|
|
53
|
+
nameLower,
|
|
54
|
+
apiKey: entry.apiKey,
|
|
55
|
+
providerId: entry.providerId,
|
|
56
|
+
providerName: entry.providerName,
|
|
57
|
+
addedAt: entry.addedAt ?? new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
filtered.push(newEntry);
|
|
61
|
+
this.save(filtered);
|
|
62
|
+
return newEntry;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Case-insensitive lookup by name or alias */
|
|
66
|
+
findByName(name: string): Provider | undefined {
|
|
67
|
+
const lower = name.toLowerCase();
|
|
68
|
+
return this.load().find((p) => p.nameLower === lower || p.name.toLowerCase() === lower);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Look up by exact API key */
|
|
72
|
+
findByKey(apiKey: string): Provider | undefined {
|
|
73
|
+
return this.load().find((p) => p.apiKey === apiKey);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Returns the first provider (default when only one is registered) */
|
|
77
|
+
getDefault(): Provider | undefined {
|
|
78
|
+
return this.load()[0];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
remove(apiKey: string): boolean {
|
|
82
|
+
const providers = this.load();
|
|
83
|
+
const filtered = providers.filter((p) => p.apiKey !== apiKey);
|
|
84
|
+
if (filtered.length === providers.length) return false;
|
|
85
|
+
this.save(filtered);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
interface TrackedRequest {
|
|
5
|
+
requestId: string;
|
|
6
|
+
refCode: string;
|
|
7
|
+
provider?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* File-based request tracker. Each request is stored as a file:
|
|
12
|
+
* {dir}/{requestId} — contains the refCode
|
|
13
|
+
* {dir}/{requestId}.provider — contains the provider name (optional)
|
|
14
|
+
*
|
|
15
|
+
* Compatible with the existing OpenClaw .requests/ directory format.
|
|
16
|
+
*/
|
|
17
|
+
export class RequestTracker {
|
|
18
|
+
constructor(private readonly dir: string) {
|
|
19
|
+
if (!existsSync(dir)) {
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
track(requestId: string, refCode: string, providerName?: string): void {
|
|
25
|
+
writeFileSync(join(this.dir, requestId), refCode);
|
|
26
|
+
if (providerName) {
|
|
27
|
+
writeFileSync(join(this.dir, `${requestId}.provider`), providerName);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getRefCode(requestId: string): string | null {
|
|
32
|
+
const file = join(this.dir, requestId);
|
|
33
|
+
if (!existsSync(file)) return null;
|
|
34
|
+
return readFileSync(file, "utf8").trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getProvider(requestId: string): string | null {
|
|
38
|
+
const file = join(this.dir, `${requestId}.provider`);
|
|
39
|
+
if (!existsSync(file)) return null;
|
|
40
|
+
return readFileSync(file, "utf8").trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
remove(requestId: string): void {
|
|
44
|
+
const refFile = join(this.dir, requestId);
|
|
45
|
+
const provFile = join(this.dir, `${requestId}.provider`);
|
|
46
|
+
if (existsSync(refFile)) unlinkSync(refFile);
|
|
47
|
+
if (existsSync(provFile)) unlinkSync(provFile);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
listActive(): TrackedRequest[] {
|
|
51
|
+
if (!existsSync(this.dir)) return [];
|
|
52
|
+
const files = readdirSync(this.dir);
|
|
53
|
+
const result: TrackedRequest[] = [];
|
|
54
|
+
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
// Skip .provider files, .watcher.pid, hidden files
|
|
57
|
+
if (file.includes(".")) continue;
|
|
58
|
+
|
|
59
|
+
const requestId = file;
|
|
60
|
+
const refCode = this.getRefCode(requestId);
|
|
61
|
+
if (!refCode) continue;
|
|
62
|
+
|
|
63
|
+
result.push({
|
|
64
|
+
requestId,
|
|
65
|
+
refCode,
|
|
66
|
+
provider: this.getProvider(requestId) ?? undefined,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export interface Provider {
|
|
2
|
+
name: string;
|
|
3
|
+
nameLower: string;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
providerId: string;
|
|
6
|
+
providerName: string;
|
|
7
|
+
addedAt: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SubmitRequestOptions {
|
|
11
|
+
question: string;
|
|
12
|
+
messages?: Array<{ role: string; content: string }>;
|
|
13
|
+
signPublicKey?: string;
|
|
14
|
+
encryptPublicKey?: string;
|
|
15
|
+
providerName?: string;
|
|
16
|
+
requiresApproval?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SubmitRequestResult {
|
|
20
|
+
requestId: string;
|
|
21
|
+
refCode: string;
|
|
22
|
+
status: string;
|
|
23
|
+
expiresAt: string;
|
|
24
|
+
providerUnavailable?: boolean;
|
|
25
|
+
nextAvailableAt?: string;
|
|
26
|
+
serverPublicKey?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PendingEvent {
|
|
30
|
+
type:
|
|
31
|
+
| "new_request"
|
|
32
|
+
| "new_message"
|
|
33
|
+
| "keys_exchanged"
|
|
34
|
+
| "responded"
|
|
35
|
+
| "closed"
|
|
36
|
+
| "cancelled";
|
|
37
|
+
requestId: string;
|
|
38
|
+
refCode: string | null;
|
|
39
|
+
from?: "provider" | "consumer";
|
|
40
|
+
messageCount?: number;
|
|
41
|
+
respondedAt?: string | null;
|
|
42
|
+
latestMessageAt?: string | null;
|
|
43
|
+
cancelledAt?: string | null;
|
|
44
|
+
question?: string | null;
|
|
45
|
+
requiresApproval?: boolean;
|
|
46
|
+
createdAt?: string;
|
|
47
|
+
expiresAt?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Message {
|
|
51
|
+
id: string;
|
|
52
|
+
from: "provider" | "consumer";
|
|
53
|
+
ciphertext: string;
|
|
54
|
+
iv: string;
|
|
55
|
+
authTag: string;
|
|
56
|
+
signature: string;
|
|
57
|
+
messageId: string;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
plaintext?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RequestStatusResponse {
|
|
63
|
+
requestId: string;
|
|
64
|
+
refCode: string | null;
|
|
65
|
+
status: string;
|
|
66
|
+
response?: string;
|
|
67
|
+
lastMessage?: string;
|
|
68
|
+
question?: string;
|
|
69
|
+
providerName?: string;
|
|
70
|
+
provider?: { id: string; name: string };
|
|
71
|
+
createdAt?: string;
|
|
72
|
+
expiresAt?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface HeySummonClientOptions {
|
|
76
|
+
baseUrl: string;
|
|
77
|
+
apiKey: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface WhoamiResult {
|
|
81
|
+
keyId: string;
|
|
82
|
+
keyName: string | null;
|
|
83
|
+
provider: {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
isActive: boolean;
|
|
87
|
+
};
|
|
88
|
+
expert: {
|
|
89
|
+
id: string;
|
|
90
|
+
name: string | null;
|
|
91
|
+
};
|
|
92
|
+
}
|