@nettee/beacon 0.2.3 → 0.2.4

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
@@ -50,18 +50,25 @@ Copy the files under [`examples`](examples) into a private Beacon home and repla
50
50
  ├── config.yaml
51
51
  ├── runtime.env
52
52
  ├── secrets.json
53
- └── profiles/
54
- └── example/
55
- ├── profile.yaml
53
+ ├── profiles/
54
+ │ └── example/
55
+ │ └── profile.yaml
56
+ └── workspace/
57
+ └── .beacon-profile/
56
58
  ├── persona.md
57
59
  └── task.md
58
60
  ```
59
61
 
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.
62
+ yaml names the workspace directory only. Beacon then loads `persona.md`
63
+ (identity) and `task.md` (process) as a pair: `{workspace}/.beacon-profile/`
64
+ first; if that pair is missing, `{profileDir}/`. It never mixes the two
65
+ places, never reads yaml `prompt:` / persona / task paths, and never falls
66
+ back to `prompt.md`. If neither place has a complete pair, Profile load fails
67
+ and lists the missing paths. Both pairs may exist; the workspace pair wins.
68
+
69
+ Beacon prepends an English platform template for this Run: workspace limit,
70
+ inbound vs Schedule vs manual capabilities, and the `reply` / `no_reply` /
71
+ `notify_card` contract.
65
72
 
66
73
  Protect the home and secret file before running Beacon:
67
74
 
@@ -102,7 +109,7 @@ created by Beacon 0.1.2 and earlier; when omitted it defaults to `sessions/`
102
109
  beside `config.yaml`. When configured, it must be absolute. Beacon creates
103
110
  per-Run directories with mode `0700` when Pi starts.
104
111
 
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.
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.
106
113
 
107
114
  Each Schedule uses a five-field cron expression and an IANA timezone. Profile
108
115
  `admin.chat_id` is the private chat used for schedule `reply` / `no_reply` and
@@ -7,6 +7,7 @@ import { parseStrictYaml } from "./yaml.js";
7
7
  const profileIdPattern = /^[a-z0-9](?:[a-z0-9_-]{0,62})$/;
8
8
  export const profilePersonaFile = "persona.md";
9
9
  export const profileTaskFile = "task.md";
10
+ export const workspaceProfileDirectoryName = ".beacon-profile";
10
11
  const scheduleSchema = z
11
12
  .object({
12
13
  id: z.string().regex(profileIdPattern),
@@ -61,8 +62,37 @@ function isWithin(parent, child) {
61
62
  const path = relative(parent, child);
62
63
  return path === "" || (!path.startsWith("..") && !isAbsolute(path));
63
64
  }
64
- async function loadRequiredMarkdown(canonicalProfileDirectory, filename) {
65
- const filePath = join(canonicalProfileDirectory, filename);
65
+ export function workspaceProfileDirectory(workspace) {
66
+ return join(workspace, workspaceProfileDirectoryName);
67
+ }
68
+ async function isExistingFile(path) {
69
+ try {
70
+ return (await stat(path)).isFile();
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ function markdownPairPaths(directory) {
77
+ return {
78
+ persona: join(directory, profilePersonaFile),
79
+ task: join(directory, profileTaskFile),
80
+ };
81
+ }
82
+ async function missingMarkdownPaths(directory) {
83
+ const paths = markdownPairPaths(directory);
84
+ const missing = [];
85
+ if (!(await isExistingFile(paths.persona)))
86
+ missing.push(paths.persona);
87
+ if (!(await isExistingFile(paths.task)))
88
+ missing.push(paths.task);
89
+ return missing;
90
+ }
91
+ async function markdownPairExists(directory) {
92
+ return (await missingMarkdownPaths(directory)).length === 0;
93
+ }
94
+ async function loadRequiredMarkdown(canonicalDirectory, filename) {
95
+ const filePath = join(canonicalDirectory, filename);
66
96
  let canonicalFilePath;
67
97
  try {
68
98
  canonicalFilePath = await realpath(filePath);
@@ -72,8 +102,8 @@ async function loadRequiredMarkdown(canonicalProfileDirectory, filename) {
72
102
  cause: error,
73
103
  });
74
104
  }
75
- if (!isWithin(canonicalProfileDirectory, canonicalFilePath)) {
76
- throw new Error(`Profile ${filename} must stay inside ${canonicalProfileDirectory}`);
105
+ if (!isWithin(canonicalDirectory, canonicalFilePath)) {
106
+ throw new Error(`Profile ${filename} must stay inside ${canonicalDirectory}`);
77
107
  }
78
108
  const text = (await readFile(canonicalFilePath, "utf8")).trim();
79
109
  if (!text) {
@@ -81,6 +111,39 @@ async function loadRequiredMarkdown(canonicalProfileDirectory, filename) {
81
111
  }
82
112
  return text;
83
113
  }
114
+ async function loadMarkdownPair(directory, boundary) {
115
+ const canonicalBoundary = await realpath(boundary);
116
+ let canonicalDirectory;
117
+ try {
118
+ canonicalDirectory = await realpath(directory);
119
+ }
120
+ catch (error) {
121
+ throw new Error(`Cannot read Profile markdown at ${directory}`, {
122
+ cause: error,
123
+ });
124
+ }
125
+ if (!isWithin(canonicalBoundary, canonicalDirectory)) {
126
+ throw new Error(`Profile markdown must stay inside ${canonicalBoundary}`);
127
+ }
128
+ return {
129
+ persona: await loadRequiredMarkdown(canonicalDirectory, profilePersonaFile),
130
+ task: await loadRequiredMarkdown(canonicalDirectory, profileTaskFile),
131
+ };
132
+ }
133
+ async function loadPersonaAndTask(profileId, workspace, profileDirectory) {
134
+ const workspaceDirectory = workspaceProfileDirectory(workspace);
135
+ if (await markdownPairExists(workspaceDirectory)) {
136
+ return loadMarkdownPair(workspaceDirectory, workspace);
137
+ }
138
+ if (await markdownPairExists(profileDirectory)) {
139
+ return loadMarkdownPair(profileDirectory, profileDirectory);
140
+ }
141
+ const missing = [
142
+ ...(await missingMarkdownPaths(workspaceDirectory)),
143
+ ...(await missingMarkdownPaths(profileDirectory)),
144
+ ];
145
+ throw new Error(`Profile ${profileId} has no complete ${profilePersonaFile} + ${profileTaskFile} pair. Missing: ${missing.join(", ")}`);
146
+ }
84
147
  export async function loadProfile(profileId, profilesDirectory = join(homedir(), ".beacon", "profiles")) {
85
148
  assertProfileId(profileId);
86
149
  const profileDirectory = join(profilesDirectory, profileId);
@@ -105,8 +168,6 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
105
168
  throw new Error(`Profile ${profileId} with schedules must declare admin.chat_id`);
106
169
  }
107
170
  const canonicalProfileDirectory = await realpath(profileDirectory);
108
- const persona = await loadRequiredMarkdown(canonicalProfileDirectory, profilePersonaFile);
109
- const task = await loadRequiredMarkdown(canonicalProfileDirectory, profileTaskFile);
110
171
  const workspace = isAbsolute(config.workspace)
111
172
  ? config.workspace
112
173
  : resolve(dirname(configPath), config.workspace);
@@ -122,6 +183,7 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
122
183
  if (!workspaceStat.isDirectory()) {
123
184
  throw new Error(`Profile workspace is not a directory: ${workspace}`);
124
185
  }
186
+ const { persona, task } = await loadPersonaAndTask(profileId, workspace, canonicalProfileDirectory);
125
187
  return {
126
188
  id: profileId,
127
189
  directory: canonicalProfileDirectory,
@@ -1,4 +1,4 @@
1
- workspace: /ABSOLUTE/PATH/TO/WORKSPACE
1
+ workspace: ../../workspace
2
2
  runtime: pi
3
3
  model:
4
4
  provider: REPLACE_WITH_PI_PROVIDER
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {