@botcord/daemon 0.2.83 → 0.2.85
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/dist/cloud-daemon.js +25 -1
- package/dist/cloud-gateway-runtime.d.ts +18 -1
- package/dist/cloud-gateway-runtime.js +33 -3
- package/dist/provision.d.ts +8 -0
- package/dist/provision.js +3 -2
- package/dist/runtime-models.js +48 -6
- package/package.json +2 -2
- package/src/__tests__/cloud-gateway-runtime.test.ts +62 -1
- package/src/__tests__/runtime-models.test.ts +50 -0
- package/src/cloud-daemon.ts +25 -1
- package/src/cloud-gateway-runtime.ts +53 -2
- package/src/provision.ts +18 -2
- package/src/runtime-models.ts +46 -6
package/dist/cloud-daemon.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* See ``docs/cloud-agent-technical-design.md`` §4 + §6.
|
|
11
11
|
*/
|
|
12
|
-
import { shouldWake } from "@botcord/protocol-core";
|
|
12
|
+
import { CONTROL_FRAME_TYPES, shouldWake } from "@botcord/protocol-core";
|
|
13
13
|
import { Gateway, resolveTranscriptEnabled, } from "./gateway/index.js";
|
|
14
14
|
import { ActivityTracker } from "./activity-tracker.js";
|
|
15
15
|
import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
|
|
@@ -201,10 +201,34 @@ export async function startCloudDaemon(opts) {
|
|
|
201
201
|
if (!opts.disableControlChannel) {
|
|
202
202
|
const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
|
|
203
203
|
const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
|
|
204
|
+
// Forward-declare controlChannel-bound emitter: the provisioner is
|
|
205
|
+
// built before the channel exists, so the closure captures the slot
|
|
206
|
+
// and reads it back lazily once cloud-daemon assigns the instance.
|
|
207
|
+
const cloudGatewayTypingEmitter = (event) => {
|
|
208
|
+
const ch = controlChannel;
|
|
209
|
+
if (!ch)
|
|
210
|
+
return;
|
|
211
|
+
ch.send({
|
|
212
|
+
id: `cgrs_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
213
|
+
type: CONTROL_FRAME_TYPES.CLOUD_GATEWAY_RUNTIME_STATUS,
|
|
214
|
+
params: {
|
|
215
|
+
eventId: event.eventId,
|
|
216
|
+
turnId: event.turnId,
|
|
217
|
+
gatewayId: event.gatewayId,
|
|
218
|
+
agentId: event.agentId,
|
|
219
|
+
conversationId: event.conversationId,
|
|
220
|
+
kind: "typing",
|
|
221
|
+
phase: event.phase,
|
|
222
|
+
traceId: event.traceId ?? null,
|
|
223
|
+
},
|
|
224
|
+
ts: Date.now(),
|
|
225
|
+
});
|
|
226
|
+
};
|
|
204
227
|
const provisioner = provisionerFactory({
|
|
205
228
|
gateway,
|
|
206
229
|
policyResolver,
|
|
207
230
|
onAgentInstalled,
|
|
231
|
+
cloudGatewayTypingEmitter,
|
|
208
232
|
});
|
|
209
233
|
const ControlChannelCtor = opts.controlChannelFactory ?? ControlChannel;
|
|
210
234
|
controlChannel = new ControlChannelCtor({
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { type GatewayInboundFrame } from "@botcord/protocol-core";
|
|
2
2
|
import type { Gateway, GatewayLogger } from "./gateway/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Fire-and-forget hook invoked when the dispatcher signals "typing" while
|
|
5
|
+
* a cloud-gateway runtime turn is in flight. The cloud daemon wires this
|
|
6
|
+
* to a control-plane `cloud_gateway_runtime_status` push so the Hub can
|
|
7
|
+
* relay it to the ingress runtime WS and the third-party provider can
|
|
8
|
+
* render a typing indicator.
|
|
9
|
+
*/
|
|
10
|
+
export interface CloudGatewayTypingEvent {
|
|
11
|
+
eventId: string;
|
|
12
|
+
turnId: string;
|
|
13
|
+
gatewayId: string;
|
|
14
|
+
agentId: string;
|
|
15
|
+
conversationId: string;
|
|
16
|
+
phase: "started" | "stopped";
|
|
17
|
+
traceId?: string | null;
|
|
18
|
+
}
|
|
19
|
+
export type CloudGatewayTypingEmitter = (event: CloudGatewayTypingEvent) => void;
|
|
3
20
|
export interface CloudGatewayRuntimeResult {
|
|
4
21
|
accepted: boolean;
|
|
5
22
|
eventId: string;
|
|
@@ -26,4 +43,4 @@ export interface CloudGatewayRuntimeResult {
|
|
|
26
43
|
* gateway_outbound_complete for gateway-ingress, which then calls the real
|
|
27
44
|
* provider API.
|
|
28
45
|
*/
|
|
29
|
-
export declare function handleCloudGatewayRuntimeInbound(gateway: Gateway, frame: GatewayInboundFrame, log?: GatewayLogger): Promise<CloudGatewayRuntimeResult>;
|
|
46
|
+
export declare function handleCloudGatewayRuntimeInbound(gateway: Gateway, frame: GatewayInboundFrame, log?: GatewayLogger, onTyping?: CloudGatewayTypingEmitter): Promise<CloudGatewayRuntimeResult>;
|
|
@@ -9,7 +9,7 @@ import { RUNTIME_FRAME_TYPES, } from "@botcord/protocol-core";
|
|
|
9
9
|
* gateway_outbound_complete for gateway-ingress, which then calls the real
|
|
10
10
|
* provider API.
|
|
11
11
|
*/
|
|
12
|
-
export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
|
|
12
|
+
export async function handleCloudGatewayRuntimeInbound(gateway, frame, log, onTyping) {
|
|
13
13
|
if (frame.type !== RUNTIME_FRAME_TYPES.GATEWAY_INBOUND) {
|
|
14
14
|
return rejected(frame, "bad_frame_type", `unsupported frame type "${frame.type}"`);
|
|
15
15
|
}
|
|
@@ -25,6 +25,7 @@ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
|
|
|
25
25
|
let accepted = false;
|
|
26
26
|
let outboundText = null;
|
|
27
27
|
let providerMessageId;
|
|
28
|
+
const turnId = `turn_${frame.event_id}`;
|
|
28
29
|
const channel = createRuntimeRelayChannel({
|
|
29
30
|
id: frame.gateway_id,
|
|
30
31
|
provider: frame.provider,
|
|
@@ -34,6 +35,29 @@ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
|
|
|
34
35
|
providerMessageId = ctx.message.traceId ?? null;
|
|
35
36
|
return { providerMessageId };
|
|
36
37
|
},
|
|
38
|
+
onTyping: onTyping
|
|
39
|
+
? (ctx) => {
|
|
40
|
+
try {
|
|
41
|
+
onTyping({
|
|
42
|
+
eventId: frame.event_id,
|
|
43
|
+
turnId,
|
|
44
|
+
gatewayId: frame.gateway_id,
|
|
45
|
+
agentId: frame.agent_id,
|
|
46
|
+
conversationId: frame.message.conversation.id,
|
|
47
|
+
phase: "started",
|
|
48
|
+
traceId: ctx.traceId ?? null,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
log?.warn?.("cloud gateway typing emit failed", {
|
|
53
|
+
eventId: frame.event_id,
|
|
54
|
+
gatewayId: frame.gateway_id,
|
|
55
|
+
agentId: frame.agent_id,
|
|
56
|
+
error: err instanceof Error ? err.message : String(err),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
: undefined,
|
|
37
61
|
});
|
|
38
62
|
const message = {
|
|
39
63
|
...frame.message,
|
|
@@ -67,7 +91,7 @@ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
|
|
|
67
91
|
gatewayId: frame.gateway_id,
|
|
68
92
|
agentId: frame.agent_id,
|
|
69
93
|
conversationId: frame.message.conversation.id,
|
|
70
|
-
turnId
|
|
94
|
+
turnId,
|
|
71
95
|
...(outboundText !== null
|
|
72
96
|
? {
|
|
73
97
|
outbound: {
|
|
@@ -83,7 +107,7 @@ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
|
|
|
83
107
|
}
|
|
84
108
|
function createRuntimeRelayChannel(opts) {
|
|
85
109
|
let lastSendAt;
|
|
86
|
-
|
|
110
|
+
const adapter = {
|
|
87
111
|
id: opts.id,
|
|
88
112
|
type: opts.provider,
|
|
89
113
|
async start() {
|
|
@@ -108,6 +132,12 @@ function createRuntimeRelayChannel(opts) {
|
|
|
108
132
|
};
|
|
109
133
|
},
|
|
110
134
|
};
|
|
135
|
+
if (opts.onTyping) {
|
|
136
|
+
adapter.typing = async (ctx) => {
|
|
137
|
+
opts.onTyping(ctx);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return adapter;
|
|
111
141
|
}
|
|
112
142
|
function rejected(frame, code, message) {
|
|
113
143
|
return {
|
package/dist/provision.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
|
|
|
4
4
|
import type { GatewayRuntimeSnapshot } from "./gateway/index.js";
|
|
5
5
|
import { type DaemonConfig } from "./config.js";
|
|
6
6
|
import type { LoginSessionStore } from "./gateway/channels/login-session.js";
|
|
7
|
+
import { type CloudGatewayTypingEmitter } from "./cloud-gateway-runtime.js";
|
|
7
8
|
/**
|
|
8
9
|
* Information passed to {@link OnAgentInstalledHook} after a successful
|
|
9
10
|
* provision. Mirrors the credential fields the daemon's per-agent caches
|
|
@@ -58,6 +59,13 @@ export interface ProvisionerOptions {
|
|
|
58
59
|
* spin up a default in-memory store.
|
|
59
60
|
*/
|
|
60
61
|
loginSessions?: LoginSessionStore;
|
|
62
|
+
/**
|
|
63
|
+
* Optional callback invoked when an in-flight `cloud_gateway_runtime_inbound`
|
|
64
|
+
* fires a typing event. The cloud daemon wires this to its control-plane
|
|
65
|
+
* connection so the Hub can relay the hint to the live ingress runtime WS.
|
|
66
|
+
* Absent for local daemons (no ingress path).
|
|
67
|
+
*/
|
|
68
|
+
cloudGatewayTypingEmitter?: CloudGatewayTypingEmitter;
|
|
61
69
|
}
|
|
62
70
|
/** The value a frame handler returns (minus the `id` which the channel fills in). */
|
|
63
71
|
type AckBody = Omit<ControlAck, "id">;
|
package/dist/provision.js
CHANGED
|
@@ -20,7 +20,7 @@ import { discoverAgentCredentials } from "./agent-discovery.js";
|
|
|
20
20
|
import { resolveMemoryDir } from "./working-memory.js";
|
|
21
21
|
import { discoverRuntimeModelCatalog } from "./runtime-models.js";
|
|
22
22
|
import { buildRuntimeSelectionExtraArgs, mergeRuntimeExtraArgs, } from "./runtime-route-options.js";
|
|
23
|
-
import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
|
|
23
|
+
import { handleCloudGatewayRuntimeInbound, } from "./cloud-gateway-runtime.js";
|
|
24
24
|
/**
|
|
25
25
|
* Build a dispatcher function that routes a `ControlFrame` to the right
|
|
26
26
|
* handler. Returned function signature matches
|
|
@@ -31,6 +31,7 @@ export function createProvisioner(opts) {
|
|
|
31
31
|
const register = opts.register ?? BotCordClient.register;
|
|
32
32
|
const policyResolver = opts.policyResolver;
|
|
33
33
|
const onAgentInstalled = opts.onAgentInstalled;
|
|
34
|
+
const cloudGatewayTypingEmitter = opts.cloudGatewayTypingEmitter;
|
|
34
35
|
const gatewayControl = createGatewayControl({
|
|
35
36
|
gateway,
|
|
36
37
|
...(opts.loginSessions ? { loginSessions: opts.loginSessions } : {}),
|
|
@@ -286,7 +287,7 @@ export function createProvisioner(opts) {
|
|
|
286
287
|
},
|
|
287
288
|
};
|
|
288
289
|
}
|
|
289
|
-
const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame);
|
|
290
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame, undefined, cloudGatewayTypingEmitter);
|
|
290
291
|
return result.accepted
|
|
291
292
|
? { ok: true, result }
|
|
292
293
|
: {
|
package/dist/runtime-models.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
const MODEL_LIST_TIMEOUT_MS = 5000;
|
|
6
6
|
const MODEL_LIST_MAX_BUFFER = 16 * 1024 * 1024;
|
|
7
|
-
const RUNTIME_CATALOG_CACHE_VERSION =
|
|
7
|
+
const RUNTIME_CATALOG_CACHE_VERSION = 2;
|
|
8
8
|
const RUNTIME_CATALOG_CACHE_FRESH_MS = 10 * 60 * 1000;
|
|
9
9
|
const DEFAULT_RUNTIME_CATALOG_CACHE_DIR = path.join(homedir(), ".botcord", "daemon", "runtime-catalog-cache");
|
|
10
10
|
const CLAUDE_ALIAS_MODELS = [
|
|
@@ -102,7 +102,7 @@ function runtimeCatalogStrategy(entry) {
|
|
|
102
102
|
discoverFresh: () => discoverDeepseekCatalog(entry.result.path),
|
|
103
103
|
fallback: () => ({
|
|
104
104
|
models: DEEPSEEK_FALLBACK_MODELS.slice(),
|
|
105
|
-
parameters: discoverDeepseekParameters(),
|
|
105
|
+
parameters: discoverDeepseekParameters(entry.result.path),
|
|
106
106
|
}),
|
|
107
107
|
};
|
|
108
108
|
case "kimi-cli":
|
|
@@ -392,7 +392,7 @@ function discoverCodexParameters(rawCatalog) {
|
|
|
392
392
|
function discoverDeepseekCatalog(command) {
|
|
393
393
|
return {
|
|
394
394
|
models: discoverDeepseekModels(command),
|
|
395
|
-
parameters: discoverDeepseekParameters(),
|
|
395
|
+
parameters: discoverDeepseekParameters(command),
|
|
396
396
|
};
|
|
397
397
|
}
|
|
398
398
|
export function discoverDeepseekModels(command) {
|
|
@@ -416,8 +416,9 @@ export function parseDeepseekModelList(raw) {
|
|
|
416
416
|
}
|
|
417
417
|
return out.length ? out : undefined;
|
|
418
418
|
}
|
|
419
|
-
function discoverDeepseekParameters() {
|
|
419
|
+
function discoverDeepseekParameters(command) {
|
|
420
420
|
const config = readConfigScalars(path.join(homedir(), ".deepseek", "config.toml"));
|
|
421
|
+
const reasoningEffortValues = discoverDeepseekReasoningEffortValues(command);
|
|
421
422
|
return [
|
|
422
423
|
compactParameter({
|
|
423
424
|
id: "model",
|
|
@@ -439,8 +440,9 @@ function discoverDeepseekParameters() {
|
|
|
439
440
|
compactParameter({
|
|
440
441
|
id: "reasoning_effort",
|
|
441
442
|
displayName: "Reasoning effort",
|
|
442
|
-
type: "string",
|
|
443
|
-
flag: "
|
|
443
|
+
type: reasoningEffortValues.length > 0 ? "enum" : "string",
|
|
444
|
+
flag: "--reasoning-effort",
|
|
445
|
+
values: reasoningEffortValues.length > 0 ? reasoningEffortValues : undefined,
|
|
444
446
|
defaultValue: config.reasoning_effort,
|
|
445
447
|
source: config.reasoning_effort ? "config" : "cli",
|
|
446
448
|
}),
|
|
@@ -462,6 +464,46 @@ function discoverDeepseekParameters() {
|
|
|
462
464
|
}),
|
|
463
465
|
];
|
|
464
466
|
}
|
|
467
|
+
function discoverDeepseekReasoningEffortValues(command) {
|
|
468
|
+
const candidates = deepseekRuntimeTemplateCandidates(command);
|
|
469
|
+
const values = new Set();
|
|
470
|
+
for (const candidate of candidates) {
|
|
471
|
+
try {
|
|
472
|
+
const raw = readFileSync(candidate)
|
|
473
|
+
.toString("latin1")
|
|
474
|
+
.replace(/[^\x20-\x7E]+/g, "\n");
|
|
475
|
+
const templateRe = /Thinking mode \(DeepSeek V4 reasoning effort\):[\s\S]{0,256}?#\s*((?:"[^"]+"\s*(?:\|\s*)?)+)/g;
|
|
476
|
+
for (const match of raw.matchAll(templateRe)) {
|
|
477
|
+
const line = match[1] ?? "";
|
|
478
|
+
for (const valueMatch of line.matchAll(/"([^"]+)"/g)) {
|
|
479
|
+
const value = valueMatch[1]?.trim();
|
|
480
|
+
if (value && /^[A-Za-z0-9_.-]+$/.test(value))
|
|
481
|
+
values.add(value);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// Try the next candidate; runtime discovery should stay best-effort.
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return Array.from(values);
|
|
490
|
+
}
|
|
491
|
+
function deepseekRuntimeTemplateCandidates(command) {
|
|
492
|
+
if (!command)
|
|
493
|
+
return [];
|
|
494
|
+
const candidates = new Set();
|
|
495
|
+
if (existsSync(command))
|
|
496
|
+
candidates.add(command);
|
|
497
|
+
const dir = path.dirname(command);
|
|
498
|
+
for (const candidate of [
|
|
499
|
+
path.join(dir, "deepseek-tui"),
|
|
500
|
+
path.join(dir, "downloads", "deepseek-tui"),
|
|
501
|
+
]) {
|
|
502
|
+
if (existsSync(candidate))
|
|
503
|
+
candidates.add(candidate);
|
|
504
|
+
}
|
|
505
|
+
return Array.from(candidates);
|
|
506
|
+
}
|
|
465
507
|
function discoverKimiCatalog() {
|
|
466
508
|
const configPath = path.join(homedir(), ".kimi", "config.toml");
|
|
467
509
|
if (!existsSync(configPath))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.85",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@botcord/cli": "^0.1.7",
|
|
31
|
-
"@botcord/protocol-core": "^0.2.
|
|
31
|
+
"@botcord/protocol-core": "^0.2.12",
|
|
32
32
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
33
33
|
"ws": "^8.20.1"
|
|
34
34
|
},
|
|
@@ -4,7 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
5
|
import { RUNTIME_FRAME_TYPES, type GatewayInboundFrame } from "@botcord/protocol-core";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
handleCloudGatewayRuntimeInbound,
|
|
9
|
+
type CloudGatewayTypingEvent,
|
|
10
|
+
} from "../cloud-gateway-runtime.js";
|
|
8
11
|
import { Gateway, type ChannelAdapter } from "../gateway/index.js";
|
|
9
12
|
|
|
10
13
|
describe("cloud gateway runtime inbound", () => {
|
|
@@ -66,6 +69,64 @@ describe("cloud gateway runtime inbound", () => {
|
|
|
66
69
|
expect(result.outbound?.finalText).toBe("hello from runtime");
|
|
67
70
|
});
|
|
68
71
|
|
|
72
|
+
it("invokes the typing emitter when the dispatcher fires typing.started", async () => {
|
|
73
|
+
const typingEvents: CloudGatewayTypingEvent[] = [];
|
|
74
|
+
const gateway = new Gateway({
|
|
75
|
+
config: {
|
|
76
|
+
channels: [],
|
|
77
|
+
defaultRoute: { runtime: "fake", cwd: tmpDir },
|
|
78
|
+
},
|
|
79
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
80
|
+
createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
|
|
81
|
+
createRuntime: () => ({
|
|
82
|
+
id: "fake",
|
|
83
|
+
async run() {
|
|
84
|
+
return { text: "ok", newSessionId: "sess_typing" };
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
transcriptEnabled: false,
|
|
88
|
+
});
|
|
89
|
+
await gateway.start();
|
|
90
|
+
|
|
91
|
+
const frame: GatewayInboundFrame = {
|
|
92
|
+
type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
|
|
93
|
+
event_id: "evt_typing",
|
|
94
|
+
gateway_id: "gw_wc_1",
|
|
95
|
+
agent_id: "ag_typing",
|
|
96
|
+
provider: "wechat",
|
|
97
|
+
message: {
|
|
98
|
+
id: "wechat:alice:t1",
|
|
99
|
+
channel: "gw_wc_1",
|
|
100
|
+
accountId: "ag_typing",
|
|
101
|
+
conversation: { id: "wechat:user:alice", kind: "direct" },
|
|
102
|
+
sender: { id: "wechat:user:alice", kind: "user" },
|
|
103
|
+
text: "ping",
|
|
104
|
+
replyTo: null,
|
|
105
|
+
mentioned: true,
|
|
106
|
+
receivedAt: Date.now(),
|
|
107
|
+
trace: { id: "wechat:alice:t1", streamable: false },
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = await handleCloudGatewayRuntimeInbound(
|
|
112
|
+
gateway,
|
|
113
|
+
frame,
|
|
114
|
+
undefined,
|
|
115
|
+
(event) => typingEvents.push(event),
|
|
116
|
+
);
|
|
117
|
+
await gateway.stop("test");
|
|
118
|
+
|
|
119
|
+
expect(result.accepted).toBe(true);
|
|
120
|
+
expect(typingEvents.length).toBeGreaterThanOrEqual(1);
|
|
121
|
+
const first = typingEvents[0]!;
|
|
122
|
+
expect(first.eventId).toBe("evt_typing");
|
|
123
|
+
expect(first.gatewayId).toBe("gw_wc_1");
|
|
124
|
+
expect(first.agentId).toBe("ag_typing");
|
|
125
|
+
expect(first.conversationId).toBe("wechat:user:alice");
|
|
126
|
+
expect(first.phase).toBe("started");
|
|
127
|
+
expect(first.traceId).toBe("wechat:alice:t1");
|
|
128
|
+
});
|
|
129
|
+
|
|
69
130
|
it("rejects frames outside the token scope", async () => {
|
|
70
131
|
const gateway = new Gateway({
|
|
71
132
|
config: {
|
|
@@ -199,6 +199,56 @@ describe("runtime model discovery parsers", () => {
|
|
|
199
199
|
]);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
it("reads DeepSeek reasoning effort choices from the installed runtime template", () => {
|
|
203
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "daemon-deepseek-catalog-"));
|
|
204
|
+
const prevHome = process.env.HOME;
|
|
205
|
+
const prevCacheDir = process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
|
|
206
|
+
try {
|
|
207
|
+
const home = path.join(tmp, "home");
|
|
208
|
+
mkdirSync(path.join(home, ".deepseek"), { recursive: true });
|
|
209
|
+
process.env.HOME = home;
|
|
210
|
+
process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = path.join(tmp, "catalog-cache");
|
|
211
|
+
writeFileSync(
|
|
212
|
+
path.join(home, ".deepseek", "config.toml"),
|
|
213
|
+
'default_text_model = "deepseek-v4-pro"\nreasoning_effort = "turbo"\n',
|
|
214
|
+
);
|
|
215
|
+
const fakeDeepseek = path.join(tmp, "deepseek");
|
|
216
|
+
writeFileSync(
|
|
217
|
+
fakeDeepseek,
|
|
218
|
+
[
|
|
219
|
+
"binary prefix",
|
|
220
|
+
"# Thinking mode (DeepSeek V4 reasoning effort):",
|
|
221
|
+
'# "adaptive" | "disabled" | "turbo"',
|
|
222
|
+
'reasoning_effort = "adaptive"',
|
|
223
|
+
].join("\n"),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const catalog = discoverRuntimeModelCatalog({
|
|
227
|
+
id: "deepseek-tui",
|
|
228
|
+
displayName: "DeepSeek TUI",
|
|
229
|
+
binary: "deepseek",
|
|
230
|
+
supportsRun: true,
|
|
231
|
+
result: { available: true, path: fakeDeepseek },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(catalog.parameters).toContainEqual({
|
|
235
|
+
id: "reasoning_effort",
|
|
236
|
+
displayName: "Reasoning effort",
|
|
237
|
+
type: "enum",
|
|
238
|
+
flag: "--reasoning-effort",
|
|
239
|
+
values: ["adaptive", "disabled", "turbo"],
|
|
240
|
+
defaultValue: "turbo",
|
|
241
|
+
source: "config",
|
|
242
|
+
});
|
|
243
|
+
} finally {
|
|
244
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
245
|
+
else process.env.HOME = prevHome;
|
|
246
|
+
if (prevCacheDir === undefined) delete process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
|
|
247
|
+
else process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = prevCacheDir;
|
|
248
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
202
252
|
it("parses Kimi models from config.toml", () => {
|
|
203
253
|
expect(
|
|
204
254
|
parseKimiConfigModels(
|
package/src/cloud-daemon.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* See ``docs/cloud-agent-technical-design.md`` §4 + §6.
|
|
11
11
|
*/
|
|
12
|
-
import { shouldWake, type AttentionPolicy } from "@botcord/protocol-core";
|
|
12
|
+
import { CONTROL_FRAME_TYPES, shouldWake, type AttentionPolicy } from "@botcord/protocol-core";
|
|
13
|
+
import type { CloudGatewayTypingEmitter } from "./cloud-gateway-runtime.js";
|
|
13
14
|
import {
|
|
14
15
|
Gateway,
|
|
15
16
|
resolveTranscriptEnabled,
|
|
@@ -284,10 +285,33 @@ export async function startCloudDaemon(
|
|
|
284
285
|
if (!opts.disableControlChannel) {
|
|
285
286
|
const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
|
|
286
287
|
const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
|
|
288
|
+
// Forward-declare controlChannel-bound emitter: the provisioner is
|
|
289
|
+
// built before the channel exists, so the closure captures the slot
|
|
290
|
+
// and reads it back lazily once cloud-daemon assigns the instance.
|
|
291
|
+
const cloudGatewayTypingEmitter: CloudGatewayTypingEmitter = (event) => {
|
|
292
|
+
const ch = controlChannel;
|
|
293
|
+
if (!ch) return;
|
|
294
|
+
ch.send({
|
|
295
|
+
id: `cgrs_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
296
|
+
type: CONTROL_FRAME_TYPES.CLOUD_GATEWAY_RUNTIME_STATUS,
|
|
297
|
+
params: {
|
|
298
|
+
eventId: event.eventId,
|
|
299
|
+
turnId: event.turnId,
|
|
300
|
+
gatewayId: event.gatewayId,
|
|
301
|
+
agentId: event.agentId,
|
|
302
|
+
conversationId: event.conversationId,
|
|
303
|
+
kind: "typing",
|
|
304
|
+
phase: event.phase,
|
|
305
|
+
traceId: event.traceId ?? null,
|
|
306
|
+
},
|
|
307
|
+
ts: Date.now(),
|
|
308
|
+
});
|
|
309
|
+
};
|
|
287
310
|
const provisioner = provisionerFactory({
|
|
288
311
|
gateway,
|
|
289
312
|
policyResolver,
|
|
290
313
|
onAgentInstalled,
|
|
314
|
+
cloudGatewayTypingEmitter,
|
|
291
315
|
});
|
|
292
316
|
const ControlChannelCtor = opts.controlChannelFactory ?? ControlChannel;
|
|
293
317
|
controlChannel = new ControlChannelCtor({
|
|
@@ -8,11 +8,31 @@ import type {
|
|
|
8
8
|
ChannelSendContext,
|
|
9
9
|
ChannelSendResult,
|
|
10
10
|
ChannelStatusSnapshot,
|
|
11
|
+
ChannelTypingContext,
|
|
11
12
|
Gateway,
|
|
12
13
|
GatewayInboundMessage,
|
|
13
14
|
GatewayLogger,
|
|
14
15
|
} from "./gateway/index.js";
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Fire-and-forget hook invoked when the dispatcher signals "typing" while
|
|
19
|
+
* a cloud-gateway runtime turn is in flight. The cloud daemon wires this
|
|
20
|
+
* to a control-plane `cloud_gateway_runtime_status` push so the Hub can
|
|
21
|
+
* relay it to the ingress runtime WS and the third-party provider can
|
|
22
|
+
* render a typing indicator.
|
|
23
|
+
*/
|
|
24
|
+
export interface CloudGatewayTypingEvent {
|
|
25
|
+
eventId: string;
|
|
26
|
+
turnId: string;
|
|
27
|
+
gatewayId: string;
|
|
28
|
+
agentId: string;
|
|
29
|
+
conversationId: string;
|
|
30
|
+
phase: "started" | "stopped";
|
|
31
|
+
traceId?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CloudGatewayTypingEmitter = (event: CloudGatewayTypingEvent) => void;
|
|
35
|
+
|
|
16
36
|
export interface CloudGatewayRuntimeResult {
|
|
17
37
|
accepted: boolean;
|
|
18
38
|
eventId: string;
|
|
@@ -44,6 +64,7 @@ export async function handleCloudGatewayRuntimeInbound(
|
|
|
44
64
|
gateway: Gateway,
|
|
45
65
|
frame: GatewayInboundFrame,
|
|
46
66
|
log?: GatewayLogger,
|
|
67
|
+
onTyping?: CloudGatewayTypingEmitter,
|
|
47
68
|
): Promise<CloudGatewayRuntimeResult> {
|
|
48
69
|
if (frame.type !== RUNTIME_FRAME_TYPES.GATEWAY_INBOUND) {
|
|
49
70
|
return rejected(frame, "bad_frame_type", `unsupported frame type "${frame.type}"`);
|
|
@@ -61,6 +82,7 @@ export async function handleCloudGatewayRuntimeInbound(
|
|
|
61
82
|
let accepted = false;
|
|
62
83
|
let outboundText: string | null = null;
|
|
63
84
|
let providerMessageId: string | null | undefined;
|
|
85
|
+
const turnId = `turn_${frame.event_id}`;
|
|
64
86
|
const channel = createRuntimeRelayChannel({
|
|
65
87
|
id: frame.gateway_id,
|
|
66
88
|
provider: frame.provider,
|
|
@@ -70,6 +92,28 @@ export async function handleCloudGatewayRuntimeInbound(
|
|
|
70
92
|
providerMessageId = ctx.message.traceId ?? null;
|
|
71
93
|
return { providerMessageId };
|
|
72
94
|
},
|
|
95
|
+
onTyping: onTyping
|
|
96
|
+
? (ctx) => {
|
|
97
|
+
try {
|
|
98
|
+
onTyping({
|
|
99
|
+
eventId: frame.event_id,
|
|
100
|
+
turnId,
|
|
101
|
+
gatewayId: frame.gateway_id,
|
|
102
|
+
agentId: frame.agent_id,
|
|
103
|
+
conversationId: frame.message.conversation.id,
|
|
104
|
+
phase: "started",
|
|
105
|
+
traceId: ctx.traceId ?? null,
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log?.warn?.("cloud gateway typing emit failed", {
|
|
109
|
+
eventId: frame.event_id,
|
|
110
|
+
gatewayId: frame.gateway_id,
|
|
111
|
+
agentId: frame.agent_id,
|
|
112
|
+
error: err instanceof Error ? err.message : String(err),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
: undefined,
|
|
73
117
|
});
|
|
74
118
|
|
|
75
119
|
const message: GatewayInboundMessage = {
|
|
@@ -105,7 +149,7 @@ export async function handleCloudGatewayRuntimeInbound(
|
|
|
105
149
|
gatewayId: frame.gateway_id,
|
|
106
150
|
agentId: frame.agent_id,
|
|
107
151
|
conversationId: frame.message.conversation.id,
|
|
108
|
-
turnId
|
|
152
|
+
turnId,
|
|
109
153
|
...(outboundText !== null
|
|
110
154
|
? {
|
|
111
155
|
outbound: {
|
|
@@ -125,9 +169,10 @@ function createRuntimeRelayChannel(opts: {
|
|
|
125
169
|
provider: string;
|
|
126
170
|
accountId: string;
|
|
127
171
|
onSend: (ctx: ChannelSendContext) => Promise<ChannelSendResult>;
|
|
172
|
+
onTyping?: (ctx: ChannelTypingContext) => void;
|
|
128
173
|
}): ChannelAdapter {
|
|
129
174
|
let lastSendAt: number | undefined;
|
|
130
|
-
|
|
175
|
+
const adapter: ChannelAdapter = {
|
|
131
176
|
id: opts.id,
|
|
132
177
|
type: opts.provider,
|
|
133
178
|
async start() {
|
|
@@ -152,6 +197,12 @@ function createRuntimeRelayChannel(opts: {
|
|
|
152
197
|
};
|
|
153
198
|
},
|
|
154
199
|
};
|
|
200
|
+
if (opts.onTyping) {
|
|
201
|
+
adapter.typing = async (ctx) => {
|
|
202
|
+
opts.onTyping!(ctx);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return adapter;
|
|
155
206
|
}
|
|
156
207
|
|
|
157
208
|
function rejected(
|
package/src/provision.ts
CHANGED
|
@@ -78,7 +78,10 @@ import {
|
|
|
78
78
|
buildRuntimeSelectionExtraArgs,
|
|
79
79
|
mergeRuntimeExtraArgs,
|
|
80
80
|
} from "./runtime-route-options.js";
|
|
81
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
handleCloudGatewayRuntimeInbound,
|
|
83
|
+
type CloudGatewayTypingEmitter,
|
|
84
|
+
} from "./cloud-gateway-runtime.js";
|
|
82
85
|
|
|
83
86
|
/**
|
|
84
87
|
* Information passed to {@link OnAgentInstalledHook} after a successful
|
|
@@ -136,6 +139,13 @@ export interface ProvisionerOptions {
|
|
|
136
139
|
* spin up a default in-memory store.
|
|
137
140
|
*/
|
|
138
141
|
loginSessions?: LoginSessionStore;
|
|
142
|
+
/**
|
|
143
|
+
* Optional callback invoked when an in-flight `cloud_gateway_runtime_inbound`
|
|
144
|
+
* fires a typing event. The cloud daemon wires this to its control-plane
|
|
145
|
+
* connection so the Hub can relay the hint to the live ingress runtime WS.
|
|
146
|
+
* Absent for local daemons (no ingress path).
|
|
147
|
+
*/
|
|
148
|
+
cloudGatewayTypingEmitter?: CloudGatewayTypingEmitter;
|
|
139
149
|
}
|
|
140
150
|
|
|
141
151
|
/** The value a frame handler returns (minus the `id` which the channel fills in). */
|
|
@@ -153,6 +163,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
153
163
|
const register = opts.register ?? BotCordClient.register;
|
|
154
164
|
const policyResolver = opts.policyResolver;
|
|
155
165
|
const onAgentInstalled = opts.onAgentInstalled;
|
|
166
|
+
const cloudGatewayTypingEmitter = opts.cloudGatewayTypingEmitter;
|
|
156
167
|
const gatewayControl = createGatewayControl({
|
|
157
168
|
gateway,
|
|
158
169
|
...(opts.loginSessions ? { loginSessions: opts.loginSessions } : {}),
|
|
@@ -440,7 +451,12 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
440
451
|
},
|
|
441
452
|
};
|
|
442
453
|
}
|
|
443
|
-
const result = await handleCloudGatewayRuntimeInbound(
|
|
454
|
+
const result = await handleCloudGatewayRuntimeInbound(
|
|
455
|
+
gateway,
|
|
456
|
+
runtimeFrame,
|
|
457
|
+
undefined,
|
|
458
|
+
cloudGatewayTypingEmitter,
|
|
459
|
+
);
|
|
444
460
|
return result.accepted
|
|
445
461
|
? { ok: true, result }
|
|
446
462
|
: {
|
package/src/runtime-models.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { RuntimeProbeEntry } from "./adapters/runtimes.js";
|
|
|
7
7
|
|
|
8
8
|
const MODEL_LIST_TIMEOUT_MS = 5000;
|
|
9
9
|
const MODEL_LIST_MAX_BUFFER = 16 * 1024 * 1024;
|
|
10
|
-
const RUNTIME_CATALOG_CACHE_VERSION =
|
|
10
|
+
const RUNTIME_CATALOG_CACHE_VERSION = 2;
|
|
11
11
|
const RUNTIME_CATALOG_CACHE_FRESH_MS = 10 * 60 * 1000;
|
|
12
12
|
const DEFAULT_RUNTIME_CATALOG_CACHE_DIR = path.join(
|
|
13
13
|
homedir(),
|
|
@@ -136,7 +136,7 @@ function runtimeCatalogStrategy(entry: RuntimeProbeEntry): RuntimeCatalogStrateg
|
|
|
136
136
|
discoverFresh: () => discoverDeepseekCatalog(entry.result.path),
|
|
137
137
|
fallback: () => ({
|
|
138
138
|
models: DEEPSEEK_FALLBACK_MODELS.slice(),
|
|
139
|
-
parameters: discoverDeepseekParameters(),
|
|
139
|
+
parameters: discoverDeepseekParameters(entry.result.path),
|
|
140
140
|
}),
|
|
141
141
|
};
|
|
142
142
|
case "kimi-cli":
|
|
@@ -433,7 +433,7 @@ function discoverCodexParameters(rawCatalog: string | null): RuntimeParameterPro
|
|
|
433
433
|
function discoverDeepseekCatalog(command: string | undefined): RuntimeModelDiscovery {
|
|
434
434
|
return {
|
|
435
435
|
models: discoverDeepseekModels(command),
|
|
436
|
-
parameters: discoverDeepseekParameters(),
|
|
436
|
+
parameters: discoverDeepseekParameters(command),
|
|
437
437
|
};
|
|
438
438
|
}
|
|
439
439
|
|
|
@@ -457,8 +457,9 @@ export function parseDeepseekModelList(raw: string): RuntimeModelProbe[] | undef
|
|
|
457
457
|
return out.length ? out : undefined;
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
function discoverDeepseekParameters(): RuntimeParameterProbe[] {
|
|
460
|
+
function discoverDeepseekParameters(command?: string): RuntimeParameterProbe[] {
|
|
461
461
|
const config = readConfigScalars(path.join(homedir(), ".deepseek", "config.toml"));
|
|
462
|
+
const reasoningEffortValues = discoverDeepseekReasoningEffortValues(command);
|
|
462
463
|
return [
|
|
463
464
|
compactParameter({
|
|
464
465
|
id: "model",
|
|
@@ -480,8 +481,9 @@ function discoverDeepseekParameters(): RuntimeParameterProbe[] {
|
|
|
480
481
|
compactParameter({
|
|
481
482
|
id: "reasoning_effort",
|
|
482
483
|
displayName: "Reasoning effort",
|
|
483
|
-
type: "string",
|
|
484
|
-
flag: "
|
|
484
|
+
type: reasoningEffortValues.length > 0 ? "enum" : "string",
|
|
485
|
+
flag: "--reasoning-effort",
|
|
486
|
+
values: reasoningEffortValues.length > 0 ? reasoningEffortValues : undefined,
|
|
485
487
|
defaultValue: config.reasoning_effort,
|
|
486
488
|
source: config.reasoning_effort ? "config" : "cli",
|
|
487
489
|
}),
|
|
@@ -504,6 +506,44 @@ function discoverDeepseekParameters(): RuntimeParameterProbe[] {
|
|
|
504
506
|
];
|
|
505
507
|
}
|
|
506
508
|
|
|
509
|
+
function discoverDeepseekReasoningEffortValues(command: string | undefined): string[] {
|
|
510
|
+
const candidates = deepseekRuntimeTemplateCandidates(command);
|
|
511
|
+
const values = new Set<string>();
|
|
512
|
+
for (const candidate of candidates) {
|
|
513
|
+
try {
|
|
514
|
+
const raw = readFileSync(candidate)
|
|
515
|
+
.toString("latin1")
|
|
516
|
+
.replace(/[^\x20-\x7E]+/g, "\n");
|
|
517
|
+
const templateRe =
|
|
518
|
+
/Thinking mode \(DeepSeek V4 reasoning effort\):[\s\S]{0,256}?#\s*((?:"[^"]+"\s*(?:\|\s*)?)+)/g;
|
|
519
|
+
for (const match of raw.matchAll(templateRe)) {
|
|
520
|
+
const line = match[1] ?? "";
|
|
521
|
+
for (const valueMatch of line.matchAll(/"([^"]+)"/g)) {
|
|
522
|
+
const value = valueMatch[1]?.trim();
|
|
523
|
+
if (value && /^[A-Za-z0-9_.-]+$/.test(value)) values.add(value);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
// Try the next candidate; runtime discovery should stay best-effort.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return Array.from(values);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function deepseekRuntimeTemplateCandidates(command: string | undefined): string[] {
|
|
534
|
+
if (!command) return [];
|
|
535
|
+
const candidates = new Set<string>();
|
|
536
|
+
if (existsSync(command)) candidates.add(command);
|
|
537
|
+
const dir = path.dirname(command);
|
|
538
|
+
for (const candidate of [
|
|
539
|
+
path.join(dir, "deepseek-tui"),
|
|
540
|
+
path.join(dir, "downloads", "deepseek-tui"),
|
|
541
|
+
]) {
|
|
542
|
+
if (existsSync(candidate)) candidates.add(candidate);
|
|
543
|
+
}
|
|
544
|
+
return Array.from(candidates);
|
|
545
|
+
}
|
|
546
|
+
|
|
507
547
|
function discoverKimiCatalog(): RuntimeModelDiscovery {
|
|
508
548
|
const configPath = path.join(homedir(), ".kimi", "config.toml");
|
|
509
549
|
if (!existsSync(configPath)) return {};
|