@nettee/beacon 0.2.2 → 0.2.3

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
@@ -53,9 +53,16 @@ Copy the files under [`examples`](examples) into a private Beacon home and repla
53
53
  └── profiles/
54
54
  └── example/
55
55
  ├── profile.yaml
56
- └── prompt.md
56
+ ├── persona.md
57
+ └── task.md
57
58
  ```
58
59
 
60
+ Each Profile directory must contain `persona.md` (identity) and `task.md`
61
+ (process). Beacon prepends an English platform template for this Run: workspace
62
+ limit, inbound vs Schedule vs manual capabilities, and the `reply` /
63
+ `no_reply` / `notify_card` contract. It does not read yaml `prompt:` or
64
+ `prompt.md`. Missing either Markdown file fails Profile load.
65
+
59
66
  Protect the home and secret file before running Beacon:
60
67
 
61
68
  ```sh
@@ -95,7 +102,7 @@ created by Beacon 0.1.2 and earlier; when omitted it defaults to `sessions/`
95
102
  beside `config.yaml`. When configured, it must be absolute. Beacon creates
96
103
  per-Run directories with mode `0700` when Pi starts.
97
104
 
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.
105
+ Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, missing `persona.md` or `task.md`, 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.
99
106
 
100
107
  Each Schedule uses a five-field cron expression and an IANA timezone. Profile
101
108
  `admin.chat_id` is the private chat used for schedule `reply` / `no_reply` and
@@ -237,7 +244,7 @@ The repository also contains Chinese operator runbooks based on the verified
237
244
 
238
245
  - [Deploy or update Beacon on macmini](docs/macmini-deploy-and-update.md)
239
246
  - [Add a Beacon Profile on macmini](docs/macmini-add-profile.md)
240
- - [Write a role-bounded Profile Prompt](docs/profile-prompt-writing.md)
247
+ - [Write a role-bounded Profile (`persona.md` / `task.md`)](docs/profile-prompt-writing.md)
241
248
 
242
249
  ## Development checks
243
250
 
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  import { nextOccurrence } from "../schedule/cron.js";
6
6
  import { parseStrictYaml } from "./yaml.js";
7
7
  const profileIdPattern = /^[a-z0-9](?:[a-z0-9_-]{0,62})$/;
8
+ export const profilePersonaFile = "persona.md";
9
+ export const profileTaskFile = "task.md";
8
10
  const scheduleSchema = z
9
11
  .object({
10
12
  id: z.string().regex(profileIdPattern),
@@ -35,7 +37,6 @@ const scheduleSchema = z
35
37
  .strict();
36
38
  const profileDocumentSchema = z
37
39
  .object({
38
- prompt: z.string().min(1),
39
40
  workspace: z.string().min(1),
40
41
  runtime: z.literal("pi"),
41
42
  model: z
@@ -60,6 +61,26 @@ function isWithin(parent, child) {
60
61
  const path = relative(parent, child);
61
62
  return path === "" || (!path.startsWith("..") && !isAbsolute(path));
62
63
  }
64
+ async function loadRequiredMarkdown(canonicalProfileDirectory, filename) {
65
+ const filePath = join(canonicalProfileDirectory, filename);
66
+ let canonicalFilePath;
67
+ try {
68
+ canonicalFilePath = await realpath(filePath);
69
+ }
70
+ catch (error) {
71
+ throw new Error(`Cannot read Profile ${filename} at ${filePath}`, {
72
+ cause: error,
73
+ });
74
+ }
75
+ if (!isWithin(canonicalProfileDirectory, canonicalFilePath)) {
76
+ throw new Error(`Profile ${filename} must stay inside ${canonicalProfileDirectory}`);
77
+ }
78
+ const text = (await readFile(canonicalFilePath, "utf8")).trim();
79
+ if (!text) {
80
+ throw new Error(`Profile ${filename} must not be empty: ${canonicalFilePath}`);
81
+ }
82
+ return text;
83
+ }
63
84
  export async function loadProfile(profileId, profilesDirectory = join(homedir(), ".beacon", "profiles")) {
64
85
  assertProfileId(profileId);
65
86
  const profileDirectory = join(profilesDirectory, profileId);
@@ -83,26 +104,9 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
83
104
  if (config.schedules.length > 0 && !config.admin) {
84
105
  throw new Error(`Profile ${profileId} with schedules must declare admin.chat_id`);
85
106
  }
86
- if (isAbsolute(config.prompt)) {
87
- throw new Error(`Profile prompt path must be relative to ${profileDirectory}`);
88
- }
89
- const promptPath = resolve(profileDirectory, config.prompt);
90
107
  const canonicalProfileDirectory = await realpath(profileDirectory);
91
- let canonicalPromptPath;
92
- try {
93
- canonicalPromptPath = await realpath(promptPath);
94
- }
95
- catch (error) {
96
- throw new Error(`Cannot read Profile prompt at ${promptPath}`, {
97
- cause: error,
98
- });
99
- }
100
- if (!isWithin(canonicalProfileDirectory, canonicalPromptPath)) {
101
- throw new Error(`Profile prompt must stay inside ${canonicalProfileDirectory}`);
102
- }
103
- const prompt = (await readFile(canonicalPromptPath, "utf8")).trim();
104
- if (!prompt)
105
- throw new Error(`Profile prompt must not be empty: ${canonicalPromptPath}`);
108
+ const persona = await loadRequiredMarkdown(canonicalProfileDirectory, profilePersonaFile);
109
+ const task = await loadRequiredMarkdown(canonicalProfileDirectory, profileTaskFile);
106
110
  const workspace = isAbsolute(config.workspace)
107
111
  ? config.workspace
108
112
  : resolve(dirname(configPath), config.workspace);
@@ -121,7 +125,8 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
121
125
  return {
122
126
  id: profileId,
123
127
  directory: canonicalProfileDirectory,
124
- prompt,
128
+ persona,
129
+ task,
125
130
  workspace,
126
131
  runtime: config.runtime,
127
132
  model: config.model,
@@ -41,6 +41,7 @@ function summarize(value, hasSessionFile) {
41
41
  acceptedAt,
42
42
  kind: asString(input?.kind) ?? "unknown",
43
43
  scheduleId: asString(input?.scheduleId),
44
+ hasNotifyTarget: asRecord(record.notifyTarget) !== undefined,
44
45
  runId: asString(run?.runId),
45
46
  state: asString(run?.state),
46
47
  failureCode: asString(failure?.code),
@@ -2,7 +2,7 @@ import { createServer, } from "node:http";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { loadProfile } from "../config/profile.js";
5
- import { buildAgentSystemPrompt } from "../runtime/system-prompt.js";
5
+ import { buildAgentSystemPrompt, } from "../runtime/system-prompt.js";
6
6
  import { exportSessionHtml } from "./export.js";
7
7
  import { findRunSummary, listRunSummaries, runIdPattern, toRunListItem, } from "./records.js";
8
8
  import { sendStaticFile } from "./static.js";
@@ -20,11 +20,24 @@ function sendText(response, status, body) {
20
20
  export function defaultUiDirectory() {
21
21
  return join(dirname(fileURLToPath(import.meta.url)), "ui");
22
22
  }
23
+ function triggerFromRunSummary(summary) {
24
+ if (summary.kind === "schedule") {
25
+ return {
26
+ kind: "schedule",
27
+ ...(summary.scheduleId ? { scheduleId: summary.scheduleId } : {}),
28
+ notify: summary.hasNotifyTarget,
29
+ };
30
+ }
31
+ if (summary.kind === "feishu_message") {
32
+ return { kind: "feishu_message", notify: false };
33
+ }
34
+ return { kind: "manual", notify: summary.hasNotifyTarget };
35
+ }
23
36
  async function beaconOwnedSystemPrompt(summary, profilesDirectory) {
24
37
  if (summary.systemPrompt)
25
38
  return summary.systemPrompt;
26
39
  try {
27
- return buildAgentSystemPrompt(await loadProfile(summary.profileId, profilesDirectory));
40
+ return buildAgentSystemPrompt(await loadProfile(summary.profileId, profilesDirectory), triggerFromRunSummary(summary));
28
41
  }
29
42
  catch {
30
43
  // Old runs without a stored prompt: reconstruction needs a readable Profile.
@@ -1,8 +1,7 @@
1
1
  export const inboundOutOfRoleReply = "这条消息不在我的职责范围内。";
2
- export const finalOutcomeSystemInstructions = [
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.",
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`.",
7
6
  "Beacon ignores ordinary assistant final text for Delivery.",
8
7
  ];
@@ -1,8 +1,8 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { inboundOutOfRoleReply } from "../outcome/instructions.js";
4
4
  import { PiRuntimeError } from "../runtime/pi-rpc.js";
5
- import { buildAgentSystemPrompt } from "../runtime/system-prompt.js";
5
+ import { agentSystemPromptTrigger, buildAgentSystemPrompt, profilePromptDigest, } from "../runtime/system-prompt.js";
6
6
  function promptFor(input) {
7
7
  if (input.kind === "manual") {
8
8
  return ["An operator manually triggered this Run.", "", input.text].join("\n");
@@ -80,12 +80,15 @@ export class RunOrchestrator {
80
80
  throw new Error(`Unknown Trigger ${triggerKey}`);
81
81
  return found;
82
82
  }
83
- agentSystemPrompt(existing) {
83
+ agentSystemPrompt(trigger, existing) {
84
84
  return existing && existing.length > 0
85
85
  ? existing
86
- : buildAgentSystemPrompt(this.options.profile);
86
+ : buildAgentSystemPrompt(this.options.profile, trigger);
87
87
  }
88
- newRun(runId, state, failure) {
88
+ triggerFor(record, input) {
89
+ return agentSystemPromptTrigger(input ?? record.input, record.notifyTarget);
90
+ }
91
+ newRun(runId, state, trigger, failure) {
89
92
  const timestamp = this.timestamp();
90
93
  return {
91
94
  runId,
@@ -97,10 +100,8 @@ export class RunOrchestrator {
97
100
  provider: this.options.profile.model.provider,
98
101
  model: this.options.profile.model.id,
99
102
  workspace: this.options.profile.workspace,
100
- promptDigest: createHash("sha256")
101
- .update(this.options.profile.prompt)
102
- .digest("hex"),
103
- systemPrompt: this.agentSystemPrompt(),
103
+ promptDigest: profilePromptDigest(this.options.profile),
104
+ systemPrompt: this.agentSystemPrompt(trigger),
104
105
  ...(failure ? { failure } : {}),
105
106
  };
106
107
  }
@@ -172,7 +173,8 @@ export class RunOrchestrator {
172
173
  await this.options.store.update(triggerKey, (current) => ({
173
174
  ...current,
174
175
  run: {
175
- ...(current.run ?? this.newRun(runId, "queued")),
176
+ ...(current.run ??
177
+ this.newRun(runId, "queued", this.triggerFor(current))),
176
178
  state: "failed",
177
179
  finishedAt: this.timestamp(),
178
180
  failure: { code, summary: detail },
@@ -199,7 +201,7 @@ export class RunOrchestrator {
199
201
  sessionId: current.run.runId,
200
202
  sessionPath: join(this.options.sessionDirectory, this.options.profile.id, current.run.runId),
201
203
  };
202
- const systemPrompt = this.agentSystemPrompt(current.run.systemPrompt);
204
+ const systemPrompt = this.agentSystemPrompt(this.triggerFor(current, input), current.run.systemPrompt);
203
205
  return {
204
206
  ...current,
205
207
  run: {
@@ -268,7 +270,7 @@ export class RunOrchestrator {
268
270
  catch (error) {
269
271
  await this.options.store.update(triggerKey, (current) => ({
270
272
  ...current,
271
- run: this.newRun(runId, "failed", {
273
+ run: this.newRun(runId, "failed", this.triggerFor(current), {
272
274
  code: "trigger_normalization_failed",
273
275
  summary: summary(error),
274
276
  }),
@@ -284,7 +286,7 @@ export class RunOrchestrator {
284
286
  await this.options.store.update(triggerKey, (current) => ({
285
287
  ...current,
286
288
  input,
287
- run: this.newRun(runId, "queued"),
289
+ run: this.newRun(runId, "queued", this.triggerFor(current, input)),
288
290
  }));
289
291
  const queued = this.options.queue.enqueue(() => this.execute(triggerKey, input, runId));
290
292
  if (!queued.accepted) {
@@ -27,7 +27,10 @@ export function createProfileRunner(profile, outcomes, beaconCliPath, runAgent =
27
27
  workspace: profile.workspace,
28
28
  provider: profile.model.provider,
29
29
  model: profile.model.id,
30
- systemPrompt: buildAgentSystemPrompt(profile),
30
+ systemPrompt: buildAgentSystemPrompt(profile, {
31
+ kind: "feishu_message",
32
+ notify: false,
33
+ }),
31
34
  outcome: { ...submission.binding, cliPath: beaconCliPath },
32
35
  });
33
36
  return submission.take();
@@ -1,10 +1,59 @@
1
- import { finalOutcomeSystemInstructions } from "../outcome/instructions.js";
2
- export function buildAgentSystemPrompt(profile) {
1
+ import { createHash } from "node:crypto";
2
+ import { finalOutcomeContractInstructions } from "../outcome/instructions.js";
3
+ export function agentSystemPromptTrigger(input, notifyTarget) {
4
+ const notify = notifyTarget !== undefined;
5
+ if (input?.kind === "schedule") {
6
+ return { kind: "schedule", scheduleId: input.scheduleId, notify };
7
+ }
8
+ if (input?.kind === "feishu_message") {
9
+ return { kind: "feishu_message", notify: false };
10
+ }
11
+ return { kind: "manual", notify };
12
+ }
13
+ export function profilePromptDigest(profile) {
14
+ return createHash("sha256")
15
+ .update(profile.persona)
16
+ .update("\n")
17
+ .update(profile.task)
18
+ .digest("hex");
19
+ }
20
+ export function buildAgentSystemPrompt(profile, trigger) {
3
21
  return [
4
- profile.prompt,
5
- "",
6
22
  `All local file reads, searches, and modifications must stay within the workspace directory and its descendants: ${profile.workspace}`,
7
23
  "",
8
- ...finalOutcomeSystemInstructions,
24
+ ...thisRunCapabilityInstructions(trigger),
25
+ "",
26
+ ...finalOutcomeContractInstructions,
27
+ "",
28
+ profile.persona,
29
+ "",
30
+ profile.task,
9
31
  ].join("\n");
10
32
  }
33
+ function thisRunCapabilityInstructions(trigger) {
34
+ if (trigger.kind === "feishu_message") {
35
+ return [
36
+ "This Run is an inbound Feishu message.",
37
+ "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.",
38
+ "Do not call `no_reply`. Do not call `notify_card`.",
39
+ ];
40
+ }
41
+ const notifyLine = trigger.notify
42
+ ? "You may also call `notify_card` at most once."
43
+ : "Do not call `notify_card`.";
44
+ if (trigger.kind === "schedule") {
45
+ const label = trigger.scheduleId
46
+ ? `This Run is Schedule ${trigger.scheduleId}.`
47
+ : "This Run is a Schedule.";
48
+ return [
49
+ label,
50
+ "Close the conversational channel with exactly one of `reply` or `no_reply`.",
51
+ notifyLine,
52
+ ];
53
+ }
54
+ return [
55
+ "This Run is a manual operator trigger.",
56
+ "Close the conversational channel with exactly one of `reply` or `no_reply`.",
57
+ notifyLine,
58
+ ];
59
+ }
@@ -0,0 +1,5 @@
1
+ You are a workspace-status assistant. Your only role is to answer requests about the configured workspace's files, version-control state, and verification results.
2
+
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
+
5
+ If a Feishu message is outside this role, do not inspect the workspace. Explain in one or two sentences that the request is out of scope.
@@ -1,4 +1,3 @@
1
- prompt: prompt.md
2
1
  workspace: /ABSOLUTE/PATH/TO/WORKSPACE
3
2
  runtime: pi
4
3
  model:
@@ -0,0 +1,5 @@
1
+ For in-role work, inspect the configured workspace and produce a concise user-facing result.
2
+
3
+ Scheduled Runs that have nothing to report should stay silent on the admin channel. If there is a report for the notify group, send a card to that group and stay silent on the admin channel unless the admin also needs a text update.
4
+
5
+ Inbound in-role work should report the result to the user. 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.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,9 +0,0 @@
1
- You are a workspace-status assistant. Your only role is to answer requests about the configured workspace's files, version-control state, and verification results.
2
-
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
-
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
-
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.