@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.
- package/dist/agent-stream/manager.d.ts +1 -1
- package/dist/agent-stream/provider-adapter.d.ts +5 -0
- package/dist/commands/router.d.ts +5 -0
- package/dist/config/chat-history.d.ts +12 -0
- package/dist/index.js +385 -23
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +385 -23
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +1 -0
- package/node_modules/@adhdev/session-host-core/dist/index.d.mts +24 -1
- package/node_modules/@adhdev/session-host-core/dist/index.d.ts +24 -1
- package/node_modules/@adhdev/session-host-core/dist/index.js +6 -1
- package/node_modules/@adhdev/session-host-core/dist/index.js.map +1 -1
- package/node_modules/@adhdev/session-host-core/dist/index.mjs +6 -1
- package/node_modules/@adhdev/session-host-core/dist/index.mjs.map +1 -1
- package/node_modules/@adhdev/session-host-core/package.json +1 -1
- package/package.json +1 -1
- package/src/agent-stream/manager.ts +2 -2
- package/src/agent-stream/provider-adapter.ts +97 -3
- package/src/cli-adapters/provider-cli-adapter.ts +3 -0
- package/src/commands/chat-commands.ts +53 -3
- package/src/commands/cli-manager.ts +14 -0
- package/src/commands/router.ts +11 -0
- package/src/config/chat-history.ts +269 -18
- package/src/providers/cli-provider-instance.ts +17 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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) {
|
package/src/commands/router.ts
CHANGED
|
@@ -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
|
|
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(
|
|
83
|
-
receivedAt
|
|
84
|
-
role
|
|
85
|
-
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
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
|
-
//
|
|
320
|
-
sliced.
|
|
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 {
|