@adhdev/daemon-core 0.8.28 → 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.
@@ -70,6 +70,64 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
70
70
  || /Cannot find context with specified id/i.test(reason);
71
71
  }
72
72
 
73
+ private titlesMatch(actual: string, expected: string): boolean {
74
+ const lhs = actual.trim().toLowerCase();
75
+ const rhs = expected.trim().toLowerCase();
76
+ if (!lhs || !rhs) return false;
77
+ return lhs === rhs || lhs.includes(rhs) || rhs.includes(lhs);
78
+ }
79
+
80
+ private messageCount(state: AgentStreamState | null | undefined): number {
81
+ return Array.isArray(state?.messages) ? state!.messages.length : 0;
82
+ }
83
+
84
+ private lastMessageSignature(state: AgentStreamState | null | undefined): string {
85
+ const messages = Array.isArray(state?.messages) ? state!.messages : [];
86
+ const last = messages[messages.length - 1] as any;
87
+ if (!last) return '';
88
+ return `${last.role || ''}:${String(last.content || '').replace(/\s+/g, ' ').trim()}`;
89
+ }
90
+
91
+ private async verifySendOutcome(
92
+ evaluate: AgentEvaluateFn,
93
+ before: AgentStreamState | null,
94
+ ): Promise<boolean> {
95
+ const beforeCount = this.messageCount(before);
96
+ const beforeSignature = this.lastMessageSignature(before);
97
+
98
+ for (let attempt = 0; attempt < 12; attempt += 1) {
99
+ await new Promise((resolve) => setTimeout(resolve, 250));
100
+ let state: AgentStreamState;
101
+ try {
102
+ state = await this.readChat(evaluate);
103
+ } catch {
104
+ continue;
105
+ }
106
+
107
+ if (state.status === 'waiting_approval') {
108
+ return true;
109
+ }
110
+
111
+ const afterCount = this.messageCount(state);
112
+ const afterSignature = this.lastMessageSignature(state);
113
+ if (afterCount > beforeCount) return true;
114
+ if (afterSignature && afterSignature !== beforeSignature) return true;
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ private async readStableBaselineState(evaluate: AgentEvaluateFn): Promise<AgentStreamState | null> {
121
+ const first = await this.readChat(evaluate);
122
+ if (this.messageCount(first) > 0 || this.lastMessageSignature(first)) {
123
+ return first;
124
+ }
125
+
126
+ await new Promise((resolve) => setTimeout(resolve, 150));
127
+ const second = await this.readChat(evaluate);
128
+ return this.messageCount(second) >= this.messageCount(first) ? second : first;
129
+ }
130
+
73
131
  async readChat(evaluate: AgentEvaluateFn): Promise<AgentStreamState> {
74
132
  const script = this.callScript('readChat');
75
133
  if (!script) return this.errorState('readChat script not available');
@@ -96,6 +154,9 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
96
154
  mode: data.mode,
97
155
  activeModal: data.activeModal,
98
156
  };
157
+ if (typeof data.title === 'string' && data.title.trim()) {
158
+ (state as any).title = data.title.trim();
159
+ }
99
160
  const controlValues = extractProviderControlValues(this.provider.controls, data);
100
161
  if (controlValues) state.controlValues = controlValues;
101
162
  const effects = normalizeProviderEffects(data);
@@ -120,6 +181,13 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
120
181
  }
121
182
 
122
183
  async sendMessage(evaluate: AgentEvaluateFn, text: string): Promise<void> {
184
+ let beforeState: AgentStreamState | null = null;
185
+ try {
186
+ beforeState = await this.readStableBaselineState(evaluate);
187
+ } catch {
188
+ beforeState = null;
189
+ }
190
+
123
191
  const params = { message: text };
124
192
  const script = this.callScript('sendMessage', params) || this.callScript('sendMessage', text);
125
193
  if (!script) throw new Error(`[${this.agentName}] sendMessage script not available`);
@@ -138,7 +206,9 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
138
206
  }
139
207
  if (parsed && typeof parsed === 'object') {
140
208
  if (parsed.sent === true || parsed.success === true || parsed.ok === true || parsed.submitted === true || parsed.dispatched === true) {
141
- return;
209
+ const verified = await this.verifySendOutcome(evaluate, beforeState);
210
+ if (verified) return;
211
+ throw new Error(`[${this.agentName}] sendMessage was not observed in chat state`);
142
212
  }
143
213
  if (typeof parsed.error === 'string' && parsed.error.trim()) {
144
214
  throw new Error(`[${this.agentName}] sendMessage failed: ${parsed.error}`);
@@ -151,7 +221,23 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
151
221
  async resolveAction(evaluate: AgentEvaluateFn, action: string, button?: string): Promise<boolean> {
152
222
  const script = this.callScript('resolveAction', { action, button });
153
223
  if (!script) return false; // Not supported if provider has no resolveAction
154
- return (await evaluate(script)) === true;
224
+ const result = await evaluate(script);
225
+ const parsed = this.parseMaybeJson(result);
226
+ if (parsed === true) return true;
227
+ if (typeof parsed === 'string') {
228
+ const normalized = parsed.trim().toLowerCase();
229
+ return normalized === 'ok'
230
+ || normalized === 'success'
231
+ || normalized === 'true'
232
+ || normalized === 'resolved'
233
+ || normalized === 'approved'
234
+ || normalized === 'rejected';
235
+ }
236
+ if (!parsed || typeof parsed !== 'object') return false;
237
+ return parsed.resolved === true
238
+ || parsed.success === true
239
+ || parsed.ok === true
240
+ || parsed.found === true;
155
241
  }
156
242
 
157
243
  async newSession(evaluate: AgentEvaluateFn): Promise<void> {
@@ -189,7 +275,15 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
189
275
  return normalized === 'true' || normalized === 'ok' || normalized === 'switched' || normalized === 'success';
190
276
  }
191
277
  if (data && typeof data === 'object') {
192
- return data.switched === true || data.success === true || data.ok === true;
278
+ if (data.switched === true || data.success === true || data.ok === true) return true;
279
+ if (typeof data.error === 'string' && data.error.trim()) return false;
280
+ }
281
+
282
+ for (let attempt = 0; attempt < 6; attempt += 1) {
283
+ await new Promise((resolve) => setTimeout(resolve, 250));
284
+ const state = await this.readChat(evaluate);
285
+ const title = typeof (state as any).title === 'string' ? (state as any).title : '';
286
+ if (this.titlesMatch(title, sessionId)) return true;
193
287
  }
194
288
  return false;
195
289
  }
@@ -1241,6 +1241,9 @@ export class ProviderCliAdapter implements CliAdapter {
1241
1241
  private looksLikeVisibleIdlePrompt(screenText: string): boolean {
1242
1242
  const text = String(screenText || '');
1243
1243
  if (!text.trim()) return false;
1244
+ if (this.cliType === 'codex-cli' && /(^|\n)\s*[❯›>]\s+(?:Find and fix a bug in @filename|Improve documentation in @filename|Use \/skills|Write tests for @filename|Explain this codebase|Summarize recent commits|Implement \{feature\}|Run \/review on my current changes)(?:\n|$)/im.test(text)) {
1245
+ return true;
1246
+ }
1244
1247
  return /(^|\n)\s*[❯›>]\s*(?:\n|$)/m.test(text)
1245
1248
  || /⏎\s+send/i.test(text)
1246
1249
  || /\?\s*for\s*shortcuts/i.test(text)
@@ -115,6 +115,51 @@ function didProviderConfirmSend(result: any): boolean {
115
115
  || parsed.dispatched === true;
116
116
  }
117
117
 
118
+ async function readExtensionChatState(h: CommandHelpers): Promise<any | null> {
119
+ try {
120
+ const evalResult = await h.evaluateProviderScript('readChat', undefined, 50000);
121
+ if (!evalResult?.result) return null;
122
+ const parsed = parseMaybeJson(evalResult.result);
123
+ return parsed && typeof parsed === 'object' ? parsed : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function getStateMessageCount(state: any): number {
130
+ return Array.isArray(state?.messages) ? state.messages.length : 0;
131
+ }
132
+
133
+ function getStateLastSignature(state: any): string {
134
+ const messages = Array.isArray(state?.messages) ? state.messages : [];
135
+ const last = messages[messages.length - 1];
136
+ if (!last) return '';
137
+ return `${last.role || ''}:${String(last.content || '').replace(/\s+/g, ' ').trim()}`;
138
+ }
139
+
140
+ async function getStableExtensionBaseline(h: CommandHelpers): Promise<any | null> {
141
+ const first = await readExtensionChatState(h);
142
+ if (getStateMessageCount(first) > 0 || getStateLastSignature(first)) return first;
143
+ await new Promise((resolve) => setTimeout(resolve, 150));
144
+ const second = await readExtensionChatState(h);
145
+ return getStateMessageCount(second) >= getStateMessageCount(first) ? second : first;
146
+ }
147
+
148
+ async function verifyExtensionSendObserved(h: CommandHelpers, before: any): Promise<boolean> {
149
+ const beforeCount = getStateMessageCount(before);
150
+ const beforeSignature = getStateLastSignature(before);
151
+ for (let attempt = 0; attempt < 12; attempt += 1) {
152
+ await new Promise((resolve) => setTimeout(resolve, 250));
153
+ const state = await readExtensionChatState(h);
154
+ if (state?.status === 'waiting_approval') return true;
155
+ const afterCount = getStateMessageCount(state);
156
+ const afterSignature = getStateLastSignature(state);
157
+ if (afterCount > beforeCount) return true;
158
+ if (afterSignature && afterSignature !== beforeSignature) return true;
159
+ }
160
+ return false;
161
+ }
162
+
118
163
  export async function handleChatHistory(h: CommandHelpers, args: any): Promise<CommandResult> {
119
164
  const { agentType, offset, limit } = args;
120
165
  const historySessionId = getHistorySessionId(h, args);
@@ -303,12 +348,17 @@ export async function handleSendChat(h: CommandHelpers, args: any): Promise<Comm
303
348
  _log(`Extension: ${provider?.type || 'unknown_extension'}`);
304
349
  // Method 1: provider sendMessage script via evaluateInSession
305
350
  try {
351
+ const beforeState = await getStableExtensionBaseline(h);
306
352
  const evalResult = await h.evaluateProviderScript('sendMessage', { message: text }, 30000);
307
353
  if (evalResult?.result) {
308
354
  const parsed = parseMaybeJson(evalResult.result);
309
355
  if (didProviderConfirmSend(parsed)) {
310
- _log(`Extension script sent OK`);
311
- return _logSendSuccess('extension-script');
356
+ const observed = await verifyExtensionSendObserved(h, beforeState);
357
+ if (observed) {
358
+ _log(`Extension script sent OK`);
359
+ return _logSendSuccess('extension-script');
360
+ }
361
+ _log(`Extension script reported send but no chat-state change was observed`);
312
362
  }
313
363
  if (parsed?.needsTypeAndSend) {
314
364
  _log(`Extension needsTypeAndSend → AgentStreamManager`);
@@ -829,7 +879,7 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
829
879
 
830
880
  // 1. Extension transport: via AgentStreamManager
831
881
  if (isExtensionTransport(transport) && h.agentStream && h.getCdp() && h.currentSession?.sessionId) {
832
- const ok = await h.agentStream.resolveSessionAction(h.getCdp()!, h.currentSession.sessionId, action);
882
+ const ok = await h.agentStream.resolveSessionAction(h.getCdp()!, h.currentSession.sessionId, action, button);
833
883
  return { success: ok };
834
884
  }
835
885
 
@@ -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 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 : '';
@@ -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 {