sakuracode 0.1.0 → 0.1.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 +1 -0
- package/README.zh-CN.md +1 -0
- package/dist/code/src/context-estimate.js +44 -0
- package/dist/code/src/permissions.js +9 -0
- package/dist/code/src/reports.js +1 -1
- package/dist/code/src/session-controller.js +68 -5
- package/dist/code/src/slash-commands.js +1 -1
- package/dist/code/src/status-line.js +11 -2
- package/dist/code/src/tui/app.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,6 +135,7 @@ sakuracode providers # providers and whether their key is set (ne
|
|
|
135
135
|
|---|---|---|
|
|
136
136
|
| Enter | send | **steer**: delivered before the agent's next model request |
|
|
137
137
|
| Tab | complete a slash command | **queue**: handled when the agent would stop |
|
|
138
|
+
| Shift-Tab | switch the review mode: auto, strict, read-only (`full-access` only with `/permissions`) | applied when the run ends |
|
|
138
139
|
| Esc | close the popup | **cancel** the run |
|
|
139
140
|
| Ctrl-C | clear the prompt; twice on an empty prompt leaves | cancel the run |
|
|
140
141
|
| Ctrl-D | leave (empty prompt) | |
|
package/README.zh-CN.md
CHANGED
|
@@ -135,6 +135,7 @@ sakuracode providers # 服务商以及它们的密钥是否已设
|
|
|
135
135
|
|---|---|---|
|
|
136
136
|
| Enter | 发送 | **引导**:在 Agent 的下一个模型请求之前送达 |
|
|
137
137
|
| Tab | 补全斜杠命令 | **排队**:Agent 要停下时处理 |
|
|
138
|
+
| Shift-Tab | 切换审查模式:auto、strict、read-only(`full-access` 只能用 `/permissions` 设置) | 运行结束后生效 |
|
|
138
139
|
| Esc | 关闭弹出列表 | **取消**这次运行 |
|
|
139
140
|
| Ctrl-C | 清空提示;在空提示上按两次退出 | 取消这次运行 |
|
|
140
141
|
| Ctrl-D | 退出(提示为空时) | |
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function messageCharacters(message) {
|
|
2
|
+
let characters = 0;
|
|
3
|
+
for (const part of message.content) {
|
|
4
|
+
if (part.type === "text")
|
|
5
|
+
characters += part.text.length;
|
|
6
|
+
else if (part.type === "tool_call")
|
|
7
|
+
characters += part.name.length + JSON.stringify(part.args ?? "").length;
|
|
8
|
+
else if (part.type === "image")
|
|
9
|
+
characters += 4000;
|
|
10
|
+
else
|
|
11
|
+
characters += JSON.stringify(part.data ?? "").length;
|
|
12
|
+
}
|
|
13
|
+
return characters;
|
|
14
|
+
}
|
|
15
|
+
/** Tokens of the log from the compaction on: the summary, the kept messages and everything after. */
|
|
16
|
+
export function estimateAfterCompaction(branch) {
|
|
17
|
+
const compaction = branch.findLastIndex((entry) => entry.type === "compaction");
|
|
18
|
+
const entry = branch[compaction];
|
|
19
|
+
if (!entry || entry.type !== "compaction")
|
|
20
|
+
return undefined;
|
|
21
|
+
const kept = branch.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
|
|
22
|
+
const from = kept === -1 ? compaction + 1 : kept;
|
|
23
|
+
let characters = entry.summary.length;
|
|
24
|
+
for (let index = from; index < branch.length; index++) {
|
|
25
|
+
const candidate = branch[index];
|
|
26
|
+
if (candidate?.type === "message")
|
|
27
|
+
characters += messageCharacters(candidate.message);
|
|
28
|
+
else if (candidate?.type === "custom_message")
|
|
29
|
+
characters += candidate.content.reduce((sum, part) => sum + (part.type === "text" ? part.text.length : 0), 0);
|
|
30
|
+
}
|
|
31
|
+
return Math.ceil(characters / 4);
|
|
32
|
+
}
|
|
33
|
+
export async function measureContext(source) {
|
|
34
|
+
const status = await source.context();
|
|
35
|
+
const branch = await source.log.branch();
|
|
36
|
+
const compaction = branch.findLastIndex((entry) => entry.type === "compaction");
|
|
37
|
+
if (compaction === -1)
|
|
38
|
+
return { tokens: status.tokens, window: status.window, estimated: false };
|
|
39
|
+
const replied = branch.slice(compaction + 1).some((entry) => entry.type === "message" && entry.message.role === "assistant" && entry.message.usage !== undefined);
|
|
40
|
+
if (replied)
|
|
41
|
+
return { tokens: status.tokens, window: status.window, estimated: false };
|
|
42
|
+
const tokens = estimateAfterCompaction(branch) ?? status.tokens;
|
|
43
|
+
return { tokens, window: status.window, estimated: true };
|
|
44
|
+
}
|
|
@@ -7,6 +7,15 @@ export const PERMISSION_PRESETS = {
|
|
|
7
7
|
strict: { name: "strict", summary: "reviewed; three refusals in a row pause the run", review: true, tools: undefined, breakerConsecutive: 3 },
|
|
8
8
|
"full-access": { name: "full-access", summary: "nothing is reviewed", review: false, tools: undefined, breakerConsecutive: undefined },
|
|
9
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* What Shift+Tab switches to: auto, then strict, then read-only, then auto again. `full-access` is never reached by the key
|
|
12
|
+
* (it needs the explicit `/permissions full-access`); from it the key goes back to auto.
|
|
13
|
+
*/
|
|
14
|
+
export function nextPermission(current) {
|
|
15
|
+
const cycle = ["auto", "strict", "read-only"];
|
|
16
|
+
const index = cycle.indexOf(current);
|
|
17
|
+
return index === -1 ? "auto" : cycle[(index + 1) % cycle.length];
|
|
18
|
+
}
|
|
10
19
|
export function isPermissionName(value) {
|
|
11
20
|
return PERMISSION_NAMES.includes(value);
|
|
12
21
|
}
|
package/dist/code/src/reports.js
CHANGED
|
@@ -39,7 +39,7 @@ export function statusReport(input) {
|
|
|
39
39
|
["Model", `${input.model} via ${input.provider}${input.thinking ? `, thinking ${input.thinking}` : ""}`],
|
|
40
40
|
["Permissions", `${input.permissions}: ${PERMISSION_PRESETS[input.permissions].summary}`],
|
|
41
41
|
["Redaction", input.redaction ? "on" : "off"],
|
|
42
|
-
["Context", `${number(input.context.tokens)} of ${number(input.context.window)} tokens used, ${free}% left${input.context.compactAt ? `, compacts at ${number(input.context.compactAt)}` : ""}`],
|
|
42
|
+
["Context", `${input.contextEstimated ? "about " : ""}${number(input.context.tokens)} of ${number(input.context.window)} tokens used${input.contextEstimated ? " (estimate until the next reply)" : ""}, ${input.contextEstimated ? "~" : ""}${free}% left${input.context.compactAt ? `, compacts at ${number(input.context.compactAt)}` : ""}`],
|
|
43
43
|
...usageRows(input),
|
|
44
44
|
["Goal", input.goal ? `${input.goal.status}: ${input.goal.objective}` : "none"],
|
|
45
45
|
["Idle compaction", input.idleCompaction],
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
// through the Transcript (what to show) and the Surface (what only a screen can do).
|
|
4
4
|
import { openAgentSession } from "./agent-session.js";
|
|
5
5
|
import { expandMentions } from "./mentions.js";
|
|
6
|
-
import { isPermissionName } from "./permissions.js";
|
|
6
|
+
import { isPermissionName, nextPermission } from "./permissions.js";
|
|
7
7
|
import { allProviders, chooseProvider, listProviders } from "./providers.js";
|
|
8
8
|
import { mcpReport, medianReplyHitRate, modelsReport, permissionsReport, statusReport } from "./reports.js";
|
|
9
9
|
import { createRunDriver } from "./run-driver.js";
|
|
10
10
|
import { sessionLabel, titleFromPrompt } from "./session-store.js";
|
|
11
11
|
import { runShell } from "./shell.js";
|
|
12
12
|
import { findCommand, helpMarkdown, parseSlash } from "./slash-commands.js";
|
|
13
|
+
import { measureContext } from "./context-estimate.js";
|
|
13
14
|
import { contextLeftPercent } from "./status-line.js";
|
|
14
15
|
import { excerpt, replayLog } from "./transcript.js";
|
|
15
16
|
import { imageMediaType } from "./images.js";
|
|
@@ -26,6 +27,13 @@ export function createController(deps) {
|
|
|
26
27
|
let session;
|
|
27
28
|
let meta;
|
|
28
29
|
let contextPercent;
|
|
30
|
+
// a mode asked for during a run; the agent is not reopened while it works
|
|
31
|
+
let pendingPermissions;
|
|
32
|
+
let contextEstimated = false;
|
|
33
|
+
let contextEstimatedShown = false;
|
|
34
|
+
let contextWindow = 0;
|
|
35
|
+
// a newer measurement wins over an older one that finishes later
|
|
36
|
+
let contextVersion = 0;
|
|
29
37
|
const firstImages = [...(deps.images ?? [])];
|
|
30
38
|
let choice = {
|
|
31
39
|
provider: deps.clients ? "scripted" : chooseProvider(deps.settings, deps.env),
|
|
@@ -35,6 +43,18 @@ export function createController(deps) {
|
|
|
35
43
|
thinking: deps.settings.thinking,
|
|
36
44
|
};
|
|
37
45
|
driver.onChange(() => surface.changed());
|
|
46
|
+
// The percentage follows the run: each finished reply carries the provider-reported usage, and a compaction changes the size at once.
|
|
47
|
+
driver.onEvent((event) => {
|
|
48
|
+
if (event.type === "message_end" && event.message.role === "assistant" && event.message.usage && contextWindow > 0) {
|
|
49
|
+
const { input, output, cacheRead, cacheWrite } = event.message.usage;
|
|
50
|
+
contextVersion++;
|
|
51
|
+
contextEstimated = false;
|
|
52
|
+
setContextPercent(contextLeftPercent(contextWindow - (input + output + cacheRead + cacheWrite), contextWindow));
|
|
53
|
+
}
|
|
54
|
+
else if (event.type === "compaction") {
|
|
55
|
+
void refreshContext().catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
38
58
|
const notice = (level, text) => transcript.add({ kind: "notice", level, text });
|
|
39
59
|
const requireSession = () => {
|
|
40
60
|
if (!session)
|
|
@@ -105,11 +125,29 @@ export function createController(deps) {
|
|
|
105
125
|
replayLog(transcript, entries);
|
|
106
126
|
notice("info", `Resumed ${sessionLabel(meta ?? target)} (${target.id.slice(0, 8)}).`);
|
|
107
127
|
}
|
|
128
|
+
async function statusContext(active) {
|
|
129
|
+
const status = await active.agent.context();
|
|
130
|
+
const measured = await measureContext(active.agent);
|
|
131
|
+
return { context: { ...status, tokens: measured.tokens, free: Math.max(0, measured.window - measured.tokens) }, contextEstimated: measured.estimated };
|
|
132
|
+
}
|
|
133
|
+
function setContextPercent(next) {
|
|
134
|
+
const flagChanged = contextEstimatedShown !== contextEstimated;
|
|
135
|
+
contextEstimatedShown = contextEstimated;
|
|
136
|
+
if (next === contextPercent && !flagChanged)
|
|
137
|
+
return;
|
|
138
|
+
contextPercent = next;
|
|
139
|
+
surface.changed();
|
|
140
|
+
}
|
|
108
141
|
async function refreshContext() {
|
|
109
142
|
if (!session)
|
|
110
143
|
return;
|
|
111
|
-
const
|
|
112
|
-
|
|
144
|
+
const version = ++contextVersion;
|
|
145
|
+
const measured = await measureContext(session.agent);
|
|
146
|
+
if (version !== contextVersion)
|
|
147
|
+
return;
|
|
148
|
+
contextWindow = measured.window;
|
|
149
|
+
contextEstimated = measured.estimated;
|
|
150
|
+
setContextPercent(contextLeftPercent(measured.window - measured.tokens, measured.window));
|
|
113
151
|
}
|
|
114
152
|
// ---------- running ----------
|
|
115
153
|
async function runInput(input, options) {
|
|
@@ -127,8 +165,18 @@ export function createController(deps) {
|
|
|
127
165
|
}
|
|
128
166
|
if (meta)
|
|
129
167
|
meta = await store.update(meta.id, {});
|
|
168
|
+
await applyPendingPermissions();
|
|
130
169
|
return outcome.result;
|
|
131
170
|
}
|
|
171
|
+
async function applyPendingPermissions() {
|
|
172
|
+
const wanted = pendingPermissions;
|
|
173
|
+
pendingPermissions = undefined;
|
|
174
|
+
if (wanted === undefined || wanted === choice.permissions)
|
|
175
|
+
return surface.changed();
|
|
176
|
+
if (await reopen({ permissions: wanted }))
|
|
177
|
+
notice("info", `Permissions: ${wanted} (Shift+Tab to change)`);
|
|
178
|
+
surface.changed();
|
|
179
|
+
}
|
|
132
180
|
function reportEnd(result) {
|
|
133
181
|
if (result.reason === "error")
|
|
134
182
|
notice("error", `The run failed: ${result.error ?? "unknown error"}`);
|
|
@@ -179,7 +227,7 @@ export function createController(deps) {
|
|
|
179
227
|
thinking: active.thinking,
|
|
180
228
|
permissions: active.permissions,
|
|
181
229
|
redaction: active.redaction,
|
|
182
|
-
|
|
230
|
+
...(await statusContext(active)),
|
|
183
231
|
usage: await active.agent.usage(),
|
|
184
232
|
medianHitRate: medianReplyHitRate(await active.agent.log.branch()),
|
|
185
233
|
goal: await active.agent.goal(),
|
|
@@ -201,6 +249,7 @@ export function createController(deps) {
|
|
|
201
249
|
return host.print(permissionsReport(choice.permissions, choice.redaction));
|
|
202
250
|
if (!isPermissionName(name))
|
|
203
251
|
return notice("error", `Unknown preset "${name}". Use auto, read-only, strict or full-access.`);
|
|
252
|
+
pendingPermissions = undefined;
|
|
204
253
|
if (await reopen({ permissions: name }))
|
|
205
254
|
notice("info", `Permissions: ${name}.`);
|
|
206
255
|
},
|
|
@@ -379,10 +428,22 @@ export function createController(deps) {
|
|
|
379
428
|
transcript.muteCompaction(false);
|
|
380
429
|
stop();
|
|
381
430
|
}
|
|
382
|
-
const after = (await requireSession().agent
|
|
431
|
+
const after = (await measureContext(requireSession().agent)).tokens;
|
|
383
432
|
notice("info", `compacted after ${idleMinutes} min idle, ${thousands(before)} to ${thousands(after)} tokens`);
|
|
384
433
|
return true;
|
|
385
434
|
},
|
|
435
|
+
async cyclePermissions() {
|
|
436
|
+
const next = nextPermission(pendingPermissions ?? choice.permissions);
|
|
437
|
+
if (driver.working || launching) {
|
|
438
|
+
pendingPermissions = next === choice.permissions ? undefined : next;
|
|
439
|
+
return surface.changed();
|
|
440
|
+
}
|
|
441
|
+
if (!session)
|
|
442
|
+
return;
|
|
443
|
+
if (await reopen({ permissions: next }))
|
|
444
|
+
notice("info", `Permissions: ${next} (Shift+Tab to change)`);
|
|
445
|
+
surface.changed();
|
|
446
|
+
},
|
|
386
447
|
statusInfo: () => ({
|
|
387
448
|
provider: choice.provider,
|
|
388
449
|
model: choice.model ?? "",
|
|
@@ -390,7 +451,9 @@ export function createController(deps) {
|
|
|
390
451
|
cwd: deps.cwd,
|
|
391
452
|
home: deps.home,
|
|
392
453
|
contextLeftPercent: contextPercent,
|
|
454
|
+
contextEstimated: contextEstimatedShown,
|
|
393
455
|
permissions: choice.permissions,
|
|
456
|
+
pendingPermissions,
|
|
394
457
|
redaction: choice.redaction,
|
|
395
458
|
working: driver.working,
|
|
396
459
|
queued: driver.pending().length,
|
|
@@ -79,5 +79,5 @@ export function complete(input) {
|
|
|
79
79
|
}
|
|
80
80
|
export function helpMarkdown() {
|
|
81
81
|
const rows = COMMANDS.map((command) => `| \`${command.usage}\` | ${command.summary} |`);
|
|
82
|
-
return ["| Command | What it does |", "|---|---|", ...rows, "", "While the agent works: **Enter** steers it, **Tab** queues a message, **Esc** cancels. `!cmd` runs a shell command yourself."].join("\n");
|
|
82
|
+
return ["| Command | What it does |", "|---|---|", ...rows, "", "While the agent works: **Enter** steers it, **Tab** queues a message, **Esc** cancels. **Shift+Tab** cycles the review mode (auto, strict, read-only; `/permissions full-access` is explicit). `!cmd` runs a shell command yourself."].join("\n");
|
|
83
83
|
}
|
|
@@ -11,12 +11,21 @@ export function shortenPath(path, home, width) {
|
|
|
11
11
|
export function contextLeftPercent(free, window) {
|
|
12
12
|
return window <= 0 ? 0 : Math.max(0, Math.min(100, Math.round((free / window) * 100)));
|
|
13
13
|
}
|
|
14
|
+
function contextText(info, pal) {
|
|
15
|
+
const text = `${info.contextEstimated ? "~" : ""}${info.contextLeftPercent}%`;
|
|
16
|
+
return (info.contextLeftPercent ?? 100) < 15 ? pal.red(text) : text;
|
|
17
|
+
}
|
|
18
|
+
function permissionsPiece(info, pal) {
|
|
19
|
+
const mode = info.pendingPermissions ?? info.permissions;
|
|
20
|
+
const paint = mode === "auto" ? pal.dim : mode === "full-access" ? pal.red : pal.yellow;
|
|
21
|
+
return info.pendingPermissions === undefined ? paint(mode) : paint(mode) + pal.dim(" (next turn)");
|
|
22
|
+
}
|
|
14
23
|
export function formatStatusLine(info, width, color) {
|
|
15
24
|
const pal = createPalette(color);
|
|
16
25
|
const pieces = [
|
|
17
26
|
pal.bold(info.model) + (info.thinking ? pal.dim(` ${info.thinking}`) : ""),
|
|
18
|
-
info.contextLeftPercent === undefined ? "" : `${info
|
|
19
|
-
info
|
|
27
|
+
info.contextLeftPercent === undefined ? "" : `${contextText(info, pal)} context left`,
|
|
28
|
+
permissionsPiece(info, pal),
|
|
20
29
|
info.redaction ? pal.magenta("redaction") : "",
|
|
21
30
|
info.queued > 0 ? pal.cyan(`${info.queued} queued`) : "",
|
|
22
31
|
shortenPath(info.cwd, info.home, Math.max(12, Math.floor(width / 3))),
|
package/dist/code/src/tui/app.js
CHANGED
|
@@ -163,6 +163,12 @@ export async function runTui(options) {
|
|
|
163
163
|
// a dropped or pasted image file becomes an @mention, which attaches it
|
|
164
164
|
const dropped = key.name === "paste" ? pastedImagePath(key.text ?? "", options.controller.cwd) : undefined;
|
|
165
165
|
const action = editor.handle(dropped ? { name: "paste", text: `@${dropped} ` } : key);
|
|
166
|
+
if (action === "shift-tab") {
|
|
167
|
+
// the command popup uses Tab; Shift+Tab never changes the mode while it is open
|
|
168
|
+
if (commands.length === 0)
|
|
169
|
+
void controller.cyclePermissions().catch((error) => showHint(error.message));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
166
172
|
if (action !== "none")
|
|
167
173
|
popupIndex = action === "tab" ? popupIndex : 0;
|
|
168
174
|
if (action === "submit")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sakuracode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "sakuracode: an open-source terminal coding agent (command-line AI coding assistant) with automatic review of every tool call. Works with DeepSeek, OpenCode Go and any OpenAI-compatible endpoint.",
|