@kevin5251984/guild 0.2.18 → 0.2.20
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 +2 -2
- package/src/catalog/subagents.ts +2 -1
- package/src/chat-parts.ts +6 -1
- package/src/cli.ts +2 -5
- package/src/compact.ts +27 -0
- package/src/db.ts +236 -68
- package/src/generate.ts +59 -28
- package/src/handlers.ts +355 -84
- package/src/harness.ts +39 -10
- package/src/host-browse.ts +149 -4
- package/src/image-gen.ts +2 -1
- package/src/llm.ts +435 -71
- package/src/mcp.ts +32 -5
- package/src/memory.ts +69 -0
- package/src/mention.ts +189 -14
- package/src/oauth.ts +204 -27
- package/src/opencode-free.ts +248 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +174 -72
- package/src/public/chat.html +354 -69
- package/src/public/i18n.js +44 -6
- package/src/public/index.html +1 -46
- package/src/public/mobile.css +597 -0
- package/src/public/mobile.html +954 -0
- package/src/public/settings.html +239 -35
- 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/reasoning-catalog.ts +346 -0
- package/src/router.ts +184 -13
- package/src/store.ts +170 -5
- package/src/subagent.ts +239 -7
- package/src/tools.ts +132 -21
- package/src/trajectory.ts +72 -9
- package/src/usage.ts +10 -0
- package/src/version.ts +25 -0
- package/vendor/protocol/src/index.ts +27 -2
package/src/mcp.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
2
8
|
import { homedir } from "node:os";
|
|
3
9
|
import { join } from "node:path";
|
|
4
10
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
@@ -215,6 +221,8 @@ class McpSession {
|
|
|
215
221
|
|
|
216
222
|
const sessions = new Map<string, McpSession>();
|
|
217
223
|
|
|
224
|
+
const REDACTED_ENV_VALUE = "***";
|
|
225
|
+
|
|
218
226
|
export function mcpPath(dataDir: string): string {
|
|
219
227
|
return join(dataDir, "mcp.json");
|
|
220
228
|
}
|
|
@@ -237,10 +245,29 @@ export function writeMcpFile(
|
|
|
237
245
|
servers: Record<string, McpLaunch>,
|
|
238
246
|
): void {
|
|
239
247
|
mkdirSync(dataDir, { recursive: true });
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
);
|
|
248
|
+
const file = mcpPath(dataDir);
|
|
249
|
+
writeFileSync(file, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`, {
|
|
250
|
+
mode: 0o600,
|
|
251
|
+
});
|
|
252
|
+
chmodSync(file, 0o600);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The wire shape for HTTP. `launch.env` holds API keys, so every value becomes
|
|
257
|
+
* a placeholder; the keys stay so the UI can still show that env is set.
|
|
258
|
+
* Spawning keeps using the full launch from `listGuildMcp` / `listActiveMcp`.
|
|
259
|
+
*/
|
|
260
|
+
export function publicMcpServer(server: McpServer): McpServer {
|
|
261
|
+
const env = server.launch?.env;
|
|
262
|
+
const launch: McpLaunch = { ...server.launch };
|
|
263
|
+
if (env && Object.keys(env).length) {
|
|
264
|
+
launch.env = Object.fromEntries(
|
|
265
|
+
Object.keys(env).map((key) => [key, REDACTED_ENV_VALUE]),
|
|
266
|
+
);
|
|
267
|
+
} else {
|
|
268
|
+
delete launch.env;
|
|
269
|
+
}
|
|
270
|
+
return { ...server, launch };
|
|
244
271
|
}
|
|
245
272
|
|
|
246
273
|
export function listGuildMcp(dataDir: string): McpServer[] {
|
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;
|
package/src/mention.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/** @channel / @quest / @here / @all still mean the whole quest. */
|
|
2
2
|
const BROADCAST = /(^|\s)@(channel|quest|here|all)\b/i;
|
|
3
3
|
const HANDLE = /@([A-Za-z0-9_-]+)/g;
|
|
4
|
-
/** Consecutive @handles at the start of a line. */
|
|
5
|
-
const LINE_LEAD = /^[ \t]*((
|
|
4
|
+
/** Consecutive @handles at the start of a line (optional backticks). */
|
|
5
|
+
const LINE_LEAD = /^[ \t]*((?:`?@[A-Za-z0-9_-]+`?[\s,、]*)+)/;
|
|
6
|
+
/** `1. @design` / `- @infra`. */
|
|
7
|
+
const LIST_MARK = /^[ \t]*(?:\d+[.)、]\s*|[-*•]\s+)/;
|
|
8
|
+
/** Assignee right after the marker, or after 通過後/最後/再叫… — not the first @ anywhere on the line. */
|
|
9
|
+
const LIST_ASSIGN =
|
|
10
|
+
/^(?:`?@([A-Za-z0-9_-]+)`?|(?:通過後|最後|再叫|交給|交棒(?:給)?|指派|(?:call|ask)\s+)\s*`?@([A-Za-z0-9_-]+)`?)/i;
|
|
11
|
+
/** Numbered/bullet item that names a later wave — do not start them this turn. */
|
|
12
|
+
const LIST_LATER =
|
|
13
|
+
/^(?:通過後|最後|然後|之後|完成後)\s*`?@([A-Za-z0-9_-]+)`?/i;
|
|
14
|
+
/** Prose: 四席完成後由 @infra / 才交 @infra. 再叫 is a handoff, not a defer. */
|
|
15
|
+
const DEFER_AT =
|
|
16
|
+
/(?:完成後|通過後|最後(?:才|由)?|才需要|才叫|才交)[^@\n]{0,24}@([A-Za-z0-9_-]+)/gi;
|
|
17
|
+
/** Bare handle after the same cues: 才需要call infra. */
|
|
18
|
+
const DEFER_BARE =
|
|
19
|
+
/(?:完成後|才需要|才叫)\s*(?:call|ask|由)\s*`?@?([A-Za-z0-9_-]+)`?/gi;
|
|
6
20
|
|
|
7
21
|
/** Drop fenced / inline code so `@pm` in a snippet does not dispatch. */
|
|
8
22
|
export function stripMentionNoise(text: string): string {
|
|
@@ -32,13 +46,26 @@ export type MentionBlock = {
|
|
|
32
46
|
start: number;
|
|
33
47
|
end: number;
|
|
34
48
|
handles: string[];
|
|
49
|
+
kind: "lead" | "list";
|
|
35
50
|
};
|
|
36
51
|
|
|
37
|
-
|
|
52
|
+
export type MentionBot = { id: string; handle: string };
|
|
53
|
+
|
|
54
|
+
function knownHandlesIn(chunk: string, known: Set<string>): string[] {
|
|
55
|
+
const names: string[] = [];
|
|
56
|
+
for (const row of chunk.matchAll(HANDLE)) {
|
|
57
|
+
const key = row[1].toLowerCase();
|
|
58
|
+
if (!known.has(key) || names.includes(key)) continue;
|
|
59
|
+
names.push(key);
|
|
60
|
+
}
|
|
61
|
+
return names;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Line-start @handle groups, plus markdown list items that name a seat. */
|
|
38
65
|
export function lineStartMentions(text: string, handles: string[]): MentionBlock[] {
|
|
39
66
|
const raw = String(text ?? "");
|
|
40
67
|
const known = new Set(handles.map((handle) => handle.toLowerCase()));
|
|
41
|
-
const starts: { start: number; handles: string[] }[] = [];
|
|
68
|
+
const starts: { start: number; handles: string[]; kind: "lead" | "list" }[] = [];
|
|
42
69
|
let fence = false;
|
|
43
70
|
let offset = 0;
|
|
44
71
|
for (const part of raw.split(/(\r?\n)/)) {
|
|
@@ -53,15 +80,22 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
|
|
|
53
80
|
continue;
|
|
54
81
|
}
|
|
55
82
|
if (!fence) {
|
|
83
|
+
const listed = LIST_MARK.test(part);
|
|
56
84
|
const lead = part.match(LINE_LEAD);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
85
|
+
let names: string[] = [];
|
|
86
|
+
if (lead) names = knownHandlesIn(lead[1], known);
|
|
87
|
+
if (!names.length && listed) {
|
|
88
|
+
const rest = part.replace(LIST_MARK, "");
|
|
89
|
+
const hit = rest.match(LIST_ASSIGN);
|
|
90
|
+
const key = (hit?.[1] || hit?.[2] || "").toLowerCase();
|
|
91
|
+
if (key && known.has(key)) names = [key];
|
|
92
|
+
}
|
|
93
|
+
if (names.length) {
|
|
94
|
+
starts.push({
|
|
95
|
+
start: offset,
|
|
96
|
+
handles: names,
|
|
97
|
+
kind: listed ? "list" : "lead",
|
|
98
|
+
});
|
|
65
99
|
}
|
|
66
100
|
}
|
|
67
101
|
offset += part.length;
|
|
@@ -70,20 +104,79 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
|
|
|
70
104
|
start: row.start,
|
|
71
105
|
end: i + 1 < starts.length ? starts[i + 1].start : raw.length,
|
|
72
106
|
handles: row.handles,
|
|
107
|
+
kind: row.kind,
|
|
73
108
|
}));
|
|
74
109
|
}
|
|
75
110
|
|
|
111
|
+
/** Fenced samples only. Keep inline `code` so 「再叫 `@infra`」 still parses. */
|
|
112
|
+
function stripFences(text: string): string {
|
|
113
|
+
return String(text ?? "").replace(/```[\s\S]*?```/g, " ");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Seats the same message says to start after someone else finishes. */
|
|
117
|
+
export function deferredHandles(text: string, handles: string[]): string[] {
|
|
118
|
+
const raw = String(text ?? "");
|
|
119
|
+
const known = new Set(handles.map((handle) => handle.toLowerCase()));
|
|
120
|
+
const out: string[] = [];
|
|
121
|
+
const add = (name: string) => {
|
|
122
|
+
const key = name.toLowerCase();
|
|
123
|
+
if (!known.has(key) || out.includes(key)) return;
|
|
124
|
+
out.push(key);
|
|
125
|
+
};
|
|
126
|
+
const scan = stripFences(raw);
|
|
127
|
+
for (const row of scan.matchAll(new RegExp(DEFER_AT.source, "gi"))) {
|
|
128
|
+
if (row[1]) add(row[1]);
|
|
129
|
+
}
|
|
130
|
+
for (const row of scan.matchAll(new RegExp(DEFER_BARE.source, "gi"))) {
|
|
131
|
+
if (row[1]) add(row[1]);
|
|
132
|
+
}
|
|
133
|
+
let fence = false;
|
|
134
|
+
for (const part of raw.split(/(\r?\n)/)) {
|
|
135
|
+
if (part === "\n" || part === "\r\n") continue;
|
|
136
|
+
const trimmed = part.trim();
|
|
137
|
+
if (trimmed.startsWith("```")) {
|
|
138
|
+
fence = !fence;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (fence || !LIST_MARK.test(part)) continue;
|
|
142
|
+
const hit = part.replace(LIST_MARK, "").match(LIST_LATER);
|
|
143
|
+
if (hit?.[1]) add(hit[1]);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function withoutDeferredIds(
|
|
149
|
+
ids: string[],
|
|
150
|
+
text: string,
|
|
151
|
+
bots: MentionBot[],
|
|
152
|
+
): string[] {
|
|
153
|
+
if (!ids.length) return ids;
|
|
154
|
+
const deferred = new Set(
|
|
155
|
+
deferredHandles(
|
|
156
|
+
text,
|
|
157
|
+
bots.map((bot) => bot.handle),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
if (!deferred.size) return ids;
|
|
161
|
+
return ids.filter((id) => {
|
|
162
|
+
const bot = bots.find((row) => row.id === id);
|
|
163
|
+
return !bot || !deferred.has(bot.handle.toLowerCase());
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
76
167
|
/**
|
|
77
168
|
* Fallback when the client did not pick an assignee.
|
|
78
169
|
* Every line-start @handle group, otherwise the first @handle in prose.
|
|
170
|
+
* Seats named as a later wave (完成後 / 最後 / 才叫) stay quiet this turn.
|
|
79
171
|
*/
|
|
80
172
|
export function summonedHandles(text: string, handles: string[]): string[] {
|
|
81
173
|
const known = new Set(handles.map((handle) => handle.toLowerCase()));
|
|
174
|
+
const deferred = new Set(deferredHandles(text, handles));
|
|
82
175
|
const take = (names: string[]): string[] => {
|
|
83
176
|
const out: string[] = [];
|
|
84
177
|
for (const name of names) {
|
|
85
178
|
const key = name.toLowerCase();
|
|
86
|
-
if (!known.has(key) || out.includes(key)) continue;
|
|
179
|
+
if (!known.has(key) || deferred.has(key) || out.includes(key)) continue;
|
|
87
180
|
out.push(key);
|
|
88
181
|
}
|
|
89
182
|
return out;
|
|
@@ -93,7 +186,8 @@ export function summonedHandles(text: string, handles: string[]): string[] {
|
|
|
93
186
|
const out: string[] = [];
|
|
94
187
|
for (const block of blocks) {
|
|
95
188
|
for (const handle of block.handles) {
|
|
96
|
-
if (
|
|
189
|
+
if (deferred.has(handle) || out.includes(handle)) continue;
|
|
190
|
+
out.push(handle);
|
|
97
191
|
}
|
|
98
192
|
}
|
|
99
193
|
return out;
|
|
@@ -104,6 +198,85 @@ export function summonedHandles(text: string, handles: string[]): string[] {
|
|
|
104
198
|
return [];
|
|
105
199
|
}
|
|
106
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Bot replies: line-start specs, else first prose @handle, plus assignment verbs
|
|
203
|
+
* even when the handle is wrapped in backticks (models quote @infra a lot).
|
|
204
|
+
* Fixture chatter like `(@pm / @rd)` has no verb, so it does not hop.
|
|
205
|
+
*/
|
|
206
|
+
const HANDOFF_ASK =
|
|
207
|
+
/(?:(?:再叫|交給|交棒(?:給)?|指派)\s*|(?:call|ask|ping|notify|handoff(?:\s+to)?)\s+)`?@([A-Za-z0-9_-]+)`?/gi;
|
|
208
|
+
|
|
209
|
+
export function handoffHandles(text: string, handles: string[]): string[] {
|
|
210
|
+
const known = new Set(handles.map((handle) => handle.toLowerCase()));
|
|
211
|
+
const deferred = new Set(deferredHandles(text, handles));
|
|
212
|
+
const out = summonedHandles(text, handles);
|
|
213
|
+
const ask = new RegExp(HANDOFF_ASK.source, "gi");
|
|
214
|
+
for (const row of stripFences(text).matchAll(ask)) {
|
|
215
|
+
const key = row[1].toLowerCase();
|
|
216
|
+
if (!known.has(key) || deferred.has(key) || out.includes(key)) continue;
|
|
217
|
+
out.push(key);
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function sanitizeMentionIds(
|
|
223
|
+
raw: unknown,
|
|
224
|
+
bots: MentionBot[],
|
|
225
|
+
): string[] {
|
|
226
|
+
if (!Array.isArray(raw)) return [];
|
|
227
|
+
const known = new Set(bots.map((bot) => bot.id));
|
|
228
|
+
const out: string[] = [];
|
|
229
|
+
for (const item of raw) {
|
|
230
|
+
if (typeof item !== "string") continue;
|
|
231
|
+
const id = item.trim();
|
|
232
|
+
if (!id || !known.has(id) || out.includes(id)) continue;
|
|
233
|
+
out.push(id);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function parseMentionIds(
|
|
239
|
+
text: string,
|
|
240
|
+
bots: MentionBot[],
|
|
241
|
+
kind: "user" | "bot",
|
|
242
|
+
): string[] {
|
|
243
|
+
if (isBroadcastMention(text)) return [];
|
|
244
|
+
const handles = bots.map((bot) => bot.handle);
|
|
245
|
+
const names =
|
|
246
|
+
kind === "bot" ? handoffHandles(text, handles) : summonedHandles(text, handles);
|
|
247
|
+
const out: string[] = [];
|
|
248
|
+
for (const name of names) {
|
|
249
|
+
const bot = bots.find(
|
|
250
|
+
(row) => row.handle.toLowerCase() === name.toLowerCase(),
|
|
251
|
+
);
|
|
252
|
+
if (!bot || out.includes(bot.id)) continue;
|
|
253
|
+
out.push(bot.id);
|
|
254
|
+
}
|
|
255
|
+
return withoutDeferredIds(out, text, bots);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Prefer the stored mention list. Older rows without the field still parse the body.
|
|
260
|
+
* An empty stored list means nobody.
|
|
261
|
+
*/
|
|
262
|
+
export function messageMentionIds(
|
|
263
|
+
message: { author: string; body: string; mentions?: string[] },
|
|
264
|
+
bots: MentionBot[],
|
|
265
|
+
): string[] {
|
|
266
|
+
const ids = Array.isArray(message.mentions)
|
|
267
|
+
? sanitizeMentionIds(message.mentions, bots)
|
|
268
|
+
: parseMentionIds(
|
|
269
|
+
message.body,
|
|
270
|
+
bots,
|
|
271
|
+
message.author === "you" ? "user" : "bot",
|
|
272
|
+
);
|
|
273
|
+
return withoutDeferredIds(
|
|
274
|
+
ids.filter((id) => id !== message.author),
|
|
275
|
+
message.body,
|
|
276
|
+
bots,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
107
280
|
/**
|
|
108
281
|
* Spec for one seat: shared preamble plus that @handle's line-start block.
|
|
109
282
|
* Full text when the handle was only summoned in prose.
|
|
@@ -119,6 +292,8 @@ export function assignmentFor(
|
|
|
119
292
|
if (!key || !blocks.length) return raw;
|
|
120
293
|
const mine = blocks.filter((block) => block.handles.includes(key));
|
|
121
294
|
if (!mine.length) return raw;
|
|
295
|
+
// Numbered plans share constraints ("don't ship until PM signs off"). Slice only classic specs.
|
|
296
|
+
if (!blocks.some((block) => block.kind === "lead")) return raw;
|
|
122
297
|
const preamble = raw.slice(0, blocks[0].start).trim();
|
|
123
298
|
const chunks = mine.map((block) => raw.slice(block.start, block.end).trim());
|
|
124
299
|
return [preamble, ...chunks].filter(Boolean).join("\n\n");
|
package/src/oauth.ts
CHANGED
|
@@ -1057,6 +1057,109 @@ function thinkingText(message: AssistantMessage | undefined): string {
|
|
|
1057
1057
|
.trim();
|
|
1058
1058
|
}
|
|
1059
1059
|
|
|
1060
|
+
/**
|
|
1061
|
+
* Codex `DEFAULT_STREAM_IDLE_TIMEOUT_MS`. No tokens on the stream — not a
|
|
1062
|
+
* wall clock on the whole turn. Do not pass this as Pi `timeoutMs` for xAI:
|
|
1063
|
+
* OpenAI-completions maps that to the SDK request timeout and kills thinking.
|
|
1064
|
+
*/
|
|
1065
|
+
export const STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
1066
|
+
|
|
1067
|
+
export function isTransientLlmError(err: unknown): boolean {
|
|
1068
|
+
if (err instanceof StreamIdleError) return false;
|
|
1069
|
+
const text = err instanceof Error ? `${err.name} ${err.message}` : String(err);
|
|
1070
|
+
return /econnreset|econnrefused|etimedout|enotfound|eai_again|fetch failed|socket|network|empty reply|429|502|503|504|5\d\d/i.test(
|
|
1071
|
+
text,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
export async function withTransientRetries<T>(
|
|
1076
|
+
run: () => Promise<T>,
|
|
1077
|
+
opts: {
|
|
1078
|
+
signal?: AbortSignal;
|
|
1079
|
+
attempts?: number;
|
|
1080
|
+
onRetry?: (attempt: number, err: unknown) => void;
|
|
1081
|
+
} = {},
|
|
1082
|
+
): Promise<T> {
|
|
1083
|
+
const attempts = opts.attempts ?? 3;
|
|
1084
|
+
let last: unknown;
|
|
1085
|
+
for (let i = 0; i < attempts; i++) {
|
|
1086
|
+
if (opts.signal?.aborted) {
|
|
1087
|
+
throw userAborted(opts.signal);
|
|
1088
|
+
}
|
|
1089
|
+
try {
|
|
1090
|
+
return await run();
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
last = err;
|
|
1093
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
1094
|
+
if (opts.signal?.aborted) throw err;
|
|
1095
|
+
if (!isTransientLlmError(err) || i === attempts - 1) throw err;
|
|
1096
|
+
opts.onRetry?.(i + 1, err);
|
|
1097
|
+
await new Promise((resolve) => setTimeout(resolve, 400 * (i + 1)));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
throw last instanceof Error ? last : new Error(String(last));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
export class StreamIdleError extends Error {
|
|
1104
|
+
readonly idleMs: number;
|
|
1105
|
+
constructor(idleMs: number) {
|
|
1106
|
+
super(
|
|
1107
|
+
`stream idle: no tokens for ${Math.round(idleMs / 1000)}s (Codex-style; not a turn wall clock). Resend or switch models.`,
|
|
1108
|
+
);
|
|
1109
|
+
this.name = "StreamIdleError";
|
|
1110
|
+
this.idleMs = idleMs;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
export function startStreamIdle(
|
|
1115
|
+
idleMs: number,
|
|
1116
|
+
parent?: AbortSignal,
|
|
1117
|
+
): {
|
|
1118
|
+
signal: AbortSignal;
|
|
1119
|
+
bump: () => void;
|
|
1120
|
+
dispose: () => void;
|
|
1121
|
+
timedOut: () => boolean;
|
|
1122
|
+
} {
|
|
1123
|
+
const ctrl = new AbortController();
|
|
1124
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1125
|
+
let timedOut = false;
|
|
1126
|
+
const fire = () => {
|
|
1127
|
+
if (timedOut || ctrl.signal.aborted) return;
|
|
1128
|
+
timedOut = true;
|
|
1129
|
+
ctrl.abort();
|
|
1130
|
+
};
|
|
1131
|
+
const bump = () => {
|
|
1132
|
+
if (timedOut || parent?.aborted) return;
|
|
1133
|
+
if (timer) clearTimeout(timer);
|
|
1134
|
+
timer = setTimeout(fire, idleMs);
|
|
1135
|
+
};
|
|
1136
|
+
const onParent = () => {
|
|
1137
|
+
if (timer) clearTimeout(timer);
|
|
1138
|
+
ctrl.abort();
|
|
1139
|
+
};
|
|
1140
|
+
if (parent?.aborted) {
|
|
1141
|
+
ctrl.abort();
|
|
1142
|
+
} else {
|
|
1143
|
+
parent?.addEventListener("abort", onParent, { once: true });
|
|
1144
|
+
}
|
|
1145
|
+
bump();
|
|
1146
|
+
return {
|
|
1147
|
+
signal: ctrl.signal,
|
|
1148
|
+
bump,
|
|
1149
|
+
dispose: () => {
|
|
1150
|
+
if (timer) clearTimeout(timer);
|
|
1151
|
+
parent?.removeEventListener("abort", onParent);
|
|
1152
|
+
},
|
|
1153
|
+
timedOut: () => timedOut,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function userAborted(signal?: AbortSignal): Error {
|
|
1158
|
+
const err = new Error("aborted");
|
|
1159
|
+
err.name = "AbortError";
|
|
1160
|
+
return err;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1060
1163
|
/** Pi streamSimple: Think chips only from thinking_delta, not a planted placeholder. */
|
|
1061
1164
|
async function streamOAuthMessage(
|
|
1062
1165
|
models: MutableModels,
|
|
@@ -1066,7 +1169,11 @@ async function streamOAuthMessage(
|
|
|
1066
1169
|
toolCtx: ToolContext,
|
|
1067
1170
|
traces: ToolTrace[],
|
|
1068
1171
|
): Promise<AssistantMessage> {
|
|
1069
|
-
const
|
|
1172
|
+
const idle = startStreamIdle(STREAM_IDLE_TIMEOUT_MS, options?.signal);
|
|
1173
|
+
const stream = models.streamSimple(model, context, {
|
|
1174
|
+
...options,
|
|
1175
|
+
signal: idle.signal,
|
|
1176
|
+
});
|
|
1070
1177
|
let lastEmit = 0;
|
|
1071
1178
|
const flush = (partial: AssistantMessage | undefined, force: boolean) => {
|
|
1072
1179
|
const think = thinkingText(partial);
|
|
@@ -1076,19 +1183,40 @@ async function streamOAuthMessage(
|
|
|
1076
1183
|
lastEmit = now;
|
|
1077
1184
|
emitProgress(toolCtx, traces, think);
|
|
1078
1185
|
};
|
|
1079
|
-
|
|
1080
|
-
if (
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1186
|
+
const failIfIdle = (): never => {
|
|
1187
|
+
if (options?.signal?.aborted) throw userAborted(options.signal);
|
|
1188
|
+
throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1189
|
+
};
|
|
1190
|
+
try {
|
|
1191
|
+
for await (const event of stream) {
|
|
1192
|
+
idle.bump();
|
|
1193
|
+
if (event.type === "thinking_start" || event.type === "thinking_delta") {
|
|
1194
|
+
flush(event.partial, event.type === "thinking_start");
|
|
1195
|
+
} else if (event.type === "thinking_end") {
|
|
1196
|
+
flush(event.partial, true);
|
|
1197
|
+
} else if (event.type === "done") {
|
|
1198
|
+
flush(event.message, true);
|
|
1199
|
+
return event.message;
|
|
1200
|
+
} else if (event.type === "error") {
|
|
1201
|
+
if (idle.timedOut() || options?.signal?.aborted) failIfIdle();
|
|
1202
|
+
return event.error;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (idle.timedOut() || options?.signal?.aborted) failIfIdle();
|
|
1206
|
+
return stream.result();
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
if (err instanceof StreamIdleError) throw err;
|
|
1209
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
1210
|
+
if (options?.signal?.aborted) throw err;
|
|
1211
|
+
if (idle.timedOut()) throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1212
|
+
}
|
|
1213
|
+
if (idle.timedOut() && !(options?.signal?.aborted)) {
|
|
1214
|
+
throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1089
1215
|
}
|
|
1216
|
+
throw err;
|
|
1217
|
+
} finally {
|
|
1218
|
+
idle.dispose();
|
|
1090
1219
|
}
|
|
1091
|
-
return stream.result();
|
|
1092
1220
|
}
|
|
1093
1221
|
|
|
1094
1222
|
function abortWhen(signal?: AbortSignal): Promise<never> {
|
|
@@ -1114,7 +1242,7 @@ export async function completeOAuth(input: {
|
|
|
1114
1242
|
system: string;
|
|
1115
1243
|
messages: { role: "user" | "assistant"; content: string }[];
|
|
1116
1244
|
temperature?: number;
|
|
1117
|
-
reasoning?:
|
|
1245
|
+
reasoning?: string;
|
|
1118
1246
|
tools?: boolean;
|
|
1119
1247
|
skills?: SkillRef[];
|
|
1120
1248
|
toolCtx?: ToolContext;
|
|
@@ -1173,7 +1301,7 @@ export async function completeOAuth(input: {
|
|
|
1173
1301
|
});
|
|
1174
1302
|
const options: {
|
|
1175
1303
|
temperature?: number;
|
|
1176
|
-
reasoning?:
|
|
1304
|
+
reasoning?: string;
|
|
1177
1305
|
signal?: AbortSignal;
|
|
1178
1306
|
transformHeaders?: (
|
|
1179
1307
|
headers: Record<string, string | null>,
|
|
@@ -1252,21 +1380,70 @@ export async function completeOAuth(input: {
|
|
|
1252
1380
|
if (fitted.length < transcript.length) {
|
|
1253
1381
|
transcript.splice(0, transcript.length, ...fitted);
|
|
1254
1382
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1383
|
+
let result: AssistantMessage;
|
|
1384
|
+
try {
|
|
1385
|
+
result = await withTransientRetries(
|
|
1386
|
+
async () => {
|
|
1387
|
+
const got = await Promise.race([
|
|
1388
|
+
streamOAuthMessage(
|
|
1389
|
+
models,
|
|
1390
|
+
model,
|
|
1391
|
+
{
|
|
1392
|
+
systemPrompt: input.system,
|
|
1393
|
+
messages: transcript,
|
|
1394
|
+
...(useTools ? { tools } : {}),
|
|
1395
|
+
},
|
|
1396
|
+
options,
|
|
1397
|
+
toolCtx,
|
|
1398
|
+
traces,
|
|
1399
|
+
),
|
|
1400
|
+
abortWhen(options.signal),
|
|
1401
|
+
]);
|
|
1402
|
+
if (got.stopReason === "aborted") {
|
|
1403
|
+
const err = new Error("aborted");
|
|
1404
|
+
err.name = "AbortError";
|
|
1405
|
+
throw err;
|
|
1406
|
+
}
|
|
1407
|
+
if (got.stopReason === "error") {
|
|
1408
|
+
throw new Error(
|
|
1409
|
+
formatOAuthError(sub.id, got.errorMessage) ||
|
|
1410
|
+
`${sub.id} request failed`,
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
const text = contentText(got.content).trim();
|
|
1414
|
+
const toolCalls =
|
|
1415
|
+
got.stopReason === "toolUse"
|
|
1416
|
+
? got.content.filter(
|
|
1417
|
+
(part): part is Extract<typeof part, { type: "toolCall" }> =>
|
|
1418
|
+
part.type === "toolCall",
|
|
1419
|
+
)
|
|
1420
|
+
: [];
|
|
1421
|
+
if (!toolCalls.length && !text && traces.length === 0) {
|
|
1422
|
+
throw new Error(`${sub.id} returned an empty reply`);
|
|
1423
|
+
}
|
|
1424
|
+
return got;
|
|
1425
|
+
},
|
|
1259
1426
|
{
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1427
|
+
signal: options.signal,
|
|
1428
|
+
onRetry: () => {
|
|
1429
|
+
emitProgress(
|
|
1430
|
+
toolCtx,
|
|
1431
|
+
traces,
|
|
1432
|
+
thinkingChunks.join("\n\n") || "連線中斷,重試中…",
|
|
1433
|
+
);
|
|
1434
|
+
},
|
|
1263
1435
|
},
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1436
|
+
);
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
if (err instanceof StreamIdleError) {
|
|
1439
|
+
return {
|
|
1440
|
+
calls: [],
|
|
1441
|
+
text: err.message,
|
|
1442
|
+
thinking: thinkingChunks.join("\n\n"),
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
throw err;
|
|
1446
|
+
}
|
|
1270
1447
|
if (result.stopReason === "aborted" || toolCtx.signal?.aborted || options.signal?.aborted) {
|
|
1271
1448
|
const err = new Error("aborted");
|
|
1272
1449
|
err.name = "AbortError";
|