@cryer/star-cli 0.3.7 → 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.
@@ -75,6 +75,10 @@ var HookConfigSchema = z.object({
75
75
  });
76
76
  var ConfigSchema = z.object({
77
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(),
78
82
  permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
79
83
  providers: z.array(ProviderConfigSchema).default([]),
80
84
  models: z.array(ModelConfigSchema).default([]),
@@ -219,6 +223,16 @@ var SUMMARY_SYSTEM_PROMPT = [
219
223
  "- outstanding TODOs and next steps",
220
224
  "Output only the summary, no preamble."
221
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
+ }
222
236
  function serializeMessage(message) {
223
237
  if (typeof message.content === "string") {
224
238
  return `${message.role}: ${message.content}`;
@@ -234,7 +248,7 @@ function serializeMessage(message) {
234
248
  return `${message.role}: ${parts.join("\n")}`;
235
249
  }
236
250
  async function summarizeMessages(messages, model, signal, temperature) {
237
- const transcript = messages.map(serializeMessage).join("\n");
251
+ const transcript = truncateSummaryInput(messages.map(serializeMessage).join("\n"));
238
252
  const { text } = await generateText({
239
253
  model,
240
254
  system: SUMMARY_SYSTEM_PROMPT,
@@ -710,6 +724,241 @@ async function runHooks(event, hooks2, ctx) {
710
724
  return result;
711
725
  }
712
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
+
713
962
  // src/llm/retry.ts
714
963
  var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
715
964
  "AI_NoSuchToolError",
@@ -788,45 +1037,6 @@ function summarizeStreamError(error) {
788
1037
  // src/llm/stream.ts
789
1038
  import { streamText } from "ai";
790
1039
 
791
- // src/llm/activity.ts
792
- var trackers = /* @__PURE__ */ new WeakMap();
793
- function streamActivity(signal) {
794
- return trackers.get(signal);
795
- }
796
- function wrapFetchWithActivity(baseFetch = fetch) {
797
- return async (input, init) => {
798
- const response = await baseFetch(input, init);
799
- const signal = init?.signal ?? (input instanceof Request ? input.signal : null);
800
- if (!signal || !response.body) return response;
801
- const tracker = { lastChunkAt: Date.now() };
802
- trackers.set(signal, tracker);
803
- const reader = response.body.getReader();
804
- const body = new ReadableStream({
805
- async pull(controller) {
806
- try {
807
- const { done, value } = await reader.read();
808
- if (done) {
809
- controller.close();
810
- return;
811
- }
812
- tracker.lastChunkAt = Date.now();
813
- controller.enqueue(value);
814
- } catch (error) {
815
- controller.error(error);
816
- }
817
- },
818
- cancel(reason) {
819
- return reader.cancel(reason);
820
- }
821
- });
822
- return new Response(body, {
823
- status: response.status,
824
- statusText: response.statusText,
825
- headers: response.headers
826
- });
827
- };
828
- }
829
-
830
1040
  // src/llm/debug.ts
831
1041
  import { appendFileSync } from "fs";
832
1042
  import path2 from "path";
@@ -874,6 +1084,24 @@ var GENERATION_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "reasoning",
874
1084
  var MAX_KEEPALIVE_SILENCE_MS = 10 * 6e4;
875
1085
  var CACHE_READ_KEYS = ["cacheReadInputTokens", "cache_read_input_tokens"];
876
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
+ }
877
1105
  function extractCacheUsage(providerMetadata) {
878
1106
  if (!providerMetadata || typeof providerMetadata !== "object") return {};
879
1107
  for (const value of Object.values(providerMetadata)) {
@@ -898,7 +1126,7 @@ async function* streamChat(opts) {
898
1126
  const { openai: openaiMetadata, ...otherMetadata } = opts.providerMetadata ?? {};
899
1127
  const result = streamText({
900
1128
  model: opts.model,
901
- messages: opts.messages,
1129
+ messages: withAnthropicCacheBreakpoints(opts.model, opts.messages),
902
1130
  tools: opts.tools,
903
1131
  abortSignal: controller.signal,
904
1132
  maxTokens: opts.maxTokens,
@@ -2979,74 +3207,108 @@ var schema5 = z6.object({
2979
3207
  offset: z6.number().int().positive().optional().describe("1-based line number to start reading from"),
2980
3208
  limit: z6.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
2981
3209
  });
2982
- var readFileTool = {
2983
- name: "read_file",
2984
- description: "Read a text file and return its contents with line numbers. Output is truncated to 2000 lines or 100KB by default.",
2985
- permission: "read",
2986
- parameters: schema5,
2987
- async execute(args, ctx) {
2988
- const filePath = path11.resolve(ctx.cwd, args.path);
2989
- const resolvedPath = await realpath2(filePath).catch(() => null);
2990
- if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
2991
- return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
2992
- }
2993
- const st = await stat5(filePath).catch(() => null);
2994
- if (!st) {
2995
- return { content: `File not found: ${args.path}`, isError: true };
2996
- }
2997
- if (st.isDirectory()) {
2998
- return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
2999
- }
3000
- const bytesToRead = Math.min(st.size, MAX_READ_BYTES);
3001
- const fileCapped = st.size > MAX_READ_BYTES;
3002
- const handle = await open(filePath, "r").catch(() => null);
3003
- if (!handle) {
3004
- return { content: `Failed to read ${args.path}`, isError: true };
3005
- }
3006
- let buf;
3007
- try {
3008
- const { bytesRead, buffer } = await handle.read(Buffer.alloc(bytesToRead), 0, bytesToRead, 0);
3009
- buf = buffer.subarray(0, bytesRead);
3010
- } finally {
3011
- await handle.close().catch(() => {
3012
- });
3013
- }
3014
- const decoded = decodeTextBuffer(buf);
3015
- if ("binary" in decoded) {
3016
- return { content: `Cannot read binary file: ${args.path}`, isError: true };
3017
- }
3018
- const lines = decoded.text.split("\n");
3019
- const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
3020
- const start = args.offset ?? 1;
3021
- const limit = args.limit ?? DEFAULT_LIMIT;
3022
- if (start > lines.length) {
3023
- return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
3024
- }
3025
- const end = Math.min(lines.length, start - 1 + limit);
3026
- const out = [];
3027
- let size = 0;
3028
- let truncatedBySize = false;
3029
- for (let i = start - 1; i < end; i++) {
3030
- const line = (lines[i] ?? "").replace(/\r$/, "");
3031
- const numbered = `${i + 1} ${line}`;
3032
- if (size + numbered.length > MAX_CHARS) {
3033
- truncatedBySize = true;
3034
- break;
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 };
3035
3224
  }
3036
- out.push(numbered);
3037
- size += numbered.length;
3038
- }
3039
- const lastShown = start - 1 + out.length;
3040
- if (truncatedBySize) {
3041
- out.push(
3042
- `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
3043
- );
3044
- } else if (end < lines.length || fileCapped) {
3045
- out.push(`... (truncated: showing lines ${start}-${end} of ${totalDesc})`);
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") };
3046
3308
  }
3047
- return { content: out.join("\n") };
3048
- }
3049
- };
3309
+ };
3310
+ }
3311
+ var readFileTool = createReadFileTool();
3050
3312
 
3051
3313
  // src/tools/fs/read-image.ts
3052
3314
  import { readFile as readFile5, realpath as realpath3, stat as stat6 } from "fs/promises";
@@ -3274,9 +3536,160 @@ var writeFileTool = {
3274
3536
  }
3275
3537
  };
3276
3538
 
3277
- // src/tools/tasks.ts
3539
+ // src/tools/screenshot.ts
3278
3540
  import { z as z9 } from "zod";
3279
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
+
3690
+ // src/tools/tasks.ts
3691
+ import { z as z10 } from "zod";
3692
+
3280
3693
  // src/agent/agent-tasks.ts
3281
3694
  import { EventEmitter as EventEmitter2 } from "events";
3282
3695
  var MAX_FINISHED_RECORDS2 = 50;
@@ -3446,8 +3859,8 @@ function formatTaskFinished(task) {
3446
3859
  }
3447
3860
 
3448
3861
  // src/tools/tasks.ts
3449
- var idSchema = z9.object({
3450
- id: z9.string().describe("Task id, e.g. task-1 or agent-1")
3862
+ var idSchema = z10.object({
3863
+ id: z10.string().describe("Task id, e.g. task-1 or agent-1")
3451
3864
  });
3452
3865
  function truncateOutput(s) {
3453
3866
  if (s.length <= MAX_OUTPUT) return s;
@@ -3464,7 +3877,7 @@ var taskListTool = {
3464
3877
  name: "task_list",
3465
3878
  description: "List background tasks (shell commands and subagents) with status and runtime.",
3466
3879
  permission: "read",
3467
- parameters: z9.object({}),
3880
+ parameters: z10.object({}),
3468
3881
  execute() {
3469
3882
  const shell = formatTaskList(defaultTaskManager.list());
3470
3883
  const agents = defaultAgentTasks.list();
@@ -3523,20 +3936,20 @@ var taskKillTool = {
3523
3936
  };
3524
3937
 
3525
3938
  // src/tools/todo.ts
3526
- import { mkdir as mkdir4, readFile as readFile6, rename, writeFile as writeFile6 } from "fs/promises";
3527
- import path15 from "path";
3528
- import { z as z10 } from "zod";
3529
- var todoItemSchema = z10.object({
3530
- id: z10.number().int(),
3531
- title: z10.string(),
3532
- status: z10.enum(["pending", "in_progress", "done"])
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"])
3533
3946
  });
3534
- var writeSchema = z10.object({
3535
- todos: z10.array(todoItemSchema).describe("The full todo list, replacing the current one")
3947
+ var writeSchema = z11.object({
3948
+ todos: z11.array(todoItemSchema).describe("The full todo list, replacing the current one")
3536
3949
  });
3537
- var readSchema = z10.object({});
3950
+ var readSchema = z11.object({});
3538
3951
  function fileFor(cwd) {
3539
- return path15.join(cwd, ".star", "todos.json");
3952
+ return path16.join(cwd, ".star", "todos.json");
3540
3953
  }
3541
3954
  var persistGuard = () => true;
3542
3955
  function setTodoPersistGuard(guard) {
@@ -3556,7 +3969,7 @@ var TodoStore = class {
3556
3969
  async load(cwd) {
3557
3970
  let raw;
3558
3971
  try {
3559
- raw = await readFile6(fileFor(cwd), "utf8");
3972
+ raw = await readFile7(fileFor(cwd), "utf8");
3560
3973
  } catch {
3561
3974
  return;
3562
3975
  }
@@ -3569,15 +3982,22 @@ var TodoStore = class {
3569
3982
  }
3570
3983
  }
3571
3984
  // Atomic write (tmp + rename): a crash or failed save can never leave a
3572
- // 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.
3573
3988
  async save(cwd) {
3574
3989
  if (!persistGuard()) {
3575
3990
  return;
3576
3991
  }
3577
- await mkdir4(path15.join(cwd, ".star"), { recursive: true });
3992
+ const items = this.list();
3578
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 });
3579
3999
  const tmp = `${file}.tmp`;
3580
- await writeFile6(tmp, `${JSON.stringify(this.list(), null, 2)}
4000
+ await writeFile6(tmp, `${JSON.stringify(items, null, 2)}
3581
4001
  `);
3582
4002
  await rename(tmp, file);
3583
4003
  }
@@ -3650,13 +4070,13 @@ function createTodoTools(store = defaultStore) {
3650
4070
  }
3651
4071
 
3652
4072
  // src/tools/web/fetch.ts
3653
- import { z as z11 } from "zod";
4073
+ import { z as z12 } from "zod";
3654
4074
  var DEFAULT_MAX_CHARS = 2e4;
3655
4075
  var MAX_BYTES = 2 * 1024 * 1024;
3656
4076
  var TIMEOUT_MS = 3e4;
3657
- var schema8 = z11.object({
3658
- url: z11.string().describe("The http(s) URL to fetch"),
3659
- maxChars: z11.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
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)")
3660
4080
  });
3661
4081
  function safeCodePoint(n, raw) {
3662
4082
  if (!Number.isInteger(n) || n < 0 || n > 1114111) {
@@ -3727,7 +4147,7 @@ var webFetchTool = {
3727
4147
  name: "web_fetch",
3728
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.",
3729
4149
  permission: "read",
3730
- parameters: schema8,
4150
+ parameters: schema9,
3731
4151
  async execute(args, ctx) {
3732
4152
  let url;
3733
4153
  try {
@@ -3790,15 +4210,15 @@ var webFetchTool = {
3790
4210
  };
3791
4211
 
3792
4212
  // src/tools/web/search.ts
3793
- import { z as z12 } from "zod";
4213
+ import { z as z13 } from "zod";
3794
4214
  var TIMEOUT_MS2 = 15e3;
3795
4215
  var DEFAULT_MAX_RESULTS = 5;
3796
4216
  var MAX_RESULTS_LIMIT = 10;
3797
4217
  var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
3798
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";
3799
- var schema9 = z12.object({
3800
- query: z12.string().min(1).describe("The search query"),
3801
- maxResults: z12.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
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(
3802
4222
  `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
3803
4223
  )
3804
4224
  });
@@ -3849,7 +4269,7 @@ var webSearchTool = {
3849
4269
  name: "web_search",
3850
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.",
3851
4271
  permission: "read",
3852
- parameters: schema9,
4272
+ parameters: schema10,
3853
4273
  async execute(args, ctx) {
3854
4274
  const maxResults = Math.min(args.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT);
3855
4275
  const url = `${SEARCH_URL}${encodeURIComponent(args.query)}`;
@@ -3911,8 +4331,11 @@ var ToolRegistry = class {
3911
4331
  // their own instance so a child's todo_write cannot clobber the parent list.
3912
4332
  constructor(todoStore) {
3913
4333
  for (const tool of [
3914
- readFileTool,
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(),
3915
4337
  readImageTool,
4338
+ screenshotTool,
3916
4339
  writeFileTool,
3917
4340
  editFileTool,
3918
4341
  globTool,
@@ -3931,6 +4354,12 @@ var ToolRegistry = class {
3931
4354
  register(tool) {
3932
4355
  this.tools.set(tool.name, tool);
3933
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
+ }
3934
4363
  get(name) {
3935
4364
  return this.tools.get(name);
3936
4365
  }
@@ -3944,12 +4373,12 @@ var ToolRegistry = class {
3944
4373
 
3945
4374
  // src/agent/project-memory.ts
3946
4375
  import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
3947
- import path16 from "path";
4376
+ import path17 from "path";
3948
4377
  var PROJECT_MEMORY_FILE = "AGENTS.md";
3949
4378
  var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
3950
4379
  var cache = /* @__PURE__ */ new Map();
3951
4380
  function readProjectMemory(cwd) {
3952
- const file = path16.join(cwd, PROJECT_MEMORY_FILE);
4381
+ const file = path17.join(cwd, PROJECT_MEMORY_FILE);
3953
4382
  let mtimeMs;
3954
4383
  try {
3955
4384
  mtimeMs = statSync2(file).mtimeMs;
@@ -3979,8 +4408,8 @@ ${body}`;
3979
4408
 
3980
4409
  // src/agent/skills.ts
3981
4410
  import { readFileSync as readFileSync3, readdirSync, statSync as statSync3 } from "fs";
3982
- import path17 from "path";
3983
- import { z as z13 } from "zod";
4411
+ import path18 from "path";
4412
+ import { z as z14 } from "zod";
3984
4413
  var SKILL_FILE = "SKILL.md";
3985
4414
  var SKILL_MAX_CHARS = 32 * 1024;
3986
4415
  var NAME_PATTERN = /^[a-z0-9-]+$/;
@@ -4010,7 +4439,7 @@ function parseSkillFile(filePath, dirName, scope) {
4010
4439
  const name = parsed?.meta.name || dirName;
4011
4440
  const description = parsed?.meta.description;
4012
4441
  if (parsed && NAME_PATTERN.test(name) && description) {
4013
- skill = { name, description, filePath, dir: path17.dirname(filePath), scope };
4442
+ skill = { name, description, filePath, dir: path18.dirname(filePath), scope };
4014
4443
  }
4015
4444
  } catch {
4016
4445
  skill = null;
@@ -4027,14 +4456,14 @@ function scanDir(base, scope, into) {
4027
4456
  }
4028
4457
  for (const entry of entries.sort()) {
4029
4458
  if (!NAME_PATTERN.test(entry)) continue;
4030
- const parsed = parseSkillFile(path17.join(base, entry, SKILL_FILE), entry, scope);
4459
+ const parsed = parseSkillFile(path18.join(base, entry, SKILL_FILE), entry, scope);
4031
4460
  if (parsed) into.set(parsed.name, parsed);
4032
4461
  }
4033
4462
  }
4034
4463
  function discoverSkills(cwd, home = starHome()) {
4035
4464
  const byName = /* @__PURE__ */ new Map();
4036
- scanDir(path17.join(home, "skills"), "user", byName);
4037
- scanDir(path17.join(cwd, ".star", "skills"), "project", byName);
4465
+ scanDir(path18.join(home, "skills"), "user", byName);
4466
+ scanDir(path18.join(cwd, ".star", "skills"), "project", byName);
4038
4467
  return [...byName.values()];
4039
4468
  }
4040
4469
  function formatSkillsBlock(skills) {
@@ -4065,8 +4494,8 @@ function createSkillTool(deps) {
4065
4494
  name: "skill",
4066
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.",
4067
4496
  permission: "read",
4068
- parameters: z13.object({
4069
- name: z13.string().describe("Name of the skill to load, as listed in the system prompt.")
4497
+ parameters: z14.object({
4498
+ name: z14.string().describe("Name of the skill to load, as listed in the system prompt.")
4070
4499
  }),
4071
4500
  async execute(args) {
4072
4501
  const skills = discoverSkills(deps.cwd, deps.home);
@@ -4096,7 +4525,7 @@ function createSkillTool(deps) {
4096
4525
  }
4097
4526
 
4098
4527
  // src/agent/subagent.ts
4099
- import { z as z14 } from "zod";
4528
+ import { z as z15 } from "zod";
4100
4529
 
4101
4530
  // src/tools/index.ts
4102
4531
  function createDefaultRegistry(todoStore) {
@@ -4137,7 +4566,7 @@ function convertUsagePricing(usage, from, to, config) {
4137
4566
  return result;
4138
4567
  }
4139
4568
  async function runSubagent(deps, args, cwd, signal) {
4140
- const { AgentLoop: AgentLoop2 } = await import("./loop-DVVPIOS7.js");
4569
+ const { AgentLoop: AgentLoop2 } = await import("./loop-2MOFLXTG.js");
4141
4570
  const child = new AgentLoop2({
4142
4571
  model: deps.model,
4143
4572
  registry: createDefaultRegistry(),
@@ -4208,10 +4637,10 @@ function createSubagentTool(deps) {
4208
4637
  name: "subagent",
4209
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).",
4210
4639
  permission: "exec",
4211
- parameters: z14.object({
4212
- prompt: z14.string().describe("Complete, self-contained instructions for the subagent."),
4213
- description: z14.string().optional().describe("Short label for the subtask, shown in the result header."),
4214
- run_in_background: z14.boolean().optional().describe(
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(
4215
4644
  "Run concurrently in the background and return a task id immediately (default false). Use for independent subtasks that can run in parallel."
4216
4645
  )
4217
4646
  }),
@@ -4238,13 +4667,13 @@ function createSubagentTool(deps) {
4238
4667
 
4239
4668
  // src/agent/user-memory.ts
4240
4669
  import { appendFileSync as appendFileSync2, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
4241
- import path18 from "path";
4242
- import { z as z15 } from "zod";
4670
+ import path19 from "path";
4671
+ import { z as z16 } from "zod";
4243
4672
  var USER_MEMORY_MAX_CHARS = 32 * 1024;
4244
4673
  var REMEMBER_TEXT_MAX_CHARS = 500;
4245
4674
  var cache3 = /* @__PURE__ */ new Map();
4246
4675
  function readUserMemory(home = starHome()) {
4247
- const file = path18.join(home, "MEMORY.md");
4676
+ const file = path19.join(home, "MEMORY.md");
4248
4677
  let mtimeMs;
4249
4678
  try {
4250
4679
  mtimeMs = statSync4(file).mtimeMs;
@@ -4276,7 +4705,7 @@ function readUserMemory(home = starHome()) {
4276
4705
  }
4277
4706
  function appendUserMemory(text, home = starHome()) {
4278
4707
  mkdirSync(home, { recursive: true });
4279
- appendFileSync2(path18.join(home, "MEMORY.md"), `- ${text}
4708
+ appendFileSync2(path19.join(home, "MEMORY.md"), `- ${text}
4280
4709
  `, "utf8");
4281
4710
  }
4282
4711
  function createRememberTool(deps = {}) {
@@ -4284,8 +4713,8 @@ function createRememberTool(deps = {}) {
4284
4713
  name: "remember",
4285
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.`,
4286
4715
  permission: "write",
4287
- parameters: z15.object({
4288
- text: z15.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
4716
+ parameters: z16.object({
4717
+ text: z16.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
4289
4718
  }),
4290
4719
  async execute(args) {
4291
4720
  appendUserMemory(args.text, deps.home);
@@ -4310,6 +4739,24 @@ function finalNudgeWarning(autoContinues, maxAutoContinues) {
4310
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." : "";
4311
4740
  }
4312
4741
  var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
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
+ }
4313
4760
  async function checkTaskComplete(model, request, finalReply, signal, temperature) {
4314
4761
  const controller = new AbortController();
4315
4762
  const timer = setTimeout(() => controller.abort(), COMPLETION_CHECK_TIMEOUT_MS);
@@ -4417,6 +4864,16 @@ var AgentLoop = class {
4417
4864
  activeTurnUserIndex = -1;
4418
4865
  titleScheduled = false;
4419
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;
4420
4877
  confirmHandler;
4421
4878
  // Hook failures never block the turn (except an explicit PreToolUse block);
4422
4879
  // their stderr surfaces through this callback (REPL system message / stderr
@@ -4462,6 +4919,29 @@ var AgentLoop = class {
4462
4919
  getMessages() {
4463
4920
  return this.messages;
4464
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
+ }
4465
4945
  // Rebinds the per-call provider metadata (e.g. after the reasoning effort
4466
4946
  // for the current model changed via /model) without recreating the loop.
4467
4947
  setProviderMetadata(metadata) {
@@ -4502,6 +4982,7 @@ var AgentLoop = class {
4502
4982
  this.messages = reconcileToolCalls(messages);
4503
4983
  this.turnMarkers = [];
4504
4984
  this.redoStack = [];
4985
+ this.opts.registry?.resetVolatileState();
4505
4986
  }
4506
4987
  // Read-only counterpart of retractLastTurn: how many messages /undo would
4507
4988
  // drop, which snapshot turn's file changes it would revert (undefined =
@@ -4533,6 +5014,7 @@ var AgentLoop = class {
4533
5014
  this.lastUndoneLabel = messageText(this.messages[userIndex]).slice(0, REDO_LABEL_MAX);
4534
5015
  this.messages = result.messages;
4535
5016
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
5017
+ this.opts.registry?.resetVolatileState();
4536
5018
  const last = this.turnMarkers[this.turnMarkers.length - 1];
4537
5019
  let turn;
4538
5020
  let tree;
@@ -4614,6 +5096,7 @@ var AgentLoop = class {
4614
5096
  if (removed === 0) return 0;
4615
5097
  this.messages = this.messages.slice(0, cut);
4616
5098
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
5099
+ this.opts.registry?.resetVolatileState();
4617
5100
  this.turnMarkers = this.turnMarkers.filter((m) => m.userIndex < cut);
4618
5101
  this.redoStack = [];
4619
5102
  return removed;
@@ -4649,7 +5132,7 @@ var AgentLoop = class {
4649
5132
  const input = this.titleInput;
4650
5133
  if (!input) return;
4651
5134
  this.titleScheduled = true;
4652
- scheduleSessionTitle(store, input, this.opts.model);
5135
+ scheduleSessionTitle(store, input, this.getAuxModel());
4653
5136
  }
4654
5137
  // A user interrupt (Esc) ends the turn mid-stream, before the normal
4655
5138
  // assistant-message persistence runs. Whatever text and tool calls already
@@ -4694,6 +5177,13 @@ var AgentLoop = class {
4694
5177
  }
4695
5178
  async *stream(input, signal, opts) {
4696
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
+ }
4697
5187
  const inputText = typeof input === "string" ? input : input.text;
4698
5188
  const images = typeof input === "string" ? [] : input.images;
4699
5189
  if (this.titleInput === void 0 && inputText.trim().length > 0) {
@@ -4773,6 +5263,7 @@ ${finished.result}`
4773
5263
  this.messages = await this.applyCompactionSummary(compacted, signal);
4774
5264
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
4775
5265
  this.turnMarkers = [];
5266
+ this.opts.registry?.resetVolatileState();
4776
5267
  }
4777
5268
  const maxRetries = Math.max(0, config.streamMaxRetries);
4778
5269
  const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
@@ -4929,11 +5420,11 @@ ${finished.result}`
4929
5420
  )}. Complete them now with tool calls, or update the list with todo_write if they are no longer needed.`;
4930
5421
  } else if (usedToolsThisTurn && !signal.aborted) {
4931
5422
  const complete = await checkTaskComplete(
4932
- this.opts.model,
5423
+ this.getAuxModel(),
4933
5424
  inputText,
4934
5425
  text,
4935
5426
  signal,
4936
- this.opts.temperature
5427
+ this.getAuxTemperature()
4937
5428
  );
4938
5429
  if (complete === false) {
4939
5430
  reason = "completion check reports the task unfinished";
@@ -5061,14 +5552,11 @@ ${finished.result}`
5061
5552
  // The permission mode can change at runtime, so the system message is
5062
5553
  // recomputed at the start of every turn instead of being frozen by the
5063
5554
  // constructor. Also restores a system message after loadMessages() (resume,
5064
- // /model switch) replaced the history with one that has none. Git context
5065
- // (branch, dirty count, recent commits) is refreshed here too, so the model
5066
- // always sees the current repo state; any git failure is silently skipped.
5067
- // Project memory (AGENTS.md in the cwd) and user memory (~/.star-cli/
5068
- // MEMORY.md) are appended as delimited blocks; both cache by mtime, so this
5069
- // stays cheap per turn. The
5070
- // skills listing is refreshed here as well, so skills added mid-session
5071
- // 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).
5072
5560
  syncSystemMessage() {
5073
5561
  const base = this.opts.system;
5074
5562
  const plan = this.opts.config.permissionMode === "plan";
@@ -5081,8 +5569,6 @@ ${finished.result}`
5081
5569
  if (userMemory) parts.push(userMemory);
5082
5570
  const skills = discoverSkills(this.opts.cwd);
5083
5571
  if (skills.length > 0) parts.push(formatSkillsBlock(skills));
5084
- const git = getGitSummaryCached(this.opts.cwd);
5085
- if (git) parts.push(formatGitSummary(git));
5086
5572
  if (parts.length === 0) return;
5087
5573
  const content = parts.join("\n\n");
5088
5574
  const head = this.messages[0];
@@ -5093,14 +5579,19 @@ ${finished.result}`
5093
5579
  }
5094
5580
  }
5095
5581
  async applyCompactionSummary(compacted, signal) {
5096
- const { config, model } = this.opts;
5097
- if (config.contextCompaction !== "summary" || !model) {
5582
+ const { config } = this.opts;
5583
+ if (config.contextCompaction !== "summary") {
5098
5584
  return compacted.messages;
5099
5585
  }
5100
5586
  const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
5101
5587
  const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
5102
5588
  try {
5103
- const summary = await summarizeMessages(dropped, model, signal, this.opts.temperature);
5589
+ const summary = await summarizeMessages(
5590
+ dropped,
5591
+ this.getAuxModel(),
5592
+ signal,
5593
+ this.getAuxTemperature()
5594
+ );
5104
5595
  const messages = compacted.messages.slice();
5105
5596
  messages[headCount] = {
5106
5597
  role: "user",
@@ -5115,7 +5606,7 @@ ${summary}`
5115
5606
  async *streamOnce(aiTools, signal, timeoutScale = 1) {
5116
5607
  for await (const event of streamChat({
5117
5608
  model: this.opts.model,
5118
- messages: this.messages,
5609
+ messages: attachTurnContext(this.messages, this.turnGitContext),
5119
5610
  tools: aiTools,
5120
5611
  abortSignal: signal,
5121
5612
  providerMetadata: this.opts.providerMetadata,
@@ -5311,7 +5802,10 @@ export {
5311
5802
  collectWorkingDiff,
5312
5803
  MAX_IMAGE_DIMENSION,
5313
5804
  reconcileToolCalls,
5314
- wrapFetchWithActivity,
5805
+ listModels,
5806
+ resolveModelConfig,
5807
+ createModel,
5808
+ reasoningEffortMetadata,
5315
5809
  starHome,
5316
5810
  globalConfigPath,
5317
5811
  sessionsDir,
@@ -5349,6 +5843,7 @@ export {
5349
5843
  createDefaultRegistry,
5350
5844
  appendUserMemory,
5351
5845
  PLAN_MODE_PROMPT,
5846
+ attachTurnContext,
5352
5847
  AgentLoop
5353
5848
  };
5354
- //# sourceMappingURL=chunk-OOOW7FC4.js.map
5849
+ //# sourceMappingURL=chunk-OHLA2DP7.js.map