@lef237/hagumi 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +310 -2
- package/package.json +27 -4
- package/src/app.ts +24 -0
- package/src/board.ts +80 -0
- package/src/chat.ts +212 -0
- package/src/chatgpt.ts +181 -0
- package/src/cli.ts +177 -0
- package/src/compact.ts +41 -0
- package/src/config.ts +96 -0
- package/src/conversation.ts +88 -0
- package/src/diff.ts +31 -0
- package/src/faces.ts +34 -0
- package/src/i18n.ts +312 -0
- package/src/log.ts +35 -0
- package/src/memory.ts +132 -0
- package/src/policy.ts +45 -0
- package/src/prompt.ts +43 -0
- package/src/providers.ts +85 -0
- package/src/sandbox.ts +159 -0
- package/src/shell.ts +215 -0
- package/src/store.ts +19 -0
- package/src/terminal.ts +254 -0
- package/src/tools/bash.ts +46 -0
- package/src/tools/board.ts +34 -0
- package/src/tools/change.ts +49 -0
- package/src/tools/edit.ts +32 -0
- package/src/tools/index.ts +83 -0
- package/src/tools/read.ts +29 -0
- package/src/tools/remember.ts +39 -0
- package/src/tools/workspace.ts +41 -0
- package/src/tools/write.ts +23 -0
- package/src/wire/chat.ts +64 -0
- package/src/wire/index.ts +38 -0
- package/src/wire/messages.ts +75 -0
- package/src/wire/responses.ts +56 -0
- package/src/wire/stream.ts +57 -0
- package/src/workspaces.ts +75 -0
package/src/shell.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Reads a command line as the shell would, to find every program it would run.
|
|
2
|
+
// This is for judging commands against the user's rules; the sandbox is what actually contains them.
|
|
3
|
+
import { basename } from "node:path"
|
|
4
|
+
|
|
5
|
+
type Token = { kind: "word" | "op"; text: string }
|
|
6
|
+
|
|
7
|
+
// The simple commands a line would run, as argument lists, including those nested in substitutions,
|
|
8
|
+
// `bash -c`, `eval`, `find -exec` and wrappers such as `env` or `xargs`. `opaque` is set when the line holds
|
|
9
|
+
// anything whose effect cannot be read from its words: substitutions, loops, writes to files, scripts.
|
|
10
|
+
export type Analysis = { commands: string[][]; opaque: boolean }
|
|
11
|
+
|
|
12
|
+
const operators = ["&&", "||", "|&", ";;", ">>", "&>", "<<<", "<<", ">&", "<&", "|", "&", ";", "(", ")", "<", ">"]
|
|
13
|
+
const separators = new Set(["&&", "||", "|&", ";;", "|", "&", ";", "(", ")"])
|
|
14
|
+
const fileWrites = new Set([">", ">>", "&>"])
|
|
15
|
+
const keywords = new Set(["!", "{", "}", "then", "else", "elif", "fi", "do", "done", "esac", "time"])
|
|
16
|
+
const controls = new Set(["if", "for", "while", "until", "case", "select", "function"])
|
|
17
|
+
const shells = new Set(["bash", "sh", "zsh", "dash", "ksh"])
|
|
18
|
+
// Programs that run another program, with their options that take a value.
|
|
19
|
+
const wrappers: Record<string, string[]> = {
|
|
20
|
+
env: ["-u", "-C", "-S"],
|
|
21
|
+
command: [],
|
|
22
|
+
builtin: [],
|
|
23
|
+
exec: ["-a"],
|
|
24
|
+
nohup: [],
|
|
25
|
+
stdbuf: [],
|
|
26
|
+
nice: ["-n"],
|
|
27
|
+
timeout: ["-s", "-k"],
|
|
28
|
+
sudo: ["-u", "-g", "-C", "-D", "-h", "-p", "-U", "-r", "-t"],
|
|
29
|
+
doas: ["-u", "-C"],
|
|
30
|
+
xargs: ["-I", "-n", "-P", "-L", "-s", "-E", "-d", "-a", "-J", "-R", "-S"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function analyze(line: string, depth = 0): Analysis {
|
|
34
|
+
if (depth > 3) return { commands: [], opaque: true }
|
|
35
|
+
const { tokens, nested, opaque: unreadable } = scan(line)
|
|
36
|
+
let opaque = unreadable || nested.length > 0
|
|
37
|
+
const commands: string[][] = []
|
|
38
|
+
let words: string[] = []
|
|
39
|
+
const endCommand = () => {
|
|
40
|
+
const found = commandsOf(words, depth)
|
|
41
|
+
commands.push(...found.commands)
|
|
42
|
+
opaque ||= found.opaque
|
|
43
|
+
words = []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
47
|
+
const token = tokens[i]!
|
|
48
|
+
if (token.kind === "word") words.push(token.text)
|
|
49
|
+
else if (separators.has(token.text)) endCommand()
|
|
50
|
+
else {
|
|
51
|
+
// A redirection: the next word is where it points, not an argument.
|
|
52
|
+
const target = tokens[i + 1]?.kind === "word" ? tokens[++i]!.text : ""
|
|
53
|
+
if (token.text === "<<") opaque = true
|
|
54
|
+
if (fileWrites.has(token.text) && target !== "/dev/null") opaque = true
|
|
55
|
+
if (token.text === ">&" && !/^\d+$/.test(target)) opaque = true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
endCommand()
|
|
59
|
+
for (const script of nested) commands.push(...analyze(script, depth + 1).commands)
|
|
60
|
+
return { commands, opaque }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function commandsOf(words: string[], depth: number): Analysis {
|
|
64
|
+
let opaque = false
|
|
65
|
+
const commands: string[][] = []
|
|
66
|
+
let rest = words
|
|
67
|
+
const skip = (count: number) => {
|
|
68
|
+
rest = rest.slice(count)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Keywords and variable assignments come before the program, not in its place.
|
|
72
|
+
while (rest[0] !== undefined && (keywords.has(rest[0]) || controls.has(rest[0]) || /^\w+=/.test(rest[0]))) {
|
|
73
|
+
if (controls.has(rest[0]) || /^\w+=/.test(rest[0])) opaque = true
|
|
74
|
+
skip(1)
|
|
75
|
+
}
|
|
76
|
+
while (rest[0] !== undefined && wrappers[basename(rest[0])]) {
|
|
77
|
+
const wrapper = basename(rest[0])
|
|
78
|
+
const takesValue = wrappers[wrapper]!
|
|
79
|
+
commands.push(rest)
|
|
80
|
+
if (wrapper === "env") opaque = true
|
|
81
|
+
skip(1)
|
|
82
|
+
while (rest[0] !== undefined && (rest[0].startsWith("-") || /^\w+=/.test(rest[0]))) {
|
|
83
|
+
skip(takesValue.includes(rest[0]) ? 2 : 1)
|
|
84
|
+
}
|
|
85
|
+
if (wrapper === "timeout") skip(1)
|
|
86
|
+
}
|
|
87
|
+
if (rest[0] === undefined) return { commands, opaque }
|
|
88
|
+
|
|
89
|
+
const program = basename(rest[0])
|
|
90
|
+
const argv = [program, ...rest.slice(1)]
|
|
91
|
+
commands.push(rest)
|
|
92
|
+
if (program.startsWith("$") || program === "source" || program === ".") opaque = true
|
|
93
|
+
if (shells.has(program) && argv.length > 1) {
|
|
94
|
+
opaque = true
|
|
95
|
+
const script = argv[argv.indexOf("-c") + 1]
|
|
96
|
+
if (argv.includes("-c") && script) commands.push(...analyze(script, depth + 1).commands)
|
|
97
|
+
}
|
|
98
|
+
if (program === "eval") {
|
|
99
|
+
opaque = true
|
|
100
|
+
commands.push(...analyze(argv.slice(1).join(" "), depth + 1).commands)
|
|
101
|
+
}
|
|
102
|
+
if (program === "find") {
|
|
103
|
+
argv.forEach((word, index) => {
|
|
104
|
+
if (!["-exec", "-execdir", "-ok", "-okdir"].includes(word)) return
|
|
105
|
+
const after = argv.slice(index + 1)
|
|
106
|
+
const end = after.findIndex((part) => part === ";" || part === "+")
|
|
107
|
+
commands.push(...commandsOf(end < 0 ? after : after.slice(0, end), depth).commands)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
return { commands, opaque }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Splits a line into words and operators as the shell does, and collects the scripts nested in
|
|
114
|
+
// $(...), `...`, <(...) and >(...).
|
|
115
|
+
function scan(line: string): { tokens: Token[]; nested: string[]; opaque: boolean } {
|
|
116
|
+
const tokens: Token[] = []
|
|
117
|
+
const nested: string[] = []
|
|
118
|
+
let opaque = false
|
|
119
|
+
let word: string | undefined
|
|
120
|
+
const add = (text: string) => {
|
|
121
|
+
word = (word ?? "") + text
|
|
122
|
+
}
|
|
123
|
+
const flush = () => {
|
|
124
|
+
if (word !== undefined) tokens.push({ kind: "word", text: word })
|
|
125
|
+
word = undefined
|
|
126
|
+
}
|
|
127
|
+
// Records a nested script starting at `start`, and returns the index of its closing character.
|
|
128
|
+
const substitution = (start: number, close: ")" | "`"): number => {
|
|
129
|
+
const end = closing(line, start, close)
|
|
130
|
+
nested.push(line.slice(start, end))
|
|
131
|
+
if (end >= line.length) opaque = true
|
|
132
|
+
return end
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let i = 0
|
|
136
|
+
while (i < line.length) {
|
|
137
|
+
const c = line[i]!
|
|
138
|
+
if (c === "\\") {
|
|
139
|
+
if (line[i + 1] !== "\n") add(line[i + 1] ?? "")
|
|
140
|
+
i += 2
|
|
141
|
+
} else if (c === "'") {
|
|
142
|
+
const end = line.indexOf("'", i + 1)
|
|
143
|
+
if (end < 0) opaque = true
|
|
144
|
+
add(line.slice(i + 1, end < 0 ? undefined : end))
|
|
145
|
+
i = end < 0 ? line.length : end + 1
|
|
146
|
+
} else if (c === '"') {
|
|
147
|
+
add("")
|
|
148
|
+
i++
|
|
149
|
+
while (i < line.length && line[i] !== '"') {
|
|
150
|
+
if (line[i] === "\\" && '"\\$`'.includes(line[i + 1] ?? "")) {
|
|
151
|
+
add(line[i + 1]!)
|
|
152
|
+
i += 2
|
|
153
|
+
} else if (line.startsWith("$(", i) || line[i] === "`") {
|
|
154
|
+
const start = line[i] === "`" ? i + 1 : i + 2
|
|
155
|
+
const end = substitution(start, line[i] === "`" ? "`" : ")")
|
|
156
|
+
add(line.slice(i, end + 1))
|
|
157
|
+
i = end + 1
|
|
158
|
+
} else {
|
|
159
|
+
add(line[i]!)
|
|
160
|
+
i++
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (i >= line.length) opaque = true
|
|
164
|
+
i++
|
|
165
|
+
} else if (line.startsWith("$(", i) || line.startsWith("<(", i) || line.startsWith(">(", i) || c === "`") {
|
|
166
|
+
const start = c === "`" ? i + 1 : i + 2
|
|
167
|
+
const end = substitution(start, c === "`" ? "`" : ")")
|
|
168
|
+
add(line.slice(i, end + 1))
|
|
169
|
+
i = end + 1
|
|
170
|
+
} else if (c === "\n") {
|
|
171
|
+
flush()
|
|
172
|
+
tokens.push({ kind: "op", text: ";" })
|
|
173
|
+
i++
|
|
174
|
+
} else if (c === " " || c === "\t") {
|
|
175
|
+
flush()
|
|
176
|
+
i++
|
|
177
|
+
} else if (c === "#" && word === undefined) {
|
|
178
|
+
const end = line.indexOf("\n", i)
|
|
179
|
+
i = end < 0 ? line.length : end
|
|
180
|
+
} else {
|
|
181
|
+
const op = operators.find((candidate) => line.startsWith(candidate, i))
|
|
182
|
+
if (!op) {
|
|
183
|
+
add(c)
|
|
184
|
+
i++
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
// A file descriptor written right before a redirection belongs to it, as in 2>&1.
|
|
188
|
+
if (word !== undefined && /^\d+$/.test(word) && /[<>]/.test(op)) word = undefined
|
|
189
|
+
flush()
|
|
190
|
+
tokens.push({ kind: "op", text: op })
|
|
191
|
+
i += op.length
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
flush()
|
|
195
|
+
return { tokens, nested, opaque }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function closing(line: string, start: number, close: ")" | "`"): number {
|
|
199
|
+
if (close === "`") {
|
|
200
|
+
const end = line.indexOf("`", start)
|
|
201
|
+
return end < 0 ? line.length : end
|
|
202
|
+
}
|
|
203
|
+
let depth = 1
|
|
204
|
+
for (let i = start; i < line.length; i++) {
|
|
205
|
+
const c = line[i]
|
|
206
|
+
if (c === "\\") i++
|
|
207
|
+
else if (c === "'") {
|
|
208
|
+
const end = line.indexOf("'", i + 1)
|
|
209
|
+
if (end < 0) return line.length
|
|
210
|
+
i = end
|
|
211
|
+
} else if (c === "(") depth++
|
|
212
|
+
else if (c === ")" && --depth === 0) return i
|
|
213
|
+
}
|
|
214
|
+
return line.length
|
|
215
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
|
|
4
|
+
export async function readJson<T>(path: string): Promise<T | undefined> {
|
|
5
|
+
const file = Bun.file(path)
|
|
6
|
+
return (await file.exists()) ? ((await file.json()) as T) : undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function writeJson(path: string, value: unknown): Promise<void> {
|
|
10
|
+
await mkdir(dirname(path), { recursive: true })
|
|
11
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Secrets are readable by the user only, including files that already existed.
|
|
15
|
+
export async function writeSecret(path: string, value: unknown): Promise<void> {
|
|
16
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
17
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
|
18
|
+
await chmod(path, 0o600)
|
|
19
|
+
}
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Everything that talks to the terminal: reading lines and secrets, and showing turns and changes.
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { basename } from "node:path"
|
|
4
|
+
import { createInterface, type Interface } from "node:readline"
|
|
5
|
+
import { type Board, boardFrom } from "./board"
|
|
6
|
+
import type { TurnEvent } from "./conversation"
|
|
7
|
+
import { type DiffLine, hunk } from "./diff"
|
|
8
|
+
import { type FaceSet, withFace } from "./faces"
|
|
9
|
+
import { Problem, t } from "./i18n"
|
|
10
|
+
import type { Verdict } from "./policy"
|
|
11
|
+
import type { ApprovalRequest, Change, ToolContext } from "./tools"
|
|
12
|
+
|
|
13
|
+
const shownDiffLines = 40
|
|
14
|
+
const tty = process.stdout.isTTY
|
|
15
|
+
|
|
16
|
+
// Shows a turn as it happens. The face follows along: thinking while the model works on a reply,
|
|
17
|
+
// working on a tool, troubled by a failure; a reply after good work comes with a happy face.
|
|
18
|
+
export async function show(events: AsyncIterable<TurnEvent>, faces: FaceSet): Promise<void> {
|
|
19
|
+
let midLine = false
|
|
20
|
+
let thinking = false
|
|
21
|
+
let replying = false
|
|
22
|
+
let worked = false
|
|
23
|
+
let stumbled = false
|
|
24
|
+
// Text and tool work are set apart by a blank line, and so is the turn from the line the user typed;
|
|
25
|
+
// tool calls in a row stay together.
|
|
26
|
+
let part: "text" | "tools" | undefined
|
|
27
|
+
// The thinking face is a placeholder on the current line, wiped once something real arrives.
|
|
28
|
+
const settle = () => {
|
|
29
|
+
if (thinking) process.stdout.write("\r\x1b[2K")
|
|
30
|
+
thinking = false
|
|
31
|
+
}
|
|
32
|
+
const line = (content: string) => {
|
|
33
|
+
settle()
|
|
34
|
+
if (midLine) process.stdout.write("\n")
|
|
35
|
+
console.log(content)
|
|
36
|
+
midLine = false
|
|
37
|
+
replying = false
|
|
38
|
+
}
|
|
39
|
+
const enter = (next: "text" | "tools") => {
|
|
40
|
+
if (part === next) return
|
|
41
|
+
settle()
|
|
42
|
+
process.stdout.write(midLine ? "\n\n" : "\n")
|
|
43
|
+
midLine = false
|
|
44
|
+
replying = false
|
|
45
|
+
part = next
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for await (const event of events) {
|
|
49
|
+
if (event.kind === "thinking") {
|
|
50
|
+
replying = false
|
|
51
|
+
if (tty && !midLine && !thinking) {
|
|
52
|
+
process.stdout.write(faces.thinking)
|
|
53
|
+
thinking = true
|
|
54
|
+
}
|
|
55
|
+
} else if (event.kind === "text") {
|
|
56
|
+
enter("text")
|
|
57
|
+
settle()
|
|
58
|
+
if (!replying) process.stdout.write(withFace(stumbled ? faces.troubled : worked ? faces.happy : faces.replying, ""))
|
|
59
|
+
replying = true
|
|
60
|
+
process.stdout.write(event.delta)
|
|
61
|
+
midLine = !event.delta.endsWith("\n")
|
|
62
|
+
} else if (event.kind === "compacted") {
|
|
63
|
+
enter("tools")
|
|
64
|
+
line(dim(t.compacted(event.outputs)))
|
|
65
|
+
} else if (event.kind === "tool_call") {
|
|
66
|
+
enter("tools")
|
|
67
|
+
const board = event.call.name === "board" ? boardIn(event.call.arguments) : undefined
|
|
68
|
+
line(withFace(faces.working, board ? `→ board\n${boardChange(board)}` : `→ ${event.call.name} ${clip(event.call.arguments, 80)}`))
|
|
69
|
+
} else if (event.ok) {
|
|
70
|
+
worked = true
|
|
71
|
+
} else {
|
|
72
|
+
stumbled = true
|
|
73
|
+
line(withFace(faces.troubled, `✗ ${event.call.name}`))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
settle()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A board in the user's language, each column with its items.
|
|
80
|
+
export function boardView(board: Board): string {
|
|
81
|
+
return (["doing", "todo", "done"] as const)
|
|
82
|
+
.filter((column) => board[column].length > 0)
|
|
83
|
+
.map((column) => [`${t.boardColumns[column]}:`, ...board[column].map((item) => ` - ${item}`)].join("\n"))
|
|
84
|
+
.join("\n")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// One project in the list: its name, what it is doing, and what comes next.
|
|
88
|
+
export function projectLine(workspace: string, board: Board): string {
|
|
89
|
+
return [
|
|
90
|
+
basename(workspace),
|
|
91
|
+
...(board.doing[0] ? [t.boardNow(clip(board.doing[0], 24))] : []),
|
|
92
|
+
...(board.todo[0] ? [t.boardNext(clip(board.todo[0], 24))] : []),
|
|
93
|
+
].join(" ")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// What the board says now, in a few lines: what is being done, what comes next, and the newest work done.
|
|
97
|
+
function boardChange(board: Board): string {
|
|
98
|
+
return [
|
|
99
|
+
...(["doing", "todo"] as const)
|
|
100
|
+
.filter((column) => board[column].length > 0)
|
|
101
|
+
.map((column) => ` ${t.boardColumns[column]}: ${board[column].join(" / ")}`),
|
|
102
|
+
...(board.done[0] ? [` ${t.boardColumns.done}: ${board.done[0]}`] : []),
|
|
103
|
+
].join("\n")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The board the model wrote, when its arguments read as one.
|
|
107
|
+
function boardIn(args: string): Board | undefined {
|
|
108
|
+
try {
|
|
109
|
+
return boardFrom(JSON.parse(args))
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A path as people write it, with the home directory as ~.
|
|
116
|
+
export function tilde(path: string): string {
|
|
117
|
+
const home = homedir()
|
|
118
|
+
return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function dim(text: string): string {
|
|
122
|
+
return tty ? `\x1b[2m${text}\x1b[0m` : text
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The chat's one reader of the terminal. A pending question takes the next line; other lines queue up
|
|
126
|
+
// as messages, and Ctrl+D ends the chat once the current message is done.
|
|
127
|
+
export function chatInput(input: Interface) {
|
|
128
|
+
const prompt = input.getPrompt()
|
|
129
|
+
let answer: ((line: string) => void) | undefined
|
|
130
|
+
return {
|
|
131
|
+
question(asked: string): Promise<string> {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
answer = resolve
|
|
134
|
+
input.setPrompt(asked)
|
|
135
|
+
input.prompt()
|
|
136
|
+
})
|
|
137
|
+
},
|
|
138
|
+
async each(handle: (line: string) => Promise<void>): Promise<void> {
|
|
139
|
+
let queue = Promise.resolve()
|
|
140
|
+
let closed = false
|
|
141
|
+
input.on("line", (line) => {
|
|
142
|
+
if (answer) {
|
|
143
|
+
const resolve = answer
|
|
144
|
+
answer = undefined
|
|
145
|
+
input.setPrompt(prompt)
|
|
146
|
+
resolve(line)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
queue = queue.then(async () => {
|
|
150
|
+
await handle(line)
|
|
151
|
+
if (!closed) input.prompt()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
input.prompt()
|
|
155
|
+
await new Promise((resolve) => input.once("close", resolve))
|
|
156
|
+
closed = true
|
|
157
|
+
// A question left open by Ctrl+D is answered with nothing, which declines.
|
|
158
|
+
answer?.("")
|
|
159
|
+
await queue
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// One question outside a chat. Without a terminal there is no one to answer, so the answer is empty.
|
|
165
|
+
export async function askOnce(prompt: string): Promise<string> {
|
|
166
|
+
if (!process.stdin.isTTY) {
|
|
167
|
+
console.log(t.noTerminal)
|
|
168
|
+
return ""
|
|
169
|
+
}
|
|
170
|
+
const input = createInterface({ input: process.stdin, output: process.stdout })
|
|
171
|
+
const answer = await new Promise<string>((resolve) => input.question(prompt, resolve))
|
|
172
|
+
input.close()
|
|
173
|
+
return answer
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Shows each action and asks for approval. "a" approves the same tool for the rest of the session;
|
|
177
|
+
// what it does is still shown.
|
|
178
|
+
export function approver(question: (prompt: string) => Promise<string>, faces: FaceSet): ToolContext["approve"] {
|
|
179
|
+
const always = new Set<string>()
|
|
180
|
+
return async (request) => {
|
|
181
|
+
showRequest(request)
|
|
182
|
+
if (always.has(request.tool)) {
|
|
183
|
+
console.log(t.approvedForSession)
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
const answer = (await question(withFace(faces.waiting, t.approve))).trim().toLowerCase()
|
|
187
|
+
if (answer === "a") always.add(request.tool)
|
|
188
|
+
return answer === "y" || answer === "a"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// What the user's rules decided without asking.
|
|
193
|
+
export function notice(request: ApprovalRequest, verdict: Verdict): void {
|
|
194
|
+
showRequest(request)
|
|
195
|
+
if (verdict.kind === "allow") console.log(t.allowedByRule(verdict.rules.join(", ")))
|
|
196
|
+
if (verdict.kind === "deny") console.log(t.deniedByRule(verdict.rule))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function showRequest(request: ApprovalRequest): void {
|
|
200
|
+
if ("change" in request) return showChange(request.tool, request.change)
|
|
201
|
+
console.log(t.commandHeader(request.command))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function showChange(tool: string, change: Change): void {
|
|
205
|
+
console.log(t.changeHeader(tool, change.path, change.before === undefined))
|
|
206
|
+
const lines = hunk(change.before, change.after)
|
|
207
|
+
for (const line of lines.slice(0, shownDiffLines)) console.log(colored(line))
|
|
208
|
+
if (lines.length > shownDiffLines) console.log(t.moreLines(lines.length - shownDiffLines))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function colored({ mark, text }: DiffLine): string {
|
|
212
|
+
const content = ` ${mark} ${text}`
|
|
213
|
+
if (!process.stdout.isTTY) return content
|
|
214
|
+
const color = { "-": 31, "+": 32, " ": 2 }[mark]
|
|
215
|
+
return `\x1b[${color}m${content}\x1b[0m`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Reads one line without echoing it, so that keys do not stay on the screen.
|
|
219
|
+
export async function readSecret(question: string): Promise<string> {
|
|
220
|
+
process.stdout.write(question)
|
|
221
|
+
if (!process.stdin.isTTY) {
|
|
222
|
+
const value = (await Bun.stdin.text()).trim()
|
|
223
|
+
process.stdout.write("\n")
|
|
224
|
+
return value
|
|
225
|
+
}
|
|
226
|
+
process.stdin.setRawMode(true)
|
|
227
|
+
let value = ""
|
|
228
|
+
try {
|
|
229
|
+
for await (const chunk of process.stdin) {
|
|
230
|
+
for (const char of String(chunk)) {
|
|
231
|
+
if (char === "\u0003") throw new Problem("cancelled")
|
|
232
|
+
if (char === "\r" || char === "\n") return value.trim()
|
|
233
|
+
value = char === "\u007f" ? value.slice(0, -1) : value + char
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return value.trim()
|
|
237
|
+
} finally {
|
|
238
|
+
process.stdin.setRawMode(false)
|
|
239
|
+
process.stdout.write("\n")
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function report(error: unknown): void {
|
|
244
|
+
console.error(error instanceof Problem ? t.problem[error.key](error.detail) : error)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function openBrowser(url: string): void {
|
|
248
|
+
const opener = process.platform === "darwin" ? "open" : Bun.which("xdg-open")
|
|
249
|
+
if (opener) Bun.spawn([opener, url], { stdout: "ignore", stderr: "ignore" })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function clip(text: string, length: number): string {
|
|
253
|
+
return text.length > length ? `${text.slice(0, length - 1)}…` : text
|
|
254
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Tool } from "."
|
|
2
|
+
import { judge } from "../policy"
|
|
3
|
+
import { type CommandResult, runSandboxed } from "../sandbox"
|
|
4
|
+
|
|
5
|
+
const defaultTimeout = 120
|
|
6
|
+
const maxTimeout = 600
|
|
7
|
+
|
|
8
|
+
export const bash: Tool = {
|
|
9
|
+
name: "bash",
|
|
10
|
+
kind: "act",
|
|
11
|
+
description:
|
|
12
|
+
"Run a shell command with bash in the working directory. It can read outside the working directory, " +
|
|
13
|
+
"except blocked credential locations such as ~/.ssh and hagumi's storage directory. Other sensitive files are not automatically hidden. " +
|
|
14
|
+
"Read an outside path only when the user explicitly requests it. It runs in a sandbox: it can write to " +
|
|
15
|
+
"the working directory and temporary directories and cannot use the network. " +
|
|
16
|
+
`Commands follow the user's allow/deny rules and approval. It stops after timeout_seconds (default ${defaultTimeout}).`,
|
|
17
|
+
parameters: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
command: { type: "string", description: "The command line, run with bash -c." },
|
|
21
|
+
timeout_seconds: { type: "integer", minimum: 1, maximum: maxTimeout },
|
|
22
|
+
},
|
|
23
|
+
required: ["command"],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
async plan(input, context) {
|
|
27
|
+
const command = String(input.command)
|
|
28
|
+
const timeoutSeconds = Math.min(Number(input.timeout_seconds ?? defaultTimeout), maxTimeout)
|
|
29
|
+
return {
|
|
30
|
+
request: { tool: "bash", command },
|
|
31
|
+
verdict: judge(command, context.rules),
|
|
32
|
+
perform: async () =>
|
|
33
|
+
describe(await runSandboxed({ command, workspace: context.workspace, timeoutSeconds }), timeoutSeconds),
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function describe(result: CommandResult, timeoutSeconds: number): string {
|
|
39
|
+
return [
|
|
40
|
+
result.timedOut ? `Stopped after ${timeoutSeconds} seconds.` : `Exit code: ${result.exitCode}`,
|
|
41
|
+
result.stdout && `stdout:\n${result.stdout}`,
|
|
42
|
+
result.stderr && `stderr:\n${result.stderr}`,
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join("\n")
|
|
46
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Tool } from "."
|
|
2
|
+
import { boardFrom, writeBoard } from "../board"
|
|
3
|
+
import { boardRule } from "../prompt"
|
|
4
|
+
|
|
5
|
+
const column = (description: string) => ({ type: "array", items: { type: "string" }, description })
|
|
6
|
+
|
|
7
|
+
// Keeps the board of this project as the work moves, without asking: it is hagumi's own note of the work,
|
|
8
|
+
// shown on the screen at every change (docs/concept.md §6.1).
|
|
9
|
+
export const board: Tool = {
|
|
10
|
+
name: "board",
|
|
11
|
+
kind: "look",
|
|
12
|
+
description: [
|
|
13
|
+
"Keep the board of this project: what you are doing now, what to do next with the most important first, and what is done.",
|
|
14
|
+
boardRule,
|
|
15
|
+
"Write the whole board each time, as it stands now, so that the work can be picked up in a later session.",
|
|
16
|
+
"Skip it for a quick question with no steps. Write each item as one short line, in the language you are speaking.",
|
|
17
|
+
].join(" "),
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
doing: column("What you are doing now."),
|
|
22
|
+
todo: column("What to do next, most important first."),
|
|
23
|
+
done: column("What is done, newest first."),
|
|
24
|
+
},
|
|
25
|
+
required: ["doing", "todo", "done"],
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
},
|
|
28
|
+
async run(input, context) {
|
|
29
|
+
const board = boardFrom(input)
|
|
30
|
+
if (!board) throw new Error("Write doing, todo and done as lists of short lines.")
|
|
31
|
+
await writeBoard(context.workspace, board, context.boards)
|
|
32
|
+
return "The board is kept."
|
|
33
|
+
},
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Changes to files: planned by a tool, approved by the user, then written here.
|
|
2
|
+
import { constants } from "node:fs"
|
|
3
|
+
import { mkdir, open, realpath, stat } from "node:fs/promises"
|
|
4
|
+
import { dirname, relative, sep } from "node:path"
|
|
5
|
+
import type { ToolContext } from "."
|
|
6
|
+
import { fingerprint, resolveInWorkspace } from "./workspace"
|
|
7
|
+
|
|
8
|
+
// `before` is undefined for a new file.
|
|
9
|
+
export type Change = { path: string; target: string; before: string | undefined; after: string }
|
|
10
|
+
|
|
11
|
+
// Resolves where a change would land, and what is there now.
|
|
12
|
+
export async function current(path: string, context: ToolContext): Promise<Omit<Change, "after">> {
|
|
13
|
+
const target = await resolveInWorkspace(path, context.workspace)
|
|
14
|
+
// Git runs hooks from here; nothing the model writes should land in it.
|
|
15
|
+
const fromRoot = relative(await realpath(context.workspace), target)
|
|
16
|
+
if (fromRoot.split(sep).includes(".git")) throw new Error(`${path} is inside .git`)
|
|
17
|
+
const file = Bun.file(target)
|
|
18
|
+
if (!(await file.exists())) return { path, target, before: undefined }
|
|
19
|
+
// Another hard link to the file may live outside the workspace, and would change with it.
|
|
20
|
+
if ((await stat(target)).nlink > 1) throw new Error(`${path} has more than one hard link`)
|
|
21
|
+
|
|
22
|
+
// A file is changed only as the model last read it, so that no one's edits are lost.
|
|
23
|
+
const before = await file.text()
|
|
24
|
+
if (context.seen.get(target) !== fingerprint(before)) {
|
|
25
|
+
throw new Error(`Read ${path} first: it has not been read in this session, or it changed since`)
|
|
26
|
+
}
|
|
27
|
+
return { path, target, before }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function apply(change: Change, context: ToolContext): Promise<string> {
|
|
31
|
+
// Approval can take a while. Look again, so that edits made meanwhile are kept,
|
|
32
|
+
// and a file swapped for a link meanwhile is not followed outside the workspace.
|
|
33
|
+
const latest = await current(change.path, context)
|
|
34
|
+
if (latest.target !== change.target || latest.before !== change.before) {
|
|
35
|
+
throw new Error(`Read ${change.path} again: it changed while awaiting approval`)
|
|
36
|
+
}
|
|
37
|
+
await mkdir(dirname(change.target), { recursive: true })
|
|
38
|
+
// Never through a link put in place at the last moment, and a new file must still be new.
|
|
39
|
+
const flags = constants.O_WRONLY | constants.O_NOFOLLOW | (change.before === undefined ? constants.O_CREAT | constants.O_EXCL : constants.O_TRUNC)
|
|
40
|
+
const file = await open(change.target, flags)
|
|
41
|
+
try {
|
|
42
|
+
await file.writeFile(change.after)
|
|
43
|
+
} finally {
|
|
44
|
+
await file.close()
|
|
45
|
+
}
|
|
46
|
+
context.seen.set(change.target, fingerprint(change.after))
|
|
47
|
+
const lines = change.after.split("\n").length
|
|
48
|
+
return `${change.before === undefined ? "Created" : "Updated"} ${change.path} (${lines} lines)`
|
|
49
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Tool } from "."
|
|
2
|
+
import { apply, current } from "./change"
|
|
3
|
+
|
|
4
|
+
export const edit: Tool = {
|
|
5
|
+
name: "edit",
|
|
6
|
+
kind: "act",
|
|
7
|
+
description:
|
|
8
|
+
"Replace one exact piece of text in a file inside the working directory. Read the file first. " +
|
|
9
|
+
"old_text must appear exactly once; include surrounding lines to make it unique. The user approves every change.",
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
path: { type: "string", description: "Relative to the working directory." },
|
|
14
|
+
old_text: { type: "string", description: "The exact text to replace, as it is in the file." },
|
|
15
|
+
new_text: { type: "string", description: "The text to put in its place." },
|
|
16
|
+
},
|
|
17
|
+
required: ["path", "old_text", "new_text"],
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
},
|
|
20
|
+
async plan(input, context) {
|
|
21
|
+
const path = String(input.path)
|
|
22
|
+
const oldText = String(input.old_text)
|
|
23
|
+
const found = await current(path, context)
|
|
24
|
+
if (found.before === undefined) throw new Error(`${path} does not exist; use write to create it`)
|
|
25
|
+
|
|
26
|
+
const count = found.before.split(oldText).length - 1
|
|
27
|
+
if (!oldText || count === 0) throw new Error(`old_text was not found in ${path}`)
|
|
28
|
+
if (count > 1) throw new Error(`old_text appears ${count} times in ${path}; include more lines to make it unique`)
|
|
29
|
+
const change = { ...found, after: found.before.replace(oldText, () => String(input.new_text)) }
|
|
30
|
+
return { request: { tool: "edit", change }, verdict: { kind: "ask" }, perform: () => apply(change, context) }
|
|
31
|
+
},
|
|
32
|
+
}
|