@adhdev/daemon-core 0.9.77-rc.5 → 0.9.77-rc.51

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 (50) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +3 -0
  2. package/dist/cli-adapters/provider-cli-adapter.d.ts +4 -0
  3. package/dist/cli-adapters/provider-cli-shared.d.ts +14 -4
  4. package/dist/commands/mesh-coordinator.d.ts +10 -0
  5. package/dist/commands/router.d.ts +4 -1
  6. package/dist/config/mesh-config.d.ts +1 -0
  7. package/dist/git/git-worktree.d.ts +15 -2
  8. package/dist/index.d.ts +10 -5
  9. package/dist/index.js +2013 -299
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +1998 -299
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/mesh/mesh-events.d.ts +14 -7
  14. package/dist/mesh/mesh-ledger-reconciliation.d.ts +55 -0
  15. package/dist/mesh/mesh-ledger.d.ts +84 -4
  16. package/dist/mesh/mesh-sync.d.ts +4 -12
  17. package/dist/mesh/mesh-work-queue.d.ts +58 -1
  18. package/dist/mesh/p2p-relay-failure.d.ts +35 -0
  19. package/dist/providers/chat-message-normalization.d.ts +1 -0
  20. package/dist/providers/cli-provider-instance.d.ts +6 -0
  21. package/dist/repo-mesh-types.d.ts +2 -0
  22. package/dist/shared-types.d.ts +38 -0
  23. package/package.json +1 -1
  24. package/src/boot/daemon-lifecycle.ts +5 -0
  25. package/src/cli-adapters/provider-cli-adapter.ts +35 -4
  26. package/src/cli-adapters/provider-cli-shared.ts +14 -4
  27. package/src/commands/cli-manager.ts +0 -4
  28. package/src/commands/mesh-coordinator.ts +55 -7
  29. package/src/commands/router.ts +847 -26
  30. package/src/commands/stream-commands.ts +8 -1
  31. package/src/config/config.ts +2 -1
  32. package/src/config/mesh-config.ts +2 -0
  33. package/src/config/workspaces.ts +1 -1
  34. package/src/git/git-worktree.ts +56 -4
  35. package/src/index.d.ts +3 -0
  36. package/src/index.ts +22 -5
  37. package/src/mesh/coordinator-prompt.ts +21 -10
  38. package/src/mesh/mesh-events.ts +532 -22
  39. package/src/mesh/mesh-ledger-reconciliation.ts +115 -0
  40. package/src/mesh/mesh-ledger.ts +209 -8
  41. package/src/mesh/mesh-sync.ts +4 -34
  42. package/src/mesh/mesh-work-queue.ts +183 -17
  43. package/src/mesh/p2p-relay-failure.ts +152 -0
  44. package/src/providers/acp-provider-instance.ts +2 -1
  45. package/src/providers/chat-message-normalization.ts +32 -0
  46. package/src/providers/cli-provider-instance.ts +155 -31
  47. package/src/providers/extension-provider-instance.ts +2 -1
  48. package/src/providers/ide-provider-instance.ts +2 -2
  49. package/src/repo-mesh-types.ts +2 -0
  50. package/src/shared-types.ts +38 -0
@@ -3,7 +3,12 @@ import { join } from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { getLedgerDir } from './mesh-ledger.js';
5
5
 
6
- export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed';
6
+ export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
7
+ export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
8
+ export type MeshHistoricalTaskStatus = Extract<MeshTaskStatus, 'completed' | 'failed' | 'cancelled'>;
9
+
10
+ export const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[] = ['pending', 'assigned'];
11
+ export const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[] = ['completed', 'failed', 'cancelled'];
7
12
 
8
13
  export interface MeshWorkQueueEntry {
9
14
  id: string;
@@ -12,10 +17,30 @@ export interface MeshWorkQueueEntry {
12
17
  status: MeshTaskStatus;
13
18
  /** If specified, only this node can claim the task (used by legacy mesh_send_task) */
14
19
  targetNodeId?: string;
20
+ /** If specified, only this runtime session can claim the task */
21
+ targetSessionId?: string;
15
22
  /** The node that actually claimed and is executing the task */
16
23
  assignedNodeId?: string;
17
24
  /** The session currently executing the task */
18
25
  assignedSessionId?: string;
26
+ /** Human/operator reason for terminal cancellation. */
27
+ cancelReason?: string;
28
+ cancelledAt?: string;
29
+ /** Human/operator reason for manually requeueing a task. */
30
+ requeueReason?: string;
31
+ requeuedAt?: string;
32
+ requeueCount?: number;
33
+ /** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
34
+ autoLaunch?: {
35
+ status: 'skipped' | 'started' | 'failed' | 'completed';
36
+ reason?: string;
37
+ nodeId?: string;
38
+ providerType?: string;
39
+ sessionId?: string;
40
+ updatedAt: string;
41
+ };
42
+ /** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
43
+ dispatchTimestamp?: string;
19
44
  createdAt: string;
20
45
  updatedAt: string;
21
46
  }
@@ -47,7 +72,7 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
47
72
  export function enqueueTask(
48
73
  meshId: string,
49
74
  message: string,
50
- opts?: { targetNodeId?: string }
75
+ opts?: { targetNodeId?: string; targetSessionId?: string }
51
76
  ): MeshWorkQueueEntry {
52
77
  const queue = readQueue(meshId);
53
78
  const entry: MeshWorkQueueEntry = {
@@ -56,6 +81,7 @@ export function enqueueTask(
56
81
  message,
57
82
  status: 'pending',
58
83
  targetNodeId: opts?.targetNodeId,
84
+ targetSessionId: opts?.targetSessionId,
59
85
  createdAt: new Date().toISOString(),
60
86
  updatedAt: new Date().toISOString(),
61
87
  };
@@ -81,13 +107,25 @@ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }):
81
107
  */
82
108
  export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
83
109
  const queue = readQueue(meshId);
110
+
111
+ // A worker must finish or fail its current queued assignment before it can
112
+ // claim another one. maxParallelTasks limits total mesh concurrency; it is
113
+ // not permission for one node/session to accumulate multiple assigned items.
114
+ const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
115
+ q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
116
+ ));
117
+ if (hasActiveAssignment) return null;
84
118
 
85
119
  // Find highest priority task:
86
- // 1. Pending tasks explicitly targeted at this node
87
- // 2. Pending tasks with no target node
88
- let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId);
120
+ // 1. Pending tasks explicitly targeted at this runtime session
121
+ // 2. Pending tasks explicitly targeted at this node (but not another session)
122
+ // 3. Pending tasks with no target node/session
123
+ let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
89
124
  if (targetIdx === -1) {
90
- targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId);
125
+ targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
126
+ }
127
+ if (targetIdx === -1) {
128
+ targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
91
129
  }
92
130
 
93
131
  if (targetIdx === -1) return null;
@@ -96,6 +134,7 @@ export function claimNextTask(meshId: string, nodeId: string, sessionId: string)
96
134
  entry.status = 'assigned';
97
135
  entry.assignedNodeId = nodeId;
98
136
  entry.assignedSessionId = sessionId;
137
+ entry.dispatchTimestamp = new Date().toISOString();
99
138
  entry.updatedAt = new Date().toISOString();
100
139
 
101
140
  writeQueue(meshId, queue);
@@ -121,6 +160,83 @@ export function updateTaskStatus(
121
160
  return queue[idx];
122
161
  }
123
162
 
163
+ export function recordTaskAutoLaunch(
164
+ meshId: string,
165
+ taskId: string,
166
+ autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
167
+ ): MeshWorkQueueEntry | null {
168
+ const queue = readQueue(meshId);
169
+ const idx = queue.findIndex(q => q.id === taskId);
170
+ if (idx === -1) return null;
171
+ const now = new Date().toISOString();
172
+ queue[idx].autoLaunch = {
173
+ ...autoLaunch,
174
+ updatedAt: now,
175
+ };
176
+ queue[idx].updatedAt = now;
177
+ writeQueue(meshId, queue);
178
+ return queue[idx];
179
+ }
180
+
181
+ /**
182
+ * Mark a queue task as manually cancelled without deleting audit history.
183
+ */
184
+ export function cancelTask(
185
+ meshId: string,
186
+ taskId: string,
187
+ opts?: { reason?: string },
188
+ ): MeshWorkQueueEntry | null {
189
+ const queue = readQueue(meshId);
190
+ const idx = queue.findIndex(q => q.id === taskId);
191
+ if (idx === -1) return null;
192
+
193
+ const now = new Date().toISOString();
194
+ queue[idx].status = 'cancelled';
195
+ queue[idx].updatedAt = now;
196
+ queue[idx].cancelledAt = now;
197
+ if (opts?.reason) queue[idx].cancelReason = opts.reason;
198
+ writeQueue(meshId, queue);
199
+ return queue[idx];
200
+ }
201
+
202
+ /**
203
+ * Return a queue task to pending for retry. By default, dead session targeting
204
+ * and assigned ownership are cleared so stale assignments do not strand again.
205
+ */
206
+ export function requeueTask(
207
+ meshId: string,
208
+ taskId: string,
209
+ opts?: {
210
+ reason?: string;
211
+ targetNodeId?: string;
212
+ targetSessionId?: string;
213
+ clearTargetNode?: boolean;
214
+ clearTargetSession?: boolean;
215
+ },
216
+ ): MeshWorkQueueEntry | null {
217
+ const queue = readQueue(meshId);
218
+ const idx = queue.findIndex(q => q.id === taskId);
219
+ if (idx === -1) return null;
220
+
221
+ const entry = queue[idx];
222
+ const now = new Date().toISOString();
223
+ entry.status = 'pending';
224
+ delete entry.assignedNodeId;
225
+ delete entry.assignedSessionId;
226
+ delete entry.cancelledAt;
227
+ delete entry.cancelReason;
228
+ if (opts?.clearTargetNode) delete entry.targetNodeId;
229
+ if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
230
+ if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
231
+ if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
232
+ entry.updatedAt = now;
233
+ entry.requeuedAt = now;
234
+ entry.requeueCount = (entry.requeueCount || 0) + 1;
235
+ if (opts?.reason) entry.requeueReason = opts.reason;
236
+ writeQueue(meshId, queue);
237
+ return entry;
238
+ }
239
+
124
240
  /**
125
241
  * Update the status of the task currently assigned to a specific session.
126
242
  */
@@ -130,24 +246,48 @@ export function updateSessionTaskStatus(
130
246
  status: MeshTaskStatus,
131
247
  ): MeshWorkQueueEntry | null {
132
248
  const queue = readQueue(meshId);
133
- // Find the most recently assigned task for this session that isn't already terminal
134
- // (In case multiple tasks were assigned to the same session over time, though rare)
249
+ // Collect all assigned tasks for this session, then pick the one with the
250
+ // most recent dispatchTimestamp (or updatedAt fallback for legacy entries).
251
+ // This prevents completing the wrong task when multiple tasks were assigned
252
+ // to the same session in rapid succession.
253
+ let bestIdx = -1;
254
+ let bestTime = 0;
135
255
  for (let i = queue.length - 1; i >= 0; i--) {
136
256
  if (queue[i].assignedSessionId === sessionId && queue[i].status === 'assigned') {
137
- queue[i].status = status;
138
- queue[i].updatedAt = new Date().toISOString();
139
- writeQueue(meshId, queue);
140
- return queue[i];
257
+ const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
258
+ if (time > bestTime) {
259
+ bestTime = time;
260
+ bestIdx = i;
261
+ }
141
262
  }
142
263
  }
143
- return null;
264
+ if (bestIdx === -1) return null;
265
+
266
+ queue[bestIdx].status = status;
267
+ queue[bestIdx].updatedAt = new Date().toISOString();
268
+ writeQueue(meshId, queue);
269
+ return queue[bestIdx];
144
270
  }
145
271
 
146
272
  export interface MeshWorkQueueStats {
273
+ total: number;
274
+ active: number;
275
+ historical: number;
147
276
  pending: number;
148
277
  assigned: number;
149
278
  completed: number;
150
279
  failed: number;
280
+ cancelled: number;
281
+ /** Source-of-truth active queue counters; only pending/assigned are live work. */
282
+ activeCounts: Record<MeshActiveTaskStatus, number>;
283
+ /** Terminal ledger records kept for audit/history; never count as active work. */
284
+ historicalCounts: Record<MeshHistoricalTaskStatus, number>;
285
+ activeAssignments: Array<{
286
+ id: string;
287
+ nodeId?: string;
288
+ sessionId?: string;
289
+ message: string;
290
+ }>;
151
291
  }
152
292
 
153
293
  /**
@@ -155,10 +295,36 @@ export interface MeshWorkQueueStats {
155
295
  */
156
296
  export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
157
297
  const queue = readQueue(meshId);
298
+ const pending = queue.filter(q => q.status === 'pending').length;
299
+ const assigned = queue.filter(q => q.status === 'assigned').length;
300
+ const completed = queue.filter(q => q.status === 'completed').length;
301
+ const failed = queue.filter(q => q.status === 'failed').length;
302
+ const cancelled = queue.filter(q => q.status === 'cancelled').length;
158
303
  return {
159
- pending: queue.filter(q => q.status === 'pending').length,
160
- assigned: queue.filter(q => q.status === 'assigned').length,
161
- completed: queue.filter(q => q.status === 'completed').length,
162
- failed: queue.filter(q => q.status === 'failed').length,
304
+ total: queue.length,
305
+ active: pending + assigned,
306
+ historical: completed + failed + cancelled,
307
+ pending,
308
+ assigned,
309
+ completed,
310
+ failed,
311
+ cancelled,
312
+ activeCounts: {
313
+ pending,
314
+ assigned,
315
+ },
316
+ historicalCounts: {
317
+ completed,
318
+ failed,
319
+ cancelled,
320
+ },
321
+ activeAssignments: queue
322
+ .filter(q => q.status === 'assigned')
323
+ .map(q => ({
324
+ id: q.id,
325
+ nodeId: q.assignedNodeId,
326
+ sessionId: q.assignedSessionId,
327
+ message: q.message,
328
+ })),
163
329
  };
164
330
  }
@@ -0,0 +1,152 @@
1
+ export type P2pRelayFailureCode =
2
+ | 'p2p_unavailable'
3
+ | 'p2p_timeout'
4
+ | 'p2p_not_connected'
5
+ | 'p2p_datachannel_closed'
6
+ | 'p2p_no_route'
7
+ | 'p2p_daemon_offline'
8
+ | 'mesh_logic_or_provider_failure';
9
+
10
+ export interface P2pRelayFailureContext {
11
+ command?: string;
12
+ targetDaemonId?: string;
13
+ }
14
+
15
+ export interface P2pRelayFailureClassification {
16
+ code: P2pRelayFailureCode;
17
+ reason: string;
18
+ transport: 'p2p' | 'unknown';
19
+ recoverable: boolean;
20
+ retryRecommended: boolean;
21
+ nextAction: string;
22
+ noFallbackReason: string;
23
+ }
24
+
25
+ export interface P2pRelayFailurePayload extends P2pRelayFailureClassification {
26
+ success: false;
27
+ error: string;
28
+ command?: string;
29
+ targetDaemonId?: string;
30
+ }
31
+
32
+ const NO_FALLBACK_REASON = 'Repo Mesh command/data-plane is P2P-only; WS/REST command fallback is intentionally disabled to preserve the transport boundary.';
33
+ const P2P_NEXT_ACTION = 'Check daemon/P2P health, wait briefly for connection establishment, then do one bounded retry or requeue the mesh task after clearing stale target session metadata.';
34
+ const NON_P2P_NEXT_ACTION = 'Inspect the provider/command error and fix the underlying logic or configuration before retrying.';
35
+
36
+ function messageFromError(error: unknown): string {
37
+ if (error instanceof Error) return error.message;
38
+ if (typeof error === 'string') return error;
39
+ if (error && typeof error === 'object') {
40
+ const candidate = (error as any).error ?? (error as any).message ?? (error as any).reason;
41
+ if (typeof candidate === 'string') return candidate;
42
+ }
43
+ return String(error || 'mesh relay command failed');
44
+ }
45
+
46
+ export function classifyP2pRelayFailure(error: unknown, _context: P2pRelayFailureContext = {}): P2pRelayFailureClassification {
47
+ const message = messageFromError(error);
48
+ const lower = message.toLowerCase();
49
+
50
+ const hasP2pSignal = /p2p|datachannel|node-datachannel|webrtc|ice|mesh_relay_command|daemon_mesh_p2p_transport/i.test(message);
51
+ const hasFailureSignal = /unavailable|missing|failed|failure|timeout|timed out|not connected|closed|disconnected|offline|no route|route unavailable|cannot send|cannot establish/i.test(message);
52
+
53
+ // Validation errors that merely mention mesh_relay_command are not transport failures.
54
+ if (/requires targetdaemonid and command|providerpriority|no inference provider|permission denied|read-only|not a member/i.test(message)) {
55
+ return {
56
+ code: 'mesh_logic_or_provider_failure',
57
+ reason: 'mesh_logic_or_provider_failure',
58
+ transport: 'unknown',
59
+ recoverable: false,
60
+ retryRecommended: false,
61
+ nextAction: NON_P2P_NEXT_ACTION,
62
+ noFallbackReason: NO_FALLBACK_REASON,
63
+ };
64
+ }
65
+
66
+ let code: P2pRelayFailureCode | null = null;
67
+ let reason = '';
68
+
69
+ if (/timeout|timed out/i.test(message) && (hasP2pSignal || /mesh transport/i.test(message))) {
70
+ code = 'p2p_timeout';
71
+ reason = 'daemon_mesh_p2p_timeout';
72
+ } else if (/no route|route unavailable/i.test(message)) {
73
+ code = 'p2p_no_route';
74
+ reason = 'daemon_mesh_p2p_no_route';
75
+ } else if (/offline|not owned|not found|not connected to server/i.test(message) && /daemon|peer|target/i.test(message)) {
76
+ code = 'p2p_daemon_offline';
77
+ reason = 'daemon_mesh_target_offline';
78
+ } else if (/closed|disconnected/i.test(message) && (hasP2pSignal || /state changed/i.test(message))) {
79
+ code = 'p2p_datachannel_closed';
80
+ reason = 'daemon_mesh_p2p_datachannel_closed';
81
+ } else if (/not connected|cannot send|cannot establish/i.test(message) && hasP2pSignal) {
82
+ code = 'p2p_not_connected';
83
+ reason = 'daemon_mesh_p2p_not_connected';
84
+ } else if (hasP2pSignal && hasFailureSignal) {
85
+ code = 'p2p_unavailable';
86
+ reason = 'daemon_mesh_p2p_transport_unavailable';
87
+ }
88
+
89
+ if (!code) {
90
+ return {
91
+ code: 'mesh_logic_or_provider_failure',
92
+ reason: 'mesh_logic_or_provider_failure',
93
+ transport: 'unknown',
94
+ recoverable: false,
95
+ retryRecommended: false,
96
+ nextAction: NON_P2P_NEXT_ACTION,
97
+ noFallbackReason: NO_FALLBACK_REASON,
98
+ };
99
+ }
100
+
101
+ return {
102
+ code,
103
+ reason,
104
+ transport: 'p2p',
105
+ recoverable: true,
106
+ retryRecommended: true,
107
+ nextAction: P2P_NEXT_ACTION,
108
+ noFallbackReason: NO_FALLBACK_REASON,
109
+ };
110
+ }
111
+
112
+ export function isP2pRelayTransportFailure(error: unknown): boolean {
113
+ return classifyP2pRelayFailure(error).recoverable === true;
114
+ }
115
+
116
+ export function buildP2pRelayFailurePayload(error: unknown, context: P2pRelayFailureContext = {}): P2pRelayFailurePayload {
117
+ const classification = classifyP2pRelayFailure(error, context);
118
+ return {
119
+ success: false,
120
+ ...classification,
121
+ error: messageFromError(error),
122
+ ...(context.command ? { command: context.command } : {}),
123
+ ...(context.targetDaemonId ? { targetDaemonId: context.targetDaemonId } : {}),
124
+ };
125
+ }
126
+
127
+ export class P2pRelayFailureError extends Error {
128
+ code: P2pRelayFailureCode;
129
+ reason: string;
130
+ transport: 'p2p' | 'unknown';
131
+ recoverable: boolean;
132
+ retryRecommended: boolean;
133
+ nextAction: string;
134
+ noFallbackReason: string;
135
+ command?: string;
136
+ targetDaemonId?: string;
137
+
138
+ constructor(message: string, context: P2pRelayFailureContext = {}) {
139
+ super(message);
140
+ this.name = 'P2pRelayFailureError';
141
+ const payload = buildP2pRelayFailurePayload(message, context);
142
+ this.code = payload.code;
143
+ this.reason = payload.reason;
144
+ this.transport = payload.transport;
145
+ this.recoverable = payload.recoverable;
146
+ this.retryRecommended = payload.retryRecommended;
147
+ this.nextAction = payload.nextAction;
148
+ this.noFallbackReason = payload.noFallbackReason;
149
+ this.command = context.command;
150
+ this.targetDaemonId = context.targetDaemonId;
151
+ }
152
+ }
@@ -61,6 +61,7 @@ import {
61
61
  buildToolChatMessage,
62
62
  buildUserChatMessage,
63
63
  normalizeChatMessages,
64
+ extractFinalSummaryFromMessages,
64
65
  } from './chat-message-normalization.js';
65
66
  import { LOG } from '../logging/logger.js';
66
67
  import type { ChatMessage } from '../types.js';
@@ -1507,7 +1508,7 @@ export class AcpProviderInstance implements ProviderInstance {
1507
1508
  });
1508
1509
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
1509
1510
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
1510
- this.pushEvent({ event: 'agent:generating_completed', chatTitle, duration, timestamp: now });
1511
+ this.pushEvent({ event: 'agent:generating_completed', chatTitle, duration, timestamp: now, finalSummary: extractFinalSummaryFromMessages(this.messages) });
1511
1512
  this.generatingStartedAt = 0;
1512
1513
  } else if (newStatus === 'stopped') {
1513
1514
  this.pushEvent({ event: 'agent:stopped', chatTitle, timestamp: now });
@@ -1,4 +1,36 @@
1
1
  import type { ChatMessage } from '../types.js';
2
+ import { flattenContent } from './contracts.js';
3
+
4
+ export function extractFinalSummaryFromMessages(
5
+ messages: ChatMessage[] | null | undefined,
6
+ maxChars: number = 500,
7
+ ): string {
8
+ if (!Array.isArray(messages) || messages.length === 0) return '';
9
+
10
+ // Find last user-facing assistant message
11
+ for (let i = messages.length - 1; i >= 0; i--) {
12
+ const msg = messages[i];
13
+ if (!msg) continue;
14
+ const classification = classifyChatMessageVisibility(msg);
15
+ if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
16
+ const text = flattenContent(msg.content).trim();
17
+ if (text) return text.slice(0, maxChars);
18
+ }
19
+ }
20
+
21
+ // Fallback: last user-facing message of any role
22
+ for (let i = messages.length - 1; i >= 0; i--) {
23
+ const msg = messages[i];
24
+ if (!msg) continue;
25
+ const classification = classifyChatMessageVisibility(msg);
26
+ if (classification.isUserFacing) {
27
+ const text = flattenContent(msg.content).trim();
28
+ if (text) return text.slice(0, maxChars);
29
+ }
30
+ }
31
+
32
+ return '';
33
+ }
2
34
 
3
35
  export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
4
36