@cryer/star-cli 0.3.1 → 0.3.3
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 +3 -1
- package/README.zh-CN.md +317 -0
- package/dist/{chunk-K4SIXWCI.js → chunk-3L6QKJM4.js} +1368 -716
- package/dist/chunk-3L6QKJM4.js.map +1 -0
- package/dist/chunk-B6P4VUBE.js +122 -0
- package/dist/chunk-B6P4VUBE.js.map +1 -0
- package/dist/{loop-ED6AAMWV.js → loop-TQ7GFTTE.js} +3 -2
- package/dist/main.js +724 -209
- package/dist/main.js.map +1 -1
- package/dist/stream-H4BYBVAZ.js +184 -0
- package/dist/stream-H4BYBVAZ.js.map +1 -0
- package/package.json +16 -25
- package/dist/chunk-K4SIXWCI.js.map +0 -1
- package/dist/stream-YQCRUBO3.js +0 -106
- package/dist/stream-YQCRUBO3.js.map +0 -1
- /package/dist/{loop-ED6AAMWV.js.map → loop-TQ7GFTTE.js.map} +0 -0
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
computeRetryDelayMs,
|
|
4
|
+
gitTreesDir,
|
|
5
|
+
isRetryableStreamError,
|
|
6
|
+
responseBodyOf,
|
|
7
|
+
starHome,
|
|
8
|
+
summarizeStreamError,
|
|
9
|
+
userMemoryPath
|
|
10
|
+
} from "./chunk-B6P4VUBE.js";
|
|
2
11
|
|
|
3
12
|
// src/agent/loop.ts
|
|
4
13
|
import { tool as aiTool, generateText as generateText3 } from "ai";
|
|
@@ -82,14 +91,18 @@ function serializeMessage(message) {
|
|
|
82
91
|
});
|
|
83
92
|
return `${message.role}: ${parts.join("\n")}`;
|
|
84
93
|
}
|
|
85
|
-
async function summarizeMessages(messages, model) {
|
|
94
|
+
async function summarizeMessages(messages, model, signal) {
|
|
86
95
|
const transcript = messages.map(serializeMessage).join("\n");
|
|
87
96
|
const { text } = await generateText({
|
|
88
97
|
model,
|
|
89
98
|
system: SUMMARY_SYSTEM_PROMPT,
|
|
90
99
|
prompt: `Summarize this conversation so far:
|
|
91
100
|
|
|
92
|
-
${transcript}
|
|
101
|
+
${transcript}`,
|
|
102
|
+
// A hung relay must not stall the whole turn on the summary: cap it at
|
|
103
|
+
// 60s and let the caller's abort (Esc) cut it short as well; the caller
|
|
104
|
+
// falls back to the truncation placeholder on any failure.
|
|
105
|
+
abortSignal: signal ? AbortSignal.any([signal, AbortSignal.timeout(6e4)]) : AbortSignal.timeout(6e4)
|
|
93
106
|
});
|
|
94
107
|
return text.trim();
|
|
95
108
|
}
|
|
@@ -165,6 +178,16 @@ function getGitSummary(cwd) {
|
|
|
165
178
|
const log = runGit(["log", "--oneline", "-3"], cwd) ?? "";
|
|
166
179
|
return { branch, dirtyCount, recentCommits: log === "" ? [] : log.split("\n") };
|
|
167
180
|
}
|
|
181
|
+
var GIT_SUMMARY_CACHE_TTL_MS = 15e3;
|
|
182
|
+
var gitSummaryCache = /* @__PURE__ */ new Map();
|
|
183
|
+
function getGitSummaryCached(cwd) {
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
const hit = gitSummaryCache.get(cwd);
|
|
186
|
+
if (hit && now - hit.at < GIT_SUMMARY_CACHE_TTL_MS) return hit.summary;
|
|
187
|
+
const summary = getGitSummary(cwd);
|
|
188
|
+
gitSummaryCache.set(cwd, { at: now, summary });
|
|
189
|
+
return summary;
|
|
190
|
+
}
|
|
168
191
|
function formatGitSummary(summary) {
|
|
169
192
|
const lines = [
|
|
170
193
|
"Git context for the working directory (refreshed each turn):",
|
|
@@ -325,7 +348,8 @@ var IMAGE_SIZE_KEYWORDS = /too[\s-]*(large|big)|exceeds?|dimensions?|resolution|
|
|
|
325
348
|
function isOversizedImageError(error) {
|
|
326
349
|
const status = error.statusCode;
|
|
327
350
|
if (typeof status !== "number" || status < 400 || status >= 500) return false;
|
|
328
|
-
|
|
351
|
+
const text = [error.message, responseBodyOf(error)].filter((part) => typeof part === "string").join("\n");
|
|
352
|
+
return /image/i.test(text) && IMAGE_SIZE_KEYWORDS.test(text);
|
|
329
353
|
}
|
|
330
354
|
function imagePartBytes(image) {
|
|
331
355
|
if (typeof image === "string") {
|
|
@@ -534,8 +558,50 @@ async function runHooks(event, hooks2, ctx) {
|
|
|
534
558
|
}
|
|
535
559
|
|
|
536
560
|
// src/permissions/gate.ts
|
|
561
|
+
import fs from "fs";
|
|
537
562
|
import path from "path";
|
|
538
563
|
|
|
564
|
+
// src/core/sensitive.ts
|
|
565
|
+
var SENSITIVE_BASENAMES = /* @__PURE__ */ new Set([
|
|
566
|
+
".env",
|
|
567
|
+
"id_rsa",
|
|
568
|
+
"id_dsa",
|
|
569
|
+
"id_ecdsa",
|
|
570
|
+
"id_ed25519",
|
|
571
|
+
".npmrc",
|
|
572
|
+
".netrc",
|
|
573
|
+
".pgpass"
|
|
574
|
+
]);
|
|
575
|
+
var SENSITIVE_SUFFIXES = [".pem", ".key", ".p12", ".pfx"];
|
|
576
|
+
var SENSITIVE_ENV_EXEMPTIONS = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template"]);
|
|
577
|
+
function pathSegments(p) {
|
|
578
|
+
return p.replace(/\\/g, "/").split("/").filter(Boolean).map((s) => s.toLowerCase());
|
|
579
|
+
}
|
|
580
|
+
function isSensitivePath(p) {
|
|
581
|
+
const segments = pathSegments(p);
|
|
582
|
+
const base = segments[segments.length - 1] ?? "";
|
|
583
|
+
if (SENSITIVE_ENV_EXEMPTIONS.has(base)) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
if (base === ".env" || base.startsWith(".env.")) {
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
if (SENSITIVE_BASENAMES.has(base)) {
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
if (SENSITIVE_SUFFIXES.some((suffix) => base.endsWith(suffix))) {
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
const dir = segments[segments.length - 2] ?? "";
|
|
596
|
+
if (dir === ".aws" && base === "credentials") {
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
if (dir === ".kube" && base === "config") {
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
|
|
539
605
|
// src/permissions/allow.ts
|
|
540
606
|
function parseAllowRule(rule) {
|
|
541
607
|
const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
|
|
@@ -559,11 +625,32 @@ function matchAllowRule(rule, req) {
|
|
|
559
625
|
const target = requestTarget(req);
|
|
560
626
|
return target !== void 0 && globMatch(rule.pattern, target);
|
|
561
627
|
}
|
|
628
|
+
function splitCommandChain(command) {
|
|
629
|
+
if (command.includes("$(") || command.includes("`")) return null;
|
|
630
|
+
return command.split(/&&|\|\||[;|\n]/).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
|
|
631
|
+
}
|
|
632
|
+
function withCommand(req, command) {
|
|
633
|
+
return {
|
|
634
|
+
...req,
|
|
635
|
+
args: { ...req.args ?? {}, command }
|
|
636
|
+
};
|
|
637
|
+
}
|
|
562
638
|
function isAllowedByRules(rules, req) {
|
|
563
|
-
return matchesAnyRule(rules, req);
|
|
639
|
+
if (req.toolName !== "bash") return matchesAnyRule(rules, req);
|
|
640
|
+
const command = requestTarget(req);
|
|
641
|
+
if (command === void 0) return matchesAnyRule(rules, req);
|
|
642
|
+
const segments = splitCommandChain(command);
|
|
643
|
+
if (segments === null) return false;
|
|
644
|
+
if (segments.length === 0) return matchesAnyRule(rules, req);
|
|
645
|
+
return segments.every((segment) => matchesAnyRule(rules, withCommand(req, segment)));
|
|
564
646
|
}
|
|
565
647
|
function isDeniedByRules(rules, req) {
|
|
566
|
-
return matchesAnyRule(rules, req);
|
|
648
|
+
if (req.toolName !== "bash") return matchesAnyRule(rules, req);
|
|
649
|
+
const command = requestTarget(req);
|
|
650
|
+
if (command === void 0) return matchesAnyRule(rules, req);
|
|
651
|
+
const segments = splitCommandChain(command);
|
|
652
|
+
if (segments === null || segments.length === 0) return matchesAnyRule(rules, req);
|
|
653
|
+
return segments.some((segment) => matchesAnyRule(rules, withCommand(req, segment)));
|
|
567
654
|
}
|
|
568
655
|
function matchesAnyRule(rules, req) {
|
|
569
656
|
return rules.some((raw) => {
|
|
@@ -577,10 +664,17 @@ function buildAllowRule(req) {
|
|
|
577
664
|
}
|
|
578
665
|
|
|
579
666
|
// src/permissions/gate.ts
|
|
580
|
-
var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file"]);
|
|
667
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "grep", "glob"]);
|
|
581
668
|
var DANGEROUS_COMMAND_PATTERNS = [
|
|
582
669
|
/rm\s+-rf\s+\/(?:\s|$|;|&)/i,
|
|
583
670
|
/rm\s+-rf\s+~(?:\s|\/|$|;|&)/i,
|
|
671
|
+
/rm\s+-rf\s+\.\/?(?:\s|$|;|&)/i,
|
|
672
|
+
/rm\s+-rf\s+\*(?:\s|$|;|&)/i,
|
|
673
|
+
/\bsudo\s+rm\b/i,
|
|
674
|
+
/\bdel\s+\/s\b/i,
|
|
675
|
+
/\brd\s+\/s\b/i,
|
|
676
|
+
/\bformat\s+[a-z]:/i,
|
|
677
|
+
/\bdiskpart\b/i,
|
|
584
678
|
/:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
|
|
585
679
|
/\bmkfs\b/i,
|
|
586
680
|
/dd\s+[^|;]*\bif=/i,
|
|
@@ -610,12 +704,30 @@ function isOutsideCwd(p, cwd) {
|
|
|
610
704
|
const base = normalizeAbsolute(cwd, cwd);
|
|
611
705
|
return abs !== base && !abs.startsWith(`${base}/`);
|
|
612
706
|
}
|
|
613
|
-
function
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
707
|
+
function resolveRealPath(p, cwd) {
|
|
708
|
+
let current = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
|
|
709
|
+
const missing = [];
|
|
710
|
+
for (; ; ) {
|
|
711
|
+
try {
|
|
712
|
+
return path.join(fs.realpathSync(current), ...missing.reverse());
|
|
713
|
+
} catch {
|
|
714
|
+
const parent = path.dirname(current);
|
|
715
|
+
if (parent === current) return null;
|
|
716
|
+
missing.push(path.basename(current));
|
|
717
|
+
current = parent;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function isOutsideForWrite(p, cwd) {
|
|
722
|
+
const resolved = resolveRealPath(p, cwd);
|
|
723
|
+
if (resolved === null) return isOutsideCwd(p, cwd);
|
|
724
|
+
let realCwd;
|
|
725
|
+
try {
|
|
726
|
+
realCwd = fs.realpathSync(cwd);
|
|
727
|
+
} catch {
|
|
728
|
+
return isOutsideCwd(p, cwd);
|
|
617
729
|
}
|
|
618
|
-
return
|
|
730
|
+
return isOutsideCwd(resolved, realCwd);
|
|
619
731
|
}
|
|
620
732
|
function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
|
|
621
733
|
if (mode === "yolo") {
|
|
@@ -627,10 +739,12 @@ function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
|
|
|
627
739
|
return "deny";
|
|
628
740
|
}
|
|
629
741
|
if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
|
|
630
|
-
|
|
742
|
+
const isWriteTool = req.toolName === "write_file" || req.toolName === "edit_file";
|
|
743
|
+
if (isWriteTool && isSensitivePath(filePath)) {
|
|
631
744
|
return "deny";
|
|
632
745
|
}
|
|
633
|
-
|
|
746
|
+
const outside = isWriteTool ? isOutsideForWrite(filePath, ctx.cwd) : isOutsideCwd(filePath, ctx.cwd);
|
|
747
|
+
if (outside) {
|
|
634
748
|
if (mode === "ask" && req.level === "read") {
|
|
635
749
|
return "ask";
|
|
636
750
|
}
|
|
@@ -701,10 +815,93 @@ function scheduleSessionTitle(store, userText, model) {
|
|
|
701
815
|
});
|
|
702
816
|
}
|
|
703
817
|
|
|
704
|
-
// src/
|
|
705
|
-
import {
|
|
818
|
+
// src/snapshot/git-tree.ts
|
|
819
|
+
import { execFile } from "child_process";
|
|
820
|
+
import { existsSync } from "fs";
|
|
821
|
+
import { mkdir } from "fs/promises";
|
|
822
|
+
import os from "os";
|
|
706
823
|
import path2 from "path";
|
|
824
|
+
var GIT_TIMEOUT_MS2 = 1e4;
|
|
825
|
+
var GIT_MAX_BUFFER2 = 8 * 1024 * 1024;
|
|
826
|
+
function runGit2(args) {
|
|
827
|
+
return new Promise((resolve) => {
|
|
828
|
+
execFile(
|
|
829
|
+
"git",
|
|
830
|
+
args,
|
|
831
|
+
{ timeout: GIT_TIMEOUT_MS2, maxBuffer: GIT_MAX_BUFFER2, encoding: "utf8" },
|
|
832
|
+
(error, stdout) => {
|
|
833
|
+
resolve(error ? null : stdout.trim());
|
|
834
|
+
}
|
|
835
|
+
);
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
var gitAvailable = null;
|
|
839
|
+
async function isGitAvailable() {
|
|
840
|
+
if (gitAvailable === null) {
|
|
841
|
+
gitAvailable = await runGit2(["--version"]) !== null;
|
|
842
|
+
}
|
|
843
|
+
return gitAvailable;
|
|
844
|
+
}
|
|
845
|
+
function treeRepoDir(cwd) {
|
|
846
|
+
const key = Buffer.from(path2.resolve(cwd), "utf8").toString("base64url");
|
|
847
|
+
return path2.join(gitTreesDir(), key);
|
|
848
|
+
}
|
|
849
|
+
function isGuardedDir(cwd) {
|
|
850
|
+
const resolved = path2.resolve(cwd);
|
|
851
|
+
const home = path2.resolve(os.homedir());
|
|
852
|
+
const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
853
|
+
return same(resolved, home) || same(resolved, path2.parse(resolved).root);
|
|
854
|
+
}
|
|
855
|
+
async function ensureTreeRepo(cwd) {
|
|
856
|
+
const dir = treeRepoDir(cwd);
|
|
857
|
+
if (existsSync(path2.join(dir, "HEAD"))) return dir;
|
|
858
|
+
await mkdir(path2.dirname(dir), { recursive: true });
|
|
859
|
+
const out = await runGit2(["init", "--bare", dir]);
|
|
860
|
+
return out === null ? null : dir;
|
|
861
|
+
}
|
|
862
|
+
function repoArgs(dir, cwd) {
|
|
863
|
+
return [`--git-dir=${dir}`, `--work-tree=${cwd}`];
|
|
864
|
+
}
|
|
865
|
+
async function trackTree(cwd) {
|
|
866
|
+
if (isGuardedDir(cwd)) return null;
|
|
867
|
+
if (!await isGitAvailable()) return null;
|
|
868
|
+
const dir = await ensureTreeRepo(cwd);
|
|
869
|
+
if (!dir) return null;
|
|
870
|
+
const base = repoArgs(dir, cwd);
|
|
871
|
+
if (await runGit2([...base, "add", "-A"]) === null) return null;
|
|
872
|
+
return runGit2([...base, "write-tree"]);
|
|
873
|
+
}
|
|
874
|
+
async function restoreTree(cwd, hash) {
|
|
875
|
+
if (isGuardedDir(cwd)) return false;
|
|
876
|
+
if (!await isGitAvailable()) return false;
|
|
877
|
+
const dir = treeRepoDir(cwd);
|
|
878
|
+
if (!existsSync(path2.join(dir, "HEAD"))) return false;
|
|
879
|
+
const base = repoArgs(dir, cwd);
|
|
880
|
+
if (await runGit2([...base, "read-tree", hash]) === null) return false;
|
|
881
|
+
if (await runGit2([...base, "checkout-index", "-f", "-a"]) === null) return false;
|
|
882
|
+
if (await runGit2([...base, "clean", "-fd"]) === null) return false;
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
async function diffTreeNames(cwd, hash) {
|
|
886
|
+
if (!await isGitAvailable()) return null;
|
|
887
|
+
const dir = treeRepoDir(cwd);
|
|
888
|
+
if (!existsSync(path2.join(dir, "HEAD"))) return null;
|
|
889
|
+
const base = repoArgs(dir, cwd);
|
|
890
|
+
const changed = await runGit2([...base, "diff", "--name-only", hash]);
|
|
891
|
+
const untracked = await runGit2([...base, "ls-files", "--others", "--exclude-standard"]);
|
|
892
|
+
if (changed === null || untracked === null) return null;
|
|
893
|
+
const names = /* @__PURE__ */ new Set();
|
|
894
|
+
for (const line of [...changed.split("\n"), ...untracked.split("\n")]) {
|
|
895
|
+
if (line !== "") names.add(line);
|
|
896
|
+
}
|
|
897
|
+
return [...names];
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// src/tools/fs/snapshots.ts
|
|
901
|
+
import { mkdir as mkdir2, readFile, rm, stat, writeFile } from "fs/promises";
|
|
902
|
+
import path3 from "path";
|
|
707
903
|
var MAX_SNAPSHOTS = 50;
|
|
904
|
+
var MAX_SNAPSHOT_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
708
905
|
var stack = [];
|
|
709
906
|
var activeTurn = 0;
|
|
710
907
|
var activeTurnMessageIndex = -1;
|
|
@@ -726,28 +923,48 @@ function currentTurnSeq() {
|
|
|
726
923
|
function currentTurnMessageIndex() {
|
|
727
924
|
return activeTurnMessageIndex;
|
|
728
925
|
}
|
|
729
|
-
|
|
926
|
+
function ownerOf(snapshot) {
|
|
927
|
+
return snapshot.owner ?? "root";
|
|
928
|
+
}
|
|
929
|
+
async function captureSnapshot(filePath, toolName, snapshotContext) {
|
|
730
930
|
let content = null;
|
|
731
931
|
let existed = false;
|
|
732
|
-
|
|
733
|
-
|
|
932
|
+
let contentTooLarge = false;
|
|
933
|
+
const st = await stat(filePath).catch(() => null);
|
|
934
|
+
if (st?.isFile()) {
|
|
734
935
|
existed = true;
|
|
735
|
-
|
|
936
|
+
if (st.size > MAX_SNAPSHOT_CONTENT_BYTES) {
|
|
937
|
+
contentTooLarge = true;
|
|
938
|
+
} else {
|
|
939
|
+
try {
|
|
940
|
+
content = await readFile(filePath, "utf8");
|
|
941
|
+
} catch {
|
|
942
|
+
existed = false;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
736
945
|
}
|
|
737
946
|
return {
|
|
738
947
|
id: nextSnapshotId(),
|
|
739
948
|
path: filePath,
|
|
740
949
|
existed,
|
|
741
950
|
content,
|
|
951
|
+
contentTooLarge: contentTooLarge || void 0,
|
|
742
952
|
toolName,
|
|
743
953
|
timestamp: Date.now(),
|
|
744
|
-
turn: activeTurn,
|
|
745
|
-
messageIndex: activeTurnMessageIndex
|
|
954
|
+
turn: snapshotContext?.turn ?? activeTurn,
|
|
955
|
+
messageIndex: snapshotContext?.messageIndex ?? activeTurnMessageIndex,
|
|
956
|
+
owner: snapshotContext?.owner ?? "root"
|
|
746
957
|
};
|
|
747
958
|
}
|
|
748
959
|
async function pushSnapshot(snapshot) {
|
|
749
960
|
stack.push(snapshot);
|
|
750
|
-
|
|
961
|
+
try {
|
|
962
|
+
await hooks?.onPush?.(snapshot);
|
|
963
|
+
} catch (err) {
|
|
964
|
+
if (process.env.STAR_DEBUG) {
|
|
965
|
+
console.error("[star] failed to persist snapshot checkpoint:", err);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
751
968
|
if (stack.length > MAX_SNAPSHOTS) {
|
|
752
969
|
const evicted = stack.splice(0, stack.length - MAX_SNAPSHOTS);
|
|
753
970
|
await removeFromStore(evicted.map((s) => s.id));
|
|
@@ -780,191 +997,156 @@ async function resolveContent(snapshot) {
|
|
|
780
997
|
}
|
|
781
998
|
async function revert(snapshot) {
|
|
782
999
|
if (snapshot.existed) {
|
|
1000
|
+
if (snapshot.contentTooLarge) {
|
|
1001
|
+
return `Skipped ${snapshot.path}: content too large, not restored.`;
|
|
1002
|
+
}
|
|
783
1003
|
const content = await resolveContent(snapshot);
|
|
784
1004
|
if (content === null) {
|
|
785
1005
|
return `Skipped ${snapshot.path}: the snapshot content is no longer available.`;
|
|
786
1006
|
}
|
|
787
|
-
await
|
|
1007
|
+
await mkdir2(path3.dirname(snapshot.path), { recursive: true });
|
|
788
1008
|
await writeFile(snapshot.path, content, "utf8");
|
|
789
1009
|
return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
|
|
790
1010
|
}
|
|
791
1011
|
await rm(snapshot.path, { force: true });
|
|
792
1012
|
return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
|
|
793
1013
|
}
|
|
794
|
-
function listTurnSnapshots(turn) {
|
|
795
|
-
return stack.filter(
|
|
1014
|
+
function listTurnSnapshots(turn, includeSubagent = false) {
|
|
1015
|
+
return stack.filter(
|
|
1016
|
+
(snapshot) => snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")
|
|
1017
|
+
).reverse();
|
|
796
1018
|
}
|
|
797
1019
|
function resolveSnapshotContent(snapshot) {
|
|
798
1020
|
return resolveContent(snapshot);
|
|
799
1021
|
}
|
|
800
|
-
async function undoTurnSnapshots(turn) {
|
|
1022
|
+
async function undoTurnSnapshots(turn, includeSubagent = false) {
|
|
801
1023
|
const matches = [];
|
|
802
1024
|
for (let i = stack.length - 1; i >= 0; i--) {
|
|
803
1025
|
const snapshot = stack[i];
|
|
804
|
-
if (snapshot && snapshot.turn === turn) {
|
|
1026
|
+
if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
|
|
805
1027
|
matches.push(snapshot);
|
|
806
|
-
stack.splice(i, 1);
|
|
807
1028
|
}
|
|
808
1029
|
}
|
|
809
|
-
await removeFromStore(matches.map((s) => s.id));
|
|
810
1030
|
const messages = [];
|
|
1031
|
+
const revertedIds = [];
|
|
811
1032
|
for (const snapshot of matches) {
|
|
812
|
-
|
|
1033
|
+
try {
|
|
1034
|
+
messages.push(await revert(snapshot));
|
|
1035
|
+
const idx = stack.indexOf(snapshot);
|
|
1036
|
+
if (idx >= 0) stack.splice(idx, 1);
|
|
1037
|
+
revertedIds.push(snapshot.id);
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
messages.push(
|
|
1040
|
+
`Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
813
1043
|
}
|
|
1044
|
+
await removeFromStore(revertedIds);
|
|
814
1045
|
return messages;
|
|
815
1046
|
}
|
|
816
|
-
async function
|
|
1047
|
+
async function dropTurnSnapshots(turn, includeSubagent = false) {
|
|
1048
|
+
const dropped = [];
|
|
1049
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
1050
|
+
const snapshot = stack[i];
|
|
1051
|
+
if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
|
|
1052
|
+
dropped.push(snapshot.id);
|
|
1053
|
+
stack.splice(i, 1);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
await removeFromStore(dropped);
|
|
1057
|
+
return dropped.length;
|
|
1058
|
+
}
|
|
1059
|
+
async function rewindToSnapshot(id, includeSubagent = false) {
|
|
817
1060
|
if (!stack.some((snapshot) => snapshot.id === id)) return null;
|
|
818
1061
|
const matches = [];
|
|
819
1062
|
for (let i = stack.length - 1; i >= 0; i--) {
|
|
820
1063
|
const snapshot = stack[i];
|
|
821
|
-
if (snapshot && snapshot.id >= id) {
|
|
1064
|
+
if (snapshot && snapshot.id >= id && (includeSubagent || ownerOf(snapshot) === "root")) {
|
|
822
1065
|
matches.push(snapshot);
|
|
823
|
-
stack.splice(i, 1);
|
|
824
1066
|
}
|
|
825
1067
|
}
|
|
826
|
-
await removeFromStore(matches.map((s) => s.id));
|
|
827
1068
|
const reverted = [];
|
|
1069
|
+
const revertedIds = [];
|
|
828
1070
|
let messageIndex = null;
|
|
829
1071
|
for (const snapshot of matches) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1072
|
+
try {
|
|
1073
|
+
reverted.push(await revert(snapshot));
|
|
1074
|
+
const idx = stack.indexOf(snapshot);
|
|
1075
|
+
if (idx >= 0) stack.splice(idx, 1);
|
|
1076
|
+
revertedIds.push(snapshot.id);
|
|
1077
|
+
if (snapshot.messageIndex >= 0) {
|
|
1078
|
+
messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
|
|
1079
|
+
}
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
reverted.push(
|
|
1082
|
+
`Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
|
|
1083
|
+
);
|
|
833
1084
|
}
|
|
834
1085
|
}
|
|
1086
|
+
await removeFromStore(revertedIds);
|
|
835
1087
|
return { reverted, messageIndex };
|
|
836
1088
|
}
|
|
837
1089
|
|
|
838
|
-
// src/tools/
|
|
839
|
-
import {
|
|
840
|
-
import
|
|
1090
|
+
// src/tools/bash.ts
|
|
1091
|
+
import { spawn as spawn3 } from "child_process";
|
|
1092
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1093
|
+
import path4 from "path";
|
|
841
1094
|
import { z } from "zod";
|
|
842
|
-
var todoItemSchema = z.object({
|
|
843
|
-
id: z.number().int(),
|
|
844
|
-
title: z.string(),
|
|
845
|
-
status: z.enum(["pending", "in_progress", "done"])
|
|
846
|
-
});
|
|
847
|
-
var writeSchema = z.object({
|
|
848
|
-
todos: z.array(todoItemSchema).describe("The full todo list, replacing the current one")
|
|
849
|
-
});
|
|
850
|
-
var readSchema = z.object({});
|
|
851
|
-
function fileFor(cwd) {
|
|
852
|
-
return path3.join(cwd, ".star", "todos.json");
|
|
853
|
-
}
|
|
854
|
-
var TodoStore = class {
|
|
855
|
-
items = /* @__PURE__ */ new Map();
|
|
856
|
-
list() {
|
|
857
|
-
return [...this.items.values()];
|
|
858
|
-
}
|
|
859
|
-
replace(items) {
|
|
860
|
-
this.items.clear();
|
|
861
|
-
for (const item of items) {
|
|
862
|
-
this.items.set(item.id, item);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
async load(cwd) {
|
|
866
|
-
let raw;
|
|
867
|
-
try {
|
|
868
|
-
raw = await readFile2(fileFor(cwd), "utf8");
|
|
869
|
-
} catch {
|
|
870
|
-
return;
|
|
871
|
-
}
|
|
872
|
-
try {
|
|
873
|
-
const parsed = JSON.parse(raw);
|
|
874
|
-
if (Array.isArray(parsed)) {
|
|
875
|
-
this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
|
|
876
|
-
}
|
|
877
|
-
} catch {
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
async save(cwd) {
|
|
881
|
-
await mkdir2(path3.join(cwd, ".star"), { recursive: true });
|
|
882
|
-
await writeFile2(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
|
|
883
|
-
`);
|
|
884
|
-
}
|
|
885
|
-
};
|
|
886
|
-
var SYMBOLS = {
|
|
887
|
-
pending: "[ ]",
|
|
888
|
-
in_progress: "[~]",
|
|
889
|
-
done: "[x]"
|
|
890
|
-
};
|
|
891
|
-
function formatTodos(items) {
|
|
892
|
-
if (items.length === 0) {
|
|
893
|
-
return "No todos.";
|
|
894
|
-
}
|
|
895
|
-
const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
|
|
896
|
-
const width = Math.max(...lines.map((l) => l.length));
|
|
897
|
-
return lines.map((line, i) => {
|
|
898
|
-
const status = items[i]?.status;
|
|
899
|
-
return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
|
|
900
|
-
}).join("\n");
|
|
901
|
-
}
|
|
902
|
-
var defaultStore = new TodoStore();
|
|
903
|
-
function parseTodoArgs(args) {
|
|
904
|
-
const parsed = writeSchema.safeParse(args);
|
|
905
|
-
return parsed.success ? parsed.data.todos : null;
|
|
906
|
-
}
|
|
907
|
-
function pendingTodoTitles(args) {
|
|
908
|
-
return (parseTodoArgs(args) ?? []).filter((t) => t.status !== "done").map((t) => t.title);
|
|
909
|
-
}
|
|
910
|
-
async function loadTodos(cwd) {
|
|
911
|
-
await defaultStore.load(cwd);
|
|
912
|
-
return defaultStore.list();
|
|
913
|
-
}
|
|
914
|
-
function resetTodos() {
|
|
915
|
-
defaultStore.replace([]);
|
|
916
|
-
}
|
|
917
|
-
function createTodoTools(store = defaultStore) {
|
|
918
|
-
const todoWrite = {
|
|
919
|
-
name: "todo_write",
|
|
920
|
-
description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
|
|
921
|
-
permission: "read",
|
|
922
|
-
parameters: writeSchema,
|
|
923
|
-
async execute(args, ctx) {
|
|
924
|
-
const parsed = writeSchema.safeParse(args);
|
|
925
|
-
if (!parsed.success) {
|
|
926
|
-
return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
|
|
927
|
-
}
|
|
928
|
-
store.replace(parsed.data.todos);
|
|
929
|
-
await store.save(ctx.cwd);
|
|
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).` };
|
|
933
|
-
}
|
|
934
|
-
};
|
|
935
|
-
const todoRead = {
|
|
936
|
-
name: "todo_read",
|
|
937
|
-
description: "Read the current todo list.",
|
|
938
|
-
permission: "read",
|
|
939
|
-
parameters: readSchema,
|
|
940
|
-
async execute() {
|
|
941
|
-
return { content: formatTodos(store.list()) };
|
|
942
|
-
}
|
|
943
|
-
};
|
|
944
|
-
return [todoWrite, todoRead];
|
|
945
|
-
}
|
|
946
1095
|
|
|
947
|
-
// src/
|
|
1096
|
+
// src/tasks/manager.ts
|
|
1097
|
+
import { spawn as spawn2 } from "child_process";
|
|
948
1098
|
import { EventEmitter } from "events";
|
|
949
|
-
var
|
|
1099
|
+
var MAX_TASK_OUTPUT = 1e5;
|
|
1100
|
+
var DEFAULT_TASK_TIMEOUT = 3600;
|
|
1101
|
+
var MAX_FINISHED_RECORDS = 50;
|
|
1102
|
+
var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
|
|
1103
|
+
var TaskManager = class extends EventEmitter {
|
|
950
1104
|
records = /* @__PURE__ */ new Map();
|
|
951
|
-
|
|
952
|
-
start(
|
|
953
|
-
const id = `
|
|
1105
|
+
seq = 0;
|
|
1106
|
+
start(opts) {
|
|
1107
|
+
const id = `task-${++this.seq}`;
|
|
1108
|
+
const spec = resolveShell();
|
|
1109
|
+
const child = spawn2(spec.shell, spec.wrap(opts.command), {
|
|
1110
|
+
cwd: opts.cwd,
|
|
1111
|
+
windowsHide: true,
|
|
1112
|
+
// POSIX: the child leads its own process group so killTree can SIGKILL
|
|
1113
|
+
// the whole tree with a negative pid (Windows uses taskkill /t).
|
|
1114
|
+
detached: process.platform !== "win32"
|
|
1115
|
+
});
|
|
954
1116
|
const rec = {
|
|
955
1117
|
id,
|
|
956
|
-
|
|
957
|
-
description:
|
|
1118
|
+
command: opts.command,
|
|
1119
|
+
description: opts.description,
|
|
958
1120
|
status: "running",
|
|
959
1121
|
startedAt: Date.now(),
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1122
|
+
output: "",
|
|
1123
|
+
truncated: false,
|
|
1124
|
+
child
|
|
963
1125
|
};
|
|
964
1126
|
this.records.set(id, rec);
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1127
|
+
const append = (d) => {
|
|
1128
|
+
rec.output += d.toString("utf8");
|
|
1129
|
+
if (rec.output.length > MAX_TASK_OUTPUT) {
|
|
1130
|
+
rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
|
|
1131
|
+
rec.truncated = true;
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
child.stdout.on("data", append);
|
|
1135
|
+
child.stderr.on("data", append);
|
|
1136
|
+
child.on("error", (err) => {
|
|
1137
|
+
append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
|
|
1138
|
+
`));
|
|
1139
|
+
if (rec.status === "running") {
|
|
1140
|
+
this.finish(rec, "failed", null);
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
child.on("close", (code) => {
|
|
1144
|
+
if (rec.status !== "running") return;
|
|
1145
|
+
this.finish(rec, code === 0 ? "completed" : "failed", code);
|
|
1146
|
+
});
|
|
1147
|
+
const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
|
|
1148
|
+
rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
|
|
1149
|
+
rec.timer.unref();
|
|
968
1150
|
this.emitUpdate(rec);
|
|
969
1151
|
return this.snapshot(rec);
|
|
970
1152
|
}
|
|
@@ -985,343 +1167,46 @@ var AgentTaskManager = class extends EventEmitter {
|
|
|
985
1167
|
kill(id) {
|
|
986
1168
|
const rec = this.records.get(id);
|
|
987
1169
|
if (!rec || rec.status !== "running") return void 0;
|
|
988
|
-
|
|
989
|
-
this.finish(rec, "stopped", rec.result || "Stopped by user.");
|
|
1170
|
+
this.terminate(rec, "stopped");
|
|
990
1171
|
return this.snapshot(rec);
|
|
991
1172
|
}
|
|
992
1173
|
cleanup() {
|
|
993
1174
|
const killed = [];
|
|
994
1175
|
for (const rec of this.records.values()) {
|
|
995
1176
|
if (rec.status === "running") {
|
|
996
|
-
|
|
997
|
-
this.finish(rec, "stopped", rec.result || "Stopped on shutdown.");
|
|
1177
|
+
this.terminate(rec, "stopped");
|
|
998
1178
|
killed.push(this.snapshot(rec));
|
|
999
1179
|
}
|
|
1000
1180
|
}
|
|
1001
1181
|
return killed;
|
|
1002
1182
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
for (const rec of this.records.values()) {
|
|
1007
|
-
if (rec.status !== "running" && !rec.notified) {
|
|
1008
|
-
rec.notified = true;
|
|
1009
|
-
drained.push(this.snapshot(rec));
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
return drained;
|
|
1183
|
+
terminate(rec, status) {
|
|
1184
|
+
killTree2(rec.child);
|
|
1185
|
+
this.finish(rec, status, null);
|
|
1013
1186
|
}
|
|
1014
|
-
finish(rec, status,
|
|
1015
|
-
if (rec.
|
|
1187
|
+
finish(rec, status, exitCode) {
|
|
1188
|
+
if (rec.timer) clearTimeout(rec.timer);
|
|
1016
1189
|
rec.status = status;
|
|
1017
|
-
rec.
|
|
1190
|
+
rec.exitCode = exitCode;
|
|
1018
1191
|
rec.endedAt = Date.now();
|
|
1192
|
+
this.pruneFinished();
|
|
1019
1193
|
this.emitUpdate(rec);
|
|
1020
1194
|
}
|
|
1195
|
+
pruneFinished() {
|
|
1196
|
+
const finished = [...this.records.values()].filter((rec) => rec.status !== "running");
|
|
1197
|
+
if (finished.length <= MAX_FINISHED_RECORDS) return;
|
|
1198
|
+
finished.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0));
|
|
1199
|
+
for (const rec of finished.slice(0, finished.length - MAX_FINISHED_RECORDS)) {
|
|
1200
|
+
this.records.delete(rec.id);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1021
1203
|
emitUpdate(rec) {
|
|
1022
1204
|
this.emit("update", this.snapshot(rec));
|
|
1023
1205
|
}
|
|
1024
1206
|
snapshot(rec) {
|
|
1025
1207
|
return {
|
|
1026
1208
|
id: rec.id,
|
|
1027
|
-
|
|
1028
|
-
description: rec.description,
|
|
1029
|
-
status: rec.status,
|
|
1030
|
-
startedAt: rec.startedAt,
|
|
1031
|
-
endedAt: rec.endedAt,
|
|
1032
|
-
result: rec.result
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
};
|
|
1036
|
-
var defaultAgentTasks = new AgentTaskManager();
|
|
1037
|
-
|
|
1038
|
-
// src/agent/project-memory.ts
|
|
1039
|
-
import { readFileSync, statSync } from "fs";
|
|
1040
|
-
import path4 from "path";
|
|
1041
|
-
var PROJECT_MEMORY_FILE = "AGENTS.md";
|
|
1042
|
-
var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
|
|
1043
|
-
var cache = /* @__PURE__ */ new Map();
|
|
1044
|
-
function readProjectMemory(cwd) {
|
|
1045
|
-
const file = path4.join(cwd, PROJECT_MEMORY_FILE);
|
|
1046
|
-
let mtimeMs;
|
|
1047
|
-
try {
|
|
1048
|
-
mtimeMs = statSync(file).mtimeMs;
|
|
1049
|
-
} catch {
|
|
1050
|
-
return null;
|
|
1051
|
-
}
|
|
1052
|
-
const hit = cache.get(file);
|
|
1053
|
-
if (hit && hit.mtimeMs === mtimeMs) return hit.block;
|
|
1054
|
-
let block = null;
|
|
1055
|
-
try {
|
|
1056
|
-
let body = readFileSync(file, "utf8").trim();
|
|
1057
|
-
if (body) {
|
|
1058
|
-
if (body.length > PROJECT_MEMORY_MAX_CHARS) {
|
|
1059
|
-
body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
|
|
1060
|
-
|
|
1061
|
-
[AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
|
|
1062
|
-
}
|
|
1063
|
-
block = `# Project instructions (AGENTS.md)
|
|
1064
|
-
${body}`;
|
|
1065
|
-
}
|
|
1066
|
-
} catch {
|
|
1067
|
-
block = null;
|
|
1068
|
-
}
|
|
1069
|
-
cache.set(file, { mtimeMs, block });
|
|
1070
|
-
return block;
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
// src/agent/skills.ts
|
|
1074
|
-
import { readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "fs";
|
|
1075
|
-
import path6 from "path";
|
|
1076
|
-
import { z as z2 } from "zod";
|
|
1077
|
-
|
|
1078
|
-
// src/config/paths.ts
|
|
1079
|
-
import os from "os";
|
|
1080
|
-
import path5 from "path";
|
|
1081
|
-
function starHome() {
|
|
1082
|
-
return process.env.STAR_HOME ?? path5.join(os.homedir(), ".star-cli");
|
|
1083
|
-
}
|
|
1084
|
-
function globalConfigPath() {
|
|
1085
|
-
return path5.join(starHome(), "config.toml");
|
|
1086
|
-
}
|
|
1087
|
-
function sessionsDir() {
|
|
1088
|
-
return path5.join(starHome(), "sessions");
|
|
1089
|
-
}
|
|
1090
|
-
function userMemoryPath() {
|
|
1091
|
-
return path5.join(starHome(), "MEMORY.md");
|
|
1092
|
-
}
|
|
1093
|
-
function envFilePath() {
|
|
1094
|
-
return path5.join(starHome(), ".env");
|
|
1095
|
-
}
|
|
1096
|
-
function projectConfigPath(cwd) {
|
|
1097
|
-
return path5.join(cwd, ".star", "config.toml");
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// src/agent/skills.ts
|
|
1101
|
-
var SKILL_FILE = "SKILL.md";
|
|
1102
|
-
var SKILL_MAX_CHARS = 32 * 1024;
|
|
1103
|
-
var NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
1104
|
-
function parseFrontmatter(raw) {
|
|
1105
|
-
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
1106
|
-
if (!match) return null;
|
|
1107
|
-
const meta = {};
|
|
1108
|
-
for (const line of (match[1] ?? "").split(/\r?\n/)) {
|
|
1109
|
-
const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
1110
|
-
if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
|
|
1111
|
-
}
|
|
1112
|
-
return { meta, body: raw.slice(match[0].length).trim() };
|
|
1113
|
-
}
|
|
1114
|
-
var cache2 = /* @__PURE__ */ new Map();
|
|
1115
|
-
function parseSkillFile(filePath, dirName, scope) {
|
|
1116
|
-
let mtimeMs;
|
|
1117
|
-
try {
|
|
1118
|
-
mtimeMs = statSync2(filePath).mtimeMs;
|
|
1119
|
-
} catch {
|
|
1120
|
-
return null;
|
|
1121
|
-
}
|
|
1122
|
-
const hit = cache2.get(filePath);
|
|
1123
|
-
if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
|
|
1124
|
-
let skill = null;
|
|
1125
|
-
try {
|
|
1126
|
-
const parsed = parseFrontmatter(readFileSync2(filePath, "utf8"));
|
|
1127
|
-
const name = parsed?.meta.name || dirName;
|
|
1128
|
-
const description = parsed?.meta.description;
|
|
1129
|
-
if (parsed && NAME_PATTERN.test(name) && description) {
|
|
1130
|
-
skill = { name, description, filePath, dir: path6.dirname(filePath), scope };
|
|
1131
|
-
}
|
|
1132
|
-
} catch {
|
|
1133
|
-
skill = null;
|
|
1134
|
-
}
|
|
1135
|
-
cache2.set(filePath, { mtimeMs, skill });
|
|
1136
|
-
return skill;
|
|
1137
|
-
}
|
|
1138
|
-
function scanDir(base, scope, into) {
|
|
1139
|
-
let entries;
|
|
1140
|
-
try {
|
|
1141
|
-
entries = readdirSync(base);
|
|
1142
|
-
} catch {
|
|
1143
|
-
return;
|
|
1144
|
-
}
|
|
1145
|
-
for (const entry of entries.sort()) {
|
|
1146
|
-
if (!NAME_PATTERN.test(entry)) continue;
|
|
1147
|
-
const parsed = parseSkillFile(path6.join(base, entry, SKILL_FILE), entry, scope);
|
|
1148
|
-
if (parsed) into.set(parsed.name, parsed);
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
function discoverSkills(cwd, home = starHome()) {
|
|
1152
|
-
const byName = /* @__PURE__ */ new Map();
|
|
1153
|
-
scanDir(path6.join(home, "skills"), "user", byName);
|
|
1154
|
-
scanDir(path6.join(cwd, ".star", "skills"), "project", byName);
|
|
1155
|
-
return [...byName.values()];
|
|
1156
|
-
}
|
|
1157
|
-
function formatSkillsBlock(skills) {
|
|
1158
|
-
const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
|
|
1159
|
-
return [
|
|
1160
|
-
"# Available skills",
|
|
1161
|
-
"When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
|
|
1162
|
-
...lines
|
|
1163
|
-
].join("\n");
|
|
1164
|
-
}
|
|
1165
|
-
function loadSkillBody(skill) {
|
|
1166
|
-
let parsed = null;
|
|
1167
|
-
try {
|
|
1168
|
-
parsed = parseFrontmatter(readFileSync2(skill.filePath, "utf8"));
|
|
1169
|
-
} catch {
|
|
1170
|
-
return null;
|
|
1171
|
-
}
|
|
1172
|
-
if (!parsed?.body) return null;
|
|
1173
|
-
if (parsed.body.length > SKILL_MAX_CHARS) {
|
|
1174
|
-
return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
|
|
1175
|
-
|
|
1176
|
-
[skill truncated to ${SKILL_MAX_CHARS} characters]`;
|
|
1177
|
-
}
|
|
1178
|
-
return parsed.body;
|
|
1179
|
-
}
|
|
1180
|
-
function createSkillTool(deps) {
|
|
1181
|
-
return {
|
|
1182
|
-
name: "skill",
|
|
1183
|
-
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.",
|
|
1184
|
-
permission: "read",
|
|
1185
|
-
parameters: z2.object({
|
|
1186
|
-
name: z2.string().describe("Name of the skill to load, as listed in the system prompt.")
|
|
1187
|
-
}),
|
|
1188
|
-
async execute(args) {
|
|
1189
|
-
const skills = discoverSkills(deps.cwd, deps.home);
|
|
1190
|
-
const skill = skills.find((s) => s.name === args.name);
|
|
1191
|
-
if (!skill) {
|
|
1192
|
-
const available = skills.map((s) => s.name).join(", ") || "none";
|
|
1193
|
-
return {
|
|
1194
|
-
content: `Unknown skill: "${args.name}". Available skills: ${available}`,
|
|
1195
|
-
isError: true
|
|
1196
|
-
};
|
|
1197
|
-
}
|
|
1198
|
-
const body = loadSkillBody(skill);
|
|
1199
|
-
if (!body) {
|
|
1200
|
-
return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
|
|
1201
|
-
}
|
|
1202
|
-
return {
|
|
1203
|
-
content: [
|
|
1204
|
-
`# Skill: ${skill.name}`,
|
|
1205
|
-
"",
|
|
1206
|
-
body,
|
|
1207
|
-
"",
|
|
1208
|
-
`Base directory for relative file references in this skill: ${skill.dir}`
|
|
1209
|
-
].join("\n")
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
};
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// src/agent/subagent.ts
|
|
1216
|
-
import { z as z12 } from "zod";
|
|
1217
|
-
|
|
1218
|
-
// src/tools/bash.ts
|
|
1219
|
-
import { spawn as spawn3 } from "child_process";
|
|
1220
|
-
import { existsSync } from "fs";
|
|
1221
|
-
import path7 from "path";
|
|
1222
|
-
import { z as z3 } from "zod";
|
|
1223
|
-
|
|
1224
|
-
// src/tasks/manager.ts
|
|
1225
|
-
import { spawn as spawn2 } from "child_process";
|
|
1226
|
-
import { EventEmitter as EventEmitter2 } from "events";
|
|
1227
|
-
var MAX_TASK_OUTPUT = 1e5;
|
|
1228
|
-
var DEFAULT_TASK_TIMEOUT = 3600;
|
|
1229
|
-
var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
|
|
1230
|
-
var TaskManager = class extends EventEmitter2 {
|
|
1231
|
-
records = /* @__PURE__ */ new Map();
|
|
1232
|
-
seq = 0;
|
|
1233
|
-
start(opts) {
|
|
1234
|
-
const id = `task-${++this.seq}`;
|
|
1235
|
-
const spec = resolveShell();
|
|
1236
|
-
const child = spawn2(spec.shell, spec.wrap(opts.command), {
|
|
1237
|
-
cwd: opts.cwd,
|
|
1238
|
-
windowsHide: true
|
|
1239
|
-
});
|
|
1240
|
-
const rec = {
|
|
1241
|
-
id,
|
|
1242
|
-
command: opts.command,
|
|
1243
|
-
description: opts.description,
|
|
1244
|
-
status: "running",
|
|
1245
|
-
startedAt: Date.now(),
|
|
1246
|
-
output: "",
|
|
1247
|
-
truncated: false,
|
|
1248
|
-
child
|
|
1249
|
-
};
|
|
1250
|
-
this.records.set(id, rec);
|
|
1251
|
-
const append = (d) => {
|
|
1252
|
-
rec.output += d.toString("utf8");
|
|
1253
|
-
if (rec.output.length > MAX_TASK_OUTPUT) {
|
|
1254
|
-
rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
|
|
1255
|
-
rec.truncated = true;
|
|
1256
|
-
}
|
|
1257
|
-
};
|
|
1258
|
-
child.stdout.on("data", append);
|
|
1259
|
-
child.stderr.on("data", append);
|
|
1260
|
-
child.on("error", (err) => {
|
|
1261
|
-
append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
|
|
1262
|
-
`));
|
|
1263
|
-
if (rec.status === "running") {
|
|
1264
|
-
this.finish(rec, "failed", null);
|
|
1265
|
-
}
|
|
1266
|
-
});
|
|
1267
|
-
child.on("close", (code) => {
|
|
1268
|
-
if (rec.status !== "running") return;
|
|
1269
|
-
this.finish(rec, code === 0 ? "completed" : "failed", code);
|
|
1270
|
-
});
|
|
1271
|
-
const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
|
|
1272
|
-
rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
|
|
1273
|
-
rec.timer.unref();
|
|
1274
|
-
this.emitUpdate(rec);
|
|
1275
|
-
return this.snapshot(rec);
|
|
1276
|
-
}
|
|
1277
|
-
get(id) {
|
|
1278
|
-
const rec = this.records.get(id);
|
|
1279
|
-
return rec ? this.snapshot(rec) : void 0;
|
|
1280
|
-
}
|
|
1281
|
-
list() {
|
|
1282
|
-
return [...this.records.values()].map((rec) => this.snapshot(rec));
|
|
1283
|
-
}
|
|
1284
|
-
runningCount() {
|
|
1285
|
-
let count = 0;
|
|
1286
|
-
for (const rec of this.records.values()) {
|
|
1287
|
-
if (rec.status === "running") count += 1;
|
|
1288
|
-
}
|
|
1289
|
-
return count;
|
|
1290
|
-
}
|
|
1291
|
-
kill(id) {
|
|
1292
|
-
const rec = this.records.get(id);
|
|
1293
|
-
if (!rec || rec.status !== "running") return void 0;
|
|
1294
|
-
this.terminate(rec, "stopped");
|
|
1295
|
-
return this.snapshot(rec);
|
|
1296
|
-
}
|
|
1297
|
-
cleanup() {
|
|
1298
|
-
const killed = [];
|
|
1299
|
-
for (const rec of this.records.values()) {
|
|
1300
|
-
if (rec.status === "running") {
|
|
1301
|
-
this.terminate(rec, "stopped");
|
|
1302
|
-
killed.push(this.snapshot(rec));
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
return killed;
|
|
1306
|
-
}
|
|
1307
|
-
terminate(rec, status) {
|
|
1308
|
-
killTree2(rec.child);
|
|
1309
|
-
this.finish(rec, status, null);
|
|
1310
|
-
}
|
|
1311
|
-
finish(rec, status, exitCode) {
|
|
1312
|
-
if (rec.timer) clearTimeout(rec.timer);
|
|
1313
|
-
rec.status = status;
|
|
1314
|
-
rec.exitCode = exitCode;
|
|
1315
|
-
rec.endedAt = Date.now();
|
|
1316
|
-
this.emitUpdate(rec);
|
|
1317
|
-
}
|
|
1318
|
-
emitUpdate(rec) {
|
|
1319
|
-
this.emit("update", this.snapshot(rec));
|
|
1320
|
-
}
|
|
1321
|
-
snapshot(rec) {
|
|
1322
|
-
return {
|
|
1323
|
-
id: rec.id,
|
|
1324
|
-
command: rec.command,
|
|
1209
|
+
command: rec.command,
|
|
1325
1210
|
description: rec.description,
|
|
1326
1211
|
status: rec.status,
|
|
1327
1212
|
startedAt: rec.startedAt,
|
|
@@ -1335,21 +1220,22 @@ var defaultTaskManager = new TaskManager();
|
|
|
1335
1220
|
|
|
1336
1221
|
// src/tools/bash.ts
|
|
1337
1222
|
var MAX_OUTPUT = 3e4;
|
|
1223
|
+
var HALF_OUTPUT = MAX_OUTPUT / 2;
|
|
1338
1224
|
var DEFAULT_TIMEOUT = 120;
|
|
1339
1225
|
var MAX_TIMEOUT = 600;
|
|
1340
1226
|
function findOnPath(exe, exclude) {
|
|
1341
|
-
for (const dir of (process.env.PATH ?? "").split(
|
|
1227
|
+
for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
|
|
1342
1228
|
if (!dir || exclude?.(dir)) {
|
|
1343
1229
|
continue;
|
|
1344
1230
|
}
|
|
1345
|
-
const full =
|
|
1346
|
-
if (
|
|
1231
|
+
const full = path4.join(dir, exe);
|
|
1232
|
+
if (existsSync2(full)) {
|
|
1347
1233
|
return full;
|
|
1348
1234
|
}
|
|
1349
1235
|
}
|
|
1350
1236
|
return null;
|
|
1351
1237
|
}
|
|
1352
|
-
function
|
|
1238
|
+
function resolveShellUncached() {
|
|
1353
1239
|
if (process.platform !== "win32") {
|
|
1354
1240
|
return { shell: "sh", wrap: (c) => ["-c", c], label: "sh" };
|
|
1355
1241
|
}
|
|
@@ -1360,8 +1246,8 @@ function resolveShell() {
|
|
|
1360
1246
|
}
|
|
1361
1247
|
const gitExe = findOnPath("git.exe");
|
|
1362
1248
|
if (gitExe) {
|
|
1363
|
-
const sibling =
|
|
1364
|
-
if (
|
|
1249
|
+
const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
|
|
1250
|
+
if (existsSync2(sibling)) {
|
|
1365
1251
|
return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
|
|
1366
1252
|
}
|
|
1367
1253
|
}
|
|
@@ -1370,42 +1256,82 @@ function resolveShell() {
|
|
|
1370
1256
|
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
|
1371
1257
|
"D:\\Git\\bin\\bash.exe"
|
|
1372
1258
|
]) {
|
|
1373
|
-
if (
|
|
1259
|
+
if (existsSync2(candidate)) {
|
|
1374
1260
|
return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
|
|
1375
1261
|
}
|
|
1376
1262
|
}
|
|
1377
1263
|
const comspec = process.env.ComSpec ?? "cmd.exe";
|
|
1378
1264
|
return { shell: comspec, wrap: (c) => ["/d", "/s", "/c", c], label: "cmd" };
|
|
1379
1265
|
}
|
|
1380
|
-
var
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1266
|
+
var cachedSpec = null;
|
|
1267
|
+
function resolveShell() {
|
|
1268
|
+
if (!cachedSpec) {
|
|
1269
|
+
cachedSpec = resolveShellUncached();
|
|
1270
|
+
}
|
|
1271
|
+
return cachedSpec;
|
|
1272
|
+
}
|
|
1273
|
+
var schema = z.object({
|
|
1274
|
+
command: z.string().describe("Shell command to execute"),
|
|
1275
|
+
timeout: z.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
|
|
1276
|
+
description: z.string().optional().describe("Short description of what the command does"),
|
|
1277
|
+
run_in_background: z.boolean().optional().describe(
|
|
1385
1278
|
"Run the command in the background and return a task id immediately (default false). Use task_output/task_kill to inspect or stop it."
|
|
1386
1279
|
)
|
|
1387
1280
|
});
|
|
1388
1281
|
function killTree2(child) {
|
|
1389
|
-
if (process.platform === "win32"
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1282
|
+
if (process.platform === "win32") {
|
|
1283
|
+
if (child.pid) {
|
|
1284
|
+
spawn3("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
1285
|
+
windowsHide: true,
|
|
1286
|
+
stdio: "ignore"
|
|
1287
|
+
}).unref();
|
|
1288
|
+
}
|
|
1394
1289
|
return;
|
|
1395
1290
|
}
|
|
1291
|
+
if (child.pid) {
|
|
1292
|
+
try {
|
|
1293
|
+
process.kill(-child.pid, "SIGKILL");
|
|
1294
|
+
return;
|
|
1295
|
+
} catch {
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1396
1298
|
child.kill("SIGKILL");
|
|
1397
1299
|
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1300
|
+
var BoundedOutput = class {
|
|
1301
|
+
head = "";
|
|
1302
|
+
tail = "";
|
|
1303
|
+
dropped = 0;
|
|
1304
|
+
overflow = false;
|
|
1305
|
+
append(text) {
|
|
1306
|
+
if (!this.overflow) {
|
|
1307
|
+
this.head += text;
|
|
1308
|
+
if (this.head.length <= MAX_OUTPUT) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
this.overflow = true;
|
|
1312
|
+
this.tail = this.head.slice(HALF_OUTPUT);
|
|
1313
|
+
this.head = this.head.slice(0, HALF_OUTPUT);
|
|
1314
|
+
if (this.tail.length > HALF_OUTPUT) {
|
|
1315
|
+
this.dropped = this.tail.length - HALF_OUTPUT;
|
|
1316
|
+
this.tail = this.tail.slice(-HALF_OUTPUT);
|
|
1317
|
+
}
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
this.tail += text;
|
|
1321
|
+
if (this.tail.length > HALF_OUTPUT) {
|
|
1322
|
+
this.dropped += this.tail.length - HALF_OUTPUT;
|
|
1323
|
+
this.tail = this.tail.slice(-HALF_OUTPUT);
|
|
1324
|
+
}
|
|
1401
1325
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
${
|
|
1408
|
-
}
|
|
1326
|
+
toString() {
|
|
1327
|
+
if (!this.overflow) {
|
|
1328
|
+
return this.head;
|
|
1329
|
+
}
|
|
1330
|
+
return `${this.head}
|
|
1331
|
+
... [${this.dropped} characters truncated] ...
|
|
1332
|
+
${this.tail}`;
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1409
1335
|
var bashTool = {
|
|
1410
1336
|
name: "bash",
|
|
1411
1337
|
description: "Execute a shell command (Git Bash on Windows when available, otherwise cmd; sh elsewhere). Stdout and stderr are merged. Output is truncated to 30000 characters. Set run_in_background for long-running commands; it returns a task id for task_list/task_output/task_kill.",
|
|
@@ -1430,9 +1356,10 @@ description: ${args.description ?? "(none)"}`
|
|
|
1430
1356
|
return new Promise((resolve) => {
|
|
1431
1357
|
const child = spawn3(spec.shell, spec.wrap(args.command), {
|
|
1432
1358
|
cwd: ctx.cwd,
|
|
1433
|
-
windowsHide: true
|
|
1359
|
+
windowsHide: true,
|
|
1360
|
+
detached: process.platform !== "win32"
|
|
1434
1361
|
});
|
|
1435
|
-
|
|
1362
|
+
const output = new BoundedOutput();
|
|
1436
1363
|
let settled = false;
|
|
1437
1364
|
const finish = (result) => {
|
|
1438
1365
|
if (settled) {
|
|
@@ -1445,36 +1372,32 @@ description: ${args.description ?? "(none)"}`
|
|
|
1445
1372
|
};
|
|
1446
1373
|
const onAbort = () => {
|
|
1447
1374
|
killTree2(child);
|
|
1448
|
-
finish({ content: `${
|
|
1375
|
+
finish({ content: `${output}
|
|
1449
1376
|
Command aborted`, isError: true });
|
|
1450
1377
|
};
|
|
1451
1378
|
const timer = setTimeout(() => {
|
|
1452
1379
|
killTree2(child);
|
|
1453
1380
|
finish({
|
|
1454
|
-
content: `${
|
|
1381
|
+
content: `${output}
|
|
1455
1382
|
Command timed out after ${timeoutSeconds}s`,
|
|
1456
1383
|
isError: true
|
|
1457
1384
|
});
|
|
1458
1385
|
}, timeoutSeconds * 1e3);
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
child.stderr.on("data", (d) => {
|
|
1463
|
-
output += d.toString("utf8");
|
|
1464
|
-
});
|
|
1386
|
+
timer.unref();
|
|
1387
|
+
child.stdout.on("data", (d) => output.append(d.toString("utf8")));
|
|
1388
|
+
child.stderr.on("data", (d) => output.append(d.toString("utf8")));
|
|
1465
1389
|
child.on("error", (err) => {
|
|
1466
1390
|
finish({ content: `Failed to start shell '${spec.label}': ${err.message}`, isError: true });
|
|
1467
1391
|
});
|
|
1468
1392
|
ctx.abortSignal?.addEventListener("abort", onAbort);
|
|
1469
1393
|
child.on("close", (code) => {
|
|
1470
|
-
const trimmed = output.replace(/\s+$/, "");
|
|
1394
|
+
const trimmed = output.toString().replace(/\s+$/, "");
|
|
1471
1395
|
if (code === 0) {
|
|
1472
|
-
finish({ content:
|
|
1396
|
+
finish({ content: trimmed || "(no output)" });
|
|
1473
1397
|
return;
|
|
1474
1398
|
}
|
|
1475
|
-
const body = truncateMiddle(trimmed);
|
|
1476
1399
|
finish({
|
|
1477
|
-
content: `${
|
|
1400
|
+
content: `${trimmed}${trimmed ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
|
|
1478
1401
|
isError: true
|
|
1479
1402
|
});
|
|
1480
1403
|
});
|
|
@@ -1483,14 +1406,14 @@ Command timed out after ${timeoutSeconds}s`,
|
|
|
1483
1406
|
};
|
|
1484
1407
|
|
|
1485
1408
|
// src/tools/fs/edit.ts
|
|
1486
|
-
import { readFile as
|
|
1487
|
-
import
|
|
1488
|
-
import { z as
|
|
1489
|
-
var schema2 =
|
|
1490
|
-
path:
|
|
1491
|
-
old_string:
|
|
1492
|
-
new_string:
|
|
1493
|
-
replace_all:
|
|
1409
|
+
import { readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
|
|
1410
|
+
import path5 from "path";
|
|
1411
|
+
import { z as z2 } from "zod";
|
|
1412
|
+
var schema2 = z2.object({
|
|
1413
|
+
path: z2.string().describe("File path, absolute or relative to the working directory"),
|
|
1414
|
+
old_string: z2.string().min(1).describe("Exact text to replace"),
|
|
1415
|
+
new_string: z2.string().describe("Replacement text"),
|
|
1416
|
+
replace_all: z2.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
|
|
1494
1417
|
});
|
|
1495
1418
|
var editFileTool = {
|
|
1496
1419
|
name: "edit_file",
|
|
@@ -1498,10 +1421,12 @@ var editFileTool = {
|
|
|
1498
1421
|
permission: "write",
|
|
1499
1422
|
parameters: schema2,
|
|
1500
1423
|
async execute(args, ctx) {
|
|
1501
|
-
const filePath =
|
|
1424
|
+
const filePath = path5.resolve(ctx.cwd, args.path);
|
|
1502
1425
|
let content;
|
|
1426
|
+
let readMtimeMs;
|
|
1503
1427
|
try {
|
|
1504
|
-
content = await
|
|
1428
|
+
content = await readFile2(filePath, "utf8");
|
|
1429
|
+
readMtimeMs = (await stat2(filePath)).mtimeMs;
|
|
1505
1430
|
} catch (err) {
|
|
1506
1431
|
return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
|
|
1507
1432
|
}
|
|
@@ -1520,34 +1445,44 @@ var editFileTool = {
|
|
|
1520
1445
|
isError: true
|
|
1521
1446
|
};
|
|
1522
1447
|
}
|
|
1523
|
-
const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, args.new_string);
|
|
1448
|
+
const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, () => args.new_string);
|
|
1524
1449
|
try {
|
|
1525
|
-
await
|
|
1450
|
+
const now = await stat2(filePath).catch(() => null);
|
|
1451
|
+
if (!now || now.mtimeMs !== readMtimeMs) {
|
|
1452
|
+
return {
|
|
1453
|
+
content: `Failed to edit ${args.path}: file changed since it was read; re-read and retry`,
|
|
1454
|
+
isError: true
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
await writeFile2(filePath, updated, "utf8");
|
|
1526
1458
|
} catch (err) {
|
|
1527
1459
|
return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
|
|
1528
1460
|
}
|
|
1461
|
+
const contentTooLarge = Buffer.byteLength(content, "utf8") > MAX_SNAPSHOT_CONTENT_BYTES;
|
|
1529
1462
|
await pushSnapshot({
|
|
1530
1463
|
id: nextSnapshotId(),
|
|
1531
1464
|
path: filePath,
|
|
1532
1465
|
existed: true,
|
|
1533
|
-
content,
|
|
1466
|
+
content: contentTooLarge ? null : content,
|
|
1467
|
+
contentTooLarge: contentTooLarge || void 0,
|
|
1534
1468
|
toolName: "edit_file",
|
|
1535
1469
|
timestamp: Date.now(),
|
|
1536
|
-
turn: currentTurnSeq(),
|
|
1537
|
-
messageIndex: currentTurnMessageIndex()
|
|
1470
|
+
turn: ctx.snapshotContext?.turn ?? currentTurnSeq(),
|
|
1471
|
+
messageIndex: ctx.snapshotContext?.messageIndex ?? currentTurnMessageIndex(),
|
|
1472
|
+
owner: ctx.snapshotContext?.owner ?? "root"
|
|
1538
1473
|
});
|
|
1539
1474
|
return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
|
|
1540
1475
|
}
|
|
1541
1476
|
};
|
|
1542
1477
|
|
|
1543
1478
|
// src/tools/fs/glob.ts
|
|
1544
|
-
import
|
|
1545
|
-
import { z as
|
|
1479
|
+
import path7 from "path";
|
|
1480
|
+
import { z as z3 } from "zod";
|
|
1546
1481
|
|
|
1547
1482
|
// src/tools/fs/util.ts
|
|
1548
|
-
import { readFileSync
|
|
1549
|
-
import { readdir, stat } from "fs/promises";
|
|
1550
|
-
import
|
|
1483
|
+
import { readFileSync, statSync } from "fs";
|
|
1484
|
+
import { readdir, stat as stat3 } from "fs/promises";
|
|
1485
|
+
import path6 from "path";
|
|
1551
1486
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
|
|
1552
1487
|
async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
|
|
1553
1488
|
const out = [];
|
|
@@ -1557,7 +1492,7 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
|
|
|
1557
1492
|
return;
|
|
1558
1493
|
}
|
|
1559
1494
|
for (const entry of entries) {
|
|
1560
|
-
const abs =
|
|
1495
|
+
const abs = path6.join(dir, entry.name);
|
|
1561
1496
|
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1562
1497
|
if (entry.isDirectory()) {
|
|
1563
1498
|
if (skipDirs.has(entry.name) || options.ignore?.(abs, true)) {
|
|
@@ -1568,8 +1503,12 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
|
|
|
1568
1503
|
if (options.ignore?.(abs, false)) {
|
|
1569
1504
|
continue;
|
|
1570
1505
|
}
|
|
1506
|
+
if (options.withMtime === false) {
|
|
1507
|
+
out.push({ abs, rel, mtimeMs: 0 });
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1571
1510
|
try {
|
|
1572
|
-
const st = await
|
|
1511
|
+
const st = await stat3(abs);
|
|
1573
1512
|
out.push({ abs, rel, mtimeMs: st.mtimeMs });
|
|
1574
1513
|
} catch {
|
|
1575
1514
|
}
|
|
@@ -1580,32 +1519,107 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
|
|
|
1580
1519
|
return out;
|
|
1581
1520
|
}
|
|
1582
1521
|
var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
|
|
1522
|
+
function escapeRe(c) {
|
|
1523
|
+
return c.replace(REGEX_SPECIALS, "\\$&");
|
|
1524
|
+
}
|
|
1583
1525
|
function globToRegExp(pattern) {
|
|
1584
|
-
let re = "";
|
|
1585
1526
|
let i = 0;
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1527
|
+
function parseClass() {
|
|
1528
|
+
let j = i + 1;
|
|
1529
|
+
let negate = false;
|
|
1530
|
+
if (pattern.charAt(j) === "!") {
|
|
1531
|
+
negate = true;
|
|
1532
|
+
j += 1;
|
|
1533
|
+
}
|
|
1534
|
+
let body = "";
|
|
1535
|
+
if (pattern.charAt(j) === "]") {
|
|
1536
|
+
body += "\\]";
|
|
1537
|
+
j += 1;
|
|
1538
|
+
}
|
|
1539
|
+
while (j < pattern.length && pattern.charAt(j) !== "]") {
|
|
1540
|
+
const ch = pattern.charAt(j);
|
|
1541
|
+
body += ch === "\\" ? "\\\\" : ch;
|
|
1542
|
+
j += 1;
|
|
1543
|
+
}
|
|
1544
|
+
if (j < pattern.length) {
|
|
1545
|
+
i = j + 1;
|
|
1546
|
+
if (body.startsWith("^")) body = `\\${body}`;
|
|
1547
|
+
return `[${negate ? "^" : ""}${body}]`;
|
|
1548
|
+
}
|
|
1549
|
+
i += 1;
|
|
1550
|
+
return "\\[";
|
|
1551
|
+
}
|
|
1552
|
+
function parseSegment(depth) {
|
|
1553
|
+
let re2 = "";
|
|
1554
|
+
while (i < pattern.length) {
|
|
1555
|
+
const c = pattern.charAt(i);
|
|
1556
|
+
if (depth > 0 && (c === "," || c === "}")) {
|
|
1557
|
+
return { re: re2, end: c };
|
|
1558
|
+
}
|
|
1559
|
+
if (c === "*") {
|
|
1560
|
+
if (pattern.charAt(i + 1) === "*") {
|
|
1561
|
+
i += 2;
|
|
1562
|
+
if (pattern.charAt(i) === "/") {
|
|
1563
|
+
i += 1;
|
|
1564
|
+
re2 += "(?:[^/]+/)*";
|
|
1565
|
+
while (pattern.startsWith("**/", i)) {
|
|
1566
|
+
i += 3;
|
|
1567
|
+
}
|
|
1568
|
+
} else {
|
|
1569
|
+
re2 += ".*";
|
|
1570
|
+
}
|
|
1594
1571
|
} else {
|
|
1595
|
-
|
|
1572
|
+
re2 += "[^/]*";
|
|
1573
|
+
i += 1;
|
|
1596
1574
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
if (c === "?") {
|
|
1578
|
+
re2 += "[^/]";
|
|
1599
1579
|
i += 1;
|
|
1580
|
+
continue;
|
|
1600
1581
|
}
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1582
|
+
if (c === "[") {
|
|
1583
|
+
re2 += parseClass();
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
if (c === "{" && depth < 2) {
|
|
1587
|
+
const close = braceEnd(i);
|
|
1588
|
+
if (close === -1) {
|
|
1589
|
+
re2 += "\\{";
|
|
1590
|
+
i += 1;
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
i += 1;
|
|
1594
|
+
const alts = [];
|
|
1595
|
+
for (; ; ) {
|
|
1596
|
+
const part = parseSegment(depth + 1);
|
|
1597
|
+
alts.push(part.re);
|
|
1598
|
+
if (part.end !== ",") break;
|
|
1599
|
+
i += 1;
|
|
1600
|
+
}
|
|
1601
|
+
i += 1;
|
|
1602
|
+
re2 += `(?:${alts.join("|")})`;
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
re2 += escapeRe(c);
|
|
1606
1606
|
i += 1;
|
|
1607
1607
|
}
|
|
1608
|
+
return { re: re2, end: "" };
|
|
1609
|
+
}
|
|
1610
|
+
function braceEnd(from) {
|
|
1611
|
+
let depth = 0;
|
|
1612
|
+
for (let j = from; j < pattern.length; j++) {
|
|
1613
|
+
const ch = pattern.charAt(j);
|
|
1614
|
+
if (ch === "{") depth += 1;
|
|
1615
|
+
else if (ch === "}") {
|
|
1616
|
+
depth -= 1;
|
|
1617
|
+
if (depth === 0) return j;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return -1;
|
|
1608
1621
|
}
|
|
1622
|
+
const { re } = parseSegment(0);
|
|
1609
1623
|
return new RegExp(`^${re}$`);
|
|
1610
1624
|
}
|
|
1611
1625
|
function matchesGlob(pattern, relPath) {
|
|
@@ -1637,10 +1651,10 @@ function parseIgnorePatterns(body) {
|
|
|
1637
1651
|
return out;
|
|
1638
1652
|
}
|
|
1639
1653
|
function loadIgnorePatterns(dir) {
|
|
1640
|
-
const file =
|
|
1654
|
+
const file = path6.join(dir, IGNORE_FILE);
|
|
1641
1655
|
let mtimeMs;
|
|
1642
1656
|
try {
|
|
1643
|
-
mtimeMs =
|
|
1657
|
+
mtimeMs = statSync(file).mtimeMs;
|
|
1644
1658
|
} catch {
|
|
1645
1659
|
return [];
|
|
1646
1660
|
}
|
|
@@ -1650,7 +1664,7 @@ function loadIgnorePatterns(dir) {
|
|
|
1650
1664
|
}
|
|
1651
1665
|
let patterns = [];
|
|
1652
1666
|
try {
|
|
1653
|
-
patterns = parseIgnorePatterns(
|
|
1667
|
+
patterns = parseIgnorePatterns(readFileSync(file, "utf8"));
|
|
1654
1668
|
} catch {
|
|
1655
1669
|
patterns = [];
|
|
1656
1670
|
}
|
|
@@ -1662,9 +1676,9 @@ function createIgnorePredicate(cwd) {
|
|
|
1662
1676
|
if (patterns.length === 0) {
|
|
1663
1677
|
return void 0;
|
|
1664
1678
|
}
|
|
1665
|
-
const root =
|
|
1679
|
+
const root = path6.resolve(cwd);
|
|
1666
1680
|
return (absPath, isDir) => {
|
|
1667
|
-
const rel =
|
|
1681
|
+
const rel = path6.relative(root, absPath).split(path6.sep).join("/");
|
|
1668
1682
|
if (!rel || rel.startsWith("..")) {
|
|
1669
1683
|
return false;
|
|
1670
1684
|
}
|
|
@@ -1679,27 +1693,26 @@ function createIgnorePredicate(cwd) {
|
|
|
1679
1693
|
return false;
|
|
1680
1694
|
};
|
|
1681
1695
|
}
|
|
1682
|
-
function isSensitivePath2(filePath) {
|
|
1683
|
-
const base = path9.basename(filePath);
|
|
1684
|
-
if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
|
|
1685
|
-
return false;
|
|
1686
|
-
}
|
|
1687
|
-
if (base === ".env" || base.startsWith(".env.")) {
|
|
1688
|
-
return true;
|
|
1689
|
-
}
|
|
1690
|
-
if (base === "id_rsa" || base.endsWith(".pem")) {
|
|
1691
|
-
return true;
|
|
1692
|
-
}
|
|
1693
|
-
return false;
|
|
1694
|
-
}
|
|
1695
1696
|
|
|
1696
1697
|
// src/tools/fs/glob.ts
|
|
1697
1698
|
var MAX_RESULTS = 100;
|
|
1698
|
-
var
|
|
1699
|
-
|
|
1700
|
-
|
|
1699
|
+
var GLOB_MAGIC = /[*?[{]/;
|
|
1700
|
+
function literalDirPrefix(pattern) {
|
|
1701
|
+
const segments = pattern.replace(/\\/g, "/").split("/").slice(0, -1);
|
|
1702
|
+
const out = [];
|
|
1703
|
+
for (const segment of segments) {
|
|
1704
|
+
if (segment === "" || GLOB_MAGIC.test(segment)) {
|
|
1705
|
+
break;
|
|
1706
|
+
}
|
|
1707
|
+
out.push(segment);
|
|
1708
|
+
}
|
|
1709
|
+
return out;
|
|
1710
|
+
}
|
|
1711
|
+
var schema3 = z3.object({
|
|
1712
|
+
pattern: z3.string().describe(
|
|
1713
|
+
"Glob pattern, e.g. 'src/**/*.ts'. Supports **, *, ?, {a,b} braces and [abc] character classes. A bare pattern like '*.ts' matches basenames recursively."
|
|
1701
1714
|
),
|
|
1702
|
-
path:
|
|
1715
|
+
path: z3.string().optional().describe("Directory to search, defaults to the working directory")
|
|
1703
1716
|
});
|
|
1704
1717
|
var globTool = {
|
|
1705
1718
|
name: "glob",
|
|
@@ -1707,47 +1720,72 @@ var globTool = {
|
|
|
1707
1720
|
permission: "read",
|
|
1708
1721
|
parameters: schema3,
|
|
1709
1722
|
async execute(args, ctx) {
|
|
1710
|
-
const root =
|
|
1711
|
-
const
|
|
1712
|
-
const
|
|
1723
|
+
const root = path7.resolve(ctx.cwd, args.path ?? ".");
|
|
1724
|
+
const prefixSegments = literalDirPrefix(args.pattern);
|
|
1725
|
+
const walkRoot = prefixSegments.length > 0 ? path7.join(root, ...prefixSegments) : root;
|
|
1726
|
+
const prefix = prefixSegments.length > 0 ? `${prefixSegments.join("/")}/` : "";
|
|
1727
|
+
const files = await walkFiles(walkRoot, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
|
|
1728
|
+
const matched = files.filter((f) => matchesGlob(args.pattern, `${prefix}${f.rel}`)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
|
|
1713
1729
|
if (matched.length === 0) {
|
|
1714
1730
|
return { content: `No files matched pattern: ${args.pattern}` };
|
|
1715
1731
|
}
|
|
1716
|
-
return { content: matched.map((f) => f.rel).join("\n") };
|
|
1732
|
+
return { content: matched.map((f) => `${prefix}${f.rel}`).join("\n") };
|
|
1717
1733
|
}
|
|
1718
1734
|
};
|
|
1719
1735
|
|
|
1720
1736
|
// src/tools/fs/grep.ts
|
|
1721
|
-
import { readFile as
|
|
1722
|
-
import
|
|
1723
|
-
import { z as
|
|
1737
|
+
import { readFile as readFile3, stat as stat4 } from "fs/promises";
|
|
1738
|
+
import path8 from "path";
|
|
1739
|
+
import { z as z4 } from "zod";
|
|
1724
1740
|
var MAX_MATCHES = 250;
|
|
1725
|
-
var
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1741
|
+
var MAX_LINE_CHARS = 500;
|
|
1742
|
+
var MAX_PATTERN_CHARS = 200;
|
|
1743
|
+
var MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
1744
|
+
function isTooComplexPattern(pattern) {
|
|
1745
|
+
return pattern.length > MAX_PATTERN_CHARS || /\)[+*]/.test(pattern) || /\)\{\d+(,\d*)?\}/.test(pattern);
|
|
1746
|
+
}
|
|
1747
|
+
var schema4 = z4.object({
|
|
1748
|
+
pattern: z4.string().describe("Regular expression to search for"),
|
|
1749
|
+
path: z4.string().optional().describe("File or directory to search, defaults to the working directory"),
|
|
1750
|
+
glob: z4.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
|
|
1751
|
+
ignoreCase: z4.boolean().optional().describe("Case-insensitive matching")
|
|
1730
1752
|
});
|
|
1731
1753
|
var grepTool = {
|
|
1732
1754
|
name: "grep",
|
|
1733
|
-
description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, node_modules and .git.",
|
|
1755
|
+
description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, files over 5MB, sensitive files, node_modules and .git.",
|
|
1734
1756
|
permission: "read",
|
|
1735
1757
|
parameters: schema4,
|
|
1736
1758
|
async execute(args, ctx) {
|
|
1759
|
+
if (isTooComplexPattern(args.pattern)) {
|
|
1760
|
+
return {
|
|
1761
|
+
content: `pattern too complex: keep it under ${MAX_PATTERN_CHARS} characters and avoid quantified groups like (a+)+`,
|
|
1762
|
+
isError: true
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1737
1765
|
let re;
|
|
1738
1766
|
try {
|
|
1739
1767
|
re = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
|
|
1740
1768
|
} catch (err) {
|
|
1741
1769
|
return { content: `Invalid regular expression: ${err.message}`, isError: true };
|
|
1742
1770
|
}
|
|
1743
|
-
const root =
|
|
1771
|
+
const root = path8.resolve(ctx.cwd, args.path ?? ".");
|
|
1744
1772
|
let files;
|
|
1745
1773
|
try {
|
|
1746
|
-
const st = await
|
|
1774
|
+
const st = await stat4(root);
|
|
1747
1775
|
if (st.isFile()) {
|
|
1748
|
-
|
|
1776
|
+
if (isSensitivePath(root)) {
|
|
1777
|
+
return {
|
|
1778
|
+
content: `Refused to grep sensitive file: ${args.path ?? "."}`,
|
|
1779
|
+
isError: true
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
files = [{ abs: root, rel: path8.basename(root), mtimeMs: st.mtimeMs }];
|
|
1749
1783
|
} else {
|
|
1750
|
-
|
|
1784
|
+
const walked = await walkFiles(root, void 0, {
|
|
1785
|
+
ignore: createIgnorePredicate(ctx.cwd),
|
|
1786
|
+
withMtime: false
|
|
1787
|
+
});
|
|
1788
|
+
files = walked.filter((f) => !isSensitivePath(f.abs));
|
|
1751
1789
|
}
|
|
1752
1790
|
} catch {
|
|
1753
1791
|
return { content: `Path not found: ${args.path ?? "."}`, isError: true };
|
|
@@ -1758,10 +1796,19 @@ var grepTool = {
|
|
|
1758
1796
|
}
|
|
1759
1797
|
const out = [];
|
|
1760
1798
|
let truncated = false;
|
|
1799
|
+
let skippedLarge = 0;
|
|
1761
1800
|
outer: for (const file of files) {
|
|
1801
|
+
const st = await stat4(file.abs).catch(() => null);
|
|
1802
|
+
if (!st) {
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
1806
|
+
skippedLarge += 1;
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1762
1809
|
let buf;
|
|
1763
1810
|
try {
|
|
1764
|
-
buf = await
|
|
1811
|
+
buf = await readFile3(file.abs);
|
|
1765
1812
|
} catch {
|
|
1766
1813
|
continue;
|
|
1767
1814
|
}
|
|
@@ -1772,7 +1819,8 @@ var grepTool = {
|
|
|
1772
1819
|
for (let i = 0; i < lines.length; i++) {
|
|
1773
1820
|
const line = lines[i] ?? "";
|
|
1774
1821
|
if (re.test(line)) {
|
|
1775
|
-
|
|
1822
|
+
const shown = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
|
|
1823
|
+
out.push(`${file.rel}:${i + 1}:${shown}`);
|
|
1776
1824
|
if (out.length >= MAX_MATCHES) {
|
|
1777
1825
|
truncated = true;
|
|
1778
1826
|
break outer;
|
|
@@ -1780,26 +1828,55 @@ var grepTool = {
|
|
|
1780
1828
|
}
|
|
1781
1829
|
}
|
|
1782
1830
|
}
|
|
1831
|
+
const skippedNote = skippedLarge > 0 ? `(skipped ${skippedLarge} file(s) larger than 5MB)` : null;
|
|
1783
1832
|
if (out.length === 0) {
|
|
1784
|
-
return {
|
|
1833
|
+
return {
|
|
1834
|
+
content: `No matches for pattern: ${args.pattern}${skippedNote ? ` ${skippedNote}` : ""}`
|
|
1835
|
+
};
|
|
1785
1836
|
}
|
|
1786
1837
|
if (truncated) {
|
|
1787
1838
|
out.push(`... (truncated: more than ${MAX_MATCHES} matches)`);
|
|
1788
1839
|
}
|
|
1840
|
+
if (skippedNote) {
|
|
1841
|
+
out.push(`... ${skippedNote}`);
|
|
1842
|
+
}
|
|
1789
1843
|
return { content: out.join("\n") };
|
|
1790
1844
|
}
|
|
1791
1845
|
};
|
|
1792
1846
|
|
|
1793
1847
|
// src/tools/fs/read.ts
|
|
1794
|
-
import {
|
|
1795
|
-
import
|
|
1796
|
-
import { z as
|
|
1848
|
+
import { open, stat as stat5 } from "fs/promises";
|
|
1849
|
+
import path9 from "path";
|
|
1850
|
+
import { z as z5 } from "zod";
|
|
1797
1851
|
var DEFAULT_LIMIT = 2e3;
|
|
1798
1852
|
var MAX_CHARS = 100 * 1024;
|
|
1799
|
-
var
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1853
|
+
var MAX_READ_BYTES = 1024 * 1024;
|
|
1854
|
+
var BINARY_PROBE_BYTES = 8192;
|
|
1855
|
+
function decodeUtf16be(buf) {
|
|
1856
|
+
const len = buf.length - buf.length % 2;
|
|
1857
|
+
const swapped = Buffer.allocUnsafe(len);
|
|
1858
|
+
for (let k = 0; k < len; k += 2) {
|
|
1859
|
+
swapped[k] = buf[k + 1] ?? 0;
|
|
1860
|
+
swapped[k + 1] = buf[k] ?? 0;
|
|
1861
|
+
}
|
|
1862
|
+
return swapped.toString("utf16le");
|
|
1863
|
+
}
|
|
1864
|
+
function decodeTextBuffer(buf) {
|
|
1865
|
+
if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
|
|
1866
|
+
return { text: buf.subarray(2).toString("utf16le") };
|
|
1867
|
+
}
|
|
1868
|
+
if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
|
|
1869
|
+
return { text: decodeUtf16be(buf.subarray(2)) };
|
|
1870
|
+
}
|
|
1871
|
+
if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
|
|
1872
|
+
return { binary: true };
|
|
1873
|
+
}
|
|
1874
|
+
return { text: buf.toString("utf8") };
|
|
1875
|
+
}
|
|
1876
|
+
var schema5 = z5.object({
|
|
1877
|
+
path: z5.string().describe("File path, absolute or relative to the working directory"),
|
|
1878
|
+
offset: z5.number().int().positive().optional().describe("1-based line number to start reading from"),
|
|
1879
|
+
limit: z5.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
|
|
1803
1880
|
});
|
|
1804
1881
|
var readFileTool = {
|
|
1805
1882
|
name: "read_file",
|
|
@@ -1807,30 +1884,48 @@ var readFileTool = {
|
|
|
1807
1884
|
permission: "read",
|
|
1808
1885
|
parameters: schema5,
|
|
1809
1886
|
async execute(args, ctx) {
|
|
1810
|
-
const filePath =
|
|
1811
|
-
if (
|
|
1887
|
+
const filePath = path9.resolve(ctx.cwd, args.path);
|
|
1888
|
+
if (isSensitivePath(filePath)) {
|
|
1812
1889
|
return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
|
|
1813
1890
|
}
|
|
1814
|
-
const st = await
|
|
1891
|
+
const st = await stat5(filePath).catch(() => null);
|
|
1815
1892
|
if (!st) {
|
|
1816
1893
|
return { content: `File not found: ${args.path}`, isError: true };
|
|
1817
1894
|
}
|
|
1818
1895
|
if (st.isDirectory()) {
|
|
1819
1896
|
return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
|
|
1820
1897
|
}
|
|
1821
|
-
const
|
|
1822
|
-
const
|
|
1898
|
+
const bytesToRead = Math.min(st.size, MAX_READ_BYTES);
|
|
1899
|
+
const fileCapped = st.size > MAX_READ_BYTES;
|
|
1900
|
+
const handle = await open(filePath, "r").catch(() => null);
|
|
1901
|
+
if (!handle) {
|
|
1902
|
+
return { content: `Failed to read ${args.path}`, isError: true };
|
|
1903
|
+
}
|
|
1904
|
+
let buf;
|
|
1905
|
+
try {
|
|
1906
|
+
const { bytesRead, buffer } = await handle.read(Buffer.alloc(bytesToRead), 0, bytesToRead, 0);
|
|
1907
|
+
buf = buffer.subarray(0, bytesRead);
|
|
1908
|
+
} finally {
|
|
1909
|
+
await handle.close().catch(() => {
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
const decoded = decodeTextBuffer(buf);
|
|
1913
|
+
if ("binary" in decoded) {
|
|
1914
|
+
return { content: `Cannot read binary file: ${args.path}`, isError: true };
|
|
1915
|
+
}
|
|
1916
|
+
const lines = decoded.text.split("\n");
|
|
1917
|
+
const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
|
|
1823
1918
|
const start = args.offset ?? 1;
|
|
1824
1919
|
const limit = args.limit ?? DEFAULT_LIMIT;
|
|
1920
|
+
if (start > lines.length) {
|
|
1921
|
+
return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
|
|
1922
|
+
}
|
|
1825
1923
|
const end = Math.min(lines.length, start - 1 + limit);
|
|
1826
1924
|
const out = [];
|
|
1827
1925
|
let size = 0;
|
|
1828
1926
|
let truncatedBySize = false;
|
|
1829
1927
|
for (let i = start - 1; i < end; i++) {
|
|
1830
|
-
const line = lines[i];
|
|
1831
|
-
if (line === void 0) {
|
|
1832
|
-
break;
|
|
1833
|
-
}
|
|
1928
|
+
const line = (lines[i] ?? "").replace(/\r$/, "");
|
|
1834
1929
|
const numbered = `${i + 1} ${line}`;
|
|
1835
1930
|
if (size + numbered.length > MAX_CHARS) {
|
|
1836
1931
|
truncatedBySize = true;
|
|
@@ -1842,22 +1937,22 @@ var readFileTool = {
|
|
|
1842
1937
|
const lastShown = start - 1 + out.length;
|
|
1843
1938
|
if (truncatedBySize) {
|
|
1844
1939
|
out.push(
|
|
1845
|
-
`... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${
|
|
1940
|
+
`... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
|
|
1846
1941
|
);
|
|
1847
|
-
} else if (end < lines.length) {
|
|
1848
|
-
out.push(`... (truncated: showing lines ${start}-${end} of ${
|
|
1942
|
+
} else if (end < lines.length || fileCapped) {
|
|
1943
|
+
out.push(`... (truncated: showing lines ${start}-${end} of ${totalDesc})`);
|
|
1849
1944
|
}
|
|
1850
1945
|
return { content: out.join("\n") };
|
|
1851
1946
|
}
|
|
1852
1947
|
};
|
|
1853
1948
|
|
|
1854
1949
|
// src/tools/fs/write.ts
|
|
1855
|
-
import { mkdir as mkdir3, writeFile as
|
|
1856
|
-
import
|
|
1857
|
-
import { z as
|
|
1858
|
-
var schema6 =
|
|
1859
|
-
path:
|
|
1860
|
-
content:
|
|
1950
|
+
import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
|
|
1951
|
+
import path10 from "path";
|
|
1952
|
+
import { z as z6 } from "zod";
|
|
1953
|
+
var schema6 = z6.object({
|
|
1954
|
+
path: z6.string().describe("File path, absolute or relative to the working directory"),
|
|
1955
|
+
content: z6.string().describe("Full content to write to the file")
|
|
1861
1956
|
});
|
|
1862
1957
|
var writeFileTool = {
|
|
1863
1958
|
name: "write_file",
|
|
@@ -1865,11 +1960,11 @@ var writeFileTool = {
|
|
|
1865
1960
|
permission: "write",
|
|
1866
1961
|
parameters: schema6,
|
|
1867
1962
|
async execute(args, ctx) {
|
|
1868
|
-
const filePath =
|
|
1869
|
-
const snapshot = await captureSnapshot(filePath, "write_file");
|
|
1963
|
+
const filePath = path10.resolve(ctx.cwd, args.path);
|
|
1964
|
+
const snapshot = await captureSnapshot(filePath, "write_file", ctx.snapshotContext);
|
|
1870
1965
|
try {
|
|
1871
|
-
await mkdir3(
|
|
1872
|
-
await
|
|
1966
|
+
await mkdir3(path10.dirname(filePath), { recursive: true });
|
|
1967
|
+
await writeFile3(filePath, args.content, "utf8");
|
|
1873
1968
|
} catch (err) {
|
|
1874
1969
|
return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
|
|
1875
1970
|
}
|
|
@@ -1879,7 +1974,118 @@ var writeFileTool = {
|
|
|
1879
1974
|
};
|
|
1880
1975
|
|
|
1881
1976
|
// src/tools/tasks.ts
|
|
1882
|
-
import { z as
|
|
1977
|
+
import { z as z7 } from "zod";
|
|
1978
|
+
|
|
1979
|
+
// src/agent/agent-tasks.ts
|
|
1980
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
1981
|
+
var MAX_FINISHED_RECORDS2 = 50;
|
|
1982
|
+
var AgentTaskManager = class extends EventEmitter2 {
|
|
1983
|
+
records = /* @__PURE__ */ new Map();
|
|
1984
|
+
counter = 0;
|
|
1985
|
+
start(run, meta) {
|
|
1986
|
+
const id = `agent-${++this.counter}`;
|
|
1987
|
+
const rec = {
|
|
1988
|
+
id,
|
|
1989
|
+
prompt: meta.prompt,
|
|
1990
|
+
description: meta.description,
|
|
1991
|
+
status: "running",
|
|
1992
|
+
startedAt: Date.now(),
|
|
1993
|
+
result: "",
|
|
1994
|
+
controller: new AbortController(),
|
|
1995
|
+
notified: false
|
|
1996
|
+
};
|
|
1997
|
+
this.records.set(id, rec);
|
|
1998
|
+
void run(rec.controller.signal).then((result) => this.finish(rec, "completed", result)).catch(
|
|
1999
|
+
(error) => this.finish(rec, "failed", error instanceof Error ? error.message : String(error))
|
|
2000
|
+
);
|
|
2001
|
+
this.emitUpdate(rec);
|
|
2002
|
+
return this.snapshot(rec);
|
|
2003
|
+
}
|
|
2004
|
+
get(id) {
|
|
2005
|
+
const rec = this.records.get(id);
|
|
2006
|
+
return rec ? this.snapshot(rec) : void 0;
|
|
2007
|
+
}
|
|
2008
|
+
list() {
|
|
2009
|
+
return [...this.records.values()].map((rec) => this.snapshot(rec));
|
|
2010
|
+
}
|
|
2011
|
+
runningCount() {
|
|
2012
|
+
let count = 0;
|
|
2013
|
+
for (const rec of this.records.values()) {
|
|
2014
|
+
if (rec.status === "running") count += 1;
|
|
2015
|
+
}
|
|
2016
|
+
return count;
|
|
2017
|
+
}
|
|
2018
|
+
kill(id) {
|
|
2019
|
+
const rec = this.records.get(id);
|
|
2020
|
+
if (!rec || rec.status !== "running") return void 0;
|
|
2021
|
+
rec.controller.abort();
|
|
2022
|
+
this.finish(rec, "stopped", rec.result || "Stopped by user.");
|
|
2023
|
+
return this.snapshot(rec);
|
|
2024
|
+
}
|
|
2025
|
+
cleanup() {
|
|
2026
|
+
const killed = [];
|
|
2027
|
+
for (const rec of this.records.values()) {
|
|
2028
|
+
if (rec.status === "running") {
|
|
2029
|
+
rec.controller.abort();
|
|
2030
|
+
this.finish(rec, "stopped", rec.result || "Stopped on shutdown.");
|
|
2031
|
+
killed.push(this.snapshot(rec));
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
return killed;
|
|
2035
|
+
}
|
|
2036
|
+
// Finished tasks whose result has not yet been handed to the parent loop.
|
|
2037
|
+
drainNotifications() {
|
|
2038
|
+
const drained = [];
|
|
2039
|
+
for (const rec of this.records.values()) {
|
|
2040
|
+
if (rec.status !== "running" && !rec.notified) {
|
|
2041
|
+
rec.notified = true;
|
|
2042
|
+
drained.push(this.snapshot(rec));
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
this.pruneFinished();
|
|
2046
|
+
return drained;
|
|
2047
|
+
}
|
|
2048
|
+
// Map iteration is insertion-ordered, so the first finished records met are
|
|
2049
|
+
// the oldest. Never drop an un-notified record: the parent has not seen its
|
|
2050
|
+
// result yet. Running records are untouched.
|
|
2051
|
+
pruneFinished() {
|
|
2052
|
+
let finished = 0;
|
|
2053
|
+
for (const rec of this.records.values()) {
|
|
2054
|
+
if (rec.status !== "running") finished += 1;
|
|
2055
|
+
}
|
|
2056
|
+
let excess = finished - MAX_FINISHED_RECORDS2;
|
|
2057
|
+
if (excess <= 0) return;
|
|
2058
|
+
for (const rec of this.records.values()) {
|
|
2059
|
+
if (excess <= 0) return;
|
|
2060
|
+
if (rec.status !== "running" && rec.notified) {
|
|
2061
|
+
this.records.delete(rec.id);
|
|
2062
|
+
excess -= 1;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
finish(rec, status, result) {
|
|
2067
|
+
if (rec.status !== "running") return;
|
|
2068
|
+
rec.status = status;
|
|
2069
|
+
rec.result = result;
|
|
2070
|
+
rec.endedAt = Date.now();
|
|
2071
|
+
this.emitUpdate(rec);
|
|
2072
|
+
}
|
|
2073
|
+
emitUpdate(rec) {
|
|
2074
|
+
this.emit("update", this.snapshot(rec));
|
|
2075
|
+
}
|
|
2076
|
+
snapshot(rec) {
|
|
2077
|
+
return {
|
|
2078
|
+
id: rec.id,
|
|
2079
|
+
prompt: rec.prompt,
|
|
2080
|
+
description: rec.description,
|
|
2081
|
+
status: rec.status,
|
|
2082
|
+
startedAt: rec.startedAt,
|
|
2083
|
+
endedAt: rec.endedAt,
|
|
2084
|
+
result: rec.result
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
var defaultAgentTasks = new AgentTaskManager();
|
|
1883
2089
|
|
|
1884
2090
|
// src/tasks/format.ts
|
|
1885
2091
|
function formatDuration(startedAt, endedAt) {
|
|
@@ -1925,9 +2131,14 @@ function formatTaskFinished(task) {
|
|
|
1925
2131
|
}
|
|
1926
2132
|
|
|
1927
2133
|
// src/tools/tasks.ts
|
|
1928
|
-
var idSchema =
|
|
1929
|
-
id:
|
|
2134
|
+
var idSchema = z7.object({
|
|
2135
|
+
id: z7.string().describe("Task id, e.g. task-1 or agent-1")
|
|
1930
2136
|
});
|
|
2137
|
+
function truncateOutput(s) {
|
|
2138
|
+
if (s.length <= MAX_OUTPUT) return s;
|
|
2139
|
+
return `[... earlier output truncated; showing the last ${MAX_OUTPUT} characters ...]
|
|
2140
|
+
${s.slice(-MAX_OUTPUT)}`;
|
|
2141
|
+
}
|
|
1931
2142
|
function formatAgentTaskLine(task) {
|
|
1932
2143
|
const duration = formatDuration(task.startedAt, task.endedAt);
|
|
1933
2144
|
const label = task.description ?? task.prompt.slice(0, 60);
|
|
@@ -1938,7 +2149,7 @@ var taskListTool = {
|
|
|
1938
2149
|
name: "task_list",
|
|
1939
2150
|
description: "List background tasks (shell commands and subagents) with status and runtime.",
|
|
1940
2151
|
permission: "read",
|
|
1941
|
-
parameters:
|
|
2152
|
+
parameters: z7.object({}),
|
|
1942
2153
|
execute() {
|
|
1943
2154
|
const shell = formatTaskList(defaultTaskManager.list());
|
|
1944
2155
|
const agents = defaultAgentTasks.list();
|
|
@@ -1956,13 +2167,13 @@ var taskOutputTool = {
|
|
|
1956
2167
|
execute(args) {
|
|
1957
2168
|
const shell = defaultTaskManager.get(args.id);
|
|
1958
2169
|
if (shell) {
|
|
1959
|
-
const body = shell.output.replace(/\s+$/, "") || "(no output yet)";
|
|
2170
|
+
const body = truncateOutput(shell.output.replace(/\s+$/, "") || "(no output yet)");
|
|
1960
2171
|
return Promise.resolve({ content: `${formatTaskLine(shell)}
|
|
1961
2172
|
${body}` });
|
|
1962
2173
|
}
|
|
1963
2174
|
const agent = defaultAgentTasks.get(args.id);
|
|
1964
2175
|
if (agent) {
|
|
1965
|
-
const body = agent.status === "running" ? "(still running)" : agent.result.replace(/\s+$/, "") || "(no report)";
|
|
2176
|
+
const body = agent.status === "running" ? "(still running)" : truncateOutput(agent.result.replace(/\s+$/, "") || "(no report)");
|
|
1966
2177
|
return Promise.resolve({ content: `${formatAgentTaskLine(agent)}
|
|
1967
2178
|
${body}` });
|
|
1968
2179
|
}
|
|
@@ -1987,26 +2198,159 @@ var taskKillTool = {
|
|
|
1987
2198
|
isError: true
|
|
1988
2199
|
});
|
|
1989
2200
|
}
|
|
1990
|
-
if (shell) {
|
|
1991
|
-
defaultTaskManager.kill(args.id);
|
|
1992
|
-
} else {
|
|
1993
|
-
defaultAgentTasks.kill(args.id);
|
|
2201
|
+
if (shell) {
|
|
2202
|
+
defaultTaskManager.kill(args.id);
|
|
2203
|
+
} else {
|
|
2204
|
+
defaultAgentTasks.kill(args.id);
|
|
2205
|
+
}
|
|
2206
|
+
return Promise.resolve({ content: `Task ${args.id} stopped.` });
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
|
|
2210
|
+
// src/tools/todo.ts
|
|
2211
|
+
import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
|
|
2212
|
+
import path11 from "path";
|
|
2213
|
+
import { z as z8 } from "zod";
|
|
2214
|
+
var todoItemSchema = z8.object({
|
|
2215
|
+
id: z8.number().int(),
|
|
2216
|
+
title: z8.string(),
|
|
2217
|
+
status: z8.enum(["pending", "in_progress", "done"])
|
|
2218
|
+
});
|
|
2219
|
+
var writeSchema = z8.object({
|
|
2220
|
+
todos: z8.array(todoItemSchema).describe("The full todo list, replacing the current one")
|
|
2221
|
+
});
|
|
2222
|
+
var readSchema = z8.object({});
|
|
2223
|
+
function fileFor(cwd) {
|
|
2224
|
+
return path11.join(cwd, ".star", "todos.json");
|
|
2225
|
+
}
|
|
2226
|
+
var persistGuard = () => true;
|
|
2227
|
+
function setTodoPersistGuard(guard) {
|
|
2228
|
+
persistGuard = guard;
|
|
2229
|
+
}
|
|
2230
|
+
var TodoStore = class {
|
|
2231
|
+
items = /* @__PURE__ */ new Map();
|
|
2232
|
+
list() {
|
|
2233
|
+
return [...this.items.values()];
|
|
2234
|
+
}
|
|
2235
|
+
replace(items) {
|
|
2236
|
+
this.items.clear();
|
|
2237
|
+
for (const item of items) {
|
|
2238
|
+
this.items.set(item.id, item);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
async load(cwd) {
|
|
2242
|
+
let raw;
|
|
2243
|
+
try {
|
|
2244
|
+
raw = await readFile4(fileFor(cwd), "utf8");
|
|
2245
|
+
} catch {
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
try {
|
|
2249
|
+
const parsed = JSON.parse(raw);
|
|
2250
|
+
if (Array.isArray(parsed)) {
|
|
2251
|
+
this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
|
|
2252
|
+
}
|
|
2253
|
+
} catch {
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
// Atomic write (tmp + rename): a crash or failed save can never leave a
|
|
2257
|
+
// truncated todos.json behind.
|
|
2258
|
+
async save(cwd) {
|
|
2259
|
+
if (!persistGuard()) {
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
await mkdir4(path11.join(cwd, ".star"), { recursive: true });
|
|
2263
|
+
const file = fileFor(cwd);
|
|
2264
|
+
const tmp = `${file}.tmp`;
|
|
2265
|
+
await writeFile4(tmp, `${JSON.stringify(this.list(), null, 2)}
|
|
2266
|
+
`);
|
|
2267
|
+
await rename(tmp, file);
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
var SYMBOLS = {
|
|
2271
|
+
pending: "[ ]",
|
|
2272
|
+
in_progress: "[~]",
|
|
2273
|
+
done: "[x]"
|
|
2274
|
+
};
|
|
2275
|
+
function formatTodos(items) {
|
|
2276
|
+
if (items.length === 0) {
|
|
2277
|
+
return "No todos.";
|
|
2278
|
+
}
|
|
2279
|
+
const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
|
|
2280
|
+
const width = Math.max(...lines.map((l) => l.length));
|
|
2281
|
+
return lines.map((line, i) => {
|
|
2282
|
+
const status = items[i]?.status;
|
|
2283
|
+
return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
|
|
2284
|
+
}).join("\n");
|
|
2285
|
+
}
|
|
2286
|
+
var defaultStore = new TodoStore();
|
|
2287
|
+
function parseTodoArgs(args) {
|
|
2288
|
+
const parsed = writeSchema.safeParse(args);
|
|
2289
|
+
return parsed.success ? parsed.data.todos : null;
|
|
2290
|
+
}
|
|
2291
|
+
function pendingTodoTitles(args) {
|
|
2292
|
+
return (parseTodoArgs(args) ?? []).filter((t) => t.status !== "done").map((t) => t.title);
|
|
2293
|
+
}
|
|
2294
|
+
async function loadTodos(cwd) {
|
|
2295
|
+
await defaultStore.load(cwd);
|
|
2296
|
+
return defaultStore.list();
|
|
2297
|
+
}
|
|
2298
|
+
function resetTodos() {
|
|
2299
|
+
defaultStore.replace([]);
|
|
2300
|
+
}
|
|
2301
|
+
function createTodoTools(store = defaultStore) {
|
|
2302
|
+
const todoWrite = {
|
|
2303
|
+
name: "todo_write",
|
|
2304
|
+
description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
|
|
2305
|
+
permission: "read",
|
|
2306
|
+
parameters: writeSchema,
|
|
2307
|
+
async execute(args, ctx) {
|
|
2308
|
+
const parsed = writeSchema.safeParse(args);
|
|
2309
|
+
if (!parsed.success) {
|
|
2310
|
+
return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
|
|
2311
|
+
}
|
|
2312
|
+
const previous = store.list();
|
|
2313
|
+
store.replace(parsed.data.todos);
|
|
2314
|
+
try {
|
|
2315
|
+
await store.save(ctx.cwd);
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
store.replace(previous);
|
|
2318
|
+
return { content: `Failed to save todos: ${err.message}`, isError: true };
|
|
2319
|
+
}
|
|
2320
|
+
const items = store.list();
|
|
2321
|
+
const done = items.filter((it) => it.status === "done").length;
|
|
2322
|
+
return { content: `Todo list updated (${done}/${items.length} done).` };
|
|
2323
|
+
}
|
|
2324
|
+
};
|
|
2325
|
+
const todoRead = {
|
|
2326
|
+
name: "todo_read",
|
|
2327
|
+
description: "Read the current todo list.",
|
|
2328
|
+
permission: "read",
|
|
2329
|
+
parameters: readSchema,
|
|
2330
|
+
async execute() {
|
|
2331
|
+
return { content: formatTodos(store.list()) };
|
|
1994
2332
|
}
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
}
|
|
2333
|
+
};
|
|
2334
|
+
return [todoWrite, todoRead];
|
|
2335
|
+
}
|
|
1998
2336
|
|
|
1999
2337
|
// src/tools/web/fetch.ts
|
|
2000
|
-
import { z as
|
|
2338
|
+
import { z as z9 } from "zod";
|
|
2001
2339
|
var DEFAULT_MAX_CHARS = 2e4;
|
|
2002
2340
|
var MAX_BYTES = 2 * 1024 * 1024;
|
|
2003
2341
|
var TIMEOUT_MS = 3e4;
|
|
2004
|
-
var schema7 =
|
|
2005
|
-
url:
|
|
2006
|
-
maxChars:
|
|
2342
|
+
var schema7 = z9.object({
|
|
2343
|
+
url: z9.string().describe("The http(s) URL to fetch"),
|
|
2344
|
+
maxChars: z9.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
|
|
2007
2345
|
});
|
|
2346
|
+
function safeCodePoint(n, raw) {
|
|
2347
|
+
if (!Number.isInteger(n) || n < 0 || n > 1114111) {
|
|
2348
|
+
return raw;
|
|
2349
|
+
}
|
|
2350
|
+
return String.fromCodePoint(n);
|
|
2351
|
+
}
|
|
2008
2352
|
function decodeEntities(s) {
|
|
2009
|
-
return s.replace(/&#(\d+);/g, (
|
|
2353
|
+
return s.replace(/&#(\d+);/g, (m, n) => safeCodePoint(Number(n), m)).replace(/&#x([0-9a-fA-F]+);/gi, (m, n) => safeCodePoint(Number.parseInt(n, 16), m)).replace(/&(quot|lt|gt|nbsp);/g, (m) => {
|
|
2010
2354
|
switch (m) {
|
|
2011
2355
|
case """:
|
|
2012
2356
|
return '"';
|
|
@@ -2066,7 +2410,7 @@ function truncate2(s, maxChars) {
|
|
|
2066
2410
|
}
|
|
2067
2411
|
var webFetchTool = {
|
|
2068
2412
|
name: "web_fetch",
|
|
2069
|
-
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).",
|
|
2413
|
+
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.",
|
|
2070
2414
|
permission: "read",
|
|
2071
2415
|
parameters: schema7,
|
|
2072
2416
|
async execute(args, ctx) {
|
|
@@ -2131,15 +2475,15 @@ var webFetchTool = {
|
|
|
2131
2475
|
};
|
|
2132
2476
|
|
|
2133
2477
|
// src/tools/web/search.ts
|
|
2134
|
-
import { z as
|
|
2478
|
+
import { z as z10 } from "zod";
|
|
2135
2479
|
var TIMEOUT_MS2 = 15e3;
|
|
2136
2480
|
var DEFAULT_MAX_RESULTS = 5;
|
|
2137
2481
|
var MAX_RESULTS_LIMIT = 10;
|
|
2138
2482
|
var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
|
|
2139
2483
|
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";
|
|
2140
|
-
var schema8 =
|
|
2141
|
-
query:
|
|
2142
|
-
maxResults:
|
|
2484
|
+
var schema8 = z10.object({
|
|
2485
|
+
query: z10.string().min(1).describe("The search query"),
|
|
2486
|
+
maxResults: z10.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
|
|
2143
2487
|
`Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
|
|
2144
2488
|
)
|
|
2145
2489
|
});
|
|
@@ -2248,7 +2592,9 @@ var webSearchTool = {
|
|
|
2248
2592
|
// src/tools/registry.ts
|
|
2249
2593
|
var ToolRegistry = class {
|
|
2250
2594
|
tools = /* @__PURE__ */ new Map();
|
|
2251
|
-
|
|
2595
|
+
// todoStore defaults to the module-level shared store; subagent loops pass
|
|
2596
|
+
// their own instance so a child's todo_write cannot clobber the parent list.
|
|
2597
|
+
constructor(todoStore) {
|
|
2252
2598
|
for (const tool of [
|
|
2253
2599
|
readFileTool,
|
|
2254
2600
|
writeFileTool,
|
|
@@ -2261,7 +2607,7 @@ var ToolRegistry = class {
|
|
|
2261
2607
|
taskListTool,
|
|
2262
2608
|
taskOutputTool,
|
|
2263
2609
|
taskKillTool,
|
|
2264
|
-
...createTodoTools()
|
|
2610
|
+
...createTodoTools(todoStore)
|
|
2265
2611
|
]) {
|
|
2266
2612
|
this.register(tool);
|
|
2267
2613
|
}
|
|
@@ -2280,13 +2626,165 @@ var ToolRegistry = class {
|
|
|
2280
2626
|
}
|
|
2281
2627
|
};
|
|
2282
2628
|
|
|
2283
|
-
// src/
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2629
|
+
// src/agent/project-memory.ts
|
|
2630
|
+
import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
2631
|
+
import path12 from "path";
|
|
2632
|
+
var PROJECT_MEMORY_FILE = "AGENTS.md";
|
|
2633
|
+
var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
|
|
2634
|
+
var cache = /* @__PURE__ */ new Map();
|
|
2635
|
+
function readProjectMemory(cwd) {
|
|
2636
|
+
const file = path12.join(cwd, PROJECT_MEMORY_FILE);
|
|
2637
|
+
let mtimeMs;
|
|
2638
|
+
try {
|
|
2639
|
+
mtimeMs = statSync2(file).mtimeMs;
|
|
2640
|
+
} catch {
|
|
2641
|
+
return null;
|
|
2642
|
+
}
|
|
2643
|
+
const hit = cache.get(file);
|
|
2644
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.block;
|
|
2645
|
+
let block = null;
|
|
2646
|
+
try {
|
|
2647
|
+
let body = readFileSync2(file, "utf8").trim();
|
|
2648
|
+
if (body) {
|
|
2649
|
+
if (body.length > PROJECT_MEMORY_MAX_CHARS) {
|
|
2650
|
+
body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
|
|
2651
|
+
|
|
2652
|
+
[AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
|
|
2653
|
+
}
|
|
2654
|
+
block = `# Project instructions (AGENTS.md)
|
|
2655
|
+
${body}`;
|
|
2656
|
+
}
|
|
2657
|
+
} catch {
|
|
2658
|
+
block = null;
|
|
2659
|
+
}
|
|
2660
|
+
cache.set(file, { mtimeMs, block });
|
|
2661
|
+
return block;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
// src/agent/skills.ts
|
|
2665
|
+
import { readFileSync as readFileSync3, readdirSync, statSync as statSync3 } from "fs";
|
|
2666
|
+
import path13 from "path";
|
|
2667
|
+
import { z as z11 } from "zod";
|
|
2668
|
+
var SKILL_FILE = "SKILL.md";
|
|
2669
|
+
var SKILL_MAX_CHARS = 32 * 1024;
|
|
2670
|
+
var NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
2671
|
+
function parseFrontmatter(raw) {
|
|
2672
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
2673
|
+
if (!match) return null;
|
|
2674
|
+
const meta = {};
|
|
2675
|
+
for (const line of (match[1] ?? "").split(/\r?\n/)) {
|
|
2676
|
+
const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
2677
|
+
if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
|
|
2678
|
+
}
|
|
2679
|
+
return { meta, body: raw.slice(match[0].length).trim() };
|
|
2680
|
+
}
|
|
2681
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
2682
|
+
function parseSkillFile(filePath, dirName, scope) {
|
|
2683
|
+
let mtimeMs;
|
|
2684
|
+
try {
|
|
2685
|
+
mtimeMs = statSync3(filePath).mtimeMs;
|
|
2686
|
+
} catch {
|
|
2687
|
+
return null;
|
|
2688
|
+
}
|
|
2689
|
+
const hit = cache2.get(filePath);
|
|
2690
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
|
|
2691
|
+
let skill = null;
|
|
2692
|
+
try {
|
|
2693
|
+
const parsed = parseFrontmatter(readFileSync3(filePath, "utf8"));
|
|
2694
|
+
const name = parsed?.meta.name || dirName;
|
|
2695
|
+
const description = parsed?.meta.description;
|
|
2696
|
+
if (parsed && NAME_PATTERN.test(name) && description) {
|
|
2697
|
+
skill = { name, description, filePath, dir: path13.dirname(filePath), scope };
|
|
2698
|
+
}
|
|
2699
|
+
} catch {
|
|
2700
|
+
skill = null;
|
|
2701
|
+
}
|
|
2702
|
+
cache2.set(filePath, { mtimeMs, skill });
|
|
2703
|
+
return skill;
|
|
2704
|
+
}
|
|
2705
|
+
function scanDir(base, scope, into) {
|
|
2706
|
+
let entries;
|
|
2707
|
+
try {
|
|
2708
|
+
entries = readdirSync(base);
|
|
2709
|
+
} catch {
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
for (const entry of entries.sort()) {
|
|
2713
|
+
if (!NAME_PATTERN.test(entry)) continue;
|
|
2714
|
+
const parsed = parseSkillFile(path13.join(base, entry, SKILL_FILE), entry, scope);
|
|
2715
|
+
if (parsed) into.set(parsed.name, parsed);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
function discoverSkills(cwd, home = starHome()) {
|
|
2719
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2720
|
+
scanDir(path13.join(home, "skills"), "user", byName);
|
|
2721
|
+
scanDir(path13.join(cwd, ".star", "skills"), "project", byName);
|
|
2722
|
+
return [...byName.values()];
|
|
2723
|
+
}
|
|
2724
|
+
function formatSkillsBlock(skills) {
|
|
2725
|
+
const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
|
|
2726
|
+
return [
|
|
2727
|
+
"# Available skills",
|
|
2728
|
+
"When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
|
|
2729
|
+
...lines
|
|
2730
|
+
].join("\n");
|
|
2731
|
+
}
|
|
2732
|
+
function loadSkillBody(skill) {
|
|
2733
|
+
let parsed = null;
|
|
2734
|
+
try {
|
|
2735
|
+
parsed = parseFrontmatter(readFileSync3(skill.filePath, "utf8"));
|
|
2736
|
+
} catch {
|
|
2737
|
+
return null;
|
|
2738
|
+
}
|
|
2739
|
+
if (!parsed?.body) return null;
|
|
2740
|
+
if (parsed.body.length > SKILL_MAX_CHARS) {
|
|
2741
|
+
return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
|
|
2742
|
+
|
|
2743
|
+
[skill truncated to ${SKILL_MAX_CHARS} characters]`;
|
|
2288
2744
|
}
|
|
2289
|
-
return
|
|
2745
|
+
return parsed.body;
|
|
2746
|
+
}
|
|
2747
|
+
function createSkillTool(deps) {
|
|
2748
|
+
return {
|
|
2749
|
+
name: "skill",
|
|
2750
|
+
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.",
|
|
2751
|
+
permission: "read",
|
|
2752
|
+
parameters: z11.object({
|
|
2753
|
+
name: z11.string().describe("Name of the skill to load, as listed in the system prompt.")
|
|
2754
|
+
}),
|
|
2755
|
+
async execute(args) {
|
|
2756
|
+
const skills = discoverSkills(deps.cwd, deps.home);
|
|
2757
|
+
const skill = skills.find((s) => s.name === args.name);
|
|
2758
|
+
if (!skill) {
|
|
2759
|
+
const available = skills.map((s) => s.name).join(", ") || "none";
|
|
2760
|
+
return {
|
|
2761
|
+
content: `Unknown skill: "${args.name}". Available skills: ${available}`,
|
|
2762
|
+
isError: true
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
const body = loadSkillBody(skill);
|
|
2766
|
+
if (!body) {
|
|
2767
|
+
return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
|
|
2768
|
+
}
|
|
2769
|
+
return {
|
|
2770
|
+
content: [
|
|
2771
|
+
`# Skill: ${skill.name}`,
|
|
2772
|
+
"",
|
|
2773
|
+
body,
|
|
2774
|
+
"",
|
|
2775
|
+
`Base directory for relative file references in this skill: ${skill.dir}`
|
|
2776
|
+
].join("\n")
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// src/agent/subagent.ts
|
|
2783
|
+
import { z as z12 } from "zod";
|
|
2784
|
+
|
|
2785
|
+
// src/tools/index.ts
|
|
2786
|
+
function createDefaultRegistry(todoStore) {
|
|
2787
|
+
return new ToolRegistry(todoStore);
|
|
2290
2788
|
}
|
|
2291
2789
|
|
|
2292
2790
|
// src/agent/subagent.ts
|
|
@@ -2294,7 +2792,7 @@ var MAX_SUBAGENT_DEPTH = 1;
|
|
|
2294
2792
|
var MAX_REPORT_CHARS = 2e4;
|
|
2295
2793
|
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.";
|
|
2296
2794
|
async function runSubagent(deps, args, cwd, signal) {
|
|
2297
|
-
const { AgentLoop: AgentLoop2 } = await import("./loop-
|
|
2795
|
+
const { AgentLoop: AgentLoop2 } = await import("./loop-TQ7GFTTE.js");
|
|
2298
2796
|
const child = new AgentLoop2({
|
|
2299
2797
|
model: deps.model,
|
|
2300
2798
|
registry: createDefaultRegistry(),
|
|
@@ -2321,6 +2819,9 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
|
|
|
2321
2819
|
} catch (e) {
|
|
2322
2820
|
error = e instanceof Error ? e.message : String(e);
|
|
2323
2821
|
}
|
|
2822
|
+
if (signal.aborted) {
|
|
2823
|
+
throw new Error("Tool execution aborted.");
|
|
2824
|
+
}
|
|
2324
2825
|
let report = "";
|
|
2325
2826
|
const messages = child.getMessages();
|
|
2326
2827
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -2440,6 +2941,9 @@ var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task us
|
|
|
2440
2941
|
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;
|
|
2441
2942
|
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
2943
|
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.";
|
|
2944
|
+
function isFilteredEmptyFinish(finishReason) {
|
|
2945
|
+
return finishReason === "content-filter";
|
|
2946
|
+
}
|
|
2443
2947
|
function isSteerableEmptyFinish(finishReason) {
|
|
2444
2948
|
return finishReason === "stop" || finishReason === "content-filter";
|
|
2445
2949
|
}
|
|
@@ -2481,20 +2985,23 @@ ${finalReply.slice(0, 2e3)}
|
|
|
2481
2985
|
}
|
|
2482
2986
|
}
|
|
2483
2987
|
var STREAM_RETRY_BASE_DELAY_MS = 1e3;
|
|
2484
|
-
var
|
|
2485
|
-
|
|
2486
|
-
"
|
|
2487
|
-
|
|
2488
|
-
"
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
return
|
|
2988
|
+
var REDO_LABEL_MAX = 40;
|
|
2989
|
+
function messageText(message) {
|
|
2990
|
+
if (!message) return "";
|
|
2991
|
+
const content = message.content;
|
|
2992
|
+
if (typeof content === "string") return content;
|
|
2993
|
+
if (Array.isArray(content)) {
|
|
2994
|
+
return content.filter((part) => part.type === "text").map((part) => "text" in part ? part.text : "").join(" ");
|
|
2995
|
+
}
|
|
2996
|
+
return "";
|
|
2997
|
+
}
|
|
2998
|
+
function stableStringify(value) {
|
|
2999
|
+
if (value === void 0) return "undefined";
|
|
3000
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
3001
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
3002
|
+
const record = value;
|
|
3003
|
+
const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
3004
|
+
return `{${entries.join(",")}}`;
|
|
2498
3005
|
}
|
|
2499
3006
|
function sleep(ms, signal) {
|
|
2500
3007
|
if (signal.aborted) return Promise.resolve(false);
|
|
@@ -2514,11 +3021,32 @@ var AgentLoop = class {
|
|
|
2514
3021
|
messages = [];
|
|
2515
3022
|
opts;
|
|
2516
3023
|
// Seq + user-message index of each turn started via stream(); lets /undo
|
|
2517
|
-
// match a retracted turn to the file snapshots it produced.
|
|
2518
|
-
//
|
|
2519
|
-
//
|
|
3024
|
+
// match a retracted turn to the file snapshots it produced. `tree` is the
|
|
3025
|
+
// whole-tree git snapshot captured at the turn's start (root loop only,
|
|
3026
|
+
// config.gitSnapshots) — when present, /undo restores it instead of
|
|
3027
|
+
// replaying per-file snapshots, which also covers bash-made changes.
|
|
3028
|
+
// Cleared when history is replaced wholesale (resume/compact), because
|
|
3029
|
+
// indices no longer line up — in that case /undo only retracts messages,
|
|
3030
|
+
// never wrong files.
|
|
2520
3031
|
turnMarkers = [];
|
|
3032
|
+
// Pre-undo tree snapshots, latest last: a git-path /undo pushes the
|
|
3033
|
+
// pre-undo working tree here so /redo can restore it exactly. Only the git
|
|
3034
|
+
// path feeds this — per-file snapshot undos stay non-redoable.
|
|
3035
|
+
redoStack = [];
|
|
3036
|
+
// Start of the most recently retracted turn's user message; undoLastTurn
|
|
3037
|
+
// consumes it as the redo label right after retractLastTurn sets it.
|
|
3038
|
+
lastUndoneLabel = "";
|
|
3039
|
+
// Doom-loop guard: signature of the most recent tool call and how many
|
|
3040
|
+
// times in a row it has repeated. Any different call resets the count.
|
|
3041
|
+
lastToolSignature = null;
|
|
3042
|
+
repeatedToolCalls = 0;
|
|
3043
|
+
// Turn context of the in-flight stream() turn, stamped onto every tool
|
|
3044
|
+
// execution so file snapshots attribute root changes to a turn and mark
|
|
3045
|
+
// subagent changes as excluded from turn-level retraction.
|
|
3046
|
+
activeTurnSeq = 0;
|
|
3047
|
+
activeTurnUserIndex = -1;
|
|
2521
3048
|
titleScheduled = false;
|
|
3049
|
+
titleInput;
|
|
2522
3050
|
confirmHandler;
|
|
2523
3051
|
// Hook failures never block the turn (except an explicit PreToolUse block);
|
|
2524
3052
|
// their stderr surfaces through this callback (REPL system message / stderr
|
|
@@ -2526,6 +3054,15 @@ var AgentLoop = class {
|
|
|
2526
3054
|
onHookWarning;
|
|
2527
3055
|
constructor(opts) {
|
|
2528
3056
|
this.opts = opts;
|
|
3057
|
+
const depth = opts.subagentDepth ?? 0;
|
|
3058
|
+
if (depth > 0) {
|
|
3059
|
+
opts.registry = new ToolRegistry(new TodoStore());
|
|
3060
|
+
} else {
|
|
3061
|
+
setTodoPersistGuard(() => {
|
|
3062
|
+
const mode = this.opts.config.permissionMode;
|
|
3063
|
+
return mode !== "readonly" && mode !== "plan";
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
2529
3066
|
if (opts.system) {
|
|
2530
3067
|
this.messages.push({ role: "system", content: opts.system });
|
|
2531
3068
|
}
|
|
@@ -2534,7 +3071,6 @@ var AgentLoop = class {
|
|
|
2534
3071
|
}
|
|
2535
3072
|
opts.registry?.register(createSkillTool({ cwd: opts.cwd }));
|
|
2536
3073
|
opts.registry?.register(createRememberTool());
|
|
2537
|
-
const depth = opts.subagentDepth ?? 0;
|
|
2538
3074
|
if (opts.registry && depth < MAX_SUBAGENT_DEPTH) {
|
|
2539
3075
|
opts.registry.register(
|
|
2540
3076
|
createSubagentTool({
|
|
@@ -2553,10 +3089,14 @@ var AgentLoop = class {
|
|
|
2553
3089
|
}
|
|
2554
3090
|
// Swaps the persistence target (/new starts a fresh session mid-REPL).
|
|
2555
3091
|
// Title generation is re-armed so the new session gets one after its first
|
|
2556
|
-
// turn, and snapshot checkpoints now flow to the new store.
|
|
3092
|
+
// turn, and snapshot checkpoints now flow to the new store. The redo stack
|
|
3093
|
+
// dies with the old session context: its trees describe working-tree states
|
|
3094
|
+
// that no longer line up with the new session's conversation.
|
|
2557
3095
|
setSessionStore(store) {
|
|
2558
3096
|
this.opts.sessionStore = store;
|
|
2559
3097
|
this.titleScheduled = false;
|
|
3098
|
+
this.titleInput = void 0;
|
|
3099
|
+
this.redoStack = [];
|
|
2560
3100
|
if (store) this.bindSnapshotHooks(store);
|
|
2561
3101
|
}
|
|
2562
3102
|
bindSnapshotHooks(store) {
|
|
@@ -2569,7 +3109,9 @@ var AgentLoop = class {
|
|
|
2569
3109
|
existed: snapshot.existed,
|
|
2570
3110
|
toolName: snapshot.toolName,
|
|
2571
3111
|
turn: snapshot.turn,
|
|
2572
|
-
messageIndex: snapshot.messageIndex
|
|
3112
|
+
messageIndex: snapshot.messageIndex,
|
|
3113
|
+
owner: snapshot.owner,
|
|
3114
|
+
contentTooLarge: snapshot.contentTooLarge
|
|
2573
3115
|
},
|
|
2574
3116
|
snapshot.content
|
|
2575
3117
|
),
|
|
@@ -2579,37 +3121,92 @@ var AgentLoop = class {
|
|
|
2579
3121
|
async loadMessages(messages) {
|
|
2580
3122
|
this.messages = reconcileToolCalls(messages);
|
|
2581
3123
|
this.turnMarkers = [];
|
|
3124
|
+
this.redoStack = [];
|
|
2582
3125
|
}
|
|
2583
3126
|
// Read-only counterpart of retractLastTurn: how many messages /undo would
|
|
2584
|
-
// drop
|
|
2585
|
-
// no verified turn, so no file snapshots may be reverted)
|
|
2586
|
-
//
|
|
3127
|
+
// drop, which snapshot turn's file changes it would revert (undefined =
|
|
3128
|
+
// no verified turn, so no file snapshots may be reverted), and the turn's
|
|
3129
|
+
// whole-tree snapshot when one was captured. Mutates nothing, so the REPL
|
|
3130
|
+
// can show the preview before the user confirms.
|
|
2587
3131
|
previewLastTurnRetraction() {
|
|
2588
3132
|
const result = retractLastTurn([...this.messages]);
|
|
2589
3133
|
if (result.removed === 0) return { removed: 0 };
|
|
2590
3134
|
const userIndex = result.messages.length;
|
|
2591
3135
|
const last = this.turnMarkers[this.turnMarkers.length - 1];
|
|
2592
|
-
const
|
|
2593
|
-
return {
|
|
3136
|
+
const verified = last && last.userIndex === userIndex;
|
|
3137
|
+
return {
|
|
3138
|
+
removed: result.removed,
|
|
3139
|
+
turn: verified ? last.seq : void 0,
|
|
3140
|
+
tree: verified ? last.tree : void 0
|
|
3141
|
+
};
|
|
2594
3142
|
}
|
|
2595
3143
|
// Drops the final user message and everything after it, and persists the
|
|
2596
3144
|
// trimmed history so a later /resume does not bring the turn back.
|
|
2597
3145
|
// `turn` is the retracted turn's snapshot seq when it could be verified
|
|
2598
3146
|
// against the marker recorded at turn start — undefined means the caller
|
|
2599
|
-
// must not revert any file snapshots.
|
|
3147
|
+
// must not revert any file snapshots. `tree` is the turn's whole-tree
|
|
3148
|
+
// snapshot when git snapshots tracked it.
|
|
2600
3149
|
async retractLastTurn() {
|
|
2601
3150
|
const result = retractLastTurn([...this.messages]);
|
|
2602
3151
|
if (result.removed === 0) return { removed: 0 };
|
|
2603
3152
|
const userIndex = result.messages.length;
|
|
3153
|
+
this.lastUndoneLabel = messageText(this.messages[userIndex]).slice(0, REDO_LABEL_MAX);
|
|
2604
3154
|
this.messages = result.messages;
|
|
2605
3155
|
await this.opts.sessionStore?.replaceMessages([...this.messages]);
|
|
2606
3156
|
const last = this.turnMarkers[this.turnMarkers.length - 1];
|
|
2607
3157
|
let turn;
|
|
3158
|
+
let tree;
|
|
2608
3159
|
if (last && last.userIndex === userIndex) {
|
|
2609
3160
|
turn = last.seq;
|
|
3161
|
+
tree = last.tree;
|
|
2610
3162
|
this.turnMarkers.pop();
|
|
2611
3163
|
}
|
|
2612
|
-
return { removed: result.removed, turn };
|
|
3164
|
+
return { removed: result.removed, turn, tree };
|
|
3165
|
+
}
|
|
3166
|
+
// The confirmed /undo: retract the last turn, then revert its file changes.
|
|
3167
|
+
// With a whole-tree snapshot the working tree is restored from git — which
|
|
3168
|
+
// also reverts changes no tool snapshot saw (bash edits, deleted files) —
|
|
3169
|
+
// and the turn's per-file snapshots are dropped instead of replayed (they
|
|
3170
|
+
// would double-restore). The pre-undo tree feeds the redo stack, so /redo
|
|
3171
|
+
// returns to the exact state before the undo. Any git failure falls back
|
|
3172
|
+
// to the per-file snapshot revert, which never pushes the redo stack.
|
|
3173
|
+
async undoLastTurn() {
|
|
3174
|
+
const { removed, turn, tree } = await this.retractLastTurn();
|
|
3175
|
+
if (removed === 0) return { removed, reverted: [] };
|
|
3176
|
+
if (turn !== void 0 && tree !== void 0) {
|
|
3177
|
+
const preUndoTree = await trackTree(this.opts.cwd);
|
|
3178
|
+
if (preUndoTree !== null && await restoreTree(this.opts.cwd, tree)) {
|
|
3179
|
+
this.redoStack.push({ tree: preUndoTree, label: this.lastUndoneLabel });
|
|
3180
|
+
const dropped = await dropTurnSnapshots(turn, true);
|
|
3181
|
+
return {
|
|
3182
|
+
removed,
|
|
3183
|
+
reverted: [
|
|
3184
|
+
`Restored the working tree from the git snapshot taken at the turn's start (${tree.slice(0, 8)}; covers changes from any tool, bash included; ${dropped} per-file snapshot(s) discarded).`
|
|
3185
|
+
],
|
|
3186
|
+
tree
|
|
3187
|
+
};
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
const reverted = turn !== void 0 ? await undoTurnSnapshots(turn) : [];
|
|
3191
|
+
return { removed, reverted };
|
|
3192
|
+
}
|
|
3193
|
+
// The redo entry /redo would restore, without popping it (for the confirm
|
|
3194
|
+
// prompt); null when there is nothing to redo.
|
|
3195
|
+
peekRedo() {
|
|
3196
|
+
return this.redoStack[this.redoStack.length - 1] ?? null;
|
|
3197
|
+
}
|
|
3198
|
+
// Restores the working tree to its state just before the last git-path
|
|
3199
|
+
// /undo. Conversation messages are not re-added — redo is file-level only.
|
|
3200
|
+
// The entry stays on the stack when the restore fails, so it can be retried.
|
|
3201
|
+
async redoLastUndo() {
|
|
3202
|
+
const entry = this.redoStack[this.redoStack.length - 1];
|
|
3203
|
+
if (!entry) return null;
|
|
3204
|
+
const names = await diffTreeNames(this.opts.cwd, entry.tree);
|
|
3205
|
+
if (!await restoreTree(this.opts.cwd, entry.tree)) {
|
|
3206
|
+
return { ok: false, tree: entry.tree, label: entry.label, files: null };
|
|
3207
|
+
}
|
|
3208
|
+
this.redoStack.pop();
|
|
3209
|
+
return { ok: true, tree: entry.tree, label: entry.label, files: names?.length ?? null };
|
|
2613
3210
|
}
|
|
2614
3211
|
// Cut point for a rewind: messages[index] should be the user message that
|
|
2615
3212
|
// started the rewound turn, but compaction may have shifted indices, so
|
|
@@ -2655,13 +3252,18 @@ var AgentLoop = class {
|
|
|
2655
3252
|
await this.opts.sessionStore?.replaceMessages([...this.messages]);
|
|
2656
3253
|
return removed;
|
|
2657
3254
|
}
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2660
|
-
//
|
|
2661
|
-
|
|
3255
|
+
// Background title generation runs once per loop, fed by the first real
|
|
3256
|
+
// user message of the session. It must NOT latch on the first assistant
|
|
3257
|
+
// step's text: that text is empty for a tool-call-only first step (the
|
|
3258
|
+
// common agentic case), which used to burn the one-shot flag on an empty
|
|
3259
|
+
// input and left every such session untitled. The store-level title check
|
|
3260
|
+
// makes resumed sessions that already have a title a no-op.
|
|
3261
|
+
maybeScheduleTitle() {
|
|
2662
3262
|
if (this.titleScheduled) return;
|
|
2663
3263
|
const store = this.opts.sessionStore;
|
|
2664
3264
|
if (!store) return;
|
|
3265
|
+
const input = this.titleInput;
|
|
3266
|
+
if (!input) return;
|
|
2665
3267
|
this.titleScheduled = true;
|
|
2666
3268
|
scheduleSessionTitle(store, input, this.opts.model);
|
|
2667
3269
|
}
|
|
@@ -2710,6 +3312,9 @@ var AgentLoop = class {
|
|
|
2710
3312
|
this.syncSystemMessage();
|
|
2711
3313
|
const inputText = typeof input === "string" ? input : input.text;
|
|
2712
3314
|
const images = typeof input === "string" ? [] : input.images;
|
|
3315
|
+
if (this.titleInput === void 0 && inputText.trim().length > 0) {
|
|
3316
|
+
this.titleInput = inputText;
|
|
3317
|
+
}
|
|
2713
3318
|
const userMessage = images.length > 0 ? {
|
|
2714
3319
|
role: "user",
|
|
2715
3320
|
content: [
|
|
@@ -2721,12 +3326,17 @@ var AgentLoop = class {
|
|
|
2721
3326
|
{ type: "text", text: inputText }
|
|
2722
3327
|
]
|
|
2723
3328
|
} : { role: "user", content: inputText };
|
|
3329
|
+
const isRoot = (this.opts.subagentDepth ?? 0) === 0;
|
|
3330
|
+
const tree = isRoot && this.opts.config.gitSnapshots ? await trackTree(this.opts.cwd) ?? void 0 : void 0;
|
|
2724
3331
|
this.messages.push(userMessage);
|
|
2725
|
-
const seq =
|
|
2726
|
-
this.turnMarkers.push({ seq, userIndex: this.messages.length - 1 });
|
|
3332
|
+
const seq = isRoot ? beginTurn(this.messages.length - 1) : currentTurnSeq();
|
|
3333
|
+
this.turnMarkers.push({ seq, userIndex: this.messages.length - 1, tree });
|
|
3334
|
+
this.activeTurnSeq = seq;
|
|
3335
|
+
this.activeTurnUserIndex = this.messages.length - 1;
|
|
2727
3336
|
await this.persist(
|
|
2728
3337
|
opts?.persistAs !== void 0 ? { role: "user", content: opts.persistAs } : userMessage
|
|
2729
3338
|
);
|
|
3339
|
+
this.maybeScheduleTitle();
|
|
2730
3340
|
const { config, registry, cwd } = this.opts;
|
|
2731
3341
|
const aiTools = this.buildAiTools();
|
|
2732
3342
|
let autoContinues = 0;
|
|
@@ -2749,7 +3359,8 @@ ${finished.result}`
|
|
|
2749
3359
|
const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
|
|
2750
3360
|
const compacted = compactMessages([...this.messages], maxTokens);
|
|
2751
3361
|
if (compacted.compacted) {
|
|
2752
|
-
this.messages = await this.applyCompactionSummary(compacted);
|
|
3362
|
+
this.messages = await this.applyCompactionSummary(compacted, signal);
|
|
3363
|
+
this.turnMarkers = [];
|
|
2753
3364
|
}
|
|
2754
3365
|
const maxRetries = Math.max(0, config.streamMaxRetries);
|
|
2755
3366
|
const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
|
|
@@ -2757,12 +3368,16 @@ ${finished.result}`
|
|
|
2757
3368
|
const toolCalls = [];
|
|
2758
3369
|
let failure = null;
|
|
2759
3370
|
let lastFinishReason;
|
|
3371
|
+
let lastUsage;
|
|
3372
|
+
let sawReasoning = false;
|
|
2760
3373
|
let oversizedImagesStripped = false;
|
|
2761
3374
|
for (let attempt = 0; ; attempt++) {
|
|
2762
3375
|
text = "";
|
|
2763
3376
|
toolCalls.length = 0;
|
|
2764
3377
|
failure = null;
|
|
2765
3378
|
lastFinishReason = void 0;
|
|
3379
|
+
lastUsage = void 0;
|
|
3380
|
+
sawReasoning = false;
|
|
2766
3381
|
const timeoutScale = Math.min(attempt + 1, 3);
|
|
2767
3382
|
try {
|
|
2768
3383
|
for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
|
|
@@ -2770,12 +3385,14 @@ ${finished.result}`
|
|
|
2770
3385
|
text += event.text;
|
|
2771
3386
|
yield event;
|
|
2772
3387
|
} else if (event.type === "reasoning") {
|
|
3388
|
+
sawReasoning = true;
|
|
2773
3389
|
yield event;
|
|
2774
3390
|
} else if (event.type === "tool-call") {
|
|
2775
3391
|
toolCalls.push({ id: event.id, name: event.name, args: event.args });
|
|
2776
3392
|
yield event;
|
|
2777
3393
|
} else if (event.type === "finish") {
|
|
2778
3394
|
lastFinishReason = event.finishReason;
|
|
3395
|
+
lastUsage = event.usage;
|
|
2779
3396
|
yield event;
|
|
2780
3397
|
} else if (event.type === "error") {
|
|
2781
3398
|
failure = event.error;
|
|
@@ -2806,15 +3423,20 @@ ${finished.result}`
|
|
|
2806
3423
|
}
|
|
2807
3424
|
if (failure && !isRetryableStreamError(failure)) break;
|
|
2808
3425
|
if (attempt >= maxRetries) break;
|
|
2809
|
-
if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 &&
|
|
3426
|
+
if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
|
|
2810
3427
|
break;
|
|
3428
|
+
const delayMs = computeRetryDelayMs(attempt, baseDelay, failure);
|
|
2811
3429
|
yield {
|
|
2812
3430
|
type: "retry",
|
|
2813
3431
|
attempt: attempt + 2,
|
|
2814
3432
|
maxAttempts: maxRetries + 1,
|
|
2815
|
-
|
|
3433
|
+
delayMs,
|
|
3434
|
+
reason: failure ? summarizeStreamError(failure) : `empty response (finish: ${lastFinishReason ?? "none"})`
|
|
2816
3435
|
};
|
|
2817
|
-
if (!await sleep(
|
|
3436
|
+
if (!await sleep(delayMs, signal)) {
|
|
3437
|
+
yield* this.persistInterrupted(text, toolCalls);
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
2818
3440
|
}
|
|
2819
3441
|
if (failure) {
|
|
2820
3442
|
yield { type: "error", error: failure };
|
|
@@ -2856,7 +3478,6 @@ ${finished.result}`
|
|
|
2856
3478
|
};
|
|
2857
3479
|
this.messages.push(assistantMessage);
|
|
2858
3480
|
await this.persist(assistantMessage);
|
|
2859
|
-
this.maybeScheduleTitle(text);
|
|
2860
3481
|
if (toolCalls.length === 0) {
|
|
2861
3482
|
let reason = null;
|
|
2862
3483
|
let nudgeText = AUTO_CONTINUE_NUDGE;
|
|
@@ -2904,7 +3525,11 @@ ${finished.result}`
|
|
|
2904
3525
|
try {
|
|
2905
3526
|
for (const call of toolCalls) {
|
|
2906
3527
|
if (signal.aborted) break;
|
|
2907
|
-
const
|
|
3528
|
+
const refusal = this.doomLoopRefusal(call);
|
|
3529
|
+
if (refusal !== null) {
|
|
3530
|
+
yield { type: "notice", message: refusal };
|
|
3531
|
+
}
|
|
3532
|
+
const result = refusal !== null ? { content: refusal, isError: true } : await this.executeTool(call, signal);
|
|
2908
3533
|
const toolMessage = {
|
|
2909
3534
|
role: "tool",
|
|
2910
3535
|
content: [
|
|
@@ -2981,7 +3606,7 @@ ${finished.result}`
|
|
|
2981
3606
|
if (userMemory) parts.push(userMemory);
|
|
2982
3607
|
const skills = discoverSkills(this.opts.cwd);
|
|
2983
3608
|
if (skills.length > 0) parts.push(formatSkillsBlock(skills));
|
|
2984
|
-
const git =
|
|
3609
|
+
const git = getGitSummaryCached(this.opts.cwd);
|
|
2985
3610
|
if (git) parts.push(formatGitSummary(git));
|
|
2986
3611
|
if (parts.length === 0) return;
|
|
2987
3612
|
const content = parts.join("\n\n");
|
|
@@ -2992,7 +3617,7 @@ ${finished.result}`
|
|
|
2992
3617
|
this.messages.unshift({ role: "system", content });
|
|
2993
3618
|
}
|
|
2994
3619
|
}
|
|
2995
|
-
async applyCompactionSummary(compacted) {
|
|
3620
|
+
async applyCompactionSummary(compacted, signal) {
|
|
2996
3621
|
const { config, model } = this.opts;
|
|
2997
3622
|
if (config.contextCompaction !== "summary" || !model) {
|
|
2998
3623
|
return compacted.messages;
|
|
@@ -3000,7 +3625,7 @@ ${finished.result}`
|
|
|
3000
3625
|
const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
|
|
3001
3626
|
const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
|
|
3002
3627
|
try {
|
|
3003
|
-
const summary = await summarizeMessages(dropped, model);
|
|
3628
|
+
const summary = await summarizeMessages(dropped, model, signal);
|
|
3004
3629
|
const messages = compacted.messages.slice();
|
|
3005
3630
|
messages[headCount] = {
|
|
3006
3631
|
role: "user",
|
|
@@ -3013,15 +3638,23 @@ ${summary}`
|
|
|
3013
3638
|
}
|
|
3014
3639
|
}
|
|
3015
3640
|
async *streamOnce(aiTools, signal, timeoutScale = 1) {
|
|
3016
|
-
const { streamChat } = await import("./stream-
|
|
3017
|
-
|
|
3641
|
+
const { streamChat } = await import("./stream-H4BYBVAZ.js");
|
|
3642
|
+
for await (const event of streamChat({
|
|
3018
3643
|
model: this.opts.model,
|
|
3019
3644
|
messages: this.messages,
|
|
3020
3645
|
tools: aiTools,
|
|
3021
3646
|
abortSignal: signal,
|
|
3022
3647
|
idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3 * timeoutScale,
|
|
3023
3648
|
firstPartTimeoutMs: this.opts.config.streamFirstChunkTimeoutSec * 1e3 * timeoutScale
|
|
3024
|
-
})
|
|
3649
|
+
})) {
|
|
3650
|
+
if (event.type === "finish" && event.truncated) {
|
|
3651
|
+
yield {
|
|
3652
|
+
type: "notice",
|
|
3653
|
+
message: "Response was cut short by the stream idle timeout \u2014 the reply may be incomplete."
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3656
|
+
yield event;
|
|
3657
|
+
}
|
|
3025
3658
|
}
|
|
3026
3659
|
buildAiTools() {
|
|
3027
3660
|
const plan = this.opts.config.permissionMode === "plan";
|
|
@@ -3051,6 +3684,23 @@ ${summary}`
|
|
|
3051
3684
|
return empty;
|
|
3052
3685
|
}
|
|
3053
3686
|
}
|
|
3687
|
+
// opencode's doom_loop guard: returns a refusal once the identical tool
|
|
3688
|
+
// call (name + stable-serialized args) has repeated config.doomLoopThreshold
|
|
3689
|
+
// times in a row; 0 disables. Any different signature resets the streak.
|
|
3690
|
+
// Subagents are separate loop instances, so each counts its own streak.
|
|
3691
|
+
doomLoopRefusal(call) {
|
|
3692
|
+
const threshold = this.opts.config.doomLoopThreshold;
|
|
3693
|
+
if (threshold <= 0) return null;
|
|
3694
|
+
const signature = `${call.name} ${stableStringify(call.args)}`;
|
|
3695
|
+
if (signature === this.lastToolSignature) {
|
|
3696
|
+
this.repeatedToolCalls += 1;
|
|
3697
|
+
} else {
|
|
3698
|
+
this.lastToolSignature = signature;
|
|
3699
|
+
this.repeatedToolCalls = 1;
|
|
3700
|
+
}
|
|
3701
|
+
if (this.repeatedToolCalls < threshold) return null;
|
|
3702
|
+
return `Refused: tool "${call.name}" was called ${this.repeatedToolCalls} times in a row with identical arguments. Stop retrying it \u2014 change your approach or report that you are blocked.`;
|
|
3703
|
+
}
|
|
3054
3704
|
async executeTool(call, signal) {
|
|
3055
3705
|
const { registry, config, cwd } = this.opts;
|
|
3056
3706
|
const tool = registry.get(call.name);
|
|
@@ -3095,7 +3745,15 @@ ${summary}`
|
|
|
3095
3745
|
}
|
|
3096
3746
|
let result;
|
|
3097
3747
|
try {
|
|
3098
|
-
result = await tool.execute(parsed.data, {
|
|
3748
|
+
result = await tool.execute(parsed.data, {
|
|
3749
|
+
cwd,
|
|
3750
|
+
abortSignal: signal,
|
|
3751
|
+
snapshotContext: {
|
|
3752
|
+
owner: (this.opts.subagentDepth ?? 0) > 0 ? "subagent" : "root",
|
|
3753
|
+
turn: this.activeTurnSeq,
|
|
3754
|
+
messageIndex: this.activeTurnUserIndex
|
|
3755
|
+
}
|
|
3756
|
+
});
|
|
3099
3757
|
} catch (error) {
|
|
3100
3758
|
if (signal.aborted) {
|
|
3101
3759
|
return { content: "Tool execution aborted.", isError: true };
|
|
@@ -3126,40 +3784,34 @@ export {
|
|
|
3126
3784
|
MAX_IMAGE_DIMENSION,
|
|
3127
3785
|
probeImageDimensions,
|
|
3128
3786
|
reconcileToolCalls,
|
|
3787
|
+
isSensitivePath,
|
|
3129
3788
|
isAllowedByRules,
|
|
3130
3789
|
buildAllowRule,
|
|
3131
3790
|
isDangerousCommand,
|
|
3791
|
+
diffTreeNames,
|
|
3132
3792
|
listSnapshots,
|
|
3133
3793
|
clearSnapshots,
|
|
3134
3794
|
hydrateSnapshots,
|
|
3135
3795
|
listTurnSnapshots,
|
|
3136
3796
|
resolveSnapshotContent,
|
|
3137
|
-
undoTurnSnapshots,
|
|
3138
3797
|
rewindToSnapshot,
|
|
3139
|
-
TodoStore,
|
|
3140
|
-
formatTodos,
|
|
3141
|
-
parseTodoArgs,
|
|
3142
|
-
loadTodos,
|
|
3143
|
-
resetTodos,
|
|
3144
|
-
starHome,
|
|
3145
|
-
globalConfigPath,
|
|
3146
|
-
sessionsDir,
|
|
3147
|
-
userMemoryPath,
|
|
3148
|
-
envFilePath,
|
|
3149
|
-
projectConfigPath,
|
|
3150
|
-
discoverSkills,
|
|
3151
3798
|
defaultTaskManager,
|
|
3152
3799
|
resolveShell,
|
|
3153
3800
|
bashTool,
|
|
3154
3801
|
SKIP_DIRS,
|
|
3155
3802
|
createIgnorePredicate,
|
|
3156
|
-
isSensitivePath2 as isSensitivePath,
|
|
3157
3803
|
formatTaskList,
|
|
3158
3804
|
formatTaskStarted,
|
|
3159
3805
|
formatTaskFinished,
|
|
3806
|
+
TodoStore,
|
|
3807
|
+
formatTodos,
|
|
3808
|
+
parseTodoArgs,
|
|
3809
|
+
loadTodos,
|
|
3810
|
+
resetTodos,
|
|
3811
|
+
discoverSkills,
|
|
3160
3812
|
createDefaultRegistry,
|
|
3161
3813
|
appendUserMemory,
|
|
3162
3814
|
PLAN_MODE_PROMPT,
|
|
3163
3815
|
AgentLoop
|
|
3164
3816
|
};
|
|
3165
|
-
//# sourceMappingURL=chunk-
|
|
3817
|
+
//# sourceMappingURL=chunk-3L6QKJM4.js.map
|