@zhongqian97-code/ecode 0.3.12 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -1
- package/dist/index.js +889 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -154,6 +154,50 @@ Type `/` to see available skills with Tab autocomplete. Skills inject structured
|
|
|
154
154
|
|
|
155
155
|
Run `/setup-matt-pocock-skills` once to configure the issue tracker and triage vocabulary for your repo.
|
|
156
156
|
|
|
157
|
+
## Automation: /loop and /goal
|
|
158
|
+
|
|
159
|
+
Run LLM tasks automatically — on a schedule or until a condition is met.
|
|
160
|
+
|
|
161
|
+
### /loop — recurring tasks
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
/loop [interval] <prompt>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Runs the prompt on a fixed interval. Interval formats: `30s`, `5m`, `1h`, `1h30m`. Defaults to 5 minutes if omitted.
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
/loop 10m check git status and summarize uncommitted changes
|
|
171
|
+
/loop 1h summarize today's work and update CHANGELOG
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### /goal — condition-driven tasks
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
/goal <condition>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Runs the condition as both the prompt and the success criterion. The LLM executes the prompt, then a separate evaluator LLM judges whether the condition has been met. Repeats until `done` or `blocked`.
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
/goal all TypeScript errors are fixed
|
|
184
|
+
/goal the test suite passes with no failures
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Managing jobs
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
/jobs — list all active jobs
|
|
191
|
+
/unloop <id> — cancel a loop job (use first 8 chars of id)
|
|
192
|
+
/ungoal <id> — cancel a goal job
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Data storage
|
|
196
|
+
|
|
197
|
+
Job state and run logs are stored in `<ECODE_LOG_DIR>/automation/` (or `~/.config/ecode/automation/` if no log dir is set). Jobs survive restarts — they resume automatically on next launch.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
157
201
|
## Session logging
|
|
158
202
|
|
|
159
203
|
Enable JSONL session logs to replay or analyze conversations:
|
|
@@ -167,7 +211,27 @@ ecode
|
|
|
167
211
|
# "logDir": "~/.ecode/logs"
|
|
168
212
|
```
|
|
169
213
|
|
|
170
|
-
Each session writes a timestamped `.jsonl` file
|
|
214
|
+
Each session writes a timestamped `.jsonl` log file and a companion `-session.json` metadata file (id, title, model, token count, turn count).
|
|
215
|
+
|
|
216
|
+
### `ecode sessions` subcommands
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# list all sessions, sorted by most recent activity
|
|
220
|
+
ecode sessions list
|
|
221
|
+
|
|
222
|
+
# show full metadata for a session (use 8-char prefix or full UUID)
|
|
223
|
+
ecode sessions inspect a1b2c3d4
|
|
224
|
+
|
|
225
|
+
# get a shell command to replay a session
|
|
226
|
+
ecode sessions replay a1b2c3d4
|
|
227
|
+
# → ecode --replay ~/.ecode/logs/2026-05-13T08-47-00.jsonl
|
|
228
|
+
|
|
229
|
+
# get a shell command to fork a session at a specific turn (default: last turn)
|
|
230
|
+
ecode sessions fork a1b2c3d4 5
|
|
231
|
+
# → ecode --fork ~/.ecode/logs/2026-05-13T08-47-00.jsonl:5
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The `fork` command is the basis for `/goal` rollback: when the evaluator detects context drift, it surfaces a `sessions fork` command pointing to the last known-good turn.
|
|
171
235
|
|
|
172
236
|
## Requirements
|
|
173
237
|
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@ const _ew=process.emitWarning.bind(process);process.emitWarning=function(w,...a)
|
|
|
3
3
|
|
|
4
4
|
// src/index.ts
|
|
5
5
|
import { createRequire } from "module";
|
|
6
|
-
import { resolve as resolve4, dirname as
|
|
6
|
+
import { resolve as resolve4, dirname as dirname7 } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import React4 from "react";
|
|
9
9
|
import { render } from "ink";
|
|
10
|
-
import { readFileSync as
|
|
10
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
11
11
|
|
|
12
12
|
// src/config.ts
|
|
13
13
|
import { existsSync, readFileSync } from "fs";
|
|
@@ -537,13 +537,13 @@ var READ_TOOL = {
|
|
|
537
537
|
}
|
|
538
538
|
};
|
|
539
539
|
async function readFile2(params) {
|
|
540
|
-
const { path:
|
|
540
|
+
const { path: path9, offset = 0, limit } = params;
|
|
541
541
|
let raw;
|
|
542
542
|
try {
|
|
543
|
-
raw = await fs.readFile(
|
|
543
|
+
raw = await fs.readFile(path9, "utf8");
|
|
544
544
|
} catch (err) {
|
|
545
545
|
const msg = err instanceof Error ? err.message : String(err);
|
|
546
|
-
return `Error reading ${
|
|
546
|
+
return `Error reading ${path9}: ${msg}`;
|
|
547
547
|
}
|
|
548
548
|
const lines = raw.split("\n");
|
|
549
549
|
const sliced = limit !== void 0 ? lines.slice(offset, offset + limit) : lines.slice(offset);
|
|
@@ -599,28 +599,28 @@ var EDIT_TOOL = {
|
|
|
599
599
|
}
|
|
600
600
|
};
|
|
601
601
|
async function editFile(params) {
|
|
602
|
-
const { path:
|
|
602
|
+
const { path: path9, old_string, new_string } = params;
|
|
603
603
|
let content;
|
|
604
604
|
try {
|
|
605
|
-
content = await fs3.readFile(
|
|
605
|
+
content = await fs3.readFile(path9, "utf8");
|
|
606
606
|
} catch (err) {
|
|
607
607
|
const msg = err instanceof Error ? err.message : String(err);
|
|
608
|
-
return `Error reading ${
|
|
608
|
+
return `Error reading ${path9}: ${msg}`;
|
|
609
609
|
}
|
|
610
610
|
const count = countOccurrences(content, old_string);
|
|
611
611
|
if (count === 0) {
|
|
612
|
-
return `Error: old_string not found in ${
|
|
612
|
+
return `Error: old_string not found in ${path9}`;
|
|
613
613
|
}
|
|
614
614
|
if (count > 1) {
|
|
615
|
-
return `Error: old_string appears ${count} times in ${
|
|
615
|
+
return `Error: old_string appears ${count} times in ${path9} (ambiguous \u2014 add more context)`;
|
|
616
616
|
}
|
|
617
617
|
const updated = content.replace(old_string, new_string);
|
|
618
618
|
try {
|
|
619
|
-
await fs3.writeFile(
|
|
620
|
-
return `Edited ${
|
|
619
|
+
await fs3.writeFile(path9, updated, "utf8");
|
|
620
|
+
return `Edited ${path9}`;
|
|
621
621
|
} catch (err) {
|
|
622
622
|
const msg = err instanceof Error ? err.message : String(err);
|
|
623
|
-
return `Error writing ${
|
|
623
|
+
return `Error writing ${path9}: ${msg}`;
|
|
624
624
|
}
|
|
625
625
|
}
|
|
626
626
|
function countOccurrences(haystack, needle) {
|
|
@@ -2126,8 +2126,735 @@ function parseMouseScroll(data) {
|
|
|
2126
2126
|
return null;
|
|
2127
2127
|
}
|
|
2128
2128
|
|
|
2129
|
+
// src/ui/App.tsx
|
|
2130
|
+
import { homedir as homedir2 } from "os";
|
|
2131
|
+
import { join as join10 } from "path";
|
|
2132
|
+
|
|
2133
|
+
// src/automation/store.ts
|
|
2134
|
+
import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir2 } from "fs/promises";
|
|
2135
|
+
import { join as join7 } from "path";
|
|
2136
|
+
function jobsFilePath(dataDir) {
|
|
2137
|
+
return join7(dataDir, "jobs.json");
|
|
2138
|
+
}
|
|
2139
|
+
async function loadJobs(dataDir) {
|
|
2140
|
+
try {
|
|
2141
|
+
const content = await readFile8(jobsFilePath(dataDir), "utf-8");
|
|
2142
|
+
const parsed = JSON.parse(content);
|
|
2143
|
+
if (!Array.isArray(parsed)) return [];
|
|
2144
|
+
return parsed;
|
|
2145
|
+
} catch {
|
|
2146
|
+
return [];
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
async function saveJobs(dataDir, jobs) {
|
|
2150
|
+
await mkdir2(dataDir, { recursive: true });
|
|
2151
|
+
await writeFile5(jobsFilePath(dataDir), JSON.stringify(jobs, null, 2), "utf-8");
|
|
2152
|
+
}
|
|
2153
|
+
async function upsertJob(dataDir, job) {
|
|
2154
|
+
const jobs = await loadJobs(dataDir);
|
|
2155
|
+
const idx = jobs.findIndex((j) => j.id === job.id);
|
|
2156
|
+
if (idx >= 0) {
|
|
2157
|
+
const updated = [...jobs.slice(0, idx), job, ...jobs.slice(idx + 1)];
|
|
2158
|
+
await saveJobs(dataDir, updated);
|
|
2159
|
+
} else {
|
|
2160
|
+
await saveJobs(dataDir, [...jobs, job]);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
async function removeJob(dataDir, id) {
|
|
2164
|
+
const jobs = await loadJobs(dataDir);
|
|
2165
|
+
const target = jobs.find((j) => j.id === id);
|
|
2166
|
+
if (!target) return void 0;
|
|
2167
|
+
await saveJobs(dataDir, jobs.filter((j) => j.id !== id));
|
|
2168
|
+
return target;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
// src/automation/runtime.ts
|
|
2172
|
+
import { randomUUID } from "crypto";
|
|
2173
|
+
|
|
2174
|
+
// src/automation/log.ts
|
|
2175
|
+
import { readFile as readFile9, appendFile, mkdir as mkdir3 } from "fs/promises";
|
|
2176
|
+
import { join as join8 } from "path";
|
|
2177
|
+
function logFilePath(logDir) {
|
|
2178
|
+
return join8(logDir, "automation-runs.jsonl");
|
|
2179
|
+
}
|
|
2180
|
+
async function appendRunLog(logDir, entry) {
|
|
2181
|
+
await mkdir3(logDir, { recursive: true });
|
|
2182
|
+
await appendFile(logFilePath(logDir), JSON.stringify(entry) + "\n", "utf-8");
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// src/automation/runtime.ts
|
|
2186
|
+
async function executeJob(job, config2) {
|
|
2187
|
+
if (job.budget?.maxTurns !== void 0 && job.runCount >= job.budget.maxTurns) {
|
|
2188
|
+
return null;
|
|
2189
|
+
}
|
|
2190
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2191
|
+
const runId = randomUUID();
|
|
2192
|
+
let summaryText;
|
|
2193
|
+
let errorMsg;
|
|
2194
|
+
let result;
|
|
2195
|
+
try {
|
|
2196
|
+
summaryText = await config2.session.run(job.promptTemplate);
|
|
2197
|
+
result = "success";
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
errorMsg = err instanceof Error ? err.message : String(err);
|
|
2200
|
+
result = "failure";
|
|
2201
|
+
}
|
|
2202
|
+
const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2203
|
+
const entry = {
|
|
2204
|
+
id: runId,
|
|
2205
|
+
jobId: job.id,
|
|
2206
|
+
startedAt,
|
|
2207
|
+
finishedAt,
|
|
2208
|
+
promptUsed: job.promptTemplate,
|
|
2209
|
+
result,
|
|
2210
|
+
summaryText,
|
|
2211
|
+
error: errorMsg
|
|
2212
|
+
};
|
|
2213
|
+
await appendRunLog(config2.logDir, entry);
|
|
2214
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2215
|
+
const updatedJob = {
|
|
2216
|
+
...job,
|
|
2217
|
+
runCount: job.runCount + 1,
|
|
2218
|
+
failureCount: result === "failure" ? job.failureCount + 1 : job.failureCount,
|
|
2219
|
+
lastRunAt: finishedAt,
|
|
2220
|
+
lastError: errorMsg,
|
|
2221
|
+
updatedAt: now
|
|
2222
|
+
};
|
|
2223
|
+
await upsertJob(config2.dataDir, updatedJob);
|
|
2224
|
+
return entry;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// src/automation/goal/evaluator.ts
|
|
2228
|
+
function parseVerdict(response) {
|
|
2229
|
+
const lower = response.toLowerCase();
|
|
2230
|
+
if (lower.includes("not_done")) return "not_done";
|
|
2231
|
+
if (lower.includes("blocked")) return "blocked";
|
|
2232
|
+
if (lower.includes("done")) return "done";
|
|
2233
|
+
return "not_done";
|
|
2234
|
+
}
|
|
2235
|
+
function buildPrompt(input) {
|
|
2236
|
+
return [
|
|
2237
|
+
"You are evaluating whether a goal condition has been met based on a work transcript.",
|
|
2238
|
+
"",
|
|
2239
|
+
`Goal condition: ${input.condition}`,
|
|
2240
|
+
"",
|
|
2241
|
+
"Work transcript:",
|
|
2242
|
+
input.transcript,
|
|
2243
|
+
"",
|
|
2244
|
+
'Reply with one of these verdicts: "done", "not_done", or "blocked".',
|
|
2245
|
+
'"done" means the condition is satisfied.',
|
|
2246
|
+
'"not_done" means the condition is not yet satisfied but work can continue.',
|
|
2247
|
+
'"blocked" means progress is impossible due to an error or blocker.',
|
|
2248
|
+
"After the verdict, explain your reasoning briefly."
|
|
2249
|
+
].join("\n");
|
|
2250
|
+
}
|
|
2251
|
+
function createLLMEvaluator(session) {
|
|
2252
|
+
return {
|
|
2253
|
+
async evaluate(input) {
|
|
2254
|
+
try {
|
|
2255
|
+
const prompt = buildPrompt(input);
|
|
2256
|
+
const response = await session.run(prompt);
|
|
2257
|
+
const verdict = parseVerdict(response);
|
|
2258
|
+
return { verdict, reason: response };
|
|
2259
|
+
} catch (error) {
|
|
2260
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2261
|
+
return { verdict: "blocked", reason: message };
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// src/automation/loop/scheduler.ts
|
|
2268
|
+
var LoopScheduler = class {
|
|
2269
|
+
options;
|
|
2270
|
+
/** 按 jobId 索引的调度表 */
|
|
2271
|
+
entries = /* @__PURE__ */ new Map();
|
|
2272
|
+
constructor(options) {
|
|
2273
|
+
this.options = options;
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* 初始化调度器,传入初始 job 列表
|
|
2277
|
+
* 每个 job 会被立即纳入调度
|
|
2278
|
+
*/
|
|
2279
|
+
start(jobs) {
|
|
2280
|
+
for (const job of jobs) {
|
|
2281
|
+
this.scheduleJob(job);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* 停止所有调度,清除全部挂起的 timer
|
|
2286
|
+
*/
|
|
2287
|
+
stop() {
|
|
2288
|
+
for (const entry of this.entries.values()) {
|
|
2289
|
+
if (entry.timerId !== void 0) {
|
|
2290
|
+
clearTimeout(entry.timerId);
|
|
2291
|
+
entry.timerId = void 0;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
this.entries.clear();
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* 动态添加一个新 job 并立即纳入调度
|
|
2298
|
+
*/
|
|
2299
|
+
add(job) {
|
|
2300
|
+
const existing = this.entries.get(job.id);
|
|
2301
|
+
if (existing?.timerId !== void 0) {
|
|
2302
|
+
clearTimeout(existing.timerId);
|
|
2303
|
+
}
|
|
2304
|
+
this.scheduleJob(job);
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* 按 id 移除 job 并取消其挂起的 timer
|
|
2308
|
+
* id 不存在时静默忽略,不抛出异常
|
|
2309
|
+
*/
|
|
2310
|
+
remove(id) {
|
|
2311
|
+
const entry = this.entries.get(id);
|
|
2312
|
+
if (entry === void 0) return;
|
|
2313
|
+
if (entry.timerId !== void 0) {
|
|
2314
|
+
clearTimeout(entry.timerId);
|
|
2315
|
+
}
|
|
2316
|
+
this.entries.delete(id);
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* 获取指定 job 的下次运行时间(ISO 字符串)
|
|
2320
|
+
* job 不存在时返回 undefined
|
|
2321
|
+
*/
|
|
2322
|
+
getNextRunAt(jobId) {
|
|
2323
|
+
const entry = this.entries.get(jobId);
|
|
2324
|
+
if (entry === void 0) return void 0;
|
|
2325
|
+
return new Date(entry.nextRunAtMs).toISOString();
|
|
2326
|
+
}
|
|
2327
|
+
// ──────────────────────────────────────────────────────────────
|
|
2328
|
+
// 私有方法
|
|
2329
|
+
// ──────────────────────────────────────────────────────────────
|
|
2330
|
+
/**
|
|
2331
|
+
* 将 job 纳入调度:
|
|
2332
|
+
* 1. 计算第一次触发的延迟
|
|
2333
|
+
* 2. 若 nextRunAt 已过期,快速推进到下一个未来时间(不补跑)
|
|
2334
|
+
* 3. 挂起 setTimeout
|
|
2335
|
+
*/
|
|
2336
|
+
scheduleJob(job) {
|
|
2337
|
+
const now = Date.now();
|
|
2338
|
+
let nextRunAtMs = new Date(job.nextRunAt).getTime();
|
|
2339
|
+
if (nextRunAtMs <= now) {
|
|
2340
|
+
const { intervalMs } = job;
|
|
2341
|
+
nextRunAtMs += intervalMs;
|
|
2342
|
+
while (nextRunAtMs <= now) {
|
|
2343
|
+
nextRunAtMs += intervalMs;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
const entry = {
|
|
2347
|
+
job,
|
|
2348
|
+
nextRunAtMs,
|
|
2349
|
+
timerId: void 0
|
|
2350
|
+
};
|
|
2351
|
+
this.entries.set(job.id, entry);
|
|
2352
|
+
this.arm(entry);
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* 为 entry 挂起一个 setTimeout,延迟到 entry.nextRunAtMs
|
|
2356
|
+
*/
|
|
2357
|
+
arm(entry) {
|
|
2358
|
+
const delay = Math.max(0, entry.nextRunAtMs - Date.now());
|
|
2359
|
+
entry.timerId = setTimeout(() => {
|
|
2360
|
+
void this.fire(entry);
|
|
2361
|
+
}, delay);
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* job 到期时的触发逻辑:
|
|
2365
|
+
* 1. 检查 TTL,若已过期则不执行、不重新调度
|
|
2366
|
+
* 2. 调用 onJobRun;失败时调用 onJobError
|
|
2367
|
+
* 3. 无论成功失败,均重新调度下一次(TTL 由下次触发时再判断)
|
|
2368
|
+
*/
|
|
2369
|
+
async fire(entry) {
|
|
2370
|
+
const { job } = entry;
|
|
2371
|
+
const now = Date.now();
|
|
2372
|
+
if (job.ttlMs !== void 0) {
|
|
2373
|
+
const expiresAt = new Date(job.createdAt).getTime() + job.ttlMs;
|
|
2374
|
+
if (now >= expiresAt) {
|
|
2375
|
+
this.entries.delete(job.id);
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
try {
|
|
2380
|
+
await this.options.onJobRun(job);
|
|
2381
|
+
} catch (error) {
|
|
2382
|
+
this.options.onJobError(job, error);
|
|
2383
|
+
}
|
|
2384
|
+
const current = this.entries.get(job.id);
|
|
2385
|
+
if (current === void 0) return;
|
|
2386
|
+
current.nextRunAtMs = Date.now() + job.intervalMs;
|
|
2387
|
+
this.arm(current);
|
|
2388
|
+
}
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
// src/automation/loop/command.ts
|
|
2392
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2393
|
+
|
|
2394
|
+
// src/automation/loop/parse.ts
|
|
2395
|
+
var DEFAULT_INTERVAL_MS = 6e5;
|
|
2396
|
+
var MIN_INTERVAL_MS = 3e4;
|
|
2397
|
+
var UNIT_MS = {
|
|
2398
|
+
s: 1e3,
|
|
2399
|
+
m: 6e4,
|
|
2400
|
+
h: 36e5
|
|
2401
|
+
};
|
|
2402
|
+
var TIME_TOKEN_RE = /(\d+)\s*([smh])/gi;
|
|
2403
|
+
function parseInterval(input) {
|
|
2404
|
+
if (input == null) return { intervalMs: DEFAULT_INTERVAL_MS };
|
|
2405
|
+
const trimmed = input.trim();
|
|
2406
|
+
if (trimmed === "") return { intervalMs: DEFAULT_INTERVAL_MS };
|
|
2407
|
+
if (trimmed.startsWith("-")) return null;
|
|
2408
|
+
const normalized = trimmed.replace(/^every\s+/i, "");
|
|
2409
|
+
if (/^\d+$/.test(normalized)) {
|
|
2410
|
+
const ms = parseInt(normalized, 10) * 1e3;
|
|
2411
|
+
return ms > 0 && ms >= MIN_INTERVAL_MS ? { intervalMs: ms } : null;
|
|
2412
|
+
}
|
|
2413
|
+
let totalMs = 0;
|
|
2414
|
+
let matchCount = 0;
|
|
2415
|
+
TIME_TOKEN_RE.lastIndex = 0;
|
|
2416
|
+
let match;
|
|
2417
|
+
while ((match = TIME_TOKEN_RE.exec(normalized)) !== null) {
|
|
2418
|
+
const value = parseInt(match[1], 10);
|
|
2419
|
+
const unit = match[2].toLowerCase();
|
|
2420
|
+
totalMs += value * UNIT_MS[unit];
|
|
2421
|
+
matchCount++;
|
|
2422
|
+
}
|
|
2423
|
+
if (matchCount === 0) return null;
|
|
2424
|
+
if (totalMs <= 0 || totalMs < MIN_INTERVAL_MS) return null;
|
|
2425
|
+
return { intervalMs: totalMs };
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// src/automation/loop/command.ts
|
|
2429
|
+
function formatInterval(ms) {
|
|
2430
|
+
const totalSec = Math.floor(ms / 1e3);
|
|
2431
|
+
const h = Math.floor(totalSec / 3600);
|
|
2432
|
+
const m = Math.floor(totalSec % 3600 / 60);
|
|
2433
|
+
const s = totalSec % 60;
|
|
2434
|
+
if (h > 0 && m === 0 && s === 0) return `${h}h`;
|
|
2435
|
+
if (h > 0 && s === 0) return `${h}h${m}m`;
|
|
2436
|
+
if (h > 0) return `${h}h${m}m${s}s`;
|
|
2437
|
+
if (m > 0 && s === 0) return `${m}m`;
|
|
2438
|
+
if (m > 0) return `${m}m${s}s`;
|
|
2439
|
+
return `${s}s`;
|
|
2440
|
+
}
|
|
2441
|
+
async function cmdLoop(args, deps) {
|
|
2442
|
+
const trimmed = args.trim();
|
|
2443
|
+
let intervalMs = DEFAULT_INTERVAL_MS;
|
|
2444
|
+
let prompt = trimmed;
|
|
2445
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
2446
|
+
if (spaceIdx !== -1) {
|
|
2447
|
+
const firstToken = trimmed.slice(0, spaceIdx);
|
|
2448
|
+
const parsed = parseInterval(firstToken);
|
|
2449
|
+
if (parsed !== null) {
|
|
2450
|
+
intervalMs = parsed.intervalMs;
|
|
2451
|
+
prompt = trimmed.slice(spaceIdx + 1).trim();
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
const now = /* @__PURE__ */ new Date();
|
|
2455
|
+
const job = {
|
|
2456
|
+
id: randomUUID2(),
|
|
2457
|
+
kind: "loop",
|
|
2458
|
+
title: prompt.slice(0, 60),
|
|
2459
|
+
createdAt: now.toISOString(),
|
|
2460
|
+
updatedAt: now.toISOString(),
|
|
2461
|
+
state: "scheduled",
|
|
2462
|
+
promptTemplate: prompt,
|
|
2463
|
+
intervalMs,
|
|
2464
|
+
nextRunAt: new Date(now.getTime() + intervalMs).toISOString(),
|
|
2465
|
+
autoRunNow: false,
|
|
2466
|
+
runCount: 0,
|
|
2467
|
+
failureCount: 0
|
|
2468
|
+
};
|
|
2469
|
+
await upsertJob(deps.dataDir, job);
|
|
2470
|
+
deps.onSchedule(job);
|
|
2471
|
+
return `\u5DF2\u521B\u5EFA loop job [${job.id.slice(0, 8)}]\uFF0C\u6BCF ${formatInterval(intervalMs)} \u6267\u884C\u4E00\u6B21\uFF1A${prompt}`;
|
|
2472
|
+
}
|
|
2473
|
+
async function cmdJobs(deps) {
|
|
2474
|
+
const jobs = await loadJobs(deps.dataDir);
|
|
2475
|
+
if (jobs.length === 0) {
|
|
2476
|
+
return "\u6682\u65E0 automation job\u3002\u4F7F\u7528 /loop <interval> <prompt> \u521B\u5EFA\u4E00\u4E2A\u3002";
|
|
2477
|
+
}
|
|
2478
|
+
const lines = jobs.map((j) => {
|
|
2479
|
+
const interval = j.kind === "loop" ? formatInterval(j.intervalMs) : "\u2014";
|
|
2480
|
+
const idPrefix = j.id.slice(0, 8);
|
|
2481
|
+
return `[${idPrefix}] ${interval} ${j.promptTemplate} (${j.state})`;
|
|
2482
|
+
});
|
|
2483
|
+
return `\u5F53\u524D job \u5217\u8868\uFF08\u5171 ${jobs.length} \u4E2A\uFF09\uFF1A
|
|
2484
|
+
${lines.join("\n")}`;
|
|
2485
|
+
}
|
|
2486
|
+
async function cmdUnloop(idOrPrefix, deps) {
|
|
2487
|
+
const jobs = await loadJobs(deps.dataDir);
|
|
2488
|
+
const target = jobs.find(
|
|
2489
|
+
(j) => j.id === idOrPrefix || j.id.startsWith(idOrPrefix)
|
|
2490
|
+
);
|
|
2491
|
+
if (target === void 0) {
|
|
2492
|
+
return `\u627E\u4E0D\u5230 job "${idOrPrefix}"\uFF0C\u8BF7\u7528 /jobs \u786E\u8BA4 id\u3002`;
|
|
2493
|
+
}
|
|
2494
|
+
await removeJob(deps.dataDir, target.id);
|
|
2495
|
+
deps.onUnschedule(target.id);
|
|
2496
|
+
return `\u5DF2\u53D6\u6D88 loop job [${target.id.slice(0, 8)}]\uFF1A${target.promptTemplate}`;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// src/automation/goal/command.ts
|
|
2500
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2501
|
+
async function cmdGoal(args, deps) {
|
|
2502
|
+
const condition = args.trim();
|
|
2503
|
+
const now = /* @__PURE__ */ new Date();
|
|
2504
|
+
const job = {
|
|
2505
|
+
id: randomUUID3(),
|
|
2506
|
+
kind: "goal",
|
|
2507
|
+
title: condition.slice(0, 60),
|
|
2508
|
+
createdAt: now.toISOString(),
|
|
2509
|
+
updatedAt: now.toISOString(),
|
|
2510
|
+
state: "scheduled",
|
|
2511
|
+
promptTemplate: condition,
|
|
2512
|
+
condition,
|
|
2513
|
+
activeTurnCount: 0,
|
|
2514
|
+
runCount: 0,
|
|
2515
|
+
failureCount: 0,
|
|
2516
|
+
// 快照字段:仅在调用方提供时写入,旧格式兼容(undefined 即不存在)
|
|
2517
|
+
snapshotLogFile: deps.snapshotLogFile,
|
|
2518
|
+
snapshotTurn: deps.snapshotTurn
|
|
2519
|
+
};
|
|
2520
|
+
await upsertJob(deps.dataDir, job);
|
|
2521
|
+
deps.onSchedule(job);
|
|
2522
|
+
return `\u5DF2\u521B\u5EFA goal job [${job.id.slice(0, 8)}]\uFF0C\u76EE\u6807\u6761\u4EF6\uFF1A${condition}`;
|
|
2523
|
+
}
|
|
2524
|
+
async function cmdUngoal(idOrPrefix, deps) {
|
|
2525
|
+
const jobs = await loadJobs(deps.dataDir);
|
|
2526
|
+
const target = jobs.find(
|
|
2527
|
+
(j) => j.id === idOrPrefix || j.id.startsWith(idOrPrefix)
|
|
2528
|
+
);
|
|
2529
|
+
if (target === void 0) {
|
|
2530
|
+
return `\u627E\u4E0D\u5230 job "${idOrPrefix}"\uFF0C\u8BF7\u7528 /jobs \u786E\u8BA4 id\u3002`;
|
|
2531
|
+
}
|
|
2532
|
+
await removeJob(deps.dataDir, target.id);
|
|
2533
|
+
deps.onUnschedule(target.id);
|
|
2534
|
+
return `\u5DF2\u53D6\u6D88 goal job [${target.id.slice(0, 8)}]\uFF1A${target.promptTemplate}`;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// src/automation/goal/rollback.ts
|
|
2538
|
+
function buildRollbackMessage(job) {
|
|
2539
|
+
if (job.snapshotLogFile === void 0 || job.snapshotTurn === void 0) {
|
|
2540
|
+
return "";
|
|
2541
|
+
}
|
|
2542
|
+
return `
|
|
2543
|
+
|
|
2544
|
+
\u4E0A\u4E0B\u6587\u53EF\u80FD\u5DF2\u504F\u79BB\u76EE\u6807\uFF0C\u56DE\u6EDA\u5230\u76EE\u6807\u521B\u5EFA\u524D\u7684\u4F4D\u7F6E\uFF1A
|
|
2545
|
+
ecode --fork ${job.snapshotLogFile}:${job.snapshotTurn}`;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// src/automation/index.ts
|
|
2549
|
+
var AutomationManager = class {
|
|
2550
|
+
constructor(config2) {
|
|
2551
|
+
this.config = config2;
|
|
2552
|
+
this.scheduler = new LoopScheduler({
|
|
2553
|
+
onJobRun: async (job) => {
|
|
2554
|
+
const entry = await executeJob(job, {
|
|
2555
|
+
dataDir: config2.dataDir,
|
|
2556
|
+
logDir: config2.logDir,
|
|
2557
|
+
session: this.createSession()
|
|
2558
|
+
});
|
|
2559
|
+
if (entry) {
|
|
2560
|
+
const status = entry.result === "success" ? "\u2713" : "\u2717";
|
|
2561
|
+
const summary = entry.summaryText ?? entry.error ?? "";
|
|
2562
|
+
config2.onJobResult?.(
|
|
2563
|
+
job.id,
|
|
2564
|
+
`[loop ${job.id.slice(0, 8)}] ${status} ${summary}`.trim()
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
},
|
|
2568
|
+
onJobError: (job, error) => {
|
|
2569
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2570
|
+
config2.onJobResult?.(job.id, `[loop ${job.id.slice(0, 8)}] \u6267\u884C\u9519\u8BEF\uFF1A${msg}`);
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
config;
|
|
2575
|
+
scheduler;
|
|
2576
|
+
/** 按 jobId 索引的 goal runner AbortController */
|
|
2577
|
+
goalRunners = /* @__PURE__ */ new Map();
|
|
2578
|
+
/**
|
|
2579
|
+
* 从持久化存储加载所有 job,启动调度器并恢复 goal runner。
|
|
2580
|
+
* 应在应用启动时调用一次。
|
|
2581
|
+
*/
|
|
2582
|
+
async start() {
|
|
2583
|
+
const jobs = await loadJobs(this.config.dataDir);
|
|
2584
|
+
const loopJobs = jobs.filter((j) => j.kind === "loop");
|
|
2585
|
+
const goalJobs = jobs.filter(
|
|
2586
|
+
(j) => j.kind === "goal" && j.state === "scheduled"
|
|
2587
|
+
);
|
|
2588
|
+
this.scheduler.start(loopJobs);
|
|
2589
|
+
for (const job of goalJobs) {
|
|
2590
|
+
this.startGoalRunner(job);
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* 停止所有调度:清除 loop timer,中止所有 goal runner。
|
|
2595
|
+
* 应在应用退出时调用。
|
|
2596
|
+
*/
|
|
2597
|
+
stop() {
|
|
2598
|
+
this.scheduler.stop();
|
|
2599
|
+
for (const [, controller] of this.goalRunners) {
|
|
2600
|
+
controller.abort();
|
|
2601
|
+
}
|
|
2602
|
+
this.goalRunners.clear();
|
|
2603
|
+
}
|
|
2604
|
+
// ──────────────────────────────────────────────────────────────
|
|
2605
|
+
// 命令处理器(转发到 loop/goal 子模块)
|
|
2606
|
+
// ──────────────────────────────────────────────────────────────
|
|
2607
|
+
async cmdLoop(args) {
|
|
2608
|
+
return cmdLoop(args, {
|
|
2609
|
+
dataDir: this.config.dataDir,
|
|
2610
|
+
onSchedule: (job) => this.scheduler.add(job),
|
|
2611
|
+
onUnschedule: (id) => this.scheduler.remove(id)
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
/**
|
|
2615
|
+
* 处理 /goal 命令,可选接收当前会话上下文以支持 blocked 时的自动回滚提示。
|
|
2616
|
+
* sessionContext 由 App.tsx 在用户提交 /goal 时注入(logFile + turnCount)。
|
|
2617
|
+
*/
|
|
2618
|
+
async cmdGoal(args, sessionContext) {
|
|
2619
|
+
return cmdGoal(args, {
|
|
2620
|
+
dataDir: this.config.dataDir,
|
|
2621
|
+
snapshotLogFile: sessionContext?.logFile,
|
|
2622
|
+
snapshotTurn: sessionContext?.turnCount,
|
|
2623
|
+
onSchedule: (job) => this.startGoalRunner(job),
|
|
2624
|
+
onUnschedule: (id) => this.stopGoalRunner(id)
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
async cmdJobs() {
|
|
2628
|
+
return cmdJobs({
|
|
2629
|
+
dataDir: this.config.dataDir,
|
|
2630
|
+
onSchedule: () => {
|
|
2631
|
+
},
|
|
2632
|
+
onUnschedule: () => {
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
async cmdUnloop(id) {
|
|
2637
|
+
return cmdUnloop(id, {
|
|
2638
|
+
dataDir: this.config.dataDir,
|
|
2639
|
+
onSchedule: () => {
|
|
2640
|
+
},
|
|
2641
|
+
onUnschedule: (jobId) => this.scheduler.remove(jobId)
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
async cmdUngoal(id) {
|
|
2645
|
+
return cmdUngoal(id, {
|
|
2646
|
+
dataDir: this.config.dataDir,
|
|
2647
|
+
onSchedule: () => {
|
|
2648
|
+
},
|
|
2649
|
+
onUnschedule: (jobId) => this.stopGoalRunner(jobId)
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
// ──────────────────────────────────────────────────────────────
|
|
2653
|
+
// 私有辅助方法
|
|
2654
|
+
// ──────────────────────────────────────────────────────────────
|
|
2655
|
+
/**
|
|
2656
|
+
* 从 ProviderClient 构建 RuntimeSessionHandle。
|
|
2657
|
+
* 将流式 chunk 拼接为完整字符串返回给调用方。
|
|
2658
|
+
*/
|
|
2659
|
+
createSession() {
|
|
2660
|
+
const { llmClient } = this.config;
|
|
2661
|
+
return {
|
|
2662
|
+
run: async (prompt) => {
|
|
2663
|
+
let text = "";
|
|
2664
|
+
const msgs = [{ role: "user", content: prompt }];
|
|
2665
|
+
for await (const chunk of llmClient.stream(msgs, [], void 0)) {
|
|
2666
|
+
if (chunk.text) text += chunk.text;
|
|
2667
|
+
}
|
|
2668
|
+
return text;
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
/** 启动 goal job 的执行协程(可被 AbortController 中断) */
|
|
2673
|
+
startGoalRunner(job) {
|
|
2674
|
+
this.stopGoalRunner(job.id);
|
|
2675
|
+
const ac = new AbortController();
|
|
2676
|
+
this.goalRunners.set(job.id, ac);
|
|
2677
|
+
void this.runGoalLoop(job, ac.signal);
|
|
2678
|
+
}
|
|
2679
|
+
/** 中止指定 goal runner */
|
|
2680
|
+
stopGoalRunner(jobId) {
|
|
2681
|
+
const ac = this.goalRunners.get(jobId);
|
|
2682
|
+
if (ac) {
|
|
2683
|
+
ac.abort();
|
|
2684
|
+
this.goalRunners.delete(jobId);
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Goal job 执行循环:
|
|
2689
|
+
* 1. 调用 executeJob(LLM worker 执行一轮)
|
|
2690
|
+
* 2. 用 LLM 评估器判断条件是否满足
|
|
2691
|
+
* 3. done → 标记 completed;blocked → 标记 error;not_done → 等待 5s 后继续
|
|
2692
|
+
* 4. AbortSignal 触发时立即退出
|
|
2693
|
+
*/
|
|
2694
|
+
async runGoalLoop(initialJob, signal) {
|
|
2695
|
+
let job = initialJob;
|
|
2696
|
+
while (!signal.aborted) {
|
|
2697
|
+
const runtimeConfig = {
|
|
2698
|
+
dataDir: this.config.dataDir,
|
|
2699
|
+
logDir: this.config.logDir,
|
|
2700
|
+
session: this.createSession()
|
|
2701
|
+
};
|
|
2702
|
+
const entry = await executeJob(job, runtimeConfig);
|
|
2703
|
+
if (!entry) break;
|
|
2704
|
+
if (signal.aborted) break;
|
|
2705
|
+
const evaluator = createLLMEvaluator(this.createSession());
|
|
2706
|
+
const evaluation = await evaluator.evaluate({
|
|
2707
|
+
condition: job.condition,
|
|
2708
|
+
transcript: entry.summaryText ?? entry.error ?? ""
|
|
2709
|
+
});
|
|
2710
|
+
const updatedJob = {
|
|
2711
|
+
...job,
|
|
2712
|
+
lastEvaluation: evaluation,
|
|
2713
|
+
activeTurnCount: job.activeTurnCount + 1,
|
|
2714
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2715
|
+
};
|
|
2716
|
+
if (evaluation.verdict === "done") {
|
|
2717
|
+
const completedJob = {
|
|
2718
|
+
...updatedJob,
|
|
2719
|
+
state: "completed",
|
|
2720
|
+
achievedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2721
|
+
};
|
|
2722
|
+
await upsertJob(this.config.dataDir, completedJob);
|
|
2723
|
+
this.config.onJobResult?.(
|
|
2724
|
+
job.id,
|
|
2725
|
+
`[goal ${job.id.slice(0, 8)}] \u76EE\u6807\u5DF2\u8FBE\u6210\uFF1A${evaluation.reason}`
|
|
2726
|
+
);
|
|
2727
|
+
break;
|
|
2728
|
+
}
|
|
2729
|
+
if (evaluation.verdict === "blocked") {
|
|
2730
|
+
const errorJob = { ...updatedJob, state: "error" };
|
|
2731
|
+
await upsertJob(this.config.dataDir, errorJob);
|
|
2732
|
+
const rollback = buildRollbackMessage(errorJob);
|
|
2733
|
+
this.config.onJobResult?.(
|
|
2734
|
+
job.id,
|
|
2735
|
+
`[goal ${job.id.slice(0, 8)}] \u6267\u884C\u53D7\u963B\uFF1A${evaluation.reason}${rollback}`
|
|
2736
|
+
);
|
|
2737
|
+
break;
|
|
2738
|
+
}
|
|
2739
|
+
await upsertJob(this.config.dataDir, updatedJob);
|
|
2740
|
+
job = updatedJob;
|
|
2741
|
+
this.config.onJobResult?.(
|
|
2742
|
+
job.id,
|
|
2743
|
+
`[goal ${job.id.slice(0, 8)}] \u7B2C ${job.activeTurnCount} \u8F6E\uFF1A\u5C1A\u672A\u5B8C\u6210\uFF0C\u7EE7\u7EED\u6267\u884C\u2026`
|
|
2744
|
+
);
|
|
2745
|
+
await new Promise((resolve5) => {
|
|
2746
|
+
const timer = setTimeout(resolve5, 5e3);
|
|
2747
|
+
signal.addEventListener("abort", () => {
|
|
2748
|
+
clearTimeout(timer);
|
|
2749
|
+
resolve5();
|
|
2750
|
+
}, { once: true });
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
this.goalRunners.delete(job.id);
|
|
2754
|
+
}
|
|
2755
|
+
};
|
|
2756
|
+
|
|
2757
|
+
// src/sessions/metadata.ts
|
|
2758
|
+
import * as crypto from "crypto";
|
|
2759
|
+
import * as fs9 from "fs";
|
|
2760
|
+
import * as path7 from "path";
|
|
2761
|
+
function metadataPathFromLogFile(logFilePath2) {
|
|
2762
|
+
const base = path7.basename(logFilePath2, ".jsonl");
|
|
2763
|
+
const dir = path7.dirname(logFilePath2);
|
|
2764
|
+
return path7.join(dir, `${base}-session.json`);
|
|
2765
|
+
}
|
|
2766
|
+
function createSessionMetadata(logFilePath2, model) {
|
|
2767
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2768
|
+
return {
|
|
2769
|
+
id: crypto.randomUUID(),
|
|
2770
|
+
startTime: now,
|
|
2771
|
+
lastActivity: now,
|
|
2772
|
+
cwd: process.cwd(),
|
|
2773
|
+
model,
|
|
2774
|
+
title: "",
|
|
2775
|
+
turnCount: 0,
|
|
2776
|
+
totalTokens: 0,
|
|
2777
|
+
logFile: path7.basename(logFilePath2)
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
function writeSessionMetadata(logFilePath2, metadata) {
|
|
2781
|
+
const metaPath = metadataPathFromLogFile(logFilePath2);
|
|
2782
|
+
try {
|
|
2783
|
+
fs9.writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + "\n");
|
|
2784
|
+
} catch (err) {
|
|
2785
|
+
process.stderr.write(`[sessions] Failed to write metadata: ${err}
|
|
2786
|
+
`);
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
function readSessionMetadata(metaFilePath) {
|
|
2790
|
+
try {
|
|
2791
|
+
const raw = fs9.readFileSync(metaFilePath, "utf-8");
|
|
2792
|
+
return JSON.parse(raw);
|
|
2793
|
+
} catch {
|
|
2794
|
+
return null;
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
function updateSessionMetadata(logFilePath2, partial) {
|
|
2798
|
+
const metaPath = metadataPathFromLogFile(logFilePath2);
|
|
2799
|
+
let existing = null;
|
|
2800
|
+
try {
|
|
2801
|
+
const raw = fs9.readFileSync(metaPath, "utf-8");
|
|
2802
|
+
existing = JSON.parse(raw);
|
|
2803
|
+
} catch {
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
writeSessionMetadata(logFilePath2, { ...existing, ...partial });
|
|
2807
|
+
}
|
|
2808
|
+
function listSessions(logDir) {
|
|
2809
|
+
try {
|
|
2810
|
+
const files = fs9.readdirSync(logDir);
|
|
2811
|
+
const metaFiles = files.filter((f) => f.endsWith("-session.json"));
|
|
2812
|
+
const sessions = [];
|
|
2813
|
+
for (const file of metaFiles) {
|
|
2814
|
+
const meta = readSessionMetadata(path7.join(logDir, file));
|
|
2815
|
+
if (meta) sessions.push(meta);
|
|
2816
|
+
}
|
|
2817
|
+
return sessions.sort(
|
|
2818
|
+
(a, b) => b.lastActivity.localeCompare(a.lastActivity)
|
|
2819
|
+
);
|
|
2820
|
+
} catch {
|
|
2821
|
+
return [];
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
function findSession(logDir, idOrPrefix) {
|
|
2825
|
+
const sessions = listSessions(logDir);
|
|
2826
|
+
return sessions.find(
|
|
2827
|
+
(s) => s.id === idOrPrefix || s.id.startsWith(idOrPrefix)
|
|
2828
|
+
) ?? null;
|
|
2829
|
+
}
|
|
2830
|
+
function generateTitle(firstUserMessage) {
|
|
2831
|
+
const oneLine = firstUserMessage.replace(/\n+/g, " ").trim();
|
|
2832
|
+
return oneLine.length > 50 ? oneLine.slice(0, 47) + "..." : oneLine;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2129
2835
|
// src/ui/App.tsx
|
|
2130
2836
|
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
2837
|
+
async function handleAutomationCommand(input, manager, sessionContext) {
|
|
2838
|
+
if (!input.startsWith("/")) return null;
|
|
2839
|
+
const spaceIdx = input.indexOf(" ");
|
|
2840
|
+
const command = spaceIdx === -1 ? input : input.slice(0, spaceIdx);
|
|
2841
|
+
const args = spaceIdx === -1 ? "" : input.slice(spaceIdx + 1);
|
|
2842
|
+
switch (command) {
|
|
2843
|
+
case "/loop":
|
|
2844
|
+
return manager.cmdLoop(args);
|
|
2845
|
+
// 传入会话快照:goal blocked 时可生成 ecode --fork 回滚命令
|
|
2846
|
+
case "/goal":
|
|
2847
|
+
return manager.cmdGoal(args, sessionContext);
|
|
2848
|
+
case "/jobs":
|
|
2849
|
+
return manager.cmdJobs();
|
|
2850
|
+
case "/unloop":
|
|
2851
|
+
return manager.cmdUnloop(args.trim());
|
|
2852
|
+
case "/ungoal":
|
|
2853
|
+
return manager.cmdUngoal(args.trim());
|
|
2854
|
+
default:
|
|
2855
|
+
return null;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2131
2858
|
function App({ config: config2, version: version2, autoMode: autoMode2 = false, registry: registry2, trustedSkillDirs: trustedSkillDirs2 = [], initialMessages: initialMessages2 = [], llmClient }) {
|
|
2132
2859
|
const { stdout } = useStdout();
|
|
2133
2860
|
const { stdin } = useStdin();
|
|
@@ -2161,6 +2888,17 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
2161
2888
|
const pendingConfirmRef = useRef2(null);
|
|
2162
2889
|
const abortControllerRef = useRef2(null);
|
|
2163
2890
|
const llmRef = useRef2(llmClient ?? createProvider(resolveActiveProfile(config2)));
|
|
2891
|
+
const automationDataDir = config2.logDir ? join10(config2.logDir, "automation") : join10(homedir2(), ".config", "ecode", "automation");
|
|
2892
|
+
const automationManagerRef = useRef2(
|
|
2893
|
+
new AutomationManager({
|
|
2894
|
+
dataDir: automationDataDir,
|
|
2895
|
+
logDir: automationDataDir,
|
|
2896
|
+
llmClient: llmRef.current,
|
|
2897
|
+
onJobResult: (_jobId, message) => {
|
|
2898
|
+
setMessages((prev) => [...prev, { role: "assistant", content: message }]);
|
|
2899
|
+
}
|
|
2900
|
+
})
|
|
2901
|
+
);
|
|
2164
2902
|
const inputRef = useRef2(null);
|
|
2165
2903
|
const [skillTools, setSkillTools] = useState3([]);
|
|
2166
2904
|
const skillToolsRef = useRef2([]);
|
|
@@ -2170,9 +2908,15 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
2170
2908
|
const [fileSuggestions, setFileSuggestions] = useState3([]);
|
|
2171
2909
|
const loggerRef = useRef2(null);
|
|
2172
2910
|
const loggedCountRef = useRef2(0);
|
|
2911
|
+
const sessionMetaRef = useRef2(null);
|
|
2912
|
+
const finalTokensRef = useRef2(0);
|
|
2173
2913
|
useEffect3(() => {
|
|
2174
2914
|
if (config2.logDir) {
|
|
2175
|
-
|
|
2915
|
+
const now = /* @__PURE__ */ new Date();
|
|
2916
|
+
loggerRef.current = createLogger(config2.logDir, now);
|
|
2917
|
+
const meta = createSessionMetadata(loggerRef.current.filePath, config2.model);
|
|
2918
|
+
writeSessionMetadata(loggerRef.current.filePath, meta);
|
|
2919
|
+
sessionMetaRef.current = meta;
|
|
2176
2920
|
}
|
|
2177
2921
|
}, []);
|
|
2178
2922
|
useEffect3(() => {
|
|
@@ -2192,6 +2936,12 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
2192
2936
|
}
|
|
2193
2937
|
loggedCountRef.current = messages.length;
|
|
2194
2938
|
}, [messages]);
|
|
2939
|
+
useEffect3(() => {
|
|
2940
|
+
void automationManagerRef.current.start();
|
|
2941
|
+
return () => {
|
|
2942
|
+
automationManagerRef.current.stop();
|
|
2943
|
+
};
|
|
2944
|
+
}, []);
|
|
2195
2945
|
useEffect3(() => {
|
|
2196
2946
|
if (!stdin || !stdout) return;
|
|
2197
2947
|
stdout.write("\x1B[?1000h\x1B[?1006h");
|
|
@@ -2389,6 +3139,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
2389
3139
|
estimated: false,
|
|
2390
3140
|
limit: getContextLimit(config2.model, config2.contextLimit)
|
|
2391
3141
|
});
|
|
3142
|
+
finalTokensRef.current = chunk.usage.totalTokens;
|
|
2392
3143
|
}
|
|
2393
3144
|
} else {
|
|
2394
3145
|
const estimatedUsed = Math.floor(
|
|
@@ -2516,6 +3267,16 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
2516
3267
|
}
|
|
2517
3268
|
}
|
|
2518
3269
|
}
|
|
3270
|
+
if (loggerRef.current && sessionMetaRef.current) {
|
|
3271
|
+
const prev = sessionMetaRef.current;
|
|
3272
|
+
const updated = {
|
|
3273
|
+
lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3274
|
+
totalTokens: finalTokensRef.current,
|
|
3275
|
+
turnCount: prev.turnCount + 1
|
|
3276
|
+
};
|
|
3277
|
+
updateSessionMetadata(loggerRef.current.filePath, updated);
|
|
3278
|
+
sessionMetaRef.current = { ...prev, ...updated };
|
|
3279
|
+
}
|
|
2519
3280
|
setStatus("idle");
|
|
2520
3281
|
setToolName(void 0);
|
|
2521
3282
|
abortControllerRef.current = null;
|
|
@@ -2539,6 +3300,25 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
2539
3300
|
setInputHistory((prev) => [trimmed, ...prev.slice(0, 99)]);
|
|
2540
3301
|
historyIndexRef.current = -1;
|
|
2541
3302
|
setScrollOffset(0);
|
|
3303
|
+
{
|
|
3304
|
+
const automationResult = await handleAutomationCommand(
|
|
3305
|
+
trimmed,
|
|
3306
|
+
automationManagerRef.current,
|
|
3307
|
+
// 注入会话快照:/goal blocked 时用于生成 ecode --fork 回滚命令
|
|
3308
|
+
{
|
|
3309
|
+
logFile: loggerRef.current?.filePath,
|
|
3310
|
+
turnCount: sessionMetaRef.current?.turnCount
|
|
3311
|
+
}
|
|
3312
|
+
);
|
|
3313
|
+
if (automationResult !== null) {
|
|
3314
|
+
setMessages((prev) => [
|
|
3315
|
+
...prev,
|
|
3316
|
+
{ role: "user", content: trimmed },
|
|
3317
|
+
{ role: "assistant", content: automationResult }
|
|
3318
|
+
]);
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
2542
3322
|
if (registry2) {
|
|
2543
3323
|
const skillResult = handleSkillInput(trimmed, registry2);
|
|
2544
3324
|
if (skillResult.type === "error") {
|
|
@@ -2582,6 +3362,11 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
2582
3362
|
const userMsg = { role: "user", content };
|
|
2583
3363
|
const nextMessages = [...messages, userMsg];
|
|
2584
3364
|
setMessages(nextMessages);
|
|
3365
|
+
if (loggerRef.current && sessionMetaRef.current && !sessionMetaRef.current.title) {
|
|
3366
|
+
const title = generateTitle(content);
|
|
3367
|
+
updateSessionMetadata(loggerRef.current.filePath, { title });
|
|
3368
|
+
sessionMetaRef.current = { ...sessionMetaRef.current, title };
|
|
3369
|
+
}
|
|
2585
3370
|
runLlmLoop(nextMessages).catch((err) => {
|
|
2586
3371
|
setStatus("idle");
|
|
2587
3372
|
setToolName(void 0);
|
|
@@ -2766,6 +3551,53 @@ function readStdin() {
|
|
|
2766
3551
|
});
|
|
2767
3552
|
}
|
|
2768
3553
|
|
|
3554
|
+
// src/sessions/command.ts
|
|
3555
|
+
import * as path8 from "path";
|
|
3556
|
+
function cmdSessionsList(logDir) {
|
|
3557
|
+
const sessions = listSessions(logDir);
|
|
3558
|
+
if (sessions.length === 0) return "(no sessions found)";
|
|
3559
|
+
const lines = sessions.map((s) => {
|
|
3560
|
+
const id = s.id.slice(0, 8);
|
|
3561
|
+
const date = new Date(s.lastActivity).toLocaleString();
|
|
3562
|
+
const title = s.title || "(no title)";
|
|
3563
|
+
const tokens = s.totalTokens.toLocaleString();
|
|
3564
|
+
return `${id} ${date} ${tokens}t ${title}`;
|
|
3565
|
+
});
|
|
3566
|
+
return `Sessions (${sessions.length}):
|
|
3567
|
+
` + lines.join("\n");
|
|
3568
|
+
}
|
|
3569
|
+
function cmdSessionsInspect(logDir, idOrPrefix) {
|
|
3570
|
+
const session = findSession(logDir, idOrPrefix);
|
|
3571
|
+
if (!session) return `Session not found: ${idOrPrefix}`;
|
|
3572
|
+
return formatInspect(session, logDir);
|
|
3573
|
+
}
|
|
3574
|
+
function cmdSessionsReplay(logDir, idOrPrefix) {
|
|
3575
|
+
const session = findSession(logDir, idOrPrefix);
|
|
3576
|
+
if (!session) return `Session not found: ${idOrPrefix}`;
|
|
3577
|
+
const logPath = path8.join(logDir, session.logFile);
|
|
3578
|
+
return `ecode --replay ${logPath}`;
|
|
3579
|
+
}
|
|
3580
|
+
function cmdSessionsFork(logDir, idOrPrefix, turn) {
|
|
3581
|
+
const session = findSession(logDir, idOrPrefix);
|
|
3582
|
+
if (!session) return `Session not found: ${idOrPrefix}`;
|
|
3583
|
+
const logPath = path8.join(logDir, session.logFile);
|
|
3584
|
+
const turnStr = turn !== void 0 ? turn : session.turnCount;
|
|
3585
|
+
return `ecode --fork ${logPath}:${turnStr}`;
|
|
3586
|
+
}
|
|
3587
|
+
function formatInspect(session, logDir) {
|
|
3588
|
+
return [
|
|
3589
|
+
`id: ${session.id}`,
|
|
3590
|
+
`title: ${session.title || "(no title)"}`,
|
|
3591
|
+
`model: ${session.model}`,
|
|
3592
|
+
`cwd: ${session.cwd}`,
|
|
3593
|
+
`started: ${new Date(session.startTime).toLocaleString()}`,
|
|
3594
|
+
`last active: ${new Date(session.lastActivity).toLocaleString()}`,
|
|
3595
|
+
`turns: ${session.turnCount}`,
|
|
3596
|
+
`tokens: ${session.totalTokens.toLocaleString()}`,
|
|
3597
|
+
`log file: ${path8.join(logDir, session.logFile)}`
|
|
3598
|
+
].join("\n");
|
|
3599
|
+
}
|
|
3600
|
+
|
|
2769
3601
|
// src/index.ts
|
|
2770
3602
|
var require2 = createRequire(import.meta.url);
|
|
2771
3603
|
var { version } = require2("../package.json");
|
|
@@ -2819,6 +3651,46 @@ for (let i = 0; i < rawArgs.length; i++) {
|
|
|
2819
3651
|
}
|
|
2820
3652
|
var config = loadConfig();
|
|
2821
3653
|
var finalConfig = cliLogDir ? { ...config, logDir: cliLogDir } : config;
|
|
3654
|
+
if (rawArgs[0] === "sessions") {
|
|
3655
|
+
const sub = rawArgs[1];
|
|
3656
|
+
const logDir = finalConfig.logDir;
|
|
3657
|
+
if (!logDir) {
|
|
3658
|
+
console.error(
|
|
3659
|
+
"Error: no log directory configured.\nSet ECODE_LOG_DIR or logDir in ~/.ecode/config.json"
|
|
3660
|
+
);
|
|
3661
|
+
process.exit(1);
|
|
3662
|
+
}
|
|
3663
|
+
let output;
|
|
3664
|
+
if (sub === "list") {
|
|
3665
|
+
output = cmdSessionsList(logDir);
|
|
3666
|
+
} else if (sub === "inspect") {
|
|
3667
|
+
const id = rawArgs[2];
|
|
3668
|
+
if (!id) {
|
|
3669
|
+
console.error("Usage: ecode sessions inspect <id>");
|
|
3670
|
+
process.exit(1);
|
|
3671
|
+
}
|
|
3672
|
+
output = cmdSessionsInspect(logDir, id);
|
|
3673
|
+
} else if (sub === "replay") {
|
|
3674
|
+
const id = rawArgs[2];
|
|
3675
|
+
if (!id) {
|
|
3676
|
+
console.error("Usage: ecode sessions replay <id>");
|
|
3677
|
+
process.exit(1);
|
|
3678
|
+
}
|
|
3679
|
+
output = cmdSessionsReplay(logDir, id);
|
|
3680
|
+
} else if (sub === "fork") {
|
|
3681
|
+
const id = rawArgs[2];
|
|
3682
|
+
if (!id) {
|
|
3683
|
+
console.error("Usage: ecode sessions fork <id> [turn]");
|
|
3684
|
+
process.exit(1);
|
|
3685
|
+
}
|
|
3686
|
+
const turnArg = rawArgs[3] ? parseInt(rawArgs[3], 10) : void 0;
|
|
3687
|
+
output = cmdSessionsFork(logDir, id, turnArg);
|
|
3688
|
+
} else {
|
|
3689
|
+
output = "Usage: ecode sessions <list|inspect|replay|fork> [args...]\n\n list \u5217\u51FA\u6240\u6709\u5386\u53F2\u4F1A\u8BDD\n inspect <id> \u663E\u793A\u4F1A\u8BDD\u8BE6\u60C5\n replay <id> \u8F93\u51FA\u7528\u4E8E\u56DE\u653E\u7684\u547D\u4EE4\n fork <id> [turn] \u8F93\u51FA\u7528\u4E8E\u5206\u53C9\u5230\u6307\u5B9A\u8F6E\u6B21\u7684\u547D\u4EE4";
|
|
3690
|
+
}
|
|
3691
|
+
console.log(output);
|
|
3692
|
+
process.exit(0);
|
|
3693
|
+
}
|
|
2822
3694
|
if (!finalConfig.apiKey) {
|
|
2823
3695
|
console.error(
|
|
2824
3696
|
"Error: no API key configured.\nSet ECODE_API_KEY or add apiKey to ~/.ecode/config.json"
|
|
@@ -2828,7 +3700,7 @@ if (!finalConfig.apiKey) {
|
|
|
2828
3700
|
var initialMessages = [];
|
|
2829
3701
|
if (replayFile) {
|
|
2830
3702
|
try {
|
|
2831
|
-
const raw =
|
|
3703
|
+
const raw = readFileSync3(replayFile, "utf-8");
|
|
2832
3704
|
initialMessages = parseReplayLog(raw);
|
|
2833
3705
|
} catch (err) {
|
|
2834
3706
|
console.error(`Error reading replay file: ${err}`);
|
|
@@ -2836,14 +3708,14 @@ if (replayFile) {
|
|
|
2836
3708
|
}
|
|
2837
3709
|
} else if (forkSpec) {
|
|
2838
3710
|
try {
|
|
2839
|
-
const raw =
|
|
3711
|
+
const raw = readFileSync3(forkSpec.file, "utf-8");
|
|
2840
3712
|
initialMessages = truncateToTurn(parseReplayLog(raw), forkSpec.turn);
|
|
2841
3713
|
} catch (err) {
|
|
2842
3714
|
console.error(`Error reading fork file: ${err}`);
|
|
2843
3715
|
process.exit(1);
|
|
2844
3716
|
}
|
|
2845
3717
|
}
|
|
2846
|
-
var __dirname =
|
|
3718
|
+
var __dirname = dirname7(fileURLToPath(import.meta.url));
|
|
2847
3719
|
var builtinSkillsDir = resolve4(__dirname, "../skills");
|
|
2848
3720
|
var userSkillsDir = resolve4(process.env.HOME ?? "~", ".ecode/skills");
|
|
2849
3721
|
var projectSkillsDir = resolve4(process.cwd(), ".ecode/skills");
|