@botcord/daemon 0.2.84 → 0.2.86
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/LICENSE +21 -0
- package/dist/cloud-daemon.js +18 -2
- package/dist/daemon-singleton.d.ts +12 -0
- package/dist/daemon-singleton.js +83 -7
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +24 -0
- package/dist/gateway/channels/botcord.js +72 -29
- package/dist/gateway/runtimes/deepseek-tui.js +5 -1
- package/dist/index.js +27 -7
- package/dist/provision.js +30 -3
- package/dist/runtime-models.js +48 -6
- package/dist/skill-index.d.ts +12 -0
- package/dist/skill-index.js +15 -4
- package/package.json +10 -11
- package/src/__tests__/cloud-daemon.test.ts +79 -0
- package/src/__tests__/daemon-singleton.test.ts +59 -1
- package/src/__tests__/provision.test.ts +42 -0
- package/src/__tests__/runtime-discovery.test.ts +17 -1
- package/src/__tests__/runtime-models.test.ts +50 -0
- package/src/__tests__/skill-index.test.ts +91 -0
- package/src/cloud-daemon.ts +18 -2
- package/src/daemon-singleton.ts +98 -6
- package/src/daemon.ts +28 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +79 -0
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +49 -0
- package/src/gateway/channels/botcord.ts +81 -33
- package/src/gateway/runtimes/deepseek-tui.ts +7 -1
- package/src/index.ts +25 -6
- package/src/provision.ts +34 -1
- package/src/runtime-models.ts +46 -6
- package/src/skill-index.ts +32 -4
package/dist/skill-index.d.ts
CHANGED
|
@@ -5,6 +5,17 @@ export interface SoftSkillEntry {
|
|
|
5
5
|
description?: string;
|
|
6
6
|
mtimeMs: number;
|
|
7
7
|
}
|
|
8
|
+
export interface AgentSkillSnapshotEntry {
|
|
9
|
+
name: string;
|
|
10
|
+
source: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
}
|
|
14
|
+
export interface AgentSkillSnapshot {
|
|
15
|
+
agentId: string;
|
|
16
|
+
skills: AgentSkillSnapshotEntry[];
|
|
17
|
+
probedAt: number;
|
|
18
|
+
}
|
|
8
19
|
export interface SkillIndexOptions {
|
|
9
20
|
extraDirs?: string[];
|
|
10
21
|
includeGlobal?: boolean;
|
|
@@ -14,4 +25,5 @@ export declare function defaultSkillDirs(agentId: string, opts?: SkillIndexOptio
|
|
|
14
25
|
source: string;
|
|
15
26
|
}>;
|
|
16
27
|
export declare function scanSoftSkills(agentId: string, opts?: SkillIndexOptions): SoftSkillEntry[];
|
|
28
|
+
export declare function collectAgentSkillSnapshot(agentId: string, opts?: SkillIndexOptions): AgentSkillSnapshot;
|
|
17
29
|
export declare function buildSoftSkillIndexPrompt(agentId: string, opts?: SkillIndexOptions): string | null;
|
package/dist/skill-index.js
CHANGED
|
@@ -34,7 +34,7 @@ export function scanSoftSkills(agentId, opts = {}) {
|
|
|
34
34
|
continue;
|
|
35
35
|
let children;
|
|
36
36
|
try {
|
|
37
|
-
children = readdirSync(root.dir);
|
|
37
|
+
children = readdirSync(root.dir).sort((a, b) => a.localeCompare(b));
|
|
38
38
|
}
|
|
39
39
|
catch {
|
|
40
40
|
continue;
|
|
@@ -69,11 +69,22 @@ export function scanSoftSkills(agentId, opts = {}) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
return Array.from(byName.values())
|
|
72
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
73
|
-
|
|
72
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
73
|
+
}
|
|
74
|
+
export function collectAgentSkillSnapshot(agentId, opts = {}) {
|
|
75
|
+
return {
|
|
76
|
+
agentId,
|
|
77
|
+
skills: scanSoftSkills(agentId, opts).map((skill) => ({
|
|
78
|
+
name: skill.name,
|
|
79
|
+
source: skill.source.startsWith("agent-") ? "workspace" : "runtime-global",
|
|
80
|
+
...(skill.description ? { description: skill.description } : {}),
|
|
81
|
+
mtimeMs: skill.mtimeMs,
|
|
82
|
+
})),
|
|
83
|
+
probedAt: Date.now(),
|
|
84
|
+
};
|
|
74
85
|
}
|
|
75
86
|
export function buildSoftSkillIndexPrompt(agentId, opts = {}) {
|
|
76
|
-
const skills = scanSoftSkills(agentId, opts);
|
|
87
|
+
const skills = scanSoftSkills(agentId, opts).slice(0, MAX_SKILLS);
|
|
77
88
|
if (skills.length === 0)
|
|
78
89
|
return null;
|
|
79
90
|
const lines = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.86",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,12 +8,6 @@
|
|
|
8
8
|
},
|
|
9
9
|
"main": "./dist/index.js",
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
|
-
"scripts": {
|
|
12
|
-
"build": "tsc -p tsconfig.build.json",
|
|
13
|
-
"prepublishOnly": "tsc -p tsconfig.build.json",
|
|
14
|
-
"test": "vitest run",
|
|
15
|
-
"test:watch": "vitest"
|
|
16
|
-
},
|
|
17
11
|
"files": [
|
|
18
12
|
"dist/",
|
|
19
13
|
"src/",
|
|
@@ -27,10 +21,10 @@
|
|
|
27
21
|
"access": "public"
|
|
28
22
|
},
|
|
29
23
|
"dependencies": {
|
|
30
|
-
"@botcord/cli": "^0.1.7",
|
|
31
|
-
"@botcord/protocol-core": "^0.2.12",
|
|
32
24
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
33
|
-
"ws": "^8.20.1"
|
|
25
|
+
"ws": "^8.20.1",
|
|
26
|
+
"@botcord/cli": "^0.1.18",
|
|
27
|
+
"@botcord/protocol-core": "^0.2.13"
|
|
34
28
|
},
|
|
35
29
|
"overrides": {
|
|
36
30
|
"axios": "^1.15.2"
|
|
@@ -40,5 +34,10 @@
|
|
|
40
34
|
"@types/ws": "^8.5.0",
|
|
41
35
|
"typescript": "^5.4.0",
|
|
42
36
|
"vitest": "^4.0.18"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest"
|
|
43
42
|
}
|
|
44
|
-
}
|
|
43
|
+
}
|
|
@@ -20,6 +20,7 @@ import type { CloudModeConfig } from "../cloud-mode.js";
|
|
|
20
20
|
import { ControlChannel } from "../control-channel.js";
|
|
21
21
|
import type { DaemonConfig } from "../config.js";
|
|
22
22
|
import type { Gateway, GatewayChannelConfig } from "../gateway/index.js";
|
|
23
|
+
import type { OnAgentInstalledHook } from "../provision.js";
|
|
23
24
|
|
|
24
25
|
class FakeWebSocket extends EventEmitter {
|
|
25
26
|
public readyState = 0;
|
|
@@ -75,6 +76,10 @@ function makeDaemonCfg(): DaemonConfig {
|
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
function sentFrames(ws: FakeWebSocket): Array<{ type?: string; params?: any }> {
|
|
80
|
+
return ws.sent.map((raw) => JSON.parse(raw));
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
describe("startCloudDaemon", () => {
|
|
79
84
|
let tmpDir: string;
|
|
80
85
|
|
|
@@ -173,6 +178,80 @@ describe("startCloudDaemon", () => {
|
|
|
173
178
|
}
|
|
174
179
|
});
|
|
175
180
|
|
|
181
|
+
it("pushes skill snapshots for agents installed before control-channel startup completes", async () => {
|
|
182
|
+
const ctor = makeFakeCtor();
|
|
183
|
+
class TestControlChannel extends ControlChannel {
|
|
184
|
+
constructor(opts: ConstructorParameters<typeof ControlChannel>[0]) {
|
|
185
|
+
super({ ...opts, webSocketCtor: ctor, hubPublicKey: null });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const handle = await startCloudDaemon({
|
|
189
|
+
cloudConfig: makeCfg(),
|
|
190
|
+
config: makeDaemonCfg(),
|
|
191
|
+
configPath: "(cloud-mode)",
|
|
192
|
+
controlChannelFactory: TestControlChannel as unknown as typeof ControlChannel,
|
|
193
|
+
provisionerFactory: ((args: { onAgentInstalled: OnAgentInstalledHook }) => {
|
|
194
|
+
args.onAgentInstalled({
|
|
195
|
+
agentId: "ag_preinstalled",
|
|
196
|
+
credentialsFile: path.join(tmpDir, "ag_preinstalled.json"),
|
|
197
|
+
hubUrl: "http://localhost:9000",
|
|
198
|
+
});
|
|
199
|
+
return vi.fn();
|
|
200
|
+
}) as unknown as typeof import("../provision.js").createProvisioner,
|
|
201
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
202
|
+
snapshotPath: path.join(tmpDir, "snapshot.json"),
|
|
203
|
+
snapshotIntervalMs: 60_000,
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
await new Promise((r) => setImmediate(r));
|
|
207
|
+
const ws = FakeWebSocket.instances[0]!;
|
|
208
|
+
const skillFrames = sentFrames(ws).filter((frame) => frame.type === "agent_skill_snapshot");
|
|
209
|
+
expect(skillFrames).toHaveLength(1);
|
|
210
|
+
expect(skillFrames[0]!.params.agentId).toBe("ag_preinstalled");
|
|
211
|
+
expect(Array.isArray(skillFrames[0]!.params.skills)).toBe(true);
|
|
212
|
+
} finally {
|
|
213
|
+
await handle.stop("test");
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("pushes a skill snapshot when a cloud agent is installed after startup", async () => {
|
|
218
|
+
let onAgentInstalled: OnAgentInstalledHook | undefined;
|
|
219
|
+
const ctor = makeFakeCtor();
|
|
220
|
+
class TestControlChannel extends ControlChannel {
|
|
221
|
+
constructor(opts: ConstructorParameters<typeof ControlChannel>[0]) {
|
|
222
|
+
super({ ...opts, webSocketCtor: ctor, hubPublicKey: null });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const handle = await startCloudDaemon({
|
|
226
|
+
cloudConfig: makeCfg(),
|
|
227
|
+
config: makeDaemonCfg(),
|
|
228
|
+
configPath: "(cloud-mode)",
|
|
229
|
+
controlChannelFactory: TestControlChannel as unknown as typeof ControlChannel,
|
|
230
|
+
provisionerFactory: ((args: { onAgentInstalled: OnAgentInstalledHook }) => {
|
|
231
|
+
onAgentInstalled = args.onAgentInstalled;
|
|
232
|
+
return vi.fn();
|
|
233
|
+
}) as unknown as typeof import("../provision.js").createProvisioner,
|
|
234
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
235
|
+
snapshotPath: path.join(tmpDir, "snapshot.json"),
|
|
236
|
+
snapshotIntervalMs: 60_000,
|
|
237
|
+
});
|
|
238
|
+
try {
|
|
239
|
+
await new Promise((r) => setImmediate(r));
|
|
240
|
+
onAgentInstalled!({
|
|
241
|
+
agentId: "ag_hot_installed",
|
|
242
|
+
credentialsFile: path.join(tmpDir, "ag_hot_installed.json"),
|
|
243
|
+
hubUrl: "http://localhost:9000",
|
|
244
|
+
});
|
|
245
|
+
const ws = FakeWebSocket.instances[0]!;
|
|
246
|
+
const skillFrames = sentFrames(ws).filter((frame) => frame.type === "agent_skill_snapshot");
|
|
247
|
+
expect(skillFrames).toHaveLength(1);
|
|
248
|
+
expect(skillFrames[0]!.params.agentId).toBe("ag_hot_installed");
|
|
249
|
+
expect(Array.isArray(skillFrames[0]!.params.skills)).toBe(true);
|
|
250
|
+
} finally {
|
|
251
|
+
await handle.stop("test");
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
176
255
|
it.each(["telegram", "wechat", "feishu"] as const)(
|
|
177
256
|
"allows %s gateway channels to be hot-plugged in cloud mode",
|
|
178
257
|
async (type) => {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
6
|
import {
|
|
7
|
+
acquireDaemonSingletonLock,
|
|
7
8
|
ensureNoOtherDaemonFromPidFile,
|
|
8
9
|
isBotCordDaemonStartCommand,
|
|
9
10
|
parseDaemonProcesses,
|
|
@@ -79,6 +80,63 @@ describe("daemon singleton pid helpers", () => {
|
|
|
79
80
|
expect(child.exitCode === null && child.signalCode === null).toBe(false);
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
it("holds an atomic singleton lock until release", async () => {
|
|
84
|
+
const pidPath = path.join(tmpDir, "daemon.pid");
|
|
85
|
+
const lockPath = `${pidPath}.lock`;
|
|
86
|
+
|
|
87
|
+
const lock = await acquireDaemonSingletonLock({
|
|
88
|
+
pidPath,
|
|
89
|
+
lockPath,
|
|
90
|
+
currentPid: process.pid,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(existsSync(lockPath)).toBe(true);
|
|
94
|
+
expect(readPid(path.join(lockPath, "owner.pid"))).toBe(process.pid);
|
|
95
|
+
|
|
96
|
+
lock.release();
|
|
97
|
+
|
|
98
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("removes stale singleton locks", async () => {
|
|
102
|
+
const pidPath = path.join(tmpDir, "daemon.pid");
|
|
103
|
+
const lockPath = `${pidPath}.lock`;
|
|
104
|
+
mkdirSync(lockPath, { recursive: true });
|
|
105
|
+
writeCurrentPid({ pidPath: path.join(lockPath, "owner.pid"), currentPid: 99999999 });
|
|
106
|
+
|
|
107
|
+
const lock = await acquireDaemonSingletonLock({
|
|
108
|
+
pidPath,
|
|
109
|
+
lockPath,
|
|
110
|
+
currentPid: process.pid,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(readPid(path.join(lockPath, "owner.pid"))).toBe(process.pid);
|
|
114
|
+
lock.release();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("terminates a live singleton lock owner before acquiring", async () => {
|
|
118
|
+
const pidPath = path.join(tmpDir, "daemon.pid");
|
|
119
|
+
const lockPath = `${pidPath}.lock`;
|
|
120
|
+
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
|
121
|
+
stdio: "ignore",
|
|
122
|
+
});
|
|
123
|
+
children.push(child);
|
|
124
|
+
await waitForPid(child);
|
|
125
|
+
mkdirSync(lockPath, { recursive: true });
|
|
126
|
+
writeCurrentPid({ pidPath: path.join(lockPath, "owner.pid"), currentPid: child.pid! });
|
|
127
|
+
|
|
128
|
+
const lock = await acquireDaemonSingletonLock({
|
|
129
|
+
pidPath,
|
|
130
|
+
lockPath,
|
|
131
|
+
currentPid: process.pid,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await waitForExit(child);
|
|
135
|
+
expect(child.exitCode === null && child.signalCode === null).toBe(false);
|
|
136
|
+
expect(readPid(path.join(lockPath, "owner.pid"))).toBe(process.pid);
|
|
137
|
+
lock.release();
|
|
138
|
+
});
|
|
139
|
+
|
|
82
140
|
it("removes stale pid files", () => {
|
|
83
141
|
const pidPath = path.join(tmpDir, "daemon.pid");
|
|
84
142
|
writeCurrentPid({ pidPath, currentPid: 99999999 });
|
|
@@ -719,6 +719,48 @@ describe("provision_agent handler writes runtime + cwd", () => {
|
|
|
719
719
|
}
|
|
720
720
|
});
|
|
721
721
|
|
|
722
|
+
it("normalizes Hub-supplied millisecond tokenExpiresAt before writing credentials", async () => {
|
|
723
|
+
const os = await import("node:os");
|
|
724
|
+
const fs = await import("node:fs");
|
|
725
|
+
const nodePath = await import("node:path");
|
|
726
|
+
|
|
727
|
+
const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-provision-"));
|
|
728
|
+
const prevHome = process.env.HOME;
|
|
729
|
+
process.env.HOME = tmp;
|
|
730
|
+
try {
|
|
731
|
+
const gw = makeFakeGateway();
|
|
732
|
+
const provisioner = createProvisioner({
|
|
733
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
734
|
+
});
|
|
735
|
+
const privateKey = Buffer.alloc(32, 9).toString("base64");
|
|
736
|
+
|
|
737
|
+
const ack = await provisioner({
|
|
738
|
+
id: "req_token_expiry",
|
|
739
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
740
|
+
params: {
|
|
741
|
+
runtime: "codex",
|
|
742
|
+
credentials: {
|
|
743
|
+
agentId: "ag_token_expiry",
|
|
744
|
+
keyId: "k_token_expiry",
|
|
745
|
+
privateKey,
|
|
746
|
+
hubUrl: "https://hub.example",
|
|
747
|
+
token: "agent-token",
|
|
748
|
+
tokenExpiresAt: 1_779_856_985_546,
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
expect(ack.ok).toBe(true);
|
|
754
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_token_expiry.json");
|
|
755
|
+
const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<string, unknown>;
|
|
756
|
+
expect(saved.tokenExpiresAt).toBe(1_779_856_985);
|
|
757
|
+
} finally {
|
|
758
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
759
|
+
else process.env.HOME = prevHome;
|
|
760
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
722
764
|
it("rejects unknown runtime ids before touching disk", async () => {
|
|
723
765
|
const gw = makeFakeGateway();
|
|
724
766
|
const provisioner = createProvisioner({
|
|
@@ -40,7 +40,7 @@ beforeEach(() => {
|
|
|
40
40
|
// mocked runtime list between cases, so reset before each.
|
|
41
41
|
clearRuntimeProbeCache();
|
|
42
42
|
});
|
|
43
|
-
const { pushRuntimeSnapshot } = await import("../daemon.js");
|
|
43
|
+
const { pushAgentSkillSnapshot, pushRuntimeSnapshot } = await import("../daemon.js");
|
|
44
44
|
const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
|
|
45
45
|
import type { GatewayChannelConfig, GatewayRuntimeSnapshot } from "../gateway/index.js";
|
|
46
46
|
|
|
@@ -474,6 +474,22 @@ describe("pushRuntimeSnapshot (first-connect push)", () => {
|
|
|
474
474
|
});
|
|
475
475
|
});
|
|
476
476
|
|
|
477
|
+
describe("pushAgentSkillSnapshot", () => {
|
|
478
|
+
it("sends an agent_skill_snapshot frame", () => {
|
|
479
|
+
const frames: any[] = [];
|
|
480
|
+
const ok = pushAgentSkillSnapshot(
|
|
481
|
+
{ send: (frame) => { frames.push(frame); return true; } },
|
|
482
|
+
"ag_skills",
|
|
483
|
+
);
|
|
484
|
+
expect(ok).toBe(true);
|
|
485
|
+
expect(frames).toHaveLength(1);
|
|
486
|
+
expect(frames[0].type).toBe("agent_skill_snapshot");
|
|
487
|
+
expect(frames[0].params.agentId).toBe("ag_skills");
|
|
488
|
+
expect(Array.isArray(frames[0].params.skills)).toBe(true);
|
|
489
|
+
expect(typeof frames[0].params.probedAt).toBe("number");
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
477
493
|
describe("attachRuntimeHealth", () => {
|
|
478
494
|
it("groups live circuit breakers onto matching runtime entries", () => {
|
|
479
495
|
const snap = {
|
|
@@ -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(
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
agentCodexHomeDir,
|
|
7
|
+
agentWorkspaceDir,
|
|
8
|
+
} from "../agent-workspace.js";
|
|
9
|
+
import {
|
|
10
|
+
buildSoftSkillIndexPrompt,
|
|
11
|
+
collectAgentSkillSnapshot,
|
|
12
|
+
scanSoftSkills,
|
|
13
|
+
} from "../skill-index.js";
|
|
14
|
+
|
|
15
|
+
let tmpDir = "";
|
|
16
|
+
let prevHome: string | undefined;
|
|
17
|
+
|
|
18
|
+
function writeSkill(dir: string, name: string, description: string): void {
|
|
19
|
+
const skillDir = path.join(dir, name);
|
|
20
|
+
mkdirSync(skillDir, { recursive: true });
|
|
21
|
+
writeFileSync(
|
|
22
|
+
path.join(skillDir, "SKILL.md"),
|
|
23
|
+
`---\nname: ${name}\ndescription: "${description}"\n---\n\n# ${name}\n`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tmpDir = mkdtempSync(path.join(tmpdir(), "skill-index-"));
|
|
29
|
+
prevHome = process.env.HOME;
|
|
30
|
+
process.env.HOME = tmpDir;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
35
|
+
else process.env.HOME = prevHome;
|
|
36
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("skill snapshots", () => {
|
|
40
|
+
it("scans agent workspace/runtime-global skills and maps UI source buckets", () => {
|
|
41
|
+
const agentId = "ag_skilltest";
|
|
42
|
+
writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "workspace-skill", "Workspace skill");
|
|
43
|
+
writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-skill", "Codex skill");
|
|
44
|
+
writeSkill(path.join(tmpDir, ".codex", "skills"), "global-skill", "Global skill");
|
|
45
|
+
|
|
46
|
+
const scanned = scanSoftSkills(agentId);
|
|
47
|
+
expect(scanned.map((s) => s.name).sort()).toEqual([
|
|
48
|
+
"codex-skill",
|
|
49
|
+
"global-skill",
|
|
50
|
+
"workspace-skill",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const snapshot = collectAgentSkillSnapshot(agentId);
|
|
54
|
+
expect(snapshot.agentId).toBe(agentId);
|
|
55
|
+
expect(snapshot.skills).toHaveLength(3);
|
|
56
|
+
expect(snapshot.skills.find((s) => s.name === "workspace-skill")?.source)
|
|
57
|
+
.toBe("workspace");
|
|
58
|
+
expect(snapshot.skills.find((s) => s.name === "codex-skill")?.source)
|
|
59
|
+
.toBe("workspace");
|
|
60
|
+
expect(snapshot.skills.find((s) => s.name === "global-skill")?.source)
|
|
61
|
+
.toBe("runtime-global");
|
|
62
|
+
expect(snapshot.probedAt).toBeGreaterThan(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns complete snapshots while keeping the prompt soft index capped", () => {
|
|
66
|
+
const agentId = "ag_manyskills";
|
|
67
|
+
const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
|
|
68
|
+
for (let i = 0; i < 30; i += 1) {
|
|
69
|
+
const name = `skill-${String(i).padStart(2, "0")}`;
|
|
70
|
+
writeSkill(workspaceSkills, name, `Skill ${i}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const scanned = scanSoftSkills(agentId);
|
|
74
|
+
expect(scanned).toHaveLength(30);
|
|
75
|
+
expect(scanned.map((s) => s.name)).toEqual(
|
|
76
|
+
Array.from({ length: 30 }, (_, i) => `skill-${String(i).padStart(2, "0")}`),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const snapshot = collectAgentSkillSnapshot(agentId);
|
|
80
|
+
expect(snapshot.skills).toHaveLength(30);
|
|
81
|
+
|
|
82
|
+
const prompt = buildSoftSkillIndexPrompt(agentId);
|
|
83
|
+
expect(prompt).not.toBeNull();
|
|
84
|
+
const skillLines = prompt
|
|
85
|
+
?.split("\n")
|
|
86
|
+
.filter((line) => line.startsWith("- skill-"));
|
|
87
|
+
expect(skillLines).toHaveLength(24);
|
|
88
|
+
expect(skillLines?.at(0)).toContain("skill-00");
|
|
89
|
+
expect(skillLines?.at(-1)).toContain("skill-23");
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/cloud-daemon.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { ControlChannel } from "./control-channel.js";
|
|
|
27
27
|
import { toGatewayConfig } from "./daemon-config-map.js";
|
|
28
28
|
import { log as daemonLog } from "./log.js";
|
|
29
29
|
import { createProvisioner } from "./provision.js";
|
|
30
|
-
import { createDaemonChannel, pushRuntimeSnapshot } from "./daemon.js";
|
|
30
|
+
import { createDaemonChannel, pushAgentSkillSnapshot, pushRuntimeSnapshot } from "./daemon.js";
|
|
31
31
|
import { SnapshotWriter } from "./snapshot-writer.js";
|
|
32
32
|
import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
33
33
|
import { readWorkingMemorySnapshot } from "./working-memory.js";
|
|
@@ -234,7 +234,20 @@ export async function startCloudDaemon(
|
|
|
234
234
|
});
|
|
235
235
|
};
|
|
236
236
|
|
|
237
|
+
const installedAgentIds = new Set<string>();
|
|
238
|
+
let controlChannel: ControlChannel | null = null;
|
|
239
|
+
const pushInstalledAgentSkillSnapshot = (agentId: string, reason: string): void => {
|
|
240
|
+
if (!controlChannel) return;
|
|
241
|
+
const pushed = pushAgentSkillSnapshot(controlChannel, agentId);
|
|
242
|
+
logger.info("cloud control-channel: agent_skill_snapshot pushed", {
|
|
243
|
+
agentId,
|
|
244
|
+
reason,
|
|
245
|
+
ok: pushed,
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
237
249
|
const onAgentInstalled: OnAgentInstalledHook = (info: InstalledAgentInfo) => {
|
|
250
|
+
installedAgentIds.add(info.agentId);
|
|
238
251
|
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
239
252
|
if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
|
240
253
|
if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
|
|
@@ -251,6 +264,7 @@ export async function startCloudDaemon(
|
|
|
251
264
|
}),
|
|
252
265
|
);
|
|
253
266
|
}
|
|
267
|
+
pushInstalledAgentSkillSnapshot(info.agentId, "agent_installed");
|
|
254
268
|
};
|
|
255
269
|
|
|
256
270
|
const gateway = new Gateway({
|
|
@@ -281,7 +295,6 @@ export async function startCloudDaemon(
|
|
|
281
295
|
await gateway.start();
|
|
282
296
|
logger.info("cloud daemon gateway started (zero agents at boot)");
|
|
283
297
|
|
|
284
|
-
let controlChannel: ControlChannel | null = null;
|
|
285
298
|
if (!opts.disableControlChannel) {
|
|
286
299
|
const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
|
|
287
300
|
const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
|
|
@@ -330,6 +343,9 @@ export async function startCloudDaemon(
|
|
|
330
343
|
logger.info("cloud control-channel started; runtime_snapshot pushed", {
|
|
331
344
|
ok: pushed,
|
|
332
345
|
});
|
|
346
|
+
for (const agentId of installedAgentIds) {
|
|
347
|
+
pushInstalledAgentSkillSnapshot(agentId, "control_channel_started");
|
|
348
|
+
}
|
|
333
349
|
} catch (err) {
|
|
334
350
|
logger.warn("cloud control-channel start failed; daemon will retry", {
|
|
335
351
|
error: err instanceof Error ? err.message : String(err),
|