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/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
+
@@ -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
+ }