goatchain 0.0.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/README.md +529 -0
- package/cli/args.mjs +113 -0
- package/cli/clack.mjs +111 -0
- package/cli/clipboard.mjs +320 -0
- package/cli/files.mjs +247 -0
- package/cli/index.mjs +299 -0
- package/cli/itermPaste.mjs +147 -0
- package/cli/persist.mjs +205 -0
- package/cli/repl.mjs +3141 -0
- package/cli/sdk.mjs +341 -0
- package/cli/sessionTransfer.mjs +118 -0
- package/cli/turn.mjs +751 -0
- package/cli/ui.mjs +138 -0
- package/cli.mjs +5 -0
- package/dist/index.cjs +4860 -0
- package/dist/index.d.cts +3479 -0
- package/dist/index.d.ts +3479 -0
- package/dist/index.js +4795 -0
- package/package.json +68 -0
package/cli/index.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { parseCliArgs } from './args.mjs'
|
|
5
|
+
import { createAgent } from './sdk.mjs'
|
|
6
|
+
import { getCachePaths, ensureDir, loadLocalConfig, loadSession, saveLocalConfig, saveSession, listSessions, renameSession, setPinnedSessions, deleteSession, deleteSessions } from './persist.mjs'
|
|
7
|
+
|
|
8
|
+
async function readPkgVersion() {
|
|
9
|
+
try {
|
|
10
|
+
const pkgUrl = new URL('../package.json', import.meta.url)
|
|
11
|
+
const { readFile } = await import('node:fs/promises')
|
|
12
|
+
const pkg = JSON.parse(await readFile(pkgUrl, 'utf8'))
|
|
13
|
+
return typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return '0.0.0'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function runOneShot({ agent, prompt }) {
|
|
21
|
+
const history = []
|
|
22
|
+
const sessionId = randomUUID()
|
|
23
|
+
for await (const event of agent.stream({
|
|
24
|
+
sessionId,
|
|
25
|
+
input: prompt,
|
|
26
|
+
messages: history,
|
|
27
|
+
toolContext: {
|
|
28
|
+
approval: {
|
|
29
|
+
strategy: 'high_risk',
|
|
30
|
+
autoApprove: process.env.GOATCHAIN_AUTO_APPROVE === '1',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
})) {
|
|
34
|
+
if (event.type === 'text_delta')
|
|
35
|
+
process.stdout.write(event.delta)
|
|
36
|
+
else if (event.type === 'requires_action') {
|
|
37
|
+
process.stderr.write(`\n[requires action] ${event.kind ?? ''} (${event.toolName ?? 'unknown tool'})\n`)
|
|
38
|
+
break
|
|
39
|
+
}
|
|
40
|
+
else if (event.type === 'done')
|
|
41
|
+
break
|
|
42
|
+
}
|
|
43
|
+
process.stdout.write('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function main(argv) {
|
|
47
|
+
const version = await readPkgVersion()
|
|
48
|
+
|
|
49
|
+
const args = parseCliArgs(argv, version)
|
|
50
|
+
|
|
51
|
+
let workspaceCwd = process.cwd()
|
|
52
|
+
let paths = getCachePaths(workspaceCwd)
|
|
53
|
+
await ensureDir(paths.sessionsDir)
|
|
54
|
+
|
|
55
|
+
let localConfig = await loadLocalConfig(paths)
|
|
56
|
+
|
|
57
|
+
let currentApiKey = args.apiKey
|
|
58
|
+
const modelId = args.modelId ?? process.env.GOATCHAIN_MODEL ?? localConfig?.defaults?.modelId ?? 'gpt-4o'
|
|
59
|
+
const system = args.system
|
|
60
|
+
let baseUrl = args.baseUrl ?? process.env.GOATCHAIN_OPENAI_BASE_URL ?? localConfig?.openai?.baseUrl
|
|
61
|
+
|
|
62
|
+
const requestDefaults = /** @type {Record<string, any>} */ ({})
|
|
63
|
+
const applyRequestDefaults = (cfg, cliArgs) => {
|
|
64
|
+
const next = {
|
|
65
|
+
...(cfg?.defaults?.requestDefaults ?? {}),
|
|
66
|
+
temperature: cliArgs?.temperature ?? (cfg?.defaults?.requestDefaults?.temperature),
|
|
67
|
+
maxOutputTokens: cliArgs?.maxTokens ?? (cfg?.defaults?.requestDefaults?.maxOutputTokens),
|
|
68
|
+
topP: cliArgs?.topP ?? (cfg?.defaults?.requestDefaults?.topP),
|
|
69
|
+
presencePenalty: cliArgs?.presencePenalty ?? (cfg?.defaults?.requestDefaults?.presencePenalty),
|
|
70
|
+
frequencyPenalty: cliArgs?.frequencyPenalty ?? (cfg?.defaults?.requestDefaults?.frequencyPenalty),
|
|
71
|
+
seed: cliArgs?.seed ?? (cfg?.defaults?.requestDefaults?.seed),
|
|
72
|
+
timeoutMs: cliArgs?.timeoutMs ?? (cfg?.defaults?.requestDefaults?.timeoutMs),
|
|
73
|
+
}
|
|
74
|
+
for (const k of Object.keys(requestDefaults))
|
|
75
|
+
delete requestDefaults[k]
|
|
76
|
+
Object.assign(requestDefaults, next)
|
|
77
|
+
}
|
|
78
|
+
applyRequestDefaults(localConfig, args)
|
|
79
|
+
|
|
80
|
+
const provider = 'openai'
|
|
81
|
+
const getApiKey = () => currentApiKey ?? process.env.OPENAI_API_KEY ?? localConfig?.openai?.apiKey
|
|
82
|
+
|
|
83
|
+
const uiPrefs = /** @type {Record<string, any>} */ ({})
|
|
84
|
+
const applyUiPrefs = (cfg) => {
|
|
85
|
+
const envEmoji = process.env.GOATCHAIN_EMOJI
|
|
86
|
+
const defaultEmoji = envEmoji === '1'
|
|
87
|
+
? true
|
|
88
|
+
: envEmoji === '0'
|
|
89
|
+
? false
|
|
90
|
+
: (process.stdout.isTTY && process.env.TERM !== 'dumb')
|
|
91
|
+
const next = {
|
|
92
|
+
toolStyle: cfg?.ui?.toolStyle ?? (process.env.GOATCHAIN_TOOL_STYLE ?? 'inline'),
|
|
93
|
+
showTools: typeof cfg?.ui?.showTools === 'boolean' ? cfg.ui.showTools : (process.env.GOATCHAIN_SHOW_TOOLS !== '0'),
|
|
94
|
+
showStatus: typeof cfg?.ui?.showStatus === 'boolean' ? cfg.ui.showStatus : (process.env.GOATCHAIN_SHOW_STATUS !== '0'),
|
|
95
|
+
showStatusLine: typeof cfg?.ui?.showStatusLine === 'boolean' ? cfg.ui.showStatusLine : (process.env.GOATCHAIN_SHOW_STATUSLINE !== '0'),
|
|
96
|
+
promptContinue: typeof cfg?.ui?.promptContinue === 'boolean' ? cfg.ui.promptContinue : (process.env.GOATCHAIN_PROMPT_CONTINUE !== '0'),
|
|
97
|
+
autoContinue: typeof cfg?.ui?.autoContinue === 'boolean' ? cfg.ui.autoContinue : (process.env.GOATCHAIN_AUTO_CONTINUE === '1'),
|
|
98
|
+
sessionsUi: cfg?.ui?.sessionsUi ?? (process.env.GOATCHAIN_SESSIONS_UI ?? 'auto'),
|
|
99
|
+
emoji: typeof cfg?.ui?.emoji === 'boolean' ? cfg.ui.emoji : defaultEmoji,
|
|
100
|
+
}
|
|
101
|
+
for (const k of Object.keys(uiPrefs))
|
|
102
|
+
delete uiPrefs[k]
|
|
103
|
+
Object.assign(uiPrefs, next)
|
|
104
|
+
}
|
|
105
|
+
applyUiPrefs(localConfig)
|
|
106
|
+
|
|
107
|
+
if (args.prompt) {
|
|
108
|
+
const { agent } = await createAgent({ getApiKey, modelId, system, baseUrl, requestDefaults })
|
|
109
|
+
await runOneShot({ agent, prompt: args.prompt })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let sessionId = args.sessionId ?? localConfig?.lastSessionId ?? randomUUID()
|
|
114
|
+
let history = []
|
|
115
|
+
let sessionCreatedAt = Date.now()
|
|
116
|
+
let sessionTitle
|
|
117
|
+
let sessionSummary
|
|
118
|
+
let sessionPinned = false
|
|
119
|
+
|
|
120
|
+
const loaded = await loadSession(paths, sessionId)
|
|
121
|
+
if (loaded) {
|
|
122
|
+
sessionCreatedAt = loaded.createdAt ?? sessionCreatedAt
|
|
123
|
+
sessionTitle = loaded.title
|
|
124
|
+
sessionSummary = loaded.summary
|
|
125
|
+
sessionPinned = Boolean(loaded.pinned)
|
|
126
|
+
history = Array.isArray(loaded.messages) ? loaded.messages : []
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let { agent, model, tools } = await createAgent({ getApiKey, modelId, system, baseUrl, requestDefaults })
|
|
130
|
+
if (loaded?.modelId && typeof model?.setModelId === 'function')
|
|
131
|
+
model.setModelId(loaded.modelId)
|
|
132
|
+
|
|
133
|
+
const persistConfig = async (extra = {}) => {
|
|
134
|
+
localConfig = await saveLocalConfig(paths, {
|
|
135
|
+
...localConfig,
|
|
136
|
+
...extra,
|
|
137
|
+
openai: {
|
|
138
|
+
...(localConfig?.openai ?? {}),
|
|
139
|
+
baseUrl,
|
|
140
|
+
apiKey: getApiKey(),
|
|
141
|
+
},
|
|
142
|
+
defaults: {
|
|
143
|
+
...(localConfig?.defaults ?? {}),
|
|
144
|
+
modelId: model.modelId,
|
|
145
|
+
requestDefaults,
|
|
146
|
+
},
|
|
147
|
+
ui: uiPrefs,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const saveAll = async () => {
|
|
152
|
+
const saved = await saveSession(paths, {
|
|
153
|
+
sessionId,
|
|
154
|
+
createdAt: sessionCreatedAt,
|
|
155
|
+
modelId: model.modelId,
|
|
156
|
+
systemPrompt: system,
|
|
157
|
+
title: sessionTitle,
|
|
158
|
+
summary: sessionSummary,
|
|
159
|
+
pinned: sessionPinned,
|
|
160
|
+
messages: history,
|
|
161
|
+
})
|
|
162
|
+
sessionTitle = saved?.title ?? sessionTitle
|
|
163
|
+
sessionSummary = saved?.summary ?? sessionSummary
|
|
164
|
+
sessionPinned = Boolean(saved?.pinned)
|
|
165
|
+
await persistConfig({ lastSessionId: sessionId })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let saveQueue = Promise.resolve()
|
|
169
|
+
const saveAllQueued = () => {
|
|
170
|
+
const run = () => saveAll()
|
|
171
|
+
saveQueue = saveQueue.then(run, run)
|
|
172
|
+
return saveQueue
|
|
173
|
+
}
|
|
174
|
+
await saveAllQueued()
|
|
175
|
+
|
|
176
|
+
const { runRepl } = await import('./repl.mjs')
|
|
177
|
+
const agentRef = { get: () => agent }
|
|
178
|
+
const modelRef = { get: () => model }
|
|
179
|
+
const baseUrlRef = { get: () => baseUrl }
|
|
180
|
+
|
|
181
|
+
const switchWorkspace = async (nextCwdRaw) => {
|
|
182
|
+
const candidate = path.resolve(process.cwd(), String(nextCwdRaw ?? ''))
|
|
183
|
+
const st = await stat(candidate)
|
|
184
|
+
if (!st.isDirectory())
|
|
185
|
+
throw new Error(`Not a directory: ${candidate}`)
|
|
186
|
+
|
|
187
|
+
process.chdir(candidate)
|
|
188
|
+
workspaceCwd = process.cwd()
|
|
189
|
+
paths = getCachePaths(workspaceCwd)
|
|
190
|
+
await ensureDir(paths.sessionsDir)
|
|
191
|
+
localConfig = await loadLocalConfig(paths)
|
|
192
|
+
applyUiPrefs(localConfig)
|
|
193
|
+
applyRequestDefaults(localConfig, null)
|
|
194
|
+
|
|
195
|
+
baseUrl = process.env.GOATCHAIN_OPENAI_BASE_URL ?? localConfig?.openai?.baseUrl ?? baseUrl
|
|
196
|
+
|
|
197
|
+
sessionId = localConfig?.lastSessionId ?? randomUUID()
|
|
198
|
+
history = []
|
|
199
|
+
sessionCreatedAt = Date.now()
|
|
200
|
+
sessionTitle = undefined
|
|
201
|
+
sessionSummary = undefined
|
|
202
|
+
sessionPinned = false
|
|
203
|
+
|
|
204
|
+
const loadedNext = await loadSession(paths, sessionId)
|
|
205
|
+
if (loadedNext) {
|
|
206
|
+
sessionCreatedAt = loadedNext.createdAt ?? sessionCreatedAt
|
|
207
|
+
sessionTitle = loadedNext.title
|
|
208
|
+
sessionSummary = loadedNext.summary
|
|
209
|
+
sessionPinned = Boolean(loadedNext.pinned)
|
|
210
|
+
history = Array.isArray(loadedNext.messages) ? loadedNext.messages : []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const nextModelId = localConfig?.defaults?.modelId ?? model.modelId ?? 'gpt-4o'
|
|
214
|
+
;({ agent, model, tools } = await createAgent({ getApiKey, modelId: nextModelId, system, baseUrl, requestDefaults }))
|
|
215
|
+
if (loadedNext?.modelId && typeof model?.setModelId === 'function')
|
|
216
|
+
model.setModelId(loadedNext.modelId)
|
|
217
|
+
|
|
218
|
+
await saveAllQueued()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const setApiKey = (key) => {
|
|
222
|
+
currentApiKey = key
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const setBaseUrl = async (url) => {
|
|
226
|
+
baseUrl = url
|
|
227
|
+
if (typeof model?.setBaseUrl === 'function') {
|
|
228
|
+
model.setBaseUrl(baseUrl)
|
|
229
|
+
await saveAllQueued()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
;({ agent, model, tools } = await createAgent({ getApiKey, modelId: model.modelId, system, baseUrl, requestDefaults }))
|
|
233
|
+
await saveAllQueued()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const session = {
|
|
237
|
+
get sessionId() {
|
|
238
|
+
return sessionId
|
|
239
|
+
},
|
|
240
|
+
set sessionId(v) {
|
|
241
|
+
sessionId = v
|
|
242
|
+
},
|
|
243
|
+
get createdAt() {
|
|
244
|
+
return sessionCreatedAt
|
|
245
|
+
},
|
|
246
|
+
set createdAt(v) {
|
|
247
|
+
sessionCreatedAt = v
|
|
248
|
+
},
|
|
249
|
+
get history() {
|
|
250
|
+
return history
|
|
251
|
+
},
|
|
252
|
+
set history(v) {
|
|
253
|
+
history = v
|
|
254
|
+
},
|
|
255
|
+
get title() {
|
|
256
|
+
return sessionTitle
|
|
257
|
+
},
|
|
258
|
+
set title(v) {
|
|
259
|
+
sessionTitle = v
|
|
260
|
+
},
|
|
261
|
+
get summary() {
|
|
262
|
+
return sessionSummary
|
|
263
|
+
},
|
|
264
|
+
set summary(v) {
|
|
265
|
+
sessionSummary = v
|
|
266
|
+
},
|
|
267
|
+
get pinned() {
|
|
268
|
+
return sessionPinned
|
|
269
|
+
},
|
|
270
|
+
set pinned(v) {
|
|
271
|
+
sessionPinned = Boolean(v)
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await runRepl({
|
|
276
|
+
version,
|
|
277
|
+
provider,
|
|
278
|
+
system,
|
|
279
|
+
agentRef,
|
|
280
|
+
modelRef,
|
|
281
|
+
toolsInfo: tools,
|
|
282
|
+
requestDefaults,
|
|
283
|
+
baseUrlRef,
|
|
284
|
+
setBaseUrl,
|
|
285
|
+
setApiKey,
|
|
286
|
+
saveAll: saveAllQueued,
|
|
287
|
+
listSessions: () => listSessions(paths),
|
|
288
|
+
loadSessionById: (id) => loadSession(paths, id),
|
|
289
|
+
saveSessionByData: (session) => saveSession(paths, session),
|
|
290
|
+
renameSessionById: (id, title) => renameSession(paths, id, title),
|
|
291
|
+
setPinnedSessionsById: (ids, pinned) => setPinnedSessions(paths, ids, pinned),
|
|
292
|
+
deleteSessionById: (id) => deleteSession(paths, id),
|
|
293
|
+
deleteSessionsById: (ids) => deleteSessions(paths, ids),
|
|
294
|
+
uiPrefs,
|
|
295
|
+
switchWorkspace,
|
|
296
|
+
getWorkspaceCwd: () => process.cwd(),
|
|
297
|
+
session,
|
|
298
|
+
})
|
|
299
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
const START = '\u001B]1337;File='
|
|
4
|
+
|
|
5
|
+
function findTerminator(s, from) {
|
|
6
|
+
const bell = s.indexOf('\u0007', from)
|
|
7
|
+
const st = s.indexOf('\u001B\\', from)
|
|
8
|
+
if (bell === -1 && st === -1)
|
|
9
|
+
return { idx: -1, len: 0 }
|
|
10
|
+
if (bell === -1)
|
|
11
|
+
return { idx: st, len: 2 }
|
|
12
|
+
if (st === -1)
|
|
13
|
+
return { idx: bell, len: 1 }
|
|
14
|
+
return bell < st ? { idx: bell, len: 1 } : { idx: st, len: 2 }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function decodeBase64Utf8(b64) {
|
|
18
|
+
try {
|
|
19
|
+
return Buffer.from(String(b64 ?? ''), 'base64').toString('utf8')
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return ''
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sniffMimeFromBase64(b64) {
|
|
27
|
+
try {
|
|
28
|
+
const head = Buffer.from(String(b64 ?? '').slice(0, 96), 'base64')
|
|
29
|
+
if (head.length >= 8) {
|
|
30
|
+
// PNG
|
|
31
|
+
if (head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4E && head[3] === 0x47 && head[4] === 0x0D && head[5] === 0x0A && head[6] === 0x1A && head[7] === 0x0A)
|
|
32
|
+
return { mime: 'image/png', ext: '.png' }
|
|
33
|
+
// JPEG
|
|
34
|
+
if (head[0] === 0xFF && head[1] === 0xD8 && head[2] === 0xFF)
|
|
35
|
+
return { mime: 'image/jpeg', ext: '.jpg' }
|
|
36
|
+
// GIF
|
|
37
|
+
if (head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x38)
|
|
38
|
+
return { mime: 'image/gif', ext: '.gif' }
|
|
39
|
+
// WEBP: RIFF....WEBP
|
|
40
|
+
if (head.length >= 12) {
|
|
41
|
+
const riff = head.subarray(0, 4).toString('ascii')
|
|
42
|
+
const webp = head.subarray(8, 12).toString('ascii')
|
|
43
|
+
if (riff === 'RIFF' && webp === 'WEBP')
|
|
44
|
+
return { mime: 'image/webp', ext: '.webp' }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
return { mime: 'application/octet-stream', ext: '' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mimeFromFilename(filename, fallbackB64) {
|
|
55
|
+
const lower = String(filename ?? '').toLowerCase()
|
|
56
|
+
if (lower.endsWith('.png'))
|
|
57
|
+
return { mime: 'image/png', ext: '.png' }
|
|
58
|
+
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
|
|
59
|
+
return { mime: 'image/jpeg', ext: '.jpg' }
|
|
60
|
+
if (lower.endsWith('.webp'))
|
|
61
|
+
return { mime: 'image/webp', ext: '.webp' }
|
|
62
|
+
if (lower.endsWith('.gif'))
|
|
63
|
+
return { mime: 'image/gif', ext: '.gif' }
|
|
64
|
+
return sniffMimeFromBase64(fallbackB64)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseOneIterm2FileSequence(seq) {
|
|
68
|
+
// seq includes START and terminator
|
|
69
|
+
const startIdx = seq.indexOf(START)
|
|
70
|
+
if (startIdx < 0)
|
|
71
|
+
return null
|
|
72
|
+
const withoutTerm = seq.endsWith('\u0007')
|
|
73
|
+
? seq.slice(0, -1)
|
|
74
|
+
: seq.endsWith('\u001B\\')
|
|
75
|
+
? seq.slice(0, -2)
|
|
76
|
+
: seq
|
|
77
|
+
|
|
78
|
+
const body = withoutTerm.slice(startIdx + START.length)
|
|
79
|
+
const colon = body.indexOf(':')
|
|
80
|
+
if (colon < 0)
|
|
81
|
+
return null
|
|
82
|
+
|
|
83
|
+
const header = body.slice(0, colon)
|
|
84
|
+
const dataRaw = body.slice(colon + 1)
|
|
85
|
+
const data = dataRaw.replace(/\s+/g, '')
|
|
86
|
+
if (!data)
|
|
87
|
+
return null
|
|
88
|
+
|
|
89
|
+
const meta = {}
|
|
90
|
+
for (const part of header.split(';')) {
|
|
91
|
+
const [k, ...rest] = part.split('=')
|
|
92
|
+
if (!k)
|
|
93
|
+
continue
|
|
94
|
+
meta[k] = rest.join('=')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const decodedName = meta.name ? decodeBase64Utf8(meta.name) : ''
|
|
98
|
+
const baseName = decodedName ? path.basename(decodedName) : ''
|
|
99
|
+
const size = meta.size ? Number(meta.size) : undefined
|
|
100
|
+
const { mime, ext } = mimeFromFilename(baseName, data)
|
|
101
|
+
|
|
102
|
+
const name = baseName || `pasted-image${ext || ''}`
|
|
103
|
+
const bytes = Number.isFinite(size) ? size : (() => {
|
|
104
|
+
try {
|
|
105
|
+
return Buffer.from(data, 'base64').byteLength
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
})()
|
|
111
|
+
|
|
112
|
+
const dataUrl = `data:${mime};base64,${data}`
|
|
113
|
+
return { name, mime, bytes, dataUrl }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function extractIterm2FilePastes(input, buffer = '') {
|
|
117
|
+
const combined = `${String(buffer ?? '')}${String(input ?? '')}`
|
|
118
|
+
if (!combined.includes(START)) {
|
|
119
|
+
return { text: String(input ?? ''), files: [], buffer: '' }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let out = ''
|
|
123
|
+
/** @type {Array<{ name: string, mime: string, bytes: number, dataUrl: string }>} */
|
|
124
|
+
const files = []
|
|
125
|
+
|
|
126
|
+
let i = 0
|
|
127
|
+
while (true) {
|
|
128
|
+
const start = combined.indexOf(START, i)
|
|
129
|
+
if (start === -1) {
|
|
130
|
+
out += combined.slice(i)
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
out += combined.slice(i, start)
|
|
134
|
+
const term = findTerminator(combined, start)
|
|
135
|
+
if (term.idx === -1) {
|
|
136
|
+
return { text: out, files, buffer: combined.slice(start) }
|
|
137
|
+
}
|
|
138
|
+
const seq = combined.slice(start, term.idx + term.len)
|
|
139
|
+
const parsed = parseOneIterm2FileSequence(seq)
|
|
140
|
+
if (parsed)
|
|
141
|
+
files.push(parsed)
|
|
142
|
+
i = term.idx + term.len
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { text: out, files, buffer: '' }
|
|
146
|
+
}
|
|
147
|
+
|
package/cli/persist.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { deriveSessionSummary, deriveSessionTitle } from './ui.mjs'
|
|
5
|
+
|
|
6
|
+
export function getCachePaths(workspaceCwd) {
|
|
7
|
+
const cacheDir = path.join(workspaceCwd, '.goatchain')
|
|
8
|
+
const sessionsDir = path.join(cacheDir, 'sessions')
|
|
9
|
+
return {
|
|
10
|
+
workspaceCwd,
|
|
11
|
+
cacheDir,
|
|
12
|
+
sessionsDir,
|
|
13
|
+
configPath: path.join(cacheDir, 'config.json'),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function ensureDir(dirPath) {
|
|
18
|
+
await mkdir(dirPath, { recursive: true })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readJsonFile(filePath) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(filePath, 'utf8')
|
|
24
|
+
return JSON.parse(raw)
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (err && typeof err === 'object' && err.code === 'ENOENT')
|
|
28
|
+
return undefined
|
|
29
|
+
throw err
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function writeJsonFileAtomic(filePath, data) {
|
|
34
|
+
const dir = path.dirname(filePath)
|
|
35
|
+
await ensureDir(dir)
|
|
36
|
+
const tmpPath = `${filePath}.tmp.${randomUUID()}`
|
|
37
|
+
const content = `${JSON.stringify(data, null, 2)}\n`
|
|
38
|
+
await writeFile(tmpPath, content, { mode: 0o600 })
|
|
39
|
+
await rename(tmpPath, filePath)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadLocalConfig(paths) {
|
|
43
|
+
const cfg = await readJsonFile(paths.configPath)
|
|
44
|
+
if (!cfg || typeof cfg !== 'object')
|
|
45
|
+
return { schemaVersion: 1 }
|
|
46
|
+
return cfg
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function saveLocalConfig(paths, cfg) {
|
|
50
|
+
const next = {
|
|
51
|
+
schemaVersion: 1,
|
|
52
|
+
...cfg,
|
|
53
|
+
updatedAt: Date.now(),
|
|
54
|
+
}
|
|
55
|
+
await writeJsonFileAtomic(paths.configPath, next)
|
|
56
|
+
return next
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sessionPath(paths, sessionId) {
|
|
60
|
+
return path.join(paths.sessionsDir, `${sessionId}.json`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function loadSession(paths, sessionId) {
|
|
64
|
+
const p = sessionPath(paths, sessionId)
|
|
65
|
+
const data = await readJsonFile(p)
|
|
66
|
+
if (!data || typeof data !== 'object')
|
|
67
|
+
return undefined
|
|
68
|
+
const messages = Array.isArray(data.messages) ? data.messages : []
|
|
69
|
+
return {
|
|
70
|
+
sessionId: typeof data.sessionId === 'string' ? data.sessionId : sessionId,
|
|
71
|
+
createdAt: typeof data.createdAt === 'number' ? data.createdAt : Date.now(),
|
|
72
|
+
updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : Date.now(),
|
|
73
|
+
modelId: typeof data.modelId === 'string' ? data.modelId : undefined,
|
|
74
|
+
systemPrompt: typeof data.systemPrompt === 'string' ? data.systemPrompt : undefined,
|
|
75
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
76
|
+
summary: typeof data.summary === 'string' ? data.summary : undefined,
|
|
77
|
+
pinned: Boolean(data.pinned),
|
|
78
|
+
messages,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function saveSession(paths, session) {
|
|
83
|
+
const p = sessionPath(paths, session.sessionId)
|
|
84
|
+
const messages = Array.isArray(session.messages) ? session.messages : []
|
|
85
|
+
const title = typeof session.title === 'string' && session.title.trim()
|
|
86
|
+
? session.title.trim()
|
|
87
|
+
: deriveSessionTitle(messages)
|
|
88
|
+
const summary = typeof session.summary === 'string' && session.summary.trim()
|
|
89
|
+
? session.summary.trim()
|
|
90
|
+
: deriveSessionSummary(messages)
|
|
91
|
+
const pinned = typeof session.pinned === 'boolean' ? session.pinned : false
|
|
92
|
+
|
|
93
|
+
const next = {
|
|
94
|
+
schemaVersion: 1,
|
|
95
|
+
sessionId: session.sessionId,
|
|
96
|
+
createdAt: session.createdAt ?? Date.now(),
|
|
97
|
+
updatedAt: Date.now(),
|
|
98
|
+
modelId: session.modelId,
|
|
99
|
+
systemPrompt: session.systemPrompt,
|
|
100
|
+
title,
|
|
101
|
+
summary,
|
|
102
|
+
pinned,
|
|
103
|
+
messages,
|
|
104
|
+
}
|
|
105
|
+
await writeJsonFileAtomic(p, next)
|
|
106
|
+
return next
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function listSessions(paths) {
|
|
110
|
+
await ensureDir(paths.sessionsDir)
|
|
111
|
+
const entries = await readdir(paths.sessionsDir)
|
|
112
|
+
const sessions = []
|
|
113
|
+
for (const name of entries) {
|
|
114
|
+
if (!name.endsWith('.json'))
|
|
115
|
+
continue
|
|
116
|
+
try {
|
|
117
|
+
const s = await loadSession(paths, name.slice(0, -'.json'.length))
|
|
118
|
+
if (s)
|
|
119
|
+
sessions.push(s)
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// ignore broken session files
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
sessions.sort((a, b) => {
|
|
126
|
+
const ap = a.pinned ? 1 : 0
|
|
127
|
+
const bp = b.pinned ? 1 : 0
|
|
128
|
+
if (bp !== ap)
|
|
129
|
+
return bp - ap
|
|
130
|
+
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
|
|
131
|
+
})
|
|
132
|
+
return sessions
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function renameSession(paths, sessionId, title) {
|
|
136
|
+
const s = await loadSession(paths, sessionId)
|
|
137
|
+
if (!s)
|
|
138
|
+
return undefined
|
|
139
|
+
const nextTitle = typeof title === 'string' ? title.trim() : ''
|
|
140
|
+
return saveSession(paths, {
|
|
141
|
+
sessionId: s.sessionId,
|
|
142
|
+
createdAt: s.createdAt,
|
|
143
|
+
modelId: s.modelId,
|
|
144
|
+
systemPrompt: s.systemPrompt,
|
|
145
|
+
title: nextTitle,
|
|
146
|
+
summary: s.summary,
|
|
147
|
+
pinned: s.pinned,
|
|
148
|
+
messages: s.messages,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function setPinned(paths, sessionId, pinned) {
|
|
153
|
+
const s = await loadSession(paths, sessionId)
|
|
154
|
+
if (!s)
|
|
155
|
+
return undefined
|
|
156
|
+
return saveSession(paths, {
|
|
157
|
+
sessionId: s.sessionId,
|
|
158
|
+
createdAt: s.createdAt,
|
|
159
|
+
modelId: s.modelId,
|
|
160
|
+
systemPrompt: s.systemPrompt,
|
|
161
|
+
title: s.title,
|
|
162
|
+
summary: s.summary,
|
|
163
|
+
pinned: Boolean(pinned),
|
|
164
|
+
messages: s.messages,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function setPinnedSessions(paths, sessionIds, pinned) {
|
|
169
|
+
const ids = Array.isArray(sessionIds) ? sessionIds : []
|
|
170
|
+
const results = []
|
|
171
|
+
for (const id of ids) {
|
|
172
|
+
if (!id || typeof id !== 'string')
|
|
173
|
+
continue
|
|
174
|
+
// eslint-disable-next-line no-await-in-loop
|
|
175
|
+
const updated = await setPinned(paths, id, pinned)
|
|
176
|
+
results.push({ sessionId: id, pinned: Boolean(pinned), updated: Boolean(updated) })
|
|
177
|
+
}
|
|
178
|
+
return results
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function deleteSession(paths, sessionId) {
|
|
182
|
+
const p = sessionPath(paths, sessionId)
|
|
183
|
+
try {
|
|
184
|
+
await unlink(p)
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (err && typeof err === 'object' && err.code === 'ENOENT')
|
|
189
|
+
return false
|
|
190
|
+
throw err
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function deleteSessions(paths, sessionIds) {
|
|
195
|
+
const ids = Array.isArray(sessionIds) ? sessionIds : []
|
|
196
|
+
const results = []
|
|
197
|
+
for (const id of ids) {
|
|
198
|
+
if (!id || typeof id !== 'string')
|
|
199
|
+
continue
|
|
200
|
+
// eslint-disable-next-line no-await-in-loop
|
|
201
|
+
const deleted = await deleteSession(paths, id)
|
|
202
|
+
results.push({ sessionId: id, deleted })
|
|
203
|
+
}
|
|
204
|
+
return results
|
|
205
|
+
}
|