vibe-coding-master 0.2.8 → 0.2.10
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/README.md +31 -9
- package/dist/backend/gateway/gateway-command-parser.js +4 -0
- package/dist/backend/gateway/gateway-service.js +263 -32
- package/dist/backend/gateway/gateway-settings-service.js +34 -0
- package/dist/backend/services/message-service.js +63 -2
- package/dist-frontend/assets/index-DC9-SB7F.css +32 -0
- package/dist-frontend/assets/{index-DHuS-DYr.js → index-DDrcJLOG.js} +37 -37
- package/dist-frontend/index.html +2 -2
- package/docs/gateway-design.md +40 -6
- package/docs/product-design.md +11 -1
- package/package.json +1 -1
- package/dist-frontend/assets/index-7lq6YPCq.css +0 -32
package/README.md
CHANGED
|
@@ -210,7 +210,7 @@ The left sidebar is intentionally compact and collapsible:
|
|
|
210
210
|
- `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
|
|
211
211
|
- `Connected Repository`: connected base repo path, branch, upstream/ahead-behind status, commit hash, working tree state, and a `Pull` button.
|
|
212
212
|
- `Settings`: `Theme`, `Flow pause alert`, `Try alert`, `Messages`, and `Events`.
|
|
213
|
-
- `Gateway`: Weixin iLink binding, Gateway on/off, Gateway translation, QR login
|
|
213
|
+
- `Gateway`: Weixin iLink binding, Gateway on/off, Gateway translation, and QR login.
|
|
214
214
|
- `VCM Harness`: fixed-install status, bootstrap completion checks, and the bootstrap terminal when one is running.
|
|
215
215
|
- `New Task`: one `task name` input.
|
|
216
216
|
- `Tasks`: task list and task status.
|
|
@@ -236,6 +236,8 @@ Gateway rules:
|
|
|
236
236
|
- One phone identity binds to one desktop VCM instance.
|
|
237
237
|
- The phone can manage projects and tasks available to that desktop VCM instance.
|
|
238
238
|
- When the desktop UI has an active task selected, Gateway uses that task automatically.
|
|
239
|
+
- After binding, VCM keeps a lightweight Gateway connection for `/start` and read-only commands even when the `Gateway` toggle is off.
|
|
240
|
+
- VCM caches the latest PM reply for each task locally, so `/start` can immediately return the current task's latest PM status when available.
|
|
239
241
|
- Plain text messages go only to the current task's `project-manager`.
|
|
240
242
|
- Gateway never sends directly to `architect`, `coder`, or `reviewer`.
|
|
241
243
|
- Gateway credentials and audit logs stay in local app state, not in connected repositories.
|
|
@@ -254,12 +256,12 @@ Gateway state is stored locally under:
|
|
|
254
256
|
3. Click `Start QR Login`.
|
|
255
257
|
4. VCM opens a global `Weixin Gateway Login` dialog with a QR code.
|
|
256
258
|
5. Scan the QR code with Weixin and confirm login on the phone.
|
|
257
|
-
6. Click `
|
|
259
|
+
6. Click `Confirm` in the dialog.
|
|
258
260
|
7. After binding succeeds, close the dialog and turn `Gateway` on in the sidebar.
|
|
259
261
|
|
|
260
|
-
`Start QR Login` creates a new Tencent iLink QR login session.
|
|
262
|
+
`Start QR Login` is shown only when Gateway is not bound. It creates a new Tencent iLink QR login session and opens the QR dialog. The dialog `Confirm` button asks iLink whether the QR code has been scanned and confirmed. After binding succeeds, the sidebar shows `Reset Binding` instead of `Start QR Login`.
|
|
261
263
|
|
|
262
|
-
The `Gateway` toggle is disabled until a QR login has produced a usable iLink token. `Reset Binding` clears the stored token and bound Weixin identity so the desktop VCM can bind again.
|
|
264
|
+
The `Gateway` toggle is disabled until a QR login has produced a usable iLink token. After binding, VCM keeps receiving `/help`, `/start`, `/status`, `/projects`, and `/tasks` even when the toggle is off. Turning `Gateway` on, either from desktop or by sending `/start`, enables PM messages, task-changing commands, and PM reply push. `Reset Binding` clears the stored token and bound Weixin identity so the desktop VCM can bind again.
|
|
263
265
|
|
|
264
266
|
When Gateway is turned on, VCM automatically turns off the browser `Flow pause alert` and disables `Try alert`. Gateway becomes the notification path, so the browser should not show blocking flow-pause dialogs while the user is managing the task from Weixin.
|
|
265
267
|
|
|
@@ -273,15 +275,30 @@ When Gateway translation is on:
|
|
|
273
275
|
- The prompt sent to PM includes only the translated English text with a `[VCM Gateway]` marker.
|
|
274
276
|
- The original Chinese text is not included in the PM prompt.
|
|
275
277
|
- PM replies are translated back to Chinese before VCM sends them to Weixin.
|
|
278
|
+
- If PM reply translation fails or times out, VCM sends a translation failure notice instead of the English source. The user can send `/retry` to retry the latest failed Gateway output translation.
|
|
276
279
|
|
|
277
280
|
When Gateway translation is off, plain Weixin text is sent to PM as-is.
|
|
278
281
|
|
|
279
282
|
### Commands
|
|
280
283
|
|
|
281
|
-
After Gateway is bound
|
|
284
|
+
After Gateway is bound, send commands in the bound Weixin DM.
|
|
285
|
+
|
|
286
|
+
When `Gateway` is off, only these commands are accepted:
|
|
287
|
+
|
|
288
|
+
```text
|
|
289
|
+
/help
|
|
290
|
+
/start
|
|
291
|
+
/status
|
|
292
|
+
/projects
|
|
293
|
+
/tasks
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
When `Gateway` is on, the full command set is accepted:
|
|
282
297
|
|
|
283
298
|
```text
|
|
284
299
|
/help
|
|
300
|
+
/start
|
|
301
|
+
/retry
|
|
285
302
|
/status
|
|
286
303
|
/projects
|
|
287
304
|
/use-project <index-or-path>
|
|
@@ -295,7 +312,7 @@ After Gateway is bound and turned on, send commands in the bound Weixin DM:
|
|
|
295
312
|
/translate off
|
|
296
313
|
```
|
|
297
314
|
|
|
298
|
-
Plain text that does not start with `/` is sent as a message to the current task's PM session.
|
|
315
|
+
Plain text that does not start with `/` is sent as a message to the current task's PM session only when `Gateway` is on.
|
|
299
316
|
|
|
300
317
|
In normal use, VCM runs one project and one task at a time. If the desktop UI has a task selected, turning Gateway on syncs that project/task into Gateway automatically. `/status` also refreshes this context, so the phone usually does not need to run `/tasks` and `/use-task` before sending a PM message.
|
|
301
318
|
|
|
@@ -316,6 +333,8 @@ Typical mobile flow:
|
|
|
316
333
|
|
|
317
334
|
- `/status`: shows Gateway, binding, translation, current project, current task, and last poll status.
|
|
318
335
|
- `/status` also adopts the current desktop project/task when one is selected.
|
|
336
|
+
- `/start`: turns Gateway on from the bound Weixin DM so full mobile task operations and PM messages are allowed. If the current task has a cached latest PM reply, `/start` includes it in the response.
|
|
337
|
+
- `/retry`: retries the latest failed Gateway output translation in the current VCM process.
|
|
319
338
|
- `/projects`: lists the current/recent repositories known by the desktop VCM.
|
|
320
339
|
- `/use-project <index-or-path>`: selects the Gateway's current project context.
|
|
321
340
|
- `/pull-current`: runs the same fast-forward-only connected repository pull as the desktop `Pull` button.
|
|
@@ -335,12 +354,14 @@ Typical mobile flow:
|
|
|
335
354
|
### Troubleshooting
|
|
336
355
|
|
|
337
356
|
- If the QR dialog does not appear, refresh the page and click `Start QR Login` again.
|
|
338
|
-
- If the QR status stays `wait`, confirm the login on the phone and click `
|
|
357
|
+
- If the QR status stays `wait`, confirm the login on the phone and click `Confirm` again.
|
|
339
358
|
- If the QR code expires, start a new QR login.
|
|
340
359
|
- If `Gateway` cannot be enabled, bind Weixin first.
|
|
341
|
-
- If
|
|
360
|
+
- If `/start` or read-only commands do not receive replies, check that the iLink token has not expired and the Weixin DM is the bound identity.
|
|
361
|
+
- If PM messages or task-changing commands are rejected, check that Gateway is on.
|
|
342
362
|
- If plain text cannot be sent to PM, select a project and task first, and make sure the task's PM session is running and idle.
|
|
343
363
|
- If PM replies are not pushed, check that Gateway is on and the PM session is producing normal Claude transcript output.
|
|
364
|
+
- If PM reply translation fails, send `/retry` from the bound Weixin DM. Retry state is memory-only and is cleared when VCM restarts.
|
|
344
365
|
|
|
345
366
|
## Translation
|
|
346
367
|
|
|
@@ -505,7 +526,7 @@ Each directed role route has exactly one message file. Route messages are the on
|
|
|
505
526
|
|
|
506
527
|
## Orchestration Modes
|
|
507
528
|
|
|
508
|
-
VCM has a task-level `Auto orchestration` switch in the role console toolbar.
|
|
529
|
+
VCM has a task-level `Auto orchestration` switch in the role console toolbar. New tasks default to auto orchestration.
|
|
509
530
|
|
|
510
531
|
When it is off, VCM is in manual mode:
|
|
511
532
|
|
|
@@ -527,6 +548,7 @@ When it is on, VCM is in auto mode:
|
|
|
527
548
|
- Just before terminal submission, VCM records `dispatchingAt`, waits briefly for the GUI to switch tabs, then writes to the embedded terminal.
|
|
528
549
|
- After successful terminal write, VCM snapshots the delivered body as message history.
|
|
529
550
|
- Claude Code `UserPromptSubmit` confirms that the prompt was accepted; VCM stores `acceptedAt` and clears the source route file if it still contains the same message.
|
|
551
|
+
- If `UserPromptSubmit` does not confirm the auto-delivered message, VCM retries Enter from the backend PTY and finally records `failureReason` on the message if submission is still not confirmed.
|
|
530
552
|
- When the GUI observes a newly dispatching auto message, it switches the active role tab to that message's target role before the message is submitted.
|
|
531
553
|
- VCM enforces sequential turn-taking from hook state: a role that has accepted a prompt is busy until its `Stop` hook fires.
|
|
532
554
|
- Additional pending files to a busy target role remain non-empty and are not written to that terminal.
|
|
@@ -8,6 +8,10 @@ export function parseGatewayCommand(input) {
|
|
|
8
8
|
switch (name) {
|
|
9
9
|
case "/help":
|
|
10
10
|
return { kind: "help" };
|
|
11
|
+
case "/start":
|
|
12
|
+
return { kind: "start" };
|
|
13
|
+
case "/retry":
|
|
14
|
+
return { kind: "retry" };
|
|
11
15
|
case "/status":
|
|
12
16
|
return { kind: "status" };
|
|
13
17
|
case "/projects":
|
|
@@ -12,17 +12,27 @@ const POLL_ERROR_BACKOFF_MS = 2_000;
|
|
|
12
12
|
const POLL_LONG_BACKOFF_MS = 30_000;
|
|
13
13
|
const MAX_FAILURES_BEFORE_LONG_BACKOFF = 3;
|
|
14
14
|
const DEFAULT_POLL_TIMEOUT_MS = 35_000;
|
|
15
|
+
const MAX_LATEST_PM_REPLY_CHARS = 8_000;
|
|
16
|
+
const GATEWAY_TRANSLATION_FAILURE_TEXT = "PM 回复已收到,但翻译失败。\n发送 /retry 重新翻译。";
|
|
17
|
+
const COMMANDS_ALLOWED_WHEN_DISABLED = new Set([
|
|
18
|
+
"help",
|
|
19
|
+
"start",
|
|
20
|
+
"status",
|
|
21
|
+
"projects",
|
|
22
|
+
"tasks"
|
|
23
|
+
]);
|
|
15
24
|
export function createGatewayService(deps) {
|
|
16
25
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
17
26
|
let pollAbort = null;
|
|
18
27
|
let pollLoopPromise = null;
|
|
19
28
|
let qrLogin = null;
|
|
29
|
+
let lastFailedTranslation = null;
|
|
20
30
|
function isRunning() {
|
|
21
31
|
return Boolean(pollAbort && !pollAbort.signal.aborted);
|
|
22
32
|
}
|
|
23
33
|
async function ensurePolling() {
|
|
24
34
|
const settings = await deps.settings.loadSettings();
|
|
25
|
-
if (!settings.
|
|
35
|
+
if (!settings.binding.token || isRunning()) {
|
|
26
36
|
return;
|
|
27
37
|
}
|
|
28
38
|
pollAbort = new AbortController();
|
|
@@ -55,7 +65,7 @@ export function createGatewayService(deps) {
|
|
|
55
65
|
while (!signal.aborted) {
|
|
56
66
|
const settings = await deps.settings.loadSettings();
|
|
57
67
|
const account = toAccount(settings);
|
|
58
|
-
if (!
|
|
68
|
+
if (!account) {
|
|
59
69
|
await savePollStatus("idle");
|
|
60
70
|
return;
|
|
61
71
|
}
|
|
@@ -99,6 +109,13 @@ export function createGatewayService(deps) {
|
|
|
99
109
|
await deps.settings.saveSettings({
|
|
100
110
|
...settingsAfterError,
|
|
101
111
|
enabled: expired ? false : settingsAfterError.enabled,
|
|
112
|
+
binding: expired
|
|
113
|
+
? {
|
|
114
|
+
...settingsAfterError.binding,
|
|
115
|
+
token: null,
|
|
116
|
+
getUpdatesBuf: ""
|
|
117
|
+
}
|
|
118
|
+
: settingsAfterError.binding,
|
|
102
119
|
lastPollStatus: {
|
|
103
120
|
state: expired ? "expired" : "error",
|
|
104
121
|
checkedAt: now(),
|
|
@@ -169,7 +186,7 @@ export function createGatewayService(deps) {
|
|
|
169
186
|
}
|
|
170
187
|
const command = parseGatewayCommand(update.text);
|
|
171
188
|
try {
|
|
172
|
-
const output = await executeCommand(command);
|
|
189
|
+
const output = await executeCommand(command, settings);
|
|
173
190
|
await reply(await deps.settings.loadSettings(), update.fromUserId, output);
|
|
174
191
|
await recordMessageStatus("inbound", "ok", update.text, undefined, command.kind);
|
|
175
192
|
await deps.audit.record({
|
|
@@ -196,10 +213,21 @@ export function createGatewayService(deps) {
|
|
|
196
213
|
});
|
|
197
214
|
}
|
|
198
215
|
}
|
|
199
|
-
async function executeCommand(command) {
|
|
216
|
+
async function executeCommand(command, settings) {
|
|
217
|
+
if (!settings.enabled && !COMMANDS_ALLOWED_WHEN_DISABLED.has(command.kind)) {
|
|
218
|
+
return [
|
|
219
|
+
"Gateway is connected but off.",
|
|
220
|
+
"Available commands while off: /help, /start, /status, /projects, /tasks.",
|
|
221
|
+
"Turn Gateway on in desktop VCM to send PM messages or run task-changing commands."
|
|
222
|
+
].join("\n");
|
|
223
|
+
}
|
|
200
224
|
switch (command.kind) {
|
|
201
225
|
case "help":
|
|
202
|
-
return helpText();
|
|
226
|
+
return helpText(settings.enabled);
|
|
227
|
+
case "start":
|
|
228
|
+
return startGateway();
|
|
229
|
+
case "retry":
|
|
230
|
+
return retryFailedTranslation();
|
|
203
231
|
case "status":
|
|
204
232
|
return statusText(await deps.settings.loadSettings());
|
|
205
233
|
case "projects":
|
|
@@ -473,9 +501,13 @@ export function createGatewayService(deps) {
|
|
|
473
501
|
deleteBranch: Boolean(task.worktreePath),
|
|
474
502
|
forceDeleteBranch: true
|
|
475
503
|
});
|
|
504
|
+
clearFailedTranslation(project.repoRoot, taskSlug);
|
|
505
|
+
const latestPmReplies = { ...settings.latestPmReplies };
|
|
506
|
+
delete latestPmReplies[latestPmReplyKey(project.repoRoot, taskSlug)];
|
|
476
507
|
await deps.settings.saveSettings({
|
|
477
508
|
...settings,
|
|
478
509
|
currentTaskSlug: null,
|
|
510
|
+
latestPmReplies,
|
|
479
511
|
pendingConfirmations: {
|
|
480
512
|
...settings.pendingConfirmations,
|
|
481
513
|
closeTask: null
|
|
@@ -501,6 +533,30 @@ export function createGatewayService(deps) {
|
|
|
501
533
|
const settings = await deps.settings.updateSettings({ translationEnabled: enabled });
|
|
502
534
|
return `Gateway translation ${settings.translationEnabled ? "on" : "off"}.`;
|
|
503
535
|
}
|
|
536
|
+
async function startGateway() {
|
|
537
|
+
const settings = await deps.settings.loadSettings();
|
|
538
|
+
if (!settings.binding.token) {
|
|
539
|
+
return "Gateway is not bound. Start QR login from desktop VCM first.";
|
|
540
|
+
}
|
|
541
|
+
if (settings.enabled) {
|
|
542
|
+
return "Gateway is already on.";
|
|
543
|
+
}
|
|
544
|
+
const enabled = await syncDesktopContext(await deps.settings.updateSettings({ enabled: true }));
|
|
545
|
+
await ensurePolling();
|
|
546
|
+
await deps.appSettings.updatePreferences({ flowPauseAlerts: false });
|
|
547
|
+
const lines = [
|
|
548
|
+
"Gateway started.",
|
|
549
|
+
"Full mobile commands and PM messages are now enabled.",
|
|
550
|
+
`Project: ${enabled.currentProjectId ?? "none"}`,
|
|
551
|
+
`Task: ${enabled.currentTaskSlug ?? "none"}`
|
|
552
|
+
];
|
|
553
|
+
const latestReply = getLatestPmReply(enabled);
|
|
554
|
+
if (latestReply) {
|
|
555
|
+
const rendered = await renderLatestPmReply(enabled, latestReply);
|
|
556
|
+
lines.push("", "Latest PM reply:", rendered.text);
|
|
557
|
+
}
|
|
558
|
+
return lines.join("\n");
|
|
559
|
+
}
|
|
504
560
|
async function sendPlainTextToPm(text) {
|
|
505
561
|
if (!text.trim()) {
|
|
506
562
|
return "Empty message ignored.";
|
|
@@ -589,14 +645,16 @@ export function createGatewayService(deps) {
|
|
|
589
645
|
void stopPolling();
|
|
590
646
|
},
|
|
591
647
|
async getStatus() {
|
|
592
|
-
|
|
648
|
+
const settings = await syncDesktopContext(await deps.settings.loadSettings());
|
|
649
|
+
await ensurePolling();
|
|
650
|
+
return deps.settings.expose(settings, isRunning());
|
|
593
651
|
},
|
|
594
652
|
async updateSettings(input) {
|
|
595
653
|
let settings = await deps.settings.updateSettings(input);
|
|
596
654
|
if (settings.enabled) {
|
|
597
655
|
settings = await syncDesktopContext(settings);
|
|
598
656
|
}
|
|
599
|
-
if (settings.
|
|
657
|
+
if (settings.binding.token) {
|
|
600
658
|
await ensurePolling();
|
|
601
659
|
}
|
|
602
660
|
else {
|
|
@@ -606,6 +664,7 @@ export function createGatewayService(deps) {
|
|
|
606
664
|
},
|
|
607
665
|
async resetBinding() {
|
|
608
666
|
await stopPolling();
|
|
667
|
+
lastFailedTranslation = null;
|
|
609
668
|
const settings = await deps.settings.resetBinding();
|
|
610
669
|
return deps.settings.expose(settings, isRunning());
|
|
611
670
|
},
|
|
@@ -662,6 +721,7 @@ export function createGatewayService(deps) {
|
|
|
662
721
|
updatedAt: now()
|
|
663
722
|
});
|
|
664
723
|
qrLogin = null;
|
|
724
|
+
await ensurePolling();
|
|
665
725
|
}
|
|
666
726
|
}
|
|
667
727
|
return {
|
|
@@ -676,19 +736,23 @@ export function createGatewayService(deps) {
|
|
|
676
736
|
if (input.session.role !== "project-manager") {
|
|
677
737
|
return;
|
|
678
738
|
}
|
|
739
|
+
const transcriptPath = resolveExistingClaudeTranscriptPath(input.session);
|
|
740
|
+
if (!transcriptPath) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const events = await readTranscriptTextEvents(transcriptPath);
|
|
744
|
+
const latestReply = selectLatestTurnReply(events, input.session);
|
|
745
|
+
if (latestReply) {
|
|
746
|
+
await saveLatestPmReply(input, latestReply);
|
|
747
|
+
}
|
|
679
748
|
const settings = await deps.settings.loadSettings();
|
|
680
749
|
const account = toAccount(settings);
|
|
681
750
|
const boundUserId = settings.binding.boundUserId;
|
|
682
751
|
if (!settings.enabled || !account || !boundUserId) {
|
|
683
752
|
return;
|
|
684
753
|
}
|
|
685
|
-
const transcriptPath = resolveExistingClaudeTranscriptPath(input.session);
|
|
686
|
-
if (!transcriptPath) {
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
754
|
const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
|
|
690
755
|
const cursor = settings.pushCursors[cursorKey];
|
|
691
|
-
const events = await readTranscriptTextEvents(transcriptPath);
|
|
692
756
|
const nextEvents = selectEventsAfterCursor(events, cursor?.lastTranscriptEventId);
|
|
693
757
|
if (nextEvents.length === 0) {
|
|
694
758
|
return;
|
|
@@ -697,25 +761,17 @@ export function createGatewayService(deps) {
|
|
|
697
761
|
if (!text) {
|
|
698
762
|
return;
|
|
699
763
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
role: "project-manager",
|
|
707
|
-
text
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
catch {
|
|
711
|
-
output = `${text}\n\n[Translation failed; original PM reply shown.]`;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
764
|
+
const output = await renderGatewayPmOutput({
|
|
765
|
+
settings,
|
|
766
|
+
repoRoot: input.repoRoot,
|
|
767
|
+
taskSlug: input.taskSlug,
|
|
768
|
+
sourceText: text
|
|
769
|
+
});
|
|
714
770
|
await deps.channel.sendText({
|
|
715
771
|
account,
|
|
716
772
|
toUserId: boundUserId,
|
|
717
773
|
contextToken: settings.binding.contextTokens[boundUserId],
|
|
718
|
-
text: output
|
|
774
|
+
text: output.text
|
|
719
775
|
});
|
|
720
776
|
const lastEvent = nextEvents.at(-1);
|
|
721
777
|
const current = await deps.settings.loadSettings();
|
|
@@ -731,20 +787,131 @@ export function createGatewayService(deps) {
|
|
|
731
787
|
lastMessageStatus: {
|
|
732
788
|
checkedAt: now(),
|
|
733
789
|
direction: "outbound",
|
|
734
|
-
result: "ok",
|
|
790
|
+
result: output.translationFailed ? "error" : "ok",
|
|
735
791
|
command: "pm-stop",
|
|
736
|
-
preview: output.slice(0, 160)
|
|
792
|
+
preview: output.text.slice(0, 160),
|
|
793
|
+
error: output.translationError
|
|
737
794
|
},
|
|
738
795
|
updatedAt: now()
|
|
739
796
|
});
|
|
740
797
|
await deps.audit.record({
|
|
741
798
|
type: "gateway.pm_push",
|
|
742
|
-
result: "ok",
|
|
799
|
+
result: output.translationFailed ? "error" : "ok",
|
|
743
800
|
command: "pm-stop",
|
|
744
|
-
preview: output
|
|
801
|
+
preview: output.text,
|
|
802
|
+
error: output.translationError
|
|
745
803
|
});
|
|
746
804
|
}
|
|
747
805
|
};
|
|
806
|
+
function getLatestPmReply(settings) {
|
|
807
|
+
if (!settings.currentProjectId || !settings.currentTaskSlug) {
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
return settings.latestPmReplies[latestPmReplyKey(settings.currentProjectId, settings.currentTaskSlug)];
|
|
811
|
+
}
|
|
812
|
+
async function renderLatestPmReply(settings, reply) {
|
|
813
|
+
const rendered = await renderGatewayPmOutput({
|
|
814
|
+
settings,
|
|
815
|
+
repoRoot: reply.repoRoot,
|
|
816
|
+
taskSlug: reply.taskSlug,
|
|
817
|
+
sourceText: reply.text
|
|
818
|
+
});
|
|
819
|
+
return {
|
|
820
|
+
...rendered,
|
|
821
|
+
text: reply.truncated && !rendered.translationFailed ? `${rendered.text}\n\n[truncated]` : rendered.text
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
async function renderGatewayPmOutput(input) {
|
|
825
|
+
if (!input.settings.translationEnabled) {
|
|
826
|
+
clearFailedTranslation(input.repoRoot, input.taskSlug);
|
|
827
|
+
return {
|
|
828
|
+
text: input.sourceText,
|
|
829
|
+
translationFailed: false
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
const text = await deps.translationService.translateGatewayOutput({
|
|
834
|
+
repoRoot: input.repoRoot,
|
|
835
|
+
taskSlug: input.taskSlug,
|
|
836
|
+
role: "project-manager",
|
|
837
|
+
text: input.sourceText
|
|
838
|
+
});
|
|
839
|
+
clearFailedTranslation(input.repoRoot, input.taskSlug);
|
|
840
|
+
return {
|
|
841
|
+
text,
|
|
842
|
+
translationFailed: false
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
const message = errorMessage(error);
|
|
847
|
+
lastFailedTranslation = {
|
|
848
|
+
repoRoot: input.repoRoot,
|
|
849
|
+
taskSlug: input.taskSlug,
|
|
850
|
+
role: "project-manager",
|
|
851
|
+
sourceText: input.sourceText,
|
|
852
|
+
failedAt: now(),
|
|
853
|
+
error: message,
|
|
854
|
+
retryCount: 0
|
|
855
|
+
};
|
|
856
|
+
return {
|
|
857
|
+
text: GATEWAY_TRANSLATION_FAILURE_TEXT,
|
|
858
|
+
translationFailed: true,
|
|
859
|
+
translationError: message
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
async function retryFailedTranslation() {
|
|
864
|
+
if (!lastFailedTranslation) {
|
|
865
|
+
return "没有可重试的翻译失败内容。";
|
|
866
|
+
}
|
|
867
|
+
const failed = lastFailedTranslation;
|
|
868
|
+
try {
|
|
869
|
+
const text = await deps.translationService.translateGatewayOutput({
|
|
870
|
+
repoRoot: failed.repoRoot,
|
|
871
|
+
taskSlug: failed.taskSlug,
|
|
872
|
+
role: failed.role,
|
|
873
|
+
text: failed.sourceText
|
|
874
|
+
});
|
|
875
|
+
lastFailedTranslation = null;
|
|
876
|
+
return `重新翻译成功:\n\n${text}`;
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
lastFailedTranslation = {
|
|
880
|
+
...failed,
|
|
881
|
+
failedAt: now(),
|
|
882
|
+
error: errorMessage(error),
|
|
883
|
+
retryCount: failed.retryCount + 1
|
|
884
|
+
};
|
|
885
|
+
return "重新翻译失败。\n稍后可继续发送 /retry。";
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function clearFailedTranslation(repoRoot, taskSlug) {
|
|
889
|
+
if (lastFailedTranslation?.repoRoot === repoRoot && lastFailedTranslation.taskSlug === taskSlug) {
|
|
890
|
+
lastFailedTranslation = null;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
async function saveLatestPmReply(input, reply) {
|
|
894
|
+
const settings = await deps.settings.loadSettings();
|
|
895
|
+
const key = latestPmReplyKey(input.repoRoot, input.taskSlug);
|
|
896
|
+
await deps.settings.saveSettings({
|
|
897
|
+
...settings,
|
|
898
|
+
latestPmReplies: {
|
|
899
|
+
...settings.latestPmReplies,
|
|
900
|
+
[key]: {
|
|
901
|
+
repoRoot: input.repoRoot,
|
|
902
|
+
taskSlug: input.taskSlug,
|
|
903
|
+
sessionId: input.session.id,
|
|
904
|
+
claudeSessionId: input.session.claudeSessionId,
|
|
905
|
+
transcriptEventId: reply.transcriptEventId,
|
|
906
|
+
transcriptTimestamp: reply.transcriptTimestamp,
|
|
907
|
+
capturedAt: now(),
|
|
908
|
+
text: reply.text,
|
|
909
|
+
truncated: reply.truncated
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
updatedAt: now()
|
|
913
|
+
});
|
|
914
|
+
}
|
|
748
915
|
}
|
|
749
916
|
async function readTranscriptTextEvents(transcriptPath) {
|
|
750
917
|
const raw = await readFile(transcriptPath, "utf8");
|
|
@@ -778,15 +945,79 @@ function selectEventsAfterCursor(events, cursorId) {
|
|
|
778
945
|
}
|
|
779
946
|
return events.slice(index + 1);
|
|
780
947
|
}
|
|
948
|
+
function selectLatestTurnReply(events, session) {
|
|
949
|
+
if (events.length === 0) {
|
|
950
|
+
return undefined;
|
|
951
|
+
}
|
|
952
|
+
const startMs = timestampMs(session.lastTurnStartedAt);
|
|
953
|
+
const endMs = timestampMs(session.lastTurnEndedAt);
|
|
954
|
+
const selected = startMs === undefined
|
|
955
|
+
? events.slice(-1)
|
|
956
|
+
: events.filter((event) => {
|
|
957
|
+
const eventMs = timestampMs(event.timestamp);
|
|
958
|
+
return eventMs !== undefined
|
|
959
|
+
&& eventMs >= startMs - 1_000
|
|
960
|
+
&& (endMs === undefined || eventMs <= endMs + 1_000);
|
|
961
|
+
});
|
|
962
|
+
if (selected.length === 0) {
|
|
963
|
+
return undefined;
|
|
964
|
+
}
|
|
965
|
+
const text = selected.map((event) => event.text).join("\n\n").trim();
|
|
966
|
+
if (!text) {
|
|
967
|
+
return undefined;
|
|
968
|
+
}
|
|
969
|
+
const limited = limitLatestPmReply(text);
|
|
970
|
+
const lastEvent = selected.at(-1);
|
|
971
|
+
return {
|
|
972
|
+
transcriptEventId: lastEvent?.id ?? null,
|
|
973
|
+
transcriptTimestamp: lastEvent?.timestamp ?? null,
|
|
974
|
+
text: limited.text,
|
|
975
|
+
truncated: limited.truncated
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
function latestPmReplyKey(repoRoot, taskSlug) {
|
|
979
|
+
return JSON.stringify([repoRoot, taskSlug]);
|
|
980
|
+
}
|
|
981
|
+
function limitLatestPmReply(text) {
|
|
982
|
+
if (text.length <= MAX_LATEST_PM_REPLY_CHARS) {
|
|
983
|
+
return { text, truncated: false };
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
text: text.slice(0, MAX_LATEST_PM_REPLY_CHARS).trimEnd(),
|
|
987
|
+
truncated: true
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
function timestampMs(value) {
|
|
991
|
+
if (!value) {
|
|
992
|
+
return undefined;
|
|
993
|
+
}
|
|
994
|
+
const parsed = Date.parse(value);
|
|
995
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
996
|
+
}
|
|
781
997
|
function projectsText(projects) {
|
|
782
998
|
if (projects.length === 0) {
|
|
783
999
|
return "No recent projects. Connect a repository from desktop VCM first.";
|
|
784
1000
|
}
|
|
785
1001
|
return projects.map((repoPath, index) => `${index + 1}. ${repoPath}`).join("\n");
|
|
786
1002
|
}
|
|
787
|
-
function helpText() {
|
|
1003
|
+
function helpText(enabled) {
|
|
1004
|
+
if (!enabled) {
|
|
1005
|
+
return [
|
|
1006
|
+
"VCM Gateway is connected but off.",
|
|
1007
|
+
"Available commands:",
|
|
1008
|
+
"/help",
|
|
1009
|
+
"/start",
|
|
1010
|
+
"/status",
|
|
1011
|
+
"/projects",
|
|
1012
|
+
"/tasks",
|
|
1013
|
+
"Send /start to enable PM messages and task-changing commands."
|
|
1014
|
+
].join("\n");
|
|
1015
|
+
}
|
|
788
1016
|
return [
|
|
789
1017
|
"VCM Gateway commands:",
|
|
1018
|
+
"/help",
|
|
1019
|
+
"/start",
|
|
1020
|
+
"/retry",
|
|
790
1021
|
"/status",
|
|
791
1022
|
"/projects",
|
|
792
1023
|
"/use-project <index-or-path>",
|
|
@@ -48,6 +48,7 @@ export function createGatewaySettingsService(deps) {
|
|
|
48
48
|
recentInboundMessageIds: []
|
|
49
49
|
},
|
|
50
50
|
pushCursors: {},
|
|
51
|
+
latestPmReplies: current.latestPmReplies,
|
|
51
52
|
lastPollStatus: {
|
|
52
53
|
state: "idle"
|
|
53
54
|
},
|
|
@@ -92,6 +93,9 @@ export function normalizeSettings(input, timestamp) {
|
|
|
92
93
|
? input.pendingConfirmations
|
|
93
94
|
: {};
|
|
94
95
|
const pushCursors = isObject(input.pushCursors) ? input.pushCursors : {};
|
|
96
|
+
const latestPmReplies = isObject(input.latestPmReplies)
|
|
97
|
+
? input.latestPmReplies
|
|
98
|
+
: {};
|
|
95
99
|
return {
|
|
96
100
|
version: 1,
|
|
97
101
|
enabled: input.enabled === true,
|
|
@@ -120,6 +124,7 @@ export function normalizeSettings(input, timestamp) {
|
|
|
120
124
|
closeTask: normalizeCloseTaskConfirmation(pendingInput.closeTask)
|
|
121
125
|
},
|
|
122
126
|
pushCursors: normalizePushCursors(pushCursors),
|
|
127
|
+
latestPmReplies: normalizeLatestPmReplies(latestPmReplies),
|
|
123
128
|
lastPollStatus: normalizePollStatus(input.lastPollStatus),
|
|
124
129
|
lastMessageStatus: normalizeMessageStatus(input.lastMessageStatus),
|
|
125
130
|
updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : timestamp
|
|
@@ -161,6 +166,35 @@ function normalizePushCursors(input) {
|
|
|
161
166
|
}
|
|
162
167
|
return out;
|
|
163
168
|
}
|
|
169
|
+
function normalizeLatestPmReplies(input) {
|
|
170
|
+
const out = {};
|
|
171
|
+
for (const [key, value] of Object.entries(input)) {
|
|
172
|
+
if (!isObject(value)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const repoRoot = normalizeNullableString(value.repoRoot);
|
|
176
|
+
const taskSlug = normalizeNullableString(value.taskSlug);
|
|
177
|
+
const sessionId = normalizeNullableString(value.sessionId);
|
|
178
|
+
const claudeSessionId = normalizeNullableString(value.claudeSessionId);
|
|
179
|
+
const capturedAt = normalizeNullableString(value.capturedAt);
|
|
180
|
+
const text = normalizeNullableString(value.text);
|
|
181
|
+
if (!repoRoot || !taskSlug || !sessionId || !claudeSessionId || !capturedAt || !text) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
out[key] = {
|
|
185
|
+
repoRoot,
|
|
186
|
+
taskSlug,
|
|
187
|
+
sessionId,
|
|
188
|
+
claudeSessionId,
|
|
189
|
+
transcriptEventId: normalizeNullableString(value.transcriptEventId),
|
|
190
|
+
transcriptTimestamp: normalizeNullableString(value.transcriptTimestamp),
|
|
191
|
+
capturedAt,
|
|
192
|
+
text,
|
|
193
|
+
truncated: value.truncated === true
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
164
198
|
function normalizePollStatus(input) {
|
|
165
199
|
if (!isObject(input)) {
|
|
166
200
|
return { state: "idle" };
|