@wlv-zedd/dsh-btw-plugin 1.0.3 → 1.0.6
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/LICENSE +21 -21
- package/README.md +110 -108
- package/README.zh-CN.md +110 -108
- package/cordis.patch.yml +4 -4
- package/lib/client.js +664 -646
- package/lib/index.js +675 -675
- package/package.json +66 -64
package/lib/index.js
CHANGED
|
@@ -1,675 +1,675 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import z from "@deepseek-ai/schemastery";
|
|
5
|
-
|
|
6
|
-
export const name = "@wlv-zedd/dsh-btw-plugin";
|
|
7
|
-
export const reusable = true;
|
|
8
|
-
export const inject = ["commands", "llm"];
|
|
9
|
-
|
|
10
|
-
export const Config = z.object({
|
|
11
|
-
model: z.string().description("Explicit LLM model name to use for side questions (e.g. 'deepseek:deepseek-chat'). If omitted, auto-inherits the active session model."),
|
|
12
|
-
maxItemsPerSession: z.number().default(50).description("Maximum number of side-question records to keep per session.")
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
const STORAGE_FILE = path.join(os.homedir(), ".dsh", "storages", "btw-history.json");
|
|
16
|
-
const SETTINGS_FILE = path.join(os.homedir(), ".dsh", "storages", "btw-settings.json");
|
|
17
|
-
|
|
18
|
-
function loadHistory() {
|
|
19
|
-
try {
|
|
20
|
-
if (fs.existsSync(STORAGE_FILE)) {
|
|
21
|
-
const raw = fs.readFileSync(STORAGE_FILE, "utf-8");
|
|
22
|
-
return JSON.parse(raw);
|
|
23
|
-
}
|
|
24
|
-
} catch {}
|
|
25
|
-
return {};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function saveHistory(history) {
|
|
29
|
-
try {
|
|
30
|
-
const dir = path.dirname(STORAGE_FILE);
|
|
31
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
32
|
-
fs.writeFileSync(STORAGE_FILE, JSON.stringify(history, null, 2), "utf-8");
|
|
33
|
-
} catch (err) {
|
|
34
|
-
console.error("[dsh-btw] Failed to save history:", err);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function loadSettings() {
|
|
39
|
-
try {
|
|
40
|
-
if (fs.existsSync(SETTINGS_FILE)) {
|
|
41
|
-
const raw = fs.readFileSync(SETTINGS_FILE, "utf-8");
|
|
42
|
-
return JSON.parse(raw);
|
|
43
|
-
}
|
|
44
|
-
} catch {}
|
|
45
|
-
return { model: "auto" };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function saveSettings(settings) {
|
|
49
|
-
try {
|
|
50
|
-
const dir = path.dirname(SETTINGS_FILE);
|
|
51
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
52
|
-
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf-8");
|
|
53
|
-
} catch (err) {
|
|
54
|
-
console.error("[dsh-btw] Failed to save settings:", err);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function getAvailableModels() {
|
|
59
|
-
const list = [{ id: "auto", name: "Inherit Session Model" }];
|
|
60
|
-
try {
|
|
61
|
-
const p = path.join(os.homedir(), ".dsh", "settings.yaml");
|
|
62
|
-
if (!fs.existsSync(p)) return list;
|
|
63
|
-
const yaml = fs.readFileSync(p, "utf8");
|
|
64
|
-
|
|
65
|
-
const providerBlocks = yaml.split(/\n {4}([a-zA-Z0-9_-]+):\n/);
|
|
66
|
-
for (let i = 1; i < providerBlocks.length; i += 2) {
|
|
67
|
-
const providerId = providerBlocks[i].trim();
|
|
68
|
-
const block = providerBlocks[i + 1];
|
|
69
|
-
if (!block || !block.includes("models:")) continue;
|
|
70
|
-
|
|
71
|
-
const dnMatch = block.match(/displayName:\s*([^\r\n]+)/);
|
|
72
|
-
const displayName = dnMatch ? dnMatch[1].replace(/['"]/g, "").trim() : providerId;
|
|
73
|
-
|
|
74
|
-
const modelsSection = block.split(/models:\s*[\r\n]+/)[1] || "";
|
|
75
|
-
const modelLines = modelsSection.split(/\n(?= {4}[a-zA-Z0-9_-]+:|\n\S|$)/)[0] || "";
|
|
76
|
-
|
|
77
|
-
const modelRegex = /-\s+id:\s*([^\r\n]+)(?:[\r\n]+[ \t]+name:\s*([^\r\n]+))?/g;
|
|
78
|
-
let m;
|
|
79
|
-
while ((m = modelRegex.exec(modelLines)) !== null) {
|
|
80
|
-
const mId = m[1].replace(/['"]/g, "").trim();
|
|
81
|
-
const mName = m[2] ? m[2].replace(/['"]/g, "").trim() : mId;
|
|
82
|
-
list.push({
|
|
83
|
-
id: `${providerId}:${mId}`,
|
|
84
|
-
name: `${displayName} — ${mName}`,
|
|
85
|
-
provider: providerId,
|
|
86
|
-
model: mId
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
} catch (err) {
|
|
91
|
-
console.warn("[dsh-btw] Failed to discover models:", err?.message);
|
|
92
|
-
}
|
|
93
|
-
return list;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function readJsonBody(req) {
|
|
97
|
-
return new Promise((resolve) => {
|
|
98
|
-
let data = "";
|
|
99
|
-
req.on("data", (chunk) => {
|
|
100
|
-
data += chunk;
|
|
101
|
-
});
|
|
102
|
-
req.on("end", () => {
|
|
103
|
-
try {
|
|
104
|
-
resolve(JSON.parse(data || "{}"));
|
|
105
|
-
} catch {
|
|
106
|
-
resolve({});
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
req.on("error", () => resolve({}));
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function sendJson(res, statusCode, body) {
|
|
114
|
-
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
|
115
|
-
res.end(JSON.stringify(body));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function getFallbackModelConfig() {
|
|
119
|
-
try {
|
|
120
|
-
const settingsPath = path.join(os.homedir(), ".dsh", "settings.yaml");
|
|
121
|
-
if (fs.existsSync(settingsPath)) {
|
|
122
|
-
const yaml = fs.readFileSync(settingsPath, "utf8");
|
|
123
|
-
const sectionMatch = yaml.match(/agent-default-model:\s*[\r\n]+((?:[ \t]+[^\r\n]+[\r\n]+)*)/);
|
|
124
|
-
if (sectionMatch && sectionMatch[1]) {
|
|
125
|
-
const section = sectionMatch[1];
|
|
126
|
-
const prov = section.match(/provider:\s*([^\s\r\n]+)/)?.[1];
|
|
127
|
-
const mod = section.match(/model:\s*([^\s\r\n]+)/)?.[1];
|
|
128
|
-
if (prov && mod) return { provider: prov, model: mod };
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
} catch {}
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function resolveSessionObject(ctx, sessionOrAgent, sessionId) {
|
|
136
|
-
if (sessionOrAgent?.session) {
|
|
137
|
-
return sessionOrAgent.session;
|
|
138
|
-
}
|
|
139
|
-
if (sessionOrAgent && typeof sessionOrAgent.deriveMessages === "function") {
|
|
140
|
-
return sessionOrAgent;
|
|
141
|
-
}
|
|
142
|
-
const sessions = typeof ctx?.get === "function" ? ctx.get("sessions") : null;
|
|
143
|
-
if (sessionId && sessions && typeof sessions.get === "function") {
|
|
144
|
-
try {
|
|
145
|
-
return sessions.get(sessionId);
|
|
146
|
-
} catch {}
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function resolveModelConfig(ctx, session, configModel) {
|
|
152
|
-
// 1. Explicit cordis.yml configuration
|
|
153
|
-
if (configModel) {
|
|
154
|
-
const parts = configModel.split(":");
|
|
155
|
-
if (parts.length === 2) return { provider: parts[0], model: parts[1] };
|
|
156
|
-
return { provider: "deepseek", model: configModel };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// 2. Global user setting from UI model picker (if not "auto")
|
|
160
|
-
const settings = loadSettings();
|
|
161
|
-
if (settings.model && settings.model !== "auto") {
|
|
162
|
-
const parts = settings.model.split(":");
|
|
163
|
-
if (parts.length === 2) return { provider: parts[0], model: parts[1] };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// 3. Auto-inherit from active session UI model picker
|
|
167
|
-
if (session) {
|
|
168
|
-
try {
|
|
169
|
-
const sessionProjections = typeof ctx?.get === "function" ? ctx.get("sessionProjections") : null;
|
|
170
|
-
const projected = sessionProjections?.stateOf?.(session, "modelSelection");
|
|
171
|
-
if (projected?.pending?.provider && projected?.pending?.model) {
|
|
172
|
-
return { provider: projected.pending.provider, model: projected.pending.model };
|
|
173
|
-
}
|
|
174
|
-
if (projected?.lastUsed?.provider && projected?.lastUsed?.model) {
|
|
175
|
-
return { provider: projected.lastUsed.provider, model: projected.lastUsed.model };
|
|
176
|
-
}
|
|
177
|
-
if (projected?.provider && projected?.model) {
|
|
178
|
-
return { provider: projected.provider, model: projected.model };
|
|
179
|
-
}
|
|
180
|
-
} catch {}
|
|
181
|
-
|
|
182
|
-
// 2. Sync with UI model picker: inspect model/selection events from session log
|
|
183
|
-
try {
|
|
184
|
-
const events = typeof session.snapshotEvents === "function"
|
|
185
|
-
? session.snapshotEvents()
|
|
186
|
-
: typeof session.ownEvents === "function"
|
|
187
|
-
? session.ownEvents()
|
|
188
|
-
: null;
|
|
189
|
-
if (Array.isArray(events)) {
|
|
190
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
191
|
-
const ev = events[i];
|
|
192
|
-
if (ev.type === "model/selection") {
|
|
193
|
-
if (ev.data?.provider && ev.data?.model) {
|
|
194
|
-
return { provider: ev.data.provider, model: ev.data.model };
|
|
195
|
-
}
|
|
196
|
-
if (ev.data?.pending?.provider && ev.data?.pending?.model) {
|
|
197
|
-
return { provider: ev.data.pending.provider, model: ev.data.pending.model };
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
} catch {}
|
|
203
|
-
|
|
204
|
-
// 3. Fallback to active request header from previous turns
|
|
205
|
-
const sessionConfig = session.requestHeader?.()?.config;
|
|
206
|
-
if (sessionConfig && sessionConfig.provider && sessionConfig.model) {
|
|
207
|
-
return { provider: sessionConfig.provider, model: sessionConfig.model };
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// 4. Fallback to default model from settings.yaml
|
|
212
|
-
const fallback = getFallbackModelConfig();
|
|
213
|
-
if (fallback && fallback.provider && fallback.model) {
|
|
214
|
-
return fallback;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
throw new Error("No active session model or configured model found. Please specify 'model' in config or run inside an active session.");
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function getMessageText(msg) {
|
|
221
|
-
if (!msg) return "";
|
|
222
|
-
if (Array.isArray(msg.content)) {
|
|
223
|
-
return msg.content
|
|
224
|
-
.filter((b) => b.type === "text" && b.text)
|
|
225
|
-
.map((b) => b.text.trim())
|
|
226
|
-
.join(" ");
|
|
227
|
-
}
|
|
228
|
-
if (typeof msg.content === "string") {
|
|
229
|
-
return msg.content.trim();
|
|
230
|
-
}
|
|
231
|
-
return "";
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function extractSessionContext(ctx, session) {
|
|
235
|
-
if (!session) return { title: "", workspace: "", sessionHistory: "" };
|
|
236
|
-
|
|
237
|
-
const title = session.header?.title || session.title || "";
|
|
238
|
-
const cwd = session.header?.cwd || session.cwd || "";
|
|
239
|
-
const workspace = cwd ? path.basename(cwd) : "";
|
|
240
|
-
|
|
241
|
-
// Build Pure Unified Turn Ledger (Turns 1 to N) dynamically from session
|
|
242
|
-
let turnEntries = [];
|
|
243
|
-
try {
|
|
244
|
-
const sessionProjections = typeof ctx?.get === "function" ? ctx.get("sessionProjections") : null;
|
|
245
|
-
const outline = sessionProjections?.stateOf?.(session, "turnOutline");
|
|
246
|
-
|
|
247
|
-
if (outline?.turns && Array.isArray(outline.turns) && outline.turns.length > 0) {
|
|
248
|
-
turnEntries = outline.turns.map((t) => {
|
|
249
|
-
const p = (t.prompt || "").replace(/\s+/g, " ").trim();
|
|
250
|
-
const r = (t.response || "").replace(/\s+/g, " ").trim();
|
|
251
|
-
return `- Earlier inquiry: User asked "${p.slice(0, 100)}" -> Outcome: ${r.slice(0, 120)}`;
|
|
252
|
-
});
|
|
253
|
-
} else if (typeof session.deriveMessages === "function") {
|
|
254
|
-
// Fallback: Pair real user messages with assistant outcomes
|
|
255
|
-
const messages = session.deriveMessages();
|
|
256
|
-
for (let i = 0; i < messages.length; i++) {
|
|
257
|
-
const m = messages[i];
|
|
258
|
-
if (m.role === "user" && m.source?.kind === "user") {
|
|
259
|
-
const p = getMessageText(m).replace(/\s+/g, " ").trim();
|
|
260
|
-
let r = "";
|
|
261
|
-
for (let j = i + 1; j < messages.length; j++) {
|
|
262
|
-
if (messages[j].role === "assistant") {
|
|
263
|
-
r = getMessageText(messages[j]).replace(/\s+/g, " ").trim();
|
|
264
|
-
break;
|
|
265
|
-
}
|
|
266
|
-
if (messages[j].role === "user" && messages[j].source?.kind === "user") {
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
turnEntries.push(`- Earlier inquiry: User asked "${p.slice(0, 100)}" -> Outcome: ${r.slice(0, 120)}`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
} catch (err) {
|
|
275
|
-
console.warn("[dsh-btw] Failed to build session turn ledger:", err?.message);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (turnEntries.length === 0) {
|
|
279
|
-
return { title, workspace, sessionHistory: "" };
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Head + Tail Preservation for long sessions (> 30 entries)
|
|
283
|
-
let finalEntries;
|
|
284
|
-
if (turnEntries.length <= 30) {
|
|
285
|
-
finalEntries = turnEntries;
|
|
286
|
-
} else {
|
|
287
|
-
const head = turnEntries.slice(0, 3);
|
|
288
|
-
const tail = turnEntries.slice(-25);
|
|
289
|
-
const skipped = turnEntries.length - (head.length + tail.length);
|
|
290
|
-
finalEntries = [
|
|
291
|
-
...head,
|
|
292
|
-
` [... ${skipped} middle entries omitted for brevity ...]`,
|
|
293
|
-
...tail
|
|
294
|
-
];
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const sessionHistory = `Session Conversation History (Chronological from Beginning to End):\n${finalEntries.join("\n")}`;
|
|
298
|
-
|
|
299
|
-
return { title, workspace, sessionHistory };
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async function streamLlmCall(ctx, targetConfig, systemPromptText, question) {
|
|
303
|
-
const prepared = await ctx.llm.prepareCall(targetConfig);
|
|
304
|
-
const options = {
|
|
305
|
-
...prepared.config,
|
|
306
|
-
system: systemPromptText,
|
|
307
|
-
messages: [
|
|
308
|
-
{
|
|
309
|
-
id: "msg-" + Date.now().toString(36) + Math.random().toString(36).slice(-4),
|
|
310
|
-
role: "user",
|
|
311
|
-
content: [{ type: "text", text: question }],
|
|
312
|
-
source: { kind: "user" }
|
|
313
|
-
}
|
|
314
|
-
],
|
|
315
|
-
signal: AbortSignal.timeout(60000)
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
const stream = prepared.stream(options);
|
|
319
|
-
let answer = "";
|
|
320
|
-
let reasoning = "";
|
|
321
|
-
let finishError = null;
|
|
322
|
-
|
|
323
|
-
for await (const chunk of stream) {
|
|
324
|
-
if (chunk.type === "text-delta" && typeof chunk.text === "string") {
|
|
325
|
-
answer += chunk.text;
|
|
326
|
-
} else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") {
|
|
327
|
-
reasoning += chunk.text;
|
|
328
|
-
} else if (chunk.type === "block-end" && chunk.block?.type === "text" && !answer) {
|
|
329
|
-
answer = chunk.block.text || "";
|
|
330
|
-
} else if (chunk.type === "finish") {
|
|
331
|
-
if (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted") {
|
|
332
|
-
finishError = chunk.reason.failure?.message || `Generation finished with ${chunk.reason.kind}`;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
if (finishError) {
|
|
338
|
-
throw new Error(finishError);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const trimmed = (answer || reasoning).trim();
|
|
342
|
-
if (trimmed) return cleanAbortedDraftPrefix(trimmed);
|
|
343
|
-
throw new Error("Model returned empty response.");
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function cleanAbortedDraftPrefix(text) {
|
|
347
|
-
if (!text || typeof text !== "string") return text;
|
|
348
|
-
let cleaned = text.trim();
|
|
349
|
-
if (cleaned.includes("```")) return cleaned;
|
|
350
|
-
if (/\b(const|let|var|function|import|export|class|interface|type|return)\b/.test(cleaned)) return cleaned;
|
|
351
|
-
|
|
352
|
-
// Detect aborted prompt-mirroring or draft fragments fused to a capitalized restart (e.g. "...kundiNapag-usapan natin...")
|
|
353
|
-
const fusedMatch = cleaned.match(/^(.*?\b[a-z]{2,})([A-Z][a-z]{2,}\b.*)$/s);
|
|
354
|
-
if (fusedMatch && fusedMatch[2]) {
|
|
355
|
-
const prefix = fusedMatch[1].trim();
|
|
356
|
-
if (prefix.includes(" ") && !/[;{}=<>()\/\\]/.test(prefix)) {
|
|
357
|
-
cleaned = fusedMatch[2].trim();
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
return cleaned;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async function generateFastAnswer(ctx, sessionOrAgent, sessionId, question, wsName, configModel, onRetryProgress) {
|
|
364
|
-
if (!ctx.llm) {
|
|
365
|
-
throw new Error("LLM service is not mounted.");
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const session = resolveSessionObject(ctx, sessionOrAgent, sessionId);
|
|
369
|
-
const targetConfig = resolveModelConfig(ctx, session, configModel);
|
|
370
|
-
const sessionCtx = extractSessionContext(ctx, session);
|
|
371
|
-
|
|
372
|
-
const now = new Date();
|
|
373
|
-
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
374
|
-
const timeStr = now.toLocaleString("en-US", {
|
|
375
|
-
timeZone: tz,
|
|
376
|
-
weekday: "long",
|
|
377
|
-
year: "numeric",
|
|
378
|
-
month: "long",
|
|
379
|
-
day: "numeric",
|
|
380
|
-
hour: "numeric",
|
|
381
|
-
minute: "2-digit",
|
|
382
|
-
second: "2-digit"
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
let systemPrompt = `You are DSH BTW, the assistant for DeepSeek Harness answering side questions (/btw) in this workspace session.
|
|
386
|
-
Live Environment:
|
|
387
|
-
- Current Time: ${timeStr} (${tz})
|
|
388
|
-
- Active Workspace: ${sessionCtx.workspace || wsName || "default"}`;
|
|
389
|
-
|
|
390
|
-
if (sessionCtx.title) {
|
|
391
|
-
systemPrompt += `\n- Session Title: ${sessionCtx.title}`;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (sessionCtx.sessionHistory) {
|
|
395
|
-
systemPrompt += `\n\nActive Session Context:\n${sessionCtx.sessionHistory}`;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
systemPrompt += `\n\nInstructions:
|
|
399
|
-
- Answer the user's side question directly, accurately, and concisely.
|
|
400
|
-
- For session inquiries (summaries, past topics, recent actions), synthesize the session history accurately.
|
|
401
|
-
- For general questions (facts, pricing, trivia, coding, translations), provide direct, factual answers.
|
|
402
|
-
- When presenting multiple topics, steps, or items, use a clean numbered list (1., 2.) or bulleted list. Avoid markdown tables (|---|).
|
|
403
|
-
- Use natural, fluent language matching the user's phrasing and language.
|
|
404
|
-
- Format responses cleanly using standard Markdown where helpful.`;
|
|
405
|
-
|
|
406
|
-
const isTransient = (err) => {
|
|
407
|
-
const msg = String(err?.message || "").toLowerCase();
|
|
408
|
-
return /500|502|503|504|internal[\s_-]*error|server[\s_-]*error|try again|rate[\s_-]*limit|overloaded|service unavailable|bad gateway|timeout|aborted|econnreset|etimedout|fetch failed/i.test(msg);
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
const retryDelays = [1000, 1500, 2000, 2500, 3000];
|
|
412
|
-
const maxRetries = retryDelays.length;
|
|
413
|
-
|
|
414
|
-
let lastErr = null;
|
|
415
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
416
|
-
try {
|
|
417
|
-
return await streamLlmCall(ctx, targetConfig, systemPrompt, question);
|
|
418
|
-
} catch (err) {
|
|
419
|
-
lastErr = err;
|
|
420
|
-
if (isTransient(err) && attempt < maxRetries) {
|
|
421
|
-
const backoffMs = retryDelays[attempt];
|
|
422
|
-
const currentRetry = attempt + 1;
|
|
423
|
-
console.warn(`[dsh-btw] Transient error on '${targetConfig.provider}' (${err?.message || "error"}). Retrying in ${backoffMs}ms (attempt ${currentRetry}/${maxRetries})...`);
|
|
424
|
-
if (typeof onRetryProgress === "function") {
|
|
425
|
-
try {
|
|
426
|
-
onRetryProgress(currentRetry, maxRetries);
|
|
427
|
-
} catch {}
|
|
428
|
-
}
|
|
429
|
-
await new Promise((r) => setTimeout(r, backoffMs));
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
break;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
throw new Error(`Provider '${targetConfig.provider}' returned error (${lastErr?.message || "unknown"}). Switch to another model in the header.`);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
async function handleSideQuery(ctx, sessionOrAgent, sessionId, question, wsName, configModel, maxItems) {
|
|
439
|
-
const history = loadHistory();
|
|
440
|
-
if (!history[sessionId]) history[sessionId] = [];
|
|
441
|
-
|
|
442
|
-
const itemId = "btw-" + Date.now().toString(36) + Math.random().toString(36).slice(-4);
|
|
443
|
-
const pendingItem = {
|
|
444
|
-
id: itemId,
|
|
445
|
-
question,
|
|
446
|
-
answer: "",
|
|
447
|
-
loading: true,
|
|
448
|
-
retryAttempt: 0,
|
|
449
|
-
maxRetries: 5,
|
|
450
|
-
source: "DSH BTW",
|
|
451
|
-
timestamp: Date.now()
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
history[sessionId].unshift(pendingItem);
|
|
455
|
-
if (history[sessionId].length > maxItems) {
|
|
456
|
-
history[sessionId] = history[sessionId].slice(0, maxItems);
|
|
457
|
-
}
|
|
458
|
-
saveHistory(history);
|
|
459
|
-
|
|
460
|
-
try {
|
|
461
|
-
const answer = await generateFastAnswer(
|
|
462
|
-
ctx,
|
|
463
|
-
sessionOrAgent,
|
|
464
|
-
sessionId,
|
|
465
|
-
question,
|
|
466
|
-
wsName,
|
|
467
|
-
configModel,
|
|
468
|
-
(attempt, max) => {
|
|
469
|
-
pendingItem.retryAttempt = attempt;
|
|
470
|
-
pendingItem.maxRetries = max;
|
|
471
|
-
saveHistory(history);
|
|
472
|
-
}
|
|
473
|
-
);
|
|
474
|
-
pendingItem.answer = answer;
|
|
475
|
-
pendingItem.loading = false;
|
|
476
|
-
pendingItem.retryAttempt = 0;
|
|
477
|
-
saveHistory(history);
|
|
478
|
-
return { answer, item: pendingItem };
|
|
479
|
-
} catch (err) {
|
|
480
|
-
const errorMsg = `Unable to resolve side question: ${err?.message || "LLM error"}`;
|
|
481
|
-
pendingItem.answer = errorMsg;
|
|
482
|
-
pendingItem.loading = false;
|
|
483
|
-
pendingItem.retryAttempt = 0;
|
|
484
|
-
saveHistory(history);
|
|
485
|
-
return { answer: errorMsg, item: pendingItem };
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
export function apply(ctx, config) {
|
|
490
|
-
const maxItems = config?.maxItemsPerSession || 50;
|
|
491
|
-
|
|
492
|
-
// 1. Register /btw slash command
|
|
493
|
-
ctx.effect(() => {
|
|
494
|
-
return ctx.commands.register({
|
|
495
|
-
name: "btw",
|
|
496
|
-
description: "Ask a quick side question without interrupting the active agent loop (/btw <question>)",
|
|
497
|
-
input: { hint: "<question to ask on the side>" },
|
|
498
|
-
handler: async (invocation) => {
|
|
499
|
-
const question = (invocation.rawInput || "").trim();
|
|
500
|
-
if (!question) return { kind: "error", text: "Usage: /btw <question to ask on the side>" };
|
|
501
|
-
const agent = invocation.agent;
|
|
502
|
-
const sessionId = agent?.session?.header?.id || agent?.id;
|
|
503
|
-
const cwd = agent?.session?.header?.cwd || "";
|
|
504
|
-
const wsName = cwd ? path.basename(cwd) : "default";
|
|
505
|
-
|
|
506
|
-
// Launch query asynchronously so composer input clears instantly on Enter!
|
|
507
|
-
void handleSideQuery(ctx, agent, sessionId, question, wsName, config?.model, maxItems);
|
|
508
|
-
return {
|
|
509
|
-
kind: "success",
|
|
510
|
-
text: ""
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
});
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
// 2. HTTP Endpoints via WebServer when mounted
|
|
517
|
-
ctx.inject(["webServer"], (webCtx) => {
|
|
518
|
-
webCtx.effect(() => {
|
|
519
|
-
const unregisterFeed = webCtx.webServer.register({
|
|
520
|
-
kind: "exact",
|
|
521
|
-
path: "/api/dsh-btw/feed",
|
|
522
|
-
handler: async (req, res) => {
|
|
523
|
-
const url = new URL(req.url || "", "http://127.0.0.1");
|
|
524
|
-
const sid = url.searchParams.get("sessionId");
|
|
525
|
-
if (!sid) {
|
|
526
|
-
sendJson(res, 400, { ok: false, error: "Missing sessionId" });
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
const history = loadHistory();
|
|
530
|
-
sendJson(res, 200, { ok: true, items: history[sid] || [] });
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
const unregisterAsk = webCtx.webServer.register({
|
|
535
|
-
kind: "exact",
|
|
536
|
-
path: "/api/dsh-btw/ask",
|
|
537
|
-
handler: async (req, res) => {
|
|
538
|
-
if (req.method !== "POST") {
|
|
539
|
-
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
const body = await readJsonBody(req);
|
|
543
|
-
const { sessionId, question, cwd } = body;
|
|
544
|
-
if (!sessionId || !question) {
|
|
545
|
-
sendJson(res, 400, { ok: false, error: "Missing sessionId or question" });
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
const wsName = cwd ? path.basename(cwd) : "default";
|
|
549
|
-
void handleSideQuery(ctx, null, sessionId, question.trim(), wsName, config?.model, maxItems);
|
|
550
|
-
sendJson(res, 200, { ok: true });
|
|
551
|
-
}
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
const unregisterDelete = webCtx.webServer.register({
|
|
555
|
-
kind: "exact",
|
|
556
|
-
path: "/api/dsh-btw/delete",
|
|
557
|
-
handler: async (req, res) => {
|
|
558
|
-
if (req.method !== "POST") {
|
|
559
|
-
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
const body = await readJsonBody(req);
|
|
563
|
-
const { sessionId, itemId } = body;
|
|
564
|
-
if (!sessionId || !itemId) {
|
|
565
|
-
sendJson(res, 400, { ok: false, error: "Missing sessionId or itemId" });
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
const history = loadHistory();
|
|
569
|
-
if (history[sessionId]) {
|
|
570
|
-
history[sessionId] = history[sessionId].filter((i) => i.id !== itemId);
|
|
571
|
-
saveHistory(history);
|
|
572
|
-
}
|
|
573
|
-
sendJson(res, 200, { ok: true });
|
|
574
|
-
}
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
const unregisterModels = webCtx.webServer.register({
|
|
578
|
-
kind: "exact",
|
|
579
|
-
path: "/api/dsh-btw/models",
|
|
580
|
-
handler: async (req, res) => {
|
|
581
|
-
if (req.method !== "GET") {
|
|
582
|
-
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
const settings = loadSettings();
|
|
586
|
-
const models = getAvailableModels();
|
|
587
|
-
sendJson(res, 200, { ok: true, models, currentModel: settings.model || "auto" });
|
|
588
|
-
}
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
const unregisterSetModel = webCtx.webServer.register({
|
|
592
|
-
kind: "exact",
|
|
593
|
-
path: "/api/dsh-btw/model",
|
|
594
|
-
handler: async (req, res) => {
|
|
595
|
-
if (req.method !== "POST") {
|
|
596
|
-
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
const body = await readJsonBody(req);
|
|
600
|
-
const settings = loadSettings();
|
|
601
|
-
settings.model = body.model || "auto";
|
|
602
|
-
saveSettings(settings);
|
|
603
|
-
sendJson(res, 200, { ok: true, currentModel: settings.model });
|
|
604
|
-
}
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
const unregisterRetry = webCtx.webServer.register({
|
|
608
|
-
kind: "exact",
|
|
609
|
-
path: "/api/dsh-btw/retry",
|
|
610
|
-
handler: async (req, res) => {
|
|
611
|
-
if (req.method !== "POST") {
|
|
612
|
-
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
const body = await readJsonBody(req);
|
|
616
|
-
const { sessionId, itemId } = body || {};
|
|
617
|
-
if (!sessionId || !itemId) {
|
|
618
|
-
sendJson(res, 400, { ok: false, error: "Missing sessionId or itemId" });
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
const history = loadHistory();
|
|
622
|
-
const sessionItems = history[sessionId] || [];
|
|
623
|
-
const target = sessionItems.find((i) => i.id === itemId);
|
|
624
|
-
if (!target) {
|
|
625
|
-
sendJson(res, 404, { ok: false, error: "Item not found" });
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
target.loading = true;
|
|
630
|
-
target.answer = "";
|
|
631
|
-
target.retryAttempt = 0;
|
|
632
|
-
target.maxRetries = 5;
|
|
633
|
-
saveHistory(history);
|
|
634
|
-
sendJson(res, 200, { ok: true });
|
|
635
|
-
|
|
636
|
-
void (async () => {
|
|
637
|
-
try {
|
|
638
|
-
const answer = await generateFastAnswer(
|
|
639
|
-
ctx,
|
|
640
|
-
null,
|
|
641
|
-
sessionId,
|
|
642
|
-
target.question,
|
|
643
|
-
"default",
|
|
644
|
-
config?.model,
|
|
645
|
-
(attempt, max) => {
|
|
646
|
-
target.retryAttempt = attempt;
|
|
647
|
-
target.maxRetries = max;
|
|
648
|
-
saveHistory(history);
|
|
649
|
-
}
|
|
650
|
-
);
|
|
651
|
-
target.answer = answer;
|
|
652
|
-
target.loading = false;
|
|
653
|
-
target.retryAttempt = 0;
|
|
654
|
-
saveHistory(history);
|
|
655
|
-
} catch (err) {
|
|
656
|
-
target.answer = `Unable to resolve side question: ${err?.message || "LLM error"}`;
|
|
657
|
-
target.loading = false;
|
|
658
|
-
target.retryAttempt = 0;
|
|
659
|
-
saveHistory(history);
|
|
660
|
-
}
|
|
661
|
-
})();
|
|
662
|
-
}
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
return () => {
|
|
666
|
-
unregisterFeed();
|
|
667
|
-
unregisterAsk();
|
|
668
|
-
unregisterDelete();
|
|
669
|
-
unregisterModels();
|
|
670
|
-
unregisterSetModel();
|
|
671
|
-
unregisterRetry();
|
|
672
|
-
};
|
|
673
|
-
});
|
|
674
|
-
});
|
|
675
|
-
}
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import z from "@deepseek-ai/schemastery";
|
|
5
|
+
|
|
6
|
+
export const name = "@wlv-zedd/dsh-btw-plugin";
|
|
7
|
+
export const reusable = true;
|
|
8
|
+
export const inject = ["commands", "llm"];
|
|
9
|
+
|
|
10
|
+
export const Config = z.object({
|
|
11
|
+
model: z.string().description("Explicit LLM model name to use for side questions (e.g. 'deepseek:deepseek-chat'). If omitted, auto-inherits the active session model."),
|
|
12
|
+
maxItemsPerSession: z.number().default(50).description("Maximum number of side-question records to keep per session.")
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const STORAGE_FILE = path.join(os.homedir(), ".dsh", "storages", "btw-history.json");
|
|
16
|
+
const SETTINGS_FILE = path.join(os.homedir(), ".dsh", "storages", "btw-settings.json");
|
|
17
|
+
|
|
18
|
+
function loadHistory() {
|
|
19
|
+
try {
|
|
20
|
+
if (fs.existsSync(STORAGE_FILE)) {
|
|
21
|
+
const raw = fs.readFileSync(STORAGE_FILE, "utf-8");
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveHistory(history) {
|
|
29
|
+
try {
|
|
30
|
+
const dir = path.dirname(STORAGE_FILE);
|
|
31
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
fs.writeFileSync(STORAGE_FILE, JSON.stringify(history, null, 2), "utf-8");
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error("[dsh-btw] Failed to save history:", err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadSettings() {
|
|
39
|
+
try {
|
|
40
|
+
if (fs.existsSync(SETTINGS_FILE)) {
|
|
41
|
+
const raw = fs.readFileSync(SETTINGS_FILE, "utf-8");
|
|
42
|
+
return JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
} catch {}
|
|
45
|
+
return { model: "auto" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveSettings(settings) {
|
|
49
|
+
try {
|
|
50
|
+
const dir = path.dirname(SETTINGS_FILE);
|
|
51
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf-8");
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error("[dsh-btw] Failed to save settings:", err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getAvailableModels() {
|
|
59
|
+
const list = [{ id: "auto", name: "Inherit Session Model" }];
|
|
60
|
+
try {
|
|
61
|
+
const p = path.join(os.homedir(), ".dsh", "settings.yaml");
|
|
62
|
+
if (!fs.existsSync(p)) return list;
|
|
63
|
+
const yaml = fs.readFileSync(p, "utf8");
|
|
64
|
+
|
|
65
|
+
const providerBlocks = yaml.split(/\n {4}([a-zA-Z0-9_-]+):\n/);
|
|
66
|
+
for (let i = 1; i < providerBlocks.length; i += 2) {
|
|
67
|
+
const providerId = providerBlocks[i].trim();
|
|
68
|
+
const block = providerBlocks[i + 1];
|
|
69
|
+
if (!block || !block.includes("models:")) continue;
|
|
70
|
+
|
|
71
|
+
const dnMatch = block.match(/displayName:\s*([^\r\n]+)/);
|
|
72
|
+
const displayName = dnMatch ? dnMatch[1].replace(/['"]/g, "").trim() : providerId;
|
|
73
|
+
|
|
74
|
+
const modelsSection = block.split(/models:\s*[\r\n]+/)[1] || "";
|
|
75
|
+
const modelLines = modelsSection.split(/\n(?= {4}[a-zA-Z0-9_-]+:|\n\S|$)/)[0] || "";
|
|
76
|
+
|
|
77
|
+
const modelRegex = /-\s+id:\s*([^\r\n]+)(?:[\r\n]+[ \t]+name:\s*([^\r\n]+))?/g;
|
|
78
|
+
let m;
|
|
79
|
+
while ((m = modelRegex.exec(modelLines)) !== null) {
|
|
80
|
+
const mId = m[1].replace(/['"]/g, "").trim();
|
|
81
|
+
const mName = m[2] ? m[2].replace(/['"]/g, "").trim() : mId;
|
|
82
|
+
list.push({
|
|
83
|
+
id: `${providerId}:${mId}`,
|
|
84
|
+
name: `${displayName} — ${mName}`,
|
|
85
|
+
provider: providerId,
|
|
86
|
+
model: mId
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.warn("[dsh-btw] Failed to discover models:", err?.message);
|
|
92
|
+
}
|
|
93
|
+
return list;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readJsonBody(req) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
let data = "";
|
|
99
|
+
req.on("data", (chunk) => {
|
|
100
|
+
data += chunk;
|
|
101
|
+
});
|
|
102
|
+
req.on("end", () => {
|
|
103
|
+
try {
|
|
104
|
+
resolve(JSON.parse(data || "{}"));
|
|
105
|
+
} catch {
|
|
106
|
+
resolve({});
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
req.on("error", () => resolve({}));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sendJson(res, statusCode, body) {
|
|
114
|
+
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
|
115
|
+
res.end(JSON.stringify(body));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getFallbackModelConfig() {
|
|
119
|
+
try {
|
|
120
|
+
const settingsPath = path.join(os.homedir(), ".dsh", "settings.yaml");
|
|
121
|
+
if (fs.existsSync(settingsPath)) {
|
|
122
|
+
const yaml = fs.readFileSync(settingsPath, "utf8");
|
|
123
|
+
const sectionMatch = yaml.match(/agent-default-model:\s*[\r\n]+((?:[ \t]+[^\r\n]+[\r\n]+)*)/);
|
|
124
|
+
if (sectionMatch && sectionMatch[1]) {
|
|
125
|
+
const section = sectionMatch[1];
|
|
126
|
+
const prov = section.match(/provider:\s*([^\s\r\n]+)/)?.[1];
|
|
127
|
+
const mod = section.match(/model:\s*([^\s\r\n]+)/)?.[1];
|
|
128
|
+
if (prov && mod) return { provider: prov, model: mod };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveSessionObject(ctx, sessionOrAgent, sessionId) {
|
|
136
|
+
if (sessionOrAgent?.session) {
|
|
137
|
+
return sessionOrAgent.session;
|
|
138
|
+
}
|
|
139
|
+
if (sessionOrAgent && typeof sessionOrAgent.deriveMessages === "function") {
|
|
140
|
+
return sessionOrAgent;
|
|
141
|
+
}
|
|
142
|
+
const sessions = typeof ctx?.get === "function" ? ctx.get("sessions") : null;
|
|
143
|
+
if (sessionId && sessions && typeof sessions.get === "function") {
|
|
144
|
+
try {
|
|
145
|
+
return sessions.get(sessionId);
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolveModelConfig(ctx, session, configModel) {
|
|
152
|
+
// 1. Explicit cordis.yml configuration
|
|
153
|
+
if (configModel) {
|
|
154
|
+
const parts = configModel.split(":");
|
|
155
|
+
if (parts.length === 2) return { provider: parts[0], model: parts[1] };
|
|
156
|
+
return { provider: "deepseek", model: configModel };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Global user setting from UI model picker (if not "auto")
|
|
160
|
+
const settings = loadSettings();
|
|
161
|
+
if (settings.model && settings.model !== "auto") {
|
|
162
|
+
const parts = settings.model.split(":");
|
|
163
|
+
if (parts.length === 2) return { provider: parts[0], model: parts[1] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 3. Auto-inherit from active session UI model picker
|
|
167
|
+
if (session) {
|
|
168
|
+
try {
|
|
169
|
+
const sessionProjections = typeof ctx?.get === "function" ? ctx.get("sessionProjections") : null;
|
|
170
|
+
const projected = sessionProjections?.stateOf?.(session, "modelSelection");
|
|
171
|
+
if (projected?.pending?.provider && projected?.pending?.model) {
|
|
172
|
+
return { provider: projected.pending.provider, model: projected.pending.model };
|
|
173
|
+
}
|
|
174
|
+
if (projected?.lastUsed?.provider && projected?.lastUsed?.model) {
|
|
175
|
+
return { provider: projected.lastUsed.provider, model: projected.lastUsed.model };
|
|
176
|
+
}
|
|
177
|
+
if (projected?.provider && projected?.model) {
|
|
178
|
+
return { provider: projected.provider, model: projected.model };
|
|
179
|
+
}
|
|
180
|
+
} catch {}
|
|
181
|
+
|
|
182
|
+
// 2. Sync with UI model picker: inspect model/selection events from session log
|
|
183
|
+
try {
|
|
184
|
+
const events = typeof session.snapshotEvents === "function"
|
|
185
|
+
? session.snapshotEvents()
|
|
186
|
+
: typeof session.ownEvents === "function"
|
|
187
|
+
? session.ownEvents()
|
|
188
|
+
: null;
|
|
189
|
+
if (Array.isArray(events)) {
|
|
190
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
191
|
+
const ev = events[i];
|
|
192
|
+
if (ev.type === "model/selection") {
|
|
193
|
+
if (ev.data?.provider && ev.data?.model) {
|
|
194
|
+
return { provider: ev.data.provider, model: ev.data.model };
|
|
195
|
+
}
|
|
196
|
+
if (ev.data?.pending?.provider && ev.data?.pending?.model) {
|
|
197
|
+
return { provider: ev.data.pending.provider, model: ev.data.pending.model };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
|
|
204
|
+
// 3. Fallback to active request header from previous turns
|
|
205
|
+
const sessionConfig = session.requestHeader?.()?.config;
|
|
206
|
+
if (sessionConfig && sessionConfig.provider && sessionConfig.model) {
|
|
207
|
+
return { provider: sessionConfig.provider, model: sessionConfig.model };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 4. Fallback to default model from settings.yaml
|
|
212
|
+
const fallback = getFallbackModelConfig();
|
|
213
|
+
if (fallback && fallback.provider && fallback.model) {
|
|
214
|
+
return fallback;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
throw new Error("No active session model or configured model found. Please specify 'model' in config or run inside an active session.");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getMessageText(msg) {
|
|
221
|
+
if (!msg) return "";
|
|
222
|
+
if (Array.isArray(msg.content)) {
|
|
223
|
+
return msg.content
|
|
224
|
+
.filter((b) => b.type === "text" && b.text)
|
|
225
|
+
.map((b) => b.text.trim())
|
|
226
|
+
.join(" ");
|
|
227
|
+
}
|
|
228
|
+
if (typeof msg.content === "string") {
|
|
229
|
+
return msg.content.trim();
|
|
230
|
+
}
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function extractSessionContext(ctx, session) {
|
|
235
|
+
if (!session) return { title: "", workspace: "", sessionHistory: "" };
|
|
236
|
+
|
|
237
|
+
const title = session.header?.title || session.title || "";
|
|
238
|
+
const cwd = session.header?.cwd || session.cwd || "";
|
|
239
|
+
const workspace = cwd ? path.basename(cwd) : "";
|
|
240
|
+
|
|
241
|
+
// Build Pure Unified Turn Ledger (Turns 1 to N) dynamically from session
|
|
242
|
+
let turnEntries = [];
|
|
243
|
+
try {
|
|
244
|
+
const sessionProjections = typeof ctx?.get === "function" ? ctx.get("sessionProjections") : null;
|
|
245
|
+
const outline = sessionProjections?.stateOf?.(session, "turnOutline");
|
|
246
|
+
|
|
247
|
+
if (outline?.turns && Array.isArray(outline.turns) && outline.turns.length > 0) {
|
|
248
|
+
turnEntries = outline.turns.map((t) => {
|
|
249
|
+
const p = (t.prompt || "").replace(/\s+/g, " ").trim();
|
|
250
|
+
const r = (t.response || "").replace(/\s+/g, " ").trim();
|
|
251
|
+
return `- Earlier inquiry: User asked "${p.slice(0, 100)}" -> Outcome: ${r.slice(0, 120)}`;
|
|
252
|
+
});
|
|
253
|
+
} else if (typeof session.deriveMessages === "function") {
|
|
254
|
+
// Fallback: Pair real user messages with assistant outcomes
|
|
255
|
+
const messages = session.deriveMessages();
|
|
256
|
+
for (let i = 0; i < messages.length; i++) {
|
|
257
|
+
const m = messages[i];
|
|
258
|
+
if (m.role === "user" && m.source?.kind === "user") {
|
|
259
|
+
const p = getMessageText(m).replace(/\s+/g, " ").trim();
|
|
260
|
+
let r = "";
|
|
261
|
+
for (let j = i + 1; j < messages.length; j++) {
|
|
262
|
+
if (messages[j].role === "assistant") {
|
|
263
|
+
r = getMessageText(messages[j]).replace(/\s+/g, " ").trim();
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
if (messages[j].role === "user" && messages[j].source?.kind === "user") {
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
turnEntries.push(`- Earlier inquiry: User asked "${p.slice(0, 100)}" -> Outcome: ${r.slice(0, 120)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.warn("[dsh-btw] Failed to build session turn ledger:", err?.message);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (turnEntries.length === 0) {
|
|
279
|
+
return { title, workspace, sessionHistory: "" };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Head + Tail Preservation for long sessions (> 30 entries)
|
|
283
|
+
let finalEntries;
|
|
284
|
+
if (turnEntries.length <= 30) {
|
|
285
|
+
finalEntries = turnEntries;
|
|
286
|
+
} else {
|
|
287
|
+
const head = turnEntries.slice(0, 3);
|
|
288
|
+
const tail = turnEntries.slice(-25);
|
|
289
|
+
const skipped = turnEntries.length - (head.length + tail.length);
|
|
290
|
+
finalEntries = [
|
|
291
|
+
...head,
|
|
292
|
+
` [... ${skipped} middle entries omitted for brevity ...]`,
|
|
293
|
+
...tail
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const sessionHistory = `Session Conversation History (Chronological from Beginning to End):\n${finalEntries.join("\n")}`;
|
|
298
|
+
|
|
299
|
+
return { title, workspace, sessionHistory };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function streamLlmCall(ctx, targetConfig, systemPromptText, question) {
|
|
303
|
+
const prepared = await ctx.llm.prepareCall(targetConfig);
|
|
304
|
+
const options = {
|
|
305
|
+
...prepared.config,
|
|
306
|
+
system: systemPromptText,
|
|
307
|
+
messages: [
|
|
308
|
+
{
|
|
309
|
+
id: "msg-" + Date.now().toString(36) + Math.random().toString(36).slice(-4),
|
|
310
|
+
role: "user",
|
|
311
|
+
content: [{ type: "text", text: question }],
|
|
312
|
+
source: { kind: "user" }
|
|
313
|
+
}
|
|
314
|
+
],
|
|
315
|
+
signal: AbortSignal.timeout(60000)
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const stream = prepared.stream(options);
|
|
319
|
+
let answer = "";
|
|
320
|
+
let reasoning = "";
|
|
321
|
+
let finishError = null;
|
|
322
|
+
|
|
323
|
+
for await (const chunk of stream) {
|
|
324
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") {
|
|
325
|
+
answer += chunk.text;
|
|
326
|
+
} else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") {
|
|
327
|
+
reasoning += chunk.text;
|
|
328
|
+
} else if (chunk.type === "block-end" && chunk.block?.type === "text" && !answer) {
|
|
329
|
+
answer = chunk.block.text || "";
|
|
330
|
+
} else if (chunk.type === "finish") {
|
|
331
|
+
if (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted") {
|
|
332
|
+
finishError = chunk.reason.failure?.message || `Generation finished with ${chunk.reason.kind}`;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (finishError) {
|
|
338
|
+
throw new Error(finishError);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const trimmed = (answer || reasoning).trim();
|
|
342
|
+
if (trimmed) return cleanAbortedDraftPrefix(trimmed);
|
|
343
|
+
throw new Error("Model returned empty response.");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function cleanAbortedDraftPrefix(text) {
|
|
347
|
+
if (!text || typeof text !== "string") return text;
|
|
348
|
+
let cleaned = text.trim();
|
|
349
|
+
if (cleaned.includes("```")) return cleaned;
|
|
350
|
+
if (/\b(const|let|var|function|import|export|class|interface|type|return)\b/.test(cleaned)) return cleaned;
|
|
351
|
+
|
|
352
|
+
// Detect aborted prompt-mirroring or draft fragments fused to a capitalized restart (e.g. "...kundiNapag-usapan natin...")
|
|
353
|
+
const fusedMatch = cleaned.match(/^(.*?\b[a-z]{2,})([A-Z][a-z]{2,}\b.*)$/s);
|
|
354
|
+
if (fusedMatch && fusedMatch[2]) {
|
|
355
|
+
const prefix = fusedMatch[1].trim();
|
|
356
|
+
if (prefix.includes(" ") && !/[;{}=<>()\/\\]/.test(prefix)) {
|
|
357
|
+
cleaned = fusedMatch[2].trim();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return cleaned;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function generateFastAnswer(ctx, sessionOrAgent, sessionId, question, wsName, configModel, onRetryProgress) {
|
|
364
|
+
if (!ctx.llm) {
|
|
365
|
+
throw new Error("LLM service is not mounted.");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const session = resolveSessionObject(ctx, sessionOrAgent, sessionId);
|
|
369
|
+
const targetConfig = resolveModelConfig(ctx, session, configModel);
|
|
370
|
+
const sessionCtx = extractSessionContext(ctx, session);
|
|
371
|
+
|
|
372
|
+
const now = new Date();
|
|
373
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
374
|
+
const timeStr = now.toLocaleString("en-US", {
|
|
375
|
+
timeZone: tz,
|
|
376
|
+
weekday: "long",
|
|
377
|
+
year: "numeric",
|
|
378
|
+
month: "long",
|
|
379
|
+
day: "numeric",
|
|
380
|
+
hour: "numeric",
|
|
381
|
+
minute: "2-digit",
|
|
382
|
+
second: "2-digit"
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
let systemPrompt = `You are DSH BTW, the assistant for DeepSeek Harness answering side questions (/btw) in this workspace session.
|
|
386
|
+
Live Environment:
|
|
387
|
+
- Current Time: ${timeStr} (${tz})
|
|
388
|
+
- Active Workspace: ${sessionCtx.workspace || wsName || "default"}`;
|
|
389
|
+
|
|
390
|
+
if (sessionCtx.title) {
|
|
391
|
+
systemPrompt += `\n- Session Title: ${sessionCtx.title}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (sessionCtx.sessionHistory) {
|
|
395
|
+
systemPrompt += `\n\nActive Session Context:\n${sessionCtx.sessionHistory}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
systemPrompt += `\n\nInstructions:
|
|
399
|
+
- Answer the user's side question directly, accurately, and concisely.
|
|
400
|
+
- For session inquiries (summaries, past topics, recent actions), synthesize the session history accurately.
|
|
401
|
+
- For general questions (facts, pricing, trivia, coding, translations), provide direct, factual answers.
|
|
402
|
+
- When presenting multiple topics, steps, or items, use a clean numbered list (1., 2.) or bulleted list. Avoid markdown tables (|---|).
|
|
403
|
+
- Use natural, fluent language matching the user's phrasing and language.
|
|
404
|
+
- Format responses cleanly using standard Markdown where helpful.`;
|
|
405
|
+
|
|
406
|
+
const isTransient = (err) => {
|
|
407
|
+
const msg = String(err?.message || "").toLowerCase();
|
|
408
|
+
return /500|502|503|504|internal[\s_-]*error|server[\s_-]*error|try again|rate[\s_-]*limit|overloaded|service unavailable|bad gateway|timeout|aborted|econnreset|etimedout|fetch failed/i.test(msg);
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const retryDelays = [1000, 1500, 2000, 2500, 3000];
|
|
412
|
+
const maxRetries = retryDelays.length;
|
|
413
|
+
|
|
414
|
+
let lastErr = null;
|
|
415
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
416
|
+
try {
|
|
417
|
+
return await streamLlmCall(ctx, targetConfig, systemPrompt, question);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
lastErr = err;
|
|
420
|
+
if (isTransient(err) && attempt < maxRetries) {
|
|
421
|
+
const backoffMs = retryDelays[attempt];
|
|
422
|
+
const currentRetry = attempt + 1;
|
|
423
|
+
console.warn(`[dsh-btw] Transient error on '${targetConfig.provider}' (${err?.message || "error"}). Retrying in ${backoffMs}ms (attempt ${currentRetry}/${maxRetries})...`);
|
|
424
|
+
if (typeof onRetryProgress === "function") {
|
|
425
|
+
try {
|
|
426
|
+
onRetryProgress(currentRetry, maxRetries);
|
|
427
|
+
} catch {}
|
|
428
|
+
}
|
|
429
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
throw new Error(`Provider '${targetConfig.provider}' returned error (${lastErr?.message || "unknown"}). Switch to another model in the header.`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function handleSideQuery(ctx, sessionOrAgent, sessionId, question, wsName, configModel, maxItems) {
|
|
439
|
+
const history = loadHistory();
|
|
440
|
+
if (!history[sessionId]) history[sessionId] = [];
|
|
441
|
+
|
|
442
|
+
const itemId = "btw-" + Date.now().toString(36) + Math.random().toString(36).slice(-4);
|
|
443
|
+
const pendingItem = {
|
|
444
|
+
id: itemId,
|
|
445
|
+
question,
|
|
446
|
+
answer: "",
|
|
447
|
+
loading: true,
|
|
448
|
+
retryAttempt: 0,
|
|
449
|
+
maxRetries: 5,
|
|
450
|
+
source: "DSH BTW",
|
|
451
|
+
timestamp: Date.now()
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
history[sessionId].unshift(pendingItem);
|
|
455
|
+
if (history[sessionId].length > maxItems) {
|
|
456
|
+
history[sessionId] = history[sessionId].slice(0, maxItems);
|
|
457
|
+
}
|
|
458
|
+
saveHistory(history);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const answer = await generateFastAnswer(
|
|
462
|
+
ctx,
|
|
463
|
+
sessionOrAgent,
|
|
464
|
+
sessionId,
|
|
465
|
+
question,
|
|
466
|
+
wsName,
|
|
467
|
+
configModel,
|
|
468
|
+
(attempt, max) => {
|
|
469
|
+
pendingItem.retryAttempt = attempt;
|
|
470
|
+
pendingItem.maxRetries = max;
|
|
471
|
+
saveHistory(history);
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
pendingItem.answer = answer;
|
|
475
|
+
pendingItem.loading = false;
|
|
476
|
+
pendingItem.retryAttempt = 0;
|
|
477
|
+
saveHistory(history);
|
|
478
|
+
return { answer, item: pendingItem };
|
|
479
|
+
} catch (err) {
|
|
480
|
+
const errorMsg = `Unable to resolve side question: ${err?.message || "LLM error"}`;
|
|
481
|
+
pendingItem.answer = errorMsg;
|
|
482
|
+
pendingItem.loading = false;
|
|
483
|
+
pendingItem.retryAttempt = 0;
|
|
484
|
+
saveHistory(history);
|
|
485
|
+
return { answer: errorMsg, item: pendingItem };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function apply(ctx, config) {
|
|
490
|
+
const maxItems = config?.maxItemsPerSession || 50;
|
|
491
|
+
|
|
492
|
+
// 1. Register /btw slash command
|
|
493
|
+
ctx.effect(() => {
|
|
494
|
+
return ctx.commands.register({
|
|
495
|
+
name: "btw",
|
|
496
|
+
description: "Ask a quick side question without interrupting the active agent loop (/btw <question>)",
|
|
497
|
+
input: { hint: "<question to ask on the side>" },
|
|
498
|
+
handler: async (invocation) => {
|
|
499
|
+
const question = (invocation.rawInput || "").trim();
|
|
500
|
+
if (!question) return { kind: "error", text: "Usage: /btw <question to ask on the side>" };
|
|
501
|
+
const agent = invocation.agent;
|
|
502
|
+
const sessionId = agent?.session?.header?.id || agent?.id;
|
|
503
|
+
const cwd = agent?.session?.header?.cwd || "";
|
|
504
|
+
const wsName = cwd ? path.basename(cwd) : "default";
|
|
505
|
+
|
|
506
|
+
// Launch query asynchronously so composer input clears instantly on Enter!
|
|
507
|
+
void handleSideQuery(ctx, agent, sessionId, question, wsName, config?.model, maxItems);
|
|
508
|
+
return {
|
|
509
|
+
kind: "success",
|
|
510
|
+
text: ""
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// 2. HTTP Endpoints via WebServer when mounted
|
|
517
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
518
|
+
webCtx.effect(() => {
|
|
519
|
+
const unregisterFeed = webCtx.webServer.register({
|
|
520
|
+
kind: "exact",
|
|
521
|
+
path: "/api/dsh-btw/feed",
|
|
522
|
+
handler: async (req, res) => {
|
|
523
|
+
const url = new URL(req.url || "", "http://127.0.0.1");
|
|
524
|
+
const sid = url.searchParams.get("sessionId");
|
|
525
|
+
if (!sid) {
|
|
526
|
+
sendJson(res, 400, { ok: false, error: "Missing sessionId" });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const history = loadHistory();
|
|
530
|
+
sendJson(res, 200, { ok: true, items: history[sid] || [] });
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const unregisterAsk = webCtx.webServer.register({
|
|
535
|
+
kind: "exact",
|
|
536
|
+
path: "/api/dsh-btw/ask",
|
|
537
|
+
handler: async (req, res) => {
|
|
538
|
+
if (req.method !== "POST") {
|
|
539
|
+
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const body = await readJsonBody(req);
|
|
543
|
+
const { sessionId, question, cwd } = body;
|
|
544
|
+
if (!sessionId || !question) {
|
|
545
|
+
sendJson(res, 400, { ok: false, error: "Missing sessionId or question" });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const wsName = cwd ? path.basename(cwd) : "default";
|
|
549
|
+
void handleSideQuery(ctx, null, sessionId, question.trim(), wsName, config?.model, maxItems);
|
|
550
|
+
sendJson(res, 200, { ok: true });
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const unregisterDelete = webCtx.webServer.register({
|
|
555
|
+
kind: "exact",
|
|
556
|
+
path: "/api/dsh-btw/delete",
|
|
557
|
+
handler: async (req, res) => {
|
|
558
|
+
if (req.method !== "POST") {
|
|
559
|
+
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const body = await readJsonBody(req);
|
|
563
|
+
const { sessionId, itemId } = body;
|
|
564
|
+
if (!sessionId || !itemId) {
|
|
565
|
+
sendJson(res, 400, { ok: false, error: "Missing sessionId or itemId" });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const history = loadHistory();
|
|
569
|
+
if (history[sessionId]) {
|
|
570
|
+
history[sessionId] = history[sessionId].filter((i) => i.id !== itemId);
|
|
571
|
+
saveHistory(history);
|
|
572
|
+
}
|
|
573
|
+
sendJson(res, 200, { ok: true });
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const unregisterModels = webCtx.webServer.register({
|
|
578
|
+
kind: "exact",
|
|
579
|
+
path: "/api/dsh-btw/models",
|
|
580
|
+
handler: async (req, res) => {
|
|
581
|
+
if (req.method !== "GET") {
|
|
582
|
+
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const settings = loadSettings();
|
|
586
|
+
const models = getAvailableModels();
|
|
587
|
+
sendJson(res, 200, { ok: true, models, currentModel: settings.model || "auto" });
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const unregisterSetModel = webCtx.webServer.register({
|
|
592
|
+
kind: "exact",
|
|
593
|
+
path: "/api/dsh-btw/model",
|
|
594
|
+
handler: async (req, res) => {
|
|
595
|
+
if (req.method !== "POST") {
|
|
596
|
+
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const body = await readJsonBody(req);
|
|
600
|
+
const settings = loadSettings();
|
|
601
|
+
settings.model = body.model || "auto";
|
|
602
|
+
saveSettings(settings);
|
|
603
|
+
sendJson(res, 200, { ok: true, currentModel: settings.model });
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const unregisterRetry = webCtx.webServer.register({
|
|
608
|
+
kind: "exact",
|
|
609
|
+
path: "/api/dsh-btw/retry",
|
|
610
|
+
handler: async (req, res) => {
|
|
611
|
+
if (req.method !== "POST") {
|
|
612
|
+
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const body = await readJsonBody(req);
|
|
616
|
+
const { sessionId, itemId } = body || {};
|
|
617
|
+
if (!sessionId || !itemId) {
|
|
618
|
+
sendJson(res, 400, { ok: false, error: "Missing sessionId or itemId" });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const history = loadHistory();
|
|
622
|
+
const sessionItems = history[sessionId] || [];
|
|
623
|
+
const target = sessionItems.find((i) => i.id === itemId);
|
|
624
|
+
if (!target) {
|
|
625
|
+
sendJson(res, 404, { ok: false, error: "Item not found" });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
target.loading = true;
|
|
630
|
+
target.answer = "";
|
|
631
|
+
target.retryAttempt = 0;
|
|
632
|
+
target.maxRetries = 5;
|
|
633
|
+
saveHistory(history);
|
|
634
|
+
sendJson(res, 200, { ok: true });
|
|
635
|
+
|
|
636
|
+
void (async () => {
|
|
637
|
+
try {
|
|
638
|
+
const answer = await generateFastAnswer(
|
|
639
|
+
ctx,
|
|
640
|
+
null,
|
|
641
|
+
sessionId,
|
|
642
|
+
target.question,
|
|
643
|
+
"default",
|
|
644
|
+
config?.model,
|
|
645
|
+
(attempt, max) => {
|
|
646
|
+
target.retryAttempt = attempt;
|
|
647
|
+
target.maxRetries = max;
|
|
648
|
+
saveHistory(history);
|
|
649
|
+
}
|
|
650
|
+
);
|
|
651
|
+
target.answer = answer;
|
|
652
|
+
target.loading = false;
|
|
653
|
+
target.retryAttempt = 0;
|
|
654
|
+
saveHistory(history);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
target.answer = `Unable to resolve side question: ${err?.message || "LLM error"}`;
|
|
657
|
+
target.loading = false;
|
|
658
|
+
target.retryAttempt = 0;
|
|
659
|
+
saveHistory(history);
|
|
660
|
+
}
|
|
661
|
+
})();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
return () => {
|
|
666
|
+
unregisterFeed();
|
|
667
|
+
unregisterAsk();
|
|
668
|
+
unregisterDelete();
|
|
669
|
+
unregisterModels();
|
|
670
|
+
unregisterSetModel();
|
|
671
|
+
unregisterRetry();
|
|
672
|
+
};
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
}
|