@nettee/beacon 0.3.0 → 0.3.2

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
@@ -109,11 +109,46 @@ created by Beacon 0.1.2 and earlier; when omitted it defaults to `sessions/`
109
109
  beside `config.yaml`. When configured, it must be absolute. Beacon creates
110
110
  per-Run directories with mode `0700` when Pi starts.
111
111
 
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.
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, missing Profile credentials, and unknown Feishu channel names all fail startup. Beacon validates all Profiles before opening a Feishu connection.
113
+
114
+ Feishu delivery destinations are registered globally in `~/.beacon/channels.yaml`
115
+ (beside `config.yaml`), then referenced by semantic `name` from each Profile:
116
+
117
+ ```yaml
118
+ # ~/.beacon/channels.yaml
119
+ version: 1
120
+ channels:
121
+ - name: admin-dm
122
+ description: Example admin direct chat
123
+ chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
124
+ - name: weekday-brief-group
125
+ description: Example weekday brief notify group
126
+ chat_id: REPLACE_WITH_GROUP_CHAT_ID
127
+ ```
128
+
129
+ ```yaml
130
+ # profile.yaml destinations
131
+ admin:
132
+ name: admin-dm
133
+ schedules:
134
+ - id: weekday-brief
135
+ cron: "0 9 * * 1-5"
136
+ timezone: Asia/Shanghai
137
+ input: Prepare the weekday brief.
138
+ notify:
139
+ name: weekday-brief-group
140
+ ```
141
+
142
+ Each channel has `name` (stable slug), `description` (human-readable Chinese or
143
+ English label), and `chat_id` (the real Feishu chat id). Profile destinations
144
+ bind `name` and may optionally override `description` for local readability;
145
+ Beacon resolves `name` → `chat_id` at load time. Unknown names fail startup.
146
+ Legacy `chat_id` directly under `admin` / `notify` is still accepted so existing
147
+ hosts can migrate one Profile at a time; new Profiles should use `name`.
113
148
 
114
149
  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_text` /
116
- `reply_card` / `no_reply` and for failure notices. Optional `notify.chat_id` on a
150
+ `admin` is the private chat used for schedule `reply_text` /
151
+ `reply_card` / `no_reply` and for failure notices. Optional `notify` on a
117
152
  Schedule is the group that receives `notify_card`. Beacon deliberately does not
118
153
  infer or fall back to another destination. A newly discovered Schedule starts at
119
154
  the current time.
@@ -181,19 +216,19 @@ Subscribe in the desired `profile.yaml`:
181
216
 
182
217
  ```yaml
183
218
  admin:
184
- chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
219
+ name: admin-dm
185
220
  listener:
186
221
  sources:
187
222
  - https://deploy.example.com/production
188
223
  types:
189
224
  - com.example.deployment.completed.v1
190
225
  notify:
191
- chat_id: REPLACE_WITH_GROUP_CHAT_ID
226
+ name: weekday-brief-group
192
227
  ```
193
228
 
194
229
  Both lists are required and nonempty. Values match exactly: **OR within each
195
230
  list, AND between `sources` and `types`**. There are no wildcards, regular
196
- expressions, or filters inside `data`. A listener requires `admin.chat_id`;
231
+ expressions, or filters inside `data`. A listener requires `admin`;
197
232
  `listener.notify` is optional. Matching Profiles each receive one Trigger with
198
233
  the complete event as input. Their `persona.md` and `task.md` define what to do.
199
234
  `reply_text` and `reply_card` go to the configured admin; `no_reply` is allowed;
@@ -311,7 +346,7 @@ beacon schedule trigger --profile example --schedule daily-report
311
346
 
312
347
  This creates a distinct durable Run on every invocation and prints its
313
348
  `run_id`. It uses the Schedule's configured `input`, replies to
314
- `admin.chat_id`, and may notify `notify.chat_id`, but does not read, initialize,
349
+ the Profile `admin` channel, and may notify the Schedule `notify` channel, but does not read, initialize,
315
350
  or advance the Schedule's cron cursor. Unknown
316
351
  Profiles or Schedules and failed Runs or Deliveries exit non-zero; failure
317
352
  messages include the `run_id` whenever a Run record was created.
@@ -371,16 +406,27 @@ as Markdown on stdout.
371
406
 
372
407
  Per-Profile state lives below `profiles/<profile-id>/state/`. Trigger claims, normalized inputs, Run state, Final Outcomes, Delivery state, and Schedule cursors use durable JSON snapshots. Records do not expire, and Beacon has no automatic cleanup task. Manually deleting state also deletes its deduplication memory.
373
408
 
374
- Every new business Run also owns a permanent Pi session. Its Run record stores
375
- both `sessionId` and `sessionPath`; the session ID is exactly the Beacon
376
- `runId`, and the path is the dedicated directory
377
- `<pi.session_directory>/<profile-id>/<runId>/`. Pi receives that mapping via
378
- `--session-dir`, `--session-id`, and a readable `Beacon <profile-id> <runId>`
379
- name. The directory contains the Pi JSONL session file and is never cleaned up
380
- by Beacon. A queued Run keeps the same mapping after restart. Queued records
381
- written by Beacon 0.1.2 or earlier are assigned the same deterministic mapping
382
- when recovered. Historical completed records remain readable and may omit the
383
- two session fields because those Runs were originally ephemeral.
409
+ Every new business Run also owns a permanent Pi session by default. Its Run
410
+ record stores both `sessionId` and `sessionPath`. For a fresh Trigger the
411
+ session ID is exactly the Beacon `runId`, and the path is the dedicated
412
+ directory `<pi.session_directory>/<profile-id>/<runId>/`. Pi receives that
413
+ mapping via `--session-dir`, `--session-id`, and a readable
414
+ `Beacon <profile-id> <runId>` name. The directory contains the Pi JSONL
415
+ session file and is never cleaned up by Beacon. A queued Run keeps the same
416
+ mapping after restart. Queued records written by Beacon 0.1.2 or earlier are
417
+ assigned the same deterministic mapping when recovered. Historical completed
418
+ records remain readable and may omit the two session fields because those Runs
419
+ were originally ephemeral.
420
+
421
+ When an inbound Feishu message is a quote-reply (`parent_id`) to a message
422
+ Beacon previously delivered (`reply_text` / `reply_card` / `notify_card`, or a
423
+ failure reply), Beacon looks up that outbound Feishu `message_id` on the
424
+ matching Delivery / notify Delivery (`providerRequestId`), then starts a new
425
+ Run that **reuses the prior Pi session** and appends one new user prompt.
426
+ Follow-up Runs keep their own `runId` but set `sessionId` / `sessionPath` to
427
+ the original session. If the quoted message is not a Beacon Delivery, or the
428
+ Delivery did not persist a Feishu `message_id`, Beacon falls back to a fresh
429
+ session.
384
430
 
385
431
  To locate a session from a reported `run_id`, first find its durable Run
386
432
  record, then inspect or export the sole JSONL file in `sessionPath`:
@@ -0,0 +1,65 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { parseStrictYaml } from "./yaml.js";
6
+ export const channelNamePattern = /^[a-z0-9](?:[a-z0-9_-]{0,62})$/;
7
+ const channelEntrySchema = z
8
+ .object({
9
+ name: z
10
+ .string()
11
+ .regex(channelNamePattern, "Channel name must be a lowercase slug (a-z, 0-9, _, -)"),
12
+ description: z.string().trim().min(1),
13
+ chat_id: z.string().trim().min(1),
14
+ })
15
+ .strict();
16
+ const channelsDocumentSchema = z
17
+ .object({
18
+ version: z.literal(1),
19
+ channels: z.array(channelEntrySchema).default([]),
20
+ })
21
+ .strict();
22
+ export function defaultChannelsPath(homeDirectory = join(homedir(), ".beacon")) {
23
+ return join(homeDirectory, "channels.yaml");
24
+ }
25
+ export function emptyChannelRegistry() {
26
+ return new Map();
27
+ }
28
+ export async function loadChannelRegistry(path) {
29
+ const metadata = await stat(path).catch((error) => {
30
+ if (error &&
31
+ typeof error === "object" &&
32
+ "code" in error &&
33
+ error.code === "ENOENT") {
34
+ return undefined;
35
+ }
36
+ throw new Error(`Cannot inspect Beacon channels file at ${path}`, {
37
+ cause: error,
38
+ });
39
+ });
40
+ if (!metadata)
41
+ return emptyChannelRegistry();
42
+ if (!metadata.isFile()) {
43
+ throw new Error(`Beacon channels path is not a file: ${path}`);
44
+ }
45
+ const document = channelsDocumentSchema.parse(await parseStrictYaml(path));
46
+ const registry = new Map();
47
+ for (const entry of document.channels) {
48
+ if (registry.has(entry.name)) {
49
+ throw new Error(`Duplicate channel name in ${path}: ${entry.name}`);
50
+ }
51
+ registry.set(entry.name, {
52
+ name: entry.name,
53
+ description: entry.description,
54
+ chatId: entry.chat_id,
55
+ });
56
+ }
57
+ return registry;
58
+ }
59
+ export function resolveChannelName(name, channels, channelsPath) {
60
+ const channel = channels.get(name);
61
+ if (!channel) {
62
+ throw new Error(`Unknown Feishu channel name ${JSON.stringify(name)}; define it in ${channelsPath}`);
63
+ }
64
+ return channel;
65
+ }
@@ -100,6 +100,7 @@ export async function loadGlobalConfig(path) {
100
100
  homeDirectory,
101
101
  profilesDirectory,
102
102
  secretsPath: resolve(homeDirectory, "secrets.json"),
103
+ channelsPath: resolve(homeDirectory, "channels.yaml"),
103
104
  runtimeEnvironmentPath: resolve(homeDirectory, "runtime.env"),
104
105
  pi: {
105
106
  executable,
@@ -4,11 +4,27 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { eventSourceSchema } from "../events/cloudevent.js";
6
6
  import { nextOccurrence } from "../schedule/cron.js";
7
+ import { channelNamePattern, defaultChannelsPath, emptyChannelRegistry, resolveChannelName, } from "./channels.js";
7
8
  import { parseStrictYaml } from "./yaml.js";
8
9
  const profileIdPattern = /^[a-z0-9](?:[a-z0-9_-]{0,62})$/;
9
10
  export const profilePersonaFile = "persona.md";
10
11
  export const profileTaskFile = "task.md";
11
12
  export const workspaceProfileDirectoryName = ".beacon-profile";
13
+ const namedDestinationSchema = z
14
+ .object({
15
+ name: z
16
+ .string()
17
+ .regex(channelNamePattern, "Channel name must be a lowercase slug (a-z, 0-9, _, -)"),
18
+ description: z.string().trim().min(1).optional(),
19
+ })
20
+ .strict();
21
+ const legacyChatDestinationSchema = z
22
+ .object({ chat_id: z.string().trim().min(1) })
23
+ .strict();
24
+ const destinationSchema = z.union([
25
+ namedDestinationSchema,
26
+ legacyChatDestinationSchema,
27
+ ]);
12
28
  const scheduleSchema = z
13
29
  .object({
14
30
  id: z.string().regex(profileIdPattern),
@@ -31,10 +47,7 @@ const scheduleSchema = z
31
47
  }
32
48
  }, "Schedule timezone must be a valid IANA timezone"),
33
49
  input: z.string().trim().min(1),
34
- notify: z
35
- .object({ chat_id: z.string().trim().min(1) })
36
- .strict()
37
- .optional(),
50
+ notify: destinationSchema.optional(),
38
51
  })
39
52
  .strict();
40
53
  const profileDocumentSchema = z
@@ -47,19 +60,13 @@ const profileDocumentSchema = z
47
60
  id: z.string().min(1),
48
61
  })
49
62
  .strict(),
50
- admin: z
51
- .object({ chat_id: z.string().trim().min(1) })
52
- .strict()
53
- .optional(),
63
+ admin: destinationSchema.optional(),
54
64
  schedules: z.array(scheduleSchema).default([]),
55
65
  listener: z
56
66
  .object({
57
67
  sources: z.array(eventSourceSchema).min(1),
58
68
  types: z.array(z.string().min(1)).min(1),
59
- notify: z
60
- .object({ chat_id: z.string().trim().min(1) })
61
- .strict()
62
- .optional(),
69
+ notify: destinationSchema.optional(),
63
70
  })
64
71
  .strict()
65
72
  .optional(),
@@ -156,10 +163,23 @@ async function loadPersonaAndTask(profileId, workspace, profileDirectory) {
156
163
  ];
157
164
  throw new Error(`Profile ${profileId} has no complete ${profilePersonaFile} + ${profileTaskFile} pair. Missing: ${missing.join(", ")}`);
158
165
  }
159
- export async function loadProfile(profileId, profilesDirectory = join(homedir(), ".beacon", "profiles")) {
166
+ function resolveDestination(destination, channels, channelsPath) {
167
+ if ("chat_id" in destination) {
168
+ return { chatId: destination.chat_id };
169
+ }
170
+ const channel = resolveChannelName(destination.name, channels, channelsPath);
171
+ return {
172
+ name: channel.name,
173
+ description: destination.description ?? channel.description,
174
+ chatId: channel.chatId,
175
+ };
176
+ }
177
+ export async function loadProfile(profileId, profilesDirectory = join(homedir(), ".beacon", "profiles"), options = {}) {
160
178
  assertProfileId(profileId);
161
179
  const profileDirectory = join(profilesDirectory, profileId);
162
180
  const configPath = join(profileDirectory, "profile.yaml");
181
+ const channels = options.channels ?? emptyChannelRegistry();
182
+ const channelsPath = options.channelsPath ?? defaultChannelsPath(dirname(profilesDirectory));
163
183
  const config = profileDocumentSchema.parse(await parseStrictYaml(configPath));
164
184
  const scheduleIds = new Set();
165
185
  for (const schedule of config.schedules) {
@@ -177,11 +197,14 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
177
197
  }
178
198
  }
179
199
  if (config.schedules.length > 0 && !config.admin) {
180
- throw new Error(`Profile ${profileId} with schedules must declare admin.chat_id`);
200
+ throw new Error(`Profile ${profileId} with schedules must declare admin`);
181
201
  }
182
202
  if (config.listener && !config.admin) {
183
- throw new Error(`Profile ${profileId} with listener must declare admin.chat_id`);
203
+ throw new Error(`Profile ${profileId} with listener must declare admin`);
184
204
  }
205
+ const admin = config.admin
206
+ ? resolveDestination(config.admin, channels, channelsPath)
207
+ : undefined;
185
208
  const canonicalProfileDirectory = await realpath(profileDirectory);
186
209
  const workspace = isAbsolute(config.workspace)
187
210
  ? config.workspace
@@ -207,14 +230,16 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
207
230
  workspace,
208
231
  runtime: config.runtime,
209
232
  model: config.model,
210
- ...(config.admin ? { admin: { chatId: config.admin.chat_id } } : {}),
233
+ ...(admin ? { admin } : {}),
211
234
  ...(config.listener
212
235
  ? {
213
236
  listener: {
214
237
  sources: config.listener.sources,
215
238
  types: config.listener.types,
216
239
  ...(config.listener.notify
217
- ? { notify: { chatId: config.listener.notify.chat_id } }
240
+ ? {
241
+ notify: resolveDestination(config.listener.notify, channels, channelsPath),
242
+ }
218
243
  : {}),
219
244
  },
220
245
  }
@@ -225,14 +250,16 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
225
250
  timezone: schedule.timezone,
226
251
  input: schedule.input,
227
252
  ...(schedule.notify
228
- ? { notify: { chatId: schedule.notify.chat_id } }
253
+ ? {
254
+ notify: resolveDestination(schedule.notify, channels, channelsPath),
255
+ }
229
256
  : {}),
230
257
  })),
231
258
  };
232
259
  }
233
260
  export function profileAdminChatId(profile) {
234
261
  if (!profile.admin?.chatId) {
235
- throw new Error(`Profile ${profile.id} must declare admin.chat_id`);
262
+ throw new Error(`Profile ${profile.id} must declare admin`);
236
263
  }
237
264
  return profile.admin.chatId;
238
265
  }
@@ -1,6 +1,6 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { loadProfile } from "./profile.js";
3
- export async function loadProfileRegistry(profilesDirectory) {
3
+ export async function loadProfileRegistry(profilesDirectory, channels, channelsPath) {
4
4
  const entries = await readdir(profilesDirectory, { withFileTypes: true });
5
5
  const ids = entries
6
6
  .filter((entry) => entry.isDirectory())
@@ -8,5 +8,8 @@ export async function loadProfileRegistry(profilesDirectory) {
8
8
  .sort((left, right) => left.localeCompare(right));
9
9
  if (ids.length === 0)
10
10
  throw new Error(`No Profiles found in ${profilesDirectory}`);
11
- return Promise.all(ids.map((id) => loadProfile(id, profilesDirectory)));
11
+ return Promise.all(ids.map((id) => loadProfile(id, profilesDirectory, {
12
+ ...(channels ? { channels } : {}),
13
+ ...(channelsPath ? { channelsPath } : {}),
14
+ })));
12
15
  }
@@ -1,6 +1,7 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { defaultChannelsPath, loadChannelRegistry, } from "../config/channels.js";
4
5
  import { loadProfile } from "../config/profile.js";
5
6
  import { buildAgentSystemPrompt, } from "../runtime/system-prompt.js";
6
7
  import { exportSessionHtml } from "./export.js";
@@ -40,7 +41,12 @@ async function beaconOwnedSystemPrompt(summary, profilesDirectory) {
40
41
  if (summary.systemPrompt)
41
42
  return summary.systemPrompt;
42
43
  try {
43
- return buildAgentSystemPrompt(await loadProfile(summary.profileId, profilesDirectory), triggerFromRunSummary(summary));
44
+ const channelsPath = defaultChannelsPath(dirname(profilesDirectory));
45
+ const channels = await loadChannelRegistry(channelsPath);
46
+ return buildAgentSystemPrompt(await loadProfile(summary.profileId, profilesDirectory, {
47
+ channels,
48
+ channelsPath,
49
+ }), triggerFromRunSummary(summary));
44
50
  }
45
51
  catch {
46
52
  // Old runs without a stored prompt: reconstruction needs a readable Profile.
package/dist/doctor.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadChannelRegistry } from "./config/channels.js";
1
2
  import { loadEventCredentials } from "./config/event-credentials.js";
2
3
  import { loadGlobalConfig } from "./config/global.js";
3
4
  import { loadProfileRegistry } from "./config/registry.js";
@@ -8,7 +9,8 @@ import { runPiAgent } from "./runtime/pi-rpc.js";
8
9
  export async function runDoctor(configPath) {
9
10
  const global = await loadGlobalConfig(configPath);
10
11
  const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
11
- const profiles = await loadProfileRegistry(global.profilesDirectory);
12
+ const channels = await loadChannelRegistry(global.channelsPath);
13
+ const profiles = await loadProfileRegistry(global.profilesDirectory, channels, global.channelsPath);
12
14
  if (global.events)
13
15
  await loadEventCredentials(global.events.credentialsPath);
14
16
  for (const profile of profiles) {
@@ -1,5 +1,10 @@
1
1
  import * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import { buildFeishuFinalOutcomeCard } from "./final-outcome-card.js";
3
+ /** Prefer Feishu's outbound `message_id` so quote-replies can resume that Run's Pi session. */
4
+ export function providerRequestIdFromMessageSend(result) {
5
+ const messageId = result.data?.message_id?.trim();
6
+ return messageId ? { providerRequestId: messageId } : {};
7
+ }
3
8
  export function waitForShutdown(signals = process) {
4
9
  return new Promise((resolve) => {
5
10
  const shutdown = () => {
@@ -121,28 +126,36 @@ export class FeishuGateway {
121
126
  async deliver(target, outcome) {
122
127
  const message = encodeFeishuFinalOutcome(outcome);
123
128
  if (target.kind === "reply") {
124
- const result = await this.client.im.v1.message.reply({
129
+ const result = (await this.client.im.v1.message.reply({
125
130
  path: { message_id: target.messageId },
126
131
  data: {
127
132
  msg_type: message.msgType,
128
133
  content: message.content,
129
134
  reply_in_thread: false,
130
135
  },
131
- });
136
+ }));
132
137
  assertSucceeded("deliver quoted reply", result);
133
- return {};
138
+ const provider = providerRequestIdFromMessageSend(result);
139
+ if (!provider.providerRequestId) {
140
+ console.error(`[beacon] Feishu quoted reply succeeded without message_id; quote-reply session resume will be unavailable for this Delivery`);
141
+ }
142
+ return provider;
134
143
  }
135
144
  if (target.kind === "chat") {
136
- const result = await this.client.im.v1.message.create({
145
+ const result = (await this.client.im.v1.message.create({
137
146
  params: { receive_id_type: "chat_id" },
138
147
  data: {
139
148
  receive_id: target.chatId,
140
149
  msg_type: message.msgType,
141
150
  content: message.content,
142
151
  },
143
- });
152
+ }));
144
153
  assertSucceeded("deliver chat message", result);
145
- return {};
154
+ const provider = providerRequestIdFromMessageSend(result);
155
+ if (!provider.providerRequestId) {
156
+ console.error(`[beacon] Feishu chat message succeeded without message_id; quote-reply session resume will be unavailable for this Delivery`);
157
+ }
158
+ return provider;
146
159
  }
147
160
  throw new Error("Feishu Gateway cannot deliver to local stdout");
148
161
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { loadChannelRegistry } from "./config/channels.js";
2
3
  import { loadGlobalConfig } from "./config/global.js";
3
4
  import { loadProfileRegistry } from "./config/registry.js";
4
5
  import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
@@ -17,7 +18,8 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
17
18
  throw new Error("Manual Trigger input must not be empty");
18
19
  const global = await loadGlobalConfig(configPath);
19
20
  const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
20
- const profiles = await loadProfileRegistry(global.profilesDirectory);
21
+ const channels = await loadChannelRegistry(global.channelsPath);
22
+ const profiles = await loadProfileRegistry(global.profilesDirectory, channels, global.channelsPath);
21
23
  const profile = profiles.find((candidate) => candidate.id === profileId);
22
24
  if (!profile)
23
25
  throw new Error(`Unknown Profile: ${profileId}`);
@@ -1,4 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import { defaultChannelsPath, loadChannelRegistry, } from "../config/channels.js";
2
3
  import { loadProfile } from "../config/profile.js";
3
4
  import { createProfileRunner } from "../run/profile-runner.js";
4
5
  import { startOutcomeServer } from "./server.js";
@@ -13,7 +14,12 @@ const doctorTrigger = {
13
14
  },
14
15
  };
15
16
  export async function runOutcomeDoctor(profileId) {
16
- const profile = await loadProfile(profileId);
17
+ const channelsPath = defaultChannelsPath();
18
+ const channels = await loadChannelRegistry(channelsPath);
19
+ const profile = await loadProfile(profileId, undefined, {
20
+ channels,
21
+ channelsPath,
22
+ });
17
23
  const outcomes = await startOutcomeServer();
18
24
  const beaconCliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
19
25
  try {
@@ -121,6 +121,26 @@ function normalizeAgentOutcome(input, outcome, notifyTarget) {
121
121
  }
122
122
  return outcome;
123
123
  }
124
+ /**
125
+ * Walk a Feishu quote chain (oldest → newest) from the direct parent backward
126
+ * and reuse the first Beacon Delivery's Pi session.
127
+ */
128
+ export async function resolveContinuedPiSession(quotedMessages, findByDeliveredMessageId) {
129
+ for (let index = quotedMessages.length - 1; index >= 0; index -= 1) {
130
+ const quoted = quotedMessages[index];
131
+ const prior = await findByDeliveredMessageId(quoted.messageId);
132
+ if (!prior?.run?.sessionId || !prior.run.sessionPath)
133
+ continue;
134
+ return {
135
+ sessionId: prior.run.sessionId,
136
+ sessionPath: prior.run.sessionPath,
137
+ ...(prior.run.systemPrompt
138
+ ? { systemPrompt: prior.run.systemPrompt }
139
+ : {}),
140
+ };
141
+ }
142
+ return undefined;
143
+ }
124
144
  export class RunOrchestrator {
125
145
  options;
126
146
  now;
@@ -150,12 +170,15 @@ export class RunOrchestrator {
150
170
  triggerFor(record, input) {
151
171
  return agentSystemPromptTrigger(input ?? record.input, record.notifyTarget);
152
172
  }
153
- newRun(runId, state, trigger, failure) {
173
+ newRun(runId, state, trigger, failure, continued) {
154
174
  const timestamp = this.timestamp();
175
+ const sessionId = continued?.sessionId ?? runId;
176
+ const sessionPath = continued?.sessionPath ??
177
+ join(this.options.sessionDirectory, this.options.profile.id, runId);
155
178
  return {
156
179
  runId,
157
- sessionId: runId,
158
- sessionPath: join(this.options.sessionDirectory, this.options.profile.id, runId),
180
+ sessionId,
181
+ sessionPath,
159
182
  state,
160
183
  queuedAt: timestamp,
161
184
  ...(state === "failed" ? { finishedAt: timestamp } : {}),
@@ -163,7 +186,7 @@ export class RunOrchestrator {
163
186
  model: this.options.profile.model.id,
164
187
  workspace: this.options.profile.workspace,
165
188
  promptDigest: profilePromptDigest(this.options.profile),
166
- systemPrompt: this.agentSystemPrompt(trigger),
189
+ systemPrompt: continued?.systemPrompt ?? this.agentSystemPrompt(trigger),
167
190
  ...(failure ? { failure } : {}),
168
191
  };
169
192
  }
@@ -363,10 +386,16 @@ export class RunOrchestrator {
363
386
  await this.deliverOutcome(triggerKey);
364
387
  return;
365
388
  }
389
+ const continued = input.kind === "feishu_message"
390
+ ? await resolveContinuedPiSession(input.quotedMessages, (messageId) => this.options.store.findByDeliveredMessageId(messageId))
391
+ : undefined;
392
+ if (continued) {
393
+ console.log(`[beacon] resuming Pi session session_id=${continued.sessionId} for trigger_key=${triggerKey} run_id=${runId}`);
394
+ }
366
395
  await this.options.store.update(triggerKey, (current) => ({
367
396
  ...current,
368
397
  input,
369
- run: this.newRun(runId, "queued", this.triggerFor(current, input)),
398
+ run: this.newRun(runId, "queued", this.triggerFor(current, input), undefined, continued),
370
399
  }));
371
400
  if (input.kind === "event") {
372
401
  await this.options.queue.enqueueWhenAvailable(() => this.execute(triggerKey, input, runId));
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { loadChannelRegistry } from "./config/channels.js";
2
3
  import { loadGlobalConfig } from "./config/global.js";
3
4
  import { profileAdminChatId } from "./config/profile.js";
4
5
  import { loadProfileRegistry } from "./config/registry.js";
@@ -57,7 +58,8 @@ export async function triggerScheduleOnce(options) {
57
58
  export async function runScheduleTrigger(configPath, profileId, scheduleId) {
58
59
  const global = await loadGlobalConfig(configPath);
59
60
  const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
60
- const profiles = await loadProfileRegistry(global.profilesDirectory);
61
+ const channels = await loadChannelRegistry(global.channelsPath);
62
+ const profiles = await loadProfileRegistry(global.profilesDirectory, channels, global.channelsPath);
61
63
  const profile = profiles.find((candidate) => candidate.id === profileId);
62
64
  if (!profile)
63
65
  throw new Error(`Unknown Profile: ${profileId}`);
package/dist/service.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { loadChannelRegistry } from "./config/channels.js";
2
3
  import { loadEventCredentials } from "./config/event-credentials.js";
3
4
  import { loadGlobalConfig } from "./config/global.js";
4
5
  import { loadProfileRegistry } from "./config/registry.js";
@@ -37,7 +38,8 @@ function shutdownController() {
37
38
  export async function runBeacon(configPath) {
38
39
  const global = await loadGlobalConfig(configPath);
39
40
  const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
40
- const profiles = await loadProfileRegistry(global.profilesDirectory);
41
+ const channels = await loadChannelRegistry(global.channelsPath);
42
+ const profiles = await loadProfileRegistry(global.profilesDirectory, channels, global.channelsPath);
41
43
  const credentials = await Promise.all(profiles.map((profile) => loadFeishuCredentials(profile.id, global.secretsPath)));
42
44
  const eventProducers = global.events
43
45
  ? await loadEventCredentials(global.events.credentialsPath)
@@ -139,10 +139,9 @@ function validateRecord(value) {
139
139
  throw new Error("Run record must contain both Pi sessionId and sessionPath or neither");
140
140
  }
141
141
  if (record.run?.sessionId &&
142
- (record.run.sessionId !== record.run.runId ||
143
- basename(record.run.sessionPath) !== record.run.runId ||
142
+ (basename(record.run.sessionPath) !== record.run.sessionId ||
144
143
  basename(dirname(record.run.sessionPath)) !== record.profileId)) {
145
- throw new Error("Run Pi session identity must map to its Profile ID and Run ID");
144
+ throw new Error("Run Pi session path must map to its Profile ID and session ID");
146
145
  }
147
146
  if (record.delivery && !record.finalOutcome) {
148
147
  throw new Error("Trigger record with Delivery must contain a Final Outcome");
@@ -295,4 +294,25 @@ export class TriggerStore {
295
294
  return records.sort((left, right) => left.acceptedAt.localeCompare(right.acceptedAt) ||
296
295
  left.triggerId.localeCompare(right.triggerId));
297
296
  }
297
+ /**
298
+ * Locate the Trigger whose reply or notify Delivery produced this Feishu
299
+ * outbound `message_id`. Newest accepted Trigger wins on collision.
300
+ */
301
+ async findByDeliveredMessageId(messageId) {
302
+ const trimmed = messageId.trim();
303
+ if (!trimmed)
304
+ return undefined;
305
+ const records = await this.list();
306
+ for (let index = records.length - 1; index >= 0; index -= 1) {
307
+ const record = records[index];
308
+ for (const field of ["delivery", "notifyDelivery"]) {
309
+ const delivery = record[field];
310
+ if (delivery?.state === "delivered" &&
311
+ delivery.providerRequestId === trimmed) {
312
+ return record;
313
+ }
314
+ }
315
+ }
316
+ return undefined;
317
+ }
298
318
  }
@@ -0,0 +1,8 @@
1
+ version: 1
2
+ channels:
3
+ - name: admin-dm
4
+ description: Example admin direct chat
5
+ chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
6
+ - name: weekday-brief-group
7
+ description: Example weekday brief notify group
8
+ chat_id: REPLACE_WITH_GROUP_CHAT_ID
@@ -4,14 +4,14 @@ model:
4
4
  provider: REPLACE_WITH_PI_PROVIDER
5
5
  id: REPLACE_WITH_PI_MODEL
6
6
  admin:
7
- chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
7
+ name: admin-dm
8
8
  schedules:
9
9
  - id: weekday-brief
10
10
  cron: "0 9 * * 1-5"
11
11
  timezone: Asia/Shanghai
12
12
  input: Prepare the weekday brief.
13
13
  notify:
14
- chat_id: REPLACE_WITH_GROUP_CHAT_ID
14
+ name: weekday-brief-group
15
15
  # Optional external event subscription. Both lists use exact matches.
16
16
  # listener:
17
17
  # sources:
@@ -19,4 +19,4 @@ schedules:
19
19
  # types:
20
20
  # - com.example.deployment.completed.v1
21
21
  # notify:
22
- # chat_id: REPLACE_WITH_GROUP_CHAT_ID
22
+ # name: weekday-brief-group
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {