@nettee/beacon 0.2.8 → 0.2.9

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 an explicit Final Outcome, and may `reply` to a person, `notify_card` a group, or stay silent on the admin channel.
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_text` / `reply_card` 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).
@@ -67,8 +67,8 @@ back to `prompt.md`. If neither place has a complete pair, Profile load fails
67
67
  and lists the missing paths. Both pairs may exist; the workspace pair wins.
68
68
 
69
69
  Beacon prepends an English platform template for this Run: workspace limit,
70
- inbound vs Schedule vs manual capabilities, the `reply` / `no_reply` /
71
- `notify_card` contract, and an optional `submit_feedback` tool.
70
+ inbound vs Schedule vs manual capabilities, the `reply_text` / `reply_card` /
71
+ `no_reply` / `notify_card` contract, and an optional `submit_feedback` tool.
72
72
 
73
73
  Protect the home and secret file before running Beacon:
74
74
 
@@ -112,10 +112,11 @@ per-Run directories with mode `0700` when Pi starts.
112
112
  Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, a missing complete `persona.md` + `task.md` pair, escaped Profile markdown, permissive secret or runtime-environment permissions, and missing Profile credentials all fail startup. Beacon validates all Profiles before opening a Feishu connection.
113
113
 
114
114
  Each Schedule uses a five-field cron expression and an IANA timezone. Profile
115
- `admin.chat_id` is the private chat used for schedule `reply` / `no_reply` and
116
- for failure notices. Optional `notify.chat_id` on a Schedule is the group that
117
- receives `notify_card`. Beacon deliberately does not infer or fall back to
118
- another destination. A newly discovered Schedule starts at the current time.
115
+ `admin.chat_id` is the private chat used for schedule `reply_text` /
116
+ `reply_card` / `no_reply` and for failure notices. Optional `notify.chat_id` on a
117
+ Schedule is the group that receives `notify_card`. Beacon deliberately does not
118
+ infer or fall back to another destination. A newly discovered Schedule starts at
119
+ the current time.
119
120
  After sleep or restart, overdue occurrences are reconciled and coalesced to the
120
121
  most recent one.
121
122
 
@@ -157,18 +158,26 @@ messages include the `run_id` whenever a Run record was created.
157
158
 
158
159
  ## Final Outcomes
159
160
 
160
- An Agent closes the conversational channel with `reply` or `no_reply`, and may
161
- also call `notify_card` on the same Run:
162
-
163
- - `reply` sends plain text. Inbound Feishu messages quote-reply the user.
164
- Schedules message the Profile admin. Inbound Runs must call `reply`, even
165
- when the message is outside the Profile's role.
166
- - `no_reply` finishes a Schedule without messaging the admin. Its `reason` is
167
- stored for audit and is never sent. Do not use it on inbound messages.
161
+ An Agent closes the conversational channel with `reply_text`, `reply_card`, or
162
+ `no_reply`, and may also call `notify_card` when the Run has a notify target:
163
+
164
+ - `reply_text` sends plain text. Inbound Feishu messages quote-reply the user.
165
+ Schedules message the Profile admin.
166
+ - `reply_card` sends an interactive card on the same conversational channel
167
+ (inbound quote-reply, or Schedule admin chat). It closes that channel by
168
+ itself.
169
+ - `no_reply` finishes without messaging the conversational channel. Its
170
+ `reason` is stored for audit and is never sent. Do not use it on inbound
171
+ messages.
168
172
  - `notify_card` posts an interactive card to the Schedule's configured group.
169
- Inbound messages cannot notify.
173
+ Only valid when the Run has a notify target.
174
+
175
+ Which of `reply_text` / `reply_card` / `notify_card` / `no_reply` to use for a
176
+ business result is decided by the Profile persona/task (or the situation), not
177
+ hard-coded by Beacon beyond channel availability (`notify_card` needs a notify
178
+ target; inbound cannot `no_reply`).
170
179
 
171
- The card tool accepts a title, Feishu-compatible Markdown body, and up to five
180
+ The card tools accept a title, Feishu-compatible Markdown body, and up to five
172
181
  HTTP(S) link buttons. The first button is styled as primary. The tools accept
173
182
  these argument shapes:
174
183
 
@@ -194,9 +203,9 @@ these argument shapes:
194
203
  Beacon binds destinations; the Agent never supplies a `chat_id`. Text remains
195
204
  text. Cards are sent with Feishu's `interactive` message type and include the
196
205
  card generation time. A schedule `no_reply` without `notify_card` records a
197
- successful Run with no Delivery. Failures `reply` a short error and never
198
- notify a group. Manual local triggers print reply text and any notify card as
199
- Markdown on stdout.
206
+ successful Run with no Delivery. Failures `reply_text` a short error and never
207
+ notify a group. Manual local triggers print reply text/card and any notify card
208
+ as Markdown on stdout.
200
209
 
201
210
  ## State and failure behavior
202
211
 
@@ -16,10 +16,6 @@ const textReplySchema = z
16
16
  const noReplySchema = z
17
17
  .object({ kind: z.literal("no_reply"), reason: nonBlank })
18
18
  .strict();
19
- const replySchema = z.discriminatedUnion("kind", [
20
- textReplySchema,
21
- noReplySchema,
22
- ]);
23
19
  const cardSchema = z
24
20
  .object({
25
21
  kind: z.literal("card"),
@@ -28,6 +24,11 @@ const cardSchema = z
28
24
  buttons: z.array(buttonSchema).max(5).default([]),
29
25
  })
30
26
  .strict();
27
+ const replySchema = z.discriminatedUnion("kind", [
28
+ textReplySchema,
29
+ noReplySchema,
30
+ cardSchema,
31
+ ]);
31
32
  export const feedbackItemSchema = z
32
33
  .object({
33
34
  priority: z.enum(["high", "medium"]),
@@ -118,7 +119,7 @@ export function mergeOutcome(current, patch) {
118
119
  };
119
120
  }
120
121
  /** Stable English summary when the Agent settles without closing reply/no_reply. */
121
- export const MISSING_REPLY_OUTCOME_SUMMARY = "Expected exactly one of `reply` or `no_reply`, but the Agent Runtime settled without submitting either";
122
+ export const MISSING_REPLY_OUTCOME_SUMMARY = "Expected exactly one of `reply_text`, `reply_card`, or `no_reply`, but the Agent Runtime settled without submitting any";
122
123
  export function isMissingReplyOutcomeError(error) {
123
124
  if (!(error instanceof Error))
124
125
  return false;
@@ -141,6 +142,8 @@ export function renderFinalOutcomeAsText(outcome) {
141
142
  const parts = [];
142
143
  if (outcome.reply.kind === "text")
143
144
  parts.push(outcome.reply.text);
145
+ if (outcome.reply.kind === "card")
146
+ parts.push(renderCard(outcome.reply));
144
147
  if (outcome.notify)
145
148
  parts.push(renderCard(outcome.notify));
146
149
  return parts.join("\n\n");
@@ -1,8 +1,10 @@
1
1
  export const inboundOutOfRoleReply = "这条消息不在我的职责范围内。";
2
2
  export const finalOutcomeContractInstructions = [
3
- "`reply` sends plain text. For an inbound Feishu message, quote-reply that message. For a Schedule, message the Profile admin. For a manual Run, print to the operator. Do not fill `chat_id`.",
4
- "`no_reply` finishes a Run without messaging anyone. Its reason is stored for audit and is never sent.",
5
- "`notify_card` posts a titled Markdown card to the Schedule's configured notify group. Do not fill `chat_id`.",
3
+ "`reply_text` sends plain text. For an inbound Feishu message, quote-reply that message. For a Schedule, message the Profile admin. For a manual Run, print to the operator. Do not fill `chat_id`.",
4
+ "`reply_card` sends a titled Markdown card on the conversational channel (inbound quote-reply, or Schedule admin chat). It closes that channel by itself. Do not also call `reply_text` or `no_reply` on the same Run. Do not fill `chat_id`.",
5
+ "`no_reply` finishes a Run without messaging the conversational channel. Its reason is stored for audit and is never sent. Do not use it on inbound Feishu messages.",
6
+ "`notify_card` posts a titled Markdown card to the Schedule's configured notify group. Call it only when this Run has a notify target. Do not fill `chat_id`.",
7
+ "Which tool to use for a given business result is decided by the Profile persona/task (or the situation), not by these platform lines alone.",
6
8
  "Beacon ignores ordinary assistant final text for Delivery.",
7
9
  ];
8
10
  export const feedbackContractInstructions = [
@@ -77,7 +77,7 @@ export function formatFailureReplyText(runId, failure) {
77
77
  if (failure?.code === "outcome_missing") {
78
78
  const parts = [
79
79
  `处理失败(run_id=${runId},code=outcome_missing):`,
80
- "期望恰好调用一次 `reply` 或 `no_reply`,但 Agent 已结束且两者均未提交。",
80
+ "期望恰好调用一次 `reply_text`、`reply_card` 或 `no_reply`,但 Agent 已结束且均未提交。",
81
81
  ];
82
82
  const detail = failure.summary
83
83
  ? detailBeyondMissingSummary(failure.summary)
@@ -216,7 +216,7 @@ export class RunOrchestrator {
216
216
  async deliverOutcome(triggerKey, create = true) {
217
217
  const current = await this.record(triggerKey);
218
218
  const outcome = current.finalOutcome.content;
219
- if (outcome.reply.kind === "text") {
219
+ if (outcome.reply.kind === "text" || outcome.reply.kind === "card") {
220
220
  await this.deliverField(triggerKey, "delivery", current.target, outcome.reply, create && current.delivery === undefined);
221
221
  }
222
222
  if (outcome.notify && current.notifyTarget) {
@@ -411,7 +411,8 @@ export class RunOrchestrator {
411
411
  if (latest.finalOutcome &&
412
412
  (latest.run.state === "succeeded" || latest.run.state === "failed")) {
413
413
  const outcome = latest.finalOutcome.content;
414
- const missingReply = outcome.reply.kind === "text" && !latest.delivery;
414
+ const missingReply = (outcome.reply.kind === "text" || outcome.reply.kind === "card") &&
415
+ !latest.delivery;
415
416
  const missingNotify = Boolean(outcome.notify) &&
416
417
  Boolean(latest.notifyTarget) &&
417
418
  !latest.notifyDelivery;
@@ -1,10 +1,20 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { parseOutcomePatch } from "../outcome/content.js";
3
- export function replyFromToolParams(params) {
3
+ export function replyTextFromToolParams(params) {
4
4
  return parseOutcomePatch({
5
5
  reply: { kind: "text", text: params.text },
6
6
  });
7
7
  }
8
+ export function replyCardFromToolParams(params) {
9
+ return parseOutcomePatch({
10
+ reply: {
11
+ kind: "card",
12
+ title: params.title,
13
+ content: params.content,
14
+ buttons: params.buttons ?? [],
15
+ },
16
+ });
17
+ }
8
18
  export function noReplyFromToolParams(params) {
9
19
  return parseOutcomePatch({
10
20
  reply: { kind: "no_reply", reason: params.reason },
@@ -63,9 +73,9 @@ function acceptedResult() {
63
73
  }
64
74
  export default function registerOutcomeTools(pi) {
65
75
  pi.registerTool({
66
- name: "reply",
67
- label: "Reply",
68
- 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.",
76
+ name: "reply_text",
77
+ label: "Reply Text",
78
+ description: "Send a plain-text reply on the conversational channel. Inbound Feishu messages quote-reply the user. Schedules message the admin. Manual Runs print to the operator.",
69
79
  parameters: {
70
80
  type: "object",
71
81
  properties: {
@@ -83,6 +93,52 @@ export default function registerOutcomeTools(pi) {
83
93
  return acceptedResult();
84
94
  },
85
95
  });
96
+ pi.registerTool({
97
+ name: "reply_card",
98
+ label: "Reply Card",
99
+ description: "Send a structured interactive card on the conversational channel (inbound quote-reply, or Schedule admin chat). Closes that channel by itself. Do not also call reply_text or no_reply on the same Run.",
100
+ parameters: {
101
+ type: "object",
102
+ properties: {
103
+ title: {
104
+ type: "string",
105
+ minLength: 1,
106
+ description: "The card header title.",
107
+ },
108
+ content: {
109
+ type: "string",
110
+ minLength: 1,
111
+ description: "The card body in Feishu-compatible Markdown.",
112
+ },
113
+ buttons: {
114
+ type: "array",
115
+ maxItems: 5,
116
+ items: {
117
+ type: "object",
118
+ properties: {
119
+ label: { type: "string", minLength: 1 },
120
+ url: { type: "string", pattern: "^https?://" },
121
+ },
122
+ required: ["label", "url"],
123
+ additionalProperties: false,
124
+ },
125
+ },
126
+ },
127
+ required: ["title", "content"],
128
+ additionalProperties: false,
129
+ },
130
+ async execute(_toolCallId, params, signal) {
131
+ await invokeBeaconCli({
132
+ reply: {
133
+ kind: "card",
134
+ title: params.title,
135
+ content: params.content,
136
+ buttons: params.buttons ?? [],
137
+ },
138
+ }, signal);
139
+ return acceptedResult();
140
+ },
141
+ });
86
142
  pi.registerTool({
87
143
  name: "no_reply",
88
144
  label: "No Reply",
@@ -107,7 +163,7 @@ export default function registerOutcomeTools(pi) {
107
163
  pi.registerTool({
108
164
  name: "notify_card",
109
165
  label: "Notify Card",
110
- 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.",
166
+ description: "Post a structured Feishu card to the Schedule's configured notify group. Only valid when this Run has a notify target. You must still close the conversational channel with reply_text, reply_card, or no_reply on the same Run.",
111
167
  parameters: {
112
168
  type: "object",
113
169
  properties: {
@@ -33,29 +33,30 @@ export function buildAgentSystemPrompt(profile, trigger) {
33
33
  ].join("\n");
34
34
  }
35
35
  function thisRunCapabilityInstructions(trigger) {
36
+ const notifyLine = trigger.notify
37
+ ? "You may also call `notify_card` at most once."
38
+ : "Do not call `notify_card`.";
36
39
  if (trigger.kind === "feishu_message") {
37
40
  return [
38
41
  "This Run is an inbound Feishu message.",
39
- "Close the conversational channel with `reply`. 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.",
40
- "Do not call `no_reply`. Do not call `notify_card`.",
42
+ "Close the conversational channel with exactly one of `reply_text` or `reply_card`.",
43
+ "Do not call `no_reply`.",
44
+ notifyLine,
41
45
  ];
42
46
  }
43
- const notifyLine = trigger.notify
44
- ? "You may also call `notify_card` at most once."
45
- : "Do not call `notify_card`.";
46
47
  if (trigger.kind === "schedule") {
47
48
  const label = trigger.scheduleId
48
49
  ? `This Run is Schedule ${trigger.scheduleId}.`
49
50
  : "This Run is a Schedule.";
50
51
  return [
51
52
  label,
52
- "Close the conversational channel with exactly one of `reply` or `no_reply`.",
53
+ "Close the conversational channel with exactly one of `reply_text`, `reply_card`, or `no_reply`.",
53
54
  notifyLine,
54
55
  ];
55
56
  }
56
57
  return [
57
58
  "This Run is a manual operator trigger.",
58
- "Close the conversational channel with exactly one of `reply` or `no_reply`.",
59
+ "Close the conversational channel with exactly one of `reply_text`, `reply_card`, or `no_reply`.",
59
60
  notifyLine,
60
61
  ];
61
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {