crewly 1.20.277 → 1.20.279

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 (96) hide show
  1. package/config/skills/agent/core/reply/SKILL.md +2 -0
  2. package/config/skills/agent/core/reply/execute.sh +15 -1
  3. package/config/skills/agent/core/reply/execute.test.sh +7 -0
  4. package/dist/backend/backend/src/constants.d.ts +64 -0
  5. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  6. package/dist/backend/backend/src/constants.js +64 -0
  7. package/dist/backend/backend/src/constants.js.map +1 -1
  8. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts +9 -0
  9. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +23 -0
  11. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
  12. package/dist/backend/backend/src/index.d.ts.map +1 -1
  13. package/dist/backend/backend/src/index.js +121 -3
  14. package/dist/backend/backend/src/index.js.map +1 -1
  15. package/dist/backend/backend/src/services/briefing/briefing-cards.d.ts +99 -0
  16. package/dist/backend/backend/src/services/briefing/briefing-cards.d.ts.map +1 -0
  17. package/dist/backend/backend/src/services/briefing/briefing-cards.js +156 -0
  18. package/dist/backend/backend/src/services/briefing/briefing-cards.js.map +1 -0
  19. package/dist/backend/backend/src/services/briefing/briefing.service.d.ts +8 -0
  20. package/dist/backend/backend/src/services/briefing/briefing.service.d.ts.map +1 -1
  21. package/dist/backend/backend/src/services/briefing/briefing.service.js +31 -9
  22. package/dist/backend/backend/src/services/briefing/briefing.service.js.map +1 -1
  23. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +33 -1
  24. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  25. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +33 -0
  26. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  27. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +21 -0
  28. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  29. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js +37 -0
  30. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  31. package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.d.ts.map +1 -1
  32. package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.js +4 -0
  33. package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.js.map +1 -1
  34. package/dist/backend/backend/src/services/drive/drive-agent.service.d.ts +228 -0
  35. package/dist/backend/backend/src/services/drive/drive-agent.service.d.ts.map +1 -0
  36. package/dist/backend/backend/src/services/drive/drive-agent.service.js +412 -0
  37. package/dist/backend/backend/src/services/drive/drive-agent.service.js.map +1 -0
  38. package/dist/backend/backend/src/services/drive/drive-cloud.contract.d.ts +92 -0
  39. package/dist/backend/backend/src/services/drive/drive-cloud.contract.d.ts.map +1 -0
  40. package/dist/backend/backend/src/services/drive/drive-cloud.contract.js +113 -0
  41. package/dist/backend/backend/src/services/drive/drive-cloud.contract.js.map +1 -0
  42. package/dist/backend/backend/src/services/drive/drive-conversation.store.d.ts +86 -0
  43. package/dist/backend/backend/src/services/drive/drive-conversation.store.d.ts.map +1 -0
  44. package/dist/backend/backend/src/services/drive/drive-conversation.store.js +102 -0
  45. package/dist/backend/backend/src/services/drive/drive-conversation.store.js.map +1 -0
  46. package/dist/backend/backend/src/services/drive/drive-recall.d.ts +65 -0
  47. package/dist/backend/backend/src/services/drive/drive-recall.d.ts.map +1 -0
  48. package/dist/backend/backend/src/services/drive/drive-recall.js +91 -0
  49. package/dist/backend/backend/src/services/drive/drive-recall.js.map +1 -0
  50. package/dist/backend/backend/src/services/drive/drive-row.utils.d.ts +19 -0
  51. package/dist/backend/backend/src/services/drive/drive-row.utils.d.ts.map +1 -0
  52. package/dist/backend/backend/src/services/drive/drive-row.utils.js +20 -0
  53. package/dist/backend/backend/src/services/drive/drive-row.utils.js.map +1 -0
  54. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  55. package/dist/backend/backend/src/services/open-items/open-items.service.js +14 -2
  56. package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
  57. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  58. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +4 -0
  59. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  60. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  61. package/dist/backend/backend/src/services/slack/slack-initializer.js +2 -1
  62. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  63. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  64. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +7 -1
  65. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  66. package/dist/backend/backend/src/services/talk/talk-live-token.service.d.ts +23 -7
  67. package/dist/backend/backend/src/services/talk/talk-live-token.service.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/talk/talk-live-token.service.js +80 -40
  69. package/dist/backend/backend/src/services/talk/talk-live-token.service.js.map +1 -1
  70. package/dist/backend/build-info.json +2 -2
  71. package/dist/cli/backend/src/constants.d.ts +64 -0
  72. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  73. package/dist/cli/backend/src/constants.js +64 -0
  74. package/dist/cli/backend/src/constants.js.map +1 -1
  75. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +33 -1
  76. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  77. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +33 -0
  78. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  79. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +21 -0
  80. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  81. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +37 -0
  82. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  83. package/dist/cli/backend/src/services/drive/drive-row.utils.d.ts +19 -0
  84. package/dist/cli/backend/src/services/drive/drive-row.utils.d.ts.map +1 -0
  85. package/dist/cli/backend/src/services/drive/drive-row.utils.js +20 -0
  86. package/dist/cli/backend/src/services/drive/drive-row.utils.js.map +1 -0
  87. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  88. package/dist/cli/backend/src/services/open-items/open-items.service.js +14 -2
  89. package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -1
  90. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  91. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +4 -0
  92. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  93. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  94. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +7 -1
  95. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  96. package/package.json +1 -1
@@ -0,0 +1,228 @@
1
+ /**
2
+ * DriveAgentService — the machine side of Drive mode
3
+ * (specs/2026-10-08-drive-mode.md §7). Crewly Cloud hosts the session (the
4
+ * voice, the targets, the inbox); this machine does what only it can:
5
+ *
6
+ * - `deliver` (push `op:'deliver'`): fetch the owner's words from Cloud with
7
+ * this machine's token, record them as the owner's voice turn in the
8
+ * agent's DM (a team → its lead's DM; a channel → a thread in its room)
9
+ * and deliver them, with a note: the owner is listening, answer with
10
+ * `reply --drive <session>`, not in Slack;
11
+ * - `agentReply` (`reply --drive`): record the answer in that conversation
12
+ * (kept off Slack) and send it to Cloud for the phone;
13
+ * - `noteChatTurn`: a plain `reply` in a Drive conversation still reaches
14
+ * the phone;
15
+ * - `recall` (push `op:'recall'`): read back an agent's recent messages to
16
+ * the owner;
17
+ * - `end` (push `op:'end'`): ask each agent with an open conversation for
18
+ * ONE recap; the recap (`reply --drive … --recap`) is posted in the
19
+ * owner's DM with the agent, or in the channel, and closes the
20
+ * conversation: owner-message tracking stops, and the open-items tracker
21
+ * takes it as delivered unless it names a next step.
22
+ *
23
+ * Nothing the owner or the agents say is logged.
24
+ *
25
+ * @module services/drive/drive-agent.service
26
+ */
27
+ import { type ComponentLogger } from '../core/logger.service.js';
28
+ import type { OwnerTurnMark } from '../briefing/briefing-cards.js';
29
+ import type { IncomingMessage } from '../cloud/cloud-sync.types.js';
30
+ import { type RecallFeedMessage } from './drive-recall.js';
31
+ import { type DriveDeliveryFetch, type DriveTargetKind } from './drive-cloud.contract.js';
32
+ import type { DriveConversationStore, DriveLocalConversation } from './drive-conversation.store.js';
33
+ /** A Drive mode failure with an HTTP status (for the agent's `reply`). */
34
+ export declare class DriveAgentError extends Error {
35
+ readonly status: number;
36
+ constructor(status: number, message: string);
37
+ }
38
+ /** Emitter of relay messages (CloudSyncService). */
39
+ export interface DriveRelaySource {
40
+ on(event: 'message', listener: (msg: IncomingMessage) => void): unknown;
41
+ off(event: 'message', listener: (msg: IncomingMessage) => void): unknown;
42
+ }
43
+ /** The Cloud session (CloudClientService). */
44
+ export interface DriveCloud {
45
+ getToken(): string | null;
46
+ getCloudUrl(): string | null;
47
+ tryRefreshToken?(): Promise<boolean>;
48
+ }
49
+ /** A chat-v2 message the fallback pickup looks at. */
50
+ export interface DriveChatTurn {
51
+ id: string;
52
+ channelId: string;
53
+ threadId?: string;
54
+ senderType: string;
55
+ senderId: string;
56
+ content: string;
57
+ createdAt: number;
58
+ metadata?: Record<string, unknown>;
59
+ }
60
+ /** Collaborators. */
61
+ export interface DriveAgentDeps {
62
+ source?: DriveRelaySource;
63
+ cloud: DriveCloud;
64
+ /** This machine's Cloud instance id */
65
+ identity: () => Promise<{
66
+ instanceId: string;
67
+ }>;
68
+ /**
69
+ * Record the owner's words as an owner turn and deliver them: the agent's
70
+ * DM (agent / team lead), or the room of a team channel (a new thread on
71
+ * the first turn, then that thread). Text carries the Drive mode note.
72
+ */
73
+ deliverOwnerTurn: (input: {
74
+ kind: DriveTargetKind;
75
+ agentSession: string;
76
+ slackChannelId?: string;
77
+ channelId?: string;
78
+ threadId?: string;
79
+ text: string;
80
+ }) => Promise<{
81
+ channelId: string;
82
+ threadId?: string;
83
+ }>;
84
+ /** Record an agent's spoken answer in its conversation (chat-v2, kept off Slack) */
85
+ recordAgentTurn: (input: {
86
+ agentSession: string;
87
+ channelId: string;
88
+ threadId?: string;
89
+ text: string;
90
+ interim: boolean;
91
+ sessionId: string;
92
+ }) => Promise<void>;
93
+ /** A harness note to an agent (the recap request) */
94
+ notifyAgent: (agentSession: string, text: string) => Promise<boolean>;
95
+ /** Post the recap where the conversation belongs (the DM with the owner / the channel), to Slack too */
96
+ postRecap: (input: {
97
+ conversation: DriveLocalConversation;
98
+ agentSession: string;
99
+ text: string;
100
+ nextStep: boolean;
101
+ }) => Promise<{
102
+ where: string;
103
+ }>;
104
+ /** chat-v2 rows since a time and the owner's last turn per conversation (recall) */
105
+ ownerFeed: (sinceMs: number) => Promise<{
106
+ messages: RecallFeedMessage[];
107
+ ownerTurns: OwnerTurnMark[];
108
+ }>;
109
+ /** Stop owner-message tracking for a closed conversation (no more nudges) */
110
+ closeTracking?: (agentSession: string, channelId: string) => void;
111
+ /** Whether an agent exists here */
112
+ agentExists?: (agentSession: string) => Promise<boolean>;
113
+ store: DriveConversationStore;
114
+ fetchImpl?: (input: string, init: RequestInit) => Promise<Response>;
115
+ now?: () => Date;
116
+ logger?: ComponentLogger;
117
+ }
118
+ /**
119
+ * Capabilities to advertise to Cloud (heartbeat and uploads).
120
+ *
121
+ * @returns `['drive_message']` while the handler runs, else `[]`
122
+ */
123
+ export declare function driveCapabilities(): string[];
124
+ /** The machine side of Drive mode — see module docs. */
125
+ export declare class DriveAgentService {
126
+ private readonly deps;
127
+ private readonly logger;
128
+ private readonly now;
129
+ private readonly fetchImpl;
130
+ private readonly inFlight;
131
+ private readonly listener;
132
+ private started;
133
+ /**
134
+ * @param deps - Collaborators
135
+ */
136
+ constructor(deps: DriveAgentDeps);
137
+ /** Subscribe to relay messages and advertise the capability. Idempotent. */
138
+ start(): void;
139
+ /** Unsubscribe. */
140
+ stop(): void;
141
+ /**
142
+ * One relay message; anything but a Drive mode push is ignored. Never throws.
143
+ *
144
+ * @param msg - Relay message
145
+ * @returns What was done
146
+ */
147
+ handle(msg: IncomingMessage): Promise<'ignored' | 'duplicate' | 'done' | 'failed'>;
148
+ private deliver;
149
+ /**
150
+ * Deliver the owner's words (already fetched from Cloud).
151
+ *
152
+ * @param d - Delivery
153
+ * @returns The local conversation
154
+ */
155
+ deliverFetched(d: DriveDeliveryFetch): Promise<DriveLocalConversation>;
156
+ /**
157
+ * An agent's answer for the owner's phone (`reply --drive <session>`), or
158
+ * its recap (`--recap`).
159
+ *
160
+ * @param agentSession - Who answers
161
+ * @param sessionId - Drive mode session
162
+ * @param input - `{ text, interim, recap }`
163
+ * @returns Which conversation, and whether it closed
164
+ * @throws DriveAgentError 400 / 404 (no such conversation) / 502 (Cloud unreachable)
165
+ */
166
+ agentReply(agentSession: string, sessionId: string, input: {
167
+ text: string;
168
+ interim: boolean;
169
+ recap: boolean;
170
+ }): Promise<{
171
+ conversationId: string;
172
+ closed: boolean;
173
+ }>;
174
+ /**
175
+ * Fallback: an agent answered a Drive mode conversation with a plain
176
+ * `reply` instead of `reply --drive`. Its words still reach the phone. Call
177
+ * for every chat-v2 message. Never throws.
178
+ *
179
+ * @param dto - The chat message
180
+ */
181
+ noteChatTurn(dto: DriveChatTurn): Promise<void>;
182
+ private recap;
183
+ /** Send an answer to Cloud and keep the local transcript. */
184
+ private forward;
185
+ private recall;
186
+ private end;
187
+ private cloudGet;
188
+ private cloudPost;
189
+ private cloudCall;
190
+ }
191
+ /**
192
+ * The owner's words as delivered: the Drive mode note, then the words.
193
+ *
194
+ * @param sessionId - Session
195
+ * @param kind - Target kind
196
+ * @param name - Target name
197
+ * @param text - The owner's words
198
+ * @returns Message text
199
+ */
200
+ export declare function ownerTurnText(sessionId: string, kind: DriveTargetKind, name: string, text: string): string;
201
+ /**
202
+ * The harness note asking an agent for its recap.
203
+ *
204
+ * @param c - Conversation
205
+ * @returns Note text
206
+ */
207
+ export declare function recapRequest(c: DriveLocalConversation): string;
208
+ /**
209
+ * The conversation an agent's reply belongs to: one it answers or is a
210
+ * member of; a recap goes to one whose recap was asked for; otherwise the one
211
+ * waiting for an answer, newest first.
212
+ *
213
+ * @param all - Local conversations
214
+ * @param sessionId - Session
215
+ * @param agentSession - Who replies
216
+ * @param recap - It is the recap
217
+ * @returns Conversation or undefined
218
+ */
219
+ export declare function pickConversation(all: readonly DriveLocalConversation[], sessionId: string, agentSession: string, recap: boolean): DriveLocalConversation | undefined;
220
+ /** @returns The running service, or null */
221
+ export declare function getDriveAgentService(): DriveAgentService | null;
222
+ /**
223
+ * Install the service (boot, tests).
224
+ *
225
+ * @param service - Service or null
226
+ */
227
+ export declare function setDriveAgentService(service: DriveAgentService | null): void;
228
+ //# sourceMappingURL=drive-agent.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drive-agent.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/drive/drive-agent.service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,412 @@
1
+ /**
2
+ * DriveAgentService — the machine side of Drive mode
3
+ * (specs/2026-10-08-drive-mode.md §7). Crewly Cloud hosts the session (the
4
+ * voice, the targets, the inbox); this machine does what only it can:
5
+ *
6
+ * - `deliver` (push `op:'deliver'`): fetch the owner's words from Cloud with
7
+ * this machine's token, record them as the owner's voice turn in the
8
+ * agent's DM (a team → its lead's DM; a channel → a thread in its room)
9
+ * and deliver them, with a note: the owner is listening, answer with
10
+ * `reply --drive <session>`, not in Slack;
11
+ * - `agentReply` (`reply --drive`): record the answer in that conversation
12
+ * (kept off Slack) and send it to Cloud for the phone;
13
+ * - `noteChatTurn`: a plain `reply` in a Drive conversation still reaches
14
+ * the phone;
15
+ * - `recall` (push `op:'recall'`): read back an agent's recent messages to
16
+ * the owner;
17
+ * - `end` (push `op:'end'`): ask each agent with an open conversation for
18
+ * ONE recap; the recap (`reply --drive … --recap`) is posted in the
19
+ * owner's DM with the agent, or in the channel, and closes the
20
+ * conversation: owner-message tracking stops, and the open-items tracker
21
+ * takes it as delivered unless it names a next step.
22
+ *
23
+ * Nothing the owner or the agents say is logged.
24
+ *
25
+ * @module services/drive/drive-agent.service
26
+ */
27
+ import { DRIVE_CONSTANTS } from '../../constants.js';
28
+ import { LoggerService } from '../core/logger.service.js';
29
+ import { clip, speakable } from '../briefing/briefing.utils.js';
30
+ import { isDriveModeRow } from './drive-row.utils.js';
31
+ import { messagesToOwner, pickRecall } from './drive-recall.js';
32
+ import { parseDeliveryFetch, parseDriveRelayData, parseRecallFetch, parseStateFetch, } from './drive-cloud.contract.js';
33
+ const C = DRIVE_CONSTANTS;
34
+ /** A recap that says nothing is left to do (the conversation is fully closed). */
35
+ const NOTHING_PENDING = /nothing (is )?(pending|left|open)|no next step|next:\s*(none|nothing)\b|没有待办|无待办|没有后续|无后续|不需要后续|没有了/i;
36
+ /** A Drive mode failure with an HTTP status (for the agent's `reply`). */
37
+ export class DriveAgentError extends Error {
38
+ status;
39
+ constructor(status, message) {
40
+ super(message);
41
+ this.status = status;
42
+ this.name = 'DriveAgentError';
43
+ }
44
+ }
45
+ let active = false;
46
+ /**
47
+ * Capabilities to advertise to Cloud (heartbeat and uploads).
48
+ *
49
+ * @returns `['drive_message']` while the handler runs, else `[]`
50
+ */
51
+ export function driveCapabilities() {
52
+ return active ? [C.CAPABILITY] : [];
53
+ }
54
+ /** The machine side of Drive mode — see module docs. */
55
+ export class DriveAgentService {
56
+ deps;
57
+ logger;
58
+ now;
59
+ fetchImpl;
60
+ inFlight = new Set();
61
+ listener = (msg) => {
62
+ void this.handle(msg);
63
+ };
64
+ started = false;
65
+ /**
66
+ * @param deps - Collaborators
67
+ */
68
+ constructor(deps) {
69
+ this.deps = deps;
70
+ this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('DriveAgent');
71
+ this.now = deps.now ?? (() => new Date());
72
+ this.fetchImpl = deps.fetchImpl ?? ((input, init) => fetch(input, init));
73
+ }
74
+ /** Subscribe to relay messages and advertise the capability. Idempotent. */
75
+ start() {
76
+ if (this.started)
77
+ return;
78
+ this.started = true;
79
+ this.deps.source?.on('message', this.listener);
80
+ active = true;
81
+ }
82
+ /** Unsubscribe. */
83
+ stop() {
84
+ if (!this.started)
85
+ return;
86
+ this.started = false;
87
+ this.deps.source?.off('message', this.listener);
88
+ active = false;
89
+ }
90
+ /**
91
+ * One relay message; anything but a Drive mode push is ignored. Never throws.
92
+ *
93
+ * @param msg - Relay message
94
+ * @returns What was done
95
+ */
96
+ async handle(msg) {
97
+ if (msg?.type !== C.RELAY_MESSAGE_TYPE)
98
+ return 'ignored';
99
+ const data = parseDriveRelayData(msg.payload);
100
+ if (!data)
101
+ return 'ignored';
102
+ const key = `${data.op}:${data.sessionId}:${data.id ?? ''}`;
103
+ if (this.inFlight.has(key))
104
+ return 'duplicate';
105
+ this.inFlight.add(key);
106
+ try {
107
+ const { instanceId } = await this.deps.identity();
108
+ if (data.instanceId !== instanceId)
109
+ return 'ignored';
110
+ if (data.op === 'deliver')
111
+ await this.deliver(data, instanceId);
112
+ else if (data.op === 'recall')
113
+ await this.recall(data, instanceId);
114
+ else
115
+ await this.end(data, instanceId);
116
+ return 'done';
117
+ }
118
+ catch (error) {
119
+ this.logger.warn('Drive mode push failed', { op: data.op, sessionId: data.sessionId, error: error instanceof Error ? error.message : String(error) });
120
+ return 'failed';
121
+ }
122
+ finally {
123
+ this.inFlight.delete(key);
124
+ }
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // Owner → agent
128
+ // ---------------------------------------------------------------------------
129
+ async deliver(data, instanceId) {
130
+ const raw = await this.cloudGet(`/${data.sessionId}/machine/deliveries/${encodeURIComponent(data.id)}?instanceId=${encodeURIComponent(instanceId)}`);
131
+ const d = parseDeliveryFetch(raw);
132
+ if (!d)
133
+ throw new Error('Cloud sent a malformed delivery');
134
+ await this.deliverFetched(d);
135
+ }
136
+ /**
137
+ * Deliver the owner's words (already fetched from Cloud).
138
+ *
139
+ * @param d - Delivery
140
+ * @returns The local conversation
141
+ */
142
+ async deliverFetched(d) {
143
+ if (this.deps.agentExists && !(await this.deps.agentExists(d.target.agentSession))) {
144
+ throw new Error(`No agent ${d.target.agentSession} on this machine`);
145
+ }
146
+ const existing = await this.deps.store.get(d.sessionId, d.conversationId);
147
+ const where = await this.deps.deliverOwnerTurn({
148
+ kind: d.target.kind,
149
+ agentSession: d.target.agentSession,
150
+ ...(d.target.slackChannelId ? { slackChannelId: d.target.slackChannelId } : {}),
151
+ ...(existing ? { channelId: existing.channelId, ...(existing.threadId ? { threadId: existing.threadId } : {}) } : {}),
152
+ text: ownerTurnText(d.sessionId, d.target.kind, d.target.name, d.text),
153
+ });
154
+ const at = this.now().toISOString();
155
+ const conv = await this.deps.store.update(d.sessionId, d.conversationId, (cur) => {
156
+ const c = cur ?? {
157
+ sessionId: d.sessionId,
158
+ conversationId: d.conversationId,
159
+ kind: d.target.kind,
160
+ targetName: d.target.name,
161
+ agentSession: d.target.agentSession,
162
+ ...(d.target.members ? { members: d.target.members } : {}),
163
+ channelId: where.channelId,
164
+ ...(where.threadId ? { threadId: where.threadId } : {}),
165
+ startedAt: at,
166
+ turns: [],
167
+ };
168
+ if (!c.threadId && where.threadId)
169
+ c.threadId = where.threadId;
170
+ c.turns.push({ from: 'owner', name: 'owner', text: d.text, at });
171
+ return c;
172
+ }, this.now().getTime());
173
+ this.logger.info('Drive mode: owner words delivered', { sessionId: d.sessionId, conversationId: d.conversationId, kind: d.target.kind, agent: d.target.agentSession });
174
+ return conv;
175
+ }
176
+ // ---------------------------------------------------------------------------
177
+ // Agent → owner
178
+ // ---------------------------------------------------------------------------
179
+ /**
180
+ * An agent's answer for the owner's phone (`reply --drive <session>`), or
181
+ * its recap (`--recap`).
182
+ *
183
+ * @param agentSession - Who answers
184
+ * @param sessionId - Drive mode session
185
+ * @param input - `{ text, interim, recap }`
186
+ * @returns Which conversation, and whether it closed
187
+ * @throws DriveAgentError 400 / 404 (no such conversation) / 502 (Cloud unreachable)
188
+ */
189
+ async agentReply(agentSession, sessionId, input) {
190
+ if (!C.SESSION_ID_PATTERN.test(sessionId))
191
+ throw new DriveAgentError(400, 'That is not a Drive mode session id (drv_…).');
192
+ const text = (input.text ?? '').trim();
193
+ if (!text)
194
+ throw new DriveAgentError(400, 'Reply text is required.');
195
+ if (text.length > C.TEXT_MAX_CHARS)
196
+ throw new DriveAgentError(400, `Too long for Drive mode (max ${C.TEXT_MAX_CHARS} characters) — say it short; details go in a normal reply.`);
197
+ const conv = pickConversation(await this.deps.store.list(), sessionId, agentSession, input.recap);
198
+ if (!conv)
199
+ throw new DriveAgentError(404, 'You have no open Drive mode conversation in that session. Answer the usual way (reply).');
200
+ if (input.recap)
201
+ return this.recap(conv, agentSession, text);
202
+ await this.deps.recordAgentTurn({ agentSession, channelId: conv.channelId, ...(conv.threadId ? { threadId: conv.threadId } : {}), text, interim: input.interim, sessionId });
203
+ await this.forward(conv, agentSession, text, { interim: input.interim });
204
+ return { conversationId: conv.conversationId, closed: false };
205
+ }
206
+ /**
207
+ * Fallback: an agent answered a Drive mode conversation with a plain
208
+ * `reply` instead of `reply --drive`. Its words still reach the phone. Call
209
+ * for every chat-v2 message. Never throws.
210
+ *
211
+ * @param dto - The chat message
212
+ */
213
+ async noteChatTurn(dto) {
214
+ try {
215
+ if (dto.senderType !== 'agent' || isDriveModeRow(dto) || !dto.content.trim())
216
+ return;
217
+ const open = (await this.deps.store.list()).filter((c) => !c.closedAt && c.channelId === dto.channelId && (!c.threadId || dto.threadId === c.threadId));
218
+ for (const c of open.reverse()) {
219
+ if (dto.senderId !== c.agentSession && !(c.members ?? []).includes(dto.senderId))
220
+ continue;
221
+ const lastOwner = [...c.turns].reverse().find((t) => t.from === 'owner');
222
+ const lastAgent = [...c.turns].reverse().find((t) => t.from === 'agent' && !t.interim);
223
+ if (!lastOwner || (lastAgent && Date.parse(lastAgent.at) >= Date.parse(lastOwner.at)))
224
+ continue;
225
+ if (dto.createdAt < Date.parse(lastOwner.at))
226
+ continue;
227
+ await this.forward(c, dto.senderId, dto.content, { interim: dto.metadata?.interim === true });
228
+ this.logger.info('Drive mode: picked up a plain reply', { sessionId: c.sessionId, conversationId: c.conversationId });
229
+ return;
230
+ }
231
+ }
232
+ catch (err) {
233
+ this.logger.warn('Drive mode: plain reply not forwarded', { error: err instanceof Error ? err.message : String(err) });
234
+ }
235
+ }
236
+ async recap(conv, agentSession, text) {
237
+ const nextStep = !NOTHING_PENDING.test(text);
238
+ const posted = await this.deps.postRecap({ conversation: conv, agentSession, text, nextStep });
239
+ await this.forward(conv, agentSession, text, { recap: true, nextStep }).catch((err) => {
240
+ // The recap is posted where it belongs; Cloud learning of it is best effort.
241
+ this.logger.warn('Drive mode: recap not reported to Cloud', { error: err instanceof Error ? err.message : String(err) });
242
+ });
243
+ await this.deps.store.update(conv.sessionId, conv.conversationId, (cur) => (cur ? { ...cur, closedAt: this.now().toISOString() } : null), this.now().getTime());
244
+ this.deps.closeTracking?.(conv.agentSession, conv.channelId);
245
+ this.logger.info('Drive mode recap posted; conversation closed', { sessionId: conv.sessionId, conversationId: conv.conversationId, where: posted.where, nextStep });
246
+ return { conversationId: conv.conversationId, closed: true };
247
+ }
248
+ /** Send an answer to Cloud and keep the local transcript. */
249
+ async forward(conv, agentSession, text, opts) {
250
+ const { instanceId } = await this.deps.identity();
251
+ await this.cloudPost(`/${conv.sessionId}/replies`, {
252
+ instanceId,
253
+ conversationId: conv.conversationId,
254
+ agentSession,
255
+ text: text.slice(0, C.TEXT_MAX_CHARS),
256
+ ...(opts.interim ? { interim: true } : {}),
257
+ ...(opts.recap ? { recap: true, nextStep: !!opts.nextStep } : {}),
258
+ });
259
+ if (opts.recap)
260
+ return;
261
+ const at = this.now().toISOString();
262
+ await this.deps.store.update(conv.sessionId, conv.conversationId, (cur) => (cur ? { ...cur, turns: [...cur.turns, { from: 'agent', name: agentSession, text: clip(speakable(text), 600), at, ...(opts.interim ? { interim: true } : {}) }] } : null), this.now().getTime());
263
+ }
264
+ // ---------------------------------------------------------------------------
265
+ // Recall / end
266
+ // ---------------------------------------------------------------------------
267
+ async recall(data, instanceId) {
268
+ const path = `/${data.sessionId}/machine/recalls/${encodeURIComponent(data.id)}`;
269
+ const req = parseRecallFetch(await this.cloudGet(`${path}?instanceId=${encodeURIComponent(instanceId)}`));
270
+ if (!req)
271
+ throw new Error('Cloud sent a malformed recall');
272
+ const feed = await this.deps.ownerFeed(this.now().getTime() - C.RECALL_WINDOW_MS);
273
+ const messages = pickRecall(messagesToOwner(feed.messages, feed.ownerTurns, req.agentSessions), req.hint);
274
+ await this.cloudPost(path, { instanceId, messages });
275
+ }
276
+ async end(data, instanceId) {
277
+ const state = parseStateFetch(await this.cloudGet(`/${data.sessionId}/machine/state?instanceId=${encodeURIComponent(instanceId)}`));
278
+ if (!state || !state.ended)
279
+ return;
280
+ const wanted = new Set(state.conversations.map((c) => c.conversationId));
281
+ const local = (await this.deps.store.list()).filter((c) => c.sessionId === data.sessionId && wanted.has(c.conversationId) && !c.closedAt && !c.recapAskedAt);
282
+ for (const c of local) {
283
+ const ok = await this.deps.notifyAgent(c.agentSession, recapRequest(c)).catch(() => false);
284
+ if (!ok) {
285
+ this.logger.warn('Drive mode: could not ask for a recap', { sessionId: c.sessionId, conversationId: c.conversationId, agent: c.agentSession });
286
+ continue;
287
+ }
288
+ await this.deps.store.update(c.sessionId, c.conversationId, (cur) => (cur ? { ...cur, recapAskedAt: this.now().toISOString() } : null), this.now().getTime());
289
+ }
290
+ }
291
+ // ---------------------------------------------------------------------------
292
+ // Cloud
293
+ // ---------------------------------------------------------------------------
294
+ async cloudGet(path) {
295
+ return this.cloudCall('GET', path);
296
+ }
297
+ async cloudPost(path, body) {
298
+ return this.cloudCall('POST', path, body);
299
+ }
300
+ async cloudCall(method, path, body, refreshed = false) {
301
+ const base = this.deps.cloud.getCloudUrl();
302
+ const token = this.deps.cloud.getToken();
303
+ if (!base || !token)
304
+ throw new DriveAgentError(503, 'This machine is not signed in to Crewly Cloud.');
305
+ let res;
306
+ try {
307
+ res = await this.fetchImpl(`${base.replace(/\/+$/, '')}${C.CLOUD_BASE_PATH}${path}`, {
308
+ method,
309
+ headers: { Authorization: `Bearer ${token}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
310
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
311
+ signal: AbortSignal.timeout(C.REQUEST_TIMEOUT_MS),
312
+ });
313
+ }
314
+ catch {
315
+ throw new DriveAgentError(502, 'Could not reach Crewly Cloud — try again in a moment.');
316
+ }
317
+ if (res.status === 401 && !refreshed && this.deps.cloud.tryRefreshToken && (await this.deps.cloud.tryRefreshToken())) {
318
+ return this.cloudCall(method, path, body, true);
319
+ }
320
+ let json = {};
321
+ try {
322
+ json = (await res.json());
323
+ }
324
+ catch {
325
+ json = {};
326
+ }
327
+ if (!res.ok || json.success === false) {
328
+ const why = typeof json.error === 'string' ? json.error : `Crewly Cloud answered ${res.status}`;
329
+ throw new DriveAgentError(res.status >= 500 ? 502 : res.status, why);
330
+ }
331
+ return json.data;
332
+ }
333
+ }
334
+ // ---------------------------------------------------------------------------
335
+ // Pure helpers
336
+ // ---------------------------------------------------------------------------
337
+ /**
338
+ * The owner's words as delivered: the Drive mode note, then the words.
339
+ *
340
+ * @param sessionId - Session
341
+ * @param kind - Target kind
342
+ * @param name - Target name
343
+ * @param text - The owner's words
344
+ * @returns Message text
345
+ */
346
+ export function ownerTurnText(sessionId, kind, name, text) {
347
+ const forTeam = kind === 'team' ? ` This is for the ${name} team: answer for the team, or hand it on.` : '';
348
+ return (`[Drive mode · session ${sessionId}] The owner is driving and LISTENING on the phone, not reading.${forTeam} ` +
349
+ `Answer with: reply --drive ${sessionId} "<1–3 short spoken sentences>". No URLs, tables, code or ids. ` +
350
+ 'If it needs work, say so in one sentence first (reply --drive … --interim) and answer when done. Not in Slack.\n\n' +
351
+ text);
352
+ }
353
+ /**
354
+ * The harness note asking an agent for its recap.
355
+ *
356
+ * @param c - Conversation
357
+ * @returns Note text
358
+ */
359
+ export function recapRequest(c) {
360
+ const where = c.kind === 'channel' ? c.targetName : c.kind === 'team' ? `your DM with the owner (for the ${c.targetName} team)` : 'your DM with the owner';
361
+ const transcript = c.turns
362
+ .slice(-12)
363
+ .map((t) => `${t.from === 'owner' ? 'Owner' : t.name}: ${clip(t.text, 400)}`)
364
+ .join('\n');
365
+ return [
366
+ `[Drive mode ended · session ${c.sessionId}] The owner ended the Drive mode conversation with you. Post ONE recap now, for reading:`,
367
+ `reply --drive ${c.sessionId} --recap "Drive mode recap — you said …; I did / answered …; next: … (or: nothing pending / waiting on X)"`,
368
+ `It is posted in ${where}. After it the conversation is closed: no follow-ups or nudges unless the recap names a next step.`,
369
+ '',
370
+ 'The conversation:',
371
+ transcript,
372
+ ].join('\n');
373
+ }
374
+ /**
375
+ * The conversation an agent's reply belongs to: one it answers or is a
376
+ * member of; a recap goes to one whose recap was asked for; otherwise the one
377
+ * waiting for an answer, newest first.
378
+ *
379
+ * @param all - Local conversations
380
+ * @param sessionId - Session
381
+ * @param agentSession - Who replies
382
+ * @param recap - It is the recap
383
+ * @returns Conversation or undefined
384
+ */
385
+ export function pickConversation(all, sessionId, agentSession, recap) {
386
+ const mine = all.filter((c) => c.sessionId === sessionId && !c.closedAt && (c.agentSession === agentSession || (c.members ?? []).includes(agentSession)));
387
+ const waiting = (c) => {
388
+ const lastOwner = [...c.turns].reverse().find((t) => t.from === 'owner');
389
+ const lastAgent = [...c.turns].reverse().find((t) => t.from === 'agent' && !t.interim);
390
+ return !!lastOwner && (!lastAgent || Date.parse(lastAgent.at) < Date.parse(lastOwner.at));
391
+ };
392
+ const pref = recap ? mine.filter((c) => c.recapAskedAt) : mine.filter(waiting);
393
+ const pool = pref.length > 0 ? pref : mine;
394
+ const own = pool.filter((c) => c.agentSession === agentSession);
395
+ const list = own.length > 0 ? own : pool;
396
+ const lastAt = (c) => Date.parse(c.turns[c.turns.length - 1]?.at ?? c.startedAt);
397
+ return [...list].sort((a, b) => lastAt(b) - lastAt(a))[0];
398
+ }
399
+ let instance = null;
400
+ /** @returns The running service, or null */
401
+ export function getDriveAgentService() {
402
+ return instance;
403
+ }
404
+ /**
405
+ * Install the service (boot, tests).
406
+ *
407
+ * @param service - Service or null
408
+ */
409
+ export function setDriveAgentService(service) {
410
+ instance = service;
411
+ }
412
+ //# sourceMappingURL=drive-agent.service.js.map