dsh-caduceus 0.7.1 → 0.10.5
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 +8 -2
- package/README.zh.md +8 -2
- package/cordis.patch.yml +2 -0
- package/dist/bridge/bridge.d.ts +7 -2
- package/dist/bridge/bridge.js +425 -17
- package/dist/bridge/types.d.ts +21 -1
- package/dist/commands/dispatch.d.ts +2 -2
- package/dist/commands/dispatch.js +3 -3
- package/dist/commands/parse.d.ts +15 -2
- package/dist/commands/parse.js +34 -3
- package/dist/config.d.ts +18 -4
- package/dist/config.js +9 -3
- package/dist/export.d.ts +44 -0
- package/dist/export.js +222 -0
- package/dist/home.d.ts +2 -0
- package/dist/home.js +4 -0
- package/dist/i18n.d.ts +42 -0
- package/dist/i18n.js +94 -0
- package/dist/index.js +48 -1
- package/dist/interactive/approval.js +1 -1
- package/dist/interactive/cards.d.ts +2 -1
- package/dist/interactive/cards.js +3 -1
- package/dist/interactive/runtime.d.ts +4 -0
- package/dist/interactive/runtime.js +15 -0
- package/dist/interactive/types.d.ts +2 -0
- package/dist/metrics.d.ts +28 -0
- package/dist/metrics.js +135 -0
- package/dist/render/cards.d.ts +4 -0
- package/dist/render/cards.js +17 -0
- package/dist/render/permission-card.d.ts +23 -0
- package/dist/render/permission-card.js +22 -0
- package/dist/status.d.ts +22 -0
- package/dist/status.js +95 -0
- package/dist/transport/feishu-response.d.ts +1 -0
- package/dist/transport/feishu-response.js +25 -0
- package/dist/transport/lark.js +5 -4
- package/dist/usage.d.ts +23 -0
- package/dist/usage.js +77 -0
- package/package.json +1 -1
package/dist/bridge/bridge.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
20
20
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
-
import { join } from 'node:path';
|
|
21
|
+
import { basename, join } from 'node:path';
|
|
22
22
|
import { planSlashLine } from '../commands/dispatch.js';
|
|
23
23
|
import { helpText, parseCommand } from '../commands/parse.js';
|
|
24
24
|
import { evaluateGate, logGate } from '../gate.js';
|
|
@@ -26,7 +26,13 @@ import { silentLogger } from '../logger.js';
|
|
|
26
26
|
import { formatScopeLine, sessionIdTimestamp, sessionTimeLabel, shortSessionId, } from '../observability.js';
|
|
27
27
|
import { buildDoctorReport, renderDoctorJson, renderDoctorText, } from '../doctor.js';
|
|
28
28
|
import { writeOwnership } from '../ownership.js';
|
|
29
|
+
import { defaultExportCacheDir } from '../home.js';
|
|
29
30
|
import { extractMediaMarkers, resolveMedia } from '../media.js';
|
|
31
|
+
import { aggregateUsage, renderUsageReport } from '../usage.js';
|
|
32
|
+
import { aggregateMetrics, formatMetricsLine } from '../metrics.js';
|
|
33
|
+
import { presetLabel, t } from '../i18n.js';
|
|
34
|
+
import { buildPermissionCard, renderPermissionText, } from '../render/permission-card.js';
|
|
35
|
+
import { readSessionLogFile, renderExportJsonl, renderExportMarkdown, truncateExport, writeExportFile, } from '../export.js';
|
|
30
36
|
import { finalCard, finalCardPlainText, progressCard, } from '../render/cards.js';
|
|
31
37
|
import { CardStream, systemClock } from '../render/stream.js';
|
|
32
38
|
import { mintSessionId, scopeKey, SCOPE_KEY_SEPARATOR } from '../session-key.js';
|
|
@@ -94,6 +100,11 @@ export class Bridge {
|
|
|
94
100
|
#queue = new ScopeQueue();
|
|
95
101
|
#states = new Map();
|
|
96
102
|
#chatIdBySession = new Map();
|
|
103
|
+
#permissionCards = new Map();
|
|
104
|
+
#stopCards = new Map();
|
|
105
|
+
#stopClicks = new Map();
|
|
106
|
+
#sessionMetrics = new Map();
|
|
107
|
+
#metricsFallbackWarned = new Set();
|
|
97
108
|
#scopeBySession = new Map();
|
|
98
109
|
constructor(options) {
|
|
99
110
|
this.#options = options;
|
|
@@ -210,10 +221,14 @@ export class Bridge {
|
|
|
210
221
|
const state = this.#states.get(key);
|
|
211
222
|
if (state === undefined)
|
|
212
223
|
return;
|
|
224
|
+
state.logEvents.push(event);
|
|
225
|
+
if (state.logEvents.length > 5000)
|
|
226
|
+
state.logEvents.shift();
|
|
213
227
|
state.status.apply(event);
|
|
214
228
|
switch (event.type) {
|
|
215
229
|
case 'turn/start': {
|
|
216
230
|
state.running = true;
|
|
231
|
+
state.stopRequested = false;
|
|
217
232
|
state.lastOutput = '';
|
|
218
233
|
state.progress?.dispose();
|
|
219
234
|
state.progress = this.#makeCardStream(state);
|
|
@@ -266,6 +281,13 @@ export class Bridge {
|
|
|
266
281
|
await this.#renderProgress(state);
|
|
267
282
|
break;
|
|
268
283
|
}
|
|
284
|
+
case 'compaction/start':
|
|
285
|
+
case 'compaction/end':
|
|
286
|
+
case 'team/member':
|
|
287
|
+
case 'team/task': {
|
|
288
|
+
await this.#renderProgress(state);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
269
291
|
default:
|
|
270
292
|
// Informational events only refresh the tracker.
|
|
271
293
|
break;
|
|
@@ -370,10 +392,11 @@ export class Bridge {
|
|
|
370
392
|
const anomaly = fails.length > 0 && failAt !== undefined
|
|
371
393
|
? `\n最近异常:${fails.join(',')} @ ${sessionTimeLabel(failAt, this.#now())}`
|
|
372
394
|
: '';
|
|
395
|
+
const permissionLine = this.#permissionLineFor(state);
|
|
373
396
|
await this.#replyText(state, `${selfLine === undefined ? '' : `${selfLine}\n`}${statusText(state.status.get(), {
|
|
374
397
|
...(title === undefined ? {} : { title }),
|
|
375
398
|
...(state.sessionId === undefined ? {} : { sessionId: state.sessionId }),
|
|
376
|
-
})}${anomaly}`);
|
|
399
|
+
})}${permissionLine === undefined ? '' : `\n${permissionLine}`}${anomaly}`);
|
|
377
400
|
return;
|
|
378
401
|
}
|
|
379
402
|
case 'stop': {
|
|
@@ -429,6 +452,18 @@ export class Bridge {
|
|
|
429
452
|
await this.#replyText(state, result.text);
|
|
430
453
|
return;
|
|
431
454
|
}
|
|
455
|
+
case 'export': {
|
|
456
|
+
await this.#exportSession(state, command.json === true);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
case 'permission': {
|
|
460
|
+
await this.#handlePermission(state, command.preset);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
case 'usage-report': {
|
|
464
|
+
await this.#handleUsage(state);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
432
467
|
case 'whoami': {
|
|
433
468
|
const openId = state.ownerOpenId ?? '';
|
|
434
469
|
const identity = this.#isAdmin(openId) ? '管理员' : '访客';
|
|
@@ -601,6 +636,207 @@ export class Bridge {
|
|
|
601
636
|
return;
|
|
602
637
|
}
|
|
603
638
|
}
|
|
639
|
+
/** `/permission`: Chinese overview; switches still go through dsh's command. */
|
|
640
|
+
/** Permission-card button callback: run the same dsh switch, then patch the card. */
|
|
641
|
+
async handlePermissionCardAction(action, name) {
|
|
642
|
+
const meta = this.#permissionCards.get(action.messageId);
|
|
643
|
+
if (meta === undefined)
|
|
644
|
+
return;
|
|
645
|
+
const state = this.#states.get(meta.key);
|
|
646
|
+
if (state === undefined)
|
|
647
|
+
return;
|
|
648
|
+
if (meta.ownerOpenId !== undefined && action.operatorOpenId !== meta.ownerOpenId) {
|
|
649
|
+
this.#logger.warn(`permission card ignored for non-owner ${action.operatorOpenId}`);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const service = this.#options.permissionPresetsProvider?.();
|
|
653
|
+
const sessionId = state.sessionId;
|
|
654
|
+
if (service === undefined || sessionId === undefined)
|
|
655
|
+
return;
|
|
656
|
+
if (!service.names.includes(name)) {
|
|
657
|
+
await this.#replyText(state, t(this.#lang()).unknownPreset(name, service.names.join(', ')));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const agent = this.#agents.get(sessionId);
|
|
661
|
+
if (agent === undefined)
|
|
662
|
+
return;
|
|
663
|
+
const result = await this.#options.runHarnessCommand?.(agent, `/permission ${name}`);
|
|
664
|
+
if (result === undefined) {
|
|
665
|
+
await this.#replyText(state, '切换失败:dsh 命令未返回结果。');
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const view = this.#permissionViewFor(state);
|
|
669
|
+
if (view !== undefined) {
|
|
670
|
+
try {
|
|
671
|
+
await this.#transport.updateCard(action.messageId, buildPermissionCard(view));
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
this.#logger.warn(`permission card patch failed: ${errorMessage(error)}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
await this.#replyText(state, translatePermissionOutput(result.text, name, this.#lang()));
|
|
678
|
+
}
|
|
679
|
+
/** `/usage`: aggregate durable assistant/message usage from the session log. */
|
|
680
|
+
async #handleUsage(state) {
|
|
681
|
+
const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
|
|
682
|
+
if (sessionId === undefined) {
|
|
683
|
+
await this.#replyText(state, '读不到会话日志(当前没有会话)');
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const provider = this.#options.usageProvider;
|
|
687
|
+
if (provider === undefined) {
|
|
688
|
+
await this.#replyText(state, '读不到会话日志(sessionQuery 服务不可用)');
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
let events;
|
|
692
|
+
try {
|
|
693
|
+
events = await provider(sessionId);
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
await this.#replyText(state, `读不到会话日志(${errorMessage(error)})`);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (events === undefined) {
|
|
700
|
+
await this.#replyText(state, '读不到会话日志(sessionQuery 服务不可用)');
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const metricsLine = formatMetricsLine(aggregateMetrics(events), this.#config.metrics ?? 'compact');
|
|
704
|
+
await this.#replyText(state, renderUsageReport(aggregateUsage(events)) +
|
|
705
|
+
(metricsLine.length === 0 ? '' : `\n${metricsLine}`));
|
|
706
|
+
}
|
|
707
|
+
#lang() {
|
|
708
|
+
return this.#options.languageProvider?.() ?? 'zh';
|
|
709
|
+
}
|
|
710
|
+
#permissionViewFor(state) {
|
|
711
|
+
const service = this.#options.permissionPresetsProvider?.();
|
|
712
|
+
const sessionId = state.sessionId;
|
|
713
|
+
if (service === undefined || sessionId === undefined)
|
|
714
|
+
return undefined;
|
|
715
|
+
const agent = this.#agents.get(sessionId);
|
|
716
|
+
if (agent === undefined)
|
|
717
|
+
return undefined;
|
|
718
|
+
const current = service.current(agent.session);
|
|
719
|
+
const presets = service.names.map((name) => {
|
|
720
|
+
try {
|
|
721
|
+
const spec = service.resolve?.(name);
|
|
722
|
+
return {
|
|
723
|
+
name,
|
|
724
|
+
resolved: spec !== undefined,
|
|
725
|
+
...(spec?.sandbox === undefined ? {} : { sandbox: String(spec.sandbox) }),
|
|
726
|
+
...(spec?.approval === undefined ? {} : { approval: String(spec.approval) }),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
return { name, resolved: false };
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
return { lang: this.#lang(), current, presets };
|
|
734
|
+
}
|
|
735
|
+
#permissionLineFor(state) {
|
|
736
|
+
const view = this.#permissionViewFor(state);
|
|
737
|
+
if (view === undefined)
|
|
738
|
+
return undefined;
|
|
739
|
+
const current = view.presets.find((preset) => preset.name === view.current);
|
|
740
|
+
const approval = current?.approval ?? '?';
|
|
741
|
+
return t(view.lang).approvalLine(view.current, t(view.lang).approvalLabel(approval));
|
|
742
|
+
}
|
|
743
|
+
async #handlePermission(state, preset) {
|
|
744
|
+
const resolved = await this.#tryResolve(state);
|
|
745
|
+
if (resolved === undefined) {
|
|
746
|
+
await this.#replyText(state, '当前没有可用会话。');
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const service = this.#options.permissionPresetsProvider?.();
|
|
750
|
+
if (service === undefined) {
|
|
751
|
+
// No service to validate/translate against: keep dsh's original output,
|
|
752
|
+
// explicitly labelled so the user knows it is the upstream text.
|
|
753
|
+
const raw = await this.#runDshCommandLine(resolved.agent, preset === undefined ? '/permission' : `/permission ${preset}`);
|
|
754
|
+
await this.#replyText(state, `(dsh 原文)\n${raw}`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (preset === undefined) {
|
|
758
|
+
const view = this.#permissionViewFor(state);
|
|
759
|
+
if (view === undefined) {
|
|
760
|
+
const raw = await this.#runDshCommandLine(resolved.agent, '/permission');
|
|
761
|
+
await this.#replyText(state, `(dsh 原文)\n${raw}`);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const card = buildPermissionCard(view);
|
|
765
|
+
try {
|
|
766
|
+
const sent = await this.#sendNewCard(state, card);
|
|
767
|
+
this.#permissionCards.set(sent.messageId, {
|
|
768
|
+
key: state.key,
|
|
769
|
+
...(state.ownerOpenId === undefined ? {} : { ownerOpenId: state.ownerOpenId }),
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
await this.#replyText(state, renderPermissionText(view));
|
|
774
|
+
}
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (!service.names.includes(preset)) {
|
|
778
|
+
await this.#replyText(state, `未知档位 ${preset},可用:${service.names.join(', ')}`);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const result = await this.#options.runHarnessCommand?.(resolved.agent, `/permission ${preset}`);
|
|
782
|
+
if (result === undefined) {
|
|
783
|
+
await this.#replyText(state, service === undefined
|
|
784
|
+
? '(dsh 原文)\n权限服务不可用,dsh 命令未返回结果。'
|
|
785
|
+
: '切换失败:dsh 命令未返回结果。');
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
await this.#replyText(state, translatePermissionOutput(result.text, preset, this.#lang()));
|
|
789
|
+
}
|
|
790
|
+
async #runDshCommandLine(agent, line) {
|
|
791
|
+
const result = await this.#options.runHarnessCommand?.(agent, line);
|
|
792
|
+
return result === undefined
|
|
793
|
+
? '权限服务不可用,dsh 命令未返回结果。'
|
|
794
|
+
: result.text;
|
|
795
|
+
}
|
|
796
|
+
/** `/export`: write a local markdown/JSONL file and hand it to the MEDIA chain. */
|
|
797
|
+
async #exportSession(state, json) {
|
|
798
|
+
const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
|
|
799
|
+
if (sessionId === undefined) {
|
|
800
|
+
await this.#replyText(state, '当前没有可导出的会话。');
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
let data;
|
|
804
|
+
try {
|
|
805
|
+
data = await this.#options.exportProvider?.(sessionId);
|
|
806
|
+
if (data === undefined)
|
|
807
|
+
data = readSessionLogFile(sessionId);
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
await this.#replyText(state, `导出失败:${errorMessage(error)}`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (data === undefined || (data.messages.length === 0 && data.rawJsonl === undefined)) {
|
|
814
|
+
await this.#replyText(state, '本会话没有可导出的日志。');
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const extension = json ? 'jsonl' : 'md';
|
|
818
|
+
const content = json
|
|
819
|
+
? truncateExport(data.rawJsonl ?? renderExportJsonl(data.messages))
|
|
820
|
+
: renderExportMarkdown({
|
|
821
|
+
sessionId,
|
|
822
|
+
...(state.title === undefined ? {} : { title: state.title }),
|
|
823
|
+
now: this.#now(),
|
|
824
|
+
messages: data.messages,
|
|
825
|
+
});
|
|
826
|
+
let path;
|
|
827
|
+
try {
|
|
828
|
+
path = writeExportFile(defaultExportCacheDir(), this.#scopeKind(state), sessionId, extension, content, this.#now());
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
await this.#replyText(state, `导出失败:写入文件失败(${errorMessage(error)})。`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const label = json ? 'JSONL' : 'markdown';
|
|
835
|
+
await this.#replyText(state, `MEDIA:${path}\n已导出会话 ${sessionId.slice(0, 8)}(${data.messages.length} 条消息,${label})。`);
|
|
836
|
+
await this.#sendOutboundMedia(state, [
|
|
837
|
+
{ path, name: basename(path), image: false },
|
|
838
|
+
]);
|
|
839
|
+
}
|
|
604
840
|
async #settleTurn(state, event) {
|
|
605
841
|
state.running = false;
|
|
606
842
|
const kind = turnEndReasonKind(event.data);
|
|
@@ -647,6 +883,8 @@ export class Bridge {
|
|
|
647
883
|
await this.#replyText(state, notice);
|
|
648
884
|
}
|
|
649
885
|
await this.#finishReactions(state, kind);
|
|
886
|
+
await this.#refreshSessionMetrics(state);
|
|
887
|
+
state.stopRequested = false;
|
|
650
888
|
state.lastOutput = '';
|
|
651
889
|
}
|
|
652
890
|
/** Extract MEDIA markers, validate paths, and annotate the final card body. */
|
|
@@ -699,7 +937,7 @@ export class Bridge {
|
|
|
699
937
|
}
|
|
700
938
|
}
|
|
701
939
|
catch (error) {
|
|
702
|
-
this.#logger.warn(`media send failed for ${entry.name}: ${errorMessage(error)}`);
|
|
940
|
+
this.#logger.warn(`media send failed for ${entry.name} (${entry.path}): ${errorMessage(error)}`);
|
|
703
941
|
}
|
|
704
942
|
}
|
|
705
943
|
}
|
|
@@ -867,19 +1105,52 @@ export class Bridge {
|
|
|
867
1105
|
return undefined;
|
|
868
1106
|
}
|
|
869
1107
|
}
|
|
870
|
-
async #
|
|
1108
|
+
async #cancelCurrentTurn(state) {
|
|
871
1109
|
const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
|
|
872
|
-
if (sessionId === undefined)
|
|
873
|
-
|
|
1110
|
+
if (sessionId === undefined)
|
|
1111
|
+
return { ok: false, reason: '本聊天/话题还没有会话。' };
|
|
1112
|
+
const agent = this.#agents.get(sessionId);
|
|
1113
|
+
if (agent === undefined)
|
|
1114
|
+
return { ok: false, reason: '当前没有在跑的会话。' };
|
|
1115
|
+
try {
|
|
1116
|
+
agent.cancel({ kind: 'user' });
|
|
1117
|
+
return { ok: true };
|
|
1118
|
+
}
|
|
1119
|
+
catch (error) {
|
|
1120
|
+
return { ok: false, reason: `取消失败:${errorMessage(error)}` };
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
async #stop(state) {
|
|
1124
|
+
const result = await this.#cancelCurrentTurn(state);
|
|
1125
|
+
if (!result.ok) {
|
|
1126
|
+
await this.#replyText(state, result.reason);
|
|
874
1127
|
return;
|
|
875
1128
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1129
|
+
await this.#replyText(state, '已停止当前回合。后台任务仍在运行,完成后结果会回来。');
|
|
1130
|
+
}
|
|
1131
|
+
/** Stop button callback: same cancel path as `/stop`, once per second. */
|
|
1132
|
+
async handleStopCardAction(action) {
|
|
1133
|
+
const key = this.#stopCards.get(action.messageId);
|
|
1134
|
+
if (key === undefined)
|
|
1135
|
+
return;
|
|
1136
|
+
const state = this.#states.get(key);
|
|
1137
|
+
if (state === undefined || !state.running)
|
|
1138
|
+
return;
|
|
1139
|
+
const now = this.#now();
|
|
1140
|
+
const last = this.#stopClicks.get(action.messageId);
|
|
1141
|
+
if (last !== undefined && now - last < 1000)
|
|
1142
|
+
return;
|
|
1143
|
+
this.#stopClicks.set(action.messageId, now);
|
|
1144
|
+
const result = await this.#cancelCurrentTurn(state);
|
|
1145
|
+
if (!result.ok) {
|
|
1146
|
+
this.#logger.warn(`stop card failed: ${result.reason}`);
|
|
879
1147
|
return;
|
|
880
1148
|
}
|
|
881
|
-
|
|
882
|
-
|
|
1149
|
+
state.stopRequested = true;
|
|
1150
|
+
if (state.progress?.messageId === action.messageId) {
|
|
1151
|
+
await this.#renderProgress(state);
|
|
1152
|
+
await state.progress?.flush();
|
|
1153
|
+
}
|
|
883
1154
|
}
|
|
884
1155
|
async #rename(state, title) {
|
|
885
1156
|
const service = this.#sessionTitleService();
|
|
@@ -1044,7 +1315,9 @@ export class Bridge {
|
|
|
1044
1315
|
* agent. Missing service warns once; setPolicy errors never block the turn.
|
|
1045
1316
|
*/
|
|
1046
1317
|
#applyApprovalPolicy(agent) {
|
|
1047
|
-
const policy = this.#options.approvalPolicy
|
|
1318
|
+
const policy = this.#options.approvalPolicy;
|
|
1319
|
+
if (policy === undefined)
|
|
1320
|
+
return; // never override dsh's own default
|
|
1048
1321
|
this.#logger.info(`agent approval policy=${policy}`);
|
|
1049
1322
|
const provider = this.#options.approvalProvider;
|
|
1050
1323
|
if (provider === undefined)
|
|
@@ -1071,11 +1344,13 @@ export class Bridge {
|
|
|
1071
1344
|
const state = this.#states.get(key);
|
|
1072
1345
|
if (state === undefined || state.ownerOpenId === undefined)
|
|
1073
1346
|
return undefined;
|
|
1347
|
+
const permissionLine = this.#permissionLineFor(state);
|
|
1074
1348
|
return {
|
|
1075
1349
|
scopeKey: state.key,
|
|
1076
1350
|
chatId: state.chatId,
|
|
1077
1351
|
ownerOpenId: state.ownerOpenId,
|
|
1078
1352
|
sessionId,
|
|
1353
|
+
...(permissionLine === undefined ? {} : { permissionLine }),
|
|
1079
1354
|
};
|
|
1080
1355
|
}
|
|
1081
1356
|
/**
|
|
@@ -1345,6 +1620,10 @@ export class Bridge {
|
|
|
1345
1620
|
}
|
|
1346
1621
|
}
|
|
1347
1622
|
#bindSession(state, sessionId) {
|
|
1623
|
+
if (state.sessionId !== undefined && state.sessionId !== sessionId) {
|
|
1624
|
+
this.#sessionMetrics.delete(state.key);
|
|
1625
|
+
this.#metricsFallbackWarned.delete(state.key);
|
|
1626
|
+
}
|
|
1348
1627
|
for (const [id, key] of this.#scopeBySession) {
|
|
1349
1628
|
if (key === state.key && id !== sessionId)
|
|
1350
1629
|
this.#scopeBySession.delete(id);
|
|
@@ -1437,13 +1716,18 @@ export class Bridge {
|
|
|
1437
1716
|
lastOutput: '',
|
|
1438
1717
|
reactionQueue: [],
|
|
1439
1718
|
reactionStarts: new Map(),
|
|
1719
|
+
logEvents: [],
|
|
1440
1720
|
};
|
|
1441
1721
|
this.#states.set(key, created);
|
|
1442
1722
|
return created;
|
|
1443
1723
|
}
|
|
1444
1724
|
#makeCardStream(state) {
|
|
1445
1725
|
const channel = {
|
|
1446
|
-
create: (card) =>
|
|
1726
|
+
create: async (card) => {
|
|
1727
|
+
const sent = await this.#sendNewCard(state, card);
|
|
1728
|
+
this.#stopCards.set(sent.messageId, state.key);
|
|
1729
|
+
return sent;
|
|
1730
|
+
},
|
|
1447
1731
|
update: async (messageId, card) => {
|
|
1448
1732
|
await this.#transport.updateCard(messageId, card);
|
|
1449
1733
|
},
|
|
@@ -1467,10 +1751,119 @@ export class Bridge {
|
|
|
1467
1751
|
if (stream === undefined)
|
|
1468
1752
|
return;
|
|
1469
1753
|
const snapshot = state.status.get();
|
|
1470
|
-
await stream.set(this.#progressCardFor(state, snapshot));
|
|
1754
|
+
await stream.set(await this.#progressCardFor(state, snapshot));
|
|
1755
|
+
}
|
|
1756
|
+
/** Full-session metrics for a card; cold cache reads the log once. */
|
|
1757
|
+
async #metricsFor(state, snapshot) {
|
|
1758
|
+
const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
|
|
1759
|
+
if (sessionId === undefined)
|
|
1760
|
+
return aggregateMetrics(state.logEvents);
|
|
1761
|
+
let cached = this.#sessionMetrics.get(state.key);
|
|
1762
|
+
if (cached === undefined || cached.sessionId !== sessionId) {
|
|
1763
|
+
const provider = this.#options.usageProvider;
|
|
1764
|
+
if (provider === undefined) {
|
|
1765
|
+
if (!this.#metricsFallbackWarned.has(state.key)) {
|
|
1766
|
+
this.#metricsFallbackWarned.add(state.key);
|
|
1767
|
+
this.#logger.warn('metrics fallback: process buffer');
|
|
1768
|
+
}
|
|
1769
|
+
return aggregateMetrics(state.logEvents);
|
|
1770
|
+
}
|
|
1771
|
+
try {
|
|
1772
|
+
const events = await provider(sessionId);
|
|
1773
|
+
if (events !== undefined) {
|
|
1774
|
+
cached = {
|
|
1775
|
+
sessionId,
|
|
1776
|
+
metrics: aggregateMetrics(events),
|
|
1777
|
+
refreshedAtSeq: events.length,
|
|
1778
|
+
turn: snapshot.turn,
|
|
1779
|
+
};
|
|
1780
|
+
this.#sessionMetrics.set(state.key, cached);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
// fall through to the process buffer
|
|
1785
|
+
}
|
|
1786
|
+
if (cached === undefined || cached.sessionId !== sessionId) {
|
|
1787
|
+
if (!this.#metricsFallbackWarned.has(state.key)) {
|
|
1788
|
+
this.#metricsFallbackWarned.add(state.key);
|
|
1789
|
+
this.#logger.warn('metrics fallback: process buffer');
|
|
1790
|
+
}
|
|
1791
|
+
return aggregateMetrics(state.logEvents);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
const base = cached.metrics;
|
|
1795
|
+
// During a turn whose events are not in the cached snapshot yet, add the
|
|
1796
|
+
// in-memory turn delta so numbers move monotonically until turn/end.
|
|
1797
|
+
if (!state.running || snapshot.turn <= cached.turn)
|
|
1798
|
+
return base;
|
|
1799
|
+
const usage = snapshot.usageTurn;
|
|
1800
|
+
const input = base.inputTokens + (usage?.input ?? 0);
|
|
1801
|
+
const output = base.outputTokens + (usage?.output ?? 0);
|
|
1802
|
+
const cacheRead = base.cacheReadTokens + (usage?.cacheRead ?? 0);
|
|
1803
|
+
const denominator = input + cacheRead;
|
|
1804
|
+
const elapsed = snapshot.startedAt === undefined ? undefined : this.#now() - snapshot.startedAt;
|
|
1805
|
+
const tokensPerSecond = elapsed !== undefined && elapsed >= 1000 && (usage?.output ?? 0) > 0
|
|
1806
|
+
? (usage?.output ?? 0) / (elapsed / 1000)
|
|
1807
|
+
: undefined;
|
|
1808
|
+
return {
|
|
1809
|
+
rounds: Math.max(base.rounds, snapshot.turn),
|
|
1810
|
+
steps: base.steps + snapshot.steps,
|
|
1811
|
+
totalTokens: base.totalTokens + (usage?.total ?? 0),
|
|
1812
|
+
inputTokens: input,
|
|
1813
|
+
outputTokens: output,
|
|
1814
|
+
cacheReadTokens: cacheRead,
|
|
1815
|
+
...(tokensPerSecond === undefined ? {} : { tokensPerSecond }),
|
|
1816
|
+
...(denominator === 0 ? {} : { cacheHitPercent: Math.round((cacheRead / denominator) * 100) }),
|
|
1817
|
+
...(base.contextPercent === undefined ? {} : { contextPercent: base.contextPercent }),
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
/** Recompute the full-session metrics once after each turn/end. */
|
|
1821
|
+
async #refreshSessionMetrics(state) {
|
|
1822
|
+
const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
|
|
1823
|
+
if (sessionId === undefined)
|
|
1824
|
+
return;
|
|
1825
|
+
const provider = this.#options.usageProvider;
|
|
1826
|
+
if (provider === undefined)
|
|
1827
|
+
return;
|
|
1828
|
+
try {
|
|
1829
|
+
const events = await provider(sessionId);
|
|
1830
|
+
if (events === undefined)
|
|
1831
|
+
return;
|
|
1832
|
+
this.#sessionMetrics.set(state.key, {
|
|
1833
|
+
sessionId,
|
|
1834
|
+
metrics: aggregateMetrics(events),
|
|
1835
|
+
refreshedAtSeq: events.length,
|
|
1836
|
+
turn: state.status.get().turn,
|
|
1837
|
+
});
|
|
1838
|
+
this.#metricsFallbackWarned.delete(state.key);
|
|
1839
|
+
}
|
|
1840
|
+
catch (error) {
|
|
1841
|
+
this.#logger.warn(`metrics refresh failed: ${errorMessage(error)}`);
|
|
1842
|
+
}
|
|
1471
1843
|
}
|
|
1472
|
-
#progressCardFor(state, snapshot) {
|
|
1473
|
-
|
|
1844
|
+
async #progressCardFor(state, snapshot) {
|
|
1845
|
+
const extraLines = [];
|
|
1846
|
+
if (snapshot.compaction === 'running')
|
|
1847
|
+
extraLines.push('上下文压缩中…');
|
|
1848
|
+
else if (snapshot.compaction === 'done') {
|
|
1849
|
+
extraLines.push(snapshot.compactionKept === undefined
|
|
1850
|
+
? '已压缩'
|
|
1851
|
+
: `已压缩 · 保留 ${snapshot.compactionKept} 条`);
|
|
1852
|
+
}
|
|
1853
|
+
else if (snapshot.compaction === 'failed')
|
|
1854
|
+
extraLines.push('上下文压缩失败');
|
|
1855
|
+
if (snapshot.teamLine !== undefined)
|
|
1856
|
+
extraLines.push(snapshot.teamLine);
|
|
1857
|
+
const metricsMode = this.#config.metrics ?? 'compact';
|
|
1858
|
+
if (metricsMode !== 'off') {
|
|
1859
|
+
const metricsLine = formatMetricsLine(await this.#metricsFor(state, snapshot), metricsMode);
|
|
1860
|
+
if (metricsLine.length > 0)
|
|
1861
|
+
extraLines.push(metricsLine);
|
|
1862
|
+
}
|
|
1863
|
+
if (state.stopRequested === true) {
|
|
1864
|
+
extraLines.push('已停止当前回合。后台任务仍在运行,完成后结果会回来。');
|
|
1865
|
+
}
|
|
1866
|
+
return progressCardForSnapshot(snapshot, state.title, recentEventText(snapshot), state.lastOutput, this.#now(), state.sessionId !== undefined && this.#presetFailures.has(state.sessionId), extraLines, state.running && state.stopRequested !== true);
|
|
1474
1867
|
}
|
|
1475
1868
|
async #replyText(state, text) {
|
|
1476
1869
|
if (text.length === 0)
|
|
@@ -1490,9 +1883,11 @@ export class Bridge {
|
|
|
1490
1883
|
}
|
|
1491
1884
|
}
|
|
1492
1885
|
/** Build the process card for one snapshot (exported for render tests). */
|
|
1493
|
-
export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tail, now, presetMissing = false) {
|
|
1886
|
+
export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tail, now, presetMissing = false, extraLines = [], stopButton = false) {
|
|
1494
1887
|
return progressCard({
|
|
1495
1888
|
title: '飞书会话 · 过程',
|
|
1889
|
+
...(extraLines.length === 0 ? {} : { extraLines }),
|
|
1890
|
+
...(stopButton ? { stopButton: true } : {}),
|
|
1496
1891
|
stateLabel: statusLabel(snapshot.state),
|
|
1497
1892
|
steps: snapshot.steps,
|
|
1498
1893
|
tools: snapshot.tools,
|
|
@@ -1515,3 +1910,16 @@ function imageMediaType(contentType) {
|
|
|
1515
1910
|
}
|
|
1516
1911
|
return 'image/jpeg';
|
|
1517
1912
|
}
|
|
1913
|
+
function translatePermissionOutput(text, requested, lang) {
|
|
1914
|
+
const dict = t(lang);
|
|
1915
|
+
const success = /^preset\s+(\S+)\s*$/.exec(text.trim());
|
|
1916
|
+
if (success !== null) {
|
|
1917
|
+
const name = success[1];
|
|
1918
|
+
return dict.switchSucceeded(presetLabel(lang, name), name);
|
|
1919
|
+
}
|
|
1920
|
+
const unknown = /^unknown preset "([^"]+)" \(available: ([^)]*)\)\s*$/.exec(text.trim());
|
|
1921
|
+
if (unknown !== null) {
|
|
1922
|
+
return dict.unknownPreset(unknown[1], unknown[2]);
|
|
1923
|
+
}
|
|
1924
|
+
return dict.switchedElsewhere(text);
|
|
1925
|
+
}
|
package/dist/bridge/types.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { HarnessCommandResult } from '../commands/harness.js';
|
|
|
13
13
|
import type { LoggerLike } from '../logger.js';
|
|
14
14
|
import type { Clock } from '../render/stream.js';
|
|
15
15
|
import type { DoctorInputs, DoctorSessionInfo } from '../doctor.js';
|
|
16
|
+
import type { SessionExportProvider } from '../export.js';
|
|
16
17
|
import type { InteractiveRuntime } from '../interactive/runtime.js';
|
|
17
18
|
import type { SessionStore } from '../session-key.js';
|
|
18
19
|
import type { Transport } from '../transport/types.js';
|
|
@@ -20,6 +21,15 @@ import type { Transport } from '../transport/types.js';
|
|
|
20
21
|
export type BusyMode = 'queue' | 'steer' | 'interrupt';
|
|
21
22
|
/** Session approval policy understood by dsh's approval service. */
|
|
22
23
|
export type ApprovalPolicy = 'ask' | 'never';
|
|
24
|
+
/** The slice of dsh's permissionPresets service the bridge needs. */
|
|
25
|
+
export interface PermissionPresetsLike {
|
|
26
|
+
readonly names: readonly string[];
|
|
27
|
+
current(session: unknown): string;
|
|
28
|
+
resolve?(name: string): {
|
|
29
|
+
readonly sandbox?: unknown;
|
|
30
|
+
readonly approval?: unknown;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
23
33
|
/** The slice of dsh's attachment service the bridge needs. */
|
|
24
34
|
export interface AttachmentStoreLike {
|
|
25
35
|
admitPromptContent(content: readonly unknown[]): Promise<readonly unknown[]>;
|
|
@@ -120,6 +130,8 @@ export interface BridgeConfig {
|
|
|
120
130
|
readonly maxSendImageBytes?: number;
|
|
121
131
|
/** Allowed roots for outbound MEDIA paths; empty = session cwd. */
|
|
122
132
|
readonly mediaRoots?: readonly string[];
|
|
133
|
+
/** Process-card/usage metrics mode (default compact). */
|
|
134
|
+
readonly metrics?: import('../metrics.js').MetricsMode;
|
|
123
135
|
/** Admin allowlist; empty disables access control (default). */
|
|
124
136
|
readonly admins?: readonly string[];
|
|
125
137
|
}
|
|
@@ -153,9 +165,17 @@ export interface BridgeOptions {
|
|
|
153
165
|
readonly rootCtx?: unknown;
|
|
154
166
|
/** Lazily resolve dsh's attachment service (image admission / file refs). */
|
|
155
167
|
readonly attachmentProvider?: () => AttachmentStoreLike | undefined;
|
|
168
|
+
/** Optional session-log provider; falls back to reading $DSH_HOME on disk. */
|
|
169
|
+
readonly exportProvider?: SessionExportProvider;
|
|
170
|
+
/** Lazily resolve dsh's permissionPresets service. */
|
|
171
|
+
readonly permissionPresetsProvider?: () => PermissionPresetsLike | undefined;
|
|
172
|
+
/** Session log reader for `/usage` (`sessionQuery.readSession().events`). */
|
|
173
|
+
readonly usageProvider?: (sessionId: string) => Promise<readonly unknown[] | undefined> | readonly unknown[] | undefined;
|
|
174
|
+
/** Locale source (dsh settings/env); default zh. */
|
|
175
|
+
readonly languageProvider?: () => import('../i18n.js').Lang;
|
|
156
176
|
/** Lazily resolve dsh's approval service (agent-scoped setPolicy). */
|
|
157
177
|
readonly approvalProvider?: () => ApprovalServiceLike | undefined;
|
|
158
|
-
/**
|
|
178
|
+
/** Explicit session approval policy; undefined leaves dsh untouched. */
|
|
159
179
|
readonly approvalPolicy?: ApprovalPolicy;
|
|
160
180
|
/** `/doctor` data collector (wired by the gateway; not available in tests by default). */
|
|
161
181
|
readonly doctorInputs?: (session: DoctorSessionInfo) => DoctorInputs | Promise<DoctorInputs>;
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The surface owns eight commands; every other slash line is forwarded to the
|
|
6
6
|
* harness command registry (`runHarnessCommand`), except Web-only commands that
|
|
7
|
-
* make no sense on a chat surface. `/export` is
|
|
8
|
-
* is intentionally
|
|
7
|
+
* make no sense on a chat surface. `/export` is now implemented by the surface
|
|
8
|
+
* itself, so the list is intentionally empty; keep the seam for future entries.
|
|
9
9
|
*
|
|
10
10
|
* @module dsh-caduceus/commands/dispatch
|
|
11
11
|
*/
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The surface owns eight commands; every other slash line is forwarded to the
|
|
6
6
|
* harness command registry (`runHarnessCommand`), except Web-only commands that
|
|
7
|
-
* make no sense on a chat surface. `/export` is
|
|
8
|
-
* is intentionally
|
|
7
|
+
* make no sense on a chat surface. `/export` is now implemented by the surface
|
|
8
|
+
* itself, so the list is intentionally empty; keep the seam for future entries.
|
|
9
9
|
*
|
|
10
10
|
* @module dsh-caduceus/commands/dispatch
|
|
11
11
|
*/
|
|
12
12
|
import { parseCommand } from './parse.js';
|
|
13
13
|
/** Commands that exist only in the Web UI and are refused on Feishu. */
|
|
14
|
-
export const WEB_ONLY_DENYLIST = [
|
|
14
|
+
export const WEB_ONLY_DENYLIST = [];
|
|
15
15
|
/** True when a slash-command name must not be forwarded to the harness. */
|
|
16
16
|
export function isWebOnlyCommand(name) {
|
|
17
17
|
return WEB_ONLY_DENYLIST.includes(name.toLowerCase());
|