@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
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The user's settings, in ~/.hagumi/config.json. The file is written with the defaults on first use,
|
|
2
|
+
// so that the settings can be read, and changed, in one place. Anything left out takes its default;
|
|
3
|
+
// `hagumi config` shows every value in effect. The file is never rewritten behind the user's back.
|
|
4
|
+
import { paths } from "./app"
|
|
5
|
+
import { type FaceSetName, faceSets } from "./faces"
|
|
6
|
+
import { Problem, resolveLanguage, type Spoken } from "./i18n"
|
|
7
|
+
import type { CommandRules } from "./policy"
|
|
8
|
+
import { readJson, writeJson } from "./store"
|
|
9
|
+
|
|
10
|
+
export const languages = ["auto", "ja", "en"] as const
|
|
11
|
+
export type Language = (typeof languages)[number]
|
|
12
|
+
|
|
13
|
+
export type Config = {
|
|
14
|
+
// What the partner is called, in each language: a name in another script would look like a mistake.
|
|
15
|
+
name: Record<Spoken, string>
|
|
16
|
+
// "auto" follows the system's locale.
|
|
17
|
+
language: Language
|
|
18
|
+
faces: FaceSetName
|
|
19
|
+
// The model a chat starts with, named <provider>/<model>.
|
|
20
|
+
model: string
|
|
21
|
+
// Where hagumi new makes a place for each chat; ~ is the home directory.
|
|
22
|
+
workspaces: string
|
|
23
|
+
commands: CommandRules
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function defaultConfig(): Config {
|
|
27
|
+
return {
|
|
28
|
+
name: { ja: "はぐみ", en: "Hagumi" },
|
|
29
|
+
language: "auto",
|
|
30
|
+
faces: "kaomoji",
|
|
31
|
+
model: "chatgpt/gpt-6-luna",
|
|
32
|
+
workspaces: "~/hagumi-work",
|
|
33
|
+
commands: {
|
|
34
|
+
// Commands that only look around.
|
|
35
|
+
allow: ["ls", "pwd", "cd", "echo", "cat", "head", "tail", "wc", "grep", "rg", "git status", "git diff", "git log", "git show"],
|
|
36
|
+
deny: ["sudo"],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The partner's name in the language it speaks now.
|
|
42
|
+
export function nameOf(config: Config): string {
|
|
43
|
+
return config.name[resolveLanguage(config.language)]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function loadConfig(path = paths.config): Promise<Config> {
|
|
47
|
+
let saved: unknown
|
|
48
|
+
try {
|
|
49
|
+
saved = await readJson(path)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Problem("badConfig", `${path}: ${error instanceof Error ? error.message : error}`)
|
|
52
|
+
}
|
|
53
|
+
const defaults = defaultConfig()
|
|
54
|
+
if (saved === undefined) {
|
|
55
|
+
await writeJson(path, defaults)
|
|
56
|
+
return defaults
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fields = (saved ?? {}) as Record<string, unknown>
|
|
60
|
+
const commands = (fields.commands ?? {}) as Record<string, unknown>
|
|
61
|
+
const wrong = (key: string, expected: string) => new Problem("badConfig", `${path}: ${key} must be ${expected}`)
|
|
62
|
+
const filled = (value: unknown) => typeof value === "string" && value.trim() !== ""
|
|
63
|
+
const oneOf = <T extends string>(key: string, allowed: readonly T[], fallback: T): T => {
|
|
64
|
+
const value = fields[key] ?? fallback
|
|
65
|
+
if (allowed.includes(value as T)) return value as T
|
|
66
|
+
throw wrong(key, `one of ${allowed.join(", ")}`)
|
|
67
|
+
}
|
|
68
|
+
const text = (key: string, fallback: string): string => {
|
|
69
|
+
const value = fields[key] ?? fallback
|
|
70
|
+
if (filled(value)) return value as string
|
|
71
|
+
throw wrong(key, "a non-empty string")
|
|
72
|
+
}
|
|
73
|
+
// One name for every language, or names by language; a language left out keeps its default name.
|
|
74
|
+
const names = (): Record<Spoken, string> => {
|
|
75
|
+
const value = fields.name ?? {}
|
|
76
|
+
if (filled(value)) return { ja: value as string, en: value as string }
|
|
77
|
+
const byLanguage = Object.entries(value as object)
|
|
78
|
+
if (typeof value === "object" && byLanguage.every(([language, name]) => language in defaults.name && filled(name))) {
|
|
79
|
+
return { ...defaults.name, ...(value as Partial<Record<Spoken, string>>) }
|
|
80
|
+
}
|
|
81
|
+
throw wrong("name", 'a name, or names by language such as { "ja": "はぐみ", "en": "Hagumi" }')
|
|
82
|
+
}
|
|
83
|
+
const list = (key: "allow" | "deny"): string[] => {
|
|
84
|
+
const value = commands[key] ?? []
|
|
85
|
+
if (Array.isArray(value) && value.every((rule) => typeof rule === "string")) return value
|
|
86
|
+
throw wrong(`commands.${key}`, "a list of strings")
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
name: names(),
|
|
90
|
+
language: oneOf("language", languages, defaults.language),
|
|
91
|
+
faces: oneOf("faces", Object.keys(faceSets) as FaceSetName[], defaults.faces),
|
|
92
|
+
model: text("model", defaults.model),
|
|
93
|
+
workspaces: text("workspaces", defaults.workspaces),
|
|
94
|
+
commands: { allow: list("allow"), deny: list("deny") },
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// hagumi's own view of a conversation. The session log is the source of truth:
|
|
2
|
+
// every request is rendered from it, so what the model sees can always be read back.
|
|
3
|
+
import { userAgent } from "./app"
|
|
4
|
+
import { compact } from "./compact"
|
|
5
|
+
import { Problem } from "./i18n"
|
|
6
|
+
import type { LogEntry, SessionLog } from "./log"
|
|
7
|
+
import { credential, type ModelChoice } from "./providers"
|
|
8
|
+
import { runTool, type Tool, type ToolContext, tools } from "./tools"
|
|
9
|
+
import { streamEvents } from "./wire/stream"
|
|
10
|
+
|
|
11
|
+
// `arguments` is the JSON text the model produced, kept as it was.
|
|
12
|
+
export type ToolCall = { id: string; name: string; arguments: string }
|
|
13
|
+
|
|
14
|
+
export type Message =
|
|
15
|
+
| { role: "user"; text: string }
|
|
16
|
+
| { role: "assistant"; text: string; toolCalls: ToolCall[] }
|
|
17
|
+
| { role: "tool"; callId: string; output: string }
|
|
18
|
+
|
|
19
|
+
// What a turn reports while it runs; the screen decides how to show it.
|
|
20
|
+
export type TurnEvent =
|
|
21
|
+
| { kind: "thinking" }
|
|
22
|
+
| { kind: "text"; delta: string }
|
|
23
|
+
| { kind: "tool_call"; call: ToolCall }
|
|
24
|
+
| { kind: "tool_result"; call: ToolCall; ok: boolean }
|
|
25
|
+
// Old tool outputs were left out of the conversation as this turn began.
|
|
26
|
+
| { kind: "compacted"; outputs: number }
|
|
27
|
+
|
|
28
|
+
// Enough for real work; stops a model that keeps calling tools from draining the plan.
|
|
29
|
+
const maxSteps = 30
|
|
30
|
+
|
|
31
|
+
export function messagesFrom(entries: LogEntry[]): Message[] {
|
|
32
|
+
return entries.flatMap((entry): Message[] => {
|
|
33
|
+
switch (entry.kind) {
|
|
34
|
+
case "user":
|
|
35
|
+
return [{ role: "user", text: String(entry.text) }]
|
|
36
|
+
case "assistant":
|
|
37
|
+
return [{ role: "assistant", text: String(entry.text), toolCalls: (entry.toolCalls as ToolCall[]) ?? [] }]
|
|
38
|
+
case "tool_result":
|
|
39
|
+
return [{ role: "tool", callId: String(entry.callId), output: String(entry.output) }]
|
|
40
|
+
default:
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// One exchange: the user speaks, then the model replies, calling tools until it has an answer.
|
|
47
|
+
export async function* turn(options: {
|
|
48
|
+
log: SessionLog
|
|
49
|
+
choice: ModelChoice
|
|
50
|
+
text: string
|
|
51
|
+
system: string
|
|
52
|
+
context: ToolContext
|
|
53
|
+
// The tools the model may call: every one, unless the turn needs none, as a search does.
|
|
54
|
+
tools?: Tool[]
|
|
55
|
+
}): AsyncGenerator<TurnEvent> {
|
|
56
|
+
const { log, choice, text, system, context, tools: offered = tools } = options
|
|
57
|
+
const { provider, wire } = choice
|
|
58
|
+
const key = await credential(provider)
|
|
59
|
+
await log.append({ kind: "user", text })
|
|
60
|
+
|
|
61
|
+
const headers = {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"User-Agent": userAgent,
|
|
64
|
+
[provider.sessionHeader]: log.id,
|
|
65
|
+
...wire.auth(key),
|
|
66
|
+
}
|
|
67
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
68
|
+
const { conversation, omitted } = compact(messagesFrom(await log.read()))
|
|
69
|
+
// What was left out can no longer be seen, so every file must be read again before it is changed.
|
|
70
|
+
if (step === 0 && omitted > 0) {
|
|
71
|
+
context.seen.clear()
|
|
72
|
+
yield { kind: "compacted", outputs: omitted }
|
|
73
|
+
}
|
|
74
|
+
yield { kind: "thinking" }
|
|
75
|
+
const body = wire.request({ model: choice.model, system, conversation, tools: offered })
|
|
76
|
+
const reply = yield* wire.read(streamEvents({ url: provider.baseUrl + wire.path, headers, body, log }))
|
|
77
|
+
await log.append({ kind: "assistant", text: reply.text, toolCalls: reply.toolCalls, model: choice.ref })
|
|
78
|
+
if (reply.toolCalls.length === 0) return
|
|
79
|
+
|
|
80
|
+
for (const call of reply.toolCalls) {
|
|
81
|
+
yield { kind: "tool_call", call }
|
|
82
|
+
const result = await runTool(call, context)
|
|
83
|
+
await log.append({ kind: "tool_result", callId: call.id, name: call.name, ...result })
|
|
84
|
+
yield { kind: "tool_result", call, ok: result.ok }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new Problem("tooManySteps", String(maxSteps))
|
|
88
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type DiffLine = { mark: " " | "-" | "+"; text: string }
|
|
2
|
+
|
|
3
|
+
// The changed region of two texts as a single hunk: lines shared at both ends are trimmed,
|
|
4
|
+
// keeping a little context. Enough to show one change for approval; `before` is undefined for a new file.
|
|
5
|
+
export function hunk(before: string | undefined, after: string, context = 2): DiffLine[] {
|
|
6
|
+
const added = (text: string): DiffLine => ({ mark: "+", text })
|
|
7
|
+
if (before === undefined) return lines(after).map(added)
|
|
8
|
+
|
|
9
|
+
const a = lines(before)
|
|
10
|
+
const b = lines(after)
|
|
11
|
+
let start = 0
|
|
12
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++
|
|
13
|
+
let endA = a.length
|
|
14
|
+
let endB = b.length
|
|
15
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
16
|
+
endA--
|
|
17
|
+
endB--
|
|
18
|
+
}
|
|
19
|
+
const kept = (text: string): DiffLine => ({ mark: " ", text })
|
|
20
|
+
return [
|
|
21
|
+
...a.slice(Math.max(0, start - context), start).map(kept),
|
|
22
|
+
...a.slice(start, endA).map((text): DiffLine => ({ mark: "-", text })),
|
|
23
|
+
...b.slice(start, endB).map(added),
|
|
24
|
+
...a.slice(endA, endA + context).map(kept),
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A final newline ends the last line rather than starting an empty one.
|
|
29
|
+
function lines(text: string): string[] {
|
|
30
|
+
return text.replace(/\n$/, "").split("\n")
|
|
31
|
+
}
|
package/src/faces.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// hagumi's face in each moment. Which set to wear is a setting of its own, apart from the language.
|
|
2
|
+
export type Mood = "greeting" | "thinking" | "working" | "waiting" | "troubled" | "replying" | "happy"
|
|
3
|
+
|
|
4
|
+
export type FaceSet = Record<Mood, string>
|
|
5
|
+
|
|
6
|
+
export const faceSets = {
|
|
7
|
+
kaomoji: {
|
|
8
|
+
greeting: "(・ω・)ノ",
|
|
9
|
+
thinking: "(・_・)…",
|
|
10
|
+
working: "(`・ω・´)",
|
|
11
|
+
waiting: "(・ω・)?",
|
|
12
|
+
troubled: "(´・ω・`)",
|
|
13
|
+
replying: "(・ω・)",
|
|
14
|
+
happy: "(^▽^)",
|
|
15
|
+
},
|
|
16
|
+
emoji: { greeting: "👋", thinking: "🤔", working: "🔧", waiting: "🙋", troubled: "😣", replying: "💬", happy: "😊" },
|
|
17
|
+
ascii: {
|
|
18
|
+
greeting: "(^_^)/",
|
|
19
|
+
thinking: "(o_o)...",
|
|
20
|
+
working: "(>_<)",
|
|
21
|
+
waiting: "(?_?)",
|
|
22
|
+
troubled: "(;_;)",
|
|
23
|
+
replying: "(^_^)",
|
|
24
|
+
happy: "(^o^)",
|
|
25
|
+
},
|
|
26
|
+
none: { greeting: "", thinking: "...", working: "", waiting: "", troubled: "", replying: "", happy: "" },
|
|
27
|
+
} satisfies Record<string, FaceSet>
|
|
28
|
+
|
|
29
|
+
export type FaceSetName = keyof typeof faceSets
|
|
30
|
+
|
|
31
|
+
// A face before some text, or just the text when the face is empty.
|
|
32
|
+
export function withFace(face: string, text: string): string {
|
|
33
|
+
return face ? `${face} ${text}` : text
|
|
34
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Every fixed piece of text the screen shows, in each language. The core never builds such text;
|
|
2
|
+
// what the partner says freely comes from the model, in the language the settings ask for.
|
|
3
|
+
|
|
4
|
+
// Problems the user can act on; anything else is reported as-is.
|
|
5
|
+
export type ProblemKey =
|
|
6
|
+
| "usage"
|
|
7
|
+
| "notSignedIn"
|
|
8
|
+
| "noTokenSharing"
|
|
9
|
+
| "sessionNotFound"
|
|
10
|
+
| "unknownModel"
|
|
11
|
+
| "unknownProvider"
|
|
12
|
+
| "tooManySteps"
|
|
13
|
+
| "badConfig"
|
|
14
|
+
| "unknownLanguage"
|
|
15
|
+
| "cancelled"
|
|
16
|
+
|
|
17
|
+
type Messages = {
|
|
18
|
+
problem: Record<ProblemKey, (detail: string) => string>
|
|
19
|
+
openUrl: (url: string) => string
|
|
20
|
+
askApiKey: (provider: string) => string
|
|
21
|
+
signedIn: (provider: string) => string
|
|
22
|
+
callbackPage: string
|
|
23
|
+
greeting: (name: string, hour: number) => string
|
|
24
|
+
welcomeBack: (name: string) => string
|
|
25
|
+
chatInfo: (workspace: string, model: string, logPath: string) => string
|
|
26
|
+
// hagumi new shows the place it made on a line of its own, easy to copy into another tab, and the way there
|
|
27
|
+
// again as it ends: a program cannot move its shell.
|
|
28
|
+
madeWorkspace: (workspace: string) => string
|
|
29
|
+
goThere: (workspace: string) => string
|
|
30
|
+
tidyEmpty: string
|
|
31
|
+
tidied: string
|
|
32
|
+
pruneNone: string
|
|
33
|
+
pruneFound: (places: string[]) => string
|
|
34
|
+
pruneAsk: string
|
|
35
|
+
boardColumns: Record<"doing" | "todo" | "done", string>
|
|
36
|
+
boardEmpty: string
|
|
37
|
+
// The list hagumi starts with, and finding older projects in it.
|
|
38
|
+
whereToStart: string
|
|
39
|
+
searchHint: string
|
|
40
|
+
searching: string
|
|
41
|
+
searchNone: string
|
|
42
|
+
whichOne: string
|
|
43
|
+
boardMade: string
|
|
44
|
+
startNew: string
|
|
45
|
+
startHere: (workspace: string) => string
|
|
46
|
+
boardNow: (item: string) => string
|
|
47
|
+
boardNext: (item: string) => string
|
|
48
|
+
pruned: (count: number) => string
|
|
49
|
+
chatHelp: string
|
|
50
|
+
model: (model: string) => string
|
|
51
|
+
language: (name: string) => string
|
|
52
|
+
changeHeader: (tool: string, path: string, isNew: boolean) => string
|
|
53
|
+
moreLines: (count: number) => string
|
|
54
|
+
commandHeader: (command: string) => string
|
|
55
|
+
approve: string
|
|
56
|
+
approvedForSession: string
|
|
57
|
+
compacted: (outputs: number) => string
|
|
58
|
+
// Asked after a reply, for each fact the partner proposed to remember.
|
|
59
|
+
rememberThis: (fact: string) => string
|
|
60
|
+
rememberSelf: (fact: string) => string
|
|
61
|
+
// Said instead of asking, when a fact comes from the user's own words.
|
|
62
|
+
kept: (fact: string) => string
|
|
63
|
+
keptSelf: (fact: string) => string
|
|
64
|
+
remembered: string
|
|
65
|
+
allowedByRule: (rules: string) => string
|
|
66
|
+
deniedByRule: (rule: string) => string
|
|
67
|
+
configShown: (path: string) => string
|
|
68
|
+
personaShown: (path: string, used: number, limit: number) => string
|
|
69
|
+
memoryShown: (path: string, used: number, limit: number) => string
|
|
70
|
+
// How the partner first speaks: the seed of persona.md, written on first use.
|
|
71
|
+
personaSeed: string
|
|
72
|
+
memoryEmpty: string
|
|
73
|
+
memoryChanges: string
|
|
74
|
+
memoryFull: (fact: string) => string
|
|
75
|
+
noTerminal: string
|
|
76
|
+
footer: (model: string, logPath: string) => string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const en: Messages = {
|
|
80
|
+
problem: {
|
|
81
|
+
usage: (providers) =>
|
|
82
|
+
[
|
|
83
|
+
"Usage:",
|
|
84
|
+
" hagumi login [provider]",
|
|
85
|
+
" hagumi models [provider]",
|
|
86
|
+
" hagumi ask [--model <provider>/<model>] [--session <id>] <text>",
|
|
87
|
+
" hagumi",
|
|
88
|
+
" hagumi . [--model <provider>/<model>] [--session <id>]",
|
|
89
|
+
" hagumi new [--model <provider>/<model>]",
|
|
90
|
+
" hagumi prune",
|
|
91
|
+
" hagumi inspect <session id>",
|
|
92
|
+
" hagumi config",
|
|
93
|
+
" hagumi memory",
|
|
94
|
+
`Providers: ${providers}`,
|
|
95
|
+
].join("\n"),
|
|
96
|
+
notSignedIn: (provider) => `Not signed in to ${provider} yet. Run \`hagumi login ${provider}\` first.`,
|
|
97
|
+
noTokenSharing: () =>
|
|
98
|
+
"Sign-in finished without permission to use your ChatGPT plan. Run `hagumi login` again and allow it.",
|
|
99
|
+
sessionNotFound: () => "No such session, or nothing has been sent in it yet.",
|
|
100
|
+
unknownModel: (ref) => `Unknown model: ${ref}. Name it as <provider>/<model>, such as chatgpt/gpt-6-luna.`,
|
|
101
|
+
unknownProvider: (provider) => `Unknown provider: ${provider}`,
|
|
102
|
+
tooManySteps: (steps) => `Stopped after ${steps} steps in one turn. Ask again to let it continue.`,
|
|
103
|
+
badConfig: (detail) => `The settings file cannot be read: ${detail}`,
|
|
104
|
+
unknownLanguage: (choices) => `Choose a language from ${choices}.`,
|
|
105
|
+
cancelled: () => "Cancelled.",
|
|
106
|
+
},
|
|
107
|
+
openUrl: (url) => `Sign in with ChatGPT in your browser:\n${url}`,
|
|
108
|
+
askApiKey: (provider) => `API key for ${provider} (hidden): `,
|
|
109
|
+
signedIn: (provider) => `Signed in to ${provider}.`,
|
|
110
|
+
callbackPage: "Received. You can close this tab and return to your terminal.",
|
|
111
|
+
greeting: (name, hour) =>
|
|
112
|
+
hour < 5
|
|
113
|
+
? `Still up? It's ${name}. Let's not stay too late.`
|
|
114
|
+
: hour < 11
|
|
115
|
+
? `Good morning! It's ${name}. Let's have a good day.`
|
|
116
|
+
: hour < 18
|
|
117
|
+
? `Hi, it's ${name}. Where shall we start?`
|
|
118
|
+
: `Good evening, it's ${name}. Thanks for today.`,
|
|
119
|
+
welcomeBack: (name) => `Welcome back! It's ${name}. Let's pick up where we left off.`,
|
|
120
|
+
chatInfo: (workspace, model, logPath) =>
|
|
121
|
+
`workspace: ${workspace}\nmodel: ${model} log: ${logPath}\n/help lists the commands. Press Ctrl+D to finish.`,
|
|
122
|
+
madeWorkspace: (workspace) => `Made a new place to work. To open it in another tab, use this path:\n ${workspace}\n`,
|
|
123
|
+
goThere: (workspace) => `To go there: cd ${workspace}`,
|
|
124
|
+
tidyEmpty: "This place is still empty. Tidy it away? [y/n] ",
|
|
125
|
+
tidied: "Tidied it away.",
|
|
126
|
+
pruneNone: "No empty places to tidy away.",
|
|
127
|
+
pruneFound: (places) => `${places.length} empty ${places.length === 1 ? "place" : "places"}:\n${places.join("\n")}`,
|
|
128
|
+
pruneAsk: "Tidy them away? [y/n] ",
|
|
129
|
+
boardColumns: { doing: "Doing", todo: "To do", done: "Done" },
|
|
130
|
+
boardEmpty: "This project's board is still empty.",
|
|
131
|
+
whereToStart: "Where shall we start? ",
|
|
132
|
+
searchHint: "For another project, type what you remember of it: a name, a day, what the work was about.",
|
|
133
|
+
searching: "Looking for it…",
|
|
134
|
+
searchNone: "Nothing matched.",
|
|
135
|
+
whichOne: "Which one? ",
|
|
136
|
+
boardMade: "I put this project on the list. Next time, pick it up from the list hagumi starts with.",
|
|
137
|
+
startNew: "Start something new",
|
|
138
|
+
startHere: (workspace) => `Start here (${workspace})`,
|
|
139
|
+
boardNow: (item) => `now: ${item}`,
|
|
140
|
+
boardNext: (item) => `next: ${item}`,
|
|
141
|
+
pruned: (count) => `Tidied away ${count}.`,
|
|
142
|
+
chatHelp: [
|
|
143
|
+
"/models [provider] the models you can use",
|
|
144
|
+
"/model <provider>/<model> switch models from the next message",
|
|
145
|
+
"/language [auto|ja|en] switch languages for this chat",
|
|
146
|
+
"/board this project's board",
|
|
147
|
+
"/help this list",
|
|
148
|
+
"Ctrl+D finish",
|
|
149
|
+
].join("\n"),
|
|
150
|
+
model: (model) => `model: ${model}`,
|
|
151
|
+
language: (name) => `language: ${name} (choose from auto / ja / en; for this chat only)`,
|
|
152
|
+
changeHeader: (tool, path, isNew) => ` ✎ ${tool} ${path}${isNew ? " (new file)" : ""}`,
|
|
153
|
+
moreLines: (count) => ` … ${count} more lines`,
|
|
154
|
+
commandHeader: (command) => ` $ ${command}\n (sandboxed: writes only inside the working directory, no network)`,
|
|
155
|
+
approve: "Allow this? [y] yes / [n] no / [a] always in this session: ",
|
|
156
|
+
approvedForSession: " (allowed for this session)",
|
|
157
|
+
compacted: (outputs) =>
|
|
158
|
+
`(The conversation grew long, so ${outputs} old tool outputs were left out. Files are read again when needed.)`,
|
|
159
|
+
rememberThis: (fact) => `Shall I remember this? "${fact}" [y/n] `,
|
|
160
|
+
rememberSelf: (fact) => `Shall I remember this about myself? "${fact}" [y/n] `,
|
|
161
|
+
kept: (fact) => ` Remembered: "${fact}" (from the next session on; hagumi memory to look it over)`,
|
|
162
|
+
keptSelf: (fact) => ` Remembered about myself: "${fact}" (from the next session on; hagumi memory to look it over)`,
|
|
163
|
+
remembered: "Remembered! I'll use it from the next session.",
|
|
164
|
+
allowedByRule: (rules) => ` (your allow list has ${rules}, so it runs without asking)`,
|
|
165
|
+
deniedByRule: (rule) => ` (your deny list has ${rule}, so it does not run)`,
|
|
166
|
+
configShown: (path) => `Settings: ${path}`,
|
|
167
|
+
personaShown: (path, used, limit) => `Persona: ${path} (${used} of ${limit} characters)`,
|
|
168
|
+
memoryShown: (path, used, limit) => `Memory: ${path} (${used} of ${limit} characters)`,
|
|
169
|
+
personaSeed: "- Speaks in a soft, casual voice, and keeps it short\n- Is openly glad when things go well\n",
|
|
170
|
+
memoryEmpty: "Nothing remembered yet.",
|
|
171
|
+
memoryChanges: "Recent changes:",
|
|
172
|
+
memoryFull: (fact) => `My memory is full, so I couldn't remember "${fact}". See \`hagumi memory\` and tidy it up.`,
|
|
173
|
+
noTerminal: " No terminal to ask, so it was declined.",
|
|
174
|
+
footer: (model, logPath) => `model: ${model}\nlog: ${logPath}`,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ja: Messages = {
|
|
178
|
+
problem: {
|
|
179
|
+
usage: (providers) =>
|
|
180
|
+
[
|
|
181
|
+
"使い方:",
|
|
182
|
+
" hagumi login [プロバイダ]",
|
|
183
|
+
" hagumi models [プロバイダ]",
|
|
184
|
+
" hagumi ask [--model <プロバイダ>/<モデル>] [--session <id>] <テキスト>",
|
|
185
|
+
" hagumi",
|
|
186
|
+
" hagumi . [--model <プロバイダ>/<モデル>] [--session <id>]",
|
|
187
|
+
" hagumi new [--model <プロバイダ>/<モデル>]",
|
|
188
|
+
" hagumi prune",
|
|
189
|
+
" hagumi inspect <セッション ID>",
|
|
190
|
+
" hagumi config",
|
|
191
|
+
`プロバイダ: ${providers}`,
|
|
192
|
+
].join("\n"),
|
|
193
|
+
notSignedIn: (provider) =>
|
|
194
|
+
`${provider} にはまだサインインしていません。先に \`hagumi login ${provider}\` を実行してください。`,
|
|
195
|
+
noTokenSharing: () =>
|
|
196
|
+
"ChatGPT プランの利用が許可されないままサインインが終わりました。もう一度 `hagumi login` を実行して許可してください。",
|
|
197
|
+
sessionNotFound: () => "そのセッションは見つからないか、まだ何も送っていません。",
|
|
198
|
+
unknownModel: (ref) =>
|
|
199
|
+
`知らないモデルです: ${ref}。<プロバイダ>/<モデル> の形で指定してください(例: chatgpt/gpt-6-luna)。`,
|
|
200
|
+
unknownProvider: (provider) => `知らないプロバイダです: ${provider}`,
|
|
201
|
+
tooManySteps: (steps) => `1ターンで ${steps} ステップに達したので止めました。続けるには、もう一度頼んでください。`,
|
|
202
|
+
badConfig: (detail) => `設定ファイルを読めません: ${detail}`,
|
|
203
|
+
unknownLanguage: (choices) => `言語は ${choices} から選んでください。`,
|
|
204
|
+
cancelled: () => "中止しました。",
|
|
205
|
+
},
|
|
206
|
+
openUrl: (url) => `ブラウザで ChatGPT にサインインしてください:\n${url}`,
|
|
207
|
+
askApiKey: (provider) => `${provider} の API キー(画面には表示しません): `,
|
|
208
|
+
signedIn: (provider) => `${provider} にサインインしました。`,
|
|
209
|
+
callbackPage: "受け取りました。このタブを閉じて、ターミナルに戻ってください。",
|
|
210
|
+
greeting: (name, hour) =>
|
|
211
|
+
hour < 5
|
|
212
|
+
? `こんばんは、${name}だよ。夜ふかしはほどほどにね。`
|
|
213
|
+
: hour < 11
|
|
214
|
+
? `おはよう、${name}だよ。今日もよろしくね。`
|
|
215
|
+
: hour < 18
|
|
216
|
+
? `こんにちは、${name}だよ。何から始めようか?`
|
|
217
|
+
: `こんばんは、${name}だよ。今日もおつかれさま。`,
|
|
218
|
+
welcomeBack: (name) => `おかえり、${name}だよ。続きをやろう。`,
|
|
219
|
+
chatInfo: (workspace, model, logPath) =>
|
|
220
|
+
`作業の場所: ${workspace}\nモデル: ${model} ログ: ${logPath}\n/help でコマンドの一覧。終わるときは Ctrl+D。`,
|
|
221
|
+
madeWorkspace: (workspace) => `新しい作業の場所を作ったよ。別のタブで開くときは、このパスをどうぞ:\n ${workspace}\n`,
|
|
222
|
+
goThere: (workspace) => `移るなら: cd ${workspace}`,
|
|
223
|
+
tidyEmpty: "この場所は空のままだけど、片付ける? [y/n] ",
|
|
224
|
+
tidied: "片付けたよ。",
|
|
225
|
+
pruneNone: "片付ける空の作業の場所はないよ。",
|
|
226
|
+
pruneFound: (places) => `空の作業の場所が ${places.length} つあるよ:\n${places.join("\n")}`,
|
|
227
|
+
pruneAsk: "片付ける? [y/n] ",
|
|
228
|
+
boardColumns: { doing: "やっている", todo: "やること", done: "終わった" },
|
|
229
|
+
boardEmpty: "このプロジェクトのボードは、まだ空だよ。",
|
|
230
|
+
whereToStart: "どこから始める? ",
|
|
231
|
+
searchHint: "ほかのプロジェクトは、覚えていること(名前、日付、どんな作業だったか)を打つと探すよ。",
|
|
232
|
+
searching: "探しているよ…",
|
|
233
|
+
searchNone: "見つからなかったよ。",
|
|
234
|
+
whichOne: "どれにする? ",
|
|
235
|
+
boardMade: "このプロジェクトを一覧に載せたよ。次から hagumi の一覧から再開できるよ。",
|
|
236
|
+
startNew: "新しく始める",
|
|
237
|
+
startHere: (workspace) => `ここで始める(${workspace})`,
|
|
238
|
+
boardNow: (item) => `いま: ${item}`,
|
|
239
|
+
boardNext: (item) => `次: ${item}`,
|
|
240
|
+
pruned: (count) => `${count} つ片付けたよ。`,
|
|
241
|
+
chatHelp: [
|
|
242
|
+
"/models [プロバイダ] 使えるモデルの一覧",
|
|
243
|
+
"/model <プロバイダ>/<モデル> 次の発言からモデルを切り替える",
|
|
244
|
+
"/language [auto|ja|en] このチャットの言語を切り替える",
|
|
245
|
+
"/board このプロジェクトのボード",
|
|
246
|
+
"/help この一覧",
|
|
247
|
+
"Ctrl+D 終わる",
|
|
248
|
+
].join("\n"),
|
|
249
|
+
model: (model) => `モデル: ${model}`,
|
|
250
|
+
language: (name) => `言語: ${name}(auto / ja / en から選べます。このチャットの間だけ有効)`,
|
|
251
|
+
changeHeader: (tool, path, isNew) => ` ✎ ${tool} ${path}${isNew ? "(新しいファイル)" : ""}`,
|
|
252
|
+
moreLines: (count) => ` …ほか ${count} 行`,
|
|
253
|
+
commandHeader: (command) => ` $ ${command}\n (サンドボックス内: 書き込みは作業ディレクトリだけ、ネットワークなし)`,
|
|
254
|
+
approve: "許可しますか? [y] はい / [n] いいえ / [a] このセッションでは常に許可: ",
|
|
255
|
+
approvedForSession: " (このセッションでは許可済み)",
|
|
256
|
+
compacted: (outputs) => `(会話が大きくなったので、古いツールの出力を ${outputs} 件省きました。必要なら読み直します)`,
|
|
257
|
+
rememberThis: (fact) => `覚えていい?「${fact}」 [y/n] `,
|
|
258
|
+
rememberSelf: (fact) => `自分のことをこう覚えていい?「${fact}」 [y/n] `,
|
|
259
|
+
kept: (fact) => ` 覚えたよ:「${fact}」(次のセッションから使うね。見直すなら hagumi memory)`,
|
|
260
|
+
keptSelf: (fact) => ` 自分のこととして覚えたよ:「${fact}」(次のセッションから使うね。見直すなら hagumi memory)`,
|
|
261
|
+
remembered: "覚えた! 次のセッションから使うね。",
|
|
262
|
+
allowedByRule: (rules) => ` (許可リストの ${rules} に一致するので、確認せずに実行します)`,
|
|
263
|
+
deniedByRule: (rule) => ` (禁止リストの ${rule} に一致するので、実行しません)`,
|
|
264
|
+
configShown: (path) => `設定ファイル: ${path}`,
|
|
265
|
+
personaShown: (path, used, limit) => `人格: ${path}(${used} / ${limit} 字)`,
|
|
266
|
+
memoryShown: (path, used, limit) => `記憶: ${path}(${used} / ${limit} 字)`,
|
|
267
|
+
personaSeed: "- 一人称は「ぼく」\n- やわらかい話し言葉で、短く話す\n- うまくいったときは、素直に喜ぶ\n",
|
|
268
|
+
memoryEmpty: "まだ何も覚えていません。",
|
|
269
|
+
memoryChanges: "最近の変更:",
|
|
270
|
+
memoryFull: (fact) => `記憶がいっぱいで、「${fact}」は覚えられなかった。hagumi memory で中身を見て、整理してね。`,
|
|
271
|
+
noTerminal: " 確認できる端末がないので、断りました。",
|
|
272
|
+
footer: (model, logPath) => `モデル: ${model}\nログ: ${logPath}`,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const messages = { en, ja }
|
|
276
|
+
|
|
277
|
+
// The languages hagumi speaks.
|
|
278
|
+
export type Spoken = keyof typeof messages
|
|
279
|
+
|
|
280
|
+
// Each language by its own name.
|
|
281
|
+
const nativeNames = { ja: "日本語", en: "English" }
|
|
282
|
+
|
|
283
|
+
// A language as people would recognize it; "auto" also tells what it resolves to.
|
|
284
|
+
export function languageName(language: "auto" | Spoken): string {
|
|
285
|
+
return language === "auto" ? `auto (${nativeNames[systemLanguage()]})` : nativeNames[language]
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The language the system's locale asks for.
|
|
289
|
+
export function systemLanguage(): Spoken {
|
|
290
|
+
return (process.env.LC_ALL || process.env.LANG || "").startsWith("ja") ? "ja" : "en"
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// What "auto" means here and now; any other choice stands as it is.
|
|
294
|
+
export function resolveLanguage(language: "auto" | Spoken): Spoken {
|
|
295
|
+
return language === "auto" ? systemLanguage() : language
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The messages in use. It follows the system until the settings say otherwise.
|
|
299
|
+
export let t: Messages = messages[systemLanguage()]
|
|
300
|
+
|
|
301
|
+
export function useLanguage(language: "auto" | Spoken): void {
|
|
302
|
+
t = messages[resolveLanguage(language)]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export class Problem extends Error {
|
|
306
|
+
constructor(
|
|
307
|
+
readonly key: ProblemKey,
|
|
308
|
+
readonly detail = "",
|
|
309
|
+
) {
|
|
310
|
+
super(key)
|
|
311
|
+
}
|
|
312
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { constants } from "node:fs"
|
|
2
|
+
import { type FileHandle, mkdir, open } from "node:fs/promises"
|
|
3
|
+
import { dirname } from "node:path"
|
|
4
|
+
import { paths } from "./app"
|
|
5
|
+
|
|
6
|
+
export type LogEntry = { at: string; kind: string; [key: string]: unknown }
|
|
7
|
+
|
|
8
|
+
export type SessionLog = {
|
|
9
|
+
id: string
|
|
10
|
+
path: string
|
|
11
|
+
append(entry: { kind: string; [key: string]: unknown }): Promise<void>
|
|
12
|
+
read(): Promise<LogEntry[]>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Append-only JSONL: every message, every request sent to a model, and every event received, as they were.
|
|
16
|
+
// Opening an existing session continues it.
|
|
17
|
+
export async function openSessionLog(id: string = crypto.randomUUID()): Promise<SessionLog> {
|
|
18
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error("Invalid session id")
|
|
19
|
+
const path = paths.session(id)
|
|
20
|
+
const existing = await privateLog(path, constants.O_RDONLY)
|
|
21
|
+
await existing?.close()
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
path,
|
|
25
|
+
async append(entry) {
|
|
26
|
+
const file = (await privateLog(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT))!
|
|
27
|
+
try {
|
|
28
|
+
await file.appendFile(`${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`)
|
|
29
|
+
} finally {
|
|
30
|
+
await file.close()
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
async read() {
|
|
34
|
+
const file = await privateLog(path, constants.O_RDONLY)
|
|
35
|
+
if (!file) return []
|
|
36
|
+
try {
|
|
37
|
+
const text = await file.readFile("utf8")
|
|
38
|
+
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line) as LogEntry)
|
|
39
|
+
} finally {
|
|
40
|
+
await file.close()
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Tighten an existing log before reading or appending. Work on the opened file, never a link's target.
|
|
47
|
+
async function privateLog(path: string, flags: number): Promise<FileHandle | undefined> {
|
|
48
|
+
const dir = dirname(path)
|
|
49
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
50
|
+
const directory = await open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
51
|
+
try {
|
|
52
|
+
await directory.chmod(0o700)
|
|
53
|
+
} finally {
|
|
54
|
+
await directory.close()
|
|
55
|
+
}
|
|
56
|
+
let file: FileHandle
|
|
57
|
+
try {
|
|
58
|
+
file = await open(path, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (!(flags & constants.O_CREAT) && (error as NodeJS.ErrnoException).code === "ENOENT") return
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const stat = await file.stat()
|
|
65
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error("Session log must be a regular file with one link")
|
|
66
|
+
await file.chmod(0o600)
|
|
67
|
+
return file
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await file.close()
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
}
|