zames_pro 1.1.1 → 1.2.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 +5 -0
- package/package.json +1 -1
- package/src/agent-loop.js +99 -12
- package/src/browser.js +22 -0
- package/src/config.js +1 -0
- package/src/index.js +403 -109
- package/src/input.js +447 -0
- package/src/sessions.js +110 -0
- package/src/spinner.js +60 -2
- package/src/system-prompt.js +39 -5
- package/src/theme.js +1 -0
- package/src/xml-toolcall.js +69 -0
package/src/input.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { theme } from './theme.js'
|
|
2
|
+
import { renderMarkdown } from './markdown.js'
|
|
3
|
+
import { randomThinkingPhrase, stripEllipsis } from './spinner.js'
|
|
4
|
+
|
|
5
|
+
// Постоянная строка ввода внизу терминала + область статуса/вывода над ней.
|
|
6
|
+
//
|
|
7
|
+
// Зачем свой редактор:
|
|
8
|
+
// * строка ввода видна ВСЕГДА и не затирается выводом агента;
|
|
9
|
+
// * спиннер/статус и ответы печатаются ВЫШЕ строки ввода (printAbove);
|
|
10
|
+
// * перерисовка учитывает перенос по ширине терминала, поэтому нет
|
|
11
|
+
// дублирования строк при многострочном вводе;
|
|
12
|
+
// * многострочный ввод: Enter отправляет, Ctrl+J (и Shift+Enter в
|
|
13
|
+
// терминалах с расширенным протоколом) вставляют перевод строки.
|
|
14
|
+
//
|
|
15
|
+
// Все управляющие символы собираются из кодов, чтобы в файле не было
|
|
16
|
+
// «сырых» ESC/CR/LF в строковых литералах (см. AGENTS.md).
|
|
17
|
+
|
|
18
|
+
const NL = String.fromCharCode(10)
|
|
19
|
+
const CR = String.fromCharCode(13)
|
|
20
|
+
const ESC = String.fromCharCode(27)
|
|
21
|
+
const PASTE_START = ESC + '[200~'
|
|
22
|
+
const PASTE_END = ESC + '[201~'
|
|
23
|
+
|
|
24
|
+
const DOTS = ['.', '..', '...']
|
|
25
|
+
const HINT = theme.dim(' · Enter — отправить, Ctrl+J — новая строка, Esc — стоп')
|
|
26
|
+
|
|
27
|
+
function safeJson(v) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(v)
|
|
30
|
+
} catch {
|
|
31
|
+
return String(v)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Видимая длина строки без ANSI-последовательностей.
|
|
36
|
+
export function visLen(s) {
|
|
37
|
+
s = String(s)
|
|
38
|
+
let n = 0
|
|
39
|
+
let i = 0
|
|
40
|
+
while (i < s.length) {
|
|
41
|
+
if (s.charCodeAt(i) === 27) {
|
|
42
|
+
i++
|
|
43
|
+
if (s[i] === '[') {
|
|
44
|
+
i++
|
|
45
|
+
while (i < s.length && !/[A-Za-z]/.test(s[i])) i++
|
|
46
|
+
i++
|
|
47
|
+
}
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
n++
|
|
51
|
+
i++
|
|
52
|
+
}
|
|
53
|
+
return n
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Раскладка ввода на визуальные строки с учётом ширины терминала.
|
|
57
|
+
// Возвращает строки (с префиксом) и позицию курсора в визуальных координатах.
|
|
58
|
+
export function layoutInput(promptStr, buf, cursor, cols) {
|
|
59
|
+
const width = Math.max(20, Number(cols) || 80)
|
|
60
|
+
const promptW = visLen(promptStr)
|
|
61
|
+
const pad = ' '.repeat(promptW)
|
|
62
|
+
const chars = Array.from(String(buf))
|
|
63
|
+
const cur = Math.max(0, Math.min(Number(cursor) || 0, chars.length))
|
|
64
|
+
const rows = []
|
|
65
|
+
let i = 0
|
|
66
|
+
while (true) {
|
|
67
|
+
const first = rows.length === 0
|
|
68
|
+
const prefix = first ? promptStr : pad
|
|
69
|
+
const avail = Math.max(1, width - promptW)
|
|
70
|
+
let text = ''
|
|
71
|
+
let count = 0
|
|
72
|
+
const start = i
|
|
73
|
+
while (i < chars.length && chars[i] !== NL && count < avail) {
|
|
74
|
+
text += chars[i]
|
|
75
|
+
i++
|
|
76
|
+
count++
|
|
77
|
+
}
|
|
78
|
+
if (i < chars.length && chars[i] === NL) {
|
|
79
|
+
i++
|
|
80
|
+
rows.push({ prefix, text, start })
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
rows.push({ prefix, text, start })
|
|
84
|
+
if (i >= chars.length) break
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let cursorRow = 0
|
|
88
|
+
let cursorCol = promptW
|
|
89
|
+
for (let r = 0; r < rows.length; r++) {
|
|
90
|
+
const row = rows[r]
|
|
91
|
+
const len = Array.from(row.text).length
|
|
92
|
+
const from = row.start
|
|
93
|
+
const to = row.start + len
|
|
94
|
+
if (cur >= from && cur <= to) {
|
|
95
|
+
const next = rows[r + 1]
|
|
96
|
+
if (cur === to && next && next.start === to) continue
|
|
97
|
+
cursorRow = r
|
|
98
|
+
cursorCol = promptW + (cur - from)
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { rows, cursorRow, cursorCol }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class LineEditor {
|
|
106
|
+
constructor({ prompt = '> ' } = {}) {
|
|
107
|
+
this.promptStr = prompt
|
|
108
|
+
this.buf = ''
|
|
109
|
+
this.cursor = 0
|
|
110
|
+
this.statusText = ''
|
|
111
|
+
this.pendingText = null
|
|
112
|
+
this.rendered = false
|
|
113
|
+
this.cursorRowFromTop = 0
|
|
114
|
+
this.busy = false
|
|
115
|
+
this.onSubmit = null
|
|
116
|
+
this.onEscape = null
|
|
117
|
+
this.onCtrlC = null
|
|
118
|
+
this._inPaste = false
|
|
119
|
+
this._dotTimer = null
|
|
120
|
+
this._dotPhase = 0
|
|
121
|
+
this._thinkBase = ''
|
|
122
|
+
this._wasRaw = false
|
|
123
|
+
this._onData = (b) => this._handle(b)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
start() {
|
|
127
|
+
const stdin = process.stdin
|
|
128
|
+
if (!stdin.isTTY || !stdin.setRawMode) return false
|
|
129
|
+
this._wasRaw = stdin.isRaw
|
|
130
|
+
stdin.setRawMode(true)
|
|
131
|
+
stdin.resume()
|
|
132
|
+
process.stdout.write(ESC + '[?2004h')
|
|
133
|
+
stdin.on('data', this._onData)
|
|
134
|
+
this._writeBlock()
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
dispose() {
|
|
139
|
+
const stdin = process.stdin
|
|
140
|
+
stdin.removeListener('data', this._onData)
|
|
141
|
+
process.stdout.write(ESC + '[?2004l')
|
|
142
|
+
this._stopDots()
|
|
143
|
+
if (this.rendered) this._eraseBlock()
|
|
144
|
+
if (stdin.setRawMode) stdin.setRawMode(this._wasRaw || false)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setPrompt(str) {
|
|
148
|
+
this.promptStr = str
|
|
149
|
+
this._render()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
clear() {
|
|
153
|
+
this.buf = ''
|
|
154
|
+
this.cursor = 0
|
|
155
|
+
this._render()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- отрисовка ----------
|
|
159
|
+
|
|
160
|
+
_eraseBlock() {
|
|
161
|
+
if (!this.rendered) return
|
|
162
|
+
if (this.cursorRowFromTop > 0) {
|
|
163
|
+
process.stdout.write(ESC + '[' + this.cursorRowFromTop + 'A')
|
|
164
|
+
}
|
|
165
|
+
process.stdout.write(CR + ESC + '[J')
|
|
166
|
+
this.rendered = false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_writeBlock() {
|
|
170
|
+
const cols = process.stdout.columns || 80
|
|
171
|
+
let out = ''
|
|
172
|
+
let top = 0
|
|
173
|
+
if (this.statusText) {
|
|
174
|
+
out += this.statusText + NL
|
|
175
|
+
top = 1
|
|
176
|
+
}
|
|
177
|
+
const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols)
|
|
178
|
+
out += lay.rows.map((r) => r.prefix + r.text).join(NL)
|
|
179
|
+
process.stdout.write(out)
|
|
180
|
+
const lastRow = lay.rows.length - 1
|
|
181
|
+
const up = lastRow - lay.cursorRow
|
|
182
|
+
if (up > 0) process.stdout.write(ESC + '[' + up + 'A')
|
|
183
|
+
process.stdout.write(CR)
|
|
184
|
+
if (lay.cursorCol > 0) process.stdout.write(ESC + '[' + lay.cursorCol + 'C')
|
|
185
|
+
this.rendered = true
|
|
186
|
+
this.cursorRowFromTop = top + lay.cursorRow
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_render() {
|
|
190
|
+
this._eraseBlock()
|
|
191
|
+
this._writeBlock()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Напечатать блок ВЫШЕ строки ввода и вернуть строку ввода на место.
|
|
195
|
+
printAbove(text) {
|
|
196
|
+
this._eraseBlock()
|
|
197
|
+
process.stdout.write(String(text) + NL)
|
|
198
|
+
this._writeBlock()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------- интерфейс, совместимый со спиннером ----------
|
|
202
|
+
|
|
203
|
+
setStatus(text) {
|
|
204
|
+
this.statusText = text || ''
|
|
205
|
+
this._render()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_stopDots() {
|
|
209
|
+
if (this._dotTimer) {
|
|
210
|
+
clearInterval(this._dotTimer)
|
|
211
|
+
this._dotTimer = null
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_startThinking() {
|
|
216
|
+
if (this.pendingText) {
|
|
217
|
+
this.setStatus(theme.prompt('✎ ') + this.pendingText + HINT)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
this._thinkBase = theme.brown(stripEllipsis(randomThinkingPhrase()))
|
|
221
|
+
this._dotPhase = 0
|
|
222
|
+
this.setStatus(this._thinkBase + DOTS[0] + HINT)
|
|
223
|
+
this._stopDots()
|
|
224
|
+
this._dotTimer = setInterval(() => {
|
|
225
|
+
this._dotPhase = (this._dotPhase + 1) % DOTS.length
|
|
226
|
+
this.setStatus(this._thinkBase + DOTS[this._dotPhase] + HINT)
|
|
227
|
+
}, 400)
|
|
228
|
+
if (this._dotTimer.unref) this._dotTimer.unref()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
thinking() {
|
|
232
|
+
this._startThinking()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
setPending(text) {
|
|
236
|
+
this.pendingText = text && String(text).length ? String(text) : null
|
|
237
|
+
if (this.pendingText) {
|
|
238
|
+
this._stopDots()
|
|
239
|
+
this.setStatus(theme.prompt('✎ ') + this.pendingText + HINT)
|
|
240
|
+
} else {
|
|
241
|
+
this._startThinking()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
stop() {
|
|
246
|
+
this._stopDots()
|
|
247
|
+
this.setStatus('')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
toolCall(name, args) {
|
|
251
|
+
const preview = safeJson(args).slice(0, 120)
|
|
252
|
+
this.printAbove(theme.tool('🔧 ' + name) + ' ' + theme.dim(preview))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
toolResult(result) {
|
|
256
|
+
const text = typeof result === 'string' ? result : safeJson(result)
|
|
257
|
+
const preview = text.slice(0, 200).split(NL).join(' ↵ ')
|
|
258
|
+
this.printAbove(theme.toolResult(' → ' + preview))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
assistant(msg) {
|
|
262
|
+
this.stop()
|
|
263
|
+
const rendered = renderMarkdown(msg)
|
|
264
|
+
this.printAbove(
|
|
265
|
+
NL + theme.assistant('● Ответ') + NL + rendered + NL + theme.dim('─'.repeat(60)),
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------- ввод ----------
|
|
270
|
+
|
|
271
|
+
_submit() {
|
|
272
|
+
const text = this.buf
|
|
273
|
+
if (!text.trim()) {
|
|
274
|
+
this._render()
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
this.buf = ''
|
|
278
|
+
this.cursor = 0
|
|
279
|
+
this.pendingText = null
|
|
280
|
+
this._stopDots()
|
|
281
|
+
this.statusText = ''
|
|
282
|
+
this.printAbove(theme.user('❯ ') + text)
|
|
283
|
+
if (this.onSubmit) this.onSubmit(text)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_insert(text) {
|
|
287
|
+
const chars = Array.from(this.buf)
|
|
288
|
+
const ins = Array.from(String(text))
|
|
289
|
+
const next = chars.slice(0, this.cursor).concat(ins, chars.slice(this.cursor))
|
|
290
|
+
this.buf = next.join('')
|
|
291
|
+
this.cursor += ins.length
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_backspace() {
|
|
295
|
+
if (this.cursor <= 0) return
|
|
296
|
+
const chars = Array.from(this.buf)
|
|
297
|
+
chars.splice(this.cursor - 1, 1)
|
|
298
|
+
this.buf = chars.join('')
|
|
299
|
+
this.cursor--
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_delete() {
|
|
303
|
+
const chars = Array.from(this.buf)
|
|
304
|
+
if (this.cursor >= chars.length) return
|
|
305
|
+
chars.splice(this.cursor, 1)
|
|
306
|
+
this.buf = chars.join('')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_left() {
|
|
310
|
+
if (this.cursor > 0) this.cursor--
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_right() {
|
|
314
|
+
if (this.cursor < Array.from(this.buf).length) this.cursor++
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_home() {
|
|
318
|
+
const chars = Array.from(this.buf)
|
|
319
|
+
let i = this.cursor
|
|
320
|
+
while (i > 0 && chars[i - 1] !== NL) i--
|
|
321
|
+
this.cursor = i
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_end() {
|
|
325
|
+
const chars = Array.from(this.buf)
|
|
326
|
+
let i = this.cursor
|
|
327
|
+
while (i < chars.length && chars[i] !== NL) i++
|
|
328
|
+
this.cursor = i
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
_up() {
|
|
332
|
+
const chars = Array.from(this.buf)
|
|
333
|
+
let start = this.cursor
|
|
334
|
+
while (start > 0 && chars[start - 1] !== NL) start--
|
|
335
|
+
if (start === 0) return
|
|
336
|
+
const col = this.cursor - start
|
|
337
|
+
const prevEnd = start - 1
|
|
338
|
+
let prevStart = prevEnd
|
|
339
|
+
while (prevStart > 0 && chars[prevStart - 1] !== NL) prevStart--
|
|
340
|
+
this.cursor = prevStart + Math.min(col, prevEnd - prevStart)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_down() {
|
|
344
|
+
const chars = Array.from(this.buf)
|
|
345
|
+
let end = this.cursor
|
|
346
|
+
while (end < chars.length && chars[end] !== NL) end++
|
|
347
|
+
if (end >= chars.length) return
|
|
348
|
+
let lineStart = this.cursor
|
|
349
|
+
while (lineStart > 0 && chars[lineStart - 1] !== NL) lineStart--
|
|
350
|
+
const col = this.cursor - lineStart
|
|
351
|
+
const nextStart = end + 1
|
|
352
|
+
let nextEnd = nextStart
|
|
353
|
+
while (nextEnd < chars.length && chars[nextEnd] !== NL) nextEnd++
|
|
354
|
+
this.cursor = nextStart + Math.min(col, nextEnd - nextStart)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_handle(data) {
|
|
358
|
+
let s = data.toString('utf-8')
|
|
359
|
+
|
|
360
|
+
if (!this._inPaste && s === ESC) {
|
|
361
|
+
if (this.onEscape) this.onEscape()
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
while (s.length) {
|
|
366
|
+
if (this._inPaste) {
|
|
367
|
+
const end = s.indexOf(PASTE_END)
|
|
368
|
+
if (end === -1) {
|
|
369
|
+
this._insert(s)
|
|
370
|
+
s = ''
|
|
371
|
+
} else {
|
|
372
|
+
this._insert(s.slice(0, end))
|
|
373
|
+
s = s.slice(end + PASTE_END.length)
|
|
374
|
+
this._inPaste = false
|
|
375
|
+
}
|
|
376
|
+
this._render()
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const pasteAt = s.indexOf(PASTE_START)
|
|
381
|
+
if (pasteAt !== -1) {
|
|
382
|
+
const before = s.slice(0, pasteAt)
|
|
383
|
+
s = s.slice(pasteAt + PASTE_START.length)
|
|
384
|
+
this._inPaste = true
|
|
385
|
+
if (before) {
|
|
386
|
+
this._insert(before)
|
|
387
|
+
this._render()
|
|
388
|
+
}
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Shift+Enter в терминалах с расширенным протоколом и Alt+Enter.
|
|
393
|
+
if (s.startsWith(ESC + '[13;2u')) { this._insert(NL); s = s.slice(7); this._render(); continue }
|
|
394
|
+
if (s.startsWith(ESC + '[27;2;13~')) { this._insert(NL); s = s.slice(10); this._render(); continue }
|
|
395
|
+
if (s.startsWith(ESC + NL)) { this._insert(NL); s = s.slice(2); this._render(); continue }
|
|
396
|
+
|
|
397
|
+
const ch = s[0]
|
|
398
|
+
const code = s.charCodeAt(0)
|
|
399
|
+
s = s.slice(1)
|
|
400
|
+
|
|
401
|
+
if (ch === CR) { this._submit(); continue }
|
|
402
|
+
if (code === 10) { this._insert(NL); this._render(); continue }
|
|
403
|
+
if (code === 3) { if (this.onCtrlC) this.onCtrlC(); continue }
|
|
404
|
+
if (code === 4) { if (!this.buf && this.onCtrlC) this.onCtrlC(); continue }
|
|
405
|
+
if (code === 1) { this._home(); this._render(); continue }
|
|
406
|
+
if (code === 5) { this._end(); this._render(); continue }
|
|
407
|
+
if (code === 21) { this.buf = ''; this.cursor = 0; this._render(); continue }
|
|
408
|
+
if (code === 127 || code === 8) { this._backspace(); this._render(); continue }
|
|
409
|
+
|
|
410
|
+
if (code === 27) {
|
|
411
|
+
if (s.startsWith('[D')) { this._left(); s = s.slice(2); this._render(); continue }
|
|
412
|
+
if (s.startsWith('[C')) { this._right(); s = s.slice(2); this._render(); continue }
|
|
413
|
+
if (s.startsWith('[A')) { this._up(); s = s.slice(2); this._render(); continue }
|
|
414
|
+
if (s.startsWith('[B')) { this._down(); s = s.slice(2); this._render(); continue }
|
|
415
|
+
if (s.startsWith('[H') || s.startsWith('[1~')) { this._home(); s = s.slice(s.startsWith('[1~') ? 3 : 2); this._render(); continue }
|
|
416
|
+
if (s.startsWith('[F') || s.startsWith('[4~')) { this._end(); s = s.slice(s.startsWith('[4~') ? 3 : 2); this._render(); continue }
|
|
417
|
+
if (s.startsWith('[3~')) { this._delete(); s = s.slice(3); this._render(); continue }
|
|
418
|
+
let j = 0
|
|
419
|
+
while (j < s.length && !/[A-Za-z~]/.test(s[j])) j++
|
|
420
|
+
s = s.slice(j + 1)
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (code < 32) continue
|
|
425
|
+
this._insert(ch)
|
|
426
|
+
this._render()
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Проверка раскладки: node src/input.js --selftest
|
|
432
|
+
export function selftest() {
|
|
433
|
+
const cases = [
|
|
434
|
+
{ p: '> ', b: 'hello', c: 5, w: 80 },
|
|
435
|
+
{ p: '> ', b: 'a' + NL + 'b', c: 3, w: 80 },
|
|
436
|
+
{ p: '> ', b: 'x'.repeat(200), c: 200, w: 40 },
|
|
437
|
+
{ p: '> ', b: '', c: 0, w: 40 },
|
|
438
|
+
]
|
|
439
|
+
for (const t of cases) {
|
|
440
|
+
const r = layoutInput(t.p, t.b, t.c, t.w)
|
|
441
|
+
console.log(JSON.stringify({ rows: r.rows.length, row: r.cursorRow, col: r.cursorCol }))
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (process.argv[1] && process.argv[1].endsWith('input.js') && process.argv.includes('--selftest')) {
|
|
446
|
+
selftest()
|
|
447
|
+
}
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
|
|
5
|
+
// Сессии (чаты DeepSeek) храним в отдельной папке, чтобы они не терялись
|
|
6
|
+
// после перезапуска процесса. Каждая сессия — отдельный JSON-файл
|
|
7
|
+
// <id>.json в ~/.zames/.sessions. last.json указывает на последнюю
|
|
8
|
+
// открытую сессию для конкретной рабочей директории.
|
|
9
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.zames', '.sessions')
|
|
10
|
+
const INDEX_FILE = path.join(SESSIONS_DIR, 'last.json')
|
|
11
|
+
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function safeName(id) {
|
|
17
|
+
return String(id).replace(/[^a-zA-Z0-9_.-]/g, '_')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Сохраняем/обновляем сессию. workdir нужен, чтобы при запуске из того же
|
|
21
|
+
// проекта восстанавливать именно его последний чат.
|
|
22
|
+
export function saveSession({ id, title = '', workdir = '' }) {
|
|
23
|
+
if (!id) return
|
|
24
|
+
try {
|
|
25
|
+
ensureDir()
|
|
26
|
+
const file = path.join(SESSIONS_DIR, safeName(id) + '.json')
|
|
27
|
+
let prev = {}
|
|
28
|
+
try { prev = JSON.parse(fs.readFileSync(file, 'utf-8')) } catch {}
|
|
29
|
+
const now = new Date().toISOString()
|
|
30
|
+
const data = {
|
|
31
|
+
id,
|
|
32
|
+
title: title || prev.title || '',
|
|
33
|
+
workdir: workdir || prev.workdir || '',
|
|
34
|
+
createdAt: prev.createdAt || now,
|
|
35
|
+
updatedAt: now,
|
|
36
|
+
}
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8')
|
|
38
|
+
writeIndex({ id, workdir: data.workdir })
|
|
39
|
+
return data
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeIndex({ id, workdir }) {
|
|
46
|
+
try {
|
|
47
|
+
ensureDir()
|
|
48
|
+
let index = {}
|
|
49
|
+
try { index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf-8')) } catch {}
|
|
50
|
+
if (!index || typeof index !== 'object') index = {}
|
|
51
|
+
if (!index.byWorkdir || typeof index.byWorkdir !== 'object') index.byWorkdir = {}
|
|
52
|
+
const key = workdir || ''
|
|
53
|
+
index.byWorkdir[key] = id
|
|
54
|
+
index.last = id
|
|
55
|
+
index.updatedAt = new Date().toISOString()
|
|
56
|
+
fs.writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Последняя сессия для рабочей директории. Если для неё ничего нет —
|
|
61
|
+
// возвращаем последнюю сессию вообще (полезно при запуске из нового места).
|
|
62
|
+
export function loadLastSession(workdir = '') {
|
|
63
|
+
try {
|
|
64
|
+
const index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf-8'))
|
|
65
|
+
const byWorkdir = index && index.byWorkdir ? index.byWorkdir : {}
|
|
66
|
+
const candidates = [byWorkdir[workdir], index.last].filter(Boolean)
|
|
67
|
+
for (const id of candidates) {
|
|
68
|
+
// Файл сессии мог быть удалён — индекс тогда устарел, и сессию
|
|
69
|
+
// восстанавливать нельзя. Пробуем следующий кандидат.
|
|
70
|
+
const s = readSession(id)
|
|
71
|
+
if (s) return s
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readSession(id) {
|
|
80
|
+
if (!id) return null
|
|
81
|
+
try {
|
|
82
|
+
const file = path.join(SESSIONS_DIR, safeName(id) + '.json')
|
|
83
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Список всех сессий, свежие — первыми.
|
|
90
|
+
export function listSessions() {
|
|
91
|
+
try {
|
|
92
|
+
ensureDir()
|
|
93
|
+
const files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json') && f !== 'last.json')
|
|
94
|
+
const out = []
|
|
95
|
+
for (const f of files) {
|
|
96
|
+
try {
|
|
97
|
+
const data = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf-8'))
|
|
98
|
+
if (data && data.id) out.push(data)
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
out.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
|
|
102
|
+
return out
|
|
103
|
+
} catch {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function sessionsDir() {
|
|
109
|
+
return SESSIONS_DIR
|
|
110
|
+
}
|
package/src/spinner.js
CHANGED
|
@@ -36,12 +36,22 @@ const THINKING_PHRASES = [
|
|
|
36
36
|
'Замешиваю кал из всего подряд…',
|
|
37
37
|
]
|
|
38
38
|
|
|
39
|
-
function randomThinkingPhrase() {
|
|
39
|
+
export function randomThinkingPhrase() {
|
|
40
40
|
return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)]
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Убираем завершающее многоточие из фразы — точки анимируем отдельно.
|
|
44
|
+
export function stripEllipsis(phrase) {
|
|
45
|
+
return phrase.replace(/[.…]+\s*$/, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
43
48
|
export function createSpinner() {
|
|
44
49
|
let spinner = null
|
|
50
|
+
let dotTimer = null
|
|
51
|
+
let dotPhase = 0
|
|
52
|
+
let pending = null
|
|
53
|
+
|
|
54
|
+
const DOTS = ['.', '..', '...']
|
|
45
55
|
|
|
46
56
|
const start = (text) => {
|
|
47
57
|
if (!spinner) spinner = ora(text).start()
|
|
@@ -49,11 +59,59 @@ export function createSpinner() {
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
const stop = () => {
|
|
62
|
+
if (dotTimer) {
|
|
63
|
+
clearInterval(dotTimer)
|
|
64
|
+
dotTimer = null
|
|
65
|
+
}
|
|
52
66
|
if (spinner) spinner.stop()
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
// Подсказка в строке статуса: во время работы агента терминал живой,
|
|
70
|
+
// можно печатать следующее сообщение. Без неё это неочевидно.
|
|
71
|
+
const HINT = theme.dim(' · печатай — Enter в очередь, Esc стоп')
|
|
72
|
+
|
|
73
|
+
// Запуск анимированного статуса: коричневый текст + «бегущие» точки.
|
|
74
|
+
const startThinking = () => {
|
|
75
|
+
const base = theme.brown(stripEllipsis(randomThinkingPhrase()))
|
|
76
|
+
start(base + DOTS[0] + HINT)
|
|
77
|
+
dotPhase = 0
|
|
78
|
+
if (dotTimer) clearInterval(dotTimer)
|
|
79
|
+
dotTimer = setInterval(() => {
|
|
80
|
+
if (!spinner) return
|
|
81
|
+
dotPhase = (dotPhase + 1) % DOTS.length
|
|
82
|
+
spinner.text = base + DOTS[dotPhase] + HINT
|
|
83
|
+
}, 400)
|
|
84
|
+
if (dotTimer.unref) dotTimer.unref()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Показать набранный, но ещё не отправленный текст вместо спиннера.
|
|
88
|
+
// Позволяет печатать сообщение прямо во время работы агента.
|
|
89
|
+
const showPending = () => {
|
|
90
|
+
if (dotTimer) {
|
|
91
|
+
clearInterval(dotTimer)
|
|
92
|
+
dotTimer = null
|
|
93
|
+
}
|
|
94
|
+
if (!spinner) spinner = ora('')
|
|
95
|
+
spinner.start()
|
|
96
|
+
spinner.text = theme.prompt('✎ ') + pending + HINT
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
return {
|
|
56
|
-
thinking: () =>
|
|
100
|
+
thinking: () => {
|
|
101
|
+
if (pending) {
|
|
102
|
+
showPending()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
startThinking()
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Текст, который пользователь набирает во время работы агента.
|
|
109
|
+
// Пустая строка / null — вернуть обычный спиннер.
|
|
110
|
+
setPending: (text) => {
|
|
111
|
+
pending = text && String(text).length ? String(text) : null
|
|
112
|
+
if (pending) showPending()
|
|
113
|
+
else startThinking()
|
|
114
|
+
},
|
|
57
115
|
|
|
58
116
|
toolCall: (name, args) => {
|
|
59
117
|
stop()
|