@nettee/beacon 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Beacon
2
2
 
3
- Beacon is a single-host service that turns Feishu messages and configured schedules into isolated Pi agent runs. Each accepted Trigger is durably claimed, produces exactly one explicit Final Outcome, and is delivered either as a quoted reply or to a configured Feishu `chat_id`.
3
+ Beacon is a single-host service that turns Feishu messages and configured schedules into isolated Pi agent runs. Each accepted Trigger is durably claimed, produces an explicit Final Outcome, and may `reply` to a person, `notify_card` a group, or stay silent on the admin channel.
4
4
 
5
5
  This repository currently implements the Feishu + Pi MVP described by the
6
6
  [Zest Dev Spec](https://github.com/nettee/beacon/blob/main/specs/change/20260913-beacon-feishu-pi-mvp/spec.md).
@@ -97,7 +97,13 @@ per-Run directories with mode `0700` when Pi starts.
97
97
 
98
98
  Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, unsafe Prompt paths, permissive secret or runtime-environment permissions, and missing Profile credentials all fail startup. Beacon validates all Profiles before opening a Feishu connection.
99
99
 
100
- Each Schedule uses a five-field cron expression and an IANA timezone. Its `delivery.chat_id` may identify either a direct chat or a group chat; Beacon deliberately does not infer or fall back to another destination. A newly discovered Schedule starts at the current time. After sleep or restart, overdue occurrences are reconciled and coalesced to the most recent one.
100
+ Each Schedule uses a five-field cron expression and an IANA timezone. Profile
101
+ `admin.chat_id` is the private chat used for schedule `reply` / `no_reply` and
102
+ for failure notices. Optional `notify.chat_id` on a Schedule is the group that
103
+ receives `notify_card`. Beacon deliberately does not infer or fall back to
104
+ another destination. A newly discovered Schedule starts at the current time.
105
+ After sleep or restart, overdue occurrences are reconciled and coalesced to the
106
+ most recent one.
101
107
 
102
108
  ## Commands
103
109
 
@@ -129,20 +135,24 @@ beacon schedule trigger --profile example --schedule daily-report
129
135
  ```
130
136
 
131
137
  This creates a distinct durable Run on every invocation and prints its
132
- `run_id`. It uses the Schedule's configured `input` and `delivery.chat_id`, but
133
- does not read, initialize, or advance the Schedule's cron cursor. Unknown
138
+ `run_id`. It uses the Schedule's configured `input`, replies to
139
+ `admin.chat_id`, and may notify `notify.chat_id`, but does not read, initialize,
140
+ or advance the Schedule's cron cursor. Unknown
134
141
  Profiles or Schedules and failed Runs or Deliveries exit non-zero; failure
135
142
  messages include the `run_id` whenever a Run record was created.
136
143
 
137
144
  ## Final Outcomes
138
145
 
139
- An Agent explicitly calls exactly one of three Final Outcome tools:
146
+ An Agent closes the conversational channel with `reply` or `no_reply`, and may
147
+ also call `notify_card` on the same Run:
140
148
 
141
- - `submit_final_outcome_text` sends a normal Feishu text message.
142
- - `submit_final_outcome_card` sends an interactive card.
143
- - `submit_final_outcome_no_reply` completes the Run without creating a
144
- Delivery. Its required `reason` is stored for internal audit and is never
145
- sent to the user.
149
+ - `reply` sends plain text. Inbound Feishu messages quote-reply the user.
150
+ Schedules message the Profile admin. Inbound Runs must call `reply`, even
151
+ when the message is outside the Profile's role.
152
+ - `no_reply` finishes a Schedule without messaging the admin. Its `reason` is
153
+ stored for audit and is never sent. Do not use it on inbound messages.
154
+ - `notify_card` posts an interactive card to the Schedule's configured group.
155
+ Inbound messages cannot notify.
146
156
 
147
157
  The card tool accepts a title, Feishu-compatible Markdown body, and up to five
148
158
  HTTP(S) link buttons. The first button is styled as primary. The tools accept
@@ -164,15 +174,15 @@ these argument shapes:
164
174
  ```
165
175
 
166
176
  ```json
167
- { "reason": "The group announcement is outside this Profile's role." }
177
+ { "reason": "No new models to onboard." }
168
178
  ```
169
179
 
170
- The selected form is stored durably as part of the Final Outcome. Both quoted
171
- message replies and scheduled chat messages preserve that choice: text remains
172
- text, while cards are sent with Feishu's `interactive` message type and include
173
- the card generation time. A no-reply outcome records a successful Run without
174
- a Delivery record. Manual local triggers render card outcomes as readable
175
- Markdown on stdout and print no user-facing content for no-reply outcomes.
180
+ Beacon binds destinations; the Agent never supplies a `chat_id`. Text remains
181
+ text. Cards are sent with Feishu's `interactive` message type and include the
182
+ card generation time. A schedule `no_reply` without `notify_card` records a
183
+ successful Run with no Delivery. Failures `reply` a short error and never
184
+ notify a group. Manual local triggers print reply text and any notify card as
185
+ Markdown on stdout.
176
186
 
177
187
  ## State and failure behavior
178
188
 
@@ -27,7 +27,10 @@ const scheduleSchema = z
27
27
  }
28
28
  }, "Schedule timezone must be a valid IANA timezone"),
29
29
  input: z.string().trim().min(1),
30
- delivery: z.object({ chat_id: z.string().trim().min(1) }).strict(),
30
+ notify: z
31
+ .object({ chat_id: z.string().trim().min(1) })
32
+ .strict()
33
+ .optional(),
31
34
  })
32
35
  .strict();
33
36
  const profileDocumentSchema = z
@@ -41,6 +44,10 @@ const profileDocumentSchema = z
41
44
  id: z.string().min(1),
42
45
  })
43
46
  .strict(),
47
+ admin: z
48
+ .object({ chat_id: z.string().trim().min(1) })
49
+ .strict()
50
+ .optional(),
44
51
  schedules: z.array(scheduleSchema).default([]),
45
52
  })
46
53
  .strict();
@@ -73,6 +80,9 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
73
80
  });
74
81
  }
75
82
  }
83
+ if (config.schedules.length > 0 && !config.admin) {
84
+ throw new Error(`Profile ${profileId} with schedules must declare admin.chat_id`);
85
+ }
76
86
  if (isAbsolute(config.prompt)) {
77
87
  throw new Error(`Profile prompt path must be relative to ${profileDirectory}`);
78
88
  }
@@ -115,12 +125,21 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
115
125
  workspace,
116
126
  runtime: config.runtime,
117
127
  model: config.model,
128
+ ...(config.admin ? { admin: { chatId: config.admin.chat_id } } : {}),
118
129
  schedules: config.schedules.map((schedule) => ({
119
130
  id: schedule.id,
120
131
  cron: schedule.cron,
121
132
  timezone: schedule.timezone,
122
133
  input: schedule.input,
123
- delivery: { chatId: schedule.delivery.chat_id },
134
+ ...(schedule.notify
135
+ ? { notify: { chatId: schedule.notify.chat_id } }
136
+ : {}),
124
137
  })),
125
138
  };
126
139
  }
140
+ export function profileAdminChatId(profile) {
141
+ if (!profile.admin?.chatId) {
142
+ throw new Error(`Profile ${profile.id} must declare admin.chat_id`);
143
+ }
144
+ return profile.admin.chatId;
145
+ }
@@ -22,9 +22,6 @@ function assertSucceeded(operation, result) {
22
22
  }
23
23
  }
24
24
  export function encodeFeishuFinalOutcome(outcome) {
25
- if (outcome.kind === "no_reply") {
26
- throw new Error("No-reply Outcomes must not be encoded for Delivery");
27
- }
28
25
  if (outcome.kind === "text") {
29
26
  return {
30
27
  msgType: "text",
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { loadGlobalConfig } from "./config/global.js";
3
3
  import { loadProfileRegistry } from "./config/registry.js";
4
4
  import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
5
- import { renderFinalOutcomeAsText } from "./outcome/content.js";
5
+ import { renderDeliveryAsText } from "./outcome/content.js";
6
6
  import { startOutcomeServer } from "./outcome/server.js";
7
7
  import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
8
8
  import { RunQueue } from "./run/queue.js";
@@ -34,7 +34,7 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
34
34
  if (target.kind !== "local_stdout") {
35
35
  throw new Error("Manual Trigger requires local stdout Delivery");
36
36
  }
37
- await output(renderFinalOutcomeAsText(outcome));
37
+ await output(renderDeliveryAsText(outcome));
38
38
  return {};
39
39
  },
40
40
  };
@@ -53,7 +53,7 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
53
53
  }));
54
54
  const record = (await store.list()).find((candidate) => candidate.triggerKey === claim.record.triggerKey);
55
55
  if (record?.run?.state !== "succeeded" ||
56
- (record.finalOutcome?.content.kind !== "no_reply" &&
56
+ (record.finalOutcome?.content.reply.kind !== "no_reply" &&
57
57
  record.delivery?.state !== "delivered")) {
58
58
  throw new Error(`Manual Trigger failed: ${record?.run?.failure?.code ?? record?.delivery?.failure?.code ?? "unknown"}`);
59
59
  }
@@ -10,30 +10,106 @@ const buttonSchema = z
10
10
  .refine((value) => value.startsWith("https://") || value.startsWith("http://"), "must use http or https"),
11
11
  })
12
12
  .strict();
13
- export const finalOutcomeContentSchema = z.discriminatedUnion("kind", [
14
- z.object({ kind: z.literal("text"), text: nonBlank }).strict(),
15
- z.object({ kind: z.literal("no_reply"), reason: nonBlank }).strict(),
16
- z
17
- .object({
18
- kind: z.literal("card"),
19
- title: nonBlank,
20
- content: nonBlank,
21
- buttons: z.array(buttonSchema).max(5).default([]),
22
- })
23
- .strict(),
13
+ const textReplySchema = z
14
+ .object({ kind: z.literal("text"), text: nonBlank })
15
+ .strict();
16
+ const noReplySchema = z
17
+ .object({ kind: z.literal("no_reply"), reason: nonBlank })
18
+ .strict();
19
+ const replySchema = z.discriminatedUnion("kind", [
20
+ textReplySchema,
21
+ noReplySchema,
22
+ ]);
23
+ const cardSchema = z
24
+ .object({
25
+ kind: z.literal("card"),
26
+ title: nonBlank,
27
+ content: nonBlank,
28
+ buttons: z.array(buttonSchema).max(5).default([]),
29
+ })
30
+ .strict();
31
+ export const finalOutcomeContentSchema = z
32
+ .object({
33
+ reply: replySchema,
34
+ notify: cardSchema.optional(),
35
+ })
36
+ .strict();
37
+ const outcomePatchSchema = z
38
+ .object({
39
+ reply: replySchema.optional(),
40
+ notify: cardSchema.optional(),
41
+ })
42
+ .strict()
43
+ .refine((value) => value.reply !== undefined || value.notify !== undefined, {
44
+ message: "Outcome submission must include reply or notify",
45
+ });
46
+ const legacyOutcomeSchema = z.discriminatedUnion("kind", [
47
+ textReplySchema,
48
+ noReplySchema,
49
+ cardSchema,
24
50
  ]);
51
+ function renderCard(card) {
52
+ return [
53
+ `# ${card.title}`,
54
+ "",
55
+ card.content,
56
+ ...card.buttons.map((button) => `\n[${button.label}](${button.url})`),
57
+ ].join("\n");
58
+ }
25
59
  export function parseFinalOutcomeContent(value) {
60
+ const legacy = legacyOutcomeSchema.safeParse(value);
61
+ if (legacy.success) {
62
+ if (legacy.data.kind === "card") {
63
+ return {
64
+ reply: { kind: "text", text: renderCard(legacy.data) },
65
+ notify: legacy.data,
66
+ };
67
+ }
68
+ return { reply: legacy.data };
69
+ }
26
70
  return finalOutcomeContentSchema.parse(value);
27
71
  }
72
+ export function parseOutcomePatch(value) {
73
+ const legacy = legacyOutcomeSchema.safeParse(value);
74
+ if (legacy.success)
75
+ return parseFinalOutcomeContent(legacy.data);
76
+ return outcomePatchSchema.parse(value);
77
+ }
78
+ export function mergeOutcome(current, patch) {
79
+ if (patch.reply && current?.reply) {
80
+ throw new Error("Reply already submitted");
81
+ }
82
+ if (patch.notify && current?.notify) {
83
+ throw new Error("Notify already submitted");
84
+ }
85
+ return {
86
+ ...(current?.reply || patch.reply
87
+ ? { reply: patch.reply ?? current?.reply }
88
+ : {}),
89
+ ...(current?.notify || patch.notify
90
+ ? { notify: patch.notify ?? current?.notify }
91
+ : {}),
92
+ };
93
+ }
94
+ export function completeOutcome(partial) {
95
+ if (!partial.reply) {
96
+ throw new Error("Agent Runtime settled without submitting reply or no_reply");
97
+ }
98
+ return {
99
+ reply: partial.reply,
100
+ ...(partial.notify ? { notify: partial.notify } : {}),
101
+ };
102
+ }
28
103
  export function renderFinalOutcomeAsText(outcome) {
29
- if (outcome.kind === "text")
30
- return outcome.text;
31
- if (outcome.kind === "no_reply")
32
- return "";
33
- return [
34
- `# ${outcome.title}`,
35
- "",
36
- outcome.content,
37
- ...outcome.buttons.map((button) => `\n[${button.label}](${button.url})`),
38
- ].join("\n");
104
+ const parts = [];
105
+ if (outcome.reply.kind === "text")
106
+ parts.push(outcome.reply.text);
107
+ if (outcome.notify)
108
+ parts.push(renderCard(outcome.notify));
109
+ return parts.join("\n\n");
110
+ }
111
+ export function renderDeliveryAsText(content) {
112
+ if (content.kind === "text")
113
+ return content.text;
114
+ return renderCard(content);
39
115
  }
@@ -18,7 +18,8 @@ export async function runOutcomeDoctor(profileId) {
18
18
  const beaconCliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
19
19
  try {
20
20
  const result = await createProfileRunner(profile, outcomes, beaconCliPath)(doctorTrigger);
21
- if (result.kind !== "text" || result.text.trim() !== "OUTCOME_CLI_OK") {
21
+ if (result.reply.kind !== "text" ||
22
+ result.reply.text.trim() !== "OUTCOME_CLI_OK") {
22
23
  throw new Error(`Outcome doctor received unexpected submitted text: ${JSON.stringify(result)}`);
23
24
  }
24
25
  console.log("[beacon] explicit Final Outcome chain ready");
@@ -1,5 +1,8 @@
1
+ export const inboundOutOfRoleReply = "这条消息不在我的职责范围内。";
1
2
  export const finalOutcomeSystemInstructions = [
2
- "When your work is complete, call exactly one of submit_final_outcome_text, submit_final_outcome_card, or submit_final_outcome_no_reply.",
3
- "Use submit_final_outcome_text for a normal response, submit_final_outcome_card when a titled Markdown summary and link buttons materially improve the result, or submit_final_outcome_no_reply when the triggering message is outside the Profile's role and should receive no response.",
3
+ "When your work is complete, close the conversational channel with exactly one of `reply` or `no_reply`. You may also call `notify_card` at most once on the same Run.",
4
+ "`reply` sends plain text. For an inbound Feishu message, quote-reply that message. For a Schedule, message the Profile admin. Inbound Runs must call `reply`, even when the message is outside this Profile's role: send a short text saying the request is out of scope. Do not call `no_reply` on inbound messages.",
5
+ "`no_reply` finishes a Schedule without messaging the admin. Its reason is stored for audit and is never sent. Do not use it for inbound Feishu messages.",
6
+ "`notify_card` posts a titled Markdown card to the Schedule's configured group. Call it only when this Run is a Schedule with a notify target. Inbound messages cannot notify.",
4
7
  "Beacon ignores ordinary assistant final text for Delivery.",
5
8
  ];
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { createServer } from "node:net";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { parseFinalOutcomeContent } from "./content.js";
6
+ import { completeOutcome, mergeOutcome, parseOutcomePatch, } from "./content.js";
7
7
  function respond(socket, response) {
8
8
  socket.end(`${JSON.stringify(response)}\n`);
9
9
  }
@@ -54,9 +54,9 @@ export async function startOutcomeServer(options = {}) {
54
54
  });
55
55
  return;
56
56
  }
57
- let outcome;
57
+ let patch;
58
58
  try {
59
- outcome = parseFinalOutcomeContent(request.outcome);
59
+ patch = parseOutcomePatch(request.outcome);
60
60
  }
61
61
  catch (error) {
62
62
  respond(socket, {
@@ -70,14 +70,16 @@ export async function startOutcomeServer(options = {}) {
70
70
  respond(socket, { ok: false, error: "unknown Run Capability" });
71
71
  return;
72
72
  }
73
- if (submission.outcome !== undefined) {
73
+ try {
74
+ submission.outcome = mergeOutcome(submission.outcome, patch);
75
+ }
76
+ catch (error) {
74
77
  respond(socket, {
75
78
  ok: false,
76
- error: "Final Outcome already submitted",
79
+ error: error instanceof Error ? error.message : String(error),
77
80
  });
78
81
  return;
79
82
  }
80
- submission.outcome = outcome;
81
83
  console.log("[beacon] Final Outcome submitted by Agent Runtime");
82
84
  respond(socket, { ok: true });
83
85
  });
@@ -98,10 +100,7 @@ export async function startOutcomeServer(options = {}) {
98
100
  binding: { socketPath, runToken },
99
101
  take() {
100
102
  submissions.delete(runToken);
101
- if (record.outcome === undefined) {
102
- throw new Error("Agent Runtime settled without submitting a Final Outcome");
103
- }
104
- return record.outcome;
103
+ return completeOutcome(record.outcome ?? {});
105
104
  },
106
105
  cancel() {
107
106
  submissions.delete(runToken);
@@ -1,13 +1,13 @@
1
1
  import { createConnection } from "node:net";
2
- import { parseFinalOutcomeContent } from "./content.js";
2
+ import { completeOutcome, mergeOutcome, parseOutcomePatch, } from "./content.js";
3
3
  export async function submitOutcome(socketPath, runToken, outcome) {
4
- const validatedOutcome = parseFinalOutcomeContent(outcome);
4
+ const patch = parseOutcomePatch(outcome);
5
5
  await new Promise((resolve, reject) => {
6
6
  const socket = createConnection(socketPath);
7
7
  socket.setEncoding("utf8");
8
8
  let responseBuffer = "";
9
9
  socket.once("connect", () => {
10
- socket.write(`${JSON.stringify({ runToken, outcome: validatedOutcome })}\n`);
10
+ socket.write(`${JSON.stringify({ runToken, outcome: patch })}\n`);
11
11
  });
12
12
  socket.on("data", (chunk) => {
13
13
  responseBuffer += chunk;
@@ -42,15 +42,16 @@ export async function submitOutcomeFromCli() {
42
42
  let input = "";
43
43
  for await (const chunk of process.stdin)
44
44
  input += chunk;
45
- let outcome;
45
+ let patch;
46
46
  try {
47
- outcome = parseFinalOutcomeContent(JSON.parse(input));
47
+ patch = parseOutcomePatch(JSON.parse(input));
48
48
  }
49
49
  catch (error) {
50
50
  throw new Error("Final Outcome stdin must be valid outcome JSON", {
51
51
  cause: error,
52
52
  });
53
53
  }
54
- await submitOutcome(socketPath, runToken, outcome);
54
+ await submitOutcome(socketPath, runToken, patch);
55
55
  console.log("Final Outcome accepted by Beacon");
56
56
  }
57
+ export { completeOutcome, mergeOutcome };
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isAbsolute, join } from "node:path";
3
+ import { inboundOutOfRoleReply } from "../outcome/instructions.js";
3
4
  import { PiRuntimeError } from "../runtime/pi-rpc.js";
4
5
  import { buildAgentSystemPrompt } from "../runtime/system-prompt.js";
5
6
  function promptFor(input) {
@@ -38,6 +39,26 @@ function summary(error) {
38
39
  const value = error instanceof Error ? error.message : String(error);
39
40
  return value.slice(0, 4096) || "unknown failure";
40
41
  }
42
+ function failureOutcome(runId) {
43
+ return {
44
+ reply: {
45
+ kind: "text",
46
+ text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
47
+ },
48
+ };
49
+ }
50
+ function normalizeAgentOutcome(input, outcome, notifyTarget) {
51
+ if (input.kind !== "schedule" && outcome.notify) {
52
+ throw new Error("notify_card is only valid on Schedule Runs");
53
+ }
54
+ if (outcome.notify && !notifyTarget) {
55
+ throw new Error("notify_card requires a configured notify chat");
56
+ }
57
+ if (input.kind === "feishu_message" && outcome.reply.kind === "no_reply") {
58
+ return { reply: { kind: "text", text: inboundOutOfRoleReply } };
59
+ }
60
+ return outcome;
61
+ }
41
62
  export class RunOrchestrator {
42
63
  options;
43
64
  now;
@@ -53,6 +74,12 @@ export class RunOrchestrator {
53
74
  timestamp() {
54
75
  return this.now().toISOString();
55
76
  }
77
+ async record(triggerKey) {
78
+ const found = (await this.options.store.list()).find((candidate) => candidate.triggerKey === triggerKey);
79
+ if (!found)
80
+ throw new Error(`Unknown Trigger ${triggerKey}`);
81
+ return found;
82
+ }
56
83
  newRun(runId, state, failure) {
57
84
  const timestamp = this.timestamp();
58
85
  return {
@@ -71,39 +98,39 @@ export class RunOrchestrator {
71
98
  ...(failure ? { failure } : {}),
72
99
  };
73
100
  }
74
- async deliver(triggerKey, create = true) {
101
+ async deliverField(triggerKey, field, target, content, create = true) {
75
102
  const deliveryId = `del_${this.id()}`;
76
- let record;
77
103
  if (create) {
78
- record = await this.options.store.update(triggerKey, (current) => ({
104
+ await this.options.store.update(triggerKey, (current) => ({
79
105
  ...current,
80
- delivery: {
106
+ [field]: {
81
107
  deliveryId,
82
- target: current.target,
108
+ target,
83
109
  state: "pending",
84
110
  },
85
111
  }));
86
112
  }
87
113
  else {
88
- record = (await this.options.store.list()).find((candidate) => candidate.triggerKey === triggerKey);
89
- if (record.delivery?.state !== "pending") {
90
- throw new Error(`Cannot resume non-pending Delivery for Trigger ${triggerKey}`);
114
+ const current = await this.record(triggerKey);
115
+ if (current[field]?.state !== "pending") {
116
+ throw new Error(`Cannot resume non-pending ${field} for Trigger ${triggerKey}`);
91
117
  }
92
118
  }
93
- record = await this.options.store.update(triggerKey, (current) => ({
119
+ await this.options.store.update(triggerKey, (current) => ({
94
120
  ...current,
95
- delivery: {
96
- ...current.delivery,
121
+ [field]: {
122
+ ...current[field],
97
123
  state: "delivering",
98
124
  startedAt: this.timestamp(),
99
125
  },
100
126
  }));
127
+ const live = await this.record(triggerKey);
101
128
  try {
102
- const result = await this.options.delivery.deliver(record.target, record.finalOutcome.content, record.delivery.deliveryId);
129
+ const result = await this.options.delivery.deliver(target, content, live[field].deliveryId);
103
130
  await this.options.store.update(triggerKey, (current) => ({
104
131
  ...current,
105
- delivery: {
106
- ...current.delivery,
132
+ [field]: {
133
+ ...current[field],
107
134
  state: "delivered",
108
135
  finishedAt: this.timestamp(),
109
136
  ...(result.providerRequestId
@@ -115,8 +142,8 @@ export class RunOrchestrator {
115
142
  catch (error) {
116
143
  await this.options.store.update(triggerKey, (current) => ({
117
144
  ...current,
118
- delivery: {
119
- ...current.delivery,
145
+ [field]: {
146
+ ...current[field],
120
147
  state: "failed",
121
148
  finishedAt: this.timestamp(),
122
149
  failure: { code: "delivery_api_failed", summary: summary(error) },
@@ -124,6 +151,16 @@ export class RunOrchestrator {
124
151
  }));
125
152
  }
126
153
  }
154
+ async deliverOutcome(triggerKey, create = true) {
155
+ const current = await this.record(triggerKey);
156
+ const outcome = current.finalOutcome.content;
157
+ if (outcome.reply.kind === "text") {
158
+ await this.deliverField(triggerKey, "delivery", current.target, outcome.reply, create && current.delivery === undefined);
159
+ }
160
+ if (outcome.notify && current.notifyTarget) {
161
+ await this.deliverField(triggerKey, "notifyDelivery", current.notifyTarget, outcome.notify, create && current.notifyDelivery === undefined);
162
+ }
163
+ }
127
164
  async fail(triggerKey, runId, code, error) {
128
165
  const detail = summary(error);
129
166
  await this.options.store.update(triggerKey, (current) => ({
@@ -136,14 +173,11 @@ export class RunOrchestrator {
136
173
  },
137
174
  finalOutcome: {
138
175
  origin: "beacon_failure",
139
- content: {
140
- kind: "text",
141
- text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
142
- },
176
+ content: failureOutcome(runId),
143
177
  submittedAt: this.timestamp(),
144
178
  },
145
179
  }));
146
- await this.deliver(triggerKey);
180
+ await this.deliverOutcome(triggerKey);
147
181
  }
148
182
  async execute(triggerKey, input, runId) {
149
183
  const record = await this.options.store.update(triggerKey, (current) => {
@@ -156,7 +190,6 @@ export class RunOrchestrator {
156
190
  sessionPath: current.run.sessionPath,
157
191
  }
158
192
  : {
159
- // Migration for queued records written by Beacon <= 0.1.2.
160
193
  sessionId: current.run.runId,
161
194
  sessionPath: join(this.options.sessionDirectory, this.options.profile.id, current.run.runId),
162
195
  };
@@ -189,7 +222,7 @@ export class RunOrchestrator {
189
222
  name: `Beacon ${this.options.profile.id} ${runId}`,
190
223
  },
191
224
  });
192
- const outcome = submission.take();
225
+ const outcome = normalizeAgentOutcome(input, submission.take(), record.notifyTarget);
193
226
  await this.options.store.update(triggerKey, (current) => ({
194
227
  ...current,
195
228
  run: {
@@ -205,15 +238,14 @@ export class RunOrchestrator {
205
238
  submittedAt: this.timestamp(),
206
239
  },
207
240
  }));
208
- if (outcome.kind !== "no_reply")
209
- await this.deliver(triggerKey);
241
+ await this.deliverOutcome(triggerKey);
210
242
  }
211
243
  catch (error) {
212
244
  submission.cancel();
213
245
  const code = error instanceof PiRuntimeError
214
246
  ? error.code
215
247
  : error instanceof Error &&
216
- /submitting a Final Outcome/.test(error.message)
248
+ /submitting a (Final Outcome|reply or no_reply)/.test(error.message)
217
249
  ? "outcome_missing"
218
250
  : "runtime_exit_failed";
219
251
  await this.fail(triggerKey, runId, code, error);
@@ -234,14 +266,11 @@ export class RunOrchestrator {
234
266
  }),
235
267
  finalOutcome: {
236
268
  origin: "beacon_failure",
237
- content: {
238
- kind: "text",
239
- text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
240
- },
269
+ content: failureOutcome(runId),
241
270
  submittedAt: this.timestamp(),
242
271
  },
243
272
  }));
244
- await this.deliver(triggerKey);
273
+ await this.deliverOutcome(triggerKey);
245
274
  return;
246
275
  }
247
276
  await this.options.store.update(triggerKey, (current) => ({
@@ -258,46 +287,52 @@ export class RunOrchestrator {
258
287
  }
259
288
  async recover() {
260
289
  for (const record of await this.options.store.list()) {
261
- if (record.delivery?.state === "delivering") {
262
- await this.options.store.update(record.triggerKey, (current) => ({
263
- ...current,
264
- delivery: {
265
- ...current.delivery,
266
- state: "failed",
267
- finishedAt: this.timestamp(),
268
- failure: {
269
- code: "delivery_interrupted",
270
- summary: "Service restarted while Delivery result was unknown",
290
+ for (const field of ["delivery", "notifyDelivery"]) {
291
+ if (record[field]?.state === "delivering") {
292
+ await this.options.store.update(record.triggerKey, (current) => ({
293
+ ...current,
294
+ [field]: {
295
+ ...current[field],
296
+ state: "failed",
297
+ finishedAt: this.timestamp(),
298
+ failure: {
299
+ code: "delivery_interrupted",
300
+ summary: "Service restarted while Delivery result was unknown",
301
+ },
271
302
  },
272
- },
273
- }));
274
- continue;
303
+ }));
304
+ }
275
305
  }
276
- if (record.delivery?.state === "pending") {
277
- await this.deliver(record.triggerKey, false);
306
+ const latest = await this.record(record.triggerKey);
307
+ if (latest.delivery?.state === "pending" ||
308
+ latest.notifyDelivery?.state === "pending") {
309
+ await this.deliverOutcome(latest.triggerKey, false);
278
310
  continue;
279
311
  }
280
- if (record.run?.state === "starting" || record.run?.state === "running") {
281
- await this.fail(record.triggerKey, record.run.runId, "service_interrupted", "Service restarted while Run was active");
312
+ if (latest.run?.state === "starting" || latest.run?.state === "running") {
313
+ await this.fail(latest.triggerKey, latest.run.runId, "service_interrupted", "Service restarted while Run was active");
282
314
  continue;
283
315
  }
284
- if (record.run?.state === "queued" && record.input) {
285
- const queued = this.options.queue.enqueue(() => this.execute(record.triggerKey, record.input, record.run.runId));
316
+ if (latest.run?.state === "queued" && latest.input) {
317
+ const queued = this.options.queue.enqueue(() => this.execute(latest.triggerKey, latest.input, latest.run.runId));
286
318
  if (!queued.accepted) {
287
319
  throw new Error("Persisted queued Runs exceed configured queue capacity");
288
320
  }
289
321
  await queued.completion;
290
322
  continue;
291
323
  }
292
- if (!record.run) {
293
- await this.fail(record.triggerKey, `run_${this.id()}`, "service_interrupted", "Service restarted before Trigger normalization completed");
324
+ if (!latest.run) {
325
+ await this.fail(latest.triggerKey, `run_${this.id()}`, "service_interrupted", "Service restarted before Trigger normalization completed");
294
326
  continue;
295
327
  }
296
- if (record.finalOutcome &&
297
- record.finalOutcome.content.kind !== "no_reply" &&
298
- !record.delivery &&
299
- (record.run.state === "succeeded" || record.run.state === "failed")) {
300
- await this.deliver(record.triggerKey);
328
+ if (latest.finalOutcome &&
329
+ (latest.run.state === "succeeded" || latest.run.state === "failed")) {
330
+ const outcome = latest.finalOutcome.content;
331
+ const missingReply = outcome.reply.kind === "text" && !latest.delivery;
332
+ const missingNotify = Boolean(outcome.notify) && !latest.notifyDelivery;
333
+ if (missingReply || missingNotify) {
334
+ await this.deliverOutcome(latest.triggerKey);
335
+ }
301
336
  }
302
337
  }
303
338
  }
@@ -1,18 +1,27 @@
1
1
  import { spawn } from "node:child_process";
2
- import { parseFinalOutcomeContent } from "../outcome/content.js";
3
- export function textOutcomeFromToolParams(params) {
4
- return parseFinalOutcomeContent({ kind: "text", text: params.text });
2
+ import { parseOutcomePatch } from "../outcome/content.js";
3
+ export function replyFromToolParams(params) {
4
+ return parseOutcomePatch({
5
+ reply: { kind: "text", text: params.text },
6
+ });
5
7
  }
6
- export function cardOutcomeFromToolParams(params) {
7
- return parseFinalOutcomeContent({
8
- kind: "card",
9
- title: params.title,
10
- content: params.content,
11
- buttons: params.buttons ?? [],
8
+ export function noReplyFromToolParams(params) {
9
+ return parseOutcomePatch({
10
+ reply: { kind: "no_reply", reason: params.reason },
12
11
  });
13
12
  }
14
- export function noReplyOutcomeFromToolParams(params) {
15
- return parseFinalOutcomeContent({ kind: "no_reply", reason: params.reason });
13
+ export function notifyCardFromToolParams(params) {
14
+ const patch = parseOutcomePatch({
15
+ notify: {
16
+ kind: "card",
17
+ title: params.title,
18
+ content: params.content,
19
+ buttons: params.buttons ?? [],
20
+ },
21
+ });
22
+ if (!patch.notify)
23
+ throw new Error("notify_card produced no card");
24
+ return patch.notify;
16
25
  }
17
26
  async function invokeBeaconCli(outcome, signal) {
18
27
  const cliPath = process.env.BEACON_CLI_PATH;
@@ -48,9 +57,9 @@ function acceptedResult() {
48
57
  }
49
58
  export default function registerOutcomeTools(pi) {
50
59
  pi.registerTool({
51
- name: "submit_final_outcome_text",
52
- label: "Submit Final Outcome Text",
53
- description: "Submit the final response as a normal text message. Call exactly one Final Outcome tool after completing the task.",
60
+ name: "reply",
61
+ label: "Reply",
62
+ description: "Send a plain-text reply. Inbound Feishu messages quote-reply the user. Schedules message the admin. Inbound Runs must use this tool, including out-of-role messages.",
54
63
  parameters: {
55
64
  type: "object",
56
65
  properties: {
@@ -64,14 +73,35 @@ export default function registerOutcomeTools(pi) {
64
73
  additionalProperties: false,
65
74
  },
66
75
  async execute(_toolCallId, params, signal) {
67
- await invokeBeaconCli(textOutcomeFromToolParams(params), signal);
76
+ await invokeBeaconCli({ reply: { kind: "text", text: params.text } }, signal);
77
+ return acceptedResult();
78
+ },
79
+ });
80
+ pi.registerTool({
81
+ name: "no_reply",
82
+ label: "No Reply",
83
+ description: "Finish a Schedule without messaging the admin. Do not use this on inbound Feishu messages.",
84
+ parameters: {
85
+ type: "object",
86
+ properties: {
87
+ reason: {
88
+ type: "string",
89
+ minLength: 1,
90
+ description: "A concise internal reason why the admin should not be messaged.",
91
+ },
92
+ },
93
+ required: ["reason"],
94
+ additionalProperties: false,
95
+ },
96
+ async execute(_toolCallId, params, signal) {
97
+ await invokeBeaconCli({ reply: { kind: "no_reply", reason: params.reason } }, signal);
68
98
  return acceptedResult();
69
99
  },
70
100
  });
71
101
  pi.registerTool({
72
- name: "submit_final_outcome_card",
73
- label: "Submit Final Outcome Card",
74
- description: "Submit the final response as a structured Feishu card. Call exactly one Final Outcome tool after completing the task.",
102
+ name: "notify_card",
103
+ label: "Notify Card",
104
+ description: "Post a structured Feishu card to the Schedule's configured notify group. Inbound messages cannot notify. You must still call reply or no_reply on the same Run.",
75
105
  parameters: {
76
106
  type: "object",
77
107
  properties: {
@@ -103,28 +133,14 @@ export default function registerOutcomeTools(pi) {
103
133
  additionalProperties: false,
104
134
  },
105
135
  async execute(_toolCallId, params, signal) {
106
- await invokeBeaconCli(cardOutcomeFromToolParams(params), signal);
107
- return acceptedResult();
108
- },
109
- });
110
- pi.registerTool({
111
- name: "submit_final_outcome_no_reply",
112
- label: "Submit No Reply Outcome",
113
- description: "Complete the run without sending a reply. Use this exactly once when the triggering message is outside the Profile's role.",
114
- parameters: {
115
- type: "object",
116
- properties: {
117
- reason: {
118
- type: "string",
119
- minLength: 1,
120
- description: "A concise internal reason why the message should not receive a reply.",
136
+ await invokeBeaconCli({
137
+ notify: {
138
+ kind: "card",
139
+ title: params.title,
140
+ content: params.content,
141
+ buttons: params.buttons ?? [],
121
142
  },
122
- },
123
- required: ["reason"],
124
- additionalProperties: false,
125
- },
126
- async execute(_toolCallId, params, signal) {
127
- await invokeBeaconCli(noReplyOutcomeFromToolParams(params), signal);
143
+ }, signal);
128
144
  return acceptedResult();
129
145
  },
130
146
  });
@@ -1,3 +1,4 @@
1
+ import { profileAdminChatId } from "../config/profile.js";
1
2
  import { occurrencesBetween } from "./cron.js";
2
3
  export class ScheduleReconciler {
3
4
  options;
@@ -18,7 +19,13 @@ export class ScheduleReconciler {
18
19
  const scheduledFor = latest.toISOString();
19
20
  const claim = await this.options.triggers.claim({
20
21
  sourceKey: ["schedule", schedule.id, scheduledFor],
21
- target: { kind: "chat", chatId: schedule.delivery.chatId },
22
+ target: {
23
+ kind: "chat",
24
+ chatId: profileAdminChatId(this.options.profile),
25
+ },
26
+ ...(schedule.notify
27
+ ? { notifyTarget: { kind: "chat", chatId: schedule.notify.chatId } }
28
+ : {}),
22
29
  ingress: {
23
30
  scheduleId: schedule.id,
24
31
  scheduledFor,
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadGlobalConfig } from "./config/global.js";
3
+ import { profileAdminChatId } from "./config/profile.js";
3
4
  import { loadProfileRegistry } from "./config/registry.js";
4
5
  import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
5
6
  import { loadFeishuCredentials } from "./config/secrets.js";
@@ -21,7 +22,10 @@ export async function triggerScheduleOnce(options) {
21
22
  const invocationId = (options.id ?? randomUUID)();
22
23
  const claim = await options.store.claim({
23
24
  sourceKey: ["manual-schedule", schedule.id, invocationId],
24
- target: { kind: "chat", chatId: schedule.delivery.chatId },
25
+ target: { kind: "chat", chatId: profileAdminChatId(options.profile) },
26
+ ...(schedule.notify
27
+ ? { notifyTarget: { kind: "chat", chatId: schedule.notify.chatId } }
28
+ : {}),
25
29
  ingress: {
26
30
  invocation: "manual",
27
31
  scheduleId: schedule.id,
@@ -3,7 +3,7 @@ import { chmod, mkdir, open, readdir, readFile, rename, } from "node:fs/promises
3
3
  import { basename, dirname, isAbsolute, join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { failureCodes, } from "../domain/types.js";
6
- import { finalOutcomeContentSchema } from "../outcome/content.js";
6
+ import { finalOutcomeContentSchema, parseFinalOutcomeContent, } from "../outcome/content.js";
7
7
  const timestamp = z.string().datetime({ offset: true });
8
8
  const failureSchema = z
9
9
  .object({ code: z.enum(failureCodes), summary: z.string().min(1).max(4096) })
@@ -69,7 +69,14 @@ const outcomeSchema = z.union([
69
69
  z
70
70
  .object({
71
71
  origin: z.enum(["agent", "beacon_failure"]),
72
- content: finalOutcomeContentSchema,
72
+ content: z.preprocess((value) => {
73
+ try {
74
+ return parseFinalOutcomeContent(value);
75
+ }
76
+ catch {
77
+ return value;
78
+ }
79
+ }, finalOutcomeContentSchema),
73
80
  submittedAt: timestamp,
74
81
  })
75
82
  .strict(),
@@ -82,7 +89,7 @@ const outcomeSchema = z.union([
82
89
  .strict()
83
90
  .transform(({ origin, text, submittedAt }) => ({
84
91
  origin,
85
- content: { kind: "text", text },
92
+ content: { reply: { kind: "text", text } },
86
93
  submittedAt,
87
94
  })),
88
95
  ]);
@@ -106,11 +113,13 @@ const triggerRecordSchema = z
106
113
  sourceKey: z.array(z.string().min(1)).min(2),
107
114
  acceptedAt: timestamp,
108
115
  target: targetSchema,
116
+ notifyTarget: targetSchema.optional(),
109
117
  input: inputSchema.optional(),
110
118
  ingress: z.record(z.string(), z.unknown()).optional(),
111
119
  run: runSchema.optional(),
112
120
  finalOutcome: outcomeSchema.optional(),
113
121
  delivery: deliverySchema.optional(),
122
+ notifyDelivery: deliverySchema.optional(),
114
123
  })
115
124
  .strict();
116
125
  function keyFor(profileId, sourceKey) {
@@ -134,6 +143,19 @@ function validateRecord(value) {
134
143
  if (record.delivery && !record.finalOutcome) {
135
144
  throw new Error("Trigger record with Delivery must contain a Final Outcome");
136
145
  }
146
+ if (record.notifyDelivery && !record.finalOutcome) {
147
+ throw new Error("Trigger record with notify Delivery must contain a Final Outcome");
148
+ }
149
+ if (record.delivery &&
150
+ record.finalOutcome?.content.reply.kind === "no_reply") {
151
+ throw new Error("no_reply Outcomes must not have a reply Delivery");
152
+ }
153
+ if (record.notifyDelivery && !record.finalOutcome?.content.notify) {
154
+ throw new Error("notify Delivery requires a notify card");
155
+ }
156
+ if (record.notifyDelivery && !record.notifyTarget) {
157
+ throw new Error("notify Delivery requires a notify target");
158
+ }
137
159
  if (record.run?.state === "succeeded" &&
138
160
  record.finalOutcome?.origin !== "agent") {
139
161
  throw new Error("Succeeded Run must contain an Agent Final Outcome");
@@ -243,6 +265,7 @@ export class TriggerStore {
243
265
  sourceKey: [...request.sourceKey],
244
266
  acceptedAt: (request.acceptedAt ?? new Date()).toISOString(),
245
267
  target: request.target,
268
+ ...(request.notifyTarget ? { notifyTarget: request.notifyTarget } : {}),
246
269
  ...(request.ingress ? { ingress: request.ingress } : {}),
247
270
  };
248
271
  await this.write(triggerKey, record);
@@ -4,10 +4,12 @@ runtime: pi
4
4
  model:
5
5
  provider: REPLACE_WITH_PI_PROVIDER
6
6
  id: REPLACE_WITH_PI_MODEL
7
+ admin:
8
+ chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
7
9
  schedules:
8
10
  - id: weekday-brief
9
11
  cron: "0 9 * * 1-5"
10
12
  timezone: Asia/Shanghai
11
13
  input: Prepare the weekday brief.
12
- delivery:
13
- chat_id: REPLACE_WITH_DIRECT_OR_GROUP_CHAT_ID
14
+ notify:
15
+ chat_id: REPLACE_WITH_GROUP_CHAT_ID
@@ -2,6 +2,8 @@ You are a workspace-status assistant. Your only role is to answer requests about
2
2
 
3
3
  Before reading files or running commands, decide whether the input is within that role. A group `@ All` does not by itself address this bot, and a direct message is not automatically relevant. Use the quoted-message chain when the current message is a follow-up.
4
4
 
5
- If the input is outside this role, immediately call `submit_final_outcome_no_reply` with a concise internal reason. Do not inspect the workspace or submit a text or card response.
5
+ If a Feishu message is outside this role, call `reply` with a short text saying the request is out of scope. Do not inspect the workspace. Do not call `no_reply` on inbound messages.
6
6
 
7
- For relevant input, work only within the configured workspace and return a concise, user-facing result through the appropriate Final Outcome tool. Required operations must fail visibly; do not fabricate fallback data or report success after incomplete work.
7
+ Scheduled Runs that have nothing to report should call `no_reply`. If a Schedule has a notify group and there is a report for that group, call `notify_card` and then `no_reply` unless the admin also needs a text update.
8
+
9
+ For in-role work, stay inside the configured workspace and call `reply` with a concise user-facing result. Required operations must fail visibly; do not fabricate fallback data or report success after incomplete work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {