@adhdev/daemon-core 0.8.27 → 0.8.29

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.
Files changed (43) hide show
  1. package/dist/agent-stream/manager.d.ts +1 -1
  2. package/dist/agent-stream/provider-adapter.d.ts +5 -0
  3. package/dist/commands/handler.d.ts +1 -0
  4. package/dist/commands/router.d.ts +5 -0
  5. package/dist/commands/stream-commands.d.ts +1 -1
  6. package/dist/config/chat-history.d.ts +12 -0
  7. package/dist/detection/cli-detector.d.ts +6 -2
  8. package/dist/detection/ide-detector.d.ts +2 -1
  9. package/dist/index.js +987 -377
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +985 -375
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/providers/acp-provider-instance.d.ts +1 -1
  14. package/dist/providers/cli-provider-instance.d.ts +1 -0
  15. package/dist/providers/provider-loader.d.ts +26 -0
  16. package/dist/shared-types.d.ts +2 -0
  17. package/dist/status/snapshot.d.ts +8 -0
  18. package/node_modules/@adhdev/session-host-core/dist/index.d.mts +24 -1
  19. package/node_modules/@adhdev/session-host-core/dist/index.d.ts +24 -1
  20. package/node_modules/@adhdev/session-host-core/dist/index.js +6 -1
  21. package/node_modules/@adhdev/session-host-core/dist/index.js.map +1 -1
  22. package/node_modules/@adhdev/session-host-core/dist/index.mjs +6 -1
  23. package/node_modules/@adhdev/session-host-core/dist/index.mjs.map +1 -1
  24. package/node_modules/@adhdev/session-host-core/package.json +1 -1
  25. package/package.json +1 -1
  26. package/src/agent-stream/manager.ts +2 -2
  27. package/src/agent-stream/provider-adapter.ts +111 -4
  28. package/src/boot/daemon-lifecycle.ts +28 -1
  29. package/src/cli-adapters/provider-cli-adapter.ts +17 -3
  30. package/src/commands/chat-commands.ts +89 -10
  31. package/src/commands/cli-manager.ts +16 -2
  32. package/src/commands/handler.ts +1 -0
  33. package/src/commands/router.ts +23 -1
  34. package/src/commands/stream-commands.ts +6 -3
  35. package/src/config/chat-history.ts +269 -18
  36. package/src/detection/cli-detector.ts +72 -29
  37. package/src/detection/ide-detector.ts +24 -8
  38. package/src/launch.ts +1 -1
  39. package/src/providers/acp-provider-instance.ts +19 -10
  40. package/src/providers/cli-provider-instance.ts +17 -2
  41. package/src/providers/provider-loader.ts +144 -11
  42. package/src/shared-types.ts +2 -0
  43. package/src/status/snapshot.ts +19 -1
@@ -522,10 +522,10 @@ export class DaemonCliManager {
522
522
  if (!cliInfo) {
523
523
  const installHint = provider?.install || '';
524
524
  const displayName = provider?.displayName || provider?.name || cliType;
525
- const spawnCmd = provider?.spawn?.command || cliType;
525
+ const spawnCmd = this.providerLoader.getSpawnCommand(normalizedType, provider?.spawn?.command || cliType);
526
526
  throw new Error(
527
527
  `${displayName} is not installed.\n` +
528
- `Command '${spawnCmd}' not found on PATH.\n` +
528
+ `Command '${spawnCmd}' is not available.\n` +
529
529
  (installHint ? `\n${installHint}\n` : '') +
530
530
  `\nRun 'adhdev doctor' for detailed diagnostics.`
531
531
  );
@@ -687,6 +687,7 @@ export class DaemonCliManager {
687
687
  if (!instanceManager) return 0;
688
688
  const sessions = records || await this.deps.listHostedCliRuntimes?.() || [];
689
689
  let restored = 0;
690
+ const restoredBindings = new Set<string>();
690
691
 
691
692
  for (const record of sessions) {
692
693
  if (!record?.runtimeId || !record?.cliType || !record?.workspace) continue;
@@ -702,6 +703,18 @@ export class DaemonCliManager {
702
703
  record.cliArgs,
703
704
  record.providerSessionId,
704
705
  );
706
+ const bindingKey = [
707
+ normalizedType,
708
+ record.workspace,
709
+ sessionBinding.providerSessionId || record.runtimeId,
710
+ ].join('::');
711
+ if (restoredBindings.has(bindingKey)) {
712
+ LOG.info(
713
+ 'CLI',
714
+ `↷ Skipping duplicate hosted runtime restore: ${record.runtimeKey || record.runtimeId} (${normalizedType} @ ${record.workspace}) binding=${sessionBinding.providerSessionId || 'runtime'}`
715
+ );
716
+ continue;
717
+ }
705
718
  try {
706
719
  await this.registerCliInstance(
707
720
  record.runtimeId,
@@ -717,6 +730,7 @@ export class DaemonCliManager {
717
730
  launchMode: 'manual',
718
731
  },
719
732
  );
733
+ restoredBindings.add(bindingKey);
720
734
  restored += 1;
721
735
  LOG.info('CLI', `♻ Restored hosted runtime: ${record.runtimeKey || record.runtimeId} (${record.displayName || record.workspace})`);
722
736
  } catch (error: any) {
@@ -44,6 +44,7 @@ export interface CommandContext {
44
44
  /** ProviderInstanceManager — for runtime settings propagation */
45
45
  instanceManager?: ProviderInstanceManager;
46
46
  sessionRegistry?: SessionRegistry;
47
+ onProviderSettingChanged?: (providerType: string, key: string, value: any) => Promise<void> | void;
47
48
  }
48
49
 
49
50
  /**
@@ -44,6 +44,7 @@ export interface SessionHostControlPlane {
44
44
  restartSession(sessionId: string): Promise<any>;
45
45
  sendSignal(sessionId: string, signal: string): Promise<any>;
46
46
  forceDetachClient(sessionId: string, clientId: string): Promise<any>;
47
+ pruneDuplicateSessions(payload?: { providerType?: string; workspace?: string; dryRun?: boolean }): Promise<any>;
47
48
  acquireWrite(payload: { sessionId: string; clientId: string; ownerType: 'agent' | 'user'; force?: boolean }): Promise<any>;
48
49
  releaseWrite(payload: { sessionId: string; clientId: string }): Promise<any>;
49
50
  }
@@ -265,6 +266,16 @@ export class DaemonCommandRouter {
265
266
  return { success: true, record };
266
267
  }
267
268
 
269
+ case 'session_host_prune_duplicate_sessions': {
270
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
271
+ const result = await this.deps.sessionHostControl.pruneDuplicateSessions({
272
+ providerType: typeof args?.providerType === 'string' ? args.providerType : undefined,
273
+ workspace: typeof args?.workspace === 'string' ? args.workspace : undefined,
274
+ dryRun: args?.dryRun === true,
275
+ });
276
+ return { success: true, result };
277
+ }
278
+
268
279
  case 'session_host_acquire_write': {
269
280
  if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
270
281
  const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
@@ -365,6 +376,11 @@ export class DaemonCommandRouter {
365
376
  if (!ideType) throw new Error('ideType required');
366
377
  const killProcess = args?.killProcess !== false; // default true
367
378
  await this.stopIde(ideType, killProcess);
379
+ try {
380
+ const results = await detectIDEs(this.deps.providerLoader);
381
+ this.deps.detectedIdes.value = results;
382
+ this.deps.providerLoader.setIdeDetectionResults(results, true);
383
+ } catch { /* ignore detection refresh errors */ }
368
384
  return { success: true, ideType, stopped: true, processKilled: killProcess };
369
385
  }
370
386
 
@@ -415,6 +431,11 @@ export class DaemonCommandRouter {
415
431
  }
416
432
  }
417
433
  this.deps.onIdeConnected?.();
434
+ try {
435
+ const results = await detectIDEs(this.deps.providerLoader);
436
+ this.deps.detectedIdes.value = results;
437
+ this.deps.providerLoader.setIdeDetectionResults(results, true);
438
+ } catch { /* ignore detection refresh errors */ }
418
439
  if (result.success && resolvedWorkspace) {
419
440
  try {
420
441
  const next = appendRecentActivity(loadState(), {
@@ -441,8 +462,9 @@ export class DaemonCommandRouter {
441
462
 
442
463
  // ─── Detect IDEs ───
443
464
  case 'detect_ides': {
444
- const results = await detectIDEs();
465
+ const results = await detectIDEs(this.deps.providerLoader);
445
466
  this.deps.detectedIdes.value = results;
467
+ this.deps.providerLoader.setIdeDetectionResults(results, true);
446
468
  return { success: true, detectedInfo: results };
447
469
  }
448
470
 
@@ -76,7 +76,7 @@ export function handleGetProviderSettings(h: CommandHelpers, args: any): Command
76
76
  return { success: true, settings: allSettings, values: allValues };
77
77
  }
78
78
 
79
- export function handleSetProviderSetting(h: CommandHelpers, args: any): CommandResult {
79
+ export async function handleSetProviderSetting(h: CommandHelpers, args: any): Promise<CommandResult> {
80
80
  const loader = h.ctx.providerLoader as ProviderLoader | undefined;
81
81
  const { providerType, key, value } = args || {};
82
82
  if (!providerType || !key || value === undefined) {
@@ -89,6 +89,7 @@ export function handleSetProviderSetting(h: CommandHelpers, args: any): CommandR
89
89
  const updated = h.ctx.instanceManager.updateInstanceSettings(providerType, allSettings);
90
90
  LOG.info('Command', `[set_provider_setting] ${providerType}.${key}=${JSON.stringify(value)} → ${updated} instance(s) updated`);
91
91
  }
92
+ await h.ctx.onProviderSettingChanged?.(providerType, key, value);
92
93
  return { success: true, providerType, key, value };
93
94
  }
94
95
  return { success: false, error: `Failed to set ${providerType}.${key} — invalid key, value, or not a public setting` };
@@ -133,7 +134,7 @@ function getCliScriptCommand(payload: any): { type: string; text?: string } | nu
133
134
 
134
135
  const command = payload.command;
135
136
  if (!command || typeof command !== 'object') return null;
136
- if (command.type !== 'send_message') return null;
137
+ if (command.type !== 'send_message' && command.type !== 'pty_write') return null;
137
138
 
138
139
  const text = typeof command.text === 'string'
139
140
  ? command.text.trim()
@@ -141,7 +142,7 @@ function getCliScriptCommand(payload: any): { type: string; text?: string } | nu
141
142
  ? command.message.trim()
142
143
  : '';
143
144
  if (!text) return null;
144
- return { type: 'send_message', text };
145
+ return { type: command.type, text };
145
146
  }
146
147
 
147
148
  function applyProviderPatch(h: CommandHelpers, args: any, payload: any): void {
@@ -191,6 +192,8 @@ async function executeProviderScript(h: CommandHelpers, args: any, scriptName: s
191
192
  const cliCommand = getCliScriptCommand(parsed.payload);
192
193
  if (cliCommand?.type === 'send_message' && cliCommand.text) {
193
194
  await adapter.sendMessage(cliCommand.text);
195
+ } else if (cliCommand?.type === 'pty_write' && cliCommand.text && adapter.writeRaw) {
196
+ adapter.writeRaw(cliCommand.text + '\r');
194
197
  }
195
198
  applyProviderPatch(h, args, parsed.payload);
196
199
  return { success: true, ...(parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : { result: parsed.payload }) };
@@ -29,6 +29,93 @@ interface HistoryMessage {
29
29
  sessionTitle?: string;
30
30
  }
31
31
 
32
+ const CODEX_STARTER_PROMPT_RE = /^(?:[›❯]\s*)?(?:Find and fix a bug in @filename|Improve documentation in @filename|Write tests for @filename|Explain this codebase|Summarize recent commits|Implement \{feature\}|Use \/skills(?: to list available skills)?|Run \/review on my current changes)$/i;
33
+
34
+ function normalizeHistoryComparable(text: string): string {
35
+ return String(text || '').replace(/\s+/g, ' ').trim();
36
+ }
37
+
38
+ function cleanupHistoryContent(agentType: string, role: HistoryMessage['role'], content: string): string {
39
+ let value = String(content || '').replace(/\r\n/g, '\n').trim();
40
+ if (!value) return '';
41
+
42
+ if (agentType === 'codex-cli' && role === 'assistant') {
43
+ const filtered = value
44
+ .split('\n')
45
+ .filter((line) => !CODEX_STARTER_PROMPT_RE.test(line.trim()))
46
+ .join('\n')
47
+ .replace(/\n{3,}/g, '\n\n')
48
+ .trim();
49
+ value = filtered;
50
+ }
51
+
52
+ return value;
53
+ }
54
+
55
+ function buildHistoryMessageHash(
56
+ agentType: string,
57
+ message: Pick<HistoryMessage, 'role' | 'content' | 'receivedAt' | 'kind'> & { historyDedupKey?: string },
58
+ ): string {
59
+ if (message.historyDedupKey) return message.historyDedupKey;
60
+ const cleaned = cleanupHistoryContent(agentType, message.role, message.content);
61
+ return `${message.kind || 'standard'}:${message.role}:${message.receivedAt || 0}:${normalizeHistoryComparable(cleaned)}`;
62
+ }
63
+
64
+ function buildHistoryMessageSignature(
65
+ agentType: string,
66
+ message: Pick<HistoryMessage, 'role' | 'content' | 'kind'>,
67
+ ): string {
68
+ const cleaned = cleanupHistoryContent(agentType, message.role, message.content);
69
+ return `${message.kind || 'standard'}:${message.role}:${normalizeHistoryComparable(cleaned)}`;
70
+ }
71
+
72
+ function isAdjacentHistoryDuplicate(
73
+ agentType: string,
74
+ previous: Pick<HistoryMessage, 'role' | 'content' | 'kind'> | null | undefined,
75
+ next: Pick<HistoryMessage, 'role' | 'content' | 'kind'> | null | undefined,
76
+ ): boolean {
77
+ if (!previous || !next) return false;
78
+ return buildHistoryMessageSignature(agentType, previous) === buildHistoryMessageSignature(agentType, next);
79
+ }
80
+
81
+ function collapseReplayAssistantTurns(agentType: string, messages: HistoryMessage[]): HistoryMessage[] {
82
+ if (agentType !== 'codex-cli') return messages;
83
+
84
+ const collapsed: HistoryMessage[] = [];
85
+ let sawAssistantSinceLastUser = false;
86
+
87
+ for (const message of messages) {
88
+ if (message.role === 'user') {
89
+ sawAssistantSinceLastUser = false;
90
+ collapsed.push(message);
91
+ continue;
92
+ }
93
+
94
+ if (message.role === 'assistant') {
95
+ if (sawAssistantSinceLastUser) continue;
96
+ sawAssistantSinceLastUser = true;
97
+ collapsed.push(message);
98
+ continue;
99
+ }
100
+
101
+ collapsed.push(message);
102
+ }
103
+
104
+ return collapsed;
105
+ }
106
+
107
+ function sanitizeHistoryMessage(agentType: string, message: HistoryMessage): HistoryMessage | null {
108
+ if (!message || (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system')) {
109
+ return null;
110
+ }
111
+ const content = cleanupHistoryContent(agentType, message.role, message.content);
112
+ if (!content) return null;
113
+ return {
114
+ ...message,
115
+ content,
116
+ };
117
+ }
118
+
32
119
  export interface SavedHistorySessionSummary {
33
120
  historySessionId: string;
34
121
  sessionTitle?: string;
@@ -43,6 +130,10 @@ export class ChatHistoryWriter {
43
130
  private lastSeenCounts = new Map<string, number>();
44
131
  /** Last seen message hash per agent (deduplication) */
45
132
  private lastSeenHashes = new Map<string, Set<string>>();
133
+ /** Last appended normalized message signature per agent/session */
134
+ private lastSeenSignatures = new Map<string, string>();
135
+ /** Last appended normalized non-system turn signature per agent/session */
136
+ private lastSeenTurnSignatures = new Map<string, string>();
46
137
  private rotated = false;
47
138
 
48
139
  /**
@@ -75,14 +166,36 @@ export class ChatHistoryWriter {
75
166
  // Filter new messages
76
167
  const newMessages: HistoryMessage[] = [];
77
168
  for (const msg of messages) {
78
- const hash = msg.historyDedupKey || `${msg.kind || 'standard'}:${msg.role}:${(msg.content || '').slice(0, 50)}`;
169
+ const role = msg.role as 'user' | 'assistant' | 'system';
170
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
171
+ const content = cleanupHistoryContent(agentType, role, msg.content || '');
172
+ if (!content) continue;
173
+ const receivedAt = msg.receivedAt || Date.now();
174
+ const hash = buildHistoryMessageHash(agentType, {
175
+ role,
176
+ content,
177
+ receivedAt,
178
+ kind: typeof msg.kind === 'string' ? msg.kind : undefined,
179
+ historyDedupKey: msg.historyDedupKey,
180
+ });
181
+ const signature = buildHistoryMessageSignature(agentType, {
182
+ role,
183
+ content,
184
+ kind: typeof msg.kind === 'string' ? msg.kind : undefined,
185
+ });
79
186
  if (seenHashes.has(hash)) continue;
187
+ if (this.lastSeenSignatures.get(dedupKey) === signature) continue;
188
+ if (role !== 'system' && this.lastSeenTurnSignatures.get(dedupKey) === signature) continue;
80
189
  seenHashes.add(hash);
190
+ this.lastSeenSignatures.set(dedupKey, signature);
191
+ if (role !== 'system') {
192
+ this.lastSeenTurnSignatures.set(dedupKey, signature);
193
+ }
81
194
  newMessages.push({
82
- ts: new Date(msg.receivedAt || Date.now()).toISOString(),
83
- receivedAt: msg.receivedAt || Date.now(),
84
- role: msg.role as 'user' | 'assistant' | 'system',
85
- content: msg.content || '',
195
+ ts: new Date(receivedAt).toISOString(),
196
+ receivedAt,
197
+ role,
198
+ content,
86
199
  kind: typeof msg.kind === 'string' ? msg.kind : undefined,
87
200
  senderName: typeof msg.senderName === 'string' ? msg.senderName : undefined,
88
201
  agent: agentType,
@@ -108,6 +221,8 @@ export class ChatHistoryWriter {
108
221
  const prevCount = this.lastSeenCounts.get(dedupKey) || 0;
109
222
  if (messages.length < prevCount * 0.5 && prevCount > 3) {
110
223
  seenHashes.clear();
224
+ this.lastSeenSignatures.delete(dedupKey);
225
+ this.lastSeenTurnSignatures.delete(dedupKey);
111
226
  for (const msg of messages) {
112
227
  seenHashes.add(msg.historyDedupKey || `${msg.kind || 'standard'}:${msg.role}:${(msg.content || '').slice(0, 50)}`);
113
228
  }
@@ -124,6 +239,62 @@ export class ChatHistoryWriter {
124
239
  }
125
240
  }
126
241
 
242
+ seedSessionHistory(
243
+ agentType: string,
244
+ messages: Array<{ role: string; content: string; receivedAt?: number; kind?: string; historyDedupKey?: string }> = [],
245
+ historySessionId?: string,
246
+ instanceId?: string,
247
+ ): void {
248
+ const effectiveHistoryKey = historySessionId || instanceId;
249
+ const dedupKey = effectiveHistoryKey ? `${agentType}:${effectiveHistoryKey}` : agentType;
250
+ const seenHashes = new Set<string>();
251
+
252
+ for (const raw of messages) {
253
+ const role = raw?.role as 'user' | 'assistant' | 'system';
254
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
255
+ const content = cleanupHistoryContent(agentType, role, raw?.content || '');
256
+ if (!content) continue;
257
+ seenHashes.add(buildHistoryMessageHash(agentType, {
258
+ role,
259
+ content,
260
+ receivedAt: raw?.receivedAt || 0,
261
+ kind: typeof raw?.kind === 'string' ? raw.kind : undefined,
262
+ historyDedupKey: raw?.historyDedupKey,
263
+ }));
264
+ }
265
+
266
+ this.lastSeenHashes.set(dedupKey, seenHashes);
267
+ this.lastSeenCounts.set(dedupKey, messages.length);
268
+ const lastMessage = [...messages].reverse().find((raw) => {
269
+ const role = raw?.role as 'user' | 'assistant' | 'system';
270
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') return false;
271
+ return !!cleanupHistoryContent(agentType, role, raw?.content || '');
272
+ });
273
+ const lastTurnMessage = [...messages].reverse().find((raw) => {
274
+ const role = raw?.role as 'user' | 'assistant';
275
+ if (role !== 'user' && role !== 'assistant') return false;
276
+ return !!cleanupHistoryContent(agentType, role, raw?.content || '');
277
+ });
278
+ if (lastMessage) {
279
+ this.lastSeenSignatures.set(dedupKey, buildHistoryMessageSignature(agentType, {
280
+ role: lastMessage.role as HistoryMessage['role'],
281
+ content: lastMessage.content,
282
+ kind: typeof lastMessage.kind === 'string' ? lastMessage.kind : undefined,
283
+ }));
284
+ } else {
285
+ this.lastSeenSignatures.delete(dedupKey);
286
+ }
287
+ if (lastTurnMessage) {
288
+ this.lastSeenTurnSignatures.set(dedupKey, buildHistoryMessageSignature(agentType, {
289
+ role: lastTurnMessage.role as 'user' | 'assistant',
290
+ content: lastTurnMessage.content,
291
+ kind: typeof lastTurnMessage.kind === 'string' ? lastTurnMessage.kind : undefined,
292
+ }));
293
+ } else {
294
+ this.lastSeenTurnSignatures.delete(dedupKey);
295
+ }
296
+ }
297
+
127
298
  appendSystemMarker(
128
299
  agentType: string,
129
300
  content: string,
@@ -171,6 +342,16 @@ export class ChatHistoryWriter {
171
342
  this.lastSeenHashes.set(toDedupKey, nextHashes);
172
343
  this.lastSeenHashes.delete(fromDedupKey);
173
344
  }
345
+ const fromSignature = this.lastSeenSignatures.get(fromDedupKey);
346
+ if (fromSignature) {
347
+ this.lastSeenSignatures.set(toDedupKey, fromSignature);
348
+ this.lastSeenSignatures.delete(fromDedupKey);
349
+ }
350
+ const fromTurnSignature = this.lastSeenTurnSignatures.get(fromDedupKey);
351
+ if (fromTurnSignature) {
352
+ this.lastSeenTurnSignatures.set(toDedupKey, fromTurnSignature);
353
+ this.lastSeenTurnSignatures.delete(fromDedupKey);
354
+ }
174
355
  const fromCount = this.lastSeenCounts.get(fromDedupKey);
175
356
  if (typeof fromCount === 'number') {
176
357
  this.lastSeenCounts.set(toDedupKey, Math.max(fromCount, this.lastSeenCounts.get(toDedupKey) || 0));
@@ -221,10 +402,69 @@ export class ChatHistoryWriter {
221
402
  }
222
403
  }
223
404
 
405
+ compactHistorySession(agentType: string, historySessionId: string): void {
406
+ const sessionId = String(historySessionId || '').trim();
407
+ if (!sessionId) return;
408
+
409
+ try {
410
+ const dir = path.join(HISTORY_DIR, this.sanitize(agentType));
411
+ if (!fs.existsSync(dir)) return;
412
+
413
+ const prefix = `${this.sanitize(sessionId)}_`;
414
+ const files = fs.readdirSync(dir)
415
+ .filter((file) => file.startsWith(prefix) && file.endsWith('.jsonl'))
416
+ .sort();
417
+
418
+ const seen = new Set<string>();
419
+ for (const file of files) {
420
+ const filePath = path.join(dir, file);
421
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
422
+ const next: HistoryMessage[] = [];
423
+
424
+ for (const line of lines) {
425
+ let parsed: HistoryMessage | null = null;
426
+ try {
427
+ parsed = JSON.parse(line) as HistoryMessage;
428
+ } catch {
429
+ parsed = null;
430
+ }
431
+ if (!parsed || parsed.historySessionId !== sessionId) continue;
432
+ const sanitized = sanitizeHistoryMessage(agentType, parsed);
433
+ if (!sanitized) continue;
434
+ const hash = buildHistoryMessageHash(agentType, sanitized);
435
+ if (seen.has(hash)) continue;
436
+ seen.add(hash);
437
+ next.push(sanitized);
438
+ }
439
+
440
+ next.sort((a, b) => a.receivedAt - b.receivedAt);
441
+ const dedupedAdjacent: HistoryMessage[] = [];
442
+ let lastTurn: HistoryMessage | null = null;
443
+ for (const entry of next) {
444
+ const previous = dedupedAdjacent[dedupedAdjacent.length - 1];
445
+ if (isAdjacentHistoryDuplicate(agentType, previous, entry)) continue;
446
+ if (entry.role !== 'system' && isAdjacentHistoryDuplicate(agentType, lastTurn, entry)) continue;
447
+ dedupedAdjacent.push(entry);
448
+ if (entry.role !== 'system') lastTurn = entry;
449
+ }
450
+ const collapsed = collapseReplayAssistantTurns(agentType, dedupedAdjacent);
451
+ if (collapsed.length === 0) {
452
+ fs.unlinkSync(filePath);
453
+ continue;
454
+ }
455
+ fs.writeFileSync(filePath, `${collapsed.map((entry) => JSON.stringify(entry)).join('\n')}\n`, 'utf-8');
456
+ }
457
+ } catch {
458
+ // Ignore compaction failure.
459
+ }
460
+ }
461
+
224
462
  /** Called when agent session is explicitly changed */
225
463
  onSessionChange(agentType: string): void {
226
464
  this.lastSeenHashes.delete(agentType);
227
465
  this.lastSeenCounts.delete(agentType);
466
+ this.lastSeenSignatures.delete(agentType);
467
+ this.lastSeenTurnSignatures.delete(agentType);
228
468
  }
229
469
 
230
470
  /** Delete history files older than 30 days */
@@ -293,31 +533,42 @@ export function readChatHistory(
293
533
  .sort()
294
534
  .reverse();
295
535
 
296
- // Read lines from all files (reverse order)
297
536
  const allMessages: HistoryMessage[] = [];
298
- const needed = offset + limit + 1; // hasMore check +1
537
+ const seen = new Set<string>();
299
538
 
300
539
  for (const file of files) {
301
- if (allMessages.length >= needed) break;
302
540
  const filePath = path.join(dir, file);
303
541
  const content = fs.readFileSync(filePath, 'utf-8');
304
542
  const lines = content.trim().split('\n').filter(Boolean);
305
-
306
- // Parse in reverse order
307
- for (let i = lines.length - 1; i >= 0; i--) {
308
- if (allMessages.length >= needed) break;
543
+
544
+ for (let i = 0; i < lines.length; i++) {
309
545
  try {
310
- allMessages.push(JSON.parse(lines[i]));
546
+ const parsed = JSON.parse(lines[i]) as HistoryMessage;
547
+ const sanitizedMessage = sanitizeHistoryMessage(agentType, parsed);
548
+ if (!sanitizedMessage) continue;
549
+ const hash = buildHistoryMessageHash(agentType, sanitizedMessage);
550
+ if (seen.has(hash)) continue;
551
+ seen.add(hash);
552
+ allMessages.push(sanitizedMessage);
311
553
  } catch { /* skip invalid lines */ }
312
554
  }
313
555
  }
314
556
 
315
- // offset/limit apply
316
- const sliced = allMessages.slice(offset, offset + limit);
317
- const hasMore = allMessages.length > offset + limit;
557
+ allMessages.sort((a, b) => a.receivedAt - b.receivedAt);
558
+ const chronological: HistoryMessage[] = [];
559
+ let lastTurn: HistoryMessage | null = null;
560
+ for (const message of allMessages) {
561
+ const previous = chronological[chronological.length - 1];
562
+ if (isAdjacentHistoryDuplicate(agentType, previous, message)) continue;
563
+ if (message.role !== 'system' && isAdjacentHistoryDuplicate(agentType, lastTurn, message)) continue;
564
+ chronological.push(message);
565
+ if (message.role !== 'system') lastTurn = message;
566
+ }
567
+ const collapsed = collapseReplayAssistantTurns(agentType, chronological);
318
568
 
319
- // Sort in chronological order (top→bottom = oldest→newest)
320
- sliced.reverse();
569
+ // offset/limit apply
570
+ const sliced = collapsed.slice(offset, offset + limit);
571
+ const hasMore = collapsed.length > offset + limit;
321
572
 
322
573
  return { messages: sliced, hasMore };
323
574
  } catch {