@cryer/star-cli 0.3.6 → 0.3.8
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/README.md +100 -41
- package/README.zh-CN.md +98 -42
- package/dist/chunk-IYWYSFSF.js +1240 -0
- package/dist/chunk-IYWYSFSF.js.map +1 -0
- package/dist/{chunk-I6FDXAWX.js → chunk-OHLA2DP7.js} +2123 -270
- package/dist/chunk-OHLA2DP7.js.map +1 -0
- package/dist/loop-2MOFLXTG.js +12 -0
- package/dist/main.js +141 -6677
- package/dist/main.js.map +1 -1
- package/dist/repl-YA23CK5R.js +5464 -0
- package/dist/repl-YA23CK5R.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-B6P4VUBE.js +0 -122
- package/dist/chunk-B6P4VUBE.js.map +0 -1
- package/dist/chunk-I6FDXAWX.js.map +0 -1
- package/dist/loop-XY3TE5NN.js +0 -11
- package/dist/stream-EFQPULQQ.js +0 -186
- package/dist/stream-EFQPULQQ.js.map +0 -1
- /package/dist/{loop-XY3TE5NN.js.map → loop-2MOFLXTG.js.map} +0 -0
|
@@ -1,13 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
computeRetryDelayMs,
|
|
4
|
-
gitTreesDir,
|
|
5
|
-
isRetryableStreamError,
|
|
6
|
-
responseBodyOf,
|
|
7
|
-
starHome,
|
|
8
|
-
summarizeStreamError,
|
|
9
|
-
userMemoryPath
|
|
10
|
-
} from "./chunk-B6P4VUBE.js";
|
|
11
2
|
|
|
12
3
|
// src/agent/loop.ts
|
|
13
4
|
import { tool as aiTool, generateText as generateText3 } from "ai";
|
|
@@ -42,6 +33,17 @@ var ModelConfigSchema = z.object({
|
|
|
42
33
|
// request carries no effort field and the server-side default applies.
|
|
43
34
|
// Anthropic maps effort to a thinking budget instead — ignored there.
|
|
44
35
|
reasoningEffort: z.string().min(1).optional(),
|
|
36
|
+
// Sampling temperature forwarded on every request. Unset = the AI SDK's
|
|
37
|
+
// own default applies (ai@4 sends 0 — it does NOT omit the field). Some
|
|
38
|
+
// endpoints reject anything but one explicit value (e.g. kimi-for-coding:
|
|
39
|
+
// "invalid temperature: only 1 is allowed") — set it here. No upper
|
|
40
|
+
// bound: ranges vary by provider and the server validates.
|
|
41
|
+
temperature: z.number().min(0).optional(),
|
|
42
|
+
// Per-model overrides of the top-level stream watchdog timeouts (unset =
|
|
43
|
+
// the global values apply). Raise streamIdleTimeoutSec for relays that
|
|
44
|
+
// buffer long generations and stall mid-stream between flushes.
|
|
45
|
+
streamIdleTimeoutSec: z.number().positive().optional(),
|
|
46
|
+
streamFirstChunkTimeoutSec: z.number().positive().optional(),
|
|
45
47
|
// Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
|
|
46
48
|
// the model is treated as unpriced.
|
|
47
49
|
promptPrice: z.number().nonnegative().optional(),
|
|
@@ -73,6 +75,10 @@ var HookConfigSchema = z.object({
|
|
|
73
75
|
});
|
|
74
76
|
var ConfigSchema = z.object({
|
|
75
77
|
defaultModel: z.string().default(""),
|
|
78
|
+
// Optional [[models]] name for cheap auxiliary LLM calls (compaction
|
|
79
|
+
// summaries, session titles, turn completion checks) — Claude Code routes
|
|
80
|
+
// these to a Haiku-class model. Unset = the active main model does them.
|
|
81
|
+
smallModel: z.string().optional(),
|
|
76
82
|
permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
|
|
77
83
|
providers: z.array(ProviderConfigSchema).default([]),
|
|
78
84
|
models: z.array(ModelConfigSchema).default([]),
|
|
@@ -91,10 +97,12 @@ var ConfigSchema = z.object({
|
|
|
91
97
|
compactThresholdTokens: z.number().int().positive().optional(),
|
|
92
98
|
// Seconds without any stream part before a stalled response is ended
|
|
93
99
|
// gracefully (some relays never send the terminal chunks). Applies once
|
|
94
|
-
//
|
|
100
|
+
// content has started streaming; the wait for the first content part gets
|
|
101
|
+
// a longer allowance (control/metadata parts don't count as content).
|
|
95
102
|
streamIdleTimeoutSec: z.number().positive().default(20),
|
|
96
|
-
// Seconds to wait for the very first
|
|
97
|
-
// thinking-model
|
|
103
|
+
// Seconds to wait for the very first content part before giving up; slow
|
|
104
|
+
// thinking-model endpoints can stay silent for minutes after accepting
|
|
105
|
+
// the request.
|
|
98
106
|
streamFirstChunkTimeoutSec: z.number().positive().default(300),
|
|
99
107
|
// Extra attempts per model request when a stream fails transiently
|
|
100
108
|
// (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a
|
|
@@ -150,11 +158,13 @@ import { generateText } from "ai";
|
|
|
150
158
|
var CHARS_PER_TOKEN = 4;
|
|
151
159
|
var MESSAGE_OVERHEAD = 4;
|
|
152
160
|
var IMAGE_PART_TOKENS = 1024;
|
|
153
|
-
|
|
161
|
+
function isCjkCode(code) {
|
|
162
|
+
return code >= 12288 && code <= 12543 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 65280 && code <= 65519;
|
|
163
|
+
}
|
|
154
164
|
function textWeight(text) {
|
|
155
165
|
let cjk = 0;
|
|
156
|
-
for (
|
|
157
|
-
if (
|
|
166
|
+
for (let i = 0; i < text.length; i++) {
|
|
167
|
+
if (isCjkCode(text.charCodeAt(i))) cjk++;
|
|
158
168
|
}
|
|
159
169
|
return text.length + cjk * (CHARS_PER_TOKEN - 1);
|
|
160
170
|
}
|
|
@@ -183,8 +193,13 @@ function imagePartCount(message) {
|
|
|
183
193
|
if (typeof message.content === "string" || message.role === "tool") return 0;
|
|
184
194
|
return message.content.filter((part) => part.type === "image" || part.type === "file").length;
|
|
185
195
|
}
|
|
196
|
+
var tokenCache = /* @__PURE__ */ new WeakMap();
|
|
186
197
|
function estimateMessageTokens(message) {
|
|
187
|
-
|
|
198
|
+
const cached = tokenCache.get(message);
|
|
199
|
+
if (cached !== void 0) return cached;
|
|
200
|
+
const tokens = Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
|
|
201
|
+
tokenCache.set(message, tokens);
|
|
202
|
+
return tokens;
|
|
188
203
|
}
|
|
189
204
|
function estimateTokens(messages) {
|
|
190
205
|
return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
|
|
@@ -208,6 +223,16 @@ var SUMMARY_SYSTEM_PROMPT = [
|
|
|
208
223
|
"- outstanding TODOs and next steps",
|
|
209
224
|
"Output only the summary, no preamble."
|
|
210
225
|
].join("\n");
|
|
226
|
+
var MAX_SUMMARY_INPUT_CHARS = 8e4;
|
|
227
|
+
var SUMMARY_HEAD_CHARS = 24e3;
|
|
228
|
+
function truncateSummaryInput(transcript) {
|
|
229
|
+
if (transcript.length <= MAX_SUMMARY_INPUT_CHARS) return transcript;
|
|
230
|
+
const tailChars = MAX_SUMMARY_INPUT_CHARS - SUMMARY_HEAD_CHARS;
|
|
231
|
+
const omitted = transcript.length - MAX_SUMMARY_INPUT_CHARS;
|
|
232
|
+
return `${transcript.slice(0, SUMMARY_HEAD_CHARS)}
|
|
233
|
+
[... ${omitted} characters omitted ...]
|
|
234
|
+
${transcript.slice(-tailChars)}`;
|
|
235
|
+
}
|
|
211
236
|
function serializeMessage(message) {
|
|
212
237
|
if (typeof message.content === "string") {
|
|
213
238
|
return `${message.role}: ${message.content}`;
|
|
@@ -222,14 +247,15 @@ function serializeMessage(message) {
|
|
|
222
247
|
});
|
|
223
248
|
return `${message.role}: ${parts.join("\n")}`;
|
|
224
249
|
}
|
|
225
|
-
async function summarizeMessages(messages, model, signal) {
|
|
226
|
-
const transcript = messages.map(serializeMessage).join("\n");
|
|
250
|
+
async function summarizeMessages(messages, model, signal, temperature) {
|
|
251
|
+
const transcript = truncateSummaryInput(messages.map(serializeMessage).join("\n"));
|
|
227
252
|
const { text } = await generateText({
|
|
228
253
|
model,
|
|
229
254
|
system: SUMMARY_SYSTEM_PROMPT,
|
|
230
255
|
prompt: `Summarize this conversation so far:
|
|
231
256
|
|
|
232
257
|
${transcript}`,
|
|
258
|
+
temperature,
|
|
233
259
|
// A hung relay must not stall the whole turn on the summary: cap it at
|
|
234
260
|
// 60s and let the caller's abort (Esc) cut it short as well; the caller
|
|
235
261
|
// falls back to the truncation placeholder on any failure.
|
|
@@ -246,26 +272,29 @@ function compactMessages(messages, maxTokens, opts = {}) {
|
|
|
246
272
|
const hasSystem = first?.role === "system";
|
|
247
273
|
const head = hasSystem && first ? [first] : [];
|
|
248
274
|
const rest = hasSystem ? messages.slice(1) : messages.slice();
|
|
275
|
+
const messageTokens = rest.map((message) => estimateMessageTokens(message));
|
|
249
276
|
const turns = [];
|
|
250
|
-
for (
|
|
277
|
+
for (let i = 0; i < rest.length; i++) {
|
|
251
278
|
const current = turns[turns.length - 1];
|
|
252
|
-
if (
|
|
253
|
-
turns.push([
|
|
279
|
+
if (rest[i]?.role === "user" || !current) {
|
|
280
|
+
turns.push({ length: 1, tokens: messageTokens[i] ?? 0 });
|
|
254
281
|
} else {
|
|
255
|
-
current.
|
|
282
|
+
current.length += 1;
|
|
283
|
+
current.tokens += messageTokens[i] ?? 0;
|
|
256
284
|
}
|
|
257
285
|
}
|
|
286
|
+
const headTokens = estimateTokens(head);
|
|
287
|
+
const restTokens = estimateTokens(rest);
|
|
258
288
|
let droppedCount = 0;
|
|
259
|
-
let
|
|
260
|
-
|
|
261
|
-
const turn = turns[turnIndex];
|
|
289
|
+
let droppedTokens = 0;
|
|
290
|
+
for (const turn of turns) {
|
|
262
291
|
if (rest.length - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
|
|
263
292
|
break;
|
|
264
293
|
}
|
|
265
294
|
droppedCount += turn.length;
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
if (
|
|
295
|
+
droppedTokens += turn.tokens;
|
|
296
|
+
const candidateTokens = headTokens + estimateMessageTokens(placeholderMessage(droppedCount)) + (restTokens - droppedTokens);
|
|
297
|
+
if (candidateTokens <= limit) {
|
|
269
298
|
break;
|
|
270
299
|
}
|
|
271
300
|
}
|
|
@@ -390,6 +419,12 @@ ${unstaged}`);
|
|
|
390
419
|
return { status, diff: text, truncated };
|
|
391
420
|
}
|
|
392
421
|
|
|
422
|
+
// src/core/http-error.ts
|
|
423
|
+
function responseBodyOf(error) {
|
|
424
|
+
const body = error.responseBody;
|
|
425
|
+
return typeof body === "string" && body.length > 0 ? body : void 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
393
428
|
// src/core/image.ts
|
|
394
429
|
var MAX_IMAGE_DIMENSION = 2e3;
|
|
395
430
|
var OVERSIZED_IMAGE_FALLBACK_BYTES = 5 * 1024 * 1024;
|
|
@@ -689,9 +724,579 @@ async function runHooks(event, hooks2, ctx) {
|
|
|
689
724
|
return result;
|
|
690
725
|
}
|
|
691
726
|
|
|
727
|
+
// src/llm/provider.ts
|
|
728
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
729
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
730
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
731
|
+
|
|
732
|
+
// src/llm/activity.ts
|
|
733
|
+
var trackers = /* @__PURE__ */ new WeakMap();
|
|
734
|
+
function streamActivity(signal) {
|
|
735
|
+
return trackers.get(signal);
|
|
736
|
+
}
|
|
737
|
+
function wrapFetchWithActivity(baseFetch = fetch) {
|
|
738
|
+
return async (input, init) => {
|
|
739
|
+
const response = await baseFetch(input, init);
|
|
740
|
+
const signal = init?.signal ?? (input instanceof Request ? input.signal : null);
|
|
741
|
+
if (!signal || !response.body) return response;
|
|
742
|
+
const tracker = { lastChunkAt: Date.now() };
|
|
743
|
+
trackers.set(signal, tracker);
|
|
744
|
+
const reader = response.body.getReader();
|
|
745
|
+
const body = new ReadableStream({
|
|
746
|
+
async pull(controller) {
|
|
747
|
+
try {
|
|
748
|
+
const { done, value } = await reader.read();
|
|
749
|
+
if (done) {
|
|
750
|
+
controller.close();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
tracker.lastChunkAt = Date.now();
|
|
754
|
+
controller.enqueue(value);
|
|
755
|
+
} catch (error) {
|
|
756
|
+
controller.error(error);
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
cancel(reason) {
|
|
760
|
+
return reader.cancel(reason);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
return new Response(body, {
|
|
764
|
+
status: response.status,
|
|
765
|
+
statusText: response.statusText,
|
|
766
|
+
headers: response.headers
|
|
767
|
+
});
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// src/llm/registry.ts
|
|
772
|
+
function listModels(config) {
|
|
773
|
+
return config.models;
|
|
774
|
+
}
|
|
775
|
+
function resolveModelConfig(config, modelName) {
|
|
776
|
+
const name = modelName ?? config.defaultModel;
|
|
777
|
+
const model = config.models.find((m) => m.name === name);
|
|
778
|
+
if (!model) {
|
|
779
|
+
const available = config.models.map((m) => m.name).join(", ") || "(none)";
|
|
780
|
+
throw new Error(`Model "${name}" not found. Available models: ${available}`);
|
|
781
|
+
}
|
|
782
|
+
return model;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/llm/sse-normalize.ts
|
|
786
|
+
function splitJsonPrefix(text) {
|
|
787
|
+
if (!text.startsWith("{")) return null;
|
|
788
|
+
let depth = 0;
|
|
789
|
+
let inString = false;
|
|
790
|
+
let escaped = false;
|
|
791
|
+
for (let i = 0; i < text.length; i++) {
|
|
792
|
+
const ch = text[i];
|
|
793
|
+
if (inString) {
|
|
794
|
+
if (escaped) escaped = false;
|
|
795
|
+
else if (ch === "\\") escaped = true;
|
|
796
|
+
else if (ch === '"') inString = false;
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (ch === '"') inString = true;
|
|
800
|
+
else if (ch === "{") depth += 1;
|
|
801
|
+
else if (ch === "}") {
|
|
802
|
+
depth -= 1;
|
|
803
|
+
if (depth === 0) return [text.slice(0, i + 1), text.slice(i + 1).trimStart()];
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
var SseNormalizeTransform = class extends TransformStream {
|
|
809
|
+
constructor() {
|
|
810
|
+
let buffer = "";
|
|
811
|
+
let pending = null;
|
|
812
|
+
const flushPending = (controller) => {
|
|
813
|
+
if (pending === null) return;
|
|
814
|
+
controller.enqueue(`data: ${pending}
|
|
815
|
+
|
|
816
|
+
`);
|
|
817
|
+
pending = null;
|
|
818
|
+
};
|
|
819
|
+
const emitPayload = (payload, controller) => {
|
|
820
|
+
let rest = payload;
|
|
821
|
+
for (; ; ) {
|
|
822
|
+
const split = splitJsonPrefix(rest);
|
|
823
|
+
if (!split) {
|
|
824
|
+
pending = rest;
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const [json, tail] = split;
|
|
828
|
+
if (tail === "") {
|
|
829
|
+
pending = json;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
controller.enqueue(`data: ${json}
|
|
833
|
+
|
|
834
|
+
`);
|
|
835
|
+
if (tail.startsWith("data:")) {
|
|
836
|
+
rest = tail.slice(5).replace(/^ /, "");
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (tail.startsWith("{")) {
|
|
840
|
+
rest = tail;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
controller.enqueue(`${tail}
|
|
844
|
+
`);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
const handleLine = (raw, controller) => {
|
|
849
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
850
|
+
if (line === "") {
|
|
851
|
+
flushPending(controller);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (line.startsWith("data:")) {
|
|
855
|
+
flushPending(controller);
|
|
856
|
+
emitPayload(line.slice(5).replace(/^ /, ""), controller);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (line.startsWith("{") && pending !== null) {
|
|
860
|
+
flushPending(controller);
|
|
861
|
+
emitPayload(line, controller);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
flushPending(controller);
|
|
865
|
+
controller.enqueue(`${line}
|
|
866
|
+
`);
|
|
867
|
+
};
|
|
868
|
+
super({
|
|
869
|
+
transform(chunk, controller) {
|
|
870
|
+
buffer += chunk;
|
|
871
|
+
let index = buffer.indexOf("\n");
|
|
872
|
+
while (index !== -1) {
|
|
873
|
+
handleLine(buffer.slice(0, index), controller);
|
|
874
|
+
buffer = buffer.slice(index + 1);
|
|
875
|
+
index = buffer.indexOf("\n");
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
flush(controller) {
|
|
879
|
+
if (buffer.length > 0) handleLine(buffer, controller);
|
|
880
|
+
flushPending(controller);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
function createSseNormalizingFetch(baseFetch = fetch) {
|
|
886
|
+
return async (input, init) => {
|
|
887
|
+
const response = await baseFetch(input, init);
|
|
888
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
889
|
+
if (!response.body || !contentType.includes("text/event-stream")) {
|
|
890
|
+
return response;
|
|
891
|
+
}
|
|
892
|
+
const body = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new SseNormalizeTransform()).pipeThrough(new TextEncoderStream());
|
|
893
|
+
const headers = new Headers(response.headers);
|
|
894
|
+
headers.delete("content-length");
|
|
895
|
+
headers.delete("content-encoding");
|
|
896
|
+
return new Response(body, {
|
|
897
|
+
status: response.status,
|
|
898
|
+
statusText: response.statusText,
|
|
899
|
+
headers
|
|
900
|
+
});
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/llm/provider.ts
|
|
905
|
+
function resolveApiKey(provider) {
|
|
906
|
+
const fromEnv = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : void 0;
|
|
907
|
+
const apiKey = fromEnv ?? provider.apiKey;
|
|
908
|
+
if (!apiKey) {
|
|
909
|
+
const hint = provider.apiKeyEnv ? `set ${provider.apiKeyEnv} or configure apiKey` : "configure apiKey";
|
|
910
|
+
throw new Error(`Missing API key for provider "${provider.name}": ${hint}`);
|
|
911
|
+
}
|
|
912
|
+
return apiKey;
|
|
913
|
+
}
|
|
914
|
+
function createModel(config, modelName) {
|
|
915
|
+
const modelConfig = resolveModelConfig(config, modelName);
|
|
916
|
+
const provider = config.providers.find((p) => p.name === modelConfig.provider);
|
|
917
|
+
if (!provider) {
|
|
918
|
+
const available = config.providers.map((p) => p.name).join(", ") || "(none)";
|
|
919
|
+
throw new Error(
|
|
920
|
+
`Provider "${modelConfig.provider}" for model "${modelConfig.name}" not found. Available providers: ${available}`
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
const apiKey = resolveApiKey(provider);
|
|
924
|
+
const trackedFetch = wrapFetchWithActivity();
|
|
925
|
+
switch (provider.protocol) {
|
|
926
|
+
case "anthropic":
|
|
927
|
+
return createAnthropic({
|
|
928
|
+
baseURL: provider.baseURL,
|
|
929
|
+
apiKey,
|
|
930
|
+
headers: provider.headers,
|
|
931
|
+
fetch: trackedFetch
|
|
932
|
+
})(modelConfig.model);
|
|
933
|
+
case "openai-compatible":
|
|
934
|
+
return createOpenAICompatible({
|
|
935
|
+
name: provider.name,
|
|
936
|
+
baseURL: provider.baseURL,
|
|
937
|
+
apiKey,
|
|
938
|
+
headers: provider.headers,
|
|
939
|
+
fetch: trackedFetch
|
|
940
|
+
})(modelConfig.model, {}, { includeUsage: true });
|
|
941
|
+
case "openai-responses":
|
|
942
|
+
return createOpenAI({
|
|
943
|
+
baseURL: provider.baseURL,
|
|
944
|
+
apiKey,
|
|
945
|
+
headers: provider.headers,
|
|
946
|
+
// Relays fronting the Responses API sometimes batch several JSON
|
|
947
|
+
// events into one SSE data payload, which the SDK's parser cannot
|
|
948
|
+
// read; normalize the wire format back to one event per data line.
|
|
949
|
+
fetch: createSseNormalizingFetch(trackedFetch)
|
|
950
|
+
}).responses(modelConfig.model);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
function reasoningEffortMetadata(config, modelName) {
|
|
954
|
+
const modelConfig = resolveModelConfig(config, modelName);
|
|
955
|
+
if (!modelConfig.reasoningEffort) return void 0;
|
|
956
|
+
const provider = config.providers.find((p) => p.name === modelConfig.provider);
|
|
957
|
+
if (!provider || provider.protocol === "anthropic") return void 0;
|
|
958
|
+
const key = provider.protocol === "openai-responses" ? "openai" : provider.name;
|
|
959
|
+
return { [key]: { reasoningEffort: modelConfig.reasoningEffort } };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/llm/retry.ts
|
|
963
|
+
var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
964
|
+
"AI_NoSuchToolError",
|
|
965
|
+
"AI_InvalidToolArgumentsError",
|
|
966
|
+
"AI_InvalidPromptError",
|
|
967
|
+
"AI_NoSuchModelError",
|
|
968
|
+
"AI_LoadAPIKeyError",
|
|
969
|
+
"AI_TypeValidationError",
|
|
970
|
+
"AI_JSONParseError"
|
|
971
|
+
]);
|
|
972
|
+
var RETRYABLE_MESSAGE_PATTERNS = [
|
|
973
|
+
/\b(408|425|429|500|502|503|504|520|521|522|523|524|529)\b/,
|
|
974
|
+
/rate.?limit|too many requests|rate increased/i,
|
|
975
|
+
/overloaded|service.?unavailable|internal (server )?error|server error|provider returned error|bad gateway|gateway timeout/i,
|
|
976
|
+
/terminated|fetch failed|failed to fetch|network.?error|upstream connect|connection (?:error|refused|lost|reset)|socket (?:hang up|connection)|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout|epipe|ehostunreach|enetunreach/i,
|
|
977
|
+
/\b(?:request|response|connection|network|stream|read|headers?|body) (?:timeout|timed out|time out)\b/i,
|
|
978
|
+
/resource.?exhausted|try (?:your request )?again|at capacity/i
|
|
979
|
+
];
|
|
980
|
+
function statusCodeOf(error) {
|
|
981
|
+
const status = error.statusCode;
|
|
982
|
+
return typeof status === "number" ? status : void 0;
|
|
983
|
+
}
|
|
984
|
+
function matchesRetryablePattern(text) {
|
|
985
|
+
return typeof text === "string" && RETRYABLE_MESSAGE_PATTERNS.some((p) => p.test(text));
|
|
986
|
+
}
|
|
987
|
+
function isRetryableStreamError(error) {
|
|
988
|
+
if (NON_RETRYABLE_ERROR_NAMES.has(error.name)) return false;
|
|
989
|
+
if (error.isRetryable === true) return true;
|
|
990
|
+
const status = statusCodeOf(error);
|
|
991
|
+
if (status !== void 0) {
|
|
992
|
+
if (status === 408 || status === 429 || status >= 500 && status !== 501) return true;
|
|
993
|
+
return matchesRetryablePattern(error.message) || matchesRetryablePattern(responseBodyOf(error));
|
|
994
|
+
}
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
var RETRY_JITTER_FACTOR = 0.25;
|
|
998
|
+
var MAX_RETRY_DELAY_MS = 6e4;
|
|
999
|
+
function retryAfterDelayMs(error) {
|
|
1000
|
+
const headers = error.responseHeaders;
|
|
1001
|
+
if (!headers || typeof headers !== "object") return void 0;
|
|
1002
|
+
const normalized = {};
|
|
1003
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1004
|
+
if (typeof value === "string") normalized[key.toLowerCase()] = value;
|
|
1005
|
+
}
|
|
1006
|
+
const afterMs = Number.parseFloat(normalized["retry-after-ms"] ?? "");
|
|
1007
|
+
if (!Number.isNaN(afterMs)) return Math.max(0, afterMs);
|
|
1008
|
+
const after = normalized["retry-after"];
|
|
1009
|
+
if (after) {
|
|
1010
|
+
const seconds = Number.parseFloat(after);
|
|
1011
|
+
if (!Number.isNaN(seconds)) return Math.max(0, Math.ceil(seconds * 1e3));
|
|
1012
|
+
const dateMs = Date.parse(after) - Date.now();
|
|
1013
|
+
if (!Number.isNaN(dateMs) && dateMs > 0) return Math.ceil(dateMs);
|
|
1014
|
+
}
|
|
1015
|
+
return void 0;
|
|
1016
|
+
}
|
|
1017
|
+
function computeRetryDelayMs(attempt, baseMs, error, random = Math.random()) {
|
|
1018
|
+
const hinted = error ? retryAfterDelayMs(error) : void 0;
|
|
1019
|
+
if (hinted !== void 0) return Math.min(hinted, MAX_RETRY_DELAY_MS);
|
|
1020
|
+
const base = baseMs * 2 ** attempt;
|
|
1021
|
+
return Math.min(Math.ceil(base + base * RETRY_JITTER_FACTOR * random), MAX_RETRY_DELAY_MS);
|
|
1022
|
+
}
|
|
1023
|
+
function summarizeStreamError(error) {
|
|
1024
|
+
const parts = [error.message || error.name];
|
|
1025
|
+
const status = statusCodeOf(error);
|
|
1026
|
+
if (status !== void 0 && !error.message.includes(String(status))) {
|
|
1027
|
+
parts.push(`(HTTP ${status})`);
|
|
1028
|
+
}
|
|
1029
|
+
const body = responseBodyOf(error);
|
|
1030
|
+
if (body && !error.message.includes(body.slice(0, 40))) {
|
|
1031
|
+
const excerpt = body.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
1032
|
+
if (excerpt) parts.push(`\u2014 ${excerpt}`);
|
|
1033
|
+
}
|
|
1034
|
+
return parts.join(" ");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// src/llm/stream.ts
|
|
1038
|
+
import { streamText } from "ai";
|
|
1039
|
+
|
|
1040
|
+
// src/llm/debug.ts
|
|
1041
|
+
import { appendFileSync } from "fs";
|
|
1042
|
+
import path2 from "path";
|
|
1043
|
+
|
|
1044
|
+
// src/config/paths.ts
|
|
1045
|
+
import os from "os";
|
|
1046
|
+
import path from "path";
|
|
1047
|
+
function starHome() {
|
|
1048
|
+
return process.env.STAR_HOME ?? path.join(os.homedir(), ".star-cli");
|
|
1049
|
+
}
|
|
1050
|
+
function globalConfigPath() {
|
|
1051
|
+
return path.join(starHome(), "config.toml");
|
|
1052
|
+
}
|
|
1053
|
+
function sessionsDir() {
|
|
1054
|
+
return path.join(starHome(), "sessions");
|
|
1055
|
+
}
|
|
1056
|
+
function userMemoryPath() {
|
|
1057
|
+
return path.join(starHome(), "MEMORY.md");
|
|
1058
|
+
}
|
|
1059
|
+
function gitTreesDir() {
|
|
1060
|
+
return path.join(starHome(), "git-trees");
|
|
1061
|
+
}
|
|
1062
|
+
function envFilePath() {
|
|
1063
|
+
return path.join(starHome(), ".env");
|
|
1064
|
+
}
|
|
1065
|
+
function projectConfigPath(cwd) {
|
|
1066
|
+
return path.join(cwd, ".star", "config.toml");
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// src/llm/debug.ts
|
|
1070
|
+
function debugStreamLog(event, data) {
|
|
1071
|
+
if (!process.env.STAR_DEBUG) return;
|
|
1072
|
+
try {
|
|
1073
|
+
const line = `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), event, ...data })}
|
|
1074
|
+
`;
|
|
1075
|
+
appendFileSync(path2.join(starHome(), "debug.log"), line);
|
|
1076
|
+
} catch {
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// src/llm/stream.ts
|
|
1081
|
+
var DEFAULT_IDLE_TIMEOUT_MS = 2e4;
|
|
1082
|
+
var DEFAULT_FIRST_PART_TIMEOUT_MS = 12e4;
|
|
1083
|
+
var GENERATION_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "reasoning", "tool-call", "tool-call-delta"]);
|
|
1084
|
+
var MAX_KEEPALIVE_SILENCE_MS = 10 * 6e4;
|
|
1085
|
+
var CACHE_READ_KEYS = ["cacheReadInputTokens", "cache_read_input_tokens"];
|
|
1086
|
+
var CACHED_PROMPT_KEYS = ["cachedPromptTokens", "cached_prompt_tokens", "cachedTokens"];
|
|
1087
|
+
function withAnthropicCacheBreakpoints(model, messages) {
|
|
1088
|
+
if (!model.provider.startsWith("anthropic")) return messages;
|
|
1089
|
+
const mark = (message) => ({
|
|
1090
|
+
...message,
|
|
1091
|
+
experimental_providerMetadata: {
|
|
1092
|
+
...message.experimental_providerMetadata,
|
|
1093
|
+
anthropic: {
|
|
1094
|
+
...message.experimental_providerMetadata?.anthropic,
|
|
1095
|
+
cacheControl: { type: "ephemeral" }
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
const result = messages.slice();
|
|
1100
|
+
if (result[0]?.role === "system") result[0] = mark(result[0]);
|
|
1101
|
+
const last = result.length - 1;
|
|
1102
|
+
if (last > 0 && result[last]) result[last] = mark(result[last]);
|
|
1103
|
+
return result;
|
|
1104
|
+
}
|
|
1105
|
+
function extractCacheUsage(providerMetadata) {
|
|
1106
|
+
if (!providerMetadata || typeof providerMetadata !== "object") return {};
|
|
1107
|
+
for (const value of Object.values(providerMetadata)) {
|
|
1108
|
+
if (!value || typeof value !== "object") continue;
|
|
1109
|
+
const record = value;
|
|
1110
|
+
for (const key of CACHE_READ_KEYS) {
|
|
1111
|
+
const n = record[key];
|
|
1112
|
+
if (typeof n === "number" && Number.isFinite(n)) return { cacheReadInputTokens: n };
|
|
1113
|
+
}
|
|
1114
|
+
for (const key of CACHED_PROMPT_KEYS) {
|
|
1115
|
+
const n = record[key];
|
|
1116
|
+
if (typeof n === "number" && Number.isFinite(n)) return { cachedPromptTokens: n };
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return {};
|
|
1120
|
+
}
|
|
1121
|
+
async function* streamChat(opts) {
|
|
1122
|
+
const controller = new AbortController();
|
|
1123
|
+
const onAbort = () => controller.abort();
|
|
1124
|
+
opts.abortSignal?.addEventListener("abort", onAbort);
|
|
1125
|
+
if (opts.abortSignal?.aborted) controller.abort();
|
|
1126
|
+
const { openai: openaiMetadata, ...otherMetadata } = opts.providerMetadata ?? {};
|
|
1127
|
+
const result = streamText({
|
|
1128
|
+
model: opts.model,
|
|
1129
|
+
messages: withAnthropicCacheBreakpoints(opts.model, opts.messages),
|
|
1130
|
+
tools: opts.tools,
|
|
1131
|
+
abortSignal: controller.signal,
|
|
1132
|
+
maxTokens: opts.maxTokens,
|
|
1133
|
+
temperature: opts.temperature,
|
|
1134
|
+
// Disable the SDK's own retries (ai@4 defaults to 2 internal attempts with
|
|
1135
|
+
// fixed backoff that ignores Retry-After and the abort signal, and
|
|
1136
|
+
// exhaustion throws an AI_RetryError stripped of status/headers/body) —
|
|
1137
|
+
// the retry policy in agent/loop.ts + llm/retry.ts owns the full budget
|
|
1138
|
+
// and needs the raw error to classify.
|
|
1139
|
+
maxRetries: 0,
|
|
1140
|
+
// Pass tool-call argument deltas through (instead of buffering until the
|
|
1141
|
+
// complete call) so large write_file payloads report receive progress.
|
|
1142
|
+
toolCallStreaming: true,
|
|
1143
|
+
experimental_providerMetadata: {
|
|
1144
|
+
// Only the openai provider (our responses-protocol path) reads these;
|
|
1145
|
+
// other providers ignore unknown metadata. The SDK's responses provider
|
|
1146
|
+
// sends every tool with strict:true by default (strictSchemas), and
|
|
1147
|
+
// strict mode requires every schema property to be required — our
|
|
1148
|
+
// classic function-calling schemas use optional properties, which
|
|
1149
|
+
// relays then reject. store:false matches how codex and opencode talk
|
|
1150
|
+
// to relays (no server-side response storage).
|
|
1151
|
+
...otherMetadata,
|
|
1152
|
+
openai: { strictSchemas: false, store: false, ...openaiMetadata }
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
1156
|
+
const firstPartTimeoutMs = opts.firstPartTimeoutMs ?? DEFAULT_FIRST_PART_TIMEOUT_MS;
|
|
1157
|
+
const iterator = result.fullStream[Symbol.asyncIterator]();
|
|
1158
|
+
let seenContent = false;
|
|
1159
|
+
let deltas = 0;
|
|
1160
|
+
let hasContent = false;
|
|
1161
|
+
const argsProgress = /* @__PURE__ */ new Map();
|
|
1162
|
+
let lastPartAt = Date.now();
|
|
1163
|
+
debugStreamLog("request", {
|
|
1164
|
+
messages: opts.messages.length,
|
|
1165
|
+
idleTimeoutMs,
|
|
1166
|
+
firstPartTimeoutMs
|
|
1167
|
+
});
|
|
1168
|
+
try {
|
|
1169
|
+
for (; ; ) {
|
|
1170
|
+
let timer;
|
|
1171
|
+
const windowMs = seenContent ? idleTimeoutMs : firstPartTimeoutMs;
|
|
1172
|
+
const idle = new Promise((resolve) => {
|
|
1173
|
+
timer = setTimeout(() => resolve(null), windowMs);
|
|
1174
|
+
});
|
|
1175
|
+
let next;
|
|
1176
|
+
try {
|
|
1177
|
+
next = await Promise.race([iterator.next(), idle]);
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
debugStreamLog("stream-throw", {
|
|
1180
|
+
seenContent,
|
|
1181
|
+
deltas,
|
|
1182
|
+
error: error instanceof Error ? summarizeStreamError(error) : String(error)
|
|
1183
|
+
});
|
|
1184
|
+
throw error;
|
|
1185
|
+
} finally {
|
|
1186
|
+
if (timer) clearTimeout(timer);
|
|
1187
|
+
}
|
|
1188
|
+
if (next === null) {
|
|
1189
|
+
const activity = streamActivity(controller.signal);
|
|
1190
|
+
const silentForMs = Date.now() - lastPartAt;
|
|
1191
|
+
const lastChunkAgoMs = activity ? Date.now() - activity.lastChunkAt : void 0;
|
|
1192
|
+
if (lastChunkAgoMs !== void 0 && lastChunkAgoMs < windowMs && silentForMs < MAX_KEEPALIVE_SILENCE_MS) {
|
|
1193
|
+
debugStreamLog("keepalive", { seenContent, deltas, lastChunkAgoMs, silentForMs });
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
debugStreamLog("idle-timeout", {
|
|
1197
|
+
seenContent,
|
|
1198
|
+
deltas,
|
|
1199
|
+
waitedMs: windowMs,
|
|
1200
|
+
lastChunkAgoMs
|
|
1201
|
+
});
|
|
1202
|
+
yield {
|
|
1203
|
+
type: "finish",
|
|
1204
|
+
finishReason: "idle-timeout",
|
|
1205
|
+
usage: void 0,
|
|
1206
|
+
// Content already streamed means the reply may be cut off
|
|
1207
|
+
// mid-thought; the loop surfaces a notice for that.
|
|
1208
|
+
...hasContent ? { truncated: true } : {}
|
|
1209
|
+
};
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (next.done) {
|
|
1213
|
+
debugStreamLog("end", { seenContent, deltas });
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
const part = next.value;
|
|
1217
|
+
lastPartAt = Date.now();
|
|
1218
|
+
if (GENERATION_PART_TYPES.has(part.type)) seenContent = true;
|
|
1219
|
+
switch (part.type) {
|
|
1220
|
+
case "text-delta":
|
|
1221
|
+
deltas++;
|
|
1222
|
+
hasContent = true;
|
|
1223
|
+
yield { type: "text-delta", text: part.textDelta };
|
|
1224
|
+
break;
|
|
1225
|
+
case "reasoning":
|
|
1226
|
+
deltas++;
|
|
1227
|
+
yield { type: "reasoning", text: part.textDelta };
|
|
1228
|
+
break;
|
|
1229
|
+
case "tool-call":
|
|
1230
|
+
hasContent = true;
|
|
1231
|
+
yield {
|
|
1232
|
+
type: "tool-call",
|
|
1233
|
+
id: part.toolCallId,
|
|
1234
|
+
name: part.toolName,
|
|
1235
|
+
args: part.args
|
|
1236
|
+
};
|
|
1237
|
+
break;
|
|
1238
|
+
case "tool-call-streaming-start": {
|
|
1239
|
+
argsProgress.set(part.toolCallId, {
|
|
1240
|
+
name: part.toolName,
|
|
1241
|
+
chars: 0,
|
|
1242
|
+
nextMark: 4096
|
|
1243
|
+
});
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
case "tool-call-delta": {
|
|
1247
|
+
const progress = argsProgress.get(part.toolCallId) ?? {
|
|
1248
|
+
name: part.toolName,
|
|
1249
|
+
chars: 0,
|
|
1250
|
+
nextMark: 4096
|
|
1251
|
+
};
|
|
1252
|
+
progress.chars += part.argsTextDelta.length;
|
|
1253
|
+
argsProgress.set(part.toolCallId, progress);
|
|
1254
|
+
if (progress.chars >= progress.nextMark) {
|
|
1255
|
+
progress.nextMark *= 2;
|
|
1256
|
+
yield { type: "tool-call-progress", name: progress.name, bytes: progress.chars };
|
|
1257
|
+
}
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
case "finish": {
|
|
1261
|
+
const finite = (n) => Number.isFinite(n) ? n : 0;
|
|
1262
|
+
const cache4 = extractCacheUsage(
|
|
1263
|
+
part.providerMetadata ?? part.experimental_providerMetadata
|
|
1264
|
+
);
|
|
1265
|
+
debugStreamLog("finish", {
|
|
1266
|
+
finishReason: part.finishReason,
|
|
1267
|
+
usage: part.usage,
|
|
1268
|
+
deltas
|
|
1269
|
+
});
|
|
1270
|
+
yield {
|
|
1271
|
+
type: "finish",
|
|
1272
|
+
finishReason: part.finishReason,
|
|
1273
|
+
usage: part.usage ? {
|
|
1274
|
+
promptTokens: finite(part.usage.promptTokens),
|
|
1275
|
+
completionTokens: finite(part.usage.completionTokens),
|
|
1276
|
+
totalTokens: finite(part.usage.totalTokens),
|
|
1277
|
+
...cache4
|
|
1278
|
+
} : void 0
|
|
1279
|
+
};
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
case "error": {
|
|
1283
|
+
if (opts.abortSignal?.aborted) return;
|
|
1284
|
+
const error = part.error instanceof Error ? part.error : new Error(String(part.error));
|
|
1285
|
+
debugStreamLog("error-part", { seenContent, deltas, error: summarizeStreamError(error) });
|
|
1286
|
+
yield { type: "error", error };
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
} finally {
|
|
1292
|
+
opts.abortSignal?.removeEventListener("abort", onAbort);
|
|
1293
|
+
controller.abort();
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
692
1297
|
// src/permissions/gate.ts
|
|
693
1298
|
import fs from "fs";
|
|
694
|
-
import
|
|
1299
|
+
import path3 from "path";
|
|
695
1300
|
|
|
696
1301
|
// src/core/sensitive.ts
|
|
697
1302
|
var SENSITIVE_BASENAMES = /* @__PURE__ */ new Set([
|
|
@@ -734,6 +1339,239 @@ function isSensitivePath(p) {
|
|
|
734
1339
|
return false;
|
|
735
1340
|
}
|
|
736
1341
|
|
|
1342
|
+
// src/permissions/shell.ts
|
|
1343
|
+
var REDIRECT_OPS = /* @__PURE__ */ new Set([">", ">>", "<", ">&", "<&"]);
|
|
1344
|
+
var FILE_REDIRECT_OPS = /* @__PURE__ */ new Set([">", ">>", "<"]);
|
|
1345
|
+
var SEGMENT_SEPARATORS = /* @__PURE__ */ new Set([
|
|
1346
|
+
"&&",
|
|
1347
|
+
"||",
|
|
1348
|
+
"&",
|
|
1349
|
+
"|",
|
|
1350
|
+
";",
|
|
1351
|
+
"\n",
|
|
1352
|
+
"(",
|
|
1353
|
+
")"
|
|
1354
|
+
]);
|
|
1355
|
+
function lexShell(command) {
|
|
1356
|
+
const tokens = [];
|
|
1357
|
+
let word = "";
|
|
1358
|
+
let doubleQuoted = false;
|
|
1359
|
+
let i = 0;
|
|
1360
|
+
const flush = () => {
|
|
1361
|
+
if (word.length > 0) {
|
|
1362
|
+
tokens.push({ kind: "word", text: word });
|
|
1363
|
+
word = "";
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
const findCloseParen = (from) => {
|
|
1367
|
+
let depth = 0;
|
|
1368
|
+
let j = from;
|
|
1369
|
+
while (j < command.length) {
|
|
1370
|
+
const c = command[j];
|
|
1371
|
+
if (c === "\\") {
|
|
1372
|
+
j += 2;
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
if (c === "'" || c === '"') {
|
|
1376
|
+
const close = command.indexOf(c, j + 1);
|
|
1377
|
+
j = close === -1 ? command.length : close + 1;
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
if (c === "(") depth += 1;
|
|
1381
|
+
if (c === ")") {
|
|
1382
|
+
depth -= 1;
|
|
1383
|
+
if (depth === 0) return j + 1;
|
|
1384
|
+
}
|
|
1385
|
+
j += 1;
|
|
1386
|
+
}
|
|
1387
|
+
return -1;
|
|
1388
|
+
};
|
|
1389
|
+
const pushSubstitution = (openParen, process2) => {
|
|
1390
|
+
const close = findCloseParen(openParen);
|
|
1391
|
+
const innerEnd = close === -1 ? command.length : close - 1;
|
|
1392
|
+
flush();
|
|
1393
|
+
tokens.push({ kind: "subst", inner: command.slice(openParen + 1, innerEnd), process: process2 });
|
|
1394
|
+
return close === -1 ? command.length : close;
|
|
1395
|
+
};
|
|
1396
|
+
const lexDollarOrBacktick = (pos) => {
|
|
1397
|
+
const c = command[pos];
|
|
1398
|
+
if (c === "`") {
|
|
1399
|
+
const close = command.indexOf("`", pos + 1);
|
|
1400
|
+
const end = close === -1 ? command.length : close;
|
|
1401
|
+
flush();
|
|
1402
|
+
tokens.push({ kind: "subst", inner: command.slice(pos + 1, end), process: false });
|
|
1403
|
+
return close === -1 ? command.length : close + 1;
|
|
1404
|
+
}
|
|
1405
|
+
const next = command[pos + 1];
|
|
1406
|
+
if (next === "(") {
|
|
1407
|
+
return pushSubstitution(pos + 1, false);
|
|
1408
|
+
}
|
|
1409
|
+
if (next === "{") {
|
|
1410
|
+
const close = command.indexOf("}", pos + 2);
|
|
1411
|
+
if (close !== -1) {
|
|
1412
|
+
word += `$${command.slice(pos + 2, close)}`;
|
|
1413
|
+
return close + 1;
|
|
1414
|
+
}
|
|
1415
|
+
word += "$";
|
|
1416
|
+
return pos + 1;
|
|
1417
|
+
}
|
|
1418
|
+
if (next !== void 0 && /[A-Za-z_]/.test(next)) {
|
|
1419
|
+
let end = pos + 1;
|
|
1420
|
+
while (end < command.length && /[A-Za-z0-9_]/.test(command[end] ?? "")) {
|
|
1421
|
+
end += 1;
|
|
1422
|
+
}
|
|
1423
|
+
word += command.slice(pos, end);
|
|
1424
|
+
return end;
|
|
1425
|
+
}
|
|
1426
|
+
word += "$";
|
|
1427
|
+
return pos + 1;
|
|
1428
|
+
};
|
|
1429
|
+
while (i < command.length) {
|
|
1430
|
+
const c = command[i];
|
|
1431
|
+
if (doubleQuoted) {
|
|
1432
|
+
if (c === '"') {
|
|
1433
|
+
doubleQuoted = false;
|
|
1434
|
+
i += 1;
|
|
1435
|
+
} else if (c === "\\") {
|
|
1436
|
+
if (i + 1 < command.length) {
|
|
1437
|
+
word += command[i + 1];
|
|
1438
|
+
i += 2;
|
|
1439
|
+
} else {
|
|
1440
|
+
word += "\\";
|
|
1441
|
+
i += 1;
|
|
1442
|
+
}
|
|
1443
|
+
} else if (c === "$" || c === "`") {
|
|
1444
|
+
i = lexDollarOrBacktick(i);
|
|
1445
|
+
} else {
|
|
1446
|
+
word += c;
|
|
1447
|
+
i += 1;
|
|
1448
|
+
}
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
if (c === "\\") {
|
|
1452
|
+
if (i + 1 < command.length) {
|
|
1453
|
+
word += command[i + 1];
|
|
1454
|
+
i += 2;
|
|
1455
|
+
} else {
|
|
1456
|
+
word += "\\";
|
|
1457
|
+
i += 1;
|
|
1458
|
+
}
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (c === "'") {
|
|
1462
|
+
const close = command.indexOf("'", i + 1);
|
|
1463
|
+
const end = close === -1 ? command.length : close;
|
|
1464
|
+
word += command.slice(i + 1, end);
|
|
1465
|
+
i = close === -1 ? command.length : close + 1;
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
if (c === '"') {
|
|
1469
|
+
doubleQuoted = true;
|
|
1470
|
+
i += 1;
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (c === "$" || c === "`") {
|
|
1474
|
+
i = lexDollarOrBacktick(i);
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
if (c === " " || c === " " || c === "\r") {
|
|
1478
|
+
flush();
|
|
1479
|
+
i += 1;
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
if (c === "\n") {
|
|
1483
|
+
flush();
|
|
1484
|
+
tokens.push({ kind: "op", op: "\n" });
|
|
1485
|
+
i += 1;
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (c === "&" || c === "|") {
|
|
1489
|
+
flush();
|
|
1490
|
+
if (command[i + 1] === c) {
|
|
1491
|
+
tokens.push({ kind: "op", op: c === "&" ? "&&" : "||" });
|
|
1492
|
+
i += 2;
|
|
1493
|
+
} else {
|
|
1494
|
+
tokens.push({ kind: "op", op: c });
|
|
1495
|
+
i += 1;
|
|
1496
|
+
}
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
if (c === ";" || c === "(" || c === ")") {
|
|
1500
|
+
flush();
|
|
1501
|
+
tokens.push({ kind: "op", op: c });
|
|
1502
|
+
i += 1;
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
if (c === ">" || c === "<") {
|
|
1506
|
+
flush();
|
|
1507
|
+
const next = command[i + 1];
|
|
1508
|
+
if (next === "(") {
|
|
1509
|
+
i = pushSubstitution(i + 1, true);
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
if (c === ">" && next === ">") {
|
|
1513
|
+
tokens.push({ kind: "op", op: ">>" });
|
|
1514
|
+
i += 2;
|
|
1515
|
+
} else if (next === "&") {
|
|
1516
|
+
tokens.push({ kind: "op", op: c === ">" ? ">&" : "<&" });
|
|
1517
|
+
i += 2;
|
|
1518
|
+
} else {
|
|
1519
|
+
tokens.push({ kind: "op", op: c });
|
|
1520
|
+
i += 1;
|
|
1521
|
+
}
|
|
1522
|
+
continue;
|
|
1523
|
+
}
|
|
1524
|
+
word += c;
|
|
1525
|
+
i += 1;
|
|
1526
|
+
}
|
|
1527
|
+
flush();
|
|
1528
|
+
return tokens;
|
|
1529
|
+
}
|
|
1530
|
+
function splitShellSegments(tokens) {
|
|
1531
|
+
const segments = [];
|
|
1532
|
+
let current = [];
|
|
1533
|
+
for (const token of tokens) {
|
|
1534
|
+
if (token.kind === "op" && SEGMENT_SEPARATORS.has(token.op)) {
|
|
1535
|
+
if (current.length > 0) {
|
|
1536
|
+
segments.push(current);
|
|
1537
|
+
current = [];
|
|
1538
|
+
}
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
current.push(token);
|
|
1542
|
+
}
|
|
1543
|
+
if (current.length > 0) {
|
|
1544
|
+
segments.push(current);
|
|
1545
|
+
}
|
|
1546
|
+
return segments;
|
|
1547
|
+
}
|
|
1548
|
+
function segmentWords(segment) {
|
|
1549
|
+
const words = [];
|
|
1550
|
+
const redirectTargets = [];
|
|
1551
|
+
for (let k = 0; k < segment.length; k += 1) {
|
|
1552
|
+
const token = segment[k];
|
|
1553
|
+
if (token.kind === "op" && REDIRECT_OPS.has(token.op)) {
|
|
1554
|
+
const target = segment[k + 1];
|
|
1555
|
+
if (target?.kind === "word") {
|
|
1556
|
+
redirectTargets.push(target.text);
|
|
1557
|
+
k += 1;
|
|
1558
|
+
}
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
if (token.kind === "word") {
|
|
1562
|
+
words.push(token.text);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return { words, redirectTargets };
|
|
1566
|
+
}
|
|
1567
|
+
function renderSegment(segment) {
|
|
1568
|
+
return segment.map((token) => {
|
|
1569
|
+
if (token.kind === "word") return token.text;
|
|
1570
|
+
if (token.kind === "op") return token.op;
|
|
1571
|
+
return token.process ? ">(\u2026)" : "$(\u2026)";
|
|
1572
|
+
}).join(" ");
|
|
1573
|
+
}
|
|
1574
|
+
|
|
737
1575
|
// src/permissions/allow.ts
|
|
738
1576
|
function parseAllowRule(rule) {
|
|
739
1577
|
const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
|
|
@@ -759,7 +1597,15 @@ function matchAllowRule(rule, req) {
|
|
|
759
1597
|
}
|
|
760
1598
|
function splitCommandChain(command) {
|
|
761
1599
|
if (command.includes("$(") || command.includes("`")) return null;
|
|
762
|
-
|
|
1600
|
+
const tokens = lexShell(command);
|
|
1601
|
+
if (tokens.some((token) => token.kind === "subst")) return null;
|
|
1602
|
+
const segments = splitShellSegments(tokens);
|
|
1603
|
+
for (const segment of segments) {
|
|
1604
|
+
if (segment.some((token) => token.kind === "op" && FILE_REDIRECT_OPS.has(token.op))) {
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
return segments.map(renderSegment).filter((segment) => segment.length > 0);
|
|
763
1609
|
}
|
|
764
1610
|
function withCommand(req, command) {
|
|
765
1611
|
return {
|
|
@@ -796,27 +1642,206 @@ function buildAllowRule(req) {
|
|
|
796
1642
|
}
|
|
797
1643
|
|
|
798
1644
|
// src/permissions/gate.ts
|
|
799
|
-
var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "grep", "glob"]);
|
|
800
|
-
var
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
/
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1645
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "read_image", "grep", "glob"]);
|
|
1646
|
+
var FORK_BOMB = /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/;
|
|
1647
|
+
var MAX_ANALYSIS_DEPTH = 8;
|
|
1648
|
+
function baseName(word) {
|
|
1649
|
+
const base = word.replace(/\\/g, "/").split("/").pop() ?? word;
|
|
1650
|
+
return base.toLowerCase().replace(/\.exe$/, "");
|
|
1651
|
+
}
|
|
1652
|
+
function isDangerousDeleteTarget(target) {
|
|
1653
|
+
if (/^\/+$/.test(target)) return true;
|
|
1654
|
+
const norm = target.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
1655
|
+
if (norm === "/*" || norm === "*") return true;
|
|
1656
|
+
if (norm === "~" || norm.startsWith("~/")) return true;
|
|
1657
|
+
if (norm === "." || norm === "./*") return true;
|
|
1658
|
+
if (/^[a-zA-Z]:$/.test(norm)) return true;
|
|
1659
|
+
if (["$HOME", "$USERPROFILE", "$HOMEPATH"].includes(norm)) return true;
|
|
1660
|
+
if (/^%(USERPROFILE|HOMEDRIVE|HOMEPATH)%$/i.test(norm)) return true;
|
|
1661
|
+
return false;
|
|
1662
|
+
}
|
|
1663
|
+
function rmIsDangerous(args) {
|
|
1664
|
+
let recursive = false;
|
|
1665
|
+
let optionsDone = false;
|
|
1666
|
+
const targets = [];
|
|
1667
|
+
for (const arg of args) {
|
|
1668
|
+
if (!optionsDone && arg === "--") {
|
|
1669
|
+
optionsDone = true;
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
if (!optionsDone && arg.startsWith("-")) {
|
|
1673
|
+
if (arg === "--recursive") {
|
|
1674
|
+
recursive = true;
|
|
1675
|
+
} else if (!arg.startsWith("--") && /[rR]/.test(arg)) {
|
|
1676
|
+
recursive = true;
|
|
1677
|
+
}
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
targets.push(arg);
|
|
1681
|
+
}
|
|
1682
|
+
return recursive && targets.some(isDangerousDeleteTarget);
|
|
1683
|
+
}
|
|
1684
|
+
function findIsDangerous(args) {
|
|
1685
|
+
if (!args.includes("-delete")) return false;
|
|
1686
|
+
const paths = [];
|
|
1687
|
+
for (const arg of args) {
|
|
1688
|
+
if (arg.startsWith("-") || arg === "(" || arg === ")" || arg === "!" || arg === ",") {
|
|
1689
|
+
break;
|
|
1690
|
+
}
|
|
1691
|
+
paths.push(arg);
|
|
1692
|
+
}
|
|
1693
|
+
return paths.length === 0 || paths.some(isDangerousDeleteTarget);
|
|
1694
|
+
}
|
|
1695
|
+
var XARGS_VALUE_OPTS = /* @__PURE__ */ new Set([
|
|
1696
|
+
"-I",
|
|
1697
|
+
"-n",
|
|
1698
|
+
"-P",
|
|
1699
|
+
"-s",
|
|
1700
|
+
"-L",
|
|
1701
|
+
"-d",
|
|
1702
|
+
"-E",
|
|
1703
|
+
"-a",
|
|
1704
|
+
"--replace",
|
|
1705
|
+
"--max-args",
|
|
1706
|
+
"--max-procs",
|
|
1707
|
+
"--max-lines",
|
|
1708
|
+
"--delimiter",
|
|
1709
|
+
"--eof",
|
|
1710
|
+
"--arg-file",
|
|
1711
|
+
"--interactive",
|
|
1712
|
+
"--exit"
|
|
1713
|
+
]);
|
|
1714
|
+
function xargsIsDangerous(args) {
|
|
1715
|
+
for (let k = 0; k < args.length; k += 1) {
|
|
1716
|
+
const arg = args[k];
|
|
1717
|
+
if (XARGS_VALUE_OPTS.has(arg)) {
|
|
1718
|
+
k += 1;
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
if (arg.startsWith("-")) {
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
return baseName(arg) === "rm";
|
|
1725
|
+
}
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
function chmodIsDangerous(args) {
|
|
1729
|
+
let recursive = false;
|
|
1730
|
+
let optionsDone = false;
|
|
1731
|
+
const operands = [];
|
|
1732
|
+
for (const arg of args) {
|
|
1733
|
+
if (!optionsDone && arg === "--") {
|
|
1734
|
+
optionsDone = true;
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
if (!optionsDone && arg.startsWith("-")) {
|
|
1738
|
+
if (arg === "--recursive" || !arg.startsWith("--") && arg.includes("R")) {
|
|
1739
|
+
recursive = true;
|
|
1740
|
+
}
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
operands.push(arg);
|
|
1744
|
+
}
|
|
1745
|
+
return recursive && operands.slice(1).some(isDangerousDeleteTarget);
|
|
1746
|
+
}
|
|
1747
|
+
function commandInvocation(words) {
|
|
1748
|
+
let sudo = false;
|
|
1749
|
+
let wrapped = false;
|
|
1750
|
+
let k = 0;
|
|
1751
|
+
for (; ; ) {
|
|
1752
|
+
const word = words[k];
|
|
1753
|
+
if (word === void 0) return null;
|
|
1754
|
+
const lower = word.toLowerCase();
|
|
1755
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
1756
|
+
k += 1;
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
if (lower === "sudo" || lower === "doas" || lower === "env") {
|
|
1760
|
+
sudo = sudo || lower !== "env";
|
|
1761
|
+
wrapped = true;
|
|
1762
|
+
k += 1;
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
if (wrapped && word.startsWith("-")) {
|
|
1766
|
+
k += 1;
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
return { name: baseName(word), rest: words.slice(k + 1), sudo };
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
function commandLineIsDangerous(command, depth) {
|
|
1773
|
+
if (depth > MAX_ANALYSIS_DEPTH) return true;
|
|
1774
|
+
const tokens = lexShell(command);
|
|
1775
|
+
for (const token of tokens) {
|
|
1776
|
+
if (token.kind === "subst" && commandLineIsDangerous(token.inner, depth + 1)) {
|
|
1777
|
+
return true;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
return splitShellSegments(tokens).some((segment) => segmentIsDangerous(segment, depth));
|
|
1781
|
+
}
|
|
1782
|
+
function segmentIsDangerous(segment, depth) {
|
|
1783
|
+
const { words, redirectTargets } = segmentWords(segment);
|
|
1784
|
+
if (redirectTargets.some((target) => /^\/dev\/sd[a-z]/i.test(target))) {
|
|
1785
|
+
return true;
|
|
1786
|
+
}
|
|
1787
|
+
const invocation = commandInvocation(words);
|
|
1788
|
+
if (!invocation) return false;
|
|
1789
|
+
const { name, rest, sudo } = invocation;
|
|
1790
|
+
if (sudo && name === "rm") return true;
|
|
1791
|
+
switch (name) {
|
|
1792
|
+
case "shutdown":
|
|
1793
|
+
case "reboot":
|
|
1794
|
+
case "poweroff":
|
|
1795
|
+
case "halt":
|
|
1796
|
+
case "diskpart":
|
|
1797
|
+
case "wipefs":
|
|
1798
|
+
case "shred":
|
|
1799
|
+
return true;
|
|
1800
|
+
case "rm":
|
|
1801
|
+
return rmIsDangerous(rest);
|
|
1802
|
+
case "rmdir":
|
|
1803
|
+
case "rd":
|
|
1804
|
+
case "del":
|
|
1805
|
+
case "erase":
|
|
1806
|
+
return rest.some((arg) => /^\/s$/i.test(arg));
|
|
1807
|
+
case "format":
|
|
1808
|
+
return rest.some((arg) => /^[a-z]:$/i.test(arg));
|
|
1809
|
+
case "dd":
|
|
1810
|
+
return rest.some((arg) => arg.startsWith("if="));
|
|
1811
|
+
case "find":
|
|
1812
|
+
return findIsDangerous(rest);
|
|
1813
|
+
case "xargs":
|
|
1814
|
+
return xargsIsDangerous(rest);
|
|
1815
|
+
case "chmod":
|
|
1816
|
+
return chmodIsDangerous(rest);
|
|
1817
|
+
case "systemctl":
|
|
1818
|
+
return rest.some((arg) => ["poweroff", "halt", "reboot"].includes(arg.toLowerCase()));
|
|
1819
|
+
case "init":
|
|
1820
|
+
return rest.some((arg) => arg === "0" || arg === "6");
|
|
1821
|
+
case "sh":
|
|
1822
|
+
case "bash":
|
|
1823
|
+
case "zsh":
|
|
1824
|
+
case "dash":
|
|
1825
|
+
case "ash": {
|
|
1826
|
+
const idx = rest.indexOf("-c");
|
|
1827
|
+
const script = idx >= 0 ? rest[idx + 1] : void 0;
|
|
1828
|
+
return script !== void 0 && commandLineIsDangerous(script, depth + 1);
|
|
1829
|
+
}
|
|
1830
|
+
case "eval":
|
|
1831
|
+
return rest.length > 0 && commandLineIsDangerous(rest.join(" "), depth + 1);
|
|
1832
|
+
case "cmd": {
|
|
1833
|
+
const idx = rest.findIndex((arg) => /^\/c$/i.test(arg));
|
|
1834
|
+
if (idx < 0) return false;
|
|
1835
|
+
const script = rest.slice(idx + 1).join(" ");
|
|
1836
|
+
return script.length > 0 && commandLineIsDangerous(script, depth + 1);
|
|
1837
|
+
}
|
|
1838
|
+
default:
|
|
1839
|
+
return name.startsWith("mkfs") && (name.length === 4 || /[.-]/.test(name.charAt(4)));
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
818
1842
|
function isDangerousCommand(command) {
|
|
819
|
-
|
|
1843
|
+
if (FORK_BOMB.test(command)) return true;
|
|
1844
|
+
return commandLineIsDangerous(command, 0);
|
|
820
1845
|
}
|
|
821
1846
|
function getStringArg(args, key) {
|
|
822
1847
|
if (typeof args === "object" && args !== null) {
|
|
@@ -828,7 +1853,7 @@ function getStringArg(args, key) {
|
|
|
828
1853
|
return void 0;
|
|
829
1854
|
}
|
|
830
1855
|
function normalizeAbsolute(p, cwd) {
|
|
831
|
-
const abs =
|
|
1856
|
+
const abs = path3.isAbsolute(p) ? path3.normalize(p) : path3.resolve(cwd, p);
|
|
832
1857
|
return abs.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
833
1858
|
}
|
|
834
1859
|
function isOutsideCwd(p, cwd) {
|
|
@@ -837,20 +1862,20 @@ function isOutsideCwd(p, cwd) {
|
|
|
837
1862
|
return abs !== base && !abs.startsWith(`${base}/`);
|
|
838
1863
|
}
|
|
839
1864
|
function resolveRealPath(p, cwd) {
|
|
840
|
-
let current =
|
|
1865
|
+
let current = path3.isAbsolute(p) ? path3.normalize(p) : path3.resolve(cwd, p);
|
|
841
1866
|
const missing = [];
|
|
842
1867
|
for (; ; ) {
|
|
843
1868
|
try {
|
|
844
|
-
return
|
|
1869
|
+
return path3.join(fs.realpathSync(current), ...missing.reverse());
|
|
845
1870
|
} catch {
|
|
846
|
-
const parent =
|
|
1871
|
+
const parent = path3.dirname(current);
|
|
847
1872
|
if (parent === current) return null;
|
|
848
|
-
missing.push(
|
|
1873
|
+
missing.push(path3.basename(current));
|
|
849
1874
|
current = parent;
|
|
850
1875
|
}
|
|
851
1876
|
}
|
|
852
1877
|
}
|
|
853
|
-
function
|
|
1878
|
+
function isOutsideResolved(p, cwd) {
|
|
854
1879
|
const resolved = resolveRealPath(p, cwd);
|
|
855
1880
|
if (resolved === null) return isOutsideCwd(p, cwd);
|
|
856
1881
|
let realCwd;
|
|
@@ -875,8 +1900,7 @@ function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
|
|
|
875
1900
|
if (isWriteTool && isSensitivePath(filePath)) {
|
|
876
1901
|
return "deny";
|
|
877
1902
|
}
|
|
878
|
-
|
|
879
|
-
if (outside) {
|
|
1903
|
+
if (isOutsideResolved(filePath, ctx.cwd)) {
|
|
880
1904
|
if (mode === "ask" && req.level === "read") {
|
|
881
1905
|
return "ask";
|
|
882
1906
|
}
|
|
@@ -950,17 +1974,40 @@ function scheduleSessionTitle(store, userText, model) {
|
|
|
950
1974
|
// src/snapshot/git-tree.ts
|
|
951
1975
|
import { execFile } from "child_process";
|
|
952
1976
|
import { existsSync } from "fs";
|
|
953
|
-
import { mkdir } from "fs/promises";
|
|
954
|
-
import
|
|
955
|
-
import
|
|
1977
|
+
import { mkdir, readdir, rm, writeFile } from "fs/promises";
|
|
1978
|
+
import os2 from "os";
|
|
1979
|
+
import path4 from "path";
|
|
956
1980
|
var GIT_TIMEOUT_MS2 = 1e4;
|
|
1981
|
+
var GC_TIMEOUT_MS = 6e4;
|
|
957
1982
|
var GIT_MAX_BUFFER2 = 8 * 1024 * 1024;
|
|
958
|
-
|
|
1983
|
+
var GC_INTERVAL = 50;
|
|
1984
|
+
var TRACK_FAILURE_TTL_MS = 5 * 60 * 1e3;
|
|
1985
|
+
var SENSITIVE_EXCLUDE_PATTERNS = [
|
|
1986
|
+
".env",
|
|
1987
|
+
".env.*",
|
|
1988
|
+
"!.env.example",
|
|
1989
|
+
"!.env.sample",
|
|
1990
|
+
"!.env.template",
|
|
1991
|
+
"id_rsa",
|
|
1992
|
+
"id_dsa",
|
|
1993
|
+
"id_ecdsa",
|
|
1994
|
+
"id_ed25519",
|
|
1995
|
+
".npmrc",
|
|
1996
|
+
".netrc",
|
|
1997
|
+
".pgpass",
|
|
1998
|
+
"*.pem",
|
|
1999
|
+
"*.key",
|
|
2000
|
+
"*.p12",
|
|
2001
|
+
"*.pfx",
|
|
2002
|
+
"**/.aws/credentials",
|
|
2003
|
+
"**/.kube/config"
|
|
2004
|
+
];
|
|
2005
|
+
function runGit2(args, timeoutMs = GIT_TIMEOUT_MS2) {
|
|
959
2006
|
return new Promise((resolve) => {
|
|
960
2007
|
execFile(
|
|
961
2008
|
"git",
|
|
962
2009
|
args,
|
|
963
|
-
{ timeout:
|
|
2010
|
+
{ timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER2, encoding: "utf8" },
|
|
964
2011
|
(error, stdout) => {
|
|
965
2012
|
resolve(error ? null : stdout.trim());
|
|
966
2013
|
}
|
|
@@ -968,6 +2015,9 @@ function runGit2(args) {
|
|
|
968
2015
|
});
|
|
969
2016
|
}
|
|
970
2017
|
var gitAvailable = null;
|
|
2018
|
+
var trackFailureUntil = /* @__PURE__ */ new Map();
|
|
2019
|
+
var trackCounts = /* @__PURE__ */ new Map();
|
|
2020
|
+
var scrubbedRepos = /* @__PURE__ */ new Set();
|
|
971
2021
|
async function isGitAvailable() {
|
|
972
2022
|
if (gitAvailable === null) {
|
|
973
2023
|
gitAvailable = await runGit2(["--version"]) !== null;
|
|
@@ -975,41 +2025,119 @@ async function isGitAvailable() {
|
|
|
975
2025
|
return gitAvailable;
|
|
976
2026
|
}
|
|
977
2027
|
function treeRepoDir(cwd) {
|
|
978
|
-
const key = Buffer.from(
|
|
979
|
-
return
|
|
2028
|
+
const key = Buffer.from(path4.resolve(cwd), "utf8").toString("base64url");
|
|
2029
|
+
return path4.join(gitTreesDir(), key);
|
|
2030
|
+
}
|
|
2031
|
+
async function removeTreeRepo(cwd) {
|
|
2032
|
+
const dir = treeRepoDir(cwd);
|
|
2033
|
+
trackFailureUntil.delete(path4.resolve(cwd));
|
|
2034
|
+
trackCounts.delete(dir);
|
|
2035
|
+
scrubbedRepos.delete(dir);
|
|
2036
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
async function pruneTreeReposExcept(remainingCwds) {
|
|
2040
|
+
let entries;
|
|
2041
|
+
try {
|
|
2042
|
+
entries = await readdir(gitTreesDir());
|
|
2043
|
+
} catch {
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
const keep = /* @__PURE__ */ new Set();
|
|
2047
|
+
for (const cwd of remainingCwds) keep.add(path4.basename(treeRepoDir(cwd)));
|
|
2048
|
+
await Promise.all(
|
|
2049
|
+
entries.filter((entry) => !keep.has(entry)).map(
|
|
2050
|
+
(entry) => rm(path4.join(gitTreesDir(), entry), { recursive: true, force: true }).catch(() => {
|
|
2051
|
+
})
|
|
2052
|
+
)
|
|
2053
|
+
);
|
|
980
2054
|
}
|
|
981
2055
|
function isGuardedDir(cwd) {
|
|
982
|
-
const resolved =
|
|
983
|
-
const home =
|
|
2056
|
+
const resolved = path4.resolve(cwd);
|
|
2057
|
+
const home = path4.resolve(os2.homedir());
|
|
984
2058
|
const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
985
|
-
return same(resolved, home) || same(resolved,
|
|
2059
|
+
return same(resolved, home) || same(resolved, path4.parse(resolved).root);
|
|
986
2060
|
}
|
|
987
2061
|
async function ensureTreeRepo(cwd) {
|
|
988
2062
|
const dir = treeRepoDir(cwd);
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
2063
|
+
try {
|
|
2064
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
2065
|
+
if (!existsSync(path4.join(dir, "HEAD"))) {
|
|
2066
|
+
const out = await runGit2(["init", "--bare", dir]);
|
|
2067
|
+
if (out === null) return null;
|
|
2068
|
+
}
|
|
2069
|
+
await mkdir(path4.join(dir, "info"), { recursive: true });
|
|
2070
|
+
await writeFile(
|
|
2071
|
+
path4.join(dir, "info", "exclude"),
|
|
2072
|
+
`${SENSITIVE_EXCLUDE_PATTERNS.join("\n")}
|
|
2073
|
+
`,
|
|
2074
|
+
"utf8"
|
|
2075
|
+
);
|
|
2076
|
+
} catch {
|
|
2077
|
+
return null;
|
|
2078
|
+
}
|
|
2079
|
+
return dir;
|
|
993
2080
|
}
|
|
994
2081
|
function repoArgs(dir, cwd) {
|
|
995
2082
|
return [`--git-dir=${dir}`, `--work-tree=${cwd}`];
|
|
996
2083
|
}
|
|
2084
|
+
async function untrackExcluded(dir, cwd) {
|
|
2085
|
+
const base = repoArgs(dir, cwd);
|
|
2086
|
+
const listed = await runGit2([...base, "ls-files", "-z", "-c", "-i", "--exclude-standard"]);
|
|
2087
|
+
if (listed === null) return false;
|
|
2088
|
+
const files = listed.split("\0").filter(Boolean);
|
|
2089
|
+
if (files.length === 0) return true;
|
|
2090
|
+
const removed = await runGit2([
|
|
2091
|
+
...base,
|
|
2092
|
+
"rm",
|
|
2093
|
+
"--cached",
|
|
2094
|
+
"--ignore-unmatch",
|
|
2095
|
+
"-q",
|
|
2096
|
+
"--",
|
|
2097
|
+
...files.map((file) => `:(literal)${file}`)
|
|
2098
|
+
]);
|
|
2099
|
+
return removed !== null;
|
|
2100
|
+
}
|
|
997
2101
|
async function trackTree(cwd) {
|
|
998
2102
|
if (isGuardedDir(cwd)) return null;
|
|
2103
|
+
const key = path4.resolve(cwd);
|
|
2104
|
+
const failedUntil = trackFailureUntil.get(key);
|
|
2105
|
+
if (failedUntil !== void 0 && Date.now() < failedUntil) return null;
|
|
2106
|
+
const tree = await trackTreeUncached(cwd);
|
|
2107
|
+
if (tree === null) {
|
|
2108
|
+
trackFailureUntil.set(key, Date.now() + TRACK_FAILURE_TTL_MS);
|
|
2109
|
+
return null;
|
|
2110
|
+
}
|
|
2111
|
+
trackFailureUntil.delete(key);
|
|
2112
|
+
return tree;
|
|
2113
|
+
}
|
|
2114
|
+
async function trackTreeUncached(cwd) {
|
|
999
2115
|
if (!await isGitAvailable()) return null;
|
|
1000
2116
|
const dir = await ensureTreeRepo(cwd);
|
|
1001
2117
|
if (!dir) return null;
|
|
1002
2118
|
const base = repoArgs(dir, cwd);
|
|
2119
|
+
if (!scrubbedRepos.has(dir)) {
|
|
2120
|
+
if (!await untrackExcluded(dir, cwd)) return null;
|
|
2121
|
+
scrubbedRepos.add(dir);
|
|
2122
|
+
}
|
|
1003
2123
|
if (await runGit2([...base, "add", "-A"]) === null) return null;
|
|
1004
|
-
|
|
2124
|
+
const tree = await runGit2([...base, "write-tree"]);
|
|
2125
|
+
if (tree === null) return null;
|
|
2126
|
+
const count = (trackCounts.get(dir) ?? 0) + 1;
|
|
2127
|
+
trackCounts.set(dir, count);
|
|
2128
|
+
if (count % GC_INTERVAL === 0) {
|
|
2129
|
+
await runGit2([`--git-dir=${dir}`, "gc", "--auto"], GC_TIMEOUT_MS);
|
|
2130
|
+
}
|
|
2131
|
+
return tree;
|
|
1005
2132
|
}
|
|
1006
2133
|
async function restoreTree(cwd, hash) {
|
|
1007
2134
|
if (isGuardedDir(cwd)) return false;
|
|
1008
2135
|
if (!await isGitAvailable()) return false;
|
|
1009
|
-
const dir =
|
|
1010
|
-
if (!
|
|
2136
|
+
const dir = await ensureTreeRepo(cwd);
|
|
2137
|
+
if (!dir) return false;
|
|
1011
2138
|
const base = repoArgs(dir, cwd);
|
|
1012
2139
|
if (await runGit2([...base, "read-tree", hash]) === null) return false;
|
|
2140
|
+
if (!await untrackExcluded(dir, cwd)) return false;
|
|
1013
2141
|
if (await runGit2([...base, "checkout-index", "-f", "-a"]) === null) return false;
|
|
1014
2142
|
if (await runGit2([...base, "clean", "-fd"]) === null) return false;
|
|
1015
2143
|
return true;
|
|
@@ -1017,21 +2145,23 @@ async function restoreTree(cwd, hash) {
|
|
|
1017
2145
|
async function diffTreeNames(cwd, hash) {
|
|
1018
2146
|
if (!await isGitAvailable()) return null;
|
|
1019
2147
|
const dir = treeRepoDir(cwd);
|
|
1020
|
-
if (!existsSync(
|
|
2148
|
+
if (!existsSync(path4.join(dir, "HEAD"))) return null;
|
|
1021
2149
|
const base = repoArgs(dir, cwd);
|
|
1022
2150
|
const changed = await runGit2([...base, "diff", "--name-only", hash]);
|
|
1023
2151
|
const untracked = await runGit2([...base, "ls-files", "--others", "--exclude-standard"]);
|
|
1024
2152
|
if (changed === null || untracked === null) return null;
|
|
1025
2153
|
const names = /* @__PURE__ */ new Set();
|
|
1026
2154
|
for (const line of [...changed.split("\n"), ...untracked.split("\n")]) {
|
|
1027
|
-
if (line !== "") names.add(line);
|
|
2155
|
+
if (line !== "" && !isSensitivePath(line)) names.add(line);
|
|
1028
2156
|
}
|
|
1029
2157
|
return [...names];
|
|
1030
2158
|
}
|
|
1031
2159
|
|
|
1032
2160
|
// src/tools/fs/snapshots.ts
|
|
1033
|
-
import {
|
|
1034
|
-
import
|
|
2161
|
+
import { randomUUID } from "crypto";
|
|
2162
|
+
import { mkdir as mkdir2, readFile, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
|
|
2163
|
+
import os3 from "os";
|
|
2164
|
+
import path5 from "path";
|
|
1035
2165
|
var MAX_SNAPSHOTS = 50;
|
|
1036
2166
|
var MAX_SNAPSHOT_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
1037
2167
|
var stack = [];
|
|
@@ -1039,6 +2169,30 @@ var activeTurn = 0;
|
|
|
1039
2169
|
var activeTurnMessageIndex = -1;
|
|
1040
2170
|
var checkpointSeq = 0;
|
|
1041
2171
|
var hooks = null;
|
|
2172
|
+
var spillDir = path5.join(
|
|
2173
|
+
os3.tmpdir(),
|
|
2174
|
+
`star-cli-snapshots-${process.pid}-${randomUUID().slice(0, 8)}`
|
|
2175
|
+
);
|
|
2176
|
+
function isSpillPath(p) {
|
|
2177
|
+
return p.startsWith(spillDir + path5.sep);
|
|
2178
|
+
}
|
|
2179
|
+
async function spillContent(snapshot) {
|
|
2180
|
+
if (snapshot.content === null || snapshot.contentFile) return;
|
|
2181
|
+
try {
|
|
2182
|
+
const file = path5.join(spillDir, `${snapshot.id}.snap`);
|
|
2183
|
+
await mkdir2(spillDir, { recursive: true, mode: 448 });
|
|
2184
|
+
await writeFile2(file, snapshot.content, { encoding: "utf8", mode: 384 });
|
|
2185
|
+
snapshot.contentFile = file;
|
|
2186
|
+
snapshot.content = null;
|
|
2187
|
+
} catch {
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
async function discardSpill(snapshot) {
|
|
2191
|
+
if (snapshot.contentFile && isSpillPath(snapshot.contentFile)) {
|
|
2192
|
+
await rm2(snapshot.contentFile, { force: true }).catch(() => {
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
1042
2196
|
function setSnapshotHooks(next) {
|
|
1043
2197
|
hooks = next;
|
|
1044
2198
|
}
|
|
@@ -1097,8 +2251,10 @@ async function pushSnapshot(snapshot) {
|
|
|
1097
2251
|
console.error("[star] failed to persist snapshot checkpoint:", err);
|
|
1098
2252
|
}
|
|
1099
2253
|
}
|
|
2254
|
+
await spillContent(snapshot);
|
|
1100
2255
|
if (stack.length > MAX_SNAPSHOTS) {
|
|
1101
2256
|
const evicted = stack.splice(0, stack.length - MAX_SNAPSHOTS);
|
|
2257
|
+
for (const old of evicted) await discardSpill(old);
|
|
1102
2258
|
await removeFromStore(evicted.map((s) => s.id));
|
|
1103
2259
|
}
|
|
1104
2260
|
}
|
|
@@ -1109,9 +2265,11 @@ function listSnapshots() {
|
|
|
1109
2265
|
return [...stack];
|
|
1110
2266
|
}
|
|
1111
2267
|
function clearSnapshots() {
|
|
2268
|
+
for (const snapshot of stack) void discardSpill(snapshot);
|
|
1112
2269
|
stack.length = 0;
|
|
1113
2270
|
}
|
|
1114
2271
|
function hydrateSnapshots(snapshots) {
|
|
2272
|
+
for (const snapshot of stack) void discardSpill(snapshot);
|
|
1115
2273
|
stack.length = 0;
|
|
1116
2274
|
stack.push(...snapshots.slice(-MAX_SNAPSHOTS));
|
|
1117
2275
|
for (const snapshot of stack) {
|
|
@@ -1136,11 +2294,11 @@ async function revert(snapshot) {
|
|
|
1136
2294
|
if (content === null) {
|
|
1137
2295
|
return `Skipped ${snapshot.path}: the snapshot content is no longer available.`;
|
|
1138
2296
|
}
|
|
1139
|
-
await mkdir2(
|
|
1140
|
-
await
|
|
2297
|
+
await mkdir2(path5.dirname(snapshot.path), { recursive: true });
|
|
2298
|
+
await writeFile2(snapshot.path, content, "utf8");
|
|
1141
2299
|
return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
|
|
1142
2300
|
}
|
|
1143
|
-
await
|
|
2301
|
+
await rm2(snapshot.path, { force: true });
|
|
1144
2302
|
return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
|
|
1145
2303
|
}
|
|
1146
2304
|
function listTurnSnapshots(turn, includeSubagent = false) {
|
|
@@ -1167,6 +2325,7 @@ async function undoTurnSnapshots(turn, includeSubagent = false) {
|
|
|
1167
2325
|
const idx = stack.indexOf(snapshot);
|
|
1168
2326
|
if (idx >= 0) stack.splice(idx, 1);
|
|
1169
2327
|
revertedIds.push(snapshot.id);
|
|
2328
|
+
await discardSpill(snapshot);
|
|
1170
2329
|
} catch (err) {
|
|
1171
2330
|
messages.push(
|
|
1172
2331
|
`Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
|
|
@@ -1178,13 +2337,16 @@ async function undoTurnSnapshots(turn, includeSubagent = false) {
|
|
|
1178
2337
|
}
|
|
1179
2338
|
async function dropTurnSnapshots(turn, includeSubagent = false) {
|
|
1180
2339
|
const dropped = [];
|
|
2340
|
+
const droppedSnapshots = [];
|
|
1181
2341
|
for (let i = stack.length - 1; i >= 0; i--) {
|
|
1182
2342
|
const snapshot = stack[i];
|
|
1183
2343
|
if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
|
|
1184
2344
|
dropped.push(snapshot.id);
|
|
2345
|
+
droppedSnapshots.push(snapshot);
|
|
1185
2346
|
stack.splice(i, 1);
|
|
1186
2347
|
}
|
|
1187
2348
|
}
|
|
2349
|
+
for (const snapshot of droppedSnapshots) await discardSpill(snapshot);
|
|
1188
2350
|
await removeFromStore(dropped);
|
|
1189
2351
|
return dropped.length;
|
|
1190
2352
|
}
|
|
@@ -1206,6 +2368,7 @@ async function rewindToSnapshot(id, includeSubagent = false) {
|
|
|
1206
2368
|
const idx = stack.indexOf(snapshot);
|
|
1207
2369
|
if (idx >= 0) stack.splice(idx, 1);
|
|
1208
2370
|
revertedIds.push(snapshot.id);
|
|
2371
|
+
await discardSpill(snapshot);
|
|
1209
2372
|
if (snapshot.messageIndex >= 0) {
|
|
1210
2373
|
messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
|
|
1211
2374
|
}
|
|
@@ -1222,7 +2385,7 @@ async function rewindToSnapshot(id, includeSubagent = false) {
|
|
|
1222
2385
|
// src/tools/bash.ts
|
|
1223
2386
|
import { spawn as spawn3 } from "child_process";
|
|
1224
2387
|
import { existsSync as existsSync2 } from "fs";
|
|
1225
|
-
import
|
|
2388
|
+
import path6 from "path";
|
|
1226
2389
|
import { z as z2 } from "zod";
|
|
1227
2390
|
|
|
1228
2391
|
// src/tasks/manager.ts
|
|
@@ -1241,6 +2404,9 @@ var TaskManager = class extends EventEmitter {
|
|
|
1241
2404
|
const child = spawn2(spec.shell, spec.wrap(opts.command), {
|
|
1242
2405
|
cwd: opts.cwd,
|
|
1243
2406
|
windowsHide: true,
|
|
2407
|
+
// No stdin: background commands that read it would hang until the
|
|
2408
|
+
// (much longer) task timeout; give them EOF immediately.
|
|
2409
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1244
2410
|
// POSIX: the child leads its own process group so killTree can SIGKILL
|
|
1245
2411
|
// the whole tree with a negative pid (Windows uses taskkill /t).
|
|
1246
2412
|
detached: process.platform !== "win32"
|
|
@@ -1356,11 +2522,11 @@ var HALF_OUTPUT = MAX_OUTPUT / 2;
|
|
|
1356
2522
|
var DEFAULT_TIMEOUT = 120;
|
|
1357
2523
|
var MAX_TIMEOUT = 600;
|
|
1358
2524
|
function findOnPath(exe, exclude) {
|
|
1359
|
-
for (const dir of (process.env.PATH ?? "").split(
|
|
2525
|
+
for (const dir of (process.env.PATH ?? "").split(path6.delimiter)) {
|
|
1360
2526
|
if (!dir || exclude?.(dir)) {
|
|
1361
2527
|
continue;
|
|
1362
2528
|
}
|
|
1363
|
-
const full =
|
|
2529
|
+
const full = path6.join(dir, exe);
|
|
1364
2530
|
if (existsSync2(full)) {
|
|
1365
2531
|
return full;
|
|
1366
2532
|
}
|
|
@@ -1378,16 +2544,22 @@ function resolveShellUncached() {
|
|
|
1378
2544
|
}
|
|
1379
2545
|
const gitExe = findOnPath("git.exe");
|
|
1380
2546
|
if (gitExe) {
|
|
1381
|
-
const sibling =
|
|
2547
|
+
const sibling = path6.join(path6.dirname(path6.dirname(gitExe)), "bin", "bash.exe");
|
|
1382
2548
|
if (existsSync2(sibling)) {
|
|
1383
2549
|
return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
|
|
1384
2550
|
}
|
|
1385
2551
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
2552
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2553
|
+
for (const root of [
|
|
2554
|
+
process.env.ProgramFiles,
|
|
2555
|
+
process.env["ProgramFiles(x86)"],
|
|
2556
|
+
process.env.ProgramW6432
|
|
1390
2557
|
]) {
|
|
2558
|
+
if (!root) continue;
|
|
2559
|
+
const candidate = path6.join(root, "Git", "bin", "bash.exe");
|
|
2560
|
+
const key = candidate.toLowerCase();
|
|
2561
|
+
if (seen.has(key)) continue;
|
|
2562
|
+
seen.add(key);
|
|
1391
2563
|
if (existsSync2(candidate)) {
|
|
1392
2564
|
return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
|
|
1393
2565
|
}
|
|
@@ -1489,7 +2661,10 @@ description: ${args.description ?? "(none)"}`
|
|
|
1489
2661
|
const child = spawn3(spec.shell, spec.wrap(args.command), {
|
|
1490
2662
|
cwd: ctx.cwd,
|
|
1491
2663
|
windowsHide: true,
|
|
1492
|
-
detached: process.platform !== "win32"
|
|
2664
|
+
detached: process.platform !== "win32",
|
|
2665
|
+
// No stdin: a command that reads it (cat, read, input()) must get
|
|
2666
|
+
// EOF immediately instead of hanging until the timeout.
|
|
2667
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1493
2668
|
});
|
|
1494
2669
|
const output = new BoundedOutput();
|
|
1495
2670
|
let settled = false;
|
|
@@ -1538,9 +2713,10 @@ Command timed out after ${timeoutSeconds}s`,
|
|
|
1538
2713
|
};
|
|
1539
2714
|
|
|
1540
2715
|
// src/tools/fs/edit.ts
|
|
1541
|
-
import { readFile as readFile2, stat as stat2, writeFile as
|
|
1542
|
-
import
|
|
2716
|
+
import { readFile as readFile2, stat as stat2, writeFile as writeFile3 } from "fs/promises";
|
|
2717
|
+
import path7 from "path";
|
|
1543
2718
|
import { z as z3 } from "zod";
|
|
2719
|
+
var BINARY_PROBE_BYTES = 8192;
|
|
1544
2720
|
var schema2 = z3.object({
|
|
1545
2721
|
path: z3.string().describe("File path, absolute or relative to the working directory"),
|
|
1546
2722
|
old_string: z3.string().min(1).describe("Exact text to replace"),
|
|
@@ -1549,16 +2725,27 @@ var schema2 = z3.object({
|
|
|
1549
2725
|
});
|
|
1550
2726
|
var editFileTool = {
|
|
1551
2727
|
name: "edit_file",
|
|
1552
|
-
description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set.",
|
|
2728
|
+
description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set. Refuses binary files and files over 5MB.",
|
|
1553
2729
|
permission: "write",
|
|
1554
2730
|
parameters: schema2,
|
|
1555
2731
|
async execute(args, ctx) {
|
|
1556
|
-
const filePath =
|
|
2732
|
+
const filePath = path7.resolve(ctx.cwd, args.path);
|
|
1557
2733
|
let content;
|
|
1558
2734
|
let readMtimeMs;
|
|
1559
2735
|
try {
|
|
1560
|
-
|
|
1561
|
-
|
|
2736
|
+
const st = await stat2(filePath);
|
|
2737
|
+
if (st.size > MAX_SNAPSHOT_CONTENT_BYTES) {
|
|
2738
|
+
return {
|
|
2739
|
+
content: `Refused to edit ${args.path}: file is ${st.size} bytes, over the 5MB limit (edits past it could not be restored by /undo)`,
|
|
2740
|
+
isError: true
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
readMtimeMs = st.mtimeMs;
|
|
2744
|
+
const buf = await readFile2(filePath);
|
|
2745
|
+
if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
|
|
2746
|
+
return { content: `Refused to edit binary file: ${args.path}`, isError: true };
|
|
2747
|
+
}
|
|
2748
|
+
content = buf.toString("utf8");
|
|
1562
2749
|
} catch (err) {
|
|
1563
2750
|
return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
|
|
1564
2751
|
}
|
|
@@ -1586,7 +2773,7 @@ var editFileTool = {
|
|
|
1586
2773
|
isError: true
|
|
1587
2774
|
};
|
|
1588
2775
|
}
|
|
1589
|
-
await
|
|
2776
|
+
await writeFile3(filePath, updated, "utf8");
|
|
1590
2777
|
} catch (err) {
|
|
1591
2778
|
return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
|
|
1592
2779
|
}
|
|
@@ -1608,23 +2795,23 @@ var editFileTool = {
|
|
|
1608
2795
|
};
|
|
1609
2796
|
|
|
1610
2797
|
// src/tools/fs/glob.ts
|
|
1611
|
-
import
|
|
2798
|
+
import path9 from "path";
|
|
1612
2799
|
import { z as z4 } from "zod";
|
|
1613
2800
|
|
|
1614
2801
|
// src/tools/fs/util.ts
|
|
1615
2802
|
import { readFileSync, statSync } from "fs";
|
|
1616
|
-
import { readdir, stat as stat3 } from "fs/promises";
|
|
1617
|
-
import
|
|
2803
|
+
import { readdir as readdir2, stat as stat3 } from "fs/promises";
|
|
2804
|
+
import path8 from "path";
|
|
1618
2805
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
|
|
1619
2806
|
async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
|
|
1620
2807
|
const out = [];
|
|
1621
2808
|
async function walk(dir, relBase) {
|
|
1622
|
-
const entries = await
|
|
2809
|
+
const entries = await readdir2(dir, { withFileTypes: true }).catch(() => null);
|
|
1623
2810
|
if (!entries) {
|
|
1624
2811
|
return;
|
|
1625
2812
|
}
|
|
1626
2813
|
for (const entry of entries) {
|
|
1627
|
-
const abs =
|
|
2814
|
+
const abs = path8.join(dir, entry.name);
|
|
1628
2815
|
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1629
2816
|
if (entry.isDirectory()) {
|
|
1630
2817
|
if (skipDirs.has(entry.name) || options.ignore?.(abs, true)) {
|
|
@@ -1783,7 +2970,7 @@ function parseIgnorePatterns(body) {
|
|
|
1783
2970
|
return out;
|
|
1784
2971
|
}
|
|
1785
2972
|
function loadIgnorePatterns(dir) {
|
|
1786
|
-
const file =
|
|
2973
|
+
const file = path8.join(dir, IGNORE_FILE);
|
|
1787
2974
|
let mtimeMs;
|
|
1788
2975
|
try {
|
|
1789
2976
|
mtimeMs = statSync(file).mtimeMs;
|
|
@@ -1808,9 +2995,9 @@ function createIgnorePredicate(cwd) {
|
|
|
1808
2995
|
if (patterns.length === 0) {
|
|
1809
2996
|
return void 0;
|
|
1810
2997
|
}
|
|
1811
|
-
const root =
|
|
2998
|
+
const root = path8.resolve(cwd);
|
|
1812
2999
|
return (absPath, isDir) => {
|
|
1813
|
-
const rel =
|
|
3000
|
+
const rel = path8.relative(root, absPath).split(path8.sep).join("/");
|
|
1814
3001
|
if (!rel || rel.startsWith("..")) {
|
|
1815
3002
|
return false;
|
|
1816
3003
|
}
|
|
@@ -1846,15 +3033,24 @@ var schema3 = z4.object({
|
|
|
1846
3033
|
),
|
|
1847
3034
|
path: z4.string().optional().describe("Directory to search, defaults to the working directory")
|
|
1848
3035
|
});
|
|
3036
|
+
function hasParentSegment(pattern) {
|
|
3037
|
+
return pattern.replace(/\\/g, "/").split("/").some((segment) => segment === "..");
|
|
3038
|
+
}
|
|
1849
3039
|
var globTool = {
|
|
1850
3040
|
name: "glob",
|
|
1851
3041
|
description: "Find files by glob pattern. Skips node_modules, .git and dist. Returns paths sorted by modification time, newest first, up to 100 results.",
|
|
1852
3042
|
permission: "read",
|
|
1853
3043
|
parameters: schema3,
|
|
1854
3044
|
async execute(args, ctx) {
|
|
1855
|
-
|
|
3045
|
+
if (hasParentSegment(args.pattern)) {
|
|
3046
|
+
return {
|
|
3047
|
+
content: `Refused glob pattern with '..' segment: ${args.pattern}`,
|
|
3048
|
+
isError: true
|
|
3049
|
+
};
|
|
3050
|
+
}
|
|
3051
|
+
const root = path9.resolve(ctx.cwd, args.path ?? ".");
|
|
1856
3052
|
const prefixSegments = literalDirPrefix(args.pattern);
|
|
1857
|
-
const walkRoot = prefixSegments.length > 0 ?
|
|
3053
|
+
const walkRoot = prefixSegments.length > 0 ? path9.join(root, ...prefixSegments) : root;
|
|
1858
3054
|
const prefix = prefixSegments.length > 0 ? `${prefixSegments.join("/")}/` : "";
|
|
1859
3055
|
const files = await walkFiles(walkRoot, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
|
|
1860
3056
|
const matched = files.filter((f) => matchesGlob(args.pattern, `${prefix}${f.rel}`)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
|
|
@@ -1866,8 +3062,8 @@ var globTool = {
|
|
|
1866
3062
|
};
|
|
1867
3063
|
|
|
1868
3064
|
// src/tools/fs/grep.ts
|
|
1869
|
-
import { readFile as readFile3, stat as stat4 } from "fs/promises";
|
|
1870
|
-
import
|
|
3065
|
+
import { readFile as readFile3, realpath, stat as stat4 } from "fs/promises";
|
|
3066
|
+
import path10 from "path";
|
|
1871
3067
|
import { z as z5 } from "zod";
|
|
1872
3068
|
var MAX_MATCHES = 250;
|
|
1873
3069
|
var MAX_LINE_CHARS = 500;
|
|
@@ -1900,18 +3096,19 @@ var grepTool = {
|
|
|
1900
3096
|
} catch (err) {
|
|
1901
3097
|
return { content: `Invalid regular expression: ${err.message}`, isError: true };
|
|
1902
3098
|
}
|
|
1903
|
-
const root =
|
|
3099
|
+
const root = path10.resolve(ctx.cwd, args.path ?? ".");
|
|
1904
3100
|
let files;
|
|
1905
3101
|
try {
|
|
1906
3102
|
const st = await stat4(root);
|
|
1907
3103
|
if (st.isFile()) {
|
|
1908
|
-
|
|
3104
|
+
const resolved = await realpath(root).catch(() => null);
|
|
3105
|
+
if (isSensitivePath(root) || resolved !== null && isSensitivePath(resolved)) {
|
|
1909
3106
|
return {
|
|
1910
3107
|
content: `Refused to grep sensitive file: ${args.path ?? "."}`,
|
|
1911
3108
|
isError: true
|
|
1912
3109
|
};
|
|
1913
3110
|
}
|
|
1914
|
-
files = [{ abs: root, rel:
|
|
3111
|
+
files = [{ abs: root, rel: path10.basename(root), mtimeMs: st.mtimeMs }];
|
|
1915
3112
|
} else {
|
|
1916
3113
|
const walked = await walkFiles(root, void 0, {
|
|
1917
3114
|
ignore: createIgnorePredicate(ctx.cwd),
|
|
@@ -1977,13 +3174,13 @@ var grepTool = {
|
|
|
1977
3174
|
};
|
|
1978
3175
|
|
|
1979
3176
|
// src/tools/fs/read.ts
|
|
1980
|
-
import { open, stat as stat5 } from "fs/promises";
|
|
1981
|
-
import
|
|
3177
|
+
import { open, realpath as realpath2, stat as stat5 } from "fs/promises";
|
|
3178
|
+
import path11 from "path";
|
|
1982
3179
|
import { z as z6 } from "zod";
|
|
1983
3180
|
var DEFAULT_LIMIT = 2e3;
|
|
1984
3181
|
var MAX_CHARS = 100 * 1024;
|
|
1985
3182
|
var MAX_READ_BYTES = 1024 * 1024;
|
|
1986
|
-
var
|
|
3183
|
+
var BINARY_PROBE_BYTES2 = 8192;
|
|
1987
3184
|
function decodeUtf16be(buf) {
|
|
1988
3185
|
const len = buf.length - buf.length % 2;
|
|
1989
3186
|
const swapped = Buffer.allocUnsafe(len);
|
|
@@ -2000,7 +3197,7 @@ function decodeTextBuffer(buf) {
|
|
|
2000
3197
|
if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
|
|
2001
3198
|
return { text: decodeUtf16be(buf.subarray(2)) };
|
|
2002
3199
|
}
|
|
2003
|
-
if (buf.subarray(0,
|
|
3200
|
+
if (buf.subarray(0, BINARY_PROBE_BYTES2).includes(0)) {
|
|
2004
3201
|
return { binary: true };
|
|
2005
3202
|
}
|
|
2006
3203
|
return { text: buf.toString("utf8") };
|
|
@@ -2010,93 +3207,327 @@ var schema5 = z6.object({
|
|
|
2010
3207
|
offset: z6.number().int().positive().optional().describe("1-based line number to start reading from"),
|
|
2011
3208
|
limit: z6.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
|
|
2012
3209
|
});
|
|
2013
|
-
var
|
|
2014
|
-
|
|
2015
|
-
|
|
3210
|
+
var READ_CACHE_MAX_ENTRIES = 200;
|
|
3211
|
+
function createReadFileTool() {
|
|
3212
|
+
const cache4 = /* @__PURE__ */ new Map();
|
|
3213
|
+
return {
|
|
3214
|
+
name: "read_file",
|
|
3215
|
+
description: "Read a text file and return its contents with line numbers. Output is truncated to 2000 lines or 100KB by default. Re-reading an unchanged file range returns a short note instead of the content.",
|
|
3216
|
+
permission: "read",
|
|
3217
|
+
parameters: schema5,
|
|
3218
|
+
reset: () => cache4.clear(),
|
|
3219
|
+
async execute(args, ctx) {
|
|
3220
|
+
const filePath = path11.resolve(ctx.cwd, args.path);
|
|
3221
|
+
const resolvedPath = await realpath2(filePath).catch(() => null);
|
|
3222
|
+
if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
|
|
3223
|
+
return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
|
|
3224
|
+
}
|
|
3225
|
+
const st = await stat5(filePath).catch(() => null);
|
|
3226
|
+
if (!st) {
|
|
3227
|
+
return { content: `File not found: ${args.path}`, isError: true };
|
|
3228
|
+
}
|
|
3229
|
+
if (st.isDirectory()) {
|
|
3230
|
+
return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
|
|
3231
|
+
}
|
|
3232
|
+
const start = args.offset ?? 1;
|
|
3233
|
+
const limit = args.limit ?? DEFAULT_LIMIT;
|
|
3234
|
+
const cacheKey = resolvedPath ?? filePath;
|
|
3235
|
+
const hit = cache4.get(cacheKey);
|
|
3236
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
|
|
3237
|
+
const requestedEnd = Math.min(hit.totalLines, start - 1 + limit);
|
|
3238
|
+
if (start >= hit.start && requestedEnd <= hit.end && start <= hit.totalLines) {
|
|
3239
|
+
return {
|
|
3240
|
+
content: `[unchanged since last read: ${args.path} \u2014 lines ${start}-${requestedEnd} of ${hit.totalLines} are already in context and the file has not changed]`
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
const bytesToRead = Math.min(st.size, MAX_READ_BYTES);
|
|
3245
|
+
const fileCapped = st.size > MAX_READ_BYTES;
|
|
3246
|
+
const handle = await open(filePath, "r").catch(() => null);
|
|
3247
|
+
if (!handle) {
|
|
3248
|
+
return { content: `Failed to read ${args.path}`, isError: true };
|
|
3249
|
+
}
|
|
3250
|
+
let buf;
|
|
3251
|
+
try {
|
|
3252
|
+
const { bytesRead, buffer } = await handle.read(
|
|
3253
|
+
Buffer.alloc(bytesToRead),
|
|
3254
|
+
0,
|
|
3255
|
+
bytesToRead,
|
|
3256
|
+
0
|
|
3257
|
+
);
|
|
3258
|
+
buf = buffer.subarray(0, bytesRead);
|
|
3259
|
+
} finally {
|
|
3260
|
+
await handle.close().catch(() => {
|
|
3261
|
+
});
|
|
3262
|
+
}
|
|
3263
|
+
const decoded = decodeTextBuffer(buf);
|
|
3264
|
+
if ("binary" in decoded) {
|
|
3265
|
+
return { content: `Cannot read binary file: ${args.path}`, isError: true };
|
|
3266
|
+
}
|
|
3267
|
+
const lines = decoded.text.split("\n");
|
|
3268
|
+
const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
|
|
3269
|
+
if (start > lines.length) {
|
|
3270
|
+
return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
|
|
3271
|
+
}
|
|
3272
|
+
const end = Math.min(lines.length, start - 1 + limit);
|
|
3273
|
+
const out = [];
|
|
3274
|
+
let size = 0;
|
|
3275
|
+
let truncatedBySize = false;
|
|
3276
|
+
for (let i = start - 1; i < end; i++) {
|
|
3277
|
+
const line = (lines[i] ?? "").replace(/\r$/, "");
|
|
3278
|
+
const numbered = `${i + 1} ${line}`;
|
|
3279
|
+
if (size + numbered.length > MAX_CHARS) {
|
|
3280
|
+
truncatedBySize = true;
|
|
3281
|
+
break;
|
|
3282
|
+
}
|
|
3283
|
+
out.push(numbered);
|
|
3284
|
+
size += numbered.length;
|
|
3285
|
+
}
|
|
3286
|
+
const lastShown = start - 1 + out.length;
|
|
3287
|
+
if (truncatedBySize) {
|
|
3288
|
+
out.push(
|
|
3289
|
+
`... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
|
|
3290
|
+
);
|
|
3291
|
+
} else if (end < lines.length || fileCapped) {
|
|
3292
|
+
out.push(`... (truncated: showing lines ${start}-${end} of ${totalDesc})`);
|
|
3293
|
+
}
|
|
3294
|
+
if (out.length > 0) {
|
|
3295
|
+
if (cache4.size >= READ_CACHE_MAX_ENTRIES) cache4.clear();
|
|
3296
|
+
const prev = hit?.mtimeMs === st.mtimeMs && hit.size === st.size ? hit : void 0;
|
|
3297
|
+
cache4.set(cacheKey, {
|
|
3298
|
+
mtimeMs: st.mtimeMs,
|
|
3299
|
+
size: st.size,
|
|
3300
|
+
totalLines: lines.length,
|
|
3301
|
+
// Extend the remembered range when the new read overlaps or abuts
|
|
3302
|
+
// the previous one; disjoint ranges replace it.
|
|
3303
|
+
start: prev && start <= prev.end + 1 ? Math.min(prev.start, start) : start,
|
|
3304
|
+
end: prev && lastShown >= prev.start - 1 ? Math.max(prev.end, lastShown) : lastShown
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
return { content: out.join("\n") };
|
|
3308
|
+
}
|
|
3309
|
+
};
|
|
3310
|
+
}
|
|
3311
|
+
var readFileTool = createReadFileTool();
|
|
3312
|
+
|
|
3313
|
+
// src/tools/fs/read-image.ts
|
|
3314
|
+
import { readFile as readFile5, realpath as realpath3, stat as stat6 } from "fs/promises";
|
|
3315
|
+
import path13 from "path";
|
|
3316
|
+
import { z as z7 } from "zod";
|
|
3317
|
+
|
|
3318
|
+
// src/core/image-resize.ts
|
|
3319
|
+
import { execFile as execFile2 } from "child_process";
|
|
3320
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
3321
|
+
import { readFile as readFile4, unlink, writeFile as writeFile4 } from "fs/promises";
|
|
3322
|
+
import os4 from "os";
|
|
3323
|
+
import path12 from "path";
|
|
3324
|
+
import { promisify } from "util";
|
|
3325
|
+
var RESIZE_TIMEOUT_MS = 1e4;
|
|
3326
|
+
var execFileAsync = promisify(execFile2);
|
|
3327
|
+
function windowsResizeScript(inputFile, outputFile, maxDimension) {
|
|
3328
|
+
const input = inputFile.replace(/'/g, "''");
|
|
3329
|
+
const output = outputFile.replace(/'/g, "''");
|
|
3330
|
+
return [
|
|
3331
|
+
"Add-Type -AssemblyName System.Drawing",
|
|
3332
|
+
`$img = [System.Drawing.Image]::FromFile('${input}')`,
|
|
3333
|
+
`$scale = [Math]::Min(1.0, ${maxDimension} / [Math]::Max($img.Width, $img.Height))`,
|
|
3334
|
+
"$w = [int][Math]::Max(1, [Math]::Round($img.Width * $scale))",
|
|
3335
|
+
"$h = [int][Math]::Max(1, [Math]::Round($img.Height * $scale))",
|
|
3336
|
+
"$bmp = New-Object System.Drawing.Bitmap($w, $h)",
|
|
3337
|
+
"$g = [System.Drawing.Graphics]::FromImage($bmp)",
|
|
3338
|
+
"$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic",
|
|
3339
|
+
"$g.DrawImage($img, 0, 0, $w, $h)",
|
|
3340
|
+
`$bmp.Save('${output}', [System.Drawing.Imaging.ImageFormat]::Png)`,
|
|
3341
|
+
"$g.Dispose(); $bmp.Dispose(); $img.Dispose()"
|
|
3342
|
+
].join("; ");
|
|
3343
|
+
}
|
|
3344
|
+
function resizeImagePlan(platform, inputFile, outputFile, maxDimension, tools = {}) {
|
|
3345
|
+
if (platform === "win32") {
|
|
3346
|
+
return {
|
|
3347
|
+
command: "powershell.exe",
|
|
3348
|
+
args: [
|
|
3349
|
+
"-NoProfile",
|
|
3350
|
+
"-NonInteractive",
|
|
3351
|
+
"-Command",
|
|
3352
|
+
windowsResizeScript(inputFile, outputFile, maxDimension)
|
|
3353
|
+
]
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
if (platform === "darwin") {
|
|
3357
|
+
return {
|
|
3358
|
+
command: "sips",
|
|
3359
|
+
args: ["-Z", String(maxDimension), inputFile, "--out", outputFile]
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
if (platform === "linux") {
|
|
3363
|
+
if (tools.hasConvert) {
|
|
3364
|
+
return {
|
|
3365
|
+
command: "convert",
|
|
3366
|
+
// -auto-orient keeps EXIF-rotated photos upright after the resize
|
|
3367
|
+
// strips their orientation metadata.
|
|
3368
|
+
args: [
|
|
3369
|
+
inputFile,
|
|
3370
|
+
"-auto-orient",
|
|
3371
|
+
"-resize",
|
|
3372
|
+
`${maxDimension}x${maxDimension}>`,
|
|
3373
|
+
outputFile
|
|
3374
|
+
]
|
|
3375
|
+
};
|
|
3376
|
+
}
|
|
3377
|
+
if (tools.hasFfmpeg) {
|
|
3378
|
+
return {
|
|
3379
|
+
command: "ffmpeg",
|
|
3380
|
+
args: [
|
|
3381
|
+
"-y",
|
|
3382
|
+
"-i",
|
|
3383
|
+
inputFile,
|
|
3384
|
+
"-vf",
|
|
3385
|
+
`scale='if(gt(iw,ih),min(iw,${maxDimension}),-1)':'if(gt(iw,ih),-1,min(ih,${maxDimension}))'`,
|
|
3386
|
+
outputFile
|
|
3387
|
+
]
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
return null;
|
|
3391
|
+
}
|
|
3392
|
+
return null;
|
|
3393
|
+
}
|
|
3394
|
+
async function commandExists(command) {
|
|
3395
|
+
const checker = process.platform === "win32" ? "where" : "which";
|
|
3396
|
+
try {
|
|
3397
|
+
await execFileAsync(checker, [command], { timeout: RESIZE_TIMEOUT_MS });
|
|
3398
|
+
return true;
|
|
3399
|
+
} catch {
|
|
3400
|
+
return false;
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
async function downsampleImageIfNeeded(image) {
|
|
3404
|
+
const buf = Buffer.from(image.data, "base64");
|
|
3405
|
+
const dims = probeImageDimensions(buf);
|
|
3406
|
+
if (!dims || Math.max(dims.width, dims.height) <= MAX_IMAGE_DIMENSION) {
|
|
3407
|
+
return { image, oversized: false, resized: false };
|
|
3408
|
+
}
|
|
3409
|
+
const inputFile = path12.join(os4.tmpdir(), `star-image-${randomUUID2()}.src`);
|
|
3410
|
+
const outputFile = path12.join(os4.tmpdir(), `star-image-${randomUUID2()}.png`);
|
|
3411
|
+
try {
|
|
3412
|
+
const [hasConvert, hasFfmpeg] = process.platform === "linux" ? await Promise.all([commandExists("convert"), commandExists("ffmpeg")]) : [false, false];
|
|
3413
|
+
const plan = resizeImagePlan(process.platform, inputFile, outputFile, MAX_IMAGE_DIMENSION, {
|
|
3414
|
+
hasConvert,
|
|
3415
|
+
hasFfmpeg
|
|
3416
|
+
});
|
|
3417
|
+
if (!plan) return { image, oversized: true, resized: false };
|
|
3418
|
+
await writeFile4(inputFile, buf);
|
|
3419
|
+
await execFileAsync(plan.command, plan.args, { timeout: RESIZE_TIMEOUT_MS });
|
|
3420
|
+
const out = await readFile4(outputFile).catch(() => null);
|
|
3421
|
+
if (!out || out.length === 0) return { image, oversized: true, resized: false };
|
|
3422
|
+
return {
|
|
3423
|
+
image: { path: image.path, mimeType: "image/png", data: out.toString("base64") },
|
|
3424
|
+
oversized: true,
|
|
3425
|
+
resized: true
|
|
3426
|
+
};
|
|
3427
|
+
} catch {
|
|
3428
|
+
return { image, oversized: true, resized: false };
|
|
3429
|
+
} finally {
|
|
3430
|
+
await unlink(inputFile).catch(() => {
|
|
3431
|
+
});
|
|
3432
|
+
await unlink(outputFile).catch(() => {
|
|
3433
|
+
});
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
// src/tools/fs/read-image.ts
|
|
3438
|
+
var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
3439
|
+
var IMAGE_MIME_TYPES = {
|
|
3440
|
+
".png": "image/png",
|
|
3441
|
+
".jpg": "image/jpeg",
|
|
3442
|
+
".jpeg": "image/jpeg",
|
|
3443
|
+
".gif": "image/gif",
|
|
3444
|
+
".webp": "image/webp"
|
|
3445
|
+
};
|
|
3446
|
+
var schema6 = z7.object({
|
|
3447
|
+
path: z7.string().describe(
|
|
3448
|
+
"Image file path (png/jpg/jpeg/gif/webp), absolute or relative to the working directory"
|
|
3449
|
+
)
|
|
3450
|
+
});
|
|
3451
|
+
var readImageTool = {
|
|
3452
|
+
name: "read_image",
|
|
3453
|
+
description: "Read an image file (png, jpg, gif, webp) and show it to the model. Use this whenever the user references a screenshot, diagram, or picture file you need to see. Images larger than 2000px are downsampled automatically.",
|
|
2016
3454
|
permission: "read",
|
|
2017
|
-
parameters:
|
|
3455
|
+
parameters: schema6,
|
|
2018
3456
|
async execute(args, ctx) {
|
|
2019
|
-
const filePath =
|
|
2020
|
-
|
|
3457
|
+
const filePath = path13.resolve(ctx.cwd, args.path);
|
|
3458
|
+
const resolvedPath = await realpath3(filePath).catch(() => null);
|
|
3459
|
+
if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
|
|
2021
3460
|
return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
|
|
2022
3461
|
}
|
|
2023
|
-
const
|
|
2024
|
-
if (!
|
|
3462
|
+
const mimeType = IMAGE_MIME_TYPES[path13.extname(filePath).toLowerCase()];
|
|
3463
|
+
if (!mimeType) {
|
|
3464
|
+
return {
|
|
3465
|
+
content: `Not a supported image (png/jpg/jpeg/gif/webp): ${args.path}`,
|
|
3466
|
+
isError: true
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
const st = await stat6(filePath).catch(() => null);
|
|
3470
|
+
if (!st || !st.isFile()) {
|
|
2025
3471
|
return { content: `File not found: ${args.path}`, isError: true };
|
|
2026
3472
|
}
|
|
2027
|
-
if (st.
|
|
2028
|
-
return {
|
|
3473
|
+
if (st.size > MAX_IMAGE_BYTES) {
|
|
3474
|
+
return {
|
|
3475
|
+
content: `Image too large (${Math.round(st.size / 1024)}KB > ${MAX_IMAGE_BYTES / 1024}KB): ${args.path}`,
|
|
3476
|
+
isError: true
|
|
3477
|
+
};
|
|
2029
3478
|
}
|
|
2030
|
-
const
|
|
2031
|
-
|
|
2032
|
-
const handle = await open(filePath, "r").catch(() => null);
|
|
2033
|
-
if (!handle) {
|
|
3479
|
+
const buf = await readFile5(filePath).catch(() => null);
|
|
3480
|
+
if (!buf || buf.length === 0) {
|
|
2034
3481
|
return { content: `Failed to read ${args.path}`, isError: true };
|
|
2035
3482
|
}
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
await handle.close().catch(() => {
|
|
2042
|
-
});
|
|
2043
|
-
}
|
|
2044
|
-
const decoded = decodeTextBuffer(buf);
|
|
2045
|
-
if ("binary" in decoded) {
|
|
2046
|
-
return { content: `Cannot read binary file: ${args.path}`, isError: true };
|
|
2047
|
-
}
|
|
2048
|
-
const lines = decoded.text.split("\n");
|
|
2049
|
-
const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
|
|
2050
|
-
const start = args.offset ?? 1;
|
|
2051
|
-
const limit = args.limit ?? DEFAULT_LIMIT;
|
|
2052
|
-
if (start > lines.length) {
|
|
2053
|
-
return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
|
|
2054
|
-
}
|
|
2055
|
-
const end = Math.min(lines.length, start - 1 + limit);
|
|
2056
|
-
const out = [];
|
|
2057
|
-
let size = 0;
|
|
2058
|
-
let truncatedBySize = false;
|
|
2059
|
-
for (let i = start - 1; i < end; i++) {
|
|
2060
|
-
const line = (lines[i] ?? "").replace(/\r$/, "");
|
|
2061
|
-
const numbered = `${i + 1} ${line}`;
|
|
2062
|
-
if (size + numbered.length > MAX_CHARS) {
|
|
2063
|
-
truncatedBySize = true;
|
|
2064
|
-
break;
|
|
2065
|
-
}
|
|
2066
|
-
out.push(numbered);
|
|
2067
|
-
size += numbered.length;
|
|
3483
|
+
if (!probeImageDimensions(buf)) {
|
|
3484
|
+
return {
|
|
3485
|
+
content: `File is not a valid image (unrecognized header): ${args.path}`,
|
|
3486
|
+
isError: true
|
|
3487
|
+
};
|
|
2068
3488
|
}
|
|
2069
|
-
const
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
3489
|
+
const downsampled = await downsampleImageIfNeeded({
|
|
3490
|
+
path: args.path,
|
|
3491
|
+
mimeType,
|
|
3492
|
+
data: buf.toString("base64")
|
|
3493
|
+
});
|
|
3494
|
+
if (downsampled.oversized && !downsampled.resized) {
|
|
3495
|
+
return {
|
|
3496
|
+
content: `Image exceeds 2000px and could not be downsampled on this platform: ${args.path}`,
|
|
3497
|
+
isError: true
|
|
3498
|
+
};
|
|
2076
3499
|
}
|
|
2077
|
-
|
|
3500
|
+
const image = downsampled.image;
|
|
3501
|
+
const dims = probeImageDimensions(Buffer.from(image.data, "base64"));
|
|
3502
|
+
const sizeKB = Math.round(image.data.length / 1024);
|
|
3503
|
+
const desc = dims ? `${dims.width}x${dims.height}` : "unknown dimensions";
|
|
3504
|
+
const resizedNote = downsampled.resized ? " (downsampled to fit 2000px)" : "";
|
|
3505
|
+
return {
|
|
3506
|
+
content: `Image loaded: ${args.path} (${desc}, ~${sizeKB}KB)${resizedNote}. The image is attached below \u2014 look at it now.`,
|
|
3507
|
+
images: [image]
|
|
3508
|
+
};
|
|
2078
3509
|
}
|
|
2079
3510
|
};
|
|
2080
3511
|
|
|
2081
3512
|
// src/tools/fs/write.ts
|
|
2082
|
-
import { mkdir as mkdir3, writeFile as
|
|
2083
|
-
import
|
|
2084
|
-
import { z as
|
|
2085
|
-
var
|
|
2086
|
-
path:
|
|
2087
|
-
content:
|
|
3513
|
+
import { mkdir as mkdir3, writeFile as writeFile5 } from "fs/promises";
|
|
3514
|
+
import path14 from "path";
|
|
3515
|
+
import { z as z8 } from "zod";
|
|
3516
|
+
var schema7 = z8.object({
|
|
3517
|
+
path: z8.string().describe("File path, absolute or relative to the working directory"),
|
|
3518
|
+
content: z8.string().describe("Full content to write to the file")
|
|
2088
3519
|
});
|
|
2089
3520
|
var writeFileTool = {
|
|
2090
3521
|
name: "write_file",
|
|
2091
3522
|
description: "Write content to a file, creating parent directories as needed. Overwrites existing files.",
|
|
2092
3523
|
permission: "write",
|
|
2093
|
-
parameters:
|
|
3524
|
+
parameters: schema7,
|
|
2094
3525
|
async execute(args, ctx) {
|
|
2095
|
-
const filePath =
|
|
3526
|
+
const filePath = path14.resolve(ctx.cwd, args.path);
|
|
2096
3527
|
const snapshot = await captureSnapshot(filePath, "write_file", ctx.snapshotContext);
|
|
2097
3528
|
try {
|
|
2098
|
-
await mkdir3(
|
|
2099
|
-
await
|
|
3529
|
+
await mkdir3(path14.dirname(filePath), { recursive: true });
|
|
3530
|
+
await writeFile5(filePath, args.content, "utf8");
|
|
2100
3531
|
} catch (err) {
|
|
2101
3532
|
return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
|
|
2102
3533
|
}
|
|
@@ -2105,8 +3536,159 @@ var writeFileTool = {
|
|
|
2105
3536
|
}
|
|
2106
3537
|
};
|
|
2107
3538
|
|
|
3539
|
+
// src/tools/screenshot.ts
|
|
3540
|
+
import { z as z9 } from "zod";
|
|
3541
|
+
|
|
3542
|
+
// src/core/screenshot.ts
|
|
3543
|
+
import { execFile as execFile3 } from "child_process";
|
|
3544
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
3545
|
+
import { readFile as readFile6, unlink as unlink2 } from "fs/promises";
|
|
3546
|
+
import os5 from "os";
|
|
3547
|
+
import path15 from "path";
|
|
3548
|
+
import { promisify as promisify2 } from "util";
|
|
3549
|
+
var SCREENSHOT_TIMEOUT_MS = 2e4;
|
|
3550
|
+
var execFileAsync2 = promisify2(execFile3);
|
|
3551
|
+
function windowsScreenshotScript(outputFile) {
|
|
3552
|
+
const output = outputFile.replace(/'/g, "''");
|
|
3553
|
+
return [
|
|
3554
|
+
`try { Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(int v);' -Name Dpi -Namespace StarShot -ErrorAction Stop; [void][StarShot.Dpi]::SetProcessDpiAwarenessContext(-4) } catch { }`,
|
|
3555
|
+
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing",
|
|
3556
|
+
"$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds",
|
|
3557
|
+
"$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height)",
|
|
3558
|
+
"$g = [System.Drawing.Graphics]::FromImage($bmp)",
|
|
3559
|
+
"$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)",
|
|
3560
|
+
`$bmp.Save('${output}', [System.Drawing.Imaging.ImageFormat]::Png)`,
|
|
3561
|
+
"$g.Dispose(); $bmp.Dispose()"
|
|
3562
|
+
].join("; ");
|
|
3563
|
+
}
|
|
3564
|
+
function screenshotPlan(platform, outputFile, env = process.env, tools = {}) {
|
|
3565
|
+
if (platform === "win32") {
|
|
3566
|
+
return {
|
|
3567
|
+
command: "powershell.exe",
|
|
3568
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", windowsScreenshotScript(outputFile)]
|
|
3569
|
+
};
|
|
3570
|
+
}
|
|
3571
|
+
if (platform === "darwin") {
|
|
3572
|
+
return { command: "screencapture", args: ["-x", "-D", "1", outputFile] };
|
|
3573
|
+
}
|
|
3574
|
+
if (platform === "linux") {
|
|
3575
|
+
if (env.WAYLAND_DISPLAY) {
|
|
3576
|
+
if (tools.grim) return { command: "grim", args: [outputFile] };
|
|
3577
|
+
if (tools["gnome-screenshot"]) {
|
|
3578
|
+
return { command: "gnome-screenshot", args: ["-f", outputFile] };
|
|
3579
|
+
}
|
|
3580
|
+
if (tools.spectacle) {
|
|
3581
|
+
return { command: "spectacle", args: ["-b", "-n", "-o", outputFile] };
|
|
3582
|
+
}
|
|
3583
|
+
return null;
|
|
3584
|
+
}
|
|
3585
|
+
if (env.DISPLAY) {
|
|
3586
|
+
if (tools.import) return { command: "import", args: ["-window", "root", outputFile] };
|
|
3587
|
+
if (tools.scrot) return { command: "scrot", args: ["--overwrite", outputFile] };
|
|
3588
|
+
if (tools["gnome-screenshot"]) {
|
|
3589
|
+
return { command: "gnome-screenshot", args: ["-f", outputFile] };
|
|
3590
|
+
}
|
|
3591
|
+
if (tools.spectacle) {
|
|
3592
|
+
return { command: "spectacle", args: ["-b", "-n", "-o", outputFile] };
|
|
3593
|
+
}
|
|
3594
|
+
return null;
|
|
3595
|
+
}
|
|
3596
|
+
return null;
|
|
3597
|
+
}
|
|
3598
|
+
return null;
|
|
3599
|
+
}
|
|
3600
|
+
function noScreenshotToolMessage(platform, env = process.env) {
|
|
3601
|
+
if (platform === "linux") {
|
|
3602
|
+
if (!env.WAYLAND_DISPLAY && !env.DISPLAY) {
|
|
3603
|
+
return "no display server (neither DISPLAY nor WAYLAND_DISPLAY is set) \u2014 this session is headless (e.g. SSH), there is no screen to capture";
|
|
3604
|
+
}
|
|
3605
|
+
return "no screenshot tool found \u2014 install one of: grim or spectacle (Wayland), scrot or ImageMagick's import (X11), or gnome-screenshot";
|
|
3606
|
+
}
|
|
3607
|
+
return `screenshots are not supported on this platform (${platform})`;
|
|
3608
|
+
}
|
|
3609
|
+
async function commandExists2(command) {
|
|
3610
|
+
const checker = process.platform === "win32" ? "where" : "which";
|
|
3611
|
+
try {
|
|
3612
|
+
await execFileAsync2(checker, [command], { timeout: SCREENSHOT_TIMEOUT_MS });
|
|
3613
|
+
return true;
|
|
3614
|
+
} catch {
|
|
3615
|
+
return false;
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
var LINUX_TOOL_NAMES = ["grim", "gnome-screenshot", "spectacle", "import", "scrot"];
|
|
3619
|
+
async function captureScreenshot() {
|
|
3620
|
+
const outputFile = path15.join(os5.tmpdir(), `star-screenshot-${randomUUID3()}.png`);
|
|
3621
|
+
try {
|
|
3622
|
+
const tools = {};
|
|
3623
|
+
if (process.platform === "linux") {
|
|
3624
|
+
const found = await Promise.all(LINUX_TOOL_NAMES.map(commandExists2));
|
|
3625
|
+
for (const [i, name] of LINUX_TOOL_NAMES.entries()) {
|
|
3626
|
+
tools[name] = found[i] ?? false;
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
const plan = screenshotPlan(process.platform, outputFile, process.env, tools);
|
|
3630
|
+
if (!plan) {
|
|
3631
|
+
return { ok: false, error: noScreenshotToolMessage(process.platform) };
|
|
3632
|
+
}
|
|
3633
|
+
try {
|
|
3634
|
+
await execFileAsync2(plan.command, plan.args, { timeout: SCREENSHOT_TIMEOUT_MS });
|
|
3635
|
+
} catch (err) {
|
|
3636
|
+
const detail = err instanceof Error && err.message ? ` (${err.message})` : "";
|
|
3637
|
+
return { ok: false, error: `${plan.command} failed to capture the screen${detail}` };
|
|
3638
|
+
}
|
|
3639
|
+
const buf = await readFile6(outputFile).catch(() => null);
|
|
3640
|
+
if (!buf || buf.length === 0) {
|
|
3641
|
+
return { ok: false, error: `${plan.command} produced no image file` };
|
|
3642
|
+
}
|
|
3643
|
+
if (!probeImageDimensions(buf)) {
|
|
3644
|
+
return { ok: false, error: `${plan.command} produced an unrecognized image file` };
|
|
3645
|
+
}
|
|
3646
|
+
const downsampled = await downsampleImageIfNeeded({
|
|
3647
|
+
path: "screen",
|
|
3648
|
+
mimeType: "image/png",
|
|
3649
|
+
data: buf.toString("base64")
|
|
3650
|
+
});
|
|
3651
|
+
if (downsampled.oversized && !downsampled.resized) {
|
|
3652
|
+
return {
|
|
3653
|
+
ok: false,
|
|
3654
|
+
error: "screenshot exceeds 2000px and could not be downsampled on this platform"
|
|
3655
|
+
};
|
|
3656
|
+
}
|
|
3657
|
+
return { ok: true, image: downsampled.image };
|
|
3658
|
+
} finally {
|
|
3659
|
+
await unlink2(outputFile).catch(() => {
|
|
3660
|
+
});
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
// src/tools/screenshot.ts
|
|
3665
|
+
var schema8 = z9.object({});
|
|
3666
|
+
function createScreenshotTool(capture = captureScreenshot) {
|
|
3667
|
+
return {
|
|
3668
|
+
name: "screenshot",
|
|
3669
|
+
description: "Capture a screenshot of the primary display and see it. Use this to check the actual visual result of a running program (web page, GUI) \u2014 e.g. after starting a dev server \u2014 instead of inferring from code. The capture shows exactly what is visible on screen, including overlapping windows: content hidden behind other windows cannot be seen, so if a specific window matters ask the user to bring it to the front first. Note the capture may include anything else currently on screen.",
|
|
3670
|
+
permission: "read",
|
|
3671
|
+
parameters: schema8,
|
|
3672
|
+
async execute() {
|
|
3673
|
+
const result = await capture();
|
|
3674
|
+
if (!result.ok) {
|
|
3675
|
+
return { content: `Screenshot failed: ${result.error}`, isError: true };
|
|
3676
|
+
}
|
|
3677
|
+
const image = result.image;
|
|
3678
|
+
const dims = probeImageDimensions(Buffer.from(image.data, "base64"));
|
|
3679
|
+
const sizeKB = Math.round(image.data.length / 1024);
|
|
3680
|
+
const desc = dims ? `${dims.width}x${dims.height}` : "unknown dimensions";
|
|
3681
|
+
return {
|
|
3682
|
+
content: `Screenshot captured (${desc}, ~${sizeKB}KB). The image is attached below \u2014 look at it now.`,
|
|
3683
|
+
images: [image]
|
|
3684
|
+
};
|
|
3685
|
+
}
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
var screenshotTool = createScreenshotTool();
|
|
3689
|
+
|
|
2108
3690
|
// src/tools/tasks.ts
|
|
2109
|
-
import { z as
|
|
3691
|
+
import { z as z10 } from "zod";
|
|
2110
3692
|
|
|
2111
3693
|
// src/agent/agent-tasks.ts
|
|
2112
3694
|
import { EventEmitter as EventEmitter2 } from "events";
|
|
@@ -2277,8 +3859,8 @@ function formatTaskFinished(task) {
|
|
|
2277
3859
|
}
|
|
2278
3860
|
|
|
2279
3861
|
// src/tools/tasks.ts
|
|
2280
|
-
var idSchema =
|
|
2281
|
-
id:
|
|
3862
|
+
var idSchema = z10.object({
|
|
3863
|
+
id: z10.string().describe("Task id, e.g. task-1 or agent-1")
|
|
2282
3864
|
});
|
|
2283
3865
|
function truncateOutput(s) {
|
|
2284
3866
|
if (s.length <= MAX_OUTPUT) return s;
|
|
@@ -2295,7 +3877,7 @@ var taskListTool = {
|
|
|
2295
3877
|
name: "task_list",
|
|
2296
3878
|
description: "List background tasks (shell commands and subagents) with status and runtime.",
|
|
2297
3879
|
permission: "read",
|
|
2298
|
-
parameters:
|
|
3880
|
+
parameters: z10.object({}),
|
|
2299
3881
|
execute() {
|
|
2300
3882
|
const shell = formatTaskList(defaultTaskManager.list());
|
|
2301
3883
|
const agents = defaultAgentTasks.list();
|
|
@@ -2354,20 +3936,20 @@ var taskKillTool = {
|
|
|
2354
3936
|
};
|
|
2355
3937
|
|
|
2356
3938
|
// src/tools/todo.ts
|
|
2357
|
-
import { mkdir as mkdir4, readFile as
|
|
2358
|
-
import
|
|
2359
|
-
import { z as
|
|
2360
|
-
var todoItemSchema =
|
|
2361
|
-
id:
|
|
2362
|
-
title:
|
|
2363
|
-
status:
|
|
3939
|
+
import { mkdir as mkdir4, readFile as readFile7, rename, rm as rm3, writeFile as writeFile6 } from "fs/promises";
|
|
3940
|
+
import path16 from "path";
|
|
3941
|
+
import { z as z11 } from "zod";
|
|
3942
|
+
var todoItemSchema = z11.object({
|
|
3943
|
+
id: z11.number().int(),
|
|
3944
|
+
title: z11.string(),
|
|
3945
|
+
status: z11.enum(["pending", "in_progress", "done"])
|
|
2364
3946
|
});
|
|
2365
|
-
var writeSchema =
|
|
2366
|
-
todos:
|
|
3947
|
+
var writeSchema = z11.object({
|
|
3948
|
+
todos: z11.array(todoItemSchema).describe("The full todo list, replacing the current one")
|
|
2367
3949
|
});
|
|
2368
|
-
var readSchema =
|
|
3950
|
+
var readSchema = z11.object({});
|
|
2369
3951
|
function fileFor(cwd) {
|
|
2370
|
-
return
|
|
3952
|
+
return path16.join(cwd, ".star", "todos.json");
|
|
2371
3953
|
}
|
|
2372
3954
|
var persistGuard = () => true;
|
|
2373
3955
|
function setTodoPersistGuard(guard) {
|
|
@@ -2387,7 +3969,7 @@ var TodoStore = class {
|
|
|
2387
3969
|
async load(cwd) {
|
|
2388
3970
|
let raw;
|
|
2389
3971
|
try {
|
|
2390
|
-
raw = await
|
|
3972
|
+
raw = await readFile7(fileFor(cwd), "utf8");
|
|
2391
3973
|
} catch {
|
|
2392
3974
|
return;
|
|
2393
3975
|
}
|
|
@@ -2400,15 +3982,22 @@ var TodoStore = class {
|
|
|
2400
3982
|
}
|
|
2401
3983
|
}
|
|
2402
3984
|
// Atomic write (tmp + rename): a crash or failed save can never leave a
|
|
2403
|
-
// truncated todos.json behind.
|
|
3985
|
+
// truncated todos.json behind. An empty or fully completed list has no
|
|
3986
|
+
// resume value — rehydrating it would only restore finished work — so the
|
|
3987
|
+
// file is deleted instead of persisted.
|
|
2404
3988
|
async save(cwd) {
|
|
2405
3989
|
if (!persistGuard()) {
|
|
2406
3990
|
return;
|
|
2407
3991
|
}
|
|
2408
|
-
|
|
3992
|
+
const items = this.list();
|
|
2409
3993
|
const file = fileFor(cwd);
|
|
3994
|
+
if (items.length === 0 || items.every((it) => it.status === "done")) {
|
|
3995
|
+
await rm3(file, { force: true });
|
|
3996
|
+
return;
|
|
3997
|
+
}
|
|
3998
|
+
await mkdir4(path16.join(cwd, ".star"), { recursive: true });
|
|
2410
3999
|
const tmp = `${file}.tmp`;
|
|
2411
|
-
await
|
|
4000
|
+
await writeFile6(tmp, `${JSON.stringify(items, null, 2)}
|
|
2412
4001
|
`);
|
|
2413
4002
|
await rename(tmp, file);
|
|
2414
4003
|
}
|
|
@@ -2481,13 +4070,13 @@ function createTodoTools(store = defaultStore) {
|
|
|
2481
4070
|
}
|
|
2482
4071
|
|
|
2483
4072
|
// src/tools/web/fetch.ts
|
|
2484
|
-
import { z as
|
|
4073
|
+
import { z as z12 } from "zod";
|
|
2485
4074
|
var DEFAULT_MAX_CHARS = 2e4;
|
|
2486
4075
|
var MAX_BYTES = 2 * 1024 * 1024;
|
|
2487
4076
|
var TIMEOUT_MS = 3e4;
|
|
2488
|
-
var
|
|
2489
|
-
url:
|
|
2490
|
-
maxChars:
|
|
4077
|
+
var schema9 = z12.object({
|
|
4078
|
+
url: z12.string().describe("The http(s) URL to fetch"),
|
|
4079
|
+
maxChars: z12.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
|
|
2491
4080
|
});
|
|
2492
4081
|
function safeCodePoint(n, raw) {
|
|
2493
4082
|
if (!Number.isInteger(n) || n < 0 || n > 1114111) {
|
|
@@ -2558,7 +4147,7 @@ var webFetchTool = {
|
|
|
2558
4147
|
name: "web_fetch",
|
|
2559
4148
|
description: "Fetch a URL over http(s) and return its contents as readable text. HTML pages are converted to plain text (tags, scripts and styles removed). Output is truncated to maxChars (default 20000). Can reach internal/intranet addresses, so do not use it for untrusted URLs.",
|
|
2560
4149
|
permission: "read",
|
|
2561
|
-
parameters:
|
|
4150
|
+
parameters: schema9,
|
|
2562
4151
|
async execute(args, ctx) {
|
|
2563
4152
|
let url;
|
|
2564
4153
|
try {
|
|
@@ -2621,15 +4210,15 @@ var webFetchTool = {
|
|
|
2621
4210
|
};
|
|
2622
4211
|
|
|
2623
4212
|
// src/tools/web/search.ts
|
|
2624
|
-
import { z as
|
|
4213
|
+
import { z as z13 } from "zod";
|
|
2625
4214
|
var TIMEOUT_MS2 = 15e3;
|
|
2626
4215
|
var DEFAULT_MAX_RESULTS = 5;
|
|
2627
4216
|
var MAX_RESULTS_LIMIT = 10;
|
|
2628
4217
|
var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
|
|
2629
4218
|
var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
2630
|
-
var
|
|
2631
|
-
query:
|
|
2632
|
-
maxResults:
|
|
4219
|
+
var schema10 = z13.object({
|
|
4220
|
+
query: z13.string().min(1).describe("The search query"),
|
|
4221
|
+
maxResults: z13.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
|
|
2633
4222
|
`Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
|
|
2634
4223
|
)
|
|
2635
4224
|
});
|
|
@@ -2680,7 +4269,7 @@ var webSearchTool = {
|
|
|
2680
4269
|
name: "web_search",
|
|
2681
4270
|
description: "Search the web using DuckDuckGo and return a numbered list of results with title, URL and snippet. No API key required. Use web_fetch to read a result's full page.",
|
|
2682
4271
|
permission: "read",
|
|
2683
|
-
parameters:
|
|
4272
|
+
parameters: schema10,
|
|
2684
4273
|
async execute(args, ctx) {
|
|
2685
4274
|
const maxResults = Math.min(args.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT);
|
|
2686
4275
|
const url = `${SEARCH_URL}${encodeURIComponent(args.query)}`;
|
|
@@ -2742,7 +4331,11 @@ var ToolRegistry = class {
|
|
|
2742
4331
|
// their own instance so a child's todo_write cannot clobber the parent list.
|
|
2743
4332
|
constructor(todoStore) {
|
|
2744
4333
|
for (const tool of [
|
|
2745
|
-
|
|
4334
|
+
// Fresh per registry (i.e. per agent loop): the unchanged-since-last-
|
|
4335
|
+
// read cache must not leak across loops, whose contexts differ.
|
|
4336
|
+
createReadFileTool(),
|
|
4337
|
+
readImageTool,
|
|
4338
|
+
screenshotTool,
|
|
2746
4339
|
writeFileTool,
|
|
2747
4340
|
editFileTool,
|
|
2748
4341
|
globTool,
|
|
@@ -2761,6 +4354,12 @@ var ToolRegistry = class {
|
|
|
2761
4354
|
register(tool) {
|
|
2762
4355
|
this.tools.set(tool.name, tool);
|
|
2763
4356
|
}
|
|
4357
|
+
// Drops per-session volatile tool state after a wholesale history rewrite
|
|
4358
|
+
// (compaction, /undo, resume): anything a tool remembers as "already sent
|
|
4359
|
+
// to the model" may no longer be in the context.
|
|
4360
|
+
resetVolatileState() {
|
|
4361
|
+
for (const tool of this.tools.values()) tool.reset?.();
|
|
4362
|
+
}
|
|
2764
4363
|
get(name) {
|
|
2765
4364
|
return this.tools.get(name);
|
|
2766
4365
|
}
|
|
@@ -2774,12 +4373,12 @@ var ToolRegistry = class {
|
|
|
2774
4373
|
|
|
2775
4374
|
// src/agent/project-memory.ts
|
|
2776
4375
|
import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
2777
|
-
import
|
|
4376
|
+
import path17 from "path";
|
|
2778
4377
|
var PROJECT_MEMORY_FILE = "AGENTS.md";
|
|
2779
4378
|
var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
|
|
2780
4379
|
var cache = /* @__PURE__ */ new Map();
|
|
2781
4380
|
function readProjectMemory(cwd) {
|
|
2782
|
-
const file =
|
|
4381
|
+
const file = path17.join(cwd, PROJECT_MEMORY_FILE);
|
|
2783
4382
|
let mtimeMs;
|
|
2784
4383
|
try {
|
|
2785
4384
|
mtimeMs = statSync2(file).mtimeMs;
|
|
@@ -2809,8 +4408,8 @@ ${body}`;
|
|
|
2809
4408
|
|
|
2810
4409
|
// src/agent/skills.ts
|
|
2811
4410
|
import { readFileSync as readFileSync3, readdirSync, statSync as statSync3 } from "fs";
|
|
2812
|
-
import
|
|
2813
|
-
import { z as
|
|
4411
|
+
import path18 from "path";
|
|
4412
|
+
import { z as z14 } from "zod";
|
|
2814
4413
|
var SKILL_FILE = "SKILL.md";
|
|
2815
4414
|
var SKILL_MAX_CHARS = 32 * 1024;
|
|
2816
4415
|
var NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
@@ -2840,7 +4439,7 @@ function parseSkillFile(filePath, dirName, scope) {
|
|
|
2840
4439
|
const name = parsed?.meta.name || dirName;
|
|
2841
4440
|
const description = parsed?.meta.description;
|
|
2842
4441
|
if (parsed && NAME_PATTERN.test(name) && description) {
|
|
2843
|
-
skill = { name, description, filePath, dir:
|
|
4442
|
+
skill = { name, description, filePath, dir: path18.dirname(filePath), scope };
|
|
2844
4443
|
}
|
|
2845
4444
|
} catch {
|
|
2846
4445
|
skill = null;
|
|
@@ -2857,14 +4456,14 @@ function scanDir(base, scope, into) {
|
|
|
2857
4456
|
}
|
|
2858
4457
|
for (const entry of entries.sort()) {
|
|
2859
4458
|
if (!NAME_PATTERN.test(entry)) continue;
|
|
2860
|
-
const parsed = parseSkillFile(
|
|
4459
|
+
const parsed = parseSkillFile(path18.join(base, entry, SKILL_FILE), entry, scope);
|
|
2861
4460
|
if (parsed) into.set(parsed.name, parsed);
|
|
2862
4461
|
}
|
|
2863
4462
|
}
|
|
2864
4463
|
function discoverSkills(cwd, home = starHome()) {
|
|
2865
4464
|
const byName = /* @__PURE__ */ new Map();
|
|
2866
|
-
scanDir(
|
|
2867
|
-
scanDir(
|
|
4465
|
+
scanDir(path18.join(home, "skills"), "user", byName);
|
|
4466
|
+
scanDir(path18.join(cwd, ".star", "skills"), "project", byName);
|
|
2868
4467
|
return [...byName.values()];
|
|
2869
4468
|
}
|
|
2870
4469
|
function formatSkillsBlock(skills) {
|
|
@@ -2895,8 +4494,8 @@ function createSkillTool(deps) {
|
|
|
2895
4494
|
name: "skill",
|
|
2896
4495
|
description: "Load the full instructions of a skill by name. Available skills are listed in the system prompt when present; call this before starting work that matches one of them.",
|
|
2897
4496
|
permission: "read",
|
|
2898
|
-
parameters:
|
|
2899
|
-
name:
|
|
4497
|
+
parameters: z14.object({
|
|
4498
|
+
name: z14.string().describe("Name of the skill to load, as listed in the system prompt.")
|
|
2900
4499
|
}),
|
|
2901
4500
|
async execute(args) {
|
|
2902
4501
|
const skills = discoverSkills(deps.cwd, deps.home);
|
|
@@ -2926,7 +4525,7 @@ function createSkillTool(deps) {
|
|
|
2926
4525
|
}
|
|
2927
4526
|
|
|
2928
4527
|
// src/agent/subagent.ts
|
|
2929
|
-
import { z as
|
|
4528
|
+
import { z as z15 } from "zod";
|
|
2930
4529
|
|
|
2931
4530
|
// src/tools/index.ts
|
|
2932
4531
|
function createDefaultRegistry(todoStore) {
|
|
@@ -2937,8 +4536,37 @@ function createDefaultRegistry(todoStore) {
|
|
|
2937
4536
|
var MAX_SUBAGENT_DEPTH = 1;
|
|
2938
4537
|
var MAX_REPORT_CHARS = 2e4;
|
|
2939
4538
|
var SUBAGENT_PROMPT = "You are a subagent spawned to handle a focused subtask on behalf of the main agent. Work autonomously with the tools available to you, then finish with a concise report of what you found or did. Your final text is returned to the main agent as the tool result \u2014 make it self-contained.";
|
|
4539
|
+
function convertUsagePricing(usage, from, to, config) {
|
|
4540
|
+
if (from === to) return usage;
|
|
4541
|
+
const fromCfg = config.models.find((m) => m.model === from.modelId);
|
|
4542
|
+
const toCfg = config.models.find((m) => m.model === to.modelId);
|
|
4543
|
+
if (fromCfg?.promptPrice === void 0 || fromCfg.completionPrice === void 0 || toCfg?.promptPrice === void 0 || toCfg.completionPrice === void 0 || toCfg.promptPrice <= 0 || toCfg.completionPrice <= 0) {
|
|
4544
|
+
return usage;
|
|
4545
|
+
}
|
|
4546
|
+
const cached = Math.min(Math.max(usage.cachedPromptTokens ?? 0, 0), usage.promptTokens);
|
|
4547
|
+
const promptUsd = (usage.promptTokens - cached) * fromCfg.promptPrice;
|
|
4548
|
+
const cachedUsd = cached * (fromCfg.cacheReadPrice ?? fromCfg.promptPrice);
|
|
4549
|
+
const cacheReadUsd = (usage.cacheReadInputTokens ?? 0) * (fromCfg.cacheReadPrice ?? 0);
|
|
4550
|
+
const completionUsd = usage.completionTokens * fromCfg.completionPrice;
|
|
4551
|
+
const scaledCached = cachedUsd / (toCfg.cacheReadPrice ?? toCfg.promptPrice);
|
|
4552
|
+
const promptTokens = Math.round(promptUsd / toCfg.promptPrice + scaledCached);
|
|
4553
|
+
const cachedPromptTokens = Math.round(scaledCached);
|
|
4554
|
+
const toCacheReadPrice = toCfg.cacheReadPrice !== void 0 && toCfg.cacheReadPrice > 0 ? toCfg.cacheReadPrice : void 0;
|
|
4555
|
+
const completionTokens = Math.round(
|
|
4556
|
+
completionUsd / toCfg.completionPrice + (toCacheReadPrice === void 0 ? cacheReadUsd / toCfg.completionPrice : 0)
|
|
4557
|
+
);
|
|
4558
|
+
const cacheReadInputTokens = toCacheReadPrice === void 0 ? 0 : Math.round(cacheReadUsd / toCacheReadPrice);
|
|
4559
|
+
const result = {
|
|
4560
|
+
promptTokens,
|
|
4561
|
+
completionTokens,
|
|
4562
|
+
totalTokens: promptTokens + completionTokens
|
|
4563
|
+
};
|
|
4564
|
+
if (cachedPromptTokens > 0) result.cachedPromptTokens = cachedPromptTokens;
|
|
4565
|
+
if (cacheReadInputTokens > 0) result.cacheReadInputTokens = cacheReadInputTokens;
|
|
4566
|
+
return result;
|
|
4567
|
+
}
|
|
2940
4568
|
async function runSubagent(deps, args, cwd, signal) {
|
|
2941
|
-
const { AgentLoop: AgentLoop2 } = await import("./loop-
|
|
4569
|
+
const { AgentLoop: AgentLoop2 } = await import("./loop-2MOFLXTG.js");
|
|
2942
4570
|
const child = new AgentLoop2({
|
|
2943
4571
|
model: deps.model,
|
|
2944
4572
|
registry: createDefaultRegistry(),
|
|
@@ -2948,12 +4576,16 @@ async function runSubagent(deps, args, cwd, signal) {
|
|
|
2948
4576
|
|
|
2949
4577
|
${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
|
|
2950
4578
|
providerMetadata: deps.providerMetadata,
|
|
4579
|
+
temperature: deps.temperature,
|
|
4580
|
+
streamIdleTimeoutSec: deps.streamIdleTimeoutSec,
|
|
4581
|
+
streamFirstChunkTimeoutSec: deps.streamFirstChunkTimeoutSec,
|
|
2951
4582
|
subagentDepth: deps.depth + 1
|
|
2952
4583
|
});
|
|
2953
4584
|
const confirmHandler = deps.getConfirmHandler?.();
|
|
2954
4585
|
if (confirmHandler) child.confirmHandler = confirmHandler;
|
|
2955
4586
|
let toolCalls = 0;
|
|
2956
4587
|
let error = null;
|
|
4588
|
+
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
2957
4589
|
try {
|
|
2958
4590
|
for await (const event of child.stream(args.prompt, signal)) {
|
|
2959
4591
|
if (event.type === "tool-result") {
|
|
@@ -2961,10 +4593,18 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
|
|
|
2961
4593
|
} else if (event.type === "error") {
|
|
2962
4594
|
error = event.error.message;
|
|
2963
4595
|
break;
|
|
4596
|
+
} else if (event.type === "finish" && event.usage) {
|
|
4597
|
+
usage.promptTokens += event.usage.promptTokens;
|
|
4598
|
+
usage.completionTokens += event.usage.completionTokens;
|
|
4599
|
+
usage.totalTokens += event.usage.totalTokens;
|
|
4600
|
+
usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + (event.usage.cachedPromptTokens ?? 0);
|
|
4601
|
+
usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + (event.usage.cacheReadInputTokens ?? 0);
|
|
2964
4602
|
}
|
|
2965
4603
|
}
|
|
2966
4604
|
} catch (e) {
|
|
2967
4605
|
error = e instanceof Error ? e.message : String(e);
|
|
4606
|
+
} finally {
|
|
4607
|
+
if (usage.totalTokens > 0) deps.onUsage?.(usage, deps.model);
|
|
2968
4608
|
}
|
|
2969
4609
|
if (signal.aborted) {
|
|
2970
4610
|
throw new Error("Tool execution aborted.");
|
|
@@ -2997,10 +4637,10 @@ function createSubagentTool(deps) {
|
|
|
2997
4637
|
name: "subagent",
|
|
2998
4638
|
description: "Spawn a subagent with its own agent loop to handle a focused subtask (research, exploration, or an isolated change). The subagent has the same tools but cannot spawn further subagents. Returns the subagent's final report. Prefer this for self-contained work that would otherwise clutter the main conversation. Set run_in_background to start it without blocking: independent subtasks then run concurrently and their reports are delivered to you automatically when they finish (also inspectable via task_list/task_output/task_kill).",
|
|
2999
4639
|
permission: "exec",
|
|
3000
|
-
parameters:
|
|
3001
|
-
prompt:
|
|
3002
|
-
description:
|
|
3003
|
-
run_in_background:
|
|
4640
|
+
parameters: z15.object({
|
|
4641
|
+
prompt: z15.string().describe("Complete, self-contained instructions for the subagent."),
|
|
4642
|
+
description: z15.string().optional().describe("Short label for the subtask, shown in the result header."),
|
|
4643
|
+
run_in_background: z15.boolean().optional().describe(
|
|
3004
4644
|
"Run concurrently in the background and return a task id immediately (default false). Use for independent subtasks that can run in parallel."
|
|
3005
4645
|
)
|
|
3006
4646
|
}),
|
|
@@ -3026,14 +4666,14 @@ function createSubagentTool(deps) {
|
|
|
3026
4666
|
}
|
|
3027
4667
|
|
|
3028
4668
|
// src/agent/user-memory.ts
|
|
3029
|
-
import { appendFileSync, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
|
|
3030
|
-
import
|
|
3031
|
-
import { z as
|
|
4669
|
+
import { appendFileSync as appendFileSync2, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
|
|
4670
|
+
import path19 from "path";
|
|
4671
|
+
import { z as z16 } from "zod";
|
|
3032
4672
|
var USER_MEMORY_MAX_CHARS = 32 * 1024;
|
|
3033
4673
|
var REMEMBER_TEXT_MAX_CHARS = 500;
|
|
3034
4674
|
var cache3 = /* @__PURE__ */ new Map();
|
|
3035
4675
|
function readUserMemory(home = starHome()) {
|
|
3036
|
-
const file =
|
|
4676
|
+
const file = path19.join(home, "MEMORY.md");
|
|
3037
4677
|
let mtimeMs;
|
|
3038
4678
|
try {
|
|
3039
4679
|
mtimeMs = statSync4(file).mtimeMs;
|
|
@@ -3065,7 +4705,7 @@ function readUserMemory(home = starHome()) {
|
|
|
3065
4705
|
}
|
|
3066
4706
|
function appendUserMemory(text, home = starHome()) {
|
|
3067
4707
|
mkdirSync(home, { recursive: true });
|
|
3068
|
-
|
|
4708
|
+
appendFileSync2(path19.join(home, "MEMORY.md"), `- ${text}
|
|
3069
4709
|
`, "utf8");
|
|
3070
4710
|
}
|
|
3071
4711
|
function createRememberTool(deps = {}) {
|
|
@@ -3073,8 +4713,8 @@ function createRememberTool(deps = {}) {
|
|
|
3073
4713
|
name: "remember",
|
|
3074
4714
|
description: `Save a note to the user's long-term memory (${userMemoryPath()}), injected into every future session. ONLY use when the user explicitly asks to remember or save something. Never use autonomously.`,
|
|
3075
4715
|
permission: "write",
|
|
3076
|
-
parameters:
|
|
3077
|
-
text:
|
|
4716
|
+
parameters: z16.object({
|
|
4717
|
+
text: z16.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
|
|
3078
4718
|
}),
|
|
3079
4719
|
async execute(args) {
|
|
3080
4720
|
appendUserMemory(args.text, deps.home);
|
|
@@ -3085,9 +4725,10 @@ function createRememberTool(deps = {}) {
|
|
|
3085
4725
|
|
|
3086
4726
|
// src/agent/loop.ts
|
|
3087
4727
|
var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task using only the read-only tools available to you, then present a concrete, step-by-step implementation plan as your final answer. You must not modify files, run shell commands, or otherwise change the system \u2014 write/exec tools are unavailable in this mode. Do not ask the user to run commands for you; note manual steps in the plan instead. The user will review your plan and approve it before any execution begins.";
|
|
3088
|
-
var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)
|
|
4728
|
+
var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?(?!知道)|I will|I'll|I am going to|I'm going to|I shall|\blet me(?!\s+know\b)|next,? I|I will now)/i;
|
|
3089
4729
|
var AUTO_CONTINUE_NUDGE = "[auto-continue] You ended your turn with words instead of actions. Do not describe or restate the plan \u2014 continue the task NOW by calling tools. Reply with text only if the task is already fully complete.";
|
|
3090
4730
|
var EMPTY_REPLY_NUDGE = "[auto-continue] Your previous reply was empty \u2014 no text and no tool calls. If the task is fully complete, reply with one short confirmation; otherwise continue NOW by calling tools.";
|
|
4731
|
+
var TRUNCATED_CONTINUE_NUDGE = "[auto-continue] Your previous reply was cut off mid-stream by a timeout \u2014 it is incomplete. Resume EXACTLY where you stopped: do not repeat or restate what you already wrote; continue with the next tool call or the remainder of the text.";
|
|
3091
4732
|
function isFilteredEmptyFinish(finishReason) {
|
|
3092
4733
|
return finishReason === "content-filter";
|
|
3093
4734
|
}
|
|
@@ -3098,7 +4739,25 @@ function finalNudgeWarning(autoContinues, maxAutoContinues) {
|
|
|
3098
4739
|
return autoContinues >= maxAutoContinues ? " This is the final automatic continuation \u2014 if your next reply again has no tool calls, nudging stops and the turn is handed back to the user. Finish the work now or summarize what remains." : "";
|
|
3099
4740
|
}
|
|
3100
4741
|
var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
|
|
3101
|
-
|
|
4742
|
+
function attachTurnContext(messages, context) {
|
|
4743
|
+
if (!context) return messages;
|
|
4744
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4745
|
+
const message = messages[i];
|
|
4746
|
+
if (message?.role !== "user") continue;
|
|
4747
|
+
const reminder = {
|
|
4748
|
+
type: "text",
|
|
4749
|
+
text: `<system-reminder>
|
|
4750
|
+
${context}
|
|
4751
|
+
</system-reminder>`
|
|
4752
|
+
};
|
|
4753
|
+
const content = typeof message.content === "string" ? message.content ? [reminder, { type: "text", text: message.content }] : [reminder] : [reminder, ...message.content];
|
|
4754
|
+
const copy = messages.slice();
|
|
4755
|
+
copy[i] = { ...message, content };
|
|
4756
|
+
return copy;
|
|
4757
|
+
}
|
|
4758
|
+
return messages;
|
|
4759
|
+
}
|
|
4760
|
+
async function checkTaskComplete(model, request, finalReply, signal, temperature) {
|
|
3102
4761
|
const controller = new AbortController();
|
|
3103
4762
|
const timer = setTimeout(() => controller.abort(), COMPLETION_CHECK_TIMEOUT_MS);
|
|
3104
4763
|
const onAbort = () => controller.abort();
|
|
@@ -3107,6 +4766,7 @@ async function checkTaskComplete(model, request, finalReply, signal) {
|
|
|
3107
4766
|
const { text } = await generateText3({
|
|
3108
4767
|
model,
|
|
3109
4768
|
maxTokens: 8,
|
|
4769
|
+
temperature,
|
|
3110
4770
|
abortSignal: controller.signal,
|
|
3111
4771
|
prompt: [
|
|
3112
4772
|
"An AI coding agent was given this task by the user:",
|
|
@@ -3184,9 +4844,19 @@ var AgentLoop = class {
|
|
|
3184
4844
|
// consumes it as the redo label right after retractLastTurn sets it.
|
|
3185
4845
|
lastUndoneLabel = "";
|
|
3186
4846
|
// Doom-loop guard: signature of the most recent tool call and how many
|
|
3187
|
-
// times in a row it has repeated. Any different call resets the count
|
|
4847
|
+
// times in a row it has repeated. Any different call resets the count, and
|
|
4848
|
+
// each new user turn restarts the streak.
|
|
3188
4849
|
lastToolSignature = null;
|
|
3189
4850
|
repeatedToolCalls = 0;
|
|
4851
|
+
// Token usage subagents consumed, waiting to fold into this loop's next
|
|
4852
|
+
// finish event with usage: sync children report when their tool call
|
|
4853
|
+
// settles, background ones whenever they finish (drained or not).
|
|
4854
|
+
// Consumers meter only root-loop finish events, so folding here bills
|
|
4855
|
+
// subagent spend with zero consumer changes — /cost, /usage and
|
|
4856
|
+
// sessionBudgetUsd all cover it. A background child still running when the
|
|
4857
|
+
// session's last finish has passed never reports (best effort, noted in
|
|
4858
|
+
// agent-tasks cleanup paths).
|
|
4859
|
+
subagentUsage = null;
|
|
3190
4860
|
// Turn context of the in-flight stream() turn, stamped onto every tool
|
|
3191
4861
|
// execution so file snapshots attribute root changes to a turn and mark
|
|
3192
4862
|
// subagent changes as excluded from turn-level retraction.
|
|
@@ -3194,6 +4864,16 @@ var AgentLoop = class {
|
|
|
3194
4864
|
activeTurnUserIndex = -1;
|
|
3195
4865
|
titleScheduled = false;
|
|
3196
4866
|
titleInput;
|
|
4867
|
+
// Rendered git context for the in-flight turn (null when unavailable);
|
|
4868
|
+
// snapshotted at turn start so every step of the turn sends the same block.
|
|
4869
|
+
turnGitContext = null;
|
|
4870
|
+
// Lazily resolved auxiliary model (config.smallModel) for cheap side calls
|
|
4871
|
+
// — compaction summaries, session titles, completion checks. Undefined =
|
|
4872
|
+
// use the main model; a resolution failure is surfaced once as a notice.
|
|
4873
|
+
auxModelResolved = false;
|
|
4874
|
+
auxModelValue;
|
|
4875
|
+
auxTemperatureValue;
|
|
4876
|
+
auxModelError = null;
|
|
3197
4877
|
confirmHandler;
|
|
3198
4878
|
// Hook failures never block the turn (except an explicit PreToolUse block);
|
|
3199
4879
|
// their stderr surfaces through this callback (REPL system message / stderr
|
|
@@ -3226,8 +4906,12 @@ var AgentLoop = class {
|
|
|
3226
4906
|
cwd: opts.cwd,
|
|
3227
4907
|
system: opts.system,
|
|
3228
4908
|
providerMetadata: opts.providerMetadata,
|
|
4909
|
+
temperature: opts.temperature,
|
|
4910
|
+
streamIdleTimeoutSec: opts.streamIdleTimeoutSec,
|
|
4911
|
+
streamFirstChunkTimeoutSec: opts.streamFirstChunkTimeoutSec,
|
|
3229
4912
|
depth,
|
|
3230
|
-
getConfirmHandler: () => this.confirmHandler
|
|
4913
|
+
getConfirmHandler: () => this.confirmHandler,
|
|
4914
|
+
onUsage: (usage, childModel) => this.addSubagentUsage(usage, childModel)
|
|
3231
4915
|
})
|
|
3232
4916
|
);
|
|
3233
4917
|
}
|
|
@@ -3235,6 +4919,29 @@ var AgentLoop = class {
|
|
|
3235
4919
|
getMessages() {
|
|
3236
4920
|
return this.messages;
|
|
3237
4921
|
}
|
|
4922
|
+
// Auxiliary model for cheap side calls: config.smallModel, resolved once.
|
|
4923
|
+
// An unresolvable name/key degrades to the main model instead of breaking
|
|
4924
|
+
// turns; the failure rides out once as a notice at the next turn start.
|
|
4925
|
+
resolveAuxModel() {
|
|
4926
|
+
if (this.auxModelResolved) return;
|
|
4927
|
+
this.auxModelResolved = true;
|
|
4928
|
+
const name = this.opts.config.smallModel;
|
|
4929
|
+
if (!name) return;
|
|
4930
|
+
try {
|
|
4931
|
+
this.auxModelValue = createModel(this.opts.config, name);
|
|
4932
|
+
this.auxTemperatureValue = resolveModelConfig(this.opts.config, name).temperature ?? this.opts.temperature;
|
|
4933
|
+
} catch (error) {
|
|
4934
|
+
this.auxModelError = `smallModel "${name}" could not be resolved (${error instanceof Error ? error.message : String(error)}); using the main model for auxiliary calls.`;
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
getAuxModel() {
|
|
4938
|
+
this.resolveAuxModel();
|
|
4939
|
+
return this.auxModelValue ?? this.opts.model;
|
|
4940
|
+
}
|
|
4941
|
+
getAuxTemperature() {
|
|
4942
|
+
this.resolveAuxModel();
|
|
4943
|
+
return this.auxModelValue ? this.auxTemperatureValue : this.opts.temperature;
|
|
4944
|
+
}
|
|
3238
4945
|
// Rebinds the per-call provider metadata (e.g. after the reasoning effort
|
|
3239
4946
|
// for the current model changed via /model) without recreating the loop.
|
|
3240
4947
|
setProviderMetadata(metadata) {
|
|
@@ -3275,6 +4982,7 @@ var AgentLoop = class {
|
|
|
3275
4982
|
this.messages = reconcileToolCalls(messages);
|
|
3276
4983
|
this.turnMarkers = [];
|
|
3277
4984
|
this.redoStack = [];
|
|
4985
|
+
this.opts.registry?.resetVolatileState();
|
|
3278
4986
|
}
|
|
3279
4987
|
// Read-only counterpart of retractLastTurn: how many messages /undo would
|
|
3280
4988
|
// drop, which snapshot turn's file changes it would revert (undefined =
|
|
@@ -3306,6 +5014,7 @@ var AgentLoop = class {
|
|
|
3306
5014
|
this.lastUndoneLabel = messageText(this.messages[userIndex]).slice(0, REDO_LABEL_MAX);
|
|
3307
5015
|
this.messages = result.messages;
|
|
3308
5016
|
await this.opts.sessionStore?.replaceMessages([...this.messages]);
|
|
5017
|
+
this.opts.registry?.resetVolatileState();
|
|
3309
5018
|
const last = this.turnMarkers[this.turnMarkers.length - 1];
|
|
3310
5019
|
let turn;
|
|
3311
5020
|
let tree;
|
|
@@ -3377,14 +5086,19 @@ var AgentLoop = class {
|
|
|
3377
5086
|
}
|
|
3378
5087
|
// Truncates the history back to the given message index (the user message
|
|
3379
5088
|
// at that index and everything after it is dropped) and persists the
|
|
3380
|
-
// trimmed history, so /rewind stays consistent across resume.
|
|
5089
|
+
// trimmed history, so /rewind stays consistent across resume. The redo
|
|
5090
|
+
// stack dies with the rewound context: its entries describe pre-/undo
|
|
5091
|
+
// working-tree states that no longer line up with this conversation, so
|
|
5092
|
+
// restoring one would clobber the rewound tree.
|
|
3381
5093
|
async retractFromIndex(index) {
|
|
3382
5094
|
const cut = this.retractionCut(index);
|
|
3383
5095
|
const removed = this.messages.length - cut;
|
|
3384
5096
|
if (removed === 0) return 0;
|
|
3385
5097
|
this.messages = this.messages.slice(0, cut);
|
|
3386
5098
|
await this.opts.sessionStore?.replaceMessages([...this.messages]);
|
|
5099
|
+
this.opts.registry?.resetVolatileState();
|
|
3387
5100
|
this.turnMarkers = this.turnMarkers.filter((m) => m.userIndex < cut);
|
|
5101
|
+
this.redoStack = [];
|
|
3388
5102
|
return removed;
|
|
3389
5103
|
}
|
|
3390
5104
|
async appendContextMessage(text, role = "user") {
|
|
@@ -3418,7 +5132,7 @@ var AgentLoop = class {
|
|
|
3418
5132
|
const input = this.titleInput;
|
|
3419
5133
|
if (!input) return;
|
|
3420
5134
|
this.titleScheduled = true;
|
|
3421
|
-
scheduleSessionTitle(store, input, this.
|
|
5135
|
+
scheduleSessionTitle(store, input, this.getAuxModel());
|
|
3422
5136
|
}
|
|
3423
5137
|
// A user interrupt (Esc) ends the turn mid-stream, before the normal
|
|
3424
5138
|
// assistant-message persistence runs. Whatever text and tool calls already
|
|
@@ -3463,6 +5177,13 @@ var AgentLoop = class {
|
|
|
3463
5177
|
}
|
|
3464
5178
|
async *stream(input, signal, opts) {
|
|
3465
5179
|
this.syncSystemMessage();
|
|
5180
|
+
const git = getGitSummaryCached(this.opts.cwd);
|
|
5181
|
+
this.turnGitContext = git ? formatGitSummary(git) : null;
|
|
5182
|
+
this.resolveAuxModel();
|
|
5183
|
+
if (this.auxModelError) {
|
|
5184
|
+
yield { type: "notice", message: this.auxModelError };
|
|
5185
|
+
this.auxModelError = null;
|
|
5186
|
+
}
|
|
3466
5187
|
const inputText = typeof input === "string" ? input : input.text;
|
|
3467
5188
|
const images = typeof input === "string" ? [] : input.images;
|
|
3468
5189
|
if (this.titleInput === void 0 && inputText.trim().length > 0) {
|
|
@@ -3492,9 +5213,12 @@ var AgentLoop = class {
|
|
|
3492
5213
|
this.maybeScheduleTitle();
|
|
3493
5214
|
const { config, registry, cwd } = this.opts;
|
|
3494
5215
|
const aiTools = this.buildAiTools();
|
|
5216
|
+
this.lastToolSignature = null;
|
|
5217
|
+
this.repeatedToolCalls = 0;
|
|
3495
5218
|
let autoContinues = 0;
|
|
3496
5219
|
let lastWasNudge = false;
|
|
3497
5220
|
let usedToolsThisTurn = false;
|
|
5221
|
+
let streamCutContinues = 0;
|
|
3498
5222
|
let openTodos = [];
|
|
3499
5223
|
const maxSteps = config.maxSteps;
|
|
3500
5224
|
let step = 0;
|
|
@@ -3508,6 +5232,7 @@ var AgentLoop = class {
|
|
|
3508
5232
|
`Max steps (${maxSteps}) reached with no tool execution since the last checkpoint, stopping.`
|
|
3509
5233
|
)
|
|
3510
5234
|
};
|
|
5235
|
+
await this.runStopHooks("max-steps");
|
|
3511
5236
|
return;
|
|
3512
5237
|
}
|
|
3513
5238
|
toolExecutedSinceCheckpoint = false;
|
|
@@ -3536,7 +5261,9 @@ ${finished.result}`
|
|
|
3536
5261
|
);
|
|
3537
5262
|
if (compacted.compacted) {
|
|
3538
5263
|
this.messages = await this.applyCompactionSummary(compacted, signal);
|
|
5264
|
+
await this.opts.sessionStore?.replaceMessages([...this.messages]);
|
|
3539
5265
|
this.turnMarkers = [];
|
|
5266
|
+
this.opts.registry?.resetVolatileState();
|
|
3540
5267
|
}
|
|
3541
5268
|
const maxRetries = Math.max(0, config.streamMaxRetries);
|
|
3542
5269
|
const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
|
|
@@ -3546,7 +5273,9 @@ ${finished.result}`
|
|
|
3546
5273
|
let lastFinishReason;
|
|
3547
5274
|
let lastUsage;
|
|
3548
5275
|
let sawReasoning = false;
|
|
5276
|
+
let wasTruncated = false;
|
|
3549
5277
|
let oversizedImagesStripped = false;
|
|
5278
|
+
const hasOutput = () => text.trim().length > 0 || toolCalls.length > 0;
|
|
3550
5279
|
for (let attempt = 0; ; attempt++) {
|
|
3551
5280
|
text = "";
|
|
3552
5281
|
toolCalls.length = 0;
|
|
@@ -3554,6 +5283,7 @@ ${finished.result}`
|
|
|
3554
5283
|
lastFinishReason = void 0;
|
|
3555
5284
|
lastUsage = void 0;
|
|
3556
5285
|
sawReasoning = false;
|
|
5286
|
+
wasTruncated = false;
|
|
3557
5287
|
const timeoutScale = Math.min(attempt + 1, 3);
|
|
3558
5288
|
try {
|
|
3559
5289
|
for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
|
|
@@ -3568,8 +5298,9 @@ ${finished.result}`
|
|
|
3568
5298
|
yield event;
|
|
3569
5299
|
} else if (event.type === "finish") {
|
|
3570
5300
|
lastFinishReason = event.finishReason;
|
|
5301
|
+
if (event.truncated) wasTruncated = true;
|
|
3571
5302
|
lastUsage = event.usage;
|
|
3572
|
-
yield event;
|
|
5303
|
+
yield this.foldSubagentUsage(event);
|
|
3573
5304
|
} else if (event.type === "error") {
|
|
3574
5305
|
failure = event.error;
|
|
3575
5306
|
} else {
|
|
@@ -3583,9 +5314,10 @@ ${finished.result}`
|
|
|
3583
5314
|
}
|
|
3584
5315
|
if (signal.aborted) {
|
|
3585
5316
|
yield* this.persistInterrupted(text, toolCalls);
|
|
5317
|
+
await this.runStopHooks("aborted");
|
|
3586
5318
|
return;
|
|
3587
5319
|
}
|
|
3588
|
-
if (!failure && (
|
|
5320
|
+
if (!failure && hasOutput()) break;
|
|
3589
5321
|
if (failure && !oversizedImagesStripped && isOversizedImageError(failure)) {
|
|
3590
5322
|
oversizedImagesStripped = true;
|
|
3591
5323
|
const removed = await this.stripOversizedImages();
|
|
@@ -3599,7 +5331,7 @@ ${finished.result}`
|
|
|
3599
5331
|
}
|
|
3600
5332
|
if (failure && !isRetryableStreamError(failure)) break;
|
|
3601
5333
|
if (attempt >= maxRetries) break;
|
|
3602
|
-
if (!failure &&
|
|
5334
|
+
if (!failure && !hasOutput() && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
|
|
3603
5335
|
break;
|
|
3604
5336
|
const delayMs = computeRetryDelayMs(attempt, failure ? baseDelay : baseDelay * 2, failure);
|
|
3605
5337
|
yield {
|
|
@@ -3611,14 +5343,16 @@ ${finished.result}`
|
|
|
3611
5343
|
};
|
|
3612
5344
|
if (!await sleep(delayMs, signal)) {
|
|
3613
5345
|
yield* this.persistInterrupted(text, toolCalls);
|
|
5346
|
+
await this.runStopHooks("aborted");
|
|
3614
5347
|
return;
|
|
3615
5348
|
}
|
|
3616
5349
|
}
|
|
3617
5350
|
if (failure) {
|
|
3618
5351
|
yield { type: "error", error: failure };
|
|
5352
|
+
await this.runStopHooks("error");
|
|
3619
5353
|
return;
|
|
3620
5354
|
}
|
|
3621
|
-
if (
|
|
5355
|
+
if (!hasOutput()) {
|
|
3622
5356
|
if (isSteerableEmptyFinish(lastFinishReason) && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
|
|
3623
5357
|
autoContinues++;
|
|
3624
5358
|
lastWasNudge = true;
|
|
@@ -3638,6 +5372,7 @@ ${finished.result}`
|
|
|
3638
5372
|
type: "error",
|
|
3639
5373
|
error: new Error("The model returned an empty response after retries; ending the turn.")
|
|
3640
5374
|
};
|
|
5375
|
+
await this.runStopHooks("empty");
|
|
3641
5376
|
return;
|
|
3642
5377
|
}
|
|
3643
5378
|
const assistantMessage = {
|
|
@@ -3655,6 +5390,27 @@ ${finished.result}`
|
|
|
3655
5390
|
this.messages.push(assistantMessage);
|
|
3656
5391
|
await this.persist(assistantMessage);
|
|
3657
5392
|
if (toolCalls.length === 0) {
|
|
5393
|
+
if (wasTruncated) {
|
|
5394
|
+
if (config.permissionMode !== "plan" && streamCutContinues < config.streamMaxRetries) {
|
|
5395
|
+
streamCutContinues++;
|
|
5396
|
+
yield {
|
|
5397
|
+
type: "notice",
|
|
5398
|
+
message: `reply cut short by a stream timeout; asking the model to resume where it stopped (${streamCutContinues}/${config.streamMaxRetries}).`
|
|
5399
|
+
};
|
|
5400
|
+
const nudge = { role: "user", content: TRUNCATED_CONTINUE_NUDGE };
|
|
5401
|
+
this.messages.push(nudge);
|
|
5402
|
+
await this.persist(nudge);
|
|
5403
|
+
continue;
|
|
5404
|
+
}
|
|
5405
|
+
if (config.permissionMode !== "plan") {
|
|
5406
|
+
yield {
|
|
5407
|
+
type: "notice",
|
|
5408
|
+
message: 'reply was cut short by a stream timeout and resume attempts are exhausted, handing the turn back \u2014 reply "continue" to keep going.'
|
|
5409
|
+
};
|
|
5410
|
+
}
|
|
5411
|
+
await this.runStopHooks("completed");
|
|
5412
|
+
return;
|
|
5413
|
+
}
|
|
3658
5414
|
let reason = null;
|
|
3659
5415
|
let nudgeText = AUTO_CONTINUE_NUDGE;
|
|
3660
5416
|
if (openTodos.length > 0) {
|
|
@@ -3662,13 +5418,27 @@ ${finished.result}`
|
|
|
3662
5418
|
nudgeText = `[auto-continue] You ended your turn with unfinished todo item(s): ${openTodos.map((t) => `"${t}"`).join(
|
|
3663
5419
|
", "
|
|
3664
5420
|
)}. Complete them now with tool calls, or update the list with todo_write if they are no longer needed.`;
|
|
5421
|
+
} else if (usedToolsThisTurn && !signal.aborted) {
|
|
5422
|
+
const complete = await checkTaskComplete(
|
|
5423
|
+
this.getAuxModel(),
|
|
5424
|
+
inputText,
|
|
5425
|
+
text,
|
|
5426
|
+
signal,
|
|
5427
|
+
this.getAuxTemperature()
|
|
5428
|
+
);
|
|
5429
|
+
if (complete === false) {
|
|
5430
|
+
reason = "completion check reports the task unfinished";
|
|
5431
|
+
} else if (complete === null) {
|
|
5432
|
+
if (lastWasNudge) {
|
|
5433
|
+
reason = "reply still had no tool calls";
|
|
5434
|
+
} else if (PENDING_WORK_PATTERN.test(text)) {
|
|
5435
|
+
reason = "reply announced unfinished work";
|
|
5436
|
+
}
|
|
5437
|
+
}
|
|
3665
5438
|
} else if (lastWasNudge) {
|
|
3666
5439
|
reason = "reply still had no tool calls";
|
|
3667
5440
|
} else if (PENDING_WORK_PATTERN.test(text)) {
|
|
3668
5441
|
reason = "reply announced unfinished work";
|
|
3669
|
-
} else if (usedToolsThisTurn && !signal.aborted) {
|
|
3670
|
-
const complete = await checkTaskComplete(this.opts.model, inputText, text, signal);
|
|
3671
|
-
if (complete === false) reason = "completion check reports the task unfinished";
|
|
3672
5442
|
}
|
|
3673
5443
|
if (reason !== null && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
|
|
3674
5444
|
autoContinues++;
|
|
@@ -3691,7 +5461,7 @@ ${finished.result}`
|
|
|
3691
5461
|
message: `${reason}; auto-continue limit reached, handing the turn back \u2014 reply "continue" to keep going.`
|
|
3692
5462
|
};
|
|
3693
5463
|
}
|
|
3694
|
-
await this.
|
|
5464
|
+
await this.runStopHooks("completed");
|
|
3695
5465
|
return;
|
|
3696
5466
|
}
|
|
3697
5467
|
lastWasNudge = false;
|
|
@@ -3722,6 +5492,24 @@ ${finished.result}`
|
|
|
3722
5492
|
this.messages.push(toolMessage);
|
|
3723
5493
|
await this.persist(toolMessage);
|
|
3724
5494
|
answered.add(call.id);
|
|
5495
|
+
if (result.images && result.images.length > 0) {
|
|
5496
|
+
const imageMessage = {
|
|
5497
|
+
role: "user",
|
|
5498
|
+
content: [
|
|
5499
|
+
...result.images.map((img) => ({
|
|
5500
|
+
type: "image",
|
|
5501
|
+
image: img.data,
|
|
5502
|
+
mimeType: img.mimeType
|
|
5503
|
+
})),
|
|
5504
|
+
...result.images.map((img) => ({
|
|
5505
|
+
type: "text",
|
|
5506
|
+
text: `[image from ${call.name}: ${img.path}]`
|
|
5507
|
+
}))
|
|
5508
|
+
]
|
|
5509
|
+
};
|
|
5510
|
+
this.messages.push(imageMessage);
|
|
5511
|
+
await this.persist(imageMessage);
|
|
5512
|
+
}
|
|
3725
5513
|
if (call.name === "todo_write" && !result.isError) {
|
|
3726
5514
|
openTodos = pendingTodoTitles(call.args);
|
|
3727
5515
|
}
|
|
@@ -3754,21 +5542,21 @@ ${finished.result}`
|
|
|
3754
5542
|
yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
|
|
3755
5543
|
}
|
|
3756
5544
|
}
|
|
3757
|
-
if (signal.aborted)
|
|
5545
|
+
if (signal.aborted) {
|
|
5546
|
+
await this.runStopHooks("aborted");
|
|
5547
|
+
return;
|
|
5548
|
+
}
|
|
3758
5549
|
step++;
|
|
3759
5550
|
}
|
|
3760
5551
|
}
|
|
3761
5552
|
// The permission mode can change at runtime, so the system message is
|
|
3762
5553
|
// recomputed at the start of every turn instead of being frozen by the
|
|
3763
5554
|
// constructor. Also restores a system message after loadMessages() (resume,
|
|
3764
|
-
// /model switch) replaced the history with one that has none.
|
|
3765
|
-
//
|
|
3766
|
-
//
|
|
3767
|
-
//
|
|
3768
|
-
//
|
|
3769
|
-
// stays cheap per turn. The
|
|
3770
|
-
// skills listing is refreshed here as well, so skills added mid-session
|
|
3771
|
-
// show up in the next turn's system prompt.
|
|
5555
|
+
// /model switch) replaced the history with one that has none. Everything in
|
|
5556
|
+
// here is stable across turns (base prompt, plan-mode flag, memories, skills
|
|
5557
|
+
// — the latter two cached by mtime), which keeps the prompt-cache prefix
|
|
5558
|
+
// intact; volatile git state rides per-turn on the latest user message
|
|
5559
|
+
// instead (attachTurnContext).
|
|
3772
5560
|
syncSystemMessage() {
|
|
3773
5561
|
const base = this.opts.system;
|
|
3774
5562
|
const plan = this.opts.config.permissionMode === "plan";
|
|
@@ -3781,26 +5569,29 @@ ${finished.result}`
|
|
|
3781
5569
|
if (userMemory) parts.push(userMemory);
|
|
3782
5570
|
const skills = discoverSkills(this.opts.cwd);
|
|
3783
5571
|
if (skills.length > 0) parts.push(formatSkillsBlock(skills));
|
|
3784
|
-
const git = getGitSummaryCached(this.opts.cwd);
|
|
3785
|
-
if (git) parts.push(formatGitSummary(git));
|
|
3786
5572
|
if (parts.length === 0) return;
|
|
3787
5573
|
const content = parts.join("\n\n");
|
|
3788
5574
|
const head = this.messages[0];
|
|
3789
5575
|
if (head?.role === "system" && typeof head.content === "string") {
|
|
3790
|
-
if (head.content !== content)
|
|
5576
|
+
if (head.content !== content) this.messages[0] = { role: "system", content };
|
|
3791
5577
|
} else if (head?.role !== "system") {
|
|
3792
5578
|
this.messages.unshift({ role: "system", content });
|
|
3793
5579
|
}
|
|
3794
5580
|
}
|
|
3795
5581
|
async applyCompactionSummary(compacted, signal) {
|
|
3796
|
-
const { config
|
|
3797
|
-
if (config.contextCompaction !== "summary"
|
|
5582
|
+
const { config } = this.opts;
|
|
5583
|
+
if (config.contextCompaction !== "summary") {
|
|
3798
5584
|
return compacted.messages;
|
|
3799
5585
|
}
|
|
3800
5586
|
const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
|
|
3801
5587
|
const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
|
|
3802
5588
|
try {
|
|
3803
|
-
const summary = await summarizeMessages(
|
|
5589
|
+
const summary = await summarizeMessages(
|
|
5590
|
+
dropped,
|
|
5591
|
+
this.getAuxModel(),
|
|
5592
|
+
signal,
|
|
5593
|
+
this.getAuxTemperature()
|
|
5594
|
+
);
|
|
3804
5595
|
const messages = compacted.messages.slice();
|
|
3805
5596
|
messages[headCount] = {
|
|
3806
5597
|
role: "user",
|
|
@@ -3813,15 +5604,15 @@ ${summary}`
|
|
|
3813
5604
|
}
|
|
3814
5605
|
}
|
|
3815
5606
|
async *streamOnce(aiTools, signal, timeoutScale = 1) {
|
|
3816
|
-
const { streamChat } = await import("./stream-EFQPULQQ.js");
|
|
3817
5607
|
for await (const event of streamChat({
|
|
3818
5608
|
model: this.opts.model,
|
|
3819
|
-
messages: this.messages,
|
|
5609
|
+
messages: attachTurnContext(this.messages, this.turnGitContext),
|
|
3820
5610
|
tools: aiTools,
|
|
3821
5611
|
abortSignal: signal,
|
|
3822
5612
|
providerMetadata: this.opts.providerMetadata,
|
|
3823
|
-
idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3 * timeoutScale,
|
|
3824
|
-
firstPartTimeoutMs: this.opts.config.streamFirstChunkTimeoutSec * 1e3 * timeoutScale
|
|
5613
|
+
idleTimeoutMs: (this.opts.streamIdleTimeoutSec ?? this.opts.config.streamIdleTimeoutSec) * 1e3 * timeoutScale,
|
|
5614
|
+
firstPartTimeoutMs: (this.opts.streamFirstChunkTimeoutSec ?? this.opts.config.streamFirstChunkTimeoutSec) * 1e3 * timeoutScale,
|
|
5615
|
+
temperature: this.opts.temperature
|
|
3825
5616
|
})) {
|
|
3826
5617
|
if (event.type === "finish" && event.truncated) {
|
|
3827
5618
|
yield {
|
|
@@ -3844,6 +5635,14 @@ ${summary}`
|
|
|
3844
5635
|
}
|
|
3845
5636
|
return tools;
|
|
3846
5637
|
}
|
|
5638
|
+
// Fires the Stop hooks for a turn's end, on every termination path (normal
|
|
5639
|
+
// completion, Esc abort, exhausted stream retries, terminal empty replies,
|
|
5640
|
+
// the maxSteps no-progress stop). The reason rides in STAR_TOOL_INPUT as
|
|
5641
|
+
// {"reason": ...}: Stop hooks ignore tool matchers and the variable was
|
|
5642
|
+
// never set for Stop before, so existing hooks are unaffected.
|
|
5643
|
+
async runStopHooks(reason) {
|
|
5644
|
+
await this.runEventHooks("Stop", void 0, { reason });
|
|
5645
|
+
}
|
|
3847
5646
|
async runEventHooks(event, toolName, toolInput) {
|
|
3848
5647
|
const empty = { blocked: false, warnings: [] };
|
|
3849
5648
|
if (this.opts.config.hooks.length === 0) return empty;
|
|
@@ -3860,6 +5659,47 @@ ${summary}`
|
|
|
3860
5659
|
return empty;
|
|
3861
5660
|
}
|
|
3862
5661
|
}
|
|
5662
|
+
// Accumulates a settled subagent's usage. When the child provably ran on a
|
|
5663
|
+
// different, fully priced model, the counts are first converted into
|
|
5664
|
+
// this-model-priced equivalents (convertUsagePricing): session cost is
|
|
5665
|
+
// token totals × the active model's price, so the conversion keeps the $
|
|
5666
|
+
// estimate accurate where raw sums would misprice.
|
|
5667
|
+
addSubagentUsage(usage, childModel) {
|
|
5668
|
+
const converted = convertUsagePricing(usage, childModel, this.opts.model, this.opts.config);
|
|
5669
|
+
const pending = this.subagentUsage ?? {
|
|
5670
|
+
promptTokens: 0,
|
|
5671
|
+
completionTokens: 0,
|
|
5672
|
+
totalTokens: 0
|
|
5673
|
+
};
|
|
5674
|
+
pending.promptTokens += converted.promptTokens;
|
|
5675
|
+
pending.completionTokens += converted.completionTokens;
|
|
5676
|
+
pending.totalTokens += converted.totalTokens;
|
|
5677
|
+
if (converted.cachedPromptTokens) {
|
|
5678
|
+
pending.cachedPromptTokens = (pending.cachedPromptTokens ?? 0) + converted.cachedPromptTokens;
|
|
5679
|
+
}
|
|
5680
|
+
if (converted.cacheReadInputTokens) {
|
|
5681
|
+
pending.cacheReadInputTokens = (pending.cacheReadInputTokens ?? 0) + converted.cacheReadInputTokens;
|
|
5682
|
+
}
|
|
5683
|
+
this.subagentUsage = pending;
|
|
5684
|
+
}
|
|
5685
|
+
// Folds pending subagent usage into an outgoing finish event (and clears
|
|
5686
|
+
// it). A finish without usage reports nothing to consumers, so the pending
|
|
5687
|
+
// amount is kept for the next finish that carries usage instead.
|
|
5688
|
+
foldSubagentUsage(event) {
|
|
5689
|
+
const pending = this.subagentUsage;
|
|
5690
|
+
if (!pending || !event.usage) return event;
|
|
5691
|
+
this.subagentUsage = null;
|
|
5692
|
+
const usage = {
|
|
5693
|
+
promptTokens: event.usage.promptTokens + pending.promptTokens,
|
|
5694
|
+
completionTokens: event.usage.completionTokens + pending.completionTokens,
|
|
5695
|
+
totalTokens: event.usage.totalTokens + pending.totalTokens
|
|
5696
|
+
};
|
|
5697
|
+
const cachedPromptTokens = (event.usage.cachedPromptTokens ?? 0) + (pending.cachedPromptTokens ?? 0);
|
|
5698
|
+
const cacheReadInputTokens = (event.usage.cacheReadInputTokens ?? 0) + (pending.cacheReadInputTokens ?? 0);
|
|
5699
|
+
if (cachedPromptTokens > 0) usage.cachedPromptTokens = cachedPromptTokens;
|
|
5700
|
+
if (cacheReadInputTokens > 0) usage.cacheReadInputTokens = cacheReadInputTokens;
|
|
5701
|
+
return { ...event, usage };
|
|
5702
|
+
}
|
|
3863
5703
|
// opencode's doom_loop guard: returns a refusal once the identical tool
|
|
3864
5704
|
// call (name + stable-serialized args) has repeated config.doomLoopThreshold
|
|
3865
5705
|
// times in a row; 0 disables. Any different signature resets the streak.
|
|
@@ -3956,17 +5796,28 @@ export {
|
|
|
3956
5796
|
compactMessages,
|
|
3957
5797
|
WORKING_DIFF_MAX_LINES,
|
|
3958
5798
|
isGitRepo,
|
|
3959
|
-
|
|
5799
|
+
getGitSummaryCached,
|
|
3960
5800
|
collectCommitContext,
|
|
3961
5801
|
buildCommitPrompt,
|
|
3962
5802
|
collectWorkingDiff,
|
|
3963
5803
|
MAX_IMAGE_DIMENSION,
|
|
3964
|
-
probeImageDimensions,
|
|
3965
5804
|
reconcileToolCalls,
|
|
5805
|
+
listModels,
|
|
5806
|
+
resolveModelConfig,
|
|
5807
|
+
createModel,
|
|
5808
|
+
reasoningEffortMetadata,
|
|
5809
|
+
starHome,
|
|
5810
|
+
globalConfigPath,
|
|
5811
|
+
sessionsDir,
|
|
5812
|
+
userMemoryPath,
|
|
5813
|
+
envFilePath,
|
|
5814
|
+
projectConfigPath,
|
|
3966
5815
|
isSensitivePath,
|
|
3967
5816
|
isAllowedByRules,
|
|
3968
5817
|
buildAllowRule,
|
|
3969
5818
|
isDangerousCommand,
|
|
5819
|
+
removeTreeRepo,
|
|
5820
|
+
pruneTreeReposExcept,
|
|
3970
5821
|
diffTreeNames,
|
|
3971
5822
|
listSnapshots,
|
|
3972
5823
|
clearSnapshots,
|
|
@@ -3979,6 +5830,7 @@ export {
|
|
|
3979
5830
|
bashTool,
|
|
3980
5831
|
SKIP_DIRS,
|
|
3981
5832
|
createIgnorePredicate,
|
|
5833
|
+
downsampleImageIfNeeded,
|
|
3982
5834
|
formatTaskList,
|
|
3983
5835
|
formatTaskStarted,
|
|
3984
5836
|
formatTaskFinished,
|
|
@@ -3991,6 +5843,7 @@ export {
|
|
|
3991
5843
|
createDefaultRegistry,
|
|
3992
5844
|
appendUserMemory,
|
|
3993
5845
|
PLAN_MODE_PROMPT,
|
|
5846
|
+
attachTurnContext,
|
|
3994
5847
|
AgentLoop
|
|
3995
5848
|
};
|
|
3996
|
-
//# sourceMappingURL=chunk-
|
|
5849
|
+
//# sourceMappingURL=chunk-OHLA2DP7.js.map
|