@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/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: "compression", name: "Compression", hint: "Context compaction" },
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: null,
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
- writeFileSync(modelsPath(dataDir), `${JSON.stringify(cleaned, null, 2)}\n`);
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 publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.env) {
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 const,
212
- ready: Boolean(resolveApiKey(p.apiKey, env) || p.stored === "literal"),
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 && role !== "chat" ? file.aux?.[role] : file.default);
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 provider = file.providers[id];
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: id,
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 fetch(`${target.baseUrl}/chat/completions`, {
522
- method: "POST",
523
- headers: {
524
- authorization: `Bearer ${target.apiKey}`,
525
- "content-type": "application/json",
526
- ...(target.headers ?? {}),
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
- body: JSON.stringify({
529
- model: target.model,
530
- temperature,
531
- messages: msgs,
532
- tools: catalog,
533
- tool_choice: "auto",
534
- }),
535
- signal: roundSignal(ctx),
536
- });
537
- if (!response.ok) return null;
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 fetch(
633
- `${target.baseUrl.replace(/\/v1$/, "")}/v1/messages`,
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
- method: "POST",
636
- headers,
637
- body: JSON.stringify({
638
- model: target.model,
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) return null;
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?: { input_tokens?: number; output_tokens?: number };
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(25_000),
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(25_000),
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(40_000),
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(40_000),
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;