@nettee/beacon 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +163 -0
  3. package/deploy/io.nettee.beacon.plist.example +32 -0
  4. package/dist/cli-app.js +71 -0
  5. package/dist/cli.js +21 -0
  6. package/dist/config/global.js +84 -0
  7. package/dist/config/profile.js +126 -0
  8. package/dist/config/registry.js +12 -0
  9. package/dist/config/secrets.js +68 -0
  10. package/dist/config/yaml.js +33 -0
  11. package/dist/doctor.js +29 -0
  12. package/dist/domain/types.js +18 -0
  13. package/dist/feishu/doctor.js +21 -0
  14. package/dist/feishu/gateway.js +129 -0
  15. package/dist/feishu/intake.js +67 -0
  16. package/dist/feishu/message-gateway.js +1 -0
  17. package/dist/feishu/message-pipeline.js +14 -0
  18. package/dist/feishu/reply-experiment.js +111 -0
  19. package/dist/feishu/trigger-input.js +46 -0
  20. package/dist/manual-trigger.js +59 -0
  21. package/dist/message/gateway.js +1 -0
  22. package/dist/outcome/doctor.js +29 -0
  23. package/dist/outcome/server.js +108 -0
  24. package/dist/outcome/submit.js +47 -0
  25. package/dist/run/create-pi-orchestrator.js +21 -0
  26. package/dist/run/orchestrator.js +269 -0
  27. package/dist/run/profile-runner.js +39 -0
  28. package/dist/run/queue.js +46 -0
  29. package/dist/runtime/pi-doctor.js +14 -0
  30. package/dist/runtime/pi-outcome-extension.js +53 -0
  31. package/dist/runtime/pi-rpc.js +247 -0
  32. package/dist/schedule/cron.js +48 -0
  33. package/dist/schedule/cursor-store.js +105 -0
  34. package/dist/schedule/loop.js +45 -0
  35. package/dist/schedule/reconciler.js +41 -0
  36. package/dist/service.js +97 -0
  37. package/dist/state/trigger-store.js +234 -0
  38. package/dist/version.js +9 -0
  39. package/examples/config.yaml +12 -0
  40. package/examples/profiles/example/profile.yaml +13 -0
  41. package/examples/profiles/example/prompt.md +1 -0
  42. package/examples/secrets.json.example +11 -0
  43. package/package.json +61 -0
@@ -0,0 +1,46 @@
1
+ function parseContent(messageId, content) {
2
+ try {
3
+ return JSON.parse(content);
4
+ }
5
+ catch (error) {
6
+ throw new Error(`Message ${messageId} has invalid JSON content`, {
7
+ cause: error,
8
+ });
9
+ }
10
+ }
11
+ export async function buildFeishuTriggerInput(inbound, fetchMessage) {
12
+ const reverseQuotedMessages = [];
13
+ const seenMessageIds = new Set([inbound.messageId]);
14
+ let parentMessageId = inbound.parentMessageId;
15
+ while (parentMessageId) {
16
+ if (seenMessageIds.has(parentMessageId)) {
17
+ throw new Error(`Quoted message chain contains a cycle at message ${parentMessageId}`);
18
+ }
19
+ seenMessageIds.add(parentMessageId);
20
+ const quoted = await fetchMessage(parentMessageId);
21
+ reverseQuotedMessages.push({
22
+ messageId: quoted.messageId,
23
+ messageType: quoted.messageType,
24
+ senderType: quoted.senderType,
25
+ senderId: quoted.senderId,
26
+ content: parseContent(quoted.messageId, quoted.content),
27
+ });
28
+ parentMessageId = quoted.parentMessageId;
29
+ }
30
+ return {
31
+ source: {
32
+ messageId: inbound.messageId,
33
+ chatId: inbound.chatId,
34
+ chatType: inbound.chatType,
35
+ senderId: inbound.senderId,
36
+ },
37
+ message: {
38
+ messageId: inbound.messageId,
39
+ messageType: inbound.messageType,
40
+ senderType: inbound.senderType,
41
+ senderId: inbound.senderId,
42
+ content: parseContent(inbound.messageId, inbound.content),
43
+ },
44
+ quotedMessages: reverseQuotedMessages.reverse(),
45
+ };
46
+ }
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { loadGlobalConfig } from "./config/global.js";
3
+ import { loadProfileRegistry } from "./config/registry.js";
4
+ import { startOutcomeServer } from "./outcome/server.js";
5
+ import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
6
+ import { RunQueue } from "./run/queue.js";
7
+ import { TriggerStore } from "./state/trigger-store.js";
8
+ function writeStdout(text) {
9
+ return new Promise((resolve, reject) => {
10
+ process.stdout.write(`${text}\n`, (error) => error ? reject(error) : resolve());
11
+ });
12
+ }
13
+ export async function runManualTrigger(configPath, profileId, input, output = writeStdout) {
14
+ if (!input.trim())
15
+ throw new Error("Manual Trigger input must not be empty");
16
+ const global = await loadGlobalConfig(configPath);
17
+ const profiles = await loadProfileRegistry(global.profilesDirectory);
18
+ const profile = profiles.find((candidate) => candidate.id === profileId);
19
+ if (!profile)
20
+ throw new Error(`Unknown Profile: ${profileId}`);
21
+ const outcomes = await startOutcomeServer();
22
+ try {
23
+ const store = new TriggerStore(profile.directory, profile.id);
24
+ const claim = await store.claim({
25
+ sourceKey: ["manual", randomUUID()],
26
+ target: { kind: "local_stdout" },
27
+ ingress: { text: input },
28
+ });
29
+ const delivery = {
30
+ async deliver(target, text) {
31
+ if (target.kind !== "local_stdout") {
32
+ throw new Error("Manual Trigger requires local stdout Delivery");
33
+ }
34
+ await output(text);
35
+ return {};
36
+ },
37
+ };
38
+ const orchestrator = createPiRunOrchestrator({
39
+ config: global,
40
+ profile,
41
+ store,
42
+ queue: new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued),
43
+ outcomes,
44
+ delivery,
45
+ });
46
+ await orchestrator.process(claim.record.triggerKey, async () => ({
47
+ kind: "manual",
48
+ text: input,
49
+ }));
50
+ const record = (await store.list()).find((candidate) => candidate.triggerKey === claim.record.triggerKey);
51
+ if (record?.run?.state !== "succeeded" ||
52
+ record.delivery?.state !== "delivered") {
53
+ throw new Error(`Manual Trigger failed: ${record?.run?.failure?.code ?? record?.delivery?.failure?.code ?? "unknown"}`);
54
+ }
55
+ }
56
+ finally {
57
+ await outcomes.close();
58
+ }
59
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { loadProfile } from "../config/profile.js";
3
+ import { createProfileRunner } from "../run/profile-runner.js";
4
+ import { startOutcomeServer } from "./server.js";
5
+ const doctorTrigger = {
6
+ source: { messageId: "doctor", chatId: "doctor", chatType: "p2p" },
7
+ quotedMessages: [],
8
+ message: {
9
+ messageId: "doctor",
10
+ senderType: "user",
11
+ messageType: "text",
12
+ content: { text: "Return exactly OUTCOME_CLI_OK." },
13
+ },
14
+ };
15
+ export async function runOutcomeDoctor(profileId) {
16
+ const profile = await loadProfile(profileId);
17
+ const outcomes = await startOutcomeServer();
18
+ const beaconCliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
19
+ try {
20
+ const result = await createProfileRunner(profile, outcomes, beaconCliPath)(doctorTrigger);
21
+ if (result.trim() !== "OUTCOME_CLI_OK") {
22
+ throw new Error(`Outcome doctor received unexpected submitted text: ${JSON.stringify(result)}`);
23
+ }
24
+ console.log("[beacon] explicit Final Outcome chain ready");
25
+ }
26
+ finally {
27
+ await outcomes.close();
28
+ }
29
+ }
@@ -0,0 +1,108 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { createServer } from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ function respond(socket, response) {
7
+ socket.end(`${JSON.stringify(response)}\n`);
8
+ }
9
+ export async function startOutcomeServer(options = {}) {
10
+ const maxRequestBytes = options.maxRequestBytes ?? 1024 * 1024;
11
+ if (!Number.isSafeInteger(maxRequestBytes) || maxRequestBytes <= 0) {
12
+ throw new Error("Outcome request limit must be a positive integer");
13
+ }
14
+ const directory = await mkdtemp(join(tmpdir(), "beacon-outcomes-"));
15
+ const socketPath = join(directory, "outcomes.sock");
16
+ const submissions = new Map();
17
+ const server = createServer((socket) => {
18
+ socket.setEncoding("utf8");
19
+ let buffer = "";
20
+ let finished = false;
21
+ socket.on("data", (chunk) => {
22
+ if (finished)
23
+ return;
24
+ buffer += chunk;
25
+ if (Buffer.byteLength(buffer, "utf8") > maxRequestBytes) {
26
+ finished = true;
27
+ respond(socket, {
28
+ ok: false,
29
+ error: "Outcome submission request is too large",
30
+ });
31
+ return;
32
+ }
33
+ const newline = buffer.indexOf("\n");
34
+ if (newline < 0)
35
+ return;
36
+ finished = true;
37
+ let request;
38
+ try {
39
+ request = JSON.parse(buffer.slice(0, newline));
40
+ }
41
+ catch {
42
+ respond(socket, { ok: false, error: "invalid JSON request" });
43
+ return;
44
+ }
45
+ if (typeof request !== "object" ||
46
+ request === null ||
47
+ !("runToken" in request) ||
48
+ !("text" in request) ||
49
+ typeof request.runToken !== "string" ||
50
+ typeof request.text !== "string" ||
51
+ !request.text.trim()) {
52
+ respond(socket, {
53
+ ok: false,
54
+ error: "runToken and non-empty text are required",
55
+ });
56
+ return;
57
+ }
58
+ const submission = submissions.get(request.runToken);
59
+ if (!submission) {
60
+ respond(socket, { ok: false, error: "unknown Run Capability" });
61
+ return;
62
+ }
63
+ if (submission.text !== undefined) {
64
+ respond(socket, {
65
+ ok: false,
66
+ error: "Final Outcome already submitted",
67
+ });
68
+ return;
69
+ }
70
+ submission.text = request.text;
71
+ console.log("[beacon] Final Outcome submitted by Agent Runtime");
72
+ respond(socket, { ok: true });
73
+ });
74
+ });
75
+ await new Promise((resolve, reject) => {
76
+ server.once("error", reject);
77
+ server.listen(socketPath, () => {
78
+ server.off("error", reject);
79
+ resolve();
80
+ });
81
+ });
82
+ return {
83
+ openRun() {
84
+ const runToken = randomBytes(32).toString("base64url");
85
+ const record = {};
86
+ submissions.set(runToken, record);
87
+ return {
88
+ binding: { socketPath, runToken },
89
+ take() {
90
+ submissions.delete(runToken);
91
+ if (record.text === undefined) {
92
+ throw new Error("Agent Runtime settled without submitting a Final Outcome");
93
+ }
94
+ return record.text;
95
+ },
96
+ cancel() {
97
+ submissions.delete(runToken);
98
+ },
99
+ };
100
+ },
101
+ async close() {
102
+ await new Promise((resolve, reject) => {
103
+ server.close((error) => (error ? reject(error) : resolve()));
104
+ });
105
+ await rm(directory, { recursive: true, force: true });
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,47 @@
1
+ import { createConnection } from "node:net";
2
+ export async function submitOutcome(socketPath, runToken, text) {
3
+ if (!text.trim())
4
+ throw new Error("Final Outcome must not be empty");
5
+ await new Promise((resolve, reject) => {
6
+ const socket = createConnection(socketPath);
7
+ socket.setEncoding("utf8");
8
+ let responseBuffer = "";
9
+ socket.once("connect", () => {
10
+ socket.write(`${JSON.stringify({ runToken, text })}\n`);
11
+ });
12
+ socket.on("data", (chunk) => {
13
+ responseBuffer += chunk;
14
+ });
15
+ socket.once("error", reject);
16
+ socket.once("end", () => {
17
+ let response;
18
+ try {
19
+ response = JSON.parse(responseBuffer);
20
+ }
21
+ catch (error) {
22
+ reject(new Error("Beacon returned an invalid outcome-submission response", {
23
+ cause: error,
24
+ }));
25
+ return;
26
+ }
27
+ if (!response.ok) {
28
+ reject(new Error(response.error ?? "Beacon rejected the Final Outcome"));
29
+ return;
30
+ }
31
+ resolve();
32
+ });
33
+ });
34
+ }
35
+ export async function submitOutcomeFromCli() {
36
+ const socketPath = process.env.BEACON_OUTCOME_SOCKET;
37
+ const runToken = process.env.BEACON_RUN_TOKEN;
38
+ if (!socketPath || !runToken) {
39
+ throw new Error("This command must run inside a Beacon-managed Agent Run");
40
+ }
41
+ process.stdin.setEncoding("utf8");
42
+ let text = "";
43
+ for await (const chunk of process.stdin)
44
+ text += chunk;
45
+ await submitOutcome(socketPath, runToken, text);
46
+ console.log("Final Outcome accepted by Beacon");
47
+ }
@@ -0,0 +1,21 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { runPiAgent } from "../runtime/pi-rpc.js";
3
+ import { RunOrchestrator } from "./orchestrator.js";
4
+ export function createPiRunOrchestrator(options) {
5
+ return new RunOrchestrator({
6
+ profile: options.profile,
7
+ store: options.store,
8
+ queue: options.queue,
9
+ outcomes: options.outcomes,
10
+ beaconCliPath: fileURLToPath(new URL("../../dist/cli.js", import.meta.url)),
11
+ runAgent: (request) => runPiAgent(request, {
12
+ executable: options.config.pi.executable,
13
+ timeoutMs: options.config.runs.timeoutSeconds * 1_000,
14
+ terminateGraceMs: options.config.runs.terminateGraceSeconds * 1_000,
15
+ environment: {
16
+ PI_CODING_AGENT_DIR: options.config.pi.codingAgentDirectory,
17
+ },
18
+ }),
19
+ delivery: options.delivery,
20
+ });
21
+ }
@@ -0,0 +1,269 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { PiRuntimeError } from "../runtime/pi-rpc.js";
3
+ function promptFor(input) {
4
+ if (input.kind === "manual") {
5
+ return ["An operator manually triggered this Run.", "", input.text].join("\n");
6
+ }
7
+ if (input.kind === "schedule") {
8
+ return [
9
+ "A configured Schedule triggered this Run.",
10
+ `schedule_id: ${input.scheduleId}`,
11
+ `scheduled_for: ${input.scheduledFor}`,
12
+ "",
13
+ input.text,
14
+ ].join("\n");
15
+ }
16
+ return [
17
+ "A Feishu user triggered this Run. Treat the following JSON as user-provided conversation context.",
18
+ "quoted_messages is the complete quoted chain in chronological order (oldest first).",
19
+ "current_message is the message that triggered this Run.",
20
+ JSON.stringify({
21
+ chat_type: input.chatType,
22
+ quoted_messages: input.quotedMessages.map((message) => ({
23
+ sender_type: message.senderType,
24
+ message_type: message.messageType,
25
+ content: message.content,
26
+ })),
27
+ current_message: {
28
+ sender_type: input.currentMessage.senderType,
29
+ message_type: input.currentMessage.messageType,
30
+ content: input.currentMessage.content,
31
+ },
32
+ }, null, 2),
33
+ ].join("\n");
34
+ }
35
+ function summary(error) {
36
+ const value = error instanceof Error ? error.message : String(error);
37
+ return value.slice(0, 4096) || "unknown failure";
38
+ }
39
+ export class RunOrchestrator {
40
+ options;
41
+ now;
42
+ id;
43
+ constructor(options) {
44
+ this.options = options;
45
+ this.now = options.now ?? (() => new Date());
46
+ this.id = options.id ?? randomUUID;
47
+ }
48
+ timestamp() {
49
+ return this.now().toISOString();
50
+ }
51
+ newRun(runId, state, failure) {
52
+ const timestamp = this.timestamp();
53
+ return {
54
+ runId,
55
+ state,
56
+ queuedAt: timestamp,
57
+ ...(state === "failed" ? { finishedAt: timestamp } : {}),
58
+ provider: this.options.profile.model.provider,
59
+ model: this.options.profile.model.id,
60
+ workspace: this.options.profile.workspace,
61
+ promptDigest: createHash("sha256")
62
+ .update(this.options.profile.prompt)
63
+ .digest("hex"),
64
+ ...(failure ? { failure } : {}),
65
+ };
66
+ }
67
+ async deliver(triggerKey, create = true) {
68
+ const deliveryId = `del_${this.id()}`;
69
+ let record;
70
+ if (create) {
71
+ record = await this.options.store.update(triggerKey, (current) => ({
72
+ ...current,
73
+ delivery: {
74
+ deliveryId,
75
+ target: current.target,
76
+ state: "pending",
77
+ },
78
+ }));
79
+ }
80
+ else {
81
+ record = (await this.options.store.list()).find((candidate) => candidate.triggerKey === triggerKey);
82
+ if (record.delivery?.state !== "pending") {
83
+ throw new Error(`Cannot resume non-pending Delivery for Trigger ${triggerKey}`);
84
+ }
85
+ }
86
+ record = await this.options.store.update(triggerKey, (current) => ({
87
+ ...current,
88
+ delivery: {
89
+ ...current.delivery,
90
+ state: "delivering",
91
+ startedAt: this.timestamp(),
92
+ },
93
+ }));
94
+ try {
95
+ const result = await this.options.delivery.deliver(record.target, record.finalOutcome.text, record.delivery.deliveryId);
96
+ await this.options.store.update(triggerKey, (current) => ({
97
+ ...current,
98
+ delivery: {
99
+ ...current.delivery,
100
+ state: "delivered",
101
+ finishedAt: this.timestamp(),
102
+ ...(result.providerRequestId
103
+ ? { providerRequestId: result.providerRequestId }
104
+ : {}),
105
+ },
106
+ }));
107
+ }
108
+ catch (error) {
109
+ await this.options.store.update(triggerKey, (current) => ({
110
+ ...current,
111
+ delivery: {
112
+ ...current.delivery,
113
+ state: "failed",
114
+ finishedAt: this.timestamp(),
115
+ failure: { code: "delivery_api_failed", summary: summary(error) },
116
+ },
117
+ }));
118
+ }
119
+ }
120
+ async fail(triggerKey, runId, code, error) {
121
+ const detail = summary(error);
122
+ await this.options.store.update(triggerKey, (current) => ({
123
+ ...current,
124
+ run: {
125
+ ...(current.run ?? this.newRun(runId, "queued")),
126
+ state: "failed",
127
+ finishedAt: this.timestamp(),
128
+ failure: { code, summary: detail },
129
+ },
130
+ finalOutcome: {
131
+ origin: "beacon_failure",
132
+ text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
133
+ submittedAt: this.timestamp(),
134
+ },
135
+ }));
136
+ await this.deliver(triggerKey);
137
+ }
138
+ async execute(triggerKey, input, runId) {
139
+ await this.options.store.update(triggerKey, (current) => ({
140
+ ...current,
141
+ run: { ...current.run, state: "starting", startedAt: this.timestamp() },
142
+ }));
143
+ await this.options.store.update(triggerKey, (current) => ({
144
+ ...current,
145
+ run: { ...current.run, state: "running" },
146
+ }));
147
+ const submission = this.options.outcomes.openRun();
148
+ try {
149
+ const completion = await this.options.runAgent({
150
+ prompt: promptFor(input),
151
+ workspace: this.options.profile.workspace,
152
+ provider: this.options.profile.model.provider,
153
+ model: this.options.profile.model.id,
154
+ systemPrompt: [
155
+ this.options.profile.prompt,
156
+ "",
157
+ "When your work is complete, call submit_final_outcome exactly once with the exact user-facing response.",
158
+ "Beacon ignores ordinary assistant final text for Delivery.",
159
+ ].join("\n"),
160
+ outcome: { ...submission.binding, cliPath: this.options.beaconCliPath },
161
+ });
162
+ const outcome = submission.take();
163
+ await this.options.store.update(triggerKey, (current) => ({
164
+ ...current,
165
+ run: {
166
+ ...current.run,
167
+ state: "succeeded",
168
+ finishedAt: this.timestamp(),
169
+ provider: completion.provider,
170
+ model: completion.model,
171
+ },
172
+ finalOutcome: {
173
+ origin: "agent",
174
+ text: outcome,
175
+ submittedAt: this.timestamp(),
176
+ },
177
+ }));
178
+ await this.deliver(triggerKey);
179
+ }
180
+ catch (error) {
181
+ submission.cancel();
182
+ const code = error instanceof PiRuntimeError
183
+ ? error.code
184
+ : error instanceof Error &&
185
+ /submitting a Final Outcome/.test(error.message)
186
+ ? "outcome_missing"
187
+ : "runtime_exit_failed";
188
+ await this.fail(triggerKey, runId, code, error);
189
+ }
190
+ }
191
+ async process(triggerKey, normalize) {
192
+ const runId = `run_${this.id()}`;
193
+ let input;
194
+ try {
195
+ input = await normalize();
196
+ }
197
+ catch (error) {
198
+ await this.options.store.update(triggerKey, (current) => ({
199
+ ...current,
200
+ run: this.newRun(runId, "failed", {
201
+ code: "trigger_normalization_failed",
202
+ summary: summary(error),
203
+ }),
204
+ finalOutcome: {
205
+ origin: "beacon_failure",
206
+ text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
207
+ submittedAt: this.timestamp(),
208
+ },
209
+ }));
210
+ await this.deliver(triggerKey);
211
+ return;
212
+ }
213
+ await this.options.store.update(triggerKey, (current) => ({
214
+ ...current,
215
+ input,
216
+ run: this.newRun(runId, "queued"),
217
+ }));
218
+ const queued = this.options.queue.enqueue(() => this.execute(triggerKey, input, runId));
219
+ if (!queued.accepted) {
220
+ await this.fail(triggerKey, runId, "capacity_exceeded", "Run queue capacity exceeded");
221
+ return;
222
+ }
223
+ await queued.completion;
224
+ }
225
+ async recover() {
226
+ for (const record of await this.options.store.list()) {
227
+ if (record.delivery?.state === "delivering") {
228
+ await this.options.store.update(record.triggerKey, (current) => ({
229
+ ...current,
230
+ delivery: {
231
+ ...current.delivery,
232
+ state: "failed",
233
+ finishedAt: this.timestamp(),
234
+ failure: {
235
+ code: "delivery_interrupted",
236
+ summary: "Service restarted while Delivery result was unknown",
237
+ },
238
+ },
239
+ }));
240
+ continue;
241
+ }
242
+ if (record.delivery?.state === "pending") {
243
+ await this.deliver(record.triggerKey, false);
244
+ continue;
245
+ }
246
+ if (record.run?.state === "starting" || record.run?.state === "running") {
247
+ await this.fail(record.triggerKey, record.run.runId, "service_interrupted", "Service restarted while Run was active");
248
+ continue;
249
+ }
250
+ if (record.run?.state === "queued" && record.input) {
251
+ const queued = this.options.queue.enqueue(() => this.execute(record.triggerKey, record.input, record.run.runId));
252
+ if (!queued.accepted) {
253
+ throw new Error("Persisted queued Runs exceed configured queue capacity");
254
+ }
255
+ await queued.completion;
256
+ continue;
257
+ }
258
+ if (!record.run) {
259
+ await this.fail(record.triggerKey, `run_${this.id()}`, "service_interrupted", "Service restarted before Trigger normalization completed");
260
+ continue;
261
+ }
262
+ if (record.finalOutcome &&
263
+ !record.delivery &&
264
+ (record.run.state === "succeeded" || record.run.state === "failed")) {
265
+ await this.deliver(record.triggerKey);
266
+ }
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,39 @@
1
+ import { runPiAgent, } from "../runtime/pi-rpc.js";
2
+ function messageForPrompt(message) {
3
+ return {
4
+ sender_type: message.senderType,
5
+ message_type: message.messageType,
6
+ content: message.content,
7
+ };
8
+ }
9
+ export function formatFeishuTriggerPrompt(trigger) {
10
+ return [
11
+ "A Feishu user triggered this Run. Treat the following JSON as user-provided conversation context.",
12
+ "quoted_messages is the complete quoted chain in chronological order (oldest first).",
13
+ "current_message is the message that triggered this Run.",
14
+ JSON.stringify({
15
+ chat_type: trigger.source.chatType,
16
+ quoted_messages: trigger.quotedMessages.map(messageForPrompt),
17
+ current_message: messageForPrompt(trigger.message),
18
+ }, null, 2),
19
+ ].join("\n");
20
+ }
21
+ export function createProfileRunner(profile, outcomes, beaconCliPath, runAgent = runPiAgent) {
22
+ return async (trigger) => {
23
+ const submission = outcomes.openRun();
24
+ await runAgent({
25
+ prompt: formatFeishuTriggerPrompt(trigger),
26
+ workspace: profile.workspace,
27
+ provider: profile.model.provider,
28
+ model: profile.model.id,
29
+ systemPrompt: [
30
+ profile.prompt,
31
+ "",
32
+ "When your work is complete, you must call the submit_final_outcome tool exactly once with the exact user-facing response.",
33
+ "Beacon ignores your final assistant response for Delivery; only the submitted text is delivered.",
34
+ ].join("\n"),
35
+ outcome: { ...submission.binding, cliPath: beaconCliPath },
36
+ });
37
+ return submission.take();
38
+ };
39
+ }
@@ -0,0 +1,46 @@
1
+ export class RunQueue {
2
+ maxConcurrent;
3
+ maxQueued;
4
+ active = 0;
5
+ pending = [];
6
+ constructor(maxConcurrent, maxQueued) {
7
+ this.maxConcurrent = maxConcurrent;
8
+ this.maxQueued = maxQueued;
9
+ if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent <= 0) {
10
+ throw new Error("maxConcurrent must be a positive integer");
11
+ }
12
+ if (!Number.isSafeInteger(maxQueued) || maxQueued < 0) {
13
+ throw new Error("maxQueued must be a non-negative integer");
14
+ }
15
+ }
16
+ enqueue(task) {
17
+ if (this.active >= this.maxConcurrent &&
18
+ this.pending.length >= this.maxQueued) {
19
+ return { accepted: false };
20
+ }
21
+ let resolve;
22
+ let reject;
23
+ const completion = new Promise((resolvePromise, rejectPromise) => {
24
+ resolve = resolvePromise;
25
+ reject = rejectPromise;
26
+ });
27
+ this.pending.push({ task, resolve, reject });
28
+ this.drain();
29
+ return { accepted: true, completion };
30
+ }
31
+ drain() {
32
+ while (this.active < this.maxConcurrent) {
33
+ const next = this.pending.shift();
34
+ if (!next)
35
+ return;
36
+ this.active += 1;
37
+ void next
38
+ .task()
39
+ .then(next.resolve, next.reject)
40
+ .finally(() => {
41
+ this.active -= 1;
42
+ this.drain();
43
+ });
44
+ }
45
+ }
46
+ }