@lef237/hagumi 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.
- 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 +214 -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 +72 -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 +170 -0
- package/src/shell.ts +215 -0
- package/src/store.ts +46 -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 +79 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Tools the model may call. Each one is plain data plus a function.
|
|
2
|
+
import type { ToolCall } from "../conversation"
|
|
3
|
+
import { gitAvailable } from "../memory"
|
|
4
|
+
import type { CommandRules, Verdict } from "../policy"
|
|
5
|
+
import { sandboxAvailable } from "../sandbox"
|
|
6
|
+
import { bash } from "./bash"
|
|
7
|
+
import { board } from "./board"
|
|
8
|
+
import type { Change } from "./change"
|
|
9
|
+
import { edit } from "./edit"
|
|
10
|
+
import { read } from "./read"
|
|
11
|
+
import { remember } from "./remember"
|
|
12
|
+
import { write } from "./write"
|
|
13
|
+
|
|
14
|
+
export type { Change }
|
|
15
|
+
|
|
16
|
+
// What the user is asked to approve.
|
|
17
|
+
export type ApprovalRequest = { tool: string } & ({ change: Change } | { command: string })
|
|
18
|
+
|
|
19
|
+
// Lives as long as a chat: where the tools may reach, what the model has read, the user's rules,
|
|
20
|
+
// where memory is kept, and the screen that asks for approval or tells what the rules decided.
|
|
21
|
+
export type ToolContext = {
|
|
22
|
+
workspace: string
|
|
23
|
+
// memory.md and persona.md, which remember measures before proposing a fact.
|
|
24
|
+
memory: string
|
|
25
|
+
persona: string
|
|
26
|
+
// Where the boards of projects are kept.
|
|
27
|
+
boards: string
|
|
28
|
+
seen: Map<string, string>
|
|
29
|
+
rules: CommandRules
|
|
30
|
+
approve(request: ApprovalRequest): Promise<boolean>
|
|
31
|
+
notice(request: ApprovalRequest, verdict: Verdict): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// An action is fully planned before anything happens, so that the user approves exactly what will be done.
|
|
35
|
+
// The verdict says whether the user's rules already decided it.
|
|
36
|
+
export type Action = { request: ApprovalRequest; verdict: Verdict; perform(): Promise<string> }
|
|
37
|
+
|
|
38
|
+
type Common = {
|
|
39
|
+
name: string
|
|
40
|
+
description: string
|
|
41
|
+
// JSON Schema of the arguments.
|
|
42
|
+
parameters: object
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A look needs no approval: it reads, proposes as remember does, or keeps hagumi's own notes as board does.
|
|
46
|
+
// An act is planned first and performed only once the user approves it.
|
|
47
|
+
export type Tool =
|
|
48
|
+
| (Common & { kind: "look"; run(input: Record<string, unknown>, context: ToolContext): Promise<string> })
|
|
49
|
+
| (Common & { kind: "act"; plan(input: Record<string, unknown>, context: ToolContext): Promise<Action> })
|
|
50
|
+
|
|
51
|
+
export type ToolResult = { ok: boolean; output: string }
|
|
52
|
+
|
|
53
|
+
// bash is offered only where it can run sandboxed, and remember only where git can keep the history of
|
|
54
|
+
// what is remembered. The list never changes within a session.
|
|
55
|
+
export const tools: Tool[] = [read, write, edit, ...(sandboxAvailable ? [bash] : []), ...(gitAvailable ? [remember] : []), board]
|
|
56
|
+
|
|
57
|
+
const outputLimit = 20_000
|
|
58
|
+
|
|
59
|
+
// Runs one call. Whatever goes wrong becomes the result the model reads, so that it can correct itself.
|
|
60
|
+
export async function runTool(call: ToolCall, context: ToolContext): Promise<ToolResult> {
|
|
61
|
+
const tool = tools.find((candidate) => candidate.name === call.name)
|
|
62
|
+
if (!tool) return { ok: false, output: `Unknown tool: ${call.name}` }
|
|
63
|
+
try {
|
|
64
|
+
const input = JSON.parse(call.arguments || "{}")
|
|
65
|
+
if (tool.kind === "look") return { ok: true, output: truncate(await tool.run(input, context)) }
|
|
66
|
+
|
|
67
|
+
const { request, verdict, perform } = await tool.plan(input, context)
|
|
68
|
+
if (verdict.kind === "deny") {
|
|
69
|
+
context.notice(request, verdict)
|
|
70
|
+
return { ok: false, output: `The user's settings forbid this (rule: ${verdict.rule}).` }
|
|
71
|
+
}
|
|
72
|
+
if (verdict.kind === "allow") context.notice(request, verdict)
|
|
73
|
+
else if (!(await context.approve(request))) return { ok: false, output: "The user declined this." }
|
|
74
|
+
return { ok: true, output: truncate(await perform()) }
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { ok: false, output: error instanceof Error ? error.message : String(error) }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function truncate(output: string): string {
|
|
81
|
+
if (output.length <= outputLimit) return output
|
|
82
|
+
return `${output.slice(0, outputLimit)}\n[truncated: ${output.length - outputLimit} more characters]`
|
|
83
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises"
|
|
2
|
+
import type { Tool } from "."
|
|
3
|
+
import { fingerprint, resolveInWorkspace } from "./workspace"
|
|
4
|
+
|
|
5
|
+
export const read: Tool = {
|
|
6
|
+
name: "read",
|
|
7
|
+
kind: "look",
|
|
8
|
+
description:
|
|
9
|
+
"Read a text file, or list a directory, inside the working directory. Paths are relative to the working directory.",
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: { path: { type: "string", description: "A file or directory, such as src/cli.ts or ." } },
|
|
13
|
+
required: ["path"],
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
},
|
|
16
|
+
async run(input, context) {
|
|
17
|
+
const target = await resolveInWorkspace(String(input.path), context.workspace)
|
|
18
|
+
if ((await stat(target)).isDirectory()) {
|
|
19
|
+
const entries = await readdir(target, { withFileTypes: true })
|
|
20
|
+
return entries
|
|
21
|
+
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
|
|
22
|
+
.sort()
|
|
23
|
+
.join("\n")
|
|
24
|
+
}
|
|
25
|
+
const content = await Bun.file(target).text()
|
|
26
|
+
context.seen.set(target, fingerprint(content))
|
|
27
|
+
return content
|
|
28
|
+
},
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Tool } from "."
|
|
2
|
+
import { factFrom, fits, placeFor } from "../memory"
|
|
3
|
+
|
|
4
|
+
// Only proposes: after the reply, hagumi keeps what came from the user's own words, and asks the user about
|
|
5
|
+
// anything that may come from a file or a command output (docs/design/memory.md).
|
|
6
|
+
export const remember: Tool = {
|
|
7
|
+
name: "remember",
|
|
8
|
+
kind: "look",
|
|
9
|
+
description: [
|
|
10
|
+
"Propose a fact worth remembering in later sessions: about the user, such as what they prefer and how they like to work; about the project, such as how it is built and tested; or about yourself, that is how you speak, such as your first person, tone, sentence endings, humor, and how you carry a conversation.",
|
|
11
|
+
"Write one short statement of fact, not an instruction, in the language you are speaking. About yourself, write a lasting habit rather than something that happened once; how you speak is about yourself even when the user asks for it.",
|
|
12
|
+
"Memory is shared by every project, so hagumi marks a fact about the project with the project's name.",
|
|
13
|
+
"After your reply, hagumi keeps it, asking the user first when it may come from a file or a command output; it takes effect from the next session.",
|
|
14
|
+
"Never propose secrets such as keys or passwords, or what matters only for the task at hand.",
|
|
15
|
+
].join(" "),
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
about: { type: "string", enum: ["user", "project", "self"], description: "Whom or what the fact is about." },
|
|
20
|
+
fact: { type: "string", description: 'One fact, such as "The user prefers short replies" or "The tests run with bun test".' },
|
|
21
|
+
},
|
|
22
|
+
required: ["about", "fact"],
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
},
|
|
25
|
+
async run(input, context) {
|
|
26
|
+
if (input.about !== "user" && input.about !== "project" && input.about !== "self") {
|
|
27
|
+
throw new Error('Say what the fact is about: "user", "project", or "self".')
|
|
28
|
+
}
|
|
29
|
+
const fact = factFrom(input, context.workspace)
|
|
30
|
+
if (!fact) throw new Error("Write the fact to remember.")
|
|
31
|
+
const { path, limit } = placeFor(input.about, context)
|
|
32
|
+
if (!(await fits(fact, path, limit))) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`${input.about === "self" ? "Your persona" : "Memory"} is full (up to ${limit} characters). Tell the user, who can tidy it by hand; \`hagumi memory\` shows where it is.`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
return "Proposed. After your reply, hagumi keeps it, asking the user first when it may come from a file or a command output."
|
|
38
|
+
},
|
|
39
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs"
|
|
2
|
+
import { realpath } from "node:fs/promises"
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
4
|
+
import { storageHome } from "../app"
|
|
5
|
+
|
|
6
|
+
// Resolves a path the model gave and refuses anything outside the workspace, symbolic links included.
|
|
7
|
+
// A path that does not exist yet is resolved through its nearest existing directory.
|
|
8
|
+
export async function resolveInWorkspace(path: string, workspace: string): Promise<string> {
|
|
9
|
+
const root = await realpath(workspace)
|
|
10
|
+
const target = await canonical(resolve(root, path))
|
|
11
|
+
const fromRoot = relative(root, target)
|
|
12
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
13
|
+
throw new Error(`${path} is outside the working directory`)
|
|
14
|
+
}
|
|
15
|
+
const fromStorage = relative(await canonical(storageHome), target)
|
|
16
|
+
if (fromStorage !== ".." && !fromStorage.startsWith(`..${sep}`) && !isAbsolute(fromStorage)) {
|
|
17
|
+
throw new Error(`${path} is inside hagumi's storage directory`)
|
|
18
|
+
}
|
|
19
|
+
return target
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function canonical(path: string): Promise<string> {
|
|
23
|
+
let existing = path
|
|
24
|
+
while (!exists(existing)) existing = dirname(existing)
|
|
25
|
+
return join(await realpath(existing), relative(existing, path))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// lstat, so that a symbolic link counts as existing even when what it points to does not.
|
|
29
|
+
function exists(path: string): boolean {
|
|
30
|
+
try {
|
|
31
|
+
lstatSync(path)
|
|
32
|
+
return true
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What the model last saw of a file, to notice changes made since.
|
|
39
|
+
export function fingerprint(content: string): string {
|
|
40
|
+
return String(Bun.hash(content))
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Tool } from "."
|
|
2
|
+
import { apply, current } from "./change"
|
|
3
|
+
|
|
4
|
+
export const write: Tool = {
|
|
5
|
+
name: "write",
|
|
6
|
+
kind: "act",
|
|
7
|
+
description:
|
|
8
|
+
"Create a file, or replace a whole file, inside the working directory. Read an existing file first. " +
|
|
9
|
+
"For small changes to an existing file, prefer edit. The user approves every change.",
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
path: { type: "string", description: "Relative to the working directory." },
|
|
14
|
+
content: { type: "string", description: "The complete new content of the file." },
|
|
15
|
+
},
|
|
16
|
+
required: ["path", "content"],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
async plan(input, context) {
|
|
20
|
+
const change = { ...(await current(String(input.path), context)), after: String(input.content) }
|
|
21
|
+
return { request: { tool: "write", change }, verdict: { kind: "ask" }, perform: () => apply(change, context) }
|
|
22
|
+
},
|
|
23
|
+
}
|
package/src/wire/chat.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The OpenAI-compatible Chat Completions API, spoken by most providers.
|
|
2
|
+
import type { Message, ToolCall } from "../conversation"
|
|
3
|
+
import { failure, type Wire } from "."
|
|
4
|
+
|
|
5
|
+
type ToolCallDelta = { index: number; id?: string; function?: { name?: string; arguments?: string } }
|
|
6
|
+
type ChatChunk = { choices?: { delta?: { content?: string | null; tool_calls?: ToolCallDelta[] } }[] }
|
|
7
|
+
|
|
8
|
+
export const chat: Wire = {
|
|
9
|
+
path: "/chat/completions",
|
|
10
|
+
auth: (key) => ({ Authorization: `Bearer ${key}` }),
|
|
11
|
+
request: ({ model, system, conversation, tools }) => ({
|
|
12
|
+
model,
|
|
13
|
+
messages: [{ role: "system", content: system }, ...conversation.map(chatMessage)],
|
|
14
|
+
tools: tools.map(({ name, description, parameters }) => ({
|
|
15
|
+
type: "function",
|
|
16
|
+
function: { name, description, parameters },
|
|
17
|
+
})),
|
|
18
|
+
stream: true,
|
|
19
|
+
stream_options: { include_usage: true },
|
|
20
|
+
}),
|
|
21
|
+
async *read(events) {
|
|
22
|
+
let text = ""
|
|
23
|
+
// A tool call arrives in pieces, matched by index.
|
|
24
|
+
const toolCalls: ToolCall[] = []
|
|
25
|
+
for await (const event of events) {
|
|
26
|
+
if ("error" in event) throw failure(event)
|
|
27
|
+
const delta = (event as ChatChunk).choices?.[0]?.delta
|
|
28
|
+
if (delta?.content) {
|
|
29
|
+
text += delta.content
|
|
30
|
+
yield { kind: "text", delta: delta.content }
|
|
31
|
+
}
|
|
32
|
+
for (const piece of delta?.tool_calls ?? []) {
|
|
33
|
+
const call = (toolCalls[piece.index] ??= { id: "", name: "", arguments: "" })
|
|
34
|
+
call.id ||= piece.id ?? ""
|
|
35
|
+
call.name += piece.function?.name ?? ""
|
|
36
|
+
call.arguments += piece.function?.arguments ?? ""
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { text, toolCalls: toolCalls.filter(Boolean) }
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function chatMessage(message: Message): object {
|
|
44
|
+
switch (message.role) {
|
|
45
|
+
case "user":
|
|
46
|
+
return { role: "user", content: message.text }
|
|
47
|
+
case "assistant":
|
|
48
|
+
return {
|
|
49
|
+
role: "assistant",
|
|
50
|
+
content: message.text || null,
|
|
51
|
+
...(message.toolCalls.length > 0
|
|
52
|
+
? {
|
|
53
|
+
tool_calls: message.toolCalls.map((call) => ({
|
|
54
|
+
id: call.id,
|
|
55
|
+
type: "function",
|
|
56
|
+
function: { name: call.name, arguments: call.arguments },
|
|
57
|
+
})),
|
|
58
|
+
}
|
|
59
|
+
: {}),
|
|
60
|
+
}
|
|
61
|
+
case "tool":
|
|
62
|
+
return { role: "tool", tool_call_id: message.callId, content: message.output }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A wire is one shape of model API. Providers differ in data; only wires and auth need code.
|
|
2
|
+
import type { Message, ToolCall, TurnEvent } from "../conversation"
|
|
3
|
+
import type { Tool } from "../tools"
|
|
4
|
+
import { chat } from "./chat"
|
|
5
|
+
import { messages } from "./messages"
|
|
6
|
+
import { responses } from "./responses"
|
|
7
|
+
|
|
8
|
+
export type StreamEvent = Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
export type Reply = { text: string; toolCalls: ToolCall[] }
|
|
11
|
+
|
|
12
|
+
export type RequestParts = {
|
|
13
|
+
model: string
|
|
14
|
+
system: string
|
|
15
|
+
conversation: Message[]
|
|
16
|
+
tools: Pick<Tool, "name" | "description" | "parameters">[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Wire = {
|
|
20
|
+
path: string
|
|
21
|
+
// How the credential travels: these headers are redacted in the log.
|
|
22
|
+
auth(key: string): Record<string, string>
|
|
23
|
+
request(parts: RequestParts): object
|
|
24
|
+
// Streams the reply text as it arrives, then returns the whole reply.
|
|
25
|
+
read(events: AsyncIterable<StreamEvent>): AsyncGenerator<Extract<TurnEvent, { kind: "text" }>, Reply>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const wires = {
|
|
29
|
+
"openai-responses": responses,
|
|
30
|
+
"openai-chat": chat,
|
|
31
|
+
"anthropic-messages": messages,
|
|
32
|
+
} satisfies Record<string, Wire>
|
|
33
|
+
|
|
34
|
+
export type WireName = keyof typeof wires
|
|
35
|
+
|
|
36
|
+
export function failure(event: StreamEvent): Error {
|
|
37
|
+
return new Error(`The response failed: ${JSON.stringify(event)}`)
|
|
38
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The Anthropic Messages API.
|
|
2
|
+
import type { Message, ToolCall } from "../conversation"
|
|
3
|
+
import { failure, type Wire } from "."
|
|
4
|
+
|
|
5
|
+
type Block = { type?: string; id?: string; name?: string }
|
|
6
|
+
type Delta = { type?: string; text?: string; partial_json?: string }
|
|
7
|
+
type AnthropicMessage = { role: "user" | "assistant"; content: string | object[] }
|
|
8
|
+
|
|
9
|
+
export const messages: Wire = {
|
|
10
|
+
path: "/messages",
|
|
11
|
+
auth: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
|
|
12
|
+
request: ({ model, system, conversation, tools }) => ({
|
|
13
|
+
model,
|
|
14
|
+
// Required by this API; generous enough for a conversational reply.
|
|
15
|
+
max_tokens: 8192,
|
|
16
|
+
system,
|
|
17
|
+
messages: anthropicMessages(conversation),
|
|
18
|
+
tools: tools.map(({ name, description, parameters }) => ({ name, description, input_schema: parameters })),
|
|
19
|
+
stream: true,
|
|
20
|
+
}),
|
|
21
|
+
async *read(events) {
|
|
22
|
+
let text = ""
|
|
23
|
+
// A tool call arrives as a block whose JSON input streams in pieces.
|
|
24
|
+
const toolCalls = new Map<number, ToolCall>()
|
|
25
|
+
for await (const event of events) {
|
|
26
|
+
if (event.type === "error") throw failure(event)
|
|
27
|
+
const block = event.content_block as Block | undefined
|
|
28
|
+
const delta = event.delta as Delta | undefined
|
|
29
|
+
if (event.type === "content_block_start" && block?.type === "tool_use") {
|
|
30
|
+
toolCalls.set(Number(event.index), { id: String(block.id), name: String(block.name), arguments: "" })
|
|
31
|
+
}
|
|
32
|
+
if (event.type !== "content_block_delta") continue
|
|
33
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
34
|
+
text += delta.text
|
|
35
|
+
yield { kind: "text", delta: delta.text }
|
|
36
|
+
}
|
|
37
|
+
const call = toolCalls.get(Number(event.index))
|
|
38
|
+
if (delta?.type === "input_json_delta" && call) call.arguments += delta.partial_json ?? ""
|
|
39
|
+
}
|
|
40
|
+
return { text, toolCalls: [...toolCalls.values()] }
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function anthropicMessages(conversation: Message[]): AnthropicMessage[] {
|
|
45
|
+
const result: AnthropicMessage[] = []
|
|
46
|
+
for (const message of conversation) {
|
|
47
|
+
if (message.role === "user") {
|
|
48
|
+
result.push({ role: "user", content: message.text })
|
|
49
|
+
} else if (message.role === "assistant") {
|
|
50
|
+
result.push({
|
|
51
|
+
role: "assistant",
|
|
52
|
+
content: [
|
|
53
|
+
...(message.text ? [{ type: "text", text: message.text }] : []),
|
|
54
|
+
...message.toolCalls.map((call) => ({ type: "tool_use", id: call.id, name: call.name, input: inputOf(call) })),
|
|
55
|
+
],
|
|
56
|
+
})
|
|
57
|
+
} else {
|
|
58
|
+
// The results of one step travel together in a single user message.
|
|
59
|
+
const block = { type: "tool_result", tool_use_id: message.callId, content: message.output }
|
|
60
|
+
const last = result.at(-1)
|
|
61
|
+
if (last?.role === "user" && Array.isArray(last.content)) last.content.push(block)
|
|
62
|
+
else result.push({ role: "user", content: [block] })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The model's arguments are kept as text; this API wants them as an object again.
|
|
69
|
+
function inputOf(call: ToolCall): object {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(call.arguments || "{}")
|
|
72
|
+
} catch {
|
|
73
|
+
return {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The OpenAI Responses API.
|
|
2
|
+
import type { Message, ToolCall } from "../conversation"
|
|
3
|
+
import { failure, type Wire } from "."
|
|
4
|
+
|
|
5
|
+
type OutputItem = { type?: string; call_id?: string; name?: string; arguments?: string }
|
|
6
|
+
|
|
7
|
+
export const responses: Wire = {
|
|
8
|
+
path: "/responses",
|
|
9
|
+
auth: (key) => ({ Authorization: `Bearer ${key}` }),
|
|
10
|
+
// Each request carries the conversation rebuilt from the local log. `store: false` asks the provider
|
|
11
|
+
// not to store responses for later API retrieval; other retention follows the provider's policies.
|
|
12
|
+
request: ({ model, system, conversation, tools }) => ({
|
|
13
|
+
model,
|
|
14
|
+
instructions: system,
|
|
15
|
+
input: conversation.flatMap(inputItems),
|
|
16
|
+
tools: tools.map(({ name, description, parameters }) => ({ type: "function", name, description, parameters })),
|
|
17
|
+
stream: true,
|
|
18
|
+
store: false,
|
|
19
|
+
}),
|
|
20
|
+
async *read(events) {
|
|
21
|
+
let text = ""
|
|
22
|
+
const toolCalls: ToolCall[] = []
|
|
23
|
+
for await (const event of events) {
|
|
24
|
+
if (event.type === "error" || event.type === "response.failed") throw failure(event)
|
|
25
|
+
if (event.type === "response.output_text.delta") {
|
|
26
|
+
const delta = String(event.delta)
|
|
27
|
+
text += delta
|
|
28
|
+
yield { kind: "text", delta }
|
|
29
|
+
}
|
|
30
|
+
const item = event.item as OutputItem | undefined
|
|
31
|
+
if (event.type === "response.output_item.done" && item?.type === "function_call") {
|
|
32
|
+
toolCalls.push({ id: String(item.call_id), name: String(item.name), arguments: item.arguments ?? "" })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { text, toolCalls }
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function inputItems(message: Message): object[] {
|
|
40
|
+
switch (message.role) {
|
|
41
|
+
case "user":
|
|
42
|
+
return [{ role: "user", content: [{ type: "input_text", text: message.text }] }]
|
|
43
|
+
case "assistant":
|
|
44
|
+
return [
|
|
45
|
+
...(message.text ? [{ role: "assistant", content: [{ type: "output_text", text: message.text }] }] : []),
|
|
46
|
+
...message.toolCalls.map((call) => ({
|
|
47
|
+
type: "function_call",
|
|
48
|
+
call_id: call.id,
|
|
49
|
+
name: call.name,
|
|
50
|
+
arguments: call.arguments,
|
|
51
|
+
})),
|
|
52
|
+
]
|
|
53
|
+
case "tool":
|
|
54
|
+
return [{ type: "function_call_output", call_id: message.callId, output: message.output }]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { SessionLog } from "../log"
|
|
2
|
+
import type { StreamEvent } from "."
|
|
3
|
+
|
|
4
|
+
const secretHeaders = ["authorization", "x-api-key"]
|
|
5
|
+
|
|
6
|
+
// Sends one request and yields its stream events. Both are logged as they are, except for secrets.
|
|
7
|
+
export async function* streamEvents(options: {
|
|
8
|
+
url: string
|
|
9
|
+
headers: Record<string, string>
|
|
10
|
+
body: object
|
|
11
|
+
log: SessionLog
|
|
12
|
+
}): AsyncGenerator<StreamEvent> {
|
|
13
|
+
const { url, headers, body, log } = options
|
|
14
|
+
await log.append({ kind: "request", url, headers: redact(headers), body })
|
|
15
|
+
|
|
16
|
+
const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) })
|
|
17
|
+
if (!response.ok || !response.body) {
|
|
18
|
+
const text = await response.text()
|
|
19
|
+
await log.append({ kind: "response_error", status: response.status, body: text })
|
|
20
|
+
throw new Error(`POST ${url} failed: ${response.status} ${text}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for await (const event of serverSentEvents(response.body)) {
|
|
24
|
+
await log.append({ kind: "response_event", event })
|
|
25
|
+
yield event
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function redact(headers: Record<string, string>): Record<string, string> {
|
|
30
|
+
return Object.fromEntries(
|
|
31
|
+
Object.entries(headers).map(([name, value]) => [
|
|
32
|
+
name,
|
|
33
|
+
secretHeaders.includes(name.toLowerCase()) ? "<redacted>" : value,
|
|
34
|
+
]),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Turns a text/event-stream body into the JSON payload of each event's `data:` lines.
|
|
39
|
+
export async function* serverSentEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<StreamEvent> {
|
|
40
|
+
const decoder = new TextDecoder()
|
|
41
|
+
let buffer = ""
|
|
42
|
+
for await (const chunk of body) {
|
|
43
|
+
buffer += decoder.decode(chunk, { stream: true }).replaceAll("\r\n", "\n")
|
|
44
|
+
let end = buffer.indexOf("\n\n")
|
|
45
|
+
while (end !== -1) {
|
|
46
|
+
const data = buffer
|
|
47
|
+
.slice(0, end)
|
|
48
|
+
.split("\n")
|
|
49
|
+
.filter((line) => line.startsWith("data:"))
|
|
50
|
+
.map((line) => line.slice(5).trimStart())
|
|
51
|
+
.join("\n")
|
|
52
|
+
buffer = buffer.slice(end + 2)
|
|
53
|
+
if (data && data !== "[DONE]") yield JSON.parse(data) as StreamEvent
|
|
54
|
+
end = buffer.indexOf("\n\n")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// hagumi new: a fresh place for each chat, named by the local date and time under the workspaces of the settings,
|
|
2
|
+
// such as ~/hagumi-work/2026-10-12/13-24 (docs/concept.md §7.4). It starts as a git repository, which the model
|
|
3
|
+
// could not create from inside its sandbox. A place left empty can be tidied away.
|
|
4
|
+
import { existsSync } from "node:fs"
|
|
5
|
+
import { mkdir, readdir, rm, rmdir } from "node:fs/promises"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import { dirname, join } from "node:path"
|
|
8
|
+
import { gitAvailable } from "./memory"
|
|
9
|
+
|
|
10
|
+
// The names hagumi new gives; nothing else under the workspaces is ever tidied away.
|
|
11
|
+
const day = /^\d{4}-\d{2}-\d{2}$/
|
|
12
|
+
const time = /^\d{2}-\d{2}(-\d+)?$/
|
|
13
|
+
|
|
14
|
+
// "~" in the settings stands for the home directory.
|
|
15
|
+
export function expandHome(path: string): string {
|
|
16
|
+
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// mkdir fails on a directory that exists, so two starts in the same minute never share one: the second gets 13-24-2.
|
|
20
|
+
export async function newWorkspace(root: string, now = new Date()): Promise<string> {
|
|
21
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
22
|
+
const today = join(expandHome(root), `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`)
|
|
23
|
+
const minute = `${pad(now.getHours())}-${pad(now.getMinutes())}`
|
|
24
|
+
await mkdir(today, { recursive: true })
|
|
25
|
+
for (let n = 1; ; n++) {
|
|
26
|
+
const dir = join(today, n === 1 ? minute : `${minute}-${n}`)
|
|
27
|
+
try {
|
|
28
|
+
await mkdir(dir)
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue
|
|
31
|
+
throw error
|
|
32
|
+
}
|
|
33
|
+
if (gitAvailable) {
|
|
34
|
+
const git = Bun.spawn(["git", "init", "--quiet"], { cwd: dir, stdin: "ignore", stdout: "ignore", stderr: "pipe" })
|
|
35
|
+
if ((await git.exited) !== 0) throw new Error(`git init: ${(await new Response(git.stderr).text()).trim()}`)
|
|
36
|
+
}
|
|
37
|
+
return dir
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Empty: nothing but a repository without a single commit. Anything else keeps a place, even a hidden file,
|
|
42
|
+
// or commits whose files have been deleted since.
|
|
43
|
+
export async function isEmpty(dir: string): Promise<boolean> {
|
|
44
|
+
const entries = await readdir(dir)
|
|
45
|
+
if (entries.length === 0) return true
|
|
46
|
+
if (entries.length > 1 || entries[0] !== ".git" || !gitAvailable) return false
|
|
47
|
+
const head = Bun.spawn(["git", "rev-parse", "--verify", "--quiet", "HEAD"], { cwd: dir, stdin: "ignore", stdout: "ignore", stderr: "ignore" })
|
|
48
|
+
return (await head.exited) !== 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The empty places hagumi new made under the workspaces, oldest first.
|
|
52
|
+
export async function emptyWorkspaces(root: string): Promise<string[]> {
|
|
53
|
+
const base = expandHome(root)
|
|
54
|
+
if (!existsSync(base)) return []
|
|
55
|
+
const found: string[] = []
|
|
56
|
+
for (const date of await subdirectories(base, day)) {
|
|
57
|
+
for (const minute of await subdirectories(join(base, date), time)) {
|
|
58
|
+
if (await isEmpty(join(base, date, minute))) found.push(join(base, date, minute))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return found
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Removes a place only if it is still empty, and its day once nothing else is left there.
|
|
65
|
+
export async function tidy(dir: string): Promise<boolean> {
|
|
66
|
+
if (!(await isEmpty(dir))) return false
|
|
67
|
+
await rm(dir, { recursive: true })
|
|
68
|
+
if ((await readdir(dirname(dir))).length === 0) await rmdir(dirname(dir))
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function subdirectories(dir: string, name: RegExp): Promise<string[]> {
|
|
73
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
74
|
+
return entries.flatMap((entry) => (entry.isDirectory() && name.test(entry.name) ? [entry.name] : [])).sort()
|
|
75
|
+
}
|