sakuracode 0.0.0-stage → 0.1.0

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.
Files changed (117) hide show
  1. package/README.md +256 -2
  2. package/README.zh-CN.md +257 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/THIRD_PARTY_NOTICES.md +13 -0
  5. package/dist/code/bin/sakuracode.mjs +5 -0
  6. package/dist/code/src/agent-session.js +41 -0
  7. package/dist/code/src/ansi.js +220 -0
  8. package/dist/code/src/cli.js +211 -0
  9. package/dist/code/src/config.js +183 -0
  10. package/dist/code/src/exec-mode.js +84 -0
  11. package/dist/code/src/framework-testing.js +2 -0
  12. package/dist/code/src/framework.js +3 -0
  13. package/dist/code/src/idle-compaction.js +38 -0
  14. package/dist/code/src/images.js +18 -0
  15. package/dist/code/src/invocation.js +39 -0
  16. package/dist/code/src/markdown.js +383 -0
  17. package/dist/code/src/mentions.js +22 -0
  18. package/dist/code/src/permissions.js +31 -0
  19. package/dist/code/src/providers.js +56 -0
  20. package/dist/code/src/reports.js +81 -0
  21. package/dist/code/src/run-driver.js +76 -0
  22. package/dist/code/src/session-controller.js +405 -0
  23. package/dist/code/src/session-store.js +92 -0
  24. package/dist/code/src/shell.js +36 -0
  25. package/dist/code/src/slash-commands.js +83 -0
  26. package/dist/code/src/status-line.js +26 -0
  27. package/dist/code/src/toml.js +256 -0
  28. package/dist/code/src/transcript.js +288 -0
  29. package/dist/code/src/tui/app.js +267 -0
  30. package/dist/code/src/tui/keys.js +151 -0
  31. package/dist/code/src/tui/line-editor.js +324 -0
  32. package/dist/code/src/tui/live-region.js +71 -0
  33. package/dist/code/src/tui/screen.js +69 -0
  34. package/dist/code/src/tui/transcript-view.js +102 -0
  35. package/dist/licenses/codex-Apache-2.0.txt +201 -0
  36. package/dist/licenses/gitleaks-MIT.txt +21 -0
  37. package/dist/licenses/pi-mono-MIT.txt +21 -0
  38. package/dist/packages/agent/src/compose.js +91 -0
  39. package/dist/packages/agent/src/index.js +161 -0
  40. package/dist/packages/agent/src/restrict-tools.js +11 -0
  41. package/dist/packages/agent/src/system-prompt.js +49 -0
  42. package/dist/packages/agent/src/wake.js +71 -0
  43. package/dist/packages/env/src/confine.js +40 -0
  44. package/dist/packages/env/src/connect-ssh.js +242 -0
  45. package/dist/packages/env/src/index.js +4 -0
  46. package/dist/packages/env/src/local.js +206 -0
  47. package/dist/packages/env/src/remote-stream.js +143 -0
  48. package/dist/packages/env/src/remote-types.js +10 -0
  49. package/dist/packages/env/src/remote.js +183 -0
  50. package/dist/packages/evals/src/bfcl.js +71 -0
  51. package/dist/packages/evals/src/index.js +2 -0
  52. package/dist/packages/evals/src/png.js +30 -0
  53. package/dist/packages/evals/src/pool.js +22 -0
  54. package/dist/packages/evals/src/providers.js +23 -0
  55. package/dist/packages/kernel/src/channel.js +34 -0
  56. package/dist/packages/kernel/src/compaction.js +86 -0
  57. package/dist/packages/kernel/src/constants.js +5 -0
  58. package/dist/packages/kernel/src/goal.js +65 -0
  59. package/dist/packages/kernel/src/hooks.js +153 -0
  60. package/dist/packages/kernel/src/index.js +4 -0
  61. package/dist/packages/kernel/src/loop.js +556 -0
  62. package/dist/packages/kernel/src/prompts.js +99 -0
  63. package/dist/packages/kernel/src/system.js +49 -0
  64. package/dist/packages/kernel/src/types.js +23 -0
  65. package/dist/packages/kernel/src/usage.js +16 -0
  66. package/dist/packages/llm/src/chat/compat.js +16 -0
  67. package/dist/packages/llm/src/chat/errors.js +61 -0
  68. package/dist/packages/llm/src/chat/request.js +109 -0
  69. package/dist/packages/llm/src/chat/retry.js +57 -0
  70. package/dist/packages/llm/src/chat/stream.js +122 -0
  71. package/dist/packages/llm/src/index.js +120 -0
  72. package/dist/packages/plugins/agents/src/index.js +206 -0
  73. package/dist/packages/plugins/agents/src/view.js +60 -0
  74. package/dist/packages/plugins/context/src/index.js +67 -0
  75. package/dist/packages/plugins/limits/src/cost.js +3 -0
  76. package/dist/packages/plugins/limits/src/index.js +31 -0
  77. package/dist/packages/plugins/redaction/src/engine.js +110 -0
  78. package/dist/packages/plugins/redaction/src/index.js +69 -0
  79. package/dist/packages/plugins/redaction/src/restore.js +44 -0
  80. package/dist/packages/plugins/redaction/src/rules.gen.js +2059 -0
  81. package/dist/packages/plugins/resilience/src/index.js +86 -0
  82. package/dist/packages/plugins/review/src/chain.js +27 -0
  83. package/dist/packages/plugins/review/src/hard-rules.js +156 -0
  84. package/dist/packages/plugins/review/src/index.js +93 -0
  85. package/dist/packages/plugins/review/src/policy.js +65 -0
  86. package/dist/packages/plugins/review/src/reviewer.js +207 -0
  87. package/dist/packages/plugins/review/src/shell.js +124 -0
  88. package/dist/packages/plugins/tools/src/artifacts.js +61 -0
  89. package/dist/packages/plugins/tools/src/builtin/args.js +46 -0
  90. package/dist/packages/plugins/tools/src/builtin/bash.js +110 -0
  91. package/dist/packages/plugins/tools/src/builtin/edit.js +71 -0
  92. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  93. package/dist/packages/plugins/tools/src/builtin/read.js +89 -0
  94. package/dist/packages/plugins/tools/src/builtin/write.js +35 -0
  95. package/dist/packages/plugins/tools/src/edit-match.js +159 -0
  96. package/dist/packages/plugins/tools/src/fold.js +65 -0
  97. package/dist/packages/plugins/tools/src/index.js +10 -0
  98. package/dist/packages/plugins/toolset/src/bm25.js +35 -0
  99. package/dist/packages/plugins/toolset/src/codemode.js +226 -0
  100. package/dist/packages/plugins/toolset/src/index.js +3 -0
  101. package/dist/packages/plugins/toolset/src/mcp.js +173 -0
  102. package/dist/packages/plugins/toolset/src/search.js +57 -0
  103. package/dist/packages/plugins/trace/src/index.js +29 -0
  104. package/dist/packages/plugins/trace/src/modules.js +28 -0
  105. package/dist/packages/session/src/file-log.js +144 -0
  106. package/dist/packages/session/src/fork.js +70 -0
  107. package/dist/packages/session/src/index.js +3 -0
  108. package/dist/packages/session/src/log-core.js +47 -0
  109. package/dist/packages/session/src/memory-log.js +7 -0
  110. package/dist/packages/session/src/projection.js +107 -0
  111. package/dist/packages/session/src/tree.js +35 -0
  112. package/dist/packages/testing/src/faux.js +69 -0
  113. package/dist/packages/testing/src/index.js +2 -0
  114. package/dist/packages/testing/src/rng.js +17 -0
  115. package/dist/sdk/index.js +10 -0
  116. package/dist/sdk/testing.js +2 -0
  117. package/package.json +54 -4
@@ -0,0 +1,84 @@
1
+ // Non-interactive mode (`exec`): run one prompt to the end, with no screen. Progress goes to stderr, the final reply to
2
+ // stdout as Markdown (rendered for a terminal, raw for a pipe), or every event as a JSON line with --json.
3
+ import { writeFile } from "node:fs/promises";
4
+ import { renderMarkdown } from "./markdown.js";
5
+ import { createController } from "./session-controller.js";
6
+ import { createTranscript, textOf } from "./transcript.js";
7
+ import { renderBlock, toolHeadline } from "./tui/transcript-view.js";
8
+ const SILENT_SURFACE = {
9
+ changed: () => undefined,
10
+ pickSession: async () => undefined,
11
+ clearScreen: () => undefined,
12
+ prefill: () => undefined,
13
+ exit: () => undefined,
14
+ };
15
+ export function exitCodeFor(result) {
16
+ if (result.reason === "done")
17
+ return 0;
18
+ if (result.reason === "cancelled")
19
+ return 130;
20
+ if (result.reason === "paused")
21
+ return 2;
22
+ return 1;
23
+ }
24
+ export async function runExec(options, deps) {
25
+ const transcript = createTranscript();
26
+ const controller = createController({ ...deps.controller, transcript, surface: SILENT_SURFACE });
27
+ try {
28
+ await controller.start(options.resume !== undefined ? { resume: options.resume } : {});
29
+ }
30
+ catch (error) {
31
+ deps.stderr.write(`${error.message}\n`);
32
+ return 1;
33
+ }
34
+ deps.onCancelable?.(() => controller.cancel());
35
+ const meta = controller.currentMeta();
36
+ const info = controller.statusInfo();
37
+ if (options.json)
38
+ deps.stdout.write(`${JSON.stringify({ type: "session", id: meta?.id, provider: info.provider, model: info.model, cwd: info.cwd, permissions: info.permissions, redaction: info.redaction })}\n`);
39
+ let finalMessage = "";
40
+ controller.driver.onEvent((event) => {
41
+ if (options.json)
42
+ deps.stdout.write(`${JSON.stringify(event)}\n`);
43
+ if (event.type === "message_end" && event.message.role === "assistant") {
44
+ const text = textOf(event.message.content);
45
+ if (text.trim() !== "")
46
+ finalMessage = text;
47
+ }
48
+ });
49
+ // Progress is optional; a warning or an error (a retry, a failure) is always shown.
50
+ transcript.subscribe((change) => {
51
+ if (change.type !== "commit")
52
+ return;
53
+ const block = change.block;
54
+ if (options.quiet && !(block.kind === "notice" && block.level !== "info"))
55
+ return;
56
+ if (block.kind === "tool" || block.kind === "notice" || block.kind === "compaction") {
57
+ const view = { width: 120, color: false, cwd: deps.controller.cwd };
58
+ deps.stderr.write(`${block.kind === "tool" ? toolHeadline(block, view) : renderBlock(block, view).join("\n")}\n`);
59
+ }
60
+ });
61
+ let result;
62
+ try {
63
+ result = await controller.runPrompt(options.prompt);
64
+ }
65
+ catch (error) {
66
+ result = { reason: "error", error: error.message };
67
+ }
68
+ if (options.json)
69
+ deps.stdout.write(`${JSON.stringify({ type: "result", ...result, finalMessage })}\n`);
70
+ else if (finalMessage !== "") {
71
+ const text = options.render ? renderMarkdown(finalMessage, { width: Math.min(deps.stdout.columns ?? 100, 120), color: options.color }).join("\n") : finalMessage;
72
+ deps.stdout.write(`${text}\n`);
73
+ }
74
+ if (result.reason === "error")
75
+ deps.stderr.write(`Error: ${result.error ?? "unknown"}\n`);
76
+ else if (result.reason === "paused")
77
+ deps.stderr.write(`The run was paused (${result.pause ?? "unknown"}).\n`);
78
+ else if (result.reason === "cancelled")
79
+ deps.stderr.write("Cancelled.\n");
80
+ if (options.outputLastMessage)
81
+ await writeFile(options.outputLastMessage, `${finalMessage}\n`);
82
+ await controller.close();
83
+ return exitCodeFor(result);
84
+ }
@@ -0,0 +1,2 @@
1
+ // The import seam to the framework's scripted model, for tests and for the test hook of the binary (SAKURACODE_FAUX_SCRIPT).
2
+ export { createFauxClient } from "../../sdk/testing.js";
@@ -0,0 +1,3 @@
1
+ // The only import seam to the Sakura framework. If the framework moves or is installed as the npm package
2
+ // `sakura-agent`, this is the one line to change.
3
+ export * from "../../sdk/index.js";
@@ -0,0 +1,38 @@
1
+ // Compacts the conversation once the session has been idle for as long as the provider keeps its prompt cache:
2
+ // the cached prefix is gone anyway then, so a smaller context costs the least. Only the timing lives here.
3
+ export function createIdleCompaction(options) {
4
+ let timer;
5
+ let compacting = false;
6
+ const arm = (delay) => {
7
+ clearTimeout(timer);
8
+ timer = undefined;
9
+ if (options.enabled) {
10
+ timer = setTimeout(fire, delay);
11
+ timer.unref();
12
+ }
13
+ };
14
+ async function fire() {
15
+ timer = undefined;
16
+ if (compacting)
17
+ return;
18
+ if (options.busy() || options.typing())
19
+ return arm(options.retryMs);
20
+ compacting = true;
21
+ try {
22
+ await options.compact();
23
+ }
24
+ catch {
25
+ // the controller reports failures; the next activity starts a new wait
26
+ }
27
+ finally {
28
+ compacting = false;
29
+ }
30
+ }
31
+ return {
32
+ touch: () => arm(options.idleMs),
33
+ stop() {
34
+ clearTimeout(timer);
35
+ timer = undefined;
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,18 @@
1
+ // Image files: which paths are images, and how a pasted or dropped path is read.
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { extname, isAbsolute, resolve } from "node:path";
4
+ const IMAGE_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" };
5
+ export function imageMediaType(path) {
6
+ return IMAGE_TYPES[extname(path).toLowerCase()];
7
+ }
8
+ /** The existing image file a pasted text names (a terminal drop gives a path, maybe quoted or with escaped spaces), or undefined. */
9
+ export function pastedImagePath(text, cwd) {
10
+ let candidate = text.trim();
11
+ if (candidate === "" || candidate.includes("\n"))
12
+ return undefined;
13
+ candidate = candidate.replace(/^(['"])(.*)\1$/, "$2").replace(/\\ /g, " ").replace(/^file:\/\//, "");
14
+ if (imageMediaType(candidate) === undefined)
15
+ return undefined;
16
+ const path = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
17
+ return existsSync(path) && statSync(path).isFile() ? path : undefined;
18
+ }
@@ -0,0 +1,39 @@
1
+ // What a command line asks for, apart from the settings flags: which mode, which session, which prompt.
2
+ import { ConfigError } from "./config.js";
3
+ import { reviewPrompt } from "./slash-commands.js";
4
+ export function parseInvocation(positionals, flags) {
5
+ const [command, ...rest] = positionals;
6
+ const last = flags.last ? "last" : undefined;
7
+ switch (command) {
8
+ case "providers":
9
+ case "sessions":
10
+ return command === "providers" ? { mode: "providers" } : { mode: "sessions" };
11
+ case "exec": {
12
+ if (rest[0] === "resume") {
13
+ const reference = last ?? rest[1];
14
+ if (reference === undefined)
15
+ throw new ConfigError("exec resume needs a session id or --last");
16
+ return { mode: "exec", resume: reference, prompt: rest.slice(last ? 1 : 2).join(" ") };
17
+ }
18
+ return { mode: "exec", resume: last ?? flags.resume, prompt: rest.join(" ") };
19
+ }
20
+ case "review":
21
+ if ([flags.base, flags.commit].filter(Boolean).length > 1)
22
+ throw new ConfigError("--base and --commit cannot be used together");
23
+ return { mode: "exec", resume: undefined, prompt: reviewPrompt({ base: flags.base, commit: flags.commit, focus: rest.join(" ") }) };
24
+ case "resume": {
25
+ const reference = last ?? rest[0];
26
+ if (reference === undefined)
27
+ return { mode: "tui", resume: undefined, prompt: "", slash: "/resume" };
28
+ return { mode: "tui", resume: reference, prompt: rest.slice(last ? 0 : 1).join(" "), slash: undefined };
29
+ }
30
+ case "fork": {
31
+ const reference = last ?? rest[0];
32
+ if (reference === undefined)
33
+ throw new ConfigError("fork needs a session id or --last");
34
+ return { mode: "tui", resume: reference, prompt: "", slash: "/fork" };
35
+ }
36
+ default:
37
+ return { mode: "tui", resume: last ?? flags.resume, prompt: positionals.join(" "), slash: undefined };
38
+ }
39
+ }
@@ -0,0 +1,383 @@
1
+ // Markdown to terminal lines. Handles the parts model replies use: headings, paragraphs, lists (nested, ordered, task),
2
+ // quotes, fenced code, inline code, bold/italic/strike, links, tables and rules.
3
+ import { createPalette, displayWidth, hyperlink, padEnd, wrapAnsi } from "./ansi.js";
4
+ export function renderMarkdown(text, options) {
5
+ const context = { pal: createPalette(options.color), color: options.color, links: options.hyperlinks ?? false };
6
+ const lines = renderBlocks(text.replace(/\r\n?/g, "\n").split("\n"), Math.max(10, options.width), context);
7
+ while (lines.length > 0 && lines[lines.length - 1] === "")
8
+ lines.pop();
9
+ return lines;
10
+ }
11
+ // ---------- blocks ----------
12
+ const FENCE = /^(\s{0,3})(`{3,}|~{3,})\s*([^`\s]*)[^`]*$/;
13
+ const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
14
+ const RULE = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
15
+ const QUOTE = /^\s{0,3}>\s?(.*)$/;
16
+ const LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
17
+ const TABLE_SEPARATOR = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/;
18
+ function renderBlocks(lines, width, context, tight = false) {
19
+ const out = [];
20
+ const addBlock = (block) => {
21
+ if (block.length === 0)
22
+ return;
23
+ if (!tight && out.length > 0 && out[out.length - 1] !== "")
24
+ out.push("");
25
+ out.push(...block);
26
+ };
27
+ let index = 0;
28
+ while (index < lines.length) {
29
+ const line = lines[index];
30
+ if (line.trim() === "") {
31
+ index += 1;
32
+ continue;
33
+ }
34
+ const fence = FENCE.exec(line);
35
+ if (fence) {
36
+ const marker = fence[2];
37
+ const body = [];
38
+ index += 1;
39
+ while (index < lines.length && !new RegExp(`^\\s{0,3}${marker[0]}{${marker.length},}\\s*$`).test(lines[index])) {
40
+ body.push(lines[index].replace(new RegExp(`^ {0,${fence[1].length}}`), ""));
41
+ index += 1;
42
+ }
43
+ index += 1; // the closing fence (a missing one ends at the end of the text)
44
+ addBlock(renderCode(body, fence[3] ?? "", width, context));
45
+ continue;
46
+ }
47
+ const heading = HEADING.exec(line);
48
+ if (heading) {
49
+ addBlock(renderHeading(heading[1].length, heading[2], width, context));
50
+ index += 1;
51
+ continue;
52
+ }
53
+ if (RULE.test(line)) {
54
+ addBlock([context.pal.dim("─".repeat(Math.min(width, 60)))]);
55
+ index += 1;
56
+ continue;
57
+ }
58
+ if (QUOTE.test(line)) {
59
+ const quoted = [];
60
+ while (index < lines.length && QUOTE.test(lines[index])) {
61
+ quoted.push(QUOTE.exec(lines[index])[1]);
62
+ index += 1;
63
+ }
64
+ addBlock(renderBlocks(quoted, width - 2, context).map((inner) => `${context.pal.dim("│")} ${inner}`.trimEnd()));
65
+ continue;
66
+ }
67
+ if (isTableStart(lines, index)) {
68
+ const rows = [];
69
+ while (index < lines.length && lines[index].trim() !== "" && lines[index].includes("|")) {
70
+ rows.push(lines[index]);
71
+ index += 1;
72
+ }
73
+ addBlock(renderTable(rows, width, context));
74
+ continue;
75
+ }
76
+ if (LIST_ITEM.test(line)) {
77
+ const taken = takeList(lines, index);
78
+ index = taken.next;
79
+ addBlock(renderList(taken.items, width, context));
80
+ continue;
81
+ }
82
+ // paragraph: runs until a blank line or the start of another block
83
+ const paragraph = [];
84
+ while (index < lines.length && lines[index].trim() !== "" && (paragraph.length === 0 || !startsBlock(lines, index))) {
85
+ paragraph.push(lines[index].trim());
86
+ index += 1;
87
+ }
88
+ addBlock(wrapAnsi(renderInline(paragraph.join("\n").replace(/\n/g, " "), context), width));
89
+ }
90
+ return out;
91
+ }
92
+ function startsBlock(lines, index) {
93
+ const line = lines[index];
94
+ return FENCE.test(line) || HEADING.test(line) || RULE.test(line) || QUOTE.test(line) || LIST_ITEM.test(line) || isTableStart(lines, index);
95
+ }
96
+ function isTableStart(lines, index) {
97
+ const header = lines[index];
98
+ const separator = lines[index + 1];
99
+ return header.includes("|") && separator !== undefined && separator.includes("-") && TABLE_SEPARATOR.test(separator) && splitRow(header).length === splitRow(separator).length;
100
+ }
101
+ function renderHeading(level, text, width, context) {
102
+ const { pal } = context;
103
+ const inline = renderInline(text, context);
104
+ const styled = level === 1 ? pal.bold(pal.underline(inline)) : level === 2 ? pal.bold(inline) : level === 3 ? pal.bold(pal.italic(inline)) : pal.italic(inline);
105
+ return wrapAnsi(styled, width);
106
+ }
107
+ // ---------- code ----------
108
+ function renderCode(body, language, width, context) {
109
+ const { pal } = context;
110
+ const label = language ? pal.dim(` ${language}`) : "";
111
+ const rendered = body.flatMap((codeLine) => {
112
+ const wrapped = wrapAnsi(codeLine === "" ? "" : codeLine.replace(/\t/g, " "), Math.max(10, width - 2));
113
+ return wrapped.map((part) => ` ${pal.cyan(part)}`);
114
+ });
115
+ const rule = pal.dim(" " + "┄".repeat(Math.min(width - 2, 40)));
116
+ return [...(label ? [label] : [rule]), ...rendered, rule];
117
+ }
118
+ function takeList(lines, start) {
119
+ const items = [];
120
+ const first = LIST_ITEM.exec(lines[start]);
121
+ const baseIndent = first[1].length;
122
+ let index = start;
123
+ while (index < lines.length) {
124
+ const match = LIST_ITEM.exec(lines[index]);
125
+ if (!match || match[1].length !== baseIndent || /\d/.test(match[2]) !== /\d/.test(first[2]))
126
+ break;
127
+ const marker = match[2];
128
+ const contentIndent = baseIndent + marker.length + 1;
129
+ const itemLines = [match[3]];
130
+ index += 1;
131
+ while (index < lines.length) {
132
+ const current = lines[index];
133
+ if (current.trim() === "") {
134
+ // a blank line stays in the item only when the next non-blank line is indented under it
135
+ let lookahead = index + 1;
136
+ while (lookahead < lines.length && lines[lookahead].trim() === "")
137
+ lookahead += 1;
138
+ const following = lines[lookahead];
139
+ if (following !== undefined && leadingSpaces(following) >= contentIndent) {
140
+ itemLines.push("");
141
+ index += 1;
142
+ continue;
143
+ }
144
+ break;
145
+ }
146
+ if (leadingSpaces(current) >= contentIndent) {
147
+ itemLines.push(current.slice(contentIndent));
148
+ index += 1;
149
+ continue;
150
+ }
151
+ const next = LIST_ITEM.exec(current);
152
+ if (next && next[1].length <= baseIndent)
153
+ break;
154
+ if (startsBlock(lines, index))
155
+ break;
156
+ itemLines.push(current.trim()); // lazy continuation of the paragraph
157
+ index += 1;
158
+ }
159
+ items.push({ ordered: /\d/.test(marker), number: Number.parseInt(marker, 10), lines: itemLines });
160
+ // blank lines between items of one list
161
+ let lookahead = index;
162
+ while (lookahead < lines.length && lines[lookahead].trim() === "")
163
+ lookahead += 1;
164
+ const following = lines[lookahead];
165
+ const nextItem = following === undefined ? null : LIST_ITEM.exec(following);
166
+ if (nextItem && nextItem[1].length === baseIndent)
167
+ index = lookahead;
168
+ else
169
+ break;
170
+ }
171
+ return { items, next: index };
172
+ }
173
+ const leadingSpaces = (line) => line.length - line.trimStart().length;
174
+ function renderList(items, width, context) {
175
+ const { pal } = context;
176
+ const out = [];
177
+ const markerWidth = Math.max(...items.map((item, position) => (item.ordered ? `${item.number + 0 * position}.`.length : 1))) + 1;
178
+ for (const item of items) {
179
+ const task = /^\[([ xX])\]\s+/.exec(item.lines[0]);
180
+ const body = task ? [item.lines[0].slice(task[0].length), ...item.lines.slice(1)] : item.lines;
181
+ const marker = task ? (task[1] === " " ? "☐" : "☑") : item.ordered ? `${item.number}.` : "•";
182
+ const inner = renderBlocks(body, width - markerWidth, context, !body.includes(""));
183
+ const styledMarker = task ? (task[1] === " " ? pal.dim(marker) : pal.green(marker)) : pal.dim(marker);
184
+ const gutter = markerWidth - displayWidth(marker);
185
+ inner.forEach((line, position) => {
186
+ out.push(position === 0 ? `${styledMarker}${" ".repeat(gutter)}${line}` : line === "" ? "" : `${" ".repeat(markerWidth)}${line}`);
187
+ });
188
+ if (inner.length === 0)
189
+ out.push(styledMarker);
190
+ }
191
+ return out;
192
+ }
193
+ // ---------- tables ----------
194
+ function splitRow(row) {
195
+ const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "");
196
+ const cells = [];
197
+ let current = "";
198
+ for (let index = 0; index < trimmed.length; index += 1) {
199
+ const char = trimmed[index];
200
+ if (char === "\\" && trimmed[index + 1] === "|") {
201
+ current += "|";
202
+ index += 1;
203
+ }
204
+ else if (char === "|") {
205
+ cells.push(current.trim());
206
+ current = "";
207
+ }
208
+ else
209
+ current += char;
210
+ }
211
+ cells.push(current.trim());
212
+ return cells;
213
+ }
214
+ function alignment(cell) {
215
+ const left = cell.startsWith(":");
216
+ const right = cell.endsWith(":");
217
+ return left && right ? "center" : right ? "right" : "left";
218
+ }
219
+ function fitColumns(natural, available) {
220
+ const widths = [...natural];
221
+ const minimum = 4;
222
+ while (widths.reduce((sum, value) => sum + value, 0) > available) {
223
+ const largest = widths.indexOf(Math.max(...widths));
224
+ if (widths[largest] <= minimum)
225
+ break;
226
+ widths[largest] = widths[largest] - 1;
227
+ }
228
+ return widths;
229
+ }
230
+ function renderTable(rows, width, context) {
231
+ const { pal } = context;
232
+ const header = splitRow(rows[0]);
233
+ const aligns = splitRow(rows[1]).map(alignment);
234
+ const body = rows.slice(2).map((row) => {
235
+ const cells = splitRow(row);
236
+ while (cells.length < header.length)
237
+ cells.push("");
238
+ return cells.slice(0, header.length);
239
+ });
240
+ const all = [header, ...body].map((row) => row.map((cell) => renderInline(cell, context)));
241
+ const natural = header.map((_, column) => Math.max(1, ...all.map((row) => Math.max(...row[column].split("\n").map(displayWidth), 0))));
242
+ const chrome = header.length * 3 + 1;
243
+ const widths = fitColumns(natural, Math.max(header.length * 4, width - chrome));
244
+ const border = (left, middle, right) => pal.dim(left + widths.map((cell) => "─".repeat(cell + 2)).join(middle) + right);
245
+ const renderRow = (cells, bold) => {
246
+ const wrapped = cells.map((cell, column) => wrapAnsi(cell, widths[column]));
247
+ const height = Math.max(...wrapped.map((cell) => cell.length));
248
+ return Array.from({ length: height }, (_, lineIndex) => {
249
+ const parts = wrapped.map((cell, column) => {
250
+ const text = cell[lineIndex] ?? "";
251
+ const styled = bold ? pal.bold(text) : text;
252
+ const gap = widths[column] - displayWidth(text);
253
+ const align = aligns[column] ?? "left";
254
+ if (align === "right")
255
+ return " ".repeat(gap) + styled;
256
+ if (align === "center")
257
+ return " ".repeat(Math.floor(gap / 2)) + styled + " ".repeat(gap - Math.floor(gap / 2));
258
+ return padEnd(styled, widths[column]);
259
+ });
260
+ return `${pal.dim("│")} ${parts.join(` ${pal.dim("│")} `)} ${pal.dim("│")}`;
261
+ });
262
+ };
263
+ const out = [border("┌", "┬", "┐"), ...renderRow(all[0], true), border("├", "┼", "┤")];
264
+ all.slice(1).forEach((row) => out.push(...renderRow(row, false)));
265
+ out.push(border("└", "┴", "┘"));
266
+ return out;
267
+ }
268
+ // ---------- inline ----------
269
+ export function renderInline(text, context) {
270
+ const { pal } = context;
271
+ let out = "";
272
+ let index = 0;
273
+ const flushPlain = (value) => {
274
+ out += value;
275
+ };
276
+ while (index < text.length) {
277
+ const char = text[index];
278
+ if (char === "\\" && index + 1 < text.length && /[\\`*_{}\[\]()#+\-.!|~<>]/.test(text[index + 1])) {
279
+ flushPlain(text[index + 1]);
280
+ index += 2;
281
+ continue;
282
+ }
283
+ if (char === "`") {
284
+ const run = /^`+/.exec(text.slice(index))[0];
285
+ const close = text.indexOf(run, index + run.length);
286
+ if (close > index + run.length - 1 && close !== -1) {
287
+ const code = text.slice(index + run.length, close).replace(/^ (.*) $/, "$1");
288
+ out += pal.cyan(code);
289
+ index = close + run.length;
290
+ continue;
291
+ }
292
+ flushPlain(run);
293
+ index += run.length;
294
+ continue;
295
+ }
296
+ if (text.startsWith("**", index) || text.startsWith("__", index)) {
297
+ const marker = text.slice(index, index + 2);
298
+ const close = findClosing(text, marker, index + 2);
299
+ if (close !== -1 && text[index + 2] !== " ") {
300
+ out += pal.bold(renderInline(text.slice(index + 2, close), context));
301
+ index = close + 2;
302
+ continue;
303
+ }
304
+ }
305
+ if (text.startsWith("~~", index)) {
306
+ const close = findClosing(text, "~~", index + 2);
307
+ if (close !== -1) {
308
+ out += pal.strike(renderInline(text.slice(index + 2, close), context));
309
+ index = close + 2;
310
+ continue;
311
+ }
312
+ }
313
+ if ((char === "*" || char === "_") && text[index + 1] !== " " && text[index + 1] !== undefined) {
314
+ const previous = text[index - 1];
315
+ const wordBefore = previous !== undefined && /[\p{L}\p{N}]/u.test(previous);
316
+ if (!(char === "_" && wordBefore)) {
317
+ const close = findClosing(text, char, index + 1);
318
+ if (close !== -1 && text[close - 1] !== " " && !(char === "_" && /[\p{L}\p{N}]/u.test(text[close + 1] ?? ""))) {
319
+ out += pal.italic(renderInline(text.slice(index + 1, close), context));
320
+ index = close + 1;
321
+ continue;
322
+ }
323
+ }
324
+ }
325
+ if (char === "[") {
326
+ const link = /^\[((?:[^\[\]]|\[[^\]]*\])*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.exec(text.slice(index));
327
+ if (link) {
328
+ const label = renderInline(link[1], context);
329
+ const url = link[2];
330
+ const shown = pal.underline(pal.blue(label));
331
+ out += context.links ? hyperlink(shown, url, true) : link[1] === url ? shown : `${shown} ${pal.dim(`(${url})`)}`;
332
+ index += link[0].length;
333
+ continue;
334
+ }
335
+ }
336
+ if (char === "<") {
337
+ const auto = /^<(https?:\/\/[^>\s]+)>/.exec(text.slice(index));
338
+ if (auto) {
339
+ out += pal.underline(pal.blue(auto[1]));
340
+ index += auto[0].length;
341
+ continue;
342
+ }
343
+ }
344
+ if (char === "h") {
345
+ const bare = /^https?:\/\/[^\s<>)\]]+[^\s<>)\].,;:!?'"]/.exec(text.slice(index));
346
+ if (bare && (index === 0 || /[\s(]/.test(text[index - 1]))) {
347
+ out += pal.underline(pal.blue(bare[0]));
348
+ index += bare[0].length;
349
+ continue;
350
+ }
351
+ }
352
+ flushPlain(char);
353
+ index += 1;
354
+ }
355
+ return out;
356
+ }
357
+ function findClosing(text, marker, from) {
358
+ let index = from;
359
+ while (index < text.length) {
360
+ if (text[index] === "`") {
361
+ const run = /^`+/.exec(text.slice(index))[0];
362
+ const close = text.indexOf(run, index + run.length);
363
+ index = close === -1 ? index + run.length : close + run.length;
364
+ continue;
365
+ }
366
+ if (text[index] === "\\") {
367
+ index += 2;
368
+ continue;
369
+ }
370
+ if (text.startsWith(marker, index) && index > from) {
371
+ if (marker.length === 1 && text[index + 1] === marker) {
372
+ index += 2;
373
+ continue;
374
+ }
375
+ return index;
376
+ }
377
+ index += 1;
378
+ }
379
+ return -1;
380
+ }
381
+ export function createInlineContext(color) {
382
+ return { pal: createPalette(color), color, links: false };
383
+ }
@@ -0,0 +1,22 @@
1
+ // `@path` mentions in a prompt: files that exist are named to the agent so that it reads them.
2
+ import { stat } from "node:fs/promises";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { imageMediaType } from "./images.js";
5
+ import { MENTION_NOTE } from "./transcript.js";
6
+ const MENTION = /(^|\s)@([^\s@]+)/g;
7
+ export async function expandMentions(prompt, cwd, home) {
8
+ const candidates = [...prompt.matchAll(MENTION)].map((match) => match[2].replace(/[.,;:!?)\]]+$/, ""));
9
+ const files = [];
10
+ const images = [];
11
+ for (const candidate of new Set(candidates)) {
12
+ const path = candidate.startsWith("~/") && home ? resolve(home, candidate.slice(2)) : isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
13
+ const info = await stat(path).catch(() => undefined);
14
+ if (info?.isFile() && imageMediaType(path))
15
+ images.push(path);
16
+ else if (info)
17
+ files.push(info.isDirectory() ? `${path}/` : path);
18
+ }
19
+ if (files.length === 0)
20
+ return { text: prompt, files, images };
21
+ return { text: `${prompt}${MENTION_NOTE}${files.map((file) => `- ${file}`).join("\n")}`, files, images };
22
+ }
@@ -0,0 +1,31 @@
1
+ // Permission presets: which plugins guard the agent. None of them restricts the working directory.
2
+ import { redactionPlugin, reviewPlugin } from "./framework.js";
3
+ export const PERMISSION_NAMES = ["auto", "read-only", "strict", "full-access"];
4
+ export const PERMISSION_PRESETS = {
5
+ auto: { name: "auto", summary: "every call is reviewed (hard rules, then a reviewer model); refusals go back to the agent", review: true, tools: undefined, breakerConsecutive: undefined },
6
+ "read-only": { name: "read-only", summary: "reviewed, and the agent only gets the read tool", review: true, tools: ["read"], breakerConsecutive: undefined },
7
+ strict: { name: "strict", summary: "reviewed; three refusals in a row pause the run", review: true, tools: undefined, breakerConsecutive: 3 },
8
+ "full-access": { name: "full-access", summary: "nothing is reviewed", review: false, tools: undefined, breakerConsecutive: undefined },
9
+ };
10
+ export function isPermissionName(value) {
11
+ return PERMISSION_NAMES.includes(value);
12
+ }
13
+ /**
14
+ * The plugins for a permission preset and the redaction switch. Redaction is added first so that the reviewer's own requests are masked too.
15
+ * Review is on unless the preset is full-access; redaction is off unless asked for.
16
+ */
17
+ export function buildGuards(options) {
18
+ const preset = PERMISSION_PRESETS[options.permissions];
19
+ const plugins = [];
20
+ const redaction = options.redaction ? redactionPlugin() : undefined;
21
+ if (redaction)
22
+ plugins.push(redaction);
23
+ if (preset.review) {
24
+ const llm = redaction ? redaction.wrap(options.reviewLlm) : options.reviewLlm;
25
+ plugins.push(reviewPlugin({ llm, directoryRules: false, ...(preset.breakerConsecutive !== undefined ? { breaker: { consecutive: preset.breakerConsecutive } } : {}) }));
26
+ }
27
+ return { plugins, tools: preset.tools };
28
+ }
29
+ export function describeThinking(level) {
30
+ return level ?? "default";
31
+ }