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.
- package/config/skills/agent/core/reply/SKILL.md +2 -0
- package/config/skills/agent/core/reply/execute.sh +15 -1
- package/config/skills/agent/core/reply/execute.test.sh +7 -0
- package/dist/backend/backend/src/constants.d.ts +64 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +64 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts +9 -0
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +23 -0
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +121 -3
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/briefing/briefing-cards.d.ts +99 -0
- package/dist/backend/backend/src/services/briefing/briefing-cards.d.ts.map +1 -0
- package/dist/backend/backend/src/services/briefing/briefing-cards.js +156 -0
- package/dist/backend/backend/src/services/briefing/briefing-cards.js.map +1 -0
- package/dist/backend/backend/src/services/briefing/briefing.service.d.ts +8 -0
- package/dist/backend/backend/src/services/briefing/briefing.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/briefing/briefing.service.js +31 -9
- package/dist/backend/backend/src/services/briefing/briefing.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +33 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +33 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +21 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js +37 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.js +4 -0
- package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.js.map +1 -1
- package/dist/backend/backend/src/services/drive/drive-agent.service.d.ts +228 -0
- package/dist/backend/backend/src/services/drive/drive-agent.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-agent.service.js +412 -0
- package/dist/backend/backend/src/services/drive/drive-agent.service.js.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-cloud.contract.d.ts +92 -0
- package/dist/backend/backend/src/services/drive/drive-cloud.contract.d.ts.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-cloud.contract.js +113 -0
- package/dist/backend/backend/src/services/drive/drive-cloud.contract.js.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-conversation.store.d.ts +86 -0
- package/dist/backend/backend/src/services/drive/drive-conversation.store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-conversation.store.js +102 -0
- package/dist/backend/backend/src/services/drive/drive-conversation.store.js.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-recall.d.ts +65 -0
- package/dist/backend/backend/src/services/drive/drive-recall.d.ts.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-recall.js +91 -0
- package/dist/backend/backend/src/services/drive/drive-recall.js.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-row.utils.d.ts +19 -0
- package/dist/backend/backend/src/services/drive/drive-row.utils.d.ts.map +1 -0
- package/dist/backend/backend/src/services/drive/drive-row.utils.js +20 -0
- package/dist/backend/backend/src/services/drive/drive-row.utils.js.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.service.js +14 -2
- package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +4 -0
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js +2 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +7 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/talk/talk-live-token.service.d.ts +23 -7
- package/dist/backend/backend/src/services/talk/talk-live-token.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/talk/talk-live-token.service.js +80 -40
- package/dist/backend/backend/src/services/talk/talk-live-token.service.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +64 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +64 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +33 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +33 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +21 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +37 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
- package/dist/cli/backend/src/services/drive/drive-row.utils.d.ts +19 -0
- package/dist/cli/backend/src/services/drive/drive-row.utils.d.ts.map +1 -0
- package/dist/cli/backend/src/services/drive/drive-row.utils.js +20 -0
- package/dist/cli/backend/src/services/drive/drive-row.utils.js.map +1 -0
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.js +14 -2
- package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +4 -0
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +7 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- 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
|