lunel-cli 0.1.43 → 0.1.45

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.
@@ -0,0 +1,315 @@
1
+ // OpenCode AI provider — wraps @opencode-ai/sdk.
2
+ // All logic extracted verbatim from cli/src/index.ts AI handlers section.
3
+ import * as crypto from "crypto";
4
+ import { createOpencodeServer, createOpencodeClient } from "@opencode-ai/sdk";
5
+ const VERBOSE_AI_LOGS = process.env.LUNEL_DEBUG_AI === "1";
6
+ const SSE_BACKOFF_INITIAL_MS = 500;
7
+ const SSE_BACKOFF_CAP_MS = 30_000;
8
+ const SSE_MAX_RETRIES = 20;
9
+ function redactSensitive(input) {
10
+ const text = typeof input === "string" ? input : JSON.stringify(input);
11
+ return text
12
+ .replace(/([A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,})/g, "[redacted_jwt]")
13
+ .replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
14
+ .replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
15
+ }
16
+ function requireData(response, label) {
17
+ if (!response.data) {
18
+ const errMsg = response.error
19
+ ? (typeof response.error === "string" ? response.error : JSON.stringify(response.error))
20
+ : `${label} returned no data`;
21
+ console.error(`[ai] ${label} failed:`, redactSensitive(errMsg), "raw response:", redactSensitive(JSON.stringify(response).substring(0, 500)));
22
+ throw new Error(errMsg);
23
+ }
24
+ return response.data;
25
+ }
26
+ export class OpenCodeProvider {
27
+ client = null;
28
+ server = null;
29
+ lastActiveSessionId = null;
30
+ shuttingDown = false;
31
+ emitter = null;
32
+ async init() {
33
+ const opencodeUsername = "lunel";
34
+ const opencodePassword = crypto.randomBytes(32).toString("base64url");
35
+ const authHeader = `Basic ${Buffer.from(`${opencodeUsername}:${opencodePassword}`).toString("base64")}`;
36
+ process.env.OPENCODE_SERVER_USERNAME = opencodeUsername;
37
+ process.env.OPENCODE_SERVER_PASSWORD = opencodePassword;
38
+ console.log("Starting OpenCode...");
39
+ this.server = await createOpencodeServer({
40
+ hostname: "127.0.0.1",
41
+ port: 0,
42
+ timeout: 15000,
43
+ });
44
+ console.log(`OpenCode server listening on ${this.server.url}`);
45
+ this.client = createOpencodeClient({
46
+ baseUrl: this.server.url,
47
+ headers: { Authorization: authHeader },
48
+ });
49
+ console.log("OpenCode ready.\n");
50
+ }
51
+ async destroy() {
52
+ this.shuttingDown = true;
53
+ }
54
+ subscribe(emitter) {
55
+ this.emitter = emitter;
56
+ this.shuttingDown = false;
57
+ // Run the SSE loop in the background — it will call emitter for each event.
58
+ this.runSseLoop();
59
+ return () => {
60
+ this.emitter = null;
61
+ };
62
+ }
63
+ setActiveSession(sessionId) {
64
+ this.lastActiveSessionId = sessionId;
65
+ }
66
+ // -------------------------------------------------------------------------
67
+ // Session management
68
+ // -------------------------------------------------------------------------
69
+ async createSession(title) {
70
+ if (VERBOSE_AI_LOGS)
71
+ console.log("[ai] createSession called");
72
+ try {
73
+ const response = await this.client.session.create({ body: { title } });
74
+ if (VERBOSE_AI_LOGS) {
75
+ console.log("[ai] createSession response ok:", !!response.data, "error:", response.error ? redactSensitive(JSON.stringify(response.error).substring(0, 200)) : "none");
76
+ }
77
+ return { session: requireData(response, "session.create") };
78
+ }
79
+ catch (err) {
80
+ console.error("[ai] createSession exception:", redactSensitive(err.message));
81
+ throw err;
82
+ }
83
+ }
84
+ async listSessions() {
85
+ if (VERBOSE_AI_LOGS)
86
+ console.log("[ai] listSessions called");
87
+ try {
88
+ const response = await this.client.session.list();
89
+ const data = requireData(response, "session.list");
90
+ if (VERBOSE_AI_LOGS) {
91
+ console.log("[ai] listSessions returned", Array.isArray(data) ? data.length : typeof data, "sessions");
92
+ }
93
+ return { sessions: data };
94
+ }
95
+ catch (err) {
96
+ console.error("[ai] listSessions exception:", err.message);
97
+ throw err;
98
+ }
99
+ }
100
+ async getSession(id) {
101
+ const response = await this.client.session.get({ path: { id } });
102
+ return { session: requireData(response, "session.get") };
103
+ }
104
+ async deleteSession(id) {
105
+ const response = await this.client.session.delete({ path: { id } });
106
+ if (response.error)
107
+ throw new Error(JSON.stringify(response.error));
108
+ return {};
109
+ }
110
+ // -------------------------------------------------------------------------
111
+ // Messages
112
+ // -------------------------------------------------------------------------
113
+ async getMessages(sessionId) {
114
+ if (VERBOSE_AI_LOGS)
115
+ console.log("[ai] getMessages called");
116
+ try {
117
+ const response = await this.client.session.messages({ path: { id: sessionId } });
118
+ const raw = requireData(response, "session.messages");
119
+ const messages = raw.map((m) => ({
120
+ id: m.info.id,
121
+ role: m.info.role,
122
+ parts: m.parts || [],
123
+ time: m.info.time,
124
+ }));
125
+ if (VERBOSE_AI_LOGS)
126
+ console.log("[ai] getMessages returned", messages.length, "messages");
127
+ return { messages };
128
+ }
129
+ catch (err) {
130
+ console.error("[ai] getMessages exception:", err.message);
131
+ throw err;
132
+ }
133
+ }
134
+ // -------------------------------------------------------------------------
135
+ // Interaction
136
+ // -------------------------------------------------------------------------
137
+ async prompt(sessionId, text, model, agent) {
138
+ if (sessionId)
139
+ this.lastActiveSessionId = sessionId;
140
+ if (VERBOSE_AI_LOGS) {
141
+ console.log("[ai] prompt called", {
142
+ hasSessionId: Boolean(sessionId),
143
+ model: redactSensitive(JSON.stringify(model || {})),
144
+ hasAgent: Boolean(agent),
145
+ textLength: typeof text === "string" ? text.length : 0,
146
+ });
147
+ }
148
+ // Fire-and-forget — results come back through the SSE event stream.
149
+ this.client.session.prompt({
150
+ path: { id: sessionId },
151
+ body: {
152
+ parts: [{ type: "text", text }],
153
+ ...(model ? { model } : {}),
154
+ ...(agent ? { agent } : {}),
155
+ },
156
+ }).catch((err) => {
157
+ console.error("[ai] prompt error:", err.message);
158
+ this.emitter?.({
159
+ type: "prompt_error",
160
+ properties: { sessionId, error: err.message },
161
+ });
162
+ });
163
+ return { ack: true };
164
+ }
165
+ async abort(sessionId) {
166
+ await this.client.session.abort({ path: { id: sessionId } });
167
+ return {};
168
+ }
169
+ // -------------------------------------------------------------------------
170
+ // Metadata
171
+ // -------------------------------------------------------------------------
172
+ async agents() {
173
+ if (VERBOSE_AI_LOGS)
174
+ console.log("[ai] getAgents called");
175
+ try {
176
+ const response = await this.client.app.agents();
177
+ const data = requireData(response, "app.agents");
178
+ if (VERBOSE_AI_LOGS) {
179
+ console.log("[ai] getAgents returned:", redactSensitive(JSON.stringify(data).substring(0, 300)));
180
+ }
181
+ return { agents: data };
182
+ }
183
+ catch (err) {
184
+ console.error("[ai] getAgents exception:", err.message);
185
+ throw err;
186
+ }
187
+ }
188
+ async providers() {
189
+ if (VERBOSE_AI_LOGS)
190
+ console.log("[ai] getProviders called");
191
+ try {
192
+ const response = await this.client.config.providers();
193
+ const data = requireData(response, "config.providers");
194
+ if (VERBOSE_AI_LOGS) {
195
+ console.log("[ai] getProviders returned", data.providers?.length, "providers, defaults:", redactSensitive(JSON.stringify(data.default)));
196
+ }
197
+ return { providers: data.providers, default: data.default };
198
+ }
199
+ catch (err) {
200
+ console.error("[ai] getProviders exception:", err.message);
201
+ throw err;
202
+ }
203
+ }
204
+ // -------------------------------------------------------------------------
205
+ // Auth
206
+ // -------------------------------------------------------------------------
207
+ async setAuth(providerId, key) {
208
+ await this.client.auth.set({
209
+ path: { id: providerId },
210
+ body: { type: "api", key },
211
+ });
212
+ return {};
213
+ }
214
+ // -------------------------------------------------------------------------
215
+ // Session operations
216
+ // -------------------------------------------------------------------------
217
+ async command(sessionId, command, args) {
218
+ const response = await this.client.session.command({
219
+ path: { id: sessionId },
220
+ body: { command, arguments: args },
221
+ });
222
+ return { result: response.data ?? null };
223
+ }
224
+ async revert(sessionId, messageId) {
225
+ await this.client.session.revert({
226
+ path: { id: sessionId },
227
+ body: { messageID: messageId },
228
+ });
229
+ return {};
230
+ }
231
+ async unrevert(sessionId) {
232
+ await this.client.session.unrevert({ path: { id: sessionId } });
233
+ return {};
234
+ }
235
+ async share(sessionId) {
236
+ const response = await this.client.session.share({ path: { id: sessionId } });
237
+ return { share: requireData(response, "session.share") };
238
+ }
239
+ async permissionReply(sessionId, permissionId, response) {
240
+ await this.client.postSessionIdPermissionsPermissionId({
241
+ path: { id: sessionId, permissionID: permissionId },
242
+ body: { response },
243
+ });
244
+ return {};
245
+ }
246
+ // -------------------------------------------------------------------------
247
+ // SSE event loop (private)
248
+ // -------------------------------------------------------------------------
249
+ async runSseLoop() {
250
+ let attempt = 0;
251
+ const backoffMs = (n) => {
252
+ const base = Math.min(SSE_BACKOFF_INITIAL_MS * 2 ** n, SSE_BACKOFF_CAP_MS);
253
+ const jitter = Math.random() * base * 0.3;
254
+ return Math.round(base + jitter);
255
+ };
256
+ while (!this.shuttingDown) {
257
+ try {
258
+ // On reconnect, verify the active session is still alive.
259
+ if (attempt > 0 && this.lastActiveSessionId) {
260
+ const checkResp = await this.client.session.get({
261
+ path: { id: this.lastActiveSessionId },
262
+ });
263
+ if (checkResp.error) {
264
+ console.warn(`[sse] OpenCode session ${this.lastActiveSessionId} was garbage-collected. Notifying app.`);
265
+ const gcSessionId = this.lastActiveSessionId;
266
+ this.lastActiveSessionId = null;
267
+ this.emitter?.({ type: "session_gc", properties: { sessionId: gcSessionId } });
268
+ }
269
+ else {
270
+ console.log(`[sse] Active session ${this.lastActiveSessionId} still valid.`);
271
+ }
272
+ }
273
+ const events = await this.client.event.subscribe();
274
+ if (attempt > 0) {
275
+ console.log(`[sse] reconnected after ${attempt} attempt(s)`);
276
+ }
277
+ attempt = 0;
278
+ for await (const raw of events.stream) {
279
+ if (this.shuttingDown)
280
+ return;
281
+ // Handle two SSE payload shapes across SDK versions:
282
+ // { type, properties, ... }
283
+ // { payload: { type, properties, ... }, directory: "..." }
284
+ const parsed = raw;
285
+ const base = parsed?.payload && typeof parsed.payload === "object"
286
+ ? parsed.payload
287
+ : parsed;
288
+ if (!base || typeof base.type !== "string") {
289
+ console.warn("[sse] Dropped malformed event:", redactSensitive(JSON.stringify(parsed).substring(0, 200)));
290
+ continue;
291
+ }
292
+ console.log("[sse]", base.type);
293
+ this.emitter?.({ type: base.type, properties: base.properties || {} });
294
+ }
295
+ console.log("[sse] Event stream ended, reconnecting...");
296
+ }
297
+ catch (err) {
298
+ if (this.shuttingDown)
299
+ return;
300
+ attempt++;
301
+ const delay = backoffMs(attempt - 1);
302
+ console.error(`[sse] Stream error (attempt ${attempt}/${SSE_MAX_RETRIES}): ${err.message}. Retrying in ${delay}ms`);
303
+ if (attempt >= SSE_MAX_RETRIES) {
304
+ console.error("[sse] Max retries reached. Sending error event to app and giving up.");
305
+ this.emitter?.({
306
+ type: "sse_dead",
307
+ properties: { error: err.message, attempts: attempt },
308
+ });
309
+ return;
310
+ }
311
+ await new Promise((resolve) => setTimeout(resolve, delay));
312
+ }
313
+ }
314
+ }
315
+ }