@vincemakes/kiso-code 0.1.20 → 0.1.22
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/dist/chat.d.ts +49 -0
- package/dist/chat.js +483 -0
- package/dist/dispatch.d.ts +30 -0
- package/dist/dispatch.js +138 -0
- package/dist/faux-glue.d.ts +44 -0
- package/dist/faux-glue.js +115 -0
- package/dist/index.d.ts +8 -8
- package/dist/index.js +31 -985
- package/dist/resume.d.ts +14 -0
- package/dist/resume.js +104 -0
- package/dist/state.d.ts +71 -0
- package/dist/state.js +78 -0
- package/dist/trust-ui.d.ts +50 -0
- package/dist/trust-ui.js +218 -0
- package/package.json +8 -8
package/dist/chat.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 (pure move) — the interactive REPL (chat), the run consumer
|
|
3
|
+
* (consumeRun — the single renderer of a run's event stream), the
|
|
4
|
+
* approval-moment mini-diff, the status spinner, and the context
|
|
5
|
+
* estimates. All bodies moved verbatim from index.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { type RunUsage } from "@vincemakes/kiso-tui";
|
|
8
|
+
import type { AgentSession } from "@vincemakes/kiso-runtime";
|
|
9
|
+
import { type LineInput } from "./state.js";
|
|
10
|
+
/**
|
|
11
|
+
* 手感批 C8 — the /compact auto-trigger, OPT-IN (default off: only an
|
|
12
|
+
* explicit KISO_AUTO_COMPACT=<ratio> enables it — the CLI never defaults
|
|
13
|
+
* it on). After every completed turn the ~ctx ratio is checked; at/over
|
|
14
|
+
* thresholdRatio the /compact full path runs (the same dispatch — same
|
|
15
|
+
* notices, same chain ordering, same mid-run refusal).
|
|
16
|
+
*/
|
|
17
|
+
export interface AutoCompact {
|
|
18
|
+
/** 0 < r < 1 — the ~ctx ratio that triggers the compaction. */
|
|
19
|
+
readonly thresholdRatio: number;
|
|
20
|
+
}
|
|
21
|
+
/** Parse KISO_AUTO_COMPACT — an invalid value is OFF, never a crash. */
|
|
22
|
+
export declare function autoCompactFromEnv(): AutoCompact | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* C 区: the model window in tokens — KISO_CONTEXT_WINDOW overrides the
|
|
25
|
+
* 200k default. The microcompact threshold is derived from it (50%), and
|
|
26
|
+
* the status line's ~ctx estimate is measured against it — one source of
|
|
27
|
+
* truth for the window.
|
|
28
|
+
*/
|
|
29
|
+
export declare function contextWindowTokens(): number;
|
|
30
|
+
/**
|
|
31
|
+
* B 区: approximate context ratio — chars/4 of the projected messages vs
|
|
32
|
+
* the model window. Marked ~ everywhere it is shown; no counting API.
|
|
33
|
+
*/
|
|
34
|
+
export declare function estimateCtxRatio(session: AgentSession): number;
|
|
35
|
+
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
36
|
+
* is gone) — docked only, 200ms rotation between the request and the
|
|
37
|
+
* first event. */
|
|
38
|
+
export declare function startStatusSpinner(onTick: (glyph: string) => void): () => void;
|
|
39
|
+
/**
|
|
40
|
+
* Consume a run, answering approval pauses as they arrive. `resumeMode`
|
|
41
|
+
* marks a session.resume() continuation. v2a: `faux` picks the status
|
|
42
|
+
* line's form; `liveInput` (non-null only in interactive chat) carries the
|
|
43
|
+
* last line THIS process's readline consumed — the double-echo filter.
|
|
44
|
+
*/
|
|
45
|
+
export declare function consumeRun(session: AgentSession, run: AsyncIterable<import("@vincemakes/kiso-core").Event>, input: LineInput, turnNo: number, faux: boolean, liveInput: {
|
|
46
|
+
current: string | null;
|
|
47
|
+
} | null, statusCb: ((usage: RunUsage, ctxRatio: number) => void) | null): Promise<import("@vincemakes/kiso-core").Event | undefined>;
|
|
48
|
+
/** Interactive REPL: stream events, pause for approvals, Ctrl+C aborts. */
|
|
49
|
+
export declare function chat(session: AgentSession, faux: boolean, input: LineInput, autoCompact?: AutoCompact): Promise<void>;
|
package/dist/chat.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 (pure move) — the interactive REPL (chat), the run consumer
|
|
3
|
+
* (consumeRun — the single renderer of a run's event stream), the
|
|
4
|
+
* approval-moment mini-diff, the status spinner, and the context
|
|
5
|
+
* estimates. All bodies moved verbatim from index.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { escapeTerminal, kUnit, palette, renderEvent, renderRecap } from "@vincemakes/kiso-tui";
|
|
9
|
+
import { editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
|
|
10
|
+
import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
|
|
11
|
+
import { dispatch } from "./dispatch.js";
|
|
12
|
+
import { CANCELLED, agentModel, body, bodyLog, dock } from "./state.js";
|
|
13
|
+
import { ask, pendingAsk, resolveUncertains } from "./trust-ui.js";
|
|
14
|
+
import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
|
|
15
|
+
import { MODES, getMode, setMode } from "./mode.js";
|
|
16
|
+
/** B 区: default context window for the ~ctx estimate (config overridable). */
|
|
17
|
+
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
18
|
+
/** Parse KISO_AUTO_COMPACT — an invalid value is OFF, never a crash. */
|
|
19
|
+
export function autoCompactFromEnv() {
|
|
20
|
+
const raw = process.env.KISO_AUTO_COMPACT;
|
|
21
|
+
if (raw === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
const ratio = Number.parseFloat(raw);
|
|
24
|
+
if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1)
|
|
25
|
+
return undefined;
|
|
26
|
+
return { thresholdRatio: ratio };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* C 区: the model window in tokens — KISO_CONTEXT_WINDOW overrides the
|
|
30
|
+
* 200k default. The microcompact threshold is derived from it (50%), and
|
|
31
|
+
* the status line's ~ctx estimate is measured against it — one source of
|
|
32
|
+
* truth for the window.
|
|
33
|
+
*/
|
|
34
|
+
export function contextWindowTokens() {
|
|
35
|
+
const window = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
|
|
36
|
+
return Number.isFinite(window) && window > 0 ? window : DEFAULT_CONTEXT_WINDOW;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* B 区: approximate context ratio — chars/4 of the projected messages vs
|
|
40
|
+
* the model window. Marked ~ everywhere it is shown; no counting API.
|
|
41
|
+
*/
|
|
42
|
+
export function estimateCtxRatio(session) {
|
|
43
|
+
const projected = session.projected();
|
|
44
|
+
const chars = JSON.stringify(projected).length;
|
|
45
|
+
return chars / 4 / contextWindowTokens();
|
|
46
|
+
}
|
|
47
|
+
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
48
|
+
* is gone) — docked only, 200ms rotation between the request and the
|
|
49
|
+
* first event. */
|
|
50
|
+
export function startStatusSpinner(onTick) {
|
|
51
|
+
if (!dock.active)
|
|
52
|
+
return () => { };
|
|
53
|
+
// v3 §03/§05: the working glyph family ▖▘▝▗, 200ms rotation — the
|
|
54
|
+
// callback repaints the running status line with the new glyph.
|
|
55
|
+
const GLYPHS = ["▖", "▘", "▝", "▗"];
|
|
56
|
+
let i = 0;
|
|
57
|
+
const timer = setInterval(() => onTick(GLYPHS[i++ % GLYPHS.length]), 200);
|
|
58
|
+
timer.unref();
|
|
59
|
+
return () => clearInterval(timer);
|
|
60
|
+
}
|
|
61
|
+
/** v2e: the approval-moment mini-diff — edit_file/write_file changes as
|
|
62
|
+
* ± lines; other tools get null (no diff, no cost). The file read is
|
|
63
|
+
* best-effort: an unreadable file yields NO diff, never a failure —
|
|
64
|
+
* the diff must never break the approval. */
|
|
65
|
+
function approvalDiff(name, input) {
|
|
66
|
+
if (name !== "edit_file" && name !== "write_file")
|
|
67
|
+
return null;
|
|
68
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
69
|
+
if (path === "")
|
|
70
|
+
return null;
|
|
71
|
+
let oldContent = null;
|
|
72
|
+
try {
|
|
73
|
+
oldContent = readFileSync(path, "utf8");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// a new write_file target (or an unreadable one) — all + degrades
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
if (name === "edit_file") {
|
|
80
|
+
const search = typeof input.search === "string" ? input.search : "";
|
|
81
|
+
const replace = typeof input.replace === "string" ? input.replace : "";
|
|
82
|
+
if (search === "")
|
|
83
|
+
return null;
|
|
84
|
+
return editFileDiff(oldContent ?? "", search, replace);
|
|
85
|
+
}
|
|
86
|
+
const content = typeof input.content === "string" ? input.content : "";
|
|
87
|
+
return writeFileDiff(oldContent, content);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null; // never let the diff break the approval
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 手感批 C5 — the translation layer: the tui renders its OWN data shape
|
|
95
|
+
* (RenderInput, zero kiso-core imports); the CLI translates its Event
|
|
96
|
+
* stream here. Events without a render (stop, expired, resolved, …) → null,
|
|
97
|
+
* and the consumer skips them — the pipe bytes stay identical.
|
|
98
|
+
*/
|
|
99
|
+
function toRenderInput(ev) {
|
|
100
|
+
switch (ev.type) {
|
|
101
|
+
case "user_input":
|
|
102
|
+
return { type: "user_input", content: ev.content };
|
|
103
|
+
case "text_delta":
|
|
104
|
+
return { type: "text_delta", text: ev.text };
|
|
105
|
+
case "text_end":
|
|
106
|
+
return { type: "text_end" };
|
|
107
|
+
case "thinking":
|
|
108
|
+
return { type: "thinking", text: ev.text };
|
|
109
|
+
case "tool_call_end":
|
|
110
|
+
return { type: "tool_call_end", name: ev.name, input: ev.input };
|
|
111
|
+
case "tool_execution_started":
|
|
112
|
+
return { type: "tool_execution_started" };
|
|
113
|
+
case "tool_execution_succeeded":
|
|
114
|
+
return { type: "tool_execution_succeeded" };
|
|
115
|
+
case "tool_execution_failed":
|
|
116
|
+
return { type: "tool_execution_failed", error: ev.error };
|
|
117
|
+
case "tool_result":
|
|
118
|
+
return { type: "tool_result", content: ev.content, isError: ev.isError };
|
|
119
|
+
case "permission_requested":
|
|
120
|
+
return { type: "permission_requested", name: ev.name, input: ev.input };
|
|
121
|
+
case "permission_decided":
|
|
122
|
+
return { type: "permission_decided", decision: ev.decision, ...(ev.reason !== undefined ? { reason: ev.reason } : {}) };
|
|
123
|
+
case "terminal":
|
|
124
|
+
return { type: "terminal", outcome: ev.outcome };
|
|
125
|
+
case "compacted":
|
|
126
|
+
return { type: "compacted", cleared: ev.cleared };
|
|
127
|
+
case "summarized":
|
|
128
|
+
return { type: "summarized", coversToSeq: ev.coversToSeq };
|
|
129
|
+
case "uncertain_pending":
|
|
130
|
+
return { type: "uncertain_pending", name: ev.name, executionId: ev.executionId, error: ev.error };
|
|
131
|
+
default:
|
|
132
|
+
return null; // events without a render (stop, expired, resolved, …)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Consume a run, answering approval pauses as they arrive. `resumeMode`
|
|
137
|
+
* marks a session.resume() continuation. v2a: `faux` picks the status
|
|
138
|
+
* line's form; `liveInput` (non-null only in interactive chat) carries the
|
|
139
|
+
* last line THIS process's readline consumed — the double-echo filter.
|
|
140
|
+
*/
|
|
141
|
+
export async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
|
|
142
|
+
let last;
|
|
143
|
+
let usage = { in: null, out: null, cache: null, known: false };
|
|
144
|
+
// v3 §02: the recap line derives ENTIRELY from the local event stream
|
|
145
|
+
// (zero tokens) — wall seconds, tool/edit counts, usage, ctx left.
|
|
146
|
+
const turnStart = Date.now();
|
|
147
|
+
let toolCount = 0;
|
|
148
|
+
let editCount = 0;
|
|
149
|
+
try {
|
|
150
|
+
for await (const ev of run) {
|
|
151
|
+
last = ev;
|
|
152
|
+
// v2a (双回显): the interactive echo was already rendered by the
|
|
153
|
+
// input source — rendering the event again is the double echo.
|
|
154
|
+
// v2b: DOCKED — the echo lives in the input row (H), NOT the body;
|
|
155
|
+
// the body render is the ONLY visible copy of the sent line.
|
|
156
|
+
if (ev.type === "user_input" &&
|
|
157
|
+
liveInput !== null &&
|
|
158
|
+
liveInput.current === (typeof ev.content === "string" ? ev.content : "") &&
|
|
159
|
+
process.stdin.isTTY &&
|
|
160
|
+
!dock.active) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// v2d: EVERY event only mutates a cell — the Body is the single
|
|
164
|
+
// writer of the scroll region, so interleaving is impossible by
|
|
165
|
+
// construction (ADR-0040).
|
|
166
|
+
switch (ev.type) {
|
|
167
|
+
case "user_input":
|
|
168
|
+
body.userLine(typeof ev.content === "string" ? ev.content : "");
|
|
169
|
+
break;
|
|
170
|
+
case "thinking":
|
|
171
|
+
body.thinkingAppend(ev.text);
|
|
172
|
+
break;
|
|
173
|
+
case "tool_call_end":
|
|
174
|
+
toolCount += 1;
|
|
175
|
+
if (ev.name === "edit_file")
|
|
176
|
+
editCount += 1;
|
|
177
|
+
body.toolStart(ev.name, ev.callId, ev.input ?? {});
|
|
178
|
+
break;
|
|
179
|
+
case "tool_execution_started":
|
|
180
|
+
body.toolRunning(ev.callId);
|
|
181
|
+
break;
|
|
182
|
+
case "tool_execution_succeeded":
|
|
183
|
+
body.toolSucceeded(ev.callId);
|
|
184
|
+
break;
|
|
185
|
+
case "tool_execution_failed":
|
|
186
|
+
body.toolFailed(ev.callId, ev.error);
|
|
187
|
+
break;
|
|
188
|
+
case "tool_result": {
|
|
189
|
+
const text = typeof ev.content === "string" ? ev.content : "";
|
|
190
|
+
body.toolResult(ev.callId, { content: text, isError: ev.isError });
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "text_delta":
|
|
194
|
+
body.textAppend(ev.text);
|
|
195
|
+
break;
|
|
196
|
+
case "text_end":
|
|
197
|
+
body.textEnd();
|
|
198
|
+
break;
|
|
199
|
+
case "usage":
|
|
200
|
+
usage = { in: ev.inputTokens, out: ev.outputTokens, cache: ev.cacheRead, known: ev.known };
|
|
201
|
+
statusCb?.(usage, estimateCtxRatio(session));
|
|
202
|
+
break;
|
|
203
|
+
case "uncertain_pending":
|
|
204
|
+
// 裁决 #12 (ADR-0038): the ⚠ line is pure INFORMATION now — the
|
|
205
|
+
// approval chain guards retries, and the human question belongs
|
|
206
|
+
// only to the crash window's recovery flow (resolveUncertains).
|
|
207
|
+
body.notice(`⚠ ${escapeTerminal(ev.name)} FAILED — the side effect may have applied. ${escapeTerminal(ev.error)}`);
|
|
208
|
+
break;
|
|
209
|
+
case "permission_requested": {
|
|
210
|
+
// v2d: the ToolCell shows the ⏸ badge; the question takes over
|
|
211
|
+
// the dock status position; the answer lands at the input line.
|
|
212
|
+
// v2e: the mini-diff for edit/write at the approval moment —
|
|
213
|
+
// the human sees the change BEFORE deciding (auto-allowed tools
|
|
214
|
+
// skip the diff: nobody is looking).
|
|
215
|
+
const name = ev.name;
|
|
216
|
+
body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
|
|
217
|
+
const decisionId = ev.decisionId;
|
|
218
|
+
const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
|
|
219
|
+
if (answer === CANCELLED) {
|
|
220
|
+
// 十: a cancellation is a CONSERVATIVE denial, explicitly
|
|
221
|
+
// distinguished from the user typing "n".
|
|
222
|
+
body.notice("[approval cancelled — treated as a denial]");
|
|
223
|
+
await session.approve(decisionId, false);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case "terminal": {
|
|
230
|
+
// v3 §02: the run's recap line REPLACES the old "done" label
|
|
231
|
+
// + status line — one local line, derived from this run's
|
|
232
|
+
// events (zero tokens). The dock's status bar still paints.
|
|
233
|
+
statusCb?.(usage, estimateCtxRatio(session));
|
|
234
|
+
const ratio = estimateCtxRatio(session);
|
|
235
|
+
bodyLog(renderRecap({
|
|
236
|
+
seconds: Math.round((Date.now() - turnStart) / 1000),
|
|
237
|
+
tools: toolCount,
|
|
238
|
+
edits: editCount,
|
|
239
|
+
usage,
|
|
240
|
+
ctxLeftPct: Number.isFinite(ratio) ? (1 - ratio) * 100 : null,
|
|
241
|
+
}));
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
default: {
|
|
245
|
+
// Events without a cell (stop, …) — the generic render, byte-
|
|
246
|
+
// preserved for the pipe path (C5: Event → RenderInput first).
|
|
247
|
+
const input = toRenderInput(ev);
|
|
248
|
+
if (input === null)
|
|
249
|
+
break;
|
|
250
|
+
const rendered = renderEvent(input, false, canonicalTargetPath);
|
|
251
|
+
if (rendered.text !== "") {
|
|
252
|
+
body.raw(rendered.text.replace(/\n$/, "").split("\n"));
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
body.thinkingEnd(); // a trailing thinking block folds at the run's end
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
}
|
|
262
|
+
return last;
|
|
263
|
+
}
|
|
264
|
+
/** Interactive REPL: stream events, pause for approvals, Ctrl+C aborts. */
|
|
265
|
+
export async function chat(session, faux, input, autoCompact) {
|
|
266
|
+
let currentRun = null;
|
|
267
|
+
let cancelled = false;
|
|
268
|
+
const turn = (text) => new Promise((resolve, reject) => {
|
|
269
|
+
queued = Math.max(0, queued - 1); // a queued turn starts
|
|
270
|
+
// v2a: the echo filter compares the user_input event against THIS
|
|
271
|
+
// turn's own input — lines that arrive ahead of their turn (piped
|
|
272
|
+
// bursts, queued replays) must not overwrite the reference.
|
|
273
|
+
liveInput.current = text;
|
|
274
|
+
const run = session.run(text);
|
|
275
|
+
currentRun = run;
|
|
276
|
+
turnNo += 1;
|
|
277
|
+
const myTurn = turnNo;
|
|
278
|
+
// v3 §03: the running state owns the status bar — the glyph
|
|
279
|
+
// rotates every 200ms; the idle state returns after the run.
|
|
280
|
+
runStart = Date.now();
|
|
281
|
+
runUsage = { in: null, out: null, cache: null, known: false };
|
|
282
|
+
const stopSpinner = startStatusSpinner((g) => {
|
|
283
|
+
runGlyph = g;
|
|
284
|
+
paintRunning();
|
|
285
|
+
});
|
|
286
|
+
(async () => {
|
|
287
|
+
let last;
|
|
288
|
+
try {
|
|
289
|
+
last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
|
|
290
|
+
stopSpinner();
|
|
291
|
+
paintIdle();
|
|
292
|
+
currentRun = null;
|
|
293
|
+
// 八: a faux script that ran out of declared turns exits
|
|
294
|
+
// loudly with a non-zero status — never a silent status 0.
|
|
295
|
+
// 第四轮(对抗): the exhaustion is a CONTROLLED rejection of
|
|
296
|
+
// this turn's promise — it propagates through the chain to
|
|
297
|
+
// chat to main's finally/catch, never an orphaned
|
|
298
|
+
// unhandled rejection from the IIFE.
|
|
299
|
+
failOnFauxExhaustion(last, faux, input);
|
|
300
|
+
// 八: after EVERY turn the prompt is re-armed — the human
|
|
301
|
+
// never types blind after the first turn.
|
|
302
|
+
input.prompt();
|
|
303
|
+
// 手感批 C8: the opt-in auto-compact — checked AFTER the
|
|
304
|
+
// turn ended (the run's terminal is in the log, the ratio
|
|
305
|
+
// is post-run).
|
|
306
|
+
await maybeAutoCompact();
|
|
307
|
+
resolve();
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
// A run failure must not freeze the REPL (review finding
|
|
311
|
+
// 11): surface it and re-arm the prompt.
|
|
312
|
+
if (err instanceof FauxExhaustionError) {
|
|
313
|
+
currentRun = null;
|
|
314
|
+
reject(err);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
console.error(`\n[run failed] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
318
|
+
currentRun = null;
|
|
319
|
+
input.prompt();
|
|
320
|
+
resolve();
|
|
321
|
+
}
|
|
322
|
+
})();
|
|
323
|
+
});
|
|
324
|
+
input.onSigint(() => {
|
|
325
|
+
if (currentRun) {
|
|
326
|
+
// 八: Ctrl+C cancels BOTH the pending question (if one is
|
|
327
|
+
// awaiting a line) and the run — the run then writes its unique
|
|
328
|
+
// aborted terminal, which the consumer keeps consuming.
|
|
329
|
+
console.log("\n[aborting run]");
|
|
330
|
+
pendingAsk?.();
|
|
331
|
+
currentRun.abort();
|
|
332
|
+
}
|
|
333
|
+
else if (pendingAsk !== null) {
|
|
334
|
+
pendingAsk?.(); // a startup/trust question — cancel it
|
|
335
|
+
}
|
|
336
|
+
else if (input.line() === "") {
|
|
337
|
+
cancelled = true;
|
|
338
|
+
console.log("\n[exit requested]");
|
|
339
|
+
input.close();
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
input.clearLine(); // v2c: Ctrl+C on a non-empty line clears it
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
input.onEot(() => {
|
|
346
|
+
if (!currentRun && pendingAsk === null && input.line() === "") {
|
|
347
|
+
cancelled = true;
|
|
348
|
+
console.log("\n[exit requested]");
|
|
349
|
+
input.close();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
input.onEscape(() => {
|
|
353
|
+
if (currentRun) {
|
|
354
|
+
console.log("\n[aborting run]");
|
|
355
|
+
pendingAsk?.();
|
|
356
|
+
currentRun.abort();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
// 第五轮(P1-11): the PERSISTENT line listener is installed BEFORE the
|
|
360
|
+
// startup recovery — a cancelled question's re-emitted "line" needs a
|
|
361
|
+
// listener from the very first instant, or the input is silently lost.
|
|
362
|
+
// Turns are SERIALIZED on a chain — piped lines arrive faster than
|
|
363
|
+
// turns complete, and concurrent runs are forbidden. Lines that arrive
|
|
364
|
+
// while the recovery is still running are QUEUED and replayed once the
|
|
365
|
+
// REPL is ready (they are never dropped).
|
|
366
|
+
const chainRef = { current: Promise.resolve() };
|
|
367
|
+
let replReady = false;
|
|
368
|
+
const queuedLines = [];
|
|
369
|
+
// B 区: user-turn counter for the status line. /last and /think read
|
|
370
|
+
// the body (the ToolCell / ThinkingCell final states).
|
|
371
|
+
let turnNo = 0;
|
|
372
|
+
// v2a: the last line THIS process's readline consumed — the double-echo
|
|
373
|
+
// filter (see consumeRun). Only interactive chat sets it.
|
|
374
|
+
const liveInput = { current: null };
|
|
375
|
+
// v2c: turns submitted while another runs are QUEUED on the chain — the
|
|
376
|
+
// live count rides the status bar (+N queued).
|
|
377
|
+
let queued = 0;
|
|
378
|
+
// v2b: the live status bar (docked only). Modes: /mode switches repaint
|
|
379
|
+
// it immediately through paintStatus (the last turn stats are kept).
|
|
380
|
+
// v3 §03: the status bar has TWO states. Idle: the mode is ALWAYS
|
|
381
|
+
// shown (default included) with the /mode hint. Running: the working
|
|
382
|
+
// glyph (▖▘▝▗ — the spinner drives it) + wall seconds + ↓ out tokens
|
|
383
|
+
// + the interrupt hint. ctx left is the live estimate everywhere.
|
|
384
|
+
let runUsage = { in: null, out: null, cache: null, known: false };
|
|
385
|
+
let runGlyph = "▖";
|
|
386
|
+
let runStart = Date.now();
|
|
387
|
+
const paintRunning = () => {
|
|
388
|
+
if (!dock.active)
|
|
389
|
+
return;
|
|
390
|
+
const ratio = estimateCtxRatio(session);
|
|
391
|
+
const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
|
|
392
|
+
const out = runUsage.out !== null ? ` ↓ ${kUnit(runUsage.out)} tokens` : "";
|
|
393
|
+
dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
|
|
394
|
+
};
|
|
395
|
+
const paintIdle = () => {
|
|
396
|
+
if (!dock.active)
|
|
397
|
+
return;
|
|
398
|
+
const ratio = estimateCtxRatio(session);
|
|
399
|
+
const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
|
|
400
|
+
dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
|
|
401
|
+
};
|
|
402
|
+
const statusCb = (u, ctx) => {
|
|
403
|
+
runUsage = u;
|
|
404
|
+
paintRunning();
|
|
405
|
+
};
|
|
406
|
+
const submitTurn = (line) => {
|
|
407
|
+
queued += 1;
|
|
408
|
+
chainRef.current = chainRef.current.then(() => turn(line));
|
|
409
|
+
};
|
|
410
|
+
const dispatchCtx = {
|
|
411
|
+
session,
|
|
412
|
+
input,
|
|
413
|
+
chainRef,
|
|
414
|
+
isRunning: () => currentRun !== null,
|
|
415
|
+
paintIdle,
|
|
416
|
+
submitTurn,
|
|
417
|
+
estimateCtx: () => estimateCtxRatio(session),
|
|
418
|
+
};
|
|
419
|
+
// 手感批 C8: the auto-compact check — the /compact FULL path via the
|
|
420
|
+
// shared dispatch (same notices, same chain ordering, same mid-run
|
|
421
|
+
// refusal — the isRunning guard here only avoids the refusal's noise).
|
|
422
|
+
// The appended segment is NOT awaited here on purpose: from inside a
|
|
423
|
+
// chain segment, awaiting the append would be circular (the segment
|
|
424
|
+
// chains after THIS segment's promise). The exit path re-awaits the
|
|
425
|
+
// chain once more after the turn — see the final awaits in chat().
|
|
426
|
+
const maybeAutoCompact = () => {
|
|
427
|
+
if (autoCompact === undefined)
|
|
428
|
+
return;
|
|
429
|
+
if (currentRun !== null)
|
|
430
|
+
return; // dispatch would refuse — skip the noise
|
|
431
|
+
const ratio = estimateCtxRatio(session);
|
|
432
|
+
if (!Number.isFinite(ratio) || ratio < autoCompact.thresholdRatio)
|
|
433
|
+
return;
|
|
434
|
+
dispatch("/compact", dispatchCtx);
|
|
435
|
+
};
|
|
436
|
+
input.onLine((line) => {
|
|
437
|
+
if (!replReady) {
|
|
438
|
+
queuedLines.push(line);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
dispatch(line, dispatchCtx);
|
|
442
|
+
});
|
|
443
|
+
// Recovery first: a session with a dangling pause or uncertain
|
|
444
|
+
// executions must resolve them BEFORE the REPL accepts new turns —
|
|
445
|
+
// otherwise the interrupted run dangles while a new one starts.
|
|
446
|
+
// 八: the startup resume is bound to currentRun — Ctrl+C during it
|
|
447
|
+
// aborts the recovery, exactly like the interactive turns.
|
|
448
|
+
await resolveUncertains(session, input, () => cancelled);
|
|
449
|
+
if (!cancelled) {
|
|
450
|
+
const recoveryRun = session.resume();
|
|
451
|
+
currentRun = recoveryRun;
|
|
452
|
+
turnNo += 1;
|
|
453
|
+
const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
|
|
454
|
+
currentRun = null;
|
|
455
|
+
failOnFauxExhaustion(last, faux, input);
|
|
456
|
+
maybeAutoCompact(); // 手感批 C8: the recovery run ended too — same check (awaited by the exit re-await)
|
|
457
|
+
}
|
|
458
|
+
if (cancelled) {
|
|
459
|
+
input.close();
|
|
460
|
+
await input.closed;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// The REPL is ready: replay anything that arrived during recovery.
|
|
464
|
+
replReady = true;
|
|
465
|
+
// v2c: dispatch SYNCHRONOUSLY — each call appends its segment to the
|
|
466
|
+
// chain variable; the final `await chain` then covers every replayed
|
|
467
|
+
// turn. A chain.then(() => dispatch()) indirection would capture the
|
|
468
|
+
// chain BEFORE the appends and the replayed turns would never be
|
|
469
|
+
// awaited (the F-group regression).
|
|
470
|
+
for (const line of queuedLines) {
|
|
471
|
+
dispatch(line, dispatchCtx);
|
|
472
|
+
}
|
|
473
|
+
queuedLines.length = 0;
|
|
474
|
+
input.prompt();
|
|
475
|
+
await input.closed;
|
|
476
|
+
await chainRef.current; // never exit while a turn is in flight
|
|
477
|
+
// 手感批 C8: the auto-compact may have appended ITS segment inside the
|
|
478
|
+
// turn (the check runs at the turn's end, after the exit-await above
|
|
479
|
+
// already captured the chain) — re-await once so the summarize either
|
|
480
|
+
// runs before the exit or the chain is already settled. One level is
|
|
481
|
+
// enough: the /compact segment appends nothing of its own.
|
|
482
|
+
await chainRef.current;
|
|
483
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 (pure move) — the ONE dispatcher: slash commands, exit, and
|
|
3
|
+
* turns. The bodies moved verbatim from chat()'s closure; chat provides
|
|
4
|
+
* the context (the chain, the run state, the prompt arming).
|
|
5
|
+
*/
|
|
6
|
+
import type { AgentSession } from "@vincemakes/kiso-runtime";
|
|
7
|
+
import { type LineInput } from "./state.js";
|
|
8
|
+
/** Everything dispatch touches that chat() owns. */
|
|
9
|
+
export interface DispatchCtx {
|
|
10
|
+
readonly session: AgentSession;
|
|
11
|
+
readonly input: LineInput;
|
|
12
|
+
/** the turn chain — every dispatch segment appends onto it (the queue
|
|
13
|
+
* replay and the REPL share the chain). */
|
|
14
|
+
readonly chainRef: {
|
|
15
|
+
current: Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
/** true while a run is in flight — /compact refuses mid-run. */
|
|
18
|
+
readonly isRunning: () => boolean;
|
|
19
|
+
/** the /mode switch repaints the status bar at once. */
|
|
20
|
+
readonly paintIdle: () => void;
|
|
21
|
+
/** submit a real turn: queue + chain (the turn closure lives in chat). */
|
|
22
|
+
readonly submitTurn: (line: string) => void;
|
|
23
|
+
/** the /status context estimate. */
|
|
24
|
+
readonly estimateCtx: () => number;
|
|
25
|
+
}
|
|
26
|
+
/** The ONE dispatcher — slash commands, exit, and turns. The recovery
|
|
27
|
+
* replay routes through it too — a queued "/last" must never become a
|
|
28
|
+
* user turn (v2c: the rl lives in main, so lines arrive earlier and the
|
|
29
|
+
* queue is the common path). */
|
|
30
|
+
export declare function dispatch(line: string, ctx: DispatchCtx): void;
|