@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/src/chat.ts ADDED
@@ -0,0 +1,212 @@
1
+ // A chat: how it starts, the commands in it, each message and its reply, and what is remembered after a reply.
2
+ import { readdir } from "node:fs/promises"
3
+ import { basename } from "node:path"
4
+ import { createInterface } from "node:readline"
5
+ import { parseArgs } from "node:util"
6
+ import { paths } from "./app"
7
+ import { columnsText, isEmptyBoard, readBoard, writeBoard } from "./board"
8
+ import { type Config, type Language, languages, nameOf } from "./config"
9
+ import { messagesFrom, turn } from "./conversation"
10
+ import { faceSets, withFace } from "./faces"
11
+ import { languageName, Problem, t, useLanguage } from "./i18n"
12
+ import { openSessionLog, type SessionLog } from "./log"
13
+ import { addMemory, fits, placeFor, proposals, readMemory, seedPersona } from "./memory"
14
+ import { systemPrompt } from "./prompt"
15
+ import { chooseModel, isSignedIn, type ModelChoice, modelIds, type Provider, providers } from "./providers"
16
+ import { approver, boardView, chatInput, dim, notice, report, show, tilde } from "./terminal"
17
+ import type { ToolContext } from "./tools"
18
+
19
+ export const conversationOptions = {
20
+ model: { type: "string" },
21
+ session: { type: "string" },
22
+ } as const
23
+
24
+ type Question = (prompt: string) => Promise<string>
25
+
26
+ // A chat, or a single question: where it is logged, which model answers, how the user is asked,
27
+ // what its tools may do, how hagumi presents itself, and what it remembers as the session starts.
28
+ type Session = {
29
+ log: SessionLog
30
+ choice: ModelChoice
31
+ question: Question
32
+ context: ToolContext
33
+ config: Config
34
+ persona: string
35
+ memory: string
36
+ // This project's board as the session starts, as the model reads it.
37
+ board: string
38
+ }
39
+
40
+ // A chat in the working directory, which its prompt names. Coming from the list, hagumi has already said hello.
41
+ export async function chat(args: string[], config: Config, greeted = false): Promise<void> {
42
+ const { values } = parseArgs({ args, options: conversationOptions })
43
+ const choice = chooseModel(values.model ?? config.model)
44
+ const log = await openSession(values.session)
45
+ const faces = faceSets[config.faces]
46
+ const workspace = process.cwd()
47
+ const hello = values.session ? t.welcomeBack(nameOf(config)) : t.greeting(nameOf(config), new Date().getHours())
48
+ if (!greeted) console.log(withFace(faces.greeting, hello))
49
+ console.log(`${dim(t.chatInfo(tilde(workspace), choice.ref, tilde(log.path)))}\n`)
50
+
51
+ // readline gives line editing and history; a resumed session recalls its own earlier inputs.
52
+ const history = messagesFrom(await log.read())
53
+ .flatMap((message) => (message.role === "user" ? [message.text] : []))
54
+ .reverse()
55
+ const prompt = `${basename(workspace)}> `
56
+ const input = chatInput(createInterface({ input: process.stdin, output: process.stdout, prompt, history }))
57
+ const session = await startSession(log, choice, input.question, config)
58
+ await input.each(async (line) => {
59
+ try {
60
+ await chatLine(line.trim(), session)
61
+ } catch (error) {
62
+ report(error)
63
+ }
64
+ })
65
+ process.stdout.write("\n")
66
+ await boardIfWorked(workspace, log, session.context.boards)
67
+ }
68
+
69
+ // A place worked in, with files in it or a long conversation behind it, gets a board as the chat ends, so that it
70
+ // shows in the list to start from; the model fills it in as the work moves.
71
+ async function boardIfWorked(workspace: string, log: SessionLog, dir: string): Promise<void> {
72
+ if (await readBoard(workspace, dir)) return
73
+ const turns = messagesFrom(await log.read()).filter((message) => message.role === "user").length
74
+ if (turns < 10 && !(await readdir(workspace)).some((name) => name !== ".git")) return
75
+ await writeBoard(workspace, { doing: [], todo: [], done: [] }, dir)
76
+ console.log(t.boardMade)
77
+ }
78
+
79
+ // The chat's commands, by name. They may change the session for the rest of the chat.
80
+ export const chatCommands: Record<string, (argument: string, session: Session) => void | Promise<void>> = {
81
+ "/help": () => console.log(t.chatHelp),
82
+ "/models": (argument, session) => printModels(argument || undefined, session.choice.ref),
83
+ "/model": (argument, session) => {
84
+ if (argument) session.choice = chooseModel(argument)
85
+ console.log(t.model(session.choice.ref))
86
+ },
87
+ // This project's board as it stands now.
88
+ "/board": async (_, session) => {
89
+ const board = await readBoard(session.context.workspace, session.context.boards)
90
+ console.log(board && !isEmptyBoard(board) ? boardView(board) : t.boardEmpty)
91
+ },
92
+ // The screen changes at once; the partner speaks the new language from the next message,
93
+ // which changes the system prompt and so starts its cache afresh.
94
+ "/language": (argument, session) => {
95
+ if (argument) {
96
+ if (!languages.includes(argument as Language)) throw new Problem("unknownLanguage", languages.join(" / "))
97
+ session.config = { ...session.config, language: argument as Language }
98
+ useLanguage(session.config.language)
99
+ }
100
+ console.log(t.language(languageName(session.config.language)))
101
+ },
102
+ }
103
+
104
+ // One line of chat: a command, or a message for the model.
105
+ async function chatLine(line: string, session: Session): Promise<void> {
106
+ const [name = "", ...rest] = line.split(/\s+/)
107
+ const command = chatCommands[name]
108
+ if (command) return command(rest.join(" "), session)
109
+ if (!line) return
110
+ await converse(session, line)
111
+ process.stdout.write("\n")
112
+ }
113
+
114
+ export async function converse(session: Session, text: string): Promise<void> {
115
+ const { log, choice, context, config, persona, memory, board } = session
116
+ const system = systemPrompt({
117
+ workspace: context.workspace,
118
+ name: nameOf(config),
119
+ language: config.language,
120
+ persona,
121
+ memory,
122
+ board,
123
+ })
124
+ await show(turn({ log, choice, text, system, context }), faceSets[config.faces])
125
+ process.stdout.write("\n")
126
+ await review(session)
127
+ }
128
+
129
+ // After the reply, each fact the partner proposed is kept or not. What came from the user's own words is kept
130
+ // at once, with a word on the screen; what may come from a file or a command output waits for the user's "y".
131
+ // Facts proposed together may not all fit, so each is measured again first. Every outcome is logged.
132
+ async function review({ log, question, context, config }: Session): Promise<void> {
133
+ const faces = faceSets[config.faces]
134
+ const proposed = proposals(await log.read(), context.workspace)
135
+ if (proposed.length > 0) process.stdout.write("\n")
136
+ for (const { about, fact, origin } of proposed) {
137
+ const { path, limit } = placeFor(about, context)
138
+ const full = !(await fits(fact, path, limit))
139
+ if (full) console.log(withFace(faces.troubled, t.memoryFull(fact)))
140
+ const ask = about === "self" ? t.rememberSelf : t.rememberThis
141
+ const approved = !full && (origin === "user" || yes(await question(withFace(faces.waiting, ask(fact)))))
142
+ if (approved) {
143
+ await addMemory(fact, { about, session: log.id, origin, workspace: context.workspace }, path)
144
+ const kept = about === "self" ? t.keptSelf : t.kept
145
+ console.log(origin === "user" ? dim(kept(fact)) : withFace(faces.happy, t.remembered))
146
+ }
147
+ await log.append({ kind: "memory", about, fact, origin, approved, full })
148
+ }
149
+ }
150
+
151
+ // The persona and memory are read once, as the session starts, so that the system prompt stays the same
152
+ // throughout. The persona is seeded first, in the language of the settings.
153
+ export async function startSession(log: SessionLog, choice: ModelChoice, question: Question, config: Config): Promise<Session> {
154
+ const context = toolContext(question, config)
155
+ await seedPersona(t.personaSeed, context.persona)
156
+ const [persona, memory, board] = await Promise.all([
157
+ readMemory(context.persona),
158
+ readMemory(context.memory),
159
+ readBoard(context.workspace, context.boards),
160
+ ])
161
+ return { log, choice, question, context, config, persona, memory, board: board ? columnsText(board) : "" }
162
+ }
163
+
164
+ // File tools stay inside the working directory; bash has separate read and write limits. Every action waits for the user,
165
+ // unless the user's rules already decide it.
166
+ export function toolContext(question: Question, config: Config): ToolContext {
167
+ return {
168
+ workspace: process.cwd(),
169
+ memory: paths.memory,
170
+ persona: paths.persona,
171
+ boards: paths.boards,
172
+ seen: new Map(),
173
+ rules: config.commands,
174
+ approve: approver(question, faceSets[config.faces]),
175
+ notice,
176
+ }
177
+ }
178
+
179
+ export function yes(answer: string): boolean {
180
+ return answer.trim().toLowerCase() === "y"
181
+ }
182
+
183
+ export function providerOf(id: string): Provider {
184
+ const provider = providers[id]
185
+ if (!provider) throw new Problem("unknownProvider", id)
186
+ return provider
187
+ }
188
+
189
+ // A new session, or an existing one to continue.
190
+ export async function openSession(id?: string): Promise<SessionLog> {
191
+ const log = await openSessionLog(id)
192
+ if (id && (await log.read()).length === 0) throw new Problem("sessionNotFound")
193
+ return log
194
+ }
195
+
196
+ // The models of one provider, or of every provider signed in to, named as `/model` expects them.
197
+ export async function printModels(providerId?: string, current?: string): Promise<void> {
198
+ const targets = providerId ? [providerOf(providerId)] : await signedInProviders()
199
+ if (targets.length === 0) throw new Problem("notSignedIn", "chatgpt")
200
+ for (const provider of targets) {
201
+ for (const model of await modelIds(provider)) {
202
+ const ref = `${provider.id}/${model}`
203
+ console.log(current === undefined ? ref : `${ref === current ? "*" : " "} ${ref}`)
204
+ }
205
+ }
206
+ }
207
+
208
+ async function signedInProviders(): Promise<Provider[]> {
209
+ const all = Object.values(providers)
210
+ const signedIn = await Promise.all(all.map(isSignedIn))
211
+ return all.filter((_, index) => signedIn[index])
212
+ }
package/src/chatgpt.ts ADDED
@@ -0,0 +1,181 @@
1
+ // Sign in with ChatGPT: lets hagumi run requests on the user's ChatGPT plan through the public Responses API.
2
+ // OpenAI allows this for personal projects that run locally:
3
+ // https://developers.openai.com/cookbook/articles/sign-in-with-chatgpt
4
+ import { createHash, randomBytes, type webcrypto } from "node:crypto"
5
+ import { paths, userAgent } from "./app"
6
+ import { Problem, t } from "./i18n"
7
+ import { readJson, writeSecret } from "./store"
8
+
9
+ const issuer = "https://auth.openai.com"
10
+ export const resource = "https://api.openai.com/v1"
11
+ const tokenSharing = "chatgpt.tokens.use.direct"
12
+
13
+ export type ChatGPTAuth = {
14
+ hostId: string
15
+ clientId: string
16
+ accessToken: string
17
+ refreshToken: string
18
+ expiresAt: number
19
+ }
20
+
21
+ type TokenResponse = {
22
+ access_token: string
23
+ refresh_token?: string
24
+ id_token?: string
25
+ expires_in?: number
26
+ scope?: string
27
+ }
28
+
29
+ export type ChatGPTModel = { slug: string; display_name: string }
30
+
31
+ export function authorizeUrl(options: {
32
+ redirect: string
33
+ challenge: string
34
+ state: string
35
+ nonce: string
36
+ hostId: string
37
+ clientId?: string
38
+ }): string {
39
+ const params = new URLSearchParams({
40
+ // The first sign-in registers a client owned by this user; later sign-ins reuse it.
41
+ client_id: options.clientId ?? "dynamic_agent_client",
42
+ ...(options.clientId ? {} : { agent_name_hint: "hagumi" }),
43
+ ext_agent_host_id: options.hostId,
44
+ response_type: "code",
45
+ redirect_uri: options.redirect,
46
+ scope: `openid profile email offline_access resource.invoke ${tokenSharing}`,
47
+ resource,
48
+ state: options.state,
49
+ nonce: options.nonce,
50
+ code_challenge_method: "S256",
51
+ code_challenge: options.challenge,
52
+ })
53
+ return `${issuer}/api/accounts/authorize?${params}`
54
+ }
55
+
56
+ export async function signIn(showUrl: (url: string) => void): Promise<ChatGPTAuth> {
57
+ const saved = await readJson<ChatGPTAuth>(paths.auth("chatgpt"))
58
+ const hostId = saved?.hostId ?? `urn:uuid:${crypto.randomUUID()}`
59
+ const verifier = random(32)
60
+ const state = random(16)
61
+ const nonce = random(16)
62
+ const callback = Promise.withResolvers<URLSearchParams>()
63
+ const server = Bun.serve({
64
+ hostname: "127.0.0.1",
65
+ port: 0,
66
+ fetch(request) {
67
+ const url = new URL(request.url)
68
+ if (url.pathname !== "/auth/callback") return new Response("Not found", { status: 404 })
69
+ callback.resolve(url.searchParams)
70
+ return new Response(t.callbackPage, { headers: { "Content-Type": "text/plain; charset=utf-8" } })
71
+ },
72
+ })
73
+
74
+ try {
75
+ const redirect = `http://127.0.0.1:${server.port}/auth/callback`
76
+ const challenge = createHash("sha256").update(verifier).digest("base64url")
77
+ showUrl(authorizeUrl({ redirect, challenge, state, nonce, hostId, clientId: saved?.clientId }))
78
+
79
+ const params = await callback.promise
80
+ const error = params.get("error_description") ?? params.get("error")
81
+ if (error) throw new Error(`ChatGPT sign-in failed: ${error}`)
82
+ if (params.get("state") !== state) throw new Error("ChatGPT sign-in returned an unexpected state")
83
+
84
+ const code = params.get("code")
85
+ const returnedId = params.get("client_id")
86
+ if (saved && returnedId && returnedId !== saved.clientId) throw new Error("ChatGPT returned a different client")
87
+ const clientId = saved?.clientId ?? returnedId
88
+ if (!code || !clientId || clientId === "dynamic_agent_client") throw new Error("ChatGPT sign-in returned no code or issued client ID")
89
+
90
+ const tokens = await requestToken({
91
+ grant_type: "authorization_code",
92
+ client_id: clientId,
93
+ code,
94
+ code_verifier: verifier,
95
+ redirect_uri: redirect,
96
+ })
97
+ if (!tokens.scope?.split(" ").includes(tokenSharing)) throw new Problem("noTokenSharing")
98
+ await checkIdToken(tokens.id_token, clientId, nonce)
99
+
100
+ const auth = toAuth(tokens, { hostId, clientId })
101
+ await writeSecret(paths.auth("chatgpt"), auth)
102
+ return auth
103
+ } finally {
104
+ server.stop()
105
+ }
106
+ }
107
+
108
+ // Returns a usable access token, refreshing it shortly before it expires.
109
+ export async function accessToken(): Promise<string> {
110
+ const auth = await readJson<ChatGPTAuth>(paths.auth("chatgpt"))
111
+ if (!auth) throw new Problem("notSignedIn", "chatgpt")
112
+ if (auth.expiresAt - Date.now() > 60_000) return auth.accessToken
113
+
114
+ const tokens = await requestToken({
115
+ grant_type: "refresh_token",
116
+ client_id: auth.clientId,
117
+ refresh_token: auth.refreshToken,
118
+ })
119
+ const refreshed = toAuth(tokens, auth)
120
+ await writeSecret(paths.auth("chatgpt"), refreshed)
121
+ return refreshed.accessToken
122
+ }
123
+
124
+ export async function listModels(token: string): Promise<ChatGPTModel[]> {
125
+ const response = await fetch(`${resource}/models`, {
126
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": userAgent },
127
+ })
128
+ if (!response.ok) throw new Error(`GET /models failed: ${response.status} ${await response.text()}`)
129
+ const { models } = (await response.json()) as {
130
+ models: (ChatGPTModel & { visibility: string; supported_in_api: boolean })[]
131
+ }
132
+ return models.filter((model) => model.visibility === "list" && model.supported_in_api)
133
+ }
134
+
135
+ async function requestToken(params: Record<string, string>): Promise<TokenResponse> {
136
+ const response = await fetch(`${issuer}/api/accounts/oauth/token`, {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": userAgent },
139
+ body: new URLSearchParams({ ...params, resource }),
140
+ })
141
+ if (!response.ok) throw new Error(`ChatGPT token request failed: ${response.status} ${await response.text()}`)
142
+ return (await response.json()) as TokenResponse
143
+ }
144
+
145
+ // Checked as OpenAI's guide for open-source apps asks: the signature against OpenAI's published keys, fetched
146
+ // fresh at each sign-in so that no cache is needed, then who issued the token, for whom, for this sign-in, and when.
147
+ // https://developers.openai.com/siwc/token-sharing-open-source/sign-in
148
+ export async function checkIdToken(idToken: string | undefined, clientId: string, nonce: string): Promise<void> {
149
+ const [head = "", body = "", signature = ""] = (idToken ?? "").split(".")
150
+ const decode = (part: string) => { try { return JSON.parse(Buffer.from(part, "base64url").toString()) } catch { return {} } }
151
+ const header = decode(head)
152
+ const response = await fetch(`${issuer}/.well-known/jwks.json`, { signal: AbortSignal.timeout(10_000) })
153
+ if (!response.ok) throw new Error(`Could not get OpenAI's signing keys: ${response.status}`)
154
+ const { keys } = (await response.json()) as { keys: (webcrypto.JsonWebKey & { kid?: string })[] }
155
+ const jwk = keys.find((key) => key.kid === header.kid)
156
+ const rsa = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }
157
+ const key = header.alg === "RS256" && jwk && (await crypto.subtle.importKey("jwk", jwk, rsa, false, ["verify"]))
158
+ const signed = key && (await crypto.subtle.verify(rsa, key, Buffer.from(signature, "base64url"), Buffer.from(`${head}.${body}`)))
159
+ const claims = decode(body)
160
+ const now = Date.now() / 1000
161
+ if (!signed || claims.iss !== issuer || ![claims.aud].flat().includes(clientId) || claims.nonce !== nonce ||
162
+ !(claims.exp > now - 5) || !(claims.iat < now + 5) || !claims.sub) {
163
+ throw new Error("ChatGPT returned an invalid ID token")
164
+ }
165
+ }
166
+
167
+ function toAuth(tokens: TokenResponse, previous: Pick<ChatGPTAuth, "hostId" | "clientId"> & { refreshToken?: string }) {
168
+ const refreshToken = tokens.refresh_token ?? previous.refreshToken
169
+ if (!refreshToken) throw new Error("ChatGPT returned no refresh token")
170
+ return {
171
+ hostId: previous.hostId,
172
+ clientId: previous.clientId,
173
+ accessToken: tokens.access_token,
174
+ refreshToken,
175
+ expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
176
+ } satisfies ChatGPTAuth
177
+ }
178
+
179
+ function random(bytes: number): string {
180
+ return randomBytes(bytes).toString("base64url")
181
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs"
3
+ import { parseArgs } from "node:util"
4
+ import { paths, prepareStorage } from "./app"
5
+ import { allBoards, type BoardEntry, columnsText } from "./board"
6
+ import { chat, conversationOptions, converse, openSession, printModels, providerOf, startSession, toolContext, yes } from "./chat"
7
+ import { signIn } from "./chatgpt"
8
+ import { type Config, loadConfig, nameOf } from "./config"
9
+ import { messagesFrom, turn } from "./conversation"
10
+ import { faceSets, withFace } from "./faces"
11
+ import { Problem, t, useLanguage } from "./i18n"
12
+ import { openSessionLog } from "./log"
13
+ import { memoryHistory, memoryLimit, personaLimit, readMemory, size } from "./memory"
14
+ import { chooseModel, providers, saveApiKey } from "./providers"
15
+ import { askOnce, dim, openBrowser, projectLine, readSecret, report, tilde } from "./terminal"
16
+ import { emptyWorkspaces, isEmpty, newWorkspace, tidy } from "./workspaces"
17
+
18
+ type Command = (args: string[], config: Config) => Promise<void>
19
+
20
+ const commands: Record<string, Command> = {
21
+ async login([id = "chatgpt"]) {
22
+ const provider = providerOf(id)
23
+ if (provider.auth === "chatgpt") {
24
+ await signIn((url) => {
25
+ console.log(t.openUrl(url))
26
+ openBrowser(url)
27
+ })
28
+ } else {
29
+ const apiKey = await readSecret(t.askApiKey(provider.id))
30
+ if (!apiKey) throw new Problem("cancelled")
31
+ await saveApiKey(provider, apiKey)
32
+ }
33
+ console.log(t.signedIn(provider.id))
34
+ },
35
+
36
+ async models([id]) {
37
+ await printModels(id)
38
+ },
39
+
40
+ async ask(args, config) {
41
+ const { values, positionals } = parseArgs({ args, options: conversationOptions, allowPositionals: true })
42
+ const text = positionals.join(" ")
43
+ if (!text) throw usage()
44
+ const choice = chooseModel(values.model ?? config.model)
45
+ const log = await openSession(values.session)
46
+ await converse(await startSession(log, choice, askOnce, config), text)
47
+ console.log(`\n${t.footer(choice.ref, log.path)}`)
48
+ },
49
+
50
+ "": home,
51
+ ".": (args, config) => chat(args, config),
52
+ new: (args, config) => fresh(args, config),
53
+
54
+ // Tidies away the empty places hagumi new left, all at once after one question; never the one in use.
55
+ async prune(_args, config) {
56
+ const empty = (await emptyWorkspaces(config.workspaces)).filter((dir) => dir !== process.cwd())
57
+ if (empty.length === 0) return console.log(t.pruneNone)
58
+ console.log(t.pruneFound(empty.map((dir) => ` ${tilde(dir)}`)))
59
+ if (!yes(await askOnce(t.pruneAsk))) return
60
+ let count = 0
61
+ for (const dir of empty) if (await tidy(dir)) count++
62
+ console.log(t.pruned(count))
63
+ },
64
+
65
+ // Shows where the settings live, and every value in effect.
66
+ async config(_args, config) {
67
+ console.log(`${t.configShown(paths.config)}\n${JSON.stringify(config, null, 2)}`)
68
+ },
69
+
70
+ // Shows where the persona and memory are kept, what they hold, and how they changed lately.
71
+ async memory() {
72
+ for (const [shown, path, limit] of [
73
+ [t.personaShown, paths.persona, personaLimit],
74
+ [t.memoryShown, paths.memory, memoryLimit],
75
+ ] as const) {
76
+ const text = await readMemory(path)
77
+ console.log(`${shown(path, size(text), limit)}\n${text ? `\n${text}` : t.memoryEmpty}\n`)
78
+ }
79
+ const changes = await memoryHistory()
80
+ if (changes.length > 0) console.log(`${t.memoryChanges}\n${changes.map((change) => ` ${change}`).join("\n")}`)
81
+ },
82
+
83
+ // Prints the last request exactly as it was sent to the model, secrets aside.
84
+ async inspect([id]) {
85
+ if (!id) throw usage()
86
+ const request = (await (await openSession(id)).read()).findLast((entry) => entry.kind === "request")
87
+ if (!request) throw new Problem("sessionNotFound")
88
+ const { url, headers, body } = request
89
+ console.log(JSON.stringify({ url, headers, body }, null, 2))
90
+ },
91
+ }
92
+
93
+ // The way in: the projects that moved most recently, then where to begin. Older ones are not shown, which is
94
+ // all their archive is, and are found by what the user remembers of them. A project is resumed in a new chat,
95
+ // since its board and memory carry on from before. Without a terminal, the list is all.
96
+ async function home(_args: string[], config: Config): Promise<void> {
97
+ const faces = faceSets[config.faces]
98
+ const all = (await allBoards()).filter(({ workspace }) => existsSync(workspace)).sort((a, b) => b.updated - a.updated)
99
+ const projects = all.slice(0, 9)
100
+ console.log(withFace(faces.greeting, t.greeting(nameOf(config), new Date().getHours())))
101
+ console.log(
102
+ [
103
+ "",
104
+ ...numbered(projects),
105
+ ` n. ${t.startNew}`,
106
+ ` . ${t.startHere(tilde(process.cwd()))}`,
107
+ ...(all.length > 0 ? [dim(` ${t.searchHint}`)] : []),
108
+ "",
109
+ ].join("\n"),
110
+ )
111
+ if (!process.stdin.isTTY) return
112
+ const answer = (await askOnce(withFace(faces.waiting, t.whereToStart))).trim()
113
+ console.log()
114
+ const chosen = /^\d+$/.test(answer) ? projects[Number(answer) - 1] : ["", "n", "."].includes(answer) ? undefined : await search(answer, all, config)
115
+ if (chosen) process.chdir(chosen.workspace)
116
+ if (chosen || answer === ".") return chat([], config, true)
117
+ if (answer === "n") return fresh([], config, true)
118
+ }
119
+
120
+ // Finds a project from what the user remembers of it: when it was, its name, or what it was about. The model
121
+ // reads every board, with no tools to call, and the user picks from what it finds.
122
+ async function search(description: string, boards: BoardEntry[], config: Config): Promise<BoardEntry | undefined> {
123
+ const faces = faceSets[config.faces]
124
+ console.log(withFace(faces.thinking, t.searching))
125
+ const log = await openSessionLog()
126
+ const listed = boards.map(({ workspace, board, updated }, index) =>
127
+ [`${index + 1}. ${workspace} (last moved ${new Date(updated).toLocaleDateString("sv-SE")})`, columnsText(board)].join("\n"),
128
+ )
129
+ const text = `${description}\n\n${listed.join("\n\n")}`
130
+ for await (const _ of turn({ log, choice: chooseModel(config.model), text, system: searchPrompt, context: toolContext(askOnce, config), tools: [] })) {
131
+ }
132
+ const reply = messagesFrom(await log.read()).findLast((message) => message.role === "assistant")
133
+ const found = [...new Set(reply?.text.match(/\d+/g) ?? [])].flatMap((n) => boards[Number(n) - 1] ?? []).slice(0, 9)
134
+ if (found.length === 0) return void console.log(t.searchNone)
135
+ console.log(["", ...numbered(found), ""].join("\n"))
136
+ const answer = (await askOnce(withFace(faces.waiting, t.whichOne))).trim()
137
+ console.log()
138
+ return found[Number(answer) - 1]
139
+ }
140
+
141
+ const searchPrompt =
142
+ "The user is looking for one of their projects and tells you what they remember of it: when it was, its name, or what it was about. " +
143
+ "Below it are the projects, each with its directory, the day its board last moved, and its board. " +
144
+ "Reply with only the numbers of the projects that match, best first, separated by spaces, or 0 if none does."
145
+
146
+ function numbered(projects: BoardEntry[]): string[] {
147
+ return projects.map(({ workspace, board }, index) => ` ${index + 1}. ${projectLine(workspace, board)}`)
148
+ }
149
+
150
+ // A fresh place to work, and a chat in it.
151
+ async function fresh(args: string[], config: Config, greeted = false): Promise<void> {
152
+ parseArgs({ args, options: { model: { type: "string" } } })
153
+ const workspace = await newWorkspace(config.workspaces)
154
+ process.chdir(workspace)
155
+ console.log(t.madeWorkspace(tilde(workspace)))
156
+ await chat(args, config, greeted)
157
+ // A place left empty is offered to be tidied away; otherwise the way there is shown.
158
+ const tidied = (await isEmpty(workspace)) && yes(await askOnce(t.tidyEmpty)) && (await tidy(workspace))
159
+ console.log(tidied ? t.tidied : t.goThere(tilde(workspace)))
160
+ }
161
+
162
+ function usage(): Problem {
163
+ return new Problem("usage", Object.keys(providers).join(", "))
164
+ }
165
+
166
+ const [name = "", ...args] = process.argv.slice(2)
167
+ try {
168
+ await prepareStorage()
169
+ const config = await loadConfig()
170
+ useLanguage(config.language)
171
+ const command = commands[name]
172
+ if (!command) throw usage()
173
+ await command(args, config)
174
+ } catch (error) {
175
+ report(error)
176
+ process.exit(1)
177
+ }
package/src/compact.ts ADDED
@@ -0,0 +1,41 @@
1
+ // Old tool output is most of what fills a conversation (docs/concept.md §6.2). Once the conversation grows
2
+ // past a size, every long output from before the current turn is replaced with a short note, all at once,
3
+ // so that the prompt cache breaks only then. The log keeps every output in full: this only shapes what is
4
+ // sent, and the same log always gives the same request.
5
+ import type { Message } from "./conversation"
6
+
7
+ // In characters, as the conversation is sent: about 30,000 tokens.
8
+ export const compactAt = 100_000
9
+ // A shorter output says too much for its size to be worth leaving out.
10
+ const longOutput = 1_000
11
+
12
+ // The conversation to send, and how many outputs were left out as the current turn began.
13
+ export function compact(messages: Message[]): { conversation: Message[]; omitted: number } {
14
+ const conversation = [...messages]
15
+ let size = 0
16
+ let omitted = 0
17
+ for (const [index, message] of messages.entries()) {
18
+ if (message.role === "user") omitted = 0
19
+ if (message.role === "user" && size > compactAt) {
20
+ for (const [earlier, old] of conversation.slice(0, index).entries()) {
21
+ if (old.role !== "tool" || old.output.length <= longOutput) continue
22
+ const output = note(old.output.length)
23
+ size -= old.output.length - output.length
24
+ conversation[earlier] = { ...old, output }
25
+ omitted++
26
+ }
27
+ }
28
+ size += sizeOf(message)
29
+ }
30
+ return { conversation, omitted }
31
+ }
32
+
33
+ function note(length: number): string {
34
+ return `[Omitted: ${length.toLocaleString("en-US")} characters of old output. Call the tool again if you need it.]`
35
+ }
36
+
37
+ function sizeOf(message: Message): number {
38
+ if (message.role === "user") return message.text.length
39
+ if (message.role === "tool") return message.output.length
40
+ return message.text.length + message.toolCalls.reduce((sum, call) => sum + call.arguments.length, 0)
41
+ }