@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 +63 -17
- package/dist/config/channels.js +65 -0
- package/dist/config/global.js +1 -0
- package/dist/config/profile.js +46 -19
- package/dist/config/registry.js +5 -2
- package/dist/dashboard/server.js +7 -1
- package/dist/doctor.js +3 -1
- package/dist/feishu/gateway.js +19 -6
- package/dist/manual-trigger.js +3 -1
- package/dist/outcome/doctor.js +7 -1
- package/dist/run/orchestrator.js +34 -5
- package/dist/schedule-trigger.js +3 -1
- package/dist/service.js +3 -1
- package/dist/state/trigger-store.js +23 -3
- package/examples/channels.yaml.example +8 -0
- package/examples/profiles/example/profile.yaml +3 -3
- package/package.json +1 -1
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,
|
|
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
|
|
116
|
-
`reply_card` / `no_reply` and for failure notices. Optional `notify
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
375
|
-
both `sessionId` and `sessionPath
|
|
376
|
-
`runId`, and the path is the dedicated
|
|
377
|
-
`<pi.session_directory>/<profile-id>/<runId>/`. Pi receives that
|
|
378
|
-
`--session-dir`, `--session-id`, and a readable
|
|
379
|
-
name. The directory contains the Pi JSONL
|
|
380
|
-
by Beacon. A queued Run keeps the same
|
|
381
|
-
written by Beacon 0.1.2 or earlier are
|
|
382
|
-
when recovered. Historical completed
|
|
383
|
-
two session fields because those Runs
|
|
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
|
+
}
|
package/dist/config/global.js
CHANGED
|
@@ -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,
|
package/dist/config/profile.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
? {
|
|
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
|
-
? {
|
|
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
|
|
262
|
+
throw new Error(`Profile ${profile.id} must declare admin`);
|
|
236
263
|
}
|
|
237
264
|
return profile.admin.chatId;
|
|
238
265
|
}
|
package/dist/config/registry.js
CHANGED
|
@@ -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
|
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) {
|
package/dist/feishu/gateway.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/manual-trigger.js
CHANGED
|
@@ -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
|
|
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}`);
|
package/dist/outcome/doctor.js
CHANGED
|
@@ -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
|
|
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 {
|
package/dist/run/orchestrator.js
CHANGED
|
@@ -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
|
|
158
|
-
sessionPath
|
|
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));
|
package/dist/schedule-trigger.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
|
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
|
}
|
|
@@ -4,14 +4,14 @@ model:
|
|
|
4
4
|
provider: REPLACE_WITH_PI_PROVIDER
|
|
5
5
|
id: REPLACE_WITH_PI_MODEL
|
|
6
6
|
admin:
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
22
|
+
# name: weekday-brief-group
|