@cryer/star-cli 0.3.0 → 0.3.1
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 +16 -8
- package/dist/{chunk-DZWTIMJ2.js → chunk-K4SIXWCI.js} +273 -14
- package/dist/chunk-K4SIXWCI.js.map +1 -0
- package/dist/{loop-EIODKYPT.js → loop-ED6AAMWV.js} +2 -2
- package/dist/main.js +1606 -387
- package/dist/main.js.map +1 -1
- package/dist/{stream-Y55NRF2S.js → stream-YQCRUBO3.js} +26 -2
- package/dist/stream-YQCRUBO3.js.map +1 -0
- package/package.json +25 -16
- package/dist/chunk-DZWTIMJ2.js.map +0 -1
- package/dist/stream-Y55NRF2S.js.map +0 -1
- /package/dist/{loop-EIODKYPT.js.map → loop-ED6AAMWV.js.map} +0 -0
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Multi-model LLM access · streaming terminal UI · tool calling · permission co
|
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
Features: streaming REPL with slash commands (+ autocomplete) · OpenAI / Anthropic / OpenAI-compatible providers · built-in fs / bash / web tools with a permission gate · git integration (`/commit` drafts Conventional Commits messages, `/diff` shows a colored working-tree diff, repo status injected into the system prompt) · plan mode with read-only research and plan approval · thinking spinner with dim reasoning preview · diff preview on write/edit approval · `@file` mentions · `!cmd` shell passthrough · custom slash commands from Markdown files · conversation compaction (`/compact`) · session persistence and resume with auto-generated titles (`/resume`, `star -r`) · subagent delegation for focused subtasks · lifecycle hooks (`PreToolUse`/`PostToolUse`/`Stop` shell commands from config) · file-write snapshots with `/undo` and checkpoint rollback with `/rewind` · TODO task tracking · background shell tasks with status-bar visibility (`/tasks`) · terminal bell on long turns and background-task completion · Markdown session export (`/export`) · `/init` + `/doctor` project scaffolding and environment checks · cost estimation · update notifier · `--json` NDJSON output for scripting.
|
|
18
|
+
Features: streaming REPL with slash commands (+ autocomplete) · OpenAI / Anthropic / OpenAI-compatible providers with an interactive `/connect` onboarding wizard · built-in fs / bash / web tools with a permission gate · git integration (`/commit` drafts Conventional Commits messages, `/diff` shows a colored working-tree diff, repo status injected into the system prompt) · plan mode with read-only research and plan approval · thinking spinner with dim reasoning preview · diff preview on write/edit approval · `@file` mentions · `!cmd` shell passthrough · custom slash commands from Markdown files · conversation compaction (`/compact`) · session persistence and resume with auto-generated titles (`/resume`, `star -r`) · subagent delegation for focused subtasks · lifecycle hooks (`PreToolUse`/`PostToolUse`/`Stop` shell commands from config) · file-write snapshots with `/undo` and checkpoint rollback with `/rewind` · TODO task tracking · background shell tasks with status-bar visibility (`/tasks`) · terminal bell on long turns and background-task completion · Markdown session export (`/export`) · `/init` + `/doctor` project scaffolding and environment checks · cost estimation · update notifier · `--json` NDJSON output for scripting.
|
|
19
19
|
|
|
20
20
|
## Requirements
|
|
21
21
|
|
|
@@ -105,14 +105,17 @@ command = "biome check --write ."
|
|
|
105
105
|
# timeoutSec = 30 # optional per-hook timeout
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
API keys resolve from the environment variable first (`apiKeyEnv`), then the `apiKey` field in the config file.
|
|
108
|
+
API keys resolve from the environment variable first (`apiKeyEnv`), then the `apiKey` field in the config file. On startup Star CLI also loads `~/.star-cli/.env` (dotenv-style `KEY=VALUE` lines) into the environment without overwriting variables that are already set — this is where `/connect` stores the keys it collects, so `config.toml` only ever references the variable name, never the key itself.
|
|
109
|
+
|
|
110
|
+
The fastest way to set up a provider is the `/connect` wizard inside the REPL: pick a preset (OpenAI, Anthropic, Kimi/Moonshot, DeepSeek) or a custom endpoint, paste the API key (masked while typing), name a model, and it appends the `[[providers]]`/`[[models]]` blocks to `config.toml` (existing content and comments preserved), writes the key to `~/.star-cli/.env` (as `STAR_API_KEY_<NAME>`, file mode 600 where the platform honors it), and optionally makes the new model the default — all without restarting.
|
|
111
|
+
|
|
109
112
|
|
|
110
113
|
## CLI flags
|
|
111
114
|
|
|
112
115
|
```
|
|
113
116
|
star start the interactive REPL
|
|
114
117
|
star -p "prompt" non-interactive print mode (pipe-friendly)
|
|
115
|
-
star -p "prompt" --image x.png attach an image (png/jpg/jpeg/gif/webp, max 5MB; repeatable)
|
|
118
|
+
star -p "prompt" --image x.png attach an image (png/jpg/jpeg/gif/webp, max 5MB; repeatable; anything past 2000px on the longest side is downsampled)
|
|
116
119
|
star -p "prompt" --json NDJSON event stream on stdout (text/tool/usage/error lines)
|
|
117
120
|
star -m gpt pick a model
|
|
118
121
|
star --permission-mode auto ask | auto | readonly | yolo | plan
|
|
@@ -140,11 +143,12 @@ star --clear-sessions all delete every stored session
|
|
|
140
143
|
| `/usage` | token usage dashboard across all sessions: totals, per-day bar chart, per-model breakdown with $ estimate |
|
|
141
144
|
| `/config` | show resolved config |
|
|
142
145
|
| `/permission [mode]` | set the permission mode — no argument opens an interactive picker; `ask` / `auto` / `readonly` / `yolo` are saved to config, `plan` stays session-only |
|
|
146
|
+
| `/connect` | interactive provider onboarding wizard: pick a preset or custom endpoint, paste the API key (masked), name a model — appends `[[providers]]`/`[[models]]` to the config, stores the key in `~/.star-cli/.env` (never in `config.toml`), and can set the new model as default + switch to it immediately |
|
|
143
147
|
| `/plan` | toggle plan mode: read-only research, then approve the generated plan before it executes (session-only) |
|
|
144
148
|
| `/memory [add <text>]` | show the long-term memory file (`~/.star-cli/MEMORY.md`, injected into every session's system prompt), or append a line to it — see [Long-term memory](#long-term-memory) |
|
|
145
149
|
| `/compact` | compact conversation history to free up context |
|
|
146
150
|
| `/export [path]` | export the current session to a Markdown file |
|
|
147
|
-
| `/undo` | undo the last conversation turn: revert its file changes (write_file/edit_file) and retract its messages — earlier turns are never touched |
|
|
151
|
+
| `/undo` | undo the last conversation turn: revert its file changes (write_file/edit_file) and retract its messages — earlier turns are never touched (previews the message count and per-file revert diffs, then asks for confirmation) |
|
|
148
152
|
| `/rewind [n]` | list file-change checkpoints, or rewind to just before checkpoint `n`: restore every file changed since then and retract the matching conversation messages (asks for confirmation first) |
|
|
149
153
|
| `/init [force]` | scan the project and generate an AGENTS.md (LLM-polished when a model is available) |
|
|
150
154
|
| `/doctor` | environment self-check (Node, shell, config, API key status, sessions dir writability) |
|
|
@@ -156,11 +160,11 @@ star --clear-sessions all delete every stored session
|
|
|
156
160
|
| `/exit` | quit |
|
|
157
161
|
| `/q` | quit (alias of `/exit`) |
|
|
158
162
|
|
|
159
|
-
Keys: `ESC` / `Ctrl+C` interrupts the current stream; on a permission prompt: `y` allow, `n` deny, `a` always allow — the generated allow-rule (e.g. `bash(npm test)`) is saved to `permissions.allow` in the config file and survives restarts. Write/edit prompts include a colored diff preview of the pending change. `Shift+Tab` cycles the permission mode for the session (`ask` → `auto` → `readonly` → `plan`; not saved to config). Input editing: arrow keys move the cursor, `Ctrl+A`/`Ctrl+E` jump to start/end, `Ctrl+U`/`Ctrl+K` delete before/after the cursor, `Ctrl+W` deletes the previous word, up/down recall history (persisted to `~/.star-cli/history` across sessions, consecutive duplicates collapsed)
|
|
163
|
+
Keys: `ESC` / `Ctrl+C` interrupts the current stream — the partial reply generated so far stays on screen (and in the session history) with a dim `[interrupted]` marker; on a permission prompt: `y` allow, `n` deny, `a` always allow — the generated allow-rule (e.g. `bash(npm test)`) is saved to `permissions.allow` in the config file and survives restarts. Write/edit prompts include a colored diff preview of the pending change. `Shift+Tab` cycles the permission mode for the session (`ask` → `auto` → `readonly` → `plan`; not saved to config). Input editing: arrow keys move the cursor, `Ctrl+A`/`Ctrl+E` jump to start/end, `Ctrl+U`/`Ctrl+K` delete before/after the cursor, `Ctrl+W` deletes the previous word, up/down recall history (persisted to `~/.star-cli/history` across sessions, capped at 50 entries, consecutive duplicates collapsed, slash commands and key-repeat garbage never recorded); while a recalled entry is unmodified up/down keep switching entries, but once you edit the text — or are typing your own multi-line input — up/down move the cursor between lines instead. `Ctrl+R` starts a reverse history search: type to filter (case-insensitive, newest match first), `Ctrl+R`/`↑` for older matches, `↓` for newer, `Enter` to accept the match into the input, `ESC`/`Ctrl+C`/`Ctrl+G` to cancel and restore what you had. Multi-line input: use `Ctrl+J` (or `Alt+Enter`), or end the line with `\` and press `Enter` — pasted multi-line text is kept intact. Pasting a large block (10+ lines or 500+ characters in one go) collapses it into a dim `[pasted #N: L lines]` placeholder so the input box stays compact; the full text is restored into what gets submitted, and `Backspace` removes the placeholder as a unit. A huge paste that the terminal delivers in several chunks is reassembled (via bracketed paste where the terminal supports it) and still collapses into a single placeholder. Typing `/` shows slash-command suggestions with their usage — prefix matches first, then fuzzy subsequence matches; `↑`/`↓` to highlight, `Tab` (or `→` at end of input) to complete, `ESC` to dismiss, and an unknown command name gets a `Did you mean: …` hint. Typing `@` completes file paths relative to the working directory with the same keys; directories end in `/` so `Tab` descends, and `node_modules`, `.git`, `dist` and `.starignore` entries are skipped. `Alt+V` pastes an image from the clipboard as an attachment (each one shows as `[image attached: clipboard.png]` above the input; uses PowerShell on Windows, pngpaste/osascript on macOS, xclip on Linux). Images larger than 2000px on the longest side are automatically downsampled to fit (keeping the aspect ratio, via platform-native tools — no extra installs on Windows/macOS; ImageMagick or ffmpeg on Linux); when resizing isn't possible the original is kept and a warning is shown. `Ctrl+V` also works where the terminal passes it through — Windows Terminal binds Ctrl+V to its own paste, so use `Alt+V` there. While a turn is streaming you can keep typing: submitted prompts (and `!` bangs) queue up as dimmed entries and auto-send in order once the turn finishes; `ESC` during a turn aborts it and clears the queue (the partial reply is kept, marked `[interrupted]`). Pressing `ESC` twice within half a second while idle retracts your last prompt and restores its text into the input for editing.
|
|
160
164
|
|
|
161
165
|
While the model is working, a spinner (`- \ | /`) shows `star is thinking…`; reasoning models also stream a dimmed tail of their thinking (last ~200 chars), which collapses to a one-line summary once the answer starts.
|
|
162
166
|
|
|
163
|
-
The status bar shows the working directory (full path on wide terminals), the git branch, the model, the permission mode, context usage as a percentage of the model's context window (its own `contextMaxTokens` when set, else the top-level one), the session cost when the model has pricing configured, and total tokens.
|
|
167
|
+
The status bar shows the working directory (full path on wide terminals), the git branch, the model, the permission mode, context usage as a percentage of the model's context window (its own `contextMaxTokens` when set, else the top-level one), the session prompt-cache hit rate when the provider reports cache usage (`cache: 42%`, or `cache: Not provided` when it never does), the session cost when the model has pricing configured, and total tokens.
|
|
164
168
|
|
|
165
169
|
## !shell passthrough
|
|
166
170
|
|
|
@@ -256,7 +260,7 @@ Prefix a path with `@` in any prompt to attach its content (REPL and print mode
|
|
|
256
260
|
star -p "summarize @README.md and @src/main.tsx"
|
|
257
261
|
```
|
|
258
262
|
|
|
259
|
-
Image files (`@screenshot.png` — png, jpg, jpeg, gif, webp, up to 5MB) are sent to the model as image input instead of inlined text, in both the REPL and print mode. In print mode you can also attach images with the repeatable `--image <path>` flag:
|
|
263
|
+
Image files (`@screenshot.png` — png, jpg, jpeg, gif, webp, up to 5MB) are sent to the model as image input instead of inlined text, in both the REPL and print mode; anything over 2000px on the longest side is downsampled first. In print mode you can also attach images with the repeatable `--image <path>` flag:
|
|
260
264
|
|
|
261
265
|
```
|
|
262
266
|
star -p "what's wrong in this UI?" --image screenshot.png --image mockup.jpg
|
|
@@ -264,6 +268,10 @@ star -p "what's wrong in this UI?" --image screenshot.png --image mockup.jpg
|
|
|
264
268
|
|
|
265
269
|
Invalid `--image` paths (unsupported type, missing, unreadable, or oversized) abort print mode with a non-zero exit.
|
|
266
270
|
|
|
271
|
+
If a provider still rejects a request because an image is too large, the agent automatically removes the oversized images from the conversation (replaced with a `[image removed: too large for the model]` placeholder, also in the stored session) and retries the request once, with a notice in the UI.
|
|
272
|
+
|
|
273
|
+
A directory mention (`@src/agent/`, with or without the trailing slash) inlines an indented tree of the directory instead of file contents — handy for giving the model project structure without pasting `tree` output. Listings honor `.starignore` and skip sensitive files, and are capped at 200 entries / 10 levels deep with a `... (truncated, N more entries)` marker when larger.
|
|
274
|
+
|
|
267
275
|
Missing, binary, oversized (>100KB for text, >5MB for images), or sensitive files (`.env`, private keys) are skipped with a note. The chat history keeps your original `@path` text, so resumed sessions don't carry the injected bulk.
|
|
268
276
|
|
|
269
277
|
## .starignore
|
|
@@ -278,7 +286,7 @@ The `subagent` tool (`exec` level, so it is hidden in plan mode and denied in re
|
|
|
278
286
|
|
|
279
287
|
Permission modes: `ask` (reads allowed, writes/exec ask) · `auto` (everything allowed except the hard-denied rules above) · `readonly` (read-only) · `yolo` (allow everything, never ask — **all safety checks disabled**, use at your own risk) · `plan` (read-only research with a plan-approval flow, see above — session-only). Switch at runtime with `/permission` (persisted to the config file), per session with `/plan` or `Shift+Tab`, or at startup with `--permission-mode`. On top of the modes, the `[permissions]` config block holds persistent `allow` and `deny` rule lists (same `bash(npm test *)` syntax); deny rules are evaluated right after the hard safety rules and beat both `auto` mode and allow-rules, while `yolo` bypasses everything.
|
|
280
288
|
|
|
281
|
-
Every successful `write_file` / `edit_file` first snapshots the file's previous content (in-memory, last 50 writes per session); `/undo`
|
|
289
|
+
Every successful `write_file` / `edit_file` first snapshots the file's previous content (in-memory, last 50 writes per session); `/undo` reverts the last turn's snapshots — restoring old content, or deleting files the turn created — but only after showing a preview of the retraction and per-file revert diffs and getting a `y`/`n` confirmation.
|
|
282
290
|
|
|
283
291
|
## Checkpoints and /rewind
|
|
284
292
|
|
|
@@ -235,6 +235,133 @@ ${unstaged}`);
|
|
|
235
235
|
return { status, diff: text, truncated };
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// src/core/image.ts
|
|
239
|
+
var MAX_IMAGE_DIMENSION = 2e3;
|
|
240
|
+
var OVERSIZED_IMAGE_FALLBACK_BYTES = 5 * 1024 * 1024;
|
|
241
|
+
var IMAGE_REMOVED_PLACEHOLDER = "[image removed: too large for the model]";
|
|
242
|
+
var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
243
|
+
function probePng(buf) {
|
|
244
|
+
if (buf.length < 24) return null;
|
|
245
|
+
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
|
|
246
|
+
if (buf[i] !== PNG_SIGNATURE[i]) return null;
|
|
247
|
+
}
|
|
248
|
+
if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
|
|
249
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
250
|
+
}
|
|
251
|
+
var JPEG_SOF_MARKERS = /* @__PURE__ */ new Set([
|
|
252
|
+
192,
|
|
253
|
+
193,
|
|
254
|
+
194,
|
|
255
|
+
195,
|
|
256
|
+
197,
|
|
257
|
+
198,
|
|
258
|
+
199,
|
|
259
|
+
201,
|
|
260
|
+
202,
|
|
261
|
+
203,
|
|
262
|
+
205,
|
|
263
|
+
206,
|
|
264
|
+
207
|
|
265
|
+
]);
|
|
266
|
+
function probeJpeg(buf) {
|
|
267
|
+
if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216) return null;
|
|
268
|
+
let offset = 2;
|
|
269
|
+
while (offset + 3 < buf.length) {
|
|
270
|
+
if (buf[offset] !== 255) return null;
|
|
271
|
+
const marker = buf[offset + 1];
|
|
272
|
+
if (marker === void 0) return null;
|
|
273
|
+
if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
274
|
+
offset += 2;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const length = buf.readUInt16BE(offset + 2);
|
|
278
|
+
if (length < 2) return null;
|
|
279
|
+
if (JPEG_SOF_MARKERS.has(marker)) {
|
|
280
|
+
if (offset + 9 > buf.length) return null;
|
|
281
|
+
return { height: buf.readUInt16BE(offset + 5), width: buf.readUInt16BE(offset + 7) };
|
|
282
|
+
}
|
|
283
|
+
offset += 2 + length;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
function probeGif(buf) {
|
|
288
|
+
if (buf.length < 10) return null;
|
|
289
|
+
const signature = buf.toString("ascii", 0, 6);
|
|
290
|
+
if (signature !== "GIF87a" && signature !== "GIF89a") return null;
|
|
291
|
+
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
292
|
+
}
|
|
293
|
+
function probeWebp(buf) {
|
|
294
|
+
if (buf.length < 30) return null;
|
|
295
|
+
if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP") {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const chunk = buf.toString("ascii", 12, 16);
|
|
299
|
+
if (chunk === "VP8X") {
|
|
300
|
+
return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
|
|
301
|
+
}
|
|
302
|
+
if (chunk === "VP8 ") {
|
|
303
|
+
return {
|
|
304
|
+
width: buf.readUInt16LE(26) & 16383,
|
|
305
|
+
height: buf.readUInt16LE(28) & 16383
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (chunk === "VP8L") {
|
|
309
|
+
if (buf[20] !== 47) return null;
|
|
310
|
+
const b1 = buf[21];
|
|
311
|
+
const b2 = buf[22];
|
|
312
|
+
const b3 = buf[23];
|
|
313
|
+
const b4 = buf[24];
|
|
314
|
+
return {
|
|
315
|
+
width: 1 + ((b2 & 63) << 8 | b1),
|
|
316
|
+
height: 1 + ((b4 & 15) << 10 | b3 << 2 | (b2 & 192) >> 6)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
function probeImageDimensions(buf) {
|
|
322
|
+
return probePng(buf) ?? probeJpeg(buf) ?? probeGif(buf) ?? probeWebp(buf);
|
|
323
|
+
}
|
|
324
|
+
var IMAGE_SIZE_KEYWORDS = /too[\s-]*(large|big)|exceeds?|dimensions?|resolution|size\s*limit|max(?:imum)?[\s-]*size/i;
|
|
325
|
+
function isOversizedImageError(error) {
|
|
326
|
+
const status = error.statusCode;
|
|
327
|
+
if (typeof status !== "number" || status < 400 || status >= 500) return false;
|
|
328
|
+
return /image/i.test(error.message) && IMAGE_SIZE_KEYWORDS.test(error.message);
|
|
329
|
+
}
|
|
330
|
+
function imagePartBytes(image) {
|
|
331
|
+
if (typeof image === "string") {
|
|
332
|
+
const base64 = image.startsWith("data:") ? image.slice(image.indexOf(",") + 1) : image;
|
|
333
|
+
try {
|
|
334
|
+
return Buffer.from(base64, "base64");
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (image instanceof Uint8Array) return Buffer.from(image);
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
function isOversizedImageData(buf) {
|
|
343
|
+
const dims = probeImageDimensions(buf);
|
|
344
|
+
if (dims) return Math.max(dims.width, dims.height) > MAX_IMAGE_DIMENSION;
|
|
345
|
+
return buf.length > OVERSIZED_IMAGE_FALLBACK_BYTES;
|
|
346
|
+
}
|
|
347
|
+
function stripOversizedImages(messages) {
|
|
348
|
+
let removed = 0;
|
|
349
|
+
const result = messages.map((message) => {
|
|
350
|
+
if (!Array.isArray(message.content)) return message;
|
|
351
|
+
let changed = false;
|
|
352
|
+
const content = message.content.map((part) => {
|
|
353
|
+
if (part.type !== "image") return part;
|
|
354
|
+
const bytes = imagePartBytes(part.image);
|
|
355
|
+
if (!bytes || !isOversizedImageData(bytes)) return part;
|
|
356
|
+
changed = true;
|
|
357
|
+
removed++;
|
|
358
|
+
return { type: "text", text: IMAGE_REMOVED_PLACEHOLDER };
|
|
359
|
+
});
|
|
360
|
+
return changed ? { ...message, content } : message;
|
|
361
|
+
});
|
|
362
|
+
return { messages: removed > 0 ? result : messages, removed };
|
|
363
|
+
}
|
|
364
|
+
|
|
238
365
|
// src/core/messages.ts
|
|
239
366
|
var MISSING_TOOL_RESULT_TEXT = "tool result missing (session was interrupted)";
|
|
240
367
|
function reconcileToolCalls(messages) {
|
|
@@ -664,6 +791,12 @@ async function revert(snapshot) {
|
|
|
664
791
|
await rm(snapshot.path, { force: true });
|
|
665
792
|
return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
|
|
666
793
|
}
|
|
794
|
+
function listTurnSnapshots(turn) {
|
|
795
|
+
return stack.filter((snapshot) => snapshot.turn === turn).reverse();
|
|
796
|
+
}
|
|
797
|
+
function resolveSnapshotContent(snapshot) {
|
|
798
|
+
return resolveContent(snapshot);
|
|
799
|
+
}
|
|
667
800
|
async function undoTurnSnapshots(turn) {
|
|
668
801
|
const matches = [];
|
|
669
802
|
for (let i = stack.length - 1; i >= 0; i--) {
|
|
@@ -778,6 +911,9 @@ async function loadTodos(cwd) {
|
|
|
778
911
|
await defaultStore.load(cwd);
|
|
779
912
|
return defaultStore.list();
|
|
780
913
|
}
|
|
914
|
+
function resetTodos() {
|
|
915
|
+
defaultStore.replace([]);
|
|
916
|
+
}
|
|
781
917
|
function createTodoTools(store = defaultStore) {
|
|
782
918
|
const todoWrite = {
|
|
783
919
|
name: "todo_write",
|
|
@@ -789,10 +925,11 @@ function createTodoTools(store = defaultStore) {
|
|
|
789
925
|
if (!parsed.success) {
|
|
790
926
|
return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
|
|
791
927
|
}
|
|
792
|
-
await store.load(ctx.cwd);
|
|
793
928
|
store.replace(parsed.data.todos);
|
|
794
929
|
await store.save(ctx.cwd);
|
|
795
|
-
|
|
930
|
+
const items = store.list();
|
|
931
|
+
const done = items.filter((it) => it.status === "done").length;
|
|
932
|
+
return { content: `Todo list updated (${done}/${items.length} done).` };
|
|
796
933
|
}
|
|
797
934
|
};
|
|
798
935
|
const todoRead = {
|
|
@@ -800,8 +937,7 @@ function createTodoTools(store = defaultStore) {
|
|
|
800
937
|
description: "Read the current todo list.",
|
|
801
938
|
permission: "read",
|
|
802
939
|
parameters: readSchema,
|
|
803
|
-
async execute(
|
|
804
|
-
await store.load(ctx.cwd);
|
|
940
|
+
async execute() {
|
|
805
941
|
return { content: formatTodos(store.list()) };
|
|
806
942
|
}
|
|
807
943
|
};
|
|
@@ -954,6 +1090,9 @@ function sessionsDir() {
|
|
|
954
1090
|
function userMemoryPath() {
|
|
955
1091
|
return path5.join(starHome(), "MEMORY.md");
|
|
956
1092
|
}
|
|
1093
|
+
function envFilePath() {
|
|
1094
|
+
return path5.join(starHome(), ".env");
|
|
1095
|
+
}
|
|
957
1096
|
function projectConfigPath(cwd) {
|
|
958
1097
|
return path5.join(cwd, ".star", "config.toml");
|
|
959
1098
|
}
|
|
@@ -2155,7 +2294,7 @@ var MAX_SUBAGENT_DEPTH = 1;
|
|
|
2155
2294
|
var MAX_REPORT_CHARS = 2e4;
|
|
2156
2295
|
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.";
|
|
2157
2296
|
async function runSubagent(deps, args, cwd, signal) {
|
|
2158
|
-
const { AgentLoop: AgentLoop2 } = await import("./loop-
|
|
2297
|
+
const { AgentLoop: AgentLoop2 } = await import("./loop-ED6AAMWV.js");
|
|
2159
2298
|
const child = new AgentLoop2({
|
|
2160
2299
|
model: deps.model,
|
|
2161
2300
|
registry: createDefaultRegistry(),
|
|
@@ -2300,6 +2439,13 @@ function createRememberTool(deps = {}) {
|
|
|
2300
2439
|
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.";
|
|
2301
2440
|
var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?|I will|I'll|I am going to|I'm going to|I shall|let me|next,? I|I will now)/i;
|
|
2302
2441
|
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.";
|
|
2442
|
+
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.";
|
|
2443
|
+
function isSteerableEmptyFinish(finishReason) {
|
|
2444
|
+
return finishReason === "stop" || finishReason === "content-filter";
|
|
2445
|
+
}
|
|
2446
|
+
function finalNudgeWarning(autoContinues, maxAutoContinues) {
|
|
2447
|
+
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." : "";
|
|
2448
|
+
}
|
|
2303
2449
|
var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
|
|
2304
2450
|
async function checkTaskComplete(model, request, finalReply, signal) {
|
|
2305
2451
|
const controller = new AbortController();
|
|
@@ -2434,6 +2580,18 @@ var AgentLoop = class {
|
|
|
2434
2580
|
this.messages = reconcileToolCalls(messages);
|
|
2435
2581
|
this.turnMarkers = [];
|
|
2436
2582
|
}
|
|
2583
|
+
// Read-only counterpart of retractLastTurn: how many messages /undo would
|
|
2584
|
+
// drop and which snapshot turn's file changes it would revert (undefined =
|
|
2585
|
+
// no verified turn, so no file snapshots may be reverted). Mutates nothing,
|
|
2586
|
+
// so the REPL can show the preview before the user confirms.
|
|
2587
|
+
previewLastTurnRetraction() {
|
|
2588
|
+
const result = retractLastTurn([...this.messages]);
|
|
2589
|
+
if (result.removed === 0) return { removed: 0 };
|
|
2590
|
+
const userIndex = result.messages.length;
|
|
2591
|
+
const last = this.turnMarkers[this.turnMarkers.length - 1];
|
|
2592
|
+
const turn = last && last.userIndex === userIndex ? last.seq : void 0;
|
|
2593
|
+
return { removed: result.removed, turn };
|
|
2594
|
+
}
|
|
2437
2595
|
// Drops the final user message and everything after it, and persists the
|
|
2438
2596
|
// trimmed history so a later /resume does not bring the turn back.
|
|
2439
2597
|
// `turn` is the retracted turn's snapshot seq when it could be verified
|
|
@@ -2487,6 +2645,16 @@ var AgentLoop = class {
|
|
|
2487
2645
|
async persist(message) {
|
|
2488
2646
|
await this.opts.sessionStore?.append(message);
|
|
2489
2647
|
}
|
|
2648
|
+
// Replaces oversized image parts in the in-memory history with text
|
|
2649
|
+
// placeholders and rewrites the persisted session, so the failed request
|
|
2650
|
+
// can be resent and a later resume never carries the offending images.
|
|
2651
|
+
async stripOversizedImages() {
|
|
2652
|
+
const { messages, removed } = stripOversizedImages(this.messages);
|
|
2653
|
+
if (removed === 0) return 0;
|
|
2654
|
+
this.messages = messages;
|
|
2655
|
+
await this.opts.sessionStore?.replaceMessages([...this.messages]);
|
|
2656
|
+
return removed;
|
|
2657
|
+
}
|
|
2490
2658
|
// After the first assistant reply, kick off background title generation
|
|
2491
2659
|
// for the session. Runs once per loop; the store-level title check makes
|
|
2492
2660
|
// resumed sessions that already have a title a no-op.
|
|
@@ -2497,6 +2665,47 @@ var AgentLoop = class {
|
|
|
2497
2665
|
this.titleScheduled = true;
|
|
2498
2666
|
scheduleSessionTitle(store, input, this.opts.model);
|
|
2499
2667
|
}
|
|
2668
|
+
// A user interrupt (Esc) ends the turn mid-stream, before the normal
|
|
2669
|
+
// assistant-message persistence runs. Whatever text and tool calls already
|
|
2670
|
+
// arrived are still persisted — the text marked with "[interrupted]" so the
|
|
2671
|
+
// cut-off stays visible after a resume and the model can tell its reply was
|
|
2672
|
+
// cut short — and streamed tool calls are closed with synthetic results so
|
|
2673
|
+
// the stored history stays valid for the API.
|
|
2674
|
+
async *persistInterrupted(text, toolCalls) {
|
|
2675
|
+
if (text.length === 0 && toolCalls.length === 0) return;
|
|
2676
|
+
const assistantMessage = {
|
|
2677
|
+
role: "assistant",
|
|
2678
|
+
content: [
|
|
2679
|
+
...text ? [{ type: "text", text: `${text} [interrupted]` }] : [],
|
|
2680
|
+
...toolCalls.map((call) => ({
|
|
2681
|
+
type: "tool-call",
|
|
2682
|
+
toolCallId: call.id,
|
|
2683
|
+
toolName: call.name,
|
|
2684
|
+
args: call.args
|
|
2685
|
+
}))
|
|
2686
|
+
]
|
|
2687
|
+
};
|
|
2688
|
+
this.messages.push(assistantMessage);
|
|
2689
|
+
await this.persist(assistantMessage);
|
|
2690
|
+
for (const call of toolCalls) {
|
|
2691
|
+
const content = "Tool execution interrupted by user.";
|
|
2692
|
+
const synthetic = {
|
|
2693
|
+
role: "tool",
|
|
2694
|
+
content: [
|
|
2695
|
+
{
|
|
2696
|
+
type: "tool-result",
|
|
2697
|
+
toolCallId: call.id,
|
|
2698
|
+
toolName: call.name,
|
|
2699
|
+
result: content
|
|
2700
|
+
}
|
|
2701
|
+
]
|
|
2702
|
+
};
|
|
2703
|
+
this.messages.push(synthetic);
|
|
2704
|
+
await this.persist(synthetic).catch(() => {
|
|
2705
|
+
});
|
|
2706
|
+
yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2500
2709
|
async *stream(input, signal, opts) {
|
|
2501
2710
|
this.syncSystemMessage();
|
|
2502
2711
|
const inputText = typeof input === "string" ? input : input.text;
|
|
@@ -2547,11 +2756,13 @@ ${finished.result}`
|
|
|
2547
2756
|
let text = "";
|
|
2548
2757
|
const toolCalls = [];
|
|
2549
2758
|
let failure = null;
|
|
2759
|
+
let lastFinishReason;
|
|
2760
|
+
let oversizedImagesStripped = false;
|
|
2550
2761
|
for (let attempt = 0; ; attempt++) {
|
|
2551
2762
|
text = "";
|
|
2552
2763
|
toolCalls.length = 0;
|
|
2553
2764
|
failure = null;
|
|
2554
|
-
|
|
2765
|
+
lastFinishReason = void 0;
|
|
2555
2766
|
const timeoutScale = Math.min(attempt + 1, 3);
|
|
2556
2767
|
try {
|
|
2557
2768
|
for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
|
|
@@ -2564,7 +2775,7 @@ ${finished.result}`
|
|
|
2564
2775
|
toolCalls.push({ id: event.id, name: event.name, args: event.args });
|
|
2565
2776
|
yield event;
|
|
2566
2777
|
} else if (event.type === "finish") {
|
|
2567
|
-
|
|
2778
|
+
lastFinishReason = event.finishReason;
|
|
2568
2779
|
yield event;
|
|
2569
2780
|
} else if (event.type === "error") {
|
|
2570
2781
|
failure = event.error;
|
|
@@ -2573,18 +2784,35 @@ ${finished.result}`
|
|
|
2573
2784
|
}
|
|
2574
2785
|
}
|
|
2575
2786
|
} catch (error) {
|
|
2576
|
-
if (signal.aborted)
|
|
2577
|
-
|
|
2787
|
+
if (!signal.aborted) {
|
|
2788
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
if (signal.aborted) {
|
|
2792
|
+
yield* this.persistInterrupted(text, toolCalls);
|
|
2793
|
+
return;
|
|
2578
2794
|
}
|
|
2579
|
-
if (signal.aborted) return;
|
|
2580
2795
|
if (!failure && (text.length > 0 || toolCalls.length > 0)) break;
|
|
2796
|
+
if (failure && !oversizedImagesStripped && isOversizedImageError(failure)) {
|
|
2797
|
+
oversizedImagesStripped = true;
|
|
2798
|
+
const removed = await this.stripOversizedImages();
|
|
2799
|
+
if (removed > 0) {
|
|
2800
|
+
yield {
|
|
2801
|
+
type: "notice",
|
|
2802
|
+
message: `Removed ${removed} oversized image(s) the model rejected; retrying the request.`
|
|
2803
|
+
};
|
|
2804
|
+
continue;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2581
2807
|
if (failure && !isRetryableStreamError(failure)) break;
|
|
2582
2808
|
if (attempt >= maxRetries) break;
|
|
2809
|
+
if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 && isSteerableEmptyFinish(lastFinishReason))
|
|
2810
|
+
break;
|
|
2583
2811
|
yield {
|
|
2584
2812
|
type: "retry",
|
|
2585
2813
|
attempt: attempt + 2,
|
|
2586
2814
|
maxAttempts: maxRetries + 1,
|
|
2587
|
-
reason: failure ? failure.message : `empty response (finish: ${
|
|
2815
|
+
reason: failure ? failure.message : `empty response (finish: ${lastFinishReason ?? "none"})`
|
|
2588
2816
|
};
|
|
2589
2817
|
if (!await sleep(baseDelay * 2 ** attempt, signal)) return;
|
|
2590
2818
|
}
|
|
@@ -2593,6 +2821,21 @@ ${finished.result}`
|
|
|
2593
2821
|
return;
|
|
2594
2822
|
}
|
|
2595
2823
|
if (text.length === 0 && toolCalls.length === 0) {
|
|
2824
|
+
if (isSteerableEmptyFinish(lastFinishReason) && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
|
|
2825
|
+
autoContinues++;
|
|
2826
|
+
lastWasNudge = true;
|
|
2827
|
+
yield {
|
|
2828
|
+
type: "notice",
|
|
2829
|
+
message: `the model returned an empty reply; asking the model to continue (${autoContinues}/${config.maxAutoContinues}).`
|
|
2830
|
+
};
|
|
2831
|
+
const nudge = {
|
|
2832
|
+
role: "user",
|
|
2833
|
+
content: EMPTY_REPLY_NUDGE + finalNudgeWarning(autoContinues, config.maxAutoContinues)
|
|
2834
|
+
};
|
|
2835
|
+
this.messages.push(nudge);
|
|
2836
|
+
await this.persist(nudge);
|
|
2837
|
+
continue;
|
|
2838
|
+
}
|
|
2596
2839
|
yield {
|
|
2597
2840
|
type: "error",
|
|
2598
2841
|
error: new Error("The model returned an empty response after retries; ending the turn.")
|
|
@@ -2637,16 +2880,26 @@ ${finished.result}`
|
|
|
2637
2880
|
type: "notice",
|
|
2638
2881
|
message: `${reason}; asking the model to continue (${autoContinues}/${config.maxAutoContinues}).`
|
|
2639
2882
|
};
|
|
2640
|
-
const nudge = {
|
|
2883
|
+
const nudge = {
|
|
2884
|
+
role: "user",
|
|
2885
|
+
content: nudgeText + finalNudgeWarning(autoContinues, config.maxAutoContinues)
|
|
2886
|
+
};
|
|
2641
2887
|
this.messages.push(nudge);
|
|
2642
2888
|
await this.persist(nudge);
|
|
2643
2889
|
continue;
|
|
2644
2890
|
}
|
|
2891
|
+
if (reason !== null && config.permissionMode !== "plan") {
|
|
2892
|
+
yield {
|
|
2893
|
+
type: "notice",
|
|
2894
|
+
message: `${reason}; auto-continue limit reached, handing the turn back \u2014 reply "continue" to keep going.`
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2645
2897
|
await this.runEventHooks("Stop");
|
|
2646
2898
|
return;
|
|
2647
2899
|
}
|
|
2648
2900
|
lastWasNudge = false;
|
|
2649
2901
|
usedToolsThisTurn = true;
|
|
2902
|
+
autoContinues = 0;
|
|
2650
2903
|
const answered = /* @__PURE__ */ new Set();
|
|
2651
2904
|
try {
|
|
2652
2905
|
for (const call of toolCalls) {
|
|
@@ -2760,7 +3013,7 @@ ${summary}`
|
|
|
2760
3013
|
}
|
|
2761
3014
|
}
|
|
2762
3015
|
async *streamOnce(aiTools, signal, timeoutScale = 1) {
|
|
2763
|
-
const { streamChat } = await import("./stream-
|
|
3016
|
+
const { streamChat } = await import("./stream-YQCRUBO3.js");
|
|
2764
3017
|
yield* streamChat({
|
|
2765
3018
|
model: this.opts.model,
|
|
2766
3019
|
messages: this.messages,
|
|
@@ -2870,6 +3123,8 @@ export {
|
|
|
2870
3123
|
collectCommitContext,
|
|
2871
3124
|
buildCommitPrompt,
|
|
2872
3125
|
collectWorkingDiff,
|
|
3126
|
+
MAX_IMAGE_DIMENSION,
|
|
3127
|
+
probeImageDimensions,
|
|
2873
3128
|
reconcileToolCalls,
|
|
2874
3129
|
isAllowedByRules,
|
|
2875
3130
|
buildAllowRule,
|
|
@@ -2877,16 +3132,20 @@ export {
|
|
|
2877
3132
|
listSnapshots,
|
|
2878
3133
|
clearSnapshots,
|
|
2879
3134
|
hydrateSnapshots,
|
|
3135
|
+
listTurnSnapshots,
|
|
3136
|
+
resolveSnapshotContent,
|
|
2880
3137
|
undoTurnSnapshots,
|
|
2881
3138
|
rewindToSnapshot,
|
|
2882
3139
|
TodoStore,
|
|
2883
3140
|
formatTodos,
|
|
2884
3141
|
parseTodoArgs,
|
|
2885
3142
|
loadTodos,
|
|
3143
|
+
resetTodos,
|
|
2886
3144
|
starHome,
|
|
2887
3145
|
globalConfigPath,
|
|
2888
3146
|
sessionsDir,
|
|
2889
3147
|
userMemoryPath,
|
|
3148
|
+
envFilePath,
|
|
2890
3149
|
projectConfigPath,
|
|
2891
3150
|
discoverSkills,
|
|
2892
3151
|
defaultTaskManager,
|
|
@@ -2903,4 +3162,4 @@ export {
|
|
|
2903
3162
|
PLAN_MODE_PROMPT,
|
|
2904
3163
|
AgentLoop
|
|
2905
3164
|
};
|
|
2906
|
-
//# sourceMappingURL=chunk-
|
|
3165
|
+
//# sourceMappingURL=chunk-K4SIXWCI.js.map
|