@adhdev/daemon-core 0.8.75 → 0.8.77

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.
@@ -13,9 +13,17 @@ import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as os from 'os';
15
15
  import { buildRuntimeSystemChatMessage } from '../providers/chat-message-normalization.js';
16
+ import { normalizeProviderSessionId } from '../providers/provider-session-id.js';
16
17
 
17
18
  const HISTORY_DIR = path.join(os.homedir(), '.adhdev', 'history');
18
19
  const RETAIN_DAYS = 30;
20
+ const SAVED_HISTORY_INDEX_VERSION = 1;
21
+ const SAVED_HISTORY_INDEX_FILE = '.saved-history-index.json';
22
+ const SAVED_HISTORY_INDEX_LOCK_SUFFIX = '.lock';
23
+ const SAVED_HISTORY_INDEX_LOCK_WAIT_MS = 1500;
24
+ const SAVED_HISTORY_INDEX_LOCK_STALE_MS = 15_000;
25
+ const SAVED_HISTORY_INDEX_LOCK_POLL_MS = 25;
26
+ export const SAVED_HISTORY_ROLLUP_THRESHOLD_BYTES = 16 * 1024 * 1024;
19
27
 
20
28
  interface SavedHistorySessionCacheEntry {
21
29
  signature: string;
@@ -24,6 +32,32 @@ interface SavedHistorySessionCacheEntry {
24
32
 
25
33
  const savedHistorySessionCache = new Map<string, SavedHistorySessionCacheEntry>();
26
34
 
35
+ interface SavedHistoryFileSummaryCacheEntry {
36
+ signature: string;
37
+ summary: SavedHistoryFileSummary | null;
38
+ }
39
+
40
+ interface SavedHistoryFileSummary {
41
+ file: string;
42
+ historySessionId: string;
43
+ messageCount: number;
44
+ firstMessageAt: number;
45
+ lastMessageAt: number;
46
+ sessionTitle?: string;
47
+ preview?: string;
48
+ workspace?: string;
49
+ }
50
+
51
+ interface PersistedSavedHistoryIndexFile {
52
+ version: number;
53
+ files: Record<string, SavedHistoryFileSummaryCacheEntry>;
54
+ sessions?: Record<string, SavedHistorySessionSummary>;
55
+ }
56
+
57
+ const savedHistoryFileSummaryCache = new Map<string, SavedHistoryFileSummaryCacheEntry>();
58
+ const savedHistoryBackgroundRefresh = new Set<string>();
59
+ const savedHistoryRollupInFlight = new Set<string>();
60
+
27
61
  interface HistoryMessage {
28
62
  ts: string; // ISO timestamp
29
63
  receivedAt: number; // epoch ms
@@ -135,6 +169,76 @@ export interface SavedHistorySessionSummary {
135
169
  workspace?: string;
136
170
  }
137
171
 
172
+ function sortSavedHistorySessionSummaries(summaries: SavedHistorySessionSummary[]): SavedHistorySessionSummary[] {
173
+ return summaries.slice().sort((a, b) => b.lastMessageAt - a.lastMessageAt);
174
+ }
175
+
176
+ function buildSavedHistorySessionSummaryMapFromEntries(entries: Map<string, SavedHistoryFileSummaryCacheEntry>): Record<string, SavedHistorySessionSummary> {
177
+ const summaries = new Map<string, SavedHistorySessionSummary>();
178
+
179
+ for (const entry of Array.from(entries.values())) {
180
+ const fileSummary = entry.summary;
181
+ if (!fileSummary || fileSummary.messageCount <= 0 || !fileSummary.lastMessageAt) continue;
182
+ const existing = summaries.get(fileSummary.historySessionId);
183
+ if (!existing) {
184
+ summaries.set(fileSummary.historySessionId, {
185
+ historySessionId: fileSummary.historySessionId,
186
+ sessionTitle: fileSummary.sessionTitle,
187
+ messageCount: fileSummary.messageCount,
188
+ firstMessageAt: fileSummary.firstMessageAt,
189
+ lastMessageAt: fileSummary.lastMessageAt,
190
+ preview: fileSummary.preview,
191
+ workspace: fileSummary.workspace,
192
+ });
193
+ continue;
194
+ }
195
+ existing.messageCount += fileSummary.messageCount;
196
+ if (!existing.firstMessageAt || fileSummary.firstMessageAt < existing.firstMessageAt) {
197
+ existing.firstMessageAt = fileSummary.firstMessageAt;
198
+ }
199
+ if (fileSummary.lastMessageAt >= existing.lastMessageAt) {
200
+ existing.lastMessageAt = fileSummary.lastMessageAt;
201
+ if (fileSummary.sessionTitle) existing.sessionTitle = fileSummary.sessionTitle;
202
+ if (fileSummary.preview) existing.preview = fileSummary.preview;
203
+ }
204
+ if (!existing.workspace && fileSummary.workspace) {
205
+ existing.workspace = fileSummary.workspace;
206
+ }
207
+ }
208
+
209
+ return Object.fromEntries(sortSavedHistorySessionSummaries(Array.from(summaries.values())).map((summary) => [summary.historySessionId, summary]));
210
+ }
211
+
212
+ function readPersistedSavedHistorySessionSummaries(dir: string): SavedHistorySessionSummary[] | null {
213
+ try {
214
+ const filePath = getSavedHistoryIndexFilePath(dir);
215
+ if (!fs.existsSync(filePath)) return null;
216
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as PersistedSavedHistoryIndexFile;
217
+ if (!raw || raw.version !== SAVED_HISTORY_INDEX_VERSION || !raw.sessions || typeof raw.sessions !== 'object') {
218
+ return null;
219
+ }
220
+ return sortSavedHistorySessionSummaries(
221
+ Object.values(raw.sessions)
222
+ .filter((summary) => !!summary && typeof summary.historySessionId === 'string' && summary.messageCount > 0 && summary.lastMessageAt > 0)
223
+ .map((summary) => ({
224
+ historySessionId: summary.historySessionId,
225
+ sessionTitle: summary.sessionTitle,
226
+ messageCount: summary.messageCount,
227
+ firstMessageAt: summary.firstMessageAt,
228
+ lastMessageAt: summary.lastMessageAt,
229
+ preview: summary.preview,
230
+ workspace: summary.workspace,
231
+ })),
232
+ );
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ export function shouldScheduleSavedHistoryRollup(totalBytes: number): boolean {
239
+ return Number.isFinite(totalBytes) && totalBytes >= SAVED_HISTORY_ROLLUP_THRESHOLD_BYTES;
240
+ }
241
+
138
242
  function sanitizeHistoryFileSegment(value?: string): string {
139
243
  return String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_');
140
244
  }
@@ -153,76 +257,459 @@ function listHistoryFiles(dir: string, historySessionId?: string): string[] {
153
257
  .reverse();
154
258
  }
155
259
 
156
- function buildSavedHistoryCacheSignature(dir: string, files: string[]): string {
157
- return files.map((file) => {
260
+ function normalizeSavedHistorySessionId(agentType: string, historySessionId: string): string {
261
+ const normalizedId = String(historySessionId || '').trim();
262
+ if (!normalizedId) return '';
263
+ const strictProviderId = normalizeProviderSessionId(agentType, normalizedId);
264
+ if (strictProviderId) return strictProviderId;
265
+ return agentType === 'hermes-cli' ? '' : normalizedId;
266
+ }
267
+
268
+ function extractSavedHistorySessionIdFromFile(agentType: string, file: string): string {
269
+ const match = file.match(/^([A-Za-z0-9_-]+)_\d{4}-\d{2}-\d{2}\.jsonl$/);
270
+ return normalizeSavedHistorySessionId(agentType, match?.[1] || '');
271
+ }
272
+
273
+ function buildSavedHistoryFileSignatureMap(dir: string, files: string[]): Map<string, string> {
274
+ return new Map(files.map((file) => {
158
275
  try {
159
276
  const stat = fs.statSync(path.join(dir, file));
160
- return `${file}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
277
+ return [file, `${file}:${stat.size}:${Math.trunc(stat.mtimeMs)}`] as const;
161
278
  } catch {
162
- return `${file}:missing`;
279
+ return [file, `${file}:missing`] as const;
163
280
  }
164
- }).join('|');
281
+ }));
282
+ }
283
+
284
+ function buildSavedHistoryCacheSignature(files: string[], fileSignatures: Map<string, string>): string {
285
+ return files.map((file) => fileSignatures.get(file) || `${file}:missing`).join('|');
286
+ }
287
+
288
+ function getSavedHistoryIndexFilePath(dir: string): string {
289
+ return path.join(dir, SAVED_HISTORY_INDEX_FILE);
165
290
  }
166
291
 
167
- function computeSavedHistorySessionSummaries(agentType: string, dir: string, files: string[]): SavedHistorySessionSummary[] {
168
- const groupedFiles = new Map<string, string[]>();
169
- const filePattern = /^([A-Za-z0-9_-]+)_\d{4}-\d{2}-\d{2}\.jsonl$/;
170
- for (const file of files) {
171
- const match = file.match(filePattern);
172
- if (!match?.[1]) continue;
173
- const historySessionId = match[1];
174
- const grouped = groupedFiles.get(historySessionId) || [];
175
- grouped.push(file);
176
- groupedFiles.set(historySessionId, grouped);
292
+ function getSavedHistoryIndexLockPath(dir: string): string {
293
+ return `${getSavedHistoryIndexFilePath(dir)}${SAVED_HISTORY_INDEX_LOCK_SUFFIX}`;
294
+ }
295
+
296
+ function sleepBlocking(ms: number): void {
297
+ if (ms <= 0) return;
298
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
299
+ }
300
+
301
+ function loadPersistedSavedHistoryIndexFromFile(dir: string): Map<string, SavedHistoryFileSummaryCacheEntry> {
302
+ try {
303
+ const filePath = getSavedHistoryIndexFilePath(dir);
304
+ if (!fs.existsSync(filePath)) return new Map();
305
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as PersistedSavedHistoryIndexFile;
306
+ if (!raw || raw.version !== SAVED_HISTORY_INDEX_VERSION || !raw.files || typeof raw.files !== 'object') {
307
+ return new Map();
308
+ }
309
+ return new Map(
310
+ Object.entries(raw.files)
311
+ .filter(([file, entry]) => !!file && !!entry && typeof entry.signature === 'string')
312
+ .map(([file, entry]) => [file, {
313
+ signature: entry.signature,
314
+ summary: entry.summary || null,
315
+ }]),
316
+ );
317
+ } catch {
318
+ return new Map();
177
319
  }
320
+ }
178
321
 
179
- const summaries: SavedHistorySessionSummary[] = [];
180
- for (const [historySessionId, grouped] of groupedFiles.entries()) {
181
- let messageCount = 0;
182
- let firstMessageAt = 0;
183
- let lastMessageAt = 0;
184
- let sessionTitle = '';
185
- let preview = '';
186
- let workspace = '';
322
+ function writePersistedSavedHistoryIndexFile(dir: string, entries: Map<string, SavedHistoryFileSummaryCacheEntry>): void {
323
+ const filePath = getSavedHistoryIndexFilePath(dir);
324
+ const tempPath = `${filePath}.tmp`;
325
+ const payload: PersistedSavedHistoryIndexFile = {
326
+ version: SAVED_HISTORY_INDEX_VERSION,
327
+ files: Object.fromEntries(entries.entries()),
328
+ sessions: buildSavedHistorySessionSummaryMapFromEntries(entries),
329
+ };
330
+ fs.writeFileSync(tempPath, JSON.stringify(payload), 'utf-8');
331
+ fs.renameSync(tempPath, filePath);
332
+ }
187
333
 
188
- for (const file of grouped.sort()) {
189
- const filePath = path.join(dir, file);
190
- const content = fs.readFileSync(filePath, 'utf-8');
191
- const lines = content.split('\n').filter(Boolean);
192
- for (const line of lines) {
193
- let parsed: HistoryMessage | null = null;
334
+ function acquireSavedHistoryIndexLock(dir: string): (() => void) | null {
335
+ const lockPath = getSavedHistoryIndexLockPath(dir);
336
+ const deadline = Date.now() + SAVED_HISTORY_INDEX_LOCK_WAIT_MS;
337
+
338
+ while (Date.now() <= deadline) {
339
+ try {
340
+ fs.mkdirSync(lockPath);
341
+ return () => {
194
342
  try {
195
- parsed = JSON.parse(line) as HistoryMessage;
343
+ fs.rmSync(lockPath, { recursive: true, force: true });
196
344
  } catch {
197
- parsed = null;
345
+ // Ignore lock cleanup failures.
198
346
  }
199
- if (!parsed || parsed.historySessionId !== historySessionId) continue;
200
- if (parsed.kind === 'session_start') {
201
- if (!workspace && parsed.workspace) workspace = parsed.workspace;
347
+ };
348
+ } catch (error: any) {
349
+ if (error?.code !== 'EEXIST') return null;
350
+ try {
351
+ const stat = fs.statSync(lockPath);
352
+ if (Date.now() - stat.mtimeMs > SAVED_HISTORY_INDEX_LOCK_STALE_MS) {
353
+ fs.rmSync(lockPath, { recursive: true, force: true });
202
354
  continue;
203
355
  }
204
- messageCount += 1;
205
- if (!firstMessageAt || parsed.receivedAt < firstMessageAt) firstMessageAt = parsed.receivedAt;
206
- if (!lastMessageAt || parsed.receivedAt > lastMessageAt) lastMessageAt = parsed.receivedAt;
207
- if (parsed.sessionTitle) sessionTitle = parsed.sessionTitle;
208
- if (parsed.role !== 'system' && parsed.content.trim()) preview = parsed.content.trim();
356
+ } catch {
357
+ // Lock disappeared between stat attempts; retry immediately.
358
+ continue;
209
359
  }
360
+ sleepBlocking(SAVED_HISTORY_INDEX_LOCK_POLL_MS);
210
361
  }
362
+ }
211
363
 
212
- if (messageCount === 0 || !lastMessageAt) continue;
213
- summaries.push({
214
- historySessionId,
215
- sessionTitle: sessionTitle || undefined,
216
- messageCount,
217
- firstMessageAt,
218
- lastMessageAt,
219
- preview: preview || undefined,
220
- workspace: workspace || undefined,
221
- });
364
+ return null;
365
+ }
366
+
367
+ function withLockedPersistedSavedHistoryIndex<T>(
368
+ dir: string,
369
+ callback: (entries: Map<string, SavedHistoryFileSummaryCacheEntry>) => T,
370
+ ): T | null {
371
+ const release = acquireSavedHistoryIndexLock(dir);
372
+ if (!release) return null;
373
+ try {
374
+ const entries = loadPersistedSavedHistoryIndexFromFile(dir);
375
+ const result = callback(entries);
376
+ writePersistedSavedHistoryIndexFile(dir, entries);
377
+ return result;
378
+ } catch {
379
+ return null;
380
+ } finally {
381
+ release();
222
382
  }
383
+ }
384
+
385
+ function loadPersistedSavedHistoryIndex(dir: string): Map<string, SavedHistoryFileSummaryCacheEntry> {
386
+ return loadPersistedSavedHistoryIndexFromFile(dir);
387
+ }
388
+
389
+ function savePersistedSavedHistoryIndex(dir: string, entries: Map<string, SavedHistoryFileSummaryCacheEntry>): void {
390
+ withLockedPersistedSavedHistoryIndex(dir, (currentEntries) => {
391
+ const incomingFiles = new Set(Array.from(entries.keys()));
392
+ for (const [file, entry] of Array.from(entries.entries())) {
393
+ const liveSignature = buildSavedHistoryFileSignature(dir, file);
394
+ const existingEntry = currentEntries.get(file);
395
+ if (existingEntry && existingEntry.signature !== liveSignature && entry.signature !== liveSignature) {
396
+ continue;
397
+ }
398
+ if (entry.signature !== liveSignature && (!existingEntry || existingEntry.signature !== liveSignature)) {
399
+ continue;
400
+ }
401
+ currentEntries.set(file, entry.signature === liveSignature ? entry : {
402
+ signature: liveSignature,
403
+ summary: existingEntry?.summary || entry.summary,
404
+ });
405
+ }
406
+ for (const file of Array.from(currentEntries.keys())) {
407
+ if (incomingFiles.has(file)) continue;
408
+ if (!fs.existsSync(path.join(dir, file))) {
409
+ currentEntries.delete(file);
410
+ }
411
+ }
412
+ });
413
+ }
223
414
 
224
- summaries.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
225
- return summaries;
415
+ function invalidatePersistedSavedHistoryIndex(agentType: string, dir: string): void {
416
+ try {
417
+ fs.rmSync(getSavedHistoryIndexFilePath(dir), { force: true });
418
+ } catch {
419
+ // Ignore persisted index cleanup failures.
420
+ }
421
+ savedHistorySessionCache.delete(agentType.replace(/[^a-zA-Z0-9_-]/g, '_'));
422
+ }
423
+
424
+ function getSavedHistoryFileSummaryCacheEntry(dir: string, file: string): SavedHistoryFileSummaryCacheEntry | null {
425
+ const filePath = path.join(dir, file);
426
+ const cached = savedHistoryFileSummaryCache.get(filePath);
427
+ if (cached) return cached;
428
+ const persisted = loadPersistedSavedHistoryIndex(dir).get(file) || null;
429
+ if (persisted) {
430
+ savedHistoryFileSummaryCache.set(filePath, persisted);
431
+ }
432
+ return persisted;
433
+ }
434
+
435
+ function buildSavedHistoryIndexFileSignature(dir: string): string {
436
+ try {
437
+ const stat = fs.statSync(getSavedHistoryIndexFilePath(dir));
438
+ return `index:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
439
+ } catch {
440
+ return 'index:missing';
441
+ }
442
+ }
443
+
444
+ function historyDirectoryHasFilesNewerThanIndex(dir: string): boolean {
445
+ try {
446
+ const indexStat = fs.statSync(getSavedHistoryIndexFilePath(dir));
447
+ const files = listHistoryFiles(dir);
448
+ for (const file of files) {
449
+ const stat = fs.statSync(path.join(dir, file));
450
+ if (stat.mtimeMs > indexStat.mtimeMs) return true;
451
+ }
452
+ return false;
453
+ } catch {
454
+ return true;
455
+ }
456
+ }
457
+
458
+ function buildSavedHistoryFileSignature(dir: string, file: string): string {
459
+ try {
460
+ const stat = fs.statSync(path.join(dir, file));
461
+ return `${file}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
462
+ } catch {
463
+ return `${file}:missing`;
464
+ }
465
+ }
466
+
467
+ function persistSavedHistoryFileSummaryEntry(agentType: string, dir: string, file: string, updater: (currentSummary: SavedHistoryFileSummary | null) => SavedHistoryFileSummary | null): void {
468
+ const filePath = path.join(dir, file);
469
+ const result = withLockedPersistedSavedHistoryIndex(dir, (entries) => {
470
+ const currentEntry = entries.get(file) || null;
471
+ const nextSummary = updater(currentEntry?.summary || null);
472
+ const nextEntry: SavedHistoryFileSummaryCacheEntry = {
473
+ signature: buildSavedHistoryFileSignature(dir, file),
474
+ summary: nextSummary,
475
+ };
476
+ entries.set(file, nextEntry);
477
+ savedHistoryFileSummaryCache.set(filePath, nextEntry);
478
+ return nextEntry;
479
+ });
480
+ if (!result) return;
481
+ if (result.summary?.historySessionId && shouldScheduleSavedHistoryRollupForSignature(result.signature)) {
482
+ scheduleSavedHistoryRollup(agentType, result.summary.historySessionId);
483
+ }
484
+ }
485
+
486
+ function updateSavedHistoryIndexForSessionStart(agentType: string, dir: string, file: string, historySessionId: string, workspace: string): void {
487
+ const normalizedSessionId = normalizeSavedHistorySessionId(agentType, historySessionId);
488
+ const normalizedWorkspace = String(workspace || '').trim();
489
+ if (!normalizedSessionId || !normalizedWorkspace) return;
490
+ persistSavedHistoryFileSummaryEntry(agentType, dir, file, (currentSummary) => ({
491
+ file,
492
+ historySessionId: normalizedSessionId,
493
+ messageCount: currentSummary?.messageCount || 0,
494
+ firstMessageAt: currentSummary?.firstMessageAt || 0,
495
+ lastMessageAt: currentSummary?.lastMessageAt || 0,
496
+ sessionTitle: currentSummary?.sessionTitle,
497
+ preview: currentSummary?.preview,
498
+ workspace: normalizedWorkspace,
499
+ }));
500
+ }
501
+
502
+ function updateSavedHistoryIndexForAppendedMessages(
503
+ agentType: string,
504
+ dir: string,
505
+ file: string,
506
+ historySessionId: string | undefined,
507
+ messages: HistoryMessage[],
508
+ ): void {
509
+ const normalizedSessionId = normalizeSavedHistorySessionId(agentType, historySessionId || '');
510
+ if (!normalizedSessionId || messages.length === 0) return;
511
+ persistSavedHistoryFileSummaryEntry(agentType, dir, file, (currentSummary) => {
512
+ const nextSummary: SavedHistoryFileSummary = {
513
+ file,
514
+ historySessionId: normalizedSessionId,
515
+ messageCount: currentSummary?.messageCount || 0,
516
+ firstMessageAt: currentSummary?.firstMessageAt || 0,
517
+ lastMessageAt: currentSummary?.lastMessageAt || 0,
518
+ sessionTitle: currentSummary?.sessionTitle,
519
+ preview: currentSummary?.preview,
520
+ workspace: currentSummary?.workspace,
521
+ };
522
+
523
+ for (const message of messages) {
524
+ if (!message || message.historySessionId !== historySessionId) continue;
525
+ if (message.kind === 'session_start') {
526
+ if (message.workspace) nextSummary.workspace = message.workspace;
527
+ continue;
528
+ }
529
+ nextSummary.messageCount += 1;
530
+ if (!nextSummary.firstMessageAt || message.receivedAt < nextSummary.firstMessageAt) {
531
+ nextSummary.firstMessageAt = message.receivedAt;
532
+ }
533
+ if (!nextSummary.lastMessageAt || message.receivedAt >= nextSummary.lastMessageAt) {
534
+ nextSummary.lastMessageAt = message.receivedAt;
535
+ if (message.sessionTitle) nextSummary.sessionTitle = message.sessionTitle;
536
+ if (message.role !== 'system' && message.content.trim()) nextSummary.preview = message.content.trim();
537
+ } else if (message.sessionTitle) {
538
+ nextSummary.sessionTitle = message.sessionTitle;
539
+ }
540
+ if (!nextSummary.preview && message.role !== 'system' && message.content.trim()) {
541
+ nextSummary.preview = message.content.trim();
542
+ }
543
+ }
544
+
545
+ return nextSummary;
546
+ });
547
+ }
548
+
549
+ function computeSavedHistoryFileSummary(agentType: string, dir: string, file: string): SavedHistoryFileSummary | null {
550
+ const historySessionId = extractSavedHistorySessionIdFromFile(agentType, file);
551
+ if (!historySessionId) return null;
552
+
553
+ const filePath = path.join(dir, file);
554
+ const content = fs.readFileSync(filePath, 'utf-8');
555
+ const lines = content.split('\n').filter(Boolean);
556
+ let messageCount = 0;
557
+ let firstMessageAt = 0;
558
+ let lastMessageAt = 0;
559
+ let sessionTitle = '';
560
+ let preview = '';
561
+ let workspace = '';
562
+
563
+ for (const line of lines) {
564
+ let parsed: HistoryMessage | null = null;
565
+ try {
566
+ parsed = JSON.parse(line) as HistoryMessage;
567
+ } catch {
568
+ parsed = null;
569
+ }
570
+ if (!parsed || parsed.historySessionId !== historySessionId) continue;
571
+ if (parsed.kind === 'session_start') {
572
+ if (!workspace && parsed.workspace) workspace = parsed.workspace;
573
+ continue;
574
+ }
575
+ messageCount += 1;
576
+ if (!firstMessageAt || parsed.receivedAt < firstMessageAt) firstMessageAt = parsed.receivedAt;
577
+ if (!lastMessageAt || parsed.receivedAt > lastMessageAt) lastMessageAt = parsed.receivedAt;
578
+ if (parsed.sessionTitle) sessionTitle = parsed.sessionTitle;
579
+ if (parsed.role !== 'system' && parsed.content.trim()) preview = parsed.content.trim();
580
+ }
581
+
582
+ if (messageCount === 0 || !lastMessageAt) return null;
583
+ return {
584
+ file,
585
+ historySessionId,
586
+ messageCount,
587
+ firstMessageAt,
588
+ lastMessageAt,
589
+ sessionTitle: sessionTitle || undefined,
590
+ preview: preview || undefined,
591
+ workspace: workspace || undefined,
592
+ };
593
+ }
594
+
595
+ function shouldScheduleSavedHistoryRollupForSignature(signature: string): boolean {
596
+ const parts = String(signature || '').split(':');
597
+ const size = Number(parts[1] || 0);
598
+ return shouldScheduleSavedHistoryRollup(size);
599
+ }
600
+
601
+ function scheduleSavedHistoryRollup(agentType: string, historySessionId: string): void {
602
+ const key = `${agentType}:${historySessionId}`;
603
+ if (!historySessionId || savedHistoryRollupInFlight.has(key)) return;
604
+ savedHistoryRollupInFlight.add(key);
605
+ setTimeout(() => {
606
+ try {
607
+ new ChatHistoryWriter().compactHistorySession(agentType, historySessionId);
608
+ } finally {
609
+ savedHistoryRollupInFlight.delete(key);
610
+ }
611
+ }, 0);
612
+ }
613
+
614
+ function scheduleSavedHistoryBackgroundRefresh(agentType: string, dir: string): void {
615
+ const key = `${agentType}:${dir}`;
616
+ if (savedHistoryBackgroundRefresh.has(key)) return;
617
+ savedHistoryBackgroundRefresh.add(key);
618
+ setTimeout(() => {
619
+ try {
620
+ if (!fs.existsSync(dir)) return;
621
+ const files = listHistoryFiles(dir);
622
+ const fileSignatures = buildSavedHistoryFileSignatureMap(dir, files);
623
+ const persistedEntries = loadPersistedSavedHistoryIndex(dir);
624
+ const computed = computeSavedHistorySessionSummaries(agentType, dir, files, fileSignatures, persistedEntries);
625
+ savePersistedSavedHistoryIndex(dir, computed.persistedEntries || new Map());
626
+ const refreshedIndexSignature = buildSavedHistoryIndexFileSignature(dir);
627
+ savedHistorySessionCache.set(agentType.replace(/[^a-zA-Z0-9_-]/g, '_'), {
628
+ signature: refreshedIndexSignature,
629
+ summaries: computed.summaries || [],
630
+ });
631
+ for (const [file, entry] of Array.from(computed.persistedEntries.entries())) {
632
+ if (!entry?.summary || !shouldScheduleSavedHistoryRollupForSignature(entry.signature)) continue;
633
+ scheduleSavedHistoryRollup(agentType, entry.summary.historySessionId);
634
+ }
635
+ } catch {
636
+ // Ignore background refresh failures.
637
+ } finally {
638
+ savedHistoryBackgroundRefresh.delete(key);
639
+ }
640
+ }, 0);
641
+ }
642
+
643
+ function computeSavedHistorySessionSummaries(
644
+ agentType: string,
645
+ dir: string,
646
+ files: string[],
647
+ fileSignatures: Map<string, string>,
648
+ persistedEntries: Map<string, SavedHistoryFileSummaryCacheEntry>,
649
+ ): { summaries: SavedHistorySessionSummary[]; persistedEntries: Map<string, SavedHistoryFileSummaryCacheEntry> } {
650
+ const summaryBySessionId = new Map<string, SavedHistorySessionSummary>();
651
+ const nextPersistedEntries = new Map<string, SavedHistoryFileSummaryCacheEntry>();
652
+
653
+ for (const file of files.slice().sort()) {
654
+ const filePath = path.join(dir, file);
655
+ const signature = fileSignatures.get(file) || `${file}:missing`;
656
+ const cached = savedHistoryFileSummaryCache.get(filePath);
657
+ const persisted = persistedEntries.get(file);
658
+ const reusableEntry = cached?.signature === signature
659
+ ? cached
660
+ : persisted?.signature === signature
661
+ ? persisted
662
+ : null;
663
+ const fileSummary = reusableEntry?.summary || computeSavedHistoryFileSummary(agentType, dir, file);
664
+ const nextEntry: SavedHistoryFileSummaryCacheEntry = reusableEntry || {
665
+ signature,
666
+ summary: fileSummary,
667
+ };
668
+
669
+ if (!reusableEntry) {
670
+ nextEntry.signature = signature;
671
+ nextEntry.summary = fileSummary;
672
+ }
673
+ savedHistoryFileSummaryCache.set(filePath, nextEntry);
674
+ nextPersistedEntries.set(file, nextEntry);
675
+
676
+ if (!fileSummary) continue;
677
+ const existing = summaryBySessionId.get(fileSummary.historySessionId);
678
+ if (fileSummary.messageCount <= 0 || !fileSummary.lastMessageAt) {
679
+ continue;
680
+ }
681
+ if (!existing) {
682
+ summaryBySessionId.set(fileSummary.historySessionId, {
683
+ historySessionId: fileSummary.historySessionId,
684
+ sessionTitle: fileSummary.sessionTitle,
685
+ messageCount: fileSummary.messageCount,
686
+ firstMessageAt: fileSummary.firstMessageAt,
687
+ lastMessageAt: fileSummary.lastMessageAt,
688
+ preview: fileSummary.preview,
689
+ workspace: fileSummary.workspace,
690
+ });
691
+ continue;
692
+ }
693
+
694
+ existing.messageCount += fileSummary.messageCount;
695
+ if (!existing.firstMessageAt || fileSummary.firstMessageAt < existing.firstMessageAt) {
696
+ existing.firstMessageAt = fileSummary.firstMessageAt;
697
+ }
698
+ if (fileSummary.lastMessageAt >= existing.lastMessageAt) {
699
+ existing.lastMessageAt = fileSummary.lastMessageAt;
700
+ if (fileSummary.sessionTitle) existing.sessionTitle = fileSummary.sessionTitle;
701
+ if (fileSummary.preview) existing.preview = fileSummary.preview;
702
+ }
703
+ if (!existing.workspace && fileSummary.workspace) {
704
+ existing.workspace = fileSummary.workspace;
705
+ }
706
+ }
707
+
708
+ return {
709
+ summaries: Array.from(summaryBySessionId.values())
710
+ .sort((a, b) => b.lastMessageAt - a.lastMessageAt),
711
+ persistedEntries: nextPersistedEntries,
712
+ };
226
713
  }
227
714
 
228
715
  export class ChatHistoryWriter {
@@ -313,9 +800,11 @@ export class ChatHistoryWriter {
313
800
 
314
801
  const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
315
802
  const filePrefix = effectiveHistoryKey ? `${this.sanitize(effectiveHistoryKey)}_` : '';
316
- const filePath = path.join(dir, `${filePrefix}${date}.jsonl`);
803
+ const fileName = `${filePrefix}${date}.jsonl`;
804
+ const filePath = path.join(dir, fileName);
317
805
  const lines = newMessages.map(m => JSON.stringify(m)).join('\n') + '\n';
318
806
  fs.appendFileSync(filePath, lines, 'utf-8');
807
+ updateSavedHistoryIndexForAppendedMessages(agentType, dir, fileName, effectiveHistoryKey, newMessages);
319
808
 
320
809
  // Detect session switch — only for unstable runtime-only histories.
321
810
  // When we have a persistent history session key, replayed read_chat payloads
@@ -438,7 +927,8 @@ export class ChatHistoryWriter {
438
927
  const dir = path.join(HISTORY_DIR, this.sanitize(agentType));
439
928
  fs.mkdirSync(dir, { recursive: true });
440
929
  const date = new Date().toISOString().slice(0, 10);
441
- const filePath = path.join(dir, `${this.sanitize(id)}_${date}.jsonl`);
930
+ const fileName = `${this.sanitize(id)}_${date}.jsonl`;
931
+ const filePath = path.join(dir, fileName);
442
932
  const record: HistoryMessage = {
443
933
  ts: new Date().toISOString(),
444
934
  receivedAt: Date.now(),
@@ -451,6 +941,7 @@ export class ChatHistoryWriter {
451
941
  workspace: ws,
452
942
  };
453
943
  fs.appendFileSync(filePath, JSON.stringify(record) + '\n', 'utf-8');
944
+ updateSavedHistoryIndexForSessionStart(agentType, dir, fileName, id, ws);
454
945
  } catch {
455
946
  // Ignore — must not affect main functionality
456
947
  }
@@ -530,6 +1021,7 @@ export class ChatHistoryWriter {
530
1021
  }
531
1022
  fs.unlinkSync(sourcePath);
532
1023
  }
1024
+ invalidatePersistedSavedHistoryIndex(agentType, dir);
533
1025
  } catch {
534
1026
  // Ignore promotion failure; future messages will still write to the new session key.
535
1027
  }
@@ -587,6 +1079,7 @@ export class ChatHistoryWriter {
587
1079
  }
588
1080
  fs.writeFileSync(filePath, `${collapsed.map((entry) => JSON.stringify(entry)).join('\n')}\n`, 'utf-8');
589
1081
  }
1082
+ invalidatePersistedSavedHistoryIndex(agentType, dir);
590
1083
  } catch {
591
1084
  // Ignore compaction failure.
592
1085
  }
@@ -613,14 +1106,19 @@ export class ChatHistoryWriter {
613
1106
  const dirPath = path.join(HISTORY_DIR, dir.name);
614
1107
  const files = fs.readdirSync(dirPath)
615
1108
  .filter(f => f.endsWith('.jsonl') || f.endsWith('.terminal.log'));
1109
+ let removedAny = false;
616
1110
 
617
1111
  for (const file of files) {
618
1112
  const filePath = path.join(dirPath, file);
619
1113
  const stat = fs.statSync(filePath);
620
1114
  if (stat.mtimeMs < cutoff) {
621
1115
  fs.unlinkSync(filePath);
1116
+ removedAny = true;
622
1117
  }
623
1118
  }
1119
+ if (removedAny) {
1120
+ invalidatePersistedSavedHistoryIndex(dir.name, dirPath);
1121
+ }
624
1122
  }
625
1123
  } catch {
626
1124
  // Ignore rotate failure
@@ -717,21 +1215,56 @@ export function listSavedHistorySessions(
717
1215
  return { sessions: [], hasMore: false };
718
1216
  }
719
1217
 
720
- const files = listHistoryFiles(dir);
721
- const signature = buildSavedHistoryCacheSignature(dir, files);
722
1218
  const cached = savedHistorySessionCache.get(sanitized);
723
- const summaries = cached?.signature === signature
724
- ? cached.summaries
725
- : computeSavedHistorySessionSummaries(agentType, dir, files);
726
- if (!cached || cached.signature !== signature) {
1219
+ const offset = Math.max(0, options.offset || 0);
1220
+ const limit = Math.max(1, options.limit || 30);
1221
+ const indexSignature = buildSavedHistoryIndexFileSignature(dir);
1222
+ let cacheWasInvalidated = false;
1223
+ if (cached) {
1224
+ const cacheLooksPersisted = cached.signature.startsWith('index:');
1225
+ const cacheStillValid = cacheLooksPersisted
1226
+ ? cached.signature === indexSignature
1227
+ : (() => {
1228
+ const files = listHistoryFiles(dir);
1229
+ const fileSignatures = buildSavedHistoryFileSignatureMap(dir, files);
1230
+ return cached.signature === buildSavedHistoryCacheSignature(files, fileSignatures);
1231
+ })();
1232
+ if (cacheStillValid) {
1233
+ const sliced = cached.summaries.slice(offset, offset + limit);
1234
+ return {
1235
+ sessions: sliced,
1236
+ hasMore: cached.summaries.length > offset + limit,
1237
+ };
1238
+ }
1239
+ cacheWasInvalidated = true;
1240
+ }
1241
+
1242
+ const persistedSessions = readPersistedSavedHistorySessionSummaries(dir);
1243
+ if (!cacheWasInvalidated && persistedSessions?.length && !historyDirectoryHasFilesNewerThanIndex(dir)) {
727
1244
  savedHistorySessionCache.set(sanitized, {
728
- signature,
729
- summaries,
1245
+ signature: indexSignature,
1246
+ summaries: persistedSessions,
730
1247
  });
1248
+ scheduleSavedHistoryBackgroundRefresh(agentType, dir);
1249
+ const sliced = persistedSessions.slice(offset, offset + limit);
1250
+ return {
1251
+ sessions: sliced,
1252
+ hasMore: persistedSessions.length > offset + limit,
1253
+ };
731
1254
  }
732
1255
 
733
- const offset = Math.max(0, options.offset || 0);
734
- const limit = Math.max(1, options.limit || 30);
1256
+ const files = listHistoryFiles(dir);
1257
+ const fileSignatures = buildSavedHistoryFileSignatureMap(dir, files);
1258
+ const signature = buildSavedHistoryCacheSignature(files, fileSignatures);
1259
+ const persistedEntries = loadPersistedSavedHistoryIndex(dir);
1260
+ const computed = computeSavedHistorySessionSummaries(agentType, dir, files, fileSignatures, persistedEntries);
1261
+ const summaries = computed.summaries || [];
1262
+ savePersistedSavedHistoryIndex(dir, computed.persistedEntries || new Map());
1263
+ savedHistorySessionCache.set(sanitized, {
1264
+ signature,
1265
+ summaries,
1266
+ });
1267
+
735
1268
  const sliced = summaries.slice(offset, offset + limit);
736
1269
  return {
737
1270
  sessions: sliced,