@kevin5251984/guild 0.2.18 → 0.2.19
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/package.json +1 -1
- package/src/catalog/subagents.ts +2 -1
- package/src/chat-parts.ts +6 -1
- package/src/compact.ts +27 -0
- package/src/db.ts +199 -64
- package/src/generate.ts +58 -27
- package/src/handlers.ts +341 -54
- package/src/harness.ts +21 -8
- package/src/image-gen.ts +2 -1
- package/src/llm.ts +400 -58
- package/src/memory.ts +69 -0
- package/src/mention.ts +119 -12
- package/src/oauth.ts +202 -25
- package/src/opencode-free.ts +247 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +174 -72
- package/src/public/chat.html +212 -43
- package/src/public/i18n.js +41 -6
- package/src/public/index.html +1 -46
- package/src/public/mobile.css +491 -0
- package/src/public/mobile.html +784 -0
- package/src/public/settings.html +188 -27
- package/src/public/skills-add.html +8 -0
- package/src/public/studio.html +171 -117
- package/src/public/style.css +220 -26
- package/src/public/subagents-add.html +8 -0
- package/src/router.ts +76 -5
- package/src/store.ts +161 -5
- package/src/subagent.ts +233 -6
- package/src/tools.ts +124 -18
- package/src/trajectory.ts +72 -9
- package/src/usage.ts +10 -0
- package/vendor/protocol/src/index.ts +14 -1
package/src/llm.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type {
|
|
4
4
|
AuxRole,
|
|
@@ -15,12 +15,16 @@ import {
|
|
|
15
15
|
formatOAuthError,
|
|
16
16
|
listSubscriptions,
|
|
17
17
|
OAUTH_PICKER_IDS,
|
|
18
|
+
STREAM_IDLE_TIMEOUT_MS,
|
|
18
19
|
storedAccessToken,
|
|
19
20
|
subscriptionByPicker,
|
|
21
|
+
withTransientRetries,
|
|
20
22
|
} from "./oauth.ts";
|
|
21
23
|
import {
|
|
24
|
+
emitProgress,
|
|
22
25
|
openaiTools,
|
|
23
26
|
roundSignal,
|
|
27
|
+
throwIfAborted,
|
|
24
28
|
TOOL_LOOP_WRAP,
|
|
25
29
|
type SkillRef,
|
|
26
30
|
type ToolContext,
|
|
@@ -35,24 +39,39 @@ import {
|
|
|
35
39
|
fromOpenAiUsage,
|
|
36
40
|
withDuration,
|
|
37
41
|
} from "./usage.ts";
|
|
42
|
+
import {
|
|
43
|
+
OPENCODE_FREE_DEFAULT_MODEL,
|
|
44
|
+
OPENCODE_FREE_PROVIDER_ID,
|
|
45
|
+
fetchOpenCodeFreeModels,
|
|
46
|
+
isKeylessProvider,
|
|
47
|
+
llmRequestHeaders,
|
|
48
|
+
openCodeFreeModels,
|
|
49
|
+
openCodeFreeProvider,
|
|
50
|
+
probeOpenCodeFreeModels,
|
|
51
|
+
selectOpenCodeFreeIds,
|
|
52
|
+
usesZenResponses,
|
|
53
|
+
type OpenCodeFreeProbe,
|
|
54
|
+
} from "./opencode-free.ts";
|
|
38
55
|
|
|
39
56
|
export const AUX_ROLES: { id: AuxRole; name: string; hint: string }[] = [
|
|
40
57
|
{ id: "vision", name: "Vision", hint: "Image analysis" },
|
|
41
58
|
{ id: "web", name: "Web extract", hint: "Page summarization" },
|
|
42
|
-
{ id: "
|
|
43
|
-
{ id: "skills", name: "Skills hub", hint: "Skill search" },
|
|
44
|
-
{ id: "approval", name: "Approval", hint: "Command risk scoring" },
|
|
45
|
-
{ id: "title", name: "Title", hint: "Session titles" },
|
|
46
|
-
{ id: "generate", name: "Generate", hint: "Soul / Agent / Skill markdown" },
|
|
59
|
+
{ id: "spawn", name: "SubAgent", hint: "explorer / worker / reviewer" },
|
|
47
60
|
];
|
|
48
61
|
|
|
62
|
+
const CONFIGURABLE_AUX = new Set(AUX_ROLES.map((role) => role.id));
|
|
63
|
+
|
|
49
64
|
export const DEFAULT_MODELS: ModelsFile = {
|
|
50
|
-
default:
|
|
65
|
+
default: {
|
|
66
|
+
provider: OPENCODE_FREE_PROVIDER_ID,
|
|
67
|
+
model: OPENCODE_FREE_DEFAULT_MODEL,
|
|
68
|
+
},
|
|
51
69
|
reasoning: "medium",
|
|
52
70
|
fast: false,
|
|
53
71
|
aux: {},
|
|
54
72
|
recent: [],
|
|
55
73
|
providers: {
|
|
74
|
+
[OPENCODE_FREE_PROVIDER_ID]: openCodeFreeProvider(),
|
|
56
75
|
openai: {
|
|
57
76
|
name: "OpenAI",
|
|
58
77
|
baseUrl: "https://api.openai.com/v1",
|
|
@@ -119,15 +138,48 @@ export function readModelsFile(dataDir: string): ModelsFile {
|
|
|
119
138
|
if (!parsed || typeof parsed !== "object" || !parsed.providers) {
|
|
120
139
|
return structuredClone(DEFAULT_MODELS);
|
|
121
140
|
}
|
|
122
|
-
return parsed;
|
|
141
|
+
if (Object.keys(parsed.providers).length === 0) return parsed;
|
|
142
|
+
return withOpenCodeFree(parsed);
|
|
123
143
|
} catch {
|
|
124
144
|
return structuredClone(DEFAULT_MODELS);
|
|
125
145
|
}
|
|
126
146
|
}
|
|
127
147
|
|
|
148
|
+
function pinOpenCodeFree(file: ModelsFile): ModelsFile {
|
|
149
|
+
const current = file.providers[OPENCODE_FREE_PROVIDER_ID];
|
|
150
|
+
if (!current) return file;
|
|
151
|
+
const keys = Object.keys(file.providers);
|
|
152
|
+
if (keys[0] === OPENCODE_FREE_PROVIDER_ID) return file;
|
|
153
|
+
const rest: ModelsFile["providers"] = {};
|
|
154
|
+
for (const [id, provider] of Object.entries(file.providers)) {
|
|
155
|
+
if (id === OPENCODE_FREE_PROVIDER_ID) continue;
|
|
156
|
+
rest[id] = provider;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
...file,
|
|
160
|
+
providers: {
|
|
161
|
+
[OPENCODE_FREE_PROVIDER_ID]: current,
|
|
162
|
+
...rest,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function withOpenCodeFree(file: ModelsFile): ModelsFile {
|
|
168
|
+
if (file.providers[OPENCODE_FREE_PROVIDER_ID]) return pinOpenCodeFree(file);
|
|
169
|
+
return pinOpenCodeFree({
|
|
170
|
+
...file,
|
|
171
|
+
providers: {
|
|
172
|
+
[OPENCODE_FREE_PROVIDER_ID]: openCodeFreeProvider(),
|
|
173
|
+
...file.providers,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
128
178
|
export function writeModelsFile(dataDir: string, file: ModelsFile): ModelsFile {
|
|
129
|
-
const cleaned = sanitizeModels(file);
|
|
130
|
-
|
|
179
|
+
const cleaned = pinOpenCodeFree(sanitizeModels(file));
|
|
180
|
+
const path = modelsPath(dataDir);
|
|
181
|
+
writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`, { mode: 0o600 });
|
|
182
|
+
chmodSync(path, 0o600);
|
|
131
183
|
return cleaned;
|
|
132
184
|
}
|
|
133
185
|
|
|
@@ -162,7 +214,7 @@ export function mergeModelsFile(
|
|
|
162
214
|
providersIn[id] = { ...provider, apiKey };
|
|
163
215
|
}
|
|
164
216
|
next.providers = providersIn;
|
|
165
|
-
return writeModelsFile(dataDir, next);
|
|
217
|
+
return writeModelsFile(dataDir, withOpenCodeFree(next));
|
|
166
218
|
}
|
|
167
219
|
|
|
168
220
|
function pushRecent(list: ModelRef[] | undefined, ref: ModelRef): ModelRef[] {
|
|
@@ -187,8 +239,51 @@ export function maskApiKey(value: string): string {
|
|
|
187
239
|
return key.slice(0, 5) + "…" + key.slice(-5);
|
|
188
240
|
}
|
|
189
241
|
|
|
190
|
-
export function
|
|
242
|
+
export async function refreshOpenCodeFreeCatalog(
|
|
243
|
+
dataDir: string,
|
|
244
|
+
force = false,
|
|
245
|
+
): Promise<{
|
|
246
|
+
models: { id: string; name?: string }[];
|
|
247
|
+
updated: boolean;
|
|
248
|
+
probe?: OpenCodeFreeProbe[];
|
|
249
|
+
}> {
|
|
191
250
|
const file = readModelsFile(dataDir);
|
|
251
|
+
const prev = file.providers[OPENCODE_FREE_PROVIDER_ID] ?? openCodeFreeProvider();
|
|
252
|
+
if (!force) {
|
|
253
|
+
return { models: prev.models, updated: false };
|
|
254
|
+
}
|
|
255
|
+
const live = await fetchOpenCodeFreeModels(4_000, true);
|
|
256
|
+
if (!live?.length) {
|
|
257
|
+
throw new StoreError(502, "couldn't sync OpenCode Free");
|
|
258
|
+
}
|
|
259
|
+
const probe = await probeOpenCodeFreeModels(live);
|
|
260
|
+
const keepId =
|
|
261
|
+
file.default?.provider === OPENCODE_FREE_PROVIDER_ID
|
|
262
|
+
? file.default.model
|
|
263
|
+
: undefined;
|
|
264
|
+
const usable = selectOpenCodeFreeIds(live, probe, keepId);
|
|
265
|
+
if (!usable.length) {
|
|
266
|
+
return { models: prev.models, updated: false, probe };
|
|
267
|
+
}
|
|
268
|
+
const ids = usable;
|
|
269
|
+
const models = openCodeFreeModels(ids);
|
|
270
|
+
const same =
|
|
271
|
+
models.length === prev.models.length &&
|
|
272
|
+
models.every((row, i) => row.id === prev.models[i]?.id);
|
|
273
|
+
if (!same) {
|
|
274
|
+
writeModelsFile(dataDir, {
|
|
275
|
+
...file,
|
|
276
|
+
providers: {
|
|
277
|
+
...file.providers,
|
|
278
|
+
[OPENCODE_FREE_PROVIDER_ID]: { ...prev, models },
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return { models, updated: !same, probe };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.env) {
|
|
286
|
+
const file = withOpenCodeFree(readModelsFile(dataDir));
|
|
192
287
|
const providers: PublicProvider[] = Object.entries(file.providers).map(
|
|
193
288
|
([id, provider]) => {
|
|
194
289
|
const key = provider.apiKey ?? "";
|
|
@@ -208,8 +303,10 @@ export function publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.e
|
|
|
208
303
|
...providers.map((p) => ({
|
|
209
304
|
id: p.id,
|
|
210
305
|
name: p.name || p.id,
|
|
211
|
-
kind: "key" as
|
|
212
|
-
ready:
|
|
306
|
+
kind: (isKeylessProvider(p.id) ? "keyless" : "key") as "key" | "keyless",
|
|
307
|
+
ready:
|
|
308
|
+
isKeylessProvider(p.id) ||
|
|
309
|
+
Boolean(resolveApiKey(p.apiKey, env) || p.stored === "literal"),
|
|
213
310
|
models: p.models,
|
|
214
311
|
})),
|
|
215
312
|
...subscriptions.map((s) => ({
|
|
@@ -292,18 +389,21 @@ export function resolveLlm(
|
|
|
292
389
|
const file = readModelsFile(dataDir);
|
|
293
390
|
const ref: ModelRef | null | undefined =
|
|
294
391
|
prefer ??
|
|
295
|
-
(role &&
|
|
392
|
+
(role && CONFIGURABLE_AUX.has(role as AuxRole)
|
|
393
|
+
? file.aux?.[role as AuxRole]
|
|
394
|
+
: file.default);
|
|
296
395
|
const tryProvider = (id: string, modelId?: string): LlmTarget | null => {
|
|
297
396
|
const oauth = oauthTarget(dataDir, id, modelId);
|
|
298
397
|
if (oauth) return oauth;
|
|
299
|
-
const
|
|
398
|
+
const providerId = isKeylessProvider(id) ? OPENCODE_FREE_PROVIDER_ID : id;
|
|
399
|
+
const provider = file.providers[providerId];
|
|
300
400
|
if (!provider) return null;
|
|
301
401
|
const apiKey = resolveApiKey(provider.apiKey, env);
|
|
302
|
-
if (!apiKey) return null;
|
|
402
|
+
if (!apiKey && !isKeylessProvider(providerId)) return null;
|
|
303
403
|
const model = modelId || provider.models[0]?.id || "";
|
|
304
404
|
if (!model) return null;
|
|
305
405
|
return {
|
|
306
|
-
providerId
|
|
406
|
+
providerId,
|
|
307
407
|
model,
|
|
308
408
|
baseUrl: provider.baseUrl.replace(/\/+$/, ""),
|
|
309
409
|
apiKey,
|
|
@@ -455,6 +555,11 @@ async function dispatchComplete(
|
|
|
455
555
|
tools: boolean,
|
|
456
556
|
ctx: ToolContext,
|
|
457
557
|
): Promise<DispatchResult | null> {
|
|
558
|
+
if (isKeylessProvider(target.providerId) && usesZenResponses(target.model)) {
|
|
559
|
+
return tools
|
|
560
|
+
? completeZenResponsesTools(target, system, messages, ctx)
|
|
561
|
+
: wrapText(await completeZenResponses(target, system, messages));
|
|
562
|
+
}
|
|
458
563
|
if (target.api === "openai-responses") {
|
|
459
564
|
const text = await completeCodex(target, system, messages);
|
|
460
565
|
return text ? { text, traces: [], thinking: "" } : null;
|
|
@@ -518,29 +623,48 @@ async function completeOpenAiTools(
|
|
|
518
623
|
if (fitted.length < msgs.length) {
|
|
519
624
|
msgs.splice(0, msgs.length, ...fitted);
|
|
520
625
|
}
|
|
521
|
-
const response = await
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
626
|
+
const response = await withTransientRetries(
|
|
627
|
+
async () => {
|
|
628
|
+
throwIfAborted(ctx);
|
|
629
|
+
const res = await fetch(`${target.baseUrl}/chat/completions`, {
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers: llmRequestHeaders(target),
|
|
632
|
+
body: JSON.stringify({
|
|
633
|
+
model: target.model,
|
|
634
|
+
temperature,
|
|
635
|
+
messages: msgs,
|
|
636
|
+
tools: catalog,
|
|
637
|
+
tool_choice: "auto",
|
|
638
|
+
}),
|
|
639
|
+
signal: roundSignal(ctx),
|
|
640
|
+
});
|
|
641
|
+
if (res.ok) return res;
|
|
642
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
643
|
+
if (res.status === 429 || res.status >= 500) throw err;
|
|
644
|
+
return res;
|
|
527
645
|
},
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
646
|
+
{
|
|
647
|
+
signal: ctx.signal,
|
|
648
|
+
onRetry: () => {
|
|
649
|
+
emitProgress(ctx, traces, "連線中斷,重試中…");
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
);
|
|
653
|
+
if (!response.ok) {
|
|
654
|
+
return {
|
|
655
|
+
calls: [],
|
|
656
|
+
text: `模型請求失敗:HTTP ${response.status}`,
|
|
657
|
+
thinking: "",
|
|
658
|
+
};
|
|
659
|
+
}
|
|
538
660
|
const data = (await response.json()) as {
|
|
539
661
|
choices?: { message?: ChatMsg; finish_reason?: string }[];
|
|
540
662
|
usage?: {
|
|
541
663
|
prompt_tokens?: number;
|
|
542
664
|
completion_tokens?: number;
|
|
543
665
|
total_tokens?: number;
|
|
666
|
+
prompt_tokens_details?: { cached_tokens?: number };
|
|
667
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
544
668
|
};
|
|
545
669
|
};
|
|
546
670
|
const message = data.choices?.[0]?.message;
|
|
@@ -629,26 +753,52 @@ async function completeAnthropicTools(
|
|
|
629
753
|
if (fitted.length < msgs.length) {
|
|
630
754
|
msgs.splice(0, msgs.length, ...fitted);
|
|
631
755
|
}
|
|
632
|
-
const response = await
|
|
633
|
-
|
|
756
|
+
const response = await withTransientRetries(
|
|
757
|
+
async () => {
|
|
758
|
+
throwIfAborted(ctx);
|
|
759
|
+
const res = await fetch(
|
|
760
|
+
`${target.baseUrl.replace(/\/v1$/, "")}/v1/messages`,
|
|
761
|
+
{
|
|
762
|
+
method: "POST",
|
|
763
|
+
headers,
|
|
764
|
+
body: JSON.stringify({
|
|
765
|
+
model: target.model,
|
|
766
|
+
max_tokens: 2048,
|
|
767
|
+
system,
|
|
768
|
+
messages: msgs,
|
|
769
|
+
tools,
|
|
770
|
+
}),
|
|
771
|
+
signal: roundSignal(ctx),
|
|
772
|
+
},
|
|
773
|
+
);
|
|
774
|
+
if (res.ok) return res;
|
|
775
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
776
|
+
if (res.status === 429 || res.status >= 500) throw err;
|
|
777
|
+
return res;
|
|
778
|
+
},
|
|
634
779
|
{
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
max_tokens: 2048,
|
|
640
|
-
system,
|
|
641
|
-
messages: msgs,
|
|
642
|
-
tools,
|
|
643
|
-
}),
|
|
644
|
-
signal: roundSignal(ctx),
|
|
780
|
+
signal: ctx.signal,
|
|
781
|
+
onRetry: () => {
|
|
782
|
+
emitProgress(ctx, traces, "連線中斷,重試中…");
|
|
783
|
+
},
|
|
645
784
|
},
|
|
646
785
|
);
|
|
647
|
-
if (!response.ok)
|
|
786
|
+
if (!response.ok) {
|
|
787
|
+
return {
|
|
788
|
+
calls: [],
|
|
789
|
+
text: `模型請求失敗:HTTP ${response.status}`,
|
|
790
|
+
thinking: "",
|
|
791
|
+
};
|
|
792
|
+
}
|
|
648
793
|
const data = (await response.json()) as {
|
|
649
794
|
stop_reason?: string;
|
|
650
795
|
content?: Part[];
|
|
651
|
-
usage?: {
|
|
796
|
+
usage?: {
|
|
797
|
+
input_tokens?: number;
|
|
798
|
+
output_tokens?: number;
|
|
799
|
+
cache_read_input_tokens?: number;
|
|
800
|
+
cache_creation_input_tokens?: number;
|
|
801
|
+
};
|
|
652
802
|
};
|
|
653
803
|
const parts = data.content ?? [];
|
|
654
804
|
lastParts = parts;
|
|
@@ -718,6 +868,203 @@ function anthropicHeaders(target: LlmTarget): Record<string, string> {
|
|
|
718
868
|
return headers;
|
|
719
869
|
}
|
|
720
870
|
|
|
871
|
+
type ZenInput =
|
|
872
|
+
| { role: "user" | "assistant"; content: string }
|
|
873
|
+
| { type: "function_call"; call_id: string; name: string; arguments: string }
|
|
874
|
+
| { type: "function_call_output"; call_id: string; output: string };
|
|
875
|
+
|
|
876
|
+
function zenResponsesTools(
|
|
877
|
+
catalog: ReturnType<typeof openaiTools>,
|
|
878
|
+
): { type: "function"; name: string; description: string; parameters: unknown }[] {
|
|
879
|
+
return catalog.map((tool) => ({
|
|
880
|
+
type: "function" as const,
|
|
881
|
+
name: tool.function.name,
|
|
882
|
+
description: tool.function.description,
|
|
883
|
+
parameters: tool.function.parameters,
|
|
884
|
+
}));
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function postZenResponses(
|
|
888
|
+
target: LlmTarget,
|
|
889
|
+
body: Record<string, unknown>,
|
|
890
|
+
signal: AbortSignal,
|
|
891
|
+
): Promise<Response> {
|
|
892
|
+
return fetch(`${target.baseUrl.replace(/\/+$/, "")}/responses`, {
|
|
893
|
+
method: "POST",
|
|
894
|
+
headers: llmRequestHeaders(target),
|
|
895
|
+
body: JSON.stringify(body),
|
|
896
|
+
signal,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function completeZenResponses(
|
|
901
|
+
target: LlmTarget,
|
|
902
|
+
system: string,
|
|
903
|
+
messages: { role: "user" | "assistant"; content: string }[],
|
|
904
|
+
): Promise<string | null> {
|
|
905
|
+
const response = await postZenResponses(
|
|
906
|
+
target,
|
|
907
|
+
{
|
|
908
|
+
model: target.model,
|
|
909
|
+
instructions: system,
|
|
910
|
+
input: messages,
|
|
911
|
+
},
|
|
912
|
+
AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
913
|
+
);
|
|
914
|
+
if (!response.ok) return null;
|
|
915
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
916
|
+
return extractResponsesText(data);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function completeZenResponsesTools(
|
|
920
|
+
target: LlmTarget,
|
|
921
|
+
system: string,
|
|
922
|
+
messages: { role: "user" | "assistant"; content: string }[],
|
|
923
|
+
ctx: ToolContext,
|
|
924
|
+
): Promise<DispatchResult | null> {
|
|
925
|
+
const input: ZenInput[] = messages.map((item) => ({
|
|
926
|
+
role: item.role,
|
|
927
|
+
content: item.content,
|
|
928
|
+
}));
|
|
929
|
+
const traces: ToolTrace[] = [];
|
|
930
|
+
const thinkingChunks: string[] = [];
|
|
931
|
+
const catalog = openaiTools(ctx.skills ?? [], ctx);
|
|
932
|
+
const tools = zenResponsesTools(catalog);
|
|
933
|
+
const usage = blankUsage();
|
|
934
|
+
const started = Date.now();
|
|
935
|
+
let lastCalls: Extract<ZenInput, { type: "function_call" }>[] = [];
|
|
936
|
+
const looped = await runAgentLoop({
|
|
937
|
+
toolCtx: ctx,
|
|
938
|
+
traces,
|
|
939
|
+
thinkingChunks,
|
|
940
|
+
nullIfNoTraces: true,
|
|
941
|
+
ask: async ({ wrap, steer }) => {
|
|
942
|
+
if (wrap) input.push({ role: "user", content: TOOL_LOOP_WRAP });
|
|
943
|
+
if (steer) input.push({ role: "user", content: steer });
|
|
944
|
+
const extra =
|
|
945
|
+
estimateSendTokens(system) +
|
|
946
|
+
estimateSendTokens(JSON.stringify(tools)) +
|
|
947
|
+
2048;
|
|
948
|
+
const fitted = trimSendMessages(input, extra);
|
|
949
|
+
if (fitted.length < input.length) {
|
|
950
|
+
input.splice(0, input.length, ...fitted);
|
|
951
|
+
}
|
|
952
|
+
const response = await withTransientRetries(
|
|
953
|
+
async () => {
|
|
954
|
+
throwIfAborted(ctx);
|
|
955
|
+
const res = await postZenResponses(
|
|
956
|
+
target,
|
|
957
|
+
{
|
|
958
|
+
model: target.model,
|
|
959
|
+
instructions: system,
|
|
960
|
+
input,
|
|
961
|
+
tools,
|
|
962
|
+
tool_choice: "auto",
|
|
963
|
+
},
|
|
964
|
+
roundSignal(ctx),
|
|
965
|
+
);
|
|
966
|
+
if (res.ok) return res;
|
|
967
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
968
|
+
if (res.status === 429 || res.status >= 500) throw err;
|
|
969
|
+
return res;
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
signal: ctx.signal,
|
|
973
|
+
onRetry: () => {
|
|
974
|
+
emitProgress(ctx, traces, "連線中斷,重試中…");
|
|
975
|
+
},
|
|
976
|
+
},
|
|
977
|
+
);
|
|
978
|
+
if (!response.ok) {
|
|
979
|
+
return {
|
|
980
|
+
calls: [],
|
|
981
|
+
text: `模型請求失敗:HTTP ${response.status}`,
|
|
982
|
+
thinking: "",
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
const data = (await response.json()) as {
|
|
986
|
+
output?: Record<string, unknown>[];
|
|
987
|
+
usage?: {
|
|
988
|
+
input_tokens?: number;
|
|
989
|
+
output_tokens?: number;
|
|
990
|
+
total_tokens?: number;
|
|
991
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
992
|
+
};
|
|
993
|
+
};
|
|
994
|
+
addUsage(
|
|
995
|
+
usage,
|
|
996
|
+
fromOpenAiUsage({
|
|
997
|
+
prompt_tokens: data.usage?.input_tokens,
|
|
998
|
+
completion_tokens: data.usage?.output_tokens,
|
|
999
|
+
total_tokens: data.usage?.total_tokens,
|
|
1000
|
+
prompt_tokens_details: {
|
|
1001
|
+
cached_tokens: data.usage?.input_tokens_details?.cached_tokens,
|
|
1002
|
+
},
|
|
1003
|
+
}),
|
|
1004
|
+
);
|
|
1005
|
+
lastCalls = [];
|
|
1006
|
+
const calls: { id: string; name: string; args: Record<string, unknown> }[] =
|
|
1007
|
+
[];
|
|
1008
|
+
for (const item of data.output ?? []) {
|
|
1009
|
+
if (item?.type !== "function_call") continue;
|
|
1010
|
+
const callId = String(item.call_id || item.id || "");
|
|
1011
|
+
const name = String(item.name || "");
|
|
1012
|
+
if (!callId || !name) continue;
|
|
1013
|
+
lastCalls.push({
|
|
1014
|
+
type: "function_call",
|
|
1015
|
+
call_id: callId,
|
|
1016
|
+
name,
|
|
1017
|
+
arguments: String(item.arguments || "{}"),
|
|
1018
|
+
});
|
|
1019
|
+
let args: Record<string, unknown> = {};
|
|
1020
|
+
try {
|
|
1021
|
+
args = JSON.parse(String(item.arguments || "{}")) as Record<
|
|
1022
|
+
string,
|
|
1023
|
+
unknown
|
|
1024
|
+
>;
|
|
1025
|
+
} catch {
|
|
1026
|
+
args = {};
|
|
1027
|
+
}
|
|
1028
|
+
calls.push({ id: callId, name, args });
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
calls,
|
|
1032
|
+
text: extractResponsesText(data as Record<string, unknown>) ?? "",
|
|
1033
|
+
thinking: "",
|
|
1034
|
+
};
|
|
1035
|
+
},
|
|
1036
|
+
onRetry: (late) => {
|
|
1037
|
+
input.push({ role: "user", content: late });
|
|
1038
|
+
},
|
|
1039
|
+
onTools: (calls, outcomes) => {
|
|
1040
|
+
for (let i = 0; i < calls.length; i++) {
|
|
1041
|
+
const raw = lastCalls[i];
|
|
1042
|
+
if (raw) input.push(raw);
|
|
1043
|
+
else {
|
|
1044
|
+
input.push({
|
|
1045
|
+
type: "function_call",
|
|
1046
|
+
call_id: calls[i].id,
|
|
1047
|
+
name: calls[i].name,
|
|
1048
|
+
arguments: JSON.stringify(calls[i].args ?? {}),
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
input.push({
|
|
1052
|
+
type: "function_call_output",
|
|
1053
|
+
call_id: calls[i].id,
|
|
1054
|
+
output: outcomes[i]?.text ?? "",
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
1059
|
+
if (!looped) return null;
|
|
1060
|
+
return {
|
|
1061
|
+
text: looped.text,
|
|
1062
|
+
traces: looped.traces,
|
|
1063
|
+
thinking: looped.thinking,
|
|
1064
|
+
usage: withDuration(usage, started),
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
721
1068
|
async function completeOpenAi(
|
|
722
1069
|
target: LlmTarget,
|
|
723
1070
|
system: string,
|
|
@@ -725,20 +1072,15 @@ async function completeOpenAi(
|
|
|
725
1072
|
temperature: number,
|
|
726
1073
|
): Promise<string | null> {
|
|
727
1074
|
const url = `${target.baseUrl}/chat/completions`;
|
|
728
|
-
const headers: Record<string, string> = {
|
|
729
|
-
authorization: `Bearer ${target.apiKey}`,
|
|
730
|
-
"content-type": "application/json",
|
|
731
|
-
...(target.headers ?? {}),
|
|
732
|
-
};
|
|
733
1075
|
const response = await fetch(url, {
|
|
734
1076
|
method: "POST",
|
|
735
|
-
headers,
|
|
1077
|
+
headers: llmRequestHeaders(target),
|
|
736
1078
|
body: JSON.stringify({
|
|
737
1079
|
model: target.model,
|
|
738
1080
|
temperature,
|
|
739
1081
|
messages: [{ role: "system", content: system }, ...messages],
|
|
740
1082
|
}),
|
|
741
|
-
signal: AbortSignal.timeout(
|
|
1083
|
+
signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
742
1084
|
});
|
|
743
1085
|
if (!response.ok) return null;
|
|
744
1086
|
const data = (await response.json()) as {
|
|
@@ -776,7 +1118,7 @@ async function completeAnthropic(
|
|
|
776
1118
|
system,
|
|
777
1119
|
messages,
|
|
778
1120
|
}),
|
|
779
|
-
signal: AbortSignal.timeout(
|
|
1121
|
+
signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
780
1122
|
});
|
|
781
1123
|
if (!response.ok) return null;
|
|
782
1124
|
const data = (await response.json()) as {
|
|
@@ -818,7 +1160,7 @@ async function completeCodex(
|
|
|
818
1160
|
method: "POST",
|
|
819
1161
|
headers,
|
|
820
1162
|
body: JSON.stringify(body),
|
|
821
|
-
signal: AbortSignal.timeout(
|
|
1163
|
+
signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
822
1164
|
});
|
|
823
1165
|
if (response.ok) {
|
|
824
1166
|
const data = (await response.json()) as Record<string, unknown>;
|
|
@@ -829,7 +1171,7 @@ async function completeCodex(
|
|
|
829
1171
|
method: "POST",
|
|
830
1172
|
headers,
|
|
831
1173
|
body: JSON.stringify({ ...body, stream: true }),
|
|
832
|
-
signal: AbortSignal.timeout(
|
|
1174
|
+
signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
|
|
833
1175
|
});
|
|
834
1176
|
if (!streamed.ok || !streamed.body) return null;
|
|
835
1177
|
return readSseText(streamed);
|
package/src/memory.ts
CHANGED
|
@@ -127,6 +127,75 @@ export async function harvestBotMemory(input: {
|
|
|
127
127
|
return { updated: true, body: input.store.writeBotMemory(input.botId, next) };
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
export function localMergeQuestMemory(
|
|
131
|
+
parent: string,
|
|
132
|
+
child: string,
|
|
133
|
+
questName: string,
|
|
134
|
+
): string | null {
|
|
135
|
+
const from = redactSecrets(String(child || "").replace(/\r\n/g, "\n")).trim();
|
|
136
|
+
if (!from) return null;
|
|
137
|
+
const into = String(parent || "").replace(/\r\n/g, "\n").trim();
|
|
138
|
+
const heading = String(questName || "side quest").replace(/\s+/g, " ").trim() || "side quest";
|
|
139
|
+
if (!into) return clipMemory(from);
|
|
140
|
+
if (into.includes(from)) return null;
|
|
141
|
+
return clipMemory(`${into}\n\n## ${heading}\n\n${from}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mergeQuestPrompt(parent: string, child: string, questName: string): string {
|
|
145
|
+
return `You merge a closed side quest's MEMORY.md into the parent channel MEMORY.md.
|
|
146
|
+
Standing notes only: names, preferences, decisions, recurring work, conventions, ownership, tech.
|
|
147
|
+
Keep useful bullets from both. Drop stale, duplicated, or contradicted ones. Max 80 lines.
|
|
148
|
+
Do not copy the whole transcript. Do not mention this merge.
|
|
149
|
+
|
|
150
|
+
Parent MEMORY.md:
|
|
151
|
+
<<<
|
|
152
|
+
${parent.trim() || "(empty)"}
|
|
153
|
+
>>>
|
|
154
|
+
|
|
155
|
+
Closed quest "${questName}" MEMORY.md:
|
|
156
|
+
<<<
|
|
157
|
+
${child.trim()}
|
|
158
|
+
>>>
|
|
159
|
+
|
|
160
|
+
Reply with the complete updated parent MEMORY.md, or exactly NO_CHANGE.`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function mergeQuestMemory(input: {
|
|
164
|
+
store: GuildStore;
|
|
165
|
+
parentId: string;
|
|
166
|
+
childId: string;
|
|
167
|
+
questName: string;
|
|
168
|
+
env?: NodeJS.ProcessEnv;
|
|
169
|
+
prefer?: ModelRef | null;
|
|
170
|
+
}): Promise<{ updated: boolean; body: string }> {
|
|
171
|
+
const parent = input.store.readChannelMemory(input.parentId);
|
|
172
|
+
const child = input.store.readChannelMemory(input.childId);
|
|
173
|
+
if (!child.trim()) return { updated: false, body: parent };
|
|
174
|
+
const result = await llmComplete({
|
|
175
|
+
dataDir: input.store.dataDir,
|
|
176
|
+
env: input.env,
|
|
177
|
+
role: "compression",
|
|
178
|
+
prefer: input.prefer,
|
|
179
|
+
tools: false,
|
|
180
|
+
temperature: 0.1,
|
|
181
|
+
system:
|
|
182
|
+
"You rewrite MEMORY.md. Output markdown or NO_CHANGE. No preamble.",
|
|
183
|
+
messages: [
|
|
184
|
+
{
|
|
185
|
+
role: "user",
|
|
186
|
+
content: mergeQuestPrompt(parent, child, input.questName),
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
const fromModel = applyMemoryUpdate(parent, result?.text ?? null);
|
|
191
|
+
const next = fromModel ?? localMergeQuestMemory(parent, child, input.questName);
|
|
192
|
+
if (next == null) return { updated: false, body: parent };
|
|
193
|
+
return {
|
|
194
|
+
updated: true,
|
|
195
|
+
body: input.store.writeChannelMemory(input.parentId, next),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
130
199
|
export async function harvestChannelMemory(input: {
|
|
131
200
|
store: GuildStore;
|
|
132
201
|
roomId: string;
|