zames_pro 1.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent-loop.js CHANGED
@@ -80,16 +80,21 @@ export async function runAgentLoop({
80
80
  // (есть tool/invoke/parameter, но JSON/XML битый) — это почти всегда
81
81
  // ошибка формата. Не считаем её финальным ответом (иначе агент молча
82
82
  // остановится), а просим модель переотправить вызов корректно.
83
- const looksLikeToolCall = /("tool"\s*:|\binvoke\b|\bparameter\b)/i.test(rawResponse)
83
+ const looksLikeToolCall =
84
+ /("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(
85
+ rawResponse,
86
+ ) || /\{\s*"?(tool|name)"?\s*:/.test(rawResponse)
84
87
  if (looksLikeToolCall && malformedRetries < MAX_MALFORMED_RETRIES) {
85
88
  malformedRetries++
86
89
  transcript?.log("malformed_toolcall", { attempt: malformedRetries, response: rawResponse })
87
90
  if (debugLog) {
88
91
  console.error("внимание: ответ похож на tool-call, но не распознан (попытка " + malformedRetries + "/" + MAX_MALFORMED_RETRIES + ")")
89
92
  }
90
- message = "Твой предыдущий ответ не распознан как вызов инструмента. " +
91
- "Ответь РОВНО одним JSON-объектом вызова инструмента, без текста до и после. " +
92
- "Например: " + String.fromCharCode(39) + "tool" + String.fromCharCode(39) + ": ..., args: {...}"
93
+ message =
94
+ 'Твой предыдущий ответ не распознан как вызов инструмента. ' +
95
+ 'Ответь РОВНО одним JSON-объектом вызова инструмента, без текста до и после. ' +
96
+ 'НЕ используй XML/DSML-теги — только JSON. ' +
97
+ 'Например: {"tool": "Read", "args": {"path": "src/index.js"}}'
93
98
  continue
94
99
  }
95
100
 
@@ -162,6 +167,27 @@ export async function runAgentLoop({
162
167
 
163
168
  // ============ JSON parsing ============
164
169
 
170
+ function repairRawControlChars(str) {
171
+ let out = ''
172
+ let inString = false
173
+ let escape = false
174
+ for (let i = 0; i < str.length; i++) {
175
+ const c = str[i]
176
+ if (escape) { out += c; escape = false; continue }
177
+ if (c === '\\') { out += c; escape = true; continue }
178
+ if (c === '"') { inString = !inString; out += c; continue }
179
+ if (inString) {
180
+ if (c === '\n') { out += '\\n'; continue }
181
+ if (c === '\r') { out += '\\r'; continue }
182
+ if (c === '\t') { out += '\\t'; continue }
183
+ const code = c.charCodeAt(0)
184
+ if (code < 0x20) { out += '\\u' + code.toString(16).padStart(4, '0'); continue }
185
+ }
186
+ out += c
187
+ }
188
+ return out
189
+ }
190
+
165
191
  function tryParse(str) {
166
192
  try {
167
193
  const obj = JSON.parse(str)
@@ -417,17 +443,34 @@ function parseToolCallPermissive(text) {
417
443
  // а не первый попавшийся '}'. Иначе вложенные объекты/массивы или
418
444
  // фигурные скобки внутри строк ломают разбор.
419
445
  const endIdx = findMatching(text, openIdx, '{', '}')
420
- if (endIdx === -1) return null
421
446
 
422
- const argsStr = text.slice(openIdx, endIdx + 1)
423
447
  if (tool === 'Edit') {
424
- const e = parseEditArgs(argsStr)
425
- if (e) return { tool, args: e }
448
+ // Edit-аргументы часто содержат сырые кавычки и переводы строк, из-за
449
+ // чего балансировка скобок сбоит. parseEditArgs рассчитан ровно на
450
+ // такой случай, поэтому пробуем его и на «хвосте» до конца текста,
451
+ // а не только на сбалансированном фрагменте.
452
+ const balanced = endIdx === -1 ? null : text.slice(openIdx, endIdx + 1)
453
+ const tailToEnd = text.slice(openIdx)
454
+ for (const frag of [balanced, tailToEnd]) {
455
+ if (!frag) continue
456
+ const e = parseEditArgs(frag)
457
+ if (e) return { tool, args: e }
458
+ }
459
+ }
460
+
461
+ // Балансировка скобок сбоит, если в строковых значениях есть сырые
462
+ // кавычки/скобки (частый случай для Bash/Write с кодом внутри). Тогда
463
+ // пробуем разобрать args из «хвоста» до конца текста и жадным парсером.
464
+ const frags = []
465
+ if (endIdx !== -1) frags.push(text.slice(openIdx, endIdx + 1))
466
+ frags.push(text.slice(openIdx))
467
+
468
+ for (const argsStr of frags) {
469
+ const args = parseArgsPermissive(argsStr)
470
+ if (args) return { tool, args }
471
+ const greedy = parseArgsGreedy(argsStr)
472
+ if (greedy) return { tool, args: greedy }
426
473
  }
427
- const args = parseArgsPermissive(argsStr)
428
- if (args) return { tool, args }
429
- const greedy = parseArgsGreedy(argsStr)
430
- if (greedy) return { tool, args: greedy }
431
474
  return null
432
475
  }
433
476
 
@@ -511,7 +554,7 @@ function findMatching(text, openIdx, openCh, closeCh) {
511
554
  return -1
512
555
  }
513
556
 
514
- function parseToolCall(text) {
557
+ export function parseToolCall(text) {
515
558
  if (!text || typeof text !== 'string') return null
516
559
 
517
560
  let cleaned = text.trim()
@@ -539,9 +582,20 @@ function parseToolCall(text) {
539
582
  const repaired = raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\')
540
583
  const second = tryParse(repaired)
541
584
  if (second) return second
585
+
586
+ // Сырые переводы строк/табы внутри строковых значений (old_string,
587
+ // new_string, content) делают JSON невалидным. Экранируем их и
588
+ // пробуем снова, иначе многострочный вызов не распознаётся.
589
+ const ctrl = repairRawControlChars(raw)
590
+ const third = tryParse(ctrl)
591
+ if (third) return third
592
+ const ctrlArr = tryParseArray(ctrl)
593
+ if (ctrlArr) return ctrlArr
542
594
  }
543
595
 
544
- const permissive = parseToolCallPermissive(cleaned)
596
+ const permissive =
597
+ parseToolCallPermissive(cleaned) ||
598
+ parseToolCallPermissive(repairRawControlChars(cleaned))
545
599
  if (permissive) return { ...permissive, _permissive: true }
546
600
 
547
601
  // Fallback: модель могла обернуть JSON в прозу и/или добавить мусорные
@@ -550,8 +604,13 @@ function parseToolCall(text) {
550
604
  const toolIdx = cleaned.search(/["']?tool["']?\s:/)
551
605
  if (toolIdx > 0) {
552
606
  let tail = cleaned.slice(toolIdx)
553
- tail = tail.replace(/<[^>]>.*$/s, '').trim()
554
- const tailPermissive = parseToolCallPermissive('{"' + tail)
607
+ // Регексп обязан матчить многосимвольные теги (<|DSML|invoke ...>),
608
+ // поэтому <[^>]*>, а не <[^>]> — иначе DSML-хвост не срезается,
609
+ // permissive-разбор падает и агент молча останавливается.
610
+ tail = tail.replace(/<[^>]*>.*$/s, '').trim()
611
+ const tailPermissive =
612
+ parseToolCallPermissive('{"' + tail) ||
613
+ parseToolCallPermissive('{"' + repairRawControlChars(tail))
555
614
  if (tailPermissive) return { ...tailPermissive, _permissive: true }
556
615
  }
557
616
 
package/src/browser.js CHANGED
@@ -79,7 +79,7 @@ export class DeepSeekBrowser {
79
79
  askRetries = 3,
80
80
  stabilityChecks = 3,
81
81
  stabilityDelayMs = 1000,
82
- minSendIntervalMs = 25000,
82
+ minSendIntervalMs = 15000,
83
83
  } = {}) {
84
84
  this.headless = headless
85
85
  this.debug = debug
package/src/config.js CHANGED
@@ -43,7 +43,7 @@ const DEFAULTS = {
43
43
  askRetries: 3,
44
44
  stabilityChecks: 3,
45
45
  stabilityDelayMs: 1000,
46
- minSendIntervalMs: 25000,
46
+ minSendIntervalMs: 15000,
47
47
  },
48
48
  }
49
49
 
package/src/index.js CHANGED
@@ -14,6 +14,12 @@ import { Transcript } from './transcript.js'
14
14
  import { UndoStore } from './undo.js'
15
15
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js'
16
16
  import { closeWeb } from './web.js'
17
+ import {
18
+ saveSession,
19
+ loadLastSession,
20
+ listSessions,
21
+ sessionsDir,
22
+ } from './sessions.js'
17
23
 
18
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
19
25
 
@@ -60,40 +66,26 @@ const chatIdArg = getArg('--chat', null)
60
66
  // переотправляется (он уже есть в начале чата). Флаг --resend-prompt
61
67
  // заставляет дослать его заново — например, если промпт обновился.
62
68
  const resendPrompt = hasFlag('--resend-prompt')
63
- // --new-chat: намеренно начать с чистого чата, игнорируя сохранённый
64
- // --resume-last: при старте вернуться в последний сохранённый чат.
65
- // По умолчанию НЕ восстанавливаем — открывается новый чат.
66
- // (Флаг --new-chat сохранён для совместимости и ничего не меняет.)
69
+ // --new-chat: намеренно начать с чистого чата, игнорируя сохранённую сессию.
70
+ // --resume-last: оставлен для совместимости (восстановление и так по умолчанию).
71
+ // По умолчанию последняя сессия для рабочей директории восстанавливается.
72
+ const newChatFlag = hasFlag('--new-chat')
67
73
  const resumeLastFlag = hasFlag('--resume-last')
68
74
 
69
- // ---------- last chat persistence ----------
70
- // Запоминаем последний открытый chat id, чтобы после перезапуска процесса
71
- // (в т.ч. автоперезапуска через `node --watch`) автоматически вернуться
72
- // в ту же сессию, а не создавать новый чат.
73
- const LAST_CHAT_FILE = path.join(ZAMES_HOME, 'last-chat.json')
74
-
75
- async function saveLastChat(id) {
75
+ // ---------- session persistence ----------
76
+ // Сессии (чаты DeepSeek) храним в ~/.zames/.sessions, чтобы они переживали
77
+ // перезапуск процесса. При старте из той же рабочей директории последняя
78
+ // сессия восстанавливается автоматически (если не передан --new-chat).
79
+ // Раньше здесь был один файл last-chat.json, который терялся при смене
80
+ // проекта и не давал списка сессий для восстановления.
81
+ function saveLastChat(id, workdir = '', title = '') {
76
82
  if (!id) return
77
- try {
78
- await fs.mkdir(ZAMES_HOME, { recursive: true })
79
- await fs.writeFile(
80
- LAST_CHAT_FILE,
81
- JSON.stringify({ id, updatedAt: new Date().toISOString() }, null, 2),
82
- 'utf-8',
83
- )
84
- } catch (e) {
85
- if (debug) console.error('last-chat: не удалось сохранить:', e.message)
86
- }
83
+ saveSession({ id, workdir, title })
87
84
  }
88
85
 
89
- async function loadLastChat() {
90
- try {
91
- const raw = await fs.readFile(LAST_CHAT_FILE, 'utf-8')
92
- const data = JSON.parse(raw)
93
- return data && typeof data.id === 'string' ? data.id : null
94
- } catch {
95
- return null
96
- }
86
+ function loadLastChat(workdir = '') {
87
+ const s = loadLastSession(workdir)
88
+ return s ? s.id : null
97
89
  }
98
90
 
99
91
 
@@ -190,8 +182,8 @@ ${theme.bold('Опции CLI:')}
190
182
  --dir <path> рабочая директория агента
191
183
  --task <text> задача одной строкой
192
184
  --chat <id> продолжить существующий чат по id
193
- --resume-last вернуться в последний сохранённый чат
194
- --new-chat начать новый чат (по умолчанию и так новый)
185
+ --resume-last вернуться в последний сохранённый чат (по умолчанию)
186
+ --new-chat начать новый чат, не восстанавливать сессию
195
187
  --resend-prompt дослать system-prompt в существующий чат
196
188
  --max-iter <n> лимит итераций (по умолчанию ${config.maxIterations})
197
189
  --headless браузер без UI
@@ -207,6 +199,8 @@ ${theme.bold('Пока агент работает:')}
207
199
 
208
200
  ${theme.bold('Обычные команды:')}
209
201
  /new, /clear новый чат (сброс контекста)
202
+ /sessions список сохранённых сессий (~/.zames/.sessions)
203
+ /resume-id <id> восстановить сессию по полному id
210
204
  /chats список последних чатов DeepSeek
211
205
  /resume <n> открыть чат №n из /chats
212
206
  /chat показать текущий chat id
@@ -235,6 +229,7 @@ ${theme.bold('Самообзор (отладка агента):')}
235
229
  ${theme.bold('Файлы:')}
236
230
  Логи: ${config.transcript.dir}
237
231
  Undo: ~/.zames/undo
232
+ Сессии: ${sessionsDir()}
238
233
  Профиль: ~/.zames/profile
239
234
  Снапшоты: ~/.zames/snapshots
240
235
  Временные: <проект>/tmp (в .gitignore, чистится при запуске)
@@ -771,13 +766,15 @@ async function main() {
771
766
  let freshChat = true
772
767
  let sendSystemPrompt = true
773
768
 
774
- // По умолчанию — новый чат. Продолжить прошлый можно явно:
775
- // --chat <id> открыть конкретный чат
776
- // --resume-last вернуться в последний сохранённый чат
769
+ // По умолчанию восстанавливаем последнюю сессию для этой директории.
770
+ // Начать с чистого листа — --new-chat.
777
771
  let resumeId = chatIdArg
778
- if (!resumeId && resumeLastFlag) {
779
- resumeId = await loadLastChat()
780
- if (resumeId) console.log(theme.system(`Восстанавливаю чат ${resumeId}...`))
772
+ if (!resumeId && !newChatFlag) {
773
+ const last = loadLastSession(currentWorkdir)
774
+ if (last && last.id) {
775
+ resumeId = last.id
776
+ console.log(theme.system(`Восстанавливаю сессию ${resumeId}...`))
777
+ }
781
778
  }
782
779
 
783
780
  if (resumeId) {
@@ -786,6 +783,7 @@ async function main() {
786
783
  await browser.openChat(resumeId)
787
784
  freshChat = false
788
785
  sendSystemPrompt = resendPrompt
786
+ saveLastChat(resumeId, currentWorkdir)
789
787
  } catch (e) {
790
788
  console.error(theme.error(`Не удалось открыть чат: ${e.message}`))
791
789
  }
@@ -842,13 +840,23 @@ async function main() {
842
840
  process.exit(0)
843
841
  })
844
842
 
845
- // По умолчанию — новый чат. Продолжить прошлый можно явно:
843
+ // Восстановление сессии при старте. По умолчанию возвращаемся в последний
844
+ // чат для этой рабочей директории (сессии лежат в ~/.zames/.sessions),
845
+ // чтобы контекст не терялся после перезапуска. Явно начать с чистого листа
846
+ // можно флагом --new-chat.
846
847
  // --chat <id> открыть конкретный чат
847
- // --resume-last вернуться в последний сохранённый чат
848
+ // --new-chat не восстанавливать, начать новый чат
848
849
  let resumeId = chatIdArg
849
- if (!resumeId && resumeLastFlag) {
850
- resumeId = await loadLastChat()
851
- if (resumeId) console.log(theme.system(`Восстанавливаю чат ${resumeId}...`))
850
+ if (!resumeId && !newChatFlag) {
851
+ const last = loadLastSession(currentWorkdir)
852
+ if (last && last.id) {
853
+ resumeId = last.id
854
+ console.log(
855
+ theme.system(
856
+ `Восстанавливаю сессию ${last.id}${last.title ? ` (${last.title})` : ''}...`,
857
+ ),
858
+ )
859
+ }
852
860
  }
853
861
 
854
862
  if (resumeId) {
@@ -858,7 +866,7 @@ async function main() {
858
866
  currentChatId = resumeId
859
867
  freshChatNext = false
860
868
  sendSystemPromptNext = resendPrompt
861
- await saveLastChat(resumeId)
869
+ saveLastChat(resumeId, currentWorkdir)
862
870
  console.log(theme.system(`Чат открыт: ${resumeId}\n`))
863
871
  } catch (e) {
864
872
  console.error(theme.error(`Не удалось открыть чат: ${e.message}`))
@@ -980,7 +988,7 @@ async function main() {
980
988
  freshChatNext = false
981
989
  sendSystemPromptNext = true
982
990
  currentChatId = await browser.getCurrentChatId()
983
- await saveLastChat(currentChatId)
991
+ saveLastChat(currentChatId, currentWorkdir)
984
992
  transcript.log('new_chat')
985
993
  console.log(theme.system('Новый чат.\n'))
986
994
  } catch (e) {
@@ -1020,7 +1028,7 @@ async function main() {
1020
1028
  freshChatNext = false
1021
1029
  sendSystemPromptNext = false
1022
1030
  currentChatId = await browser.getCurrentChatId()
1023
- await saveLastChat(currentChatId)
1031
+ saveLastChat(currentChatId, currentWorkdir)
1024
1032
 
1025
1033
  console.log(
1026
1034
  theme.user(
@@ -1105,7 +1113,7 @@ async function main() {
1105
1113
  freshChatNext = false
1106
1114
  sendSystemPromptNext = false
1107
1115
  currentChatId = await browser.getCurrentChatId()
1108
- await saveLastChat(currentChatId)
1116
+ saveLastChat(currentChatId, currentWorkdir)
1109
1117
 
1110
1118
  console.log(
1111
1119
  theme.user(
@@ -1232,7 +1240,7 @@ async function main() {
1232
1240
  currentChatId = pick.id
1233
1241
  freshChatNext = false
1234
1242
  sendSystemPromptNext = resendPrompt
1235
- await saveLastChat(pick.id)
1243
+ saveLastChat(pick.id, currentWorkdir, pick.title)
1236
1244
  transcript.log('resume_chat', { id: pick.id, title: pick.title })
1237
1245
  console.log(
1238
1246
  theme.assistant(`Чат открыт.`) +
@@ -1263,6 +1271,51 @@ async function main() {
1263
1271
  continue
1264
1272
  }
1265
1273
 
1274
+ if (lower === '/sessions' || lower === '/session') {
1275
+ const all = listSessions()
1276
+ console.log(theme.system(`Папка сессий: ${sessionsDir()}`))
1277
+ if (!all.length) {
1278
+ console.log(
1279
+ theme.system(
1280
+ 'Сохранённых сессий нет. Они появятся после первой задачи/чата.',
1281
+ ),
1282
+ )
1283
+ } else {
1284
+ all.forEach((s, i) => {
1285
+ const n = String(i + 1).padStart(2, ' ')
1286
+ const mark = s.id === currentChatId ? theme.user(' *') : ''
1287
+ const title = s.title ? ` ${s.title}` : ''
1288
+ const wd = s.workdir ? theme.dim(` [${dirLabel(s.workdir)}]`) : ''
1289
+ console.log(` ${theme.user(n)}. ${s.id.slice(0, 8)}…${title}${wd}${mark}`)
1290
+ })
1291
+ console.log(
1292
+ theme.system('Восстановить: /resume-id <id> (полный id) или /resume <n> после /chats.'),
1293
+ )
1294
+ }
1295
+ continue
1296
+ }
1297
+
1298
+ if (lower.startsWith('/resume-id ')) {
1299
+ const id = trimmed.slice('/resume-id'.length).trim()
1300
+ if (!id) {
1301
+ console.error(theme.error('Использование: /resume-id <chat id>'))
1302
+ continue
1303
+ }
1304
+ try {
1305
+ console.log(theme.system(`Открываю чат ${id}...`))
1306
+ await browser.openChat(id)
1307
+ currentChatId = id
1308
+ freshChatNext = false
1309
+ sendSystemPromptNext = resendPrompt
1310
+ saveLastChat(id, currentWorkdir)
1311
+ transcript.log('resume_chat', { id })
1312
+ console.log(theme.assistant('Чат открыт.') + theme.system(String.fromCharCode(10)))
1313
+ } catch (e) {
1314
+ console.error(theme.error('Не удалось открыть чат:'), e.message)
1315
+ }
1316
+ continue
1317
+ }
1318
+
1266
1319
  if (lower === '/pwd') {
1267
1320
  console.log(theme.system(currentWorkdir))
1268
1321
  continue
@@ -1313,8 +1366,9 @@ async function main() {
1313
1366
  theme.system(`Resend prompt (--resend-prompt): ${resendPrompt ? 'да' : 'нет'}`),
1314
1367
  )
1315
1368
  console.log(
1316
- theme.system(`Last chat: ${(await loadLastChat()) || 'нет'}`),
1369
+ theme.system(`Last chat: ${loadLastChat(currentWorkdir) || 'нет'}`),
1317
1370
  )
1371
+ console.log(theme.system(`Сессии: ${sessionsDir()}`))
1318
1372
  console.log(
1319
1373
  theme.system(`Dev mode (auto-reload): ${devMode ? 'вкл' : 'выкл'}`),
1320
1374
  )
@@ -1445,7 +1499,7 @@ async function main() {
1445
1499
  if (!currentChatId) {
1446
1500
  currentChatId = await browser.getCurrentChatId()
1447
1501
  }
1448
- await saveLastChat(currentChatId)
1502
+ saveLastChat(currentChatId, currentWorkdir)
1449
1503
  }
1450
1504
 
1451
1505
  if (editor) editor.dispose()
@@ -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
+ }